summaryrefslogtreecommitdiffstats
path: root/browser/components/aboutlogins/tests/chrome
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--browser/components/aboutlogins/tests/chrome/.eslintrc.js16
-rw-r--r--browser/components/aboutlogins/tests/chrome/aboutlogins_common.js98
-rw-r--r--browser/components/aboutlogins/tests/chrome/chrome.toml16
-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.html577
-rw-r--r--browser/components/aboutlogins/tests/chrome/test_login_list.html712
-rw-r--r--browser/components/aboutlogins/tests/chrome/test_menu_button.html260
9 files changed, 2080 insertions, 0 deletions
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..e881b6ca22
--- /dev/null
+++ b/browser/components/aboutlogins/tests/chrome/aboutlogins_common.js
@@ -0,0 +1,98 @@
+"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() {},
+ disconnectRoot() {},
+ 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.toml b/browser/components/aboutlogins/tests/chrome/chrome.toml
new file mode 100644
index 0000000000..2653b6e821
--- /dev/null
+++ b/browser/components/aboutlogins/tests/chrome/chrome.toml
@@ -0,0 +1,16 @@
+[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..284fb69c65
--- /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..5403487599
--- /dev/null
+++ b/browser/components/aboutlogins/tests/chrome/test_login_item.html
@@ -0,0 +1,577 @@
+<!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/login-command-button.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 type="module" src="chrome://browser/content/aboutlogins/components/login-alert.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_LOGIN_3 = {
+ guid: "987654321",
+ origin: "https://example.com",
+ username: "user2",
+ password: "pass2",
+ timeCreated: "4000",
+ timePasswordChanged: "4000",
+ timeLastUsed: "4000",
+};
+
+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 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);
+ await asyncElementRendered();
+
+ 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("login-breach-alert");
+ ok(!isHidden(breachAlert), "Breach alert should be visible");
+ is(breachAlert.hostname, TEST_LOGIN_1.origin, "Link in the text should point to the login origin");
+ let vulernableAlert = gLoginItem.shadowRoot.querySelector("login-vulnerable-password-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("login-breach-alert");
+ ok(isHidden(breachAlert), "Breach alert should not be visible on login without an associated breach.");
+ let vulernableAlert = gLoginItem.shadowRoot.querySelector("login-vulnerable-password-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("login-breach-alert");
+ ok(isHidden(breachAlert), "Breach alert should not be visible on login without an associated breach.");
+ let vulernableAlert = gLoginItem.shadowRoot.querySelector("login-vulnerable-password-alert");
+ ok(!isHidden(vulernableAlert), "Vulnerable alert should be visible");
+ is(vulernableAlert.shadowRoot.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("login-breach-alert");
+ ok(isHidden(breachAlert), "Breach alert should not be visible on login without an associated breach.");
+ let vulernableAlert = gLoginItem.shadowRoot.querySelector("login-vulnerable-password-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_keyboard_shortcut() {
+ gLoginItem.setLogin(TEST_LOGIN_1);
+ let usernameInput = gLoginItem.shadowRoot.querySelector("input[name='username']");
+ usernameInput.placeholder = "dummy placeholder";
+ const ev = new KeyboardEvent("keydown", { altKey:true, key:"Enter" });
+ window.dispatchEvent(ev);
+ 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_edit_login_cancel_keyboard_shortcut() {
+ 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");
+
+ const ev = new KeyboardEvent("keydown", { key: "Escape" });
+ window.dispatchEvent(ev);
+
+ 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-password-button should be enabled");
+
+ copyUsernameButton.click();
+ await asyncElementRendered();
+
+ copyPasswordButton.click();
+ await asyncElementRendered();
+
+ let loginNoUsername = Object.assign({}, TEST_LOGIN_2, {username: ""});
+ gLoginItem.setLogin(loginNoUsername);
+ await asyncElementRendered();
+
+ ok(copyUsernameButton.disabled, "The copy-username-button should be disabled when the username is empty");
+ ok(!copyPasswordButton.disabled, "The copy-password-button should be enabled");
+
+ copyPasswordButton.click();
+ });
+
+ add_task(async function test_login_timeline_state() {
+ gLoginItem.setLogin(TEST_LOGIN_1);
+ await asyncElementRendered();
+
+ let timeline = getLoginTimeline(gLoginItem);
+ ok(!isHidden(timeline), "Timeline should be visible");
+ is(timeline.history.length, 3, "All 3 timestamps (created, updated, used) must be shown")
+ let actions = timeline.shadowRoot.querySelectorAll(".action");
+ verifyTimelineActions(actions, [
+ "login-item-timeline-action-created",
+ "login-item-timeline-action-updated",
+ "login-item-timeline-action-used",
+ ]);
+
+ gLoginItem.setLogin(TEST_LOGIN_3);
+ await asyncElementRendered();
+
+ timeline = getLoginTimeline(gLoginItem);
+ ok(!isHidden(timeline), "Timeline should be visible");
+ is(timeline.history.length, 2, "Created and Last Used must be shown only")
+ actions = timeline.shadowRoot.querySelectorAll(".action");
+ verifyTimelineActions(actions, [
+ "login-item-timeline-action-created",
+ "login-item-timeline-action-used",
+ ]);
+ });
+
+</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..4c811bf2eb
--- /dev/null
+++ b/browser/components/aboutlogins/tests/chrome/test_login_list.html
@@ -0,0 +1,712 @@
+<!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 type="module" src="chrome://browser/content/aboutlogins/components/login-command-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 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]);
+ await asyncElementRendered();
+
+ 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].shadowRoot.querySelector(".subtitle");
+ 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]);
+ await asyncElementRendered();
+ await asyncElementRendered();
+
+ 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].shadowRoot.querySelector("list-item").shadowRoot.querySelector(".list-item").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].title, TEST_LOGIN_1.title,
+ "login-list-item origin should match");
+ is(loginListItems[0].username, 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);
+ await asyncElementRendered();
+ await asyncElementRendered();
+
+ let loginListItems = gLoginList.shadowRoot.querySelectorAll("login-list-item:not(#new-login-list-item, [hidden])");
+ let alertIcon = loginListItems[0].shadowRoot.querySelector(".alert-icon");
+ is(loginListItems[0].dataset.guid, TEST_LOGIN_1.guid, "The first login should be TEST_LOGIN_1");
+ ok(loginListItems[0].notificationIcon !== "vulnerable", "The first login should not have the .vulnerable class");
+ ok(loginListItems[0].notificationIcon === "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].notificationIcon === "vulnerable", "The second login should have the .vulnerable class");
+ ok(loginListItems[1].notificationIcon !== ("breached"), "The second login should not have the .breached class");
+ alertIcon = loginListItems[1].shadowRoot.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].shadowRoot.querySelector(".alert-icon");;
+ ok(loginListItems[2].notificationIcon !== "vulnerable", "The third login should not have the .vulnerable class");
+ ok(loginListItems[2].notificationIcon !== "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() {
+ await asyncElementRendered();
+
+ function findItemFromUsername(list, username) {
+ for (let item of list) {
+ if ((item._cachedUsername || (item._cachedUsername = item.username)) == 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]);
+ await asyncElementRendered();
+
+ 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].username, "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-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].title, TEST_LOGIN_1.title,
+ "login-list-item origin should match");
+ is(loginListItems[0].username, modifiedLogin.username,
+ "login-list-item username should have been updated");
+ is(loginListItems[1].username, 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].title, newLogin.title,
+ "login-list-item origin should match");
+ is(loginListItems[2].username, 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].notificationIcon === "breached", true, "Breached login should be displayed at top of list");
+ is(loginListItems[1].notificationIcon !== "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>