const TEST_ORIGIN = "https://example.org"; const TEST_URL_PATH = `${TEST_ORIGIN}${DIRECTORY_PATH}form_basic_login.html`; const PREF = "browser.autocomplete.removeRecords.enabled"; const AC_L10N = new Localization( ["toolkit/main-window/autocomplete.ftl"], true ); const LOGINS_DATA = [ { origin: TEST_ORIGIN, username: "user1", password: "pass1" }, { origin: TEST_ORIGIN, username: "user2", password: "pass2" }, ]; add_setup(async () => { await Services.logins.addLogins( LOGINS_DATA.map(login => LoginTestUtils.testData.formLogin(login)) ); }); // Every flyout delete dispatches a real reauthentication request. Stub it for // the whole file so no task can raise a genuine OS auth prompt and wedge the // browser. let gReauthAuthorized = false; let gReauthCalls = []; add_setup(async () => { const { LoginHelper } = ChromeUtils.importESModule( "resource://gre/modules/LoginHelper.sys.mjs" ); const original = LoginHelper.requestReauth; LoginHelper.requestReauth = async (...args) => { gReauthCalls.push(args); // A real prompt takes focus, which tears the dropdown down before it // resolves. Do the same so callers see the ordering they see in the wild. const popup = document.getElementById("PopupAutoComplete"); if (popup.state != "closed") { popup.hidePopup(); await TestUtils.waitForCondition( () => popup.state == "closed", "Wait for the dropdown to close before reauthentication resolves" ); } return { isAuthorized: gReauthAuthorized, telemetryEvent: null }; }; registerCleanupFunction(() => { LoginHelper.requestReauth = original; }); }); function getSecondaryAction(popup, index) { const item = popup.firstChild.getItemAtIndex(index); const rowItem = item.querySelector("autocomplete-row-item"); const button = rowItem.shadowRoot.querySelector( "moz-button.secondary-action" ); return { item, rowItem, button }; } async function selectRow(item, index) { for (let i = 0; i <= index; i++) { await EventUtils.synthesizeKey("KEY_ArrowDown"); } await TestUtils.waitForCondition( () => item.hasAttribute("selected"), "Wait for the login row to become active" ); } function waitForFlyout(itemLabel) { return TestUtils.waitForCondition( () => [...document.querySelectorAll("menupopup")].find( m => m.state == "open" && [...m.querySelectorAll("menuitem")].some( mi => mi.getAttribute("label") === itemLabel ) ), "Wait for the flyout menu to open" ); } async function openFlyoutByKeyboard(item, rowItem) { await selectRow(item, 0); await EventUtils.synthesizeKey("KEY_Tab"); await TestUtils.waitForCondition( () => rowItem.hasAttribute("subfocused"), "Wait for the secondary action to become sub-focused" ); await EventUtils.synthesizeKey("KEY_Enter"); return waitForFlyout(rowItem.actions.secondary.actions[0].label); } async function openFlyout(popup, button, label) { await TestUtils.waitForCondition( () => button.checkVisibility({ checkVisibilityCSS: true }), "Wait for the secondary action button to be visible" ); // The click opens the flyout on mousedown, but a stray event can dismiss it // before it settles; re-click while the panel is still up (a missed click // would hit the row and close it) until the flyout sticks. const menupopup = await TestUtils.waitForCondition(() => { const found = [...popup.querySelectorAll("menupopup")].find(m => [...m.querySelectorAll("menuitem")].some( mi => mi.getAttribute("label") === label ) ); if (found) { return found; } if (popup.state == "open") { EventUtils.synthesizeMouseAtCenter(button, {}); } return false; }, "Wait for the flyout menu to open"); if (menupopup.state != "open") { await BrowserTestUtils.waitForEvent(menupopup, "popupshown"); } return menupopup; } add_task(async function test_edit_icon_when_pref_disabled() { await SpecialPowers.pushPrefEnv({ set: [[PREF, false]] }); await BrowserTestUtils.withNewTab( { gBrowser, url: TEST_URL_PATH }, async function (browser) { const popup = document.getElementById("PopupAutoComplete"); await openACPopup(popup, browser, "#form-basic-username"); const { rowItem, button } = getSecondaryAction(popup, 0); Assert.equal( rowItem.actions.secondary.type, "edit", "Login row keeps the edit secondary action when the pref is off" ); Assert.ok( button.iconSrc.endsWith("edit.svg"), "The secondary action shows the edit icon" ); await closePopup(popup); } ); await SpecialPowers.popPrefEnv(); }); add_task(async function test_flyout_when_pref_enabled() { await SpecialPowers.pushPrefEnv({ set: [[PREF, true]] }); await BrowserTestUtils.withNewTab( { gBrowser, url: TEST_URL_PATH }, async function (browser) { const popup = document.getElementById("PopupAutoComplete"); await openACPopup(popup, browser, "#form-basic-username"); const { item, rowItem, button } = getSecondaryAction(popup, 0); Assert.equal( rowItem.actions.secondary.type, "menupopup", "Login row shows the flyout secondary action when the pref is on" ); Assert.ok( button.iconSrc.endsWith("more.svg"), "The secondary action shows the more icon" ); await selectRow(item, 0); await TestUtils.waitForCondition( () => button.checkVisibility({ checkVisibilityCSS: true }), "Secondary action is visible when the row is active" ); const [editAction, deleteAction] = rowItem.actions.secondary.actions; const menupopup = await openFlyout(popup, button, editAction.label); const labels = [...menupopup.querySelectorAll("menuitem")].map(mi => mi.getAttribute("label") ); Assert.deepEqual( labels, [editAction.label, deleteAction.label], "Flyout contains the Edit and Delete items" ); Assert.ok( popup.contains(menupopup), "The flyout is hosted inside the autocomplete panel" ); Assert.equal(popup.state, "open", "The autocomplete popup stays open"); const menuHidden = BrowserTestUtils.waitForEvent( menupopup, "popuphiding" ); menupopup.hidePopup(); await menuHidden; await TestUtils.waitForCondition( () => !popup.contains(menupopup), "The flyout menupopup is removed after closing" ); Assert.equal( popup.state, "open", "The autocomplete popup remains open after the flyout closes" ); // The flyout lives inside the panel, so its popup events bubble up to // the panel's own listeners and to AutoCompleteParent's. Neither may // mistake them for the panel itself opening or closing, otherwise the // actor tears itself down and every later secondary action no-ops. Assert.ok( popup.mPopupOpen, "The panel still considers itself open after the flyout closes" ); const { AutoCompleteParent } = ChromeUtils.importESModule( "moz-src:///toolkit/actors/AutoCompleteParent.sys.mjs" ); Assert.ok( AutoCompleteParent.getCurrentActor(), "The autocomplete actor is still current after the flyout closes" ); await closePopup(popup); } ); await SpecialPowers.popPrefEnv(); }); add_task(async function test_flyout_opens_via_keyboard() { await SpecialPowers.pushPrefEnv({ set: [[PREF, true]] }); await BrowserTestUtils.withNewTab( { gBrowser, url: TEST_URL_PATH }, async function (browser) { const popup = document.getElementById("PopupAutoComplete"); await openACPopup(popup, browser, "#form-basic-username"); const { item, rowItem } = getSecondaryAction(popup, 0); await selectRow(item, 0); await EventUtils.synthesizeKey("KEY_Tab"); await TestUtils.waitForCondition( () => rowItem.hasAttribute("subfocused"), "Wait for the secondary action to become sub-focused" ); const editLabel = rowItem.actions.secondary.actions[0].label; await EventUtils.synthesizeKey("KEY_Enter"); const menupopup = await TestUtils.waitForCondition( () => [...document.querySelectorAll("menupopup")].find(m => [...m.querySelectorAll("menuitem")].some( mi => mi.getAttribute("label") === editLabel ) ), "Wait for the flyout to open from the keyboard" ); Assert.equal(popup.state, "open", "The autocomplete popup stays open"); const menuHidden = BrowserTestUtils.waitForEvent( menupopup, "popuphiding" ); menupopup.hidePopup(); await menuHidden; await closePopup(popup); } ); await SpecialPowers.popPrefEnv(); }); // The action button must stay pinned to the row for as long as the flyout is // open, not only while the row is hovered or selected. add_task(async function test_action_button_persists_while_flyout_open() { await SpecialPowers.pushPrefEnv({ set: [[PREF, true]] }); await BrowserTestUtils.withNewTab( { gBrowser, url: TEST_URL_PATH }, async function (browser) { const popup = document.getElementById("PopupAutoComplete"); await openACPopup(popup, browser, "#form-basic-username"); const { item, rowItem, button } = getSecondaryAction(popup, 0); await selectRow(item, 0); const editLabel = rowItem.actions.secondary.actions[0].label; const menupopup = await openFlyout(popup, button, editLabel); Assert.ok( rowItem.hasAttribute("menuopen"), "The row is marked open while the flyout is showing" ); const menuHidden = BrowserTestUtils.waitForEvent( menupopup, "popuphiding" ); menupopup.hidePopup(); await menuHidden; await TestUtils.waitForCondition( () => !rowItem.hasAttribute("menuopen"), "The open marker is cleared after the flyout closes" ); await closePopup(popup); } ); await SpecialPowers.popPrefEnv(); }); // Popup should route each item to its own action index through // the parent's dispatch path. add_task(async function test_flyout_actions_dispatch_by_index() { const { AutoCompleteParent } = ChromeUtils.importESModule( "moz-src:///toolkit/actors/AutoCompleteParent.sys.mjs" ); await SpecialPowers.pushPrefEnv({ set: [[PREF, true]] }); await BrowserTestUtils.withNewTab( { gBrowser, url: TEST_URL_PATH }, async function (browser) { const popup = document.getElementById("PopupAutoComplete"); await openACPopup(popup, browser, "#form-basic-username"); const { item, rowItem } = getSecondaryAction(popup, 0); await selectRow(item, 0); const calls = []; const original = AutoCompleteParent.prototype.selectAutoCompleteEntry; AutoCompleteParent.prototype.selectAutoCompleteEntry = function ( ...args ) { calls.push(args); return original.apply(this, args); }; try { const { actions } = rowItem.actions.secondary; Assert.equal(actions.length, 2, "Flyout has two actions"); actions[0].action(); Assert.deepEqual( calls.at(-1), [true, 0], "First flyout item dispatches secondary action index 0" ); actions[1].action(); Assert.deepEqual( calls.at(-1), [true, 1], "Second flyout item dispatches secondary action index 1" ); } finally { AutoCompleteParent.prototype.selectAutoCompleteEntry = original; } await closePopup(popup); } ); await SpecialPowers.popPrefEnv(); }); // Closing the autocomplete panel should tear down any flyout a row left open. add_task(async function test_flyout_closes_with_panel() { await SpecialPowers.pushPrefEnv({ set: [[PREF, true]] }); await BrowserTestUtils.withNewTab( { gBrowser, url: TEST_URL_PATH }, async function (browser) { const popup = document.getElementById("PopupAutoComplete"); await openACPopup(popup, browser, "#form-basic-username"); const { item, rowItem, button } = getSecondaryAction(popup, 0); await selectRow(item, 0); const editLabel = rowItem.actions.secondary.actions[0].label; const menupopup = await openFlyout(popup, button, editLabel); await closePopup(popup); await TestUtils.waitForCondition( () => !popup.contains(menupopup), "The flyout is removed when the autocomplete panel closes" ); Assert.ok( !rowItem.hasAttribute("menuopen"), "The row's open marker is cleared when the panel closes" ); } ); await SpecialPowers.popPrefEnv(); }); add_task(async function test_activating_flyout_item_keeps_panel_open() { await SpecialPowers.pushPrefEnv({ set: [[PREF, true]] }); await BrowserTestUtils.withNewTab( { gBrowser, url: TEST_URL_PATH }, async function (browser) { const popup = document.getElementById("PopupAutoComplete"); await openACPopup(popup, browser, "#form-basic-username"); const { item, rowItem } = getSecondaryAction(popup, 0); const menupopup = await openFlyoutByKeyboard(item, rowItem); const menuitems = [...menupopup.querySelectorAll("menuitem")]; for (const menuitem of menuitems) { Assert.equal( menuitem.getAttribute("closemenu"), "single", "Flyout items close only their own menu, not the popup chain" ); } const menuHidden = BrowserTestUtils.waitForEvent( menupopup, "popuphiding" ); menupopup.activateItem(menuitems[0]); await menuHidden; Assert.equal( popup.state, "open", "The autocomplete panel stays open after a flyout item is activated" ); await closePopup(popup); } ); await SpecialPowers.popPrefEnv(); }); add_task(async function test_subfocus_tracks_the_flyout() { await SpecialPowers.pushPrefEnv({ set: [[PREF, true]] }); await BrowserTestUtils.withNewTab( { gBrowser, url: TEST_URL_PATH }, async function (browser) { const popup = document.getElementById("PopupAutoComplete"); await openACPopup(popup, browser, "#form-basic-username"); const { item, rowItem } = getSecondaryAction(popup, 0); const menupopup = await openFlyoutByKeyboard(item, rowItem); Assert.ok( rowItem.hasAttribute("subfocused"), "The button keeps its focus indicator while the flyout is showing" ); Assert.ok( rowItem.hasAttribute("menuopen"), "The row is marked open while the flyout is showing" ); const menuHidden = BrowserTestUtils.waitForEvent( menupopup, "popuphiding" ); menupopup.hidePopup(); await menuHidden; await TestUtils.waitForCondition( () => !rowItem.hasAttribute("subfocused"), "Wait for the sub-selection to drop when the flyout closes" ); Assert.ok( !popup._secondaryActionFocused, "The panel and the row agree that the sub-selection is gone" ); Assert.equal(popup.state, "open", "The autocomplete panel stays open"); await closePopup(popup); } ); await SpecialPowers.popPrefEnv(); }); add_task(async function test_secondary_action_menu_semantics() { await SpecialPowers.pushPrefEnv({ set: [[PREF, true]] }); await BrowserTestUtils.withNewTab( { gBrowser, url: TEST_URL_PATH }, async function (browser) { const popup = document.getElementById("PopupAutoComplete"); await openACPopup(popup, browser, "#form-basic-username"); const { item, rowItem, button } = getSecondaryAction(popup, 0); const { label } = rowItem.actions.secondary; Assert.ok( label.includes("user1"), `The button is named after the row it belongs to, got "${label}"` ); const innerButton = button.shadowRoot.querySelector("#main-button"); Assert.equal( innerButton.getAttribute("title"), label, "The button's accessible name comes from its title" ); Assert.ok( !innerButton.hasAttribute("aria-label"), "The name is not duplicated across title and aria-label" ); Assert.equal( innerButton.getAttribute("aria-haspopup"), "menu", "The button advertises that it opens a menu" ); Assert.equal( innerButton.getAttribute("aria-expanded"), "false", "The button reports its menu as collapsed" ); const menupopup = await openFlyoutByKeyboard(item, rowItem); Assert.equal( menupopup.getAttribute("aria-label"), label, "The flyout has an accessible name" ); await TestUtils.waitForCondition( () => innerButton.getAttribute("aria-expanded") === "true", "Wait for the button to report its menu as expanded" ); const menuHidden = BrowserTestUtils.waitForEvent( menupopup, "popuphiding" ); menupopup.hidePopup(); await menuHidden; await TestUtils.waitForCondition( () => innerButton.getAttribute("aria-expanded") === "false", "Wait for the button to report its menu as collapsed again" ); await closePopup(popup); } ); await SpecialPowers.popPrefEnv(); }); // Moving the pointer into the flyout takes it off the row, which clears the // dropdown's selection. The flyout must restore it, otherwise AutoCompleteParent // has no entry to dispatch the delete action for. add_task(async function test_delete_works_after_pointer_leaves_row() { await SpecialPowers.pushPrefEnv({ set: [[PREF, true]] }); gReauthAuthorized = true; gReauthCalls = []; let sawDialog = false; await BrowserTestUtils.withNewTab( { gBrowser, url: TEST_URL_PATH }, async function (browser) { const popup = document.getElementById("PopupAutoComplete"); await openACPopup(popup, browser, "#form-basic-username"); const { item, rowItem, button } = getSecondaryAction(popup, 0); const richlistbox = popup.richlistbox; await TestUtils.waitForCondition(() => { EventUtils.synthesizeMouseAtCenter(item, { type: "mousemove" }); return richlistbox.hasAttribute("pointerselected"); }, "Wait for the row to be selected by the pointer"); Assert.equal(popup.selectedIndex, 0, "The hovered row is selected"); const deleteLabel = rowItem.actions.secondary.actions[1].label; const menupopup = await openFlyout(popup, button, deleteLabel); const menuitem = [...menupopup.querySelectorAll("menuitem")].find( mi => mi.getAttribute("label") === deleteLabel ); richlistbox.dispatchEvent( new MouseEvent("mouseout", { bubbles: true, relatedTarget: null }) ); Assert.equal( popup.selectedIndex, -1, "The dropdown drops its selection once the pointer leaves the row" ); const dialogClosed = BrowserTestUtils.promiseAlertDialog( null, undefined, { callback: win => { sawDialog = true; win.document.querySelector("dialog").getButton("cancel").click(); }, } ); menupopup.activateItem(menuitem); await dialogClosed; Assert.equal(gReauthCalls.length, 1, "Reauthentication was requested"); Assert.ok( sawDialog, "The delete action still resolves to the row the flyout belongs to" ); await TestUtils.waitForCondition( () => popup.state == "open", "Wait for the dropdown to come back after the confirmation" ); await closePopup(popup); } ); gReauthAuthorized = false; await SpecialPowers.popPrefEnv(); }); add_task(async function test_delete_reauthenticates_then_confirms() { await SpecialPowers.pushPrefEnv({ set: [[PREF, true]] }); gReauthAuthorized = true; gReauthCalls = []; await BrowserTestUtils.withNewTab( { gBrowser, url: TEST_URL_PATH }, async function (browser) { const popup = document.getElementById("PopupAutoComplete"); await openACPopup(popup, browser, "#form-basic-username"); const { item, rowItem, button } = getSecondaryAction(popup, 0); await selectRow(item, 0); const deleteLabel = rowItem.actions.secondary.actions[1].label; const menupopup = await openFlyout(popup, button, deleteLabel); const menuitem = [...menupopup.querySelectorAll("menuitem")].find( mi => mi.getAttribute("label") === deleteLabel ); let dialogWin; const dialogClosed = BrowserTestUtils.promiseAlertDialog( null, undefined, { callback: async win => { dialogWin = win; const [title, message, confirmButton] = AC_L10N.formatValuesSync([ { id: "autocomplete-remove-password-title" }, { id: "autocomplete-remove-record-message" }, { id: "autocomplete-remove-record-button" }, ]); Assert.equal( win.document.getElementById("infoTitle").textContent, title, "The confirmation asks whether to remove the password" ); Assert.equal( win.document.getElementById("infoBody").textContent, message, "The confirmation warns the action cannot be undone" ); const dialog = win.document.querySelector("dialog"); Assert.equal( dialog.getButton("accept").label, confirmButton, "The accept button is labelled Remove" ); dialog.getButton("cancel").click(); }, } ); menupopup.activateItem(menuitem); await dialogClosed; Assert.equal(gReauthCalls.length, 1, "Reauthentication was requested"); Assert.equal( gReauthCalls[0][4], "delete_autocomplete", "Reauthentication is recorded against the autocomplete delete" ); Assert.ok(dialogWin, "The confirmation dialog was shown"); await TestUtils.waitForCondition( () => popup.state == "open", "Wait for the dropdown to come back after the confirmation" ); Assert.equal( popup.state, "open", "The dropdown is restored once the confirmation is dismissed" ); const logins = await Services.logins.getAllLogins(); Assert.equal( logins.length, LOGINS_DATA.length, "Cancelling the confirmation leaves the login in place" ); await closePopup(popup); } ); gReauthAuthorized = false; await SpecialPowers.popPrefEnv(); }); add_task(async function test_delete_skips_confirm_when_reauth_fails() { await SpecialPowers.pushPrefEnv({ set: [[PREF, true]] }); gReauthAuthorized = false; gReauthCalls = []; let sawDialog = false; const observer = () => { sawDialog = true; }; Services.obs.addObserver(observer, "common-dialog-loaded"); await BrowserTestUtils.withNewTab( { gBrowser, url: TEST_URL_PATH }, async function (browser) { const popup = document.getElementById("PopupAutoComplete"); await openACPopup(popup, browser, "#form-basic-username"); const { item, rowItem, button } = getSecondaryAction(popup, 0); await selectRow(item, 0); const deleteLabel = rowItem.actions.secondary.actions[1].label; const menupopup = await openFlyout(popup, button, deleteLabel); const menuitem = [...menupopup.querySelectorAll("menuitem")].find( mi => mi.getAttribute("label") === deleteLabel ); menupopup.activateItem(menuitem); await TestUtils.waitForCondition( () => gReauthCalls.length, "Wait for reauthentication to be requested" ); await TestUtils.waitForTick(); Assert.ok(!sawDialog, "No confirmation is shown when reauth is declined"); await TestUtils.waitForCondition( () => popup.state == "open", "The dropdown comes back even when reauthentication is declined" ); Assert.equal( popup.state, "open", "The dropdown is restored when reauthentication is declined" ); await closePopup(popup); } ); Services.obs.removeObserver(observer, "common-dialog-loaded"); await SpecialPowers.popPrefEnv(); });