Loading browser/components/extensions/ExtensionPopups.sys.mjs +36 −5 Original line number Diff line number Diff line Loading @@ -4,16 +4,18 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this file, * You can obtain one at http://mozilla.org/MPL/2.0/. */ const lazy = {}; import { ExtensionCommon } from "resource://gre/modules/ExtensionCommon.sys.mjs"; import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs"; import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; ChromeUtils.defineESModuleGetters(lazy, { const lazy = XPCOMUtils.declareLazy({ CustomizableUI: "resource:///modules/CustomizableUI.sys.mjs", ExtensionParent: "resource://gre/modules/ExtensionParent.sys.mjs", setTimeout: "resource://gre/modules/Timer.sys.mjs", }); import { ExtensionCommon } from "resource://gre/modules/ExtensionCommon.sys.mjs"; import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs"; // Delay defaults to 500 ms via modules/libpref/init/all.js, for all builds: delayBeforeEnablingButtons: { pref: "security.notification_enable_delay" }, }); var { DefaultWeakMap, promiseEvent } = ExtensionUtils; Loading @@ -37,6 +39,33 @@ function promisePopupShown(popup) { }); } function addPanelHidingHandler(panel) { function handleClick(event) { if ( event.target.closest( "panel:not(#unified-extensions-panel),#notifications-toolbar" ) ) { event.preventDefault(); event.stopImmediatePropagation(); Services.console.logStringMessage( "Ignored click shortly after extension popup was closed" ); } } panel.addEventListener( "popuphiding", () => { const window = panel.ownerGlobal; window.addEventListener("click", handleClick, true); window.setTimeout(() => { window.removeEventListener("click", handleClick, true); }, lazy.delayBeforeEnablingButtons); }, { once: true, capture: true } ); } const REMOTE_PANEL_ID = "webextension-remote-preload-panel"; export class BasePopup { Loading Loading @@ -479,6 +508,7 @@ export class PanelPopup extends BasePopup { }, { once: true } ); addPanelHidingHandler(panel); super(extension, panel, popupURL, browserStyle); } Loading Loading @@ -587,6 +617,7 @@ export class ViewPopup extends BasePopup { once: true, capture: true, }); addPanelHidingHandler(this.panel); if (this.extension.remote) { this.panel.setAttribute("remote", "true"); } Loading browser/components/extensions/test/browser/browser.toml +2 −0 Original line number Diff line number Diff line Loading @@ -371,6 +371,8 @@ https_first_disabled = true ["browser_ext_persistent_storage_permission_indication.js"] ["browser_ext_popup_after_close.js"] ["browser_ext_popup_api_injection.js"] ["browser_ext_popup_background.js"] Loading browser/components/extensions/test/browser/browser_ext_popup_after_close.js 0 → 100644 +262 −0 Original line number Diff line number Diff line "use strict"; const { AddonTestUtils } = ChromeUtils.importESModule( "resource://testing-common/AddonTestUtils.sys.mjs" ); const { PermissionTestUtils } = ChromeUtils.importESModule( "resource://testing-common/PermissionTestUtils.sys.mjs" ); AddonTestUtils.initMochitest(this); // Default value of security.notification_enable_delay is 500. // To avoid unnecessary delays in the test, we choose a short (non-zero) delay. const delayBeforeEnablingButtons = 10; const MSG_NO_CLICK = "Ignored click shortly after extension popup was closed"; let gPopupExtension; add_setup(async () => { registerCleanupFunction(() => gPopupExtension.unload()); gPopupExtension = ExtensionTestUtils.loadExtension({ manifest: { page_action: { default_popup: "popup.html", show_matches: ["<all_urls>"], }, browser_action: { default_popup: "popup.html", }, }, files: { "popup.html": "Extension popup here", }, }); await gPopupExtension.startup(); let tab = await BrowserTestUtils.openNewForegroundTab( gBrowser, "https://example.com/", true, true ); gBrowser.selectedTab = tab; registerCleanupFunction(async () => { // Clean up anything left behind by showNotificationPanel(). await PermissionTestUtils.remove( tab.linkedBrowser.currentURI, "desktop-notification" ); BrowserTestUtils.removeTab(tab); }); await SpecialPowers.pushPrefEnv({ set: [["security.notification_enable_delay", delayBeforeEnablingButtons]], }); }); async function waitForDelayElapsed() { info(`Waiting for delay ${delayBeforeEnablingButtons}ms to be elapsed`); // Waiting for an "arbitrary" time is unavoidable because we need to verify // the effectiveness of a time-based delay. The timeout is chosen to be small // (so the test does not take too long) and the actions around the delay are // deterministic (to minimize the odds of intermittent failures). // eslint-disable-next-line mozilla/no-arbitrary-setTimeout await new Promise(r => setTimeout(r, delayBeforeEnablingButtons)); } async function showNotificationPanel() { const shownPromise = BrowserTestUtils.waitForEvent( PopupNotifications.panel, "popupshown" ); // Remove previously stored perm if any, to make sure that the // Notification.requestPermission call does not resolve immediately. await PermissionTestUtils.remove( gBrowser.selectedBrowser.currentURI, "desktop-notification" ); await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { // Notification.requestPermission() requires user activation. content.document.notifyUserGestureActivation(); content.Notification.requestPermission(); // Fire and forget. }); info("Waiting for Notification panel to appear"); await shownPromise; return PopupNotifications.panel; } function clickNotificationPanel(panel) { is(panel.state, "open", "Sanity check: notification panel is open"); const but = panel.querySelector("button.popup-notification-primary-button"); ok(but, "Found button to click in notification panel"); EventUtils.synthesizeMouseAtCenter(but, {}, window); } function clickNotificationInToolbar(notifInToolbar) { ok( notifInToolbar.closest("#notifications-toolbar"), "Sanity check: notification is inside toolbar" ); const but = notifInToolbar.closeButton; ok(but, "Found button to click in notification in toolbar"); EventUtils.synthesizeMouseAtCenter(but, {}, window); } // Verify that clicks are temporarily ignored. // triggerRealClick should try to click on a button. async function verifyClickImmediatelyAfterPopupClose({ triggerRealClick, promiseFinalClickResult, }) { let { messages: m1 } = await AddonTestUtils.promiseConsoleOutput(() => { // NOTE: This is the very first thing that runs immediately after the popup // is closed. The lack of other async delay ensures that we can pick a // short delayBeforeEnablingButtons (security.notification_enable_delay) // value for fast yet deterministic tests. triggerRealClick(); triggerRealClick(); triggerRealClick(); }); is( m1.filter(m => m.message.includes(MSG_NO_CLICK)).length, 3, "Click should be ignored while the delay is in effect" ); await waitForDelayElapsed(); let finalClickResult = promiseFinalClickResult(); let { messages: m2 } = await AddonTestUtils.promiseConsoleOutput(() => { triggerRealClick(); }); is( m2.filter(m => m.message.includes(MSG_NO_CLICK)).length, 0, "Click should be processed as usual after delay" ); await finalClickResult; info("Final click was effective"); } add_task(async function test_panel_click_after_browserAction_close() { let otherPanel = await showNotificationPanel(); let popupOpened = awaitExtensionPanel(gPopupExtension); await clickBrowserAction(gPopupExtension); await popupOpened; info("Browser action panel opened"); await closeBrowserAction(gPopupExtension); info("Browser action panel closed"); await verifyClickImmediatelyAfterPopupClose({ triggerRealClick: () => clickNotificationPanel(otherPanel), promiseFinalClickResult() { return BrowserTestUtils.waitForEvent(otherPanel, "popuphidden"); }, }); }); // Verify that the common logic also works for page actions. We do not need to // enumerate every case, but as a sanity check do it at least once. add_task(async function test_panel_click_after_pageAction_close() { let otherPanel = await showNotificationPanel(); let popupOpened = awaitExtensionPanel(gPopupExtension); await clickPageAction(gPopupExtension); await popupOpened; info("pageAction panel opened"); await closePageAction(gPopupExtension); info("pageAction panel closed"); await verifyClickImmediatelyAfterPopupClose({ triggerRealClick: () => clickNotificationPanel(otherPanel), promiseFinalClickResult() { return BrowserTestUtils.waitForEvent(otherPanel, "popuphidden"); }, }); }); // Verify that we temporarily ignore clicks inside the notification toolbar // (#notifications-toolbar) when needed. There are many ways to trigger this, // we test it via the popup blocker. add_task(async function test_toolbar_click_after_browserAction_close() { await SpecialPowers.pushPrefEnv({ set: [["dom.disable_open_during_load", true]], }); await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { // We want window.open() to be rejected. content.document.clearUserGestureActivation(); let win = content.wrappedJSObject.window.open(); Assert.ok(!win, "window.open() should be blocked by popup blocker"); }); let notifInToolbar = await TestUtils.waitForCondition(() => { let notificationBox = gBrowser.getNotificationBox(); return notificationBox.getNotificationWithValue("popup-blocked"); }); let popupOpened = awaitExtensionPanel(gPopupExtension); await clickBrowserAction(gPopupExtension); await popupOpened; info("Browser action panel opened"); await closeBrowserAction(gPopupExtension); info("Browser action panel closed"); await verifyClickImmediatelyAfterPopupClose({ triggerRealClick: () => clickNotificationInToolbar(notifInToolbar), promiseFinalClickResult() { return TestUtils.waitForCondition(() => !notifInToolbar.isConnected); }, }); await SpecialPowers.popPrefEnv(); }); add_task(async function test_click_inside_extensions_panel_is_unaffected() { await SpecialPowers.pushPrefEnv({ // Use the default delay instead of a short delay, to make sure that even // if the extensions panel initialization is slow, that we'd catch // unexpectedly ignored clicks, if any. set: [["security.notification_enable_delay", 500]], }); let popupOpened = awaitExtensionPanel(gPopupExtension); await clickBrowserAction(gPopupExtension); await popupOpened; info("Browser action panel opened"); await closeBrowserAction(gPopupExtension); info("Browser action panel closed"); let { messages } = await AddonTestUtils.promiseConsoleOutput(async () => { const viewShown = BrowserTestUtils.waitForEvent( gUnifiedExtensions.panel.querySelector("#unified-extensions-view"), "ViewShown" ); EventUtils.synthesizeMouseAtCenter(gUnifiedExtensions.button, {}, window); await viewShown; info("Extensions panel is shown, now clicking on extension button"); const { node } = getBrowserActionWidget(gPopupExtension).forWindow(window); const but = node.querySelector(".unified-extensions-item-action-button"); let popupOpenedAgain = awaitExtensionPanel(gPopupExtension); EventUtils.synthesizeMouseAtCenter(but, {}, window); let popupBrowser = await popupOpenedAgain; // Since we have an open panel anyway, let's check what happens when we try // to click inside. Although our implementation does not special-case // extension popups, it appears that the click is not intercepted. info("Extension popup was opened, now clicking inside"); await BrowserTestUtils.synthesizeMouseAtCenter("body", {}, popupBrowser); await closeBrowserAction(gPopupExtension); }); is( messages.filter(m => m.message.includes(MSG_NO_CLICK)).length, 0, "None of the clicks in the extensions panel should be ignored" ); await SpecialPowers.popPrefEnv(); }); Loading
browser/components/extensions/ExtensionPopups.sys.mjs +36 −5 Original line number Diff line number Diff line Loading @@ -4,16 +4,18 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this file, * You can obtain one at http://mozilla.org/MPL/2.0/. */ const lazy = {}; import { ExtensionCommon } from "resource://gre/modules/ExtensionCommon.sys.mjs"; import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs"; import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; ChromeUtils.defineESModuleGetters(lazy, { const lazy = XPCOMUtils.declareLazy({ CustomizableUI: "resource:///modules/CustomizableUI.sys.mjs", ExtensionParent: "resource://gre/modules/ExtensionParent.sys.mjs", setTimeout: "resource://gre/modules/Timer.sys.mjs", }); import { ExtensionCommon } from "resource://gre/modules/ExtensionCommon.sys.mjs"; import { ExtensionUtils } from "resource://gre/modules/ExtensionUtils.sys.mjs"; // Delay defaults to 500 ms via modules/libpref/init/all.js, for all builds: delayBeforeEnablingButtons: { pref: "security.notification_enable_delay" }, }); var { DefaultWeakMap, promiseEvent } = ExtensionUtils; Loading @@ -37,6 +39,33 @@ function promisePopupShown(popup) { }); } function addPanelHidingHandler(panel) { function handleClick(event) { if ( event.target.closest( "panel:not(#unified-extensions-panel),#notifications-toolbar" ) ) { event.preventDefault(); event.stopImmediatePropagation(); Services.console.logStringMessage( "Ignored click shortly after extension popup was closed" ); } } panel.addEventListener( "popuphiding", () => { const window = panel.ownerGlobal; window.addEventListener("click", handleClick, true); window.setTimeout(() => { window.removeEventListener("click", handleClick, true); }, lazy.delayBeforeEnablingButtons); }, { once: true, capture: true } ); } const REMOTE_PANEL_ID = "webextension-remote-preload-panel"; export class BasePopup { Loading Loading @@ -479,6 +508,7 @@ export class PanelPopup extends BasePopup { }, { once: true } ); addPanelHidingHandler(panel); super(extension, panel, popupURL, browserStyle); } Loading Loading @@ -587,6 +617,7 @@ export class ViewPopup extends BasePopup { once: true, capture: true, }); addPanelHidingHandler(this.panel); if (this.extension.remote) { this.panel.setAttribute("remote", "true"); } Loading
browser/components/extensions/test/browser/browser.toml +2 −0 Original line number Diff line number Diff line Loading @@ -371,6 +371,8 @@ https_first_disabled = true ["browser_ext_persistent_storage_permission_indication.js"] ["browser_ext_popup_after_close.js"] ["browser_ext_popup_api_injection.js"] ["browser_ext_popup_background.js"] Loading
browser/components/extensions/test/browser/browser_ext_popup_after_close.js 0 → 100644 +262 −0 Original line number Diff line number Diff line "use strict"; const { AddonTestUtils } = ChromeUtils.importESModule( "resource://testing-common/AddonTestUtils.sys.mjs" ); const { PermissionTestUtils } = ChromeUtils.importESModule( "resource://testing-common/PermissionTestUtils.sys.mjs" ); AddonTestUtils.initMochitest(this); // Default value of security.notification_enable_delay is 500. // To avoid unnecessary delays in the test, we choose a short (non-zero) delay. const delayBeforeEnablingButtons = 10; const MSG_NO_CLICK = "Ignored click shortly after extension popup was closed"; let gPopupExtension; add_setup(async () => { registerCleanupFunction(() => gPopupExtension.unload()); gPopupExtension = ExtensionTestUtils.loadExtension({ manifest: { page_action: { default_popup: "popup.html", show_matches: ["<all_urls>"], }, browser_action: { default_popup: "popup.html", }, }, files: { "popup.html": "Extension popup here", }, }); await gPopupExtension.startup(); let tab = await BrowserTestUtils.openNewForegroundTab( gBrowser, "https://example.com/", true, true ); gBrowser.selectedTab = tab; registerCleanupFunction(async () => { // Clean up anything left behind by showNotificationPanel(). await PermissionTestUtils.remove( tab.linkedBrowser.currentURI, "desktop-notification" ); BrowserTestUtils.removeTab(tab); }); await SpecialPowers.pushPrefEnv({ set: [["security.notification_enable_delay", delayBeforeEnablingButtons]], }); }); async function waitForDelayElapsed() { info(`Waiting for delay ${delayBeforeEnablingButtons}ms to be elapsed`); // Waiting for an "arbitrary" time is unavoidable because we need to verify // the effectiveness of a time-based delay. The timeout is chosen to be small // (so the test does not take too long) and the actions around the delay are // deterministic (to minimize the odds of intermittent failures). // eslint-disable-next-line mozilla/no-arbitrary-setTimeout await new Promise(r => setTimeout(r, delayBeforeEnablingButtons)); } async function showNotificationPanel() { const shownPromise = BrowserTestUtils.waitForEvent( PopupNotifications.panel, "popupshown" ); // Remove previously stored perm if any, to make sure that the // Notification.requestPermission call does not resolve immediately. await PermissionTestUtils.remove( gBrowser.selectedBrowser.currentURI, "desktop-notification" ); await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { // Notification.requestPermission() requires user activation. content.document.notifyUserGestureActivation(); content.Notification.requestPermission(); // Fire and forget. }); info("Waiting for Notification panel to appear"); await shownPromise; return PopupNotifications.panel; } function clickNotificationPanel(panel) { is(panel.state, "open", "Sanity check: notification panel is open"); const but = panel.querySelector("button.popup-notification-primary-button"); ok(but, "Found button to click in notification panel"); EventUtils.synthesizeMouseAtCenter(but, {}, window); } function clickNotificationInToolbar(notifInToolbar) { ok( notifInToolbar.closest("#notifications-toolbar"), "Sanity check: notification is inside toolbar" ); const but = notifInToolbar.closeButton; ok(but, "Found button to click in notification in toolbar"); EventUtils.synthesizeMouseAtCenter(but, {}, window); } // Verify that clicks are temporarily ignored. // triggerRealClick should try to click on a button. async function verifyClickImmediatelyAfterPopupClose({ triggerRealClick, promiseFinalClickResult, }) { let { messages: m1 } = await AddonTestUtils.promiseConsoleOutput(() => { // NOTE: This is the very first thing that runs immediately after the popup // is closed. The lack of other async delay ensures that we can pick a // short delayBeforeEnablingButtons (security.notification_enable_delay) // value for fast yet deterministic tests. triggerRealClick(); triggerRealClick(); triggerRealClick(); }); is( m1.filter(m => m.message.includes(MSG_NO_CLICK)).length, 3, "Click should be ignored while the delay is in effect" ); await waitForDelayElapsed(); let finalClickResult = promiseFinalClickResult(); let { messages: m2 } = await AddonTestUtils.promiseConsoleOutput(() => { triggerRealClick(); }); is( m2.filter(m => m.message.includes(MSG_NO_CLICK)).length, 0, "Click should be processed as usual after delay" ); await finalClickResult; info("Final click was effective"); } add_task(async function test_panel_click_after_browserAction_close() { let otherPanel = await showNotificationPanel(); let popupOpened = awaitExtensionPanel(gPopupExtension); await clickBrowserAction(gPopupExtension); await popupOpened; info("Browser action panel opened"); await closeBrowserAction(gPopupExtension); info("Browser action panel closed"); await verifyClickImmediatelyAfterPopupClose({ triggerRealClick: () => clickNotificationPanel(otherPanel), promiseFinalClickResult() { return BrowserTestUtils.waitForEvent(otherPanel, "popuphidden"); }, }); }); // Verify that the common logic also works for page actions. We do not need to // enumerate every case, but as a sanity check do it at least once. add_task(async function test_panel_click_after_pageAction_close() { let otherPanel = await showNotificationPanel(); let popupOpened = awaitExtensionPanel(gPopupExtension); await clickPageAction(gPopupExtension); await popupOpened; info("pageAction panel opened"); await closePageAction(gPopupExtension); info("pageAction panel closed"); await verifyClickImmediatelyAfterPopupClose({ triggerRealClick: () => clickNotificationPanel(otherPanel), promiseFinalClickResult() { return BrowserTestUtils.waitForEvent(otherPanel, "popuphidden"); }, }); }); // Verify that we temporarily ignore clicks inside the notification toolbar // (#notifications-toolbar) when needed. There are many ways to trigger this, // we test it via the popup blocker. add_task(async function test_toolbar_click_after_browserAction_close() { await SpecialPowers.pushPrefEnv({ set: [["dom.disable_open_during_load", true]], }); await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => { // We want window.open() to be rejected. content.document.clearUserGestureActivation(); let win = content.wrappedJSObject.window.open(); Assert.ok(!win, "window.open() should be blocked by popup blocker"); }); let notifInToolbar = await TestUtils.waitForCondition(() => { let notificationBox = gBrowser.getNotificationBox(); return notificationBox.getNotificationWithValue("popup-blocked"); }); let popupOpened = awaitExtensionPanel(gPopupExtension); await clickBrowserAction(gPopupExtension); await popupOpened; info("Browser action panel opened"); await closeBrowserAction(gPopupExtension); info("Browser action panel closed"); await verifyClickImmediatelyAfterPopupClose({ triggerRealClick: () => clickNotificationInToolbar(notifInToolbar), promiseFinalClickResult() { return TestUtils.waitForCondition(() => !notifInToolbar.isConnected); }, }); await SpecialPowers.popPrefEnv(); }); add_task(async function test_click_inside_extensions_panel_is_unaffected() { await SpecialPowers.pushPrefEnv({ // Use the default delay instead of a short delay, to make sure that even // if the extensions panel initialization is slow, that we'd catch // unexpectedly ignored clicks, if any. set: [["security.notification_enable_delay", 500]], }); let popupOpened = awaitExtensionPanel(gPopupExtension); await clickBrowserAction(gPopupExtension); await popupOpened; info("Browser action panel opened"); await closeBrowserAction(gPopupExtension); info("Browser action panel closed"); let { messages } = await AddonTestUtils.promiseConsoleOutput(async () => { const viewShown = BrowserTestUtils.waitForEvent( gUnifiedExtensions.panel.querySelector("#unified-extensions-view"), "ViewShown" ); EventUtils.synthesizeMouseAtCenter(gUnifiedExtensions.button, {}, window); await viewShown; info("Extensions panel is shown, now clicking on extension button"); const { node } = getBrowserActionWidget(gPopupExtension).forWindow(window); const but = node.querySelector(".unified-extensions-item-action-button"); let popupOpenedAgain = awaitExtensionPanel(gPopupExtension); EventUtils.synthesizeMouseAtCenter(but, {}, window); let popupBrowser = await popupOpenedAgain; // Since we have an open panel anyway, let's check what happens when we try // to click inside. Although our implementation does not special-case // extension popups, it appears that the click is not intercepted. info("Extension popup was opened, now clicking inside"); await BrowserTestUtils.synthesizeMouseAtCenter("body", {}, popupBrowser); await closeBrowserAction(gPopupExtension); }); is( messages.filter(m => m.message.includes(MSG_NO_CLICK)).length, 0, "None of the clicks in the extensions panel should be ignored" ); await SpecialPowers.popPrefEnv(); });