diff options
Diffstat (limited to '')
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> |