/* Any copyright is dedicated to the Public Domain. http://creativecommons.org/publicdomain/zero/1.0/ */ "use strict"; const TEST_SECURITY_DELAY = 5000; SimpleTest.requestCompleteLog(); /** * Shows a test PopupNotification. */ function showNotification() { PopupNotifications.show( gBrowser.selectedBrowser, "foo", "Hello, World!", "default-notification-icon", { label: "ok", accessKey: "o", callback: () => {}, }, [ { label: "cancel", accessKey: "c", callback: () => {}, }, ], { // Make test notifications persistent to ensure they are only closed // explicitly by test actions and survive tab switches. persistent: true, } ); } add_setup(async function () { // Set a longer security delay for PopupNotification actions so we can test // the delay even if the test runs slowly. await SpecialPowers.pushPrefEnv({ set: [["security.notification_enable_delay", TEST_SECURITY_DELAY]], }); }); async function ensureSecurityDelayReady() { /** * The security delay calculation in PopupNotification.sys.mjs is dependent on * the monotonically increasing value of performance.now. This timestamp is * not relative to a fixed date, but to runtime. * We need to wait for the value performance.now() to be larger than the * security delay in order to observe the bug. Only then does the * timeSinceShown check in PopupNotifications.sys.mjs lead to a timeSinceShown * value that is unconditionally greater than lazy.buttonDelay for * notification.timeShown = null = 0. * See: https://searchfox.org/mozilla-central/rev/f32d5f3949a3f4f185122142b29f2e3ab776836e/toolkit/modules/PopupNotifications.sys.mjs#1870-1872 * * When running in automation as part of a larger test suite performance.now() * should usually be already sufficiently high in which case this check should * directly resolve. */ await TestUtils.waitForCondition( () => performance.now() > TEST_SECURITY_DELAY, "Wait for performance.now() > SECURITY_DELAY", 500, 50 ); } /** * Tests that when we show a second notification while the panel is open the * timeShown attribute is correctly set and the security delay is enforced * properly. */ add_task(async function test_timeShownMultipleNotifications() { await ensureSecurityDelayReady(); ok( !PopupNotifications.isPanelOpen, "PopupNotification panel should not be open initially." ); info("Open the first notification."); let popupShownPromise = waitForNotificationPanel(); showNotification(); await popupShownPromise; ok( PopupNotifications.isPanelOpen, "PopupNotification should be open after first show call." ); is( PopupNotifications._currentNotifications.length, 1, "There should only be one notification" ); let notification = PopupNotifications.getNotification( "foo", gBrowser.selectedBrowser ); is(notification?.id, "foo", "There should be a notification with id foo"); ok(notification.timeShown, "The notification should have timeShown set"); info( "Call show again with the same notification id while the PopupNotification panel is still open." ); showNotification(); ok( PopupNotifications.isPanelOpen, "PopupNotification should still open after second show call." ); notification = PopupNotifications.getNotification( "foo", gBrowser.selectedBrowser ); is( PopupNotifications._currentNotifications.length, 1, "There should still only be one notification" ); is( notification?.id, "foo", "There should still be a notification with id foo" ); ok(notification.timeShown, "The notification should have timeShown set"); let notificationHiddenPromise = waitForNotificationPanelHidden(); info("Trigger main action via button click during security delay"); // Wait for a tick of the event loop to ensure the button we're clicking // has been slotted into moz-button-group await new Promise(resolve => setTimeout(resolve, 0)); triggerMainCommand(PopupNotifications.panel); await new Promise(resolve => setTimeout(resolve, 0)); ok(PopupNotifications.isPanelOpen, "PopupNotification should still be open."); notification = PopupNotifications.getNotification( "foo", gBrowser.selectedBrowser ); ok( notification, "Notification should still be open because we clicked during the security delay." ); // If the notification is no longer shown (test failure) skip the remaining // checks. if (!notification) { return; } // Ensure that once the security delay has passed the notification can be // closed again. let fakeTimeShown = TEST_SECURITY_DELAY + 500; info(`Manually set timeShown to ${fakeTimeShown}ms in the past.`); notification.timeShown = performance.now() - fakeTimeShown; info("Trigger main action via button click outside security delay"); triggerMainCommand(PopupNotifications.panel); info("Wait for panel to be hidden."); await notificationHiddenPromise; ok( !PopupNotifications.getNotification("foo", gBrowser.selectedBrowser), "Should not longer see the notification." ); }); /** * Tests that when we reshow a notification after a tab switch the timeShown * attribute is correctly reset and the security delay is enforced. */ add_task(async function test_notificationReshowTabSwitch() { await ensureSecurityDelayReady(); ok( !PopupNotifications.isPanelOpen, "PopupNotification panel should not be open initially." ); info("Open the first notification."); let popupShownPromise = waitForNotificationPanel(); showNotification(); await popupShownPromise; ok( PopupNotifications.isPanelOpen, "PopupNotification should be open after first show call." ); let notification = PopupNotifications.getNotification( "foo", gBrowser.selectedBrowser ); is(notification?.id, "foo", "There should be a notification with id foo"); ok(notification.timeShown, "The notification should have timeShown set"); info("Trigger main action via button click during security delay"); triggerMainCommand(PopupNotifications.panel); await new Promise(resolve => setTimeout(resolve, 0)); ok(PopupNotifications.isPanelOpen, "PopupNotification should still be open."); notification = PopupNotifications.getNotification( "foo", gBrowser.selectedBrowser ); ok( notification, "Notification should still be open because we clicked during the security delay." ); // If the notification is no longer shown (test failure) skip the remaining // checks. if (!notification) { return; } let panelHiddenPromise = waitForNotificationPanelHidden(); let panelShownPromise; info("Open a new tab which hides the notification panel."); await BrowserTestUtils.withNewTab("https://example.com", async browser => { info("Wait for panel to be hidden by tab switch."); await panelHiddenPromise; info( "Keep the tab open until the security delay for the original notification show has expired." ); await new Promise(resolve => // eslint-disable-next-line mozilla/no-arbitrary-setTimeout setTimeout(resolve, TEST_SECURITY_DELAY + 500) ); panelShownPromise = waitForNotificationPanel(); }); info( "Wait for the panel to show again after the tab close. We're showing the original tab again." ); await panelShownPromise; ok( PopupNotifications.isPanelOpen, "PopupNotification should be shown after tab close." ); notification = PopupNotifications.getNotification( "foo", gBrowser.selectedBrowser ); is( notification?.id, "foo", "There should still be a notification with id foo" ); let notificationHiddenPromise = waitForNotificationPanelHidden(); info( "Because we re-show the panel after tab close / switch the security delay should have reset." ); info("Trigger main action via button click during the new security delay."); triggerMainCommand(PopupNotifications.panel); await new Promise(resolve => setTimeout(resolve, 0)); ok(PopupNotifications.isPanelOpen, "PopupNotification should still be open."); notification = PopupNotifications.getNotification( "foo", gBrowser.selectedBrowser ); ok( notification, "Notification should still be open because we clicked during the security delay." ); // If the notification is no longer shown (test failure) skip the remaining // checks. if (!notification) { return; } // Ensure that once the security delay has passed the notification can be // closed again. let fakeTimeShown = TEST_SECURITY_DELAY + 500; info(`Manually set timeShown to ${fakeTimeShown}ms in the past.`); notification.timeShown = performance.now() - fakeTimeShown; info("Trigger main action via button click outside security delay"); triggerMainCommand(PopupNotifications.panel); info("Wait for panel to be hidden."); await notificationHiddenPromise; ok( !PopupNotifications.getNotification("foo", gBrowser.selectedBrowser), "Should not longer see the notification." ); }); /** * Tests that the security delay gets reset when a window is repositioned and * the PopupNotifications panel position is updated. */ add_task(async function test_notificationWindowMove() { await ensureSecurityDelayReady(); info("Open a notification."); let popupShownPromise = waitForNotificationPanel(); showNotification(); await popupShownPromise; ok( PopupNotifications.isPanelOpen, "PopupNotification should be open after show call." ); // Test that the initial security delay works. info("Trigger main action via button click during the new security delay."); triggerMainCommand(PopupNotifications.panel); await new Promise(resolve => setTimeout(resolve, 0)); ok(PopupNotifications.isPanelOpen, "PopupNotification should still be open."); let notification = PopupNotifications.getNotification( "foo", gBrowser.selectedBrowser ); ok( notification, "Notification should still be open because we clicked during the security delay." ); // If the notification is no longer shown (test failure) skip the remaining // checks. if (!notification) { return; } info("Wait for security delay to expire."); await new Promise(resolve => // eslint-disable-next-line mozilla/no-arbitrary-setTimeout setTimeout(resolve, TEST_SECURITY_DELAY + 500) ); info("Reposition the window"); // Remember original window position. let { screenX, screenY } = window; let promisePopupPositioned = BrowserTestUtils.waitForEvent( PopupNotifications.panel, "popuppositioned" ); // Move the window. window.moveTo(200, 200); // Wait for the panel to reposition and the PopupNotifications listener to run. await promisePopupPositioned; await new Promise(resolve => setTimeout(resolve, 0)); info("Trigger main action via button click during the new security delay."); triggerMainCommand(PopupNotifications.panel); await new Promise(resolve => setTimeout(resolve, 0)); ok(PopupNotifications.isPanelOpen, "PopupNotification should still be open."); notification = PopupNotifications.getNotification( "foo", gBrowser.selectedBrowser ); ok( notification, "Notification should still be open because we clicked during the security delay." ); // If the notification is no longer shown (test failure) skip the remaining // checks. if (!notification) { return; } // Ensure that once the security delay has passed the notification can be // closed again. let fakeTimeShown = TEST_SECURITY_DELAY + 500; info(`Manually set timeShown to ${fakeTimeShown}ms in the past.`); notification.timeShown = performance.now() - fakeTimeShown; info("Trigger main action via button click outside security delay"); let notificationHiddenPromise = waitForNotificationPanelHidden(); triggerMainCommand(PopupNotifications.panel); info("Wait for panel to be hidden."); await notificationHiddenPromise; ok( !PopupNotifications.getNotification("foo", gBrowser.selectedBrowser), "Should not longer see the notification." ); // Reset window position window.moveTo(screenX, screenY); }); /** * Tests that the security delay gets extended if a notification is shown during * a full screen transition. */ add_task(async function test_notificationDuringFullScreenTransition() { // Log full screen transition messages. let loggingObserver = { observe(subject, topic) { info("Observed topic: " + topic); }, }; Services.obs.addObserver(loggingObserver, "fullscreen-transition-start"); Services.obs.addObserver(loggingObserver, "fullscreen-transition-end"); // Unregister observers when the test ends: registerCleanupFunction(() => { Services.obs.removeObserver(loggingObserver, "fullscreen-transition-start"); Services.obs.removeObserver(loggingObserver, "fullscreen-transition-end"); }); if (Services.appinfo.OS == "Linux") { ok( "Skipping test on Linux because of disabled full screen transition in CI." ); return; } // Bug 1882527: Intermittent failures on macOS. if (Services.appinfo.OS == "Darwin") { ok("Skipping test on macOS because of intermittent failures."); return; } await BrowserTestUtils.withNewTab("https://example.com", async browser => { await SpecialPowers.pushPrefEnv({ set: [ // Set a short security delay so we can observe it being extended. ["security.notification_enable_delay", 1], // Set a longer full screen exit transition so the test works on slow builds. ["full-screen-api.transition-duration.leave", "1000 1000"], // Waive the user activation requirement for full screen requests. // The PoC this test is based on relies on spam clicking which grants // user activation in the popup that requests full screen. // This isn't reliable in automation. ["full-screen-api.allow-trusted-requests-only", false], // macOS native full screen is not affected by the full screen // transition overlap. Test with the old full screen implementation. ["full-screen-api.macos-native-full-screen", false], ], }); await ensureSecurityDelayReady(); ok( !PopupNotifications.isPanelOpen, "PopupNotification panel should not be open initially." ); info("Open a notification."); let popupShownPromise = waitForNotificationPanel(); showNotification(); await popupShownPromise; ok( PopupNotifications.isPanelOpen, "PopupNotification should be open after show call." ); let notification = PopupNotifications.getNotification("foo", browser); is(notification?.id, "foo", "There should be a notification with id foo"); info( "Open a new tab via window.open, enter full screen and remove the tab." ); // There are two transitions, one for full screen entry and one for full screen exit. let transitionStartCount = 0; let transitionEndCount = 0; let promiseFullScreenTransitionStart = TestUtils.topicObserved( "fullscreen-transition-start", () => { transitionStartCount++; return transitionStartCount == 2; } ); let promiseFullScreenTransitionEnd = TestUtils.topicObserved( "fullscreen-transition-end", () => { transitionEndCount++; return transitionEndCount == 2; } ); let notificationShownPromise = waitForNotificationPanel(); await SpecialPowers.spawn(browser, [], () => { // Use eval to execute in the privilege context of the website. content.eval(` let button = document.createElement("button"); button.id = "triggerBtn"; button.innerText = "Open Popup"; button.addEventListener("click", () => { let popup = window.open("about:blank"); popup.document.write( "" ); popup.document.write( "" ); }); // Insert button at the top so the synthesized click works. Otherwise // the button may be outside of the viewport. document.body.prepend(button); `); }); let timeClick = performance.now(); await BrowserTestUtils.synthesizeMouseAtCenter("#triggerBtn", {}, browser); info("Wait for the exit transition to start. It's the second transition."); await promiseFullScreenTransitionStart; info("Full screen transition start"); ok(true, "Full screen transition started"); ok( window.isInFullScreenTransition, "Full screen transition is still running." ); info( "Wait for notification to re-show on tab switch, after the popup has been closed" ); await notificationShownPromise; ok( window.isInFullScreenTransition, "Full screen transition is still running." ); info( "about to trigger notification. time between btn click and notification show: " + (performance.now() - timeClick) ); info( "Trigger main action via button click during the extended security delay." ); triggerMainCommand(PopupNotifications.panel); await new Promise(resolve => setTimeout(resolve, 0)); ok( PopupNotifications.isPanelOpen, "PopupNotification should still be open." ); notification = PopupNotifications.getNotification( "foo", gBrowser.selectedBrowser ); ok( notification, "Notification should still be open because we clicked during the security delay." ); info("Wait for full screen transition end."); await promiseFullScreenTransitionEnd; info("Full screen transition end"); }); });