Commit 46d9acec authored by Rob Wu's avatar Rob Wu Committed by Pier Angelo Vendrame
Browse files

Bug 1940116 - Ignore clicks shortly after extension popup closes r=rpl,Gijs

parent c6675bdc
Loading
Loading
Loading
Loading
+36 −5
Original line number Diff line number Diff line
@@ -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;

@@ -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 {
@@ -479,6 +508,7 @@ export class PanelPopup extends BasePopup {
      },
      { once: true }
    );
    addPanelHidingHandler(panel);

    super(extension, panel, popupURL, browserStyle);
  }
@@ -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");
    }
+2 −0
Original line number Diff line number Diff line
@@ -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"]
+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();
});