summaryrefslogtreecommitdiffstats
path: root/browser/components/aboutlogins/tests
diff options
context:
space:
mode:
authorDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 19:33:14 +0000
committerDaniel Baumann <daniel.baumann@progress-linux.org>2024-04-07 19:33:14 +0000
commit36d22d82aa202bb199967e9512281e9a53db42c9 (patch)
tree105e8c98ddea1c1e4784a60a5a6410fa416be2de /browser/components/aboutlogins/tests
parentInitial commit. (diff)
downloadfirefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.tar.xz
firefox-esr-36d22d82aa202bb199967e9512281e9a53db42c9.zip
Adding upstream version 115.7.0esr.upstream/115.7.0esrupstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
-rw-r--r--browser/components/aboutlogins/tests/browser/AboutLoginsTestUtils.sys.mjs107
-rw-r--r--browser/components/aboutlogins/tests/browser/browser.ini58
-rw-r--r--browser/components/aboutlogins/tests/browser/browser_aaa_eventTelemetry_run_first.js271
-rw-r--r--browser/components/aboutlogins/tests/browser/browser_alertDismissedAfterChangingPassword.js227
-rw-r--r--browser/components/aboutlogins/tests/browser/browser_breachAlertShowingForAddedLogin.js123
-rw-r--r--browser/components/aboutlogins/tests/browser/browser_confirmDeleteDialog.js128
-rw-r--r--browser/components/aboutlogins/tests/browser/browser_contextmenuFillLogins.js185
-rw-r--r--browser/components/aboutlogins/tests/browser/browser_copyToClipboardButton.js118
-rw-r--r--browser/components/aboutlogins/tests/browser/browser_createLogin.js535
-rw-r--r--browser/components/aboutlogins/tests/browser/browser_deleteLogin.js182
-rw-r--r--browser/components/aboutlogins/tests/browser/browser_fxAccounts.js96
-rw-r--r--browser/components/aboutlogins/tests/browser/browser_loginFilter.js60
-rw-r--r--browser/components/aboutlogins/tests/browser/browser_loginItemErrors.js153
-rw-r--r--browser/components/aboutlogins/tests/browser/browser_loginListChanges.js144
-rw-r--r--browser/components/aboutlogins/tests/browser/browser_loginSortOrderRestored.js172
-rw-r--r--browser/components/aboutlogins/tests/browser/browser_noLoginsView.js199
-rw-r--r--browser/components/aboutlogins/tests/browser/browser_openExport.js149
-rw-r--r--browser/components/aboutlogins/tests/browser/browser_openFiltered.js295
-rw-r--r--browser/components/aboutlogins/tests/browser/browser_openImport.js60
-rw-r--r--browser/components/aboutlogins/tests/browser/browser_openImportCSV.js411
-rw-r--r--browser/components/aboutlogins/tests/browser/browser_openPreferences.js82
-rw-r--r--browser/components/aboutlogins/tests/browser/browser_openPreferencesExternal.js65
-rw-r--r--browser/components/aboutlogins/tests/browser/browser_openSite.js94
-rw-r--r--browser/components/aboutlogins/tests/browser/browser_osAuthDialog.js165
-rw-r--r--browser/components/aboutlogins/tests/browser/browser_primaryPassword.js282
-rw-r--r--browser/components/aboutlogins/tests/browser/browser_removeAllDialog.js555
-rw-r--r--browser/components/aboutlogins/tests/browser/browser_sessionRestore.js62
-rw-r--r--browser/components/aboutlogins/tests/browser/browser_tabKeyNav.js276
-rw-r--r--browser/components/aboutlogins/tests/browser/browser_updateLogin.js421
-rw-r--r--browser/components/aboutlogins/tests/browser/browser_vulnerableLoginAddedInSecondaryWindow.js223
-rw-r--r--browser/components/aboutlogins/tests/browser/head.js225
-rw-r--r--browser/components/aboutlogins/tests/chrome/.eslintrc.js16
-rw-r--r--browser/components/aboutlogins/tests/chrome/aboutlogins_common.js97
-rw-r--r--browser/components/aboutlogins/tests/chrome/chrome.ini13
-rw-r--r--browser/components/aboutlogins/tests/chrome/test_confirm_delete_dialog.html127
-rw-r--r--browser/components/aboutlogins/tests/chrome/test_fxaccounts_button.html96
-rw-r--r--browser/components/aboutlogins/tests/chrome/test_login_filter.html178
-rw-r--r--browser/components/aboutlogins/tests/chrome/test_login_item.html481
-rw-r--r--browser/components/aboutlogins/tests/chrome/test_login_list.html697
-rw-r--r--browser/components/aboutlogins/tests/chrome/test_menu_button.html260
-rw-r--r--browser/components/aboutlogins/tests/unit/head.js22
-rw-r--r--browser/components/aboutlogins/tests/unit/test_getPotentialBreachesByLoginGUID.js327
-rw-r--r--browser/components/aboutlogins/tests/unit/xpcshell.ini7
43 files changed, 8444 insertions, 0 deletions
diff --git a/browser/components/aboutlogins/tests/browser/AboutLoginsTestUtils.sys.mjs b/browser/components/aboutlogins/tests/browser/AboutLoginsTestUtils.sys.mjs
new file mode 100644
index 0000000000..44a51b80ad
--- /dev/null
+++ b/browser/components/aboutlogins/tests/browser/AboutLoginsTestUtils.sys.mjs
@@ -0,0 +1,107 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+/**
+ * An utility class to help out with the about:logins and about:loginsimportreport DOM interaction for the tests.
+ *
+ */
+export class AboutLoginsTestUtils {
+ /**
+ * An utility method to fetch the data from the CSV import success dialog.
+ *
+ * @param {content} content
+ * The content object.
+ * @param {ContentTaskUtils} ContentTaskUtils
+ * The ContentTaskUtils object.
+ * @returns {Promise<Object>} A promise that contains added, modified, noChange and errors count.
+ */
+ static async getCsvImportSuccessDialogData(content, ContentTaskUtils) {
+ let dialog = Cu.waiveXrays(
+ content.document.querySelector("import-summary-dialog")
+ );
+ await ContentTaskUtils.waitForCondition(
+ () => !dialog.hidden,
+ "Waiting for the dialog to be visible"
+ );
+
+ let added = dialog.shadowRoot.querySelector(
+ ".import-items-added .result-count"
+ ).textContent;
+ let modified = dialog.shadowRoot.querySelector(
+ ".import-items-modified .result-count"
+ ).textContent;
+ let noChange = dialog.shadowRoot.querySelector(
+ ".import-items-no-change .result-count"
+ ).textContent;
+ let errors = dialog.shadowRoot.querySelector(
+ ".import-items-errors .result-count"
+ ).textContent;
+ return {
+ added,
+ modified,
+ noChange,
+ errors,
+ l10nFocused: dialog.shadowRoot.activeElement.getAttribute("data-l10n-id"),
+ };
+ }
+
+ /**
+ * An utility method to fetch the data from the CSV import error dialog.
+ *
+ * @param {content} content
+ * The content object.
+ * @returns {Promise<Object>} A promise that contains the hidden state and l10n id for title, description and focused element.
+ */
+ static async getCsvImportErrorDialogData(content) {
+ const dialog = Cu.waiveXrays(
+ content.document.querySelector("import-error-dialog")
+ );
+ const l10nTitle = dialog._genericDialog
+ .querySelector(".error-title")
+ .getAttribute("data-l10n-id");
+ const l10nDescription = dialog._genericDialog
+ .querySelector(".error-description")
+ .getAttribute("data-l10n-id");
+ return {
+ hidden: dialog.hidden,
+ l10nFocused: dialog.shadowRoot.activeElement.getAttribute("data-l10n-id"),
+ l10nTitle,
+ l10nDescription,
+ };
+ }
+
+ /**
+ * An utility method to fetch data from the about:loginsimportreport page.
+ * It also cleans up the tab so you don't have to.
+ *
+ * @param {content} content
+ * The content object.
+ * @returns {Promise<Object>} A promise that contains the detailed report data like added, modified, noChange, errors and rows.
+ */
+ static async getCsvImportReportData(content) {
+ const rows = [];
+ for (let element of content.document.querySelectorAll(".row-details")) {
+ rows.push(element.getAttribute("data-l10n-id"));
+ }
+ const added = content.document.querySelector(
+ ".new-logins .result-count"
+ ).textContent;
+ const modified = content.document.querySelector(
+ ".exiting-logins .result-count"
+ ).textContent;
+ const noChange = content.document.querySelector(
+ ".duplicate-logins .result-count"
+ ).textContent;
+ const errors = content.document.querySelector(
+ ".errors-logins .result-count"
+ ).textContent;
+ return {
+ rows,
+ added,
+ modified,
+ noChange,
+ errors,
+ };
+ }
+}
diff --git a/browser/components/aboutlogins/tests/browser/browser.ini b/browser/components/aboutlogins/tests/browser/browser.ini
new file mode 100644
index 0000000000..06eb8ab92a
--- /dev/null
+++ b/browser/components/aboutlogins/tests/browser/browser.ini
@@ -0,0 +1,58 @@
+[DEFAULT]
+support-files =
+ head.js
+prefs =
+ signon.management.page.vulnerable-passwords.enabled=true
+ signon.management.page.os-auth.enabled=true
+ # lower the interval for event telemetry in the content process to update the parent process
+ toolkit.telemetry.ipcBatchTimeout=10
+
+# Run first so content events from previous tests won't trickle in.
+# Skip ASAN and debug since waiting for content events is already slow.
+[browser_aaa_eventTelemetry_run_first.js]
+skip-if =
+ asan || tsan || ccov || debug || (os == "win" && !debug) # bug 1605494 is more prevalent on linux, Bug 1627419
+ os == 'linux' && bits == 64 && !debug # Bug 1648862
+[browser_alertDismissedAfterChangingPassword.js]
+skip-if =
+ os == "mac" && os_version == "10.15" && !debug # Bug 1684513
+[browser_breachAlertShowingForAddedLogin.js]
+[browser_confirmDeleteDialog.js]
+[browser_contextmenuFillLogins.js]
+skip-if = win10_2004 && debug # Bug 1723573
+[browser_copyToClipboardButton.js]
+[browser_createLogin.js]
+[browser_deleteLogin.js]
+[browser_fxAccounts.js]
+[browser_loginFilter.js]
+[browser_loginItemErrors.js]
+skip-if = debug # Bug 1577710
+[browser_loginListChanges.js]
+[browser_loginSortOrderRestored.js]
+skip-if = os == 'linux' && bits == 64 && os_version == '18.04' # Bug 1587625; Bug 1587626 for linux1804
+[browser_noLoginsView.js]
+[browser_openExport.js]
+[browser_openFiltered.js]
+[browser_openImport.js]
+skip-if =
+ os != "win" && os != "mac" # import is only available on Windows and macOS
+ os == "mac" && !debug # bug 1775753
+[browser_openImportCSV.js]
+[browser_openPreferences.js]
+[browser_openPreferencesExternal.js]
+[browser_openSite.js]
+skip-if =
+ os == "linux" && bits == 64 # Bug 1581889
+[browser_osAuthDialog.js]
+skip-if = (os == 'linux') # bug 1527745
+[browser_primaryPassword.js]
+skip-if =
+ (os == 'linux') # bug 1569789
+[browser_removeAllDialog.js]
+[browser_sessionRestore.js]
+skip-if =
+ tsan
+ debug # Bug 1576876
+[browser_tabKeyNav.js]
+[browser_updateLogin.js]
+[browser_vulnerableLoginAddedInSecondaryWindow.js]
diff --git a/browser/components/aboutlogins/tests/browser/browser_aaa_eventTelemetry_run_first.js b/browser/components/aboutlogins/tests/browser/browser_aaa_eventTelemetry_run_first.js
new file mode 100644
index 0000000000..0ff77a2240
--- /dev/null
+++ b/browser/components/aboutlogins/tests/browser/browser_aaa_eventTelemetry_run_first.js
@@ -0,0 +1,271 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+requestLongerTimeout(2);
+
+const { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+
+EXPECTED_BREACH = {
+ AddedDate: "2018-12-20T23:56:26Z",
+ BreachDate: "2018-12-16",
+ Domain: "breached.example.com",
+ Name: "Breached",
+ PwnCount: 1643100,
+ DataClasses: ["Email addresses", "Usernames", "Passwords", "IP addresses"],
+ _status: "synced",
+ id: "047940fe-d2fd-4314-b636-b4a952ee0043",
+ last_modified: "1541615610052",
+ schema: "1541615609018",
+};
+
+let VULNERABLE_TEST_LOGIN2 = new nsLoginInfo(
+ "https://2.example.com",
+ "https://2.example.com",
+ null,
+ "user2",
+ "pass3",
+ "username",
+ "password"
+);
+
+add_setup(async function () {
+ TEST_LOGIN1 = await addLogin(TEST_LOGIN1);
+ VULNERABLE_TEST_LOGIN2 = await addLogin(VULNERABLE_TEST_LOGIN2);
+ TEST_LOGIN3 = await addLogin(TEST_LOGIN3);
+
+ await TestUtils.waitForCondition(() => {
+ Services.telemetry.clearEvents();
+ let events = Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS,
+ true
+ ).content;
+ return !events || !events.length;
+ }, "Waiting for telemetry events to get cleared");
+
+ await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ url: "about:logins",
+ });
+ registerCleanupFunction(() => {
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ Services.logins.removeAllUserFacingLogins();
+ });
+});
+
+add_task(async function test_telemetry_events() {
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () {
+ let loginList = content.document.querySelector("login-list");
+ let loginListItem = loginList.shadowRoot.querySelector(
+ ".login-list-item.breached"
+ );
+ loginListItem.click();
+ });
+ await LoginTestUtils.telemetry.waitForEventCount(2);
+
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () {
+ let loginItem = content.document.querySelector("login-item");
+ let copyButton = loginItem.shadowRoot.querySelector(
+ ".copy-username-button"
+ );
+ copyButton.click();
+ });
+ await LoginTestUtils.telemetry.waitForEventCount(3);
+
+ if (OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) {
+ let reauthObserved = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true);
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () {
+ let loginItem = content.document.querySelector("login-item");
+ let copyButton = loginItem.shadowRoot.querySelector(
+ ".copy-password-button"
+ );
+ copyButton.click();
+ });
+ await reauthObserved;
+ // When reauth is observed an extra telemetry event will be recorded
+ // for the reauth, hence the event count increasing by 2 here, and later
+ // in the test as well.
+ await LoginTestUtils.telemetry.waitForEventCount(5);
+ }
+ let nextTelemetryEventCount = OSKeyStoreTestUtils.canTestOSKeyStoreLogin()
+ ? 6
+ : 4;
+
+ let promiseNewTab = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ TEST_LOGIN3.origin + "/"
+ );
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () {
+ let loginItem = content.document.querySelector("login-item");
+ let originInput = loginItem.shadowRoot.querySelector(".origin-input");
+ originInput.click();
+ });
+ let newTab = await promiseNewTab;
+ Assert.ok(true, "New tab opened to " + TEST_LOGIN3.origin);
+ BrowserTestUtils.removeTab(newTab);
+ await LoginTestUtils.telemetry.waitForEventCount(nextTelemetryEventCount++);
+
+ // Show the password
+ if (OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) {
+ let reauthObserved = forceAuthTimeoutAndWaitForOSKeyStoreLogin({
+ loginResult: true,
+ });
+ nextTelemetryEventCount++; // An extra event is observed for the reauth event.
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () {
+ let loginItem = content.document.querySelector("login-item");
+ let revealCheckbox = loginItem.shadowRoot.querySelector(
+ ".reveal-password-checkbox"
+ );
+ revealCheckbox.click();
+ });
+ await reauthObserved;
+ await LoginTestUtils.telemetry.waitForEventCount(nextTelemetryEventCount++);
+
+ // Hide the password
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () {
+ let loginItem = content.document.querySelector("login-item");
+ let revealCheckbox = loginItem.shadowRoot.querySelector(
+ ".reveal-password-checkbox"
+ );
+ revealCheckbox.click();
+ });
+ await LoginTestUtils.telemetry.waitForEventCount(nextTelemetryEventCount++);
+
+ // Don't force the auth timeout here to check that `auth_skipped: true` is set as
+ // in `extra`.
+ nextTelemetryEventCount++; // An extra event is observed for the reauth event.
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () {
+ let loginItem = content.document.querySelector("login-item");
+ let editButton = loginItem.shadowRoot.querySelector(".edit-button");
+ editButton.click();
+ });
+ await LoginTestUtils.telemetry.waitForEventCount(nextTelemetryEventCount++);
+
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () {
+ let loginItem = content.document.querySelector("login-item");
+ let usernameField = loginItem.shadowRoot.querySelector(
+ 'input[name="username"]'
+ );
+ usernameField.value = "user1-modified";
+
+ let saveButton = loginItem.shadowRoot.querySelector(
+ ".save-changes-button"
+ );
+ saveButton.click();
+ });
+ await LoginTestUtils.telemetry.waitForEventCount(nextTelemetryEventCount++);
+ }
+
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () {
+ let loginItem = content.document.querySelector("login-item");
+ let deleteButton = loginItem.shadowRoot.querySelector(".delete-button");
+ deleteButton.click();
+ let confirmDeleteDialog = content.document.querySelector(
+ "confirmation-dialog"
+ );
+ let confirmDeleteButton =
+ confirmDeleteDialog.shadowRoot.querySelector(".confirm-button");
+ confirmDeleteButton.click();
+ });
+ await LoginTestUtils.telemetry.waitForEventCount(nextTelemetryEventCount++);
+
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () {
+ let newLoginButton = content.document
+ .querySelector("login-list")
+ .shadowRoot.querySelector(".create-login-button");
+ newLoginButton.click();
+ });
+ await LoginTestUtils.telemetry.waitForEventCount(nextTelemetryEventCount++);
+
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () {
+ let loginItem = content.document.querySelector("login-item");
+ let cancelButton = loginItem.shadowRoot.querySelector(".cancel-button");
+ cancelButton.click();
+ });
+ await LoginTestUtils.telemetry.waitForEventCount(nextTelemetryEventCount++);
+
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () {
+ let loginList = content.document.querySelector("login-list");
+ let loginListItem = loginList.shadowRoot.querySelector(
+ ".login-list-item.vulnerable"
+ );
+ loginListItem.click();
+ });
+ await LoginTestUtils.telemetry.waitForEventCount(nextTelemetryEventCount++);
+
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () {
+ let loginItem = content.document.querySelector("login-item");
+ let copyButton = loginItem.shadowRoot.querySelector(
+ ".copy-username-button"
+ );
+ copyButton.click();
+ });
+ await LoginTestUtils.telemetry.waitForEventCount(nextTelemetryEventCount++);
+
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () {
+ let loginItem = content.document.querySelector("login-item");
+ let deleteButton = loginItem.shadowRoot.querySelector(".delete-button");
+ deleteButton.click();
+ let confirmDeleteDialog = content.document.querySelector(
+ "confirmation-dialog"
+ );
+ let confirmDeleteButton =
+ confirmDeleteDialog.shadowRoot.querySelector(".confirm-button");
+ confirmDeleteButton.click();
+ });
+ await LoginTestUtils.telemetry.waitForEventCount(nextTelemetryEventCount++);
+
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () {
+ let loginSort = content.document
+ .querySelector("login-list")
+ .shadowRoot.querySelector("#login-sort");
+ loginSort.value = "last-used";
+ loginSort.dispatchEvent(new content.Event("change", { bubbles: true }));
+ });
+ await LoginTestUtils.telemetry.waitForEventCount(nextTelemetryEventCount++);
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("signon.management.page.sort");
+ });
+
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () {
+ const loginList = content.document.querySelector("login-list");
+ const loginFilter = loginList.shadowRoot.querySelector("login-filter");
+ const input = loginFilter.shadowRoot.querySelector("input");
+ input.setUserInput("test");
+ });
+ await LoginTestUtils.telemetry.waitForEventCount(nextTelemetryEventCount++);
+
+ const testOSAuth = OSKeyStoreTestUtils.canTestOSKeyStoreLogin();
+ let expectedEvents = [
+ [true, "pwmgr", "open_management", "direct"],
+ [true, "pwmgr", "select", "existing_login", null, { breached: "true" }],
+ [true, "pwmgr", "copy", "username", null, { breached: "true" }],
+ [testOSAuth, "pwmgr", "reauthenticate", "os_auth", "success"],
+ [testOSAuth, "pwmgr", "copy", "password", null, { breached: "true" }],
+ [true, "pwmgr", "open_site", "existing_login", null, { breached: "true" }],
+ [testOSAuth, "pwmgr", "reauthenticate", "os_auth", "success"],
+ [testOSAuth, "pwmgr", "show", "password", null, { breached: "true" }],
+ [testOSAuth, "pwmgr", "hide", "password", null, { breached: "true" }],
+ [testOSAuth, "pwmgr", "reauthenticate", "os_auth", "success_no_prompt"],
+ [testOSAuth, "pwmgr", "edit", "existing_login", null, { breached: "true" }],
+ [testOSAuth, "pwmgr", "save", "existing_login", null, { breached: "true" }],
+ [true, "pwmgr", "delete", "existing_login", null, { breached: "true" }],
+ [true, "pwmgr", "new", "new_login"],
+ [true, "pwmgr", "cancel", "new_login"],
+ [true, "pwmgr", "select", "existing_login", null, { vulnerable: "true" }],
+ [true, "pwmgr", "copy", "username", null, { vulnerable: "true" }],
+ [true, "pwmgr", "delete", "existing_login", null, { vulnerable: "true" }],
+ [true, "pwmgr", "sort", "list"],
+ [true, "pwmgr", "filter", "list"],
+ ];
+ expectedEvents = expectedEvents
+ .filter(event => event[0])
+ .map(event => event.slice(1));
+
+ TelemetryTestUtils.assertEvents(
+ expectedEvents,
+ { category: "pwmgr" },
+ { clear: true, process: "content" }
+ );
+});
diff --git a/browser/components/aboutlogins/tests/browser/browser_alertDismissedAfterChangingPassword.js b/browser/components/aboutlogins/tests/browser/browser_alertDismissedAfterChangingPassword.js
new file mode 100644
index 0000000000..623df38fcb
--- /dev/null
+++ b/browser/components/aboutlogins/tests/browser/browser_alertDismissedAfterChangingPassword.js
@@ -0,0 +1,227 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+EXPECTED_BREACH = {
+ AddedDate: "2018-12-20T23:56:26Z",
+ BreachDate: "2018-12-16",
+ Domain: "breached.example.com",
+ Name: "Breached",
+ PwnCount: 1643100,
+ DataClasses: ["Email addresses", "Usernames", "Passwords", "IP addresses"],
+ _status: "synced",
+ id: "047940fe-d2fd-4314-b636-b4a952ee0043",
+ last_modified: "1541615610052",
+ schema: "1541615609018",
+};
+
+let VULNERABLE_TEST_LOGIN2 = new nsLoginInfo(
+ "https://2.example.com",
+ "https://2.example.com",
+ null,
+ "user2",
+ "pass3",
+ "username",
+ "password"
+);
+
+add_setup(async function () {
+ TEST_LOGIN1 = await addLogin(TEST_LOGIN1);
+ VULNERABLE_TEST_LOGIN2 = await addLogin(VULNERABLE_TEST_LOGIN2);
+ TEST_LOGIN3 = await addLogin(TEST_LOGIN3);
+ await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ url: "about:logins",
+ });
+ registerCleanupFunction(() => {
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ Services.logins.removeAllUserFacingLogins();
+ });
+});
+
+add_task(async function test_added_login_shows_breach_warning() {
+ let browser = gBrowser.selectedBrowser;
+ await SpecialPowers.spawn(
+ browser,
+ [[TEST_LOGIN1.guid, VULNERABLE_TEST_LOGIN2.guid, TEST_LOGIN3.guid]],
+ async ([regularLoginGuid, vulnerableLoginGuid, breachedLoginGuid]) => {
+ let loginList = Cu.waiveXrays(
+ content.document.querySelector("login-list")
+ );
+ await ContentTaskUtils.waitForCondition(
+ () => loginList.shadowRoot.querySelectorAll(".login-list-item").length,
+ "Waiting for login-list to get populated"
+ );
+ let { listItem: regularListItem } = loginList._logins[regularLoginGuid];
+ let { listItem: vulnerableListItem } =
+ loginList._logins[vulnerableLoginGuid];
+ let { listItem: breachedListItem } = loginList._logins[breachedLoginGuid];
+ await ContentTaskUtils.waitForCondition(() => {
+ return (
+ !regularListItem.matches(".breached.vulnerable") &&
+ vulnerableListItem.matches(".vulnerable") &&
+ breachedListItem.matches(".breached")
+ );
+ }, `waiting for the list items to get their classes updated: ${regularListItem.className} / ${vulnerableListItem.className} / ${breachedListItem.className}`);
+ Assert.ok(
+ !regularListItem.classList.contains("breached") &&
+ !regularListItem.classList.contains("vulnerable"),
+ "regular login should not be marked breached or vulnerable: " +
+ regularLoginGuid.className
+ );
+ Assert.ok(
+ !vulnerableListItem.classList.contains("breached") &&
+ vulnerableListItem.classList.contains("vulnerable"),
+ "vulnerable login should be marked vulnerable: " +
+ vulnerableListItem.className
+ );
+ Assert.ok(
+ breachedListItem.classList.contains("breached") &&
+ !breachedListItem.classList.contains("vulnerable"),
+ "breached login should be marked breached: " +
+ breachedListItem.className
+ );
+
+ breachedListItem.click();
+ let loginItem = Cu.waiveXrays(
+ content.document.querySelector("login-item")
+ );
+ await ContentTaskUtils.waitForCondition(() => {
+ return loginItem._login && loginItem._login.guid == breachedLoginGuid;
+ }, "waiting for breached login to get selected");
+ Assert.ok(
+ !ContentTaskUtils.is_hidden(
+ loginItem.shadowRoot.querySelector(".breach-alert")
+ ),
+ "the breach alert should be visible"
+ );
+ }
+ );
+
+ if (!OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) {
+ info(
+ "leaving test early since the remaining part of the test requires 'edit' mode which requires 'oskeystore' login"
+ );
+ return;
+ }
+
+ let reauthObserved = forceAuthTimeoutAndWaitForOSKeyStoreLogin({
+ loginResult: true,
+ });
+ // Change the password on the breached login and check that the
+ // login is no longer marked as breached. The vulnerable login
+ // should still be marked as vulnerable afterwards.
+ await SpecialPowers.spawn(browser, [], () => {
+ let loginItem = Cu.waiveXrays(content.document.querySelector("login-item"));
+ loginItem.shadowRoot.querySelector(".edit-button").click();
+ });
+ await reauthObserved;
+ await SpecialPowers.spawn(
+ browser,
+ [[TEST_LOGIN1.guid, VULNERABLE_TEST_LOGIN2.guid, TEST_LOGIN3.guid]],
+ async ([regularLoginGuid, vulnerableLoginGuid, breachedLoginGuid]) => {
+ let loginList = Cu.waiveXrays(
+ content.document.querySelector("login-list")
+ );
+ let loginItem = Cu.waiveXrays(
+ content.document.querySelector("login-item")
+ );
+ await ContentTaskUtils.waitForCondition(
+ () => loginItem.dataset.editing == "true",
+ "waiting for login-item to enter edit mode"
+ );
+
+ // The password display field is in the DOM when password input is unfocused.
+ // To get the password input field, ensure it receives focus.
+ let passwordInput = loginItem.shadowRoot.querySelector(
+ "input[type='password']"
+ );
+ passwordInput.focus();
+ passwordInput = loginItem.shadowRoot.querySelector(
+ "input[name='password']"
+ );
+
+ const CHANGED_PASSWORD_VALUE = "changedPassword";
+ passwordInput.value = CHANGED_PASSWORD_VALUE;
+ let saveChangesButton = loginItem.shadowRoot.querySelector(
+ ".save-changes-button"
+ );
+ saveChangesButton.click();
+
+ await ContentTaskUtils.waitForCondition(() => {
+ return (
+ loginList._logins[breachedLoginGuid].login.password ==
+ CHANGED_PASSWORD_VALUE
+ );
+ }, "waiting for stored login to get updated");
+
+ Assert.ok(
+ ContentTaskUtils.is_hidden(
+ loginItem.shadowRoot.querySelector(".breach-alert")
+ ),
+ "the breach alert should be hidden now"
+ );
+
+ let { listItem: breachedListItem } = loginList._logins[breachedLoginGuid];
+ let { listItem: vulnerableListItem } =
+ loginList._logins[vulnerableLoginGuid];
+ Assert.ok(
+ !breachedListItem.classList.contains("breached") &&
+ !breachedListItem.classList.contains("vulnerable"),
+ "the originally breached login should no longer be marked as breached"
+ );
+ Assert.ok(
+ !vulnerableListItem.classList.contains("breached") &&
+ vulnerableListItem.classList.contains("vulnerable"),
+ "vulnerable login should still be marked vulnerable: " +
+ vulnerableListItem.className
+ );
+
+ // Change the password on the vulnerable login and check that the
+ // login is no longer marked as vulnerable.
+ vulnerableListItem.click();
+ await ContentTaskUtils.waitForCondition(() => {
+ return loginItem._login && loginItem._login.guid == vulnerableLoginGuid;
+ }, "waiting for vulnerable login to get selected");
+ Assert.ok(
+ !ContentTaskUtils.is_hidden(
+ loginItem.shadowRoot.querySelector(".vulnerable-alert")
+ ),
+ "the vulnerable alert should be visible"
+ );
+ loginItem.shadowRoot.querySelector(".edit-button").click();
+ await ContentTaskUtils.waitForCondition(
+ () => loginItem.dataset.editing == "true",
+ "waiting for login-item to enter edit mode"
+ );
+
+ passwordInput.value = CHANGED_PASSWORD_VALUE;
+ saveChangesButton.click();
+
+ await ContentTaskUtils.waitForCondition(() => {
+ return (
+ loginList._logins[vulnerableLoginGuid].login.password ==
+ CHANGED_PASSWORD_VALUE
+ );
+ }, "waiting for stored login to get updated");
+
+ Assert.ok(
+ ContentTaskUtils.is_hidden(
+ loginItem.shadowRoot.querySelector(".vulnerable-alert")
+ ),
+ "the vulnerable alert should be hidden now"
+ );
+ Assert.equal(
+ vulnerableListItem.querySelector(".alert-icon").src,
+ "",
+ ".alert-icon for the vulnerable list item should have its source removed"
+ );
+ vulnerableListItem = loginList._logins[vulnerableLoginGuid].listItem;
+ Assert.ok(
+ !vulnerableListItem.classList.contains("breached") &&
+ !vulnerableListItem.classList.contains("vulnerable"),
+ "vulnerable login should no longer be marked vulnerable: " +
+ vulnerableListItem.className
+ );
+ }
+ );
+});
diff --git a/browser/components/aboutlogins/tests/browser/browser_breachAlertShowingForAddedLogin.js b/browser/components/aboutlogins/tests/browser/browser_breachAlertShowingForAddedLogin.js
new file mode 100644
index 0000000000..a5aef703fa
--- /dev/null
+++ b/browser/components/aboutlogins/tests/browser/browser_breachAlertShowingForAddedLogin.js
@@ -0,0 +1,123 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+EXPECTED_BREACH = {
+ AddedDate: "2018-12-20T23:56:26Z",
+ BreachDate: "2018-12-16",
+ Domain: "breached.example.com",
+ Name: "Breached",
+ PwnCount: 1643100,
+ DataClasses: ["Email addresses", "Usernames", "Passwords", "IP addresses"],
+ _status: "synced",
+ id: "047940fe-d2fd-4314-b636-b4a952ee0043",
+ last_modified: "1541615610052",
+ schema: "1541615609018",
+};
+
+add_setup(async function () {
+ await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ url: "about:logins",
+ });
+ registerCleanupFunction(() => {
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ Services.logins.removeAllUserFacingLogins();
+ });
+});
+
+add_task(async function test_added_login_shows_breach_warning() {
+ let browser = gBrowser.selectedBrowser;
+ await SpecialPowers.spawn(browser, [], async () => {
+ let loginList = Cu.waiveXrays(content.document.querySelector("login-list"));
+ Assert.equal(
+ loginList._loginGuidsSortedOrder.length,
+ 0,
+ "the login list should be empty"
+ );
+ });
+
+ TEST_LOGIN3 = await addLogin(TEST_LOGIN3);
+ await SpecialPowers.spawn(
+ browser,
+ [TEST_LOGIN3.guid],
+ async aTestLogin3Guid => {
+ let loginList = Cu.waiveXrays(
+ content.document.querySelector("login-list")
+ );
+ await ContentTaskUtils.waitForCondition(
+ () => loginList._loginGuidsSortedOrder.length == 1,
+ "waiting for login list count to equal one. count=" +
+ loginList._loginGuidsSortedOrder.length
+ );
+ Assert.equal(
+ loginList._loginGuidsSortedOrder.length,
+ 1,
+ "one login should be in the list"
+ );
+ let breachedLoginListItems;
+ await ContentTaskUtils.waitForCondition(() => {
+ breachedLoginListItems = loginList._list.querySelectorAll(
+ ".login-list-item[data-guid].breached"
+ );
+ return breachedLoginListItems.length == 1;
+ }, "waiting for the login to get marked as breached");
+ Assert.equal(
+ breachedLoginListItems[0].dataset.guid,
+ aTestLogin3Guid,
+ "the breached login should be login3"
+ );
+ }
+ );
+
+ info("adding a login that uses the same password as the breached login");
+ let vulnerableLogin = new nsLoginInfo(
+ "https://2.example.com",
+ "https://2.example.com",
+ null,
+ "user2",
+ "pass3",
+ "username",
+ "password"
+ );
+ vulnerableLogin = await addLogin(vulnerableLogin);
+ await SpecialPowers.spawn(
+ browser,
+ [[TEST_LOGIN3.guid, vulnerableLogin.guid]],
+ async ([aTestLogin3Guid, aVulnerableLoginGuid]) => {
+ let loginList = Cu.waiveXrays(
+ content.document.querySelector("login-list")
+ );
+ await ContentTaskUtils.waitForCondition(
+ () => loginList._loginGuidsSortedOrder.length == 2,
+ "waiting for login list count to equal two. count=" +
+ loginList._loginGuidsSortedOrder.length
+ );
+ Assert.equal(
+ loginList._loginGuidsSortedOrder.length,
+ 2,
+ "two logins should be in the list"
+ );
+ let breachedAndVulnerableLoginListItems;
+ await ContentTaskUtils.waitForCondition(() => {
+ breachedAndVulnerableLoginListItems = [
+ ...loginList._list.querySelectorAll(".breached, .vulnerable"),
+ ];
+ return breachedAndVulnerableLoginListItems.length == 2;
+ }, "waiting for the logins to get marked as breached and vulnerable");
+ Assert.ok(
+ !!breachedAndVulnerableLoginListItems.find(
+ listItem => listItem.dataset.guid == aTestLogin3Guid
+ ),
+ "the list should include the breached login: " +
+ breachedAndVulnerableLoginListItems.map(li => li.dataset.guid)
+ );
+ Assert.ok(
+ !!breachedAndVulnerableLoginListItems.find(
+ listItem => listItem.dataset.guid == aVulnerableLoginGuid
+ ),
+ "the list should include the vulnerable login: " +
+ breachedAndVulnerableLoginListItems.map(li => li.dataset.guid)
+ );
+ }
+ );
+});
diff --git a/browser/components/aboutlogins/tests/browser/browser_confirmDeleteDialog.js b/browser/components/aboutlogins/tests/browser/browser_confirmDeleteDialog.js
new file mode 100644
index 0000000000..52c288c780
--- /dev/null
+++ b/browser/components/aboutlogins/tests/browser/browser_confirmDeleteDialog.js
@@ -0,0 +1,128 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+add_setup(async function () {
+ await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ url: "about:logins",
+ });
+ registerCleanupFunction(() => {
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ });
+});
+
+add_task(async function test() {
+ let browser = gBrowser.selectedBrowser;
+
+ await SpecialPowers.spawn(browser, [], async () => {
+ let loginItem = Cu.waiveXrays(content.document.querySelector("login-item"));
+
+ let showPromise = loginItem.showConfirmationDialog("delete");
+
+ let dialog = Cu.waiveXrays(
+ content.document.querySelector("confirmation-dialog")
+ );
+ let cancelButton = dialog.shadowRoot.querySelector(".cancel-button");
+ let confirmDeleteButton =
+ dialog.shadowRoot.querySelector(".confirm-button");
+ let dismissButton = dialog.shadowRoot.querySelector(".dismiss-button");
+ let message = dialog.shadowRoot.querySelector(".message");
+ let title = dialog.shadowRoot.querySelector(".title");
+
+ await content.document.l10n.translateElements([
+ title,
+ message,
+ cancelButton,
+ confirmDeleteButton,
+ ]);
+
+ Assert.equal(
+ title.textContent,
+ "Remove this login?",
+ "Title contents should match l10n attribute set on outer element"
+ );
+ Assert.equal(
+ message.textContent,
+ "This action cannot be undone.",
+ "Message contents should match l10n attribute set on outer element"
+ );
+ Assert.equal(
+ cancelButton.textContent,
+ "Cancel",
+ "Cancel button contents should match l10n attribute set on outer element"
+ );
+ Assert.equal(
+ confirmDeleteButton.textContent,
+ "Remove",
+ "Remove button contents should match l10n attribute set on outer element"
+ );
+
+ cancelButton.click();
+ try {
+ await showPromise;
+ Assert.ok(
+ false,
+ "Promise returned by show() should not resolve after clicking cancel button"
+ );
+ } catch (ex) {
+ Assert.ok(
+ true,
+ "Promise returned by show() should reject after clicking cancel button"
+ );
+ }
+ await ContentTaskUtils.waitForCondition(
+ () => dialog.hidden,
+ "Waiting for the dialog to be hidden"
+ );
+ Assert.ok(
+ dialog.hidden,
+ "Dialog should be hidden after clicking cancel button"
+ );
+
+ showPromise = loginItem.showConfirmationDialog("delete");
+ dismissButton.click();
+ try {
+ await showPromise;
+ Assert.ok(
+ false,
+ "Promise returned by show() should not resolve after clicking dismiss button"
+ );
+ } catch (ex) {
+ Assert.ok(
+ true,
+ "Promise returned by show() should reject after clicking dismiss button"
+ );
+ }
+ await ContentTaskUtils.waitForCondition(
+ () => dialog.hidden,
+ "Waiting for the dialog to be hidden"
+ );
+ Assert.ok(
+ dialog.hidden,
+ "Dialog should be hidden after clicking dismiss button"
+ );
+
+ showPromise = loginItem.showConfirmationDialog("delete");
+ confirmDeleteButton.click();
+ try {
+ await showPromise;
+ Assert.ok(
+ true,
+ "Promise returned by show() should resolve after clicking confirm button"
+ );
+ } catch (ex) {
+ Assert.ok(
+ false,
+ "Promise returned by show() should not reject after clicking confirm button"
+ );
+ }
+ await ContentTaskUtils.waitForCondition(
+ () => dialog.hidden,
+ "Waiting for the dialog to be hidden"
+ );
+ Assert.ok(
+ dialog.hidden,
+ "Dialog should be hidden after clicking confirm button"
+ );
+ });
+});
diff --git a/browser/components/aboutlogins/tests/browser/browser_contextmenuFillLogins.js b/browser/components/aboutlogins/tests/browser/browser_contextmenuFillLogins.js
new file mode 100644
index 0000000000..a10d92baac
--- /dev/null
+++ b/browser/components/aboutlogins/tests/browser/browser_contextmenuFillLogins.js
@@ -0,0 +1,185 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+add_setup(async function () {
+ TEST_LOGIN1 = await addLogin(TEST_LOGIN1);
+ await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ url: "about:logins",
+ });
+ registerCleanupFunction(() => {
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ });
+});
+
+const gTests = [
+ {
+ name: "test contextmenu on password field in create login view",
+ async setup(browser) {
+ // load up the create login view
+ await SpecialPowers.spawn(browser, [], async () => {
+ let loginList = Cu.waiveXrays(
+ content.document.querySelector("login-list")
+ );
+ let createButton = loginList._createLoginButton;
+ createButton.click();
+ });
+ },
+ },
+];
+
+if (OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) {
+ gTests[gTests.length] = {
+ name: "test contextmenu on password field in edit login view",
+ async setup(browser) {
+ let osAuthDialogShown = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true);
+
+ // load up the edit login view
+ await SpecialPowers.spawn(
+ browser,
+ [LoginHelper.loginToVanillaObject(TEST_LOGIN1)],
+ async login => {
+ let loginList = content.document.querySelector("login-list");
+ let loginListItem = loginList.shadowRoot.querySelector(
+ ".login-list-item[data-guid]:not([hidden])"
+ );
+ info("Clicking on the first login");
+
+ loginListItem.click();
+ let loginItem = Cu.waiveXrays(
+ content.document.querySelector("login-item")
+ );
+ await ContentTaskUtils.waitForCondition(() => {
+ return (
+ loginItem._login.guid == loginListItem.dataset.guid &&
+ loginItem._login.guid == login.guid
+ );
+ }, "Waiting for login item to get populated");
+ let editButton = loginItem.shadowRoot.querySelector(".edit-button");
+ editButton.click();
+ }
+ );
+ await osAuthDialogShown;
+ await SpecialPowers.spawn(browser, [], async () => {
+ let loginItem = Cu.waiveXrays(
+ content.document.querySelector("login-item")
+ );
+ await ContentTaskUtils.waitForCondition(
+ () => loginItem.dataset.editing,
+ "Waiting for login-item to be in editing state"
+ );
+ });
+ },
+ };
+}
+
+/**
+ * Synthesize mouse clicks to open the password manager context menu popup
+ * for a target input element.
+ *
+ */
+async function openContextMenuForPasswordInput(browser) {
+ const doc = browser.ownerDocument;
+ const CONTEXT_MENU = doc.getElementById("contentAreaContextMenu");
+
+ let contextMenuShownPromise = BrowserTestUtils.waitForEvent(
+ CONTEXT_MENU,
+ "popupshown"
+ );
+
+ let passwordInputCoords = await SpecialPowers.spawn(browser, [], async () => {
+ let loginItem = Cu.waiveXrays(content.document.querySelector("login-item"));
+
+ // The password display field is in the DOM when password input is unfocused.
+ // To get the password input field, ensure it receives focus.
+ let passwordInput = loginItem.shadowRoot.querySelector(
+ "input[type='password']"
+ );
+ passwordInput.focus();
+ passwordInput = loginItem.shadowRoot.querySelector(
+ "input[name='password']"
+ );
+
+ passwordInput.focus();
+ let passwordRect = passwordInput.getBoundingClientRect();
+
+ // listen for the contextmenu event so we can assert on receiving it
+ // and examine the target
+ content.contextmenuPromise = new Promise(resolve => {
+ content.document.body.addEventListener(
+ "contextmenu",
+ event => {
+ info(
+ `Received event on target: ${event.target.nodeName}, type: ${event.target.type}`
+ );
+ content.console.log("got contextmenu event: ", event);
+ resolve(event);
+ },
+ { once: true }
+ );
+ });
+
+ let coords = {
+ x: passwordRect.x + passwordRect.width / 2,
+ y: passwordRect.y + passwordRect.height / 2,
+ };
+ return coords;
+ });
+
+ // add the offsets of the <browser> in the chrome window
+ let browserOffsets = browser.getBoundingClientRect();
+ let offsetX = browserOffsets.x + passwordInputCoords.x;
+ let offsetY = browserOffsets.y + passwordInputCoords.y;
+
+ // Synthesize a right mouse click over the password input element, we have to trigger
+ // both events because formfill code relies on this event happening before the contextmenu
+ // (which it does for real user input) in order to not show the password autocomplete.
+ let eventDetails = { type: "mousedown", button: 2 };
+ await EventUtils.synthesizeMouseAtPoint(offsetX, offsetY, eventDetails);
+
+ // Synthesize a contextmenu event to actually open the context menu.
+ eventDetails = { type: "contextmenu", button: 2 };
+ await EventUtils.synthesizeMouseAtPoint(offsetX, offsetY, eventDetails);
+
+ await SpecialPowers.spawn(browser, [], async () => {
+ let event = await content.contextmenuPromise;
+ // XXX the event target here is the login-item element,
+ // not the input[type='password'] in its shadowRoot
+ info("contextmenu event target: " + event.target.nodeName);
+ });
+
+ info("waiting for contextMenuShownPromise");
+ await contextMenuShownPromise;
+ return CONTEXT_MENU;
+}
+
+async function testContextMenuOnInputField(testData) {
+ let browser = gBrowser.selectedBrowser;
+
+ await SimpleTest.promiseFocus(browser.ownerGlobal);
+ await testData.setup(browser);
+
+ info("test setup completed");
+ let contextMenu = await openContextMenuForPasswordInput(browser);
+ let fillItem = contextMenu.querySelector("#fill-login");
+ Assert.ok(fillItem, "fill menu item exists");
+ Assert.ok(
+ fillItem && EventUtils.isHidden(fillItem),
+ "fill menu item is hidden"
+ );
+
+ let promiseHidden = BrowserTestUtils.waitForEvent(contextMenu, "popuphidden");
+ info("Calling hidePopup on contextMenu");
+ contextMenu.hidePopup();
+ info("waiting for promiseHidden");
+ await promiseHidden;
+}
+
+for (let testData of gTests) {
+ let tmp = {
+ async [testData.name]() {
+ await testContextMenuOnInputField(testData);
+ },
+ };
+ add_task(tmp[testData.name]);
+}
diff --git a/browser/components/aboutlogins/tests/browser/browser_copyToClipboardButton.js b/browser/components/aboutlogins/tests/browser/browser_copyToClipboardButton.js
new file mode 100644
index 0000000000..f8ca37cc47
--- /dev/null
+++ b/browser/components/aboutlogins/tests/browser/browser_copyToClipboardButton.js
@@ -0,0 +1,118 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+add_task(async function test() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["dom.events.testing.asyncClipboard", true]],
+ });
+
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: "about:logins" },
+ async function (browser) {
+ let TEST_LOGIN = {
+ guid: "70a",
+ username: "jared",
+ password: "deraj",
+ origin: "https://www.example.com",
+ };
+
+ await SpecialPowers.spawn(browser, [TEST_LOGIN], async function (login) {
+ let loginItem = Cu.waiveXrays(
+ content.document.querySelector("login-item")
+ );
+
+ // The login object needs to be cloned into the content global.
+ loginItem.setLogin(Cu.cloneInto(login, content));
+
+ // Lower the timeout for the test.
+ Object.defineProperty(
+ loginItem.constructor,
+ "COPY_BUTTON_RESET_TIMEOUT",
+ {
+ configurable: true,
+ writable: true,
+ value: 1000,
+ }
+ );
+ });
+
+ let testCases = [[TEST_LOGIN.username, ".copy-username-button"]];
+ if (OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) {
+ testCases[1] = [TEST_LOGIN.password, ".copy-password-button"];
+ }
+
+ for (let testCase of testCases) {
+ let testObj = {
+ expectedValue: testCase[0],
+ copyButtonSelector: testCase[1],
+ };
+ info(
+ "waiting for " + testObj.expectedValue + " to be placed on clipboard"
+ );
+ let reauthObserved = true;
+ if (testObj.copyButtonSelector.includes("password")) {
+ reauthObserved = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true);
+ }
+
+ await SimpleTest.promiseClipboardChange(
+ testObj.expectedValue,
+ async () => {
+ await SpecialPowers.spawn(
+ browser,
+ [testObj],
+ async function (aTestObj) {
+ let loginItem = content.document.querySelector("login-item");
+ let copyButton = loginItem.shadowRoot.querySelector(
+ aTestObj.copyButtonSelector
+ );
+ info("Clicking 'copy' button");
+ copyButton.click();
+ }
+ );
+ }
+ );
+ await reauthObserved;
+ Assert.ok(true, testObj.expectedValue + " is on clipboard now");
+
+ await SpecialPowers.spawn(
+ browser,
+ [testObj],
+ async function (aTestObj) {
+ let loginItem = Cu.waiveXrays(
+ content.document.querySelector("login-item")
+ );
+ let copyButton = loginItem.shadowRoot.querySelector(
+ aTestObj.copyButtonSelector
+ );
+ let otherCopyButton =
+ copyButton == loginItem._copyUsernameButton
+ ? loginItem._copyPasswordButton
+ : loginItem._copyUsernameButton;
+ Assert.ok(
+ !otherCopyButton.dataset.copied,
+ "The other copy button should have the 'copied' state removed"
+ );
+ Assert.ok(
+ copyButton.dataset.copied,
+ "Success message should be shown"
+ );
+ }
+ );
+ }
+
+ // Wait for the 'copied' attribute to get removed from the copyPassword
+ // button, which is the last button that is clicked in the above testcase.
+ // Since another Copy button isn't clicked, the state won't get cleared
+ // instantly. This test covers the built-in timeout of the visual display.
+ await SpecialPowers.spawn(browser, [], async () => {
+ let copyButton = Cu.waiveXrays(
+ content.document.querySelector("login-item")
+ )._copyPasswordButton;
+ await ContentTaskUtils.waitForCondition(
+ () => !copyButton.dataset.copied,
+ "'copied' attribute should be removed after a timeout"
+ );
+ });
+ }
+ );
+});
diff --git a/browser/components/aboutlogins/tests/browser/browser_createLogin.js b/browser/components/aboutlogins/tests/browser/browser_createLogin.js
new file mode 100644
index 0000000000..a876320ecf
--- /dev/null
+++ b/browser/components/aboutlogins/tests/browser/browser_createLogin.js
@@ -0,0 +1,535 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+add_setup(async function () {
+ let aboutLoginsTab = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ url: "about:logins",
+ });
+ registerCleanupFunction(() => {
+ BrowserTestUtils.removeTab(aboutLoginsTab);
+ Services.logins.removeAllUserFacingLogins();
+ });
+});
+
+add_task(async function test_create_login() {
+ let browser = gBrowser.selectedBrowser;
+ await SpecialPowers.spawn(browser, [], async () => {
+ let loginList = Cu.waiveXrays(content.document.querySelector("login-list"));
+ Assert.ok(
+ !loginList._selectedGuid,
+ "should not be a selected guid by default"
+ );
+ Assert.ok(
+ content.document.documentElement.classList.contains("no-logins"),
+ "Should initially be in no logins view"
+ );
+ Assert.ok(
+ loginList.classList.contains("no-logins"),
+ "login-list should initially be in no logins view"
+ );
+ Assert.equal(
+ loginList._loginGuidsSortedOrder.length,
+ 0,
+ "login list should be empty"
+ );
+ });
+
+ let testCases = [
+ ["ftp://ftp.example.com/", "ftp://ftp.example.com"],
+ ["https://example.com/foo", "https://example.com"],
+ ["http://example.com/", "http://example.com"],
+ [
+ "https://testuser1:testpass1@bugzilla.mozilla.org/show_bug.cgi?id=1556934",
+ "https://bugzilla.mozilla.org",
+ ],
+ ["https://www.example.com/bar", "https://www.example.com"],
+ ];
+
+ for (let i = 0; i < testCases.length; i++) {
+ let originTuple = testCases[i];
+ info("Testcase " + i);
+ let storageChangedPromised = TestUtils.topicObserved(
+ "passwordmgr-storage-changed",
+ (_, data) => data == "addLogin"
+ );
+
+ await SpecialPowers.spawn(
+ browser,
+ [[originTuple, i]],
+ async ([aOriginTuple, index]) => {
+ let loginList = Cu.waiveXrays(
+ content.document.querySelector("login-list")
+ );
+ let createButton = loginList._createLoginButton;
+ Assert.ok(
+ ContentTaskUtils.is_hidden(loginList._blankLoginListItem),
+ "the blank login list item should be hidden initially"
+ );
+ Assert.ok(
+ !createButton.disabled,
+ "Create button should not be disabled initially"
+ );
+
+ let loginItem = Cu.waiveXrays(
+ content.document.querySelector("login-item")
+ );
+ let usernameInput = loginItem.shadowRoot.querySelector(
+ "input[name='username']"
+ );
+ usernameInput.placeholder = "dummy placeholder";
+
+ createButton.click();
+
+ Assert.ok(
+ ContentTaskUtils.is_visible(loginList._blankLoginListItem),
+ "the blank login list item should be visible after clicking on the create button"
+ );
+ Assert.ok(
+ createButton.disabled,
+ "Create button should be disabled after being clicked"
+ );
+
+ let cancelButton = loginItem.shadowRoot.querySelector(".cancel-button");
+ Assert.ok(
+ ContentTaskUtils.is_visible(cancelButton),
+ "cancel button should be visible in create mode with no logins saved"
+ );
+
+ let originInput = loginItem.shadowRoot.querySelector(
+ "input[name='origin']"
+ );
+ let passwordInput = loginItem.shadowRoot.querySelector(
+ "input[name='password']"
+ );
+
+ Assert.equal(
+ content.document.l10n.getAttributes(usernameInput).id,
+ null,
+ "there should be no placeholder id on the username input in edit mode"
+ );
+ Assert.equal(
+ usernameInput.placeholder,
+ "",
+ "there should be no placeholder on the username input in edit mode"
+ );
+ originInput.value = aOriginTuple[0];
+ usernameInput.value = "testuser1";
+ passwordInput.value = "testpass1";
+
+ let saveChangesButton = loginItem.shadowRoot.querySelector(
+ ".save-changes-button"
+ );
+ saveChangesButton.click();
+ }
+ );
+
+ info("waiting for login to get added to storage");
+ await storageChangedPromised;
+ info("login added to storage");
+
+ let canTestOSKeyStoreLogin = OSKeyStoreTestUtils.canTestOSKeyStoreLogin();
+ if (canTestOSKeyStoreLogin) {
+ storageChangedPromised = TestUtils.topicObserved(
+ "passwordmgr-storage-changed",
+ (_, data) => data == "modifyLogin"
+ );
+ }
+ await SpecialPowers.spawn(browser, [originTuple], async aOriginTuple => {
+ await ContentTaskUtils.waitForCondition(() => {
+ return !content.document.documentElement.classList.contains(
+ "no-logins"
+ );
+ }, "waiting for no-logins view to exit");
+ Assert.ok(
+ !content.document.documentElement.classList.contains("no-logins"),
+ "Should no longer be in no logins view"
+ );
+ let loginList = Cu.waiveXrays(
+ content.document.querySelector("login-list")
+ );
+ Assert.ok(
+ !loginList.classList.contains("no-logins"),
+ "login-list should no longer be in no logins view"
+ );
+ Assert.ok(
+ ContentTaskUtils.is_hidden(loginList._blankLoginListItem),
+ "the blank login list item should be hidden after adding new login"
+ );
+ Assert.ok(
+ !loginList._createLoginButton.disabled,
+ "Create button shouldn't be disabled after exiting create login view"
+ );
+
+ let loginGuid = await ContentTaskUtils.waitForCondition(() => {
+ return loginList._loginGuidsSortedOrder.find(
+ guid => loginList._logins[guid].login.origin == aOriginTuple[1]
+ );
+ }, "Waiting for login to be displayed");
+ Assert.ok(loginGuid, "Expected login found in login-list");
+
+ let loginItem = Cu.waiveXrays(
+ content.document.querySelector("login-item")
+ );
+ Assert.equal(loginItem._login.guid, loginGuid, "login-item should match");
+
+ let { login, listItem } = loginList._logins[loginGuid];
+ Assert.ok(
+ listItem.classList.contains("selected"),
+ "list item should be selected"
+ );
+ Assert.equal(
+ login.origin,
+ aOriginTuple[1],
+ "Stored login should only include the origin of the URL provided during creation"
+ );
+ Assert.equal(
+ login.username,
+ "testuser1",
+ "Stored login should have username provided during creation"
+ );
+ Assert.equal(
+ login.password,
+ "testpass1",
+ "Stored login should have password provided during creation"
+ );
+
+ let usernameInput = loginItem.shadowRoot.querySelector(
+ "input[name='username']"
+ );
+ await ContentTaskUtils.waitForCondition(
+ () => usernameInput.placeholder,
+ "waiting for placeholder to get set"
+ );
+ Assert.ok(
+ usernameInput.placeholder,
+ "there should be a placeholder on the username input when not in edit mode"
+ );
+ });
+
+ if (!canTestOSKeyStoreLogin) {
+ continue;
+ }
+
+ let reauthObserved = forceAuthTimeoutAndWaitForOSKeyStoreLogin({
+ loginResult: true,
+ });
+ await SpecialPowers.spawn(browser, [originTuple], async aOriginTuple => {
+ let loginItem = Cu.waiveXrays(
+ content.document.querySelector("login-item")
+ );
+ let editButton = loginItem.shadowRoot.querySelector(".edit-button");
+ info("clicking on edit button");
+ editButton.click();
+ });
+ info("waiting for oskeystore auth");
+ await reauthObserved;
+
+ await SpecialPowers.spawn(browser, [originTuple], async aOriginTuple => {
+ let loginItem = Cu.waiveXrays(
+ content.document.querySelector("login-item")
+ );
+ await ContentTaskUtils.waitForCondition(
+ () => loginItem.dataset.editing,
+ "waiting for 'edit' mode"
+ );
+ info("in edit mode");
+
+ let usernameInput = loginItem.shadowRoot.querySelector(
+ "input[name='username']"
+ );
+ let passwordInput = loginItem.shadowRoot.querySelector(
+ "input[type='password']"
+ );
+ passwordInput.focus();
+ passwordInput = loginItem.shadowRoot.querySelector(
+ "input[name='password']"
+ );
+ usernameInput.value = "testuser2";
+ passwordInput.value = "testpass2";
+
+ let saveChangesButton = loginItem.shadowRoot.querySelector(
+ ".save-changes-button"
+ );
+ info("clicking save changes button");
+ saveChangesButton.click();
+ });
+
+ info("waiting for login to get modified in storage");
+ await storageChangedPromised;
+ info("login modified in storage");
+
+ await SpecialPowers.spawn(browser, [originTuple], async aOriginTuple => {
+ let loginList = Cu.waiveXrays(
+ content.document.querySelector("login-list")
+ );
+ let login;
+ await ContentTaskUtils.waitForCondition(() => {
+ login = Object.values(loginList._logins).find(
+ obj => obj.login.origin == aOriginTuple[1]
+ ).login;
+ info(`${login.origin} / ${login.username} / ${login.password}`);
+ return (
+ login.origin == aOriginTuple[1] &&
+ login.username == "testuser2" &&
+ login.password == "testpass2"
+ );
+ }, "waiting for the login to get updated");
+ Assert.equal(
+ login.origin,
+ aOriginTuple[1],
+ "Stored login should only include the origin of the URL provided during creation"
+ );
+ Assert.equal(
+ login.username,
+ "testuser2",
+ "Stored login should have modified username"
+ );
+ Assert.equal(
+ login.password,
+ "testpass2",
+ "Stored login should have modified password"
+ );
+ });
+ }
+
+ await SpecialPowers.spawn(
+ browser,
+ [testCases.length],
+ async testCasesLength => {
+ let loginList = Cu.waiveXrays(
+ content.document.querySelector("login-list")
+ );
+ Assert.equal(
+ loginList._loginGuidsSortedOrder.length,
+ 5,
+ "login list should have a login per testcase"
+ );
+ }
+ );
+});
+
+add_task(async function test_cancel_create_login() {
+ let browser = gBrowser.selectedBrowser;
+ await SpecialPowers.spawn(browser, [], async () => {
+ let loginList = Cu.waiveXrays(content.document.querySelector("login-list"));
+ Assert.ok(
+ loginList._selectedGuid,
+ "there should be a selected guid before create mode"
+ );
+ Assert.ok(
+ ContentTaskUtils.is_hidden(loginList._blankLoginListItem),
+ "the blank login list item should be hidden before create mode"
+ );
+
+ let createButton = content.document
+ .querySelector("login-list")
+ .shadowRoot.querySelector(".create-login-button");
+ createButton.click();
+
+ Assert.ok(
+ !loginList._selectedGuid,
+ "there should be no selected guid when in create mode"
+ );
+ Assert.ok(
+ ContentTaskUtils.is_visible(loginList._blankLoginListItem),
+ "the blank login list item should be visible in create mode"
+ );
+
+ let loginItem = Cu.waiveXrays(content.document.querySelector("login-item"));
+ let cancelButton = loginItem.shadowRoot.querySelector(".cancel-button");
+ cancelButton.click();
+
+ Assert.ok(
+ loginList._selectedGuid,
+ "there should be a selected guid after canceling create mode"
+ );
+ Assert.ok(
+ ContentTaskUtils.is_hidden(loginList._blankLoginListItem),
+ "the blank login list item should be hidden after canceling create mode"
+ );
+ });
+});
+
+add_task(
+ async function test_cancel_create_login_with_filter_showing_one_login() {
+ const browser = gBrowser.selectedBrowser;
+ await SpecialPowers.spawn(browser, [], async () => {
+ const loginList = Cu.waiveXrays(
+ content.document.querySelector("login-list")
+ );
+
+ const loginFilter = Cu.waiveXrays(
+ loginList.shadowRoot.querySelector("login-filter")
+ );
+ loginFilter.value = "bugzilla.mozilla.org";
+ Assert.equal(
+ loginList._list.querySelectorAll(
+ ".login-list-item[data-guid]:not([hidden])"
+ ).length,
+ 1,
+ "filter should have one login showing"
+ );
+ let visibleLoginGuid = loginList.shadowRoot.querySelectorAll(
+ ".login-list-item[data-guid]:not([hidden])"
+ )[0].dataset.guid;
+
+ let createButton = loginList._createLoginButton;
+ createButton.click();
+
+ let loginItem = Cu.waiveXrays(
+ content.document.querySelector("login-item")
+ );
+ let cancelButton = loginItem.shadowRoot.querySelector(".cancel-button");
+ Assert.ok(
+ ContentTaskUtils.is_visible(cancelButton),
+ "cancel button should be visible in create mode with one login showing"
+ );
+ cancelButton.click();
+
+ Assert.equal(
+ loginFilter.value,
+ "bugzilla.mozilla.org",
+ "login-filter should not be cleared if there was a login in the list"
+ );
+ Assert.equal(
+ loginList.shadowRoot.querySelectorAll(
+ ".login-list-item[data-guid]:not([hidden])"
+ )[0].dataset.guid,
+ visibleLoginGuid,
+ "the same login should still be visible"
+ );
+ });
+ }
+);
+
+add_task(async function test_cancel_create_login_with_logins_filtered_out() {
+ const browser = gBrowser.selectedBrowser;
+ await SpecialPowers.spawn(browser, [], async () => {
+ const loginList = Cu.waiveXrays(
+ content.document.querySelector("login-list")
+ );
+ const loginFilter = Cu.waiveXrays(
+ loginList.shadowRoot.querySelector("login-filter")
+ );
+ loginFilter.value = "XXX-no-logins-should-match-this-XXX";
+ await Promise.resolve();
+ Assert.equal(
+ loginList._list.querySelectorAll(
+ ".login-list-item[data-guid]:not([hidden])"
+ ).length,
+ 0,
+ "filter should have no logins showing"
+ );
+
+ let createButton = loginList._createLoginButton;
+ createButton.click();
+
+ let loginItem = Cu.waiveXrays(content.document.querySelector("login-item"));
+ let cancelButton = loginItem.shadowRoot.querySelector(".cancel-button");
+ Assert.ok(
+ ContentTaskUtils.is_visible(cancelButton),
+ "cancel button should be visible in create mode with no logins showing"
+ );
+ cancelButton.click();
+ await Promise.resolve();
+
+ Assert.equal(
+ loginFilter.value,
+ "",
+ "login-filter should be cleared if there were no logins in the list"
+ );
+ let visibleLoginItems = loginList.shadowRoot.querySelectorAll(
+ ".login-list-item[data-guid]:not([hidden])"
+ );
+ Assert.equal(
+ visibleLoginItems.length,
+ 5,
+ "all logins should be visible with blank filter"
+ );
+ Assert.equal(
+ loginList._selectedGuid,
+ visibleLoginItems[0].dataset.guid,
+ "the first item in the list should be selected"
+ );
+ });
+});
+
+add_task(async function test_create_duplicate_login() {
+ if (!OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) {
+ return;
+ }
+
+ let browser = gBrowser.selectedBrowser;
+ EXPECTED_ERROR_MESSAGE = "This login already exists.";
+ await SpecialPowers.spawn(browser, [], async () => {
+ let loginList = Cu.waiveXrays(content.document.querySelector("login-list"));
+ let createButton = loginList._createLoginButton;
+ createButton.click();
+
+ let loginItem = Cu.waiveXrays(content.document.querySelector("login-item"));
+ let originInput = loginItem.shadowRoot.querySelector(
+ "input[name='origin']"
+ );
+ let usernameInput = loginItem.shadowRoot.querySelector(
+ "input[name='username']"
+ );
+ let passwordInput = loginItem.shadowRoot.querySelector(
+ "input[name='password']"
+ );
+ const EXISTING_USERNAME = "testuser2";
+ const EXISTING_ORIGIN = "https://example.com";
+ originInput.value = EXISTING_ORIGIN;
+ usernameInput.value = EXISTING_USERNAME;
+ passwordInput.value = "different password value";
+
+ let saveChangesButton = loginItem.shadowRoot.querySelector(
+ ".save-changes-button"
+ );
+ saveChangesButton.click();
+
+ await ContentTaskUtils.waitForCondition(
+ () => !loginItem._errorMessage.hidden,
+ "waiting until the error message is visible"
+ );
+ let duplicatedGuid = Object.values(loginList._logins).find(
+ v =>
+ v.login.origin == EXISTING_ORIGIN &&
+ v.login.username == EXISTING_USERNAME
+ ).login.guid;
+ Assert.equal(
+ loginItem._errorMessageLink.dataset.errorGuid,
+ duplicatedGuid,
+ "Error message has GUID of existing duplicated login set on it"
+ );
+
+ let confirmationDialog = Cu.waiveXrays(
+ content.document.querySelector("confirmation-dialog")
+ );
+ Assert.ok(
+ confirmationDialog.hidden,
+ "the discard-changes dialog should be hidden before clicking the error-message-text"
+ );
+ loginItem._errorMessageLink.querySelector("a").click();
+ Assert.ok(
+ !confirmationDialog.hidden,
+ "the discard-changes dialog should be visible"
+ );
+ let discardChangesButton =
+ confirmationDialog.shadowRoot.querySelector(".confirm-button");
+ discardChangesButton.click();
+
+ await ContentTaskUtils.waitForCondition(
+ () =>
+ Object.keys(loginItem._login).length > 1 &&
+ loginItem._login.guid == duplicatedGuid,
+ "waiting until the existing duplicated login is selected"
+ );
+ Assert.equal(
+ loginList._selectedGuid,
+ duplicatedGuid,
+ "the duplicated login should be selected in the list"
+ );
+ });
+ EXPECTED_ERROR_MESSAGE = null;
+});
diff --git a/browser/components/aboutlogins/tests/browser/browser_deleteLogin.js b/browser/components/aboutlogins/tests/browser/browser_deleteLogin.js
new file mode 100644
index 0000000000..8aa4201378
--- /dev/null
+++ b/browser/components/aboutlogins/tests/browser/browser_deleteLogin.js
@@ -0,0 +1,182 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+add_setup(async function () {
+ TEST_LOGIN1 = await addLogin(TEST_LOGIN1);
+ TEST_LOGIN2 = await addLogin(TEST_LOGIN2);
+ await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ url: "about:logins",
+ });
+ registerCleanupFunction(() => {
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ });
+});
+
+add_task(async function test_show_logins() {
+ let browser = gBrowser.selectedBrowser;
+
+ await SpecialPowers.spawn(
+ browser,
+ [[TEST_LOGIN1.guid, TEST_LOGIN2.guid]],
+ async loginGuids => {
+ let loginList = Cu.waiveXrays(
+ content.document.querySelector("login-list")
+ );
+ let loginFound = await ContentTaskUtils.waitForCondition(() => {
+ return (
+ loginList._loginGuidsSortedOrder.length == 2 &&
+ loginList._loginGuidsSortedOrder.includes(loginGuids[0]) &&
+ loginList._loginGuidsSortedOrder.includes(loginGuids[1])
+ );
+ }, "Waiting for logins to be displayed");
+ Assert.ok(
+ !content.document.documentElement.classList.contains("no-logins"),
+ "Should no longer be in no logins view"
+ );
+ Assert.ok(
+ !loginList.classList.contains("no-logins"),
+ "login-list should no longer be in no logins view"
+ );
+ Assert.ok(loginFound, "Newly added logins should be added to the page");
+ }
+ );
+});
+
+add_task(async function test_login_item() {
+ let browser = gBrowser.selectedBrowser;
+
+ function waitForDelete() {
+ let numLogins = Services.logins.countLogins("", "", "");
+ return TestUtils.waitForCondition(
+ () => Services.logins.countLogins("", "", "") < numLogins,
+ "Error waiting for login deletion"
+ );
+ }
+
+ async function deleteFirstLoginAfterEdit() {
+ await SpecialPowers.spawn(browser, [], async () => {
+ let loginList = content.document.querySelector("login-list");
+ let loginListItem = loginList.shadowRoot.querySelector(
+ ".login-list-item[data-guid]:not([hidden])"
+ );
+ info("Clicking on the first login");
+ loginListItem.click();
+
+ let loginItem = Cu.waiveXrays(
+ content.document.querySelector("login-item")
+ );
+ let loginItemPopulated = await ContentTaskUtils.waitForCondition(() => {
+ return loginItem._login.guid == loginListItem.dataset.guid;
+ }, "Waiting for login item to get populated");
+ Assert.ok(loginItemPopulated, "The login item should get populated");
+ });
+ let reauthObserved = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true);
+ await SpecialPowers.spawn(browser, [], async () => {
+ let loginItem = Cu.waiveXrays(
+ content.document.querySelector("login-item")
+ );
+ let editButton = loginItem.shadowRoot.querySelector(".edit-button");
+ editButton.click();
+ });
+ await reauthObserved;
+ return SpecialPowers.spawn(browser, [], async () => {
+ let loginItem = Cu.waiveXrays(
+ content.document.querySelector("login-item")
+ );
+ let usernameInput = loginItem.shadowRoot.querySelector(
+ "input[name='username']"
+ );
+ let passwordInput = loginItem._passwordInput;
+ usernameInput.value += "-undone";
+ passwordInput.value += "-undone";
+
+ let deleteButton = loginItem.shadowRoot.querySelector(".delete-button");
+ deleteButton.click();
+
+ let confirmDeleteDialog = Cu.waiveXrays(
+ content.document.querySelector("confirmation-dialog")
+ );
+ let confirmButton =
+ confirmDeleteDialog.shadowRoot.querySelector(".confirm-button");
+ confirmButton.click();
+ });
+ }
+
+ function deleteFirstLogin() {
+ return SpecialPowers.spawn(browser, [], async () => {
+ let loginList = content.document.querySelector("login-list");
+ let loginListItem = loginList.shadowRoot.querySelector(
+ ".login-list-item[data-guid]:not([hidden])"
+ );
+ info("Clicking on the first login");
+ loginListItem.click();
+
+ let loginItem = Cu.waiveXrays(
+ content.document.querySelector("login-item")
+ );
+ let loginItemPopulated = await ContentTaskUtils.waitForCondition(() => {
+ return loginItem._login.guid == loginListItem.dataset.guid;
+ }, "Waiting for login item to get populated");
+ Assert.ok(loginItemPopulated, "The login item should get populated");
+
+ let deleteButton = loginItem.shadowRoot.querySelector(".delete-button");
+ deleteButton.click();
+
+ let confirmDeleteDialog = Cu.waiveXrays(
+ content.document.querySelector("confirmation-dialog")
+ );
+ let confirmButton =
+ confirmDeleteDialog.shadowRoot.querySelector(".confirm-button");
+ confirmButton.click();
+ });
+ }
+
+ let onDeletePromise;
+ if (OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) {
+ // Can only test Edit mode in official builds
+ onDeletePromise = waitForDelete();
+ await deleteFirstLoginAfterEdit();
+ await onDeletePromise;
+
+ await SpecialPowers.spawn(browser, [], async () => {
+ let loginList = content.document.querySelector("login-list");
+ Assert.ok(
+ !content.document.documentElement.classList.contains("no-logins"),
+ "Should not be in no logins view as there is still one login"
+ );
+ Assert.ok(
+ !loginList.classList.contains("no-logins"),
+ "Should not be in no logins view as there is still one login"
+ );
+
+ let confirmDiscardDialog = Cu.waiveXrays(
+ content.document.querySelector("confirmation-dialog")
+ );
+ Assert.ok(
+ confirmDiscardDialog.hidden,
+ "Discard confirm dialog should not show up after delete an edited login"
+ );
+ });
+ } else {
+ onDeletePromise = waitForDelete();
+ await deleteFirstLogin();
+ await onDeletePromise;
+ }
+
+ onDeletePromise = waitForDelete();
+ await deleteFirstLogin();
+ await onDeletePromise;
+
+ await SpecialPowers.spawn(browser, [], async () => {
+ let loginList = content.document.querySelector("login-list");
+ Assert.ok(
+ content.document.documentElement.classList.contains("no-logins"),
+ "Should be in no logins view as all logins got deleted"
+ );
+ Assert.ok(
+ loginList.classList.contains("no-logins"),
+ "login-list should be in no logins view as all logins got deleted"
+ );
+ });
+});
diff --git a/browser/components/aboutlogins/tests/browser/browser_fxAccounts.js b/browser/components/aboutlogins/tests/browser/browser_fxAccounts.js
new file mode 100644
index 0000000000..b66f204c92
--- /dev/null
+++ b/browser/components/aboutlogins/tests/browser/browser_fxAccounts.js
@@ -0,0 +1,96 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const { UIState } = ChromeUtils.importESModule(
+ "resource://services-sync/UIState.sys.mjs"
+);
+
+function mockState(state) {
+ UIState.get = () => ({
+ status: state.status,
+ lastSync: new Date(),
+ email: state.email,
+ avatarURL: state.avatarURL,
+ });
+}
+
+add_setup(async function () {
+ let aboutLoginsTab = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ url: "about:logins",
+ });
+ let getState = UIState.get;
+ registerCleanupFunction(() => {
+ BrowserTestUtils.removeTab(aboutLoginsTab);
+ UIState.get = getState;
+ });
+});
+
+add_task(async function test_logged_out() {
+ mockState({ status: UIState.STATUS_NOT_CONFIGURED });
+ Services.obs.notifyObservers(null, UIState.ON_UPDATE);
+
+ let browser = gBrowser.selectedBrowser;
+ await SpecialPowers.spawn(browser, [], async () => {
+ let fxAccountsButton = content.document.querySelector("fxaccounts-button");
+ Assert.ok(fxAccountsButton, "fxAccountsButton should exist");
+ fxAccountsButton = Cu.waiveXrays(fxAccountsButton);
+ await ContentTaskUtils.waitForCondition(
+ () => fxAccountsButton._loggedIn === false,
+ "waiting for _loggedIn to strictly equal false"
+ );
+ Assert.equal(
+ fxAccountsButton._loggedIn,
+ false,
+ "state should reflect not logged in"
+ );
+ });
+});
+
+add_task(async function test_login_syncing_enabled() {
+ const TEST_EMAIL = "test@example.com";
+ const TEST_AVATAR_URL =
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==";
+ mockState({
+ status: UIState.STATUS_SIGNED_IN,
+ email: TEST_EMAIL,
+ avatarURL: TEST_AVATAR_URL,
+ });
+ await SpecialPowers.pushPrefEnv({
+ set: [["services.sync.engine.passwords", true]],
+ });
+ Services.obs.notifyObservers(null, UIState.ON_UPDATE);
+
+ let browser = gBrowser.selectedBrowser;
+ await SpecialPowers.spawn(
+ browser,
+ [[TEST_EMAIL, TEST_AVATAR_URL]],
+ async ([expectedEmail, expectedAvatarURL]) => {
+ let fxAccountsButton =
+ content.document.querySelector("fxaccounts-button");
+ Assert.ok(fxAccountsButton, "fxAccountsButton should exist");
+ fxAccountsButton = Cu.waiveXrays(fxAccountsButton);
+ await ContentTaskUtils.waitForCondition(
+ () => fxAccountsButton._email === expectedEmail,
+ "waiting for _email to strictly equal expectedEmail"
+ );
+ Assert.equal(
+ fxAccountsButton._loggedIn,
+ true,
+ "state should reflect logged in"
+ );
+ Assert.equal(
+ fxAccountsButton._email,
+ expectedEmail,
+ "state should have email set"
+ );
+ Assert.equal(
+ fxAccountsButton._avatarURL,
+ expectedAvatarURL,
+ "state should have avatarURL set"
+ );
+ }
+ );
+
+ await SpecialPowers.popPrefEnv();
+});
diff --git a/browser/components/aboutlogins/tests/browser/browser_loginFilter.js b/browser/components/aboutlogins/tests/browser/browser_loginFilter.js
new file mode 100644
index 0000000000..765e68713f
--- /dev/null
+++ b/browser/components/aboutlogins/tests/browser/browser_loginFilter.js
@@ -0,0 +1,60 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+add_setup(async function () {
+ const aboutLoginsTab = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ url: "about:logins",
+ });
+ registerCleanupFunction(() => {
+ BrowserTestUtils.removeTab(aboutLoginsTab);
+ });
+});
+
+add_task(async function focus_filter_by_ctrl_f() {
+ const browser = gBrowser.selectedBrowser;
+ await SpecialPowers.spawn(browser, [], async () => {
+ function getActiveElement() {
+ let element = content.document.activeElement;
+
+ while (element?.shadowRoot) {
+ element = element.shadowRoot.activeElement;
+ }
+
+ return element;
+ }
+
+ //// State after load
+
+ const loginFilter = content.document
+ .querySelector("login-list")
+ .shadowRoot.querySelector("login-filter")
+ .shadowRoot.querySelector("input");
+ Assert.equal(
+ getActiveElement(),
+ loginFilter,
+ "login filter must be focused after opening about:logins"
+ );
+
+ //// Focus something else (Create Login button)
+
+ content.document
+ .querySelector("login-list")
+ .shadowRoot.querySelector(".create-login-button")
+ .focus();
+ Assert.notEqual(
+ getActiveElement(),
+ loginFilter,
+ "login filter is not focused"
+ );
+
+ //// Ctrl+F key
+
+ EventUtils.synthesizeKey("f", { accelKey: true }, content);
+ Assert.equal(
+ getActiveElement(),
+ loginFilter,
+ "Ctrl+F/Cmd+F focused login filter"
+ );
+ });
+});
diff --git a/browser/components/aboutlogins/tests/browser/browser_loginItemErrors.js b/browser/components/aboutlogins/tests/browser/browser_loginItemErrors.js
new file mode 100644
index 0000000000..952149f0db
--- /dev/null
+++ b/browser/components/aboutlogins/tests/browser/browser_loginItemErrors.js
@@ -0,0 +1,153 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+add_setup(async function () {
+ await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ url: "about:logins",
+ });
+ registerCleanupFunction(() => {
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ Services.logins.removeAllUserFacingLogins();
+ });
+});
+
+add_task(async function test_showLoginItemErrors() {
+ const browser = gBrowser.selectedBrowser;
+ let LOGIN_TO_UPDATE = new nsLoginInfo(
+ "https://example.com",
+ "https://example.com",
+ null,
+ "user2",
+ "pass2"
+ );
+ LOGIN_TO_UPDATE = await Services.logins.addLoginAsync(LOGIN_TO_UPDATE);
+ EXPECTED_ERROR_MESSAGE = "This login already exists.";
+ const LOGIN_UPDATES = {
+ origin: "https://example.com",
+ password: "my1GoodPassword",
+ username: "user1",
+ };
+
+ await SpecialPowers.spawn(
+ browser,
+ [[LoginHelper.loginToVanillaObject(LOGIN_TO_UPDATE), LOGIN_UPDATES]],
+ async ([loginToUpdate, loginUpdates]) => {
+ const loginItem = Cu.waiveXrays(
+ content.document.querySelector("login-item")
+ );
+ const loginItemErrorMessage = Cu.waiveXrays(
+ loginItem.shadowRoot.querySelector(".error-message")
+ );
+ const loginList = Cu.waiveXrays(
+ content.document.querySelector("login-list")
+ );
+
+ const createButton = loginList._createLoginButton;
+ createButton.click();
+
+ const event = Cu.cloneInto(
+ {
+ bubbles: true,
+ detail: loginUpdates,
+ },
+ content
+ );
+
+ content.dispatchEvent(
+ // adds first login
+ new content.CustomEvent("AboutLoginsCreateLogin", event)
+ );
+
+ await ContentTaskUtils.waitForCondition(() => {
+ return (
+ loginList.shadowRoot.querySelectorAll(".login-list-item").length === 3
+ );
+ }, "Waiting for login item to be created.");
+
+ Assert.ok(
+ loginItemErrorMessage.hidden,
+ "An error message should not be displayed after adding a new login."
+ );
+
+ content.dispatchEvent(
+ // adds a duplicate of the first login
+ new content.CustomEvent("AboutLoginsCreateLogin", event)
+ );
+
+ const loginItemErrorMessageVisible =
+ await ContentTaskUtils.waitForCondition(() => {
+ return !loginItemErrorMessage.hidden;
+ }, "Waiting for error message to be shown after attempting to create a duplicate login.");
+ Assert.ok(
+ loginItemErrorMessageVisible,
+ "An error message should be shown after user attempts to add a login that already exists."
+ );
+
+ const loginItemErrorMessageText =
+ loginItemErrorMessage.querySelector("span:not([hidden])");
+ Assert.equal(
+ loginItemErrorMessageText.dataset.l10nId,
+ "about-logins-error-message-duplicate-login-with-link",
+ "The correct error message is displayed."
+ );
+
+ let loginListItem = Cu.waiveXrays(
+ loginList.shadowRoot.querySelector(
+ `.login-list-item[data-guid='${loginToUpdate.guid}']`
+ )
+ );
+ loginListItem.click();
+
+ Assert.ok(
+ loginItemErrorMessage.hidden,
+ "The error message should no longer be visible."
+ );
+ }
+ );
+ if (!OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) {
+ // The rest of the test uses Edit mode which causes an OS prompt in official builds.
+ return;
+ }
+ let reauthObserved = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true);
+ await SpecialPowers.spawn(
+ browser,
+ [[LoginHelper.loginToVanillaObject(LOGIN_TO_UPDATE), LOGIN_UPDATES]],
+ async ([loginToUpdate, loginUpdates]) => {
+ const loginItem = Cu.waiveXrays(
+ content.document.querySelector("login-item")
+ );
+ const editButton = loginItem.shadowRoot.querySelector(".edit-button");
+ editButton.click();
+
+ const updateEvent = Cu.cloneInto(
+ {
+ bubbles: true,
+ detail: Object.assign({ guid: loginToUpdate.guid }, loginUpdates),
+ },
+ content
+ );
+
+ content.dispatchEvent(
+ // attempt to update LOGIN_TO_UPDATE to a username/origin combination that already exists.
+ new content.CustomEvent("AboutLoginsUpdateLogin", updateEvent)
+ );
+
+ const loginItemErrorMessage = Cu.waiveXrays(
+ loginItem.shadowRoot.querySelector(".error-message")
+ );
+ const loginAlreadyExistsErrorShownAfterUpdate =
+ await ContentTaskUtils.waitForCondition(() => {
+ return !loginItemErrorMessage.hidden;
+ }, "Waiting for error message to show after updating login to existing login.");
+ Assert.ok(
+ loginAlreadyExistsErrorShownAfterUpdate,
+ "An error message should be shown after updating a login to a username/origin combination that already exists."
+ );
+ }
+ );
+ info("making sure os auth dialog is shown");
+ await reauthObserved;
+ info("saw os auth dialog");
+ EXPECTED_ERROR_MESSAGE = null;
+});
diff --git a/browser/components/aboutlogins/tests/browser/browser_loginListChanges.js b/browser/components/aboutlogins/tests/browser/browser_loginListChanges.js
new file mode 100644
index 0000000000..13df6c1ef6
--- /dev/null
+++ b/browser/components/aboutlogins/tests/browser/browser_loginListChanges.js
@@ -0,0 +1,144 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+add_setup(async function () {
+ await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ url: "about:logins",
+ });
+ registerCleanupFunction(() => {
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ });
+});
+
+add_task(async function test_login_added() {
+ let login = {
+ guid: "70",
+ username: "jared",
+ password: "deraj",
+ origin: "https://www.example.com",
+ };
+ let browser = gBrowser.selectedBrowser;
+ browser.browsingContext.currentWindowGlobal
+ .getActor("AboutLogins")
+ .sendAsyncMessage("AboutLogins:LoginAdded", login);
+
+ await SpecialPowers.spawn(browser, [login], async addedLogin => {
+ let loginList = Cu.waiveXrays(content.document.querySelector("login-list"));
+ let loginFound = await ContentTaskUtils.waitForCondition(() => {
+ return (
+ loginList._loginGuidsSortedOrder.length == 1 &&
+ loginList._loginGuidsSortedOrder[0] == addedLogin.guid
+ );
+ }, "Waiting for login to be added");
+ Assert.ok(loginFound, "Newly added logins should be added to the page");
+ });
+});
+
+add_task(async function test_login_modified() {
+ let login = {
+ guid: "70",
+ username: "jared@example.com",
+ password: "deraj",
+ origin: "https://www.example.com",
+ };
+ let browser = gBrowser.selectedBrowser;
+ browser.browsingContext.currentWindowGlobal
+ .getActor("AboutLogins")
+ .sendAsyncMessage("AboutLogins:LoginModified", login);
+
+ await SpecialPowers.spawn(browser, [login], async modifiedLogin => {
+ let loginList = Cu.waiveXrays(content.document.querySelector("login-list"));
+ let loginFound = await ContentTaskUtils.waitForCondition(() => {
+ return (
+ loginList._loginGuidsSortedOrder.length == 1 &&
+ loginList._loginGuidsSortedOrder[0] == modifiedLogin.guid &&
+ loginList._logins[loginList._loginGuidsSortedOrder[0]].login.username ==
+ modifiedLogin.username
+ );
+ }, "Waiting for username to get updated");
+ Assert.ok(loginFound, "The login should get updated on the page");
+ });
+});
+
+add_task(async function test_login_removed() {
+ let login = {
+ guid: "70",
+ username: "jared@example.com",
+ password: "deraj",
+ origin: "https://www.example.com",
+ };
+ let browser = gBrowser.selectedBrowser;
+ browser.browsingContext.currentWindowGlobal
+ .getActor("AboutLogins")
+ .sendAsyncMessage("AboutLogins:LoginRemoved", login);
+
+ await SpecialPowers.spawn(browser, [login], async removedLogin => {
+ let loginList = Cu.waiveXrays(content.document.querySelector("login-list"));
+ let loginRemoved = await ContentTaskUtils.waitForCondition(() => {
+ return !loginList._loginGuidsSortedOrder.length;
+ }, "Waiting for login to get removed");
+ Assert.ok(loginRemoved, "The login should be removed from the page");
+ });
+});
+
+add_task(async function test_all_logins_removed() {
+ // Setup the test with 2 logins.
+ let logins = [
+ {
+ guid: "70",
+ username: "jared",
+ password: "deraj",
+ origin: "https://www.example.com",
+ },
+ {
+ guid: "71",
+ username: "ntim",
+ password: "verysecurepassword",
+ origin: "https://www.example.com",
+ },
+ ];
+
+ let browser = gBrowser.selectedBrowser;
+ browser.browsingContext.currentWindowGlobal
+ .getActor("AboutLogins")
+ .sendAsyncMessage("AboutLogins:AllLogins", logins);
+
+ await SpecialPowers.spawn(browser, [logins], async addedLogins => {
+ let loginList = Cu.waiveXrays(content.document.querySelector("login-list"));
+ let loginFound = await ContentTaskUtils.waitForCondition(() => {
+ return (
+ loginList._loginGuidsSortedOrder.length == 2 &&
+ loginList._loginGuidsSortedOrder[0] == addedLogins[0].guid &&
+ loginList._loginGuidsSortedOrder[1] == addedLogins[1].guid
+ );
+ }, "Waiting for login to be added");
+ Assert.ok(loginFound, "Newly added logins should be added to the page");
+ Assert.ok(
+ !content.document.documentElement.classList.contains("no-logins"),
+ "Should not be in no logins view after adding logins"
+ );
+ Assert.ok(
+ !loginList.classList.contains("no-logins"),
+ "login-list should not be in no logins view after adding logins"
+ );
+ });
+
+ Services.logins.removeAllUserFacingLogins();
+
+ await SpecialPowers.spawn(browser, [], async () => {
+ let loginList = Cu.waiveXrays(content.document.querySelector("login-list"));
+ let loginFound = await ContentTaskUtils.waitForCondition(() => {
+ return !loginList._loginGuidsSortedOrder.length;
+ }, "Waiting for logins to be cleared");
+ Assert.ok(loginFound, "Logins should be cleared");
+ Assert.ok(
+ content.document.documentElement.classList.contains("no-logins"),
+ "Should be in no logins view after clearing"
+ );
+ Assert.ok(
+ loginList.classList.contains("no-logins"),
+ "login-list should be in no logins view after clearing"
+ );
+ });
+});
diff --git a/browser/components/aboutlogins/tests/browser/browser_loginSortOrderRestored.js b/browser/components/aboutlogins/tests/browser/browser_loginSortOrderRestored.js
new file mode 100644
index 0000000000..fb39dda30c
--- /dev/null
+++ b/browser/components/aboutlogins/tests/browser/browser_loginSortOrderRestored.js
@@ -0,0 +1,172 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+EXPECTED_BREACH = {
+ AddedDate: "2018-12-20T23:56:26Z",
+ BreachDate: "2018-12-16",
+ Domain: "breached.example.com",
+ Name: "Breached",
+ PwnCount: 1643100,
+ DataClasses: ["Email addresses", "Usernames", "Passwords", "IP addresses"],
+ _status: "synced",
+ id: "047940fe-d2fd-4314-b636-b4a952ee0043",
+ last_modified: "1541615610052",
+ schema: "1541615609018",
+};
+
+const SORT_PREF_NAME = "signon.management.page.sort";
+
+add_setup(async function () {
+ TEST_LOGIN3.QueryInterface(Ci.nsILoginMetaInfo).timePasswordChanged = 1;
+ TEST_LOGIN1 = await addLogin(TEST_LOGIN1);
+ info(`TEST_LOGIN1 added with guid=${TEST_LOGIN1.guid}`);
+ TEST_LOGIN3 = await addLogin(TEST_LOGIN3);
+ info(`TEST_LOGIN3 added with guid=${TEST_LOGIN3.guid}`);
+ registerCleanupFunction(() => {
+ Services.logins.removeAllUserFacingLogins();
+ Services.prefs.clearUserPref(SORT_PREF_NAME);
+ });
+});
+
+add_task(async function test_sort_order_persisted() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:logins",
+ },
+ async function (browser) {
+ await ContentTask.spawn(
+ browser,
+ [TEST_LOGIN1.guid, TEST_LOGIN3.guid],
+ async function ([testLogin1Guid, testLogin3Guid]) {
+ let loginList = Cu.waiveXrays(
+ content.document.querySelector("login-list")
+ );
+ await ContentTaskUtils.waitForCondition(
+ () => loginList._sortSelect.value == "alerts",
+ "Waiting for login-list sort to get changed to 'alerts'. Current value is: " +
+ loginList._sortSelect.value
+ );
+ Assert.equal(
+ loginList._sortSelect.value,
+ "alerts",
+ "selected sort should be 'alerts' since there is a breached login"
+ );
+ Assert.equal(
+ loginList._list.querySelector(
+ ".login-list-item[data-guid]:not([hidden])"
+ ).dataset.guid,
+ testLogin3Guid,
+ "the first login should be TEST_LOGIN3 since they are sorted by alerts"
+ );
+
+ loginList._sortSelect.value = "last-changed";
+ loginList._sortSelect.dispatchEvent(
+ new content.Event("change", { bubbles: true })
+ );
+ Assert.equal(
+ loginList._list.querySelector(
+ ".login-list-item[data-guid]:not([hidden])"
+ ).dataset.guid,
+ testLogin1Guid,
+ "the first login should be TEST_LOGIN1 since it has the most recent timePasswordChanged value"
+ );
+ }
+ );
+ }
+ );
+
+ Assert.equal(
+ Services.prefs.getCharPref(SORT_PREF_NAME),
+ "last-changed",
+ "'last-changed' should be stored in the pref"
+ );
+
+ // Set the pref to the value used in Fx70-76 to confirm our
+ // backwards-compat support that "breached" is changed to "alerts"
+ Services.prefs.setCharPref(SORT_PREF_NAME, "breached");
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:logins",
+ },
+ async function (browser) {
+ await ContentTask.spawn(
+ browser,
+ TEST_LOGIN3.guid,
+ async function (testLogin3Guid) {
+ let loginList = Cu.waiveXrays(
+ content.document.querySelector("login-list")
+ );
+ await ContentTaskUtils.waitForCondition(
+ () => loginList._sortSelect.value == "alerts",
+ "Waiting for login-list sort to get changed to 'alerts'. Current value is: " +
+ loginList._sortSelect.value
+ );
+ Assert.equal(
+ loginList._sortSelect.value,
+ "alerts",
+ "selected sort should be restored to 'alerts' since 'breached' was in prefs"
+ );
+ Assert.equal(
+ loginList._list.querySelector(
+ ".login-list-item[data-guid]:not([hidden])"
+ ).dataset.guid,
+ testLogin3Guid,
+ "the first login should be TEST_LOGIN3 since they are sorted by alerts"
+ );
+ }
+ );
+ }
+ );
+
+ let storageChangedPromised = TestUtils.topicObserved(
+ "passwordmgr-storage-changed",
+ (_, data) => data == "removeLogin"
+ );
+ Services.logins.removeLogin(TEST_LOGIN3);
+ await storageChangedPromised;
+ TEST_LOGIN2 = await addLogin(TEST_LOGIN2);
+
+ Assert.equal(
+ Services.prefs.getCharPref(SORT_PREF_NAME),
+ "breached",
+ "confirm that the stored sort is still 'breached' and as such shouldn't apply when the page loads"
+ );
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:logins",
+ },
+ async function (browser) {
+ await ContentTask.spawn(
+ browser,
+ TEST_LOGIN2.guid,
+ async function (testLogin2Guid) {
+ let loginList = Cu.waiveXrays(
+ content.document.querySelector("login-list")
+ );
+ await ContentTaskUtils.waitForCondition(
+ () =>
+ loginList._list.querySelector(
+ ".login-list-item[data-guid]:not([hidden])"
+ ),
+ "wait for a visible loging to get populated"
+ );
+ Assert.equal(
+ loginList._sortSelect.value,
+ "name",
+ "selected sort should be name since 'alerts' no longer applies with no breached or vulnerable logins"
+ );
+ Assert.equal(
+ loginList._list.querySelector(
+ ".login-list-item[data-guid]:not([hidden])"
+ ).dataset.guid,
+ testLogin2Guid,
+ "the first login should be TEST_LOGIN2 since it is sorted first by 'name'"
+ );
+ }
+ );
+ }
+ );
+});
diff --git a/browser/components/aboutlogins/tests/browser/browser_noLoginsView.js b/browser/components/aboutlogins/tests/browser/browser_noLoginsView.js
new file mode 100644
index 0000000000..b86304aac1
--- /dev/null
+++ b/browser/components/aboutlogins/tests/browser/browser_noLoginsView.js
@@ -0,0 +1,199 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+add_setup(async function () {
+ await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ url: "about:logins",
+ });
+ registerCleanupFunction(() => {
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ });
+});
+
+add_task(async function test_no_logins_class() {
+ let { platform } = AppConstants;
+ let wizardPromise;
+
+ // The import link is hidden on Linux, so we don't wait for the migration
+ // wizard to open on that platform.
+ if (AppConstants.platform != "linux") {
+ wizardPromise = BrowserTestUtils.waitForMigrationWizard(window);
+ }
+
+ await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [platform],
+ async aPlatform => {
+ let loginList = content.document.querySelector("login-list");
+
+ Assert.ok(
+ content.document.documentElement.classList.contains("no-logins"),
+ "root should be in no logins view"
+ );
+ Assert.ok(
+ loginList.classList.contains("no-logins"),
+ "login-list should be in no logins view"
+ );
+
+ let loginIntro = Cu.waiveXrays(
+ content.document.querySelector("login-intro")
+ );
+ let loginItem = content.document.querySelector("login-item");
+ let loginListIntro = loginList.shadowRoot.querySelector(".intro");
+ let loginListList = loginList.shadowRoot.querySelector("ol");
+
+ Assert.ok(
+ !ContentTaskUtils.is_hidden(loginIntro),
+ "login-intro should be shown in no logins view"
+ );
+ Assert.ok(
+ !ContentTaskUtils.is_hidden(loginListIntro),
+ "login-list intro should be shown in no logins view"
+ );
+
+ Assert.ok(
+ ContentTaskUtils.is_hidden(loginItem),
+ "login-item should be hidden in no logins view"
+ );
+ Assert.ok(
+ ContentTaskUtils.is_hidden(loginListList),
+ "login-list logins list should be hidden in no logins view"
+ );
+ Assert.equal(
+ content.document.l10n.getAttributes(
+ loginIntro.shadowRoot.querySelector(".heading")
+ ).id,
+ "about-logins-login-intro-heading-logged-out2",
+ "The default message should be the non-logged-in message"
+ );
+ Assert.ok(
+ loginIntro.shadowRoot
+ .querySelector("a.intro-help-link")
+ .href.includes("password-manager-remember-delete-edit-logins"),
+ "Check support href populated"
+ );
+
+ loginIntro.updateState(Cu.cloneInto({ loggedIn: true }, content));
+
+ Assert.equal(
+ content.document.l10n.getAttributes(
+ loginIntro.shadowRoot.querySelector(".heading")
+ ).id,
+ "about-logins-login-intro-heading-logged-in",
+ "When logged in the message should update"
+ );
+
+ let importClass = Services.prefs.getBoolPref(
+ "signon.management.page.fileImport.enabled"
+ )
+ ? ".intro-import-text.file-import"
+ : ".intro-import-text.no-file-import";
+ Assert.equal(
+ ContentTaskUtils.is_hidden(
+ loginIntro.shadowRoot.querySelector(importClass)
+ ),
+ aPlatform == "linux",
+ "the import link should be hidden on Linux builds"
+ );
+ if (aPlatform == "linux") {
+ // End the test now for Linux since the link is hidden.
+ return;
+ }
+ loginIntro.shadowRoot.querySelector(importClass + " > a").click();
+ info("waiting for MigrationWizard to open");
+ }
+ );
+ if (AppConstants.platform == "linux") {
+ // End the test now for Linux since the link is hidden.
+ return;
+ }
+ let wizard = await wizardPromise;
+ Assert.ok(wizard, "Migrator window opened");
+ await BrowserTestUtils.closeMigrationWizard(wizard);
+});
+
+add_task(
+ async function login_selected_when_login_added_and_in_no_logins_view() {
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => {
+ let loginList = content.document.querySelector("login-list");
+ let loginItem = content.document.querySelector("login-item");
+ let loginIntro = content.document.querySelector("login-intro");
+ Assert.ok(
+ loginList.classList.contains("empty-search"),
+ "login-list should be showing no logins view from a search with no results"
+ );
+ Assert.ok(
+ loginList.classList.contains("no-logins"),
+ "login-list should be showing no logins view since there are no saved logins"
+ );
+ Assert.ok(
+ !loginList.classList.contains("create-login-selected"),
+ "login-list should not be in create-login-selected mode"
+ );
+ Assert.ok(
+ loginItem.classList.contains("no-logins"),
+ "login-item should be marked as having no-logins"
+ );
+ Assert.ok(
+ ContentTaskUtils.is_hidden(loginItem),
+ "login-item should be hidden"
+ );
+ Assert.ok(
+ !ContentTaskUtils.is_hidden(loginIntro),
+ "login-intro should be visible"
+ );
+ });
+
+ TEST_LOGIN1 = await addLogin(TEST_LOGIN1);
+
+ await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [TEST_LOGIN1.guid],
+ async testLogin1Guid => {
+ let loginList = content.document.querySelector("login-list");
+ let loginItem = content.document.querySelector("login-item");
+ let loginIntro = content.document.querySelector("login-intro");
+ await ContentTaskUtils.waitForCondition(() => {
+ return !loginList.classList.contains("no-logins");
+ }, "waiting for login-list to leave the no-logins view");
+ Assert.ok(
+ !loginList.classList.contains("empty-search"),
+ "login-list should not be showing no logins view since one login exists"
+ );
+ Assert.ok(
+ !loginList.classList.contains("no-logins"),
+ "login-list should not be showing no logins view since one login exists"
+ );
+ Assert.ok(
+ !loginList.classList.contains("create-login-selected"),
+ "login-list should not be in create-login-selected mode"
+ );
+ Assert.equal(
+ loginList.shadowRoot.querySelector(
+ ".login-list-item.selected[data-guid]"
+ ).dataset.guid,
+ testLogin1Guid,
+ "the login that was just added should be selected"
+ );
+ Assert.ok(
+ !loginItem.classList.contains("no-logins"),
+ "login-item should not be marked as having no-logins"
+ );
+ Assert.equal(
+ Cu.waiveXrays(loginItem)._login.guid,
+ testLogin1Guid,
+ "the login-item should have the newly added login selected"
+ );
+ Assert.ok(
+ !ContentTaskUtils.is_hidden(loginItem),
+ "login-item should be visible"
+ );
+ Assert.ok(
+ ContentTaskUtils.is_hidden(loginIntro),
+ "login-intro should be hidden"
+ );
+ }
+ );
+ }
+);
diff --git a/browser/components/aboutlogins/tests/browser/browser_openExport.js b/browser/components/aboutlogins/tests/browser/browser_openExport.js
new file mode 100644
index 0000000000..c5df84c447
--- /dev/null
+++ b/browser/components/aboutlogins/tests/browser/browser_openExport.js
@@ -0,0 +1,149 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+/**
+ * Test the export logins file picker appears.
+ */
+
+let { OSKeyStore } = ChromeUtils.importESModule(
+ "resource://gre/modules/OSKeyStore.sys.mjs"
+);
+let { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+
+let { MockFilePicker } = SpecialPowers;
+
+add_setup(async function () {
+ await TestUtils.waitForCondition(() => {
+ Services.telemetry.clearEvents();
+ let events = Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS,
+ true
+ ).content;
+ return !events || !events.length;
+ }, "Waiting for content telemetry events to get cleared");
+
+ MockFilePicker.init(window);
+ MockFilePicker.useAnyFile();
+ MockFilePicker.returnValue = MockFilePicker.returnOK;
+
+ registerCleanupFunction(() => {
+ MockFilePicker.cleanup();
+ });
+});
+
+function waitForFilePicker() {
+ return new Promise(resolve => {
+ MockFilePicker.showCallback = () => {
+ MockFilePicker.showCallback = null;
+ Assert.ok(true, "Saw the file picker");
+ resolve();
+ };
+ });
+}
+
+add_task(async function test_open_export() {
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: "about:logins" },
+ async function (browser) {
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "menu-button",
+ {},
+ browser
+ );
+
+ await SpecialPowers.spawn(browser, [], async () => {
+ let menuButton = content.document.querySelector("menu-button");
+ return ContentTaskUtils.waitForCondition(function waitForMenu() {
+ return !menuButton.shadowRoot.querySelector(".menu").hidden;
+ }, "waiting for menu to open");
+ });
+
+ function getExportMenuItem() {
+ let menuButton = window.document.querySelector("menu-button");
+ let exportButton =
+ menuButton.shadowRoot.querySelector(".menuitem-export");
+ return exportButton;
+ }
+
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ getExportMenuItem,
+ {},
+ browser
+ );
+
+ // First event is for opening about:logins
+ await LoginTestUtils.telemetry.waitForEventCount(2);
+ TelemetryTestUtils.assertEvents(
+ [["pwmgr", "mgmt_menu_item_used", "export"]],
+ { category: "pwmgr", method: "mgmt_menu_item_used" },
+ { process: "content" }
+ );
+
+ info("Clicking confirm button");
+ let osReAuthPromise = null;
+
+ if (
+ OSKeyStore.canReauth() &&
+ !OSKeyStoreTestUtils.canTestOSKeyStoreLogin()
+ ) {
+ todo(
+ OSKeyStoreTestUtils.canTestOSKeyStoreLogin(),
+ "Cannot test OS key store login in this build."
+ );
+ return;
+ }
+
+ if (OSKeyStore.canReauth()) {
+ osReAuthPromise = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true);
+ }
+ let filePicker = waitForFilePicker();
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ () => {
+ let confirmExportDialog = window.document.querySelector(
+ "confirmation-dialog"
+ );
+ return confirmExportDialog.shadowRoot.querySelector(
+ ".confirm-button"
+ );
+ },
+ {},
+ browser
+ );
+
+ if (osReAuthPromise) {
+ Assert.ok(osReAuthPromise, "Waiting for OS re-auth promise");
+ await osReAuthPromise;
+ }
+
+ info("waiting for Export file picker to get opened");
+ await filePicker;
+ Assert.ok(true, "Export file picker opened");
+
+ info("Waiting for the export to complete");
+ let expectedEvents = [
+ [
+ "pwmgr",
+ "reauthenticate",
+ "os_auth",
+ osReAuthPromise ? "success" : "success_unsupported_platform",
+ ],
+ ["pwmgr", "mgmt_menu_item_used", "export_complete"],
+ ];
+ await LoginTestUtils.telemetry.waitForEventCount(
+ expectedEvents.length,
+ "parent"
+ );
+
+ TelemetryTestUtils.assertEvents(
+ expectedEvents,
+ { category: "pwmgr", method: /(reauthenticate|mgmt_menu_item_used)/ },
+ { process: "parent" }
+ );
+ }
+ );
+});
diff --git a/browser/components/aboutlogins/tests/browser/browser_openFiltered.js b/browser/components/aboutlogins/tests/browser/browser_openFiltered.js
new file mode 100644
index 0000000000..fcd9692065
--- /dev/null
+++ b/browser/components/aboutlogins/tests/browser/browser_openFiltered.js
@@ -0,0 +1,295 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+add_setup(async function () {
+ let storageChangedPromised = TestUtils.topicObserved(
+ "passwordmgr-storage-changed",
+ (_, data) => data == "addLogin"
+ );
+ TEST_LOGIN1 = await Services.logins.addLoginAsync(TEST_LOGIN1);
+ await storageChangedPromised;
+ storageChangedPromised = TestUtils.topicObserved(
+ "passwordmgr-storage-changed",
+ (_, data) => data == "addLogin"
+ );
+ TEST_LOGIN2 = await Services.logins.addLoginAsync(TEST_LOGIN2);
+ await storageChangedPromised;
+ let tabOpenedPromise = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ url =>
+ url.includes(
+ `about:logins?filter=${encodeURIComponent(TEST_LOGIN1.origin)}`
+ ),
+ true
+ );
+ LoginHelper.openPasswordManager(window, {
+ filterString: TEST_LOGIN1.origin,
+ entryPoint: "preferences",
+ });
+ await tabOpenedPromise;
+ registerCleanupFunction(() => {
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ Services.logins.removeAllUserFacingLogins();
+ });
+});
+
+add_task(async function test_query_parameter_filter() {
+ let browser = gBrowser.selectedBrowser;
+ let vanillaLogins = [
+ LoginHelper.loginToVanillaObject(TEST_LOGIN1),
+ LoginHelper.loginToVanillaObject(TEST_LOGIN2),
+ ];
+ await SpecialPowers.spawn(browser, [vanillaLogins], async logins => {
+ const loginList = Cu.waiveXrays(
+ content.document.querySelector("login-list")
+ );
+ await ContentTaskUtils.waitForCondition(() => {
+ return loginList._loginGuidsSortedOrder.length == 2;
+ }, "Waiting for logins to be cached");
+
+ await ContentTaskUtils.waitForCondition(() => {
+ const selectedLoginItem = Cu.waiveXrays(
+ loginList.shadowRoot.querySelector("li[aria-selected='true']")
+ );
+ return selectedLoginItem.dataset.guid === logins[0].guid;
+ }, "Waiting for TEST_LOGIN1 to be selected for the login-item view");
+
+ const loginItem = Cu.waiveXrays(
+ content.document.querySelector("login-item")
+ );
+
+ Assert.ok(
+ ContentTaskUtils.is_visible(loginItem),
+ "login-item should be visible when a login is selected"
+ );
+ const loginIntro = content.document.querySelector("login-intro");
+ Assert.ok(
+ ContentTaskUtils.is_hidden(loginIntro),
+ "login-intro should be hidden when a login is selected"
+ );
+
+ const loginFilter = loginList.shadowRoot.querySelector("login-filter");
+
+ const xRayLoginFilter = Cu.waiveXrays(loginFilter);
+ Assert.equal(
+ xRayLoginFilter.value,
+ logins[0].origin,
+ "The filter should be prepopulated"
+ );
+ Assert.equal(
+ loginList.shadowRoot.activeElement,
+ loginFilter,
+ "login-filter should be focused"
+ );
+ Assert.equal(
+ loginFilter.shadowRoot.activeElement,
+ loginFilter.shadowRoot.querySelector(".filter"),
+ "the actual input inside of login-filter should be focused"
+ );
+
+ let hiddenLoginListItems = loginList.shadowRoot.querySelectorAll(
+ ".login-list-item[hidden]"
+ );
+ let visibleLoginListItems = loginList.shadowRoot.querySelectorAll(
+ ".login-list-item:not([hidden])"
+ );
+ Assert.equal(
+ visibleLoginListItems.length,
+ 1,
+ "The one login should be visible"
+ );
+ Assert.equal(
+ visibleLoginListItems[0].dataset.guid,
+ logins[0].guid,
+ "TEST_LOGIN1 should be visible"
+ );
+ Assert.equal(
+ hiddenLoginListItems.length,
+ 2,
+ "One saved login and one blank login should be hidden"
+ );
+ Assert.equal(
+ hiddenLoginListItems[0].id,
+ "new-login-list-item",
+ "#new-login-list-item should be hidden"
+ );
+ Assert.equal(
+ hiddenLoginListItems[1].dataset.guid,
+ logins[1].guid,
+ "TEST_LOGIN2 should be hidden"
+ );
+ });
+});
+
+add_task(async function test_query_parameter_filter_no_logins_for_site() {
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ const HOSTNAME_WITH_NO_LOGINS = "xxx-no-logins-for-site-xxx";
+ let tabOpenedPromise = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ url =>
+ url.includes(
+ `about:logins?filter=${encodeURIComponent(HOSTNAME_WITH_NO_LOGINS)}`
+ ),
+ true
+ );
+ LoginHelper.openPasswordManager(window, {
+ filterString: HOSTNAME_WITH_NO_LOGINS,
+ entryPoint: "preferences",
+ });
+ await tabOpenedPromise;
+
+ let browser = gBrowser.selectedBrowser;
+ await SpecialPowers.spawn(browser, [], async () => {
+ let loginList = Cu.waiveXrays(content.document.querySelector("login-list"));
+ await ContentTaskUtils.waitForCondition(() => {
+ return loginList._loginGuidsSortedOrder.length == 2;
+ }, "Waiting for logins to be cached");
+ Assert.equal(
+ loginList._loginGuidsSortedOrder.length,
+ 2,
+ "login list should have two logins stored"
+ );
+
+ Assert.ok(
+ ContentTaskUtils.is_hidden(loginList._list),
+ "the login list should be hidden when there is a search with no results"
+ );
+ let intro = loginList.shadowRoot.querySelector(".intro");
+ Assert.ok(
+ ContentTaskUtils.is_hidden(intro),
+ "the intro should be hidden when there is a search with no results"
+ );
+ let emptySearchMessage = loginList.shadowRoot.querySelector(
+ ".empty-search-message"
+ );
+ Assert.ok(
+ ContentTaskUtils.is_visible(emptySearchMessage),
+ "the empty search message should be visible when there is a search with no results"
+ );
+
+ let visibleLoginListItems = loginList.shadowRoot.querySelectorAll(
+ ".login-list-item:not([hidden])"
+ );
+ Assert.equal(visibleLoginListItems.length, 0, "No login should be visible");
+
+ Assert.ok(
+ !loginList._createLoginButton.disabled,
+ "create button should be enabled"
+ );
+
+ let loginItem = content.document.querySelector("login-item");
+ Assert.ok(!loginItem.dataset.isNewLogin, "should not be in create mode");
+ Assert.ok(!loginItem.dataset.editing, "should not be in edit mode");
+ Assert.ok(
+ ContentTaskUtils.is_hidden(loginItem),
+ "login-item should be hidden when a login is not selected and we're not in create mode"
+ );
+ let loginIntro = content.document.querySelector("login-intro");
+ Assert.ok(
+ ContentTaskUtils.is_hidden(loginIntro),
+ "login-intro should be hidden when a login is not selected and we're not in create mode"
+ );
+
+ loginList._createLoginButton.click();
+
+ Assert.ok(loginItem.dataset.isNewLogin, "should be in create mode");
+ Assert.ok(loginItem.dataset.editing, "should be in edit mode");
+ Assert.ok(
+ ContentTaskUtils.is_visible(loginItem),
+ "login-item should be visible in create mode"
+ );
+ Assert.ok(
+ ContentTaskUtils.is_hidden(loginIntro),
+ "login-intro should be hidden in create mode"
+ );
+ });
+});
+
+add_task(async function test_query_parameter_filter_no_login_until_backspace() {
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ let tabOpenedPromise = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ "about:logins?filter=" + encodeURIComponent(TEST_LOGIN1.origin) + "x",
+ true
+ );
+ LoginHelper.openPasswordManager(window, {
+ filterString: TEST_LOGIN1.origin + "x",
+ entryPoint: "preferences",
+ });
+ await tabOpenedPromise;
+
+ let browser = gBrowser.selectedBrowser;
+ await SpecialPowers.spawn(browser, [], async () => {
+ let loginList = Cu.waiveXrays(content.document.querySelector("login-list"));
+ await ContentTaskUtils.waitForCondition(() => {
+ return loginList._loginGuidsSortedOrder.length == 2;
+ }, "Waiting for logins to be cached");
+ Assert.equal(
+ loginList._loginGuidsSortedOrder.length,
+ 2,
+ "login list should have two logins stored"
+ );
+
+ Assert.ok(
+ ContentTaskUtils.is_hidden(loginList._list),
+ "the login list should be hidden when there is a search with no results"
+ );
+
+ // Backspace the trailing 'x' to get matching logins
+ const EventUtils = ContentTaskUtils.getEventUtils(content);
+ EventUtils.sendChar("KEY_Backspace", content);
+
+ let intro = loginList.shadowRoot.querySelector(".intro");
+ Assert.ok(
+ ContentTaskUtils.is_hidden(intro),
+ "the intro should be hidden when there is no selection"
+ );
+ let emptySearchMessage = loginList.shadowRoot.querySelector(
+ ".empty-search-message"
+ );
+ Assert.ok(
+ ContentTaskUtils.is_hidden(emptySearchMessage),
+ "the empty search message should be hidden when there is matching logins"
+ );
+
+ let visibleLoginListItems = loginList.shadowRoot.querySelectorAll(
+ ".login-list-item:not([hidden])"
+ );
+ Assert.equal(
+ visibleLoginListItems.length,
+ 1,
+ "One login should be visible after backspacing"
+ );
+
+ Assert.ok(
+ !loginList._createLoginButton.disabled,
+ "create button should be enabled"
+ );
+
+ let loginItem = content.document.querySelector("login-item");
+ Assert.ok(!loginItem.dataset.isNewLogin, "should not be in create mode");
+ Assert.ok(!loginItem.dataset.editing, "should not be in edit mode");
+ Assert.ok(
+ ContentTaskUtils.is_hidden(loginItem),
+ "login-item should be hidden when a login is not selected and we're not in create mode"
+ );
+ let loginIntro = content.document.querySelector("login-intro");
+ Assert.ok(
+ ContentTaskUtils.is_hidden(loginIntro),
+ "login-intro should be hidden when a login is not selected and we're not in create mode"
+ );
+
+ loginList._createLoginButton.click();
+
+ Assert.ok(loginItem.dataset.isNewLogin, "should be in create mode");
+ Assert.ok(loginItem.dataset.editing, "should be in edit mode");
+ Assert.ok(
+ ContentTaskUtils.is_visible(loginItem),
+ "login-item should be visible in create mode"
+ );
+ Assert.ok(
+ ContentTaskUtils.is_hidden(loginIntro),
+ "login-intro should be hidden in create mode"
+ );
+ });
+});
diff --git a/browser/components/aboutlogins/tests/browser/browser_openImport.js b/browser/components/aboutlogins/tests/browser/browser_openImport.js
new file mode 100644
index 0000000000..627e0d6e3b
--- /dev/null
+++ b/browser/components/aboutlogins/tests/browser/browser_openImport.js
@@ -0,0 +1,60 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+let { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+
+add_setup(async function () {
+ await TestUtils.waitForCondition(() => {
+ Services.telemetry.clearEvents();
+ let events = Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS,
+ true
+ ).content;
+ return !events || !events.length;
+ }, "Waiting for content telemetry events to get cleared");
+
+ let aboutLoginsTab = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ url: "about:logins",
+ });
+ registerCleanupFunction(() => {
+ BrowserTestUtils.removeTab(aboutLoginsTab);
+ });
+});
+
+add_task(async function test_open_import() {
+ let promiseImportWindow = BrowserTestUtils.waitForMigrationWizard(window);
+
+ let browser = gBrowser.selectedBrowser;
+ await BrowserTestUtils.synthesizeMouseAtCenter("menu-button", {}, browser);
+ await SpecialPowers.spawn(browser, [], async () => {
+ return ContentTaskUtils.waitForCondition(() => {
+ let menuButton = Cu.waiveXrays(
+ content.document.querySelector("menu-button")
+ );
+ return !menuButton.shadowRoot.querySelector(".menu").hidden;
+ }, "waiting for menu to open");
+ });
+
+ function getImportItem() {
+ let menuButton = window.document.querySelector("menu-button");
+ return menuButton.shadowRoot.querySelector(".menuitem-import-browser");
+ }
+ await BrowserTestUtils.synthesizeMouseAtCenter(getImportItem, {}, browser);
+
+ info("waiting for Import to get opened");
+ let importWindow = await promiseImportWindow;
+ Assert.ok(true, "Import opened");
+
+ // First event is for opening about:logins
+ await LoginTestUtils.telemetry.waitForEventCount(2);
+ TelemetryTestUtils.assertEvents(
+ [["pwmgr", "mgmt_menu_item_used", "import_from_browser"]],
+ { category: "pwmgr", method: "mgmt_menu_item_used" },
+ { process: "content" }
+ );
+
+ await BrowserTestUtils.closeMigrationWizard(importWindow);
+});
diff --git a/browser/components/aboutlogins/tests/browser/browser_openImportCSV.js b/browser/components/aboutlogins/tests/browser/browser_openImportCSV.js
new file mode 100644
index 0000000000..c4994215d8
--- /dev/null
+++ b/browser/components/aboutlogins/tests/browser/browser_openImportCSV.js
@@ -0,0 +1,411 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+const { FileTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/FileTestUtils.sys.mjs"
+);
+
+let { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+
+let { MockFilePicker } = SpecialPowers;
+
+/**
+ * A helper class to deal with Login CSV import UI.
+ */
+class CsvImportHelper {
+ /**
+ * Waits until the mock file picker is opened and sets the destFilePath as it's selected file.
+ *
+ * @param {nsIFile} destFile
+ * The file being passed to the picker.
+ * @returns {string} A promise that is resolved when the picker selects the file.
+ */
+ static waitForOpenFilePicker(destFile) {
+ return new Promise(resolve => {
+ MockFilePicker.showCallback = fp => {
+ info("showCallback");
+ info("fileName: " + destFile.path);
+ MockFilePicker.setFiles([destFile]);
+ MockFilePicker.filterIndex = 1;
+ info("done showCallback");
+ resolve();
+ };
+ });
+ }
+
+ /**
+ * Clicks the 3 dot menu and then "Import from a file..." and then it serves a CSV file.
+ * It also does the needed assertions and telemetry validations.
+ * If you await for it to return, it will have processed the CSV file already.
+ *
+ * @param {browser} browser
+ * The browser object.
+ * @param {string[]} linesInFile
+ * An array of strings to be used to generate the CSV file. Each string is a line.
+ * @returns {Promise} A promise that is resolved when the picker selects the file.
+ */
+ static async clickImportFromCsvMenu(browser, linesInFile) {
+ MockFilePicker.init(window);
+ MockFilePicker.returnValue = MockFilePicker.returnOK;
+ let csvFile = await LoginTestUtils.file.setupCsvFileWithLines(linesInFile);
+
+ await BrowserTestUtils.synthesizeMouseAtCenter("menu-button", {}, browser);
+
+ await SpecialPowers.spawn(browser, [], async () => {
+ let menuButton = content.document.querySelector("menu-button");
+ return ContentTaskUtils.waitForCondition(function waitForMenu() {
+ return !menuButton.shadowRoot.querySelector(".menu").hidden;
+ }, "waiting for menu to open");
+ });
+
+ Services.telemetry.clearEvents();
+
+ function getImportMenuItem() {
+ let menuButton = window.document.querySelector("menu-button");
+ let importButton = menuButton.shadowRoot.querySelector(
+ ".menuitem-import-file"
+ );
+ // Force the menu item to be visible for the test.
+ importButton.hidden = false;
+ return importButton;
+ }
+
+ BrowserTestUtils.synthesizeMouseAtCenter(getImportMenuItem, {}, browser);
+
+ async function waitForFilePicker() {
+ let filePickerPromise = CsvImportHelper.waitForOpenFilePicker(csvFile);
+ // First event is for opening about:logins
+ await LoginTestUtils.telemetry.waitForEventCount(
+ 1,
+ "content",
+ "pwmgr",
+ "mgmt_menu_item_used"
+ );
+ TelemetryTestUtils.assertEvents(
+ [["pwmgr", "mgmt_menu_item_used", "import_from_csv"]],
+ { category: "pwmgr", method: "mgmt_menu_item_used" },
+ { process: "content", clear: false }
+ );
+
+ info("waiting for Import file picker to get opened");
+ await filePickerPromise;
+ Assert.ok(true, "Import file picker opened");
+ }
+
+ await waitForFilePicker();
+ }
+
+ /**
+ * An utility method to fetch the data from the CSV import success dialog.
+ *
+ * @param {browser} browser
+ * The browser object.
+ * @returns {Promise<Object>} A promise that contains added, modified, noChange and errors count.
+ */
+ static async getCsvImportSuccessDialogData(browser) {
+ return SpecialPowers.spawn(browser, [], async () => {
+ let dialog = Cu.waiveXrays(
+ content.document.querySelector("import-summary-dialog")
+ );
+ await ContentTaskUtils.waitForCondition(
+ () => !dialog.hidden,
+ "Waiting for the dialog to be visible"
+ );
+
+ let added = dialog.shadowRoot.querySelector(
+ ".import-items-added .result-count"
+ ).textContent;
+ let modified = dialog.shadowRoot.querySelector(
+ ".import-items-modified .result-count"
+ ).textContent;
+ let noChange = dialog.shadowRoot.querySelector(
+ ".import-items-no-change .result-count"
+ ).textContent;
+ let errors = dialog.shadowRoot.querySelector(
+ ".import-items-errors .result-count"
+ ).textContent;
+ const dialogData = {
+ added,
+ modified,
+ noChange,
+ errors,
+ };
+ if (dialog.shadowRoot.activeElement) {
+ dialogData.l10nFocused =
+ dialog.shadowRoot.activeElement.getAttribute("data-l10n-id");
+ }
+ return dialogData;
+ });
+ }
+
+ /**
+ * An utility method to fetch the data from the CSV import error dialog.
+ *
+ * @param {browser} browser
+ * The browser object.
+ * @returns {Promise<Object>} A promise that contains the hidden state and l10n id for title, description and focused element.
+ */
+ static async getCsvImportErrorDialogData(browser) {
+ return SpecialPowers.spawn(browser, [], async () => {
+ const dialog = Cu.waiveXrays(
+ content.document.querySelector("import-error-dialog")
+ );
+ const l10nTitle = dialog._genericDialog
+ .querySelector(".error-title")
+ .getAttribute("data-l10n-id");
+ const l10nDescription = dialog._genericDialog
+ .querySelector(".error-description")
+ .getAttribute("data-l10n-id");
+ return {
+ hidden: dialog.hidden,
+ l10nFocused:
+ dialog.shadowRoot.activeElement.getAttribute("data-l10n-id"),
+ l10nTitle,
+ l10nDescription,
+ };
+ });
+ }
+
+ /**
+ * An utility method to wait until CSV import is complete.
+ *
+ * @returns {Promise} A promise that gets resolved when the import is complete.
+ */
+ static async waitForImportToComplete() {
+ info("Waiting for the import to complete");
+ await LoginTestUtils.telemetry.waitForEventCount(1, "parent");
+ TelemetryTestUtils.assertEvents(
+ [["pwmgr", "mgmt_menu_item_used", "import_csv_complete"]],
+ { category: "pwmgr", method: "mgmt_menu_item_used" },
+ { process: "parent" }
+ );
+ }
+
+ /**
+ * An utility method open the about:loginsimportreport page.
+ *
+ * @param {browser} browser
+ * The browser object.
+ * @returns {Promise<Object>} A promise that contains the about:loginsimportreport tab.
+ */
+ static async clickDetailedReport(browser) {
+ let loadedReportTab = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ "about:loginsimportreport",
+ true
+ );
+ await SpecialPowers.spawn(browser, [], async () => {
+ let dialog = Cu.waiveXrays(
+ content.document.querySelector("import-summary-dialog")
+ );
+ await ContentTaskUtils.waitForCondition(
+ () => !dialog.hidden,
+ "Waiting for the dialog to be visible"
+ );
+ let detailedReportLink = dialog.shadowRoot.querySelector(
+ ".open-detailed-report"
+ );
+
+ detailedReportLink.click();
+ });
+ return loadedReportTab;
+ }
+
+ /**
+ * An utility method to fetch data from the about:loginsimportreport page.
+ *
+ * @param {browser} browser
+ * The browser object.
+ * @returns {Promise<Object>} A promise that contains the detailed report data like added, modified, noChange, errors and rows.
+ */
+ static async getDetailedReportData(browser) {
+ const data = await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [],
+ async () => {
+ function getCount(selector) {
+ const attribute = content.document
+ .querySelector(selector)
+ .getAttribute("data-l10n-args");
+ return JSON.parse(attribute).count;
+ }
+ const rows = [];
+ for (let element of content.document.querySelectorAll(".row-details")) {
+ rows.push(element.getAttribute("data-l10n-id"));
+ }
+ const added = getCount(".new-logins");
+ const modified = getCount(".exiting-logins");
+ const noChange = getCount(".duplicate-logins");
+ const errors = getCount(".errors-logins");
+ return {
+ rows,
+ added,
+ modified,
+ noChange,
+ errors,
+ };
+ }
+ );
+ return data;
+ }
+}
+
+const random = Math.round(Math.random() * 100000001);
+
+add_setup(async function () {
+ registerCleanupFunction(() => {
+ Services.logins.removeAllUserFacingLogins();
+ });
+});
+
+add_task(async function test_open_import_one_item_from_csv() {
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: "about:logins" },
+ async browser => {
+ await CsvImportHelper.clickImportFromCsvMenu(browser, [
+ "url,username,password,httpRealm,formActionOrigin,guid,timeCreated,timeLastUsed,timePasswordChanged",
+ `https://example.com,joe${random}@example.com,qwerty,My realm,,{${random}-e194-4279-ae1b-d7d281bb46f0},1589617814635,1589710449871,1589617846802`,
+ ]);
+ await CsvImportHelper.waitForImportToComplete();
+
+ let summary = await CsvImportHelper.getCsvImportSuccessDialogData(
+ browser
+ );
+ Assert.equal(summary.added, "1", "It should have one item as added");
+ Assert.equal(
+ summary.l10nFocused,
+ "about-logins-import-dialog-done",
+ "dismiss button should be focused"
+ );
+ }
+ );
+});
+
+add_task(async function test_open_import_all_four_categories() {
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: "about:logins" },
+ async browser => {
+ const initialCsvData = [
+ "url,username,password,httpRealm,formActionOrigin,guid,timeCreated,timeLastUsed,timePasswordChanged",
+ `https://example1.com,existing${random},existing,,,{${random}-07a1-4bcf-86f0-7d56b9c1f48f},1582229924361,1582495972623,1582229924000`,
+ `https://example1.com,duplicate,duplicate,,,{dddd0080-07a1-4bcf-86f0-7d56b9c1f48f},1582229924361,1582495972623,1582229924363`,
+ ];
+ const updatedCsvData = [
+ "url,username,password,httpRealm,formActionOrigin,guid,timeCreated,timeLastUsed,timePasswordChanged",
+ `https://example1.com,added${random},added,,,,,,`,
+ `https://example1.com,existing${random},modified,,,{${random}-07a1-4bcf-86f0-7d56b9c1f48f},1582229924361,1582495972623,1582229924363`,
+ `https://example1.com,duplicate,duplicate,,,{dddd0080-07a1-4bcf-86f0-7d56b9c1f48f},1582229924361,1582495972623,1582229924363`,
+ `https://example1.com,error,,,,,,,`,
+ ];
+
+ await CsvImportHelper.clickImportFromCsvMenu(browser, initialCsvData);
+ await CsvImportHelper.waitForImportToComplete();
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "dismiss-button",
+ {},
+ browser
+ );
+ await CsvImportHelper.clickImportFromCsvMenu(browser, updatedCsvData);
+ await CsvImportHelper.waitForImportToComplete();
+
+ let summary = await CsvImportHelper.getCsvImportSuccessDialogData(
+ browser
+ );
+ Assert.equal(summary.added, "1", "It should have one item as added");
+ Assert.equal(
+ summary.modified,
+ "1",
+ "It should have one item as modified"
+ );
+ Assert.equal(
+ summary.noChange,
+ "1",
+ "It should have one item as unchanged"
+ );
+ Assert.equal(summary.errors, "1", "It should have one item as error");
+ }
+ );
+});
+
+add_task(async function test_open_import_all_four_detailed_report() {
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: "about:logins" },
+ async browser => {
+ const initialCsvData = [
+ "url,username,password,httpRealm,formActionOrigin,guid,timeCreated,timeLastUsed,timePasswordChanged",
+ `https://example2.com,existing${random},existing,,,{${random}-07a1-4bcf-86f0-7d56b9c1f48f},1582229924361,1582495972623,1582229924000`,
+ "https://example2.com,duplicate,duplicate,,,{dddd0080-07a1-4bcf-86f0-7d56b9c1f48f},1582229924361,1582495972623,1582229924363",
+ ];
+ const updatedCsvData = [
+ "url,username,password,httpRealm,formActionOrigin,guid,timeCreated,timeLastUsed,timePasswordChanged",
+ `https://example2.com,added${random},added,,,,,,`,
+ `https://example2.com,existing${random},modified,,,{${random}-07a1-4bcf-86f0-7d56b9c1f48f},1582229924361,1582495972623,1582229924363`,
+ "https://example2.com,duplicate,duplicate,,,{dddd0080-07a1-4bcf-86f0-7d56b9c1f48f},1582229924361,1582495972623,1582229924363",
+ "https://example2.com,error,,,,,,,",
+ ];
+
+ await CsvImportHelper.clickImportFromCsvMenu(browser, initialCsvData);
+ await CsvImportHelper.waitForImportToComplete();
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "dismiss-button",
+ {},
+ browser
+ );
+ await CsvImportHelper.clickImportFromCsvMenu(browser, updatedCsvData);
+ await CsvImportHelper.waitForImportToComplete();
+ const reportTab = await CsvImportHelper.clickDetailedReport(browser);
+ const report = await CsvImportHelper.getDetailedReportData(browser);
+ BrowserTestUtils.removeTab(reportTab);
+ const { added, modified, noChange, errors, rows } = report;
+ Assert.equal(added, 1, "It should have one item as added");
+ Assert.equal(modified, 1, "It should have one item as modified");
+ Assert.equal(noChange, 1, "It should have one item as unchanged");
+ Assert.equal(errors, 1, "It should have one item as error");
+ Assert.deepEqual(
+ [
+ "about-logins-import-report-row-description-added",
+ "about-logins-import-report-row-description-modified",
+ "about-logins-import-report-row-description-no-change",
+ "about-logins-import-report-row-description-error-missing-field",
+ ],
+ rows,
+ "It should have expected rows in order"
+ );
+ }
+ );
+});
+
+add_task(async function test_open_import_from_csv_with_invalid_file() {
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: "about:logins" },
+ async browser => {
+ await CsvImportHelper.clickImportFromCsvMenu(browser, [
+ "invalid csv file",
+ ]);
+
+ info("Waiting for the import error dialog");
+ const errorDialog = await CsvImportHelper.getCsvImportErrorDialogData(
+ browser
+ );
+ Assert.equal(errorDialog.hidden, false, "Dialog should not be hidden");
+ Assert.equal(
+ errorDialog.l10nTitle,
+ "about-logins-import-dialog-error-file-format-title",
+ "Dialog error title should be correct"
+ );
+ Assert.equal(
+ errorDialog.l10nDescription,
+ "about-logins-import-dialog-error-file-format-description",
+ "Dialog error description should be correct"
+ );
+ Assert.equal(
+ errorDialog.l10nFocused,
+ "about-logins-import-dialog-error-learn-more",
+ "Learn more link should be focused."
+ );
+ }
+ );
+});
diff --git a/browser/components/aboutlogins/tests/browser/browser_openPreferences.js b/browser/components/aboutlogins/tests/browser/browser_openPreferences.js
new file mode 100644
index 0000000000..57ca74ba87
--- /dev/null
+++ b/browser/components/aboutlogins/tests/browser/browser_openPreferences.js
@@ -0,0 +1,82 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+let { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+
+add_setup(async function () {
+ await TestUtils.waitForCondition(() => {
+ Services.telemetry.clearEvents();
+ let events = Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS,
+ true
+ ).content;
+ return !events || !events.length;
+ }, "Waiting for content telemetry events to get cleared");
+
+ await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ url: "about:logins",
+ });
+ registerCleanupFunction(() => {
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ });
+});
+
+add_task(async function test_open_preferences() {
+ // We want to make sure we visit about:preferences#privacy-logins , as that is
+ // what causes us to scroll to and highlight the "logins" section. However,
+ // about:preferences will redirect the URL, so the eventual load event will happen
+ // on about:preferences#privacy . The `wantLoad` parameter we pass to
+ // `waitForNewTab` needs to take this into account:
+ let seenFirstURL = false;
+ let promiseNewTab = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ url => {
+ if (url == "about:preferences#privacy-logins") {
+ seenFirstURL = true;
+ return true;
+ } else if (url == "about:preferences#privacy") {
+ Assert.ok(
+ seenFirstURL,
+ "Must have seen an onLocationChange notification for the privacy-logins hash"
+ );
+ return true;
+ }
+ return false;
+ },
+ true
+ );
+
+ let browser = gBrowser.selectedBrowser;
+ await BrowserTestUtils.synthesizeMouseAtCenter("menu-button", {}, browser);
+ await SpecialPowers.spawn(browser, [], async () => {
+ return ContentTaskUtils.waitForCondition(() => {
+ let menuButton = Cu.waiveXrays(
+ content.document.querySelector("menu-button")
+ );
+ return !menuButton.shadowRoot.querySelector(".menu").hidden;
+ }, "waiting for menu to open");
+ });
+
+ function getPrefsItem() {
+ let menuButton = window.document.querySelector("menu-button");
+ return menuButton.shadowRoot.querySelector(".menuitem-preferences");
+ }
+ await BrowserTestUtils.synthesizeMouseAtCenter(getPrefsItem, {}, browser);
+
+ info("waiting for new tab to get opened");
+ let newTab = await promiseNewTab;
+ Assert.ok(true, "New tab opened to about:preferences");
+
+ BrowserTestUtils.removeTab(newTab);
+
+ // First event is for opening about:logins
+ await LoginTestUtils.telemetry.waitForEventCount(2);
+ TelemetryTestUtils.assertEvents(
+ [["pwmgr", "mgmt_menu_item_used", "preferences"]],
+ { category: "pwmgr", method: "mgmt_menu_item_used" },
+ { process: "content" }
+ );
+});
diff --git a/browser/components/aboutlogins/tests/browser/browser_openPreferencesExternal.js b/browser/components/aboutlogins/tests/browser/browser_openPreferencesExternal.js
new file mode 100644
index 0000000000..e4290371fb
--- /dev/null
+++ b/browser/components/aboutlogins/tests/browser/browser_openPreferencesExternal.js
@@ -0,0 +1,65 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+add_setup(async function () {
+ await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ url: "about:logins",
+ });
+ registerCleanupFunction(() => {
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ });
+});
+
+add_task(async function test_open_feedback() {
+ const menuArray = [
+ {
+ urlFinal:
+ "https://example.com/password-manager-remember-delete-edit-logins",
+ urlBase: "https://example.com/",
+ pref: "app.support.baseURL",
+ selector: ".menuitem-help",
+ },
+ ];
+
+ for (const { urlFinal, urlBase, pref, selector } of menuArray) {
+ info("Test on " + urlFinal);
+
+ await SpecialPowers.pushPrefEnv({
+ set: [[pref, urlBase]],
+ });
+
+ let promiseNewTab = BrowserTestUtils.waitForNewTab(gBrowser, urlFinal);
+
+ let browser = gBrowser.selectedBrowser;
+ await BrowserTestUtils.synthesizeMouseAtCenter("menu-button", {}, browser);
+ await SpecialPowers.spawn(browser, [], async () => {
+ return ContentTaskUtils.waitForCondition(() => {
+ let menuButton = content.document.querySelector("menu-button");
+ return !menuButton.shadowRoot.querySelector(".menu").hidden;
+ }, "waiting for menu to open");
+ });
+
+ // Not using synthesizeMouseAtCenter here because the element we want clicked on
+ // is in the shadow DOM and therefore requires using a function 1st argument
+ // to BrowserTestUtils.synthesizeMouseAtCenter but we need to pass an
+ // arbitrary selector. See bug 1557489 for more info. As a workaround, this
+ // manually calculates the position to click.
+ let { x, y } = await SpecialPowers.spawn(
+ browser,
+ [selector],
+ async menuItemSelector => {
+ let menuButton = content.document.querySelector("menu-button");
+ let prefsItem = menuButton.shadowRoot.querySelector(menuItemSelector);
+ return prefsItem.getBoundingClientRect();
+ }
+ );
+ await BrowserTestUtils.synthesizeMouseAtPoint(x + 5, y + 5, {}, browser);
+
+ info("waiting for new tab to get opened");
+ let newTab = await promiseNewTab;
+ Assert.ok(true, "New tab opened to" + urlFinal);
+
+ BrowserTestUtils.removeTab(newTab);
+ }
+});
diff --git a/browser/components/aboutlogins/tests/browser/browser_openSite.js b/browser/components/aboutlogins/tests/browser/browser_openSite.js
new file mode 100644
index 0000000000..f33d57a8e4
--- /dev/null
+++ b/browser/components/aboutlogins/tests/browser/browser_openSite.js
@@ -0,0 +1,94 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+add_setup(async function () {
+ TEST_LOGIN1 = await addLogin(TEST_LOGIN1);
+ await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ url: "about:logins",
+ });
+ registerCleanupFunction(() => {
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ Services.logins.removeAllUserFacingLogins();
+ });
+});
+
+add_task(async function test_launch_login_item() {
+ let promiseNewTab = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ TEST_LOGIN1.origin + "/"
+ );
+
+ let browser = gBrowser.selectedBrowser;
+
+ await SpecialPowers.spawn(browser, [], async () => {
+ let loginItem = Cu.waiveXrays(content.document.querySelector("login-item"));
+ let originInput = loginItem.shadowRoot.querySelector("a[name='origin']");
+ let EventUtils = ContentTaskUtils.getEventUtils(content);
+ // Use synthesizeMouseAtCenter to generate an event that more closely resembles the
+ // properties of the event object that will be seen when the user clicks the element
+ // (.click() sets originalTarget while synthesizeMouse has originalTarget as a Restricted object).
+ await EventUtils.synthesizeMouseAtCenter(originInput, {}, content);
+ });
+
+ info("waiting for new tab to get opened");
+ let newTab = await promiseNewTab;
+ Assert.ok(true, "New tab opened to " + TEST_LOGIN1.origin);
+ BrowserTestUtils.removeTab(newTab);
+
+ if (!OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) {
+ return;
+ }
+
+ promiseNewTab = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ TEST_LOGIN1.origin + "/"
+ );
+ let reauthObserved = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true);
+ await SpecialPowers.spawn(browser, [], async () => {
+ let loginItem = Cu.waiveXrays(content.document.querySelector("login-item"));
+ loginItem._editButton.click();
+ });
+ await reauthObserved;
+ await SpecialPowers.spawn(browser, [], async () => {
+ let loginItem = Cu.waiveXrays(content.document.querySelector("login-item"));
+ loginItem._usernameInput.value += "-changed";
+
+ Assert.ok(
+ content.document.querySelector("confirmation-dialog").hidden,
+ "discard-changes confirmation-dialog should be hidden before opening the site"
+ );
+
+ let originInput = loginItem.shadowRoot.querySelector("a[name='origin']");
+ let EventUtils = ContentTaskUtils.getEventUtils(content);
+ // Use synthesizeMouseAtCenter to generate an event that more closely resembles the
+ // properties of the event object that will be seen when the user clicks the element
+ // (.click() sets originalTarget while synthesizeMouse has originalTarget as a Restricted object).
+ await EventUtils.synthesizeMouseAtCenter(originInput, {}, content);
+ });
+
+ info("waiting for new tab to get opened");
+ newTab = await promiseNewTab;
+ Assert.ok(true, "New tab opened to " + TEST_LOGIN1.origin);
+
+ let modifiedLogin = TEST_LOGIN1.clone();
+ modifiedLogin.timeLastUsed = 9000;
+ let storageChangedPromised = TestUtils.topicObserved(
+ "passwordmgr-storage-changed",
+ (_, data) => data == "modifyLogin"
+ );
+ Services.logins.modifyLogin(TEST_LOGIN1, modifiedLogin);
+ await storageChangedPromised;
+
+ BrowserTestUtils.removeTab(newTab);
+
+ await SpecialPowers.spawn(browser, [], async () => {
+ await ContentTaskUtils.waitForCondition(() => {
+ return !content.document.querySelector("confirmation-dialog").hidden;
+ }, "waiting for confirmation-dialog to appear");
+ Assert.ok(
+ !content.document.querySelector("confirmation-dialog").hidden,
+ "discard-changes confirmation-dialog should be visible after logging in to a site with a modified login present in the form"
+ );
+ });
+});
diff --git a/browser/components/aboutlogins/tests/browser/browser_osAuthDialog.js b/browser/components/aboutlogins/tests/browser/browser_osAuthDialog.js
new file mode 100644
index 0000000000..ca054e449a
--- /dev/null
+++ b/browser/components/aboutlogins/tests/browser/browser_osAuthDialog.js
@@ -0,0 +1,165 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+add_task(async function test() {
+ info(
+ `updatechannel: ${UpdateUtils.getUpdateChannel(false)}; platform: ${
+ AppConstants.platform
+ }`
+ );
+ if (!OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) {
+ Assert.ok(
+ true,
+ `skipping test since oskeystore cannot be automated in this environment`
+ );
+ return;
+ }
+
+ TEST_LOGIN1 = await addLogin(TEST_LOGIN1);
+
+ await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ url: "about:logins",
+ });
+
+ registerCleanupFunction(function () {
+ Services.logins.removeAllUserFacingLogins();
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ });
+
+ // Show OS auth dialog when Reveal Password checkbox is checked if not on a new login
+ let osAuthDialogShown = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(false);
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () {
+ let loginItem = content.document.querySelector("login-item");
+ let revealCheckbox = loginItem.shadowRoot.querySelector(
+ ".reveal-password-checkbox"
+ );
+ revealCheckbox.click();
+ });
+ await osAuthDialogShown;
+ info("OS auth dialog shown and canceled");
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () {
+ let loginItem = content.document.querySelector("login-item");
+ let revealCheckbox = loginItem.shadowRoot.querySelector(
+ ".reveal-password-checkbox"
+ );
+ Assert.ok(
+ !revealCheckbox.checked,
+ "reveal checkbox should be unchecked if OS auth dialog canceled"
+ );
+ });
+ osAuthDialogShown = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true);
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () {
+ let loginItem = content.document.querySelector("login-item");
+ let revealCheckbox = loginItem.shadowRoot.querySelector(
+ ".reveal-password-checkbox"
+ );
+ revealCheckbox.click();
+ });
+ await osAuthDialogShown;
+ info("OS auth dialog shown and authenticated");
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () {
+ let loginItem = content.document.querySelector("login-item");
+ let revealCheckbox = loginItem.shadowRoot.querySelector(
+ ".reveal-password-checkbox"
+ );
+ Assert.ok(
+ revealCheckbox.checked,
+ "reveal checkbox should be checked if OS auth dialog authenticated"
+ );
+ });
+
+ info("'Edit' shouldn't show the prompt since the user has authenticated now");
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () {
+ let loginItem = content.document.querySelector("login-item");
+ Assert.ok(
+ !loginItem.dataset.editing,
+ "Not in edit mode before clicking 'Edit'"
+ );
+ let editButton = loginItem.shadowRoot.querySelector(".edit-button");
+ editButton.click();
+
+ await ContentTaskUtils.waitForCondition(
+ () => loginItem.dataset.editing,
+ "waiting for 'edit' mode"
+ );
+ Assert.ok(loginItem.dataset.editing, "In edit mode");
+ });
+
+ info("Test that the OS auth prompt is shown after about:logins is reopened");
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ url: "about:logins",
+ });
+
+ // Show OS auth dialog since the page has been reloaded.
+ osAuthDialogShown = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(false);
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () {
+ let loginItem = content.document.querySelector("login-item");
+ let revealCheckbox = loginItem.shadowRoot.querySelector(
+ ".reveal-password-checkbox"
+ );
+ revealCheckbox.click();
+ });
+ await osAuthDialogShown;
+ info("OS auth dialog shown and canceled");
+
+ // Show OS auth dialog since the previous attempt was canceled
+ osAuthDialogShown = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true);
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () {
+ let loginItem = content.document.querySelector("login-item");
+ let revealCheckbox = loginItem.shadowRoot.querySelector(
+ ".reveal-password-checkbox"
+ );
+ revealCheckbox.click();
+ info("clicking on reveal checkbox to hide the password");
+ revealCheckbox.click();
+ });
+ await osAuthDialogShown;
+ info("OS auth dialog shown and passed");
+
+ // Show OS auth dialog since the timeout will have expired
+ osAuthDialogShown = forceAuthTimeoutAndWaitForOSKeyStoreLogin({
+ loginResult: true,
+ });
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () {
+ let loginItem = content.document.querySelector("login-item");
+ let revealCheckbox = loginItem.shadowRoot.querySelector(
+ ".reveal-password-checkbox"
+ );
+ info("clicking on reveal checkbox to reveal password");
+ revealCheckbox.click();
+ });
+ info("waiting for os auth dialog");
+ await osAuthDialogShown;
+ info("OS auth dialog shown and passed after timeout expiration");
+
+ // Disable the OS auth feature and confirm the prompt doesn't appear
+ await SpecialPowers.pushPrefEnv({
+ set: [["signon.management.page.os-auth.enabled", false]],
+ });
+ info("Reload about:logins to reset the timeout");
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ url: "about:logins",
+ });
+
+ info("'Edit' shouldn't show the prompt since the feature has been disabled");
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () {
+ let loginItem = content.document.querySelector("login-item");
+ Assert.ok(
+ !loginItem.dataset.editing,
+ "Not in edit mode before clicking 'Edit'"
+ );
+ let editButton = loginItem.shadowRoot.querySelector(".edit-button");
+ editButton.click();
+
+ await ContentTaskUtils.waitForCondition(
+ () => loginItem.dataset.editing,
+ "waiting for 'edit' mode"
+ );
+ Assert.ok(loginItem.dataset.editing, "In edit mode");
+ });
+});
diff --git a/browser/components/aboutlogins/tests/browser/browser_primaryPassword.js b/browser/components/aboutlogins/tests/browser/browser_primaryPassword.js
new file mode 100644
index 0000000000..79a1e9a1da
--- /dev/null
+++ b/browser/components/aboutlogins/tests/browser/browser_primaryPassword.js
@@ -0,0 +1,282 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+function waitForLoginCountToReach(browser, loginCount) {
+ return SpecialPowers.spawn(
+ browser,
+ [loginCount],
+ async expectedLoginCount => {
+ let loginList = Cu.waiveXrays(
+ content.document.querySelector("login-list")
+ );
+ await ContentTaskUtils.waitForCondition(() => {
+ return loginList._loginGuidsSortedOrder.length == expectedLoginCount;
+ });
+ return loginList._loginGuidsSortedOrder.length;
+ }
+ );
+}
+
+add_setup(async function () {
+ await addLogin(TEST_LOGIN1);
+ registerCleanupFunction(() => {
+ Services.logins.removeAllUserFacingLogins();
+ LoginTestUtils.primaryPassword.disable();
+ });
+});
+
+add_task(async function test() {
+ // Confirm that the mocking of the OS auth dialog isn't enabled so the
+ // test will timeout if a real OS auth dialog is shown. We don't show
+ // the OS auth dialog when Primary Password is enabled.
+ Assert.equal(
+ Services.prefs.getStringPref(
+ "toolkit.osKeyStore.unofficialBuildOnlyLogin",
+ ""
+ ),
+ "",
+ "Pref should be set to default value of empty string to start the test"
+ );
+ LoginTestUtils.primaryPassword.enable();
+
+ let mpDialogShown = forceAuthTimeoutAndWaitForMPDialog("cancel");
+ await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ url: "about:logins",
+ });
+ await mpDialogShown;
+
+ let browser = gBrowser.selectedBrowser;
+ let logins = await waitForLoginCountToReach(browser, 0);
+ Assert.equal(
+ logins,
+ 0,
+ "No logins should be displayed when MP is set and unauthenticated"
+ );
+
+ let notification;
+ await TestUtils.waitForCondition(
+ () =>
+ (notification = gBrowser
+ .getNotificationBox()
+ .getNotificationWithValue("primary-password-login-required")),
+ "waiting for primary-password-login-required notification"
+ );
+
+ Assert.ok(
+ notification,
+ "primary-password-login-required notification should be visible"
+ );
+
+ let buttons = notification.buttonContainer.querySelectorAll(
+ ".notification-button"
+ );
+ Assert.equal(buttons.length, 1, "Should have one button.");
+
+ let refreshPromise = BrowserTestUtils.browserLoaded(browser);
+ // Sign in with the Primary Password this time the dialog is shown
+ mpDialogShown = forceAuthTimeoutAndWaitForMPDialog("authenticate");
+ // Click the button to reload the page.
+ buttons[0].click();
+ await refreshPromise;
+ info("Page reloaded");
+
+ await mpDialogShown;
+ info("Primary Password dialog shown and authenticated");
+
+ logins = await waitForLoginCountToReach(browser, 1);
+ Assert.equal(
+ logins,
+ 1,
+ "Logins should be displayed when MP is set and authenticated"
+ );
+
+ // Show MP dialog when Copy Password button clicked
+ mpDialogShown = forceAuthTimeoutAndWaitForMPDialog("cancel");
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () {
+ let loginItem = content.document.querySelector("login-item");
+ let copyButton = loginItem.shadowRoot.querySelector(
+ ".copy-password-button"
+ );
+ copyButton.click();
+ });
+ await mpDialogShown;
+ info("Primary Password dialog shown and canceled");
+ mpDialogShown = forceAuthTimeoutAndWaitForMPDialog("authenticate");
+ info("Clicking copy password button again");
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () {
+ let loginItem = content.document.querySelector("login-item");
+ let copyButton = loginItem.shadowRoot.querySelector(
+ ".copy-password-button"
+ );
+ copyButton.click();
+ });
+ await mpDialogShown;
+ info("Primary Password dialog shown and authenticated");
+ await SpecialPowers.spawn(browser, [], async function () {
+ let loginItem = content.document.querySelector("login-item");
+ let copyButton = loginItem.shadowRoot.querySelector(
+ ".copy-password-button"
+ );
+ await ContentTaskUtils.waitForCondition(() => {
+ return copyButton.disabled;
+ }, "Waiting for copy button to be disabled");
+ info("Password was copied to clipboard");
+ });
+
+ // Show MP dialog when Reveal Password checkbox is checked if not on a new login
+ mpDialogShown = forceAuthTimeoutAndWaitForMPDialog("cancel");
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () {
+ let loginItem = content.document.querySelector("login-item");
+ let revealCheckbox = loginItem.shadowRoot.querySelector(
+ ".reveal-password-checkbox"
+ );
+ revealCheckbox.click();
+ });
+ await mpDialogShown;
+ info("Primary Password dialog shown and canceled");
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () {
+ let loginItem = content.document.querySelector("login-item");
+ let revealCheckbox = loginItem.shadowRoot.querySelector(
+ ".reveal-password-checkbox"
+ );
+ Assert.ok(
+ !revealCheckbox.checked,
+ "reveal checkbox should be unchecked if MP dialog canceled"
+ );
+ });
+ mpDialogShown = forceAuthTimeoutAndWaitForMPDialog("authenticate");
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () {
+ let loginItem = content.document.querySelector("login-item");
+ let revealCheckbox = loginItem.shadowRoot.querySelector(
+ ".reveal-password-checkbox"
+ );
+ revealCheckbox.click();
+ });
+ await mpDialogShown;
+ info("Primary Password dialog shown and authenticated");
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () {
+ let loginItem = content.document.querySelector("login-item");
+ let revealCheckbox = loginItem.shadowRoot.querySelector(
+ ".reveal-password-checkbox"
+ );
+ Assert.ok(
+ revealCheckbox.checked,
+ "reveal checkbox should be checked if MP dialog authenticated"
+ );
+ });
+
+ info("Test toggling the password visibility on a new login");
+ await SpecialPowers.spawn(browser, [], async function createNewToggle() {
+ let createButton = content.document
+ .querySelector("login-list")
+ .shadowRoot.querySelector(".create-login-button");
+ createButton.click();
+
+ let loginItem = Cu.waiveXrays(content.document.querySelector("login-item"));
+ let passwordField = loginItem.shadowRoot.querySelector(
+ "input[name='password']"
+ );
+ let revealCheckbox = loginItem.shadowRoot.querySelector(
+ ".reveal-password-checkbox"
+ );
+ Assert.ok(ContentTaskUtils.is_visible(revealCheckbox), "Toggle visible");
+ Assert.ok(!revealCheckbox.checked, "Not revealed initially");
+ Assert.equal(passwordField.type, "password", "type is password");
+ revealCheckbox.click();
+
+ await ContentTaskUtils.waitForCondition(() => {
+ return passwordField.type == "text";
+ }, "Waiting for type='text'");
+ Assert.ok(revealCheckbox.checked, "Not revealed after click");
+
+ let cancelButton = loginItem.shadowRoot.querySelector(".cancel-button");
+ cancelButton.click();
+ });
+
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () {
+ const loginList = Cu.waiveXrays(
+ content.document.querySelector("login-list")
+ );
+
+ const loginFilter = Cu.waiveXrays(
+ loginList.shadowRoot.querySelector("login-filter")
+ );
+ loginFilter.value = "pass1";
+ Assert.equal(
+ loginList._list.querySelectorAll(
+ ".login-list-item[data-guid]:not([hidden])"
+ ).length,
+ 1,
+ "login-list should show corresponding result when primary password is enabled"
+ );
+ loginFilter.value = "";
+ Assert.equal(
+ loginList._list.querySelectorAll(
+ ".login-list-item[data-guid]:not([hidden])"
+ ).length,
+ 1,
+ "login-list should show all results since the filter is empty"
+ );
+ });
+ LoginTestUtils.primaryPassword.disable();
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () {
+ Cu.waiveXrays(content).AboutLoginsUtils.primaryPasswordEnabled = false;
+ const loginList = Cu.waiveXrays(
+ content.document.querySelector("login-list")
+ );
+ const loginFilter = Cu.waiveXrays(
+ loginList.shadowRoot.querySelector("login-filter")
+ );
+ loginFilter.value = "pass1";
+ Assert.equal(
+ loginList._list.querySelectorAll(
+ ".login-list-item[data-guid]:not([hidden])"
+ ).length,
+ 1,
+ "login-list should show login with matching password since MP is disabled"
+ );
+ });
+
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+});
+
+add_task(async function test_login_item_after_successful_auth() {
+ // Confirm that the mocking of the OS auth dialog isn't enabled so the
+ // test will timeout if a real OS auth dialog is shown. We don't show
+ // the OS auth dialog when Primary Password is enabled.
+ Assert.equal(
+ Services.prefs.getStringPref(
+ "toolkit.osKeyStore.unofficialBuildOnlyLogin",
+ ""
+ ),
+ "",
+ "Pref should be set to default value of empty string to start the test"
+ );
+ LoginTestUtils.primaryPassword.enable();
+
+ let mpDialogShown = forceAuthTimeoutAndWaitForMPDialog("authenticate");
+ await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ url: "about:logins",
+ });
+ await mpDialogShown;
+
+ let browser = gBrowser.selectedBrowser;
+ let logins = await waitForLoginCountToReach(browser, 1);
+ Assert.equal(
+ logins,
+ 1,
+ "Logins should be displayed when MP is set and authenticated"
+ );
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () {
+ let loginItem = content.document.querySelector("login-item");
+ Assert.ok(
+ !loginItem.classList.contains("no-logins"),
+ "Login item should have content after MP is authenticated"
+ );
+ });
+
+ LoginTestUtils.primaryPassword.disable();
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+});
diff --git a/browser/components/aboutlogins/tests/browser/browser_removeAllDialog.js b/browser/components/aboutlogins/tests/browser/browser_removeAllDialog.js
new file mode 100644
index 0000000000..41503e2b4d
--- /dev/null
+++ b/browser/components/aboutlogins/tests/browser/browser_removeAllDialog.js
@@ -0,0 +1,555 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+/* eslint-disable mozilla/no-arbitrary-setTimeout */
+
+const OS_REAUTH_PREF = "signon.management.page.os-auth.enabled";
+
+async function openRemoveAllDialog(browser) {
+ await SimpleTest.promiseFocus(browser);
+ await BrowserTestUtils.synthesizeMouseAtCenter("menu-button", {}, browser);
+ await SpecialPowers.spawn(browser, [], async () => {
+ let menuButton = content.document.querySelector("menu-button");
+ let menu = menuButton.shadowRoot.querySelector("ul.menu");
+ await ContentTaskUtils.waitForCondition(() => !menu.hidden);
+ });
+ function getRemoveAllMenuButton() {
+ let menuButton = window.document.querySelector("menu-button");
+ return menuButton.shadowRoot.querySelector(".menuitem-remove-all-logins");
+ }
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ getRemoveAllMenuButton,
+ {},
+ browser
+ );
+ info("remove all dialog should be opened");
+}
+
+async function activateLoginItemEdit(browser) {
+ await SimpleTest.promiseFocus(browser);
+ await SpecialPowers.spawn(browser, [], async () => {
+ let loginItem = content.document.querySelector("login-item");
+ Assert.ok(loginItem, "Login item should exist");
+ });
+ function getLoginItemEditButton() {
+ let loginItem = window.document.querySelector("login-item");
+ return loginItem.shadowRoot.querySelector(".edit-button");
+ }
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ getLoginItemEditButton,
+ {},
+ browser
+ );
+ await SpecialPowers.spawn(browser, [], async () => {
+ let loginItem = content.document.querySelector("login-item");
+ loginItem.shadowRoot.querySelector(".edit-button").click();
+ await ContentTaskUtils.waitForCondition(
+ () => loginItem.dataset.editing,
+ "Waiting for login-item to enter edit mode"
+ );
+ });
+ info("login-item should be in edit mode");
+}
+
+async function activateCreateNewLogin(browser) {
+ await SimpleTest.promiseFocus(browser);
+ function getCreateNewLoginButton() {
+ let loginList = window.document.querySelector("login-list");
+ return loginList.shadowRoot.querySelector(".create-login-button");
+ }
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ getCreateNewLoginButton,
+ {},
+ browser
+ );
+}
+
+async function waitForRemoveAllLogins() {
+ return new Promise(resolve => {
+ Services.obs.addObserver(function observer(subject, topic, changeType) {
+ if (changeType != "removeAllLogins") {
+ return;
+ }
+
+ Services.obs.removeObserver(observer, "passwordmgr-storage-changed");
+ resolve();
+ }, "passwordmgr-storage-changed");
+ });
+}
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [[OS_REAUTH_PREF, false]],
+ });
+ await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ url: "about:logins",
+ });
+ registerCleanupFunction(async () => {
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ Services.logins.removeAllUserFacingLogins();
+ await SpecialPowers.popPrefEnv();
+ });
+ TEST_LOGIN1 = await addLogin(TEST_LOGIN1);
+});
+
+add_task(async function test_remove_all_dialog_l10n() {
+ Assert.ok(TEST_LOGIN1, "test_login1");
+ let browser = gBrowser.selectedBrowser;
+ await openRemoveAllDialog(browser);
+ await SpecialPowers.spawn(browser, [], async () => {
+ const EventUtils = ContentTaskUtils.getEventUtils(content);
+ let dialog = Cu.waiveXrays(
+ content.document.querySelector("remove-logins-dialog")
+ );
+ Assert.ok(!dialog.hidden);
+ let title = dialog.shadowRoot.querySelector(".title");
+ let message = dialog.shadowRoot.querySelector(".message");
+ let label = dialog.shadowRoot.querySelector(".checkbox-text");
+ let cancelButton = dialog.shadowRoot.querySelector(".cancel-button");
+ let removeAllButton = dialog.shadowRoot.querySelector(".confirm-button");
+ await content.document.l10n.translateElements([
+ title,
+ message,
+ label,
+ cancelButton,
+ removeAllButton,
+ ]);
+ Assert.equal(
+ title.dataset.l10nId,
+ "about-logins-confirm-remove-all-dialog-title",
+ "Title contents should match l10n-id attribute set on element"
+ );
+ Assert.equal(
+ message.dataset.l10nId,
+ "about-logins-confirm-remove-all-dialog-message",
+ "Message contents should match l10n-id attribute set on element"
+ );
+ Assert.equal(
+ label.dataset.l10nId,
+ "about-logins-confirm-remove-all-dialog-checkbox-label",
+ "Label contents should match l10n-id attribute set on outer element"
+ );
+ Assert.equal(
+ cancelButton.dataset.l10nId,
+ "confirmation-dialog-cancel-button",
+ "Cancel button contents should match l10n-id attribute set on outer element"
+ );
+ Assert.equal(
+ removeAllButton.dataset.l10nId,
+ "about-logins-confirm-remove-all-dialog-confirm-button-label",
+ "Remove all button contents should match l10n-id attribute set on outer element"
+ );
+ Assert.equal(
+ JSON.parse(title.dataset.l10nArgs).count,
+ 1,
+ "Title contents should match l10n-args attribute set on element"
+ );
+ Assert.equal(
+ JSON.parse(message.dataset.l10nArgs).count,
+ 1,
+ "Message contents should match l10n-args attribute set on element"
+ );
+ Assert.equal(
+ JSON.parse(label.dataset.l10nArgs).count,
+ 1,
+ "Label contents should match l10n-id attribute set on outer element"
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ dialog.shadowRoot.querySelector(".cancel-button"),
+ {},
+ content
+ );
+ await ContentTaskUtils.waitForCondition(
+ () => dialog.hidden,
+ "Waiting for the dialog to be hidden after clicking cancel button"
+ );
+ });
+});
+
+add_task(async function test_remove_all_dialog_keyboard_navigation() {
+ let browser = gBrowser.selectedBrowser;
+ await openRemoveAllDialog(browser);
+ await SpecialPowers.spawn(browser, [], async () => {
+ const EventUtils = ContentTaskUtils.getEventUtils(content);
+ let dialog = Cu.waiveXrays(
+ content.document.querySelector("remove-logins-dialog")
+ );
+ let cancelButton = dialog.shadowRoot.querySelector(".cancel-button");
+ let removeAllButton = dialog.shadowRoot.querySelector(".confirm-button");
+ Assert.equal(
+ removeAllButton.disabled,
+ true,
+ "Remove all should be disabled on dialog open"
+ );
+ await EventUtils.synthesizeKey(" ", {}, content);
+ Assert.equal(
+ removeAllButton.disabled,
+ false,
+ "Remove all should be enabled when activating the checkbox"
+ );
+ await EventUtils.synthesizeKey(" ", {}, content);
+ Assert.equal(
+ removeAllButton.disabled,
+ true,
+ "Remove all should be disabled after deactivating the checkbox"
+ );
+ await EventUtils.synthesizeKey("KEY_Tab", {}, content);
+ Assert.equal(
+ dialog.shadowRoot.activeElement,
+ cancelButton,
+ "Cancel button should be the next element in tab order"
+ );
+ await EventUtils.synthesizeKey(" ", {}, content);
+ await ContentTaskUtils.waitForCondition(
+ () => dialog.hidden,
+ "Waiting for the dialog to be hidden after activating cancel button via Space key"
+ );
+ });
+ await openRemoveAllDialog(browser);
+ await SpecialPowers.spawn(browser, [], async () => {
+ let dialog = Cu.waiveXrays(
+ content.document.querySelector("remove-logins-dialog")
+ );
+ await EventUtils.synthesizeKey("KEY_Escape", {}, content);
+ await ContentTaskUtils.waitForCondition(
+ () => dialog.hidden,
+ "Waiting for the dialog to be hidden after activating Escape key"
+ );
+ });
+ await openRemoveAllDialog(browser);
+ await SpecialPowers.spawn(browser, [], async () => {
+ let dialog = Cu.waiveXrays(
+ content.document.querySelector("remove-logins-dialog")
+ );
+ let dismissButton = dialog.shadowRoot.querySelector(".dismiss-button");
+ await EventUtils.synthesizeKey("KEY_Tab", { shiftKey: true }, content);
+ Assert.equal(
+ dialog.shadowRoot.activeElement,
+ dismissButton,
+ "dismiss button should be focused"
+ );
+ await EventUtils.synthesizeKey(" ", {}, content);
+ await ContentTaskUtils.waitForCondition(
+ () => dialog.hidden,
+ "Waiting for the dialog to be hidden after activating X button"
+ );
+ });
+});
+
+add_task(async function test_remove_all_dialog_remove_logins() {
+ TEST_LOGIN2 = await addLogin(TEST_LOGIN2);
+ let browser = gBrowser.selectedBrowser;
+ let removeAllPromise = waitForRemoveAllLogins();
+
+ await openRemoveAllDialog(browser);
+ await SpecialPowers.spawn(browser, [], async () => {
+ let dialog = Cu.waiveXrays(
+ content.document.querySelector("remove-logins-dialog")
+ );
+ let title = dialog.shadowRoot.querySelector(".title");
+ let message = dialog.shadowRoot.querySelector(".message");
+ let label = dialog.shadowRoot.querySelector(".checkbox-text");
+ let cancelButton = dialog.shadowRoot.querySelector(".cancel-button");
+ let removeAllButton = dialog.shadowRoot.querySelector(".confirm-button");
+
+ let checkbox = dialog.shadowRoot.querySelector(".checkbox");
+
+ await content.document.l10n.translateElements([
+ title,
+ message,
+ cancelButton,
+ removeAllButton,
+ label,
+ checkbox,
+ ]);
+ Assert.equal(
+ dialog.shadowRoot.activeElement,
+ checkbox,
+ "Checkbox should be the focused element on dialog open"
+ );
+ Assert.equal(
+ title.dataset.l10nId,
+ "about-logins-confirm-remove-all-dialog-title",
+ "Title contents should match l10n-id attribute set on element"
+ );
+ Assert.equal(
+ JSON.parse(title.dataset.l10nArgs).count,
+ 2,
+ "Title contents should match l10n-args attribute set on element"
+ );
+ Assert.equal(
+ message.dataset.l10nId,
+ "about-logins-confirm-remove-all-dialog-message",
+ "Message contents should match l10n-id attribute set on element"
+ );
+ Assert.equal(
+ JSON.parse(message.dataset.l10nArgs).count,
+ 2,
+ "Message contents should match l10n-args attribute set on element"
+ );
+ Assert.equal(
+ label.dataset.l10nId,
+ "about-logins-confirm-remove-all-dialog-checkbox-label",
+ "Label contents should match l10n-id attribute set on outer element"
+ );
+ Assert.equal(
+ JSON.parse(label.dataset.l10nArgs).count,
+ 2,
+ "Label contents should match l10n-id attribute set on outer element"
+ );
+ Assert.equal(
+ cancelButton.dataset.l10nId,
+ "confirmation-dialog-cancel-button",
+ "Cancel button contents should match l10n-id attribute set on outer element"
+ );
+ Assert.equal(
+ removeAllButton.dataset.l10nId,
+ "about-logins-confirm-remove-all-dialog-confirm-button-label",
+ "Remove all button contents should match l10n-id attribute set on outer element"
+ );
+ Assert.equal(
+ removeAllButton.disabled,
+ true,
+ "Remove all button should be disabled on dialog open"
+ );
+ });
+ function activateConfirmCheckbox() {
+ let dialog = window.document.querySelector("remove-logins-dialog");
+ return dialog.shadowRoot.querySelector(".checkbox");
+ }
+
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ activateConfirmCheckbox,
+ {},
+ browser
+ );
+
+ await SpecialPowers.spawn(browser, [], async () => {
+ let dialog = Cu.waiveXrays(
+ content.document.querySelector("remove-logins-dialog")
+ );
+ let removeAllButton = dialog.shadowRoot.querySelector(".confirm-button");
+ Assert.equal(
+ removeAllButton.disabled,
+ false,
+ "Remove all should be enabled after clicking the checkbox"
+ );
+ });
+ function getDialogRemoveAllButton() {
+ let dialog = window.document.querySelector("remove-logins-dialog");
+ return dialog.shadowRoot.querySelector(".confirm-button");
+ }
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ getDialogRemoveAllButton,
+ {},
+ browser
+ );
+ await removeAllPromise;
+ await SpecialPowers.spawn(browser, [], async () => {
+ let loginList = Cu.waiveXrays(content.document.querySelector("login-list"));
+ await ContentTaskUtils.waitForCondition(
+ () => content.document.documentElement.classList.contains("no-logins"),
+ "Waiting for no logins view since all logins should be deleted"
+ );
+ await ContentTaskUtils.waitForCondition(
+ () =>
+ !content.document.documentElement.classList.contains("login-selected"),
+ "Waiting for the FxA Sync illustration to reappear"
+ );
+ await ContentTaskUtils.waitForCondition(
+ () => loginList.classList.contains("no-logins"),
+ "Waiting for login-list to be in no logins view as all logins should be deleted"
+ );
+ });
+ await BrowserTestUtils.synthesizeMouseAtCenter("menu-button", {}, browser);
+ await SpecialPowers.spawn(browser, [], async () => {
+ let menuButton = content.document.querySelector("menu-button");
+ let removeAllMenuButton = menuButton.shadowRoot.querySelector(
+ ".menuitem-remove-all-logins"
+ );
+ Assert.ok(
+ removeAllMenuButton.disabled,
+ "Remove all logins menu button is disabled if there are no logins"
+ );
+ });
+ await SpecialPowers.spawn(browser, [], async () => {
+ let menuButton = Cu.waiveXrays(
+ content.document.querySelector("menu-button")
+ );
+ let menu = menuButton.shadowRoot.querySelector("ul.menu");
+ await EventUtils.synthesizeKey("KEY_Escape", {}, content);
+ await ContentTaskUtils.waitForCondition(
+ () => menu.hidden,
+ "Waiting for menu to close"
+ );
+ });
+});
+
+add_task(async function test_edit_mode_resets_on_remove_all_with_login() {
+ TEST_LOGIN2 = await addLogin(TEST_LOGIN2);
+ let removeAllPromise = waitForRemoveAllLogins();
+ let browser = gBrowser.selectedBrowser;
+ await activateLoginItemEdit(browser);
+ await openRemoveAllDialog(browser);
+ await SpecialPowers.spawn(browser, [], async () => {
+ let loginItem = content.document.querySelector("login-item");
+ Assert.ok(
+ loginItem.dataset.editing,
+ "Login item is still in edit mode when the remove all dialog opens"
+ );
+ });
+ function getDialogCancelButton() {
+ let dialog = window.document.querySelector("remove-logins-dialog");
+ return dialog.shadowRoot.querySelector(".cancel-button");
+ }
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ getDialogCancelButton,
+ {},
+ browser
+ );
+ await TestUtils.waitForTick();
+ await SpecialPowers.spawn(browser, [], async () => {
+ let loginItem = content.document.querySelector("login-item");
+ Assert.ok(
+ loginItem.dataset.editing,
+ "Login item should be in editing mode after activating the cancel button in the remove all dialog"
+ );
+ });
+
+ await openRemoveAllDialog(browser);
+ function activateConfirmCheckbox() {
+ let dialog = window.document.querySelector("remove-logins-dialog");
+ return dialog.shadowRoot.querySelector(".checkbox");
+ }
+
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ activateConfirmCheckbox,
+ {},
+ browser
+ );
+ await SpecialPowers.spawn(browser, [], async () => {
+ let dialog = Cu.waiveXrays(
+ content.document.querySelector("remove-logins-dialog")
+ );
+ let removeAllButton = dialog.shadowRoot.querySelector(".confirm-button");
+ Assert.equal(
+ removeAllButton.disabled,
+ false,
+ "Remove all should be enabled after clicking the checkbox"
+ );
+ });
+ function getDialogRemoveAllButton() {
+ let dialog = window.document.querySelector("remove-logins-dialog");
+ return dialog.shadowRoot.querySelector(".confirm-button");
+ }
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ getDialogRemoveAllButton,
+ {},
+ browser
+ );
+ await TestUtils.waitForTick();
+ await SpecialPowers.spawn(browser, [], async () => {
+ let loginItem = content.document.querySelector("login-item");
+ Assert.ok(
+ !loginItem.dataset.editing,
+ "Login item should not be in editing mode after activating the confirm button in the remove all dialog"
+ );
+ });
+ await removeAllPromise;
+});
+
+add_task(async function test_remove_all_when_creating_new_login() {
+ TEST_LOGIN2 = await addLogin(TEST_LOGIN2);
+ let removeAllPromise = waitForRemoveAllLogins();
+ let browser = gBrowser.selectedBrowser;
+ await activateCreateNewLogin(browser);
+ await openRemoveAllDialog(browser);
+ await SpecialPowers.spawn(browser, [], async () => {
+ let loginItem = content.document.querySelector("login-item");
+ Assert.ok(
+ loginItem.dataset.editing,
+ "Login item should be in edit mode when the remove all dialog opens"
+ );
+ Assert.ok(
+ loginItem.dataset.isNewLogin,
+ "Login item should be in the 'new login' state when the remove all dialog opens"
+ );
+ });
+ function getDialogCancelButton() {
+ let dialog = window.document.querySelector("remove-logins-dialog");
+ return dialog.shadowRoot.querySelector(".cancel-button");
+ }
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ getDialogCancelButton,
+ {},
+ browser
+ );
+ await SpecialPowers.spawn(browser, [], async () => {
+ let loginItem = content.document.querySelector("login-item");
+ Assert.ok(
+ loginItem.dataset.editing,
+ "Login item is still in edit mode after cancelling out of the remove all dialog"
+ );
+ Assert.ok(
+ loginItem.dataset.isNewLogin,
+ "Login item should be in the 'newLogin' state after cancelling out of the remove all dialog"
+ );
+ });
+
+ await openRemoveAllDialog(browser);
+ function activateConfirmCheckbox() {
+ let dialog = window.document.querySelector("remove-logins-dialog");
+ return dialog.shadowRoot.querySelector(".checkbox");
+ }
+
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ activateConfirmCheckbox,
+ {},
+ browser
+ );
+ await SpecialPowers.spawn(browser, [], async () => {
+ let dialog = Cu.waiveXrays(
+ content.document.querySelector("remove-logins-dialog")
+ );
+ let removeAllButton = dialog.shadowRoot.querySelector(".confirm-button");
+ Assert.equal(
+ removeAllButton.disabled,
+ false,
+ "Remove all should be enabled after clicking the checkbox"
+ );
+ });
+ function getDialogRemoveAllButton() {
+ let dialog = window.document.querySelector("remove-logins-dialog");
+ return dialog.shadowRoot.querySelector(".confirm-button");
+ }
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ getDialogRemoveAllButton,
+ {},
+ browser
+ );
+ await SpecialPowers.spawn(browser, [], async () => {
+ let loginItem = content.document.querySelector("login-item");
+ Assert.ok(
+ !loginItem.dataset.editing,
+ "Login item should not be in editing mode after activating the confirm button in the remove all dialog"
+ );
+ Assert.ok(
+ !loginItem.dataset.isNewLogin,
+ "Login item should not be in 'new login' mode after activating the confirm button in the remove all dialog"
+ );
+ });
+ await removeAllPromise;
+});
+
+add_task(async function test_ensure_icons_are_not_draggable() {
+ TEST_LOGIN2 = await addLogin(TEST_LOGIN2);
+ let browser = gBrowser.selectedBrowser;
+ await openRemoveAllDialog(browser);
+ await SpecialPowers.spawn(browser, [], async () => {
+ let dialog = content.document.querySelector("remove-logins-dialog");
+ let warningIcon = dialog.shadowRoot.querySelector(".warning-icon");
+ Assert.ok(!warningIcon.draggable, "Warning icon should not be draggable");
+ let dismissIcon = dialog.shadowRoot.querySelector(".dismiss-icon");
+ Assert.ok(!dismissIcon.draggable, "Dismiss icon should not be draggable");
+ });
+});
diff --git a/browser/components/aboutlogins/tests/browser/browser_sessionRestore.js b/browser/components/aboutlogins/tests/browser/browser_sessionRestore.js
new file mode 100644
index 0000000000..5ab03f9867
--- /dev/null
+++ b/browser/components/aboutlogins/tests/browser/browser_sessionRestore.js
@@ -0,0 +1,62 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+async function checkLoginDisplayed(browser, testGuid) {
+ await SpecialPowers.spawn(browser, [testGuid], async function (guid) {
+ let loginList = Cu.waiveXrays(content.document.querySelector("login-list"));
+ let loginFound = await ContentTaskUtils.waitForCondition(() => {
+ return (
+ loginList._loginGuidsSortedOrder.length == 1 &&
+ loginList._loginGuidsSortedOrder[0] == guid
+ );
+ }, "Waiting for login to be displayed in page");
+ Assert.ok(loginFound, "Confirming that login is displayed in page");
+ });
+}
+
+add_task(async function () {
+ TEST_LOGIN1 = await addLogin(TEST_LOGIN1);
+ registerCleanupFunction(() => {
+ Services.logins.removeAllUserFacingLogins();
+ });
+
+ const testGuid = TEST_LOGIN1.guid;
+ const tab = BrowserTestUtils.addTab(gBrowser, "about:logins");
+ const browser = gBrowser.getBrowserForTab(tab);
+ await BrowserTestUtils.browserLoaded(browser);
+
+ await checkLoginDisplayed(browser, testGuid);
+
+ BrowserTestUtils.removeTab(tab);
+ info("Adding a lazy about:logins tab...");
+ let lazyTab = BrowserTestUtils.addTab(gBrowser, "about:logins", {
+ createLazyBrowser: true,
+ });
+
+ Assert.equal(lazyTab.linkedPanel, "", "Tab is lazy");
+ let tabLoaded = new Promise(resolve => {
+ gBrowser.addTabsProgressListener({
+ async onLocationChange(aBrowser) {
+ if (lazyTab.linkedBrowser == aBrowser) {
+ gBrowser.removeTabsProgressListener(this);
+ await Promise.resolve();
+ resolve();
+ }
+ },
+ });
+ });
+
+ info("Switching tab to cause it to get restored");
+ const browserLoaded = BrowserTestUtils.browserLoaded(lazyTab.linkedBrowser);
+ await BrowserTestUtils.switchTab(gBrowser, lazyTab);
+
+ await tabLoaded;
+ await browserLoaded;
+
+ let lazyBrowser = lazyTab.linkedBrowser;
+ await checkLoginDisplayed(lazyBrowser, testGuid);
+
+ BrowserTestUtils.removeTab(lazyTab);
+});
diff --git a/browser/components/aboutlogins/tests/browser/browser_tabKeyNav.js b/browser/components/aboutlogins/tests/browser/browser_tabKeyNav.js
new file mode 100644
index 0000000000..0305107d23
--- /dev/null
+++ b/browser/components/aboutlogins/tests/browser/browser_tabKeyNav.js
@@ -0,0 +1,276 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+EXPECTED_BREACH = {
+ AddedDate: "2018-12-20T23:56:26Z",
+ BreachDate: "2018-12-16",
+ Domain: "breached.example.com",
+ Name: "Breached",
+ PwnCount: 1643100,
+ DataClasses: ["Email addresses", "Usernames", "Passwords", "IP addresses"],
+ _status: "synced",
+ id: "047940fe-d2fd-4314-b636-b4a952ee0043",
+ last_modified: "1541615610052",
+ schema: "1541615609018",
+};
+
+add_setup(async function () {
+ TEST_LOGIN1 = await addLogin(TEST_LOGIN1);
+ await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ url: "about:logins",
+ });
+ registerCleanupFunction(() => {
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ Services.logins.removeAllUserFacingLogins();
+ });
+});
+
+add_task(async function test_tab_key_nav() {
+ const browser = gBrowser.selectedBrowser;
+ await SpecialPowers.spawn(browser, [], async () => {
+ // Helper function for getting the resulting DOM element given a list of selectors possibly inside shadow DOM
+ const selectWithShadowRootIfNeeded = (document, selectorsArray) =>
+ selectorsArray.reduce(
+ (selectionSoFar, currentSelector) =>
+ selectionSoFar.shadowRoot
+ ? selectionSoFar.shadowRoot.querySelector(currentSelector)
+ : selectionSoFar.querySelector(currentSelector),
+ document
+ );
+
+ const EventUtils = ContentTaskUtils.getEventUtils(content);
+ // list [selector, shadow root selector] for each element
+ // in the order we expect them to be navigated.
+ const expectedElementsInOrder = [
+ ["login-list", "login-filter", "input"],
+ ["login-list", "button.create-login-button"],
+ ["login-list", "select#login-sort"],
+ ["login-list", "ol"],
+ ["login-item", "button.edit-button"],
+ ["login-item", "button.delete-button"],
+ ["login-item", "a.origin-input"],
+ ["login-item", "button.copy-username-button"],
+ ["login-item", "input.reveal-password-checkbox"],
+ ["login-item", "button.copy-password-button"],
+ ];
+
+ const firstElement = selectWithShadowRootIfNeeded(
+ content.document,
+ expectedElementsInOrder.at(0)
+ );
+
+ const lastElement = selectWithShadowRootIfNeeded(
+ content.document,
+ expectedElementsInOrder.at(-1)
+ );
+
+ async function tab() {
+ EventUtils.synthesizeKey("KEY_Tab", {}, content);
+ await new Promise(resolve => content.requestAnimationFrame(resolve));
+ // The following line can help with focus trap debugging:
+ // await new Promise(resolve => content.window.setTimeout(resolve, 500));
+ }
+ async function shiftTab() {
+ EventUtils.synthesizeKey("KEY_Tab", { shiftKey: true }, content);
+ await new Promise(resolve => content.requestAnimationFrame(resolve));
+ // await new Promise(resolve => content.window.setTimeout(resolve, 500));
+ }
+
+ // Getting focused shadow DOM element itself instead of shadowRoot,
+ // using recursion for any component-nesting level, as in:
+ // document.activeElement.shadowRoot.activeElement.shadowRoot.activeElement
+ function getFocusedElement() {
+ let element = content.document.activeElement;
+ const getShadowRootFocus = e => {
+ if (e.shadowRoot) {
+ return getShadowRootFocus(e.shadowRoot.activeElement);
+ }
+ return e;
+ };
+ return getShadowRootFocus(element);
+ }
+
+ // Ensure the test starts in a valid state
+ firstElement.focus();
+ // Assert that we tab navigate correctly
+ for (let expectedSelector of expectedElementsInOrder) {
+ const expectedElement = selectWithShadowRootIfNeeded(
+ content.document,
+ expectedSelector
+ );
+
+ // By default, MacOS will skip over certain text controls, such as links.
+ if (
+ content.window.navigator.platform.toLowerCase().includes("mac") &&
+ expectedElement.tagName === "A"
+ ) {
+ continue;
+ }
+
+ const actualElement = getFocusedElement();
+
+ Assert.equal(
+ actualElement,
+ expectedElement,
+ "Actual focused element should equal the expected focused element"
+ );
+ await tab();
+ }
+
+ lastElement.focus();
+
+ // Assert that we shift + tab navigate correctly starting from the last ordered element
+ for (let expectedSelector of expectedElementsInOrder.reverse()) {
+ const expectedElement = selectWithShadowRootIfNeeded(
+ content.document,
+ expectedSelector
+ );
+ // By default, MacOS will skip over certain text controls, such as links.
+ if (
+ content.window.navigator.platform.toLowerCase().includes("mac") &&
+ expectedElement.tagName === "A"
+ ) {
+ continue;
+ }
+
+ const actualElement = getFocusedElement();
+ Assert.equal(
+ actualElement,
+ expectedElement,
+ "Actual focused element should equal the expected focused element"
+ );
+ await shiftTab();
+ }
+ await tab(); // tab back to the first element
+ });
+});
+
+add_task(async function test_tab_to_create_button() {
+ const browser = gBrowser.selectedBrowser;
+ await SpecialPowers.spawn(browser, [], async () => {
+ const EventUtils = ContentTaskUtils.getEventUtils(content);
+
+ function waitForAnimationFrame() {
+ return new Promise(resolve => content.requestAnimationFrame(resolve));
+ }
+
+ async function tab() {
+ EventUtils.synthesizeKey("KEY_Tab", {}, content);
+ await waitForAnimationFrame();
+ }
+
+ const loginList = content.document.querySelector("login-list");
+ const loginFilter = loginList.shadowRoot.querySelector("login-filter");
+ const loginSort = loginList.shadowRoot.getElementById("login-sort");
+ const loginListbox = loginList.shadowRoot.querySelector("ol");
+ const createButton = loginList.shadowRoot.querySelector(
+ ".create-login-button"
+ );
+
+ const getFocusedElement = () => loginList.shadowRoot.activeElement;
+ Assert.equal(getFocusedElement(), loginFilter, "login-filter is focused");
+
+ await tab();
+ Assert.equal(getFocusedElement(), createButton, "create button is focused");
+
+ await tab();
+ Assert.equal(getFocusedElement(), loginSort, "login sort is focused");
+
+ await tab();
+ Assert.equal(getFocusedElement(), loginListbox, "listbox is focused next");
+
+ await tab();
+ Assert.equal(getFocusedElement(), null, "login-list isn't focused again");
+ });
+});
+
+add_task(async function test_tab_to_edit_button() {
+ TEST_LOGIN3 = await addLogin(TEST_LOGIN3);
+ let browser = gBrowser.selectedBrowser;
+ await SpecialPowers.spawn(
+ browser,
+ [[TEST_LOGIN1.guid, TEST_LOGIN3.guid]],
+ async ([testLoginNormalGuid, testLoginBreachedGuid]) => {
+ const EventUtils = ContentTaskUtils.getEventUtils(content);
+
+ function waitForAnimationFrame() {
+ return new Promise(resolve => content.requestAnimationFrame(resolve));
+ }
+
+ async function tab() {
+ EventUtils.synthesizeKey("KEY_Tab", {}, content);
+ await waitForAnimationFrame();
+ }
+
+ const loginList = content.document.querySelector("login-list");
+ const loginItem = content.document.querySelector("login-item");
+ const loginFilter = loginList.shadowRoot.querySelector("login-filter");
+ const createButton = loginList.shadowRoot.querySelector(
+ ".create-login-button"
+ );
+ const loginSort = loginList.shadowRoot.getElementById("login-sort");
+ const loginListbox = loginList.shadowRoot.querySelector("ol");
+ const editButton = loginItem.shadowRoot.querySelector(".edit-button");
+ const breachAlert = loginItem.shadowRoot.querySelector(".breach-alert");
+ const getFocusedElement = () => {
+ if (content.document.activeElement == loginList) {
+ return loginList.shadowRoot.activeElement;
+ }
+ if (content.document.activeElement == loginItem) {
+ return loginItem.shadowRoot.activeElement;
+ }
+ if (content.document.activeElement == loginFilter) {
+ return loginFilter.shadowRoot.activeElement;
+ }
+ Assert.ok(
+ false,
+ "not expecting a different element to get focused in this test: " +
+ content.document.activeElement.outerHTML
+ );
+ return undefined;
+ };
+
+ for (let guidToSelect of [testLoginNormalGuid, testLoginBreachedGuid]) {
+ let loginListItem = loginList.shadowRoot.querySelector(
+ `.login-list-item[data-guid="${guidToSelect}"]`
+ );
+ loginListItem.click();
+ await ContentTaskUtils.waitForCondition(() => {
+ let waivedLoginItem = Cu.waiveXrays(loginItem);
+ return (
+ waivedLoginItem._login &&
+ waivedLoginItem._login.guid == guidToSelect
+ );
+ }, "waiting for login-item to show the selected login");
+
+ Assert.equal(
+ breachAlert.hidden,
+ guidToSelect == testLoginNormalGuid,
+ ".breach-alert should be hidden if the login is not breached. current login breached? " +
+ (guidToSelect == testLoginBreachedGuid)
+ );
+
+ createButton.focus();
+ Assert.equal(
+ getFocusedElement(),
+ createButton,
+ "create button is focused"
+ );
+
+ await tab();
+ Assert.equal(getFocusedElement(), loginSort, "login sort is focused");
+
+ await tab();
+ Assert.equal(
+ getFocusedElement(),
+ loginListbox,
+ "listbox is focused next"
+ );
+
+ await tab();
+ Assert.equal(getFocusedElement(), editButton, "edit button is focused");
+ }
+ }
+ );
+});
diff --git a/browser/components/aboutlogins/tests/browser/browser_updateLogin.js b/browser/components/aboutlogins/tests/browser/browser_updateLogin.js
new file mode 100644
index 0000000000..efa8bbdd7b
--- /dev/null
+++ b/browser/components/aboutlogins/tests/browser/browser_updateLogin.js
@@ -0,0 +1,421 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const { CONCEALED_PASSWORD_TEXT } = ChromeUtils.importESModule(
+ "chrome://browser/content/aboutlogins/aboutLoginsUtils.mjs"
+);
+
+add_setup(async function () {
+ TEST_LOGIN1 = await addLogin(TEST_LOGIN1);
+ await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ url: "about:logins",
+ });
+ registerCleanupFunction(() => {
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ Services.logins.removeAllUserFacingLogins();
+ });
+});
+
+add_task(async function test_show_logins() {
+ let browser = gBrowser.selectedBrowser;
+ await SpecialPowers.spawn(browser, [TEST_LOGIN1.guid], async loginGuid => {
+ let loginList = Cu.waiveXrays(content.document.querySelector("login-list"));
+ let loginFound = await ContentTaskUtils.waitForCondition(() => {
+ return (
+ loginList._loginGuidsSortedOrder.length == 1 &&
+ loginList._loginGuidsSortedOrder[0] == loginGuid
+ );
+ }, "Waiting for login to be displayed");
+ Assert.ok(
+ loginFound,
+ "Stored logins should be displayed upon loading the page"
+ );
+ });
+});
+
+add_task(async function test_login_item() {
+ if (!OSKeyStoreTestUtils.canTestOSKeyStoreLogin()) {
+ return;
+ }
+
+ async function test_discard_dialog(
+ login,
+ exitPointSelector,
+ concealedPasswordText
+ ) {
+ let loginItem = Cu.waiveXrays(content.document.querySelector("login-item"));
+ let loginList = Cu.waiveXrays(content.document.querySelector("login-list"));
+ await ContentTaskUtils.waitForCondition(
+ () => loginItem.dataset.editing,
+ "Entering edit mode"
+ );
+ await Promise.resolve();
+
+ let usernameInput = loginItem.shadowRoot.querySelector(
+ "input[name='username']"
+ );
+ let passwordInput = loginItem._passwordInput;
+ usernameInput.value += "-undome";
+ passwordInput.value += "-undome";
+
+ let dialog = content.document.querySelector("confirmation-dialog");
+ Assert.ok(dialog.hidden, "Confirm dialog should initially be hidden");
+
+ let exitPoint =
+ loginItem.shadowRoot.querySelector(exitPointSelector) ||
+ loginList.shadowRoot.querySelector(exitPointSelector);
+ exitPoint.click();
+
+ Assert.ok(!dialog.hidden, "Confirm dialog should be visible");
+
+ let confirmDiscardButton =
+ dialog.shadowRoot.querySelector(".confirm-button");
+ await content.document.l10n.translateElements([
+ dialog.shadowRoot.querySelector(".title"),
+ dialog.shadowRoot.querySelector(".message"),
+ confirmDiscardButton,
+ ]);
+
+ confirmDiscardButton.click();
+
+ Assert.ok(
+ dialog.hidden,
+ "Confirm dialog should be hidden after confirming"
+ );
+
+ await Promise.resolve();
+
+ let loginListItem = Cu.waiveXrays(
+ loginList.shadowRoot.querySelector(".login-list-item[data-guid]")
+ );
+
+ loginListItem.click();
+
+ await ContentTaskUtils.waitForCondition(
+ () => usernameInput.value == login.username
+ );
+
+ Assert.equal(
+ usernameInput.value,
+ login.username,
+ "Username change should be reverted"
+ );
+ Assert.equal(
+ passwordInput.value,
+ login.password,
+ "Password change should be reverted"
+ );
+ let passwordDisplayInput = loginItem._passwordDisplayInput;
+ Assert.equal(
+ passwordDisplayInput.value,
+ concealedPasswordText,
+ "Password change should be reverted for display"
+ );
+ Assert.ok(
+ !passwordInput.hasAttribute("value"),
+ "Password shouldn't be exposed in @value"
+ );
+ Assert.equal(
+ passwordInput.style.width,
+ login.password.length + "ch",
+ "Password field width shouldn't have changed"
+ );
+ }
+
+ let browser = gBrowser.selectedBrowser;
+ let reauthObserved = OSKeyStoreTestUtils.waitForOSKeyStoreLogin(true);
+ await SpecialPowers.spawn(
+ browser,
+ [LoginHelper.loginToVanillaObject(TEST_LOGIN1)],
+ async login => {
+ let loginList = Cu.waiveXrays(
+ content.document.querySelector("login-list")
+ );
+ let loginListItem = Cu.waiveXrays(
+ loginList.shadowRoot.querySelector(".login-list-item[data-guid]")
+ );
+ loginListItem.click();
+
+ let loginItem = Cu.waiveXrays(
+ content.document.querySelector("login-item")
+ );
+ let loginItemPopulated = await ContentTaskUtils.waitForCondition(() => {
+ return (
+ loginItem._login.guid == loginListItem.dataset.guid &&
+ loginItem._login.guid == login.guid
+ );
+ }, "Waiting for login item to get populated");
+ Assert.ok(loginItemPopulated, "The login item should get populated");
+
+ let editButton = loginItem.shadowRoot.querySelector(".edit-button");
+ editButton.click();
+ }
+ );
+ info("waiting for oskeystore auth #1");
+ await reauthObserved;
+ await SpecialPowers.spawn(
+ browser,
+ [
+ LoginHelper.loginToVanillaObject(TEST_LOGIN1),
+ ".create-login-button",
+ CONCEALED_PASSWORD_TEXT,
+ ],
+ test_discard_dialog
+ );
+ reauthObserved = forceAuthTimeoutAndWaitForOSKeyStoreLogin({
+ loginResult: true,
+ });
+ await SpecialPowers.spawn(browser, [], async () => {
+ let loginItem = Cu.waiveXrays(content.document.querySelector("login-item"));
+ let editButton = loginItem.shadowRoot.querySelector(".edit-button");
+ editButton.click();
+ });
+ info("waiting for oskeystore auth #2");
+ await reauthObserved;
+ await SpecialPowers.spawn(
+ browser,
+ [
+ LoginHelper.loginToVanillaObject(TEST_LOGIN1),
+ ".cancel-button",
+ CONCEALED_PASSWORD_TEXT,
+ ],
+ test_discard_dialog
+ );
+ reauthObserved = forceAuthTimeoutAndWaitForOSKeyStoreLogin({
+ loginResult: true,
+ });
+ await SpecialPowers.spawn(browser, [], async () => {
+ let loginItem = Cu.waiveXrays(content.document.querySelector("login-item"));
+ let editButton = loginItem.shadowRoot.querySelector(".edit-button");
+ editButton.click();
+ });
+ info("waiting for oskeystore auth #3");
+ await reauthObserved;
+ await SpecialPowers.spawn(
+ browser,
+ [LoginHelper.loginToVanillaObject(TEST_LOGIN1), CONCEALED_PASSWORD_TEXT],
+ async (login, concealedPasswordText) => {
+ let loginItem = Cu.waiveXrays(
+ content.document.querySelector("login-item")
+ );
+ await ContentTaskUtils.waitForCondition(
+ () => loginItem.dataset.editing,
+ "Entering edit mode"
+ );
+ await Promise.resolve();
+ let usernameInput = loginItem.shadowRoot.querySelector(
+ "input[name='username']"
+ );
+ let passwordInput = loginItem._passwordInput;
+ let passwordDisplayInput = loginItem._passwordDisplayInput;
+
+ Assert.ok(
+ loginItem.dataset.editing,
+ "LoginItem should be in 'edit' mode"
+ );
+ Assert.equal(
+ passwordInput.type,
+ "password",
+ "Password should still be hidden before revealed in edit mode"
+ );
+
+ passwordDisplayInput.focus();
+
+ let revealCheckbox = loginItem.shadowRoot.querySelector(
+ ".reveal-password-checkbox"
+ );
+ Assert.ok(
+ revealCheckbox.checked,
+ "reveal-checkbox should be checked when password input is focused"
+ );
+
+ Assert.equal(
+ passwordInput.type,
+ "text",
+ "Password should be shown as text when focused"
+ );
+ let saveChangesButton = loginItem.shadowRoot.querySelector(
+ ".save-changes-button"
+ );
+
+ saveChangesButton.click();
+
+ await ContentTaskUtils.waitForCondition(() => {
+ let editButton = loginItem.shadowRoot.querySelector(".edit-button");
+ return !editButton.disabled;
+ }, "Waiting to exit edit mode");
+
+ Assert.ok(
+ !revealCheckbox.checked,
+ "reveal-checkbox should be unchecked after saving changes"
+ );
+ Assert.ok(
+ !loginItem.dataset.editing,
+ "LoginItem should not be in 'edit' mode after saving"
+ );
+ Assert.equal(
+ passwordInput.type,
+ "password",
+ "Password should be hidden after exiting edit mode"
+ );
+ Assert.equal(
+ usernameInput.value,
+ login.username,
+ "Username change should be reverted"
+ );
+ Assert.equal(
+ passwordInput.value,
+ login.password,
+ "Password change should be reverted"
+ );
+ Assert.equal(
+ passwordDisplayInput.value,
+ concealedPasswordText,
+ "Password change should be reverted for display"
+ );
+ Assert.ok(
+ !passwordInput.hasAttribute("value"),
+ "Password shouldn't be exposed in @value"
+ );
+ Assert.equal(
+ passwordInput.style.width,
+ login.password.length + "ch",
+ "Password field width shouldn't have changed"
+ );
+ }
+ );
+ reauthObserved = forceAuthTimeoutAndWaitForOSKeyStoreLogin({
+ loginResult: true,
+ });
+ await SpecialPowers.spawn(browser, [], async () => {
+ let loginItem = Cu.waiveXrays(content.document.querySelector("login-item"));
+ let editButton = loginItem.shadowRoot.querySelector(".edit-button");
+ editButton.click();
+ });
+ info("waiting for oskeystore auth #4");
+ await reauthObserved;
+ await SpecialPowers.spawn(
+ browser,
+ [LoginHelper.loginToVanillaObject(TEST_LOGIN1)],
+ async login => {
+ let loginItem = Cu.waiveXrays(
+ content.document.querySelector("login-item")
+ );
+ await ContentTaskUtils.waitForCondition(
+ () => loginItem.dataset.editing,
+ "Entering edit mode"
+ );
+ await Promise.resolve();
+
+ let revealCheckbox = loginItem.shadowRoot.querySelector(
+ ".reveal-password-checkbox"
+ );
+ revealCheckbox.click();
+ Assert.ok(
+ revealCheckbox.checked,
+ "reveal-checkbox should be checked after clicking"
+ );
+
+ let usernameInput = loginItem.shadowRoot.querySelector(
+ "input[name='username']"
+ );
+ let passwordInput = loginItem._passwordInput;
+
+ usernameInput.value += "-saveme";
+ passwordInput.value += "-saveme";
+
+ Assert.ok(
+ loginItem.dataset.editing,
+ "LoginItem should be in 'edit' mode"
+ );
+
+ let saveChangesButton = loginItem.shadowRoot.querySelector(
+ ".save-changes-button"
+ );
+ saveChangesButton.click();
+
+ await ContentTaskUtils.waitForCondition(() => {
+ let loginList = Cu.waiveXrays(
+ content.document.querySelector("login-list")
+ );
+ let guid = loginList._loginGuidsSortedOrder[0];
+ let updatedLogin = loginList._logins[guid].login;
+ return (
+ updatedLogin &&
+ updatedLogin.username == usernameInput.value &&
+ updatedLogin.password == passwordInput.value
+ );
+ }, "Waiting for corresponding login in login list to update");
+
+ Assert.ok(
+ !revealCheckbox.checked,
+ "reveal-checkbox should be unchecked after saving changes"
+ );
+ Assert.ok(
+ !loginItem.dataset.editing,
+ "LoginItem should not be in 'edit' mode after saving"
+ );
+ Assert.equal(
+ passwordInput.style.width,
+ passwordInput.value.length + "ch",
+ "Password field width should be correctly updated"
+ );
+ }
+ );
+ reauthObserved = forceAuthTimeoutAndWaitForOSKeyStoreLogin({
+ loginResult: true,
+ });
+ await SpecialPowers.spawn(browser, [], async () => {
+ let loginItem = Cu.waiveXrays(content.document.querySelector("login-item"));
+ let editButton = loginItem.shadowRoot.querySelector(".edit-button");
+ editButton.click();
+ });
+ info("waiting for oskeystore auth #5");
+ await reauthObserved;
+ await SpecialPowers.spawn(
+ browser,
+ [LoginHelper.loginToVanillaObject(TEST_LOGIN1)],
+ async login => {
+ let loginItem = Cu.waiveXrays(
+ content.document.querySelector("login-item")
+ );
+ await ContentTaskUtils.waitForCondition(
+ () => loginItem.dataset.editing,
+ "Entering edit mode"
+ );
+ await Promise.resolve();
+
+ Assert.ok(
+ loginItem.dataset.editing,
+ "LoginItem should be in 'edit' mode"
+ );
+ let deleteButton = loginItem.shadowRoot.querySelector(".delete-button");
+ deleteButton.click();
+ let confirmDeleteDialog = Cu.waiveXrays(
+ content.document.querySelector("confirmation-dialog")
+ );
+ let confirmDeleteButton =
+ confirmDeleteDialog.shadowRoot.querySelector(".confirm-button");
+ confirmDeleteButton.click();
+
+ let loginList = Cu.waiveXrays(
+ content.document.querySelector("login-list")
+ );
+ let loginListItem = Cu.waiveXrays(
+ loginList.shadowRoot.querySelector(".login-list-item[data-guid]")
+ );
+ await ContentTaskUtils.waitForCondition(() => {
+ loginListItem = loginList.shadowRoot.querySelector(
+ ".login-list-item[data-guid]"
+ );
+ return !loginListItem;
+ }, "Waiting for login to be removed from list");
+
+ Assert.ok(
+ !loginItem.dataset.editing,
+ "LoginItem should not be in 'edit' mode after deleting"
+ );
+ }
+ );
+});
diff --git a/browser/components/aboutlogins/tests/browser/browser_vulnerableLoginAddedInSecondaryWindow.js b/browser/components/aboutlogins/tests/browser/browser_vulnerableLoginAddedInSecondaryWindow.js
new file mode 100644
index 0000000000..fac3e91af4
--- /dev/null
+++ b/browser/components/aboutlogins/tests/browser/browser_vulnerableLoginAddedInSecondaryWindow.js
@@ -0,0 +1,223 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+EXPECTED_BREACH = {
+ AddedDate: "2018-12-20T23:56:26Z",
+ BreachDate: "2018-12-16",
+ Domain: "breached.example.com",
+ Name: "Breached",
+ PwnCount: 1643100,
+ DataClasses: ["Email addresses", "Usernames", "Passwords", "IP addresses"],
+ _status: "synced",
+ id: "047940fe-d2fd-4314-b636-b4a952ee0043",
+ last_modified: "1541615610052",
+ schema: "1541615609018",
+};
+
+let tabInSecondWindow;
+
+add_setup(async function () {
+ TEST_LOGIN1 = await addLogin(TEST_LOGIN1);
+ TEST_LOGIN2 = await addLogin(TEST_LOGIN2);
+ TEST_LOGIN3 = await addLogin(TEST_LOGIN3);
+
+ let breaches = await LoginBreaches.getPotentialBreachesByLoginGUID([
+ TEST_LOGIN3,
+ ]);
+ Assert.ok(breaches.size, "TEST_LOGIN3 should be marked as breached");
+
+ // Remove the breached login so the 'alerts' option
+ // is hidden when opening about:logins.
+ Services.logins.removeLogin(TEST_LOGIN3);
+
+ await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ url: "about:logins",
+ });
+
+ let newWin = await BrowserTestUtils.openNewBrowserWindow();
+ tabInSecondWindow = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser: newWin.gBrowser,
+ url: "about:logins",
+ });
+
+ registerCleanupFunction(async () => {
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ Services.logins.removeAllUserFacingLogins();
+ await BrowserTestUtils.closeWindow(newWin);
+ });
+});
+
+add_task(async function test_new_login_marked_vulnerable_in_both_windows() {
+ const ORIGIN_FOR_NEW_VULNERABLE_LOGIN = "https://vulnerable";
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => {
+ let loginList = Cu.waiveXrays(content.document.querySelector("login-list"));
+ Assert.ok(
+ loginList.shadowRoot.querySelector("#login-sort").namedItem("alerts")
+ .hidden,
+ "The 'alerts' option should be hidden before adding a vulnerable login to the list"
+ );
+ });
+
+ await SpecialPowers.spawn(
+ tabInSecondWindow.linkedBrowser,
+ [[TEST_LOGIN3.password, ORIGIN_FOR_NEW_VULNERABLE_LOGIN]],
+ async ([passwordOfBreachedAccount, originForNewVulnerableLogin]) => {
+ let loginList = content.document.querySelector("login-list");
+
+ await ContentTaskUtils.waitForCondition(
+ () =>
+ loginList.shadowRoot.querySelectorAll(".login-list-item[data-guid]")
+ .length == 2,
+ "waiting for all two initials logins to get added to login-list"
+ );
+
+ let loginSort = loginList.shadowRoot.querySelector("#login-sort");
+ Assert.ok(
+ loginSort.namedItem("alerts").hidden,
+ "The 'alerts' option should be hidden when there are no breached or vulnerable logins in the list"
+ );
+
+ let createButton = loginList.shadowRoot.querySelector(
+ ".create-login-button"
+ );
+ createButton.click();
+
+ let loginItem = content.document.querySelector("login-item");
+ await ContentTaskUtils.waitForCondition(
+ () => loginItem.dataset.isNewLogin,
+ "waiting for create login form to be visible"
+ );
+
+ let originInput = loginItem.shadowRoot.querySelector(
+ "input[name='origin']"
+ );
+ originInput.value = originForNewVulnerableLogin;
+ let passwordInput = loginItem.shadowRoot.querySelector(
+ "input[name='password']"
+ );
+ passwordInput.value = passwordOfBreachedAccount;
+
+ let saveButton = loginItem.shadowRoot.querySelector(
+ ".save-changes-button"
+ );
+ saveButton.click();
+
+ await ContentTaskUtils.waitForCondition(
+ () =>
+ loginList.shadowRoot.querySelectorAll(".login-list-item[data-guid]")
+ .length == 3,
+ "waiting for new login to get added to login-list"
+ );
+
+ let vulnerableLoginGuid = Cu.waiveXrays(loginItem)._login.guid;
+ let vulnerableListItem = loginList.shadowRoot.querySelector(
+ `.login-list-item[data-guid="${vulnerableLoginGuid}"]`
+ );
+
+ Assert.ok(
+ vulnerableListItem.classList.contains("vulnerable"),
+ "vulnerable login list item should be marked as such"
+ );
+ Assert.ok(
+ !loginItem.shadowRoot.querySelector(".vulnerable-alert").hidden,
+ "vulnerable alert on login-item should be visible"
+ );
+
+ Assert.ok(
+ !loginSort.namedItem("alerts").hidden,
+ "The 'alerts' option should be visible after adding a vulnerable login to the list"
+ );
+ }
+ );
+ console.log("xxxxxxx ---- 0");
+
+ tabInSecondWindow.linkedBrowser.reload();
+ await BrowserTestUtils.browserLoaded(
+ tabInSecondWindow.linkedBrowser,
+ false,
+ url => url.includes("about:logins")
+ );
+
+ console.log("xxxxxxx ---- 1");
+
+ await SpecialPowers.spawn(tabInSecondWindow.linkedBrowser, [], async () => {
+ let loginList = content.document.querySelector("login-list");
+ let loginSort = loginList.shadowRoot.querySelector("#login-sort");
+
+ await ContentTaskUtils.waitForCondition(
+ () => loginSort.value == "alerts",
+ "waiting for sort to get updated to 'alerts'"
+ );
+
+ Assert.equal(
+ loginSort.value,
+ "alerts",
+ "The login list should be sorted by Alerts"
+ );
+ let loginListItems = loginList.shadowRoot.querySelectorAll(
+ ".login-list-item[data-guid]"
+ );
+ for (let i = 1; i < loginListItems.length; i++) {
+ if (loginListItems[i].matches(".vulnerable, .breached")) {
+ Assert.ok(
+ loginListItems[i - 1].matches(".vulnerable, .breached"),
+ `The previous login-list-item must be vulnerable or breached if the current one is (second window, i=${i})`
+ );
+ }
+ }
+ });
+ console.log("xxxxxxx ---- 2");
+
+ await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [ORIGIN_FOR_NEW_VULNERABLE_LOGIN],
+ async originForNewVulnerableLogin => {
+ let loginList = Cu.waiveXrays(
+ content.document.querySelector("login-list")
+ );
+ let vulnerableListItem;
+ await ContentTaskUtils.waitForCondition(() => {
+ let entry = Object.entries(loginList._logins).find(
+ ([guid, { login, listItem }]) =>
+ login.origin == originForNewVulnerableLogin
+ );
+ vulnerableListItem = entry[1].listItem;
+ return !!entry;
+ }, "waiting for vulnerable list item to get added to login-list");
+ Assert.ok(
+ vulnerableListItem.classList.contains("vulnerable"),
+ "vulnerable login list item should be marked as such"
+ );
+
+ Assert.ok(
+ !loginList.shadowRoot.querySelector("#login-sort").namedItem("alerts")
+ .hidden,
+ "The 'alerts' option should be visible after adding a vulnerable login to the list"
+ );
+ }
+ );
+ gBrowser.selectedBrowser.reload();
+ await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser, false, url =>
+ url.includes("about:logins")
+ );
+
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => {
+ let loginList = Cu.waiveXrays(content.document.querySelector("login-list"));
+ await ContentTaskUtils.waitForCondition(
+ () => loginList.shadowRoot.querySelector("#login-sort").value == "alerts",
+ "waiting for sort to get updated to 'alerts'"
+ );
+ let loginListItems = loginList.shadowRoot.querySelectorAll(
+ ".login-list-item[data-guid]"
+ );
+ for (let i = 1; i < loginListItems.length; i++) {
+ if (loginListItems[i].matches(".vulnerable, .breached")) {
+ Assert.ok(
+ loginListItems[i - 1].matches(".vulnerable, .breached"),
+ `The previous login-list-item must be vulnerable or breached if the current one is (first window, i=${i})`
+ );
+ }
+ }
+ });
+});
diff --git a/browser/components/aboutlogins/tests/browser/head.js b/browser/components/aboutlogins/tests/browser/head.js
new file mode 100644
index 0000000000..2aec0e632a
--- /dev/null
+++ b/browser/components/aboutlogins/tests/browser/head.js
@@ -0,0 +1,225 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+let { LoginBreaches } = ChromeUtils.importESModule(
+ "resource:///modules/LoginBreaches.sys.mjs"
+);
+let { RemoteSettings } = ChromeUtils.importESModule(
+ "resource://services-settings/remote-settings.sys.mjs"
+);
+let { _AboutLogins } = ChromeUtils.importESModule(
+ "resource:///actors/AboutLoginsParent.sys.mjs"
+);
+let { OSKeyStoreTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/OSKeyStoreTestUtils.sys.mjs"
+);
+var { LoginTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/LoginTestUtils.sys.mjs"
+);
+
+let nsLoginInfo = new Components.Constructor(
+ "@mozilla.org/login-manager/loginInfo;1",
+ Ci.nsILoginInfo,
+ "init"
+);
+
+let TEST_LOGIN1 = new nsLoginInfo(
+ "https://example.com",
+ "https://example.com",
+ null,
+ "user1",
+ "pass1",
+ "username",
+ "password"
+);
+let TEST_LOGIN2 = new nsLoginInfo(
+ "https://2.example.com",
+ "https://2.example.com",
+ null,
+ "user2",
+ "pass2",
+ "username",
+ "password"
+);
+
+let TEST_LOGIN3 = new nsLoginInfo(
+ "https://breached.example.com",
+ "https://breached.example.com",
+ null,
+ "breachedLogin1",
+ "pass3",
+ "breachedLogin",
+ "password"
+);
+TEST_LOGIN3.QueryInterface(Ci.nsILoginMetaInfo).timePasswordChanged = 123456;
+
+async function addLogin(login) {
+ const result = await Services.logins.addLoginAsync(login);
+ registerCleanupFunction(() => {
+ let matchData = Cc["@mozilla.org/hash-property-bag;1"].createInstance(
+ Ci.nsIWritablePropertyBag2
+ );
+ matchData.setPropertyAsAUTF8String("guid", result.guid);
+
+ let logins = Services.logins.searchLogins(matchData);
+ if (!logins.length) {
+ return;
+ }
+ // Use the login that was returned from searchLogins
+ // in case the initial login object was changed by the test code,
+ // since removeLogin makes sure that the login argument exactly
+ // matches the login that it will be removing.
+ Services.logins.removeLogin(logins[0]);
+ });
+ return result;
+}
+
+let EXPECTED_BREACH = null;
+let EXPECTED_ERROR_MESSAGE = null;
+add_setup(async function setup_head() {
+ const db = RemoteSettings(LoginBreaches.REMOTE_SETTINGS_COLLECTION).db;
+ if (EXPECTED_BREACH) {
+ await db.create(EXPECTED_BREACH, {
+ useRecordId: true,
+ });
+ }
+ await db.importChanges({}, Date.now());
+ if (EXPECTED_BREACH) {
+ await RemoteSettings(LoginBreaches.REMOTE_SETTINGS_COLLECTION).emit(
+ "sync",
+ { data: { current: [EXPECTED_BREACH] } }
+ );
+ }
+
+ SpecialPowers.registerConsoleListener(function onConsoleMessage(msg) {
+ if (msg.isWarning || !msg.errorMessage) {
+ // Ignore warnings and non-errors.
+ return;
+ }
+
+ if (msg.errorMessage.includes('Unknown event: ["jsonfile", "load"')) {
+ // Ignore telemetry errors from JSONFile.sys.mjs.
+ return;
+ }
+
+ if (
+ msg.errorMessage == "Refreshing device list failed." ||
+ msg.errorMessage == "Skipping device list refresh; not signed in"
+ ) {
+ // Ignore errors from browser-sync.js.
+ return;
+ }
+ if (
+ msg.errorMessage.includes(
+ "ReferenceError: MigrationWizard is not defined"
+ )
+ ) {
+ // todo(Bug 1587237): Ignore error when loading the Migration Wizard in automation.
+ return;
+ }
+ if (
+ msg.errorMessage.includes("Error detecting Chrome profiles") ||
+ msg.errorMessage.includes(
+ "Library/Application Support/Chromium/Local State (No such file or directory)"
+ ) ||
+ msg.errorMessage.includes(
+ "Library/Application Support/Google/Chrome/Local State (No such file or directory)"
+ )
+ ) {
+ // Ignore errors that can occur when the migrator is looking for a
+ // Chrome/Chromium profile
+ return;
+ }
+ if (msg.errorMessage.includes("Can't find profile directory.")) {
+ // Ignore error messages for no profile found in old XULStore.jsm
+ return;
+ }
+ if (msg.errorMessage.includes("Error reading typed URL history")) {
+ // The Migrator when opened can log this exception if there is no Edge
+ // history on the machine.
+ return;
+ }
+ if (msg.errorMessage.includes(EXPECTED_ERROR_MESSAGE)) {
+ return;
+ }
+ if (msg.errorMessage == "FILE_FORMAT_ERROR") {
+ // Ignore errors handled by the error message dialog.
+ return;
+ }
+ if (
+ msg.errorMessage ==
+ "NotFoundError: No such JSWindowActor 'MarionetteEvents'"
+ ) {
+ // Ignore MarionetteEvents error (Bug 1730837, Bug 1710079).
+ return;
+ }
+ Assert.ok(false, msg.message || msg.errorMessage);
+ });
+
+ registerCleanupFunction(async () => {
+ EXPECTED_ERROR_MESSAGE = null;
+ await db.clear();
+ Services.telemetry.clearEvents();
+ SpecialPowers.postConsoleSentinel();
+ });
+});
+
+/**
+ * Waits for the primary password prompt and performs an action.
+ * @param {string} action Set to "authenticate" to log in or "cancel" to
+ * close the dialog without logging in.
+ */
+function waitForMPDialog(action, aWindow = window) {
+ const BRAND_BUNDLE = Services.strings.createBundle(
+ "chrome://branding/locale/brand.properties"
+ );
+ const BRAND_FULL_NAME = BRAND_BUNDLE.GetStringFromName("brandFullName");
+ let dialogShown = TestUtils.topicObserved("common-dialog-loaded");
+ return dialogShown.then(function ([subject]) {
+ let dialog = subject.Dialog;
+ let expected = "Password Required - " + BRAND_FULL_NAME;
+ Assert.equal(
+ dialog.args.title,
+ expected,
+ "Dialog is the Primary Password dialog"
+ );
+ if (action == "authenticate") {
+ SpecialPowers.wrap(dialog.ui.password1Textbox).setUserInput(
+ LoginTestUtils.primaryPassword.primaryPassword
+ );
+ dialog.ui.button0.click();
+ } else if (action == "cancel") {
+ dialog.ui.button1.click();
+ }
+ return BrowserTestUtils.waitForEvent(aWindow, "DOMModalDialogClosed");
+ });
+}
+
+/**
+ * Allows for tests to reset the MP auth expiration and
+ * return a promise that will resolve after the MP dialog has
+ * been presented.
+ *
+ * @param {string} action Set to "authenticate" to log in or "cancel" to
+ * close the dialog without logging in.
+ * @returns {Promise} Resolves after the MP dialog has been presented and actioned upon
+ */
+function forceAuthTimeoutAndWaitForMPDialog(action, aWindow = window) {
+ const AUTH_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes (duplicated from AboutLoginsParent.sys.mjs)
+ _AboutLogins._authExpirationTime -= AUTH_TIMEOUT_MS + 1;
+ return waitForMPDialog(action, aWindow);
+}
+
+/**
+ * Allows for tests to reset the OS auth expiration and
+ * return a promise that will resolve after the OS auth dialog has
+ * been presented.
+ *
+ * @param {bool} loginResult True if the auth prompt should pass, otherwise false will fail
+ * @returns {Promise} Resolves after the OS auth dialog has been presented
+ */
+function forceAuthTimeoutAndWaitForOSKeyStoreLogin({ loginResult }) {
+ const AUTH_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes (duplicated from AboutLoginsParent.sys.mjs)
+ _AboutLogins._authExpirationTime -= AUTH_TIMEOUT_MS + 1;
+ return OSKeyStoreTestUtils.waitForOSKeyStoreLogin(loginResult);
+}
diff --git a/browser/components/aboutlogins/tests/chrome/.eslintrc.js b/browser/components/aboutlogins/tests/chrome/.eslintrc.js
new file mode 100644
index 0000000000..9b6510bdd2
--- /dev/null
+++ b/browser/components/aboutlogins/tests/chrome/.eslintrc.js
@@ -0,0 +1,16 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+module.exports = {
+ overrides: [
+ {
+ files: ["test_login_item.html"],
+ parserOptions: {
+ sourceType: "module",
+ },
+ },
+ ],
+};
diff --git a/browser/components/aboutlogins/tests/chrome/aboutlogins_common.js b/browser/components/aboutlogins/tests/chrome/aboutlogins_common.js
new file mode 100644
index 0000000000..d24c962da0
--- /dev/null
+++ b/browser/components/aboutlogins/tests/chrome/aboutlogins_common.js
@@ -0,0 +1,97 @@
+"use strict";
+
+/* exported asyncElementRendered, importDependencies */
+
+/**
+ * A helper to await on while waiting for an asynchronous rendering of a Custom
+ * Element.
+ * @returns {Promise}
+ */
+function asyncElementRendered() {
+ return Promise.resolve();
+}
+
+/**
+ * Import the templates from the real page to avoid duplication in the tests.
+ * @param {HTMLIFrameElement} templateFrame - Frame to copy the resources from
+ * @param {HTMLElement} destinationEl - Where to append the copied resources
+ */
+function importDependencies(templateFrame, destinationEl) {
+ let promises = [];
+ for (let template of templateFrame.contentDocument.querySelectorAll(
+ "template"
+ )) {
+ let imported = document.importNode(template, true);
+ destinationEl.appendChild(imported);
+ // Preload the styles in the actual page, to ensure they're loaded on time.
+ for (let element of imported.content.querySelectorAll(
+ "link[rel='stylesheet']"
+ )) {
+ let clone = element.cloneNode(true);
+ promises.push(
+ new Promise(resolve => {
+ clone.onload = function () {
+ resolve();
+ clone.remove();
+ };
+ })
+ );
+ destinationEl.appendChild(clone);
+ }
+ }
+ return Promise.all(promises);
+}
+
+Object.defineProperty(document, "l10n", {
+ configurable: true,
+ writable: true,
+ value: {
+ connectRoot() {},
+ translateElements() {
+ return Promise.resolve();
+ },
+ getAttributes(element) {
+ return {
+ id: element.getAttribute("data-l10n-id"),
+ args: element.getAttribute("data-l10n-args")
+ ? JSON.parse(element.getAttribute("data-l10n-args"))
+ : {},
+ };
+ },
+ setAttributes(element, id, args) {
+ element.setAttribute("data-l10n-id", id);
+ if (args) {
+ element.setAttribute("data-l10n-args", JSON.stringify(args));
+ } else {
+ element.removeAttribute("data-l10n-args");
+ }
+ },
+ },
+});
+
+Object.defineProperty(window, "AboutLoginsUtils", {
+ configurable: true,
+ writable: true,
+ value: {
+ getLoginOrigin(uriString) {
+ return uriString;
+ },
+ setFocus(element) {
+ return element.focus();
+ },
+ async promptForPrimaryPassword(resolve, messageId) {
+ resolve(true);
+ },
+ doLoginsMatch(login1, login2) {
+ return (
+ login1.origin == login2.origin &&
+ login1.username == login2.username &&
+ login1.password == login2.password
+ );
+ },
+ fileImportEnabled: SpecialPowers.getBoolPref(
+ "signon.management.page.fileImport.enabled"
+ ),
+ primaryPasswordEnabled: false,
+ },
+});
diff --git a/browser/components/aboutlogins/tests/chrome/chrome.ini b/browser/components/aboutlogins/tests/chrome/chrome.ini
new file mode 100644
index 0000000000..ac1ba7076c
--- /dev/null
+++ b/browser/components/aboutlogins/tests/chrome/chrome.ini
@@ -0,0 +1,13 @@
+[DEFAULT]
+scheme = https
+prefs =
+ identity.fxaccounts.enabled=true
+support-files =
+ aboutlogins_common.js
+
+[test_confirm_delete_dialog.html]
+[test_fxaccounts_button.html]
+[test_login_filter.html]
+[test_login_item.html]
+[test_login_list.html]
+[test_menu_button.html]
diff --git a/browser/components/aboutlogins/tests/chrome/test_confirm_delete_dialog.html b/browser/components/aboutlogins/tests/chrome/test_confirm_delete_dialog.html
new file mode 100644
index 0000000000..68a58aee4f
--- /dev/null
+++ b/browser/components/aboutlogins/tests/chrome/test_confirm_delete_dialog.html
@@ -0,0 +1,127 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test the confirmation-dialog component
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test the confirmation-dialog component</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script>
+ <script type="module" src="chrome://browser/content/aboutlogins/components/confirmation-dialog.mjs"></script>
+ <script src="aboutlogins_common.js"></script>
+
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+ <p id="display">
+ </p>
+<div id="content" style="display: none">
+ <iframe id="templateFrame" src="chrome://browser/content/aboutlogins/aboutLogins.html"
+ sandbox="allow-same-origin"></iframe>
+</div>
+<pre id="test">
+</pre>
+<script>
+/** Test the confirmation-dialog component **/
+
+let options = {
+ title: "confirm-delete-dialog-title",
+ message: "confirm-delete-dialog-message",
+ confirmButtonLabel: "confirm-delete-dialog-confirm-button"
+};
+let cancelButton, confirmButton, gConfirmationDialog;
+add_setup(async () => {
+ let templateFrame = document.getElementById("templateFrame");
+ let displayEl = document.getElementById("display");
+ await importDependencies(templateFrame, displayEl);
+
+ gConfirmationDialog = document.createElement("confirmation-dialog");
+ displayEl.appendChild(gConfirmationDialog);
+ ok(gConfirmationDialog, "The dialog should exist");
+
+ cancelButton = gConfirmationDialog.shadowRoot.querySelector(".cancel-button");
+ confirmButton = gConfirmationDialog.shadowRoot.querySelector(".confirm-button");
+ ok(cancelButton, "The cancel button should exist");
+ ok(confirmButton, "The confirm button should exist");
+});
+
+add_task(async function test_escape_key_to_cancel() {
+ gConfirmationDialog.show(options);
+ ok(!gConfirmationDialog.hidden, "The dialog should be visible");
+ sendKey("ESCAPE");
+ ok(gConfirmationDialog.hidden, "The dialog should be hidden after hitting Escape");
+ gConfirmationDialog.hide();
+});
+
+add_task(async function test_initial_focus() {
+ gConfirmationDialog.show(options);
+ ok(!gConfirmationDialog.hidden, "The dialog should be visible");
+ is(gConfirmationDialog.shadowRoot.activeElement, confirmButton,
+ "After initially opening the dialog, the confirm button should be focused");
+ gConfirmationDialog.hide();
+});
+
+add_task(async function test_tab_focus() {
+ gConfirmationDialog.show(options);
+ ok(!gConfirmationDialog.hidden, "The dialog should be visible");
+ sendKey("TAB");
+ is(gConfirmationDialog.shadowRoot.activeElement, cancelButton,
+ "After opening the dialog and tabbing once, the cancel button should be focused");
+ gConfirmationDialog.hide();
+});
+
+add_task(async function test_enter_key_to_cancel() {
+ let showPromise = gConfirmationDialog.show(options);
+ ok(!gConfirmationDialog.hidden, "The dialog should be visible");
+ sendKey("RETURN");
+ try {
+ await showPromise;
+ ok(true, "The dialog Promise should resolve after hitting Return with the confirm button focused");
+ } catch (ex) {
+ ok(false, "The dialog Promise should not reject after hitting Return with the confirm button focused");
+ }
+});
+
+add_task(async function test_enter_key_to_confirm() {
+ let showPromise = gConfirmationDialog.show(options);
+ ok(!gConfirmationDialog.hidden, "The dialog should be visible");
+ sendKey("TAB");
+ sendKey("RETURN");
+ try {
+ await showPromise;
+ ok(false, "The dialog Promise should not resolve after hitting Return with the cancel button focused");
+ } catch (ex) {
+ ok(true, "The dialog Promise should reject after hitting Return with the cancel button focused");
+ }
+});
+
+add_task(async function test_dialog_focus_trap() {
+ let displayEl = document.getElementById("display");
+ let displayElChildSpan = document.createElement("span");
+ displayElChildSpan.tabIndex = 0;
+ displayElChildSpan.id = "display-child";
+ displayEl.appendChild(displayElChildSpan);
+
+ gConfirmationDialog.show(options);
+
+ ok(!gConfirmationDialog.hidden, "The dialog should be visible");
+ ok(displayElChildSpan.tabIndex === -1, "The tabIndex value for elements with a hardcoded tabIndex attribute should be reset to '-1'.")
+ ok(displayElChildSpan.dataset.oldTabIndex === "0", "Existing tabIndex values should be stored in `dataset.oldTabIndex`.")
+
+ const isActiveElemDialogOrHTMLorBODY = (elemTagName) => {
+ return (["HTML", "BODY", "CONFIRMATION-DIALOG"].includes(elemTagName));
+ }
+
+ let iterator = 0;
+ while(iterator < 20) {
+ sendKey("TAB");
+ isnot(document.activeElement.id, "display-child", "The display-child element should not gain focus when the dialog is showing");
+ ok(isActiveElemDialogOrHTMLorBODY(document.activeElement.tagName), "The confirmation-dialog should always have focus when the dialog is showing");
+ iterator++;
+ }
+});
+
+</script>
+</body>
+</html>
diff --git a/browser/components/aboutlogins/tests/chrome/test_fxaccounts_button.html b/browser/components/aboutlogins/tests/chrome/test_fxaccounts_button.html
new file mode 100644
index 0000000000..ce6046bf2a
--- /dev/null
+++ b/browser/components/aboutlogins/tests/chrome/test_fxaccounts_button.html
@@ -0,0 +1,96 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test the fxaccounts-button component
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test the fxaccounts-button component</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script>
+ <script type="module" src="chrome://browser/content/aboutlogins/components/fxaccounts-button.mjs"></script>
+ <script src="aboutlogins_common.js"></script>
+
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+ <p id="display">
+ </p>
+<div id="content" style="display: none">
+ <iframe id="templateFrame" src="chrome://browser/content/aboutlogins/aboutLogins.html"
+ sandbox="allow-same-origin"></iframe>
+</div>
+<pre id="test">
+</pre>
+<script>
+/** Test the fxaccounts-button component **/
+
+const TEST_AVATAR_URL = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg==";
+
+let gFxAccountsButton;
+add_setup(async () => {
+ let templateFrame = document.getElementById("templateFrame");
+ let displayEl = document.getElementById("display");
+ await importDependencies(templateFrame, displayEl);
+
+ gFxAccountsButton = document.createElement("fxaccounts-button");
+ displayEl.appendChild(gFxAccountsButton);
+});
+
+add_task(async function test_default_state() {
+ ok(gFxAccountsButton, "FxAccountsButton exists");
+ ok(!isHidden(gFxAccountsButton.shadowRoot.querySelector(".logged-out-view")),
+ "logged-out-view view is visible by default");
+ ok(isHidden(gFxAccountsButton.shadowRoot.querySelector(".logged-in-view")),
+ "logged-in-view view is hidden by default");
+});
+
+add_task(async function test_logged_in_without_login_syncing() {
+ gFxAccountsButton.updateState({
+ fxAccountsEnabled: true,
+ loggedIn: true,
+ loginSyncingEnabled: false,
+ });
+
+ ok(isHidden(gFxAccountsButton.shadowRoot.querySelector(".logged-out-view")),
+ "logged-out-view view is hidden");
+ ok(!isHidden(gFxAccountsButton.shadowRoot.querySelector(".logged-in-view")),
+ "logged-in-view view is visible");
+});
+
+add_task(async function test_logged_in_with_login_syncing() {
+ const TEST_EMAIL = "test@example.com";
+
+ gFxAccountsButton.updateState({
+ fxAccountsEnabled: true,
+ loggedIn: true,
+ loginSyncingEnabled: true,
+ email: TEST_EMAIL,
+ avatarURL: TEST_AVATAR_URL,
+ });
+
+ ok(isHidden(gFxAccountsButton.shadowRoot.querySelector(".logged-out-view")),
+ "logged-out-view view is hidden");
+ ok(!isHidden(gFxAccountsButton.shadowRoot.querySelector(".logged-in-view")),
+ "logged-in-view view is visible");
+ is(gFxAccountsButton.shadowRoot.querySelector(".fxaccount-email").textContent,
+ TEST_EMAIL,
+ "email should be shown");
+ is(gFxAccountsButton.shadowRoot.querySelector(".fxaccounts-avatar-button").style.getPropertyValue("--avatar-url"),
+ `url(${TEST_AVATAR_URL})`,
+ "--avatar-url should be set");
+});
+
+add_task(async function test_fxaccounts_disabled() {
+ gFxAccountsButton.updateState({
+ fxAccountsEnabled: false,
+ });
+
+ ok(isHidden(gFxAccountsButton),
+ "the whole button is hidden when fxaccounts are disabled");
+});
+
+</script>
+
+</body>
+</html>
diff --git a/browser/components/aboutlogins/tests/chrome/test_login_filter.html b/browser/components/aboutlogins/tests/chrome/test_login_filter.html
new file mode 100644
index 0000000000..00e0a96a51
--- /dev/null
+++ b/browser/components/aboutlogins/tests/chrome/test_login_filter.html
@@ -0,0 +1,178 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test the login-filter component
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test the login-filter component</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script>
+ <script type="module" src="chrome://browser/content/aboutlogins/components/login-filter.mjs"></script>
+ <script type="module" src="chrome://browser/content/aboutlogins/components/login-list.mjs"></script>
+ <script src="aboutlogins_common.js"></script>
+
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+ <p id="display">
+ </p>
+<div id="content" style="display: none">
+ <iframe id="templateFrame" src="chrome://browser/content/aboutlogins/aboutLogins.html"
+ sandbox="allow-same-origin"></iframe>
+</div>
+<pre id="test">
+</pre>
+<script>
+/** Test the login-filter component **/
+
+let gLoginFilter;
+let gLoginList;
+add_setup(async () => {
+ let templateFrame = document.getElementById("templateFrame");
+ let displayEl = document.getElementById("display");
+ importDependencies(templateFrame, displayEl);
+
+ gLoginFilter = document.createElement("login-filter");
+ displayEl.appendChild(gLoginFilter);
+
+ gLoginList = document.createElement("login-list");
+ displayEl.appendChild(gLoginList);
+});
+
+add_task(async function test_empty_filter() {
+ ok(gLoginFilter, "loginFilter exists");
+ is(gLoginFilter.shadowRoot.querySelector("input").value, "", "Initially empty");
+});
+
+add_task(async function test_input_events() {
+ let filterEvent = null;
+ window.addEventListener("AboutLoginsFilterLogins", event => filterEvent = event);
+ let input = SpecialPowers.wrap(gLoginFilter.shadowRoot.querySelector("input"));
+ input.setUserInput("test");
+ ok(filterEvent, "Filter event received");
+ is(filterEvent.detail, "test", "Event includes input value");
+});
+
+add_task(async function test_list_filtered() {
+ const LOGINS = [{
+ guid: "123456789",
+ origin: "https://example.com",
+ username: "user1",
+ password: "pass1",
+ }, {
+ guid: "987654321",
+ origin: "https://example.com",
+ username: "user2",
+ password: "pass2",
+ }];
+ gLoginList.setLogins(LOGINS);
+
+ let tests = [
+ ["", 2],
+ [LOGINS[0].username, 1],
+ [LOGINS[0].username + "-notfound", 0],
+ [LOGINS[0].username.substr(2, 3), 1],
+ ["", 2],
+ // The password is also used for search when MP is disabled.
+ [LOGINS[0].password, 1],
+ [LOGINS[0].password + "-notfound", 0],
+ [LOGINS[0].password.substr(2, 3), 1],
+ ["", 2],
+ [LOGINS[0].origin, 2],
+ [LOGINS[0].origin + "-notfound", 0],
+ [LOGINS[0].origin.substr(2, 3), 2],
+ ["", 2],
+ // The guid is not used for search.
+ [LOGINS[0].guid, 0],
+ [LOGINS[0].guid + "-notfound", 0],
+ [LOGINS[0].guid.substr(0, 2), 0],
+ ["", 2],
+ ];
+
+ let loginFilterInput = gLoginFilter.shadowRoot.querySelector("input");
+ loginFilterInput.focus();
+
+ for (let i = 0; i < tests.length; i++) {
+ info("Testcase: " + i);
+
+ let testObj = {
+ testCase: i,
+ query: tests[i][0],
+ resultExpectedCount: tests[i][1],
+ };
+
+ let filterLength = loginFilterInput.value.length;
+ while (filterLength-- > 0) {
+ sendKey("BACK_SPACE");
+ }
+ sendString(testObj.query);
+
+ await SimpleTest.promiseWaitForCondition(() => {
+ let countElement = gLoginList.shadowRoot.querySelector(".count");
+ return countElement.hasAttribute("data-l10n-args") &&
+ JSON.parse(countElement.getAttribute("data-l10n-args")).count == testObj.resultExpectedCount;
+ }, `Waiting for the search result count to update to ${testObj.resultExpectedCount} (tc#${testObj.testCase})`);
+ }
+});
+
+ add_task(async function test_keys_in_filter() {
+ const LOGINS = [{
+ guid: "123456789",
+ origin: "https://example.com",
+ username: "user1",
+ password: "pass1",
+ }, {
+ guid: "987654321",
+ origin: "https://example.com",
+ username: "user2",
+ password: "pass2",
+ }, {
+ guid: "333333333",
+ origin: "https://example.com",
+ username: "user3",
+ password: "pass3",
+ }];
+ gLoginList.setLogins(LOGINS);
+
+ const ol = gLoginList.shadowRoot.querySelector("ol");
+ const loginFilterInput = gLoginFilter.shadowRoot.querySelector("input");
+ loginFilterInput.focus();
+
+ // Up/down keys must select previous/next item in the list
+ function pressKeyAndExpectSelection(key, selectedIndex) {
+ sendKey(key);
+ ok(ol.querySelectorAll(".login-list-item[data-guid]:not([hidden])")[selectedIndex]?.classList?.contains("keyboard-selected"),
+ `item ${selectedIndex} should be marked as keyboard-selected`);
+ is(ol.querySelector(".selected").dataset.guid, LOGINS[selectedIndex].guid, `item ${selectedIndex} must be selected`);
+ }
+
+ pressKeyAndExpectSelection("DOWN", 1);
+ pressKeyAndExpectSelection("DOWN", 2);
+
+ // ENTER key in search box must click on selected item in the list
+ sendKey("RETURN");
+ is(ol.querySelector(".selected").dataset.guid, LOGINS[2].guid, "item 2 must still be selected");
+
+ pressKeyAndExpectSelection("DOWN", 2);
+ pressKeyAndExpectSelection("UP", 1);
+ pressKeyAndExpectSelection("UP", 0);
+ pressKeyAndExpectSelection("UP", 0);
+
+ // ESC must clear search box
+ async function expectItemCount(count) {
+ await SimpleTest.promiseWaitForCondition(() =>
+ JSON.parse(gLoginList.shadowRoot.querySelector(".count").getAttribute("data-l10n-args"))?.count == count,
+ `Waiting for the search result count to update to ${count}`);
+ }
+
+ sendString("unique string");
+ await expectItemCount(0);
+ sendKey("Escape");
+ ok(!loginFilterInput.value, "ESC must clear filter input");
+ await expectItemCount(LOGINS.length);
+ });
+</script>
+
+</body>
+</html>
diff --git a/browser/components/aboutlogins/tests/chrome/test_login_item.html b/browser/components/aboutlogins/tests/chrome/test_login_item.html
new file mode 100644
index 0000000000..a7946a0618
--- /dev/null
+++ b/browser/components/aboutlogins/tests/chrome/test_login_item.html
@@ -0,0 +1,481 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test the login-item component
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test the login-item component</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script>
+ <script type="module" src="chrome://browser/content/aboutlogins/components/login-item.mjs"></script>
+ <script type="module" src="chrome://browser/content/aboutlogins/components/confirmation-dialog.mjs"></script>
+ <script type="module" src="chrome://browser/content/aboutlogins/components/login-timeline.mjs"></script>
+ <script src="aboutlogins_common.js"></script>
+
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+ <p id="display">
+ </p>
+<div id="content" style="display: none">
+ <iframe id="templateFrame" src="chrome://browser/content/aboutlogins/aboutLogins.html"
+ sandbox="allow-same-origin"></iframe>
+</div>
+<pre id="test">
+</pre>
+<script type="module">
+
+import { CONCEALED_PASSWORD_TEXT } from "chrome://browser/content/aboutlogins/aboutLoginsUtils.mjs";
+
+/** Test the login-item component **/
+
+let gLoginItem, gConfirmationDialog;
+const TEST_LOGIN_1 = {
+ guid: "123456789",
+ origin: "https://example.com",
+ username: "user1",
+ password: "pass1",
+ timeCreated: "1000",
+ timePasswordChanged: "2000",
+ timeLastUsed: "4000",
+};
+
+const TEST_LOGIN_2 = {
+ guid: "987654321",
+ origin: "https://example.com",
+ username: "user2",
+ password: "pass2",
+ timeCreated: "2000",
+ timePasswordChanged: "4000",
+ timeLastUsed: "8000",
+};
+
+const TEST_BREACH = {
+ Name: "Test-Breach",
+ breachAlertURL: "https://monitor.firefox.com/breach-details/Test-Breach",
+};
+
+const TEST_BREACHES_MAP = new Map();
+TEST_BREACHES_MAP.set(TEST_LOGIN_1.guid, TEST_BREACH);
+
+const TEST_VULNERABLE_MAP = new Map();
+TEST_VULNERABLE_MAP.set(TEST_LOGIN_2.guid, true);
+
+const getLoginTimeline = loginItem =>
+ loginItem.shadowRoot.querySelector("login-timeline");
+
+const verifyTimelineActions = (actions, expectedActions) => {
+ is(
+ actions.length,
+ expectedActions.length,
+ `Number timeline actions length is correct. Actual: ${actions.length}. Expected: ${expectedActions.length}`
+ );
+
+ actions.forEach((point, index) => {
+ let actionId = document.l10n.getAttributes(point).id;
+ let expectedAction = expectedActions[index];
+
+ is(
+ actionId,
+ expectedAction,
+ `Rendered action is correct. Actual: ${actionId}. Expected: ${expectedAction}`
+ );
+ });
+};
+
+add_setup(async () => {
+ let templateFrame = document.getElementById("templateFrame");
+ let displayEl = document.getElementById("display");
+ await importDependencies(templateFrame, displayEl);
+
+ gLoginItem = document.createElement("login-item");
+ displayEl.appendChild(gLoginItem);
+
+ gConfirmationDialog = document.createElement("confirmation-dialog");
+ gConfirmationDialog.hidden = true;
+ displayEl.appendChild(gConfirmationDialog);
+});
+
+add_task(async function test_empty_item() {
+ ok(gLoginItem, "loginItem exists");
+ is(gLoginItem.shadowRoot.querySelector("a[name='origin']").getAttribute("href"), "", "origin should be blank");
+ is(gLoginItem.shadowRoot.querySelector("input[name='username']").value, "", "username should be blank");
+ is(gLoginItem._passwordInput.value, "", "password should be blank");
+ ok(!gLoginItem._passwordInput.isConnected, "Real password input should be disconnected");
+ is(gLoginItem._passwordDisplayInput.value, "", "password display should be blank");
+ ok(!isHidden(gLoginItem._passwordDisplayInput), "Password display input should be visible")
+ ok(isHidden(getLoginTimeline(gLoginItem)), "Timeline should be hidden");
+});
+
+add_task(async function test_set_login() {
+ gLoginItem.setLogin(TEST_LOGIN_1);
+ await asyncElementRendered();
+
+ ok(!gLoginItem.dataset.editing, "loginItem should not be in 'edit' mode");
+ ok(!gLoginItem.dataset.isNewLogin, "loginItem should not be in 'isNewLogin' mode");
+ ok(isHidden(gLoginItem._originInput), "Origin input should be hidden when not in edit mode");
+ ok(!isHidden(gLoginItem._originDisplayInput), "Origin display link should be visible when not in edit mode");
+ let originLink = gLoginItem.shadowRoot.querySelector("a[name='origin']");
+ is(originLink.getAttribute("href"), TEST_LOGIN_1.origin, "origin should be populated");
+ let usernameInput = gLoginItem.shadowRoot.querySelector("input[name='username']");
+ is(usernameInput.value, TEST_LOGIN_1.username, "username should be populated");
+ is(document.l10n.getAttributes(usernameInput).id, "about-logins-login-item-username", "username field should have default placeholder when not editing");
+
+ let passwordInput = gLoginItem._passwordInput;
+ is(passwordInput.value, TEST_LOGIN_1.password, "password should be populated");
+ ok(!passwordInput.hasAttribute("value"), "Password shouldn't be exposed in @value");
+ ok(!gLoginItem._passwordInput.isConnected, "Real password input should be disconnected");
+ let passwordDisplayInput = gLoginItem._passwordDisplayInput;
+ is(passwordDisplayInput.value, CONCEALED_PASSWORD_TEXT, "password display should be populated");
+ ok(!isHidden(passwordDisplayInput), "Password display input should be visible");
+
+ let timeline = getLoginTimeline(gLoginItem);
+ ok(!isHidden(timeline), "Timeline should be visible");
+ let actions = timeline.shadowRoot.querySelectorAll(".action");
+ verifyTimelineActions(actions, [
+ "login-item-timeline-action-created",
+ "login-item-timeline-action-updated",
+ "login-item-timeline-action-used",
+ ]);
+
+ let copyButtons = [...gLoginItem.shadowRoot.querySelectorAll(".copy-button")];
+ ok(copyButtons.every(button => !isHidden(button)), "The copy buttons should be visible when viewing a login");
+
+ let loginNoUsername = Object.assign({}, TEST_LOGIN_1, {username: ""});
+ gLoginItem.setLogin(loginNoUsername);
+ ok(!gLoginItem.dataset.editing, "loginItem should not be in 'edit' mode");
+ is(document.l10n.getAttributes(usernameInput).id, "about-logins-login-item-username", "username field should have default placeholder when username is not present and not editing");
+ let copyUsernameButton = gLoginItem.shadowRoot.querySelector(".copy-username-button");
+ ok(copyUsernameButton.disabled, "The copy-username-button should be disabled if there is no username");
+
+ usernameInput.placeholder = "dummy placeholder";
+ gLoginItem.shadowRoot.querySelector(".edit-button").click();
+ await asyncElementRendered();
+ is(
+ document.l10n.getAttributes(usernameInput).id,
+ null,
+ "there should be no placeholder id on the username input in edit mode"
+ );
+ is(usernameInput.placeholder, "", "there should be no placeholder on the username input in edit mode");
+});
+
+add_task(async function test_update_breaches() {
+ gLoginItem.setLogin(TEST_LOGIN_1);
+ gLoginItem.setBreaches(TEST_BREACHES_MAP);
+ await asyncElementRendered();
+
+ let breachAlert = gLoginItem.shadowRoot.querySelector(".breach-alert");
+ ok(!isHidden(breachAlert), "Breach alert should be visible");
+ is(breachAlert.querySelector(".alert-link").href, TEST_LOGIN_1.origin + "/", "Link in the text should point to the login origin");
+ let vulernableAlert = gLoginItem.shadowRoot.querySelector(".vulnerable-alert");
+ ok(isHidden(vulernableAlert), "Vulnerable alert should not be visible on a non-vulnerable login.");
+});
+
+add_task(async function test_breach_alert_is_correctly_hidden() {
+ gLoginItem.setLogin(TEST_LOGIN_2);
+ gLoginItem.setBreaches(TEST_BREACHES_MAP);
+ await asyncElementRendered();
+
+ let breachAlert = gLoginItem.shadowRoot.querySelector(".breach-alert");
+ ok(isHidden(breachAlert), "Breach alert should not be visible on login without an associated breach.");
+ let vulernableAlert = gLoginItem.shadowRoot.querySelector(".vulnerable-alert");
+ ok(isHidden(vulernableAlert), "Vulnerable alert should not be visible on a non-vulnerable login.");
+});
+
+add_task(async function test_update_vulnerable() {
+ gLoginItem.setLogin(TEST_LOGIN_2);
+ gLoginItem.setVulnerableLogins(TEST_VULNERABLE_MAP);
+ await asyncElementRendered();
+
+ let breachAlert = gLoginItem.shadowRoot.querySelector(".breach-alert");
+ ok(isHidden(breachAlert), "Breach alert should not be visible on login without an associated breach.");
+ let vulernableAlert = gLoginItem.shadowRoot.querySelector(".vulnerable-alert");
+ ok(!isHidden(vulernableAlert), "Vulnerable alert should be visible");
+ is(vulernableAlert.querySelector(".alert-link").href, TEST_LOGIN_2.origin + "/", "Link in the text should point to the login origin");
+});
+
+add_task(async function test_vulnerable_alert_is_correctly_hidden() {
+ gLoginItem.setLogin(TEST_LOGIN_1);
+ gLoginItem.setVulnerableLogins(TEST_VULNERABLE_MAP);
+ gLoginItem.setBreaches(new Map());
+ await asyncElementRendered();
+
+ let breachAlert = gLoginItem.shadowRoot.querySelector(".breach-alert");
+ ok(isHidden(breachAlert), "Breach alert should not be visible on login without an associated breach.");
+ let vulernableAlert = gLoginItem.shadowRoot.querySelector(".vulnerable-alert");
+ ok(isHidden(vulernableAlert), "Vulnerable alert should not be visible on a non-vulnerable login.");
+});
+
+add_task(async function test_edit_login() {
+ gLoginItem.setLogin(TEST_LOGIN_1);
+ let usernameInput = gLoginItem.shadowRoot.querySelector("input[name='username']");
+ usernameInput.placeholder = "dummy placeholder";
+ gLoginItem.shadowRoot.querySelector(".edit-button").click();
+ await asyncElementRendered();
+ await asyncElementRendered();
+
+ ok(gLoginItem.dataset.editing, "loginItem should be in 'edit' mode");
+ ok(isHidden(gLoginItem.shadowRoot.querySelector(".edit-button")), "edit button should be hidden in 'edit' mode");
+ ok(!gLoginItem.dataset.isNewLogin, "loginItem should not be in 'isNewLogin' mode");
+ let deleteButton = gLoginItem.shadowRoot.querySelector(".delete-button");
+ ok(!deleteButton.disabled, "Delete button should be enabled when editing a login");
+ ok(isHidden(gLoginItem._originInput), "Origin input should be hidden in edit mode");
+ ok(!isHidden(gLoginItem._originDisplayInput), "Origin display link should be visible in edit mode");
+ is(gLoginItem.shadowRoot.querySelector("a[name='origin']").getAttribute("href"), TEST_LOGIN_1.origin, "origin should be populated");
+ is(usernameInput.value, TEST_LOGIN_1.username, "username should be populated");
+ is(usernameInput, document.activeElement?.shadowRoot?.activeElement, "username is focused");
+ is(usernameInput.selectionStart, 0, "username value is selected from start");
+ is(usernameInput.selectionEnd, usernameInput.value.length, "username value is selected to the end");
+ is(
+ document.l10n.getAttributes(usernameInput).id,
+ null,
+ "there should be no placeholder id on the username input in edit mode"
+ );
+ is(usernameInput.placeholder, "", "there should be no placeholder on the username input in edit mode");
+ is(gLoginItem._passwordInput.value, TEST_LOGIN_1.password, "password should be populated");
+ is(gLoginItem._passwordDisplayInput.value, CONCEALED_PASSWORD_TEXT, "password display should be populated");
+
+ let timeline = getLoginTimeline(gLoginItem);
+ ok(!isHidden(timeline), "Timeline should be visible");
+
+ let copyButtons = [...gLoginItem.shadowRoot.querySelectorAll(".copy-button")];
+ ok(copyButtons.every(button => isHidden(button)), "The copy buttons should be hidden when editing a login");
+
+ usernameInput.value = "newUsername";
+ gLoginItem._passwordInput.value = "newPassword";
+
+ let updateEventDispatched = false;
+ document.addEventListener("AboutLoginsUpdateLogin", event => {
+ is(event.detail.guid, TEST_LOGIN_1.guid, "event should include guid");
+ is(event.detail.origin, TEST_LOGIN_1.origin, "event should include origin");
+ is(event.detail.username, "newUsername", "event should include new username");
+ is(event.detail.password, "newPassword", "event should include new password");
+ updateEventDispatched = true;
+ }, {once: true});
+ gLoginItem.shadowRoot.querySelector(".save-changes-button").click();
+ ok(updateEventDispatched, "Clicking the .save-changes-button should dispatch the AboutLoginsUpdateLogin event");
+});
+
+add_task(async function test_edit_login_cancel() {
+ gLoginItem.setLogin(TEST_LOGIN_1);
+ gLoginItem.shadowRoot.querySelector(".edit-button").click();
+ await asyncElementRendered();
+
+ ok(gLoginItem.dataset.editing, "loginItem should be in 'edit' mode");
+ is(!!gLoginItem.dataset.isNewLogin, false,
+ "loginItem should not be in 'isNewLogin' mode");
+
+ gLoginItem.shadowRoot.querySelector(".cancel-button").click();
+ gConfirmationDialog.shadowRoot.querySelector(".confirm-button").click();
+
+ await SimpleTest.promiseWaitForCondition(
+ () => gConfirmationDialog.hidden,
+ "waiting for confirmation dialog to hide"
+ );
+
+ ok(!gLoginItem.dataset.editing, "loginItem should not be in 'edit' mode");
+ ok(!gLoginItem.dataset.isNewLogin, "loginItem should not be in 'isNewLogin' mode");
+});
+
+add_task(async function test_reveal_password_change_selected_login() {
+ gLoginItem.setLogin(TEST_LOGIN_1);
+ let revealCheckbox = gLoginItem.shadowRoot.querySelector(".reveal-password-checkbox");
+ let passwordInput = gLoginItem._passwordInput;
+
+ ok(!revealCheckbox.checked, "reveal-checkbox should not be checked by default");
+ is(passwordInput.type, "password", "Password should be masked by default");
+ revealCheckbox.click();
+ ok(revealCheckbox.checked, "reveal-checkbox should be checked after clicking");
+ await SimpleTest.promiseWaitForCondition(() => passwordInput.type == "text",
+ "waiting for password input type to change after checking for primary password");
+ is(passwordInput.type, "text", "Password should be unmasked when checkbox is clicked");
+ ok(!isHidden(passwordInput), "Password input should be visible");
+
+ let editButton = gLoginItem.shadowRoot.querySelector(".edit-button");
+ editButton.click();
+ await asyncElementRendered();
+ ok(!isHidden(passwordInput), "Password input should still be visible");
+ ok(revealCheckbox.checked, "reveal-checkbox should remain checked when entering 'edit' mode");
+ gLoginItem.shadowRoot.querySelector(".cancel-button").click();
+ ok(!revealCheckbox.checked, "reveal-checkbox should be unchecked after canceling 'edit' mode");
+ revealCheckbox.click();
+ ok(isHidden(passwordInput), "Password input should be hidden");
+ ok(!isHidden(gLoginItem._passwordDisplayInput), "Password display should be visible");
+ gLoginItem.setLogin(TEST_LOGIN_2);
+ ok(!revealCheckbox.checked, "reveal-checkbox should be unchecked when changing logins");
+ is(passwordInput.type, "password", "Password should be masked by default when switching logins");
+ ok(isHidden(passwordInput), "Password input should be hidden");
+ ok(!isHidden(gLoginItem._passwordDisplayInput), "Password display should be visible");
+});
+
+add_task(async function test_set_login_empty() {
+ gLoginItem.setLogin({});
+ await asyncElementRendered();
+
+ ok(gLoginItem.dataset.editing, "loginItem should be in 'edit' mode");
+ ok(isHidden(gLoginItem.shadowRoot.querySelector(".edit-button")), "edit button should be hidden in 'edit' mode");
+ ok(gLoginItem.dataset.isNewLogin, "loginItem should be in 'isNewLogin' mode");
+ let deleteButton = gLoginItem.shadowRoot.querySelector(".delete-button");
+ ok(deleteButton.disabled, "Delete button should be disabled when creating a login");
+ ok(!isHidden(gLoginItem._originInput), "Origin input should be visible in new login edit mode");
+ ok(isHidden(gLoginItem._originDisplayInput), "Origin display should be hidden in new login edit mode");
+ is(gLoginItem.shadowRoot.querySelector("input[name='origin']").value, "", "origin should be empty");
+ is(gLoginItem.shadowRoot.querySelector("input[name='username']").value, "", "username should be empty");
+ is(gLoginItem._passwordInput.value, "", "password should be empty");
+ ok(!isHidden(gLoginItem._passwordInput), "Real password input should be visible in edit mode");
+ ok(isHidden(gLoginItem._passwordDisplayInput), "Password display should be hidden in edit mode");
+ ok(!gLoginItem._passwordInput.hasAttribute("value"), "Password shouldn't be exposed in @value");
+
+ let timeline = getLoginTimeline(gLoginItem);
+ ok(isHidden(timeline), "Timeline should be visible");
+
+ let copyButtons = [...gLoginItem.shadowRoot.querySelectorAll(".copy-button")];
+ ok(copyButtons.every(button => isHidden(button)), "The copy buttons should be hidden when creating a login");
+
+ let createEventDispatched = false;
+ document.addEventListener("AboutLoginsCreateLogin", event => {
+ createEventDispatched = true;
+ }, {once: true});
+ gLoginItem.shadowRoot.querySelector(".save-changes-button").click();
+ ok(!createEventDispatched, "Clicking the .save-changes-button shouldn't dispatch the event when fields are invalid");
+ let originInput = gLoginItem.shadowRoot.querySelector("input[name='origin']");
+ ok(originInput.matches(":invalid"), "origin value is required");
+ is(originInput.value, "", "origin input should be blank at start");
+
+ for (let originTuple of [
+ ["ftp://ftp.example.com/", "ftp://ftp.example.com/"],
+ ["https://example.com/", "https://example.com/"],
+ ["http://example.com/", "http://example.com/"],
+ ["www.example.com/bar", "https://www.example.com/bar"],
+ ["example.com/foo", "https://example.com/foo"],
+ ]) {
+ originInput.value = originTuple[0];
+ sendKey("TAB");
+ is(originInput.value, originTuple[1],
+ "origin input should have https:// prefix when not provided by user");
+ // Return focus back to the origin input
+ synthesizeKey("VK_TAB", { shiftKey: true });
+ }
+
+ gLoginItem.shadowRoot.querySelector("input[name='username']").value = "user1";
+ gLoginItem._passwordInput.value = "pass1";
+
+ document.addEventListener("AboutLoginsCreateLogin", event => {
+ is(event.detail.guid, undefined, "event should not include guid");
+ is(event.detail.origin, "https://example.com/foo", "event should include origin");
+ is(event.detail.username, "user1", "event should include new username");
+ is(event.detail.password, "pass1", "event should include new password");
+ createEventDispatched = true;
+ }, {once: true});
+ gLoginItem.shadowRoot.querySelector(".save-changes-button").click();
+ ok(createEventDispatched, "Clicking the .save-changes-button should dispatch the AboutLoginsCreateLogin event");
+});
+
+add_task(async function test_different_login_modified() {
+ gLoginItem.setLogin(TEST_LOGIN_1);
+ let otherLogin = Object.assign({}, TEST_LOGIN_1, {username: "fakeuser", guid: "fakeguid"});
+ gLoginItem.loginModified(otherLogin);
+ await asyncElementRendered();
+
+ is(gLoginItem.shadowRoot.querySelector("a[name='origin']").getAttribute("href"), TEST_LOGIN_1.origin, "origin should be unchanged");
+ is(gLoginItem.shadowRoot.querySelector("input[name='username']").value, TEST_LOGIN_1.username, "username should be unchanged");
+ is(gLoginItem._passwordInput.value, TEST_LOGIN_1.password, "password should be unchanged");
+ ok(!gLoginItem._passwordInput.hasAttribute("value"), "Password shouldn't be exposed in @value");
+ ok(!gLoginItem._passwordInput.isConnected, "Real password input should be disconnected in masked non-edit mode");
+ ok(!isHidden(gLoginItem._passwordDisplayInput), "Password display should be visible in masked non-edit mode");
+});
+
+add_task(async function test_different_login_removed() {
+ gLoginItem.setLogin(TEST_LOGIN_1);
+ let otherLogin = Object.assign({}, TEST_LOGIN_1, {username: "fakeuser", guid: "fakeguid"});
+ gLoginItem.loginRemoved(otherLogin);
+ await asyncElementRendered();
+
+ is(gLoginItem.shadowRoot.querySelector("a[name='origin']").getAttribute("href"), TEST_LOGIN_1.origin, "origin should be unchanged");
+ is(gLoginItem.shadowRoot.querySelector("input[name='username']").value, TEST_LOGIN_1.username, "username should be unchanged");
+ is(gLoginItem._passwordInput.value, TEST_LOGIN_1.password, "password should be unchanged");
+ ok(!gLoginItem._passwordInput.hasAttribute("value"), "Password shouldn't be exposed in @value");
+ ok(!gLoginItem._passwordInput.isConnected, "Real password input should be disconnected in masked non-edit mode");
+ ok(!isHidden(gLoginItem._passwordDisplayInput), "Password display should be visible in masked non-edit mode");
+});
+
+add_task(async function test_login_modified() {
+ gLoginItem.setLogin(TEST_LOGIN_1);
+ let modifiedLogin = Object.assign({}, TEST_LOGIN_1, {username: "updateduser"});
+ gLoginItem.loginModified(modifiedLogin);
+ await asyncElementRendered();
+
+ is(gLoginItem.shadowRoot.querySelector("a[name='origin']").getAttribute("href"), modifiedLogin.origin, "origin should be updated");
+ is(gLoginItem.shadowRoot.querySelector("input[name='username']").value, modifiedLogin.username, "username should be updated");
+ is(gLoginItem._passwordInput.value, modifiedLogin.password, "password should be updated");
+ ok(!gLoginItem._passwordInput.hasAttribute("value"), "Password shouldn't be exposed in @value");
+ ok(!gLoginItem._passwordInput.isConnected, "Real password input should be disconnected in masked non-edit mode");
+ ok(!isHidden(gLoginItem._passwordDisplayInput), "Password display should be visible in masked non-edit mode");
+});
+
+add_task(async function test_login_removed() {
+ gLoginItem.setLogin(TEST_LOGIN_1);
+ gLoginItem.loginRemoved(TEST_LOGIN_1);
+ await asyncElementRendered();
+
+ is(gLoginItem.shadowRoot.querySelector("input[name='origin']").value, "", "origin should be cleared");
+ is(gLoginItem.shadowRoot.querySelector("input[name='username']").value, "", "username should be cleared");
+ is(gLoginItem._passwordInput.value, "", "password should be cleared");
+ ok(!gLoginItem._passwordInput.hasAttribute("value"), "Password shouldn't be exposed in @value");
+
+ let timeline = getLoginTimeline(gLoginItem);
+ ok(isHidden(timeline), "Timeline should be visible");
+});
+
+add_task(async function test_login_long_username_scrollLeft_reset() {
+ let loginLongUsername = Object.assign({}, TEST_LOGIN_1, {username: "user2longnamelongnamelongnamelongnamelongname"});
+ gLoginItem.setLogin(loginLongUsername);
+ gLoginItem.shadowRoot.querySelector(".edit-button").click();
+ await asyncElementRendered();
+ await asyncElementRendered();
+ let usernameInput = gLoginItem.shadowRoot.querySelector("input[name='username']");
+ usernameInput.scrollLeft = usernameInput.scrollLeftMax;
+ gLoginItem.shadowRoot.querySelector(".cancel-button").click();
+ is(usernameInput.scrollLeft, 0, "username input should be scrolled horizontally to the beginning");
+});
+
+add_task(async function test_copy_button_state() {
+ gLoginItem.setLogin(TEST_LOGIN_1);
+ await asyncElementRendered();
+
+ let copyUsernameButton = gLoginItem.shadowRoot.querySelector(".copy-username-button");
+ ok(!copyUsernameButton.disabled, "The copy-username-button should be enabled");
+
+ let copyPasswordButton = gLoginItem.shadowRoot.querySelector(".copy-password-button");
+ ok(!copyPasswordButton.disabled, "The copy-passwoed-button should be enabled");
+
+ copyUsernameButton.click();
+ ok(copyUsernameButton.disabled, "The copy-username-button should be disabled when it is clicked");
+ ok(!copyPasswordButton.disabled, "The copy-passwoed-button should be enabled when the copy-username-button is clicked");
+
+ copyPasswordButton.click();
+ await SimpleTest.promiseWaitForCondition(() => copyPasswordButton.disabled,
+ "waiting for copy-password-button to become disabled after checking for primary password");
+
+ ok(copyPasswordButton.disabled, "The copy-passwoed-button should be disabled when it is clicked");
+ ok(!copyUsernameButton.disabled, "The copy-username-button should be enabled when the copy-password-button is clicked");
+
+ let loginNoUsername = Object.assign({}, TEST_LOGIN_2, {username: ""});
+ gLoginItem.setLogin(loginNoUsername);
+
+ ok(copyUsernameButton.disabled, "The copy-username-button should be disabled when the username is empty");
+ ok(!copyPasswordButton.disabled, "The copy-passwoed-button should be enabled");
+
+ copyPasswordButton.click();
+ await SimpleTest.promiseWaitForCondition(() => copyPasswordButton.disabled,
+ "waiting for copy-password-button to become disabled after checking for primary password");
+
+ ok(copyPasswordButton.disabled, "The copy-passwoed-button should be disabled when it is clicked");
+ ok(copyUsernameButton.disabled, "The copy-username-button should still be disabled after clicking the password button when the username is empty");
+});
+
+</script>
+
+</body>
+</html>
diff --git a/browser/components/aboutlogins/tests/chrome/test_login_list.html b/browser/components/aboutlogins/tests/chrome/test_login_list.html
new file mode 100644
index 0000000000..98342978fb
--- /dev/null
+++ b/browser/components/aboutlogins/tests/chrome/test_login_list.html
@@ -0,0 +1,697 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test the login-list component
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test the login-list component</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script>
+ <script type="module" src="chrome://browser/content/aboutlogins/components/login-list.mjs"></script>
+ <script src="aboutlogins_common.js"></script>
+
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+ <p id="display">
+ </p>
+<div id="content" style="display: none">
+ <iframe id="templateFrame" src="chrome://browser/content/aboutlogins/aboutLogins.html"
+ sandbox="allow-same-origin"></iframe>
+</div>
+<pre id="test">
+</pre>
+<script>
+/** Test the login-list component **/
+
+let gLoginList;
+const TEST_LOGIN_1 = {
+ guid: "123456789",
+ origin: "https://abc.example.com",
+ httpRealm: null,
+ username: "user1",
+ password: "pass1",
+ title: "abc.example.com",
+ // new Date("December 13, 2018").getTime()
+ timeLastUsed: 1544677200000,
+ timePasswordChanged: 1544677200000,
+};
+const TEST_LOGIN_2 = {
+ guid: "987654321",
+ origin: "https://example.com",
+ httpRealm: null,
+ username: "user2",
+ password: "pass2",
+ title: "example.com",
+ // new Date("June 1, 2019").getTime()
+ timeLastUsed: 1559361600000,
+ timePasswordChanged: 1559361600000,
+};
+const TEST_LOGIN_3 = {
+ guid: "1111122222",
+ origin: "https://def.example.com",
+ httpRealm: null,
+ username: "",
+ password: "pass3",
+ title: "def.example.com",
+ // new Date("June 1, 2019").getTime()
+ timeLastUsed: 1559361600000,
+ timePasswordChanged: 1559361600000,
+};
+const TEST_HTTP_AUTH_LOGIN_1 = {
+ guid: "8675309",
+ origin: "https://httpauth.example.com",
+ httpRealm: "My Realm",
+ username: "http_auth_user",
+ password: "pass4",
+ title: "httpauth.example.com (My Realm)",
+ // new Date("June 1, 2019").getTime()
+ timeLastUsed: 1559361600000,
+ timePasswordChanged: 1559361600000,
+};
+
+const TEST_BREACH = {
+ AddedDate: "2018-12-20T23:56:26Z",
+ BreachDate: "2018-12-11",
+ Domain: "abc.example.com",
+ Name: "ABC Example",
+ PwnCount: 1643100,
+ DataClasses: ["Usernames", "Passwords"],
+ _status: "synced",
+ id: "047940fe-d2fd-4314-b636-b4a952ee0043",
+ last_modified: "1541615610052",
+ schema: "1541615609018",
+ breachAlertURL: "https://monitor.firefox.com/breach-details/ABC-Example",
+};
+
+
+const TEST_BREACHES_MAP = new Map();
+TEST_BREACHES_MAP.set(TEST_LOGIN_1.guid, TEST_BREACH);
+
+add_setup(async () => {
+ let templateFrame = document.getElementById("templateFrame");
+ let displayEl = document.getElementById("display");
+ await importDependencies(templateFrame, displayEl);
+
+ gLoginList = document.createElement("login-list");
+ displayEl.appendChild(gLoginList);
+});
+
+add_task(async function test_empty_list() {
+ ok(gLoginList, "loginList exists");
+ is(gLoginList.textContent, "", "Initially empty");
+ gLoginList.classList.add("no-logins");
+ let loginListBox = gLoginList.shadowRoot.querySelector("ol");
+ let introText = gLoginList.shadowRoot.querySelector(".intro");
+ let emptySearchText = gLoginList.shadowRoot.querySelector(".empty-search-message");
+ ok(isHidden(loginListBox), "The login-list ol should be hidden when there are no logins");
+ ok(!isHidden(introText), "The intro text should be visible when the list is empty");
+ ok(isHidden(emptySearchText), "The empty-search text should be hidden when there are no logins");
+
+ gLoginList.classList.add("create-login-selected");
+ ok(!isHidden(loginListBox), "The login-list ol should be visible when the create-login mode is active");
+ ok(isHidden(introText), "The intro text should be hidden when the create-login mode is active");
+ ok(isHidden(emptySearchText), "The empty-search text should be hidden when the create-login mode is active");
+ gLoginList.classList.remove("create-login-selected");
+
+ window.dispatchEvent(new CustomEvent("AboutLoginsFilterLogins", {
+ bubbles: true,
+ detail: "foo",
+ }));
+ ok(isHidden(loginListBox), "The login-list ol should be hidden when there are no logins");
+ ok(!isHidden(introText), "The intro text should be visible when the list is empty");
+ ok(isHidden(emptySearchText), "The empty-search text should be hidden when there are no logins even if a filter is applied");
+
+ // Clean up state for next test
+ gLoginList.classList.remove("no-logins");
+ window.dispatchEvent(new CustomEvent("AboutLoginsFilterLogins", {
+ bubbles: true,
+ detail: "",
+ }));
+});
+
+add_task(async function test_keyboard_navigation() {
+ let logins = [];
+ for (let i = 0; i < 20; i++) {
+ let suffix = i % 2 ? "odd" : "even";
+ logins.push(Object.assign({}, TEST_LOGIN_1, {
+ guid: "" + i,
+ username: `testuser-${suffix}-${i}`,
+ password: `testpass-${suffix}-${i}`,
+ }));
+ }
+ gLoginList.setLogins(logins);
+ let ol = gLoginList.shadowRoot.querySelector("ol");
+ is(ol.querySelectorAll(".login-list-item[data-guid]").length, 20, "there should be 20 logins in the list");
+ is(ol.querySelectorAll(".login-list-item[data-guid]:not([hidden])").length, 20, "all logins should be visible");
+ window.dispatchEvent(new CustomEvent("AboutLoginsFilterLogins", {
+ bubbles: true,
+ detail: "odd",
+ }));
+ is(ol.querySelectorAll(".login-list-item[data-guid]:not([hidden])").length, 10, "half of the logins in the list");
+
+ while (document.activeElement != gLoginList &&
+ gLoginList.shadowRoot.querySelector("#login-sort") != gLoginList.shadowRoot.activeElement) {
+ sendKey("TAB");
+ await new Promise(resolve => requestAnimationFrame(resolve));
+ }
+ sendKey("TAB");
+ let loginSort = gLoginList.shadowRoot.querySelector("#login-sort");
+ await SimpleTest.promiseWaitForCondition(() => loginSort == gLoginList.shadowRoot.activeElement,
+ "waiting for login-sort to get focus");
+ ok(loginSort == gLoginList.shadowRoot.activeElement, "#login-sort should be focused after tabbing to it");
+
+ sendKey("TAB");
+ await SimpleTest.promiseWaitForCondition(() => ol.matches(":focus"),
+ "waiting for 'ol' to get focus");
+ ok(ol.matches(":focus"), "'ol' should be focused after tabbing to it");
+
+ let selectedGuid = ol.querySelectorAll(".login-list-item[data-guid]:not([hidden])")[0].dataset.guid;
+ let loginSelectedEvent = null;
+ gLoginList.addEventListener("AboutLoginsLoginSelected", event => loginSelectedEvent = event, {once: true});
+ sendKey("RETURN");
+ is(ol.querySelector(".selected").dataset.guid, selectedGuid, "item should be marked as selected");
+ ok(loginSelectedEvent, "AboutLoginsLoginSelected event should be dispatched on pressing Enter");
+ is(loginSelectedEvent.detail.guid, selectedGuid, "event should have expected login attached");
+
+ for (let [keyFwd, keyRev] of [["LEFT", "RIGHT"], ["DOWN", "UP"]]) {
+ sendKey(keyFwd);
+ await SimpleTest.promiseWaitForCondition(() => ol.getAttribute("aria-activedescendant") == ol.querySelectorAll(".login-list-item[data-guid]:not([hidden])")[1].id,
+ `waiting for second item in list to get focused (${keyFwd})`);
+ ok(ol.querySelectorAll(".login-list-item[data-guid]:not([hidden])")[1].classList.contains("keyboard-selected"), `second item should be marked as keyboard-selected (${keyFwd})`);
+
+ sendKey(keyRev);
+ await SimpleTest.promiseWaitForCondition(() => ol.getAttribute("aria-activedescendant") == ol.querySelectorAll(".login-list-item[data-guid]:not([hidden])")[0].id,
+ `waiting for first item in list to get focused (${keyRev})`);
+ ok(ol.querySelectorAll(".login-list-item[data-guid]:not([hidden])")[0].classList.contains("keyboard-selected"), `first item should be marked as keyboard-selected (${keyRev})`);
+ }
+
+ sendKey("DOWN");
+ await SimpleTest.promiseWaitForCondition(() => ol.getAttribute("aria-activedescendant") == ol.querySelectorAll(".login-list-item[data-guid]:not([hidden])")[1].id,
+ `waiting for second item in list to get focused (DOWN)`);
+ ok(ol.querySelectorAll(".login-list-item[data-guid]:not([hidden])")[1].classList.contains("keyboard-selected"), `second item should be marked as keyboard-selected (DOWN)`);
+ selectedGuid = ol.querySelectorAll(".login-list-item[data-guid]:not([hidden])")[1].dataset.guid;
+
+ synthesizeKey("VK_DOWN", { repeat: 5 });
+ ok(ol.querySelectorAll(".login-list-item[data-guid]:not([hidden])")[6].classList.contains("keyboard-selected"), `sixth item should be marked as keyboard-selected after 5 DOWN repeats`);
+ synthesizeKey("VK_UP", { repeat: 5 });
+ ok(ol.querySelectorAll(".login-list-item[data-guid]:not([hidden])")[1].classList.contains("keyboard-selected"), `second item should be marked as keyboard-selected again after 5 UP repeats`);
+
+ loginSelectedEvent = null;
+ gLoginList.addEventListener("AboutLoginsLoginSelected", event => loginSelectedEvent = event, {once: true});
+ sendKey("RETURN");
+ is(ol.querySelector(".selected").dataset.guid, selectedGuid, "item should be marked as selected");
+ ok(loginSelectedEvent, "AboutLoginsLoginSelected event should be dispatched on pressing Enter");
+ is(loginSelectedEvent.detail.guid, selectedGuid, "event should have expected login attached");
+
+ // Clean up state for next test
+ gLoginList.classList.remove("no-logins");
+ window.dispatchEvent(new CustomEvent("AboutLoginsFilterLogins", {
+ bubbles: true,
+ detail: "",
+ }));
+});
+
+add_task(async function test_empty_login_username_in_list() {
+ // Clear the selection so the 'new' login will be in the list too.
+ window.dispatchEvent(new CustomEvent("AboutLoginsLoginSelected", {
+ detail: {},
+ }));
+
+ gLoginList.setLogins([TEST_LOGIN_3]);
+ let loginListItems = gLoginList.shadowRoot.querySelectorAll(".login-list-item:not(#new-login-list-item, [hidden])");
+ is(loginListItems.length, 1, "The one stored login should be displayed");
+ is(loginListItems[0].dataset.guid, TEST_LOGIN_3.guid, "login-list-item should have correct guid attribute");
+ let loginUsername = loginListItems[0].querySelector(".username");
+ is(loginUsername.getAttribute("data-l10n-id"), "login-list-item-subtitle-missing-username", "login should show missing username text");
+});
+
+add_task(async function test_populated_list() {
+ gLoginList.setLogins([TEST_LOGIN_1, TEST_LOGIN_2]);
+ let loginListItems = gLoginList.shadowRoot.querySelectorAll(".login-list-item:not(#new-login-list-item, [hidden])");
+ is(loginListItems.length, 2, "The two stored logins should be displayed");
+ is(loginListItems[0].getAttribute("role"), "option", "Each login-list-item should have role='option'");
+ is(loginListItems[0].dataset.guid, TEST_LOGIN_1.guid, "login-list-item should have correct guid attribute");
+ is(loginListItems[0].querySelector(".title").textContent, TEST_LOGIN_1.title,
+ "login-list-item origin should match");
+ is(loginListItems[0].querySelector(".username").textContent, TEST_LOGIN_1.username,
+ "login-list-item username should match");
+ ok(loginListItems[0].classList.contains("selected"), "The first item should be selected by default");
+ ok(!loginListItems[1].classList.contains("selected"), "The second item should not be selected by default");
+ loginListItems[0].click();
+ loginListItems = gLoginList.shadowRoot.querySelectorAll(".login-list-item:not(#new-login-list-item, [hidden])");
+ is(loginListItems.length, 2, "After selecting one, only the two stored logins should be displayed");
+ ok(loginListItems[0].classList.contains("selected"), "The first item should be selected");
+ ok(!loginListItems[1].classList.contains("selected"), "The second item should still not be selected");
+});
+
+add_task(async function test_breach_indicator() {
+ gLoginList.setLogins([TEST_LOGIN_1, TEST_LOGIN_2, Object.assign({}, TEST_LOGIN_3, {password: TEST_LOGIN_1.password})]);
+ gLoginList.setBreaches(TEST_BREACHES_MAP);
+ let vulnerableLogins = new Map();
+ vulnerableLogins.set(TEST_LOGIN_1.guid, true);
+ vulnerableLogins.set(TEST_LOGIN_3.guid, true);
+ gLoginList.setVulnerableLogins(vulnerableLogins);
+ let loginListItems = gLoginList.shadowRoot.querySelectorAll(".login-list-item:not(#new-login-list-item, [hidden])");
+ let alertIcon = loginListItems[0].querySelector(".alert-icon");
+ is(loginListItems[0].dataset.guid, TEST_LOGIN_1.guid, "The first login should be TEST_LOGIN_1");
+ ok(!loginListItems[0].classList.contains("vulnerable"), "The first login should not have the .vulnerable class");
+ ok(loginListItems[0].classList.contains("breached"), "The first login should have the .breached class.");
+ is(alertIcon.src, "chrome://browser/content/aboutlogins/icons/breached-website.svg", "The alert icon should be the breach warning icon");
+ is(loginListItems[1].dataset.guid, TEST_LOGIN_3.guid, "The second login should be TEST_LOGIN_3");
+ ok(loginListItems[1].classList.contains("vulnerable"), "The second login should have the .vulnerable class");
+ ok(!loginListItems[1].classList.contains("breached"), "The second login should not have the .breached class");
+ alertIcon = loginListItems[1].querySelector(".alert-icon");
+ is(alertIcon.src, "chrome://browser/content/aboutlogins/icons/vulnerable-password.svg", "The alert icon should be the vulnerable password icon");
+ is(loginListItems[2].dataset.guid, TEST_LOGIN_2.guid, "The third login should be TEST_LOGIN_2");
+ alertIcon = loginListItems[2].querySelector(".alert-icon");
+ ok(!loginListItems[2].classList.contains("vulnerable"), "The third login should not have the .vulnerable class");
+ ok(!loginListItems[2].classList.contains("breached"), "The third login should not have the .breached class");
+ is(alertIcon.src, "chrome://mochitests/content/chrome/browser/components/aboutlogins/tests/chrome/test_login_list.html", "The alert icon src should be empty");
+});
+
+function assertCount({ count, total }) {
+ const countSpan = gLoginList.shadowRoot.querySelector(".count");
+ const actual = JSON.parse(countSpan.getAttribute("data-l10n-args"));
+ isDeeply(actual, { count, total }, "Login count updated");
+}
+
+add_task(async function test_filtered_list() {
+ function findItemFromUsername(list, username) {
+ for (let item of list) {
+ if ((item._cachedUsername || (item._cachedUsername = item.querySelector('.username').textContent)) == username) {
+ return item;
+ }
+ }
+ ok(false, `The ${username} wasn't in the list of logins.`)
+ return list[0];
+ }
+ gLoginList.setLogins([TEST_LOGIN_1, TEST_LOGIN_2]);
+ let emptySearchText = gLoginList.shadowRoot.querySelector(".empty-search-message");
+ ok(isHidden(emptySearchText), "The empty search text should be hidden when there are results in the list");
+ is(gLoginList.shadowRoot.querySelectorAll(".login-list-item:not(#new-login-list-item, [hidden])").length, 2, "Both logins should be visible");
+
+ assertCount({ count: 2, total: 2 });
+ window.dispatchEvent(new CustomEvent("AboutLoginsFilterLogins", {
+ bubbles: true,
+ detail: "user1",
+ }));
+ assertCount({ count: 1, total: 2 });
+ ok(isHidden(emptySearchText), "The empty search text should be hidden when there are results in the list");
+ let loginListItems = gLoginList.shadowRoot.querySelectorAll(".login-list-item[data-guid]");
+ is(loginListItems[0].querySelector(".username").textContent, "user1", "user1 is expected first");
+ ok(!loginListItems[0].hidden, "user1 should remain visible");
+ ok(loginListItems[1].hidden, "user2 should be hidden");
+ window.dispatchEvent(new CustomEvent("AboutLoginsFilterLogins", {
+ bubbles: true,
+ detail: "user2",
+ }));
+ assertCount({ count: 1, total: 2 });
+ ok(isHidden(emptySearchText), "The empty search text should be hidden when there are results in the list");
+ loginListItems = gLoginList.shadowRoot.querySelectorAll(".login-list-item[data-guid]");
+ ok(findItemFromUsername(loginListItems, 'user1').hidden, "user1 should be hidden");
+ ok(!findItemFromUsername(loginListItems, 'user2').hidden, "user2 should be visible");
+ window.dispatchEvent(new CustomEvent("AboutLoginsFilterLogins", {
+ bubbles: true,
+ detail: "user",
+ }));
+ assertCount({ count: 2, total: 2 });
+ ok(!gLoginList._sortSelect.disabled, "The sort should be enabled when there are visible logins in the list");
+ ok(isHidden(emptySearchText), "The empty search text should be hidden when there are results in the list");
+ loginListItems = gLoginList.shadowRoot.querySelectorAll(".login-list-item[data-guid]");
+ ok(!findItemFromUsername(loginListItems, 'user1').hidden, "user1 should be visible");
+ ok(!findItemFromUsername(loginListItems, 'user2').hidden, "user2 should be visible");
+ window.dispatchEvent(new CustomEvent("AboutLoginsFilterLogins", {
+ bubbles: true,
+ detail: "foo",
+ }));
+ assertCount({ count: 0, total: 2 });
+ ok(gLoginList._sortSelect.disabled, "The sort should be disabled when there are no visible logins in the list");
+ ok(!isHidden(emptySearchText), "The empty search text should be visible when there are no results in the list");
+ isnot(gLoginList.shadowRoot.querySelector(".container > ol").getAttribute("aria-activedescendant"), "new-login-list-item", "new-login-list-item shouldn't be the active descendant");
+ loginListItems = gLoginList.shadowRoot.querySelectorAll(".login-list-item[data-guid]");
+ ok(findItemFromUsername(loginListItems, 'user1').hidden, "user1 should be hidden");
+ ok(findItemFromUsername(loginListItems, 'user2').hidden, "user2 should be hidden");
+ window.dispatchEvent(new CustomEvent("AboutLoginsFilterLogins", {
+ bubbles: true,
+ detail: "",
+ }));
+ ok(!gLoginList._sortSelect.disabled, "The sort should be re-enabled when there are visible logins in the list");
+ ok(isHidden(emptySearchText), "The empty search text should be hidden when there are results in the list");
+ assertCount({ count: 2, total: 2 });
+ loginListItems = gLoginList.shadowRoot.querySelectorAll(".login-list-item[data-guid]");
+ ok(!findItemFromUsername(loginListItems, 'user1').hidden, "user1 should be visible");
+ ok(!findItemFromUsername(loginListItems, 'user2').hidden, "user2 should be visible");
+
+ info("Add an HTTP Auth login");
+ gLoginList.setLogins([TEST_LOGIN_1, TEST_LOGIN_2, TEST_HTTP_AUTH_LOGIN_1]);
+ await asyncElementRendered();
+ assertCount({ count: 3, total: 3 });
+ info("Filter by httpRealm");
+ window.dispatchEvent(new CustomEvent("AboutLoginsFilterLogins", {
+ bubbles: true,
+ detail: "realm",
+ }));
+ assertCount({ count: 1, total: 3 });
+ loginListItems = gLoginList.shadowRoot.querySelectorAll(".login-list-item[data-guid]");
+ ok(findItemFromUsername(loginListItems, 'user1').hidden, "user1 should be hidden");
+ ok(findItemFromUsername(loginListItems, 'user2').hidden, "user2 should be hidden");
+ ok(!findItemFromUsername(loginListItems, 'http_auth_user').hidden, "http_auth_user should be visible");
+
+ gLoginList.setLogins([TEST_LOGIN_1, TEST_LOGIN_2]);
+ window.dispatchEvent(new CustomEvent("AboutLoginsFilterLogins", {
+ bubbles: true,
+ detail: "",
+ }));
+ await asyncElementRendered();
+});
+
+add_task(async function test_initial_empty_results() {
+ // Create a new instance to reset state
+ gLoginList.remove();
+ gLoginList = document.createElement("login-list");
+ document.getElementById("display").appendChild(gLoginList);
+ await asyncElementRendered();
+
+ let emptySearchText = gLoginList.shadowRoot.querySelector(".empty-search-message");
+
+ window.dispatchEvent(new CustomEvent("AboutLoginsFilterLogins", {
+ bubbles: true,
+ detail: "foo",
+ }));
+ assertCount({ count: 0, total: 0 });
+ ok(gLoginList._sortSelect.disabled, "The sort should be disabled when there are no visible logins in the list");
+ ok(!isHidden(emptySearchText), "The empty search text should be visible when there are no results in the list");
+ isnot(gLoginList.shadowRoot.querySelector(".container > ol").getAttribute("aria-activedescendant"), "new-login-list-item", "new-login-list-item shouldn't be the active descendant");
+ ok(gLoginList.shadowRoot.querySelector("#new-login-list-item").hidden, "new-login-list-item should be @hidden");
+
+ gLoginList.setLogins([TEST_LOGIN_1, TEST_LOGIN_2]);
+ window.dispatchEvent(new CustomEvent("AboutLoginsFilterLogins", {
+ bubbles: true,
+ detail: "",
+ }));
+ await asyncElementRendered();
+});
+
+add_task(async function test_login_modified() {
+ let modifiedLogin = Object.assign(TEST_LOGIN_1, {username: "user11"});
+ gLoginList.loginModified(modifiedLogin);
+ await asyncElementRendered();
+ let loginListItems = gLoginList.shadowRoot.querySelectorAll(".login-list-item[data-guid]:not([hidden])");
+ is(loginListItems.length, 2, "Both logins should be displayed");
+ is(loginListItems[0].dataset.guid, TEST_LOGIN_1.guid, "login-list-item should have correct guid attribute");
+ is(loginListItems[0].querySelector(".title").textContent, TEST_LOGIN_1.title,
+ "login-list-item origin should match");
+ is(loginListItems[0].querySelector(".username").textContent, modifiedLogin.username,
+ "login-list-item username should have been updated");
+ is(loginListItems[1].querySelector(".username").textContent, TEST_LOGIN_2.username,
+ "login-list-item2 username should remain unchanged");
+});
+
+add_task(async function test_login_added() {
+ info("selected sort: " + gLoginList.shadowRoot.getElementById("login-sort").selectedIndex);
+
+ let loginListItems = gLoginList.shadowRoot.querySelectorAll(".login-list-item:not(#new-login-list-item, [hidden])");
+ is(loginListItems.length, 2, "Should have two logins at start of test");
+ let newLogin = Object.assign({}, TEST_LOGIN_1, {title: "example2.example.com", guid: "111222"});
+ gLoginList.loginAdded(newLogin);
+ await asyncElementRendered();
+ loginListItems = gLoginList.shadowRoot.querySelectorAll(".login-list-item:not(#new-login-list-item, [hidden])");
+ is(loginListItems.length, 3, "New login should be added to the list");
+ is(loginListItems[0].dataset.guid, TEST_LOGIN_1.guid, "login-list-item1 should have correct guid attribute");
+ is(loginListItems[1].dataset.guid, TEST_LOGIN_2.guid, "login-list-item2 should have correct guid attribute");
+ is(loginListItems[2].dataset.guid, newLogin.guid, "login-list-item3 should have correct guid attribute");
+ is(loginListItems[2].querySelector(".title").textContent, newLogin.title,
+ "login-list-item origin should match");
+ is(loginListItems[2].querySelector(".username").textContent, newLogin.username,
+ "login-list-item username should have been updated");
+});
+
+add_task(async function test_login_removed() {
+ gLoginList.loginRemoved({guid: "111222"});
+ await asyncElementRendered();
+ let loginListItems = gLoginList.shadowRoot.querySelectorAll(".login-list-item:not(#new-login-list-item, [hidden])");
+ is(loginListItems.length, 2, "New login should be removed from the list");
+ is(loginListItems[0].dataset.guid, TEST_LOGIN_1.guid, "login-list-item1 should have correct guid attribute");
+ is(loginListItems[1].dataset.guid, TEST_LOGIN_2.guid, "login-list-item2 should have correct guid attribute");
+});
+
+add_task(async function test_login_added_filtered() {
+ assertCount({ count: 2, total: 2 });
+ window.dispatchEvent(new CustomEvent("AboutLoginsFilterLogins", {
+ detail: "user1",
+ }));
+ assertCount({ count: 1, total: 2 });
+
+ let newLogin = Object.assign({}, TEST_LOGIN_1, {title: "example2.example.com", username: "user22", guid: "111222"});
+ gLoginList.loginAdded(newLogin);
+ await asyncElementRendered();
+ let loginListItems = gLoginList.shadowRoot.querySelectorAll(".login-list-item[data-guid]");
+ is(loginListItems.length, 3, "New login should be added to the list");
+ is(loginListItems[0].dataset.guid, TEST_LOGIN_1.guid, "login-list-item1 should have correct guid attribute");
+ is(loginListItems[1].dataset.guid, TEST_LOGIN_2.guid, "login-list-item2 should have correct guid attribute");
+ is(loginListItems[2].dataset.guid, newLogin.guid, "login-list-item3 should have correct guid attribute");
+ ok(!loginListItems[0].hidden, "login-list-item1 should be visible");
+ ok(loginListItems[1].hidden, "login-list-item2 should be hidden");
+ ok(loginListItems[2].hidden, "login-list-item3 should be hidden");
+ assertCount({ count: 1, total: 3 });
+});
+
+add_task(async function test_sorted_list() {
+ function dispatchChangeEvent(target) {
+ let event = document.createEvent("UIEvent");
+ event.initEvent("change", true, true);
+ target.dispatchEvent(event);
+ }
+
+ // Clear the filter
+ window.dispatchEvent(new CustomEvent("AboutLoginsFilterLogins", {
+ detail: "",
+ }));
+
+ // Clear the selection so the 'new' login will be in the list too.
+ window.dispatchEvent(new CustomEvent("AboutLoginsLoginSelected", {
+ detail: {},
+ }));
+
+ // make sure that the logins have distinct orderings based on sort order
+ let [guid1, guid2, guid3] = gLoginList._loginGuidsSortedOrder;
+ gLoginList._logins[guid1].login.timeLastUsed = 0;
+ gLoginList._logins[guid2].login.timeLastUsed = 1;
+ gLoginList._logins[guid3].login.timeLastUsed = 2;
+ gLoginList._logins[guid1].login.title = "a";
+ gLoginList._logins[guid2].login.title = "b";
+ gLoginList._logins[guid3].login.title = "c";
+ gLoginList._logins[guid1].login.username = "a";
+ gLoginList._logins[guid2].login.username = "b";
+ gLoginList._logins[guid3].login.username = "c";
+ gLoginList._logins[guid1].login.timePasswordChanged = 1;
+ gLoginList._logins[guid2].login.timePasswordChanged = 2;
+ gLoginList._logins[guid3].login.timePasswordChanged = 0;
+
+ // sort by last used
+ let loginSort = gLoginList.shadowRoot.getElementById("login-sort");
+ loginSort.value = "last-used";
+ dispatchChangeEvent(loginSort);
+ let loginListItems = gLoginList.shadowRoot.querySelectorAll(".login-list-item:not(#new-login-list-item, [hidden])");
+ is(loginListItems.length, 3, "The list should contain the three stored logins");
+ let timeUsed1 = gLoginList._logins[loginListItems[0].dataset.guid].login.timeLastUsed;
+ let timeUsed2 = gLoginList._logins[loginListItems[1].dataset.guid].login.timeLastUsed;
+ let timeUsed3 = gLoginList._logins[loginListItems[2].dataset.guid].login.timeLastUsed;
+ is(timeUsed1 > timeUsed2, true, "Logins sorted by timeLastUsed. First: " + timeUsed1 + "; Second: " + timeUsed2);
+ is(timeUsed2 > timeUsed3, true, "Logins sorted by timeLastUsed. Second: " + timeUsed2 + "; Third: " + timeUsed3);
+
+ // sort by title
+ loginSort.value = "name";
+ dispatchChangeEvent(loginSort);
+ loginListItems = gLoginList.shadowRoot.querySelectorAll(".login-list-item:not(#new-login-list-item, [hidden])");
+ let title1 = gLoginList._logins[loginListItems[0].dataset.guid].login.title;
+ let title2 = gLoginList._logins[loginListItems[1].dataset.guid].login.title;
+ let title3 = gLoginList._logins[loginListItems[2].dataset.guid].login.title;
+ is(title1.localeCompare(title2), -1, "Logins sorted by title. First: " + title1 + "; Second: " + title2);
+ is(title2.localeCompare(title3), -1, "Logins sorted by title. Second: " + title2 + "; Third: " + title3);
+
+ // sort by title in reverse alphabetical order
+ loginSort.value = "name-reverse";
+ dispatchChangeEvent(loginSort);
+ loginListItems = gLoginList.shadowRoot.querySelectorAll(".login-list-item:not(#new-login-list-item, [hidden])");
+ title1 = gLoginList._logins[loginListItems[0].dataset.guid].login.title;
+ title2 = gLoginList._logins[loginListItems[1].dataset.guid].login.title;
+ title3 = gLoginList._logins[loginListItems[2].dataset.guid].login.title;
+ let testDescription = "Logins sorted by title in reverse alphabetical order."
+ is(title1.localeCompare(title2), 1, `${testDescription} First: ${title2}; Second: ${title1}`);
+ is(title2.localeCompare(title3), 1, `${testDescription} Second: ${title3}; Third: ${title2}`);
+
+ // sort by last changed
+ loginSort.value = "last-changed";
+ dispatchChangeEvent(loginSort);
+ loginListItems = gLoginList.shadowRoot.querySelectorAll(".login-list-item:not(#new-login-list-item, [hidden])");
+ let pwChanged1 = gLoginList._logins[loginListItems[0].dataset.guid].login.timePasswordChanged;
+ let pwChanged2 = gLoginList._logins[loginListItems[1].dataset.guid].login.timePasswordChanged;
+ let pwChanged3 = gLoginList._logins[loginListItems[2].dataset.guid].login.timePasswordChanged;
+ is(pwChanged1 > pwChanged2, true, "Logins sorted by timePasswordChanged. First: " + pwChanged1 + "; Second: " + pwChanged2);
+ is(pwChanged2 > pwChanged3, true, "Logins sorted by timePasswordChanged. Second: " + pwChanged2 + "; Third: " + pwChanged3);
+
+ // sort by breached when there are breached logins
+ gLoginList.setBreaches(TEST_BREACHES_MAP);
+ loginSort.value = "alerts";
+ let vulnerableLogins = new Map();
+ gLoginList.setVulnerableLogins(vulnerableLogins);
+ dispatchChangeEvent(loginSort);
+ loginListItems = gLoginList.shadowRoot.querySelectorAll(".login-list-item:not(#new-login-list-item, [hidden])");
+ is(loginListItems[0].classList.contains("breached"), true, "Breached login should be displayed at top of list");
+ is(!loginListItems[1].classList.contains("breached"), true, "Non-breached login should be displayed below breached");
+
+ // sort by username
+ loginSort.value = "username";
+ dispatchChangeEvent(loginSort);
+ loginListItems = gLoginList.shadowRoot.querySelectorAll(".login-list-item:not(#new-login-list-item, [hidden])");
+ let username1 = gLoginList._logins[loginListItems[0].dataset.guid].login.username;
+ let username2 = gLoginList._logins[loginListItems[1].dataset.guid].login.username;
+ let username3 = gLoginList._logins[loginListItems[2].dataset.guid].login.username;
+ is(username1.localeCompare(username2), -1, "Logins sorted by username. First: " + username1 + "; Second: " + username2);
+ is(username2.localeCompare(username3), -1, "Logins sorted by username. Second: " + username2 + "; Third: " + username3);
+
+ // sort by username in reverse alphabetical order
+ loginSort.value = "username-reverse";
+ dispatchChangeEvent(loginSort);
+ loginListItems = gLoginList.shadowRoot.querySelectorAll(".login-list-item:not(#new-login-list-item, [hidden])");
+ username1 = gLoginList._logins[loginListItems[0].dataset.guid].login.username;
+ username2 = gLoginList._logins[loginListItems[1].dataset.guid].login.username;
+ username3 = gLoginList._logins[loginListItems[2].dataset.guid].login.username;
+ testDescription = "Logins sorted by username in reverse alphabetical order.";
+ is(username3.localeCompare(username2), -1, `${testDescription} First: ${username3} Second: ${username2}`);
+ is(username2.localeCompare(username1), -1, `${testDescription} Second: ${username2} Third: ${username1}`);
+
+ // sort by name when there are no breached logins
+ gLoginList.setBreaches(new Map());
+ loginSort.value = "alerts";
+ dispatchChangeEvent(loginSort);
+ loginListItems = gLoginList.shadowRoot.querySelectorAll(".login-list-item:not(#new-login-list-item, [hidden])");
+ title1 = gLoginList._logins[loginListItems[0].dataset.guid].login.title;
+ title2 = gLoginList._logins[loginListItems[1].dataset.guid].login.title;
+ is(title1.localeCompare(title2), -1, "Logins should be sorted alphabetically by hostname");
+});
+
+add_task(async function test_login_list_item_removed_next_selected() {
+ let logins = [];
+ for (let i = 0; i < 12; i++) {
+ let group = i % 2 ? "BB" : "AA";
+ // Create logins of the form `jared0AAa@example.com`,
+ // `jared1BBb@example.com`, `jared2AAc@example.com`, etc.
+ logins.push({
+ guid: `${i}`,
+ username: `jared${i}${group}${String.fromCharCode(97 + i)}@example.com`,
+ password: "omgsecret!!1",
+ origin: "https://www.example.com",
+ });
+ }
+
+ gLoginList.setLogins(logins);
+ let visibleLogins = gLoginList.shadowRoot.querySelectorAll(".login-list-item[data-guid]:not([hidden])");
+ await SimpleTest.promiseWaitForCondition(() => {
+ return visibleLogins.length == 12;
+ }, "Waiting for all logins to be visible");
+ is(gLoginList._selectedGuid, logins[0].guid, "login0 should be selected by default");
+
+ window.dispatchEvent(
+ new CustomEvent("AboutLoginsFilterLogins", {
+ bubbles: true,
+ detail: "BB",
+ })
+ );
+
+ await SimpleTest.promiseWaitForCondition(() => {
+ visibleLogins = gLoginList.shadowRoot.querySelectorAll(".login-list-item[data-guid]:not([hidden])");
+ return visibleLogins.length == 6;
+ }, "Only logins with BB in the username should be visible, visible count: " + visibleLogins.length);
+
+ is(gLoginList._selectedGuid, logins[0].guid, "login0 should still be selected after filtering");
+
+ gLoginList.loginRemoved({guid: logins[0].guid});
+
+ await SimpleTest.promiseWaitForCondition(() => {
+ return gLoginList._loginGuidsSortedOrder.length == 11;
+ }, "Waiting for login to get removed");
+
+ await SimpleTest.promiseWaitForCondition(() => {
+ visibleLogins = gLoginList.shadowRoot.querySelectorAll(".login-list-item[data-guid]:not([hidden])");
+ return visibleLogins.length == 6;
+ }, "the number of visible logins should not change, got " + visibleLogins.length);
+ is(gLoginList._selectedGuid, logins[1].guid,
+ "login1 should be selected after delete since the deleted login was not visible and login1 was the first in the list");
+
+ let loginToSwitchTo = gLoginList._logins[visibleLogins[1].dataset.guid].login;
+ window.dispatchEvent(
+ new CustomEvent("AboutLoginsLoginSelected", {
+ bubbles: true,
+ detail: loginToSwitchTo,
+ })
+ );
+ is(gLoginList._selectedGuid, loginToSwitchTo.guid, "login3 should be selected");
+
+ gLoginList.loginRemoved({guid: logins[3].guid});
+
+ await SimpleTest.promiseWaitForCondition(() => {
+ return gLoginList._loginGuidsSortedOrder.length == 10;
+ }, "Waiting for login to get removed");
+
+ await SimpleTest.promiseWaitForCondition(() => {
+ visibleLogins = gLoginList.shadowRoot.querySelectorAll(
+ ".login-list-item[data-guid]:not([hidden])"
+ );
+ return visibleLogins.length == 5;
+ }, "the number of filtered logins should decrease by 1");
+ is(gLoginList._selectedGuid, visibleLogins[0].dataset.guid, "the first login should now be selected");
+
+ gLoginList.loginRemoved({guid: logins[1].guid});
+
+ await SimpleTest.promiseWaitForCondition(() => {
+ return gLoginList._loginGuidsSortedOrder.length == 9;
+ }, "Waiting for login to get removed");
+
+ await SimpleTest.promiseWaitForCondition(() => {
+ visibleLogins = gLoginList.shadowRoot.querySelectorAll(
+ ".login-list-item[data-guid]:not([hidden])"
+ );
+ return visibleLogins.length == 4;
+ }, "the number of filtered logins should decrease by 1");
+ is(gLoginList._selectedGuid, visibleLogins[0].dataset.guid, "the first login should now still be selected");
+
+ loginToSwitchTo = gLoginList._logins[visibleLogins[3].dataset.guid].login;
+ window.dispatchEvent(
+ new CustomEvent("AboutLoginsLoginSelected", {
+ bubbles: true,
+ detail: loginToSwitchTo,
+ })
+ );
+ is(gLoginList._selectedGuid, visibleLogins[3].dataset.guid, "the last login should now still be selected");
+
+ gLoginList.loginRemoved({guid: logins[10].guid});
+
+ await SimpleTest.promiseWaitForCondition(() => {
+ return gLoginList._loginGuidsSortedOrder.length == 8;
+ }, "Waiting for login to get removed");
+
+ await SimpleTest.promiseWaitForCondition(() => {
+ visibleLogins = gLoginList.shadowRoot.querySelectorAll(
+ ".login-list-item[data-guid]:not([hidden])"
+ );
+ return visibleLogins.length == 4;
+ }, "the number of filtered logins should decrease by 1");
+ is(gLoginList._selectedGuid, visibleLogins[3].dataset.guid, "the last login should now be selected");
+
+ loginToSwitchTo = gLoginList._logins[visibleLogins[2].dataset.guid].login;
+ window.dispatchEvent(
+ new CustomEvent("AboutLoginsLoginSelected", {
+ bubbles: true,
+ detail: loginToSwitchTo,
+ })
+ );
+ is(gLoginList._selectedGuid, visibleLogins[2].dataset.guid, "the last login should now still be selected");
+});
+</script>
+
+</body>
+</html>
diff --git a/browser/components/aboutlogins/tests/chrome/test_menu_button.html b/browser/components/aboutlogins/tests/chrome/test_menu_button.html
new file mode 100644
index 0000000000..2beede09f1
--- /dev/null
+++ b/browser/components/aboutlogins/tests/chrome/test_menu_button.html
@@ -0,0 +1,260 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test the menu-button component
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test the menu-button component</title>
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+ <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script>
+ <script type="module" src="chrome://browser/content/aboutlogins/components/menu-button.mjs"></script>
+ <script src="aboutlogins_common.js"></script>
+
+ <link rel="stylesheet" href="/tests/SimpleTest/test.css"/>
+</head>
+<body>
+ <p id="display">
+ </p>
+<div id="content" style="display: none">
+ <iframe id="templateFrame" src="chrome://browser/content/aboutlogins/aboutLogins.html"
+ sandbox="allow-same-origin"></iframe>
+</div>
+<pre id="test">
+</pre>
+<script>
+/** Test the menu-button component **/
+
+let gMenuButton;
+add_setup(async () => {
+ let templateFrame = document.getElementById("templateFrame");
+ let displayEl = document.getElementById("display");
+ await importDependencies(templateFrame, displayEl);
+
+ gMenuButton = document.createElement("menu-button");
+ displayEl.appendChild(gMenuButton);
+ gMenuButton.style.marginInlineStart = "100px";
+
+ isnot(document.activeElement, gMenuButton, "menu-button should not be focused by default");
+ while (document.activeElement != gMenuButton) {
+ sendKey("TAB");
+ await new Promise(resolve => requestAnimationFrame(resolve));
+ }
+});
+
+add_task(async function test_menu_click_button () {
+ let menu = gMenuButton.shadowRoot.querySelector(".menu");
+ let menuButton = gMenuButton.shadowRoot.querySelector(".menu-button");
+ ok(menu.hidden, "menu should be hidden before being clicked");
+ await synthesizeMouseAtCenter(menuButton, {});
+ await new Promise(resolve => requestAnimationFrame(resolve));
+ ok(!menu.hidden, "menu should be visible after clicked");
+
+ let menuListSeparators = gMenuButton.shadowRoot.querySelectorAll(".menuitem-separator");
+ await synthesizeMouseAtCenter(menuListSeparators[0], {});
+ await new Promise(resolve => requestAnimationFrame(resolve));
+ ok(!menu.hidden, "menu should still be visible after menu separator has been clicked");
+
+ let menuListButtons = gMenuButton.shadowRoot.querySelectorAll(".menuitem-button");
+ await synthesizeMouseAtCenter(menuListButtons[0], {});
+ await new Promise(resolve => requestAnimationFrame(resolve));
+ ok(menu.hidden, "menu should be hidden after a button has been clicked");
+});
+
+add_task(async function test_menu_click_outside () {
+ let menu = gMenuButton.shadowRoot.querySelector(".menu");
+ let menuButton = gMenuButton.shadowRoot.querySelector(".menu-button");
+ ok(menu.hidden, "menu should be hidden before being clicked");
+ await synthesizeMouseAtCenter(menuButton, {});
+ await new Promise(resolve => requestAnimationFrame(resolve));
+ ok(!menu.hidden, "menu should be visible after clicked");
+
+ let menuListSeparators = gMenuButton.shadowRoot.querySelectorAll(".menuitem-separator");
+ await synthesizeMouseAtCenter(menuListSeparators[0], {});
+ await new Promise(resolve => requestAnimationFrame(resolve));
+ ok(!menu.hidden, "menu should still be visible after menu separator has been clicked");
+
+ let outsideEl = document.getElementById("test");
+ await synthesizeMouseAtCenter(outsideEl, {});
+ await new Promise(resolve => requestAnimationFrame(resolve));
+ ok(menu.hidden, "menu should be hidden after a click outside of the menu has been clicked");
+
+ for (let key of ["KEY_ArrowDown", "KEY_ArrowUp"]) {
+ synthesizeKey(key);
+ await new Promise(resolve => requestAnimationFrame(resolve));
+ ok(menu.hidden, `menu should still be hidden when ${key} is entered`);
+ }
+});
+
+add_task(async function test_menu_esc_after_click_disabled_item () {
+ let menu = gMenuButton.shadowRoot.querySelector(".menu");
+ let menuButton = gMenuButton.shadowRoot.querySelector(".menu-button");
+ ok(menu.hidden, "menu should be hidden before being clicked");
+ await synthesizeMouseAtCenter(menuButton, {});
+ await new Promise(resolve => requestAnimationFrame(resolve));
+ ok(!menu.hidden, "menu should be visible after clicked");
+
+ let menuListSeparators = gMenuButton.shadowRoot.querySelectorAll(".menuitem-separator");
+ await synthesizeMouseAtCenter(menuListSeparators[0], {});
+ await new Promise(resolve => requestAnimationFrame(resolve));
+ ok(!menu.hidden, "menu should still be visible after menu separator has been clicked");
+
+ sendKey("ESCAPE");
+ await new Promise(resolve => requestAnimationFrame(resolve));
+ ok(menu.hidden, "menu should be hidden after pressing 'escape'");
+});
+
+add_task(async function test_menu_open_close() {
+ is(document.activeElement, gMenuButton, "menu-button should be focused to start the test");
+
+ let menu = gMenuButton.shadowRoot.querySelector(".menu");
+ is(true, menu.hidden, "menu should be hidden before pressing 'space'");
+ sendKey("SPACE");
+ await new Promise(resolve => requestAnimationFrame(resolve));
+ ok(!menu.hidden, "menu should be visible after pressing 'space'");
+
+ sendKey("ESCAPE");
+ await new Promise(resolve => requestAnimationFrame(resolve));
+ ok(menu.hidden, "menu should be hidden after pressing 'escape'");
+ is(gMenuButton.shadowRoot.activeElement, gMenuButton.shadowRoot.querySelector(".menu-button"),
+ "the .menu-button should be focused after closing the menu via keyboard");
+
+ sendKey("RETURN");
+ let firstVisibleItem = gMenuButton.shadowRoot.querySelector(".menuitem-button:not([hidden])");
+ await SimpleTest.promiseWaitForCondition(() => firstVisibleItem.matches(":focus"),
+ "waiting for firstVisibleItem to get focus");
+
+ ok(!menu.hidden, "menu should be visible after pressing 'return'");
+ ok(firstVisibleItem.matches(":focus"), "firstVisibleItem should be focused after opening popup");
+
+ synthesizeKey("VK_TAB", { shiftKey: true });
+ await SimpleTest.promiseWaitForCondition(() => !firstVisibleItem.matches(":focus"),
+ "waiting for firstVisibleItem to lose focus");
+ ok(!firstVisibleItem.matches(":focus"), "firstVisibleItem should lose focus after tabbing away from it");
+ sendKey("TAB");
+ await SimpleTest.promiseWaitForCondition(() => firstVisibleItem.matches(":focus"),
+ "waiting for firstVisibleItem to get focus again");
+ ok(firstVisibleItem.matches(":focus"), "firstVisibleItem should be focused after tabbing to it again");
+ if (SpecialPowers.getBoolPref("signon.management.page.fileImport.enabled")) {
+ sendKey("TAB"); // Import from file
+ }
+ sendKey("TAB"); // Export
+ sendKey("TAB"); // Remove All Logins
+
+ if (navigator.platform == "Win32" || navigator.platform == "MacIntel") {
+ // The Import menuitem is only visible on Windows/macOS, where we will need another Tab
+ // press to get to the Preferences item.
+ let preferencesItem = gMenuButton.shadowRoot.querySelector(".menuitem-preferences");
+ sendKey("DOWN");
+ await SimpleTest.promiseWaitForCondition(() => preferencesItem.matches(":focus"),
+ "waiting for preferencesItem to gain focus");
+ ok(preferencesItem.matches(":focus"), `.menuitem-preferences should be now be focused (DOWN)`);
+ sendKey("UP");
+ await SimpleTest.promiseWaitForCondition(() => !preferencesItem.matches(":focus"),
+ `waiting for preferencesItem to lose focus (UP)`);
+ ok(!preferencesItem.matches(":focus"), `.menuitem-preferences should lose focus after pressing up`);
+
+ sendKey("TAB");
+ await SimpleTest.promiseWaitForCondition(() => preferencesItem.matches(":focus"),
+ "waiting for preferencesItem to get focus");
+ ok(preferencesItem.matches(":focus"), ".menuitem-preferences should be focused after tabbing to it");
+ }
+
+ let openPreferencesEvent = null;
+ ok(!menu.hidden, "menu should be visible before pressing 'space' on .menuitem-preferences");
+ window.addEventListener(
+ "AboutLoginsOpenPreferences",
+ event => openPreferencesEvent = event,
+ {once: true}
+ );
+ sendKey("SPACE");
+ ok(openPreferencesEvent, "AboutLoginsOpenPreferences event should be dispatched after pressing 'space' on .menuitem-preferences");
+ ok(menu.hidden, "menu should be hidden after pressing 'space' on .menuitem-preferences");
+
+ // Clean up task
+ sendKey("TAB");
+ synthesizeKey("VK_TAB", { shiftKey: true });
+});
+
+add_task(async function test_menu_keyboard_cycling() {
+ function waitForElementFocus(selector) {
+ return SimpleTest.promiseWaitForCondition(
+ () => gMenuButton.shadowRoot.querySelector(selector).matches(":focus"),
+ `waiting for ${selector} to be focused`
+ );
+ }
+
+ function getFocusedMenuItem() {
+ return gMenuButton.shadowRoot.querySelector(".menuitem-button:focus");
+ }
+
+ let allItems = [
+ "menuitem-export",
+ "menuitem-remove-all-logins",
+ "menuitem-preferences",
+ "menuitem-help",
+ ];
+ if (SpecialPowers.getBoolPref("signon.management.page.fileImport.enabled")) {
+ allItems = ["menuitem-import-file", ...allItems];
+ }
+ if (navigator.platform == "Win32" || navigator.platform == "MacIntel") {
+ allItems = ["menuitem-import-browser", ...allItems];
+ }
+
+ let menu = gMenuButton.shadowRoot.querySelector(".menu");
+
+ is(document.activeElement, gMenuButton, "menu-button should be focused to start the test");
+ is(true, menu.hidden, "menu should be hidden before pressing 'space'");
+
+ sendKey("RETURN");
+
+ await SimpleTest.promiseWaitForCondition(() => !menu.hidden, "waiting for menu to show");
+
+ ok(!menu.hidden, "menu should be visible after pressing 'enter'");
+
+ for (let item of allItems) {
+ await waitForElementFocus("." + item);
+ ok(
+ getFocusedMenuItem().classList.contains(item),
+ `.${item} should be selected after key is pressed`
+ );
+ sendKey("DOWN");
+ }
+
+
+ await waitForElementFocus("." + allItems[0]);
+ ok(
+ getFocusedMenuItem().classList.contains(allItems[0]),
+ "Focused item should not change if left arrow is pressed"
+ )
+ sendKey("LEFT");
+
+ await waitForElementFocus("." + allItems[0]);
+ ok(
+ getFocusedMenuItem().classList.contains(allItems[0]),
+ "Focused item should not change if right arrow is pressed"
+ )
+ sendKey("RIGHT");
+
+ await waitForElementFocus("." + allItems[0]);
+ ok(
+ getFocusedMenuItem().classList.contains(allItems[0]),
+ "Last item should cycle back to first item"
+ );
+
+ sendKey("UP");
+
+ let reversedItems = allItems.reverse();
+ for (let item of reversedItems) {
+ await waitForElementFocus("." + item);
+ ok(
+ getFocusedMenuItem().classList.contains(item),
+ `.${item} should be selected after up key is pressed`
+ );
+ sendKey("UP");
+ }
+});
+</script>
+
+</body>
+</html>
diff --git a/browser/components/aboutlogins/tests/unit/head.js b/browser/components/aboutlogins/tests/unit/head.js
new file mode 100644
index 0000000000..938e06e3c0
--- /dev/null
+++ b/browser/components/aboutlogins/tests/unit/head.js
@@ -0,0 +1,22 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { LoginTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/LoginTestUtils.sys.mjs"
+);
+const { LoginHelper } = ChromeUtils.importESModule(
+ "resource://gre/modules/LoginHelper.sys.mjs"
+);
+
+const TestData = LoginTestUtils.testData;
+const newPropertyBag = LoginHelper.newPropertyBag;
+
+/**
+ * All the tests are implemented with add_task, this starts them automatically.
+ */
+function run_test() {
+ do_get_profile();
+ run_next_test();
+}
diff --git a/browser/components/aboutlogins/tests/unit/test_getPotentialBreachesByLoginGUID.js b/browser/components/aboutlogins/tests/unit/test_getPotentialBreachesByLoginGUID.js
new file mode 100644
index 0000000000..a868572a6a
--- /dev/null
+++ b/browser/components/aboutlogins/tests/unit/test_getPotentialBreachesByLoginGUID.js
@@ -0,0 +1,327 @@
+/**
+ * Test LoginBreaches.getPotentialBreachesByLoginGUID
+ */
+
+"use strict";
+
+const { RemoteSettings } = ChromeUtils.importESModule(
+ "resource://services-settings/remote-settings.sys.mjs"
+);
+
+// Initializing BrowserGlue requires a profile on Windows.
+do_get_profile();
+
+const gBrowserGlue = Cc["@mozilla.org/browser/browserglue;1"].getService(
+ Ci.nsIObserver
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ LoginBreaches: "resource:///modules/LoginBreaches.sys.mjs",
+});
+
+const TEST_BREACHES = [
+ {
+ AddedDate: "2018-12-20T23:56:26Z",
+ BreachDate: "2018-12-16",
+ Domain: "breached.com",
+ Name: "Breached",
+ PwnCount: 1643100,
+ DataClasses: ["Email addresses", "Usernames", "Passwords", "IP addresses"],
+ _status: "synced",
+ id: "047940fe-d2fd-4314-b636-b4a952ee0043",
+ last_modified: "1541615610052",
+ schema: "1541615609018",
+ },
+ {
+ AddedDate: "2018-12-20T23:56:26Z",
+ BreachDate: "2018-12-16",
+ Domain: "breached-subdomain.host.com",
+ Name: "Only a Sub-Domain was Breached",
+ PwnCount: 2754200,
+ DataClasses: ["Email addresses", "Usernames", "Passwords", "IP addresses"],
+ _status: "synced",
+ id: "047940fe-d2fd-4314-b636-b4a952ee0044",
+ last_modified: "1541615610052",
+ schema: "1541615609018",
+ },
+ {
+ AddedDate: "2018-12-20T23:56:26Z",
+ BreachDate: "2018-12-16",
+ Domain: "breached-site-without-passwords.com",
+ Name: "Breached Site without passwords",
+ PwnCount: 987654,
+ DataClasses: ["Email addresses", "Usernames", "IP addresses"],
+ _status: "synced",
+ id: "047940fe-d2fd-4314-b636-b4a952ee0045",
+ last_modified: "1541615610052",
+ schema: "1541615609018",
+ },
+];
+
+const CRASHING_URI_LOGIN = LoginTestUtils.testData.formLogin({
+ origin: "chrome://grwatcher",
+ formActionOrigin: "https://www.example.com",
+ username: "username",
+ password: "password",
+ timePasswordChanged: new Date("2018-12-15").getTime(),
+});
+const NOT_BREACHED_LOGIN = LoginTestUtils.testData.formLogin({
+ origin: "https://www.example.com",
+ formActionOrigin: "https://www.example.com",
+ username: "username",
+ password: "password",
+ timePasswordChanged: new Date("2018-12-15").getTime(),
+});
+const BREACHED_LOGIN = LoginTestUtils.testData.formLogin({
+ origin: "https://www.breached.com",
+ formActionOrigin: "https://www.breached.com",
+ username: "username",
+ password: "password",
+ timePasswordChanged: new Date("2018-12-15").getTime(),
+});
+const NOT_BREACHED_SUBDOMAIN_LOGIN = LoginTestUtils.testData.formLogin({
+ origin: "https://not-breached-subdomain.host.com",
+ formActionOrigin: "https://not-breached-subdomain.host.com",
+ username: "username",
+ password: "password",
+});
+const BREACHED_SUBDOMAIN_LOGIN = LoginTestUtils.testData.formLogin({
+ origin: "https://breached-subdomain.host.com",
+ formActionOrigin: "https://breached-subdomain.host.com",
+ username: "username",
+ password: "password",
+ timePasswordChanged: new Date("2018-12-15").getTime(),
+});
+const LOGIN_FOR_BREACHED_SITE_WITHOUT_PASSWORDS =
+ LoginTestUtils.testData.formLogin({
+ origin: "https://breached-site-without-passwords.com",
+ formActionOrigin: "https://breached-site-without-passwords.com",
+ username: "username",
+ password: "password",
+ timePasswordChanged: new Date("2018-12-15").getTime(),
+ });
+const LOGIN_WITH_NON_STANDARD_URI = LoginTestUtils.testData.formLogin({
+ origin: "someApp://random/path/to/login",
+ formActionOrigin: "someApp://random/path/to/login",
+ username: "username",
+ password: "password",
+ timePasswordChanged: new Date("2018-12-15").getTime(),
+});
+
+add_task(async function test_notBreachedLogin() {
+ await Services.logins.addLoginAsync(NOT_BREACHED_LOGIN);
+ const breachesByLoginGUID =
+ await LoginBreaches.getPotentialBreachesByLoginGUID(
+ [NOT_BREACHED_LOGIN],
+ TEST_BREACHES
+ );
+ Assert.strictEqual(
+ breachesByLoginGUID.size,
+ 0,
+ "Should be 0 breached logins."
+ );
+});
+
+add_task(async function test_breachedLogin() {
+ await Services.logins.addLoginAsync(BREACHED_LOGIN);
+ const breachesByLoginGUID =
+ await LoginBreaches.getPotentialBreachesByLoginGUID(
+ [NOT_BREACHED_LOGIN, BREACHED_LOGIN],
+ TEST_BREACHES
+ );
+ Assert.strictEqual(
+ breachesByLoginGUID.size,
+ 1,
+ "Should be 1 breached login: " + BREACHED_LOGIN.origin
+ );
+ Assert.strictEqual(
+ breachesByLoginGUID.get(BREACHED_LOGIN.guid).breachAlertURL,
+ "https://monitor.firefox.com/breach-details/Breached?utm_source=firefox-desktop&utm_medium=referral&utm_campaign=about-logins&utm_content=about-logins",
+ "Breach alert link should be equal to the breachAlertURL"
+ );
+});
+
+add_task(async function test_breachedLoginAfterCrashingUriLogin() {
+ await Services.logins.addLoginAsync(CRASHING_URI_LOGIN);
+
+ const breachesByLoginGUID =
+ await LoginBreaches.getPotentialBreachesByLoginGUID(
+ [CRASHING_URI_LOGIN, BREACHED_LOGIN],
+ TEST_BREACHES
+ );
+ Assert.strictEqual(
+ breachesByLoginGUID.size,
+ 1,
+ "Should be 1 breached login: " + BREACHED_LOGIN.origin
+ );
+ Assert.strictEqual(
+ breachesByLoginGUID.get(BREACHED_LOGIN.guid).breachAlertURL,
+ "https://monitor.firefox.com/breach-details/Breached?utm_source=firefox-desktop&utm_medium=referral&utm_campaign=about-logins&utm_content=about-logins",
+ "Breach alert link should be equal to the breachAlertURL"
+ );
+});
+
+add_task(async function test_notBreachedSubdomain() {
+ await Services.logins.addLoginAsync(NOT_BREACHED_SUBDOMAIN_LOGIN);
+
+ const breachesByLoginGUID =
+ await LoginBreaches.getPotentialBreachesByLoginGUID(
+ [NOT_BREACHED_LOGIN, NOT_BREACHED_SUBDOMAIN_LOGIN],
+ TEST_BREACHES
+ );
+ Assert.strictEqual(
+ breachesByLoginGUID.size,
+ 0,
+ "Should be 0 breached logins."
+ );
+});
+
+add_task(async function test_breachedSubdomain() {
+ await Services.logins.addLoginAsync(BREACHED_SUBDOMAIN_LOGIN);
+
+ const breachesByLoginGUID =
+ await LoginBreaches.getPotentialBreachesByLoginGUID(
+ [NOT_BREACHED_SUBDOMAIN_LOGIN, BREACHED_SUBDOMAIN_LOGIN],
+ TEST_BREACHES
+ );
+ Assert.strictEqual(
+ breachesByLoginGUID.size,
+ 1,
+ "Should be 1 breached login: " + BREACHED_SUBDOMAIN_LOGIN.origin
+ );
+});
+
+add_task(async function test_breachedSiteWithoutPasswords() {
+ await Services.logins.addLoginAsync(
+ LOGIN_FOR_BREACHED_SITE_WITHOUT_PASSWORDS
+ );
+
+ const breachesByLoginGUID =
+ await LoginBreaches.getPotentialBreachesByLoginGUID(
+ [LOGIN_FOR_BREACHED_SITE_WITHOUT_PASSWORDS],
+ TEST_BREACHES
+ );
+ Assert.strictEqual(
+ breachesByLoginGUID.size,
+ 0,
+ "Should be 0 breached login: " +
+ LOGIN_FOR_BREACHED_SITE_WITHOUT_PASSWORDS.origin
+ );
+});
+
+add_task(async function test_breachAlertHiddenAfterDismissal() {
+ BREACHED_LOGIN.guid = "{d2de5ac1-4de6-e544-a7af-1f75abcba92b}";
+
+ await Services.logins.initializationPromise;
+ const storageJSON = Services.logins.wrappedJSObject._storage.wrappedJSObject;
+
+ storageJSON.recordBreachAlertDismissal(BREACHED_LOGIN.guid);
+
+ const breachesByLoginGUID =
+ await LoginBreaches.getPotentialBreachesByLoginGUID(
+ [BREACHED_LOGIN, NOT_BREACHED_LOGIN],
+ TEST_BREACHES
+ );
+ Assert.strictEqual(
+ breachesByLoginGUID.size,
+ 0,
+ "Should be 0 breached logins after dismissal: " + BREACHED_LOGIN.origin
+ );
+
+ info("Clear login storage");
+ Services.logins.removeAllUserFacingLogins();
+
+ const breachesByLoginGUID2 =
+ await LoginBreaches.getPotentialBreachesByLoginGUID(
+ [BREACHED_LOGIN, NOT_BREACHED_LOGIN],
+ TEST_BREACHES
+ );
+ Assert.strictEqual(
+ breachesByLoginGUID2.size,
+ 1,
+ "Breached login should re-appear after clearing storage: " +
+ BREACHED_LOGIN.origin
+ );
+});
+
+add_task(async function test_newBreachAfterDismissal() {
+ TEST_BREACHES[0].AddedDate = new Date().toISOString();
+
+ const breachesByLoginGUID =
+ await LoginBreaches.getPotentialBreachesByLoginGUID(
+ [BREACHED_LOGIN, NOT_BREACHED_LOGIN],
+ TEST_BREACHES
+ );
+
+ Assert.strictEqual(
+ breachesByLoginGUID.size,
+ 1,
+ "Should be 1 breached login after new breach following the dismissal of a previous breach: " +
+ BREACHED_LOGIN.origin
+ );
+});
+
+add_task(async function test_ExceptionsThrownByNonStandardURIsAreCaught() {
+ await Services.logins.addLoginAsync(LOGIN_WITH_NON_STANDARD_URI);
+
+ const breachesByLoginGUID =
+ await LoginBreaches.getPotentialBreachesByLoginGUID(
+ [LOGIN_WITH_NON_STANDARD_URI, BREACHED_LOGIN],
+ TEST_BREACHES
+ );
+
+ Assert.strictEqual(
+ breachesByLoginGUID.size,
+ 1,
+ "Exceptions thrown by logins with non-standard URIs should be caught."
+ );
+});
+
+add_task(async function test_setBreachesFromRemoteSettingsSync() {
+ const login = NOT_BREACHED_SUBDOMAIN_LOGIN;
+ const nowExampleIsInBreachedRecords = [
+ {
+ AddedDate: "2018-12-20T23:56:26Z",
+ BreachDate: "2018-12-16",
+ Domain: "not-breached-subdomain.host.com",
+ Name: "not-breached-subdomain.host.com is now breached!",
+ PwnCount: 1643100,
+ DataClasses: [
+ "Email addresses",
+ "Usernames",
+ "Passwords",
+ "IP addresses",
+ ],
+ _status: "synced",
+ id: "047940fe-d2fd-4314-b636-b4a952ee0044",
+ last_modified: "1541615610052",
+ schema: "1541615609018",
+ },
+ ];
+ async function emitSync() {
+ await RemoteSettings(LoginBreaches.REMOTE_SETTINGS_COLLECTION).emit(
+ "sync",
+ { data: { current: nowExampleIsInBreachedRecords } }
+ );
+ }
+
+ const beforeSyncBreachesByLoginGUID =
+ await LoginBreaches.getPotentialBreachesByLoginGUID([login]);
+ Assert.strictEqual(
+ beforeSyncBreachesByLoginGUID.size,
+ 0,
+ "Should be 0 breached login before not-breached-subdomain.host.com is added to fxmonitor-breaches collection and synced: "
+ );
+ gBrowserGlue.observe(null, "browser-glue-test", "add-breaches-sync-handler");
+ const db = RemoteSettings(LoginBreaches.REMOTE_SETTINGS_COLLECTION).db;
+ await db.importChanges({}, Date.now(), [nowExampleIsInBreachedRecords[0]]);
+ await emitSync();
+
+ const breachesByLoginGUID =
+ await LoginBreaches.getPotentialBreachesByLoginGUID([login]);
+ Assert.strictEqual(
+ breachesByLoginGUID.size,
+ 1,
+ "Should be 1 breached login after not-breached-subdomain.host.com is added to fxmonitor-breaches collection and synced: "
+ );
+});
diff --git a/browser/components/aboutlogins/tests/unit/xpcshell.ini b/browser/components/aboutlogins/tests/unit/xpcshell.ini
new file mode 100644
index 0000000000..e827d6d688
--- /dev/null
+++ b/browser/components/aboutlogins/tests/unit/xpcshell.ini
@@ -0,0 +1,7 @@
+[DEFAULT]
+skip-if = toolkit == 'android' # bug 1730213
+head = head.js
+firefox-appdir = browser
+
+[test_getPotentialBreachesByLoginGUID.js]
+tags = remote-settings