/* 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"; const { ExtensionPermissions } = ChromeUtils.importESModule( "resource://gre/modules/ExtensionPermissions.sys.mjs" ); const PAGE = "http://mochi.test:8888/browser/browser/components/extensions/test/browser/context.html"; const PAGE_BASE = PAGE.replace("context.html", ""); const PAGE_HOST_PATTERN = "http://mochi.test/*"; const EXPECT_TARGET_ELEMENT = 13337; async function grantOptionalPermission(extension, permissions) { let ext = WebExtensionPolicy.getByID(extension.id).extension; return ExtensionPermissions.add(extension.id, permissions, ext); } var someOtherTab, testTab; add_setup(async function () { await SpecialPowers.pushPrefEnv({ set: [["extensions.manifestV3.enabled", true]], }); // To help diagnose an intermittent later. SimpleTest.requestCompleteLog(); // Setup the test tab now, rather than for each test someOtherTab = gBrowser.selectedTab; testTab = await BrowserTestUtils.openNewForegroundTab(gBrowser, PAGE); registerCleanupFunction(() => BrowserTestUtils.removeTab(testTab)); }); // Registers a context menu using menus.create(menuCreateParams) and checks // whether the menus.onShown and menus.onHidden events are fired as expected. // doOpenMenu must open the menu and its returned promise must resolve after the // menu is shown. Similarly, doCloseMenu must hide the menu. async function testShowHideEvent({ menuCreateParams, id, doOpenMenu, doCloseMenu, expectedShownEvent, expectedShownEventWithPermissions = null, forceTabToBackground = false, manifest_version = 2, }) { async function background(menu_create_params) { const [tab] = await browser.tabs.query({ active: true, currentWindow: true, }); if (browser.pageAction) { await browser.pageAction.show(tab.id); } let shownEvents = []; let hiddenEvents = []; browser.menus.onShown.addListener((...args) => { browser.test.log(`==> onShown args ${JSON.stringify(args)}`); let [info, shownTab] = args; if (info.targetElementId) { // In this test, we aren't interested in the exact value, // only in whether it is set or not. info.targetElementId = 13337; // = EXPECT_TARGET_ELEMENT } shownEvents.push(info); if (menu_create_params.title.includes("TEST_EXPECT_NO_TAB")) { browser.test.assertEq(undefined, shownTab, "expect no tab"); } else { browser.test.assertEq(tab.id, shownTab?.id, "expected tab"); } browser.test.assertEq(2, args.length, "expected number of onShown args"); }); browser.menus.onHidden.addListener(event => hiddenEvents.push(event)); browser.test.onMessage.addListener(async msg => { switch (msg) { case "register-menu": let menuId; await new Promise(resolve => { menuId = browser.menus.create(menu_create_params, resolve); }); browser.test.assertEq( 0, shownEvents.length, "no onShown before menu" ); browser.test.assertEq( 0, hiddenEvents.length, "no onHidden before menu" ); browser.test.sendMessage("menu-registered", menuId); break; case "assert-menu-shown": browser.test.assertEq(1, shownEvents.length, "expected onShown"); browser.test.assertEq( 0, hiddenEvents.length, "no onHidden before closing" ); browser.test.sendMessage("onShown-event-data", shownEvents[0]); break; case "assert-menu-hidden": browser.test.assertEq( 1, shownEvents.length, "expected no more onShown" ); browser.test.assertEq(1, hiddenEvents.length, "expected onHidden"); browser.test.sendMessage("onHidden-event-data", hiddenEvents[0]); break; case "optional-menu-shown-with-permissions": browser.test.assertEq( 2, shownEvents.length, "expected second onShown" ); browser.test.sendMessage("onShown-event-data2", shownEvents[1]); break; } }); browser.test.sendMessage("ready"); } // Tab must initially open as a foreground tab, because the test extension // looks for the active tab. if (gBrowser.selectedTab != testTab) { await BrowserTestUtils.switchTab(gBrowser, testTab); } let useAddonManager, browser_specific_settings; const action = manifest_version < 3 ? "browser_action" : "action"; // hook up AOM so event pages in MV3 work. if (manifest_version > 2) { browser_specific_settings = { gecko: { id } }; useAddonManager = "temporary"; } let extension = ExtensionTestUtils.loadExtension({ background: `(${background})(${JSON.stringify(menuCreateParams)})`, useAddonManager, manifest: { manifest_version, browser_specific_settings, page_action: {}, [action]: { default_popup: "popup.html", default_area: "navbar", }, permissions: ["menus"], optional_permissions: [PAGE_HOST_PATTERN], }, files: { "popup.html": `Popup body`, }, }); await extension.startup(); await extension.awaitMessage("ready"); extension.sendMessage("register-menu"); let menuId = await extension.awaitMessage("menu-registered"); info(`menu registered ${menuId}`); if (forceTabToBackground && gBrowser.selectedTab != someOtherTab) { await BrowserTestUtils.switchTab(gBrowser, someOtherTab); } await doOpenMenu(extension, testTab); extension.sendMessage("assert-menu-shown"); let shownEvent = await extension.awaitMessage("onShown-event-data"); // menuCreateParams.id is not set, therefore a numeric ID is generated. expectedShownEvent.menuIds = [menuId]; Assert.deepEqual(shownEvent, expectedShownEvent, "expected onShown info"); await doCloseMenu(extension); extension.sendMessage("assert-menu-hidden"); let hiddenEvent = await extension.awaitMessage("onHidden-event-data"); is(hiddenEvent, undefined, "expected no event data for onHidden event"); if (expectedShownEventWithPermissions) { expectedShownEventWithPermissions.menuIds = [menuId]; await grantOptionalPermission(extension, { permissions: [], origins: [PAGE_HOST_PATTERN], }); await doOpenMenu(extension, testTab); extension.sendMessage("optional-menu-shown-with-permissions"); let shownEvent2 = await extension.awaitMessage("onShown-event-data2"); Assert.deepEqual( shownEvent2, expectedShownEventWithPermissions, "expected onShown info when host permissions are enabled" ); await doCloseMenu(extension); } await extension.unload(); } // Make sure that we won't trigger onShown when extensions cannot add menus. add_task(async function test_no_show_hide_for_unsupported_menu() { let extension = ExtensionTestUtils.loadExtension({ background() { let events = []; browser.menus.onShown.addListener(data => events.push(data)); browser.menus.onHidden.addListener(() => events.push("onHidden")); browser.test.onMessage.addListener(() => { browser.test.assertEq( "[]", JSON.stringify(events), "Should not have any events when the context is unsupported." ); browser.test.notifyPass("done listening to menu events"); }); }, manifest: { permissions: ["menus"], }, }); await extension.startup(); // Open and close a menu for which the extension cannot add menu items. await openChromeContextMenu("toolbar-context-menu", "#stop-reload-button"); await closeChromeContextMenu("toolbar-context-menu"); extension.sendMessage("check menu events"); await extension.awaitFinish("done listening to menu events"); await extension.unload(); }); add_task(async function test_show_hide_without_menu_item() { let extension = ExtensionTestUtils.loadExtension({ background() { let events = []; browser.menus.onShown.addListener(data => events.push(data)); browser.menus.onHidden.addListener(() => events.push("onHidden")); browser.test.onMessage.addListener(() => { browser.test.sendMessage("events from menuless extension", events); }); browser.menus.create({ title: "never shown", documentUrlPatterns: ["*://url-pattern-that-never-matches/*"], contexts: ["all"], }); }, manifest: { permissions: ["menus", PAGE_HOST_PATTERN], }, }); await extension.startup(); // Run another context menu test where onShown/onHidden will fire. await testShowHideEvent({ menuCreateParams: { title: "any menu item", contexts: ["all"], }, expectedShownEvent: { contexts: ["page", "all"], viewType: "tab", editable: false, frameId: 0, }, async doOpenMenu() { await openContextMenu("body"); }, async doCloseMenu() { await closeExtensionContextMenu(); }, }); // Now the menu has been shown and hidden, and in another extension the // onShown/onHidden events have been dispatched. extension.sendMessage("check menu events"); let events = await extension.awaitMessage("events from menuless extension"); is(events.length, 2, "expect two events"); is(events[1], "onHidden", "last event should be onHidden"); ok(events[0].targetElementId, "info.targetElementId must be set in onShown"); delete events[0].targetElementId; Assert.deepEqual( events[0], { menuIds: [], contexts: ["page", "all"], viewType: "tab", editable: false, pageUrl: PAGE, frameId: 0, }, "expected onShown info from menuless extension" ); await extension.unload(); }); add_task(async function test_show_hide_pageAction() { await testShowHideEvent({ menuCreateParams: { title: "pageAction item", contexts: ["page_action"], }, expectedShownEvent: { contexts: ["page_action", "all"], viewType: undefined, editable: false, }, expectedShownEventWithPermissions: { contexts: ["page_action", "all"], viewType: undefined, editable: false, pageUrl: PAGE, }, async doOpenMenu(extension) { await openActionContextMenu(extension, "page"); }, async doCloseMenu() { await closeActionContextMenu(null, "page"); }, }); }); add_task(async function test_show_hide_browserAction() { await testShowHideEvent({ menuCreateParams: { title: "browserAction item", contexts: ["browser_action"], }, expectedShownEvent: { contexts: ["browser_action", "all"], viewType: undefined, editable: false, }, expectedShownEventWithPermissions: { contexts: ["browser_action", "all"], viewType: undefined, editable: false, pageUrl: PAGE, }, async doOpenMenu(extension) { await openActionContextMenu(extension, "browser"); }, async doCloseMenu() { await closeActionContextMenu(); }, }); }); add_task(async function test_show_hide_browserAction_v3() { await testShowHideEvent({ manifest_version: 3, id: "browser-action@mochitest", menuCreateParams: { id: "action_item", title: "Action item", contexts: ["action"], }, expectedShownEvent: { contexts: ["action", "all"], viewType: undefined, editable: false, }, expectedShownEventWithPermissions: { contexts: ["action", "all"], viewType: undefined, editable: false, pageUrl: PAGE, }, async doOpenMenu(extension) { await openActionContextMenu(extension, "browser"); }, async doCloseMenu() { await closeActionContextMenu(); }, }); }); add_task(async function test_show_hide_browserAction_popup() { let popupUrl; await testShowHideEvent({ menuCreateParams: { title: "browserAction popup - TEST_EXPECT_NO_TAB", contexts: ["all", "browser_action"], }, expectedShownEvent: { contexts: ["page", "all"], viewType: "popup", frameId: 0, editable: false, get pageUrl() { return popupUrl; }, targetElementId: EXPECT_TARGET_ELEMENT, }, expectedShownEventWithPermissions: { contexts: ["page", "all"], viewType: "popup", frameId: 0, editable: false, get pageUrl() { return popupUrl; }, targetElementId: EXPECT_TARGET_ELEMENT, }, async doOpenMenu(extension) { popupUrl = `moz-extension://${extension.uuid}/popup.html`; await clickBrowserAction(extension); await openContextMenuInPopup(extension); }, async doCloseMenu(extension) { await closeExtensionContextMenu(); await closeBrowserAction(extension); }, }); }); add_task(async function test_show_hide_browserAction_popup_v3() { let popupUrl; await testShowHideEvent({ manifest_version: 3, id: "browser-action-popup@mochitest", menuCreateParams: { id: "action_popup", title: "Action popup - TEST_EXPECT_NO_TAB", contexts: ["all", "action"], }, expectedShownEvent: { contexts: ["page", "all"], viewType: "popup", frameId: 0, editable: false, get pageUrl() { return popupUrl; }, targetElementId: EXPECT_TARGET_ELEMENT, }, expectedShownEventWithPermissions: { contexts: ["page", "all"], viewType: "popup", frameId: 0, editable: false, get pageUrl() { return popupUrl; }, targetElementId: EXPECT_TARGET_ELEMENT, }, async doOpenMenu(extension) { popupUrl = `moz-extension://${extension.uuid}/popup.html`; await clickBrowserAction(extension); await openContextMenuInPopup(extension); }, async doCloseMenu(extension) { await closeExtensionContextMenu(); await closeBrowserAction(extension); }, }); }); // Common code used by test_show_hide_tab and test_show_hide_tab_via_tab_panel. async function testShowHideTabMenu({ doOpenTabContextMenu, doCloseTabContextMenu, }) { await testShowHideEvent({ // To verify that the event matches the contextmenu target, switch to // an unrelated tab before opening a contextmenu on the desired tab. forceTabToBackground: true, menuCreateParams: { title: "tab menu item", contexts: ["tab"], }, expectedShownEvent: { contexts: ["tab"], viewType: undefined, editable: false, }, expectedShownEventWithPermissions: { contexts: ["tab"], viewType: undefined, editable: false, pageUrl: PAGE, }, async doOpenMenu(extension, contextTab) { await doOpenTabContextMenu(contextTab); }, async doCloseMenu() { await doCloseTabContextMenu(); }, }); } add_task(async function test_show_hide_tab() { await testShowHideTabMenu({ async doOpenTabContextMenu(contextTab) { await openTabContextMenu(contextTab); }, async doCloseTabContextMenu() { await closeTabContextMenu(); }, }); }); // Checks that right-clicking on a tab in the tabs panel (the one that appears // when there are many tabs, or when browser.tabs.tabmanager.enabled = true) // results in an event that is associated with the expected tab. add_task(async function test_show_hide_tab_via_tab_panel() { gTabsPanel.init(); const tabContainer = document.getElementById("tabbrowser-tabs"); let shouldAddOverflow = !tabContainer.hasAttribute("overflow"); const revertTabContainerAttribute = () => { if (shouldAddOverflow) { // Revert attribute if it was changed. tabContainer.removeAttribute("overflow"); // The function is going to be called twice, but let's run the logic once. shouldAddOverflow = false; } }; if (shouldAddOverflow) { // Ensure the visibility of the "all tabs menu" button (#alltabs-button). tabContainer.setAttribute("overflow", "true"); // Register cleanup function in case the test fails before we reach the end. registerCleanupFunction(revertTabContainerAttribute); } const allTabsView = document.getElementById("allTabsMenu-allTabsView"); await testShowHideTabMenu({ async doOpenTabContextMenu(contextTab) { // Show the tabs panel. let allTabsPopupShownPromise = BrowserTestUtils.waitForEvent( allTabsView, "ViewShown" ); gTabsPanel.showAllTabsPanel(); await allTabsPopupShownPromise; // Find the menu item that is associated with the given tab let index = Array.prototype.findIndex.call( gTabsPanel.allTabsViewTabs.children, toolbaritem => toolbaritem.tab === contextTab ); ok(index !== -1, "sanity check: tabs panel has item for the tab"); // Finally, open the context menu on it. await openChromeContextMenu( "tabContextMenu", `.all-tabs-item:nth-child(${index + 1})` ); }, async doCloseTabContextMenu() { await closeTabContextMenu(); let allTabsPopupHiddenPromise = BrowserTestUtils.waitForEvent( allTabsView.panelMultiView, "PanelMultiViewHidden" ); gTabsPanel.hideAllTabsPanel(); await allTabsPopupHiddenPromise; }, }); revertTabContainerAttribute(); }); add_task(async function test_show_hide_tools_menu() { await testShowHideEvent({ menuCreateParams: { title: "menu item", contexts: ["tools_menu"], }, expectedShownEvent: { contexts: ["tools_menu"], viewType: undefined, editable: false, }, expectedShownEventWithPermissions: { contexts: ["tools_menu"], viewType: undefined, editable: false, pageUrl: PAGE, }, async doOpenMenu() { await openToolsMenu(); }, async doCloseMenu() { await closeToolsMenu(); }, }); }); add_task(async function test_show_hide_page() { await testShowHideEvent({ menuCreateParams: { title: "page menu item", contexts: ["page"], }, expectedShownEvent: { contexts: ["page", "all"], viewType: "tab", editable: false, frameId: 0, }, expectedShownEventWithPermissions: { contexts: ["page", "all"], viewType: "tab", editable: false, pageUrl: PAGE, frameId: 0, targetElementId: EXPECT_TARGET_ELEMENT, }, async doOpenMenu() { await openContextMenu("body"); }, async doCloseMenu() { await closeExtensionContextMenu(); }, }); }); add_task(async function test_show_hide_frame() { // frame info will be determined before opening the menu. let frameId; await testShowHideEvent({ menuCreateParams: { title: "subframe menu item", contexts: ["frame"], }, expectedShownEvent: { contexts: ["frame", "all"], viewType: "tab", editable: false, get frameId() { return frameId; }, }, expectedShownEventWithPermissions: { contexts: ["frame", "all"], viewType: "tab", editable: false, get frameId() { return frameId; }, pageUrl: PAGE, frameUrl: PAGE_BASE + "context_frame.html", targetElementId: EXPECT_TARGET_ELEMENT, }, async doOpenMenu() { frameId = await SpecialPowers.spawn( gBrowser.selectedBrowser, [], function () { const { WebNavigationFrames } = ChromeUtils.importESModule( "resource://gre/modules/WebNavigationFrames.sys.mjs" ); let { contentWindow } = content.document.getElementById("frame"); return WebNavigationFrames.getFrameId(contentWindow); } ); await openContextMenuInFrame(); }, async doCloseMenu() { await closeExtensionContextMenu(); }, }); }); add_task(async function test_show_hide_password() { await testShowHideEvent({ menuCreateParams: { title: "password item", contexts: ["password"], }, expectedShownEvent: { contexts: ["editable", "password", "all"], viewType: "tab", editable: true, frameId: 0, }, expectedShownEventWithPermissions: { contexts: ["editable", "password", "all"], viewType: "tab", editable: true, frameId: 0, pageUrl: PAGE, targetElementId: EXPECT_TARGET_ELEMENT, }, async doOpenMenu() { await openContextMenu("#password"); }, async doCloseMenu() { await closeExtensionContextMenu(); }, }); }); add_task(async function test_show_hide_link() { await testShowHideEvent({ menuCreateParams: { title: "link item", contexts: ["link"], }, expectedShownEvent: { contexts: ["link", "all"], viewType: "tab", editable: false, frameId: 0, }, expectedShownEventWithPermissions: { contexts: ["link", "all"], viewType: "tab", editable: false, frameId: 0, linkText: "Some link", linkUrl: PAGE_BASE + "some-link", pageUrl: PAGE, targetElementId: EXPECT_TARGET_ELEMENT, }, async doOpenMenu() { await openContextMenu("#link1"); }, async doCloseMenu() { await closeExtensionContextMenu(); }, }); }); add_task(async function test_show_hide_image_link() { await testShowHideEvent({ menuCreateParams: { title: "image item", contexts: ["image"], }, expectedShownEvent: { contexts: ["image", "link", "all"], viewType: "tab", mediaType: "image", editable: false, frameId: 0, }, expectedShownEventWithPermissions: { contexts: ["image", "link", "all"], viewType: "tab", mediaType: "image", editable: false, frameId: 0, // Apparently, when a link has no content, its href is used as linkText. linkText: PAGE_BASE + "image-around-some-link", linkUrl: PAGE_BASE + "image-around-some-link", srcUrl: PAGE_BASE + "ctxmenu-image.png", pageUrl: PAGE, targetElementId: EXPECT_TARGET_ELEMENT, }, async doOpenMenu() { await openContextMenu("#img-wrapped-in-link"); }, async doCloseMenu() { await closeExtensionContextMenu(); }, }); }); add_task(async function test_show_hide_editable_selection() { let selectionText; await testShowHideEvent({ menuCreateParams: { title: "editable item", contexts: ["editable"], }, expectedShownEvent: { contexts: ["editable", "selection", "all"], viewType: "tab", editable: true, frameId: 0, }, expectedShownEventWithPermissions: { contexts: ["editable", "selection", "all"], viewType: "tab", editable: true, frameId: 0, pageUrl: PAGE, get selectionText() { return selectionText; }, targetElementId: EXPECT_TARGET_ELEMENT, }, async doOpenMenu() { // Select lots of text in the test page before opening the menu. selectionText = await SpecialPowers.spawn( gBrowser.selectedBrowser, [], function () { let node = content.document.getElementById("editabletext"); node.scrollIntoView(); node.select(); node.focus(); return node.value; } ); await openContextMenu("#editabletext"); }, async doCloseMenu() { await closeExtensionContextMenu(); }, }); }); add_task(async function test_show_hide_video() { const VIDEO_URL = "data:video/webm,xxx"; await testShowHideEvent({ menuCreateParams: { title: "video item", contexts: ["video"], }, expectedShownEvent: { contexts: ["video", "all"], viewType: "tab", mediaType: "video", editable: false, frameId: 0, }, expectedShownEventWithPermissions: { contexts: ["video", "all"], viewType: "tab", mediaType: "video", editable: false, frameId: 0, srcUrl: VIDEO_URL, pageUrl: PAGE, targetElementId: EXPECT_TARGET_ELEMENT, }, async doOpenMenu() { await SpecialPowers.spawn( gBrowser.selectedBrowser, [VIDEO_URL], function (VIDEO_URL) { let video = content.document.createElement("video"); video.controls = true; video.src = VIDEO_URL; content.document.body.appendChild(video); video.scrollIntoView(); video.focus(); } ); await openContextMenu("video"); }, async doCloseMenu() { await closeExtensionContextMenu(); }, }); }); add_task(async function test_show_hide_audio() { const AUDIO_URL = "data:audio/ogg,xxx"; await testShowHideEvent({ menuCreateParams: { title: "audio item", contexts: ["audio"], }, expectedShownEvent: { contexts: ["audio", "all"], viewType: "tab", mediaType: "audio", editable: false, frameId: 0, }, expectedShownEventWithPermissions: { contexts: ["audio", "all"], viewType: "tab", mediaType: "audio", editable: false, frameId: 0, srcUrl: AUDIO_URL, pageUrl: PAGE, targetElementId: EXPECT_TARGET_ELEMENT, }, async doOpenMenu() { await SpecialPowers.spawn( gBrowser.selectedBrowser, [AUDIO_URL], function (AUDIO_URL) { let audio = content.document.createElement("audio"); audio.controls = true; audio.src = AUDIO_URL; content.document.body.appendChild(audio); audio.scrollIntoView(); audio.focus(); } ); await openContextMenu("audio"); }, async doCloseMenu() { await closeExtensionContextMenu(); }, }); });