diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-19 00:47:55 +0000 |
commit | 26a029d407be480d791972afb5975cf62c9360a6 (patch) | |
tree | f435a8308119effd964b339f76abb83a57c29483 /browser/components/extensions/test | |
parent | Initial commit. (diff) | |
download | firefox-26a029d407be480d791972afb5975cf62c9360a6.tar.xz firefox-26a029d407be480d791972afb5975cf62c9360a6.zip |
Adding upstream version 124.0.1.upstream/124.0.1
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'browser/components/extensions/test')
345 files changed, 72962 insertions, 0 deletions
diff --git a/browser/components/extensions/test/AppUiTestDelegate.sys.mjs b/browser/components/extensions/test/AppUiTestDelegate.sys.mjs new file mode 100644 index 0000000000..f68392800d --- /dev/null +++ b/browser/components/extensions/test/AppUiTestDelegate.sys.mjs @@ -0,0 +1,227 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +import { Assert } from "resource://testing-common/Assert.sys.mjs"; +import { BrowserTestUtils } from "resource://testing-common/BrowserTestUtils.sys.mjs"; + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + CustomizableUI: "resource:///modules/CustomizableUI.sys.mjs", +}); + +async function promiseAnimationFrame(window) { + await new Promise(resolve => window.requestAnimationFrame(resolve)); + + let { tm } = Services; + return new Promise(resolve => tm.dispatchToMainThread(resolve)); +} + +function makeWidgetId(id) { + id = id.toLowerCase(); + return id.replace(/[^a-z0-9_-]/g, "_"); +} + +async function getPageActionButtonId(window, extensionId) { + // This would normally be set automatically on navigation, and cleared + // when the user types a value into the URL bar, to show and hide page + // identity info and icons such as page action buttons. + // + // Unfortunately, that doesn't happen automatically in browser chrome + // tests. + window.gURLBar.setPageProxyState("valid"); + + let { gIdentityHandler } = window.gBrowser.ownerGlobal; + // If the current tab is blank and the previously selected tab was an internal + // page, the urlbar will now be showing the internal identity box due to the + // setPageProxyState call above. The page action button is hidden in that + // case, so make sure we're not showing the internal identity box. + gIdentityHandler._identityBox.classList.remove("chromeUI"); + + await promiseAnimationFrame(window); + + return window.BrowserPageActions.urlbarButtonNodeIDForActionID( + makeWidgetId(extensionId) + ); +} + +async function getPageActionButton(window, extensionId) { + let pageActionId = await getPageActionButtonId(window, extensionId); + return window.document.getElementById(pageActionId); +} + +async function clickPageAction(window, extensionId, modifiers = {}) { + let pageActionId = await getPageActionButtonId(window, extensionId); + await BrowserTestUtils.synthesizeMouseAtCenter( + `#${pageActionId}`, + modifiers, + window.browsingContext + ); + return new Promise(resolve => Services.tm.dispatchToMainThread(resolve)); +} + +function getBrowserActionWidgetId(extensionId) { + return makeWidgetId(extensionId) + "-browser-action"; +} + +function getBrowserActionWidget(extensionId) { + return lazy.CustomizableUI.getWidget(getBrowserActionWidgetId(extensionId)); +} + +async function showBrowserAction(window, extensionId) { + let group = getBrowserActionWidget(extensionId); + let widget = group.forWindow(window); + if (!widget.node) { + return; + } + + let navbar = window.document.getElementById("nav-bar"); + if (group.areaType == lazy.CustomizableUI.TYPE_TOOLBAR) { + Assert.equal( + widget.overflowed, + navbar.hasAttribute("overflowing"), + "Expect widget overflow state to match toolbar" + ); + } else if (group.areaType == lazy.CustomizableUI.TYPE_PANEL) { + let panel = window.gUnifiedExtensions.panel; + let shown = BrowserTestUtils.waitForPopupEvent(panel, "shown"); + window.gUnifiedExtensions.togglePanel(); + await shown; + } +} + +async function clickBrowserAction(window, extensionId, modifiers) { + await promiseAnimationFrame(window); + await showBrowserAction(window, extensionId); + + if (modifiers) { + let widgetId = getBrowserActionWidgetId(extensionId); + BrowserTestUtils.synthesizeMouseAtCenter( + `#${widgetId}`, + modifiers, + window.browsingContext + ); + } else { + let widget = getBrowserActionWidget(extensionId).forWindow(window); + widget.node.firstElementChild.click(); + } +} + +async function promisePopupShown(popup) { + return new Promise(resolve => { + if (popup.state == "open") { + resolve(); + } else { + let onPopupShown = event => { + popup.removeEventListener("popupshown", onPopupShown); + resolve(); + }; + popup.addEventListener("popupshown", onPopupShown); + } + }); +} + +function awaitBrowserLoaded(browser) { + if ( + browser.ownerGlobal.document.readyState === "complete" && + browser.currentURI.spec !== "about:blank" + ) { + return Promise.resolve(); + } + return new Promise(resolve => { + const listener = ev => { + if (browser.currentURI.spec === "about:blank") { + return; + } + browser.removeEventListener("AppTestDelegate:load", listener); + resolve(); + }; + browser.addEventListener("AppTestDelegate:load", listener); + }); +} + +function getPanelForNode(node) { + return node.closest("panel"); +} + +async function awaitExtensionPanel(window, extensionId, awaitLoad = true) { + let { originalTarget: browser } = await BrowserTestUtils.waitForEvent( + window.document, + "WebExtPopupLoaded", + true, + event => event.detail.extension.id === extensionId + ); + + await Promise.all([ + promisePopupShown(getPanelForNode(browser)), + + awaitLoad && awaitBrowserLoaded(browser), + ]); + + return browser; +} + +function closeBrowserAction(window, extensionId) { + let group = getBrowserActionWidget(extensionId); + + let node = window.document.getElementById(group.viewId); + lazy.CustomizableUI.hidePanelForNode(node); + + return Promise.resolve(); +} + +function getPageActionPopup(window, extensionId) { + let panelId = makeWidgetId(extensionId) + "-panel"; + return window.document.getElementById(panelId); +} + +function closePageAction(window, extensionId) { + let node = getPageActionPopup(window, extensionId); + if (node) { + return promisePopupShown(node).then(() => { + node.hidePopup(); + }); + } + + return Promise.resolve(); +} + +function openNewForegroundTab(window, url, waitForLoad = true) { + return BrowserTestUtils.openNewForegroundTab( + window.gBrowser, + url, + waitForLoad + ); +} + +async function removeTab(tab) { + BrowserTestUtils.removeTab(tab); +} + +// These methods are exported so that they can be used in head.js but are +// *not* part of the AppUiTestDelegate API. +export var AppUiTestInternals = { + awaitBrowserLoaded, + getBrowserActionWidget, + getBrowserActionWidgetId, + getPageActionButton, + getPageActionPopup, + getPanelForNode, + makeWidgetId, + promiseAnimationFrame, + promisePopupShown, + showBrowserAction, +}; + +// These methods are part of the AppUiTestDelegate API. All implementations need +// to be kept in sync. For details, see: +// testing/specialpowers/content/AppTestDelegateParent.sys.mjs +export var AppUiTestDelegate = { + awaitExtensionPanel, + clickBrowserAction, + clickPageAction, + closeBrowserAction, + closePageAction, + openNewForegroundTab, + removeTab, +}; diff --git a/browser/components/extensions/test/browser/.eslintrc.js b/browser/components/extensions/test/browser/.eslintrc.js new file mode 100644 index 0000000000..a0509253d6 --- /dev/null +++ b/browser/components/extensions/test/browser/.eslintrc.js @@ -0,0 +1,11 @@ +"use strict"; + +module.exports = { + env: { + webextensions: true, + }, + + rules: { + "no-shadow": 0, + }, +}; diff --git a/browser/components/extensions/test/browser/authenticate.sjs b/browser/components/extensions/test/browser/authenticate.sjs new file mode 100644 index 0000000000..be1aac246b --- /dev/null +++ b/browser/components/extensions/test/browser/authenticate.sjs @@ -0,0 +1,85 @@ +"use strict"; + +function handleRequest(request, response) { + let match; + let requestAuth = true; + + // Allow the caller to drive how authentication is processed via the query. + // Eg, http://localhost:8888/authenticate.sjs?user=foo&realm=bar + // The extra ? allows the user/pass/realm checks to succeed if the name is + // at the beginning of the query string. + let query = "?" + request.queryString; + + let expected_user = "test", + expected_pass = "testpass", + realm = "mochitest"; + + // user=xxx + match = /[^_]user=([^&]*)/.exec(query); + if (match) { + expected_user = match[1]; + } + + // pass=xxx + match = /[^_]pass=([^&]*)/.exec(query); + if (match) { + expected_pass = match[1]; + } + + // realm=xxx + match = /[^_]realm=([^&]*)/.exec(query); + if (match) { + realm = match[1]; + } + + // Look for an authentication header, if any, in the request. + // + // EG: Authorization: Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ== + // + // This test only supports Basic auth. The value sent by the client is + // "username:password", obscured with base64 encoding. + + let actual_user = "", + actual_pass = "", + authHeader; + if (request.hasHeader("Authorization")) { + authHeader = request.getHeader("Authorization"); + match = /Basic (.+)/.exec(authHeader); + if (match.length != 2) { + throw new Error("Couldn't parse auth header: " + authHeader); + } + /* eslint-disable-next-line no-use-before-define */ + let userpass = atob(match[1]); + match = /(.*):(.*)/.exec(userpass); + if (match.length != 3) { + throw new Error("Couldn't decode auth header: " + userpass); + } + actual_user = match[1]; + actual_pass = match[2]; + } + + // Don't request authentication if the credentials we got were what we + // expected. + if (expected_user == actual_user && expected_pass == actual_pass) { + requestAuth = false; + } + + if (requestAuth) { + response.setStatusLine("1.0", 401, "Authentication required"); + response.setHeader("WWW-Authenticate", 'basic realm="' + realm + '"', true); + } else { + response.setStatusLine("1.0", 200, "OK"); + } + + response.setHeader("Content-Type", "application/xhtml+xml", false); + response.write("<html xmlns='http://www.w3.org/1999/xhtml'>"); + response.write( + "<p>Login: <span id='ok'>" + + (requestAuth ? "FAIL" : "PASS") + + "</span></p>\n" + ); + response.write("<p>Auth: <span id='auth'>" + authHeader + "</span></p>\n"); + response.write("<p>User: <span id='user'>" + actual_user + "</span></p>\n"); + response.write("<p>Pass: <span id='pass'>" + actual_pass + "</span></p>\n"); + response.write("</html>"); +} diff --git a/browser/components/extensions/test/browser/browser-private.toml b/browser/components/extensions/test/browser/browser-private.toml new file mode 100644 index 0000000000..6d4c44f16c --- /dev/null +++ b/browser/components/extensions/test/browser/browser-private.toml @@ -0,0 +1,11 @@ +[DEFAULT] +# +# This manifest lists tests that use permanent private browsing mode. +# +tags = "webextensions" +prefs = ["browser.privatebrowsing.autostart=true"] +support-files = ["head.js"] + +["browser_ext_tabs_cookieStoreId_private.js"] + +["browser_ext_tabs_newtab_private.js"] diff --git a/browser/components/extensions/test/browser/browser.toml b/browser/components/extensions/test/browser/browser.toml new file mode 100644 index 0000000000..c185ebd4e7 --- /dev/null +++ b/browser/components/extensions/test/browser/browser.toml @@ -0,0 +1,702 @@ +[DEFAULT] +tags = "webextensions" +prefs = [ + "browser.sessionstore.closedTabsFromAllWindows=true", + "browser.sessionstore.closedTabsFromClosedWindows=true", + "dom.ipc.keepProcessesAlive.extension=1", # We don't want to reset this at the end of the test, so that we don't have to spawn a new extension child process for each test unit. + "dom.animations-api.core.enabled=true", + "dom.animations-api.timelines.enabled=true", + "javascript.options.asyncstack_capture_debuggee_only=false", +] +support-files = [ + "head.js", + "head_devtools.js", + "silence.ogg", + "head_browserAction.js", + "head_pageAction.js", + "head_sessions.js", + "head_unified_extensions.js", + "head_webNavigation.js", + "context.html", + "context_frame.html", + "ctxmenu-image.png", + "context_with_redirect.html", + "context_tabs_onUpdated_page.html", + "context_tabs_onUpdated_iframe.html", + "file_dataTransfer_files.html", + "file_find_frames.html", + "file_popup_api_injection_a.html", + "file_popup_api_injection_b.html", + "file_iframe_document.html", + "file_inspectedwindow_eval.html", + "file_inspectedwindow_reload_target.sjs", + "file_slowed_document.sjs", + "file_bypass_cache.sjs", + "file_dummy.html", + "file_title.html", + "file_with_xorigin_frame.html", + "file_with_example_com_frame.html", + "webNav_createdTarget.html", + "webNav_createdTargetSource.html", + "webNav_createdTargetSource_subframe.html", + "redirect_to.sjs", + "search-engines/*", + "searchSuggestionEngine.xml", + "searchSuggestionEngine.sjs", + "empty.xpi", + "../../../../../toolkit/components/extensions/test/mochitest/head_webrequest.js", + "../../../../../toolkit/components/extensions/test/mochitest/redirection.sjs", + "../../../../../toolkit/components/reader/test/readerModeNonArticle.html", + "../../../../../toolkit/components/reader/test/readerModeArticle.html", +] +skip-if = ["os == 'linux' && asan"] # Bug 1721945 - Software WebRender + +["browser_AMBrowserExtensionsImport.js"] + +["browser_ExtensionControlledPopup.js"] + +["browser_ext_action_popup_allowed_urls.js"] + +["browser_ext_activeScript.js"] + +["browser_ext_addon_debugging_netmonitor.js"] + +["browser_ext_autocompletepopup.js"] +disabled = "bug 1438663 # same focus issue as Bug 1438663" + +["browser_ext_autoplayInBackground.js"] + +["browser_ext_browserAction_activeTab.js"] + +["browser_ext_browserAction_area.js"] + +["browser_ext_browserAction_click_types.js"] + +["browser_ext_browserAction_context.js"] +https_first_disabled = true +skip-if = [ + "os == 'linux' && debug", # Bug 1504096 + "os == 'linux' && socketprocess_networking", +] + +["browser_ext_browserAction_contextMenu.js"] +skip-if = ["os == 'linux'"] # bug 1369197 + +["browser_ext_browserAction_disabled.js"] + +["browser_ext_browserAction_experiment.js"] + +["browser_ext_browserAction_getUserSettings.js"] + +["browser_ext_browserAction_incognito.js"] + +["browser_ext_browserAction_keyclick.js"] + +["browser_ext_browserAction_pageAction_icon.js"] + +["browser_ext_browserAction_pageAction_icon_permissions.js"] + +["browser_ext_browserAction_popup.js"] + +["browser_ext_browserAction_popup_port.js"] + +["browser_ext_browserAction_popup_preload.js"] +skip-if = [ + "os == 'win' && !debug", + "verify && debug && os == 'mac'", # bug 1352668 +] + +["browser_ext_browserAction_popup_preload_smoketest.js"] +skip-if = ["debug"] # Bug 1746047 + +["browser_ext_browserAction_popup_resize.js"] + +["browser_ext_browserAction_popup_resize_bottom.js"] +skip-if = ["debug"] # Bug 1522164 + +["browser_ext_browserAction_simple.js"] + +["browser_ext_browserAction_telemetry.js"] + +["browser_ext_browserAction_theme_icons.js"] + +["browser_ext_browsingData_cookieStoreId.js"] + +["browser_ext_browsingData_formData.js"] + +["browser_ext_browsingData_history.js"] + +["browser_ext_chrome_settings_overrides_home.js"] + +["browser_ext_commands_execute_browser_action.js"] + +["browser_ext_commands_execute_page_action.js"] +skip-if = [ + "verify && os == 'linux'", + "verify && os == 'mac'", +] + +["browser_ext_commands_execute_sidebar_action.js"] + +["browser_ext_commands_getAll.js"] + +["browser_ext_commands_onChanged.js"] + +["browser_ext_commands_onCommand.js"] +skip-if = ["debug"] # bug 1553577 + +["browser_ext_commands_update.js"] + +["browser_ext_connect_and_move_tabs.js"] + +["browser_ext_contentscript_animate.js"] + +["browser_ext_contentscript_connect.js"] + +["browser_ext_contentscript_cross_docGroup_adoption.js"] +https_first_disabled = true + +["browser_ext_contentscript_cross_docGroup_adoption_xhr.js"] +https_first_disabled = true + +["browser_ext_contentscript_dataTransfer_files.js"] + +["browser_ext_contentscript_in_parent.js"] + +["browser_ext_contentscript_incognito.js"] + +["browser_ext_contentscript_nontab_connect.js"] + +["browser_ext_contentscript_sender_url.js"] +skip-if = ["debug"] # The nature of the reduced STR test triggers an unrelated debug assertion in DOM IPC code, see bug 1736590. + +["browser_ext_contextMenus.js"] +support-files = [ "!/browser/components/places/tests/browser/head.js"] + +["browser_ext_contextMenus_bookmarks.js"] +support-files = [ "!/browser/components/places/tests/browser/head.js"] + +["browser_ext_contextMenus_checkboxes.js"] + +["browser_ext_contextMenus_commands.js"] + +["browser_ext_contextMenus_icons.js"] + +["browser_ext_contextMenus_onclick.js"] +https_first_disabled = true + +["browser_ext_contextMenus_radioGroups.js"] + +["browser_ext_contextMenus_srcUrl_redirect.js"] + +["browser_ext_contextMenus_targetUrlPatterns.js"] +skip-if = ["apple_silicon"] # Disabled due to bleedover with other tests when run in regular suites; passes in "failures" jobs + +["browser_ext_contextMenus_uninstall.js"] + +["browser_ext_contextMenus_urlPatterns.js"] + +["browser_ext_currentWindow.js"] + +["browser_ext_devtools_inspectedWindow.js"] + +["browser_ext_devtools_inspectedWindow_eval_bindings.js"] + +["browser_ext_devtools_inspectedWindow_eval_file.js"] + +["browser_ext_devtools_inspectedWindow_reload.js"] + +["browser_ext_devtools_inspectedWindow_targetSwitch.js"] + +["browser_ext_devtools_network.js"] +https_first_disabled = true + +["browser_ext_devtools_network_targetSwitch.js"] +https_first_disabled = true + +["browser_ext_devtools_optional.js"] + +["browser_ext_devtools_page.js"] + +["browser_ext_devtools_page_incognito.js"] + +["browser_ext_devtools_panel.js"] + +["browser_ext_devtools_panels_elements.js"] + +["browser_ext_devtools_panels_elements_sidebar.js"] +support-files = ["../../../../../devtools/client/inspector/extensions/test/head_devtools_inspector_sidebar.js"] + +["browser_ext_find.js"] +https_first_disabled = true +skip-if = [ + "verify && os == 'linux'", + "verify && os == 'mac'", +] + +["browser_ext_getViews.js"] + +["browser_ext_history_redirect.js"] + +["browser_ext_identity_indication.js"] + +["browser_ext_incognito_popup.js"] + +["browser_ext_incognito_views.js"] +skip-if = [ + "apple_silicon && !fission", # Disabled due to bleedover with other tests when run in regular suites; passes in "failures" jobs +] + +["browser_ext_lastError.js"] + +["browser_ext_management.js"] + +["browser_ext_menus.js"] +https_first_disabled = true + +["browser_ext_menus_accesskey.js"] + +["browser_ext_menus_activeTab.js"] + +["browser_ext_menus_capture_secondary_click.js"] + +["browser_ext_menus_errors.js"] + +["browser_ext_menus_event_order.js"] + +["browser_ext_menus_eventpage.js"] + +["browser_ext_menus_events.js"] + +["browser_ext_menus_events_after_context_destroy.js"] + +["browser_ext_menus_incognito.js"] + +["browser_ext_menus_refresh.js"] +skip-if = ["a11y_checks"] # Bug 1858041 and 1835079 for causing intermittent crashes + +["browser_ext_menus_replace_menu.js"] + +["browser_ext_menus_replace_menu_context.js"] +https_first_disabled = true + +["browser_ext_menus_replace_menu_permissions.js"] + +["browser_ext_menus_targetElement.js"] + +["browser_ext_menus_targetElement_extension.js"] +skip-if = ["apple_silicon"] # Disabled due to bleedover with other tests when run in regular suites; passes in "failures" jobs + +["browser_ext_menus_targetElement_shadow.js"] + +["browser_ext_menus_viewType.js"] +skip-if = ["apple_silicon"] # Disabled due to bleedover with other tests when run in regular suites; passes in "failures" jobs + +["browser_ext_menus_visible.js"] + +["browser_ext_mousewheel_zoom.js"] + +["browser_ext_nontab_process_switch.js"] +https_first_disabled = true + +["browser_ext_omnibox.js"] + +["browser_ext_openPanel.js"] +skip-if = [ + "verify && !debug && os == 'linux'", + "verify && !debug && os == 'mac'", +] + +["browser_ext_optionsPage_activity.js"] + +["browser_ext_optionsPage_browser_style.js"] + +["browser_ext_optionsPage_links_open_in_tabs.js"] + +["browser_ext_optionsPage_modals.js"] + +["browser_ext_optionsPage_popups.js"] + +["browser_ext_optionsPage_privileges.js"] +https_first_disabled = true + +["browser_ext_originControls.js"] + +["browser_ext_pageAction_activeTab.js"] + +["browser_ext_pageAction_click_types.js"] + +["browser_ext_pageAction_context.js"] +https_first_disabled = true +skip-if = ["verify && !debug && os == 'linux'"] + +["browser_ext_pageAction_contextMenu.js"] + +["browser_ext_pageAction_popup.js"] + +["browser_ext_pageAction_popup_resize.js"] +skip-if = ["verify && debug && os == 'mac'"] + +["browser_ext_pageAction_show_matches.js"] +https_first_disabled = true + +["browser_ext_pageAction_simple.js"] + +["browser_ext_pageAction_telemetry.js"] + +["browser_ext_pageAction_title.js"] + +["browser_ext_persistent_storage_permission_indication.js"] + +["browser_ext_popup_api_injection.js"] + +["browser_ext_popup_background.js"] + +["browser_ext_popup_corners.js"] + +["browser_ext_popup_focus.js"] + +["browser_ext_popup_links_open_in_tabs.js"] + +["browser_ext_popup_requestPermission.js"] + +["browser_ext_popup_select.js"] +skip-if = [ + "debug", # FIXME: re-enable on debug build (bug 1442822) + "os != 'win'", +] + +["browser_ext_popup_select_in_oopif.js"] +skip-if = ["os == 'linux' && swgl && fission && tsan"] # high frequency intermittent + +["browser_ext_popup_sendMessage.js"] + +["browser_ext_popup_shutdown.js"] + +["browser_ext_port_disconnect_on_crash.js"] +https_first_disabled = true +run-if = ["crashreporter"] + +["browser_ext_port_disconnect_on_window_close.js"] + +["browser_ext_reload_manifest_cache.js"] + +["browser_ext_request_permissions.js"] + +["browser_ext_runtime_onPerformanceWarning.js"] + +["browser_ext_runtime_openOptionsPage.js"] + +["browser_ext_runtime_openOptionsPage_uninstall.js"] + +["browser_ext_runtime_setUninstallURL.js"] + +["browser_ext_search.js"] + +["browser_ext_search_favicon.js"] + +["browser_ext_search_query.js"] + +["browser_ext_sessions_forgetClosedTab.js"] + +["browser_ext_sessions_forgetClosedWindow.js"] + +["browser_ext_sessions_getRecentlyClosed.js"] +https_first_disabled = true + +["browser_ext_sessions_getRecentlyClosed_private.js"] + +["browser_ext_sessions_getRecentlyClosed_tabs.js"] +support-files = [ + "file_has_non_web_controlled_blank_page_link.html", + "wait-a-bit.sjs", +] + +["browser_ext_sessions_incognito.js"] + +["browser_ext_sessions_restore.js"] + +["browser_ext_sessions_restoreTab.js"] +https_first_disabled = true + +["browser_ext_sessions_restore_private.js"] + +["browser_ext_sessions_window_tab_value.js"] +https_first_disabled = true +skip-if = ["debug"] # Bug 1394984 disable debug builds on all platforms + +["browser_ext_settings_overrides_default_search.js"] + +["browser_ext_sidebarAction.js"] + +["browser_ext_sidebarAction_browser_style.js"] + +["browser_ext_sidebarAction_click.js"] + +["browser_ext_sidebarAction_context.js"] + +["browser_ext_sidebarAction_contextMenu.js"] +skip-if = ["apple_silicon"] # Disabled due to bleedover with other tests when run in regular suites; passes in "failures" jobs + +["browser_ext_sidebarAction_httpAuth.js"] +support-files = ["authenticate.sjs"] + +["browser_ext_sidebarAction_incognito.js"] +skip-if = ["true"] # Bug 1575369 + +["browser_ext_sidebarAction_runtime.js"] + +["browser_ext_sidebarAction_tabs.js"] + +["browser_ext_sidebarAction_windows.js"] + +["browser_ext_sidebar_requestPermission.js"] + +["browser_ext_simple.js"] + +["browser_ext_slow_script.js"] +https_first_disabled = true +skip-if = [ + "debug", + "asan", + "tsan" # Bug 1874317 +] + +["browser_ext_tab_runtimeConnect.js"] + +["browser_ext_tabs_attention.js"] +https_first_disabled = true + +["browser_ext_tabs_audio.js"] + +["browser_ext_tabs_autoDiscardable.js"] + +["browser_ext_tabs_containerIsolation.js"] +https_first_disabled = true + +["browser_ext_tabs_cookieStoreId.js"] + +["browser_ext_tabs_create.js"] + +["browser_ext_tabs_create_invalid_url.js"] + +["browser_ext_tabs_create_url.js"] + +["browser_ext_tabs_discard.js"] + +["browser_ext_tabs_discard_reversed.js"] +https_first_disabled = true +skip-if = [ + "os == 'mac'", # Bug 1722607 + "os == 'linux' && debug", #Bug 1722607 +] + +["browser_ext_tabs_discarded.js"] +https_first_disabled = true + +["browser_ext_tabs_duplicate.js"] +https_first_disabled = true + +["browser_ext_tabs_events.js"] + +["browser_ext_tabs_events_order.js"] +https_first_disabled = true + +["browser_ext_tabs_executeScript.js"] +https_first_disabled = true + +["browser_ext_tabs_executeScript_about_blank.js"] + +["browser_ext_tabs_executeScript_bad.js"] + +["browser_ext_tabs_executeScript_file.js"] + +["browser_ext_tabs_executeScript_good.js"] + +["browser_ext_tabs_executeScript_multiple.js"] + +["browser_ext_tabs_executeScript_no_create.js"] + +["browser_ext_tabs_executeScript_runAt.js"] + +["browser_ext_tabs_getCurrent.js"] + +["browser_ext_tabs_goBack_goForward.js"] + +["browser_ext_tabs_hide.js"] +https_first_disabled = true + +["browser_ext_tabs_hide_update.js"] +https_first_disabled = true + +["browser_ext_tabs_highlight.js"] + +["browser_ext_tabs_incognito_not_allowed.js"] + +["browser_ext_tabs_insertCSS.js"] +https_first_disabled = true + +["browser_ext_tabs_lastAccessed.js"] + +["browser_ext_tabs_lazy.js"] + +["browser_ext_tabs_move_array.js"] +https_first_disabled = true + +["browser_ext_tabs_move_array_multiple_windows.js"] + +["browser_ext_tabs_move_discarded.js"] + +["browser_ext_tabs_move_window.js"] +skip-if = ["win10_2009 && debug"] # Bug 1730374 + +["browser_ext_tabs_move_window_multiple.js"] + +["browser_ext_tabs_move_window_pinned.js"] + +["browser_ext_tabs_onCreated.js"] + +["browser_ext_tabs_onHighlighted.js"] + +["browser_ext_tabs_onUpdated.js"] + +["browser_ext_tabs_onUpdated_filter.js"] + +["browser_ext_tabs_opener.js"] + +["browser_ext_tabs_printPreview.js"] +https_first_disabled = true + +["browser_ext_tabs_query.js"] +https_first_disabled = true + +["browser_ext_tabs_readerMode.js"] +https_first_disabled = true + +["browser_ext_tabs_reload.js"] + +["browser_ext_tabs_reload_bypass_cache.js"] + +["browser_ext_tabs_remove.js"] + +["browser_ext_tabs_removeCSS.js"] + +["browser_ext_tabs_saveAsPDF.js"] +https_first_disabled = true + +["browser_ext_tabs_sendMessage.js"] +https_first_disabled = true + +["browser_ext_tabs_sharingState.js"] +https_first_disabled = true + +["browser_ext_tabs_successors.js"] + +["browser_ext_tabs_update.js"] + +["browser_ext_tabs_update_highlighted.js"] + +["browser_ext_tabs_update_url.js"] + +["browser_ext_tabs_warmup.js"] +https_first_disabled = true + +["browser_ext_tabs_zoom.js"] + +["browser_ext_themes_validation.js"] + +["browser_ext_topSites.js"] + +["browser_ext_url_overrides_newtab.js"] +skip-if = [ + "os == 'linux' && os_version == '18.04'", # Bug 1651261 + "win11_2009 && asan", # Bug 1797751 + "win11_2009 && debug", # Bug 1797751 +] + +["browser_ext_user_events.js"] + +["browser_ext_webNavigation_containerIsolation.js"] +https_first_disabled = true + +["browser_ext_webNavigation_frameId0.js"] + +["browser_ext_webNavigation_getFrames.js"] + +["browser_ext_webNavigation_onCreatedNavigationTarget.js"] + +["browser_ext_webNavigation_onCreatedNavigationTarget_contextmenu.js"] + +["browser_ext_webNavigation_onCreatedNavigationTarget_named_window.js"] + +["browser_ext_webNavigation_onCreatedNavigationTarget_subframe_window_open.js"] + +["browser_ext_webNavigation_onCreatedNavigationTarget_window_open.js"] + +["browser_ext_webNavigation_urlbar_transitions.js"] +https_first_disabled = true + +["browser_ext_webRequest.js"] + +["browser_ext_webRequest_error_after_stopped_or_closed.js"] + +["browser_ext_webrtc.js"] +skip-if = ["os == 'mac'"] # Bug 1565738 + +["browser_ext_windows.js"] +https_first_disabled = true + +["browser_ext_windows_allowScriptsToClose.js"] +https_first_disabled = true + +["browser_ext_windows_create.js"] +skip-if = ["verify && os == 'mac'"] +tags = "fullscreen" + +["browser_ext_windows_create_cookieStoreId.js"] + +["browser_ext_windows_create_params.js"] + +["browser_ext_windows_create_tabId.js"] +https_first_disabled = true + +["browser_ext_windows_create_url.js"] + +["browser_ext_windows_events.js"] + +["browser_ext_windows_incognito.js"] + +["browser_ext_windows_remove.js"] + +["browser_ext_windows_size.js"] +skip-if = [ + "os == 'mac'", + "os == 'linux' && os_version == '18.04' && debug", # Fails when windows are randomly opened in fullscreen mode, Bug 1638027 +] + +["browser_ext_windows_update.js"] +skip-if = [ + "verify && os == 'mac'", + "os == 'mac' && os_version == '10.15' && debug", # Bug 1780998 + "os == 'linux' && os_version == '18.04'", # Bug 1533982 for linux1804 +] +tags = "fullscreen" + +["browser_toolbar_prefers_color_scheme.js"] + +["browser_unified_extensions.js"] +fail-if = ["a11y_checks"] # Bug 1854460 clicked browser may not be accessible + +["browser_unified_extensions_accessibility.js"] + +["browser_unified_extensions_context_menu.js"] +skip-if = ["true"] #Bug 1800712 + +["browser_unified_extensions_cui.js"] + +["browser_unified_extensions_doorhangers.js"] + +["browser_unified_extensions_messages.js"] + +["browser_unified_extensions_overflowable_toolbar.js"] +tags = "overflowable-toolbar" diff --git a/browser/components/extensions/test/browser/browser_AMBrowserExtensionsImport.js b/browser/components/extensions/test/browser/browser_AMBrowserExtensionsImport.js new file mode 100644 index 0000000000..a680edb454 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_AMBrowserExtensionsImport.js @@ -0,0 +1,286 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +const { AMBrowserExtensionsImport } = ChromeUtils.importESModule( + "resource://gre/modules/AddonManager.sys.mjs" +); + +const { AddonTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/AddonTestUtils.sys.mjs" +); + +// This test verifies the appmenu UI when there are pending imported add-ons. +// The UI in `about:addons` is covered by tests in +// `toolkit/mozapps/extensions/test/browser/browser_AMBrowserExtensionsImport.js`. + +AddonTestUtils.initMochitest(this); + +const TEST_SERVER = AddonTestUtils.createHttpServer(); + +const ADDONS = { + ext1: { + manifest: { + name: "Ext 1", + version: "1.0", + browser_specific_settings: { gecko: { id: "ff@ext-1" } }, + permissions: ["history"], + }, + }, + ext2: { + manifest: { + name: "Ext 2", + version: "1.0", + browser_specific_settings: { gecko: { id: "ff@ext-2" } }, + permissions: ["history"], + }, + }, +}; +// Populated in `setup()`. +const XPIS = {}; +// Populated in `setup()`. +const ADDON_SEARCH_RESULTS = {}; + +const mockAddonRepository = ({ addons = [] }) => { + return { + async getMappedAddons(browserID, extensionIDs) { + return Promise.resolve({ + addons, + matchedIDs: [], + unmatchedIDs: [], + }); + }, + }; +}; + +add_setup(async function setup() { + for (const [name, data] of Object.entries(ADDONS)) { + XPIS[name] = AddonTestUtils.createTempWebExtensionFile(data); + TEST_SERVER.registerFile(`/addons/${name}.xpi`, XPIS[name]); + + ADDON_SEARCH_RESULTS[name] = { + id: data.manifest.browser_specific_settings.gecko.id, + name: data.name, + version: data.version, + sourceURI: Services.io.newURI( + `http://localhost:${TEST_SERVER.identity.primaryPort}/addons/${name}.xpi` + ), + icons: {}, + }; + } + + registerCleanupFunction(() => { + // Clear the add-on repository override. + AMBrowserExtensionsImport._addonRepository = null; + }); +}); + +add_task(async function test_appmenu_notification() { + const browserID = "some-browser-id"; + const extensionIDs = ["ext-1", "ext-2"]; + AMBrowserExtensionsImport._addonRepository = mockAddonRepository({ + addons: Object.values(ADDON_SEARCH_RESULTS), + }); + const menuButton = document.getElementById("PanelUI-menu-button"); + + let promiseTopic = TestUtils.topicObserved( + "webextension-imported-addons-pending" + ); + // Start a first import... + await AMBrowserExtensionsImport.stageInstalls(browserID, extensionIDs); + await promiseTopic; + Assert.equal( + menuButton.getAttribute("badge-status"), + "addon-alert", + "expected menu button to have the addon-alert badge" + ); + + // ...then cancel it. + promiseTopic = TestUtils.topicObserved( + "webextension-imported-addons-cancelled" + ); + await AMBrowserExtensionsImport.cancelInstalls(); + await promiseTopic; + Assert.ok( + !menuButton.hasAttribute("badge-status"), + "expected menu button to no longer have the addon-alert badge" + ); + + // We start a second import here, then we complete it with the UI. + promiseTopic = TestUtils.topicObserved( + "webextension-imported-addons-pending" + ); + const result = await AMBrowserExtensionsImport.stageInstalls( + browserID, + extensionIDs + ); + await promiseTopic; + Assert.equal( + menuButton.getAttribute("badge-status"), + "addon-alert", + "expected menu button to have the addon-alert badge" + ); + + // Open the appmenu panel in order to verify the notification. + const menuPanelShown = BrowserTestUtils.waitForEvent( + PanelUI.panel, + "ViewShown" + ); + menuButton.click(); + await menuPanelShown; + + const notifications = PanelUI.addonNotificationContainer; + Assert.equal( + notifications.children.length, + 1, + "expected a notification about the imported add-ons" + ); + + const endedPromises = result.importedAddonIDs.map(id => + AddonTestUtils.promiseInstallEvent("onInstallEnded") + ); + const menuPanelHidden = BrowserTestUtils.waitForEvent( + PanelUI.panel, + "popuphidden" + ); + promiseTopic = TestUtils.topicObserved( + "webextension-imported-addons-complete" + ); + // Complete the installation of the add-ons by clicking on the notification. + notifications.children[0].click(); + await Promise.all([...endedPromises, promiseTopic, menuPanelHidden]); + Assert.ok( + !menuButton.hasAttribute("badge-status"), + "expected menu button to no longer have the addon-alert badge" + ); + + for (const id of result.importedAddonIDs) { + const addon = await AddonManager.getAddonByID(id); + Assert.ok(addon.isActive, `expected add-on "${id}" to be enabled`); + await addon.uninstall(); + } +}); + +add_task(async function test_appmenu_notification_with_sideloaded_addon() { + await SpecialPowers.pushPrefEnv({ + set: [["extensions.autoDisableScopes", 15]], + }); + + const menuButton = document.getElementById("PanelUI-menu-button"); + + // Load a sideloaded add-on, which will be user-disabled by default. + const sideloadedXPI = AddonTestUtils.createTempWebExtensionFile({ + manifest: { + name: "sideloaded add-on", + version: "1.0", + browser_specific_settings: { gecko: { id: "ff@sideloaded" } }, + }, + }); + await AddonTestUtils.manuallyInstall(sideloadedXPI); + const changePromise = new Promise(resolve => + ExtensionsUI.once("change", resolve) + ); + ExtensionsUI._checkForSideloaded(); + await changePromise; + + // We should have a badge for the sideloaded add-on on the appmenu button. + Assert.equal( + menuButton.getAttribute("badge-status"), + "addon-alert", + "expected menu button to have the addon-alert badge" + ); + + // Now, we start an import... + const browserID = "some-browser-id"; + const extensionIDs = ["ext-1", "ext-2"]; + AMBrowserExtensionsImport._addonRepository = mockAddonRepository({ + addons: Object.values(ADDON_SEARCH_RESULTS), + }); + + let promiseTopic = TestUtils.topicObserved( + "webextension-imported-addons-pending" + ); + await AMBrowserExtensionsImport.stageInstalls(browserID, extensionIDs); + await promiseTopic; + + // Badge should still be shown. + Assert.equal( + menuButton.getAttribute("badge-status"), + "addon-alert", + "expected menu button to have the addon-alert badge" + ); + + // Open the appmenu panel in order to verify the notifications. + const menuPanelShown = BrowserTestUtils.waitForEvent( + PanelUI.panel, + "ViewShown" + ); + menuButton.click(); + await menuPanelShown; + + // We expect two notifications: one for the imported add-ons (listed first), + // and the second about the sideloaded add-on. + const notifications = PanelUI.addonNotificationContainer; + Assert.equal(notifications.children.length, 2, "expected two notifications"); + Assert.equal( + notifications.children[0].id, + "webext-imported-addons", + "expected a notification for the imported add-ons" + ); + Assert.equal( + notifications.children[1].id, + "webext-perms-sideload-menu-item", + "expected a notification for the sideloaded add-on" + ); + + // Cancel the import to make sure the UI is updated accordingly. + promiseTopic = TestUtils.topicObserved( + "webextension-imported-addons-cancelled" + ); + await AMBrowserExtensionsImport.cancelInstalls(); + await promiseTopic; + + // Badge should still be shown since there is still the sideloaded + // notification but we do not expect the notification for the imported + // add-ons anymore. + Assert.ok( + menuButton.hasAttribute("badge-status"), + "expected menu button to have the addon-alert badge" + ); + Assert.equal(notifications.children.length, 1, "expected a notification"); + Assert.equal( + notifications.children[0].id, + "webext-perms-sideload-menu-item", + "expected a notification for the sideloaded add-on" + ); + + // Click the sideloaded add-on notification. + const menuPanelHidden = BrowserTestUtils.waitForEvent( + PanelUI.panel, + "popuphidden" + ); + const popupPromise = promisePopupNotificationShown( + "addon-webext-permissions" + ); + notifications.children[0].click(); + const [popup] = await Promise.all([popupPromise, menuPanelHidden]); + + // The user should see a new popup asking them about enabling the sideloaded + // add-on. Let's keep the add-on disabled. + popup.secondaryButton.click(); + + // The badge should be hidden now, and there shouldn't be any notification in + // the panel anymore. + Assert.ok( + !menuButton.hasAttribute("badge-status"), + "expected menu button to no longer have the addon-alert badge" + ); + Assert.equal(notifications.children.length, 0, "expected no notification"); + + // Unload the sideloaded add-on. + const addon = await AddonManager.getAddonByID("ff@sideloaded"); + Assert.ok(addon.userDisabled, "expected add-on to be disabled"); + await addon.uninstall(); + + await SpecialPowers.popPrefEnv(); +}); diff --git a/browser/components/extensions/test/browser/browser_ExtensionControlledPopup.js b/browser/components/extensions/test/browser/browser_ExtensionControlledPopup.js new file mode 100644 index 0000000000..2e1e178215 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ExtensionControlledPopup.js @@ -0,0 +1,241 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ + +"use strict"; +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + ExtensionControlledPopup: + "resource:///modules/ExtensionControlledPopup.sys.mjs", + ExtensionSettingsStore: + "resource://gre/modules/ExtensionSettingsStore.sys.mjs", +}); + +function createMarkup(doc, popup) { + let panel = ExtensionControlledPopup._getAndMaybeCreatePanel(doc); + let popupnotification = doc.createXULElement("popupnotification"); + let attributes = { + id: "extension-controlled-notification", + class: "extension-controlled-notification", + popupid: "extension-controlled", + hidden: "true", + label: "ExtControlled", + buttonlabel: "Keep Changes", + buttonaccesskey: "K", + secondarybuttonlabel: "Restore Settings", + secondarybuttonaccesskey: "R", + closebuttonhidden: "true", + dropmarkerhidden: "true", + checkboxhidden: "true", + }; + Object.entries(attributes).forEach(([key, value]) => { + popupnotification.setAttribute(key, value); + }); + let content = doc.createXULElement("popupnotificationcontent"); + content.setAttribute("orient", "vertical"); + let description = doc.createXULElement("description"); + description.setAttribute("id", "extension-controlled-description"); + content.appendChild(description); + popupnotification.appendChild(content); + panel.appendChild(popupnotification); + + registerCleanupFunction(function removePopup() { + popupnotification.remove(); + }); + + return { panel, popupnotification }; +} + +/* + * This function is a unit test for ExtensionControlledPopup. It is also tested + * where it is being used (currently New Tab and homepage). An empty extension + * is used along with the expected markup as an example. + */ +add_task(async function testExtensionControlledPopup() { + let id = "ext-controlled@mochi.test"; + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { gecko: { id } }, + name: "Ext Controlled", + }, + // We need to be able to find the extension using AddonManager. + useAddonManager: "temporary", + }); + + await extension.startup(); + let addon = await AddonManager.getAddonByID(id); + await ExtensionSettingsStore.initialize(); + + let confirmedType = "extension-controlled-confirmed"; + let onObserverAdded = sinon.spy(); + let onObserverRemoved = sinon.spy(); + let observerTopic = "extension-controlled-event"; + let beforeDisableAddon = sinon.spy(); + let settingType = "extension-controlled"; + let settingKey = "some-key"; + let popup = new ExtensionControlledPopup({ + confirmedType, + observerTopic, + popupnotificationId: "extension-controlled-notification", + settingType, + settingKey, + descriptionId: "extension-controlled-description", + descriptionMessageId: "newTabControlled.message2", + learnMoreLink: "extension-controlled", + onObserverAdded, + onObserverRemoved, + beforeDisableAddon, + }); + + let doc = Services.wm.getMostRecentWindow("navigator:browser").document; + let { panel, popupnotification } = createMarkup(doc, popup); + + function openPopupWithEvent() { + let popupShown = promisePopupShown(panel); + Services.obs.notifyObservers(null, observerTopic); + return popupShown; + } + + function closePopupWithAction(action, extensionId) { + let done; + if (action == "ignore") { + panel.hidePopup(); + } else if (action == "button") { + done = TestUtils.waitForCondition(() => { + return ExtensionSettingsStore.getSetting(confirmedType, id, id).value; + }); + popupnotification.button.click(); + } else if (action == "secondarybutton") { + done = awaitEvent("shutdown", id); + popupnotification.secondaryButton.click(); + } + return done; + } + + // No callbacks are initially called. + ok(!onObserverAdded.called, "No observer has been added"); + ok(!onObserverRemoved.called, "No observer has been removed"); + ok(!beforeDisableAddon.called, "Settings have not been restored"); + + // Add the setting and observer. + await ExtensionSettingsStore.addSetting( + id, + settingType, + settingKey, + "controlled", + () => "init" + ); + await popup.addObserver(id); + + // Ensure the panel isn't open. + ok(onObserverAdded.called, "Observing the event"); + onObserverAdded.resetHistory(); + ok(!onObserverRemoved.called, "Observing the event"); + ok(!beforeDisableAddon.called, "Settings have not been restored"); + Assert.notEqual( + panel.getAttribute("panelopen"), + "true", + "The panel is closed" + ); + is(popupnotification.hidden, true, "The popup is hidden"); + is(addon.userDisabled, false, "The extension is enabled"); + is( + await popup.userHasConfirmed(id), + false, + "The user is not initially confirmed" + ); + + // The popup should opened based on the observer event. + await openPopupWithEvent(); + + ok(!onObserverAdded.called, "Only one observer has been registered"); + ok(onObserverRemoved.called, "The observer was removed"); + onObserverRemoved.resetHistory(); + ok(!beforeDisableAddon.called, "Settings have not been restored"); + is(panel.getAttribute("panelopen"), "true", "The panel is open"); + is(popupnotification.hidden, false, "The popup content is visible"); + is(await popup.userHasConfirmed(id), false, "The user has not confirmed yet"); + + // Verify the description is populated. + let description = doc.getElementById("extension-controlled-description"); + is( + description.textContent, + "An extension, Ext Controlled, changed the page you see when you open a new tab.Learn more", + "The extension name is in the description" + ); + let link = description.querySelector("a.learnMore"); + is( + link.href, + "http://127.0.0.1:8888/support-dummy/extension-controlled", + "The link has the href set from learnMoreLink" + ); + + // Force close the popup, as if a user clicked away from it. + await closePopupWithAction("ignore"); + + // Nothing was recorded, but we won't show it again. + ok(!onObserverAdded.called, "The observer hasn't changed"); + ok(!onObserverRemoved.called, "The observer hasn't changed"); + is(await popup.userHasConfirmed(id), false, "The user has not confirmed"); + is(addon.userDisabled, false, "The extension is still enabled"); + + // Force add the observer again to keep changes. + await popup.addObserver(id); + ok(onObserverAdded.called, "The observer was added again"); + onObserverAdded.resetHistory(); + ok(!onObserverRemoved.called, "The observer is still registered"); + is(await popup.userHasConfirmed(id), false, "The user has not confirmed"); + + // Wait for popup. + await openPopupWithEvent(); + + // Keep the changes. + await closePopupWithAction("button"); + + // The observer is removed, but the notification is saved. + ok(!onObserverAdded.called, "The observer wasn't added"); + ok(onObserverRemoved.called, "The observer was removed"); + onObserverRemoved.resetHistory(); + is(await popup.userHasConfirmed(id), true, "The user has confirmed"); + is(addon.userDisabled, false, "The extension is still enabled"); + + // Adding the observer again for this add-on won't work, since it is + // confirmed. + await popup.addObserver(id); + ok(!onObserverAdded.called, "The observer isn't added"); + ok(!onObserverRemoved.called, "The observer isn't removed"); + is(await popup.userHasConfirmed(id), true, "The user has confirmed"); + + // Clear that the user was notified. + await popup.clearConfirmation(id); + is( + await popup.userHasConfirmed(id), + false, + "The user confirmation has been cleared" + ); + + // Force add the observer again to restore changes. + await popup.addObserver(id); + ok(onObserverAdded.called, "The observer was added a third time"); + onObserverAdded.resetHistory(); + ok(!onObserverRemoved.called, "The observer is still active"); + ok(!beforeDisableAddon.called, "We haven't disabled the add-on yet"); + is(await popup.userHasConfirmed(id), false, "The user has not confirmed"); + + // Wait for popup. + await openPopupWithEvent(); + + // Restore the settings. + await closePopupWithAction("secondarybutton"); + + // The observer is removed and the add-on is now disabled. + ok(!onObserverAdded.called, "There is no observer"); + ok(onObserverRemoved.called, "The observer has been removed"); + ok(beforeDisableAddon.called, "The beforeDisableAddon callback was fired"); + is(await popup.userHasConfirmed(id), false, "The user has not confirmed"); + is(addon.userDisabled, true, "The extension is now disabled"); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_action_popup_allowed_urls.js b/browser/components/extensions/test/browser/browser_ext_action_popup_allowed_urls.js new file mode 100644 index 0000000000..8a985161ce --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_action_popup_allowed_urls.js @@ -0,0 +1,283 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_setup(async () => { + await SpecialPowers.pushPrefEnv({ + set: [["extensions.manifestV3.enabled", true]], + }); +}); + +add_task(async function test_actions_setPopup_allowed_urls() { + const otherExtension = ExtensionTestUtils.loadExtension({}); + const extensionDefinition = { + background() { + browser.test.onMessage.addListener(async (msg, ...args) => { + if (msg === "set-popup") { + const apiNs = args[0]; + const popupOptions = args[1]; + if (apiNs === "pageAction") { + popupOptions.tabId = ( + await browser.tabs.query({ active: true }) + )[0].id; + } + + let error; + try { + await browser[apiNs].setPopup(popupOptions); + } catch (err) { + error = err; + } + browser.test.sendMessage("set-popup:done", { + error: error && String(error), + }); + return; + } + + browser.test.fail(`Unexpected test message: ${msg}`); + }); + }, + }; + + await otherExtension.startup(); + + const testCases = [ + // https urls are disallowed on mv3 but currently allowed on mv2. + [ + "action", + "https://example.com", + { + manifest_version: 3, + action: {}, + }, + { + disallowed: true, + errorMessage: "Access denied for URL https://example.com", + }, + ], + + [ + "pageAction", + "https://example.com", + { + manifest_version: 3, + page_action: {}, + }, + { + disallowed: true, + errorMessage: "Access denied for URL https://example.com", + }, + ], + + [ + "browserAction", + "https://example.com", + { + manifest_version: 2, + browser_action: {}, + }, + { + disallowed: false, + }, + ], + + [ + "pageAction", + "https://example.com", + { + manifest_version: 2, + page_action: {}, + }, + { + disallowed: false, + }, + ], + + // absolute moz-extension url from same extension expected to be allowed in MV3 and MV2. + + [ + "action", + extension => `moz-extension://${extension.uuid}/page.html`, + { + manifest_version: 3, + action: {}, + }, + { + disallowed: false, + }, + ], + + [ + "browserAction", + extension => `moz-extension://${extension.uuid}/page.html`, + { + manifest_version: 2, + browser_action: { default_popup: "popup.html" }, + }, + { + disallowed: false, + }, + ], + + [ + "pageAction", + extension => `moz-extension://${extension.uuid}/page.html`, + { + manifest_version: 3, + page_action: {}, + }, + { + disallowed: false, + }, + ], + + [ + "pageAction", + extension => `moz-extension://${extension.uuid}/page.html`, + { + manifest_version: 2, + page_action: {}, + }, + { + disallowed: false, + }, + ], + + // absolute moz-extension url from other extensions expected to be disallowed in MV3 and MV2. + + [ + "action", + `moz-extension://${otherExtension.uuid}/page.html`, + { + manifest_version: 3, + action: {}, + }, + { + disallowed: true, + errorMessage: `Access denied for URL moz-extension://${otherExtension.uuid}/page.html`, + }, + ], + + [ + "browserAction", + `moz-extension://${otherExtension.uuid}/page.html`, + { + manifest_version: 2, + browser_action: {}, + }, + { + disallowed: true, + errorMessage: `Access denied for URL moz-extension://${otherExtension.uuid}/page.html`, + }, + ], + + [ + "pageAction", + `moz-extension://${otherExtension.uuid}/page.html`, + { + manifest_version: 3, + page_action: {}, + }, + { + disallowed: true, + errorMessage: `Access denied for URL moz-extension://${otherExtension.uuid}/page.html`, + }, + ], + + [ + "pageAction", + `moz-extension://${otherExtension.uuid}/page.html`, + { + manifest_version: 2, + page_action: {}, + }, + { + disallowed: true, + errorMessage: `Access denied for URL moz-extension://${otherExtension.uuid}/page.html`, + }, + ], + + // Empty url should also be allowed (as it resets the popup url currently set). + [ + "action", + null, + { + manifest_version: 3, + action: {}, + }, + { + disallowed: false, + }, + ], + + [ + "browserAction", + null, + { + manifest_version: 2, + browser_action: {}, + }, + { + disallowed: false, + }, + ], + + [ + "pageAction", + null, + { + manifest_version: 3, + page_action: {}, + }, + { + disallowed: false, + }, + ], + + [ + "pageAction", + null, + { + manifest_version: 2, + page_action: {}, + }, + { + disallowed: false, + }, + ], + ]; + + for (const [apiNs, popupUrl, manifest, expects] of testCases) { + const extension = ExtensionTestUtils.loadExtension({ + ...extensionDefinition, + manifest, + }); + await extension.startup(); + + const popup = + typeof popupUrl === "function" ? popupUrl(extension) : popupUrl; + + info( + `Testing ${apiNs}.setPopup({ popup: ${popup} }) on manifest_version ${ + manifest.manifest_version ?? 2 + }` + ); + + const popupOptions = { popup }; + extension.sendMessage("set-popup", apiNs, popupOptions); + + const { error } = await extension.awaitMessage("set-popup:done"); + if (expects.disallowed) { + ok( + error?.includes(expects.errorMessage), + `Got expected error on url ${popup}: ${error}` + ); + } else { + is(error, undefined, `Expected url ${popup} to be allowed`); + } + await extension.unload(); + } + + await otherExtension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_activeScript.js b/browser/components/extensions/test/browser/browser_ext_activeScript.js new file mode 100644 index 0000000000..231825795c --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_activeScript.js @@ -0,0 +1,480 @@ +"use strict"; + +requestLongerTimeout(2); + +const { ExtensionPermissions } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionPermissions.sys.mjs" +); + +loadTestSubscript("head_unified_extensions.js"); + +function makeRunAtScript(runAt) { + return ` + window.order ??= []; + window.order.push("${runAt}"); + browser.test.sendMessage("injected", "order@" + window.order.join()); + `; +} + +async function makeExtension({ + id, + manifest_version = 3, + granted = [], + noStaticScript = false, + verifyExtensionsPanel, +}) { + info(`Loading extension ` + JSON.stringify({ id, granted })); + + let manifest = { + manifest_version, + browser_specific_settings: { gecko: { id } }, + permissions: ["activeTab", "scripting"], + content_scripts: noStaticScript + ? [] + : [ + { + matches: ["*://*/*"], + js: ["static.js"], + }, + ], + }; + + if (!verifyExtensionsPanel) { + // Pin the browser action widget to the navbar (toolbar). + if (manifest_version === 3) { + manifest.action = { + default_area: "navbar", + }; + } else { + manifest.browser_action = { + default_area: "navbar", + }; + } + } + + let ext = ExtensionTestUtils.loadExtension({ + manifest, + useAddonManager: "temporary", + + background() { + let expectCount = 0; + + const executeCountScript = tab => + browser.scripting.executeScript({ + target: { tabId: tab.id }, + func: expectCount => { + let retryCount = 0; + + function tryScriptCount() { + let id = browser.runtime.id.split("@")[0]; + let count = document.body.dataset[id] | 0; + if (count < expectCount && retryCount < 100) { + // This needs to run after all scripts, to confirm the correct + // number of scripts was injected. The two paths are inherently + // independant, and since there's a variable number of content + // scripts, there's no easy/better way to do it than a delay + // and retry for up to 100 frames. + + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + setTimeout(tryScriptCount, 30); + retryCount++; + return; + } + browser.test.sendMessage("scriptCount", count); + } + + tryScriptCount(); + }, + args: [expectCount], + }); + + browser.test.onMessage.addListener(async (msg, arg) => { + if (msg === "dynamic-script") { + await browser.scripting.registerContentScripts([arg]); + browser.test.sendMessage("dynamic-script-done"); + } else if (msg === "injected-flush?") { + browser.test.sendMessage("injected", "flush"); + } else if (msg === "expect-count") { + expectCount = arg; + browser.test.sendMessage("expect-done"); + } else if (msg === "execute-count-script") { + const [tab] = await browser.tabs.query({ + active: true, + currentWindow: true, + }); + browser.test.withHandlingUserInput(() => executeCountScript(tab)); + } + }); + + let action = browser.action || browser.browserAction; + // For some test cases, we don't define a browser action in the manifest. + if (action) { + action.onClicked.addListener(executeCountScript); + } + }, + + files: { + "static.js"() { + // Need to use DOM attributes (or the dataset), because two different + // content script sandboxes (from top frame and the same-origin iframe) + // get different wrappers, they don't see each other's top expandos. + + // Need to avoid using the @ character (from the extension id) + // because it's not allowed as part of the DOM attribute name. + + let id = browser.runtime.id.split("@")[0]; + top.document.body.dataset[id] = (top.document.body.dataset[id] | 0) + 1; + + browser.test.log( + `Static content script from ${id} running on ${location.href}.` + ); + + browser.test.sendMessage("injected", "static@" + location.host); + }, + "dynamic.js"() { + let id = browser.runtime.id.split("@")[0]; + top.document.body.dataset[id] = (top.document.body.dataset[id] | 0) + 1; + + browser.test.log( + `Dynamic content script from ${id} running on ${location.href}.` + ); + + let frame = window === top ? "top" : "frame"; + browser.test.sendMessage( + "injected", + `dynamic-${frame}@${location.host}` + ); + }, + "document_start.js": makeRunAtScript("document_start"), + "document_end.js": makeRunAtScript("document_end"), + "document_idle.js": makeRunAtScript("document_idle"), + }, + }); + + if (granted?.length) { + info("Granting initial permissions."); + await ExtensionPermissions.add(id, { permissions: [], origins: granted }); + } + + await ext.startup(); + return ext; +} + +async function testActiveScript( + extension, + expectCount, + expectHosts, + win, + verifyExtensionsPanel +) { + info(`Testing ${extension.id} on ${gBrowser.currentURI.spec}.`); + + extension.sendMessage("expect-count", expectCount); + await extension.awaitMessage("expect-done"); + + if (verifyExtensionsPanel) { + await clickUnifiedExtensionsItem(win, extension.id, true); + } else { + await clickBrowserAction(extension, win); + } + + let received = []; + for (let host of expectHosts) { + info(`Waiting for a script to run in a ${host} frame.`); + received.push(await extension.awaitMessage("injected")); + } + + extension.sendMessage("injected-flush?"); + info("Waiting for the flush message between test runs."); + let flush = await extension.awaitMessage("injected"); + is(flush, "flush", "Messages properly flushed."); + + is(received.sort().join(), expectHosts.join(), "All messages received."); + + // To cover the activeTab counter assertion for extensions that don't trigger + // an action/browserAction onClicked event, we send an explicit test message + // here. + // The test extension queries the current active tab and then execute the + // counter content script from inside a `browser.test.withHandlingUserInput()` + // callback. + if (verifyExtensionsPanel) { + extension.sendMessage("execute-count-script"); + } + + info(`Awaiting the counter from the activeTab content script.`); + let scriptCount = await extension.awaitMessage("scriptCount"); + is(scriptCount | 0, expectCount, "Expected number of scripts running"); +} + +const verifyActionActiveScript = async ({ + win = window, + verifyExtensionsPanel = false, +} = {}) => { + // Static MV2 extension content scripts are not affected. + let ext0 = await makeExtension({ + id: "ext0@test", + manifest_version: 2, + granted: ["*://example.com/*"], + verifyExtensionsPanel, + }); + + let ext1 = await makeExtension({ + id: "ext1@test", + verifyExtensionsPanel, + }); + + let ext2 = await makeExtension({ + id: "ext2@test", + granted: ["*://example.com/*"], + verifyExtensionsPanel, + }); + + let ext3 = await makeExtension({ + id: "ext3@test", + granted: ["*://mochi.test/*"], + verifyExtensionsPanel, + }); + + // Test run_at script ordering. + let ext4 = await makeExtension({ + id: "ext4@test", + verifyExtensionsPanel, + }); + + // Test without static scripts in the manifest, because they add optional + // permissions, and we specifically want to test activeTab without them. + let ext5 = await makeExtension({ + id: "ext5@test", + noStaticScript: true, + verifyExtensionsPanel, + }); + + await BrowserTestUtils.withNewTab("about:blank", async () => { + info("No content scripts run on top level about:blank."); + await testActiveScript(ext0, 0, [], win, verifyExtensionsPanel); + await testActiveScript(ext1, 0, [], win, verifyExtensionsPanel); + await testActiveScript(ext2, 0, [], win, verifyExtensionsPanel); + await testActiveScript(ext3, 0, [], win, verifyExtensionsPanel); + await testActiveScript(ext4, 0, [], win, verifyExtensionsPanel); + await testActiveScript(ext5, 0, [], win, verifyExtensionsPanel); + }); + + let dynamicScript = { + id: "script", + js: ["dynamic.js"], + matches: ["<all_urls>"], + allFrames: true, + persistAcrossSessions: false, + }; + + // MV2 extensions don't support activeScript. This dynamic script won't run + // when action button is clicked, but will run on example.com automatically. + ext0.sendMessage("dynamic-script", dynamicScript); + await ext0.awaitMessage("dynamic-script-done"); + + // Only ext3 will have a dynamic script, matching <all_urls> with allFrames. + ext3.sendMessage("dynamic-script", dynamicScript); + await ext3.awaitMessage("dynamic-script-done"); + + // ext5 will have only dynamic scripts and activeTab, so no host permissions. + ext5.sendMessage("dynamic-script", dynamicScript); + await ext5.awaitMessage("dynamic-script-done"); + + let url = + "https://example.com/browser/browser/components/extensions/test/browser/file_with_xorigin_frame.html"; + + await BrowserTestUtils.withNewTab(url, async browser => { + info("ext0 is MV2, static content script should run automatically."); + info("ext0 has example.com permission, dynamic scripts should also run."); + let received = [ + await ext0.awaitMessage("injected"), + await ext0.awaitMessage("injected"), + await ext0.awaitMessage("injected"), + ]; + is( + received.sort().join(), + "dynamic-frame@example.com,dynamic-top@example.com,static@example.com", + "All messages received" + ); + + info("Clicking ext0 button should not run content script again."); + await testActiveScript(ext0, 3, [], win, verifyExtensionsPanel); + + info("ext2 has host permission, content script should run automatically."); + let static2 = await ext2.awaitMessage("injected"); + is(static2, "static@example.com", "Script ran automatically"); + + info("Clicking ext2 button should not run content script again."); + await testActiveScript(ext2, 1, [], win, verifyExtensionsPanel); + + await testActiveScript( + ext1, + 1, + ["static@example.com"], + win, + verifyExtensionsPanel + ); + + await testActiveScript( + ext3, + 3, + [ + "dynamic-frame@example.com", + "dynamic-top@example.com", + "static@example.com", + ], + win, + verifyExtensionsPanel + ); + + await testActiveScript( + ext4, + 1, + ["static@example.com"], + win, + verifyExtensionsPanel + ); + + info("ext5 only has dynamic scripts that run with activeTab."); + await testActiveScript( + ext5, + 2, + ["dynamic-frame@example.com", "dynamic-top@example.com"], + win, + verifyExtensionsPanel + ); + + // Navigate same-origin iframe to another page, activeScripts shouldn't run. + let bc = browser.browsingContext.children[0].children[0]; + SpecialPowers.spawn(bc, [], () => { + content.location.href = "file_dummy.html"; + }); + // But dynamic script from ext0 should run automatically again. + let dynamic0 = await ext0.awaitMessage("injected"); + is(dynamic0, "dynamic-frame@example.com", "Script ran automatically"); + + info("Clicking all buttons again should not activeScripts."); + await testActiveScript(ext0, 4, [], win, verifyExtensionsPanel); + await testActiveScript(ext1, 1, [], win, verifyExtensionsPanel); + await testActiveScript(ext2, 1, [], win, verifyExtensionsPanel); + // Except ext3 dynamic allFrames script runs in the new navigated page. + await testActiveScript( + ext3, + 4, + ["dynamic-frame@example.com"], + win, + verifyExtensionsPanel + ); + await testActiveScript(ext4, 1, [], win, verifyExtensionsPanel); + + // ext5 dynamic allFrames script also runs in the new navigated page. + await testActiveScript( + ext5, + 3, + ["dynamic-frame@example.com"], + win, + verifyExtensionsPanel + ); + }); + + // Register run_at content scripts in reverse order. + for (let runAt of ["document_idle", "document_end", "document_start"]) { + ext4.sendMessage("dynamic-script", { + id: runAt, + runAt: runAt, + js: [`${runAt}.js`], + matches: ["http://mochi.test/*"], + persistAcrossSessions: false, + }); + await ext4.awaitMessage("dynamic-script-done"); + } + + await BrowserTestUtils.withNewTab("http://mochi.test:8888/", async () => { + info("ext0 is MV2, static content script should run automatically."); + let static0 = await ext0.awaitMessage("injected"); + is(static0, "static@mochi.test:8888", "Script ran automatically."); + + info("Clicking ext0 button should not run content script again."); + await testActiveScript(ext0, 1, [], win, verifyExtensionsPanel); + + info("ext3 has host permission, content script should run automatically."); + let received3 = [ + await ext3.awaitMessage("injected"), + await ext3.awaitMessage("injected"), + ]; + is( + received3.sort().join(), + "dynamic-top@mochi.test:8888,static@mochi.test:8888", + "All messages received." + ); + + info("Clicking ext3 button should not run content script again."); + await testActiveScript(ext3, 2, [], win, verifyExtensionsPanel); + + await testActiveScript( + ext1, + 1, + ["static@mochi.test:8888"], + win, + verifyExtensionsPanel + ); + await testActiveScript( + ext2, + 1, + ["static@mochi.test:8888"], + win, + verifyExtensionsPanel + ); + + // Expect run_at content scripts to run in the correct order. + await testActiveScript( + ext4, + 1, + [ + "order@document_start", + "order@document_start,document_end", + "order@document_start,document_end,document_idle", + "static@mochi.test:8888", + ], + win, + verifyExtensionsPanel + ); + + info("ext5 dynamic scripts with activeTab should run when activated."); + await testActiveScript( + ext5, + 1, + ["dynamic-top@mochi.test:8888"], + win, + verifyExtensionsPanel + ); + + info("Clicking all buttons again should not run content scripts."); + await testActiveScript(ext0, 1, [], win, verifyExtensionsPanel); + await testActiveScript(ext1, 1, [], win, verifyExtensionsPanel); + await testActiveScript(ext2, 1, [], win, verifyExtensionsPanel); + await testActiveScript(ext3, 2, [], win, verifyExtensionsPanel); + await testActiveScript(ext4, 1, [], win, verifyExtensionsPanel); + await testActiveScript(ext5, 1, [], win, verifyExtensionsPanel); + + // TODO: We must unload the extensions here, not after we close the tab but + // this should not be needed ideally. Bug 1768532 describes why we need to + // unload the extensions from within `.withNewTab()` for now. Once this bug + // is fixed, we should move the unload calls below to after the + // `.withNewTab()` block. + await ext0.unload(); + await ext1.unload(); + await ext2.unload(); + await ext3.unload(); + await ext4.unload(); + await ext5.unload(); + }); +}; + +add_task(async function test_action_activeScript() { + await verifyActionActiveScript(); +}); + +add_task(async function test_activeScript_with_unified_extensions_panel() { + await verifyActionActiveScript({ verifyExtensionsPanel: true }); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_addon_debugging_netmonitor.js b/browser/components/extensions/test/browser/browser_ext_addon_debugging_netmonitor.js new file mode 100644 index 0000000000..f436a19657 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_addon_debugging_netmonitor.js @@ -0,0 +1,116 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { require } = ChromeUtils.importESModule( + "resource://devtools/shared/loader/Loader.sys.mjs" +); + +const { gDevTools } = require("devtools/client/framework/devtools"); + +async function setupToolboxTest(extensionId) { + const toolbox = await gDevTools.showToolboxForWebExtension(extensionId); + + async function waitFor(condition) { + while (!condition()) { + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(done => window.setTimeout(done, 1000)); + } + } + + const netmonitor = await toolbox.selectTool("netmonitor"); + + const expectedURL = "http://mochi.test:8888/?test_netmonitor=1"; + + // Call a function defined in the target extension to make it + // fetch from an expected http url. + await toolbox.commands.scriptCommand.execute( + `doFetchHTTPRequest("${expectedURL}");` + ); + + await waitFor(() => { + return !netmonitor.panelWin.document.querySelector( + ".request-list-empty-notice" + ); + }); + + let { store } = netmonitor.panelWin; + + // NOTE: we need to filter the requests to the ones that we expect until + // the network monitor is not yet filtering out the requests that are not + // coming from an extension window or a descendent of an extension window, + // in both oop and non-oop extension mode (filed as Bug 1442621). + function filterRequest(request) { + return request.url === expectedURL; + } + + let requests; + + await waitFor(() => { + requests = Array.from(store.getState().requests.requests.values()).filter( + filterRequest + ); + + return !!requests.length; + }); + + // Call a function defined in the target extension to make assertions + // on the network requests collected by the netmonitor panel. + await toolbox.commands.scriptCommand.execute( + `testNetworkRequestReceived(${JSON.stringify(requests)});` + ); + + await toolbox.destroy(); +} + +add_task(async function test_addon_debugging_netmonitor_panel() { + const EXTENSION_ID = "test-monitor-panel@mozilla"; + + function background() { + let expectedURL; + window.doFetchHTTPRequest = async function (urlToFetch) { + expectedURL = urlToFetch; + await fetch(urlToFetch); + }; + window.testNetworkRequestReceived = async function (requests) { + browser.test.log( + "Addon Debugging Netmonitor panel collected requests: " + + JSON.stringify(requests) + ); + browser.test.assertEq(1, requests.length, "Got one request logged"); + browser.test.assertEq("GET", requests[0].method, "Got a GET request"); + browser.test.assertEq( + expectedURL, + requests[0].url, + "Got the expected request url" + ); + + browser.test.notifyPass("netmonitor_request_logged"); + }; + browser.test.sendMessage("ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + useAddonManager: "temporary", + manifest: { + permissions: ["http://mochi.test/"], + browser_specific_settings: { + gecko: { id: EXTENSION_ID }, + }, + }, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + + const onToolboxClose = setupToolboxTest(EXTENSION_ID); + await Promise.all([ + extension.awaitFinish("netmonitor_request_logged"), + onToolboxClose, + ]); + + info("Addon Toolbox closed"); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_autocompletepopup.js b/browser/components/extensions/test/browser/browser_ext_autocompletepopup.js new file mode 100644 index 0000000000..7188d61ca6 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_autocompletepopup.js @@ -0,0 +1,90 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function testAutocompletePopup() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_action: { + default_popup: "page.html", + browser_style: false, + }, + page_action: { + default_popup: "page.html", + browser_style: false, + }, + }, + background: async function () { + let [tab] = await browser.tabs.query({ + active: true, + currentWindow: true, + }); + await browser.pageAction.show(tab.id); + browser.test.sendMessage("ready"); + }, + files: { + "page.html": `<!DOCTYPE html> + <html> + <head><meta charset="utf-8"></head> + <body> + <div> + <input placeholder="Test input" id="test-input" list="test-list" /> + <datalist id="test-list"> + <option value="aa"> + <option value="ab"> + <option value="ae"> + <option value="af"> + <option value="ak"> + <option value="am"> + <option value="an"> + <option value="ar"> + </datalist> + </div> + </body> + </html>`, + }, + }); + + async function testDatalist(browser, doc) { + let autocompletePopup = doc.getElementById("PopupAutoComplete"); + let opened = promisePopupShown(autocompletePopup); + info("click in test-input now"); + // two clicks to open + await BrowserTestUtils.synthesizeMouseAtCenter("#test-input", {}, browser); + await BrowserTestUtils.synthesizeMouseAtCenter("#test-input", {}, browser); + info("wait for opened event"); + await opened; + // third to close + let closed = promisePopupHidden(autocompletePopup); + info("click in test-input now"); + await BrowserTestUtils.synthesizeMouseAtCenter("#test-input", {}, browser); + info("wait for closed event"); + await closed; + // If this didn't work, we hang. Other tests deal with testing the actual functionality of datalist. + ok(true, "datalist popup has been shown"); + } + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.com/" + ); + await extension.startup(); + await extension.awaitMessage("ready"); + + clickPageAction(extension); + // intentional misspell so eslint is ok with browser in background script. + let bowser = await awaitExtensionPanel(extension); + ok(!!bowser, "panel opened with browser"); + await testDatalist(bowser, document); + closePageAction(extension); + await new Promise(resolve => setTimeout(resolve, 0)); + + clickBrowserAction(extension); + bowser = await awaitExtensionPanel(extension); + ok(!!bowser, "panel opened with browser"); + await testDatalist(bowser, document); + closeBrowserAction(extension); + await new Promise(resolve => setTimeout(resolve, 0)); + + await extension.unload(); + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_autoplayInBackground.js b/browser/components/extensions/test/browser/browser_ext_autoplayInBackground.js new file mode 100644 index 0000000000..e082b1e9bf --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_autoplayInBackground.js @@ -0,0 +1,52 @@ +"use strict"; + +function setup_test_preference(enableScript) { + return SpecialPowers.pushPrefEnv({ + set: [ + ["media.autoplay.default", 1], + ["media.autoplay.blocking_policy", 0], + ["media.autoplay.allow-extension-background-pages", enableScript], + ], + }); +} + +async function testAutoplayInBackgroundScript(enableScript) { + info(`- setup test preference, enableScript=${enableScript} -`); + await setup_test_preference(enableScript); + + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.test.log("- create audio in background page -"); + let audio = new Audio(); + audio.src = + "https://example.com/browser/browser/components/extensions/test/browser/silence.ogg"; + audio.play().then( + function () { + browser.test.log("play succeed!"); + browser.test.sendMessage("play-succeed"); + }, + function () { + browser.test.log("play promise was rejected!"); + browser.test.sendMessage("play-failed"); + } + ); + }, + }); + + await extension.startup(); + + if (enableScript) { + await extension.awaitMessage("play-succeed"); + ok(true, "play promise was resolved!"); + } else { + await extension.awaitMessage("play-failed"); + ok(true, "play promise was rejected!"); + } + + await extension.unload(); +} + +add_task(async function testMain() { + await testAutoplayInBackgroundScript(true /* enable autoplay */); + await testAutoplayInBackgroundScript(false /* enable autoplay */); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_browserAction_activeTab.js b/browser/components/extensions/test/browser/browser_ext_browserAction_activeTab.js new file mode 100644 index 0000000000..4bc24a2df2 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_browserAction_activeTab.js @@ -0,0 +1,195 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function test_middle_click_with_activeTab() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "https://example.com/" + ); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_action: { + default_area: "navbar", + }, + permissions: ["activeTab"], + }, + + background() { + browser.browserAction.onClicked.addListener(async (tab, info) => { + browser.test.assertEq(1, info.button, "Expected button value"); + browser.test.assertEq( + "https://example.com/", + tab.url, + "tab.url has the expected url" + ); + await browser.tabs.insertCSS(tab.id, { + code: "body { border: 20px solid red; }", + }); + browser.test.sendMessage("onClick"); + }); + browser.test.sendMessage("ready"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + + let ext = WebExtensionPolicy.getByID(extension.id).extension; + is( + ext.tabManager.hasActiveTabPermission(tab), + false, + "Active tab was not granted permission" + ); + + await clickBrowserAction(extension, window, { button: 1 }); + await extension.awaitMessage("onClick"); + + is( + ext.tabManager.hasActiveTabPermission(tab), + true, + "Active tab was granted permission" + ); + await extension.unload(); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_middle_click_with_activeTab_and_popup() { + const { browserActionFor } = Management.global; + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "https://example.com/" + ); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_action: { + default_popup: "popup.html", + browser_style: true, + default_area: "navbar", + }, + permissions: ["activeTab"], + }, + + files: { + "popup.html": `<!DOCTYPE html><html><head><meta charset="utf-8"></head></html>`, + }, + + background() { + browser.browserAction.onClicked.addListener(async (tab, info) => { + browser.test.assertEq(1, info.button, "Expected button value"); + browser.test.assertEq( + "https://example.com/", + tab.url, + "tab.url has the expected url" + ); + await browser.tabs.insertCSS(tab.id, { + code: "body { border: 20px solid red; }", + }); + browser.test.sendMessage("onClick"); + }); + browser.test.sendMessage("ready"); + }, + }); + + // Make sure the mouse isn't hovering over the browserAction widget. + EventUtils.synthesizeMouseAtCenter( + gURLBar.textbox, + { type: "mouseover" }, + window + ); + + await extension.startup(); + await extension.awaitMessage("ready"); + + let widget = getBrowserActionWidget(extension).forWindow(window); + let ext = WebExtensionPolicy.getByID(extension.id).extension; + let browserAction = browserActionFor(ext); + + EventUtils.synthesizeMouseAtCenter( + widget.node, + { type: "mouseover" }, + window + ); + + EventUtils.synthesizeMouseAtCenter( + widget.node, + { type: "mousedown", button: 0 }, + window + ); + + isnot(browserAction.pendingPopup, null, "Have pending popup"); + is(browserAction.action.activeTabForPreload, tab, "Tab to revoke was saved"); + + await clickBrowserAction(extension, window, { button: 1 }); + await extension.awaitMessage("onClick"); + + is( + browserAction.action.activeTabForPreload, + null, + "Tab to revoke was removed" + ); + + is( + browserAction.tabManager.hasActiveTabPermission(tab), + true, + "Active tab was granted permission" + ); + + EventUtils.synthesizeMouseAtCenter( + widget.node, + { type: "mouseup", button: 0 }, + window + ); + + EventUtils.synthesizeMouseAtCenter(widget.node, { type: "mouseout" }, window); + + await extension.unload(); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_middle_click_without_activeTab() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "https://example.com/" + ); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_action: { + default_area: "navbar", + }, + }, + + background() { + browser.browserAction.onClicked.addListener(async (tab, info) => { + browser.test.assertEq(1, info.button, "Expected button value"); + browser.test.assertEq(tab.url, undefined, "tab.url is undefined"); + await browser.test.assertRejects( + browser.tabs.insertCSS(tab.id, { + code: "body { border: 20px solid red; }", + }), + "Missing host permission for the tab", + "expected failure of tabs.insertCSS without permission" + ); + browser.test.sendMessage("onClick"); + }); + browser.test.sendMessage("ready"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + + await clickBrowserAction(extension, window, { button: 1 }); + await extension.awaitMessage("onClick"); + + await extension.unload(); + + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_browserAction_area.js b/browser/components/extensions/test/browser/browser_ext_browserAction_area.js new file mode 100644 index 0000000000..90ed744a78 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_browserAction_area.js @@ -0,0 +1,126 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +var browserAreas = { + navbar: CustomizableUI.AREA_NAVBAR, + menupanel: getCustomizableUIPanelID(), + tabstrip: CustomizableUI.AREA_TABSTRIP, + personaltoolbar: CustomizableUI.AREA_BOOKMARKS, +}; + +async function testInArea(area) { + let manifest = { + browser_action: { + browser_style: true, + }, + }; + if (area) { + manifest.browser_action.default_area = area; + } + let extension = ExtensionTestUtils.loadExtension({ + manifest, + }); + await extension.startup(); + let widget = getBrowserActionWidget(extension); + let placement = CustomizableUI.getPlacementOfWidget(widget.id); + let fallbackDefaultArea = CustomizableUI.AREA_ADDONS; + is( + placement && placement.area, + browserAreas[area] || fallbackDefaultArea, + `widget located in correct area` + ); + await extension.unload(); +} + +add_task(async function testBrowserActionDefaultArea() { + await testInArea(); +}); + +add_task(async function testBrowserActionInToolbar() { + await testInArea("navbar"); +}); + +add_task(async function testBrowserActionInMenuPanel() { + await testInArea("menupanel"); +}); + +add_task(async function testBrowserActionInTabStrip() { + await testInArea("tabstrip"); +}); + +add_task(async function testBrowserActionInPersonalToolbar() { + await testInArea("personaltoolbar"); +}); + +add_task(async function testPolicyOverridesBrowserActionToNavbar() { + const { EnterprisePolicyTesting } = ChromeUtils.importESModule( + "resource://testing-common/EnterprisePolicyTesting.sys.mjs" + ); + await EnterprisePolicyTesting.setupPolicyEngineWithJson({ + policies: { + ExtensionSettings: { + "policyBrowserActionAreaNavBarTest@mozilla.com": { + default_area: "navbar", + }, + }, + }, + }); + let manifest = { + browser_action: {}, + browser_specific_settings: { + gecko: { + id: "policyBrowserActionAreaNavBarTest@mozilla.com", + }, + }, + }; + let extension = ExtensionTestUtils.loadExtension({ + manifest, + }); + await extension.startup(); + let widget = getBrowserActionWidget(extension); + let placement = CustomizableUI.getPlacementOfWidget(widget.id); + is( + placement && placement.area, + CustomizableUI.AREA_NAVBAR, + `widget located in nav bar` + ); + await extension.unload(); +}); + +add_task(async function testPolicyOverridesBrowserActionToMenuPanel() { + const { EnterprisePolicyTesting } = ChromeUtils.importESModule( + "resource://testing-common/EnterprisePolicyTesting.sys.mjs" + ); + await EnterprisePolicyTesting.setupPolicyEngineWithJson({ + policies: { + ExtensionSettings: { + "policyBrowserActionAreaMenuPanelTest@mozilla.com": { + default_area: "menupanel", + }, + }, + }, + }); + let manifest = { + browser_action: { + default_area: "navbar", + }, + browser_specific_settings: { + gecko: { + id: "policyBrowserActionAreaMenuPanelTest@mozilla.com", + }, + }, + }; + let extension = ExtensionTestUtils.loadExtension({ + manifest, + }); + await extension.startup(); + let widget = getBrowserActionWidget(extension); + let placement = CustomizableUI.getPlacementOfWidget(widget.id); + is( + placement && placement.area, + getCustomizableUIPanelID(), + `widget located in extensions menu` + ); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_browserAction_click_types.js b/browser/components/extensions/test/browser/browser_ext_browserAction_click_types.js new file mode 100644 index 0000000000..6614bb62c2 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_browserAction_click_types.js @@ -0,0 +1,269 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +async function test_clickData({ manifest_version, persistent }) { + const action = manifest_version < 3 ? "browser_action" : "action"; + const background = { scripts: ["background.js"] }; + + if (persistent != null) { + background.persistent = persistent; + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version, + [action]: {}, + background, + }, + + files: { + "background.js": function backgroundScript() { + function onClicked(tab, info) { + let button = info.button; + let modifiers = info.modifiers; + browser.test.sendMessage("onClick", { button, modifiers }); + } + + const apiNS = + browser.runtime.getManifest().manifest_version >= 3 + ? "action" + : "browserAction"; + + browser[apiNS].onClicked.addListener(onClicked); + browser.test.sendMessage("ready"); + }, + }, + }); + + const map = { + shiftKey: "Shift", + altKey: "Alt", + metaKey: "Command", + ctrlKey: "Ctrl", + }; + + function assertSingleModifier(info, modifier, area) { + if (modifier === "ctrlKey" && AppConstants.platform === "macosx") { + is( + info.modifiers.length, + 2, + `MacCtrl modifier with control click on Mac` + ); + is( + info.modifiers[1], + "MacCtrl", + `MacCtrl modifier with control click on Mac` + ); + } else { + is( + info.modifiers.length, + 1, + `No unnecessary modifiers for exactly one key on event` + ); + } + + is(info.modifiers[0], map[modifier], `Correct modifier on ${area} click`); + } + + await extension.startup(); + await extension.awaitMessage("ready"); + + if (manifest_version >= 3 || persistent === false) { + // NOTE: even in MV3 where the API namespace is technically "action", + // the event listeners will be persisted into the startup data + // with "browserAction" as the module, because that is the name + // of the module shared by both MV2 browserAction and MV3 action APIs. + assertPersistentListeners(extension, "browserAction", "onClicked", { + primed: false, + }); + await extension.terminateBackground(); + assertPersistentListeners(extension, "browserAction", "onClicked", { + primed: true, + }); + } + + for (let area of [CustomizableUI.AREA_NAVBAR, getCustomizableUIPanelID()]) { + let widget = getBrowserActionWidget(extension); + CustomizableUI.addWidgetToArea(widget.id, area); + + for (let modifier of Object.keys(map)) { + for (let i = 0; i < 2; i++) { + info(`test click with button ${i} modifier ${modifier}`); + let clickEventData = { button: i }; + clickEventData[modifier] = true; + await clickBrowserAction(extension, window, clickEventData); + let details = await extension.awaitMessage("onClick"); + + is(details.button, i, `Correct button in ${area} click`); + assertSingleModifier(details, modifier, area); + } + + info(`test keypress with modifier ${modifier}`); + let keypressEventData = {}; + keypressEventData[modifier] = true; + await triggerBrowserActionWithKeyboard(extension, " ", keypressEventData); + let details = await extension.awaitMessage("onClick"); + + is(details.button, 0, `Key command emulates left click`); + assertSingleModifier(details, modifier, area); + } + } + + if (manifest_version >= 3 || persistent === false) { + // The background event page is expected to have been + // spawned again to handle the action onClicked event. + await extension.awaitMessage("ready"); + } + + await extension.unload(); +} + +async function test_clickData_reset({ manifest_version }) { + const action = manifest_version < 3 ? "browser_action" : "action"; + const browser_action_command = + manifest_version < 3 ? "_execute_browser_action" : "_execute_action"; + const browser_action_key = "j"; + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version, + [action]: { + default_area: "navbar", + }, + page_action: {}, + commands: { + [browser_action_command]: { + suggested_key: { + default: "Alt+Shift+J", + }, + }, + }, + }, + + async background() { + function onBrowserActionClicked(tab, info) { + browser.test.sendMessage("onClick", info); + } + + function onPageActionClicked(tab, info) { + browser.test.sendMessage("open-popup"); + } + + const { manifest_version } = browser.runtime.getManifest(); + const apiNS = manifest_version >= 3 ? "action" : "browserAction"; + + browser[apiNS].onClicked.addListener(onBrowserActionClicked); + + // pageAction should only be available in MV2 extensions. + if (manifest_version < 3) { + browser.pageAction.onClicked.addListener(onPageActionClicked); + + let [tab] = await browser.tabs.query({ + active: true, + currentWindow: true, + }); + await browser.pageAction.show(tab.id); + } + + browser.test.sendMessage("ready"); + }, + }); + + // Pollute the state of the browserAction's lastClickInfo + async function clickBrowserActionWithModifiers() { + await clickBrowserAction(extension, window, { button: 1, shiftKey: true }); + let info = await extension.awaitMessage("onClick"); + is(info.button, 1, "Got expected ClickData button details"); + is(info.modifiers[0], "Shift", "Got expected ClickData modifiers details"); + } + + function assertInfoReset(info) { + is(info.button, 0, `ClickData button reset properly`); + is(info.modifiers.length, 0, `ClickData modifiers reset properly`); + } + + await extension.startup(); + await extension.awaitMessage("ready"); + + if (manifest_version >= 3) { + // NOTE: even in MV3 where the API namespace is technically "action", + // the event listeners will be persisted into the startup data + // with "browserAction" as the module, because that is the name + // of the module shared by both MV2 browserAction and MV3 action APIs. + assertPersistentListeners(extension, "browserAction", "onClicked", { + primed: false, + }); + await extension.terminateBackground(); + assertPersistentListeners(extension, "browserAction", "onClicked", { + primed: true, + }); + } + + await clickBrowserActionWithModifiers(); + + if (manifest_version >= 3) { + // The background event page is expected to have been + // spawned again to handle the action onClicked event. + await extension.awaitMessage("ready"); + } else { + extension.onMessage("open-popup", () => { + EventUtils.synthesizeKey(browser_action_key, { + altKey: true, + shiftKey: true, + }); + }); + + // pageAction should only be available in MV2 extensions. + await clickPageAction(extension); + + // NOTE: the pageAction event listener then triggers browserAction.onClicked + assertInfoReset(await extension.awaitMessage("onClick")); + } + + await clickBrowserActionWithModifiers(); + + await triggerBrowserActionWithKeyboard(extension, " "); + assertInfoReset(await extension.awaitMessage("onClick")); + + await clickBrowserActionWithModifiers(); + + await triggerBrowserActionWithKeyboard(extension, " "); + assertInfoReset(await extension.awaitMessage("onClick")); + + await extension.unload(); +} + +add_task(function test_clickData_MV2() { + return test_clickData({ manifest_version: 2 }); +}); + +add_task(async function test_clickData_MV2_eventpage() { + await SpecialPowers.pushPrefEnv({ + set: [["extensions.eventPages.enabled", true]], + }); + await test_clickData({ + manifest_version: 2, + persistent: false, + }); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_clickData_MV3() { + await SpecialPowers.pushPrefEnv({ + set: [["extensions.manifestV3.enabled", true]], + }); + await test_clickData({ manifest_version: 3 }); + await SpecialPowers.popPrefEnv(); +}); + +add_task(function test_clickData_reset_MV2() { + return test_clickData_reset({ manifest_version: 2 }); +}); + +add_task(async function test_clickData_reset_MV3() { + await SpecialPowers.pushPrefEnv({ + set: [["extensions.manifestV3.enabled", true]], + }); + await test_clickData_reset({ manifest_version: 3 }); + await SpecialPowers.popPrefEnv(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_browserAction_context.js b/browser/components/extensions/test/browser/browser_ext_browserAction_context.js new file mode 100644 index 0000000000..c1f8184c78 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_browserAction_context.js @@ -0,0 +1,1194 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +async function runTests(options) { + async function background(getTests) { + let manifest = browser.runtime.getManifest(); + let { manifest_version } = manifest; + const action = manifest_version < 3 ? "browserAction" : "action"; + async function checkExtAPIDetails(expecting, details) { + let title = await browser[action].getTitle(details); + browser.test.assertEq( + expecting.title, + title, + "expected value from getTitle" + ); + + let popup = await browser[action].getPopup(details); + browser.test.assertEq( + expecting.popup, + popup, + "expected value from getPopup" + ); + + let badge = await browser[action].getBadgeText(details); + browser.test.assertEq( + expecting.badge, + badge, + "expected value from getBadge" + ); + + let badgeBackgroundColor = await browser[action].getBadgeBackgroundColor( + details + ); + browser.test.assertEq( + String(expecting.badgeBackgroundColor), + String(badgeBackgroundColor), + "expected value from getBadgeBackgroundColor" + ); + + let badgeTextColor = await browser[action].getBadgeTextColor(details); + browser.test.assertEq( + String(expecting.badgeTextColor), + String(badgeTextColor), + "expected value from getBadgeTextColor" + ); + + let enabled = await browser[action].isEnabled(details); + browser.test.assertEq( + expecting.enabled, + enabled, + "expected value from isEnabled" + ); + } + + let tabs = []; + let windows = []; + let tests = getTests(tabs, windows); + + { + let tabId = 0xdeadbeef; + let calls = [ + () => browser[action].enable(tabId), + () => browser[action].disable(tabId), + () => browser[action].setTitle({ tabId, title: "foo" }), + () => browser[action].setIcon({ tabId, path: "foo.png" }), + () => browser[action].setPopup({ tabId, popup: "foo.html" }), + () => browser[action].setBadgeText({ tabId, text: "foo" }), + () => + browser[action].setBadgeBackgroundColor({ + tabId, + color: [0xff, 0, 0, 0xff], + }), + () => + browser[action].setBadgeTextColor({ + tabId, + color: [0, 0xff, 0xff, 0xff], + }), + ]; + + for (let call of calls) { + await browser.test.assertRejects( + new Promise(resolve => resolve(call())), + RegExp(`Invalid tab ID: ${tabId}`), + "Expected invalid tab ID error" + ); + } + } + + // Runs the next test in the `tests` array, checks the results, + // and passes control back to the outer test scope. + function nextTest() { + let test = tests.shift(); + + test(async (expectTab, expectWindow, expectGlobal, expectDefault) => { + expectGlobal = { ...expectDefault, ...expectGlobal }; + expectWindow = { ...expectGlobal, ...expectWindow }; + expectTab = { ...expectWindow, ...expectTab }; + + // Check that the API returns the expected values, and then + // run the next test. + let [{ windowId, id: tabId }] = await browser.tabs.query({ + active: true, + currentWindow: true, + }); + await checkExtAPIDetails(expectTab, { tabId }); + await checkExtAPIDetails(expectWindow, { windowId }); + await checkExtAPIDetails(expectGlobal, {}); + + // Check that the actual icon has the expected values, then + // run the next test. + browser.test.sendMessage("nextTest", expectTab, windowId, tests.length); + }); + } + + browser.test.onMessage.addListener(msg => { + if (msg != "runNextTest") { + browser.test.fail("Expecting 'runNextTest' message"); + } + + nextTest(); + }); + + let [{ id, windowId }] = await browser.tabs.query({ + active: true, + currentWindow: true, + }); + tabs.push(id); + windows.push(windowId); + nextTest(); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: options.manifest, + + files: options.files || {}, + + background: `(${background})(${options.getTests})`, + }); + + function serializeColor([r, g, b, a]) { + if (a === 255) { + return `rgb(${r}, ${g}, ${b})`; + } + return `rgba(${r}, ${g}, ${b}, ${a / 255})`; + } + + let browserActionId; + async function checkWidgetDetails(details, windowId) { + let { document } = Services.wm.getOuterWindowWithId(windowId); + if (!browserActionId) { + browserActionId = `${makeWidgetId(extension.id)}-browser-action`; + } + + let node = document.getElementById(browserActionId); + let button = node.firstElementChild; + + ok(button, "button exists"); + + let title = details.title || options.manifest.name; + + // NOTE: resorting to waitForCondition to prevent frequent + // intermittent failures due to multiple action API calls + // being queued. + if (getListStyleImage(button) !== details.icon) { + info(`wait for action icon url to be set to ${details.icon}`); + await TestUtils.waitForCondition( + () => getListStyleImage(button) === details.icon, + "Wait for the expected icon URL to be set" + ); + } + + // NOTE: resorting to waitForCondition to prevent frequent + // intermittent failures due to multiple action API calls + // being queued. + if (button.getAttribute("tooltiptext") !== title) { + info(`wait for action tooltiptext to be set to ${title}`); + await TestUtils.waitForCondition( + () => button.getAttribute("tooltiptext") === title, + "Wait for expected title to be set" + ); + } + + is(getListStyleImage(button), details.icon, "icon URL is correct"); + is(button.getAttribute("tooltiptext"), title, "image title is correct"); + is(button.getAttribute("label"), title, "image label is correct"); + is(button.getAttribute("badge"), details.badge, "badge text is correct"); + is( + button.getAttribute("disabled") == "true", + !details.enabled, + "disabled state is correct" + ); + + if (details.badge) { + let badge = button.badgeLabel; + let style = window.getComputedStyle(badge); + let expected = { + backgroundColor: serializeColor(details.badgeBackgroundColor), + color: serializeColor(details.badgeTextColor), + }; + for (let [prop, value] of Object.entries(expected)) { + // NOTE: resorting to waitForCondition to prevent frequent + // intermittent failures due to multiple action API calls + // being queued. + if (style[prop] !== value) { + info(`wait for badge ${prop} to be set to ${value}`); + await TestUtils.waitForCondition( + () => window.getComputedStyle(badge)[prop] === value, + `Wait for expected badge ${prop} to be set` + ); + } + } + } + + // TODO: Popup URL. + } + + let awaitFinish = new Promise(resolve => { + extension.onMessage( + "nextTest", + async (expecting, windowId, testsRemaining) => { + await promiseAnimationFrame(); + await checkWidgetDetails(expecting, windowId); + + if (testsRemaining) { + extension.sendMessage("runNextTest"); + } else { + resolve(); + } + } + ); + }); + + await extension.startup(); + + await awaitFinish; + + await extension.unload(); +} + +let tabSwitchTestData = { + files: { + "_locales/en/messages.json": { + popup: { + message: "default.html", + description: "Popup", + }, + + title: { + message: "Title", + description: "Title", + }, + }, + + "default.png": imageBuffer, + "global.png": imageBuffer, + "1.png": imageBuffer, + "2.png": imageBuffer, + }, + + getTests: function (tabs, windows) { + let manifest = browser.runtime.getManifest(); + let { manifest_version } = manifest; + const action = manifest_version < 3 ? "browserAction" : "action"; + + let details = [ + { + icon: browser.runtime.getURL("default.png"), + popup: browser.runtime.getURL("default.html"), + title: "Default Title", + badge: "", + badgeBackgroundColor: [0xd9, 0, 0, 255], + badgeTextColor: [0xff, 0xff, 0xff, 0xff], + enabled: true, + }, + { icon: browser.runtime.getURL("1.png") }, + { + icon: browser.runtime.getURL("2.png"), + popup: browser.runtime.getURL("2.html"), + title: "Title 2", + badge: "2", + badgeBackgroundColor: [0xff, 0, 0, 0xff], + badgeTextColor: [0, 0xff, 0xff, 0xff], + enabled: false, + }, + { + icon: browser.runtime.getURL("global.png"), + popup: browser.runtime.getURL("global.html"), + title: "Global Title", + badge: "g", + badgeBackgroundColor: [0, 0xff, 0, 0xff], + badgeTextColor: [0xff, 0, 0xff, 0xff], + enabled: false, + }, + { + icon: browser.runtime.getURL("global.png"), + popup: browser.runtime.getURL("global.html"), + title: "Global Title", + badge: "g", + badgeBackgroundColor: [0, 0xff, 0, 0xff], + badgeTextColor: [0xff, 0, 0xff, 0xff], + }, + ]; + + let promiseTabLoad = details => { + return new Promise(resolve => { + browser.tabs.onUpdated.addListener(function listener(tabId, changed) { + if (tabId == details.id && changed.url == details.url) { + browser.tabs.onUpdated.removeListener(listener); + resolve(); + } + }); + }); + }; + + return [ + async expect => { + browser.test.log("Initial state, expect default properties."); + + expect(null, null, null, details[0]); + }, + async expect => { + browser.test.log( + "Change the icon in the current tab. Expect default properties excluding the icon." + ); + browser[action].setIcon({ tabId: tabs[0], path: "1.png" }); + + expect(details[1], null, null, details[0]); + }, + async expect => { + browser.test.log("Create a new tab. Expect default properties."); + let tab = await browser.tabs.create({ + active: true, + url: "about:blank?0", + }); + tabs.push(tab.id); + + browser.test.log("Await tab load."); + let promise = promiseTabLoad({ id: tabs[1], url: "about:blank?0" }); + let { url } = await browser.tabs.get(tabs[1]); + if (url === "about:blank") { + await promise; + } + + expect(null, null, null, details[0]); + }, + async expect => { + browser.test.log("Change properties. Expect new properties."); + let tabId = tabs[1]; + browser[action].setIcon({ tabId, path: "2.png" }); + browser[action].setPopup({ tabId, popup: "2.html" }); + browser[action].setTitle({ tabId, title: "Title 2" }); + browser[action].setBadgeText({ tabId, text: "2" }); + browser[action].setBadgeBackgroundColor({ + tabId, + color: "#ff0000", + }); + browser[action].setBadgeTextColor({ tabId, color: "#00ffff" }); + browser[action].disable(tabId); + + expect(details[2], null, null, details[0]); + }, + async expect => { + browser.test.log( + "Switch back to the first tab. Expect previously set properties." + ); + await browser.tabs.update(tabs[0], { active: true }); + expect(details[1], null, null, details[0]); + }, + async expect => { + browser.test.log( + "Change global values, expect those changes reflected." + ); + browser[action].setIcon({ path: "global.png" }); + browser[action].setPopup({ popup: "global.html" }); + browser[action].setTitle({ title: "Global Title" }); + browser[action].setBadgeText({ text: "g" }); + browser[action].setBadgeBackgroundColor({ + color: [0, 0xff, 0, 0xff], + }); + browser[action].setBadgeTextColor({ + color: [0xff, 0, 0xff, 0xff], + }); + browser[action].disable(); + + expect(details[1], null, details[3], details[0]); + }, + async expect => { + browser.test.log("Re-enable globally. Expect enabled."); + browser[action].enable(); + + expect(details[1], null, details[4], details[0]); + }, + async expect => { + browser.test.log( + "Switch back to tab 2. Expect former tab values, and new global values from previous steps." + ); + await browser.tabs.update(tabs[1], { active: true }); + + expect(details[2], null, details[4], details[0]); + }, + async expect => { + browser.test.log( + "Navigate to a new page. Expect tab-specific values to be cleared." + ); + + let promise = promiseTabLoad({ id: tabs[1], url: "about:blank?1" }); + browser.tabs.update(tabs[1], { url: "about:blank?1" }); + await promise; + + expect(null, null, details[4], details[0]); + }, + async expect => { + browser.test.log( + "Delete tab, switch back to tab 1. Expect previous results again." + ); + await browser.tabs.remove(tabs[1]); + expect(details[1], null, details[4], details[0]); + }, + async expect => { + browser.test.log("Create a new tab. Expect new global properties."); + let tab = await browser.tabs.create({ + active: true, + url: "about:blank?2", + }); + tabs.push(tab.id); + expect(null, null, details[4], details[0]); + }, + async expect => { + browser.test.log("Delete tab."); + await browser.tabs.remove(tabs[2]); + expect(details[1], null, details[4], details[0]); + }, + ]; + }, +}; + +add_task(async function testTabSwitchContext() { + await runTests({ + manifest: { + browser_action: { + default_icon: "default.png", + default_popup: "__MSG_popup__", + default_title: "Default __MSG_title__", + default_area: "navbar", + }, + + default_locale: "en", + + permissions: ["tabs"], + }, + ...tabSwitchTestData, + }); +}); + +add_task(async function testTabSwitchActionContext() { + await SpecialPowers.pushPrefEnv({ + set: [["extensions.manifestV3.enabled", true]], + }); + + await runTests({ + manifest: { + manifest_version: 3, + action: { + default_icon: "default.png", + default_popup: "__MSG_popup__", + default_title: "Default __MSG_title__", + default_area: "navbar", + }, + default_locale: "en", + permissions: ["tabs"], + }, + ...tabSwitchTestData, + }); +}); + +add_task(async function testDefaultTitle() { + await runTests({ + manifest: { + name: "Foo Extension", + + browser_action: { + default_icon: "icon.png", + default_area: "navbar", + }, + + permissions: ["tabs"], + }, + + files: { + "icon.png": imageBuffer, + }, + + getTests: function (tabs, windows) { + let details = [ + { + title: "Foo Extension", + popup: "", + badge: "", + badgeBackgroundColor: [0xd9, 0, 0, 255], + badgeTextColor: [0xff, 0xff, 0xff, 0xff], + icon: browser.runtime.getURL("icon.png"), + enabled: true, + }, + { title: "Foo Title" }, + { title: "Bar Title" }, + ]; + + return [ + async expect => { + browser.test.log("Initial state. Expect default properties."); + + expect(null, null, null, details[0]); + }, + async expect => { + browser.test.log("Change the tab title. Expect new title."); + browser.browserAction.setTitle({ + tabId: tabs[0], + title: "Foo Title", + }); + + expect(details[1], null, null, details[0]); + }, + async expect => { + browser.test.log("Change the global title. Expect same properties."); + browser.browserAction.setTitle({ title: "Bar Title" }); + + expect(details[1], null, details[2], details[0]); + }, + async expect => { + browser.test.log("Clear the tab title. Expect new global title."); + browser.browserAction.setTitle({ tabId: tabs[0], title: null }); + + expect(null, null, details[2], details[0]); + }, + async expect => { + browser.test.log("Clear the global title. Expect default title."); + browser.browserAction.setTitle({ title: null }); + + expect(null, null, null, details[0]); + }, + async expect => { + browser.test.assertRejects( + browser.browserAction.setPopup({ popup: "about:addons" }), + /Access denied for URL about:addons/, + "unable to set popup to about:addons" + ); + + expect(null, null, null, details[0]); + }, + ]; + }, + }); +}); + +add_task(async function testBadgeColorPersistence() { + const extension = ExtensionTestUtils.loadExtension({ + background() { + browser.test.onMessage.addListener((msg, arg) => { + browser.browserAction[msg](arg); + }); + }, + manifest: { + browser_action: { + default_area: "navbar", + }, + }, + }); + await extension.startup(); + + function getBadgeForWindow(win) { + const widget = getBrowserActionWidget(extension).forWindow(win).node; + return widget.firstElementChild.badgeLabel; + } + + let badge = getBadgeForWindow(window); + const badgeChanged = new Promise(resolve => { + const observer = new MutationObserver(() => resolve()); + observer.observe(badge, { attributes: true, attributeFilter: ["style"] }); + }); + + extension.sendMessage("setBadgeText", { text: "hi" }); + extension.sendMessage("setBadgeBackgroundColor", { color: [0, 255, 0, 255] }); + + await badgeChanged; + + is(badge.textContent, "hi", "badge text is set in first window"); + is( + badge.style.backgroundColor, + "rgb(0, 255, 0)", + "badge color is set in first window" + ); + + let windowOpenedPromise = BrowserTestUtils.waitForNewWindow(); + let win = OpenBrowserWindow(); + await windowOpenedPromise; + + badge = getBadgeForWindow(win); + is(badge.textContent, "hi", "badge text is set in new window"); + is( + badge.style.backgroundColor, + "rgb(0, 255, 0)", + "badge color is set in new window" + ); + + await BrowserTestUtils.closeWindow(win); + await extension.unload(); +}); + +add_task(async function testPropertyRemoval() { + await runTests({ + manifest: { + name: "Generated extension", + browser_action: { + default_icon: "default.png", + default_popup: "default.html", + default_title: "Default Title", + default_area: "navbar", + }, + }, + + files: { + "default.png": imageBuffer, + "global.png": imageBuffer, + "global2.png": imageBuffer, + "window.png": imageBuffer, + "tab.png": imageBuffer, + }, + + getTests: function (tabs, windows) { + let defaultIcon = "chrome://mozapps/skin/extensions/extensionGeneric.svg"; + let details = [ + { + icon: browser.runtime.getURL("default.png"), + popup: browser.runtime.getURL("default.html"), + title: "Default Title", + badge: "", + badgeBackgroundColor: [0xd9, 0x00, 0x00, 0xff], + badgeTextColor: [0xff, 0xff, 0xff, 0xff], + enabled: true, + }, + { + icon: browser.runtime.getURL("global.png"), + popup: browser.runtime.getURL("global.html"), + title: "global", + badge: "global", + badgeBackgroundColor: [0x11, 0x11, 0x11, 0xff], + badgeTextColor: [0x99, 0x99, 0x99, 0xff], + }, + { + icon: browser.runtime.getURL("window.png"), + popup: browser.runtime.getURL("window.html"), + title: "window", + badge: "window", + badgeBackgroundColor: [0x22, 0x22, 0x22, 0xff], + badgeTextColor: [0x88, 0x88, 0x88, 0xff], + }, + { + icon: browser.runtime.getURL("tab.png"), + popup: browser.runtime.getURL("tab.html"), + title: "tab", + badge: "tab", + badgeBackgroundColor: [0x33, 0x33, 0x33, 0xff], + badgeTextColor: [0x77, 0x77, 0x77, 0xff], + }, + { + icon: defaultIcon, + popup: "", + title: "", + badge: "", + badgeBackgroundColor: [0x33, 0x33, 0x33, 0xff], + badgeTextColor: [0x77, 0x77, 0x77, 0xff], + }, + { + icon: browser.runtime.getURL("global2.png"), + popup: browser.runtime.getURL("global2.html"), + title: "global2", + badge: "global2", + badgeBackgroundColor: [0x44, 0x44, 0x44, 0xff], + badgeTextColor: [0x66, 0x66, 0x66, 0xff], + }, + ]; + + return [ + async expect => { + browser.test.log("Initial state, expect default properties."); + expect(null, null, null, details[0]); + }, + async expect => { + browser.test.log("Set global values, expect the new values."); + browser.browserAction.setIcon({ path: "global.png" }); + browser.browserAction.setPopup({ popup: "global.html" }); + browser.browserAction.setTitle({ title: "global" }); + browser.browserAction.setBadgeText({ text: "global" }); + browser.browserAction.setBadgeBackgroundColor({ color: "#111" }); + browser.browserAction.setBadgeTextColor({ color: "#999" }); + expect(null, null, details[1], details[0]); + }, + async expect => { + browser.test.log("Set window values, expect the new values."); + let windowId = windows[0]; + browser.browserAction.setIcon({ windowId, path: "window.png" }); + browser.browserAction.setPopup({ windowId, popup: "window.html" }); + browser.browserAction.setTitle({ windowId, title: "window" }); + browser.browserAction.setBadgeText({ windowId, text: "window" }); + browser.browserAction.setBadgeBackgroundColor({ + windowId, + color: "#222", + }); + browser.browserAction.setBadgeTextColor({ windowId, color: "#888" }); + expect(null, details[2], details[1], details[0]); + }, + async expect => { + browser.test.log("Set tab values, expect the new values."); + let tabId = tabs[0]; + browser.browserAction.setIcon({ tabId, path: "tab.png" }); + browser.browserAction.setPopup({ tabId, popup: "tab.html" }); + browser.browserAction.setTitle({ tabId, title: "tab" }); + browser.browserAction.setBadgeText({ tabId, text: "tab" }); + browser.browserAction.setBadgeBackgroundColor({ + tabId, + color: "#333", + }); + browser.browserAction.setBadgeTextColor({ tabId, color: "#777" }); + expect(details[3], details[2], details[1], details[0]); + }, + async expect => { + browser.test.log( + "Set empty tab values, expect empty values except for colors." + ); + let tabId = tabs[0]; + browser.browserAction.setIcon({ tabId, path: "" }); + browser.browserAction.setPopup({ tabId, popup: "" }); + browser.browserAction.setTitle({ tabId, title: "" }); + browser.browserAction.setBadgeText({ tabId, text: "" }); + await browser.test.assertRejects( + browser.browserAction.setBadgeBackgroundColor({ tabId, color: "" }), + /^Invalid badge background color: ""$/, + "Expected invalid badge background color error" + ); + await browser.test.assertRejects( + browser.browserAction.setBadgeTextColor({ tabId, color: "" }), + /^Invalid badge text color: ""$/, + "Expected invalid badge text color error" + ); + expect(details[4], details[2], details[1], details[0]); + }, + async expect => { + browser.test.log("Remove tab values, expect window values."); + let tabId = tabs[0]; + browser.browserAction.setIcon({ tabId, path: null }); + browser.browserAction.setPopup({ tabId, popup: null }); + browser.browserAction.setTitle({ tabId, title: null }); + browser.browserAction.setBadgeText({ tabId, text: null }); + browser.browserAction.setBadgeBackgroundColor({ tabId, color: null }); + browser.browserAction.setBadgeTextColor({ tabId, color: null }); + expect(null, details[2], details[1], details[0]); + }, + async expect => { + browser.test.log("Remove window values, expect global values."); + let windowId = windows[0]; + browser.browserAction.setIcon({ windowId, path: null }); + browser.browserAction.setPopup({ windowId, popup: null }); + browser.browserAction.setTitle({ windowId, title: null }); + browser.browserAction.setBadgeText({ windowId, text: null }); + browser.browserAction.setBadgeBackgroundColor({ + windowId, + color: null, + }); + browser.browserAction.setBadgeTextColor({ windowId, color: null }); + expect(null, null, details[1], details[0]); + }, + async expect => { + browser.test.log("Change global values, expect the new values."); + browser.browserAction.setIcon({ path: "global2.png" }); + browser.browserAction.setPopup({ popup: "global2.html" }); + browser.browserAction.setTitle({ title: "global2" }); + browser.browserAction.setBadgeText({ text: "global2" }); + browser.browserAction.setBadgeBackgroundColor({ color: "#444" }); + browser.browserAction.setBadgeTextColor({ color: "#666" }); + expect(null, null, details[5], details[0]); + }, + async expect => { + browser.test.log("Remove global values, expect defaults."); + browser.browserAction.setIcon({ path: null }); + browser.browserAction.setPopup({ popup: null }); + browser.browserAction.setBadgeText({ text: null }); + browser.browserAction.setTitle({ title: null }); + browser.browserAction.setBadgeBackgroundColor({ color: null }); + browser.browserAction.setBadgeTextColor({ color: null }); + expect(null, null, null, details[0]); + }, + ]; + }, + }); +}); + +add_task(async function testMultipleWindows() { + await runTests({ + manifest: { + browser_action: { + default_icon: "default.png", + default_popup: "default.html", + default_title: "Default Title", + default_area: "navbar", + }, + }, + + files: { + "default.png": imageBuffer, + "window1.png": imageBuffer, + "window2.png": imageBuffer, + }, + + getTests: function (tabs, windows) { + let details = [ + { + icon: browser.runtime.getURL("default.png"), + popup: browser.runtime.getURL("default.html"), + title: "Default Title", + badge: "", + badgeBackgroundColor: [0xd9, 0x00, 0x00, 0xff], + badgeTextColor: [0xff, 0xff, 0xff, 0xff], + enabled: true, + }, + { + icon: browser.runtime.getURL("window1.png"), + popup: browser.runtime.getURL("window1.html"), + title: "window1", + badge: "w1", + badgeBackgroundColor: [0x11, 0x11, 0x11, 0xff], + badgeTextColor: [0x99, 0x99, 0x99, 0xff], + }, + { + icon: browser.runtime.getURL("window2.png"), + popup: browser.runtime.getURL("window2.html"), + title: "window2", + badge: "w2", + badgeBackgroundColor: [0x22, 0x22, 0x22, 0xff], + badgeTextColor: [0x88, 0x88, 0x88, 0xff], + }, + { title: "tab" }, + ]; + + return [ + async expect => { + browser.test.log("Initial state, expect default properties."); + expect(null, null, null, details[0]); + }, + async expect => { + browser.test.log("Set window values, expect the new values."); + let windowId = windows[0]; + browser.browserAction.setIcon({ windowId, path: "window1.png" }); + browser.browserAction.setPopup({ windowId, popup: "window1.html" }); + browser.browserAction.setTitle({ windowId, title: "window1" }); + browser.browserAction.setBadgeText({ windowId, text: "w1" }); + browser.browserAction.setBadgeBackgroundColor({ + windowId, + color: "#111", + }); + browser.browserAction.setBadgeTextColor({ windowId, color: "#999" }); + expect(null, details[1], null, details[0]); + }, + async expect => { + browser.test.log("Create a new tab, expect window values."); + let tab = await browser.tabs.create({ active: true }); + tabs.push(tab.id); + expect(null, details[1], null, details[0]); + }, + async expect => { + browser.test.log("Set a tab title, expect it."); + await browser.browserAction.setTitle({ + tabId: tabs[1], + title: "tab", + }); + expect(details[3], details[1], null, details[0]); + }, + async expect => { + browser.test.log("Open a new window, expect default values."); + let { id } = await browser.windows.create(); + windows.push(id); + expect(null, null, null, details[0]); + }, + async expect => { + browser.test.log("Set window values, expect the new values."); + let windowId = windows[1]; + browser.browserAction.setIcon({ windowId, path: "window2.png" }); + browser.browserAction.setPopup({ windowId, popup: "window2.html" }); + browser.browserAction.setTitle({ windowId, title: "window2" }); + browser.browserAction.setBadgeText({ windowId, text: "w2" }); + browser.browserAction.setBadgeBackgroundColor({ + windowId, + color: "#222", + }); + browser.browserAction.setBadgeTextColor({ windowId, color: "#888" }); + expect(null, details[2], null, details[0]); + }, + async expect => { + browser.test.log( + "Move tab from old window to the new one. Tab-specific data" + + " is preserved but inheritance is from the new window" + ); + await browser.tabs.move(tabs[1], { windowId: windows[1], index: -1 }); + await browser.tabs.update(tabs[1], { active: true }); + expect(details[3], details[2], null, details[0]); + }, + async expect => { + browser.test.log("Close the initial tab of the new window."); + let [{ id }] = await browser.tabs.query({ + windowId: windows[1], + index: 0, + }); + await browser.tabs.remove(id); + expect(details[3], details[2], null, details[0]); + }, + async expect => { + browser.test.log( + "Move the previous tab to a 3rd window, the 2nd one will close." + ); + await browser.windows.create({ tabId: tabs[1] }); + expect(details[3], null, null, details[0]); + }, + async expect => { + browser.test.log("Close the tab, go back to the 1st window."); + await browser.tabs.remove(tabs[1]); + expect(null, details[1], null, details[0]); + }, + async expect => { + browser.test.log( + "Assert failures for bad parameters. Expect no change" + ); + + let calls = { + setIcon: { path: "default.png" }, + setPopup: { popup: "default.html" }, + setTitle: { title: "Default Title" }, + setBadgeText: { text: "" }, + setBadgeBackgroundColor: { color: [0xd9, 0x00, 0x00, 0xff] }, + setBadgeTextColor: { color: [0xff, 0xff, 0xff, 0xff] }, + getPopup: {}, + getTitle: {}, + getBadgeText: {}, + getBadgeBackgroundColor: {}, + }; + for (let [method, arg] of Object.entries(calls)) { + browser.test.assertThrows( + () => browser.browserAction[method]({ ...arg, windowId: -3 }), + /-3 is too small \(must be at least -2\)/, + method + " with invalid windowId" + ); + await browser.test.assertRejects( + browser.browserAction[method]({ + ...arg, + tabId: tabs[0], + windowId: windows[0], + }), + /Only one of tabId and windowId can be specified/, + method + " with both tabId and windowId" + ); + } + + expect(null, details[1], null, details[0]); + }, + ]; + }, + }); +}); + +add_task(async function testDefaultBadgeTextColor() { + await runTests({ + manifest: { + browser_action: { + default_icon: "default.png", + default_popup: "default.html", + default_title: "Default Title", + default_area: "navbar", + }, + }, + + files: { + "default.png": imageBuffer, + "window1.png": imageBuffer, + "window2.png": imageBuffer, + }, + + getTests: function (tabs, windows) { + let details = [ + { + icon: browser.runtime.getURL("default.png"), + popup: browser.runtime.getURL("default.html"), + title: "Default Title", + badge: "", + badgeBackgroundColor: [0xd9, 0x00, 0x00, 0xff], + badgeTextColor: [0xff, 0xff, 0xff, 0xff], + enabled: true, + }, + { + badgeBackgroundColor: [0xff, 0xff, 0x00, 0xff], + badgeTextColor: [0x00, 0x00, 0x00, 0xff], + }, + { + badgeBackgroundColor: [0x00, 0x00, 0xff, 0xff], + badgeTextColor: [0xff, 0xff, 0xff, 0xff], + }, + { + badgeBackgroundColor: [0xff, 0xff, 0xff, 0x00], + badgeTextColor: [0x00, 0x00, 0x00, 0xff], + }, + { + badgeBackgroundColor: [0x00, 0x00, 0xff, 0xff], + badgeTextColor: [0xff, 0x00, 0xff, 0xff], + }, + { badgeBackgroundColor: [0xff, 0xff, 0xff, 0x00] }, + { + badgeBackgroundColor: [0x00, 0x00, 0x00, 0x00], + badgeTextColor: [0xff, 0xff, 0xff, 0xff], + }, + ]; + + return [ + async expect => { + browser.test.log("Initial state, expect default properties."); + expect(null, null, null, details[0]); + }, + async expect => { + browser.test.log("Set a global light bgcolor, expect black text."); + browser.browserAction.setBadgeBackgroundColor({ color: "#ff0" }); + expect(null, null, details[1], details[0]); + }, + async expect => { + browser.test.log( + "Set a window-specific dark bgcolor, expect white text." + ); + let windowId = windows[0]; + browser.browserAction.setBadgeBackgroundColor({ + windowId, + color: "#00f", + }); + expect(null, details[2], details[1], details[0]); + }, + async expect => { + browser.test.log( + "Set a tab-specific transparent-white bgcolor, expect black text." + ); + let tabId = tabs[0]; + browser.browserAction.setBadgeBackgroundColor({ + tabId, + color: "#fff0", + }); + expect(details[3], details[2], details[1], details[0]); + }, + async expect => { + browser.test.log( + "Set a window-specific text color, expect it in the tab." + ); + let windowId = windows[0]; + browser.browserAction.setBadgeTextColor({ windowId, color: "#f0f" }); + expect(details[5], details[4], details[1], details[0]); + }, + async expect => { + browser.test.log( + "Remove the window-specific text color, expect black again." + ); + let windowId = windows[0]; + browser.browserAction.setBadgeTextColor({ windowId, color: null }); + expect(details[3], details[2], details[1], details[0]); + }, + async expect => { + browser.test.log( + "Set a tab-specific transparent-black bgcolor, expect white text." + ); + let tabId = tabs[0]; + browser.browserAction.setBadgeBackgroundColor({ + tabId, + color: "#0000", + }); + expect(details[6], details[2], details[1], details[0]); + }, + ]; + }, + }); +}); + +add_task(async function testNavigationClearsData() { + let url = "http://example.com/"; + let default_title = "Default title"; + let tab_title = "Tab title"; + + const { + Management: { + global: { tabTracker }, + }, + } = ChromeUtils.importESModule("resource://gre/modules/Extension.sys.mjs"); + let extension, + tabs = []; + async function addTab(...args) { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, ...args); + tabs.push(tab); + return tab; + } + async function sendMessage(method, param, expect, msg) { + extension.sendMessage({ method, param, expect, msg }); + await extension.awaitMessage("done"); + } + async function expectTabSpecificData(tab, msg) { + let tabId = tabTracker.getId(tab); + await sendMessage("getBadgeText", { tabId }, "foo", msg); + await sendMessage("getTitle", { tabId }, tab_title, msg); + } + async function expectDefaultData(tab, msg) { + let tabId = tabTracker.getId(tab); + await sendMessage("getBadgeText", { tabId }, "", msg); + await sendMessage("getTitle", { tabId }, default_title, msg); + } + async function setTabSpecificData(tab) { + let tabId = tabTracker.getId(tab); + await expectDefaultData( + tab, + "Expect default data before setting tab-specific data." + ); + await sendMessage("setBadgeText", { tabId, text: "foo" }); + await sendMessage("setTitle", { tabId, title: tab_title }); + await expectTabSpecificData( + tab, + "Expect tab-specific data after setting it." + ); + } + + info("Load a tab before installing the extension"); + let tab1 = await addTab(url, true, true); + + extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_action: { default_title }, + }, + background: function () { + browser.test.onMessage.addListener( + async ({ method, param, expect, msg }) => { + let result = await browser.browserAction[method](param); + if (expect !== undefined) { + browser.test.assertEq(expect, result, msg); + } + browser.test.sendMessage("done"); + } + ); + }, + }); + await extension.startup(); + + info("Set tab-specific data to the existing tab."); + await setTabSpecificData(tab1); + + info("Add a hash. Does not cause navigation."); + await navigateTab(tab1, url + "#hash"); + await expectTabSpecificData( + tab1, + "Adding a hash does not clear tab-specific data" + ); + + info("Remove the hash. Causes navigation."); + await navigateTab(tab1, url); + await expectDefaultData(tab1, "Removing hash clears tab-specific data"); + + info("Open a new tab, set tab-specific data to it."); + let tab2 = await addTab("about:newtab", false, false); + await setTabSpecificData(tab2); + + info("Load a page in that tab."); + await navigateTab(tab2, url); + await expectDefaultData(tab2, "Loading a page clears tab-specific data."); + + info("Set tab-specific data."); + await setTabSpecificData(tab2); + + info("Push history state. Does not cause navigation."); + await historyPushState(tab2, url + "/path"); + await expectTabSpecificData( + tab2, + "history.pushState() does not clear tab-specific data" + ); + + info("Navigate when the tab is not selected"); + gBrowser.selectedTab = tab1; + await navigateTab(tab2, url); + await expectDefaultData( + tab2, + "Navigating clears tab-specific data, even when not selected." + ); + + for (let tab of tabs) { + await BrowserTestUtils.removeTab(tab); + } + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_browserAction_contextMenu.js b/browser/components/extensions/test/browser/browser_ext_browserAction_contextMenu.js new file mode 100644 index 0000000000..9544ed43a4 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_browserAction_contextMenu.js @@ -0,0 +1,880 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + AbuseReporter: "resource://gre/modules/AbuseReporter.sys.mjs", +}); + +XPCOMUtils.defineLazyPreferenceGetter( + this, + "ABUSE_REPORT_ENABLED", + "extensions.abuseReport.enabled", + false +); + +let extData = { + manifest: { + permissions: ["contextMenus"], + browser_action: { + default_popup: "popup.html", + default_area: "navbar", + }, + }, + useAddonManager: "temporary", + + files: { + "popup.html": ` + <!DOCTYPE html> + <html> + <head><meta charset="utf-8"/> + </head> + <body> + <span id="text">A Test Popup</span> + <img id="testimg" src="data:image/svg+xml,<svg></svg>" height="10" width="10"> + </body></html> + `, + }, + + background: function () { + browser.contextMenus.create({ + id: "clickme-page", + title: "Click me!", + contexts: ["all"], + }); + }, +}; + +let contextMenuItems = { + "context-sep-navigation": "hidden", + "context-viewsource": "", + "inspect-separator": "hidden", + "context-inspect": "hidden", + "context-inspect-a11y": "hidden", + "context-bookmarkpage": "hidden", +}; +if (AppConstants.platform == "macosx") { + contextMenuItems["context-back"] = "hidden"; + contextMenuItems["context-forward"] = "hidden"; + contextMenuItems["context-reload"] = "hidden"; + contextMenuItems["context-stop"] = "hidden"; +} else { + contextMenuItems["context-navigation"] = "hidden"; +} + +const TOOLBAR_CONTEXT_MENU = "toolbar-context-menu"; +const UNIFIED_CONTEXT_MENU = "unified-extensions-context-menu"; + +loadTestSubscript("head_unified_extensions.js"); + +add_setup(async function test_setup() { + CustomizableUI.addWidgetToArea("home-button", "nav-bar"); + registerCleanupFunction(() => + CustomizableUI.removeWidgetFromArea("home-button") + ); + + await SpecialPowers.pushPrefEnv({ + set: [ + [ + "extensions.abuseReport.amoFormURL", + "https://example.org/%LOCALE%/%APP%/feedback/addon/%addonID%/", + ], + ], + }); +}); + +async function browseraction_popup_contextmenu_helper() { + let extension = ExtensionTestUtils.loadExtension(extData); + await extension.startup(); + + await clickBrowserAction(extension); + + let contentAreaContextMenu = await openContextMenuInPopup(extension); + let item = contentAreaContextMenu.getElementsByAttribute( + "label", + "Click me!" + ); + is(item.length, 1, "contextMenu item for page was found"); + await closeContextMenu(contentAreaContextMenu); + + await closeBrowserAction(extension); + + await extension.unload(); +} + +async function browseraction_popup_contextmenu_hidden_items_helper() { + let extension = ExtensionTestUtils.loadExtension(extData); + await extension.startup(); + + await clickBrowserAction(extension); + + let contentAreaContextMenu = await openContextMenuInPopup(extension, "#text"); + + let item, state; + for (const itemID in contextMenuItems) { + info(`Checking ${itemID}`); + item = contentAreaContextMenu.querySelector(`#${itemID}`); + state = contextMenuItems[itemID]; + + if (state !== "") { + ok(item[state], `${itemID} is ${state}`); + + if (state !== "hidden") { + ok(!item.hidden, `Disabled ${itemID} is not hidden`); + } + } else { + ok(!item.hidden, `${itemID} is not hidden`); + ok(!item.disabled, `${itemID} is not disabled`); + } + } + + await closeContextMenu(contentAreaContextMenu); + + await closeBrowserAction(extension); + + await extension.unload(); +} + +async function browseraction_popup_image_contextmenu_helper() { + let extension = ExtensionTestUtils.loadExtension(extData); + await extension.startup(); + + await clickBrowserAction(extension); + + let contentAreaContextMenu = await openContextMenuInPopup( + extension, + "#testimg" + ); + + let item = contentAreaContextMenu.querySelector("#context-copyimage"); + ok(!item.hidden); + ok(!item.disabled); + + await closeContextMenu(contentAreaContextMenu); + + await closeBrowserAction(extension); + + await extension.unload(); +} + +function openContextMenu(menuId, targetId) { + info(`Open context menu ${menuId} at ${targetId}`); + return openChromeContextMenu(menuId, "#" + CSS.escape(targetId)); +} + +function waitForElementShown(element) { + let win = element.ownerGlobal; + let dwu = win.windowUtils; + return BrowserTestUtils.waitForCondition(() => { + info("Waiting for overflow button to have non-0 size"); + let bounds = dwu.getBoundsWithoutFlushing(element); + return bounds.width > 0 && bounds.height > 0; + }); +} + +async function browseraction_contextmenu_manage_extension_helper() { + let id = "addon_id@example.com"; + let buttonId = `${makeWidgetId(id)}-BAP`; + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { id }, + }, + browser_action: { + default_area: "navbar", + }, + options_ui: { + page: "options.html", + }, + }, + useAddonManager: "temporary", + files: { + "options.html": `<script src="options.js"></script>`, + "options.js": `browser.test.sendMessage("options-loaded");`, + }, + }); + + function checkVisibility(menu, visible) { + let removeExtension = menu.querySelector( + ".customize-context-removeExtension" + ); + let manageExtension = menu.querySelector( + ".customize-context-manageExtension" + ); + let reportExtension = menu.querySelector( + ".customize-context-reportExtension" + ); + let separator = reportExtension.nextElementSibling; + + info(`Check visibility: ${visible}`); + let expected = visible ? "visible" : "hidden"; + is( + removeExtension.hidden, + !visible, + `Remove Extension should be ${expected}` + ); + is( + manageExtension.hidden, + !visible, + `Manage Extension should be ${expected}` + ); + is( + reportExtension.hidden, + !ABUSE_REPORT_ENABLED || !visible, + `Report Extension should be ${expected}` + ); + is( + separator.hidden, + !visible, + `Separator after Manage Extension should be ${expected}` + ); + } + + async function testContextMenu(menuId, customizing) { + info(`Open browserAction context menu in ${menuId} on ${buttonId}`); + let menu = await openContextMenu(menuId, buttonId); + await checkVisibility(menu, true); + + info(`Choosing 'Manage Extension' in ${menuId} should load options`); + let addonManagerPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + "about:addons", + true + ); + let manageExtension = menu.querySelector( + ".customize-context-manageExtension" + ); + await closeChromeContextMenu(menuId, manageExtension); + let managerWindow = (await addonManagerPromise).linkedBrowser.contentWindow; + + // Check the UI to make sure that the correct view is loaded. + is( + managerWindow.gViewController.currentViewId, + `addons://detail/${encodeURIComponent(id)}`, + "Expected extension details view in about:addons" + ); + // In HTML about:addons, the default view does not show the inline + // options browser, so we should not receive an "options-loaded" event. + // (if we do, the test will fail due to the unexpected message). + + info( + `Remove the opened tab, and await customize mode to be restored if necessary` + ); + let tab = gBrowser.selectedTab; + is(tab.linkedBrowser.currentURI.spec, "about:addons"); + if (customizing) { + let customizationReady = BrowserTestUtils.waitForEvent( + gNavToolbox, + "customizationready" + ); + gBrowser.removeTab(tab); + await customizationReady; + } else { + gBrowser.removeTab(tab); + } + + return menu; + } + + async function main(customizing) { + if (customizing) { + info("Enter customize mode"); + let customizationReady = BrowserTestUtils.waitForEvent( + gNavToolbox, + "customizationready" + ); + gCustomizeMode.enter(); + await customizationReady; + } + + info("Test toolbar context menu in browserAction"); + let toolbarCtxMenu = await testContextMenu( + TOOLBAR_CONTEXT_MENU, + customizing + ); + + info("Check toolbar context menu in another button"); + let otherButtonId = "home-button"; + await openContextMenu(TOOLBAR_CONTEXT_MENU, otherButtonId); + checkVisibility(toolbarCtxMenu, false); + toolbarCtxMenu.hidePopup(); + + info("Check toolbar context menu without triggerNode"); + toolbarCtxMenu.openPopup(); + checkVisibility(toolbarCtxMenu, false); + toolbarCtxMenu.hidePopup(); + + CustomizableUI.addWidgetToArea( + otherButtonId, + CustomizableUI.AREA_FIXED_OVERFLOW_PANEL + ); + + info("Wait until the overflow menu is ready"); + let overflowButton = document.getElementById("nav-bar-overflow-button"); + let icon = overflowButton.icon; + await waitForElementShown(icon); + + if (!customizing) { + info("Open overflow menu"); + let menu = document.getElementById("widget-overflow"); + let shown = BrowserTestUtils.waitForEvent(menu, "popupshown"); + overflowButton.click(); + await shown; + } + + info("Check overflow menu context menu in another button"); + let overflowMenuCtxMenu = await openContextMenu( + "customizationPanelItemContextMenu", + otherButtonId + ); + checkVisibility(overflowMenuCtxMenu, false); + overflowMenuCtxMenu.hidePopup(); + + info("Put other button action back in nav-bar"); + CustomizableUI.addWidgetToArea(otherButtonId, CustomizableUI.AREA_NAVBAR); + + if (customizing) { + info("Exit customize mode"); + let afterCustomization = BrowserTestUtils.waitForEvent( + gNavToolbox, + "aftercustomization" + ); + gCustomizeMode.exit(); + await afterCustomization; + } + } + + await extension.startup(); + + info( + "Add a dummy tab to prevent about:addons from being loaded in the initial about:blank tab" + ); + let dummyTab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.com", + true, + true + ); + + info("Run tests in normal mode"); + await main(false); + + info("Run tests in customize mode"); + await main(true); + + info("Close the dummy tab and finish"); + gBrowser.removeTab(dummyTab); + await extension.unload(); +} + +async function runTestContextMenu({ id, customizing, testContextMenu }) { + let widgetId = makeWidgetId(id); + let nodeId = `${widgetId}-browser-action`; + if (customizing) { + info("Enter customize mode"); + let customizationReady = BrowserTestUtils.waitForEvent( + gNavToolbox, + "customizationready" + ); + gCustomizeMode.enter(); + await customizationReady; + } + + info("Test toolbar context menu in browserAction"); + await testContextMenu(TOOLBAR_CONTEXT_MENU, customizing); + + info("Pin the browserAction to the addons panel"); + CustomizableUI.addWidgetToArea(nodeId, CustomizableUI.AREA_ADDONS); + + if (!customizing) { + info("Open addons panel"); + gUnifiedExtensions.togglePanel(); + await BrowserTestUtils.waitForEvent(gUnifiedExtensions.panel, "popupshown"); + info("Test browserAction in addons panel"); + await testContextMenu(UNIFIED_CONTEXT_MENU, customizing); + } else { + todo( + false, + "The browserAction cannot be accessed from customize " + + "mode when in the addons panel." + ); + } + + info("Restore initial state"); + CustomizableUI.addWidgetToArea(nodeId, CustomizableUI.AREA_NAVBAR); + + if (customizing) { + info("Exit customize mode"); + let afterCustomization = BrowserTestUtils.waitForEvent( + gNavToolbox, + "aftercustomization" + ); + gCustomizeMode.exit(); + await afterCustomization; + } +} + +async function browseraction_contextmenu_remove_extension_helper() { + let id = "addon_id@example.com"; + let name = "Awesome Add-on"; + let buttonId = `${makeWidgetId(id)}-BAP`; + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + name, + browser_specific_settings: { + gecko: { id }, + }, + browser_action: { + default_area: "navbar", + }, + }, + useAddonManager: "temporary", + }); + let brand = Services.strings + .createBundle("chrome://branding/locale/brand.properties") + .GetStringFromName("brandShorterName"); + let { prompt } = Services; + let promptService = { + _response: 1, + QueryInterface: ChromeUtils.generateQI(["nsIPromptService"]), + confirmEx: function (...args) { + promptService._resolveArgs(args); + return promptService._response; + }, + confirmArgs() { + return new Promise(resolve => { + promptService._resolveArgs = resolve; + }); + }, + }; + Services.prompt = promptService; + registerCleanupFunction(() => { + Services.prompt = prompt; + }); + + async function testContextMenu(menuId, customizing) { + info(`Open browserAction context menu in ${menuId}`); + let confirmArgs = promptService.confirmArgs(); + let menu = await openContextMenu(menuId, buttonId); + + info(`Choosing 'Remove Extension' in ${menuId} should show confirm dialog`); + let removeItemQuery = + menuId == UNIFIED_CONTEXT_MENU + ? ".unified-extensions-context-menu-remove-extension" + : ".customize-context-removeExtension"; + let removeExtension = menu.querySelector(removeItemQuery); + await closeChromeContextMenu(menuId, removeExtension); + let args = await confirmArgs; + is(args[1], `Remove ${name}?`); + if (!Services.prefs.getBoolPref("prompts.windowPromptSubDialog", false)) { + is(args[2], `Remove ${name} from ${brand}?`); + } + is(args[4], "Remove"); + return menu; + } + + await extension.startup(); + + info("Run tests in normal mode"); + await runTestContextMenu({ + id, + customizing: false, + testContextMenu, + }); + + info("Run tests in customize mode"); + await runTestContextMenu({ + id, + customizing: true, + testContextMenu, + }); + + // We'll only get one of these because in customize mode, the browserAction + // is not accessible when in the addons panel. + todo( + false, + "Should record a second removal event when browserAction " + + "becomes available in customize mode." + ); + + let addon = await AddonManager.getAddonByID(id); + ok(addon, "Addon is still installed"); + + promptService._response = 0; + let uninstalled = new Promise(resolve => { + AddonManager.addAddonListener({ + onUninstalled(addon) { + is(addon.id, id, "The expected add-on has been uninstalled"); + AddonManager.removeAddonListener(this); + resolve(); + }, + }); + }); + await testContextMenu(TOOLBAR_CONTEXT_MENU, false); + await uninstalled; + + addon = await AddonManager.getAddonByID(id); + ok(!addon, "Addon has been uninstalled"); + + await extension.unload(); + + // We've got a cleanup function registered to restore this, but on debug + // builds, it seems that sometimes the cleanup function won't run soon + // enough and we'll leak this window because of the fake prompt function + // staying alive on Services. We work around this by restoring prompt + // here within the test if we've gotten here without throwing. + Services.prompt = prompt; +} + +// This test case verify reporting an extension from the browserAction +// context menu (when the browserAction is in the toolbox and in the +// overwflow menu, and repeat the test with and without the customize +// mode enabled). +async function browseraction_contextmenu_report_extension_helper() { + await SpecialPowers.pushPrefEnv({ + set: [["extensions.abuseReport.enabled", true]], + }); + + let id = "addon_id@example.com"; + let name = "Bad Add-on"; + let buttonId = `${makeWidgetId(id)}-browser-action`; + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + name, + author: "Bad author", + browser_specific_settings: { + gecko: { id }, + }, + browser_action: { + default_area: "navbar", + }, + }, + useAddonManager: "temporary", + }); + + async function testReportDialog(viaUnifiedContextMenu) { + const reportDialogWindow = await BrowserTestUtils.waitForCondition( + () => AbuseReporter.getOpenDialog(), + "Wait for the abuse report dialog to have been opened" + ); + + const reportDialogParams = reportDialogWindow.arguments[0].wrappedJSObject; + is( + reportDialogParams.report.addon.id, + id, + "Abuse report dialog has the expected addon id" + ); + is( + reportDialogParams.report.reportEntryPoint, + viaUnifiedContextMenu ? "unified_context_menu" : "toolbar_context_menu", + "Abuse report dialog has the expected reportEntryPoint" + ); + + info("Wait the report dialog to complete rendering"); + await reportDialogParams.promiseReportPanel; + info("Close the report dialog"); + reportDialogWindow.close(); + is( + await reportDialogParams.promiseReport, + undefined, + "Report resolved as user cancelled when the window is closed" + ); + } + + async function testContextMenu(menuId, customizing) { + info(`Open browserAction context menu in ${menuId}`); + let menu = await openContextMenu(menuId, buttonId); + + info(`Choosing 'Report Extension' in ${menuId}`); + + let usingUnifiedContextMenu = menuId == UNIFIED_CONTEXT_MENU; + let reportItemQuery = usingUnifiedContextMenu + ? ".unified-extensions-context-menu-report-extension" + : ".customize-context-reportExtension"; + let reportExtension = menu.querySelector(reportItemQuery); + + ok(!reportExtension.hidden, "Report extension should be visibile"); + + let aboutAddonsBrowser; + + if (AbuseReporter.amoFormEnabled) { + const reportURL = Services.urlFormatter + .formatURLPref("extensions.abuseReport.amoFormURL") + .replace("%addonID%", id); + + const promiseReportTab = BrowserTestUtils.waitForNewTab( + gBrowser, + reportURL, + /* waitForLoad */ false, + // Expect it to be the next tab opened + /* waitForAnyTab */ false + ); + await closeChromeContextMenu(menuId, reportExtension); + const reportTab = await promiseReportTab; + // Remove the report tab and expect the selected tab + // to become the about:addons tab. + BrowserTestUtils.removeTab(reportTab); + is( + gBrowser.selectedBrowser.currentURI.spec, + "about:blank", + "Expect about:addons tab to not have been opened (amoFormEnabled=true)" + ); + } else { + // When running in customizing mode "about:addons" will load in a new tab, + // otherwise it will replace the existing blank tab. + const onceAboutAddonsTab = customizing + ? BrowserTestUtils.waitForNewTab(gBrowser, "about:addons") + : BrowserTestUtils.waitForCondition(() => { + return gBrowser.currentURI.spec === "about:addons"; + }, "Wait an about:addons tab to be opened"); + await closeChromeContextMenu(menuId, reportExtension); + await onceAboutAddonsTab; + const browser = gBrowser.selectedBrowser; + is( + browser.currentURI.spec, + "about:addons", + "Got about:addons tab selected (amoFormEnabled=false)" + ); + // Do not wait for the about:addons tab to be loaded if its + // document is already readyState==complete. + // This prevents intermittent timeout failures while running + // this test in optimized builds. + if (browser.contentDocument?.readyState != "complete") { + await BrowserTestUtils.browserLoaded(browser); + } + await testReportDialog(usingUnifiedContextMenu); + aboutAddonsBrowser = browser; + } + + // Close the new about:addons tab when running in customize mode, + // or cancel the abuse report if the about:addons page has been + // loaded in the existing blank tab. + if (customizing) { + info("Closing the about:addons tab"); + let customizationReady = BrowserTestUtils.waitForEvent( + gNavToolbox, + "customizationready" + ); + gBrowser.removeTab(gBrowser.selectedTab); + await customizationReady; + } else if (aboutAddonsBrowser) { + info("Navigate the about:addons tab to about:blank"); + BrowserTestUtils.startLoadingURIString(aboutAddonsBrowser, "about:blank"); + await BrowserTestUtils.browserLoaded(aboutAddonsBrowser); + } + + return menu; + } + + await extension.startup(); + + info("Run tests in normal mode"); + await runTestContextMenu({ + id, + customizing: false, + testContextMenu, + }); + + info("Run tests in customize mode"); + await runTestContextMenu({ + id, + customizing: true, + testContextMenu, + }); + + await extension.unload(); + + // Opening the about:addons page will reuse an about:blank tab if it was + // a new tab that was never navigated, otherwise opens in a new tab when + // the test is triggering the report action (then we navigate it to + // about:blank). Here we cleanup all the blank tabs lets behind but one + // (otherwise the window running the test will be closed and the test + // would be failing with a timeout). + info("Cleanup about:blank tabs"); + while (gBrowser.visibleTabs.length > 1) { + is( + gBrowser.selectedBrowser.currentURI.spec, + "about:blank", + "Expect an about:blank tab" + ); + const promiseRemovedTab = BrowserTestUtils.waitForEvent( + gBrowser.selectedTab, + "TabClose" + ); + BrowserTestUtils.removeTab(gBrowser.selectedTab); + await promiseRemovedTab; + } +} + +/** + * Tests that built-in buttons see the Pin to Overflow and Remove items in + * the toolbar context menu and don't see the Pin to Toolbar item, since + * that's reserved for extension widgets. + * + * @returns {Promise} + */ +async function test_no_toolbar_pinning_on_builtin_helper() { + let menu = await openContextMenu(TOOLBAR_CONTEXT_MENU, "home-button"); + info(`Pin to Overflow and Remove from Toolbar should be visible.`); + let pinToOverflow = menu.querySelector(".customize-context-moveToPanel"); + let removeFromToolbar = menu.querySelector( + ".customize-context-removeFromToolbar" + ); + Assert.ok(!pinToOverflow.hidden, "Pin to Overflow is visible."); + Assert.ok(!removeFromToolbar.hidden, "Remove from Toolbar is visible."); + info(`This button should have "Pin to Toolbar" hidden`); + let pinToToolbar = menu.querySelector(".customize-context-pinToToolbar"); + Assert.ok(pinToToolbar.hidden, "Pin to Overflow is hidden."); + menu.hidePopup(); +} + +add_task(async function test_unified_extensions_ui() { + await browseraction_popup_contextmenu_helper(); + await browseraction_popup_contextmenu_hidden_items_helper(); + await browseraction_popup_image_contextmenu_helper(); + await browseraction_contextmenu_manage_extension_helper(); + await browseraction_contextmenu_remove_extension_helper(); + await test_no_toolbar_pinning_on_builtin_helper(); +}); + +add_task(async function test_report_amoFormEnabled() { + await SpecialPowers.pushPrefEnv({ + set: [["extensions.abuseReport.amoFormEnabled", true]], + }); + await browseraction_contextmenu_report_extension_helper(); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_report_amoFormDisabled() { + await SpecialPowers.pushPrefEnv({ + set: [["extensions.abuseReport.amoFormEnabled", false]], + }); + await browseraction_contextmenu_report_extension_helper(); + await SpecialPowers.popPrefEnv(); +}); + +/** + * Tests that if Unified Extensions is enabled, that browser actions can + * be unpinned from the toolbar to the addons panel and back again, via + * a context menu item. + */ +add_task(async function test_unified_extensions_toolbar_pinning() { + let id = "addon_id@example.com"; + let nodeId = `${makeWidgetId(id)}-browser-action`; + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { id }, + }, + browser_action: { + default_area: "navbar", + }, + }, + useAddonManager: "temporary", + }); + await extension.startup(); + + Assert.equal( + CustomizableUI.getPlacementOfWidget(nodeId).area, + CustomizableUI.AREA_NAVBAR, + "Should start placed in the nav-bar." + ); + + let menu = await openContextMenu(TOOLBAR_CONTEXT_MENU, nodeId); + + info(`Pin to Overflow and Remove from Toolbar should be hidden.`); + let pinToOverflow = menu.querySelector(".customize-context-moveToPanel"); + let removeFromToolbar = menu.querySelector( + ".customize-context-removeFromToolbar" + ); + Assert.ok(pinToOverflow.hidden, "Pin to Overflow is hidden."); + Assert.ok(removeFromToolbar.hidden, "Remove from Toolbar is hidden."); + + info( + `This button should have "Pin to Toolbar" visible and checked by default.` + ); + let pinToToolbar = menu.querySelector(".customize-context-pinToToolbar"); + Assert.ok(!pinToToolbar.hidden, "Pin to Toolbar is visible."); + Assert.equal( + pinToToolbar.getAttribute("checked"), + "true", + "Pin to Toolbar is checked." + ); + + info("Pinning addon to the addons panel."); + await closeChromeContextMenu(TOOLBAR_CONTEXT_MENU, pinToToolbar); + + Assert.equal( + CustomizableUI.getPlacementOfWidget(nodeId).area, + CustomizableUI.AREA_ADDONS, + "Should have moved the button to the addons panel." + ); + + info("Opening addons panel"); + gUnifiedExtensions.togglePanel(); + await BrowserTestUtils.waitForEvent(gUnifiedExtensions.panel, "popupshown"); + info("Testing unpinning in the addons panel"); + + menu = await openContextMenu(UNIFIED_CONTEXT_MENU, nodeId); + + // The UNIFIED_CONTEXT_MENU has a different node for pinToToolbar, so + // we have to requery for it. + pinToToolbar = menu.querySelector( + ".unified-extensions-context-menu-pin-to-toolbar" + ); + + Assert.ok(!pinToToolbar.hidden, "Pin to Toolbar is visible."); + Assert.equal( + pinToToolbar.getAttribute("checked"), + "false", + "Pin to Toolbar is not checked." + ); + await closeChromeContextMenu(UNIFIED_CONTEXT_MENU, pinToToolbar); + + Assert.equal( + CustomizableUI.getPlacementOfWidget(nodeId).area, + CustomizableUI.AREA_NAVBAR, + "Should have moved the button back to the nav-bar." + ); + + await extension.unload(); +}); + +/** + * Tests that there's no Pin to Toolbar option for unified-extensions-item's + * in the add-ons panel, since these do not represent browser action buttons. + */ +add_task(async function test_unified_extensions_item_no_pinning() { + let id = "addon_id@example.com"; + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { id }, + }, + }, + useAddonManager: "temporary", + }); + await extension.startup(); + + info("Opening addons panel"); + let panel = gUnifiedExtensions.panel; + await openExtensionsPanel(); + + let items = panel.querySelectorAll("unified-extensions-item"); + Assert.ok( + !!items.length, + "There should be at least one unified-extensions-item." + ); + + let menu = await openChromeContextMenu( + UNIFIED_CONTEXT_MENU, + `unified-extensions-item[extension-id='${id}']` + ); + let pinToToolbar = menu.querySelector( + ".unified-extensions-context-menu-pin-to-toolbar" + ); + Assert.ok(pinToToolbar.hidden, "Pin to Toolbar is hidden."); + menu.hidePopup(); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_browserAction_disabled.js b/browser/components/extensions/test/browser/browser_ext_browserAction_disabled.js new file mode 100644 index 0000000000..b4581bcd0e --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_browserAction_disabled.js @@ -0,0 +1,101 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function testDisabled() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_action: { + default_area: "navbar", + }, + }, + + background: function () { + let clicked = false; + + browser.browserAction.onClicked.addListener(() => { + browser.test.log("Got click event"); + clicked = true; + }); + + browser.test.onMessage.addListener((msg, expectClick) => { + if (msg == "enable") { + browser.test.log("enable browserAction"); + browser.browserAction.enable(); + } else if (msg == "disable") { + browser.test.log("disable browserAction"); + browser.browserAction.disable(); + } else if (msg == "check-clicked") { + browser.test.assertEq(expectClick, clicked, "got click event?"); + clicked = false; + } else { + browser.test.fail("Unexpected message"); + } + + browser.test.sendMessage("next-test"); + }); + + browser.test.sendMessage("ready"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + + await clickBrowserAction(extension); + await new Promise(resolve => setTimeout(resolve, 0)); + + extension.sendMessage("check-clicked", true); + await extension.awaitMessage("next-test"); + + extension.sendMessage("disable"); + await extension.awaitMessage("next-test"); + + await clickBrowserAction(extension); + await new Promise(resolve => setTimeout(resolve, 0)); + + extension.sendMessage("check-clicked", false); + await extension.awaitMessage("next-test"); + + // We intentionally turn off this a11y check, because the following click + // is targeting a disabled control to confirm the click event won't come through. + // It is not meant to be interactive and is not expected to be accessible: + AccessibilityUtils.setEnv({ + mustBeEnabled: false, + }); + await clickBrowserAction(extension, window, { button: 1 }); + await new Promise(resolve => setTimeout(resolve, 0)); + AccessibilityUtils.resetEnv(); + + extension.sendMessage("check-clicked", false); + await extension.awaitMessage("next-test"); + + let widget = getBrowserActionWidget(extension); + CustomizableUI.addWidgetToArea(widget.id, getCustomizableUIPanelID()); + + // We intentionally turn off this a11y check, because the following click + // is targeting a disabled control to confirm the click event won't come through. + // It is not meant to be interactive and is not expected to be accessible: + AccessibilityUtils.setEnv({ + mustBeEnabled: false, + }); + await clickBrowserAction(extension, window, { button: 1 }); + await new Promise(resolve => setTimeout(resolve, 0)); + AccessibilityUtils.resetEnv(); + + extension.sendMessage("check-clicked", false); + await extension.awaitMessage("next-test"); + + CustomizableUI.addWidgetToArea(widget.id, CustomizableUI.AREA_NAVBAR); + + extension.sendMessage("enable"); + await extension.awaitMessage("next-test"); + + await clickBrowserAction(extension); + await new Promise(resolve => setTimeout(resolve, 0)); + + extension.sendMessage("check-clicked", true); + await extension.awaitMessage("next-test"); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_browserAction_experiment.js b/browser/components/extensions/test/browser/browser_ext_browserAction_experiment.js new file mode 100644 index 0000000000..d33553146e --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_browserAction_experiment.js @@ -0,0 +1,159 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +let fooExperimentAPIs = { + foo: { + schema: "schema.json", + parent: { + scopes: ["addon_parent"], + script: "parent.js", + paths: [["experiments", "foo", "parent"]], + }, + child: { + scopes: ["addon_child"], + script: "child.js", + paths: [["experiments", "foo", "child"]], + }, + }, +}; + +let fooExperimentFiles = { + "schema.json": JSON.stringify([ + { + namespace: "experiments.foo", + types: [ + { + id: "Meh", + type: "object", + properties: {}, + }, + ], + functions: [ + { + name: "parent", + type: "function", + async: true, + parameters: [], + }, + { + name: "child", + type: "function", + parameters: [], + returns: { type: "string" }, + }, + ], + }, + ]), + + /* globals ExtensionAPI */ + "parent.js": () => { + this.foo = class extends ExtensionAPI { + getAPI(context) { + return { + experiments: { + foo: { + parent() { + return Promise.resolve("parent"); + }, + }, + }, + }; + } + }; + }, + + "child.js": () => { + this.foo = class extends ExtensionAPI { + getAPI(context) { + return { + experiments: { + foo: { + child() { + return "child"; + }, + }, + }, + }; + } + }; + }, +}; + +async function testFooExperiment() { + browser.test.assertEq( + "object", + typeof browser.experiments, + "typeof browser.experiments" + ); + + browser.test.assertEq( + "object", + typeof browser.experiments.foo, + "typeof browser.experiments.foo" + ); + + browser.test.assertEq( + "function", + typeof browser.experiments.foo.child, + "typeof browser.experiments.foo.child" + ); + + browser.test.assertEq( + "function", + typeof browser.experiments.foo.parent, + "typeof browser.experiments.foo.parent" + ); + + browser.test.assertEq( + "child", + browser.experiments.foo.child(), + "foo.child()" + ); + + browser.test.assertEq( + "parent", + await browser.experiments.foo.parent(), + "await foo.parent()" + ); +} + +add_task(async function test_browseraction_with_experiment() { + async function background() { + await new Promise(resolve => + browser.browserAction.onClicked.addListener(resolve) + ); + browser.test.log("Got browserAction.onClicked"); + + await testFooExperiment(); + + browser.test.notifyPass("background-browserAction-experiments.foo"); + } + + let extension = ExtensionTestUtils.loadExtension({ + isPrivileged: true, + + manifest: { + browser_action: { + default_area: "navbar", + }, + + experiment_apis: fooExperimentAPIs, + }, + + background: ` + ${testFooExperiment} + (${background})(); + `, + + files: fooExperimentFiles, + }); + + await extension.startup(); + + await clickBrowserAction(extension, window); + + await extension.awaitFinish("background-browserAction-experiments.foo"); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_browserAction_getUserSettings.js b/browser/components/extensions/test/browser/browser_ext_browserAction_getUserSettings.js new file mode 100644 index 0000000000..97ff709788 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_browserAction_getUserSettings.js @@ -0,0 +1,244 @@ +"use strict"; + +async function background(test) { + let resolvers = {}; + let tests = {}; + + browser.test.onMessage.addListener(id => { + let resolver = resolvers[id]; + browser.test.assertTrue(resolver, `Found resolver for ${id}`); + browser.test.assertTrue(resolver.resolve, `${id} was not resolved yet`); + resolver.resolve(id); + resolver.resolve = null; // resolve can be used only once. + }); + + async function pinToToolbar(shouldPinToToolbar) { + let identifier = `${ + shouldPinToToolbar ? "pin-to-toolbar" : "unpin-from-toolbar" + }-${Object.keys(resolvers).length}`; + resolvers[identifier] = {}; + resolvers[identifier].promise = new Promise( + _resolve => (resolvers[identifier].resolve = _resolve) + ); + browser.test.sendMessage("pinToToolbar", { + identifier, + shouldPinToToolbar, + }); + await resolvers[identifier].promise; + } + + let { manifest_version } = await browser.runtime.getManifest(); + let action = browser.browserAction; + + if (manifest_version === 3) { + action = browser.action; + } + + tests.getUserSettings = async function () { + let userSettings = await action.getUserSettings(); + + await pinToToolbar(true); + userSettings = await action.getUserSettings(); + browser.test.assertTrue( + userSettings.isOnToolbar, + "isOnToolbar should be true after pinning to toolbar" + ); + + await pinToToolbar(false); + userSettings = await action.getUserSettings(); + browser.test.assertFalse( + userSettings.isOnToolbar, + "isOnToolbar should be false after unpinning" + ); + + await pinToToolbar(true); + userSettings = await action.getUserSettings(); + browser.test.assertTrue( + userSettings.isOnToolbar, + "isOnToolbar should be true after repinning" + ); + + await pinToToolbar(false); + userSettings = await action.getUserSettings(); + browser.test.assertFalse( + userSettings.isOnToolbar, + "isOnToolbar should be false after unpinning" + ); + + await browser.test.notifyPass("getUserSettings"); + }; + + tests.default_area_getUserSettings = async function () { + let userSettings = await action.getUserSettings(); + + browser.test.assertTrue( + userSettings.isOnToolbar, + "isOnToolbar should be true when one of ['navbar', 'tabstrip', 'personaltoolbar'] default_area is specified in manifest.json" + ); + + await pinToToolbar(false); + userSettings = await action.getUserSettings(); + browser.test.assertFalse( + userSettings.isOnToolbar, + "isOnToolbar should be false after unpinning" + ); + + await browser.test.notifyPass("getUserSettings"); + }; + + tests.menupanel_default_area_getUserSettings = async function () { + let userSettings = await action.getUserSettings(); + + browser.test.assertFalse( + userSettings.isOnToolbar, + "isOnToolbar should be false when default_area is 'menupanel' in manifest.json" + ); + + await pinToToolbar(true); + userSettings = await action.getUserSettings(); + browser.test.assertTrue( + userSettings.isOnToolbar, + "isOnToolbar should be true after pinning" + ); + + await browser.test.notifyPass("getUserSettings"); + }; + + tests[test](); +} + +function pinToToolbar(shouldPinToToolbar, extension) { + let newArea = shouldPinToToolbar + ? CustomizableUI.AREA_NAVBAR + : CustomizableUI.AREA_ADDONS; + let newPosition = shouldPinToToolbar ? undefined : 0; + let widget = getBrowserActionWidget(extension); + CustomizableUI.addWidgetToArea(widget.id, newArea, newPosition); +} + +add_task(async function browserAction_getUserSettings() { + let manifest = { + manifest: { + manifest_version: 2, + browser_action: {}, + }, + background: `(${background})('getUserSettings')`, + }; + let extension = ExtensionTestUtils.loadExtension(manifest); + extension.onMessage("pinToToolbar", ({ identifier, shouldPinToToolbar }) => { + pinToToolbar(shouldPinToToolbar, extension); + extension.sendMessage(identifier); + }); + await extension.startup(); + await extension.awaitFinish("getUserSettings"); + await extension.unload(); +}); + +add_task(async function action_getUserSettings() { + let manifest = { + manifest: { + manifest_version: 3, + action: {}, + }, + background: `(${background})('getUserSettings')`, + }; + + let extension = ExtensionTestUtils.loadExtension(manifest); + extension.onMessage("pinToToolbar", ({ identifier, shouldPinToToolbar }) => { + pinToToolbar(shouldPinToToolbar, extension); + extension.sendMessage(identifier); + }); + await extension.startup(); + await extension.awaitFinish("getUserSettings"); + await extension.unload(); +}); + +add_task(async function browserAction_getUserSettings_default_area() { + for (let default_area of ["navbar", "tabstrip", "personaltoolbar"]) { + let manifest = { + manifest: { + manifest_version: 2, + browser_action: { + default_area, + }, + }, + background: `(${background})('default_area_getUserSettings')`, + }; + let extension = ExtensionTestUtils.loadExtension(manifest); + extension.onMessage( + "pinToToolbar", + ({ identifier, shouldPinToToolbar }) => { + pinToToolbar(shouldPinToToolbar, extension); + extension.sendMessage(identifier); + } + ); + await extension.startup(); + await extension.awaitFinish("getUserSettings"); + await extension.unload(); + } +}); + +add_task(async function action_getUserSettings_default_area() { + for (let default_area of ["navbar", "tabstrip", "personaltoolbar"]) { + let manifest = { + manifest: { + manifest_version: 3, + action: { + default_area, + }, + }, + background: `(${background})('default_area_getUserSettings')`, + }; + let extension = ExtensionTestUtils.loadExtension(manifest); + extension.onMessage( + "pinToToolbar", + ({ identifier, shouldPinToToolbar }) => { + pinToToolbar(shouldPinToToolbar, extension); + extension.sendMessage(identifier); + } + ); + await extension.startup(); + await extension.awaitFinish("getUserSettings"); + await extension.unload(); + } +}); + +add_task(async function browserAction_getUserSettings_menupanel_default_area() { + let manifest = { + manifest: { + manifest_version: 2, + browser_action: { + default_area: "menupanel", + }, + }, + background: `(${background})('menupanel_default_area_getUserSettings')`, + }; + let extension = ExtensionTestUtils.loadExtension(manifest); + extension.onMessage("pinToToolbar", ({ identifier, shouldPinToToolbar }) => { + pinToToolbar(shouldPinToToolbar, extension); + extension.sendMessage(identifier); + }); + await extension.startup(); + await extension.awaitFinish("getUserSettings"); + await extension.unload(); +}); + +add_task(async function action_getUserSettings_menupanel_default_area() { + let manifest = { + manifest: { + manifest_version: 3, + action: { + default_area: "menupanel", + }, + }, + background: `(${background})('menupanel_default_area_getUserSettings')`, + }; + let extension = ExtensionTestUtils.loadExtension(manifest); + extension.onMessage("pinToToolbar", ({ identifier, shouldPinToToolbar }) => { + pinToToolbar(shouldPinToToolbar, extension); + extension.sendMessage(identifier); + }); + await extension.startup(); + await extension.awaitFinish("getUserSettings"); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_browserAction_incognito.js b/browser/components/extensions/test/browser/browser_ext_browserAction_incognito.js new file mode 100644 index 0000000000..e3f9ea97fa --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_browserAction_incognito.js @@ -0,0 +1,48 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +async function testIncognito(incognitoOverride) { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_action: { + default_area: "navbar", + }, + }, + incognitoOverride, + }); + + // We test three windows, the public window, a private window prior + // to extension start, and one created after. This tests that CUI + // creates the widgets (or not) as it should. + let p1 = await BrowserTestUtils.openNewBrowserWindow({ private: true }); + await extension.startup(); + let p2 = await BrowserTestUtils.openNewBrowserWindow({ private: true }); + + let action = getBrowserActionWidget(extension); + await showBrowserAction(extension); + await showBrowserAction(extension, p1); + await showBrowserAction(extension, p2); + + ok(!!action.forWindow(window).node, "popup exists in non-private window"); + + for (let win of [p1, p2]) { + let node = action.forWindow(win).node; + if (incognitoOverride == "spanning") { + ok(!!node, "popup exists in private window"); + } else { + ok(!node, "popup does not exist in private window"); + } + + await BrowserTestUtils.closeWindow(win); + } + await extension.unload(); +} + +add_task(async function test_browserAction_not_allowed() { + await testIncognito(); +}); + +add_task(async function test_browserAction_allowed() { + await testIncognito("spanning"); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_browserAction_keyclick.js b/browser/components/extensions/test/browser/browser_ext_browserAction_keyclick.js new file mode 100644 index 0000000000..1cf5dbb8b3 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_browserAction_keyclick.js @@ -0,0 +1,68 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +// Like focusButtonAndPressKey, but leaves time between keydown and keyup +// rather than dispatching both synchronously. This allows time for the +// element's `open` property to go back to being `false` if forced to true +// synchronously in response to keydown. +async function focusButtonAndPressKeyWithDelay(key, elem, modifiers) { + let focused = BrowserTestUtils.waitForEvent(elem, "focus", true); + elem.setAttribute("tabindex", "-1"); + elem.focus(); + elem.removeAttribute("tabindex"); + await focused; + + EventUtils.synthesizeKey(key, { type: "keydown", modifiers }); + await new Promise(executeSoon); + EventUtils.synthesizeKey(key, { type: "keyup", modifiers }); + let blurred = BrowserTestUtils.waitForEvent(elem, "blur", true); + elem.blur(); + await blurred; +} + +// This test verifies that pressing enter while a page action is focused +// triggers the action once and only once. +add_task(async function testKeyBrowserAction() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_action: { + default_area: "navbar", + }, + }, + + async background() { + let counter = 0; + + browser.browserAction.onClicked.addListener(() => { + counter++; + }); + + browser.test.onMessage.addListener(async msg => { + browser.test.assertEq( + "checkCounter", + msg, + "expected check counter message" + ); + browser.test.sendMessage("counter", counter); + }); + + browser.test.sendMessage("ready"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + + let elem = getBrowserActionWidget(extension).forWindow(window).node; + + await promiseAnimationFrame(window); + await showBrowserAction(extension, window); + await focusButtonAndPressKeyWithDelay(" ", elem.firstElementChild, {}); + + extension.sendMessage("checkCounter"); + let counter = await extension.awaitMessage("counter"); + is(counter, 1, "Key only triggered button once"); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_browserAction_pageAction_icon.js b/browser/components/extensions/test/browser/browser_ext_browserAction_pageAction_icon.js new file mode 100644 index 0000000000..1561e4d6a8 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_browserAction_pageAction_icon.js @@ -0,0 +1,651 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +function testHiDpiImage(button, images1x, images2x, prop) { + let image = getRawListStyleImage(button); + info(image); + info(button.outerHTML); + let image1x = images1x[prop]; + let image2x = images2x[prop]; + is( + image, + `image-set(url("${image1x}") 1dppx, url("${image2x}") 2dppx)`, + prop + ); +} + +// Test that various combinations of icon details specs, for both paths +// and ImageData objects, result in the correct image being displayed in +// all display resolutions. +add_task(async function testDetailsObjects() { + function background() { + function getImageData(color) { + let canvas = document.createElement("canvas"); + canvas.width = 2; + canvas.height = 2; + let canvasContext = canvas.getContext("2d"); + + canvasContext.clearRect(0, 0, canvas.width, canvas.height); + canvasContext.fillStyle = color; + canvasContext.fillRect(0, 0, 1, 1); + + return { + url: canvas.toDataURL("image/png"), + imageData: canvasContext.getImageData( + 0, + 0, + canvas.width, + canvas.height + ), + }; + } + + let imageData = { + red: getImageData("red"), + green: getImageData("green"), + }; + + // eslint-disable indent, indent-legacy + let iconDetails = [ + // Only paths. + { + details: { path: "a.png" }, + resolutions: { + 1: { + browserActionImageURL: browser.runtime.getURL("data/a.png"), + pageActionImageURL: browser.runtime.getURL("data/a.png"), + }, + 2: { + browserActionImageURL: browser.runtime.getURL("data/a.png"), + pageActionImageURL: browser.runtime.getURL("data/a.png"), + }, + }, + }, + { + details: { path: "/a.png" }, + resolutions: { + 1: { + browserActionImageURL: browser.runtime.getURL("a.png"), + pageActionImageURL: browser.runtime.getURL("a.png"), + }, + 2: { + browserActionImageURL: browser.runtime.getURL("a.png"), + pageActionImageURL: browser.runtime.getURL("a.png"), + }, + }, + }, + { + details: { path: { 19: "a.png" } }, + resolutions: { + 1: { + browserActionImageURL: browser.runtime.getURL("data/a.png"), + pageActionImageURL: browser.runtime.getURL("data/a.png"), + }, + 2: { + browserActionImageURL: browser.runtime.getURL("data/a.png"), + pageActionImageURL: browser.runtime.getURL("data/a.png"), + }, + }, + }, + { + details: { path: { 38: "a.png" } }, + resolutions: { + 1: { + browserActionImageURL: browser.runtime.getURL("data/a.png"), + pageActionImageURL: browser.runtime.getURL("data/a.png"), + }, + 2: { + browserActionImageURL: browser.runtime.getURL("data/a.png"), + pageActionImageURL: browser.runtime.getURL("data/a.png"), + }, + }, + }, + { + details: { path: { 19: "a.png", 38: "a-x2.png" } }, + resolutions: { + 1: { + browserActionImageURL: browser.runtime.getURL("data/a.png"), + pageActionImageURL: browser.runtime.getURL("data/a.png"), + }, + 2: { + browserActionImageURL: browser.runtime.getURL("data/a-x2.png"), + pageActionImageURL: browser.runtime.getURL("data/a-x2.png"), + }, + }, + }, + { + details: { + path: { 16: "a-16.png", 32: "a-32.png", 64: "a-64.png" }, + }, + resolutions: { + 1: { + browserActionImageURL: browser.runtime.getURL("data/a-16.png"), + pageActionImageURL: browser.runtime.getURL("data/a-16.png"), + }, + 2: { + browserActionImageURL: browser.runtime.getURL("data/a-32.png"), + pageActionImageURL: browser.runtime.getURL("data/a-32.png"), + }, + }, + }, + + // Test that CSS strings are escaped properly. + { + details: { path: 'a.png#" \\' }, + resolutions: { + 1: { + browserActionImageURL: browser.runtime.getURL( + "data/a.png#%22%20%5C" + ), + pageActionImageURL: browser.runtime.getURL("data/a.png#%22%20%5C"), + }, + 2: { + browserActionImageURL: browser.runtime.getURL( + "data/a.png#%22%20%5C" + ), + pageActionImageURL: browser.runtime.getURL("data/a.png#%22%20%5C"), + }, + }, + }, + + // Only ImageData objects. + { + details: { imageData: imageData.red.imageData }, + resolutions: { + 1: { + browserActionImageURL: imageData.red.url, + pageActionImageURL: imageData.red.url, + }, + 2: { + browserActionImageURL: imageData.red.url, + pageActionImageURL: imageData.red.url, + }, + }, + }, + { + details: { imageData: { 19: imageData.red.imageData } }, + resolutions: { + 1: { + browserActionImageURL: imageData.red.url, + pageActionImageURL: imageData.red.url, + }, + 2: { + browserActionImageURL: imageData.red.url, + pageActionImageURL: imageData.red.url, + }, + }, + }, + { + details: { imageData: { 38: imageData.red.imageData } }, + resolutions: { + 1: { + browserActionImageURL: imageData.red.url, + pageActionImageURL: imageData.red.url, + }, + 2: { + browserActionImageURL: imageData.red.url, + pageActionImageURL: imageData.red.url, + }, + }, + }, + { + details: { + imageData: { + 19: imageData.red.imageData, + 38: imageData.green.imageData, + }, + }, + resolutions: { + 1: { + browserActionImageURL: imageData.red.url, + pageActionImageURL: imageData.red.url, + }, + 2: { + browserActionImageURL: imageData.green.url, + pageActionImageURL: imageData.green.url, + }, + }, + }, + + // Mixed path and imageData objects. + // + // The behavior is currently undefined if both |path| and + // |imageData| specify icons of the same size. + { + details: { + path: { 19: "a.png" }, + imageData: { 38: imageData.red.imageData }, + }, + resolutions: { + 1: { + browserActionImageURL: browser.runtime.getURL("data/a.png"), + pageActionImageURL: browser.runtime.getURL("data/a.png"), + }, + 2: { + browserActionImageURL: imageData.red.url, + pageActionImageURL: imageData.red.url, + }, + }, + }, + { + details: { + path: { 38: "a.png" }, + imageData: { 19: imageData.red.imageData }, + }, + resolutions: { + 1: { + browserActionImageURL: imageData.red.url, + pageActionImageURL: imageData.red.url, + }, + 2: { + browserActionImageURL: browser.runtime.getURL("data/a.png"), + pageActionImageURL: browser.runtime.getURL("data/a.png"), + }, + }, + }, + + // A path or ImageData object by itself is treated as a 19px icon. + { + details: { + path: "a.png", + imageData: { 38: imageData.red.imageData }, + }, + resolutions: { + 1: { + browserActionImageURL: browser.runtime.getURL("data/a.png"), + pageActionImageURL: browser.runtime.getURL("data/a.png"), + }, + 2: { + browserActionImageURL: imageData.red.url, + pageActionImageURL: imageData.red.url, + }, + }, + }, + { + details: { + path: { 38: "a.png" }, + imageData: imageData.red.imageData, + }, + resolutions: { + 1: { + browserActionImageURL: imageData.red.url, + pageActionImageURL: imageData.red.url, + }, + 2: { + browserActionImageURL: browser.runtime.getURL("data/a.png"), + pageActionImageURL: browser.runtime.getURL("data/a.png"), + }, + }, + }, + + // Various resolutions + { + details: { path: { 18: "a.png", 36: "a-x2.png" } }, + resolutions: { + 1: { + browserActionImageURL: browser.runtime.getURL("data/a.png"), + pageActionImageURL: browser.runtime.getURL("data/a.png"), + }, + 2: { + browserActionImageURL: browser.runtime.getURL("data/a-x2.png"), + pageActionImageURL: browser.runtime.getURL("data/a-x2.png"), + }, + }, + }, + { + details: { path: { 16: "a.png", 30: "a-x2.png" } }, + resolutions: { + 1: { + browserActionImageURL: browser.runtime.getURL("data/a.png"), + pageActionImageURL: browser.runtime.getURL("data/a.png"), + }, + 2: { + browserActionImageURL: browser.runtime.getURL("data/a-x2.png"), + pageActionImageURL: browser.runtime.getURL("data/a-x2.png"), + }, + }, + }, + { + details: { path: { 16: "16.png", 100: "100.png" } }, + resolutions: { + 1: { + browserActionImageURL: browser.runtime.getURL("data/16.png"), + pageActionImageURL: browser.runtime.getURL("data/16.png"), + }, + 2: { + browserActionImageURL: browser.runtime.getURL("data/100.png"), + pageActionImageURL: browser.runtime.getURL("data/100.png"), + }, + }, + }, + { + details: { path: { 2: "2.png" } }, + resolutions: { + 1: { + browserActionImageURL: browser.runtime.getURL("data/2.png"), + pageActionImageURL: browser.runtime.getURL("data/2.png"), + }, + 2: { + browserActionImageURL: browser.runtime.getURL("data/2.png"), + pageActionImageURL: browser.runtime.getURL("data/2.png"), + }, + }, + }, + { + details: { + path: { + 16: "16.svg", + 18: "18.svg", + }, + }, + resolutions: { + 1: { + browserActionImageURL: browser.runtime.getURL("data/16.svg"), + pageActionImageURL: browser.runtime.getURL("data/16.svg"), + }, + 2: { + browserActionImageURL: browser.runtime.getURL("data/18.svg"), + pageActionImageURL: browser.runtime.getURL("data/18.svg"), + }, + }, + }, + { + details: { + path: { + 6: "6.png", + 18: "18.png", + 36: "36.png", + 48: "48.png", + 128: "128.png", + }, + }, + resolutions: { + 1: { + browserActionImageURL: browser.runtime.getURL("data/18.png"), + pageActionImageURL: browser.runtime.getURL("data/18.png"), + }, + 2: { + browserActionImageURL: browser.runtime.getURL("data/36.png"), + pageActionImageURL: browser.runtime.getURL("data/36.png"), + }, + }, + menuResolutions: { + 1: browser.runtime.getURL("data/36.png"), + 2: browser.runtime.getURL("data/128.png"), + }, + }, + { + details: { + path: { + 16: "16.png", + 18: "18.png", + 32: "32.png", + 48: "48.png", + 64: "64.png", + 128: "128.png", + }, + }, + resolutions: { + 1: { + browserActionImageURL: browser.runtime.getURL("data/16.png"), + pageActionImageURL: browser.runtime.getURL("data/16.png"), + }, + 2: { + browserActionImageURL: browser.runtime.getURL("data/32.png"), + pageActionImageURL: browser.runtime.getURL("data/32.png"), + }, + }, + menuResolutions: { + 1: browser.runtime.getURL("data/32.png"), + 2: browser.runtime.getURL("data/64.png"), + }, + }, + { + details: { + path: { + 18: "18.png", + 32: "32.png", + 48: "48.png", + 128: "128.png", + }, + }, + resolutions: { + 1: { + browserActionImageURL: browser.runtime.getURL("data/32.png"), + pageActionImageURL: browser.runtime.getURL("data/32.png"), + }, + 2: { + browserActionImageURL: browser.runtime.getURL("data/32.png"), + pageActionImageURL: browser.runtime.getURL("data/32.png"), + }, + }, + }, + ]; + /* eslint-enable indent, indent-legacy */ + + // Allow serializing ImageData objects for logging. + ImageData.prototype.toJSON = () => "<ImageData>"; + + let tabId; + + browser.test.onMessage.addListener((msg, test) => { + if (msg != "setIcon") { + browser.test.fail("expecting 'setIcon' message"); + } + + let details = iconDetails[test.index]; + + let detailString = JSON.stringify(details); + browser.test.log( + `Setting browerAction/pageAction to ${detailString} expecting URLs ${JSON.stringify( + details.resolutions + )}` + ); + + Promise.all([ + browser.browserAction.setIcon( + Object.assign({ tabId }, details.details) + ), + browser.pageAction.setIcon(Object.assign({ tabId }, details.details)), + ]).then(() => { + browser.test.sendMessage("iconSet"); + }); + }); + + // Generate a list of tests and resolutions to send back to the test + // context. + // + // This process is a bit convoluted, because the outer test context needs + // to handle checking the button nodes and changing the screen resolution, + // but it can't pass us icon definitions with ImageData objects. This + // shouldn't be a problem, since structured clones should handle ImageData + // objects without issue. Unfortunately, |cloneInto| implements a slightly + // different algorithm than we use in web APIs, and does not handle them + // correctly. + let tests = []; + for (let [idx, icon] of iconDetails.entries()) { + tests.push({ + index: idx, + menuResolutions: icon.menuResolutions, + resolutions: icon.resolutions, + }); + } + + // Sort by resolution, so we don't needlessly switch back and forth + // between each test. + tests.sort(test => test.resolution); + + browser.tabs.query({ active: true, currentWindow: true }, tabs => { + tabId = tabs[0].id; + browser.pageAction.show(tabId).then(() => { + browser.test.sendMessage("ready", tests); + }); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_action: { + default_area: "navbar", + }, + page_action: {}, + background: { + page: "data/background.html", + }, + }, + + files: { + "data/background.html": `<script src="background.js"></script>`, + "data/background.js": background, + + "data/16.svg": imageBuffer, + "data/18.svg": imageBuffer, + + "data/16.png": imageBuffer, + "data/18.png": imageBuffer, + "data/32.png": imageBuffer, + "data/36.png": imageBuffer, + "data/48.png": imageBuffer, + "data/64.png": imageBuffer, + "data/128.png": imageBuffer, + + "a.png": imageBuffer, + "data/2.png": imageBuffer, + "data/100.png": imageBuffer, + "data/a.png": imageBuffer, + "data/a-x2.png": imageBuffer, + }, + }); + + await extension.startup(); + + let pageActionId = BrowserPageActions.urlbarButtonNodeIDForActionID( + makeWidgetId(extension.id) + ); + let browserActionWidget = getBrowserActionWidget(extension); + + let tests = await extension.awaitMessage("ready"); + await promiseAnimationFrame(); + + // The initial icon should be the default icon since no icon is in the manifest. + const DEFAULT_ICON = "chrome://mozapps/skin/extensions/extensionGeneric.svg"; + let browserActionButton = + browserActionWidget.forWindow(window).node.firstElementChild; + let pageActionImage = document.getElementById(pageActionId); + is( + getListStyleImage(browserActionButton), + DEFAULT_ICON, + `browser action has the correct default image` + ); + is( + getListStyleImage(pageActionImage), + DEFAULT_ICON, + `page action has the correct default image` + ); + + for (let test of tests) { + extension.sendMessage("setIcon", test); + await extension.awaitMessage("iconSet"); + + await promiseAnimationFrame(); + + testHiDpiImage( + browserActionButton, + test.resolutions[1], + test.resolutions[2], + "browserActionImageURL" + ); + testHiDpiImage( + pageActionImage, + test.resolutions[1], + test.resolutions[2], + "pageActionImageURL" + ); + + if (!test.menuResolutions) { + continue; + } + } + + await extension.unload(); +}); + +// NOTE: The current goal of this test is ensuring that Bug 1397196 has been fixed, +// and so this test extension manifest has a browser action which specify +// a theme based icon and a pageAction, both the pageAction and the browserAction +// have a common default_icon. +// +// Once Bug 1398156 will be fixed, this test should be converted into testing that +// the browserAction and pageAction themed icons (as well as any other cached icon, +// e.g. the sidebar and devtools panel icons) can be specified in the same extension +// and do not conflict with each other. +// +// This test currently fails without the related fix, but only if the browserAction +// API has been already loaded before the pageAction, otherwise the icons will be cached +// in the opposite order and the test is not able to reproduce the issue anymore. +add_task(async function testPageActionIconLoadingOnBrowserActionThemedIcon() { + async function background() { + const tabs = await browser.tabs.query({ active: true }); + await browser.pageAction.show(tabs[0].id); + + browser.test.sendMessage("ready"); + } + + const extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + name: "Foo Extension", + + browser_action: { + default_icon: "common_cached_icon.png", + default_popup: "default_popup.html", + default_title: "BrowserAction title", + default_area: "navbar", + theme_icons: [ + { + dark: "1.png", + light: "2.png", + size: 16, + }, + ], + }, + + page_action: { + default_icon: "common_cached_icon.png", + default_popup: "default_popup.html", + default_title: "PageAction title", + }, + + permissions: ["tabs"], + }, + + files: { + "common_cached_icon.png": imageBuffer, + "1.png": imageBuffer, + "2.png": imageBuffer, + "default_popup.html": "<!DOCTYPE html><html><body>popup</body></html>", + }, + }); + + await extension.startup(); + + await extension.awaitMessage("ready"); + + await promiseAnimationFrame(); + + let pageActionId = BrowserPageActions.urlbarButtonNodeIDForActionID( + makeWidgetId(extension.id) + ); + let pageActionImage = document.getElementById(pageActionId); + + const iconURL = new URL(getListStyleImage(pageActionImage)); + + is( + iconURL.pathname, + "/common_cached_icon.png", + "Got the expected pageAction icon url" + ); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_browserAction_pageAction_icon_permissions.js b/browser/components/extensions/test/browser/browser_ext_browserAction_pageAction_icon_permissions.js new file mode 100644 index 0000000000..5a94a0dde1 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_browserAction_pageAction_icon_permissions.js @@ -0,0 +1,239 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +PromiseTestUtils.allowMatchingRejectionsGlobally(/packaging errors/); + +// Test that an error is thrown when providing invalid icon sizes +add_task(async function testInvalidIconSizes() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_action: { + default_area: "navbar", + }, + page_action: {}, + }, + + background: function () { + browser.tabs.query({ active: true, currentWindow: true }, tabs => { + let tabId = tabs[0].id; + + let promises = []; + for (let api of ["pageAction", "browserAction"]) { + // helper function to run setIcon and check if it fails + let assertSetIconThrows = function (detail, error, message) { + detail.tabId = tabId; + browser.test.assertThrows( + () => browser[api].setIcon(detail), + /an unexpected .* property/, + "setIcon with invalid icon size" + ); + }; + + let imageData = new ImageData(1, 1); + + // test invalid icon size inputs + for (let type of ["path", "imageData"]) { + let img = type == "imageData" ? imageData : "test.png"; + + assertSetIconThrows({ [type]: { abcdef: img } }); + assertSetIconThrows({ [type]: { "48px": img } }); + assertSetIconThrows({ [type]: { 20.5: img } }); + assertSetIconThrows({ [type]: { "5.0": img } }); + assertSetIconThrows({ [type]: { "-300": img } }); + assertSetIconThrows({ [type]: { abc: img, 5: img } }); + } + + assertSetIconThrows({ + imageData: { abcdef: imageData }, + path: { 5: "test.png" }, + }); + assertSetIconThrows({ + path: { abcdef: "test.png" }, + imageData: { 5: imageData }, + }); + } + + Promise.all(promises).then(() => { + browser.test.notifyPass("setIcon with invalid icon size"); + }); + }); + }, + }); + + await Promise.all([ + extension.startup(), + extension.awaitFinish("setIcon with invalid icon size"), + ]); + + await extension.unload(); +}); + +// Test that default icon details in the manifest.json file are handled +// correctly. +add_task(async function testDefaultDetails() { + // TODO: Test localized variants. + let icons = [ + "foo/bar.png", + "/foo/bar.png", + { 38: "foo/bar.png" }, + { 70: "foo/bar.png" }, + ]; + + if (window.devicePixelRatio > 1) { + icons.push({ 38: "baz/quux.png", 70: "foo/bar.png" }); + } else { + icons.push({ 38: "foo/bar.png", 70: "baz/quux@2x.png" }); + } + + let expectedURL = new RegExp( + String.raw`^moz-extension://[^/]+/foo/bar\.png$` + ); + + for (let icon of icons) { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_action: { default_icon: icon, default_area: "navbar" }, + page_action: { default_icon: icon }, + }, + + background: function () { + browser.tabs.query({ active: true, currentWindow: true }, tabs => { + let tabId = tabs[0].id; + + browser.pageAction.show(tabId).then(() => { + browser.test.sendMessage("ready"); + }); + }); + }, + + files: { + "foo/bar.png": imageBuffer, + "baz/quux.png": imageBuffer, + "baz/quux@2x.png": imageBuffer, + }, + }); + + await Promise.all([extension.startup(), extension.awaitMessage("ready")]); + + let browserActionId = makeWidgetId(extension.id) + "-browser-action"; + let pageActionId = BrowserPageActions.urlbarButtonNodeIDForActionID( + makeWidgetId(extension.id) + ); + + await promiseAnimationFrame(); + + let browserActionButton = + document.getElementById(browserActionId).firstElementChild; + let image = getListStyleImage(browserActionButton); + + ok( + expectedURL.test(image), + `browser action image ${image} matches ${expectedURL}` + ); + + let pageActionImage = document.getElementById(pageActionId); + image = getListStyleImage(pageActionImage); + + ok( + expectedURL.test(image), + `page action image ${image} matches ${expectedURL}` + ); + + await extension.unload(); + + let node = document.getElementById(pageActionId); + is(node, null, "pageAction image removed from document"); + } +}); + +// Check that attempts to load a privileged URL as an icon image fail. +add_task(async function testSecureURLsDenied() { + // Test URLs passed to setIcon. + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_action: { default_area: "navbar" }, + page_action: {}, + }, + + background: function () { + browser.tabs.query({ active: true, currentWindow: true }, tabs => { + let tabId = tabs[0].id; + + let urls = [ + "chrome://browser/content/browser.xhtml", + "javascript:true", + ]; + + let promises = []; + for (let url of urls) { + for (let api of ["pageAction", "browserAction"]) { + promises.push( + browser.test.assertRejects( + browser[api].setIcon({ tabId, path: url }), + /Illegal URL/, + `Load of '${url}' should fail.` + ) + ); + } + } + + Promise.all(promises).then(() => { + browser.test.notifyPass("setIcon security tests"); + }); + }); + }, + }); + + await extension.startup(); + + await extension.awaitFinish("setIcon security tests"); + await extension.unload(); +}); + +add_task(async function testSecureManifestURLsDenied() { + // Test URLs included in the manifest. + + let urls = ["chrome://browser/content/browser.xhtml", "javascript:true"]; + + let apis = ["browser_action", "page_action"]; + + for (let url of urls) { + for (let api of apis) { + info(`TEST ${api} icon url: ${url}`); + + let matchURLForbidden = url => ({ + message: new RegExp(`match the format "strictRelativeUrl"`), + }); + + let messages = [matchURLForbidden(url)]; + + let waitForConsole = new Promise(resolve => { + // Not necessary in browser-chrome tests, but monitorConsole gripes + // if we don't call it. + SimpleTest.waitForExplicitFinish(); + + SimpleTest.monitorConsole(resolve, messages); + }); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + [api]: { + default_icon: url, + default_area: "navbar", + }, + }, + }); + + await Assert.rejects( + extension.startup(), + /startup failed/, + "Manifest rejected" + ); + + SimpleTest.endMonitorConsole(); + await waitForConsole; + } + } +}); diff --git a/browser/components/extensions/test/browser/browser_ext_browserAction_popup.js b/browser/components/extensions/test/browser/browser_ext_browserAction_popup.js new file mode 100644 index 0000000000..136f30eb41 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_browserAction_popup.js @@ -0,0 +1,370 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +function getBrowserAction(extension) { + const { + global: { browserActionFor }, + } = Management; + + let ext = WebExtensionPolicy.getByID(extension.id)?.extension; + return browserActionFor(ext); +} + +async function assertViewCount(extension, count, waitForPromise) { + let ext = WebExtensionPolicy.getByID(extension.id).extension; + + if (waitForPromise) { + await waitForPromise; + } + + is( + ext.views.size, + count, + "Should have the expected number of extension views" + ); +} + +function promiseExtensionPageClosed(extension, viewType) { + return new Promise(resolve => { + const policy = WebExtensionPolicy.getByID(extension.id); + + const listener = (evtType, context) => { + if (context.viewType === viewType) { + policy.extension.off("extension-proxy-context-load", listener); + + context.callOnClose({ + close: resolve, + }); + } + }; + policy.extension.on("extension-proxy-context-load", listener); + }); +} + +let scriptPage = url => + `<html><head><meta charset="utf-8"><script src="${url}"></script></head><body>${url}</body></html>`; + +async function testInArea(area) { + let extension = ExtensionTestUtils.loadExtension({ + background() { + let middleClickShowPopup = false; + browser.browserAction.onClicked.addListener((tabs, info) => { + browser.test.sendMessage("browserAction-onClicked"); + if (info.button === 1 && middleClickShowPopup) { + browser.browserAction.openPopup(); + } + }); + + browser.test.onMessage.addListener(async msg => { + if (msg.type === "setBrowserActionPopup") { + let opts = { popup: msg.popup }; + if (msg.onCurrentWindowId) { + let { id } = await browser.windows.getCurrent(); + opts = { ...opts, windowId: id }; + } else if (msg.onActiveTabId) { + let [{ id }] = await browser.tabs.query({ + active: true, + currentWindow: true, + }); + opts = { ...opts, tabId: id }; + } + await browser.browserAction.setPopup(opts); + browser.test.sendMessage("setBrowserActionPopup:done"); + } else if (msg.type === "setMiddleClickShowPopup") { + middleClickShowPopup = msg.show; + browser.test.sendMessage("setMiddleClickShowPopup:done"); + } + }); + + browser.test.sendMessage("background-page-ready"); + }, + manifest: { + browser_action: { + default_popup: "popup-a.html", + browser_style: true, + }, + }, + + files: { + "popup-a.html": scriptPage("popup-a.js"), + "popup-a.js": function () { + browser.test.onMessage.addListener(msg => { + if (msg == "close-popup-using-window.close") { + window.close(); + } + }); + + window.onload = () => { + let color = window.getComputedStyle(document.body).color; + browser.test.assertEq("rgb(34, 36, 38)", color); + browser.test.sendMessage("from-popup", "popup-a"); + }; + }, + + "data/popup-b.html": scriptPage("popup-b.js"), + "data/popup-b.js": function () { + window.onload = () => { + browser.test.sendMessage("from-popup", "popup-b"); + }; + }, + + "data/popup-c.html": scriptPage("popup-c.js"), + "data/popup-c.js": function () { + // Close the popup before the document is fully-loaded to make sure that + // we handle this case sanely. + browser.test.sendMessage("from-popup", "popup-c"); + window.close(); + }, + }, + }); + + await Promise.all([ + extension.startup(), + extension.awaitMessage("background-page-ready"), + ]); + + let widget = getBrowserActionWidget(extension); + + // Move the browserAction widget to the area targeted by this test. + CustomizableUI.addWidgetToArea(widget.id, area); + + async function setBrowserActionPopup(opts) { + extension.sendMessage({ type: "setBrowserActionPopup", ...opts }); + await extension.awaitMessage("setBrowserActionPopup:done"); + } + + async function setShowPopupOnMiddleClick(show) { + extension.sendMessage({ type: "setMiddleClickShowPopup", show }); + await extension.awaitMessage("setMiddleClickShowPopup:done"); + } + + async function runTest({ + actionType, + waitForPopupLoaded, + expectPopup, + expectOnClicked, + closePopup, + }) { + const oncePopupPageClosed = promiseExtensionPageClosed(extension, "popup"); + const oncePopupLoaded = waitForPopupLoaded + ? awaitExtensionPanel(extension) + : undefined; + + if (actionType === "click") { + clickBrowserAction(extension); + } else if (actionType === "trigger") { + getBrowserAction(extension).triggerAction(window); + } else if (actionType === "middleClick") { + clickBrowserAction(extension, window, { button: 1 }); + } + + if (expectOnClicked) { + await extension.awaitMessage("browserAction-onClicked"); + } + + if (expectPopup) { + info(`Waiting for popup: ${expectPopup}`); + is( + await extension.awaitMessage("from-popup"), + expectPopup, + "expected popup opened" + ); + } + + await oncePopupLoaded; + + if (closePopup) { + info("Closing popup"); + await closeBrowserAction(extension); + await assertViewCount(extension, 1, oncePopupPageClosed); + } + + return { oncePopupPageClosed }; + } + + // Run the sequence of test cases. + const tests = [ + async () => { + info(`Click browser action, expect popup "a".`); + + await runTest({ + actionType: "click", + expectPopup: "popup-a", + closePopup: true, + }); + }, + async () => { + info(`Click browser action again, expect popup "a".`); + + await runTest({ + actionType: "click", + expectPopup: "popup-a", + waitForPopupLoaded: true, + closePopup: true, + }); + }, + async () => { + info(`Call triggerAction, expect popup "a" again. Leave popup open.`); + + const { oncePopupPageClosed } = await runTest({ + actionType: "trigger", + expectPopup: "popup-a", + waitForPopupLoaded: true, + }); + + await assertViewCount(extension, 2); + + info(`Call triggerAction again. Expect remaining popup closed.`); + getBrowserAction(extension).triggerAction(window); + + await assertViewCount(extension, 1, oncePopupPageClosed); + }, + async () => { + info(`Call triggerAction again. Expect popup "a" again.`); + + await runTest({ + actionType: "trigger", + expectPopup: "popup-a", + closePopup: true, + }); + }, + async () => { + info(`Set popup to "c" and click browser action. Expect popup "c".`); + + await setBrowserActionPopup({ popup: "data/popup-c.html" }); + + const { oncePopupPageClosed } = await runTest({ + actionType: "click", + expectPopup: "popup-c", + }); + + await assertViewCount(extension, 1, oncePopupPageClosed); + }, + async () => { + info(`Set popup to "b" and click browser action. Expect popup "b".`); + + await setBrowserActionPopup({ popup: "data/popup-b.html" }); + + await runTest({ + actionType: "click", + expectPopup: "popup-b", + closePopup: true, + }); + }, + async () => { + info(`Click browser action again, expect popup "b".`); + + await runTest({ + actionType: "click", + expectPopup: "popup-b", + closePopup: true, + }); + }, + async () => { + info(`Middle-click browser action, expect an event only.`); + + await setShowPopupOnMiddleClick(false); + + await runTest({ + actionType: "middleClick", + expectOnClicked: true, + }); + }, + async () => { + info( + `Middle-click browser action again, expect a click event then a popup.` + ); + + await setShowPopupOnMiddleClick(true); + + await runTest({ + actionType: "middleClick", + expectOnClicked: true, + expectPopup: "popup-b", + closePopup: true, + }); + }, + async () => { + info(`Clear popup URL. Click browser action. Expect click event.`); + + await setBrowserActionPopup({ popup: "" }); + + await runTest({ + actionType: "click", + expectOnClicked: true, + }); + }, + async () => { + info(`Click browser action again. Expect another click event.`); + + await runTest({ + actionType: "click", + expectOnClicked: true, + }); + }, + async () => { + info(`Call triggerAction. Expect click event.`); + + await runTest({ + actionType: "trigger", + expectOnClicked: true, + }); + }, + async () => { + info( + `Set window-specific popup to "b" and click browser action. Expect popup "b".` + ); + + await setBrowserActionPopup({ + popup: "data/popup-b.html", + onCurrentWindowId: true, + }); + + await runTest({ + actionType: "click", + expectPopup: "popup-b", + closePopup: true, + }); + }, + async () => { + info( + `Set tab-specific popup to "a" and click browser action. Expect popup "a", and leave open.` + ); + + await setBrowserActionPopup({ + popup: "/popup-a.html", + onActiveTabId: true, + }); + + const { oncePopupPageClosed } = await runTest({ + actionType: "click", + expectPopup: "popup-a", + }); + assertViewCount(extension, 2); + + info(`Tell popup "a" to call window.close(). Expect popup closed.`); + extension.sendMessage("close-popup-using-window.close"); + + await assertViewCount(extension, 1, oncePopupPageClosed); + }, + ]; + + for (let test of tests) { + await test(); + } + + // Unload the extension and verify that the browserAction widget is gone. + await extension.unload(); + + let view = document.getElementById(widget.viewId); + is(view, null, "browserAction view removed from document"); +} + +add_task(async function testBrowserActionInToolbar() { + await testInArea(CustomizableUI.AREA_NAVBAR); +}); + +add_task(async function testBrowserActionInPanel() { + await testInArea(getCustomizableUIPanelID()); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_browserAction_popup_port.js b/browser/components/extensions/test/browser/browser_ext_browserAction_popup_port.js new file mode 100644 index 0000000000..415d69738d --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_browserAction_popup_port.js @@ -0,0 +1,56 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +let scriptPage = url => + `<html><head><meta charset="utf-8"><script src="${url}"></script></head><body>${url}</body></html>`; + +// Tests that message ports still function correctly after a browserAction popup +// <browser> has been reparented. +add_task(async function test_browserActionPort() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_action: { + default_popup: "popup.html", + default_area: "navbar", + browser_style: true, + }, + }, + + background() { + new Promise(resolve => { + browser.runtime.onConnect.addListener(port => { + resolve( + Promise.all([ + new Promise(r => port.onMessage.addListener(r)), + new Promise(r => port.onDisconnect.addListener(r)), + ]) + ); + }); + }).then(([msg]) => { + browser.test.assertEq("Hallo.", msg, "Got expected message"); + browser.test.notifyPass("browserAction-popup-port"); + }); + }, + + files: { + "popup.html": scriptPage("popup.js"), + "popup.js"() { + let port = browser.runtime.connect(); + window.onload = () => { + setTimeout(() => { + port.postMessage("Hallo."); + window.close(); + }, 0); + }; + }, + }, + }); + + await extension.startup(); + + await clickBrowserAction(extension); + + await extension.awaitFinish("browserAction-popup-port"); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_browserAction_popup_preload.js b/browser/components/extensions/test/browser/browser_ext_browserAction_popup_preload.js new file mode 100644 index 0000000000..4c47c1b13b --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_browserAction_popup_preload.js @@ -0,0 +1,472 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ +"use strict"; + +let scriptPage = url => + `<html><head><meta charset="utf-8"><script src="${url}"></script></head><body>${url}</body></html>`; + +add_task(async function testBrowserActionClickCanceled() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.com/" + ); + + // Make sure the mouse isn't hovering over the browserAction widget. + EventUtils.synthesizeMouseAtCenter( + gURLBar.textbox, + { type: "mousemove" }, + window + ); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_action: { + default_popup: "popup.html", + default_area: "navbar", + browser_style: true, + }, + permissions: ["activeTab"], + }, + + files: { + "popup.html": `<!DOCTYPE html><html><head><meta charset="utf-8"></head></html>`, + }, + }); + + await extension.startup(); + + const { + Management: { + global: { browserActionFor }, + }, + } = ChromeUtils.importESModule("resource://gre/modules/Extension.sys.mjs"); + + let ext = WebExtensionPolicy.getByID(extension.id)?.extension; + let browserAction = browserActionFor(ext); + + let widget = getBrowserActionWidget(extension).forWindow(window); + + // Test canceled click. + EventUtils.synthesizeMouseAtCenter( + widget.node, + { type: "mousemove" }, + window + ); + EventUtils.synthesizeMouseAtCenter( + widget.node, + { type: "mousedown", button: 0 }, + window + ); + + isnot(browserAction.pendingPopup, null, "Have pending popup"); + is( + browserAction.pendingPopup.window, + window, + "Have pending popup for the correct window" + ); + + is(browserAction.pendingPopupTimeout, null, "Have no pending popup timeout"); + + is(browserAction.action.activeTabForPreload, tab, "Tab to revoke was saved"); + is( + browserAction.tabManager.hasActiveTabPermission(tab), + true, + "Active tab was granted permission" + ); + + // We intentionally turn off this a11y check, because the following click + // is send on the <body> to dismiss the pending popup using an alternative way + // of the popup dismissal, where the other way like `Esc` key is available, + // therefore this test can be ignored. + AccessibilityUtils.setEnv({ + mustHaveAccessibleRule: false, + }); + EventUtils.synthesizeMouseAtCenter( + document.documentElement, + { type: "mousemove" }, + window + ); + EventUtils.synthesizeMouseAtCenter( + document.documentElement, + { type: "mouseup", button: 0 }, + window + ); + AccessibilityUtils.resetEnv(); + + is(browserAction.pendingPopup, null, "Pending popup was cleared"); + is(browserAction.pendingPopupTimeout, null, "Have no pending popup timeout"); + + is( + browserAction.action.activeTabForPreload, + null, + "Tab to revoke was removed" + ); + is( + browserAction.tabManager.hasActiveTabPermission(tab), + false, + "Permission was revoked from tab" + ); + + // Test completed click. + EventUtils.synthesizeMouseAtCenter( + widget.node, + { type: "mousemove" }, + window + ); + EventUtils.synthesizeMouseAtCenter( + widget.node, + { type: "mousedown", button: 0 }, + window + ); + + isnot(browserAction.pendingPopup, null, "Have pending popup"); + is( + browserAction.pendingPopup.window, + window, + "Have pending popup for the correct window" + ); + + is(browserAction.pendingPopupTimeout, null, "Have no pending popup timeout"); + + // We need to do these tests during the mouseup event cycle, since the click + // and command events will be dispatched immediately after mouseup, and void + // the results. + let mouseUpPromise = BrowserTestUtils.waitForEvent( + widget.node, + "mouseup", + false, + event => { + isnot(browserAction.pendingPopup, null, "Pending popup was not cleared"); + isnot( + browserAction.pendingPopupTimeout, + null, + "Have a pending popup timeout" + ); + return true; + } + ); + + EventUtils.synthesizeMouseAtCenter( + widget.node, + { type: "mouseup", button: 0 }, + window + ); + + await mouseUpPromise; + + is(browserAction.pendingPopup, null, "Pending popup was cleared"); + is( + browserAction.pendingPopupTimeout, + null, + "Pending popup timeout was cleared" + ); + + await promisePopupShown(getBrowserActionPopup(extension)); + await closeBrowserAction(extension); + + await extension.unload(); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function testBrowserActionDisabled() { + // Make sure the mouse isn't hovering over the browserAction widget. + EventUtils.synthesizeMouseAtCenter( + gURLBar.textbox, + { type: "mousemove" }, + window + ); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_action: { + default_popup: "popup.html", + default_area: "navbar", + browser_style: true, + }, + }, + + background: async function () { + await browser.browserAction.disable(); + browser.test.sendMessage("browserAction-disabled"); + }, + + files: { + "popup.html": `<!DOCTYPE html><html><head><meta charset="utf-8"><script src="popup.js"></script></head></html>`, + "popup.js"() { + browser.test.fail("Should not get here"); + }, + }, + }); + + await extension.startup(); + + await extension.awaitMessage("browserAction-disabled"); + await promiseAnimationFrame(); + + const { + Management: { + global: { browserActionFor }, + }, + } = ChromeUtils.importESModule("resource://gre/modules/Extension.sys.mjs"); + + let ext = WebExtensionPolicy.getByID(extension.id)?.extension; + let browserAction = browserActionFor(ext); + + let widget = getBrowserActionWidget(extension).forWindow(window); + let button = widget.node.firstElementChild; + + is(button.getAttribute("disabled"), "true", "Button is disabled"); + is(browserAction.pendingPopup, null, "Have no pending popup prior to click"); + + // Test canceled click. + EventUtils.synthesizeMouseAtCenter( + widget.node, + { type: "mousemove" }, + window + ); + EventUtils.synthesizeMouseAtCenter( + widget.node, + { type: "mousedown", button: 0 }, + window + ); + + is(browserAction.pendingPopup, null, "Have no pending popup"); + is(browserAction.pendingPopupTimeout, null, "Have no pending popup timeout"); + + // We intentionally turn off this a11y check, because the following click + // is send on the <body> to dismiss the pending popup using an alternative way + // of the popup dismissal, where the other way like `Esc` key is available, + // therefore this test can be ignored. + AccessibilityUtils.setEnv({ + mustHaveAccessibleRule: false, + }); + EventUtils.synthesizeMouseAtCenter( + document.documentElement, + { type: "mousemove" }, + window + ); + EventUtils.synthesizeMouseAtCenter( + document.documentElement, + { type: "mouseup", button: 0 }, + window + ); + AccessibilityUtils.resetEnv(); + + is(browserAction.pendingPopup, null, "Have no pending popup"); + is(browserAction.pendingPopupTimeout, null, "Have no pending popup timeout"); + + // Test completed click. + EventUtils.synthesizeMouseAtCenter( + widget.node, + { type: "mousemove" }, + window + ); + EventUtils.synthesizeMouseAtCenter( + widget.node, + { type: "mousedown", button: 0 }, + window + ); + + is(browserAction.pendingPopup, null, "Have no pending popup"); + is(browserAction.pendingPopupTimeout, null, "Have no pending popup timeout"); + + // We need to do these tests during the mouseup event cycle, since the click + // and command events will be dispatched immediately after mouseup, and void + // the results. + let mouseUpPromise = BrowserTestUtils.waitForEvent( + widget.node, + "mouseup", + false, + event => { + is(browserAction.pendingPopup, null, "Have no pending popup"); + is( + browserAction.pendingPopupTimeout, + null, + "Have no pending popup timeout" + ); + return true; + } + ); + + // We intentionally turn off a11y_checks, because the following click + // is targeting a disabled control to confirm the click event won't come through. + // It is not meant to be interactive and is not expected to be accessible: + AccessibilityUtils.setEnv({ + mustBeEnabled: false, + }); + EventUtils.synthesizeMouseAtCenter( + widget.node, + { type: "mouseup", button: 0 }, + window + ); + AccessibilityUtils.resetEnv(); + + await mouseUpPromise; + + is(browserAction.pendingPopup, null, "Have no pending popup"); + is(browserAction.pendingPopupTimeout, null, "Have no pending popup timeout"); + + // Give the popup a chance to load and trigger a failure, if it was + // erroneously opened. + await new Promise(resolve => setTimeout(resolve, 250)); + + await extension.unload(); +}); + +add_task(async function testBrowserActionTabPopulation() { + // Note: This test relates to https://bugzilla.mozilla.org/show_bug.cgi?id=1310019 + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_action: { + default_popup: "popup.html", + default_area: "navbar", + browser_style: true, + }, + permissions: ["activeTab"], + }, + + files: { + "popup.html": scriptPage("popup.js"), + "popup.js": function () { + browser.tabs.query({ active: true, currentWindow: true }).then(tabs => { + browser.test.assertEq( + "mochitest index /", + tabs[0].title, + "Tab has the expected title on first click" + ); + browser.test.sendMessage("tabTitle"); + }); + }, + }, + }); + + let win = await BrowserTestUtils.openNewBrowserWindow(); + BrowserTestUtils.startLoadingURIString( + win.gBrowser.selectedBrowser, + "http://example.com/" + ); + await BrowserTestUtils.browserLoaded(win.gBrowser.selectedBrowser); + + // Make sure the mouse isn't hovering over the browserAction widget. + EventUtils.synthesizeMouseAtCenter( + win.gURLBar.textbox, + { type: "mousemove" }, + win + ); + + await extension.startup(); + + let widget = getBrowserActionWidget(extension).forWindow(win); + EventUtils.synthesizeMouseAtCenter(widget.node, { type: "mousemove" }, win); + EventUtils.synthesizeMouseAtCenter( + widget.node, + { type: "mousedown", button: 0 }, + win + ); + + await new Promise(resolve => setTimeout(resolve, 100)); + + EventUtils.synthesizeMouseAtCenter( + widget.node, + { type: "mouseup", button: 0 }, + win + ); + + await extension.awaitMessage("tabTitle"); + + await extension.unload(); + await BrowserTestUtils.closeWindow(win); +}); + +add_task(async function testClosePopupDuringPreload() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_action: { + default_popup: "popup.html", + default_area: "navbar", + browser_style: true, + }, + }, + + files: { + "popup.html": scriptPage("popup.js"), + "popup.js": function () { + browser.test.sendMessage("popup_loaded"); + window.close(); + }, + }, + }); + + // Make sure the mouse isn't hovering over the browserAction widget. + EventUtils.synthesizeMouseAtCenter( + gURLBar.textbox, + { type: "mousemove" }, + window + ); + + await extension.startup(); + + const { + Management: { + global: { browserActionFor }, + }, + } = ChromeUtils.importESModule("resource://gre/modules/Extension.sys.mjs"); + + let ext = WebExtensionPolicy.getByID(extension.id)?.extension; + let browserAction = browserActionFor(ext); + + let widget = getBrowserActionWidget(extension).forWindow(window); + + EventUtils.synthesizeMouseAtCenter( + widget.node, + { type: "mousemove" }, + window + ); + EventUtils.synthesizeMouseAtCenter( + widget.node, + { type: "mousedown", button: 0 }, + window + ); + + isnot(browserAction.pendingPopup, null, "Have pending popup"); + is( + browserAction.pendingPopup.window, + window, + "Have pending popup for the correct window" + ); + + await extension.awaitMessage("popup_loaded"); + try { + await browserAction.pendingPopup.browserLoaded; + } catch (e) { + is( + e.message, + "Popup destroyed", + "Popup content should have been destroyed" + ); + } + + let promiseViewShowing = BrowserTestUtils.waitForEvent( + document, + "ViewShowing", + false, + ev => ev.target.id === browserAction.viewId + ); + EventUtils.synthesizeMouseAtCenter( + widget.node, + { type: "mouseup", button: 0 }, + window + ); + + // The popup panel may become visible after the ViewShowing event. Wait a bit + // to ensure that the popup is not shown when window.close() was used. + await promiseViewShowing; + await new Promise(resolve => setTimeout(resolve, 50)); + + let panel = getBrowserActionPopup(extension); + is(panel, null, "Popup panel should have been closed"); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_browserAction_popup_preload_smoketest.js b/browser/components/extensions/test/browser/browser_ext_browserAction_popup_preload_smoketest.js new file mode 100644 index 0000000000..98e66e6c7a --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_browserAction_popup_preload_smoketest.js @@ -0,0 +1,194 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ +"use strict"; + +// This test does create and cancel the preloaded popup +// multiple times and in some cases it takes longer than +// the default timeouts allows. +requestLongerTimeout(4); + +const { AddonTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/AddonTestUtils.sys.mjs" +); + +async function installTestAddon(addonId, unpacked = false) { + let xpi = AddonTestUtils.createTempWebExtensionFile({ + manifest: { + browser_specific_settings: { gecko: { id: addonId } }, + browser_action: { + default_popup: "popup.html", + default_area: "navbar", + }, + }, + files: { + "popup.html": `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + <link rel="stylesheet" href="popup.css"> + </head> + <body> + </body> + </html> + `, + "popup.css": `@import "imported.css";`, + "imported.css": ` + /* Increasing the imported.css file size to increase the + * chances to trigger the stylesheet preload issue that + * has been fixed by Bug 1735899 consistently and with + * a smaller number of preloaded popup cancelled. + * + * ${new Array(600000).fill("x").join("\n")} + */ + body { width: 300px; height: 300px; background: red; } + `, + }, + }); + + if (unpacked) { + // This temporary directory is going to be removed from the + // cleanup function, but also make it unique as we do for the + // other temporary files (e.g. like getTemporaryFile as defined + // in XPInstall.jsm). + const random = Math.round(Math.random() * 36 ** 3).toString(36); + const tmpDirName = `mochitest_unpacked_addons_${random}`; + let tmpExtPath = FileUtils.getDir("TmpD", [tmpDirName]); + tmpExtPath.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + registerCleanupFunction(() => { + tmpExtPath.remove(true); + }); + + // Unpacking the xpi file into the tempoary directory. + const extDir = await AddonTestUtils.manuallyInstall( + xpi, + tmpExtPath, + null, + /* unpacked */ true + ); + + // Install temporarily as unpacked. + return AddonManager.installTemporaryAddon(extDir); + } + + // Install temporarily as packed. + return AddonManager.installTemporaryAddon(xpi); +} + +async function waitForExtensionAndBrowserAction(addonId) { + const { + Management: { + global: { browserActionFor }, + }, + } = ChromeUtils.importESModule("resource://gre/modules/Extension.sys.mjs"); + + // trigger a number of preloads + let extension; + let browserAction; + await TestUtils.waitForCondition(() => { + extension = WebExtensionPolicy.getByID(addonId)?.extension; + browserAction = extension && browserActionFor(extension); + return browserAction; + }, "got the extension and browserAction"); + + let widget = getBrowserActionWidget(extension).forWindow(window); + + return { extension, browserAction, widget }; +} + +async function testCancelPreloadedPopup({ browserAction, widget }) { + // Trigger the preloaded popup. + EventUtils.synthesizeMouseAtCenter( + widget.node, + { type: "mouseover", button: 0 }, + window + ); + await TestUtils.waitForCondition( + () => browserAction.pendingPopup, + "Wait for the preloaded popup" + ); + // Cancel the preloaded popup. + EventUtils.synthesizeMouseAtCenter( + widget.node, + { type: "mouseout", button: 0 }, + window + ); + EventUtils.synthesizeMouseAtCenter( + window.gURLBar.textbox, + { type: "mouseover" }, + window + ); + await TestUtils.waitForCondition( + () => !browserAction.pendingPopup, + "Wait for the preloaded popup to be cancelled" + ); +} + +async function testPopupLoadCompleted({ extension, browserAction, widget }) { + const promiseViewShowing = BrowserTestUtils.waitForEvent( + document, + "ViewShowing", + false, + ev => ev.target.id === browserAction.viewId + ); + EventUtils.synthesizeMouseAtCenter( + widget.node, + { type: "mousedown", button: 0 }, + window + ); + EventUtils.synthesizeMouseAtCenter( + widget.node, + { type: "mouseup", button: 0 }, + window + ); + + info("Await the browserAction popup to be shown"); + await promiseViewShowing; + + info("Await the browserAction popup to be fully loaded"); + const browser = await awaitExtensionPanel( + extension, + window, + /* awaitLoad */ true + ); + + await TestUtils.waitForCondition(async () => { + const docReadyState = await SpecialPowers.spawn(browser, [], () => { + return this.content.document.readyState; + }); + + return docReadyState === "complete"; + }, "Wait the popup document to get to readyState complete"); + + ok(true, "Popup document was fully loaded"); +} + +// This test is covering a scenario similar to the one fixed in Bug 1735899, +// and possibly some other similar ones that may slip through unnoticed. +add_task(async function testCancelPopupPreloadRaceOnUnpackedAddon() { + const ID = "preloaded-popup@test"; + const addon = await installTestAddon(ID, /* unpacked */ true); + const { extension, browserAction, widget } = + await waitForExtensionAndBrowserAction(ID); + info("Preload popup and cancel it multiple times"); + for (let i = 0; i < 200; i++) { + await testCancelPreloadedPopup({ browserAction, widget }); + } + await testPopupLoadCompleted({ extension, browserAction, widget }); + await addon.uninstall(); +}); + +// This test is covering a scenario similar to the one fixed in Bug 1735899, +// and possibly some other similar ones that may slip through unnoticed. +add_task(async function testCancelPopupPreloadRaceOnPackedAddon() { + const ID = "preloaded-popup@test"; + const addon = await installTestAddon(ID, /* unpacked */ false); + const { extension, browserAction, widget } = + await waitForExtensionAndBrowserAction(ID); + info("Preload popup and cancel it multiple times"); + for (let i = 0; i < 200; i++) { + await testCancelPreloadedPopup({ browserAction, widget }); + } + await testPopupLoadCompleted({ extension, browserAction, widget }); + await addon.uninstall(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_browserAction_popup_resize.js b/browser/components/extensions/test/browser/browser_ext_browserAction_popup_resize.js new file mode 100644 index 0000000000..4a7ec8f2b7 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_browserAction_popup_resize.js @@ -0,0 +1,79 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +loadTestSubscript("head_browserAction.js"); + +add_task(async function testBrowserActionPopupResize() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_action: { + default_popup: "popup.html", + default_area: "navbar", + browser_style: true, + }, + }, + + files: { + "popup.html": + '<!DOCTYPE html><html><head><meta charset="utf-8"></head></html>', + }, + }); + + await extension.startup(); + + let browser = await openBrowserActionPanel(extension, undefined, true); + + async function checkSize(expected) { + let dims = await promiseContentDimensions(browser); + + Assert.lessOrEqual( + Math.abs(dims.window.innerHeight - expected), + 1, + `Panel window should be ${expected}px tall (was ${dims.window.innerHeight})` + ); + is( + dims.body.clientHeight, + dims.body.scrollHeight, + "Panel body should be tall enough to fit its contents" + ); + + // Tolerate if it is 1px too wide, as that may happen with the current resizing method. + Assert.lessOrEqual( + Math.abs(dims.window.innerWidth - expected), + 1, + `Panel window should be ${expected}px wide` + ); + is( + dims.body.clientWidth, + dims.body.scrollWidth, + "Panel body should be wide enough to fit its contents" + ); + } + + function setSize(size) { + content.document.body.style.height = `${size}px`; + content.document.body.style.width = `${size}px`; + } + + let sizes = [200, 400, 300]; + + for (let size of sizes) { + await alterContent(browser, setSize, size); + await checkSize(size); + } + + let popup = getBrowserActionPopup(extension); + await closeBrowserAction(extension); + is(popup.state, "closed", "browserAction popup has been closed"); + + await extension.unload(); +}); + +add_task(async function testBrowserActionMenuResizeStandards() { + await testPopupSize(true); +}); + +add_task(async function testBrowserActionMenuResizeQuirks() { + await testPopupSize(false); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_browserAction_popup_resize_bottom.js b/browser/components/extensions/test/browser/browser_ext_browserAction_popup_resize_bottom.js new file mode 100644 index 0000000000..3370be0053 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_browserAction_popup_resize_bottom.js @@ -0,0 +1,39 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +loadTestSubscript("head_browserAction.js"); + +// Test that we still make reasonable maximum size calculations when the window +// is close enough to the bottom of the screen that the menu panel opens above, +// rather than below, its button. +add_task(async function testBrowserActionMenuResizeBottomArrow() { + const WIDTH = 800; + const HEIGHT = 80; + + let left = screen.availLeft + screen.availWidth - WIDTH; + let top = screen.availTop + screen.availHeight - HEIGHT; + + let win = await BrowserTestUtils.openNewBrowserWindow(); + + win.resizeTo(WIDTH, HEIGHT); + + // Sometimes we run into problems on Linux with resizing being asynchronous + // and window managers not allowing us to move the window so that any part of + // it is off-screen, so we need to try more than once. + for (let i = 0; i < 20; i++) { + win.moveTo(left, top); + + if (win.screenX == left && win.screenY == top) { + break; + } + + await delay(100); + } + + await SimpleTest.promiseFocus(win); + + await testPopupSize(true, win, "bottom"); + + await BrowserTestUtils.closeWindow(win); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_browserAction_simple.js b/browser/components/extensions/test/browser/browser_ext_browserAction_simple.js new file mode 100644 index 0000000000..c9ac05c17f --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_browserAction_simple.js @@ -0,0 +1,105 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +async function testAction(manifest_version) { + const action = manifest_version < 3 ? "browser_action" : "action"; + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version, + [action]: { + default_popup: "popup.html", + default_area: "navbar", + unrecognized_property: "with-a-random-value", + }, + icons: { 32: "icon.png" }, + }, + + files: { + "popup.html": ` + <!DOCTYPE html> + <html><body> + <script src="popup.js"></script> + </body></html> + `, + + "popup.js": function () { + window.onload = () => { + browser.runtime.sendMessage("from-popup"); + }; + }, + "icon.png": imageBuffer, + }, + + background: function () { + browser.runtime.onMessage.addListener(msg => { + browser.test.assertEq(msg, "from-popup", "correct message received"); + browser.test.sendMessage("popup"); + }); + + // Test what api namespace is valid, make sure both are not. + let manifest = browser.runtime.getManifest(); + let { manifest_version } = manifest; + browser.test.assertEq( + manifest_version == 2, + "browserAction" in browser, + "browserAction is available" + ); + browser.test.assertEq( + manifest_version !== 2, + "action" in browser, + "action is available" + ); + }, + }); + + SimpleTest.waitForExplicitFinish(); + let waitForConsole = new Promise(resolve => { + SimpleTest.monitorConsole(resolve, [ + { + message: new RegExp( + `Reading manifest: Warning processing ${action}.unrecognized_property: An unexpected property was found` + ), + }, + ]); + }); + + ExtensionTestUtils.failOnSchemaWarnings(false); + await extension.startup(); + ExtensionTestUtils.failOnSchemaWarnings(true); + + let widgetGroup = getBrowserActionWidget(extension); + ok(widgetGroup.webExtension, "The extension property was set."); + + // Do this a few times to make sure the pop-up is reloaded each time. + for (let i = 0; i < 3; i++) { + clickBrowserAction(extension); + + let widget = widgetGroup.forWindow(window); + let image = getComputedStyle(widget.node.firstElementChild).listStyleImage; + + ok(image.includes("/icon.png"), "The extension's icon is used"); + await extension.awaitMessage("popup"); + + closeBrowserAction(extension); + } + + await extension.unload(); + + SimpleTest.endMonitorConsole(); + await waitForConsole; +} + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["extensions.manifestV3.enabled", true]], + }); +}); + +add_task(async function test_browserAction() { + await testAction(2); +}); + +add_task(async function test_action() { + await testAction(3); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_browserAction_telemetry.js b/browser/components/extensions/test/browser/browser_ext_browserAction_telemetry.js new file mode 100644 index 0000000000..9e55b49c7d --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_browserAction_telemetry.js @@ -0,0 +1,386 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const TIMING_HISTOGRAM = "WEBEXT_BROWSERACTION_POPUP_OPEN_MS"; +const TIMING_HISTOGRAM_KEYED = "WEBEXT_BROWSERACTION_POPUP_OPEN_MS_BY_ADDONID"; +const RESULT_HISTOGRAM = "WEBEXT_BROWSERACTION_POPUP_PRELOAD_RESULT_COUNT"; +const RESULT_HISTOGRAM_KEYED = + "WEBEXT_BROWSERACTION_POPUP_PRELOAD_RESULT_COUNT_BY_ADDONID"; + +const EXTENSION_ID1 = "@test-extension1"; +const EXTENSION_ID2 = "@test-extension2"; + +// Keep this in sync with the order in Histograms.json for +// WEBEXT_BROWSERACTION_POPUP_PRELOAD_RESULT_COUNT +const CATEGORIES = ["popupShown", "clearAfterHover", "clearAfterMousedown"]; +const GLEAN_RESULT_LABELS = [...CATEGORIES, "__other__"]; + +function assertGleanPreloadResultLabelCounter(expectedLabelsValue) { + for (const label of GLEAN_RESULT_LABELS) { + const expectedLabelValue = expectedLabelsValue[label]; + Assert.deepEqual( + Glean.extensionsCounters.browserActionPreloadResult[label].testGetValue(), + expectedLabelValue, + `Expect Glean browserActionPreloadResult metric label ${label} to be ${ + expectedLabelValue > 0 ? expectedLabelValue : "empty" + }` + ); + } +} + +function assertGleanPreloadResultLabelCounterEmpty() { + // All empty labels passed to the other helpers to make it + // assert that all labels are empty. + assertGleanPreloadResultLabelCounter({}); +} + +/** + * Takes a Telemetry histogram snapshot and makes sure + * that the index for that value (as defined by CATEGORIES) + * has a count of 1, and that it's the only value that + * has been incremented. + * + * @param {object} snapshot + * The Telemetry histogram snapshot to examine. + * @param {string} category + * The category in CATEGORIES whose index we expect to have + * been set to 1. + */ +function assertOnlyOneTypeSet(snapshot, category) { + let categoryIndex = CATEGORIES.indexOf(category); + Assert.equal( + snapshot.values[categoryIndex], + 1, + `Should have seen the ${category} count increment.` + ); + // Use Array.prototype.reduce to sum up all of the + // snapshot.count entries + Assert.equal( + Object.values(snapshot.values).reduce((a, b) => a + b, 0), + 1, + "Should only be 1 collected value." + ); +} + +add_task(async function testBrowserActionTelemetryTiming() { + let extensionOptions = { + manifest: { + browser_action: { + default_popup: "popup.html", + default_area: "navbar", + browser_style: true, + }, + }, + + files: { + "popup.html": `<!DOCTYPE html><html><head><meta charset="utf-8"></head><body><div></div></body></html>`, + }, + }; + let extension1 = ExtensionTestUtils.loadExtension({ + ...extensionOptions, + manifest: { + ...extensionOptions.manifest, + browser_specific_settings: { + gecko: { id: EXTENSION_ID1 }, + }, + }, + }); + let extension2 = ExtensionTestUtils.loadExtension({ + ...extensionOptions, + manifest: { + ...extensionOptions.manifest, + browser_specific_settings: { + gecko: { id: EXTENSION_ID2 }, + }, + }, + }); + + let histogram = Services.telemetry.getHistogramById(TIMING_HISTOGRAM); + let histogramKeyed = Services.telemetry.getKeyedHistogramById( + TIMING_HISTOGRAM_KEYED + ); + + histogram.clear(); + histogramKeyed.clear(); + Services.fog.testResetFOG(); + + is( + histogram.snapshot().sum, + 0, + `No data recorded for histogram: ${TIMING_HISTOGRAM}.` + ); + is( + Object.keys(histogramKeyed).length, + 0, + `No data recorded for histogram: ${TIMING_HISTOGRAM_KEYED}.` + ); + Assert.deepEqual( + Glean.extensionsTiming.browserActionPopupOpen.testGetValue(), + undefined, + "No data recorded for glean metric extensionsTiming.browserActionPopupOpen" + ); + + await extension1.startup(); + await extension2.startup(); + + is( + histogram.snapshot().sum, + 0, + `No data recorded for histogram after startup: ${TIMING_HISTOGRAM}.` + ); + is( + Object.keys(histogramKeyed).length, + 0, + `No data recorded for histogram after startup: ${TIMING_HISTOGRAM_KEYED}.` + ); + Assert.deepEqual( + Glean.extensionsTiming.browserActionPopupOpen.testGetValue(), + undefined, + "No data recorded for glean metric extensionsTiming.browserActionPopupOpen" + ); + + clickBrowserAction(extension1); + await awaitExtensionPanel(extension1); + let sumOld = histogram.snapshot().sum; + Assert.greater( + sumOld, + 0, + `Data recorded for first extension for histogram: ${TIMING_HISTOGRAM}.` + ); + + let oldKeyedSnapshot = histogramKeyed.snapshot(); + Assert.deepEqual( + Object.keys(oldKeyedSnapshot), + [EXTENSION_ID1], + `Data recorded for first extension for histogram: ${TIMING_HISTOGRAM_KEYED}.` + ); + Assert.greater( + oldKeyedSnapshot[EXTENSION_ID1].sum, + 0, + `Data recorded for first extension for histogram: ${TIMING_HISTOGRAM_KEYED}.` + ); + + let gleanSumOld = + Glean.extensionsTiming.browserActionPopupOpen.testGetValue()?.sum; + Assert.greater( + gleanSumOld, + 0, + "Data recorded for first extension on glean metric extensionsTiming.browserActionPopupOpen" + ); + + await closeBrowserAction(extension1); + + clickBrowserAction(extension2); + await awaitExtensionPanel(extension2); + let sumNew = histogram.snapshot().sum; + Assert.greater( + sumNew, + sumOld, + `Data recorded for second extension for histogram: ${TIMING_HISTOGRAM}.` + ); + sumOld = sumNew; + + let gleanSumNew = + Glean.extensionsTiming.browserActionPopupOpen.testGetValue()?.sum; + Assert.greater( + gleanSumNew, + gleanSumOld, + "Data recorded for second extension on glean metric extensionsTiming.browserActionPopupOpen" + ); + gleanSumOld = gleanSumNew; + + let newKeyedSnapshot = histogramKeyed.snapshot(); + Assert.deepEqual( + Object.keys(newKeyedSnapshot).sort(), + [EXTENSION_ID1, EXTENSION_ID2], + `Data recorded for second extension for histogram: ${TIMING_HISTOGRAM_KEYED}.` + ); + Assert.greater( + newKeyedSnapshot[EXTENSION_ID2].sum, + 0, + `Data recorded for second extension for histogram: ${TIMING_HISTOGRAM_KEYED}.` + ); + is( + newKeyedSnapshot[EXTENSION_ID1].sum, + oldKeyedSnapshot[EXTENSION_ID1].sum, + `Data recorded for first extension should not change for histogram: ${TIMING_HISTOGRAM_KEYED}.` + ); + oldKeyedSnapshot = newKeyedSnapshot; + + await closeBrowserAction(extension2); + + clickBrowserAction(extension2); + await awaitExtensionPanel(extension2); + sumNew = histogram.snapshot().sum; + Assert.greater( + sumNew, + sumOld, + `Data recorded for second opening of popup for histogram: ${TIMING_HISTOGRAM}.` + ); + sumOld = sumNew; + + gleanSumNew = + Glean.extensionsTiming.browserActionPopupOpen.testGetValue()?.sum; + Assert.greater( + gleanSumNew, + gleanSumOld, + "Data recorded for second popup opening on glean metric extensionsTiming.browserActionPopupOpen" + ); + gleanSumOld = gleanSumNew; + + newKeyedSnapshot = histogramKeyed.snapshot(); + Assert.greater( + newKeyedSnapshot[EXTENSION_ID2].sum, + oldKeyedSnapshot[EXTENSION_ID2].sum, + `Data recorded for second opening of popup for histogram: ${TIMING_HISTOGRAM_KEYED}.` + ); + is( + newKeyedSnapshot[EXTENSION_ID1].sum, + oldKeyedSnapshot[EXTENSION_ID1].sum, + `Data recorded for first extension should not change for histogram: ${TIMING_HISTOGRAM_KEYED}.` + ); + oldKeyedSnapshot = newKeyedSnapshot; + + await closeBrowserAction(extension2); + + clickBrowserAction(extension1); + await awaitExtensionPanel(extension1); + sumNew = histogram.snapshot().sum; + Assert.greater( + sumNew, + sumOld, + `Data recorded for third opening of popup for histogram: ${TIMING_HISTOGRAM}.` + ); + + gleanSumNew = + Glean.extensionsTiming.browserActionPopupOpen.testGetValue()?.sum; + Assert.greater( + gleanSumNew, + gleanSumOld, + "Data recorded for third popup opening on glean metric extensionsTiming.browserActionPopupOpen" + ); + + newKeyedSnapshot = histogramKeyed.snapshot(); + Assert.greater( + newKeyedSnapshot[EXTENSION_ID1].sum, + oldKeyedSnapshot[EXTENSION_ID1].sum, + `Data recorded for second opening of popup for histogram: ${TIMING_HISTOGRAM_KEYED}.` + ); + is( + newKeyedSnapshot[EXTENSION_ID2].sum, + oldKeyedSnapshot[EXTENSION_ID2].sum, + `Data recorded for second extension should not change for histogram: ${TIMING_HISTOGRAM_KEYED}.` + ); + + await closeBrowserAction(extension1); + + await extension1.unload(); + await extension2.unload(); +}); + +add_task(async function testBrowserActionTelemetryResults() { + let extensionOptions = { + manifest: { + browser_specific_settings: { + gecko: { id: EXTENSION_ID1 }, + }, + browser_action: { + default_popup: "popup.html", + default_area: "navbar", + browser_style: true, + }, + }, + + files: { + "popup.html": `<!DOCTYPE html><html><head><meta charset="utf-8"></head><body><div></div></body></html>`, + }, + }; + let extension = ExtensionTestUtils.loadExtension(extensionOptions); + + let histogram = Services.telemetry.getHistogramById(RESULT_HISTOGRAM); + let histogramKeyed = Services.telemetry.getKeyedHistogramById( + RESULT_HISTOGRAM_KEYED + ); + + histogram.clear(); + histogramKeyed.clear(); + Services.fog.testResetFOG(); + + is( + histogram.snapshot().sum, + 0, + `No data recorded for histogram: ${RESULT_HISTOGRAM}.` + ); + is( + Object.keys(histogramKeyed).length, + 0, + `No data recorded for histogram: ${RESULT_HISTOGRAM_KEYED}.` + ); + assertGleanPreloadResultLabelCounterEmpty(); + + await extension.startup(); + + // Make sure the mouse isn't hovering over the browserAction widget to start. + EventUtils.synthesizeMouseAtCenter( + gURLBar.textbox, + { type: "mouseover" }, + window + ); + + let widget = getBrowserActionWidget(extension).forWindow(window); + + // Hover the mouse over the browserAction widget and then move it away. + EventUtils.synthesizeMouseAtCenter( + widget.node, + { type: "mouseover", button: 0 }, + window + ); + EventUtils.synthesizeMouseAtCenter( + widget.node, + { type: "mouseout", button: 0 }, + window + ); + EventUtils.synthesizeMouseAtCenter( + document.documentElement, + { type: "mousemove" }, + window + ); + + assertOnlyOneTypeSet(histogram.snapshot(), "clearAfterHover"); + assertGleanPreloadResultLabelCounter({ clearAfterHover: 1 }); + + let keyedSnapshot = histogramKeyed.snapshot(); + Assert.deepEqual( + Object.keys(keyedSnapshot), + [EXTENSION_ID1], + `Data recorded for histogram: ${RESULT_HISTOGRAM_KEYED}.` + ); + assertOnlyOneTypeSet(keyedSnapshot[EXTENSION_ID1], "clearAfterHover"); + + histogram.clear(); + histogramKeyed.clear(); + Services.fog.testResetFOG(); + + // TODO: Create a test for cancel after mousedown. + // This is tricky because calling mouseout after mousedown causes a + // "Hover" event to be added to the queue in ext-browserAction.js. + + clickBrowserAction(extension); + await awaitExtensionPanel(extension); + + assertOnlyOneTypeSet(histogram.snapshot(), "popupShown"); + assertGleanPreloadResultLabelCounter({ popupShown: 1 }); + + keyedSnapshot = histogramKeyed.snapshot(); + Assert.deepEqual( + Object.keys(keyedSnapshot), + [EXTENSION_ID1], + `Data recorded for histogram: ${RESULT_HISTOGRAM_KEYED}.` + ); + assertOnlyOneTypeSet(keyedSnapshot[EXTENSION_ID1], "popupShown"); + + await closeBrowserAction(extension); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_browserAction_theme_icons.js b/browser/components/extensions/test/browser/browser_ext_browserAction_theme_icons.js new file mode 100644 index 0000000000..6e3add4a1c --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_browserAction_theme_icons.js @@ -0,0 +1,370 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const LIGHT_THEME_COLORS = { + frame: "#FFF", + tab_background_text: "#000", +}; + +const DARK_THEME_COLORS = { + frame: "#000", + tab_background_text: "#FFF", +}; + +const TOOLBAR_MAPPING = { + navbar: "nav-bar", + tabstrip: "TabsToolbar", +}; + +async function testBrowserAction(extension, expectedIcon) { + let browserActionWidget = getBrowserActionWidget(extension); + await promiseAnimationFrame(); + let browserActionButton = browserActionWidget.forWindow(window).node; + let image = getListStyleImage(browserActionButton.firstElementChild); + ok( + image.includes(expectedIcon), + `Expected browser action icon (${image}) to be ${expectedIcon}` + ); +} + +async function testStaticTheme(options) { + let { + themeData, + themeIcons, + withDefaultIcon, + expectedIcon, + defaultArea = "navbar", + } = options; + + let manifest = { + browser_action: { + theme_icons: themeIcons, + default_area: defaultArea, + }, + }; + + if (withDefaultIcon) { + manifest.browser_action.default_icon = "default.png"; + } + + let extension = ExtensionTestUtils.loadExtension({ manifest }); + + await extension.startup(); + + // Ensure we show the menupanel at least once. This makes sure that the + // elements we're going to query the style of are in the flat tree. + if (defaultArea == "menupanel") { + let shown = BrowserTestUtils.waitForPopupEvent( + window.gUnifiedExtensions.panel, + "shown" + ); + window.gUnifiedExtensions.togglePanel(); + await shown; + } + + // Confirm that the browser action has the correct default icon before a theme is loaded. + let toolbarId = TOOLBAR_MAPPING[defaultArea]; + let expectedDefaultIcon; + // Some platforms have dark toolbars by default, take it in account when picking the default icon. + if ( + toolbarId && + document.getElementById(toolbarId).hasAttribute("brighttext") + ) { + expectedDefaultIcon = "light.png"; + } else { + expectedDefaultIcon = withDefaultIcon ? "default.png" : "dark.png"; + } + await testBrowserAction(extension, expectedDefaultIcon); + + let theme = ExtensionTestUtils.loadExtension({ + manifest: { + theme: { + colors: themeData, + }, + }, + }); + + await theme.startup(); + + // Confirm that the correct icon is used when the theme is loaded. + if (expectedIcon == "dark") { + // The dark icon should be used if the area is light. + await testBrowserAction(extension, "dark.png"); + } else { + // The light icon should be used if the area is dark. + await testBrowserAction(extension, "light.png"); + } + + await theme.unload(); + + // Confirm that the browser action has the correct default icon when the theme is unloaded. + await testBrowserAction(extension, expectedDefaultIcon); + + await extension.unload(); +} + +add_task(async function browseraction_theme_icons_light_theme() { + await testStaticTheme({ + themeData: LIGHT_THEME_COLORS, + expectedIcon: "dark", + themeIcons: [ + { + light: "light.png", + dark: "dark.png", + size: 19, + }, + ], + withDefaultIcon: true, + }); + await testStaticTheme({ + themeData: LIGHT_THEME_COLORS, + expectedIcon: "dark", + themeIcons: [ + { + light: "light.png", + dark: "dark.png", + size: 16, + }, + { + light: "light.png", + dark: "dark.png", + size: 32, + }, + ], + withDefaultIcon: false, + }); +}); + +add_task(async function browseraction_theme_icons_dark_theme() { + await testStaticTheme({ + themeData: DARK_THEME_COLORS, + expectedIcon: "light", + themeIcons: [ + { + light: "light.png", + dark: "dark.png", + size: 19, + }, + ], + withDefaultIcon: true, + }); + await testStaticTheme({ + themeData: DARK_THEME_COLORS, + expectedIcon: "light", + themeIcons: [ + { + light: "light.png", + dark: "dark.png", + size: 16, + }, + { + light: "light.png", + dark: "dark.png", + size: 32, + }, + ], + withDefaultIcon: false, + }); +}); + +add_task(async function browseraction_theme_icons_different_toolbars() { + let themeData = { + frame: "#000", + tab_background_text: "#fff", + toolbar: "#fff", + bookmark_text: "#000", + }; + await testStaticTheme({ + themeData, + expectedIcon: "dark", + themeIcons: [ + { + light: "light.png", + dark: "dark.png", + size: 19, + }, + ], + withDefaultIcon: true, + }); + await testStaticTheme({ + themeData, + expectedIcon: "dark", + themeIcons: [ + { + light: "light.png", + dark: "dark.png", + size: 16, + }, + { + light: "light.png", + dark: "dark.png", + size: 32, + }, + ], + }); + await testStaticTheme({ + themeData, + expectedIcon: "light", + defaultArea: "tabstrip", + themeIcons: [ + { + light: "light.png", + dark: "dark.png", + size: 19, + }, + ], + withDefaultIcon: true, + }); + await testStaticTheme({ + themeData, + expectedIcon: "light", + defaultArea: "tabstrip", + themeIcons: [ + { + light: "light.png", + dark: "dark.png", + size: 16, + }, + { + light: "light.png", + dark: "dark.png", + size: 32, + }, + ], + }); +}); + +add_task(async function browseraction_theme_icons_overflow_panel() { + let themeData = { + popup: "#000", + popup_text: "#fff", + }; + await testStaticTheme({ + themeData, + expectedIcon: "dark", + themeIcons: [ + { + light: "light.png", + dark: "dark.png", + size: 19, + }, + ], + withDefaultIcon: true, + }); + await testStaticTheme({ + themeData, + expectedIcon: "dark", + themeIcons: [ + { + light: "light.png", + dark: "dark.png", + size: 16, + }, + { + light: "light.png", + dark: "dark.png", + size: 32, + }, + ], + }); + + await testStaticTheme({ + themeData, + expectedIcon: "light", + defaultArea: "menupanel", + themeIcons: [ + { + light: "light.png", + dark: "dark.png", + size: 19, + }, + ], + withDefaultIcon: true, + }); + await testStaticTheme({ + themeData, + expectedIcon: "light", + defaultArea: "menupanel", + themeIcons: [ + { + light: "light.png", + dark: "dark.png", + size: 16, + }, + { + light: "light.png", + dark: "dark.png", + size: 32, + }, + ], + }); +}); + +add_task(async function browseraction_theme_icons_dynamic_theme() { + let themeExtension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["theme"], + }, + background() { + browser.test.onMessage.addListener((msg, colors) => { + if (msg === "update-theme") { + browser.theme.update({ + colors: colors, + }); + browser.test.sendMessage("theme-updated"); + } + }); + }, + }); + + await themeExtension.startup(); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_action: { + default_icon: "default.png", + default_area: "navbar", + theme_icons: [ + { + light: "light.png", + dark: "dark.png", + size: 16, + }, + { + light: "light.png", + dark: "dark.png", + size: 32, + }, + ], + }, + }, + }); + + await extension.startup(); + + // Confirm that the browser action has the default icon before a theme is set. + await testBrowserAction(extension, "default.png"); + + // Update the theme to a light theme. + themeExtension.sendMessage("update-theme", LIGHT_THEME_COLORS); + await themeExtension.awaitMessage("theme-updated"); + + // Confirm that the dark icon is used for the light theme. + await testBrowserAction(extension, "dark.png"); + + // Update the theme to a dark theme. + themeExtension.sendMessage("update-theme", DARK_THEME_COLORS); + await themeExtension.awaitMessage("theme-updated"); + + // Confirm that the light icon is used for the dark theme. + await testBrowserAction(extension, "light.png"); + + // Unload the theme. + await themeExtension.unload(); + + // Confirm that the default icon is used when the theme is unloaded. + await testBrowserAction(extension, "default.png"); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_browsingData_cookieStoreId.js b/browser/components/extensions/test/browser/browser_ext_browsingData_cookieStoreId.js new file mode 100644 index 0000000000..a7acbcfdf3 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_browsingData_cookieStoreId.js @@ -0,0 +1,86 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_remove_unsupported() { + await SpecialPowers.pushPrefEnv({ + set: [["privacy.userContext.enabled", true]], + }); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["browsingData"], + }, + async background() { + for (let dataType of [ + "cache", + "downloads", + "formData", + "history", + "passwords", + "pluginData", + "serviceWorkers", + ]) { + await browser.test.assertRejects( + browser.browsingData.remove( + { cookieStoreId: "firefox-default" }, + { + [dataType]: true, + } + ), + `Firefox does not support clearing ${dataType} with 'cookieStoreId'.`, + `Should reject for unsupported dataType: ${dataType}` + ); + } + + // Smoke test that doesn't delete anything. + await browser.browsingData.remove( + { cookieStoreId: "firefox-container-1" }, + {} + ); + + browser.test.sendMessage("done"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); + + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_invalid_id() { + await SpecialPowers.pushPrefEnv({ + set: [["privacy.userContext.enabled", true]], + }); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["browsingData"], + }, + async background() { + for (let cookieStoreId of [ + "firefox-DEFAULT", // should be "firefox-default" + "firefox-private222", + "firefox", + "firefox-container-", + "firefox-container-100000", + ]) { + await browser.test.assertRejects( + browser.browsingData.remove({ cookieStoreId }, { cookies: true }), + `Invalid cookieStoreId: ${cookieStoreId}`, + `Should reject invalid cookieStoreId: ${cookieStoreId}` + ); + } + + browser.test.sendMessage("done"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); + + await SpecialPowers.popPrefEnv(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_browsingData_formData.js b/browser/components/extensions/test/browser/browser_ext_browsingData_formData.js new file mode 100644 index 0000000000..06d928b9b1 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_browsingData_formData.js @@ -0,0 +1,175 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + FormHistory: "resource://gre/modules/FormHistory.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", +}); + +const REFERENCE_DATE = Date.now(); + +async function countEntries(fieldname, message, expected) { + let count = await FormHistory.count({ fieldname }); + is(count, expected, message); +} + +async function setupFormHistory() { + async function searchFirstEntry(terms, params) { + return (await FormHistory.search(terms, params))[0]; + } + + // Make sure we've got a clean DB to start with, then add the entries we'll be testing. + await FormHistory.update([ + { op: "remove" }, + { + op: "add", + fieldname: "reference", + value: "reference", + }, + { + op: "add", + fieldname: "10secondsAgo", + value: "10s", + }, + { + op: "add", + fieldname: "10minutesAgo", + value: "10m", + }, + ]); + + // Age the entries to the proper vintage. + let timestamp = PlacesUtils.toPRTime(REFERENCE_DATE); + let result = await searchFirstEntry(["guid"], { fieldname: "reference" }); + await FormHistory.update({ + op: "update", + firstUsed: timestamp, + guid: result.guid, + }); + + timestamp = PlacesUtils.toPRTime(REFERENCE_DATE - 10000); + result = await searchFirstEntry(["guid"], { fieldname: "10secondsAgo" }); + await FormHistory.update({ + op: "update", + firstUsed: timestamp, + guid: result.guid, + }); + + timestamp = PlacesUtils.toPRTime(REFERENCE_DATE - 10000 * 60); + result = await searchFirstEntry(["guid"], { fieldname: "10minutesAgo" }); + await FormHistory.update({ + op: "update", + firstUsed: timestamp, + guid: result.guid, + }); + + // Sanity check. + await countEntries( + "reference", + "Checking for 10minutes form history entry creation", + 1 + ); + await countEntries( + "10secondsAgo", + "Checking for 1hour form history entry creation", + 1 + ); + await countEntries( + "10minutesAgo", + "Checking for 1hour10minutes form history entry creation", + 1 + ); +} + +add_task(async function testFormData() { + function background() { + browser.test.onMessage.addListener(async (msg, options) => { + if (msg == "removeFormData") { + await browser.browsingData.removeFormData(options); + } else { + await browser.browsingData.remove(options, { formData: true }); + } + browser.test.sendMessage("formDataRemoved"); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["browsingData"], + }, + }); + + async function testRemovalMethod(method) { + // Clear form data with no since value. + await setupFormHistory(); + extension.sendMessage(method, {}); + await extension.awaitMessage("formDataRemoved"); + + await countEntries( + "reference", + "reference form entry should be deleted.", + 0 + ); + await countEntries( + "10secondsAgo", + "10secondsAgo form entry should be deleted.", + 0 + ); + await countEntries( + "10minutesAgo", + "10minutesAgo form entry should be deleted.", + 0 + ); + + // Clear form data with recent since value. + await setupFormHistory(); + extension.sendMessage(method, { since: REFERENCE_DATE }); + await extension.awaitMessage("formDataRemoved"); + + await countEntries( + "reference", + "reference form entry should be deleted.", + 0 + ); + await countEntries( + "10secondsAgo", + "10secondsAgo form entry should still exist.", + 1 + ); + await countEntries( + "10minutesAgo", + "10minutesAgo form entry should still exist.", + 1 + ); + + // Clear form data with old since value. + await setupFormHistory(); + extension.sendMessage(method, { since: REFERENCE_DATE - 1000000 }); + await extension.awaitMessage("formDataRemoved"); + + await countEntries( + "reference", + "reference form entry should be deleted.", + 0 + ); + await countEntries( + "10secondsAgo", + "10secondsAgo form entry should be deleted.", + 0 + ); + await countEntries( + "10minutesAgo", + "10minutesAgo form entry should be deleted.", + 0 + ); + } + + await extension.startup(); + + await testRemovalMethod("removeFormData"); + await testRemovalMethod("remove"); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_browsingData_history.js b/browser/components/extensions/test/browser/browser_ext_browsingData_history.js new file mode 100644 index 0000000000..2f696f3154 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_browsingData_history.js @@ -0,0 +1,123 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + PlacesTestUtils: "resource://testing-common/PlacesTestUtils.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", +}); + +const OLD_URL = "http://example.com/"; +const RECENT_URL = "http://example.com/2/"; +const REFERENCE_DATE = new Date(); + +// Visits to add via addVisits +const PLACEINFO = [ + { + uri: RECENT_URL, + title: `test visit for ${RECENT_URL}`, + visitDate: REFERENCE_DATE, + }, + { + uri: OLD_URL, + title: `test visit for ${OLD_URL}`, + visitDate: new Date(Number(REFERENCE_DATE) - 1000), + }, + { + uri: OLD_URL, + title: `test visit for ${OLD_URL}`, + visitDate: new Date(Number(REFERENCE_DATE) - 2000), + }, +]; + +async function setupHistory() { + await PlacesUtils.history.clear(); + await PlacesTestUtils.addVisits(PLACEINFO); + is( + await PlacesTestUtils.visitsInDB(RECENT_URL), + 1, + "Expected number of visits found in history database." + ); + is( + await PlacesTestUtils.visitsInDB(OLD_URL), + 2, + "Expected number of visits found in history database." + ); +} + +add_task(async function testHistory() { + function background() { + browser.test.onMessage.addListener(async (msg, options) => { + if (msg == "removeHistory") { + await browser.browsingData.removeHistory(options); + } else { + await browser.browsingData.remove(options, { history: true }); + } + browser.test.sendMessage("historyRemoved"); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["browsingData"], + }, + }); + + async function testRemovalMethod(method) { + // Clear history with no since value. + await setupHistory(); + extension.sendMessage(method, {}); + await extension.awaitMessage("historyRemoved"); + + is( + await PlacesTestUtils.visitsInDB(RECENT_URL), + 0, + "Expected number of visits removed from history database." + ); + is( + await PlacesTestUtils.visitsInDB(OLD_URL), + 0, + "Expected number of visits removed from history database." + ); + + // Clear history with recent since value. + await setupHistory(); + extension.sendMessage(method, { since: REFERENCE_DATE - 1000 }); + await extension.awaitMessage("historyRemoved"); + + is( + await PlacesTestUtils.visitsInDB(RECENT_URL), + 0, + "Expected number of visits removed from history database." + ); + is( + await PlacesTestUtils.visitsInDB(OLD_URL), + 1, + "Expected number of visits removed from history database." + ); + + // Clear history with old since value. + await setupHistory(); + extension.sendMessage(method, { since: REFERENCE_DATE - 100000 }); + await extension.awaitMessage("historyRemoved"); + + is( + await PlacesTestUtils.visitsInDB(RECENT_URL), + 0, + "Expected number of visits removed from history database." + ); + is( + await PlacesTestUtils.visitsInDB(OLD_URL), + 0, + "Expected number of visits removed from history database." + ); + } + + await extension.startup(); + + await testRemovalMethod("removeHistory"); + await testRemovalMethod("remove"); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_chrome_settings_overrides_home.js b/browser/components/extensions/test/browser/browser_ext_chrome_settings_overrides_home.js new file mode 100644 index 0000000000..b67952c03c --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_chrome_settings_overrides_home.js @@ -0,0 +1,843 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ + +"use strict"; + +requestLongerTimeout(4); + +ChromeUtils.defineESModuleGetters(this, { + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", + ExtensionControlledPopup: + "resource:///modules/ExtensionControlledPopup.sys.mjs", + ExtensionSettingsStore: + "resource://gre/modules/ExtensionSettingsStore.sys.mjs", + HomePage: "resource:///modules/HomePage.sys.mjs", +}); + +// Named this way so they correspond to the extensions +const HOME_URI_2 = "http://example.com/"; +const HOME_URI_3 = "http://example.org/"; +const HOME_URI_4 = "http://example.net/"; + +const CONTROLLED_BY_THIS = "controlled_by_this_extension"; +const CONTROLLED_BY_OTHER = "controlled_by_other_extensions"; +const NOT_CONTROLLABLE = "not_controllable"; + +const HOMEPAGE_URL_PREF = "browser.startup.homepage"; + +const getHomePageURL = () => { + return Services.prefs.getStringPref(HOMEPAGE_URL_PREF); +}; + +function isConfirmed(id) { + let item = ExtensionSettingsStore.getSetting("homepageNotification", id); + return !!(item && item.value); +} + +async function assertPreferencesShown(_spotlight) { + await TestUtils.waitForCondition( + () => gBrowser.currentURI.spec == "about:preferences#home", + "Should open about:preferences." + ); + + await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [_spotlight], + async spotlight => { + let doc = content.document; + let section = await ContentTaskUtils.waitForCondition( + () => doc.querySelector(".spotlight"), + "The spotlight should appear." + ); + Assert.equal( + section.getAttribute("data-subcategory"), + spotlight, + "The correct section is spotlighted." + ); + } + ); + + BrowserTestUtils.removeTab(gBrowser.selectedTab); +} + +add_task(async function test_multiple_extensions_overriding_home_page() { + let defaultHomePage = getHomePageURL(); + + function background() { + browser.test.onMessage.addListener(async msg => { + switch (msg) { + case "checkHomepage": + let homepage = await browser.browserSettings.homepageOverride.get({}); + browser.test.sendMessage("homepage", homepage); + break; + case "trySet": + let setResult = await browser.browserSettings.homepageOverride.set({ + value: "foo", + }); + browser.test.assertFalse( + setResult, + "Calling homepageOverride.set returns false." + ); + browser.test.sendMessage("homepageSet"); + break; + case "tryClear": + let clearResult = + await browser.browserSettings.homepageOverride.clear({}); + browser.test.assertFalse( + clearResult, + "Calling homepageOverride.clear returns false." + ); + browser.test.sendMessage("homepageCleared"); + break; + } + }); + } + + let extObj = { + manifest: { + chrome_settings_overrides: {}, + permissions: ["browserSettings"], + }, + useAddonManager: "temporary", + background, + }; + + let ext1 = ExtensionTestUtils.loadExtension(extObj); + + extObj.manifest.chrome_settings_overrides = { homepage: HOME_URI_2 }; + let ext2 = ExtensionTestUtils.loadExtension(extObj); + + extObj.manifest.chrome_settings_overrides = { homepage: HOME_URI_3 }; + let ext3 = ExtensionTestUtils.loadExtension(extObj); + + extObj.manifest.chrome_settings_overrides = { homepage: HOME_URI_4 }; + let ext4 = ExtensionTestUtils.loadExtension(extObj); + + extObj.manifest.chrome_settings_overrides = {}; + let ext5 = ExtensionTestUtils.loadExtension(extObj); + + async function checkHomepageOverride( + ext, + expectedValue, + expectedLevelOfControl + ) { + ext.sendMessage("checkHomepage"); + let homepage = await ext.awaitMessage("homepage"); + is( + homepage.value, + expectedValue, + `homepageOverride setting returns the expected value: ${expectedValue}.` + ); + is( + homepage.levelOfControl, + expectedLevelOfControl, + `homepageOverride setting returns the expected levelOfControl: ${expectedLevelOfControl}.` + ); + } + + await ext1.startup(); + + is(getHomePageURL(), defaultHomePage, "Home url should be the default"); + await checkHomepageOverride(ext1, getHomePageURL(), NOT_CONTROLLABLE); + + // Because we are expecting the pref to change when we start or unload, we + // need to wait on a pref change. This is because the pref management is + // async and can happen after the startup/unload is finished. + let prefPromise = promisePrefChangeObserved(HOMEPAGE_URL_PREF); + await ext2.startup(); + await prefPromise; + + ok( + getHomePageURL().endsWith(HOME_URI_2), + "Home url should be overridden by the second extension." + ); + + await checkHomepageOverride(ext1, HOME_URI_2, CONTROLLED_BY_OTHER); + + // Verify that calling set and clear do nothing. + ext2.sendMessage("trySet"); + await ext2.awaitMessage("homepageSet"); + await checkHomepageOverride(ext1, HOME_URI_2, CONTROLLED_BY_OTHER); + + ext2.sendMessage("tryClear"); + await ext2.awaitMessage("homepageCleared"); + await checkHomepageOverride(ext1, HOME_URI_2, CONTROLLED_BY_OTHER); + + // Because we are unloading an earlier extension, browser.startup.homepage won't change + await ext1.unload(); + + await checkHomepageOverride(ext2, HOME_URI_2, CONTROLLED_BY_THIS); + + ok( + getHomePageURL().endsWith(HOME_URI_2), + "Home url should be overridden by the second extension." + ); + + prefPromise = promisePrefChangeObserved(HOMEPAGE_URL_PREF); + await ext3.startup(); + await prefPromise; + + ok( + getHomePageURL().endsWith(HOME_URI_3), + "Home url should be overridden by the third extension." + ); + + await checkHomepageOverride(ext3, HOME_URI_3, CONTROLLED_BY_THIS); + + // Because we are unloading an earlier extension, browser.startup.homepage won't change + await ext2.unload(); + + ok( + getHomePageURL().endsWith(HOME_URI_3), + "Home url should be overridden by the third extension." + ); + + await checkHomepageOverride(ext3, HOME_URI_3, CONTROLLED_BY_THIS); + + prefPromise = promisePrefChangeObserved(HOMEPAGE_URL_PREF); + await ext4.startup(); + await prefPromise; + + ok( + getHomePageURL().endsWith(HOME_URI_4), + "Home url should be overridden by the third extension." + ); + + await checkHomepageOverride(ext3, HOME_URI_4, CONTROLLED_BY_OTHER); + + prefPromise = promisePrefChangeObserved(HOMEPAGE_URL_PREF); + await ext4.unload(); + await prefPromise; + + ok( + getHomePageURL().endsWith(HOME_URI_3), + "Home url should be overridden by the third extension." + ); + + await checkHomepageOverride(ext3, HOME_URI_3, CONTROLLED_BY_THIS); + + prefPromise = promisePrefChangeObserved(HOMEPAGE_URL_PREF); + await ext3.unload(); + await prefPromise; + + is(getHomePageURL(), defaultHomePage, "Home url should be reset to default"); + + await ext5.startup(); + await checkHomepageOverride(ext5, defaultHomePage, NOT_CONTROLLABLE); + await ext5.unload(); +}); + +const HOME_URI_1 = "http://example.com/"; +const USER_URI = "http://example.edu/"; + +add_task(async function test_extension_setting_home_page_back() { + let defaultHomePage = getHomePageURL(); + + Services.prefs.setStringPref(HOMEPAGE_URL_PREF, USER_URI); + + is(getHomePageURL(), USER_URI, "Home url should be the user set value"); + + let ext1 = ExtensionTestUtils.loadExtension({ + manifest: { chrome_settings_overrides: { homepage: HOME_URI_1 } }, + useAddonManager: "temporary", + }); + + // Because we are expecting the pref to change when we start or unload, we + // need to wait on a pref change. This is because the pref management is + // async and can happen after the startup/unload is finished. + let prefPromise = promisePrefChangeObserved(HOMEPAGE_URL_PREF); + await ext1.startup(); + await prefPromise; + + ok( + getHomePageURL().endsWith(HOME_URI_1), + "Home url should be overridden by the second extension." + ); + + prefPromise = promisePrefChangeObserved(HOMEPAGE_URL_PREF); + await ext1.unload(); + await prefPromise; + + is(getHomePageURL(), USER_URI, "Home url should be the user set value"); + + Services.prefs.clearUserPref(HOMEPAGE_URL_PREF); + + is(getHomePageURL(), defaultHomePage, "Home url should be the default"); +}); + +add_task(async function test_disable() { + const ID = "id@tests.mozilla.org"; + let defaultHomePage = getHomePageURL(); + + let ext1 = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { + id: ID, + }, + }, + chrome_settings_overrides: { + homepage: HOME_URI_1, + }, + }, + useAddonManager: "temporary", + }); + + let prefPromise = promisePrefChangeObserved(HOMEPAGE_URL_PREF); + await ext1.startup(); + await prefPromise; + + is( + getHomePageURL(), + HOME_URI_1, + "Home url should be overridden by the extension." + ); + + let addon = await AddonManager.getAddonByID(ID); + is(addon.id, ID, "Found the correct add-on."); + + let disabledPromise = awaitEvent("shutdown", ID); + prefPromise = promisePrefChangeObserved(HOMEPAGE_URL_PREF); + await addon.disable(); + await Promise.all([disabledPromise, prefPromise]); + + is(getHomePageURL(), defaultHomePage, "Home url should be the default"); + + let enabledPromise = awaitEvent("ready", ID); + prefPromise = promisePrefChangeObserved(HOMEPAGE_URL_PREF); + await addon.enable(); + await Promise.all([enabledPromise, prefPromise]); + + is( + getHomePageURL(), + HOME_URI_1, + "Home url should be overridden by the extension." + ); + + prefPromise = promisePrefChangeObserved(HOMEPAGE_URL_PREF); + await ext1.unload(); + await prefPromise; + + is(getHomePageURL(), defaultHomePage, "Home url should be the default"); +}); + +add_task(async function test_local() { + let ext1 = ExtensionTestUtils.loadExtension({ + manifest: { chrome_settings_overrides: { homepage: "home.html" } }, + useAddonManager: "temporary", + }); + + let prefPromise = promisePrefChangeObserved(HOMEPAGE_URL_PREF); + await ext1.startup(); + await prefPromise; + + let homepage = getHomePageURL(); + ok( + homepage.startsWith("moz-extension") && homepage.endsWith("home.html"), + "Home url should be relative to extension." + ); + + await ext1.unload(); +}); + +add_task(async function test_multiple() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + chrome_settings_overrides: { + homepage: + "https://mozilla.org/|https://developer.mozilla.org/|https://addons.mozilla.org/", + }, + }, + useAddonManager: "temporary", + }); + + let prefPromise = promisePrefChangeObserved(HOMEPAGE_URL_PREF); + await extension.startup(); + await prefPromise; + + is( + getHomePageURL(), + "https://mozilla.org/%7Chttps://developer.mozilla.org/%7Chttps://addons.mozilla.org/", + "The homepage encodes | so only one homepage is allowed" + ); + + await extension.unload(); +}); + +add_task(async function test_doorhanger_homepage_button() { + let defaultHomePage = getHomePageURL(); + // These extensions are temporarily loaded so that the AddonManager can see + // them and the extension's shutdown handlers are called. + let ext1 = ExtensionTestUtils.loadExtension({ + manifest: { chrome_settings_overrides: { homepage: "ext1.html" } }, + files: { "ext1.html": "<h1>1</h1>" }, + useAddonManager: "temporary", + }); + let ext2 = ExtensionTestUtils.loadExtension({ + manifest: { chrome_settings_overrides: { homepage: "ext2.html" } }, + files: { "ext2.html": "<h1>2</h1>" }, + useAddonManager: "temporary", + }); + + let panel = ExtensionControlledPopup._getAndMaybeCreatePanel(document); + let popupnotification = document.getElementById( + "extension-homepage-notification" + ); + + await ext1.startup(); + await ext2.startup(); + + let popupShown = promisePopupShown(panel); + BrowserHome(); + await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser, false, () => + gURLBar.value.endsWith("ext2.html") + ); + await popupShown; + + // Click Manage. + let popupHidden = promisePopupHidden(panel); + // Ensures the preferences tab opens, checks the spotlight, and then closes it + let spotlightShown = assertPreferencesShown("homeOverride"); + popupnotification.secondaryButton.click(); + await popupHidden; + await spotlightShown; + + let prefPromise = promisePrefChangeObserved(HOMEPAGE_URL_PREF); + await ext2.unload(); + await prefPromise; + + // Expect a new doorhanger for the next extension. + popupShown = promisePopupShown(panel); + await BrowserTestUtils.openNewForegroundTab(gBrowser, "about:blank"); + let openHomepage = TestUtils.topicObserved("browser-open-homepage-start"); + BrowserHome(); + await openHomepage; + await popupShown; + await TestUtils.waitForCondition( + () => gURLBar.value.endsWith("ext1.html"), + "ext1 is in control" + ); + + // Click manage again. + popupHidden = promisePopupHidden(panel); + // Ensures the preferences tab opens, checks the spotlight, and then closes it + spotlightShown = assertPreferencesShown("homeOverride"); + popupnotification.secondaryButton.click(); + await popupHidden; + await spotlightShown; + + prefPromise = promisePrefChangeObserved(HOMEPAGE_URL_PREF); + await ext1.unload(); + await prefPromise; + + BrowserTestUtils.removeTab(gBrowser.selectedTab); + openHomepage = TestUtils.topicObserved("browser-open-homepage-start"); + BrowserHome(); + await openHomepage; + + is(getHomePageURL(), defaultHomePage, "The homepage is set back to default"); +}); + +add_task(async function test_doorhanger_new_window() { + // These extensions are temporarily loaded so that the AddonManager can see + // them and the extension's shutdown handlers are called. + let ext1Id = "ext1@mochi.test"; + let ext1 = ExtensionTestUtils.loadExtension({ + manifest: { + chrome_settings_overrides: { homepage: "ext1.html" }, + browser_specific_settings: { + gecko: { id: ext1Id }, + }, + name: "Ext1", + }, + files: { "ext1.html": "<h1>1</h1>" }, + useAddonManager: "temporary", + }); + let ext2 = ExtensionTestUtils.loadExtension({ + background() { + browser.test.sendMessage("url", browser.runtime.getURL("ext2.html")); + }, + manifest: { + chrome_settings_overrides: { homepage: "ext2.html" }, + name: "Ext2", + }, + files: { "ext2.html": "<h1>2</h1>" }, + useAddonManager: "temporary", + }); + + await ext1.startup(); + await ext2.startup(); + let url = await ext2.awaitMessage("url"); + + await SpecialPowers.pushPrefEnv({ set: [["browser.startup.page", 1]] }); + + let windowOpenedPromise = BrowserTestUtils.waitForNewWindow({ url }); + let win = OpenBrowserWindow(); + await windowOpenedPromise; + let doc = win.document; + let panel = ExtensionControlledPopup._getAndMaybeCreatePanel(doc); + await promisePopupShown(panel); + + let description = doc.getElementById( + "extension-homepage-notification-description" + ); + + await TestUtils.waitForCondition( + () => win.gURLBar.value.endsWith("ext2.html"), + "ext2 is in control" + ); + + is( + description.textContent, + "An extension, Ext2, changed what you see when you open your homepage and new windows.Learn more", + "The extension name is in the popup" + ); + + // Click Manage. + let popupHidden = promisePopupHidden(panel); + let popupnotification = doc.getElementById("extension-homepage-notification"); + popupnotification.secondaryButton.click(); + await popupHidden; + + let prefPromise = promisePrefChangeObserved(HOMEPAGE_URL_PREF); + await ext2.unload(); + await prefPromise; + + // Expect a new doorhanger for the next extension. + let popupShown = promisePopupShown(panel); + await BrowserTestUtils.openNewForegroundTab(win.gBrowser, "about:blank"); + let openHomepage = TestUtils.topicObserved("browser-open-homepage-start"); + win.BrowserHome(); + await openHomepage; + await popupShown; + + await TestUtils.waitForCondition( + () => win.gURLBar.value.endsWith("ext1.html"), + "ext1 is in control" + ); + + is( + description.textContent, + "An extension, Ext1, changed what you see when you open your homepage and new windows.Learn more", + "The extension name is in the popup" + ); + + // Click Keep Changes. + popupnotification.button.click(); + await TestUtils.waitForCondition(() => isConfirmed(ext1Id)); + + ok( + getHomePageURL().endsWith("ext1.html"), + "The homepage is still the first eextension" + ); + + await BrowserTestUtils.closeWindow(win); + await ext1.unload(); + + ok(!isConfirmed(ext1Id), "The confirmation is cleaned up on uninstall"); + // Skipping for window leak in debug builds, follow up bug: 1678412 +}).skip(AppConstants.DEBUG); + +async function testHomePageWindow(options = {}) { + let windowOpenedPromise = BrowserTestUtils.waitForNewWindow(); + let win = OpenBrowserWindow(options.options); + let openHomepage = TestUtils.topicObserved("browser-open-homepage-start"); + await windowOpenedPromise; + let doc = win.document; + let panel = ExtensionControlledPopup._getAndMaybeCreatePanel(doc); + + let popupShown = options.expectPanel && promisePopupShown(panel); + win.BrowserHome(); + await Promise.all([ + BrowserTestUtils.browserLoaded(win.gBrowser.selectedBrowser), + openHomepage, + popupShown, + ]); + + await options.test(win); + + if (options.expectPanel) { + let popupHidden = promisePopupHidden(panel); + panel.hidePopup(); + await popupHidden; + } + await BrowserTestUtils.closeWindow(win); +} + +add_task(async function test_overriding_home_page_incognito_not_allowed() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.startup.page", 1]], + }); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + chrome_settings_overrides: { homepage: "home.html" }, + name: "extension", + }, + files: { "home.html": "<h1>1</h1>" }, + useAddonManager: "temporary", + }); + + await extension.startup(); + let url = `moz-extension://${extension.uuid}/home.html`; + + await testHomePageWindow({ + expectPanel: true, + test(win) { + let doc = win.document; + let description = doc.getElementById( + "extension-homepage-notification-description" + ); + let popupnotification = description.closest("popupnotification"); + is( + description.textContent, + "An extension, extension, changed what you see when you open your homepage and new windows.Learn more", + "The extension name is in the popup" + ); + is( + popupnotification.hidden, + false, + "The expected popup notification is visible" + ); + + Assert.equal(HomePage.get(win), url, "The homepage is not set"); + Assert.equal( + win.gURLBar.value, + url, + "home page not used in private window" + ); + }, + }); + + await testHomePageWindow({ + expectPanel: false, + options: { private: true }, + test(win) { + Assert.notEqual(HomePage.get(win), url, "The homepage is not set"); + Assert.notEqual( + win.gURLBar.value, + url, + "home page not used in private window" + ); + }, + }); + + await extension.unload(); +}); + +add_task(async function test_overriding_home_page_incognito_spanning() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + chrome_settings_overrides: { homepage: "home.html" }, + name: "private extension", + browser_specific_settings: { + gecko: { id: "@spanning-home" }, + }, + }, + files: { "home.html": "<h1>1</h1>" }, + useAddonManager: "permanent", + incognitoOverride: "spanning", + }); + + await extension.startup(); + + // private window uses extension homepage + await testHomePageWindow({ + expectPanel: true, + options: { private: true }, + test(win) { + Assert.equal( + HomePage.get(win), + `moz-extension://${extension.uuid}/home.html`, + "The homepage is set" + ); + Assert.equal( + win.gURLBar.value, + `moz-extension://${extension.uuid}/home.html`, + "extension is control in window" + ); + }, + }); + + await extension.unload(); +}); + +add_task(async function test_overriding_home_page_incognito_external() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + chrome_settings_overrides: { homepage: "/home.html" }, + name: "extension", + }, + useAddonManager: "temporary", + files: { "home.html": "<h1>non-private home</h1>" }, + }); + + await extension.startup(); + + // non-private window uses extension homepage + await testHomePageWindow({ + expectPanel: true, + test(win) { + Assert.equal( + HomePage.get(win), + `moz-extension://${extension.uuid}/home.html`, + "The homepage is set" + ); + Assert.equal( + win.gURLBar.value, + `moz-extension://${extension.uuid}/home.html`, + "extension is control in window" + ); + }, + }); + + // private window does not use extension window + await testHomePageWindow({ + expectPanel: false, + options: { private: true }, + test(win) { + Assert.notEqual( + HomePage.get(win), + `moz-extension://${extension.uuid}/home.html`, + "The homepage is not set" + ); + Assert.notEqual( + win.gURLBar.value, + `moz-extension://${extension.uuid}/home.html`, + "home page not used in private window" + ); + }, + }); + + await extension.unload(); +}); + +// This tests that the homepage provided by an extension can be opened by any extension +// and does not require web_accessible_resource entries. +async function _test_overriding_home_page_open(manifest_version) { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version, + chrome_settings_overrides: { homepage: "home.html" }, + name: "homepage provider", + browser_specific_settings: { + gecko: { id: "homepage@mochitest" }, + }, + }, + files: { + "home.html": `<h1>Home Page!</h1><pre id="result"></pre><script src="home.js"></script>`, + "home.js": () => { + document.querySelector("#result").textContent = "homepage loaded"; + }, + }, + useAddonManager: "permanent", + }); + + await extension.startup(); + + // ensure it works and deal with initial panel prompt. + await testHomePageWindow({ + expectPanel: true, + async test(win) { + Assert.equal( + HomePage.get(win), + `moz-extension://${extension.uuid}/home.html`, + "The homepage is set" + ); + Assert.equal( + win.gURLBar.value, + `moz-extension://${extension.uuid}/home.html`, + "extension is control in window" + ); + const { selectedBrowser } = win.gBrowser; + const result = await SpecialPowers.spawn( + selectedBrowser, + [], + async () => { + const { document } = this.content; + if (document.readyState !== "complete") { + await new Promise(resolve => (document.onload = resolve)); + } + return document.querySelector("#result").textContent; + } + ); + Assert.equal( + result, + "homepage loaded", + "Overridden homepage loaded successfully" + ); + }, + }); + + // Extension used to open the homepage in a new window. + let opener = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + async background() { + let win; + browser.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => { + if (tab.windowId !== win.id || tab.status !== "complete") { + return; + } + browser.test.sendMessage("created", tab.url); + }); + browser.test.onMessage.addListener(async msg => { + if (msg == "create") { + win = await browser.windows.create({}); + browser.test.assertTrue( + win.id !== browser.windows.WINDOW_ID_NONE, + "New window was created." + ); + } + }); + }, + }); + + function listener(msg) { + Assert.ok(!/may not load or link to moz-extension/.test(msg.message)); + } + Services.console.registerListener(listener); + registerCleanupFunction(() => { + Services.console.unregisterListener(listener); + }); + + await opener.startup(); + const promiseNewWindow = BrowserTestUtils.waitForNewWindow(); + await opener.sendMessage("create"); + let homepageUrl = await opener.awaitMessage("created"); + + Assert.equal( + homepageUrl, + `moz-extension://${extension.uuid}/home.html`, + "The homepage is set" + ); + + const newWin = await promiseNewWindow; + Assert.equal( + await SpecialPowers.spawn(newWin.gBrowser.selectedBrowser, [], async () => { + const { document } = this.content; + if (document.readyState !== "complete") { + await new Promise(resolve => (document.onload = resolve)); + } + return document.querySelector("#result").textContent; + }), + "homepage loaded", + "Overridden homepage loaded as expected" + ); + + await BrowserTestUtils.closeWindow(newWin); + await opener.unload(); + await extension.unload(); +} + +add_task(async function test_overriding_home_page_open_mv2() { + await _test_overriding_home_page_open(2); +}); + +add_task(async function test_overriding_home_page_open_mv3() { + await SpecialPowers.pushPrefEnv({ + set: [["extensions.manifestV3.enabled", true]], + }); + await _test_overriding_home_page_open(3); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_commands_execute_browser_action.js b/browser/components/extensions/test/browser/browser_ext_commands_execute_browser_action.js new file mode 100644 index 0000000000..526dfbbeeb --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_commands_execute_browser_action.js @@ -0,0 +1,194 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function testTabSwitchActionContext() { + await SpecialPowers.pushPrefEnv({ + set: [["extensions.manifestV3.enabled", true]], + }); +}); + +async function testExecuteBrowserActionWithOptions(options = {}) { + // Make sure the mouse isn't hovering over the browserAction widget. + EventUtils.synthesizeMouseAtCenter( + gURLBar.textbox, + { type: "mouseover" }, + window + ); + + let extensionOptions = {}; + + let browser_action = + options.manifest_version > 2 ? "action" : "browser_action"; + let browser_action_key = options.manifest_version > 2 ? "a" : "j"; + + // We accept any command in the manifest, so here we add commands for + // both V2 and V3, but only the command that matches the manifest version + // should ever work. + extensionOptions.manifest = { + manifest_version: options.manifest_version || 2, + commands: { + _execute_browser_action: { + suggested_key: { + default: "Alt+Shift+J", + }, + }, + _execute_action: { + suggested_key: { + default: "Alt+Shift+A", + }, + }, + }, + [browser_action]: { + browser_style: true, + }, + }; + + if (options.withPopup) { + extensionOptions.manifest[browser_action].default_popup = "popup.html"; + + extensionOptions.files = { + "popup.html": ` + <!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + <script src="popup.js"></script> + </head> + </html> + `, + + "popup.js": function () { + browser.runtime.sendMessage("from-browser-action-popup"); + }, + }; + } + + extensionOptions.background = () => { + let manifest = browser.runtime.getManifest(); + let { manifest_version } = manifest; + const action = manifest_version < 3 ? "browserAction" : "action"; + + browser.test.onMessage.addListener((message, options) => { + browser.commands.onCommand.addListener(commandName => { + if ( + ["_execute_browser_action", "_execute_action"].includes(commandName) + ) { + browser.test.assertEq( + commandName, + options.expectedCommand, + `The onCommand listener fired for ${commandName}.` + ); + browser[action].openPopup(); + } + }); + + if (!options.expectedCommand) { + browser[action].onClicked.addListener(() => { + if (options.withPopup) { + browser.test.fail( + "The onClick listener should never fire if the browserAction has a popup." + ); + browser.test.notifyFail("execute-browser-action-on-clicked-fired"); + } else { + browser.test.notifyPass("execute-browser-action-on-clicked-fired"); + } + }); + } + + browser.runtime.onMessage.addListener(msg => { + if (msg == "from-browser-action-popup") { + browser.test.notifyPass("execute-browser-action-popup-opened"); + } + }); + + browser.test.sendMessage("send-keys"); + }); + }; + + let extension = ExtensionTestUtils.loadExtension(extensionOptions); + + extension.onMessage("send-keys", () => { + EventUtils.synthesizeKey(browser_action_key, { + altKey: true, + shiftKey: true, + }); + }); + + await extension.startup(); + + await SimpleTest.promiseFocus(window); + + if (options.inArea) { + let widget = getBrowserActionWidget(extension); + CustomizableUI.addWidgetToArea(widget.id, options.inArea); + } + + extension.sendMessage("options", options); + + if (options.withPopup) { + await extension.awaitFinish("execute-browser-action-popup-opened"); + + if (!getBrowserActionPopup(extension)) { + await awaitExtensionPanel(extension); + } + await closeBrowserAction(extension); + } else { + await extension.awaitFinish("execute-browser-action-on-clicked-fired"); + } + await extension.unload(); +} + +add_task(async function test_execute_browser_action_with_popup() { + await testExecuteBrowserActionWithOptions({ + withPopup: true, + }); +}); + +add_task(async function test_execute_browser_action_without_popup() { + await testExecuteBrowserActionWithOptions(); +}); + +add_task(async function test_execute_browser_action_command() { + await testExecuteBrowserActionWithOptions({ + withPopup: true, + expectedCommand: "_execute_browser_action", + }); +}); + +add_task(async function test_execute_action_with_popup() { + await testExecuteBrowserActionWithOptions({ + withPopup: true, + manifest_version: 3, + }); +}); + +add_task(async function test_execute_action_without_popup() { + await testExecuteBrowserActionWithOptions({ + manifest_version: 3, + }); +}); + +add_task(async function test_execute_action_command() { + await testExecuteBrowserActionWithOptions({ + withPopup: true, + expectedCommand: "_execute_action", + }); +}); + +add_task( + async function test_execute_browser_action_in_hamburger_menu_with_popup() { + await testExecuteBrowserActionWithOptions({ + withPopup: true, + inArea: getCustomizableUIPanelID(), + }); + } +); + +add_task( + async function test_execute_browser_action_in_hamburger_menu_without_popup() { + await testExecuteBrowserActionWithOptions({ + inArea: getCustomizableUIPanelID(), + }); + } +); diff --git a/browser/components/extensions/test/browser/browser_ext_commands_execute_page_action.js b/browser/components/extensions/test/browser/browser_ext_commands_execute_page_action.js new file mode 100644 index 0000000000..12c4bb7ef8 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_commands_execute_page_action.js @@ -0,0 +1,204 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const scriptPage = url => + `<html><head><meta charset="utf-8"><script src="${url}"></script></head><body>Test Popup</body></html>`; + +add_task(async function test_execute_page_action_without_popup() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + commands: { + _execute_page_action: { + suggested_key: { + default: "Alt+Shift+J", + }, + }, + "send-keys-command": { + suggested_key: { + default: "Alt+Shift+3", + }, + }, + }, + page_action: {}, + }, + + background: function () { + let isShown = false; + + browser.commands.onCommand.addListener(commandName => { + if (commandName == "_execute_page_action") { + browser.test.fail( + `The onCommand listener should never fire for ${commandName}.` + ); + } else if (commandName == "send-keys-command") { + if (!isShown) { + isShown = true; + browser.tabs.query({ currentWindow: true, active: true }, tabs => { + tabs.forEach(tab => { + browser.pageAction.show(tab.id); + }); + browser.test.sendMessage("send-keys"); + }); + } + } + }); + + browser.pageAction.onClicked.addListener(() => { + browser.test.assertTrue( + isShown, + "The onClicked event should fire if the page action is shown." + ); + browser.test.notifyPass("page-action-without-popup"); + }); + + browser.test.sendMessage("send-keys"); + }, + }); + + extension.onMessage("send-keys", () => { + EventUtils.synthesizeKey("j", { altKey: true, shiftKey: true }); + EventUtils.synthesizeKey("3", { altKey: true, shiftKey: true }); + }); + + await extension.startup(); + await extension.awaitFinish("page-action-without-popup"); + await extension.unload(); +}); + +add_task(async function test_execute_page_action_with_popup() { + let tab = await BrowserTestUtils.openNewForegroundTab( + window.gBrowser, + "http://example.com/" + ); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + commands: { + _execute_page_action: { + suggested_key: { + default: "Alt+Shift+J", + }, + }, + "send-keys-command": { + suggested_key: { + default: "Alt+Shift+3", + }, + }, + }, + page_action: { + default_popup: "popup.html", + browser_style: true, + }, + }, + + files: { + "popup.html": scriptPage("popup.js"), + "popup.js": function () { + browser.runtime.sendMessage("popup-opened"); + }, + }, + + background: function () { + let isShown = false; + + browser.commands.onCommand.addListener(message => { + if (message == "_execute_page_action") { + browser.test.fail( + `The onCommand listener should never fire for ${message}.` + ); + } + + if (message == "send-keys-command") { + if (!isShown) { + isShown = true; + browser.tabs.query({ currentWindow: true, active: true }, tabs => { + tabs.forEach(tab => { + browser.pageAction.show(tab.id); + }); + browser.test.sendMessage("send-keys"); + }); + } + } + }); + + browser.pageAction.onClicked.addListener(() => { + browser.test.fail( + `The onClicked listener should never fire when the pageAction has a popup.` + ); + }); + + browser.runtime.onMessage.addListener(msg => { + browser.test.assertEq(msg, "popup-opened", "expected popup opened"); + browser.test.assertTrue( + isShown, + "The onClicked event should fire if the page action is shown." + ); + browser.test.notifyPass("page-action-with-popup"); + }); + + browser.test.sendMessage("send-keys"); + }, + }); + + extension.onMessage("send-keys", () => { + EventUtils.synthesizeKey("j", { altKey: true, shiftKey: true }); + EventUtils.synthesizeKey("3", { altKey: true, shiftKey: true }); + }); + + await extension.startup(); + await extension.awaitFinish("page-action-with-popup"); + await extension.unload(); + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_execute_page_action_with_matching() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + commands: { + _execute_page_action: { + suggested_key: { + default: "Alt+Shift+J", + }, + }, + }, + page_action: { + default_popup: "popup.html", + show_matches: ["<all_urls>"], + browser_style: true, + }, + }, + + files: { + "popup.html": scriptPage("popup.js"), + "popup.js": function () { + window.addEventListener( + "load", + () => { + browser.test.notifyPass("page-action-with-popup"); + }, + { once: true } + ); + }, + }, + }); + + await extension.startup(); + let tab = await BrowserTestUtils.openNewForegroundTab( + window.gBrowser, + "http://example.com/" + ); + EventUtils.synthesizeKey("j", { altKey: true, shiftKey: true }); + info("Waiting for pageAction open."); + await extension.awaitFinish("page-action-with-popup"); + + // Bug 1447796 make sure the key command can close the page action + let panel = document.getElementById(`${makeWidgetId(extension.id)}-panel`); + let hidden = promisePopupHidden(panel); + EventUtils.synthesizeKey("j", { altKey: true, shiftKey: true }); + info("Waiting for pageAction close."); + await hidden; + + await extension.unload(); + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_commands_execute_sidebar_action.js b/browser/components/extensions/test/browser/browser_ext_commands_execute_sidebar_action.js new file mode 100644 index 0000000000..0f96138d09 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_commands_execute_sidebar_action.js @@ -0,0 +1,56 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_execute_sidebar_action() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + commands: { + _execute_sidebar_action: { + suggested_key: { + default: "Alt+Shift+J", + }, + }, + }, + sidebar_action: { + default_panel: "sidebar.html", + }, + }, + // We want to confirm that sidebar is not shown on every extension start, + // so we use an explicit APP_STARTUP. + startupReason: "APP_STARTUP", + files: { + "sidebar.html": ` + <!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + <script src="sidebar.js"></script> + </head> + </html> + `, + + "sidebar.js": function () { + browser.runtime.sendMessage("from-sidebar-action"); + }, + }, + background() { + browser.runtime.onMessage.addListener(msg => { + if (msg == "from-sidebar-action") { + browser.test.notifyPass("execute-sidebar-action-opened"); + } + }); + }, + }); + + await extension.startup(); + await SimpleTest.promiseFocus(window); + ok( + document.getElementById("sidebar-box").hidden, + `Sidebar box is not visible after "not-first" startup.` + ); + // Send the key to open the sidebar. + EventUtils.synthesizeKey("j", { altKey: true, shiftKey: true }); + await extension.awaitFinish("execute-sidebar-action-opened"); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_commands_getAll.js b/browser/components/extensions/test/browser/browser_ext_commands_getAll.js new file mode 100644 index 0000000000..d9b4c16272 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_commands_getAll.js @@ -0,0 +1,142 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function () { + let extension = ExtensionTestUtils.loadExtension({ + files: { + "_locales/en/messages.json": { + with_translation: { + message: "The description", + description: "A description", + }, + }, + }, + manifest: { + name: "Commands Extension", + default_locale: "en", + commands: { + "with-desciption": { + suggested_key: { + default: "Ctrl+Shift+Y", + }, + description: "should have a description", + }, + "without-description": { + suggested_key: { + default: "Ctrl+Shift+D", + }, + }, + "with-platform-info": { + suggested_key: { + mac: "Ctrl+Shift+M", + linux: "Ctrl+Shift+L", + windows: "Ctrl+Shift+W", + android: "Ctrl+Shift+A", + }, + }, + "with-translation": { + description: "__MSG_with_translation__", + }, + "without-suggested-key": { + description: "has no suggested_key", + }, + "without-suggested-key-nor-description": {}, + }, + }, + + background: function () { + browser.test.onMessage.addListener((message, additionalScope) => { + browser.commands.getAll(commands => { + let errorMessage = "getAll should return an array of commands"; + browser.test.assertEq(commands.length, 6, errorMessage); + + let command = commands.find(c => c.name == "with-desciption"); + + errorMessage = + "The description should match what is provided in the manifest"; + browser.test.assertEq( + "should have a description", + command.description, + errorMessage + ); + + errorMessage = + "The shortcut should match the default shortcut provided in the manifest"; + browser.test.assertEq("Ctrl+Shift+Y", command.shortcut, errorMessage); + + command = commands.find(c => c.name == "without-description"); + + errorMessage = + "The description should be empty when it is not provided"; + browser.test.assertEq(null, command.description, errorMessage); + + errorMessage = + "The shortcut should match the default shortcut provided in the manifest"; + browser.test.assertEq("Ctrl+Shift+D", command.shortcut, errorMessage); + + let platformKeys = { + macosx: "M", + linux: "L", + win: "W", + android: "A", + }; + + command = commands.find(c => c.name == "with-platform-info"); + let platformKey = platformKeys[additionalScope.platform]; + let shortcut = `Ctrl+Shift+${platformKey}`; + errorMessage = `The shortcut should match the one provided in the manifest for OS='${additionalScope.platform}'`; + browser.test.assertEq(shortcut, command.shortcut, errorMessage); + + command = commands.find(c => c.name == "with-translation"); + browser.test.assertEq( + command.description, + "The description", + "The description can be localized" + ); + + command = commands.find(c => c.name == "without-suggested-key"); + + browser.test.assertEq( + "has no suggested_key", + command.description, + "The description should match what is provided in the manifest" + ); + + browser.test.assertEq( + "", + command.shortcut, + "The shortcut should be empty if not provided" + ); + + command = commands.find( + c => c.name == "without-suggested-key-nor-description" + ); + + browser.test.assertEq( + null, + command.description, + "The description should be empty when it is not provided" + ); + + browser.test.assertEq( + "", + command.shortcut, + "The shortcut should be empty if not provided" + ); + + browser.test.notifyPass("commands"); + }); + }); + browser.test.sendMessage("ready"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + extension.sendMessage("additional-scope", { + platform: AppConstants.platform, + }); + await extension.awaitFinish("commands"); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_commands_onChanged.js b/browser/components/extensions/test/browser/browser_ext_commands_onChanged.js new file mode 100644 index 0000000000..2b44d472cc --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_commands_onChanged.js @@ -0,0 +1,59 @@ +"use strict"; + +add_task(async function () { + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + browser_specific_settings: { gecko: { id: "commands@mochi.test" } }, + commands: { + foo: { + suggested_key: { + default: "Ctrl+Shift+V", + }, + description: "The foo command", + }, + }, + }, + background: async function () { + const { commands } = browser.runtime.getManifest(); + + const originalFoo = commands.foo; + + let resolver = {}; + resolver.promise = new Promise(resolve => (resolver.resolve = resolve)); + + browser.commands.onChanged.addListener(update => { + browser.test.assertDeepEq( + update, + { + name: "foo", + newShortcut: "Ctrl+Shift+L", + oldShortcut: originalFoo.suggested_key.default, + }, + `The name should match what was provided in the manifest. + The new shortcut should match what was provided in the update. + The old shortcut should match what was provided in the manifest + ` + ); + browser.test.assertFalse( + resolver.hasResolvedAlready, + `resolver was not resolved yet` + ); + resolver.resolve(); + resolver.hasResolvedAlready = true; + }); + + await browser.commands.update({ name: "foo", shortcut: "Ctrl+Shift+L" }); + // We're checking that nothing emits when + // the new shortcut is identical to the old one + await browser.commands.update({ name: "foo", shortcut: "Ctrl+Shift+L" }); + + await resolver.promise; + + browser.test.notifyPass("commands"); + }, + }); + await extension.startup(); + await extension.awaitFinish("commands"); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_commands_onCommand.js b/browser/components/extensions/test/browser/browser_ext_commands_onCommand.js new file mode 100644 index 0000000000..db900f7ea4 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_commands_onCommand.js @@ -0,0 +1,442 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_user_defined_commands() { + const testCommands = [ + // Ctrl Shortcuts + { + name: "toggle-ctrl-a", + shortcut: "Ctrl+A", + key: "A", + modifiers: { + accelKey: true, + }, + }, + { + name: "toggle-ctrl-up", + shortcut: "Ctrl+Up", + key: "VK_UP", + modifiers: { + accelKey: true, + }, + }, + // Alt Shortcuts + { + name: "toggle-alt-a", + shortcut: "Alt+A", + key: "A", + modifiers: { + altKey: true, + }, + }, + { + name: "toggle-alt-down", + shortcut: "Alt+Down", + key: "VK_DOWN", + modifiers: { + altKey: true, + }, + }, + // Mac Shortcuts + { + name: "toggle-command-shift-page-up", + shortcutMac: "Command+Shift+PageUp", + key: "VK_PAGE_UP", + modifiers: { + accelKey: true, + shiftKey: true, + }, + }, + { + name: "toggle-mac-control-shift+period", + shortcut: "Ctrl+Shift+Period", + shortcutMac: "MacCtrl+Shift+Period", + key: "VK_PERIOD", + modifiers: { + ctrlKey: true, + shiftKey: true, + }, + }, + // Ctrl+Shift Shortcuts + { + name: "toggle-ctrl-shift-left", + shortcut: "Ctrl+Shift+Left", + key: "VK_LEFT", + modifiers: { + accelKey: true, + shiftKey: true, + }, + }, + { + name: "toggle-ctrl-shift-1", + shortcut: "Ctrl+Shift+1", + key: "1", + modifiers: { + accelKey: true, + shiftKey: true, + }, + }, + // Alt+Shift Shortcuts + { + name: "toggle-alt-shift-1", + shortcut: "Alt+Shift+1", + key: "1", + modifiers: { + altKey: true, + shiftKey: true, + }, + }, + { + name: "toggle-alt-shift-a", + shortcut: "Alt+Shift+A", + key: "A", + modifiers: { + altKey: true, + shiftKey: true, + }, + }, + { + name: "toggle-alt-shift-right", + shortcut: "Alt+Shift+Right", + key: "VK_RIGHT", + modifiers: { + altKey: true, + shiftKey: true, + }, + }, + // Function keys + { + name: "function-keys-Alt+Shift+F3", + shortcut: "Alt+Shift+F3", + key: "VK_F3", + modifiers: { + altKey: true, + shiftKey: true, + }, + }, + { + name: "function-keys-F2", + shortcut: "F2", + key: "VK_F2", + modifiers: { + altKey: false, + shiftKey: false, + }, + }, + // Misc Shortcuts + { + name: "valid-command-with-unrecognized-property-name", + shortcut: "Alt+Shift+3", + key: "3", + modifiers: { + altKey: true, + shiftKey: true, + }, + unrecognized_property: "with-a-random-value", + }, + { + name: "spaces-in-shortcut-name", + shortcut: " Alt + Shift + 2 ", + key: "2", + modifiers: { + altKey: true, + shiftKey: true, + }, + }, + { + name: "toggle-ctrl-space", + shortcut: "Ctrl+Space", + key: "VK_SPACE", + modifiers: { + accelKey: true, + }, + }, + { + name: "toggle-ctrl-comma", + shortcut: "Ctrl+Comma", + key: "VK_COMMA", + modifiers: { + accelKey: true, + }, + }, + { + name: "toggle-ctrl-period", + shortcut: "Ctrl+Period", + key: "VK_PERIOD", + modifiers: { + accelKey: true, + }, + }, + { + name: "toggle-ctrl-alt-v", + shortcut: "Ctrl+Alt+V", + key: "V", + modifiers: { + accelKey: true, + altKey: true, + }, + }, + ]; + + // Create a window before the extension is loaded. + let win1 = await BrowserTestUtils.openNewBrowserWindow(); + BrowserTestUtils.startLoadingURIString( + win1.gBrowser.selectedBrowser, + "about:robots" + ); + await BrowserTestUtils.browserLoaded(win1.gBrowser.selectedBrowser); + + // We would have previously focused the window's content area after the + // navigation from about:blank to about:robots, but bug 1596738 changed this + // to prevent the browser element from stealing focus from the urlbar. + // + // Some of these command tests (specifically alt-a on linux) were designed + // based on focus being in the browser content, so we need to manually focus + // the browser here to preserve that assumption. + win1.gBrowser.selectedBrowser.focus(); + + let commands = {}; + let isMac = AppConstants.platform == "macosx"; + let totalMacOnlyCommands = 0; + let numberNumericCommands = 4; + + for (let testCommand of testCommands) { + let command = { + suggested_key: {}, + }; + + if (testCommand.shortcut) { + command.suggested_key.default = testCommand.shortcut; + } + + if (testCommand.shortcutMac) { + command.suggested_key.mac = testCommand.shortcutMac; + } + + if (testCommand.shortcutMac && !testCommand.shortcut) { + totalMacOnlyCommands++; + } + + if (testCommand.unrecognized_property) { + command.unrecognized_property = testCommand.unrecognized_property; + } + + commands[testCommand.name] = command; + } + + function background() { + browser.commands.onCommand.addListener(commandName => { + browser.test.sendMessage("oncommand", commandName); + }); + browser.test.sendMessage("ready"); + } + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + commands: commands, + }, + background, + }); + + SimpleTest.waitForExplicitFinish(); + let waitForConsole = new Promise(resolve => { + SimpleTest.monitorConsole(resolve, [ + { + message: + /Reading manifest: Warning processing commands.*.unrecognized_property: An unexpected property was found/, + }, + ]); + }); + ExtensionTestUtils.failOnSchemaWarnings(false); + await extension.startup(); + ExtensionTestUtils.failOnSchemaWarnings(true); + await extension.awaitMessage("ready"); + + async function runTest(window) { + for (let testCommand of testCommands) { + if (testCommand.shortcutMac && !testCommand.shortcut && !isMac) { + continue; + } + EventUtils.synthesizeKey(testCommand.key, testCommand.modifiers, window); + let message = await extension.awaitMessage("oncommand"); + is( + message, + testCommand.name, + `Expected onCommand listener to fire with the correct name: ${testCommand.name}` + ); + } + } + + // Create another window after the extension is loaded. + let win2 = await BrowserTestUtils.openNewBrowserWindow(); + BrowserTestUtils.startLoadingURIString( + win2.gBrowser.selectedBrowser, + "about:robots" + ); + await BrowserTestUtils.browserLoaded(win2.gBrowser.selectedBrowser); + + // See comment above. + win2.gBrowser.selectedBrowser.focus(); + + let totalTestCommands = + Object.keys(testCommands).length + numberNumericCommands; + let expectedCommandsRegistered = isMac + ? totalTestCommands + : totalTestCommands - totalMacOnlyCommands; + + // Confirm the keysets have been added to both windows. + let keysetID = `ext-keyset-id-${makeWidgetId(extension.id)}`; + let keyset = win1.document.getElementById(keysetID); + Assert.notEqual(keyset, null, "Expected keyset to exist"); + is( + keyset.children.length, + expectedCommandsRegistered, + "Expected keyset to have the correct number of children" + ); + + keyset = win2.document.getElementById(keysetID); + Assert.notEqual(keyset, null, "Expected keyset to exist"); + is( + keyset.children.length, + expectedCommandsRegistered, + "Expected keyset to have the correct number of children" + ); + + // Confirm that the commands are registered to both windows. + await focusWindow(win1); + await runTest(win1); + + await focusWindow(win2); + await runTest(win2); + + let privateWin = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + BrowserTestUtils.startLoadingURIString( + privateWin.gBrowser.selectedBrowser, + "about:robots" + ); + await BrowserTestUtils.browserLoaded(privateWin.gBrowser.selectedBrowser); + + // See comment above. + privateWin.gBrowser.selectedBrowser.focus(); + + keyset = privateWin.document.getElementById(keysetID); + is(keyset, null, "Expected keyset is not added to private windows"); + + await extension.unload(); + + // Confirm that the keysets have been removed from both windows after the extension is unloaded. + keyset = win1.document.getElementById(keysetID); + is(keyset, null, "Expected keyset to be removed from the window"); + + keyset = win2.document.getElementById(keysetID); + is(keyset, null, "Expected keyset to be removed from the window"); + + // Test that given permission the keyset is added to the private window. + extension = ExtensionTestUtils.loadExtension({ + manifest: { + commands: commands, + }, + incognitoOverride: "spanning", + background, + }); + + // unrecognized_property in manifest triggers warning. + ExtensionTestUtils.failOnSchemaWarnings(false); + await extension.startup(); + ExtensionTestUtils.failOnSchemaWarnings(true); + await extension.awaitMessage("ready"); + keysetID = `ext-keyset-id-${makeWidgetId(extension.id)}`; + + keyset = win1.document.getElementById(keysetID); + Assert.notEqual(keyset, null, "Expected keyset to exist on win1"); + is( + keyset.children.length, + expectedCommandsRegistered, + "Expected keyset to have the correct number of children" + ); + + keyset = win2.document.getElementById(keysetID); + Assert.notEqual(keyset, null, "Expected keyset to exist on win2"); + is( + keyset.children.length, + expectedCommandsRegistered, + "Expected keyset to have the correct number of children" + ); + + keyset = privateWin.document.getElementById(keysetID); + Assert.notEqual(keyset, null, "Expected keyset was added to private windows"); + is( + keyset.children.length, + expectedCommandsRegistered, + "Expected keyset to have the correct number of children" + ); + + await focusWindow(privateWin); + await runTest(privateWin); + + await extension.unload(); + + keyset = privateWin.document.getElementById(keysetID); + is(keyset, null, "Expected keyset to be removed from the private window"); + + await BrowserTestUtils.closeWindow(win1); + await BrowserTestUtils.closeWindow(win2); + await BrowserTestUtils.closeWindow(privateWin); + + SimpleTest.endMonitorConsole(); + await waitForConsole; +}); + +add_task(async function test_commands_event_page() { + await SpecialPowers.pushPrefEnv({ + set: [["extensions.eventPages.enabled", true]], + }); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + browser_specific_settings: { gecko: { id: "eventpage@commands" } }, + background: { persistent: false }, + commands: { + "toggle-feature": { + suggested_key: { + default: "Alt+Shift+J", + }, + }, + }, + }, + background() { + browser.commands.onCommand.addListener(name => { + browser.test.assertEq(name, "toggle-feature", "command received"); + browser.test.sendMessage("onCommand"); + }); + browser.test.sendMessage("ready"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + assertPersistentListeners(extension, "commands", "onCommand", { + primed: false, + }); + + // test events waken background + await extension.terminateBackground(); + assertPersistentListeners(extension, "commands", "onCommand", { + primed: true, + }); + + EventUtils.synthesizeKey("j", { altKey: true, shiftKey: true }); + + await extension.awaitMessage("ready"); + await extension.awaitMessage("onCommand"); + ok(true, "persistent event woke background"); + assertPersistentListeners(extension, "commands", "onCommand", { + primed: false, + }); + + await extension.unload(); + await SpecialPowers.popPrefEnv(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_commands_update.js b/browser/components/extensions/test/browser/browser_ext_commands_update.js new file mode 100644 index 0000000000..5e1f05e346 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_commands_update.js @@ -0,0 +1,428 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", + ExtensionSettingsStore: + "resource://gre/modules/ExtensionSettingsStore.sys.mjs", +}); + +function enableAddon(addon) { + return new Promise(resolve => { + AddonManager.addAddonListener({ + onEnabled(enabledAddon) { + if (enabledAddon.id == addon.id) { + resolve(); + AddonManager.removeAddonListener(this); + } + }, + }); + addon.enable(); + }); +} + +function disableAddon(addon) { + return new Promise(resolve => { + AddonManager.addAddonListener({ + onDisabled(disabledAddon) { + if (disabledAddon.id == addon.id) { + resolve(); + AddonManager.removeAddonListener(this); + } + }, + }); + addon.disable(); + }); +} + +add_task(async function test_update_defined_command() { + let extension; + let updatedExtension; + + registerCleanupFunction(async () => { + await extension.unload(); + + // updatedExtension might not have started up if we didn't make it that far. + if (updatedExtension) { + await updatedExtension.unload(); + } + + // Check that ESS is cleaned up on uninstall. + let storedCommands = ExtensionSettingsStore.getAllForExtension( + extension.id, + "commands" + ); + is(storedCommands.length, 0, "There are no stored commands after unload"); + }); + + extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + version: "1.0", + browser_specific_settings: { gecko: { id: "commands@mochi.test" } }, + commands: { + foo: { + suggested_key: { + default: "Ctrl+Shift+I", + }, + description: "The foo command", + }, + }, + }, + background() { + browser.test.onMessage.addListener(async (msg, data) => { + if (msg == "update") { + await browser.commands.update(data); + return browser.test.sendMessage("updateDone"); + } else if (msg == "reset") { + await browser.commands.reset(data); + return browser.test.sendMessage("resetDone"); + } else if (msg != "run") { + return; + } + // Test initial manifest command. + let commands = await browser.commands.getAll(); + browser.test.assertEq(1, commands.length, "There is 1 command"); + let command = commands[0]; + browser.test.assertEq("foo", command.name, "The name is right"); + browser.test.assertEq( + "The foo command", + command.description, + "The description is right" + ); + browser.test.assertEq( + "Ctrl+Shift+I", + command.shortcut, + "The shortcut is right" + ); + + // Update the shortcut. + await browser.commands.update({ + name: "foo", + shortcut: "Ctrl+Shift+L", + }); + + // Test the updated shortcut. + commands = await browser.commands.getAll(); + browser.test.assertEq(1, commands.length, "There is still 1 command"); + command = commands[0]; + browser.test.assertEq("foo", command.name, "The name is unchanged"); + browser.test.assertEq( + "The foo command", + command.description, + "The description is unchanged" + ); + browser.test.assertEq( + "Ctrl+Shift+L", + command.shortcut, + "The shortcut is updated" + ); + + // Update the description. + await browser.commands.update({ + name: "foo", + description: "The only command", + }); + + // Test the updated shortcut. + commands = await browser.commands.getAll(); + browser.test.assertEq(1, commands.length, "There is still 1 command"); + command = commands[0]; + browser.test.assertEq("foo", command.name, "The name is unchanged"); + browser.test.assertEq( + "The only command", + command.description, + "The description is updated" + ); + browser.test.assertEq( + "Ctrl+Shift+L", + command.shortcut, + "The shortcut is unchanged" + ); + + // Clear the shortcut. + await browser.commands.update({ + name: "foo", + shortcut: "", + }); + commands = await browser.commands.getAll(); + browser.test.assertEq(1, commands.length, "There is still 1 command"); + command = commands[0]; + browser.test.assertEq("foo", command.name, "The name is unchanged"); + browser.test.assertEq( + "The only command", + command.description, + "The description is unchanged" + ); + browser.test.assertEq("", command.shortcut, "The shortcut is empty"); + + // Update the description and shortcut. + await browser.commands.update({ + name: "foo", + description: "The new command", + shortcut: " Alt+ Shift +9", + }); + + // Test the updated shortcut. + commands = await browser.commands.getAll(); + browser.test.assertEq(1, commands.length, "There is still 1 command"); + command = commands[0]; + browser.test.assertEq("foo", command.name, "The name is unchanged"); + browser.test.assertEq( + "The new command", + command.description, + "The description is updated" + ); + browser.test.assertEq( + "Alt+Shift+9", + command.shortcut, + "The shortcut is updated" + ); + + // Test a bad shortcut update. + browser.test.assertThrows( + () => + browser.commands.update({ name: "foo", shortcut: "Ctl+Shift+L" }), + /Type error for parameter detail .+ primary modifier and a key/, + "It rejects for a bad shortcut" + ); + + // Try to update a command that doesn't exist. + await browser.test.assertRejects( + browser.commands.update({ name: "bar", shortcut: "Ctrl+Shift+L" }), + 'Unknown command "bar"', + "It rejects for an unknown command" + ); + + browser.test.notifyPass("commands"); + }); + browser.test.sendMessage("ready"); + }, + }); + + await extension.startup(); + + function extensionKeyset(extensionId) { + return document.getElementById( + makeWidgetId(`ext-keyset-id-${extensionId}`) + ); + } + + function checkKey(extensionId, shortcutKey, modifiers) { + let keyset = extensionKeyset(extensionId); + is(keyset.children.length, 1, "There is 1 key in the keyset"); + let key = keyset.children[0]; + is(key.getAttribute("key"), shortcutKey, "The key is correct"); + is(key.getAttribute("modifiers"), modifiers, "The modifiers are correct"); + } + + function checkNumericKey(extensionId, key, modifiers) { + let keyset = extensionKeyset(extensionId); + is( + keyset.children.length, + 2, + "There are 2 keys in the keyset now, 1 of which contains a keycode." + ); + let numpadKey = keyset.children[0]; + is( + numpadKey.getAttribute("keycode"), + `VK_NUMPAD${key}`, + "The numpad keycode is correct." + ); + is( + numpadKey.getAttribute("modifiers"), + modifiers, + "The modifiers are correct" + ); + + let originalNumericKey = keyset.children[1]; + is( + originalNumericKey.getAttribute("keycode"), + `VK_${key}`, + "The original key is correct." + ); + is( + originalNumericKey.getAttribute("modifiers"), + modifiers, + "The modifiers are correct" + ); + } + + // Check that the <key> is set for the original shortcut. + checkKey(extension.id, "I", "accel,shift"); + + await extension.awaitMessage("ready"); + extension.sendMessage("run"); + await extension.awaitFinish("commands"); + + // Check that the <keycode> has been updated. + checkNumericKey(extension.id, "9", "alt,shift"); + + // Check that the updated command is stored in ExtensionSettingsStore. + let storedCommands = ExtensionSettingsStore.getAllForExtension( + extension.id, + "commands" + ); + is(storedCommands.length, 1, "There is only one stored command"); + let command = ExtensionSettingsStore.getSetting( + "commands", + "foo", + extension.id + ).value; + is(command.description, "The new command", "The description is stored"); + is(command.shortcut, "Alt+Shift+9", "The shortcut is stored"); + + // Check that the key is updated immediately. + extension.sendMessage("update", { name: "foo", shortcut: "Ctrl+Shift+M" }); + await extension.awaitMessage("updateDone"); + checkKey(extension.id, "M", "accel,shift"); + + // Ensure all successive updates are stored. + // Force the command to only have a description saved. + await ExtensionSettingsStore.addSetting(extension.id, "commands", "foo", { + description: "description only", + }); + // This command now only has a description set in storage, also update the shortcut. + extension.sendMessage("update", { name: "foo", shortcut: "Alt+Shift+9" }); + await extension.awaitMessage("updateDone"); + let storedCommand = await ExtensionSettingsStore.getSetting( + "commands", + "foo", + extension.id + ); + is( + storedCommand.value.shortcut, + "Alt+Shift+9", + "The shortcut is saved correctly" + ); + is( + storedCommand.value.description, + "description only", + "The description is saved correctly" + ); + + // Calling browser.commands.reset("foo") should reset to manifest version. + extension.sendMessage("reset", "foo"); + await extension.awaitMessage("resetDone"); + + checkKey(extension.id, "I", "accel,shift"); + + // Check that enable/disable removes the keyset and reloads the saved command. + let addon = await AddonManager.getAddonByID(extension.id); + await disableAddon(addon); + let keyset = extensionKeyset(extension.id); + is(keyset, null, "The extension keyset is removed when disabled"); + // Add some commands to storage, only "foo" should get loaded. + await ExtensionSettingsStore.addSetting(extension.id, "commands", "foo", { + shortcut: "Alt+Shift+9", + }); + await ExtensionSettingsStore.addSetting(extension.id, "commands", "unknown", { + shortcut: "Ctrl+Shift+P", + }); + storedCommands = ExtensionSettingsStore.getAllForExtension( + extension.id, + "commands" + ); + is(storedCommands.length, 2, "There are now 2 commands stored"); + await enableAddon(addon); + // Wait for the keyset to appear (it's async on enable). + await TestUtils.waitForCondition(() => extensionKeyset(extension.id)); + // The keyset is back with the value from ExtensionSettingsStore. + checkNumericKey(extension.id, "9", "alt,shift"); + + // Check that an update to a shortcut in the manifest is mapped correctly. + updatedExtension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + version: "1.0", + browser_specific_settings: { gecko: { id: "commands@mochi.test" } }, + commands: { + foo: { + suggested_key: { + default: "Ctrl+Shift+L", + }, + description: "The foo command", + }, + }, + }, + }); + await updatedExtension.startup(); + + await TestUtils.waitForCondition(() => extensionKeyset(extension.id)); + // Shortcut is unchanged since it was previously updated. + checkNumericKey(extension.id, "9", "alt,shift"); +}); + +add_task(async function updateSidebarCommand() { + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + commands: { + _execute_sidebar_action: { + suggested_key: { + default: "Ctrl+Shift+E", + }, + }, + }, + sidebar_action: { + default_panel: "sidebar.html", + }, + }, + background() { + browser.test.onMessage.addListener(async (msg, data) => { + if (msg == "updateShortcut") { + await browser.commands.update(data); + return browser.test.sendMessage("done"); + } + throw new Error("Unknown message"); + }); + }, + files: { + "sidebar.html": ` + <!DOCTYPE html> + <html> + <head><meta charset="utf-8"/> + <script src="sidebar.js"></script> + </head> + <body> + A Test Sidebar + </body></html> + `, + + "sidebar.js": function () { + window.onload = () => { + browser.test.sendMessage("sidebar"); + }; + }, + }, + }); + await extension.startup(); + await extension.awaitMessage("sidebar"); + + // Show and hide the switcher panel to generate the initial shortcuts. + let switcherShown = promisePopupShown(SidebarUI._switcherPanel); + SidebarUI.showSwitcherPanel(); + await switcherShown; + let switcherHidden = promisePopupHidden(SidebarUI._switcherPanel); + SidebarUI.hideSwitcherPanel(); + await switcherHidden; + + let menuitemId = `sidebarswitcher_menu_${makeWidgetId( + extension.id + )}-sidebar-action`; + let menuitem = document.getElementById(menuitemId); + let acceltext = menuitem.getAttribute("acceltext"); + ok(acceltext.endsWith("E"), "The menuitem has the accel text set"); + + extension.sendMessage("updateShortcut", { + name: "_execute_sidebar_action", + shortcut: "Ctrl+Shift+M", + }); + await extension.awaitMessage("done"); + + acceltext = menuitem.getAttribute("acceltext"); + ok(acceltext.endsWith("M"), "The menuitem accel text has been updated"); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_connect_and_move_tabs.js b/browser/components/extensions/test/browser/browser_ext_connect_and_move_tabs.js new file mode 100644 index 0000000000..50f5380ce6 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_connect_and_move_tabs.js @@ -0,0 +1,104 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +// Tests that the Port object created by browser.runtime.connect is not +// prematurely disconnected as the underlying message managers change when a +// tab is moved between windows. + +function loadExtension() { + return ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + js: ["script.js"], + matches: ["http://mochi.test/?discoTest"], + }, + ], + }, + background() { + browser.runtime.onConnect.addListener(port => { + port.onDisconnect.addListener(() => { + browser.test.fail( + "onDisconnect should not fire because the port is to be closed from this side" + ); + browser.test.sendMessage("port_disconnected"); + }); + port.onMessage.addListener(async msg => { + browser.test.assertEq("connect_from_script", msg, "expected message"); + // Move a tab to a new window and back. Regression test for bugzil.la/1448674 + let { windowId, id: tabId, index } = port.sender.tab; + await browser.windows.create({ tabId }); + await browser.tabs.move(tabId, { index, windowId }); + await browser.windows.create({ tabId }); + await browser.tabs.move(tabId, { index, windowId }); + try { + // When the port is unexpectedly disconnected, postMessage will throw an error. + port.postMessage("ping"); + } catch (e) { + browser.test.fail(`Error: ${e} :: ${e.stack}`); + browser.test.sendMessage("port_ping_ponged_before_disconnect"); + } + }); + + browser.runtime.onMessage.addListener(async (msg, sender) => { + if (msg == "disconnect-me") { + port.disconnect(); + // Now port.onDisconnect should fire in the content script. + } else if (msg == "close-tab") { + await browser.tabs.remove(sender.tab.id); + browser.test.sendMessage("closed_tab"); + } + }); + }); + + browser.test.onMessage.addListener(msg => { + browser.test.assertEq("open_extension_tab", msg, "expected message"); + browser.tabs.create({ url: "tab.html" }); + }); + }, + + files: { + "tab.html": ` + <!DOCTYPE html><meta charset="utf-8"> + <script src="script.js"></script> + `, + "script.js": function () { + let port = browser.runtime.connect(); + port.onMessage.addListener(msg => { + browser.test.assertEq("ping", msg, "expected message"); + browser.test.sendMessage("port_ping_ponged_before_disconnect"); + port.onDisconnect.addListener(() => { + browser.test.sendMessage("port_disconnected"); + browser.runtime.sendMessage("close-tab"); + }); + browser.runtime.sendMessage("disconnect-me"); + }); + port.postMessage("connect_from_script"); + }, + }, + }); +} + +add_task(async function contentscript_connect_and_move_tabs() { + let extension = loadExtension(); + await extension.startup(); + await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://mochi.test:8888/?discoTest" + ); + await extension.awaitMessage("port_ping_ponged_before_disconnect"); + await extension.awaitMessage("port_disconnected"); + await extension.awaitMessage("closed_tab"); + await extension.unload(); +}); + +add_task(async function extension_tab_connect_and_move_tabs() { + let extension = loadExtension(); + await extension.startup(); + extension.sendMessage("open_extension_tab"); + await extension.awaitMessage("port_ping_ponged_before_disconnect"); + await extension.awaitMessage("port_disconnected"); + await extension.awaitMessage("closed_tab"); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_contentscript_animate.js b/browser/components/extensions/test/browser/browser_ext_contentscript_animate.js new file mode 100644 index 0000000000..bbe90207a4 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_contentscript_animate.js @@ -0,0 +1,135 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_animate() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://mochi.test:8888/" + ); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://mochi.test/*"], + js: ["content-script.js"], + }, + ], + }, + + files: { + "content-script.js": function () { + let elem = document.getElementsByTagName("body")[0]; + elem.style.border = "2px solid red"; + + let anim = elem.animate({ opacity: [1, 0] }, 2000); + let frames = anim.effect.getKeyframes(); + browser.test.assertEq( + frames.length, + 2, + "frames for Element.animate should be non-zero" + ); + browser.test.assertEq( + frames[0].opacity, + "1", + "first frame opacity for Element.animate should be specified value" + ); + browser.test.assertEq( + frames[0].computedOffset, + 0, + "first frame offset for Element.animate should be 0" + ); + browser.test.assertEq( + frames[1].opacity, + "0", + "last frame opacity for Element.animate should be specified value" + ); + browser.test.assertEq( + frames[1].computedOffset, + 1, + "last frame offset for Element.animate should be 1" + ); + + browser.test.notifyPass("contentScriptAnimate"); + }, + }, + }); + + await extension.startup(); + await extension.awaitFinish("contentScriptAnimate"); + await extension.unload(); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_KeyframeEffect() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://mochi.test:8888/" + ); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://mochi.test/*"], + js: ["content-script.js"], + }, + ], + }, + + files: { + "content-script.js": function () { + let elem = document.getElementsByTagName("body")[0]; + elem.style.border = "2px solid red"; + + let effect = new KeyframeEffect( + elem, + [ + { opacity: 1, offset: 0 }, + { opacity: 0, offset: 1 }, + ], + { duration: 1000, fill: "forwards" } + ); + let frames = effect.getKeyframes(); + browser.test.assertEq( + frames.length, + 2, + "frames for KeyframeEffect ctor should be non-zero" + ); + browser.test.assertEq( + frames[0].opacity, + "1", + "first frame opacity for KeyframeEffect ctor should be specified value" + ); + browser.test.assertEq( + frames[0].computedOffset, + 0, + "first frame offset for KeyframeEffect ctor should be 0" + ); + browser.test.assertEq( + frames[1].opacity, + "0", + "last frame opacity for KeyframeEffect ctor should be specified value" + ); + browser.test.assertEq( + frames[1].computedOffset, + 1, + "last frame offset for KeyframeEffect ctor should be 1" + ); + + let animation = new Animation(effect, document.timeline); + animation.play(); + + browser.test.notifyPass("contentScriptKeyframeEffect"); + }, + }, + }); + + await extension.startup(); + await extension.awaitFinish("contentScriptKeyframeEffect"); + await extension.unload(); + + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_contentscript_connect.js b/browser/components/extensions/test/browser/browser_ext_contentscript_connect.js new file mode 100644 index 0000000000..11dbe88240 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_contentscript_connect.js @@ -0,0 +1,94 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function () { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://mochi.test:8888/" + ); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["http://mochi.test/"], + }, + + background: function () { + let ports_received = 0; + let port_messages_received = 0; + + browser.runtime.onConnect.addListener(port => { + browser.test.assertTrue(!!port, "port1 received"); + + ports_received++; + browser.test.assertEq(1, ports_received, "1 port received"); + + port.onMessage.addListener((msg, msgPort) => { + browser.test.assertEq( + "port message", + msg, + "listener1 port message received" + ); + browser.test.assertEq( + port, + msgPort, + "onMessage should receive port as second argument" + ); + + port_messages_received++; + browser.test.assertEq( + 1, + port_messages_received, + "1 port message received" + ); + }); + }); + browser.runtime.onConnect.addListener(port => { + browser.test.assertTrue(!!port, "port2 received"); + + ports_received++; + browser.test.assertEq(2, ports_received, "2 ports received"); + + port.onMessage.addListener((msg, msgPort) => { + browser.test.assertEq( + "port message", + msg, + "listener2 port message received" + ); + browser.test.assertEq( + port, + msgPort, + "onMessage should receive port as second argument" + ); + + port_messages_received++; + browser.test.assertEq( + 2, + port_messages_received, + "2 port messages received" + ); + + browser.test.notifyPass("contentscript_connect.pass"); + }); + }); + + browser.tabs.executeScript({ file: "script.js" }).catch(e => { + browser.test.fail(`Error: ${e} :: ${e.stack}`); + browser.test.notifyFail("contentscript_connect.pass"); + }); + }, + + files: { + "script.js": function () { + let port = browser.runtime.connect(); + port.postMessage("port message"); + }, + }, + }); + + await extension.startup(); + await extension.awaitFinish("contentscript_connect.pass"); + await extension.unload(); + + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_contentscript_cross_docGroup_adoption.js b/browser/components/extensions/test/browser/browser_ext_contentscript_cross_docGroup_adoption.js new file mode 100644 index 0000000000..a0d367ec93 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_contentscript_cross_docGroup_adoption.js @@ -0,0 +1,63 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_cross_docGroup_adoption() { + await SpecialPowers.pushPrefEnv({ + set: [["extensions.content_web_accessible.enabled", true]], + }); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.com/" + ); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://example.com/"], + js: ["content-script.js"], + }, + ], + web_accessible_resources: ["current.html"], + }, + + files: { + "current.html": "<html>data</html>", + "content-script.js": function () { + let iframe = document.createElement("iframe"); + iframe.src = browser.runtime.getURL("current.html"); + document.body.appendChild(iframe); + + iframe.addEventListener( + "load", + () => { + let parser = new DOMParser(); + let bold = parser.parseFromString( + "<b>NodeAdopted</b>", + "text/html" + ); + let doc = iframe.contentDocument; + + let node = document.adoptNode(bold.documentElement); + doc.replaceChild(node, doc.documentElement); + + const expected = + "<html><head></head><body><b>NodeAdopted</b></body></html>"; + browser.test.assertEq(expected, doc.documentElement.outerHTML); + + browser.test.notifyPass("nodeAdopted"); + }, + { once: true } + ); + }, + }, + }); + + await extension.startup(); + await extension.awaitFinish("nodeAdopted"); + await extension.unload(); + + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_contentscript_cross_docGroup_adoption_xhr.js b/browser/components/extensions/test/browser/browser_ext_contentscript_cross_docGroup_adoption_xhr.js new file mode 100644 index 0000000000..274efb71f3 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_contentscript_cross_docGroup_adoption_xhr.js @@ -0,0 +1,56 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_cross_docGroup_adoption() { + await SpecialPowers.pushPrefEnv({ + set: [["extensions.content_web_accessible.enabled", true]], + }); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.com/" + ); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://example.com/"], + js: ["content-script.js"], + }, + ], + web_accessible_resources: ["blank.html"], + }, + + files: { + "blank.html": "<html>data</html>", + "content-script.js": function () { + let xhr = new XMLHttpRequest(); + xhr.responseType = "document"; + xhr.open("GET", browser.runtime.getURL("blank.html")); + + xhr.onload = function () { + let doc = xhr.response; + try { + let node = doc.body.cloneNode(true); + document.body.appendChild(node); + browser.test.notifyPass("nodeAdopted"); + } catch (SecurityError) { + browser.test.assertTrue( + false, + "The above node adoption should not fail" + ); + } + }; + xhr.send(); + }, + }, + }); + + await extension.startup(); + await extension.awaitFinish("nodeAdopted"); + await extension.unload(); + + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_contentscript_dataTransfer_files.js b/browser/components/extensions/test/browser/browser_ext_contentscript_dataTransfer_files.js new file mode 100644 index 0000000000..4819f98475 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_contentscript_dataTransfer_files.js @@ -0,0 +1,104 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const TEST_ORIGIN = "http://mochi.test:8888"; +const TEST_BASEURL = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + TEST_ORIGIN +); + +const TEST_URL = `${TEST_BASEURL}file_dataTransfer_files.html`; + +// This test ensure that we don't cache the DataTransfer files instances when +// they are being accessed by an extension content or user script (regression +// test related to Bug 1707214). +add_task(async function test_contentAndUserScripts_dataTransfer_files() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["http://mochi.test/"], + user_scripts: {}, + }, + + background: async function () { + await browser.contentScripts.register({ + js: [{ file: "content_script.js" }], + matches: ["http://mochi.test/*"], + runAt: "document_start", + }); + + await browser.userScripts.register({ + js: [{ file: "user_script.js" }], + matches: ["http://mochi.test/*"], + runAt: "document_start", + }); + + browser.test.sendMessage("scripts-registered"); + }, + + files: { + "content_script.js": function () { + document.addEventListener( + "drop", + function (e) { + const files = e.dataTransfer.files || []; + document.querySelector("#result-content-script").textContent = + files[0]?.name; + }, + { once: true, capture: true } + ); + + // Export a function that will be called by the drop event listener subscribed + // by the test page itself, which is the last one to be registered and then + // executed. This function retrieve the test results and send them to be + // asserted for the expected filenames. + this.exportFunction( + () => { + const results = { + contentScript: document.querySelector("#result-content-script") + .textContent, + userScript: document.querySelector("#result-user-script") + .textContent, + pageScript: document.querySelector("#result-page-script") + .textContent, + }; + browser.test.sendMessage("test-done", results); + }, + window, + { defineAs: "testDone" } + ); + }, + "user_script.js": function () { + document.addEventListener( + "drop", + function (e) { + const files = e.dataTransfer.files || []; + document.querySelector("#result-user-script").textContent = + files[0]?.name; + }, + { once: true, capture: true } + ); + }, + }, + }); + + await extension.startup(); + await extension.awaitMessage("scripts-registered"); + + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL); + + const results = await extension.awaitMessage("test-done"); + const expectedFilename = "testfile.html"; + Assert.deepEqual( + results, + { + contentScript: expectedFilename, + userScript: expectedFilename, + pageScript: expectedFilename, + }, + "Got the expected drag and drop filenames" + ); + + BrowserTestUtils.removeTab(tab); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_contentscript_in_parent.js b/browser/components/extensions/test/browser/browser_ext_contentscript_in_parent.js new file mode 100644 index 0000000000..5a8bbcb589 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_contentscript_in_parent.js @@ -0,0 +1,101 @@ +"use strict"; + +const TELEMETRY_EVENT = "security#javascriptLoad#parentProcess"; + +add_task(async function test_contentscript_telemetry() { + // Turn on telemetry and reset it to the previous state once the test is completed. + // const telemetryCanRecordBase = Services.telemetry.canRecordBase; + // Services.telemetry.canRecordBase = true; + // SimpleTest.registerCleanupFunction(() => { + // Services.telemetry.canRecordBase = telemetryCanRecordBase; + // }); + + function background() { + browser.test.onMessage.addListener(async msg => { + if (msg !== "execute") { + return; + } + browser.tabs.executeScript({ + file: "execute_script.js", + allFrames: true, + matchAboutBlank: true, + runAt: "document_start", + }); + + await browser.userScripts.register({ + js: [{ file: "user_script.js" }], + matches: ["<all_urls>"], + matchAboutBlank: true, + allFrames: true, + runAt: "document_start", + }); + + await browser.contentScripts.register({ + js: [{ file: "content_script.js" }], + matches: ["<all_urls>"], + matchAboutBlank: true, + allFrames: true, + runAt: "document_start", + }); + + browser.test.sendMessage("executed"); + }); + } + + let extensionData = { + manifest: { + permissions: ["tabs", "<all_urls>"], + user_scripts: {}, + }, + background, + files: { + // Fail if this ever executes. + "execute_script.js": 'browser.test.fail("content-script-run");', + "user_script.js": 'browser.test.fail("content-script-run");', + "content_script.js": 'browser.test.fail("content-script-run");', + }, + }; + + function getSecurityEventCount() { + let snap = Services.telemetry.getSnapshotForKeyedScalars(); + return snap.parent["telemetry.event_counts"][TELEMETRY_EVENT] || 0; + } + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:preferences" + ); + is( + getSecurityEventCount(), + 0, + `No events recorded before startup: ${TELEMETRY_EVENT}.` + ); + + let extension = ExtensionTestUtils.loadExtension(extensionData); + + await extension.startup(); + is( + getSecurityEventCount(), + 0, + `No events recorded after startup: ${TELEMETRY_EVENT}.` + ); + + extension.sendMessage("execute"); + await extension.awaitMessage("executed"); + + // Do another load. + BrowserTestUtils.removeTab(tab); + tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:preferences" + ); + + BrowserTestUtils.removeTab(tab); + await extension.unload(); + + is( + getSecurityEventCount(), + 0, + `No events recorded after executeScript: ${TELEMETRY_EVENT}.` + ); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_contentscript_incognito.js b/browser/components/extensions/test/browser/browser_ext_contentscript_incognito.js new file mode 100644 index 0000000000..fc365a2a4a --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_contentscript_incognito.js @@ -0,0 +1,42 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +// Test that calling window.open from a content script running in a private +// window does not trigger a crash (regression test introduced in Bug 1653530 +// to cover the issue introduced in Bug 1616353 and fixed by Bug 1638793). +add_task(async function test_contentscript_window_open_doesnot_crash() { + const extension = ExtensionTestUtils.loadExtension({ + incognitoOverride: "spanning", + manifest: { + content_scripts: [ + { + matches: ["https://example.com/*"], + js: ["test_window_open.js"], + }, + ], + }, + files: { + "test_window_open.js": function () { + const newWin = window.open(); + browser.test.log("calling window.open did not triggered a crash"); + browser.test.sendMessage("window-open-called", !!newWin); + }, + }, + }); + await extension.startup(); + + const winPrivate = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + + await BrowserTestUtils.openNewForegroundTab( + winPrivate.gBrowser, + "https://example.com" + ); + const newWinOpened = await extension.awaitMessage("window-open-called"); + ok(newWinOpened, "Content script successfully open a new window"); + + await extension.unload(); + await BrowserTestUtils.closeWindow(winPrivate); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_contentscript_nontab_connect.js b/browser/components/extensions/test/browser/browser_ext_contentscript_nontab_connect.js new file mode 100644 index 0000000000..548c35399f --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_contentscript_nontab_connect.js @@ -0,0 +1,116 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +// This script is loaded in a non-tab extension context, and starts the test by +// loading an iframe that runs contentScript as a content script. +function extensionScript() { + let FRAME_URL = browser.runtime.getManifest().content_scripts[0].matches[0]; + // Cannot use :8888 in the manifest because of bug 1468162. + FRAME_URL = FRAME_URL.replace("mochi.test", "mochi.test:8888"); + + browser.runtime.onConnect.addListener(port => { + browser.test.assertEq(port.sender.tab, undefined, "Sender is not a tab"); + browser.test.assertEq(port.sender.frameId, undefined, "frameId unset"); + browser.test.assertEq(port.sender.url, FRAME_URL, "Expected sender URL"); + + port.onMessage.addListener(msg => { + browser.test.assertEq("pong", msg, "Reply from content script"); + port.disconnect(); + }); + port.postMessage("ping"); + }); + + browser.test.log(`Going to open ${FRAME_URL} at ${location.pathname}`); + let f = document.createElement("iframe"); + f.src = FRAME_URL; + document.body.appendChild(f); +} + +function contentScript() { + browser.test.log(`Running content script at ${document.URL}`); + + let port = browser.runtime.connect(); + port.onMessage.addListener(msg => { + browser.test.assertEq("ping", msg, "Expected message to content script"); + port.postMessage("pong"); + }); + port.onDisconnect.addListener(() => { + browser.test.sendMessage("disconnected_in_content_script"); + }); +} + +add_task(async function connect_from_background_frame() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://mochi.test/?background"], + js: ["contentscript.js"], + all_frames: true, + }, + ], + }, + files: { + "contentscript.js": contentScript, + }, + background: extensionScript, + }); + await extension.startup(); + await extension.awaitMessage("disconnected_in_content_script"); + await extension.unload(); +}); + +add_task(async function connect_from_sidebar_panel() { + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", // To automatically show sidebar on load. + manifest: { + content_scripts: [ + { + matches: ["http://mochi.test/?sidebar"], + js: ["contentscript.js"], + all_frames: true, + }, + ], + sidebar_action: { + default_panel: "sidebar.html", + }, + }, + files: { + "contentscript.js": contentScript, + "sidebar.html": `<!DOCTYPE html><meta charset="utf-8"><body><script src="sidebar.js"></script></body>`, + "sidebar.js": extensionScript, + }, + }); + await extension.startup(); + await extension.awaitMessage("disconnected_in_content_script"); + await extension.unload(); +}); + +add_task(async function connect_from_browser_action_popup() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://mochi.test/?browser_action_popup"], + js: ["contentscript.js"], + all_frames: true, + }, + ], + browser_action: { + default_popup: "popup.html", + default_area: "navbar", + }, + }, + files: { + "contentscript.js": contentScript, + "popup.html": `<!DOCTYPE html><meta charset="utf-8"><body><script src="popup.js"></script></body>`, + "popup.js": extensionScript, + }, + }); + await extension.startup(); + await clickBrowserAction(extension); + await extension.awaitMessage("disconnected_in_content_script"); + await closeBrowserAction(extension); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_contentscript_sender_url.js b/browser/components/extensions/test/browser/browser_ext_contentscript_sender_url.js new file mode 100644 index 0000000000..f751c3c202 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_contentscript_sender_url.js @@ -0,0 +1,67 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_sender_url() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + matches: ["http://mochi.test/*"], + run_at: "document_start", + js: ["script.js"], + }, + ], + }, + + background() { + browser.runtime.onMessage.addListener((msg, sender) => { + browser.test.log("Message received."); + browser.test.sendMessage("sender.url", sender.url); + }); + }, + + files: { + "script.js"() { + browser.test.log("Content script loaded."); + browser.runtime.sendMessage(0); + }, + }, + }); + + const image = + "http://mochi.test:8888/browser/browser/components/extensions/test/browser/ctxmenu-image.png"; + + // Bug is only visible and test only works without Fission, + // or with Fission but without BFcache in parent. + await SpecialPowers.pushPrefEnv({ + set: [["fission.bfcacheInParent", false]], + }); + + function awaitNewTab() { + return BrowserTestUtils.waitForLocationChange(gBrowser, "about:newtab"); + } + + await extension.startup(); + + await BrowserTestUtils.withNewTab({ gBrowser }, async browser => { + let newTab = awaitNewTab(); + BrowserTestUtils.startLoadingURIString(browser, "about:newtab"); + await newTab; + + BrowserTestUtils.startLoadingURIString(browser, image); + let url = await extension.awaitMessage("sender.url"); + is(url, image, `Correct sender.url: ${url}`); + + let wentBack = awaitNewTab(); + await browser.goBack(); + await wentBack; + + await browser.goForward(); + url = await extension.awaitMessage("sender.url"); + is(url, image, `Correct sender.url: ${url}`); + }); + + await extension.unload(); + await SpecialPowers.popPrefEnv(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_contextMenus.js b/browser/components/extensions/test/browser/browser_ext_contextMenus.js new file mode 100644 index 0000000000..c816c89f82 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_contextMenus.js @@ -0,0 +1,854 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/browser/components/places/tests/browser/head.js", + this +); +/* globals withSidebarTree, synthesizeClickOnSelectedTreeCell, promiseLibrary, promiseLibraryClosed + */ + +const PAGE = + "http://mochi.test:8888/browser/browser/components/extensions/test/browser/context.html"; + +add_task(async function () { + let tab1 = await BrowserTestUtils.openNewForegroundTab(gBrowser, PAGE); + + gBrowser.selectedTab = tab1; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["contextMenus"], + }, + + background: function () { + browser.test.assertEq( + browser.contextMenus.ContextType.TAB, + "tab", + "ContextType is available" + ); + browser.contextMenus.create({ + id: "clickme-image", + title: "Click me!", + contexts: ["image"], + }); + browser.contextMenus.create( + { + id: "clickme-page", + title: "Click me!", + contexts: ["page"], + }, + () => { + browser.test.sendMessage("ready"); + } + ); + }, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + + let contentAreaContextMenu = await openContextMenu("#img1"); + let item = contentAreaContextMenu.getElementsByAttribute( + "label", + "Click me!" + ); + is(item.length, 1, "contextMenu item for image was found"); + await closeContextMenu(); + + contentAreaContextMenu = await openContextMenu("body"); + item = contentAreaContextMenu.getElementsByAttribute("label", "Click me!"); + is(item.length, 1, "contextMenu item for page was found"); + await closeContextMenu(); + + await extension.unload(); + + BrowserTestUtils.removeTab(tab1); +}); + +add_task(async function () { + let tab1 = await BrowserTestUtils.openNewForegroundTab(gBrowser, PAGE); + + gBrowser.selectedTab = tab1; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["contextMenus"], + }, + + background: async function () { + browser.test.onMessage.addListener(msg => { + if (msg == "removeall") { + browser.contextMenus.removeAll(); + browser.test.sendMessage("removed"); + } + }); + + // A generic onclick callback function. + function genericOnClick(info, tab) { + browser.test.sendMessage("onclick", { info, tab }); + } + + browser.contextMenus.onClicked.addListener((info, tab) => { + browser.test.sendMessage("browser.contextMenus.onClicked", { + info, + tab, + }); + }); + + browser.contextMenus.create({ + contexts: ["all"], + type: "separator", + }); + + let contexts = [ + "page", + "link", + "selection", + "image", + "editable", + "password", + ]; + for (let i = 0; i < contexts.length; i++) { + let context = contexts[i]; + let title = context; + browser.contextMenus.create({ + title: title, + contexts: [context], + id: "ext-" + context, + onclick: genericOnClick, + }); + if (context == "selection") { + browser.contextMenus.update("ext-selection", { + title: "selection is: '%s'", + onclick: genericOnClick, + }); + } + } + + let parent = browser.contextMenus.create({ + title: "parent", + }); + browser.contextMenus.create({ + title: "child1", + parentId: parent, + onclick: genericOnClick, + }); + let child2 = browser.contextMenus.create({ + title: "child2", + parentId: parent, + onclick: genericOnClick, + }); + + let parentToDel = browser.contextMenus.create({ + title: "parentToDel", + }); + browser.contextMenus.create({ + title: "child1", + parentId: parentToDel, + onclick: genericOnClick, + }); + browser.contextMenus.create({ + title: "child2", + parentId: parentToDel, + onclick: genericOnClick, + }); + browser.contextMenus.remove(parentToDel); + + browser.contextMenus.create({ + title: "Without onclick property", + id: "ext-without-onclick", + }); + + await browser.test.assertRejects( + browser.contextMenus.update(parent, { parentId: child2 }), + /cannot be an ancestor/, + "Should not be able to reparent an item as descendent of itself" + ); + + browser.test.sendMessage("contextmenus"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("contextmenus"); + + let expectedClickInfo = { + menuItemId: "ext-image", + mediaType: "image", + srcUrl: + "http://mochi.test:8888/browser/browser/components/extensions/test/browser/ctxmenu-image.png", + pageUrl: PAGE, + editable: false, + }; + + function checkClickInfo(result) { + for (let i of Object.keys(expectedClickInfo)) { + is( + result.info[i], + expectedClickInfo[i], + "click info " + + i + + " expected to be: " + + expectedClickInfo[i] + + " but was: " + + result.info[i] + ); + } + is( + expectedClickInfo.pageSrc, + result.tab.url, + "click info page source is the right tab" + ); + } + + let extensionMenuRoot = await openExtensionContextMenu(); + + // Check some menu items + let items = extensionMenuRoot.getElementsByAttribute("label", "image"); + is(items.length, 1, "contextMenu item for image was found (context=image)"); + let image = items[0]; + + items = extensionMenuRoot.getElementsByAttribute("label", "selection-edited"); + is( + items.length, + 0, + "contextMenu item for selection was not found (context=image)" + ); + + items = extensionMenuRoot.getElementsByAttribute("label", "parentToDel"); + is( + items.length, + 0, + "contextMenu item for removed parent was not found (context=image)" + ); + + items = extensionMenuRoot.getElementsByAttribute("label", "parent"); + is(items.length, 1, "contextMenu item for parent was found (context=image)"); + + is( + items[0].menupopup.children.length, + 2, + "child items for parent were found (context=image)" + ); + + // Click on ext-image item and check the click results + await closeExtensionContextMenu(image); + + let result = await extension.awaitMessage("onclick"); + checkClickInfo(result); + result = await extension.awaitMessage("browser.contextMenus.onClicked"); + checkClickInfo(result); + + // Test "link" context and OnClick data property. + extensionMenuRoot = await openExtensionContextMenu("[href=some-link]"); + + // Click on ext-link and check the click results + items = extensionMenuRoot.getElementsByAttribute("label", "link"); + is(items.length, 1, "contextMenu item for parent was found (context=link)"); + let link = items[0]; + + expectedClickInfo = { + menuItemId: "ext-link", + linkUrl: + "http://mochi.test:8888/browser/browser/components/extensions/test/browser/some-link", + linkText: "Some link", + pageUrl: PAGE, + editable: false, + }; + + await closeExtensionContextMenu(link); + + result = await extension.awaitMessage("onclick"); + checkClickInfo(result); + result = await extension.awaitMessage("browser.contextMenus.onClicked"); + checkClickInfo(result); + + // Test "editable" context and OnClick data property. + extensionMenuRoot = await openExtensionContextMenu("#edit-me"); + + // Check some menu items. + items = extensionMenuRoot.getElementsByAttribute("label", "editable"); + is( + items.length, + 1, + "contextMenu item for text input element was found (context=editable)" + ); + let editable = items[0]; + + // Click on ext-editable item and check the click results. + await closeExtensionContextMenu(editable); + + expectedClickInfo = { + menuItemId: "ext-editable", + pageUrl: PAGE, + editable: true, + }; + + result = await extension.awaitMessage("onclick"); + checkClickInfo(result); + result = await extension.awaitMessage("browser.contextMenus.onClicked"); + checkClickInfo(result); + + extensionMenuRoot = await openExtensionContextMenu("#readonly-text"); + + // Check some menu items. + items = extensionMenuRoot.getElementsByAttribute("label", "editable"); + is( + items.length, + 0, + "contextMenu item for text input element was not found (context=editable fails for readonly items)" + ); + + // Hide the popup "manually" because there's nothing to click. + await closeContextMenu(); + + // Test "editable" context on type=tel and type=number items, and OnClick data property. + extensionMenuRoot = await openExtensionContextMenu("#call-me-maybe"); + + // Check some menu items. + items = extensionMenuRoot.getElementsByAttribute("label", "editable"); + is( + items.length, + 1, + "contextMenu item for text input element was found (context=editable)" + ); + editable = items[0]; + + // Click on ext-editable item and check the click results. + await closeExtensionContextMenu(editable); + + expectedClickInfo = { + menuItemId: "ext-editable", + pageUrl: PAGE, + editable: true, + }; + + result = await extension.awaitMessage("onclick"); + checkClickInfo(result); + result = await extension.awaitMessage("browser.contextMenus.onClicked"); + checkClickInfo(result); + + extensionMenuRoot = await openExtensionContextMenu("#number-input"); + + // Check some menu items. + items = extensionMenuRoot.getElementsByAttribute("label", "editable"); + is( + items.length, + 1, + "contextMenu item for text input element was found (context=editable)" + ); + editable = items[0]; + + // Click on ext-editable item and check the click results. + await closeExtensionContextMenu(editable); + + expectedClickInfo = { + menuItemId: "ext-editable", + pageUrl: PAGE, + editable: true, + }; + + result = await extension.awaitMessage("onclick"); + checkClickInfo(result); + result = await extension.awaitMessage("browser.contextMenus.onClicked"); + checkClickInfo(result); + + extensionMenuRoot = await openExtensionContextMenu("#password"); + items = extensionMenuRoot.getElementsByAttribute("label", "password"); + is( + items.length, + 1, + "contextMenu item for password input element was found (context=password)" + ); + let password = items[0]; + await closeExtensionContextMenu(password); + expectedClickInfo = { + menuItemId: "ext-password", + pageUrl: PAGE, + editable: true, + }; + + result = await extension.awaitMessage("onclick"); + checkClickInfo(result); + result = await extension.awaitMessage("browser.contextMenus.onClicked"); + checkClickInfo(result); + + extensionMenuRoot = await openExtensionContextMenu("#noneditablepassword"); + items = extensionMenuRoot.getElementsByAttribute("label", "password"); + is( + items.length, + 1, + "contextMenu item for password input element was found (context=password)" + ); + password = items[0]; + await closeExtensionContextMenu(password); + expectedClickInfo.editable = false; + result = await extension.awaitMessage("onclick"); + checkClickInfo(result); + result = await extension.awaitMessage("browser.contextMenus.onClicked"); + checkClickInfo(result); + + // Select some text + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function (arg) { + let doc = content.document; + let range = doc.createRange(); + let selection = content.getSelection(); + selection.removeAllRanges(); + let textNode = doc.getElementById("img1").previousSibling; + range.setStart(textNode, 0); + range.setEnd(textNode, 100); + selection.addRange(range); + }); + + // Bring up context menu again + extensionMenuRoot = await openExtensionContextMenu(); + + // Check some menu items + items = extensionMenuRoot.getElementsByAttribute( + "label", + "Without onclick property" + ); + is(items.length, 1, "contextMenu item was found (context=page)"); + + await closeExtensionContextMenu(items[0]); + + expectedClickInfo = { + menuItemId: "ext-without-onclick", + pageUrl: PAGE, + }; + + result = await extension.awaitMessage("browser.contextMenus.onClicked"); + checkClickInfo(result); + + // Bring up context menu again + extensionMenuRoot = await openExtensionContextMenu(); + + // Check some menu items + items = extensionMenuRoot.getElementsByAttribute( + "label", + "selection is: 'just some text 12345678901234567890123456789012\u2026'" + ); + is( + items.length, + 1, + "contextMenu item for selection was found (context=selection)" + ); + let selectionItem = items[0]; + + items = extensionMenuRoot.getElementsByAttribute("label", "selection"); + is( + items.length, + 0, + "contextMenu item label update worked (context=selection)" + ); + + await closeExtensionContextMenu(selectionItem); + + expectedClickInfo = { + menuItemId: "ext-selection", + pageUrl: PAGE, + selectionText: + " just some text 1234567890123456789012345678901234567890123456789012345678901234567890123456789012", + }; + + result = await extension.awaitMessage("onclick"); + checkClickInfo(result); + result = await extension.awaitMessage("browser.contextMenus.onClicked"); + checkClickInfo(result); + + // Select a lot of text + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], function (arg) { + let doc = content.document; + let range = doc.createRange(); + let selection = content.getSelection(); + selection.removeAllRanges(); + let textNode = doc.getElementById("longtext").firstChild; + range.setStart(textNode, 0); + range.setEnd(textNode, textNode.length); + selection.addRange(range); + }); + + // Bring up context menu again + extensionMenuRoot = await openExtensionContextMenu("#longtext"); + + // Check some menu items + items = extensionMenuRoot.getElementsByAttribute( + "label", + "selection is: 'Sed ut perspiciatis unde omnis iste natus error\u2026'" + ); + is( + items.length, + 1, + `contextMenu item for longtext selection was found (context=selection)` + ); + await closeExtensionContextMenu(items[0]); + + expectedClickInfo = { + menuItemId: "ext-selection", + pageUrl: PAGE, + }; + + result = await extension.awaitMessage("onclick"); + checkClickInfo(result); + result = await extension.awaitMessage("browser.contextMenus.onClicked"); + checkClickInfo(result); + ok( + result.info.selectionText.endsWith("quo voluptas nulla pariatur?"), + "long text selection worked" + ); + + // Select a lot of text, excercise the editable element code path in + // the Browser:GetSelection handler. + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], function (arg) { + let doc = content.document; + let node = doc.getElementById("editabletext"); + // content.js handleContentContextMenu fails intermittently without focus. + node.focus(); + node.selectionStart = 0; + node.selectionEnd = 844; + }); + + // Bring up context menu again + extensionMenuRoot = await openExtensionContextMenu("#editabletext"); + + // Check some menu items + items = extensionMenuRoot.getElementsByAttribute("label", "editable"); + is( + items.length, + 1, + "contextMenu item for text input element was found (context=editable)" + ); + await closeExtensionContextMenu(items[0]); + + expectedClickInfo = { + menuItemId: "ext-editable", + editable: true, + pageUrl: PAGE, + }; + + result = await extension.awaitMessage("onclick"); + checkClickInfo(result); + result = await extension.awaitMessage("browser.contextMenus.onClicked"); + checkClickInfo(result); + ok( + result.info.selectionText.endsWith( + "perferendis doloribus asperiores repellat." + ), + "long text selection worked" + ); + + extension.sendMessage("removeall"); + await extension.awaitMessage("removed"); + + let contentAreaContextMenu = await openContextMenu("#img1"); + items = contentAreaContextMenu.getElementsByAttribute( + "ext-type", + "top-level-menu" + ); + is(items.length, 0, "top level item was not found (after removeAll()"); + await closeContextMenu(); + + await extension.unload(); + BrowserTestUtils.removeTab(tab1); +}); + +add_task(async function testRemoveAllWithTwoExtensions() { + const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, PAGE); + const manifest = { permissions: ["contextMenus"] }; + + const first = ExtensionTestUtils.loadExtension({ + manifest, + background() { + browser.contextMenus.create({ title: "alpha", contexts: ["all"] }); + + browser.contextMenus.onClicked.addListener(() => { + browser.contextMenus.removeAll(); + }); + browser.test.onMessage.addListener(msg => { + if (msg == "ping") { + browser.test.sendMessage("pong-alpha"); + return; + } + browser.contextMenus.create({ title: "gamma", contexts: ["all"] }); + }); + }, + }); + + const second = ExtensionTestUtils.loadExtension({ + manifest, + background() { + browser.contextMenus.create({ title: "beta", contexts: ["all"] }); + + browser.contextMenus.onClicked.addListener(() => { + browser.contextMenus.removeAll(); + }); + + browser.test.onMessage.addListener(() => { + browser.test.sendMessage("pong-beta"); + }); + }, + }); + + await first.startup(); + await second.startup(); + + async function confirmMenuItems(...items) { + // Round-trip to extension to make sure that the context menu state has been + // updated by the async contextMenus.create / contextMenus.removeAll calls. + first.sendMessage("ping"); + second.sendMessage("ping"); + await first.awaitMessage("pong-alpha"); + await second.awaitMessage("pong-beta"); + + const menu = await openContextMenu(); + for (const id of ["alpha", "beta", "gamma"]) { + const expected = items.includes(id); + const found = menu.getElementsByAttribute("label", id); + is( + !!found.length, + expected, + `menu item ${id} ${expected ? "" : "not "}found` + ); + } + // Return the first menu item, we need to click it. + return menu.getElementsByAttribute("label", items[0])[0]; + } + + // Confirm alpha, beta exist; click alpha to remove it. + const alpha = await confirmMenuItems("alpha", "beta"); + await closeExtensionContextMenu(alpha); + + // Confirm only beta exists. + await confirmMenuItems("beta"); + await closeContextMenu(); + + // Create gamma, confirm, click. + first.sendMessage("create"); + const beta = await confirmMenuItems("beta", "gamma"); + await closeExtensionContextMenu(beta); + + // Confirm only gamma is left. + await confirmMenuItems("gamma"); + await closeContextMenu(); + + await first.unload(); + await second.unload(); + BrowserTestUtils.removeTab(tab); +}); + +function bookmarkContextMenuExtension() { + return ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["contextMenus", "bookmarks", "activeTab"], + }, + async background() { + const url = "https://example.com/"; + const title = "Example"; + let newBookmark = await browser.bookmarks.create({ + url, + title, + parentId: "toolbar_____", + }); + browser.contextMenus.onClicked.addListener(async (info, tab) => { + browser.test.assertEq( + undefined, + tab, + "click event in bookmarks menu is not associated with any tab" + ); + browser.test.assertEq( + newBookmark.id, + info.bookmarkId, + "Bookmark ID matches" + ); + + await browser.test.assertRejects( + browser.tabs.executeScript({ code: "'some code';" }), + /Missing host permission for the tab/, + "Content script should not run, activeTab should not be granted to bookmark menu events" + ); + + let [bookmark] = await browser.bookmarks.get(info.bookmarkId); + browser.test.assertEq(title, bookmark.title, "Bookmark title matches"); + browser.test.assertEq(url, bookmark.url, "Bookmark url matches"); + browser.test.assertFalse( + info.hasOwnProperty("pageUrl"), + "Context menu does not expose pageUrl" + ); + await browser.bookmarks.remove(info.bookmarkId); + browser.test.sendMessage("test-finish"); + }); + browser.contextMenus.create( + { + title: "Get bookmark", + contexts: ["bookmark"], + }, + () => { + browser.test.sendMessage("bookmark-created", newBookmark.id); + } + ); + }, + }); +} + +add_task(async function test_bookmark_contextmenu() { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, PAGE); + + await toggleBookmarksToolbar(true); + + const extension = bookmarkContextMenuExtension(); + + await extension.startup(); + await extension.awaitMessage("bookmark-created"); + let menu = await openChromeContextMenu( + "placesContext", + "#PersonalToolbar .bookmark-item:last-child" + ); + + let menuItem = menu.getElementsByAttribute("label", "Get bookmark")[0]; + closeChromeContextMenu("placesContext", menuItem); + + await extension.awaitMessage("test-finish"); + await extension.unload(); + await toggleBookmarksToolbar(false); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_bookmark_sidebar_contextmenu() { + await withSidebarTree("bookmarks", async tree => { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, PAGE); + + let extension = bookmarkContextMenuExtension(); + await extension.startup(); + let bookmarkGuid = await extension.awaitMessage("bookmark-created"); + + let sidebar = window.SidebarUI.browser; + let menu = sidebar.contentDocument.getElementById("placesContext"); + tree.selectItems([bookmarkGuid]); + let shown = BrowserTestUtils.waitForEvent(menu, "popupshown"); + synthesizeClickOnSelectedTreeCell(tree, { type: "contextmenu" }); + await shown; + + let menuItem = menu.getElementsByAttribute("label", "Get bookmark")[0]; + closeChromeContextMenu("placesContext", menuItem, sidebar.contentWindow); + await extension.awaitMessage("test-finish"); + await extension.unload(); + + BrowserTestUtils.removeTab(tab); + }); +}); + +function bookmarkFolderContextMenuExtension() { + return ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["contextMenus", "bookmarks"], + }, + async background() { + const title = "Example"; + let newBookmark = await browser.bookmarks.create({ + title, + parentId: "toolbar_____", + }); + await new Promise(resolve => + browser.contextMenus.create( + { + title: "Get bookmark", + contexts: ["bookmark"], + }, + resolve + ) + ); + browser.contextMenus.onClicked.addListener(async info => { + browser.test.assertEq( + newBookmark.id, + info.bookmarkId, + "Bookmark ID matches" + ); + + let [bookmark] = await browser.bookmarks.get(info.bookmarkId); + browser.test.assertEq(title, bookmark.title, "Bookmark title matches"); + browser.test.assertFalse( + info.hasOwnProperty("pageUrl"), + "Context menu does not expose pageUrl" + ); + await browser.bookmarks.remove(info.bookmarkId); + browser.test.sendMessage("test-finish"); + }); + browser.test.sendMessage("bookmark-created", newBookmark.id); + }, + }); +} + +add_task(async function test_organizer_contextmenu() { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, PAGE); + let library = await promiseLibrary("BookmarksToolbar"); + + let menu = library.document.getElementById("placesContext"); + let mainTree = library.document.getElementById("placeContent"); + let leftTree = library.document.getElementById("placesList"); + + let tests = [ + [mainTree, bookmarkContextMenuExtension], + [mainTree, bookmarkFolderContextMenuExtension], + [leftTree, bookmarkFolderContextMenuExtension], + ]; + + for (let [tree, makeExtension] of tests) { + let extension = makeExtension(); + await extension.startup(); + let bookmarkGuid = await extension.awaitMessage("bookmark-created"); + + tree.selectItems([bookmarkGuid]); + let shown = BrowserTestUtils.waitForEvent(menu, "popupshown"); + synthesizeClickOnSelectedTreeCell(tree, { type: "contextmenu" }); + await shown; + + let menuItem = menu.getElementsByAttribute("label", "Get bookmark")[0]; + closeChromeContextMenu("placesContext", menuItem, library); + await extension.awaitMessage("test-finish"); + await extension.unload(); + } + + await promiseLibraryClosed(library); + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_bookmark_context_requires_permission() { + await toggleBookmarksToolbar(true); + + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["contextMenus"], + }, + background() { + browser.contextMenus.create( + { + title: "Get bookmark", + contexts: ["bookmark"], + }, + () => { + browser.test.sendMessage("bookmark-created"); + } + ); + }, + }); + await extension.startup(); + await extension.awaitMessage("bookmark-created"); + let menu = await openChromeContextMenu( + "placesContext", + "#PersonalToolbar .bookmark-item:last-child" + ); + + Assert.equal( + menu.getElementsByAttribute("label", "Get bookmark").length, + 0, + "bookmark context menu not created with `bookmarks` permission." + ); + + closeChromeContextMenu("placesContext"); + + await extension.unload(); + await toggleBookmarksToolbar(false); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_contextMenus_bookmarks.js b/browser/components/extensions/test/browser/browser_ext_contextMenus_bookmarks.js new file mode 100644 index 0000000000..1e95899513 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_contextMenus_bookmarks.js @@ -0,0 +1,115 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +Services.scriptloader.loadSubScript( + "chrome://mochitests/content/browser/browser/components/places/tests/browser/head.js", + this +); +/* globals withSidebarTree, synthesizeClickOnSelectedTreeCell, promiseLibrary, promiseLibraryClosed */ + +function bookmarkContextMenuExtension() { + return ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["contextMenus", "bookmarks"], + }, + async background() { + const CONTEXT_ENTRY_LABEL = "Test Context Entry "; + + browser.contextMenus.create( + { + title: CONTEXT_ENTRY_LABEL, + contexts: ["bookmark"], + onclick: (info, tab) => { + browser.test.sendMessage(`clicked`, info.bookmarkId); + }, + }, + () => { + browser.test.assertEq( + browser.runtime.lastError, + null, + "Created context menu" + ); + browser.test.sendMessage("created", CONTEXT_ENTRY_LABEL); + } + ); + }, + }); +} + +add_task(async function test_bookmark_sidebar_contextmenu() { + await withSidebarTree("bookmarks", async tree => { + let extension = bookmarkContextMenuExtension(); + await extension.startup(); + let context_entry_label = await extension.awaitMessage("created"); + + const expected_bookmarkID_2_virtualID = new Map([ + ["toolbar_____", "toolbar____v"], // Bookmarks Toolbar + ["menu________", "menu_______v"], // Bookmarks Menu + ["unfiled_____", "unfiled____v"], // Other Bookmarks + ]); + + for (let [ + expectedBookmarkID, + expectedVirtualID, + ] of expected_bookmarkID_2_virtualID) { + info(`Testing context menu for Bookmark ID "${expectedBookmarkID}"`); + let sidebar = window.SidebarUI.browser; + let menu = sidebar.contentDocument.getElementById("placesContext"); + tree.selectItems([expectedBookmarkID]); + + let min = {}, + max = {}; + tree.view.selection.getRangeAt(0, min, max); + let node = tree.view.nodeForTreeIndex(min.value); + const actualVirtualID = node.bookmarkGuid; + Assert.equal(actualVirtualID, expectedVirtualID, "virtualIDs match"); + + let shown = BrowserTestUtils.waitForEvent(menu, "popupshown"); + synthesizeClickOnSelectedTreeCell(tree, { type: "contextmenu" }); + await shown; + + let menuItem = menu.getElementsByAttribute( + "label", + context_entry_label + )[0]; + closeChromeContextMenu("placesContext", menuItem, sidebar.contentWindow); + + const actualBookmarkID = await extension.awaitMessage(`clicked`); + Assert.equal(actualBookmarkID, expectedBookmarkID, "bookmarkIDs match"); + } + await extension.unload(); + }); +}); + +add_task(async function test_bookmark_library_contextmenu() { + let extension = bookmarkContextMenuExtension(); + await extension.startup(); + let context_entry_label = await extension.awaitMessage("created"); + + let library = await promiseLibrary("BookmarksToolbar"); + let menu = library.document.getElementById("placesContext"); + let leftTree = library.document.getElementById("placesList"); + + const treeIDs = [ + "allbms_____v", + "history____v", + "downloads__v", + "tags_______v", + ]; + + for (let treeID of treeIDs) { + info(`Testing context menu for TreeID "${treeID}"`); + leftTree.selectItems([treeID]); + + let shown = BrowserTestUtils.waitForEvent(menu, "popupshown"); + synthesizeClickOnSelectedTreeCell(leftTree, { type: "contextmenu" }); + await shown; + + let items = menu.getElementsByAttribute("label", context_entry_label); + Assert.equal(items.length, 0, "no extension context entry"); + closeChromeContextMenu("placesContext", null, library); + } + await extension.unload(); + await promiseLibraryClosed(library); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_contextMenus_checkboxes.js b/browser/components/extensions/test/browser/browser_ext_contextMenus_checkboxes.js new file mode 100644 index 0000000000..a471ae7e58 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_contextMenus_checkboxes.js @@ -0,0 +1,157 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function () { + let tab1 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://mochi.test:8888/browser/browser/components/extensions/test/browser/context.html" + ); + + gBrowser.selectedTab = tab1; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["contextMenus"], + }, + + background: function () { + // Report onClickData info back. + browser.contextMenus.onClicked.addListener(info => { + browser.test.sendMessage("contextmenus-click", info); + }); + + browser.contextMenus.create({ + title: "Checkbox", + type: "checkbox", + }); + + browser.test.sendMessage("single-contextmenu-item-added"); + + browser.test.onMessage.addListener(msg => { + if (msg !== "add-additional-menu-items") { + return; + } + + browser.contextMenus.create({ + type: "separator", + }); + + browser.contextMenus.create({ + title: "Checkbox", + type: "checkbox", + checked: true, + }); + + browser.contextMenus.create({ + title: "Checkbox", + type: "checkbox", + }); + + browser.test.notifyPass("contextmenus-checkboxes"); + }); + }, + }); + + await extension.startup(); + + await extension.awaitMessage("single-contextmenu-item-added"); + + async function testSingleCheckboxItem() { + let extensionMenuRoot = await openExtensionContextMenu(); + + // On Linux, the single menu item should be contained in a submenu. + if (AppConstants.platform === "linux") { + let items = extensionMenuRoot.getElementsByAttribute("type", "checkbox"); + is(items.length, 1, "single checkbox should be in the submenu on Linux"); + await closeContextMenu(); + } else { + is( + extensionMenuRoot, + null, + "there should be no submenu for a single checkbox item" + ); + await closeContextMenu(); + } + } + + await testSingleCheckboxItem(); + + extension.sendMessage("add-additional-menu-items"); + await extension.awaitFinish("contextmenus-checkboxes"); + + function confirmCheckboxStates(extensionMenuRoot, expectedStates) { + let checkboxItems = extensionMenuRoot.getElementsByAttribute( + "type", + "checkbox" + ); + + is( + checkboxItems.length, + 3, + "there should be 3 checkbox items in the context menu" + ); + + is( + checkboxItems[0].hasAttribute("checked"), + expectedStates[0], + `checkbox item 1 has state (checked=${expectedStates[0]})` + ); + is( + checkboxItems[1].hasAttribute("checked"), + expectedStates[1], + `checkbox item 2 has state (checked=${expectedStates[1]})` + ); + is( + checkboxItems[2].hasAttribute("checked"), + expectedStates[2], + `checkbox item 3 has state (checked=${expectedStates[2]})` + ); + + return extensionMenuRoot.getElementsByAttribute("type", "checkbox"); + } + + function confirmOnClickData(onClickData, id, was, checked) { + is( + onClickData.wasChecked, + was, + `checkbox item ${id} was ${was ? "" : "not "}checked before the click` + ); + is( + onClickData.checked, + checked, + `checkbox item ${id} is ${checked ? "" : "not "}checked after the click` + ); + } + + let extensionMenuRoot = await openExtensionContextMenu(); + let items = confirmCheckboxStates(extensionMenuRoot, [false, true, false]); + await closeExtensionContextMenu(items[0]); + + let result = await extension.awaitMessage("contextmenus-click"); + confirmOnClickData(result, 1, false, true); + + extensionMenuRoot = await openExtensionContextMenu(); + items = confirmCheckboxStates(extensionMenuRoot, [true, true, false]); + await closeExtensionContextMenu(items[2]); + + result = await extension.awaitMessage("contextmenus-click"); + confirmOnClickData(result, 3, false, true); + + extensionMenuRoot = await openExtensionContextMenu(); + items = confirmCheckboxStates(extensionMenuRoot, [true, true, true]); + await closeExtensionContextMenu(items[0]); + + result = await extension.awaitMessage("contextmenus-click"); + confirmOnClickData(result, 1, true, false); + + extensionMenuRoot = await openExtensionContextMenu(); + items = confirmCheckboxStates(extensionMenuRoot, [false, true, true]); + await closeExtensionContextMenu(items[2]); + + result = await extension.awaitMessage("contextmenus-click"); + confirmOnClickData(result, 3, true, false); + + await extension.unload(); + BrowserTestUtils.removeTab(tab1); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_contextMenus_commands.js b/browser/components/extensions/test/browser/browser_ext_contextMenus_commands.js new file mode 100644 index 0000000000..5a8f1db208 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_contextMenus_commands.js @@ -0,0 +1,158 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function testTabSwitchActionContext() { + await SpecialPowers.pushPrefEnv({ + set: [["extensions.manifestV3.enabled", true]], + }); +}); + +add_task(async function test_actions_context_menu() { + function background() { + browser.contextMenus.create({ + title: "open_browser_action", + contexts: ["all"], + command: "_execute_browser_action", + }); + browser.contextMenus.create({ + title: "open_page_action", + contexts: ["all"], + command: "_execute_page_action", + }); + browser.contextMenus.create({ + title: "open_sidebar_action", + contexts: ["all"], + command: "_execute_sidebar_action", + }); + browser.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { + browser.pageAction.show(tabId); + }); + browser.contextMenus.onClicked.addListener(() => { + browser.test.fail(`menu onClicked should not have been received`); + }); + browser.test.sendMessage("ready"); + } + + function testScript() { + window.onload = () => { + browser.test.sendMessage("test-opened", true); + }; + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + name: "contextMenus commands", + permissions: ["contextMenus", "activeTab", "tabs"], + browser_action: { + default_title: "Test BrowserAction", + default_popup: "test.html", + browser_style: true, + }, + page_action: { + default_title: "Test PageAction", + default_popup: "test.html", + browser_style: true, + }, + sidebar_action: { + default_title: "Test Sidebar", + default_panel: "test.html", + }, + }, + background, + files: { + "test.html": `<!DOCTYPE html><meta charset="utf-8"><script src="test.js"></script>`, + "test.js": testScript, + }, + }); + + async function testContext(id) { + const menu = await openExtensionContextMenu(); + const items = menu.getElementsByAttribute("label", id); + is(items.length, 1, `exactly one menu item found`); + await closeExtensionContextMenu(items[0]); + return extension.awaitMessage("test-opened"); + } + + await extension.startup(); + await extension.awaitMessage("ready"); + + // open a page so page action works + const PAGE = + "http://mochi.test:8888/browser/browser/components/extensions/test/browser/context.html?test=commands"; + const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, PAGE); + + ok( + await testContext("open_sidebar_action"), + "_execute_sidebar_action worked" + ); + ok( + await testContext("open_browser_action"), + "_execute_browser_action worked" + ); + ok(await testContext("open_page_action"), "_execute_page_action worked"); + + BrowserTestUtils.removeTab(tab); + await extension.unload(); +}); + +add_task(async function test_v3_action_context_menu() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + name: "contextMenus commands", + manifest_version: 3, + permissions: ["contextMenus"], + action: { + default_title: "Test Action", + default_popup: "test.html", + // TODO bug 1830712: Remove this. Probably not even needed for the test. + browser_style: true, + }, + }, + background() { + browser.contextMenus.onClicked.addListener(() => { + browser.test.fail(`menu onClicked should not have been received`); + }); + + browser.contextMenus.create( + { + id: "open_action", + title: "open_action", + contexts: ["all"], + command: "_execute_action", + }, + () => { + browser.test.sendMessage("ready"); + } + ); + }, + files: { + "test.html": `<!DOCTYPE html><meta charset="utf-8"><script src="test.js"></script>`, + "test.js": () => { + window.onload = () => { + browser.test.sendMessage("test-opened", true); + }; + }, + }, + }); + + async function testContext(id) { + const menu = await openContextMenu(); + const items = menu.getElementsByAttribute("label", id); + is(items.length, 1, `exactly one menu item found`); + await closeExtensionContextMenu(items[0]); + return extension.awaitMessage("test-opened"); + } + + await extension.startup(); + await extension.awaitMessage("ready"); + + const PAGE = + "http://mochi.test:8888/browser/browser/components/extensions/test/browser/context.html?test=commands"; + const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, PAGE); + + ok(await testContext("open_action"), "_execute_action worked"); + + BrowserTestUtils.removeTab(tab); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_contextMenus_icons.js b/browser/components/extensions/test/browser/browser_ext_contextMenus_icons.js new file mode 100644 index 0000000000..30d4d528b2 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_contextMenus_icons.js @@ -0,0 +1,493 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const PAGE = + "http://mochi.test:8888/browser/browser/components/extensions/test/browser/context.html?test=icons"; + +add_task(async function test_root_icon() { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, PAGE); + + let encodedImageData = + "iVBORw0KGgoAAAANSUhEUgAAACQAAAAkCAYAAADhAJiYAAAC4klEQVRYhdWXLWzbQBSADQtDAwsHC1tUhUxqfL67lk2tdn+OJg0ODU0rLByqgqINBY6tmlbn7LMTJ5FaFVVBk1G0oUGjG2jT2Y7jxmmcbU/6iJ+f36fz+e5sGP9riCGm9hB37RG+scd4Yo/wsDXCZyIE2xuXsce4bY+wXkAsQtzYmExrfFgvkJkRbkzo1ehoxx5iXcgI/9iYUGt8WH9MqDXEcmNChmEYrRCf2SHWeYgQx3x0tLNRIeKQLTtEFyJEep4NTuhk8BC+yMrwEE3+iozo42d8gK7FAOkMsRiiN8QhW2ttSK5QTfRRV4QoymVeJMvPvDp7gCZigD613MN6yRFA3SWarow9QB9LCfG+NeF9qCtjAKOSQjCqVKhfVsiHEQ+grgx/lRGqUihAc1uL8EFD+KCRO+GrF4J61phcoRoPoEzkYhZYpykh5sMb7kOdIeY+jHKur4QI4Feh4AFX1nVeLxrAvQchGsBz5ls6wa2QdwcvIcE2863bTH79KOvsz/uUYJsp+J0pSzNlDckVqqVGUAF+n6uS7txcOl6wot4JVy70ufDLy4pWLUQVPE81pRI0mGe9oxLMHSeohHvMs/STUNaUK6vDPCvOyxMFDx4achehRDJmHnydnkPww5OFfLxrGIZBFDyYl4LpMzlTQFIP6AQx86w2UeYBccFpJrcKv5L9eGDtUAU6RIELqsB74uynjy/UBRF1gS5BTFxwQT1wTiXoUg9MH7m/3NZRRoi5IJytUbMgzv4Wc832+oQkiKgEehmyMkkpKsFkQV11QsRJL5rJYBLItQgRaUZEmnoZXsomz3vGiWw+I9KMF9SVFOqZEemZekli1jN3U/UOqhHHvC6oWWGElhfSpGdOk6+O9prdwvtLj5BjRsQxdRnot+Zeifpy/2/0stktKTRNLmbk0mwXyl8253fyojj+8rxOHNAhjjm5n0/5OOCGOKBzkrMO0Z75lvSAzKlrF32Z/3z8BqLAn+yMV7VhAAAAAElFTkSuQmCC"; + const IMAGE_ARRAYBUFFER = imageBufferFromDataURI(encodedImageData); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + name: "contextMenus icons", + permissions: ["contextMenus"], + icons: { + 18: "extension.png", + }, + }, + + files: { + "extension.png": IMAGE_ARRAYBUFFER, + }, + + background: function () { + let menuitemId = browser.contextMenus.create({ + title: "child-to-delete", + onclick: () => { + browser.contextMenus.remove(menuitemId); + browser.test.sendMessage("child-deleted"); + }, + }); + + browser.contextMenus.create( + { + title: "child", + }, + () => { + browser.test.sendMessage("contextmenus-icons"); + } + ); + }, + }); + + let confirmContextMenuIcon = rootElements => { + let expectedURL = new RegExp( + String.raw`^moz-extension://[^/]+/extension\.png$` + ); + is(rootElements.length, 1, "Found exactly one menu item"); + let imageUrl = rootElements[0].getAttribute("image"); + ok( + expectedURL.test(imageUrl), + "The context menu should display the extension icon next to the root element" + ); + }; + + await extension.startup(); + await extension.awaitMessage("contextmenus-icons"); + + let extensionMenu = await openExtensionContextMenu(); + + let contextMenu = document.getElementById("contentAreaContextMenu"); + let topLevelMenuItem = contextMenu.getElementsByAttribute( + "ext-type", + "top-level-menu" + ); + confirmContextMenuIcon(topLevelMenuItem); + + let childToDelete = extensionMenu.getElementsByAttribute( + "label", + "child-to-delete" + ); + is(childToDelete.length, 1, "Found exactly one child to delete"); + await closeExtensionContextMenu(childToDelete[0]); + await extension.awaitMessage("child-deleted"); + + await openExtensionContextMenu(); + + contextMenu = document.getElementById("contentAreaContextMenu"); + topLevelMenuItem = contextMenu.getElementsByAttribute("label", "child"); + + confirmContextMenuIcon(topLevelMenuItem); + await closeContextMenu(); + + await extension.unload(); + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_child_icon() { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, PAGE); + + let blackIconData = + "iVBORw0KGgoAAAANSUhEUgAAABIAAAASCAIAAADZrBkAAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4QYGEhkO2P07+gAAABl0RVh0Q29tbWVudABDcmVhdGVkIHdpdGggR0lNUFeBDhcAAAARSURBVCjPY2AYBaNgFAxPAAAD3gABo0ohTgAAAABJRU5ErkJggg=="; + const IMAGE_ARRAYBUFFER_BLACK = imageBufferFromDataURI(blackIconData); + + let redIconData = + "iVBORw0KGgoAAAANSUhEUgAAABIAAAASCAIAAADZrBkAAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4QYGEgw1XkM0ygAAABl0RVh0Q29tbWVudABDcmVhdGVkIHdpdGggR0lNUFeBDhcAAAAYSURBVCjPY/zPQA5gYhjVNqptVNsg1wYAItkBI/GNR3YAAAAASUVORK5CYII="; + const IMAGE_ARRAYBUFFER_RED = imageBufferFromDataURI(redIconData); + + let blueIconData = + "iVBORw0KGgoAAAANSUhEUgAAABIAAAASCAIAAADZrBkAAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4QYGEg0QDFzRzAAAABl0RVh0Q29tbWVudABDcmVhdGVkIHdpdGggR0lNUFeBDhcAAAAbSURBVCjPY2SQ+89AOmBiIAuMahvVNqqNftoAlKMBQZXKX9kAAAAASUVORK5CYII="; + const IMAGE_ARRAYBUFFER_BLUE = imageBufferFromDataURI(blueIconData); + + let greenIconData = + "iVBORw0KGgoAAAANSUhEUgAAABIAAAASCAIAAADZrBkAAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4QYGEg0rvVc46AAAABl0RVh0Q29tbWVudABDcmVhdGVkIHdpdGggR0lNUFeBDhcAAAAaSURBVCjPY+Q8xkAGYGJgGNU2qm1U2+DWBgBolADz1beTnwAAAABJRU5ErkJggg=="; + const IMAGE_ARRAYBUFFER_GREEN = imageBufferFromDataURI(greenIconData); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["contextMenus"], + icons: { + 18: "black_icon.png", + }, + }, + + files: { + "black_icon.png": IMAGE_ARRAYBUFFER_BLACK, + "red_icon.png": IMAGE_ARRAYBUFFER_RED, + "blue_icon.png": IMAGE_ARRAYBUFFER_BLUE, + "green_icon.png": IMAGE_ARRAYBUFFER_GREEN, + }, + + background: function () { + browser.test.onMessage.addListener(msg => { + if (msg !== "add-additional-contextmenu-items") { + return; + } + + browser.contextMenus.create({ + title: "child2", + id: "contextmenu-child2", + icons: { + 18: "blue_icon.png", + }, + }); + + browser.contextMenus.create( + { + title: "child3", + id: "contextmenu-child3", + icons: { + 18: "green_icon.png", + }, + }, + () => { + browser.test.sendMessage("extra-contextmenu-items-added"); + } + ); + }); + + browser.contextMenus.create( + { + title: "child1", + id: "contextmenu-child1", + icons: { + 18: "red_icon.png", + }, + }, + () => { + browser.test.sendMessage("single-contextmenu-item-added"); + } + ); + }, + }); + + let confirmContextMenuIcon = (element, imageName) => { + let imageURL = element.getAttribute("image"); + ok( + imageURL.endsWith(imageName), + "The context menu should display the extension icon next to the child element" + ); + }; + + await extension.startup(); + + await extension.awaitMessage("single-contextmenu-item-added"); + + let contextMenu = await openContextMenu(); + let contextMenuChild1 = contextMenu.getElementsByAttribute( + "label", + "child1" + )[0]; + confirmContextMenuIcon(contextMenuChild1, "black_icon.png"); + + await closeContextMenu(); + + extension.sendMessage("add-additional-contextmenu-items"); + await extension.awaitMessage("extra-contextmenu-items-added"); + + contextMenu = await openExtensionContextMenu(); + + contextMenuChild1 = contextMenu.getElementsByAttribute("label", "child1")[0]; + confirmContextMenuIcon(contextMenuChild1, "red_icon.png"); + + let contextMenuChild2 = contextMenu.getElementsByAttribute( + "label", + "child2" + )[0]; + confirmContextMenuIcon(contextMenuChild2, "blue_icon.png"); + + let contextMenuChild3 = contextMenu.getElementsByAttribute( + "label", + "child3" + )[0]; + confirmContextMenuIcon(contextMenuChild3, "green_icon.png"); + + await closeContextMenu(); + + await extension.unload(); + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_manifest_without_icons() { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, PAGE); + + let redIconData = + "iVBORw0KGgoAAAANSUhEUgAAABIAAAASCAIAAADZrBkAAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4QYGEgw1XkM0ygAAABl0RVh0Q29tbWVudABDcmVhdGVkIHdpdGggR0lNUFeBDhcAAAAYSURBVCjPY/zPQA5gYhjVNqptVNsg1wYAItkBI/GNR3YAAAAASUVORK5CYII="; + const IMAGE_ARRAYBUFFER_RED = imageBufferFromDataURI(redIconData); + + let greenIconData = + "iVBORw0KGgoAAAANSUhEUgAAABIAAAASCAIAAADZrBkAAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4QYGEg0rvVc46AAAABl0RVh0Q29tbWVudABDcmVhdGVkIHdpdGggR0lNUFeBDhcAAAAaSURBVCjPY+Q8xkAGYGJgGNU2qm1U2+DWBgBolADz1beTnwAAAABJRU5ErkJggg=="; + const IMAGE_ARRAYBUFFER_GREEN = imageBufferFromDataURI(greenIconData); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + name: "contextMenus icons", + permissions: ["contextMenus"], + }, + files: { + "red.png": IMAGE_ARRAYBUFFER_RED, + "green.png": IMAGE_ARRAYBUFFER_GREEN, + }, + + background() { + browser.contextMenus.create( + { + title: "first item", + icons: { + 18: "red.png", + }, + onclick() { + browser.contextMenus.create( + { + title: "second item", + icons: { + 18: "green.png", + }, + }, + () => { + browser.test.sendMessage("added-second-item"); + } + ); + }, + }, + () => { + browser.test.sendMessage("contextmenus-icons"); + } + ); + }, + }); + + await extension.startup(); + await extension.awaitMessage("contextmenus-icons"); + + let menu = await openContextMenu(); + let items = menu.getElementsByAttribute("label", "first item"); + is(items.length, 1, "Found first item"); + // manifest.json does not declare icons, so the root menu item shouldn't have an icon either. + is(items[0].getAttribute("image"), "", "Root menu must not have an icon"); + + await closeExtensionContextMenu(items[0]); + await extension.awaitMessage("added-second-item"); + + menu = await openExtensionContextMenu(); + items = document.querySelectorAll( + "#contentAreaContextMenu [ext-type='top-level-menu']" + ); + is(items.length, 1, "Auto-generated root item exists"); + is( + items[0].getAttribute("image"), + "", + "Auto-generated menu root must not have an icon" + ); + + items = menu.getElementsByAttribute("label", "first item"); + is(items.length, 1, "First child item should exist"); + is( + items[0].getAttribute("image").split("/").pop(), + "red.png", + "First item should have an icon" + ); + + items = menu.getElementsByAttribute("label", "second item"); + is(items.length, 1, "Secobnd child item should exist"); + is( + items[0].getAttribute("image").split("/").pop(), + "green.png", + "Second item should have an icon" + ); + + await closeExtensionContextMenu(); + await extension.unload(); + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_child_icon_update() { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, PAGE); + + let blackIconData = + "iVBORw0KGgoAAAANSUhEUgAAABIAAAASCAIAAADZrBkAAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4QYGEhkO2P07+gAAABl0RVh0Q29tbWVudABDcmVhdGVkIHdpdGggR0lNUFeBDhcAAAARSURBVCjPY2AYBaNgFAxPAAAD3gABo0ohTgAAAABJRU5ErkJggg=="; + const IMAGE_ARRAYBUFFER_BLACK = imageBufferFromDataURI(blackIconData); + + let redIconData = + "iVBORw0KGgoAAAANSUhEUgAAABIAAAASCAIAAADZrBkAAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4QYGEgw1XkM0ygAAABl0RVh0Q29tbWVudABDcmVhdGVkIHdpdGggR0lNUFeBDhcAAAAYSURBVCjPY/zPQA5gYhjVNqptVNsg1wYAItkBI/GNR3YAAAAASUVORK5CYII="; + const IMAGE_ARRAYBUFFER_RED = imageBufferFromDataURI(redIconData); + + let blueIconData = + "iVBORw0KGgoAAAANSUhEUgAAABIAAAASCAIAAADZrBkAAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4QYGEg0QDFzRzAAAABl0RVh0Q29tbWVudABDcmVhdGVkIHdpdGggR0lNUFeBDhcAAAAbSURBVCjPY2SQ+89AOmBiIAuMahvVNqqNftoAlKMBQZXKX9kAAAAASUVORK5CYII="; + const IMAGE_ARRAYBUFFER_BLUE = imageBufferFromDataURI(blueIconData); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["contextMenus"], + icons: { + 18: "black_icon.png", + }, + }, + + files: { + "black_icon.png": IMAGE_ARRAYBUFFER_BLACK, + "red_icon.png": IMAGE_ARRAYBUFFER_RED, + "blue_icon.png": IMAGE_ARRAYBUFFER_BLUE, + }, + + background: function () { + browser.test.onMessage.addListener(msg => { + if (msg === "update-contextmenu-item") { + browser.contextMenus.update( + "contextmenu-child2", + { + icons: { + 18: "blue_icon.png", + }, + }, + () => { + browser.test.sendMessage("contextmenu-item-updated"); + } + ); + } else if (msg === "update-contextmenu-item-without-icons") { + browser.contextMenus.update("contextmenu-child2", {}, () => { + browser.test.sendMessage("contextmenu-item-updated-without-icons"); + }); + } else if (msg === "update-contextmenu-item-with-icons-as-null") { + browser.contextMenus.update( + "contextmenu-child2", + { + icons: null, + }, + () => { + browser.test.sendMessage( + "contextmenu-item-updated-with-icons-as-null" + ); + } + ); + } else if (msg === "update-contextmenu-item-when-its-the-only-child") { + browser.contextMenus.update( + "contextmenu-child1", + { + icons: { + 18: "blue_icon.png", + }, + }, + () => { + browser.test.sendMessage( + "contextmenu-item-updated-when-its-only-child" + ); + } + ); + } + }); + + browser.contextMenus.create({ + title: "child1", + id: "contextmenu-child1", + icons: { + 18: "blue_icon.png", + }, + }); + + let menuitemId = browser.contextMenus.create( + { + title: "child2", + id: "contextmenu-child2", + icons: { + 18: "red_icon.png", + }, + onclick: async () => { + await browser.contextMenus.remove(menuitemId); + browser.test.sendMessage("child-deleted"); + }, + }, + () => { + browser.test.sendMessage("contextmenu-items-added"); + } + ); + }, + }); + + let confirmContextMenuIcon = (element, imageName) => { + let imageURL = element.getAttribute("image"); + ok( + imageURL.endsWith(imageName), + "The context menu should display the extension icon next to the child element" + ); + }; + + await extension.startup(); + + await extension.awaitMessage("contextmenu-items-added"); + let contextMenu = await openExtensionContextMenu(); + + let contextMenuChild1 = contextMenu.getElementsByAttribute( + "label", + "child1" + )[0]; + confirmContextMenuIcon(contextMenuChild1, "blue_icon.png"); + + let contextMenuChild2 = contextMenu.getElementsByAttribute( + "label", + "child2" + )[0]; + confirmContextMenuIcon(contextMenuChild2, "red_icon.png"); + + await closeContextMenu(); + + extension.sendMessage("update-contextmenu-item"); + await extension.awaitMessage("contextmenu-item-updated"); + + contextMenu = await openExtensionContextMenu(); + + contextMenuChild2 = contextMenu.getElementsByAttribute("label", "child2")[0]; + confirmContextMenuIcon(contextMenuChild2, "blue_icon.png"); + + await closeContextMenu(); + + extension.sendMessage("update-contextmenu-item-without-icons"); + await extension.awaitMessage("contextmenu-item-updated-without-icons"); + + contextMenu = await openExtensionContextMenu(); + + contextMenuChild2 = contextMenu.getElementsByAttribute("label", "child2")[0]; + confirmContextMenuIcon(contextMenuChild2, "blue_icon.png"); + + await closeContextMenu(); + + extension.sendMessage("update-contextmenu-item-with-icons-as-null"); + await extension.awaitMessage("contextmenu-item-updated-with-icons-as-null"); + + contextMenu = await openExtensionContextMenu(); + + contextMenuChild2 = contextMenu.getElementsByAttribute("label", "child2")[0]; + is( + contextMenuChild2.getAttribute("image"), + "", + "Second child should not have an icon" + ); + + await closeExtensionContextMenu(contextMenuChild2); + await extension.awaitMessage("child-deleted"); + + contextMenu = await openContextMenu(); + + contextMenuChild1 = contextMenu.getElementsByAttribute("label", "child1")[0]; + confirmContextMenuIcon(contextMenuChild1, "black_icon.png"); + + await closeContextMenu(); + + extension.sendMessage("update-contextmenu-item-when-its-the-only-child"); + await extension.awaitMessage("contextmenu-item-updated-when-its-only-child"); + + contextMenu = await openContextMenu(); + + contextMenuChild1 = contextMenu.getElementsByAttribute("label", "child1")[0]; + confirmContextMenuIcon(contextMenuChild1, "black_icon.png"); + + await closeContextMenu(); + + await extension.unload(); + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_contextMenus_onclick.js b/browser/components/extensions/test/browser/browser_ext_contextMenus_onclick.js new file mode 100644 index 0000000000..72c365f7d7 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_contextMenus_onclick.js @@ -0,0 +1,297 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const PAGE = + "http://mochi.test:8888/browser/browser/components/extensions/test/browser/context.html"; + +// Loaded both as a background script and a tab page. +function testScript() { + let page = location.pathname.includes("tab.html") ? "tab" : "background"; + let clickCounts = { + old: 0, + new: 0, + }; + browser.contextMenus.onClicked.addListener(() => { + // Async to give other onclick handlers a chance to fire. + setTimeout(() => { + browser.test.sendMessage("onClicked-fired", page); + }); + }); + browser.test.onMessage.addListener((toPage, msg) => { + if (toPage !== page) { + return; + } + browser.test.log(`Received ${msg} for ${toPage}`); + if (msg == "get-click-counts") { + browser.test.sendMessage("click-counts", clickCounts); + } else if (msg == "clear-click-counts") { + clickCounts.old = clickCounts.new = 0; + browser.test.sendMessage("next"); + } else if (msg == "create-with-onclick") { + browser.contextMenus.create( + { + id: "iden", + title: "tifier", + onclick() { + ++clickCounts.old; + browser.test.log( + `onclick fired for original onclick property in ${page}` + ); + }, + }, + () => browser.test.sendMessage("next") + ); + } else if (msg == "create-without-onclick") { + browser.contextMenus.create( + { + id: "iden", + title: "tifier", + }, + () => browser.test.sendMessage("next") + ); + } else if (msg == "update-without-onclick") { + browser.contextMenus.update( + "iden", + { + enabled: true, // Already enabled, so this does nothing. + }, + () => browser.test.sendMessage("next") + ); + } else if (msg == "update-with-onclick") { + browser.contextMenus.update( + "iden", + { + onclick() { + ++clickCounts.new; + browser.test.log( + `onclick fired for updated onclick property in ${page}` + ); + }, + }, + () => browser.test.sendMessage("next") + ); + } else if (msg == "remove") { + browser.contextMenus.remove("iden", () => + browser.test.sendMessage("next") + ); + } else if (msg == "removeAll") { + browser.contextMenus.removeAll(() => browser.test.sendMessage("next")); + } + }); + + if (page == "background") { + browser.test.log("Opening tab.html"); + browser.tabs.create({ + url: "tab.html", + active: false, // To not interfere with the context menu tests. + }); + } else { + // Sanity check - the pages must be in the same process. + let pages = browser.extension.getViews(); + browser.test.assertTrue( + pages.includes(window), + "Expected this tab to be an extension view" + ); + pages = pages.filter(w => w !== window); + browser.test.assertEq( + pages[0], + browser.extension.getBackgroundPage(), + "Expected the other page to be a background page" + ); + browser.test.sendMessage("tab.html ready"); + } +} + +add_task(async function () { + let tab1 = await BrowserTestUtils.openNewForegroundTab(gBrowser, PAGE); + + gBrowser.selectedTab = tab1; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["contextMenus"], + }, + background: testScript, + files: { + "tab.html": `<!DOCTYPE html><meta charset="utf-8"><script src="tab.js"></script>`, + "tab.js": testScript, + }, + }); + await extension.startup(); + await extension.awaitMessage("tab.html ready"); + + async function clickContextMenu() { + // Using openContextMenu instead of openExtensionContextMenu because the + // test extension has only one context menu item. + let extensionMenuRoot = await openContextMenu(); + let items = extensionMenuRoot.getElementsByAttribute("label", "tifier"); + is(items.length, 1, "Expected one context menu item"); + await closeExtensionContextMenu(items[0]); + // One of them is "tab", the other is "background". + info(`onClicked from: ${await extension.awaitMessage("onClicked-fired")}`); + info(`onClicked from: ${await extension.awaitMessage("onClicked-fired")}`); + } + + function getCounts(page) { + extension.sendMessage(page, "get-click-counts"); + return extension.awaitMessage("click-counts"); + } + async function resetCounts() { + extension.sendMessage("tab", "clear-click-counts"); + extension.sendMessage("background", "clear-click-counts"); + await extension.awaitMessage("next"); + await extension.awaitMessage("next"); + } + + // During this test, at most one "onclick" attribute is expected at any time. + for (let pageOne of ["background", "tab"]) { + for (let pageTwo of ["background", "tab"]) { + info(`Testing with menu created by ${pageOne} and updated by ${pageTwo}`); + extension.sendMessage(pageOne, "create-with-onclick"); + await extension.awaitMessage("next"); + + // Test that update without onclick attribute does not clear the existing + // onclick handler. + extension.sendMessage(pageTwo, "update-without-onclick"); + await extension.awaitMessage("next"); + await clickContextMenu(); + let clickCounts = await getCounts(pageOne); + is( + clickCounts.old, + 1, + `Original onclick should still be present in ${pageOne}` + ); + is(clickCounts.new, 0, `Not expecting any new handlers in ${pageOne}`); + if (pageOne !== pageTwo) { + clickCounts = await getCounts(pageTwo); + is(clickCounts.old, 0, `Not expecting any handlers in ${pageTwo}`); + is(clickCounts.new, 0, `Not expecting any new handlers in ${pageTwo}`); + } + await resetCounts(); + + // Test that update with onclick handler in a different page clears the + // existing handler and activates the new onclick handler. + extension.sendMessage(pageTwo, "update-with-onclick"); + await extension.awaitMessage("next"); + await clickContextMenu(); + clickCounts = await getCounts(pageOne); + is(clickCounts.old, 0, `Original onclick should be gone from ${pageOne}`); + if (pageOne !== pageTwo) { + is( + clickCounts.new, + 0, + `Still not expecting new handlers in ${pageOne}` + ); + } + clickCounts = await getCounts(pageTwo); + if (pageOne !== pageTwo) { + is(clickCounts.old, 0, `Not expecting an old onclick in ${pageTwo}`); + } + is(clickCounts.new, 1, `New onclick should be triggered in ${pageTwo}`); + await resetCounts(); + + // Test that updating the handler (different again from the last `update` + // call, but the same as the `create` call) clears the existing handler + // and activates the new onclick handler. + extension.sendMessage(pageOne, "update-with-onclick"); + await extension.awaitMessage("next"); + await clickContextMenu(); + clickCounts = await getCounts(pageOne); + is(clickCounts.new, 1, `onclick should be triggered in ${pageOne}`); + if (pageOne !== pageTwo) { + clickCounts = await getCounts(pageTwo); + is(clickCounts.new, 0, `onclick should be gone from ${pageTwo}`); + } + await resetCounts(); + + // Test that removing the context menu and recreating it with the same ID + // (in a different context) does not leave behind any onclick handlers. + extension.sendMessage(pageTwo, "remove"); + await extension.awaitMessage("next"); + extension.sendMessage(pageTwo, "create-without-onclick"); + await extension.awaitMessage("next"); + await clickContextMenu(); + clickCounts = await getCounts(pageOne); + is(clickCounts.new, 0, `Did not expect any click handlers in ${pageOne}`); + if (pageOne !== pageTwo) { + clickCounts = await getCounts(pageTwo); + is( + clickCounts.new, + 0, + `Did not expect any click handlers in ${pageTwo}` + ); + } + await resetCounts(); + + // Remove context menu for the next iteration of the test. And just to get + // more coverage, let's use removeAll instead of remove. + extension.sendMessage(pageOne, "removeAll"); + await extension.awaitMessage("next"); + } + } + await extension.unload(); + BrowserTestUtils.removeTab(tab1); +}); + +add_task(async function test_onclick_modifiers() { + const manifest = { + permissions: ["contextMenus"], + }; + + function background() { + function onclick(info) { + browser.test.sendMessage("click", info); + } + browser.contextMenus.create( + { contexts: ["all"], title: "modify", onclick }, + () => { + browser.test.sendMessage("ready"); + } + ); + } + + const extension = ExtensionTestUtils.loadExtension({ manifest, background }); + const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, PAGE); + + await extension.startup(); + await extension.awaitMessage("ready"); + + async function click(modifiers = {}) { + const menu = await openContextMenu(); + const items = menu.getElementsByAttribute("label", "modify"); + is(items.length, 1, "Got exactly one context menu item"); + await closeExtensionContextMenu(items[0], modifiers); + return extension.awaitMessage("click"); + } + + const plain = await click(); + is(plain.modifiers.length, 0, "modifiers array empty with a plain click"); + + const shift = await click({ shiftKey: true }); + is(shift.modifiers.join(), "Shift", "Correct modifier: Shift"); + + const ctrl = await click({ ctrlKey: true }); + if (AppConstants.platform !== "macosx") { + is(ctrl.modifiers.join(), "Ctrl", "Correct modifier: Ctrl"); + } else { + is( + ctrl.modifiers.sort().join(), + "Ctrl,MacCtrl", + "Correct modifier: Ctrl (and MacCtrl)" + ); + + const meta = await click({ metaKey: true }); + is(meta.modifiers.join(), "Command", "Correct modifier: Command"); + } + + const altShift = await click({ altKey: true, shiftKey: true }); + is( + altShift.modifiers.sort().join(), + "Alt,Shift", + "Correct modifiers: Shift+Alt" + ); + + BrowserTestUtils.removeTab(tab); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_contextMenus_radioGroups.js b/browser/components/extensions/test/browser/browser_ext_contextMenus_radioGroups.js new file mode 100644 index 0000000000..9ca09df242 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_contextMenus_radioGroups.js @@ -0,0 +1,140 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function () { + let tab1 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://mochi.test:8888/browser/browser/components/extensions/test/browser/context.html" + ); + + gBrowser.selectedTab = tab1; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["contextMenus"], + }, + + background: function () { + // Report onClickData info back. + browser.contextMenus.onClicked.addListener(info => { + browser.test.sendMessage("contextmenus-click", info); + }); + + browser.contextMenus.create({ + title: "radio-group-1", + type: "radio", + checked: true, + }); + + browser.contextMenus.create({ + type: "separator", + }); + + browser.contextMenus.create({ + title: "radio-group-2", + type: "radio", + }); + + browser.contextMenus.create({ + title: "radio-group-2", + type: "radio", + }); + + browser.test.notifyPass("contextmenus-radio-groups"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("contextmenus-radio-groups"); + + function confirmRadioGroupStates(extensionMenuRoot, expectedStates) { + let radioItems = extensionMenuRoot.getElementsByAttribute("type", "radio"); + let radioGroup1 = extensionMenuRoot.getElementsByAttribute( + "label", + "radio-group-1" + ); + let radioGroup2 = extensionMenuRoot.getElementsByAttribute( + "label", + "radio-group-2" + ); + + is( + radioItems.length, + 3, + "there should be 3 radio items in the context menu" + ); + is( + radioGroup1.length, + 1, + "the first radio group should only have 1 radio item" + ); + is( + radioGroup2.length, + 2, + "the second radio group should only have 2 radio items" + ); + + is( + radioGroup1[0].hasAttribute("checked"), + expectedStates[0], + `radio item 1 has state (checked=${expectedStates[0]})` + ); + is( + radioGroup2[0].hasAttribute("checked"), + expectedStates[1], + `radio item 2 has state (checked=${expectedStates[1]})` + ); + is( + radioGroup2[1].hasAttribute("checked"), + expectedStates[2], + `radio item 3 has state (checked=${expectedStates[2]})` + ); + + return extensionMenuRoot.getElementsByAttribute("type", "radio"); + } + + function confirmOnClickData(onClickData, id, was, checked) { + is( + onClickData.wasChecked, + was, + `radio item ${id} was ${was ? "" : "not "}checked before the click` + ); + is( + onClickData.checked, + checked, + `radio item ${id} is ${checked ? "" : "not "}checked after the click` + ); + } + + let extensionMenuRoot = await openExtensionContextMenu(); + let items = confirmRadioGroupStates(extensionMenuRoot, [true, false, false]); + await closeExtensionContextMenu(items[1]); + + let result = await extension.awaitMessage("contextmenus-click"); + confirmOnClickData(result, 2, false, true); + + extensionMenuRoot = await openExtensionContextMenu(); + items = confirmRadioGroupStates(extensionMenuRoot, [true, true, false]); + await closeExtensionContextMenu(items[2]); + + result = await extension.awaitMessage("contextmenus-click"); + confirmOnClickData(result, 3, false, true); + + extensionMenuRoot = await openExtensionContextMenu(); + items = confirmRadioGroupStates(extensionMenuRoot, [true, false, true]); + await closeExtensionContextMenu(items[0]); + + result = await extension.awaitMessage("contextmenus-click"); + confirmOnClickData(result, 1, true, true); + + extensionMenuRoot = await openExtensionContextMenu(); + items = confirmRadioGroupStates(extensionMenuRoot, [true, false, true]); + await closeExtensionContextMenu(items[0]); + + result = await extension.awaitMessage("contextmenus-click"); + confirmOnClickData(result, 1, true, true); + + await extension.unload(); + BrowserTestUtils.removeTab(tab1); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_contextMenus_srcUrl_redirect.js b/browser/components/extensions/test/browser/browser_ext_contextMenus_srcUrl_redirect.js new file mode 100644 index 0000000000..2d00ca356a --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_contextMenus_srcUrl_redirect.js @@ -0,0 +1,69 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_srcUrl_of_redirected_image() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["contextMenus"], + }, + background() { + browser.contextMenus.onClicked.addListener(info => { + browser.test.assertEq( + "before_redir", + info.menuItemId, + "Expected menu item matched for pre-redirect URL" + ); + browser.test.assertEq("image", info.mediaType, "Expected mediaType"); + browser.test.assertEq( + "http://mochi.test:8888/browser/browser/components/extensions/test/browser/redirect_to.sjs?ctxmenu-image.png", + info.srcUrl, + "Expected srcUrl" + ); + browser.test.sendMessage("contextMenus_onClicked"); + }); + browser.contextMenus.create({ + id: "before_redir", + title: "MyMenu", + targetUrlPatterns: ["*://*/*redirect_to.sjs*"], + }); + browser.contextMenus.create( + { + id: "after_redir", + title: "MyMenu", + targetUrlPatterns: ["*://*/*/ctxmenu-image.png*"], + }, + () => { + browser.test.sendMessage("menus_setup"); + } + ); + }, + }); + await extension.startup(); + await extension.awaitMessage("menus_setup"); + + await BrowserTestUtils.withNewTab( + { + gBrowser, + url: "http://mochi.test:8888/browser/browser/components/extensions/test/browser/context_with_redirect.html", + }, + async browser => { + // Verify that the image has been loaded, which implies that the redirect has + // been followed. + let imgWidth = await SpecialPowers.spawn(browser, [], () => { + let img = content.document.getElementById("img_that_redirects"); + return img.naturalWidth; + }); + is(imgWidth, 100, "Image has been loaded"); + + let menu = await openContextMenu("#img_that_redirects"); + let items = menu.getElementsByAttribute("label", "MyMenu"); + is(items.length, 1, "Only one menu item should have been matched"); + + await closeExtensionContextMenu(items[0]); + await extension.awaitMessage("contextMenus_onClicked"); + } + ); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_contextMenus_targetUrlPatterns.js b/browser/components/extensions/test/browser/browser_ext_contextMenus_targetUrlPatterns.js new file mode 100644 index 0000000000..b28487064f --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_contextMenus_targetUrlPatterns.js @@ -0,0 +1,317 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function unsupportedSchemes() { + const testcases = [ + { + // Link to URL with query string parameters only. + testUrl: "magnet:?xt=urn:btih:somesha1hash&dn=displayname.txt", + matchingPatterns: [ + "magnet:*", + "magnet:?xt=*", + "magnet:?xt=*txt", + "magnet:*?xt=*txt", + ], + nonmatchingPatterns: [ + // Although <all_urls> matches unsupported schemes in Chromium, + // we have specified that <all_urls> only matches all supported + // schemes. To match any scheme, an extension should not set the + // targetUrlPatterns field - this is checked below in subtest + // unsupportedSchemeWithoutTargetUrlPatterns. + "<all_urls>", + "agnet:*", + "magne:*", + ], + }, + { + // Link to bookmarklet. + testUrl: "javascript:-URL", + matchingPatterns: ["javascript:*", "javascript:*URL", "javascript:-URL"], + nonmatchingPatterns: [ + "<all_urls>", + "javascript://-URL", + "javascript:javascript:-URL", + ], + }, + { + // Link to bookmarklet with comment. + testUrl: "javascript://-URL", + matchingPatterns: [ + "javascript:*", + "javascript://-URL", + "javascript:*URL", + ], + nonmatchingPatterns: ["<all_urls>", "javascript:-URL"], + }, + { + // Link to data-URI. + testUrl: "data:application/foo,bar", + matchingPatterns: [ + "<all_urls>", + "data:application/foo,bar", + "data:*,*", + "data:*", + ], + nonmatchingPatterns: ["data:,bar", "data:application/foo,"], + }, + { + // Extension page. + testUrl: "moz-extension://uuid/manifest.json", + matchingPatterns: ["moz-extension://*/*"], + nonmatchingPatterns: [ + "<all_urls>", + "moz-extension://uuid/not/manifest.json*", + ], + }, + { + // While the scheme is supported, the URL is invalid. + testUrl: "http://", + matchingPatterns: [], + nonmatchingPatterns: ["http://*/*", "<all_urls>"], + }, + ]; + + async function testScript(testcases) { + let testcase; + + browser.contextMenus.onShown.addListener(({ menuIds, linkUrl }) => { + browser.test.assertEq(testcase.testUrl, linkUrl, "Expected linkUrl"); + for (let pattern of testcase.matchingPatterns) { + browser.test.assertTrue( + menuIds.includes(pattern), + `Menu item with targetUrlPattern="${pattern}" should be shown at ${testcase.testUrl}` + ); + } + for (let pattern of testcase.nonmatchingPatterns) { + browser.test.assertFalse( + menuIds.includes(pattern), + `Menu item with targetUrlPattern="${pattern}" should not be shown at ${testcase.testUrl}` + ); + } + testcase = null; + browser.test.sendMessage("onShown_checked"); + }); + + browser.test.onMessage.addListener(async (msg, params) => { + browser.test.assertEq("setupTest", msg, "Expected message"); + + // Save test case in global variable for use in the onShown event. + testcase = params; + browser.test.log(`Running test for link with URL: ${testcase.testUrl}`); + document.getElementById("test_link_element").href = testcase.testUrl; + await browser.contextMenus.removeAll(); + for (let targetUrlPattern of [ + ...testcase.matchingPatterns, + ...testcase.nonmatchingPatterns, + ]) { + await new Promise(resolve => { + browser.test.log(`Creating menu with "${targetUrlPattern}"`); + browser.contextMenus.create( + { + id: targetUrlPattern, + contexts: ["link"], + title: "Some menu item", + targetUrlPatterns: [targetUrlPattern], + }, + resolve + ); + }); + } + browser.test.sendMessage("setupTest_ready"); + }); + browser.test.sendMessage("ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["contextMenus"], + }, + background() { + browser.tabs.create({ url: "testrunner.html" }); + }, + files: { + "testrunner.js": `(${testScript})()`, + "testrunner.html": ` + <!DOCTYPE html><meta charset="utf-8"> + <body> + <a id="test_link_element">Test link</a> + <script src="testrunner.js"></script> + </body> + `, + }, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + for (let testcase of testcases) { + extension.sendMessage("setupTest", testcase); + await extension.awaitMessage("setupTest_ready"); + + await openExtensionContextMenu("#test_link_element"); + await extension.awaitMessage("onShown_checked"); + await closeContextMenu(); + } + await extension.unload(); +}); + +async function testLinkMenuWithoutTargetUrlPatterns(linkUrl) { + function background(expectedLinkUrl) { + let menuId; + browser.contextMenus.onShown.addListener(({ menuIds, linkUrl }) => { + browser.test.assertEq(1, menuIds.length, "Expected number of menus"); + browser.test.assertEq(menuId, menuIds[0], "Expected menu ID"); + browser.test.assertEq(expectedLinkUrl, linkUrl, "Expected linkUrl"); + browser.test.sendMessage("done"); + }); + menuId = browser.contextMenus.create( + { + contexts: ["link"], + title: "Test menu item without targetUrlPattern", + }, + () => { + browser.tabs.create({ url: "testpage.html" }); + } + ); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["contextMenus"], + }, + background: `(${background})("${linkUrl}")`, + files: { + "testpage.js": `browser.test.sendMessage("ready")`, + "testpage.html": ` + <!DOCTYPE html><meta charset="utf-8"> + <a id="test_link_element" href="${linkUrl}">Test link</a> + <script src="testpage.js"></script> + `, + }, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + // Wait for the browser window chrome document to be flushed before + // trying to trigger the context menu in the newly created tab, + // to prevent intermittent failures (e.g. Bug 1775558). + await gBrowser.ownerGlobal.promiseDocumentFlushed(() => {}); + await openExtensionContextMenu("#test_link_element"); + await extension.awaitMessage("done"); + await closeContextMenu(); + + await extension.unload(); +} + +// Tests that a menu item is shown on links with an unsupported scheme if +// targetUrlPatterns is not set. +add_task(async function unsupportedSchemeWithoutPattern() { + await testLinkMenuWithoutTargetUrlPatterns("unsupported-scheme:data"); +}); + +// Tests that a menu item is shown on links with an invalid http:-URL if +// targetUrlPatterns is not set. +add_task(async function invalidHttpUrlWithoutPattern() { + await testLinkMenuWithoutTargetUrlPatterns("http://"); +}); + +add_task(async function privileged_are_allowed_to_use_restrictedSchemes() { + let privilegedExtension = ExtensionTestUtils.loadExtension({ + isPrivileged: true, + manifest: { + permissions: ["tabs", "contextMenus", "mozillaAddons"], + }, + async background() { + browser.contextMenus.create({ + id: "privileged-extension", + title: "Privileged Extension", + contexts: ["page"], + documentUrlPatterns: ["about:reader*"], + }); + + browser.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => { + if ( + changeInfo.status === "complete" && + tab.url.startsWith("about:reader") + ) { + browser.test.sendMessage("readerModeEntered"); + } + }); + + browser.test.onMessage.addListener(async msg => { + if (msg !== "enterReaderMode") { + browser.test.fail(`Received unexpected test message: ${msg}`); + return; + } + + browser.tabs.toggleReaderMode(); + }); + }, + }); + + let nonPrivilegedExtension = ExtensionTestUtils.loadExtension({ + isPrivileged: false, + manifest: { + permissions: ["contextMenus", "mozillaAddons"], + }, + async background() { + browser.contextMenus.create({ + id: "non-privileged-extension", + title: "Non Privileged Extension", + contexts: ["page"], + documentUrlPatterns: ["about:reader*"], + }); + }, + }); + + const baseUrl = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "http://example.com" + ); + const url = `${baseUrl}/readerModeArticle.html`; + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + url, + true, + true + ); + + await Promise.all([ + privilegedExtension.startup(), + nonPrivilegedExtension.startup(), + ]); + + privilegedExtension.sendMessage("enterReaderMode"); + await privilegedExtension.awaitMessage("readerModeEntered"); + + const contextMenu = await openContextMenu("body > h1"); + + let item = contextMenu.getElementsByAttribute( + "label", + "Privileged Extension" + ); + is( + item.length, + 1, + "Privileged extension's contextMenu item found as expected" + ); + + item = contextMenu.getElementsByAttribute( + "label", + "Non Privileged Extension" + ); + is( + item.length, + 0, + "Non privileged extension's contextMenu not found as expected" + ); + + await closeContextMenu(); + + BrowserTestUtils.removeTab(tab); + + await Promise.all([ + privilegedExtension.unload(), + nonPrivilegedExtension.unload(), + ]); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_contextMenus_uninstall.js b/browser/components/extensions/test/browser/browser_ext_contextMenus_uninstall.js new file mode 100644 index 0000000000..0d0b7cac7b --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_contextMenus_uninstall.js @@ -0,0 +1,114 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function () { + let tab1 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://mochi.test:8888/browser/browser/components/extensions/test/browser/context.html" + ); + + // Install an extension. + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["contextMenus"], + }, + + background: function () { + browser.contextMenus.create({ title: "a" }); + browser.contextMenus.create({ title: "b" }); + browser.test.notifyPass("contextmenus-icons"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("contextmenus-icons"); + + // Open the context menu. + let contextMenu = await openContextMenu("#img1"); + + // Confirm that the extension menu item exists. + let topLevelExtensionMenuItems = contextMenu.getElementsByAttribute( + "ext-type", + "top-level-menu" + ); + is( + topLevelExtensionMenuItems.length, + 1, + "the top level extension menu item exists" + ); + + await closeContextMenu(); + + // Uninstall the extension. + await extension.unload(); + + // Open the context menu. + contextMenu = await openContextMenu("#img1"); + + // Confirm that the extension menu item has been removed. + topLevelExtensionMenuItems = contextMenu.getElementsByAttribute( + "ext-type", + "top-level-menu" + ); + is( + topLevelExtensionMenuItems.length, + 0, + "no top level extension menu items should exist" + ); + + await closeContextMenu(); + + // Install a new extension. + extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["contextMenus"], + }, + background: function () { + browser.contextMenus.create({ title: "c" }); + browser.contextMenus.create({ title: "d" }); + browser.test.notifyPass("contextmenus-uninstall-second-extension"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("contextmenus-uninstall-second-extension"); + + // Open the context menu. + contextMenu = await openContextMenu("#img1"); + + // Confirm that only the new extension menu item is in the context menu. + topLevelExtensionMenuItems = contextMenu.getElementsByAttribute( + "ext-type", + "top-level-menu" + ); + is( + topLevelExtensionMenuItems.length, + 1, + "only one top level extension menu item should exist" + ); + + // Close the context menu. + await closeContextMenu(); + + // Uninstall the extension. + await extension.unload(); + + // Open the context menu. + contextMenu = await openContextMenu("#img1"); + + // Confirm that no extension menu items exist. + topLevelExtensionMenuItems = contextMenu.getElementsByAttribute( + "ext-type", + "top-level-menu" + ); + is( + topLevelExtensionMenuItems.length, + 0, + "no top level extension menu items should exist" + ); + + await closeContextMenu(); + + BrowserTestUtils.removeTab(tab1); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_contextMenus_urlPatterns.js b/browser/components/extensions/test/browser/browser_ext_contextMenus_urlPatterns.js new file mode 100644 index 0000000000..fc8bc28523 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_contextMenus_urlPatterns.js @@ -0,0 +1,337 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function () { + let tab1 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://mochi.test:8888/browser/browser/components/extensions/test/browser/context.html" + ); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["contextMenus"], + }, + + background: function () { + // Test menu items using targetUrlPatterns. + browser.contextMenus.create({ + title: "targetUrlPatterns-patternMatches-contextAll", + targetUrlPatterns: ["*://*/*ctxmenu-image.png", "*://*/*some-link"], + contexts: ["all"], + }); + + browser.contextMenus.create({ + title: "targetUrlPatterns-patternMatches-contextImage", + targetUrlPatterns: ["*://*/*ctxmenu-image.png"], + contexts: ["image"], + }); + + browser.contextMenus.create({ + title: "targetUrlPatterns-patternMatches-contextLink", + targetUrlPatterns: ["*://*/*some-link"], + contexts: ["link"], + }); + + browser.contextMenus.create({ + title: "targetUrlPatterns-patternDoesNotMatch-contextAll", + targetUrlPatterns: ["*://*/does-not-match"], + contexts: ["all"], + }); + + browser.contextMenus.create({ + title: "targetUrlPatterns-patternDoesNotMatch-contextImage", + targetUrlPatterns: ["*://*/does-not-match"], + contexts: ["image"], + }); + + browser.contextMenus.create({ + title: "targetUrlPatterns-patternDoesNotMatch-contextLink", + targetUrlPatterns: ["*://*/does-not-match"], + contexts: ["link"], + }); + + // Test menu items using documentUrlPatterns. + browser.contextMenus.create({ + title: "documentUrlPatterns-patternMatches-contextAll", + documentUrlPatterns: ["*://*/*context*.html"], + contexts: ["all"], + }); + + browser.contextMenus.create({ + title: "documentUrlPatterns-patternMatches-contextFrame", + documentUrlPatterns: ["*://*/*context_frame.html"], + contexts: ["frame"], + }); + + browser.contextMenus.create({ + title: "documentUrlPatterns-patternMatches-contextImage", + documentUrlPatterns: [ + "*://*/*context.html", + "http://*/url-that-does-not-match", + ], + contexts: ["image"], + }); + + browser.contextMenus.create({ + title: "documentUrlPatterns-patternMatches-contextLink", + documentUrlPatterns: ["*://*/*context.html", "*://*/does-not-match"], + contexts: ["link"], + }); + + browser.contextMenus.create({ + title: "documentUrlPatterns-patternDoesNotMatch-contextAll", + documentUrlPatterns: ["*://*/does-not-match"], + contexts: ["all"], + }); + + browser.contextMenus.create({ + title: "documentUrlPatterns-patternDoesNotMatch-contextImage", + documentUrlPatterns: ["*://*/does-not-match"], + contexts: ["image"], + }); + + browser.contextMenus.create({ + title: "documentUrlPatterns-patternDoesNotMatch-contextLink", + documentUrlPatterns: ["*://*/does-not-match"], + contexts: ["link"], + }); + + // Test menu items using both targetUrlPatterns and documentUrlPatterns. + browser.contextMenus.create({ + title: + "documentUrlPatterns-patternMatches-targetUrlPatterns-patternMatches-contextAll", + documentUrlPatterns: ["*://*/*context.html"], + targetUrlPatterns: ["*://*/*ctxmenu-image.png"], + contexts: ["all"], + }); + + browser.contextMenus.create({ + title: + "documentUrlPatterns-patternDoesNotMatch-targetUrlPatterns-patternMatches-contextAll", + documentUrlPatterns: ["*://*/does-not-match"], + targetUrlPatterns: ["*://*/*ctxmenu-image.png"], + contexts: ["all"], + }); + + browser.contextMenus.create({ + title: + "documentUrlPatterns-patternMatches-targetUrlPatterns-patternDoesNotMatch-contextAll", + documentUrlPatterns: ["*://*/*context.html"], + targetUrlPatterns: ["*://*/does-not-match"], + contexts: ["all"], + }); + + browser.contextMenus.create({ + title: + "documentUrlPatterns-patternDoesNotMatch-targetUrlPatterns-patternDoesNotMatch-contextAll", + documentUrlPatterns: ["*://*/does-not-match"], + targetUrlPatterns: ["*://*/does-not-match"], + contexts: ["all"], + }); + + browser.contextMenus.create({ + title: + "documentUrlPatterns-patternMatches-targetUrlPatterns-patternMatches-contextImage", + documentUrlPatterns: ["*://*/*context.html"], + targetUrlPatterns: ["*://*/*ctxmenu-image.png"], + contexts: ["image"], + }); + + browser.contextMenus.create({ + title: + "documentUrlPatterns-patternDoesNotMatch-targetUrlPatterns-patternMatches-contextImage", + documentUrlPatterns: ["*://*/does-not-match"], + targetUrlPatterns: ["*://*/*ctxmenu-image.png"], + contexts: ["image"], + }); + + browser.contextMenus.create({ + title: + "documentUrlPatterns-patternMatches-targetUrlPatterns-patternDoesNotMatch-contextImage", + documentUrlPatterns: ["*://*/*context.html"], + targetUrlPatterns: ["*://*/does-not-match"], + contexts: ["image"], + }); + + browser.contextMenus.create({ + title: + "documentUrlPatterns-patternDoesNotMatch-targetUrlPatterns-patternDoesNotMatch-contextImage", + documentUrlPatterns: ["*://*/does-not-match/"], + targetUrlPatterns: ["*://*/does-not-match"], + contexts: ["image"], + }); + + browser.test.notifyPass("contextmenus-urlPatterns"); + }, + }); + + function confirmContextMenuItems(menu, expected) { + for (let [label, shouldShow] of expected) { + let items = menu.getElementsByAttribute("label", label); + if (shouldShow) { + is( + items.length, + 1, + `The menu item for label ${label} was correctly shown` + ); + } else { + is( + items.length, + 0, + `The menu item for label ${label} was correctly not shown` + ); + } + } + } + + await extension.startup(); + await extension.awaitFinish("contextmenus-urlPatterns"); + + let extensionContextMenu = await openExtensionContextMenu("#img1"); + let expected = [ + ["targetUrlPatterns-patternMatches-contextAll", true], + ["targetUrlPatterns-patternMatches-contextImage", true], + ["targetUrlPatterns-patternMatches-contextLink", false], + ["targetUrlPatterns-patternDoesNotMatch-contextAll", false], + ["targetUrlPatterns-patternDoesNotMatch-contextImage", false], + ["targetUrlPatterns-patternDoesNotMatch-contextLink", false], + ["documentUrlPatterns-patternMatches-contextAll", true], + ["documentUrlPatterns-patternMatches-contextImage", true], + ["documentUrlPatterns-patternMatches-contextLink", false], + ["documentUrlPatterns-patternDoesNotMatch-contextAll", false], + ["documentUrlPatterns-patternDoesNotMatch-contextImage", false], + ["documentUrlPatterns-patternDoesNotMatch-contextLink", false], + [ + "documentUrlPatterns-patternMatches-targetUrlPatterns-patternMatches-contextAll", + true, + ], + [ + "documentUrlPatterns-patternDoesNotMatch-targetUrlPatterns-patternMatches-contextAll", + false, + ], + [ + "documentUrlPatterns-patternMatches-targetUrlPatterns-patternDoesNotMatch-contextAll", + false, + ], + [ + "documentUrlPatterns-patternDoesNotMatch-targetUrlPatterns-patternDoesNotMatch-contextAll", + false, + ], + [ + "documentUrlPatterns-patternMatches-targetUrlPatterns-patternMatches-contextImage", + true, + ], + [ + "documentUrlPatterns-patternDoesNotMatch-targetUrlPatterns-patternMatches-contextImage", + false, + ], + [ + "documentUrlPatterns-patternMatches-targetUrlPatterns-patternDoesNotMatch-contextImage", + false, + ], + [ + "documentUrlPatterns-patternDoesNotMatch-targetUrlPatterns-patternDoesNotMatch-contextImage", + false, + ], + ]; + await confirmContextMenuItems(extensionContextMenu, expected); + await closeContextMenu(); + + let contextMenu = await openContextMenu("body"); + expected = [ + ["targetUrlPatterns-patternMatches-contextAll", false], + ["targetUrlPatterns-patternMatches-contextImage", false], + ["targetUrlPatterns-patternMatches-contextLink", false], + ["targetUrlPatterns-patternDoesNotMatch-contextAll", false], + ["targetUrlPatterns-patternDoesNotMatch-contextImage", false], + ["targetUrlPatterns-patternDoesNotMatch-contextLink", false], + ["documentUrlPatterns-patternMatches-contextAll", true], + ["documentUrlPatterns-patternMatches-contextImage", false], + ["documentUrlPatterns-patternMatches-contextLink", false], + ["documentUrlPatterns-patternDoesNotMatch-contextAll", false], + ["documentUrlPatterns-patternDoesNotMatch-contextImage", false], + ["documentUrlPatterns-patternDoesNotMatch-contextLink", false], + [ + "documentUrlPatterns-patternMatches-targetUrlPatterns-patternMatches-contextAll", + false, + ], + [ + "documentUrlPatterns-patternDoesNotMatch-targetUrlPatterns-patternMatches-contextAll", + false, + ], + [ + "documentUrlPatterns-patternMatches-targetUrlPatterns-patternDoesNotMatch-contextAll", + false, + ], + [ + "documentUrlPatterns-patternDoesNotMatch-targetUrlPatterns-patternDoesNotMatch-contextAll", + false, + ], + [ + "documentUrlPatterns-patternMatches-targetUrlPatterns-patternMatches-contextImage", + false, + ], + [ + "documentUrlPatterns-patternDoesNotMatch-targetUrlPatterns-patternMatches-contextImage", + false, + ], + [ + "documentUrlPatterns-patternMatches-targetUrlPatterns-patternDoesNotMatch-contextImage", + false, + ], + [ + "documentUrlPatterns-patternDoesNotMatch-targetUrlPatterns-patternDoesNotMatch-contextImage", + false, + ], + ]; + await confirmContextMenuItems(contextMenu, expected); + await closeContextMenu(); + + contextMenu = await openContextMenu("#link1"); + expected = [ + ["targetUrlPatterns-patternMatches-contextAll", true], + ["targetUrlPatterns-patternMatches-contextImage", false], + ["targetUrlPatterns-patternMatches-contextLink", true], + ["targetUrlPatterns-patternDoesNotMatch-contextAll", false], + ["targetUrlPatterns-patternDoesNotMatch-contextImage", false], + ["targetUrlPatterns-patternDoesNotMatch-contextLink", false], + ["documentUrlPatterns-patternMatches-contextAll", true], + ["documentUrlPatterns-patternMatches-contextImage", false], + ["documentUrlPatterns-patternMatches-contextLink", true], + ["documentUrlPatterns-patternDoesNotMatch-contextAll", false], + ["documentUrlPatterns-patternDoesNotMatch-contextImage", false], + ["documentUrlPatterns-patternDoesNotMatch-contextLink", false], + ]; + await confirmContextMenuItems(contextMenu, expected); + await closeContextMenu(); + + contextMenu = await openContextMenu("#img-wrapped-in-link"); + expected = [ + ["targetUrlPatterns-patternMatches-contextAll", true], + ["targetUrlPatterns-patternMatches-contextImage", true], + ["targetUrlPatterns-patternMatches-contextLink", true], + ["targetUrlPatterns-patternDoesNotMatch-contextAll", false], + ["targetUrlPatterns-patternDoesNotMatch-contextImage", false], + ["targetUrlPatterns-patternDoesNotMatch-contextLink", false], + ["documentUrlPatterns-patternMatches-contextAll", true], + ["documentUrlPatterns-patternMatches-contextImage", true], + ["documentUrlPatterns-patternMatches-contextLink", true], + ["documentUrlPatterns-patternDoesNotMatch-contextAll", false], + ["documentUrlPatterns-patternDoesNotMatch-contextImage", false], + ["documentUrlPatterns-patternDoesNotMatch-contextLink", false], + ]; + await confirmContextMenuItems(contextMenu, expected); + await closeContextMenu(); + + contextMenu = await openContextMenuInFrame(); + expected = [ + ["documentUrlPatterns-patternMatches-contextAll", true], + ["documentUrlPatterns-patternMatches-contextFrame", true], + ]; + await confirmContextMenuItems(contextMenu, expected); + await closeContextMenu(); + + await extension.unload(); + BrowserTestUtils.removeTab(tab1); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_currentWindow.js b/browser/components/extensions/test/browser/browser_ext_currentWindow.js new file mode 100644 index 0000000000..86f55e445f --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_currentWindow.js @@ -0,0 +1,183 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +function genericChecker() { + let kind = "background"; + let path = window.location.pathname; + if (path.includes("/popup.html")) { + kind = "popup"; + } else if (path.includes("/page.html")) { + kind = "page"; + } + + browser.test.onMessage.addListener((msg, ...args) => { + if (msg == kind + "-check-current1") { + browser.tabs.query( + { + currentWindow: true, + }, + function (tabs) { + browser.test.sendMessage("result", tabs[0].windowId); + } + ); + } else if (msg == kind + "-check-current2") { + browser.tabs.query( + { + windowId: browser.windows.WINDOW_ID_CURRENT, + }, + function (tabs) { + browser.test.sendMessage("result", tabs[0].windowId); + } + ); + } else if (msg == kind + "-check-current3") { + browser.windows.getCurrent(function (window) { + browser.test.sendMessage("result", window.id); + }); + } else if (msg == kind + "-open-page") { + browser.tabs.create({ + windowId: args[0], + url: browser.runtime.getURL("page.html"), + }); + } else if (msg == kind + "-close-page") { + browser.tabs.query( + { + windowId: args[0], + }, + tabs => { + let tab = tabs.find(tab => tab.url.includes("/page.html")); + browser.tabs.remove(tab.id, () => { + browser.test.sendMessage("closed"); + }); + } + ); + } + }); + browser.test.sendMessage(kind + "-ready"); +} + +add_task(async function () { + let win1 = await BrowserTestUtils.openNewBrowserWindow(); + let win2 = await BrowserTestUtils.openNewBrowserWindow(); + + await focusWindow(win2); + + BrowserTestUtils.startLoadingURIString( + win1.gBrowser.selectedBrowser, + "about:robots" + ); + await BrowserTestUtils.browserLoaded(win1.gBrowser.selectedBrowser); + + BrowserTestUtils.startLoadingURIString( + win2.gBrowser.selectedBrowser, + "about:config" + ); + await BrowserTestUtils.browserLoaded(win2.gBrowser.selectedBrowser); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + + browser_action: { + default_popup: "popup.html", + default_area: "navbar", + }, + }, + + files: { + "page.html": ` + <!DOCTYPE html> + <html><body> + <script src="page.js"></script> + </body></html> + `, + + "page.js": genericChecker, + + "popup.html": ` + <!DOCTYPE html> + <html><body> + <script src="popup.js"></script> + </body></html> + `, + + "popup.js": genericChecker, + }, + + background: genericChecker, + }); + + await Promise.all([ + extension.startup(), + extension.awaitMessage("background-ready"), + ]); + + const { + Management: { + global: { windowTracker }, + }, + } = ChromeUtils.importESModule("resource://gre/modules/Extension.sys.mjs"); + + let winId1 = windowTracker.getId(win1); + let winId2 = windowTracker.getId(win2); + + async function checkWindow(kind, winId, name) { + extension.sendMessage(kind + "-check-current1"); + is( + await extension.awaitMessage("result"), + winId, + `${name} is on top (check 1) [${kind}]` + ); + extension.sendMessage(kind + "-check-current2"); + is( + await extension.awaitMessage("result"), + winId, + `${name} is on top (check 2) [${kind}]` + ); + extension.sendMessage(kind + "-check-current3"); + is( + await extension.awaitMessage("result"), + winId, + `${name} is on top (check 3) [${kind}]` + ); + } + + async function triggerPopup(win, callback) { + await clickBrowserAction(extension, win); + await awaitExtensionPanel(extension, win); + + await extension.awaitMessage("popup-ready"); + + await callback(); + + closeBrowserAction(extension, win); + } + + await focusWindow(win1); + await checkWindow("background", winId1, "win1"); + await triggerPopup(win1, async function () { + await checkWindow("popup", winId1, "win1"); + }); + + await focusWindow(win2); + await checkWindow("background", winId2, "win2"); + await triggerPopup(win2, async function () { + await checkWindow("popup", winId2, "win2"); + }); + + async function triggerPage(winId, name) { + extension.sendMessage("background-open-page", winId); + await extension.awaitMessage("page-ready"); + await checkWindow("page", winId, name); + extension.sendMessage("background-close-page", winId); + await extension.awaitMessage("closed"); + } + + await triggerPage(winId1, "win1"); + await triggerPage(winId2, "win2"); + + await extension.unload(); + + await BrowserTestUtils.closeWindow(win1); + await BrowserTestUtils.closeWindow(win2); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_devtools_inspectedWindow.js b/browser/components/extensions/test/browser/browser_ext_devtools_inspectedWindow.js new file mode 100644 index 0000000000..0cfcb33ab3 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_devtools_inspectedWindow.js @@ -0,0 +1,540 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +loadTestSubscript("head_devtools.js"); + +/** + * Helper that returns the id of the last additional/extension tool for a provided + * toolbox. + * + * @param {object} toolbox + * The DevTools toolbox object. + * @param {string} label + * The expected label for the additional tool. + * @returns {string} the id of the last additional panel. + */ +function getAdditionalPanelId(toolbox, label) { + // Copy the tools array and pop the last element from it. + const panelDef = toolbox.getAdditionalTools().slice().pop(); + is(panelDef.label, label, "Additional panel label is the expected label"); + return panelDef.id; +} + +/** + * Helper that returns the number of existing target actors for the content browserId + * + * @param {Tab} tab + * @returns {Integer} the number of targets + */ +function getTargetActorsCount(tab) { + return SpecialPowers.spawn(tab.linkedBrowser, [], () => { + const { TargetActorRegistry } = ChromeUtils.importESModule( + "resource://devtools/server/actors/targets/target-actor-registry.sys.mjs" + ); + + // Retrieve the target actor instances + return TargetActorRegistry.getTargetActorsCountForBrowserElement( + content.browsingContext.browserId + ); + }); +} + +/** + * this test file ensures that: + * + * - the devtools page gets only a subset of the runtime API namespace. + * - devtools.inspectedWindow.tabId is the same tabId that we can retrieve + * in the background page using the tabs API namespace. + * - devtools API is available in the devtools page sub-frames when a valid + * extension URL has been loaded. + * - devtools.inspectedWindow.eval: + * - returns a serialized version of the evaluation result. + * - returns the expected error object when the return value serialization raises a + * "TypeError: cyclic object value" exception. + * - returns the expected exception when an exception has been raised from the evaluated + * javascript code. + */ +add_task(async function test_devtools_inspectedWindow_tabId() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://mochi.test:8888/" + ); + + async function background() { + browser.test.assertEq( + undefined, + browser.devtools, + "No devtools APIs should be available in the background page" + ); + + const tabs = await browser.tabs.query({ + active: true, + lastFocusedWindow: true, + }); + browser.test.sendMessage("current-tab-id", tabs[0].id); + } + + function devtools_page() { + browser.test.assertEq( + undefined, + browser.runtime.getBackgroundPage, + "The `runtime.getBackgroundPage` API method should be missing in a devtools_page context" + ); + + try { + let tabId = browser.devtools.inspectedWindow.tabId; + browser.test.sendMessage("inspectedWindow-tab-id", tabId); + } catch (err) { + browser.test.sendMessage("inspectedWindow-tab-id", undefined); + throw err; + } + } + + function devtools_page_iframe() { + try { + let tabId = browser.devtools.inspectedWindow.tabId; + browser.test.sendMessage( + "devtools_page_iframe.inspectedWindow-tab-id", + tabId + ); + } catch (err) { + browser.test.fail(`Error: ${err} :: ${err.stack}`); + browser.test.sendMessage( + "devtools_page_iframe.inspectedWindow-tab-id", + undefined + ); + } + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + devtools_page: "devtools_page.html", + }, + files: { + "devtools_page.html": `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + </head> + <body> + <iframe src="/devtools_page_iframe.html"></iframe> + <script src="devtools_page.js"></script> + </body> + </html>`, + "devtools_page.js": devtools_page, + "devtools_page_iframe.html": `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + </head> + <body> + <script src="devtools_page_iframe.js"></script> + </body> + </html>`, + "devtools_page_iframe.js": devtools_page_iframe, + }, + }); + + await extension.startup(); + + let backgroundPageCurrentTabId = await extension.awaitMessage( + "current-tab-id" + ); + + await openToolboxForTab(tab); + + let devtoolsInspectedWindowTabId = await extension.awaitMessage( + "inspectedWindow-tab-id" + ); + + is( + devtoolsInspectedWindowTabId, + backgroundPageCurrentTabId, + "Got the expected tabId from devtool.inspectedWindow.tabId" + ); + + let devtoolsPageIframeTabId = await extension.awaitMessage( + "devtools_page_iframe.inspectedWindow-tab-id" + ); + + is( + devtoolsPageIframeTabId, + backgroundPageCurrentTabId, + "Got the expected tabId from devtool.inspectedWindow.tabId called in a devtool_page iframe" + ); + + await closeToolboxForTab(tab); + + await extension.unload(); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_devtools_inspectedWindow_eval() { + const TEST_TARGET_URL = "http://mochi.test:8888/"; + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + TEST_TARGET_URL + ); + + function devtools_page() { + browser.test.onMessage.addListener(async (msg, ...args) => { + if (msg !== "inspectedWindow-eval-request") { + browser.test.fail(`Unexpected test message received: ${msg}`); + return; + } + + try { + const [evalResult, errorResult] = + await browser.devtools.inspectedWindow.eval(...args); + browser.test.sendMessage("inspectedWindow-eval-result", { + evalResult, + errorResult, + }); + } catch (err) { + browser.test.sendMessage("inspectedWindow-eval-result"); + browser.test.fail(`Error: ${err} :: ${err.stack}`); + } + }); + browser.test.sendMessage("devtools-page-loaded"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + devtools_page: "devtools_page.html", + }, + files: { + "devtools_page.html": `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + <script text="text/javascript" src="devtools_page.js"></script> + </head> + <body> + </body> + </html>`, + "devtools_page.js": devtools_page, + }, + }); + + await extension.startup(); + + await openToolboxForTab(tab); + + info("Wait the devtools page load"); + await extension.awaitMessage("devtools-page-loaded"); + + const evalTestCases = [ + // Successful evaluation results. + { + args: ["window.location.href"], + expectedResults: { evalResult: TEST_TARGET_URL, errorResult: undefined }, + }, + + // Error evaluation results. + { + args: ["window"], + expectedResults: { + evalResult: undefined, + errorResult: { + isError: true, + code: "E_PROTOCOLERROR", + description: "Inspector protocol error: %s", + details: ["TypeError: cyclic object value"], + }, + }, + }, + + // Exception evaluation results. + { + args: ["throw new Error('fake eval exception');"], + expectedResults: { + evalResult: undefined, + errorResult: { + isException: true, + value: /Error: fake eval exception\n.*moz-extension:\/\//, + }, + }, + }, + ]; + + for (let testCase of evalTestCases) { + info(`test inspectedWindow.eval with ${JSON.stringify(testCase)}`); + + const { args, expectedResults } = testCase; + + extension.sendMessage(`inspectedWindow-eval-request`, ...args); + + const { evalResult, errorResult } = await extension.awaitMessage( + `inspectedWindow-eval-result` + ); + + Assert.deepEqual( + evalResult, + expectedResults.evalResult, + "Got the expected eval result" + ); + + if (errorResult) { + for (const errorPropName of Object.keys(expectedResults.errorResult)) { + const expected = expectedResults.errorResult[errorPropName]; + const actual = errorResult[errorPropName]; + + if (expected instanceof RegExp) { + ok( + expected.test(actual), + `Got exceptionInfo.${errorPropName} value ${actual} matches ${expected}` + ); + } else { + Assert.deepEqual( + actual, + expected, + `Got the expected exceptionInfo.${errorPropName} value` + ); + } + } + } + } + + await closeToolboxForTab(tab); + + await extension.unload(); + + BrowserTestUtils.removeTab(tab); +}); + +/** + * This test asserts that both the page and the panel can use devtools.inspectedWindow. + * See regression in Bug https://bugzilla.mozilla.org/show_bug.cgi?id=1392531 + */ +add_task(async function test_devtools_inspectedWindow_eval_in_page_and_panel() { + const TEST_TARGET_URL = "http://mochi.test:8888/"; + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + TEST_TARGET_URL + ); + + async function devtools_page() { + await browser.devtools.panels.create( + "test-eval", + "fake-icon.png", + "devtools_panel.html" + ); + + browser.test.onMessage.addListener(async (msg, ...args) => { + switch (msg) { + case "inspectedWindow-page-eval-request": { + const [evalResult, errorResult] = + await browser.devtools.inspectedWindow.eval(...args); + browser.test.sendMessage("inspectedWindow-page-eval-result", { + evalResult, + errorResult, + }); + break; + } + case "inspectedWindow-panel-eval-request": + // Ignore the test message expected by the devtools panel. + break; + default: + browser.test.fail(`Unexpected test message received: ${msg}`); + } + }); + + browser.test.sendMessage("devtools_panel_created"); + } + + function devtools_panel() { + browser.test.onMessage.addListener(async (msg, ...args) => { + switch (msg) { + case "inspectedWindow-panel-eval-request": { + const [evalResult, errorResult] = + await browser.devtools.inspectedWindow.eval(...args); + browser.test.sendMessage("inspectedWindow-panel-eval-result", { + evalResult, + errorResult, + }); + break; + } + case "inspectedWindow-page-eval-request": + // Ignore the test message expected by the devtools page. + break; + default: + browser.test.fail(`Unexpected test message received: ${msg}`); + } + }); + browser.test.sendMessage("devtools_panel_initialized"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + devtools_page: "devtools_page.html", + }, + files: { + "devtools_page.html": `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + <script text="text/javascript" src="devtools_page.js"></script> + </head> + <body> + </body> + </html>`, + "devtools_page.js": devtools_page, + "devtools_panel.html": `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + </head> + <body> + DEVTOOLS PANEL + <script src="devtools_panel.js"></script> + </body> + </html>`, + "devtools_panel.js": devtools_panel, + }, + }); + + await extension.startup(); + + const toolbox = await openToolboxForTab(tab); + + info("Wait for devtools_panel_created event"); + await extension.awaitMessage("devtools_panel_created"); + + info("Switch to the extension test panel"); + await openToolboxForTab(tab, getAdditionalPanelId(toolbox, "test-eval")); + + info("Wait for devtools_panel_initialized event"); + await extension.awaitMessage("devtools_panel_initialized"); + + info( + `test inspectedWindow.eval with eval(window.location.href) from the devtools page` + ); + extension.sendMessage( + `inspectedWindow-page-eval-request`, + "window.location.href" + ); + + info("Wait for response from the page"); + let { evalResult } = await extension.awaitMessage( + `inspectedWindow-page-eval-result` + ); + Assert.deepEqual( + evalResult, + TEST_TARGET_URL, + "Got the expected eval result in the page" + ); + + info( + `test inspectedWindow.eval with eval(window.location.href) from the devtools panel` + ); + extension.sendMessage( + `inspectedWindow-panel-eval-request`, + "window.location.href" + ); + + info("Wait for response from the panel"); + ({ evalResult } = await extension.awaitMessage( + `inspectedWindow-panel-eval-result` + )); + Assert.deepEqual( + evalResult, + TEST_TARGET_URL, + "Got the expected eval result in the panel" + ); + + // Cleanup + await closeToolboxForTab(tab); + await extension.unload(); + BrowserTestUtils.removeTab(tab); +}); + +/** + * This test asserts that there's only one target created by the extension, and that + * closing the DevTools toolbox destroys it. + * See Bug 1652016 + */ +add_task(async function test_devtools_inspectedWindow_eval_target_lifecycle() { + const TEST_TARGET_URL = "http://mochi.test:8888/"; + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + TEST_TARGET_URL + ); + + function devtools_page() { + browser.test.onMessage.addListener(async (msg, ...args) => { + if (msg !== "inspectedWindow-eval-requests") { + browser.test.fail(`Unexpected test message received: ${msg}`); + return; + } + + const promises = []; + for (let i = 0; i < 10; i++) { + promises.push(browser.devtools.inspectedWindow.eval(`${i * 2}`)); + } + + await Promise.all(promises); + browser.test.sendMessage("inspectedWindow-eval-requests-done"); + }); + browser.test.sendMessage("devtools-page-loaded"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + devtools_page: "devtools_page.html", + }, + files: { + "devtools_page.html": `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + <script text="text/javascript" src="devtools_page.js"></script> + </head> + <body> + </body> + </html>`, + "devtools_page.js": devtools_page, + }, + }); + + await extension.startup(); + + await openToolboxForTab(tab); + await extension.awaitMessage("devtools-page-loaded"); + + let targetsCount = await getTargetActorsCount(tab); + is( + targetsCount, + 1, + "There's only one target for the content page, the one for DevTools Toolbox" + ); + + info("Check that evaluating multiple times doesn't create multiple targets"); + const onEvalRequestsDone = extension.awaitMessage( + `inspectedWindow-eval-requests-done` + ); + extension.sendMessage(`inspectedWindow-eval-requests`); + + info("Wait for response from the panel"); + await onEvalRequestsDone; + + targetsCount = await getTargetActorsCount(tab); + is( + targetsCount, + 2, + "Only 1 additional target was created when calling inspectedWindow.eval" + ); + + info( + "Close the toolbox and make sure the extension gets unloaded, and the target destroyed" + ); + await closeToolboxForTab(tab); + + targetsCount = await getTargetActorsCount(tab); + is(targetsCount, 0, "All targets were removed as toolbox was closed"); + + await extension.unload(); + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_devtools_inspectedWindow_eval_bindings.js b/browser/components/extensions/test/browser/browser_ext_devtools_inspectedWindow_eval_bindings.js new file mode 100644 index 0000000000..a5f910e698 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_devtools_inspectedWindow_eval_bindings.js @@ -0,0 +1,270 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +loadTestSubscript("head_devtools.js"); + +const BASE_URL = + "http://mochi.test:8888/browser/browser/components/extensions/test/browser/"; + +/** + * this test file ensures that: + * + * - devtools.inspectedWindow.eval provides the expected $0 and inspect bindings + */ +add_task(async function test_devtools_inspectedWindow_eval_bindings() { + const TEST_TARGET_URL = `${BASE_URL}/file_inspectedwindow_eval.html`; + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + TEST_TARGET_URL + ); + + function devtools_page() { + browser.test.onMessage.addListener(async (msg, ...args) => { + if (msg !== "inspectedWindow-eval-request") { + browser.test.fail(`Unexpected test message received: ${msg}`); + return; + } + + try { + const [evalResult, errorResult] = + await browser.devtools.inspectedWindow.eval(...args); + browser.test.sendMessage("inspectedWindow-eval-result", { + evalResult, + errorResult, + }); + } catch (err) { + browser.test.sendMessage("inspectedWindow-eval-result"); + browser.test.fail(`Error: ${err} :: ${err.stack}`); + } + }); + browser.test.sendMessage("devtools-page-loaded"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + devtools_page: "devtools_page.html", + }, + files: { + "devtools_page.html": `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + <script text="text/javascript" src="devtools_page.js"></script> + </head> + <body> + </body> + </html>`, + "devtools_page.js": devtools_page, + }, + }); + + await extension.startup(); + + const toolbox = await openToolboxForTab(tab); + await extension.awaitMessage("devtools-page-loaded"); + + // Test $0 binding with no selected node + info("Test inspectedWindow.eval $0 binding with no selected node"); + + const evalNoSelectedNodePromise = extension.awaitMessage( + `inspectedWindow-eval-result` + ); + extension.sendMessage(`inspectedWindow-eval-request`, "$0"); + const evalNoSelectedNodeResult = await evalNoSelectedNodePromise; + + Assert.deepEqual( + evalNoSelectedNodeResult, + { evalResult: undefined, errorResult: undefined }, + "Got the expected eval result" + ); + + // Test $0 binding with a selected node in the inspector. + + await openToolboxForTab(tab, "inspector"); + + info( + "Test inspectedWindow.eval $0 binding with a selected node in the inspector" + ); + + const evalSelectedNodePromise = extension.awaitMessage( + `inspectedWindow-eval-result` + ); + extension.sendMessage(`inspectedWindow-eval-request`, "$0 && $0.tagName"); + const evalSelectedNodeResult = await evalSelectedNodePromise; + + Assert.deepEqual( + evalSelectedNodeResult, + { evalResult: "BODY", errorResult: undefined }, + "Got the expected eval result" + ); + + // Test that inspect($0) switch the developer toolbox to the inspector. + + await openToolboxForTab(tab, TOOLBOX_BLANK_PANEL_ID); + + const inspectorPanelSelectedPromise = (async () => { + const toolId = await toolbox.once("select"); + + if (toolId === "inspector") { + info("Toolbox has been switched to the inspector as expected"); + const selectedNodeName = + toolbox.selection.nodeFront && + toolbox.selection.nodeFront._form.nodeName; + is( + selectedNodeName, + "A", + "The expected DOM node has been selected in the inspector" + ); + } else { + throw new Error( + `inspector panel expected, ${toolId} has been selected instead` + ); + } + })(); + + info("Test inspectedWindow.eval inspect() binding called for a DOM element"); + extension.sendMessage( + `inspectedWindow-eval-request`, + "inspect(document.querySelector('a#link-to-inspect'))" + ); + await extension.awaitMessage(`inspectedWindow-eval-result`); + + info( + "Wait for the toolbox to switch to the inspector and the expected node has been selected" + ); + await inspectorPanelSelectedPromise; + + function expectedSourceSelected(sourceFilename, sourceLine) { + return () => { + const dbg = toolbox.getPanel("jsdebugger"); + const selectedLocation = dbg._selectors.getSelectedLocation( + dbg._getState() + ); + + if (!selectedLocation) { + return false; + } + + return ( + selectedLocation.source.id.includes(sourceFilename) && + selectedLocation.line == sourceLine + ); + }; + } + + info("Test inspectedWindow.eval inspect() binding called for a function"); + + const debuggerPanelSelectedPromise = (async () => { + const toolId = await toolbox.once("select"); + + if (toolId === "jsdebugger") { + info("Toolbox has been switched to the jsdebugger as expected"); + } else { + throw new Error( + `jsdebugger panel expected, ${toolId} has been selected instead` + ); + } + })(); + + extension.sendMessage( + `inspectedWindow-eval-request`, + "inspect(test_inspect_function)" + ); + await extension.awaitMessage(`inspectedWindow-eval-result`); + await debuggerPanelSelectedPromise; + + await BrowserTestUtils.waitForCondition( + expectedSourceSelected("file_inspectedwindow_eval.html", 9), + "Wait the expected function to be selected in the jsdebugger panel" + ); + + info("Test inspectedWindow.eval inspect() bound function"); + + extension.sendMessage( + `inspectedWindow-eval-request`, + "inspect(test_bound_function)" + ); + await extension.awaitMessage(`inspectedWindow-eval-result`); + + await BrowserTestUtils.waitForCondition( + expectedSourceSelected("file_inspectedwindow_eval.html", 15), + "Wait the expected function to be selected in the jsdebugger panel" + ); + + info("Test inspectedWindow.eval inspect() binding called for a JS object"); + + const splitPanelOpenedPromise = (async () => { + await toolbox.once("split-console"); + const { hud } = toolbox.getPanel("webconsole"); + + // Wait for the message to appear on the console. + const messageNode = await new Promise(resolve => { + hud.ui.on("new-messages", function onThisMessage(messages) { + for (let m of messages) { + resolve(m.node); + hud.ui.off("new-messages", onThisMessage); + return; + } + }); + }); + let objectInspectors = [...messageNode.querySelectorAll(".tree")]; + is( + objectInspectors.length, + 1, + "There is the expected number of object inspectors" + ); + + // We need to wait for the object to be expanded so we don't call the server on a closed connection. + const [oi] = objectInspectors; + let nodes = oi.querySelectorAll(".node"); + + Assert.greaterOrEqual( + nodes.length, + 1, + "The object preview is rendered as expected" + ); + + // The tree can still be collapsed since the properties are fetched asynchronously. + if (nodes.length === 1) { + info("Waiting for the object properties to be displayed"); + // If this is the case, we wait for the properties to be fetched and displayed. + await new Promise(resolve => { + const observer = new MutationObserver(mutations => { + resolve(); + observer.disconnect(); + }); + observer.observe(oi, { childList: true }); + }); + + // Retrieve the new nodes. + nodes = oi.querySelectorAll(".node"); + } + + // We should have 3 nodes : + // ▼ Object { testkey: "testvalue" } + // | testkey: "testvalue" + // | ▶︎ __proto__: Object { … } + is(nodes.length, 3, "The object preview has the expected number of nodes"); + })(); + + const inspectJSObjectPromise = extension.awaitMessage( + `inspectedWindow-eval-result` + ); + extension.sendMessage( + `inspectedWindow-eval-request`, + "inspect({testkey: 'testvalue'})" + ); + await inspectJSObjectPromise; + + info("Wait for the split console to be opened and the JS object inspected"); + await splitPanelOpenedPromise; + info("Split console has been opened as expected"); + + await closeToolboxForTab(tab); + + await extension.unload(); + + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_devtools_inspectedWindow_eval_file.js b/browser/components/extensions/test/browser/browser_ext_devtools_inspectedWindow_eval_file.js new file mode 100644 index 0000000000..419fc964e7 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_devtools_inspectedWindow_eval_file.js @@ -0,0 +1,54 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +loadTestSubscript("head_devtools.js"); + +const FILE_URL = Services.io.newFileURI( + new FileUtils.File(getTestFilePath("file_dummy.html")) +).spec; + +add_task(async function test_devtools_inspectedWindow_eval_in_file_url() { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, FILE_URL); + + async function devtools_page() { + try { + const [evalResult, errorResult] = + await browser.devtools.inspectedWindow.eval("location.protocol"); + browser.test.assertEq(undefined, errorResult, "eval should not fail"); + browser.test.assertEq("file:", evalResult, "eval should succeed"); + browser.test.notifyPass("inspectedWindow-eval-file"); + } catch (err) { + browser.test.fail(`Error: ${err} :: ${err.stack}`); + browser.test.notifyFail("inspectedWindow-eval-file"); + } + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + devtools_page: "devtools_page.html", + }, + files: { + "devtools_page.html": `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + <script src="devtools_page.js"></script> + </head> + </html>`, + "devtools_page.js": devtools_page, + }, + }); + + await extension.startup(); + + await openToolboxForTab(tab); + + await extension.awaitFinish("inspectedWindow-eval-file"); + + await closeToolboxForTab(tab); + + await extension.unload(); + + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_devtools_inspectedWindow_reload.js b/browser/components/extensions/test/browser/browser_ext_devtools_inspectedWindow_reload.js new file mode 100644 index 0000000000..9f7d452a2c --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_devtools_inspectedWindow_reload.js @@ -0,0 +1,481 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +// Like most of the mochitest-browser devtools test, +// on debug test machine, it takes about 50s to run the test. +requestLongerTimeout(4); + +loadTestSubscript("head_devtools.js"); + +// Allow rejections related to closing the devtools toolbox too soon after the test +// has already verified the details that were relevant for that test case +// (e.g. this was triggering an intermittent failure in shippable optimized +// builds, tracked Bug 1707644). +PromiseTestUtils.allowMatchingRejectionsGlobally( + /Connection closed, pending request to/ +); + +const TEST_ORIGIN = "http://mochi.test:8888"; +const TEST_BASE = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "" +); +const TEST_PATH = `${TEST_BASE}file_inspectedwindow_reload_target.sjs`; + +// Small helper which provides the common steps to the following reload test cases. +async function runReloadTestCase({ + urlParams, + background, + devtoolsPage, + testCase, + closeToolbox = true, +}) { + const TEST_TARGET_URL = `${TEST_ORIGIN}/${TEST_PATH}?${urlParams}`; + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + TEST_TARGET_URL + ); + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + devtools_page: "devtools_page.html", + permissions: ["webNavigation", "<all_urls>"], + }, + files: { + "devtools_page.html": `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + <script type="text/javascript" src="devtools_page.js"></script> + </head> + <body> + </body> + </html>`, + "devtools_page.js": devtoolsPage, + }, + }); + + await extension.startup(); + + const toolbox = await openToolboxForTab(tab); + + // Wait the test extension to be ready. + await extension.awaitMessage("devtools_inspected_window_reload.ready"); + + info("devtools page ready"); + + // Run the test case. + await testCase(extension, tab, toolbox); + + if (closeToolbox) { + await closeToolboxForTab(tab); + } + + BrowserTestUtils.removeTab(tab); + + await extension.unload(); +} + +add_task(async function test_devtools_inspectedWindow_reload_ignore_cache() { + function background() { + // Wait until the devtools page is ready to run the test. + browser.runtime.onMessage.addListener(async msg => { + if (msg !== "devtools_page.ready") { + browser.test.fail(`Unexpected message received: ${msg}`); + return; + } + + const tabs = await browser.tabs.query({ active: true }); + const activeTabId = tabs[0].id; + let reloads = 0; + + browser.webNavigation.onCompleted.addListener(async details => { + if (details.tabId == activeTabId && details.frameId == 0) { + reloads++; + + // This test expects two `devtools.inspectedWindow.reload` calls: + // the first one without any options and the second one with + // `ignoreCache=true`. + let expectedContent; + let enabled; + + switch (reloads) { + case 1: + enabled = false; + expectedContent = "empty cache headers"; + break; + case 2: + enabled = true; + expectedContent = "no-cache:no-cache"; + break; + } + + if (!expectedContent) { + browser.test.fail(`Unexpected number of tab reloads: ${reloads}`); + } else { + try { + const code = `document.body.textContent`; + const [text] = await browser.tabs.executeScript(activeTabId, { + code, + }); + + browser.test.assertEq( + text, + expectedContent, + `Got the expected cache headers with ignoreCache=${enabled}` + ); + } catch (err) { + browser.test.fail(`Error: ${err.message} - ${err.stack}`); + } + } + + browser.test.sendMessage( + "devtools_inspectedWindow_reload_checkIgnoreCache.done" + ); + } + }); + + browser.test.sendMessage("devtools_inspected_window_reload.ready"); + }); + } + + async function devtoolsPage() { + browser.test.onMessage.addListener(msg => { + switch (msg) { + case "no-ignore-cache": + browser.devtools.inspectedWindow.reload(); + break; + case "ignore-cache": + browser.devtools.inspectedWindow.reload({ ignoreCache: true }); + break; + default: + browser.test.fail(`Unexpected test message received: ${msg}`); + } + }); + + browser.runtime.sendMessage("devtools_page.ready"); + } + + await runReloadTestCase({ + urlParams: "test=cache", + background, + devtoolsPage, + testCase: async function (extension) { + for (const testMessage of ["no-ignore-cache", "ignore-cache"]) { + extension.sendMessage(testMessage); + await extension.awaitMessage( + "devtools_inspectedWindow_reload_checkIgnoreCache.done" + ); + } + }, + }); +}); + +add_task( + async function test_devtools_inspectedWindow_reload_custom_user_agent() { + const CUSTOM_USER_AGENT = "CustomizedUserAgent"; + + function background() { + browser.runtime.onMessage.addListener(async msg => { + if (msg !== "devtools_page.ready") { + browser.test.fail(`Unexpected message received: ${msg}`); + return; + } + + browser.test.sendMessage("devtools_inspected_window_reload.ready"); + }); + } + + function devtoolsPage() { + browser.test.onMessage.addListener(async msg => { + switch (msg) { + case "no-custom-user-agent": + await browser.devtools.inspectedWindow.reload({}); + break; + case "custom-user-agent": + await browser.devtools.inspectedWindow.reload({ + userAgent: "CustomizedUserAgent", + }); + break; + default: + browser.test.fail(`Unexpected test message received: ${msg}`); + } + }); + + browser.runtime.sendMessage("devtools_page.ready"); + } + + async function checkUserAgent(expectedUA) { + const contexts = + gBrowser.selectedBrowser.browsingContext.getAllBrowsingContextsInSubtree(); + + const uniqueRemoteTypes = new Set(); + for (const context of contexts) { + uniqueRemoteTypes.add(context.currentRemoteType); + } + + info( + `Got ${contexts.length} with remoteTypes: ${Array.from( + uniqueRemoteTypes + )}` + ); + Assert.greaterOrEqual( + contexts.length, + 2, + "There should be at least 2 browsing contexts" + ); + + if (Services.appinfo.fissionAutostart) { + Assert.greaterOrEqual( + uniqueRemoteTypes.size, + 2, + "Expect at least one cross origin sub frame" + ); + } + + for (const context of contexts) { + const url = context.currentURI?.spec?.replace( + context.currentURI?.query, + "…" + ); + info( + `Checking user agent on ${url} (remoteType: ${context.currentRemoteType})` + ); + await SpecialPowers.spawn(context, [expectedUA], async _expectedUA => { + is( + content.navigator.userAgent, + _expectedUA, + `expected navigator.userAgent value` + ); + is( + content.wrappedJSObject.initialUserAgent, + _expectedUA, + `expected navigator.userAgent value at startup` + ); + if (content.wrappedJSObject.userAgentHeader) { + is( + content.wrappedJSObject.userAgentHeader, + _expectedUA, + `user agent header has expected value` + ); + } + }); + } + } + + await runReloadTestCase({ + urlParams: "test=user-agent", + background, + devtoolsPage, + closeToolbox: false, + testCase: async function (extension, tab, toolbox) { + info("Get the initial user agent"); + const initialUserAgent = await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [], + () => { + return content.navigator.userAgent; + } + ); + + info( + "Check that calling inspectedWindow.reload without userAgent does not change the user agent of the page" + ); + let onPageLoaded = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + true + ); + extension.sendMessage("no-custom-user-agent"); + await onPageLoaded; + + await checkUserAgent(initialUserAgent); + + info( + "Check that calling inspectedWindow.reload with userAgent does change the user agent of the page" + ); + onPageLoaded = BrowserTestUtils.browserLoaded( + gBrowser.selectedBrowser, + true + ); + extension.sendMessage("custom-user-agent"); + await onPageLoaded; + + await checkUserAgent(CUSTOM_USER_AGENT); + + info("Check that the user agent persists after a reload"); + await BrowserTestUtils.reloadTab(tab, /* includeSubFrames */ true); + await checkUserAgent(CUSTOM_USER_AGENT); + + info( + "Check that the user agent persists after navigating to a new browsing context" + ); + const previousBrowsingContextId = + gBrowser.selectedBrowser.browsingContext.id; + + // Navigate to a different origin + await navigateToWithDevToolsOpen( + tab, + `https://example.com/${TEST_PATH}?test=user-agent&crossOriginIsolated=true` + ); + + isnot( + gBrowser.selectedBrowser.browsingContext.id, + previousBrowsingContextId, + "A new browsing context was created" + ); + + await checkUserAgent(CUSTOM_USER_AGENT); + + info( + "Check that closing DevTools resets the user agent of the page to its initial value" + ); + + await closeToolboxForTab(tab); + + // XXX: This is needed at the moment since Navigator.cpp retrieves the UserAgent from the + // headers (when there's no custom user agent). And here, since we reloaded the page once + // we set the custom user agent, the header was set accordingly and still holds the custom + // user agent value. This should be fixed by Bug 1705326. + is( + gBrowser.selectedBrowser.browsingContext.customUserAgent, + "", + "The flag on the browsing context was reset" + ); + await checkUserAgent(CUSTOM_USER_AGENT); + await BrowserTestUtils.reloadTab(tab, /* includeSubFrames */ true); + await checkUserAgent(initialUserAgent); + }, + }); + } +); + +add_task(async function test_devtools_inspectedWindow_reload_injected_script() { + function background() { + function getIframesTextContent() { + let docs = []; + for ( + let iframe, doc = document; + doc; + doc = iframe && iframe.contentDocument + ) { + docs.push(doc); + iframe = doc.querySelector("iframe"); + } + + return docs.map(doc => doc.querySelector("pre").textContent); + } + + browser.runtime.onMessage.addListener(async msg => { + if (msg !== "devtools_page.ready") { + browser.test.fail(`Unexpected message received: ${msg}`); + return; + } + + const tabs = await browser.tabs.query({ active: true }); + const activeTabId = tabs[0].id; + let reloads = 0; + + browser.webNavigation.onCompleted.addListener(async details => { + if (details.tabId == activeTabId && details.frameId == 0) { + reloads++; + + let expectedContent; + let enabled; + + switch (reloads) { + case 1: + enabled = false; + expectedContent = "injected script NOT executed"; + break; + case 2: + enabled = true; + expectedContent = "injected script executed first"; + break; + default: + browser.test.fail(`Unexpected number of tab reloads: ${reloads}`); + } + + if (!expectedContent) { + browser.test.fail(`Unexpected number of tab reloads: ${reloads}`); + } else { + let expectedResults = new Array(4).fill(expectedContent); + let code = `(${getIframesTextContent})()`; + + try { + let [results] = await browser.tabs.executeScript(activeTabId, { + code, + }); + + browser.test.assertEq( + JSON.stringify(expectedResults), + JSON.stringify(results), + `Got the expected result with injectScript=${enabled}` + ); + } catch (err) { + browser.test.fail(`Error: ${err.message} - ${err.stack}`); + } + } + + browser.test.sendMessage( + `devtools_inspectedWindow_reload_injectedScript.done` + ); + } + }); + + browser.test.sendMessage("devtools_inspected_window_reload.ready"); + }); + } + + function devtoolsPage() { + function injectedScript() { + if (!window.pageScriptExecutedFirst) { + window.addEventListener( + "DOMContentLoaded", + function listener() { + document.querySelector("pre").textContent = + "injected script executed first"; + }, + { once: true } + ); + } + } + + browser.test.onMessage.addListener(msg => { + switch (msg) { + case "no-injected-script": + browser.devtools.inspectedWindow.reload({}); + break; + case "injected-script": + browser.devtools.inspectedWindow.reload({ + injectedScript: `new ${injectedScript}`, + }); + break; + default: + browser.test.fail(`Unexpected test message received: ${msg}`); + } + }); + + browser.runtime.sendMessage("devtools_page.ready"); + } + + await runReloadTestCase({ + urlParams: "test=injected-script&frames=3", + background, + devtoolsPage, + testCase: async function (extension) { + extension.sendMessage("no-injected-script"); + + await extension.awaitMessage( + "devtools_inspectedWindow_reload_injectedScript.done" + ); + + extension.sendMessage("injected-script"); + + await extension.awaitMessage( + "devtools_inspectedWindow_reload_injectedScript.done" + ); + }, + }); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_devtools_inspectedWindow_targetSwitch.js b/browser/components/extensions/test/browser/browser_ext_devtools_inspectedWindow_targetSwitch.js new file mode 100644 index 0000000000..514ee399b5 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_devtools_inspectedWindow_targetSwitch.js @@ -0,0 +1,128 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +// Like most of the mochitest-browser devtools test, +// on debug test machine, it takes about 50s to run the test. +requestLongerTimeout(4); + +loadTestSubscript("head_devtools.js"); + +const MAIN_PROCESS_PAGE = "about:robots"; +const CONTENT_PROCESS_PAGE = + "data:text/html,<title>content process page</title>"; +const CONTENT_PROCESS_PAGE2 = "http://example.com/"; + +async function assertInspectedWindow(extension, tab) { + const onReloaded = BrowserTestUtils.browserLoaded(tab.linkedBrowser); + extension.sendMessage("inspectedWindow-reload-request"); + await onReloaded; + ok(true, "inspectedWindow works correctly"); +} + +async function getCurrentTabId(extension) { + extension.sendMessage("inspectedWindow-tabId-request"); + return extension.awaitMessage("inspectedWindow-tabId-response"); +} + +async function navigateTo(uri, tab, toolbox, extension) { + const originalTabId = await getCurrentTabId(extension); + + const promiseBrowserLoaded = BrowserTestUtils.browserLoaded( + tab.linkedBrowser + ); + const onSwitched = toolbox.commands.targetCommand.once("switched-target"); + BrowserTestUtils.startLoadingURIString(tab.linkedBrowser, uri); + info("Wait for the tab to be loaded"); + await promiseBrowserLoaded; + info("Wait for the toolbox target to have been switched"); + await onSwitched; + + const currentTabId = await getCurrentTabId(extension); + is( + originalTabId, + currentTabId, + "inspectWindow.tabId is not changed even when navigating to a page running on another process." + ); +} + +/** + * This test checks whether inspectedWindow works well even target-switching happens. + */ +add_task(async () => { + const tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + CONTENT_PROCESS_PAGE + ); + + async function devtools_page() { + browser.test.onMessage.addListener(async message => { + if (message === "inspectedWindow-reload-request") { + browser.devtools.inspectedWindow.reload(); + } else if (message === "inspectedWindow-tabId-request") { + browser.test.sendMessage( + "inspectedWindow-tabId-response", + browser.devtools.inspectedWindow.tabId + ); + } else { + browser.test.fail(`Unexpected test message received: ${message}`); + } + }); + + browser.test.sendMessage("devtools-page-loaded"); + } + + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + devtools_page: "devtools_page.html", + }, + files: { + "devtools_page.html": `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + </head> + <body> + <script src="devtools_page.js"></script> + </body> + </html>`, + "devtools_page.js": devtools_page, + }, + }); + await extension.startup(); + + info("Open the developer toolbox"); + const toolbox = await openToolboxForTab(tab); + + info("Wait the devtools page load"); + await extension.awaitMessage("devtools-page-loaded"); + + info("Check whether inspectedWindow works on content process page"); + await assertInspectedWindow(extension, tab); + + info("Navigate to a page running on main process"); + await navigateTo(MAIN_PROCESS_PAGE, tab, toolbox, extension); + + info("Check whether inspectedWindow works on main process page"); + await assertInspectedWindow(extension, tab); + + info("Return to a page running on content process again"); + await navigateTo(CONTENT_PROCESS_PAGE, tab, toolbox, extension); + + info("Check whether inspectedWindow works again"); + await assertInspectedWindow(extension, tab); + + // Navigate to an url that should switch to another target + // when fission is enabled. + if (SpecialPowers.useRemoteSubframes) { + info("Navigate to another page running on content process"); + await navigateTo(CONTENT_PROCESS_PAGE2, tab, toolbox, extension); + + info("Check whether inspectedWindow works again"); + await assertInspectedWindow(extension, tab); + } + + await extension.unload(); + await closeToolboxForTab(tab); + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_devtools_network.js b/browser/components/extensions/test/browser/browser_ext_devtools_network.js new file mode 100644 index 0000000000..fa6613357e --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_devtools_network.js @@ -0,0 +1,298 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +loadTestSubscript("head_devtools.js"); + +// Allow rejections related to closing the devtools toolbox too soon after the test +// has already verified the details that were relevant for that test case. +PromiseTestUtils.allowMatchingRejectionsGlobally( + /can't be sent as the connection just closed/ +); + +function background() { + browser.test.onMessage.addListener(msg => { + let code; + if (msg === "navigate") { + code = "window.wrappedJSObject.location.href = 'http://example.com/';"; + browser.tabs.executeScript({ code }); + } else if (msg === "reload") { + code = "window.wrappedJSObject.location.reload(true);"; + browser.tabs.executeScript({ code }); + } + }); + browser.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { + if (changeInfo.status === "complete" && tab.url === "http://example.com/") { + browser.test.sendMessage("tabUpdated"); + } + }); + browser.test.sendMessage("ready"); +} + +function devtools_page() { + let eventCount = 0; + let listener = url => { + eventCount++; + browser.test.assertEq( + "http://example.com/", + url, + "onNavigated received the expected url." + ); + browser.test.sendMessage("onNavigatedFired", eventCount); + + if (eventCount === 2) { + eventCount = 0; + browser.devtools.network.onNavigated.removeListener(listener); + } + }; + browser.devtools.network.onNavigated.addListener(listener); + + let harLogCount = 0; + let harListener = async msg => { + if (msg !== "getHAR") { + return; + } + + harLogCount++; + + const harLog = await browser.devtools.network.getHAR(); + browser.test.sendMessage("getHAR-result", harLog); + + if (harLogCount === 3) { + harLogCount = 0; + browser.test.onMessage.removeListener(harListener); + } + }; + browser.test.onMessage.addListener(harListener); + + let requestFinishedListener = async request => { + browser.test.assertTrue(request.request, "Request entry must exist"); + browser.test.assertTrue(request.response, "Response entry must exist"); + + browser.test.sendMessage("onRequestFinished"); + + // Get response content using callback + request.getContent((content, encoding) => { + browser.test.sendMessage("onRequestFinished-callbackExecuted", [ + content, + encoding, + ]); + }); + + // Get response content using returned promise + request.getContent().then(([content, encoding]) => { + browser.test.sendMessage("onRequestFinished-promiseResolved", [ + content, + encoding, + ]); + }); + + browser.devtools.network.onRequestFinished.removeListener( + requestFinishedListener + ); + }; + + browser.test.onMessage.addListener(msg => { + if (msg === "addOnRequestFinishedListener") { + browser.devtools.network.onRequestFinished.addListener( + requestFinishedListener + ); + } + }); + browser.test.sendMessage("devtools-page-loaded"); +} + +let extData = { + background, + manifest: { + permissions: ["tabs", "http://mochi.test/", "http://example.com/"], + devtools_page: "devtools_page.html", + }, + files: { + "devtools_page.html": `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + <script src="devtools_page.js"></script> + </head> + <body> + </body> + </html>`, + "devtools_page.js": devtools_page, + }, +}; + +async function waitForRequestAdded(toolbox) { + let netPanel = await toolbox.getNetMonitorAPI(); + return new Promise(resolve => { + netPanel.once("NetMonitor:RequestAdded", () => { + resolve(); + }); + }); +} + +async function navigateToolboxTarget(extension, toolbox) { + extension.sendMessage("navigate"); + + // Wait till the navigation is complete. + await Promise.all([ + extension.awaitMessage("tabUpdated"), + extension.awaitMessage("onNavigatedFired"), + waitForRequestAdded(toolbox), + ]); +} + +/** + * Test for `chrome.devtools.network.onNavigate()` API + */ +add_task(async function test_devtools_network_on_navigated() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://mochi.test:8888/" + ); + let extension = ExtensionTestUtils.loadExtension(extData); + + await extension.startup(); + await extension.awaitMessage("ready"); + + await openToolboxForTab(tab); + + info("Wait the devtools page load"); + await extension.awaitMessage("devtools-page-loaded"); + + extension.sendMessage("navigate"); + await extension.awaitMessage("tabUpdated"); + let eventCount = await extension.awaitMessage("onNavigatedFired"); + is(eventCount, 1, "The expected number of events were fired."); + + extension.sendMessage("reload"); + await extension.awaitMessage("tabUpdated"); + eventCount = await extension.awaitMessage("onNavigatedFired"); + is(eventCount, 2, "The expected number of events were fired."); + + // do a reload after the listener has been removed, do not expect a message to be sent + extension.sendMessage("reload"); + await extension.awaitMessage("tabUpdated"); + + await closeToolboxForTab(tab); + + await extension.unload(); + + BrowserTestUtils.removeTab(tab); +}); + +/** + * Test for `chrome.devtools.network.getHAR()` API + */ +add_task(async function test_devtools_network_get_har() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://mochi.test:8888/" + ); + let extension = ExtensionTestUtils.loadExtension(extData); + + await extension.startup(); + await extension.awaitMessage("ready"); + + // Open the Toolbox + const toolbox = await openToolboxForTab(tab); + + info("Wait the devtools page load"); + await extension.awaitMessage("devtools-page-loaded"); + + // Get HAR, it should be empty since no data collected yet. + const getHAREmptyPromise = extension.awaitMessage("getHAR-result"); + extension.sendMessage("getHAR"); + const getHAREmptyResult = await getHAREmptyPromise; + is(getHAREmptyResult.entries.length, 0, "HAR log should be empty"); + + // Reload the page to collect some HTTP requests. + await navigateToolboxTarget(extension, toolbox); + + // Get HAR, it should not be empty now. + const getHARPromise = extension.awaitMessage("getHAR-result"); + extension.sendMessage("getHAR"); + const getHARResult = await getHARPromise; + is(getHARResult.entries.length, 1, "HAR log should not be empty"); + + // Select the Net panel and reload page again. + await toolbox.selectTool("netmonitor"); + await navigateToolboxTarget(extension, toolbox); + + // Get HAR again, it should not be empty even if + // the Network panel is selected now. + const getHAREmptyPromiseWithPanel = extension.awaitMessage("getHAR-result"); + extension.sendMessage("getHAR"); + const emptyResultWithPanel = await getHAREmptyPromiseWithPanel; + is(emptyResultWithPanel.entries.length, 1, "HAR log should not be empty"); + + // Shutdown + await closeToolboxForTab(tab); + + await extension.unload(); + + BrowserTestUtils.removeTab(tab); +}); + +/** + * Test for `chrome.devtools.network.onRequestFinished()` API + */ +add_task(async function test_devtools_network_on_request_finished() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://mochi.test:8888/" + ); + let extension = ExtensionTestUtils.loadExtension(extData); + + await extension.startup(); + await extension.awaitMessage("ready"); + + // Open the Toolbox + const toolbox = await openToolboxForTab(tab); + + info("Wait the devtools page load"); + await extension.awaitMessage("devtools-page-loaded"); + + // Wait the extension to subscribe the onRequestFinished listener. + await extension.sendMessage("addOnRequestFinishedListener"); + + // Reload the page + await navigateToolboxTarget(extension, toolbox); + + info("Wait for an onRequestFinished event"); + await extension.awaitMessage("onRequestFinished"); + + // Wait for response content being fetched. + info("Wait for request.getBody results"); + let [callbackRes, promiseRes] = await Promise.all([ + extension.awaitMessage("onRequestFinished-callbackExecuted"), + extension.awaitMessage("onRequestFinished-promiseResolved"), + ]); + + ok( + callbackRes[0].startsWith("<!DOCTYPE html>"), + "The expected content has been retrieved." + ); + is( + callbackRes[1], + "text/html; charset=utf-8", + "The expected content has been retrieved." + ); + is( + promiseRes[0], + callbackRes[0], + "The resolved value is equal to the one received in the callback API mode" + ); + is( + promiseRes[1], + callbackRes[1], + "The resolved value is equal to the one received in the callback API mode" + ); + + // Shutdown + await closeToolboxForTab(tab); + + await extension.unload(); + + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_devtools_network_targetSwitch.js b/browser/components/extensions/test/browser/browser_ext_devtools_network_targetSwitch.js new file mode 100644 index 0000000000..229067fd45 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_devtools_network_targetSwitch.js @@ -0,0 +1,74 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +// Like most of the mochitest-browser devtools test, +// on debug test machine, it takes about 50s to run the test. +requestLongerTimeout(4); + +loadTestSubscript("head_devtools.js"); + +const MAIN_PROCESS_PAGE = "about:robots"; +const CONTENT_PROCESS_PAGE = "http://example.com/"; + +async function testOnNavigatedEvent(uri, tab, toolbox, extension) { + const onNavigated = extension.awaitMessage("network-onNavigated"); + const onSwitched = toolbox.commands.targetCommand.once("switched-target"); + BrowserTestUtils.startLoadingURIString(tab.linkedBrowser, uri); + await onSwitched; + const result = await onNavigated; + is(result, uri, "devtools.network.onNavigated works correctly"); +} + +/** + * This test checks whether network works well even target-switching happens. + */ +add_task(async () => { + const tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + CONTENT_PROCESS_PAGE + ); + + async function devtools_page() { + browser.devtools.network.onNavigated.addListener(url => { + browser.test.sendMessage("network-onNavigated", url); + }); + + browser.test.sendMessage("devtools-page-loaded"); + } + + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + devtools_page: "devtools_page.html", + }, + files: { + "devtools_page.html": `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + </head> + <body> + <script src="devtools_page.js"></script> + </body> + </html>`, + "devtools_page.js": devtools_page, + }, + }); + await extension.startup(); + + info("Open the developer toolbox"); + const toolbox = await openToolboxForTab(tab); + + info("Wait the devtools page load"); + await extension.awaitMessage("devtools-page-loaded"); + + info("Navigate to a page running on main process"); + await testOnNavigatedEvent(MAIN_PROCESS_PAGE, tab, toolbox, extension); + + info("Return to a page running on content process again"); + await testOnNavigatedEvent(CONTENT_PROCESS_PAGE, tab, toolbox, extension); + + await extension.unload(); + await closeToolboxForTab(tab); + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_devtools_optional.js b/browser/components/extensions/test/browser/browser_ext_devtools_optional.js new file mode 100644 index 0000000000..54ca061c67 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_devtools_optional.js @@ -0,0 +1,169 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +loadTestSubscript("head_devtools.js"); + +/** + * This test file ensures that: + * + * - "devtools" permission can be used as an optional permission + * - the extension devtools page and panels are not disabled/enabled on changes + * to unrelated optional permissions. + */ +add_task(async function test_devtools_optional_permission() { + Services.prefs.setBoolPref( + "extensions.webextOptionalPermissionPrompts", + false + ); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("extensions.webextOptionalPermissionPrompts"); + }); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://mochi.test:8888/" + ); + + function background() { + browser.test.onMessage.addListener(async (msg, perm) => { + if (msg === "request") { + let granted = await new Promise(resolve => { + browser.test.withHandlingUserInput(() => { + resolve(browser.permissions.request(perm)); + }); + }); + browser.test.assertTrue(granted, "permission request succeeded"); + browser.test.sendMessage("done"); + } else if (msg === "revoke") { + await browser.permissions.remove(perm); + browser.test.sendMessage("done"); + } + }); + } + + function devtools_page() { + browser.test.sendMessage("devtools_page_loaded"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + optional_permissions: ["devtools", "*://mochi.test/*"], + devtools_page: "devtools_page.html", + }, + files: { + "devtools_page.html": `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + </head> + <body> + <script src="devtools_page.js"></script> + </body> + </html>`, + "devtools_page.js": devtools_page, + }, + }); + + await extension.startup(); + + function checkEnabled(expect = false, { expectIsUserSet = true } = {}) { + const prefName = `devtools.webextensions.${extension.id}.enabled`; + Assert.equal( + expect, + Services.prefs.getBoolPref(prefName, false), + `Got the expected value set on pref ${prefName}` + ); + + Assert.equal( + expectIsUserSet, + Services.prefs.prefHasUserValue(prefName), + `pref "${prefName}" ${ + expectIsUserSet ? "should" : "should not" + } be user set` + ); + } + + checkEnabled(false, { expectIsUserSet: false }); + + // Open the devtools first, then request permission + info("Open the developer toolbox"); + await openToolboxForTab(tab); + assertDevToolsExtensionEnabled(extension.uuid, false); + + info( + "Request unrelated permission, expect devtools page and panel to be disabled" + ); + extension.sendMessage("request", { + permissions: [], + origins: ["*://mochi.test/*"], + }); + await extension.awaitMessage("done"); + checkEnabled(false, { expectIsUserSet: false }); + + info( + "Request devtools permission, expect devtools page and panel to be enabled" + ); + extension.sendMessage("request", { + permissions: ["devtools"], + origins: [], + }); + await extension.awaitMessage("done"); + checkEnabled(true); + + info("Wait the devtools page load"); + await extension.awaitMessage("devtools_page_loaded"); + assertDevToolsExtensionEnabled(extension.uuid, true); + + info( + "Revoke unrelated permission, expect devtools page and panel to stay enabled" + ); + extension.sendMessage("revoke", { + permissions: [], + origins: ["*://mochi.test/*"], + }); + await extension.awaitMessage("done"); + checkEnabled(true); + + info( + "Revoke devtools permission, expect devtools page and panel to be destroyed" + ); + let policy = WebExtensionPolicy.getByID(extension.id); + let closed = new Promise(resolve => { + // eslint-disable-next-line mozilla/balanced-listeners + policy.extension.on("devtools-page-shutdown", resolve); + }); + + extension.sendMessage("revoke", { + permissions: ["devtools"], + origins: [], + }); + await extension.awaitMessage("done"); + + await closed; + checkEnabled(false); + assertDevToolsExtensionEnabled(extension.uuid, false); + + info("Close the developer toolbox"); + await closeToolboxForTab(tab); + + extension.sendMessage("request", { + permissions: ["devtools"], + origins: [], + }); + await extension.awaitMessage("done"); + + info("Open the developer toolbox"); + openToolboxForTab(tab); + + checkEnabled(true); + + info("Wait the devtools page load"); + await extension.awaitMessage("devtools_page_loaded"); + assertDevToolsExtensionEnabled(extension.uuid, true); + await extension.unload(); + + await closeToolboxForTab(tab); + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_devtools_page.js b/browser/components/extensions/test/browser/browser_ext_devtools_page.js new file mode 100644 index 0000000000..8f2700c657 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_devtools_page.js @@ -0,0 +1,304 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +loadTestSubscript("head_devtools.js"); + +/** + * This test file ensures that: + * + * - the devtools_page property creates a new WebExtensions context + * - the devtools_page can exchange messages with the background page + */ +add_task(async function test_devtools_page_runtime_api_messaging() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://mochi.test:8888/" + ); + + function background() { + browser.runtime.onMessage.addListener((msg, sender) => { + if (sender.tab) { + browser.test.sendMessage("content_script_message_received"); + } + }); + + browser.runtime.onConnect.addListener(port => { + if (port.sender.tab) { + browser.test.sendMessage("content_script_port_received"); + return; + } + + let portMessageReceived = false; + + port.onDisconnect.addListener(() => { + browser.test.assertTrue( + portMessageReceived, + "Got a port message before the port disconnect event" + ); + browser.test.sendMessage("devtools_page_connect.done"); + }); + + port.onMessage.addListener(msg => { + portMessageReceived = true; + browser.test.assertEq( + "devtools -> background port message", + msg, + "Got the expected message from the devtools page" + ); + port.postMessage("background -> devtools port message"); + }); + }); + } + + function devtools_page() { + browser.runtime.onConnect.addListener(port => { + // Fail if a content script port has been received by the devtools page (Bug 1383310). + if (port.sender.tab) { + browser.test.fail( + `A DevTools page should not receive ports from content scripts` + ); + } + }); + + browser.runtime.onMessage.addListener((msg, sender) => { + // Fail if a content script message has been received by the devtools page (Bug 1383310). + if (sender.tab) { + browser.test.fail( + `A DevTools page should not receive messages from content scripts` + ); + } + }); + + const port = browser.runtime.connect(); + port.onMessage.addListener(msg => { + browser.test.assertEq( + "background -> devtools port message", + msg, + "Got the expected message from the background page" + ); + port.disconnect(); + }); + port.postMessage("devtools -> background port message"); + + browser.test.sendMessage("devtools_page_loaded"); + } + + function content_script() { + browser.test.onMessage.addListener(msg => { + switch (msg) { + case "content_script.send_message": + browser.runtime.sendMessage("content_script_message"); + break; + case "content_script.connect_port": + const port = browser.runtime.connect(); + port.disconnect(); + break; + default: + browser.test.fail( + `Unexpected message ${msg} received by content script` + ); + } + }); + + browser.test.sendMessage("content_script_loaded"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + devtools_page: "devtools_page.html", + content_scripts: [ + { + js: ["content_script.js"], + matches: ["http://mochi.test/*"], + }, + ], + }, + files: { + "content_script.js": content_script, + "devtools_page.html": `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + </head> + <body> + <script src="devtools_page.js"></script> + </body> + </html>`, + "devtools_page.js": devtools_page, + }, + }); + + await extension.startup(); + + info("Wait the content script load"); + await extension.awaitMessage("content_script_loaded"); + + info("Open the developer toolbox"); + await openToolboxForTab(tab); + + info("Wait the devtools page load"); + await extension.awaitMessage("devtools_page_loaded"); + + info("Wait the connection 'devtools_page -> background' to complete"); + await extension.awaitMessage("devtools_page_connect.done"); + + // Send a message from the content script and expect it to be received from + // the background page (repeated twice to be sure that the devtools_page had + // the chance to receive the message and fail as expected). + info( + "Wait for 2 content script messages to be received from the background page" + ); + extension.sendMessage("content_script.send_message"); + await extension.awaitMessage("content_script_message_received"); + extension.sendMessage("content_script.send_message"); + await extension.awaitMessage("content_script_message_received"); + + // Create a port from the content script and expect a port to be received from + // the background page (repeated twice to be sure that the devtools_page had + // the chance to receive the message and fail as expected). + info( + "Wait for 2 content script ports to be received from the background page" + ); + extension.sendMessage("content_script.connect_port"); + await extension.awaitMessage("content_script_port_received"); + extension.sendMessage("content_script.connect_port"); + await extension.awaitMessage("content_script_port_received"); + + await closeToolboxForTab(tab); + + await extension.unload(); + + BrowserTestUtils.removeTab(tab); +}); + +/** + * This test file ensures that: + * + * - the devtools_page can exchange messages with an extension tab page + */ + +add_task(async function test_devtools_page_and_extension_tab_messaging() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://mochi.test:8888/" + ); + + function background() { + browser.runtime.onMessage.addListener((msg, sender) => { + if (sender.tab) { + browser.test.sendMessage("extension_tab_message_received"); + } + }); + + browser.runtime.onConnect.addListener(port => { + if (port.sender.tab) { + browser.test.sendMessage("extension_tab_port_received"); + } + }); + + browser.tabs.create({ url: browser.runtime.getURL("extension_tab.html") }); + } + + function devtools_page() { + browser.runtime.onConnect.addListener(port => { + browser.test.sendMessage("devtools_page_onconnect"); + }); + + browser.runtime.onMessage.addListener((msg, sender) => { + browser.test.sendMessage("devtools_page_onmessage"); + }); + + browser.test.sendMessage("devtools_page_loaded"); + } + + function extension_tab() { + browser.test.onMessage.addListener(msg => { + switch (msg) { + case "extension_tab.send_message": + browser.runtime.sendMessage("content_script_message"); + break; + case "extension_tab.connect_port": + const port = browser.runtime.connect(); + port.disconnect(); + break; + default: + browser.test.fail( + `Unexpected message ${msg} received by content script` + ); + } + }); + + browser.test.sendMessage("extension_tab_loaded"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + devtools_page: "devtools_page.html", + }, + files: { + "extension_tab.html": `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + </head> + <body> + <h1>Extension Tab Page</h1> + <script src="extension_tab.js"></script> + </body> + </html>`, + "extension_tab.js": extension_tab, + "devtools_page.html": `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + </head> + <body> + <script src="devtools_page.js"></script> + </body> + </html>`, + "devtools_page.js": devtools_page, + }, + }); + + await extension.startup(); + + info("Wait the extension tab page load"); + await extension.awaitMessage("extension_tab_loaded"); + + info("Open the developer toolbox"); + await openToolboxForTab(tab); + + info("Wait the devtools page load"); + await extension.awaitMessage("devtools_page_loaded"); + + extension.sendMessage("extension_tab.send_message"); + + info( + "Wait for an extension tab message to be received from the devtools page" + ); + await extension.awaitMessage("devtools_page_onmessage"); + + info( + "Wait for an extension tab message to be received from the background page" + ); + await extension.awaitMessage("extension_tab_message_received"); + + extension.sendMessage("extension_tab.connect_port"); + + info("Wait for an extension tab port to be received from the devtools page"); + await extension.awaitMessage("devtools_page_onconnect"); + + info( + "Wait for an extension tab port to be received from the background page" + ); + await extension.awaitMessage("extension_tab_port_received"); + + await closeToolboxForTab(tab); + + await extension.unload(); + + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_devtools_page_incognito.js b/browser/components/extensions/test/browser/browser_ext_devtools_page_incognito.js new file mode 100644 index 0000000000..fe87913563 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_devtools_page_incognito.js @@ -0,0 +1,92 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +loadTestSubscript("head_devtools.js"); + +async function testIncognito(incognitoOverride) { + let privateAllowed = incognitoOverride == "spanning"; + + function devtools_page(privateAllowed) { + if (!privateAllowed) { + browser.test.fail( + "Extension devtools_page should not be created on private tabs if not allowed" + ); + } + + browser.test.sendMessage("devtools_page:loaded"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + devtools_page: "devtools_page.html", + }, + incognitoOverride, + files: { + "devtools_page.html": ` + <!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + <script src="devtools_page.js"></script> + </head> + </html> + `, + "devtools_page.js": `(${devtools_page})(${privateAllowed})`, + }, + }); + + let existingPrivateWindow = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + + await extension.startup(); + + await openToolboxForTab(existingPrivateWindow.gBrowser.selectedTab); + + if (privateAllowed) { + // Wait the devtools_page to be loaded if it is allowed. + await extension.awaitMessage("devtools_page:loaded"); + } + + // If the devtools_page is created for a not allowed extension, the devtools page will + // trigger a test failure, but let's make an explicit assertion otherwise mochitest will + // complain because there was no assertion in the test. + ok( + true, + `Opened toolbox on an existing private window (extension ${incognitoOverride})` + ); + + let newPrivateWindow = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + + await openToolboxForTab(newPrivateWindow.gBrowser.selectedTab); + + if (privateAllowed) { + await extension.awaitMessage("devtools_page:loaded"); + } + + // If the devtools_page is created for a not allowed extension, the devtools page will + // trigger a test failure. + ok( + true, + `Opened toolbox on a newprivate window (extension ${incognitoOverride})` + ); + + // Close opened toolboxes and private windows. + await closeToolboxForTab(existingPrivateWindow.gBrowser.selectedTab); + await closeToolboxForTab(newPrivateWindow.gBrowser.selectedTab); + await BrowserTestUtils.closeWindow(existingPrivateWindow); + await BrowserTestUtils.closeWindow(newPrivateWindow); + + await extension.unload(); +} + +add_task(async function test_devtools_page_not_allowed() { + await testIncognito(); +}); + +add_task(async function test_devtools_page_allowed() { + await testIncognito("spanning"); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_devtools_panel.js b/browser/components/extensions/test/browser/browser_ext_devtools_panel.js new file mode 100644 index 0000000000..c9bf12b9fd --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_devtools_panel.js @@ -0,0 +1,812 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +// Like most of the mochitest-browser devtools test, +// on debug test machine, it takes about 50s to run the test. +requestLongerTimeout(4); + +loadTestSubscript("head_devtools.js"); + +ChromeUtils.defineESModuleGetters(this, { + Preferences: "resource://gre/modules/Preferences.sys.mjs", +}); + +const DEVTOOLS_THEME_PREF = "devtools.theme"; + +/** + * This test file ensures that: + * + * - devtools.panels.themeName returns the correct value, + * both from a page and a panel. + * - devtools.panels.onThemeChanged fires for theme changes, + * both from a page and a panel. + * - devtools.panels.create is able to create a devtools panel. + */ + +function createPage(jsScript, bodyText = "") { + return `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + </head> + <body> + ${bodyText} + <script src="${jsScript}"></script> + </body> + </html>`; +} + +async function test_theme_name(testWithPanel = false) { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://mochi.test:8888/" + ); + + function switchTheme(theme) { + const waitforThemeChanged = gDevTools.once("theme-changed"); + Preferences.set(DEVTOOLS_THEME_PREF, theme); + return waitforThemeChanged; + } + + async function testThemeSwitching(extension, locations = ["page"]) { + for (let newTheme of ["dark", "light"]) { + await switchTheme(newTheme); + for (let location of locations) { + is( + await extension.awaitMessage(`devtools_theme_changed_${location}`), + newTheme, + `The onThemeChanged event listener fired for the ${location}.` + ); + is( + await extension.awaitMessage(`current_theme_${location}`), + newTheme, + `The current theme is reported as expected for the ${location}.` + ); + } + } + } + + async function devtools_page(createPanel) { + if (createPanel) { + await browser.devtools.panels.create( + "Test Panel Theme", + "fake-icon.png", + "devtools_panel.html" + ); + } + + browser.devtools.panels.onThemeChanged.addListener(themeName => { + browser.test.sendMessage("devtools_theme_changed_page", themeName); + browser.test.sendMessage( + "current_theme_page", + browser.devtools.panels.themeName + ); + }); + + browser.test.sendMessage( + "initial_theme_page", + browser.devtools.panels.themeName + ); + } + + async function devtools_panel() { + browser.devtools.panels.onThemeChanged.addListener(themeName => { + browser.test.sendMessage("devtools_theme_changed_panel", themeName); + browser.test.sendMessage( + "current_theme_panel", + browser.devtools.panels.themeName + ); + }); + + browser.test.sendMessage( + "initial_theme_panel", + browser.devtools.panels.themeName + ); + } + + let files = { + "devtools_page.html": createPage("devtools_page.js"), + "devtools_page.js": `(${devtools_page})(${testWithPanel})`, + }; + + if (testWithPanel) { + files["devtools_panel.js"] = devtools_panel; + files["devtools_panel.html"] = createPage( + "devtools_panel.js", + "Test Panel Theme" + ); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + devtools_page: "devtools_page.html", + }, + files, + }); + + // Ensure that the initial value of the devtools theme is "light". + await SpecialPowers.pushPrefEnv({ set: [[DEVTOOLS_THEME_PREF, "light"]] }); + registerCleanupFunction(async function () { + await SpecialPowers.popPrefEnv(); + }); + + await extension.startup(); + + const toolbox = await openToolboxForTab(tab); + + info("Waiting initial theme from devtools_page"); + is( + await extension.awaitMessage("initial_theme_page"), + "light", + "The initial theme is reported as expected." + ); + + if (testWithPanel) { + let toolboxAdditionalTools = toolbox.getAdditionalTools(); + is( + toolboxAdditionalTools.length, + 1, + "Got the expected number of toolbox specific panel registered." + ); + + let panelId = toolboxAdditionalTools[0].id; + + await gDevTools.showToolboxForTab(tab, { toolId: panelId }); + is( + await extension.awaitMessage("initial_theme_panel"), + "light", + "The initial theme is reported as expected from a devtools panel." + ); + + await testThemeSwitching(extension, ["page", "panel"]); + } else { + await testThemeSwitching(extension); + } + + await closeToolboxForTab(tab); + + await extension.unload(); + + BrowserTestUtils.removeTab(tab); +} + +add_task(async function test_devtools_page_theme() { + await test_theme_name(false); +}); + +add_task(async function test_devtools_panel_theme() { + await test_theme_name(true); +}); + +add_task(async function test_devtools_page_panels_create() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://mochi.test:8888/" + ); + + async function devtools_page() { + const result = { + devtoolsPageTabId: browser.devtools.inspectedWindow.tabId, + panelCreated: 0, + panelShown: 0, + panelHidden: 0, + }; + + try { + const panel = await browser.devtools.panels.create( + "Test Panel Create", + "fake-icon.png", + "devtools_panel.html" + ); + + result.panelCreated++; + + panel.onShown.addListener(contentWindow => { + result.panelShown++; + browser.test.assertEq( + "complete", + contentWindow.document.readyState, + "Got the expected 'complete' panel document readyState" + ); + browser.test.assertEq( + "test_panel_global", + contentWindow.TEST_PANEL_GLOBAL, + "Got the expected global in the panel contentWindow" + ); + browser.test.sendMessage("devtools_panel_shown", result); + }); + + panel.onHidden.addListener(() => { + result.panelHidden++; + + browser.test.sendMessage("devtools_panel_hidden", result); + }); + + browser.test.sendMessage("devtools_panel_created"); + } catch (err) { + // Make the test able to fail fast when it is going to be a failure. + browser.test.sendMessage("devtools_panel_created"); + throw err; + } + } + + function devtools_panel() { + // Set a property in the global and check that it is defined + // and accessible from the devtools_page when the panel.onShown + // event has been received. + window.TEST_PANEL_GLOBAL = "test_panel_global"; + + browser.test.sendMessage( + "devtools_panel_inspectedWindow_tabId", + browser.devtools.inspectedWindow.tabId + ); + } + + const longPrefix = new Array(80).fill("x").join(""); + // Extension ID includes "inspector" to verify Bug 1474379 doesn't regress. + const EXTENSION_ID = `${longPrefix}-inspector@create-devtools-panel.test`; + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + devtools_page: "devtools_page.html", + browser_specific_settings: { + gecko: { id: EXTENSION_ID }, + }, + }, + files: { + "devtools_page.html": createPage("devtools_page.js"), + "devtools_page.js": devtools_page, + "devtools_panel.html": createPage( + "devtools_panel.js", + "Test Panel Create" + ), + "devtools_panel.js": devtools_panel, + }, + }); + + await extension.startup(); + + const extensionPrefBranch = `devtools.webextensions.${EXTENSION_ID}.`; + const extensionPrefName = `${extensionPrefBranch}enabled`; + + let prefBranch = Services.prefs.getBranch(extensionPrefBranch); + ok( + prefBranch, + "The preference branch for the extension should have been created" + ); + is( + prefBranch.getBoolPref("enabled", false), + true, + "The 'enabled' bool preference for the extension should be initially true" + ); + + // Get the devtools panel info for the first item in the toolbox additional tools array. + const getPanelInfo = toolbox => { + let toolboxAdditionalTools = toolbox.getAdditionalTools(); + is( + toolboxAdditionalTools.length, + 1, + "Got the expected number of toolbox specific panel registered." + ); + return toolboxAdditionalTools[0]; + }; + + // Test the devtools panel shown and hide events. + const testPanelShowAndHide = async ({ + tab, + panelId, + isFirstPanelLoad, + expectedResults, + }) => { + info("Wait Addon Devtools Panel to be shown"); + + await gDevTools.showToolboxForTab(tab, { toolId: panelId }); + const { devtoolsPageTabId } = await extension.awaitMessage( + "devtools_panel_shown" + ); + + // If the panel is loaded for the first time, we expect to also + // receive the test messages and assert that both the page and the panel + // have the same devtools.inspectedWindow.tabId value. + if (isFirstPanelLoad) { + const devtoolsPanelTabId = await extension.awaitMessage( + "devtools_panel_inspectedWindow_tabId" + ); + is( + devtoolsPanelTabId, + devtoolsPageTabId, + "Got the same devtools.inspectedWindow.tabId from devtools page and panel" + ); + } + + info("Wait Addon Devtools Panel to be shown"); + + await gDevTools.showToolboxForTab(tab, { toolId: "testBlankPanel" }); + const results = await extension.awaitMessage("devtools_panel_hidden"); + + // We already checked the tabId, remove it from the results, so that we can check + // the remaining properties using a single Assert.deepEqual. + delete results.devtoolsPageTabId; + + Assert.deepEqual( + results, + expectedResults, + "Got the expected number of created panels and shown/hidden events" + ); + }; + + // Test the extension devtools_page enabling/disabling through the related + // about:config preference. + const testExtensionDevToolsPref = async ({ + prefValue, + toolbox, + oldPanelId, + }) => { + if (!prefValue) { + // Test that the extension devtools_page is shutting down when the related + // about:config preference has been set to false, and the panel on its left + // is being selected. + info( + "Turning off the extension devtools page from its about:config preference" + ); + let waitToolSelected = toolbox.once("select"); + Services.prefs.setBoolPref(extensionPrefName, false); + const selectedTool = await waitToolSelected; + isnot( + selectedTool, + oldPanelId, + "Expect a different panel to be selected" + ); + + let toolboxAdditionalTools = toolbox.getAdditionalTools(); + is( + toolboxAdditionalTools.length, + 0, + "Extension devtools panel unregistered" + ); + is( + toolbox.visibleAdditionalTools.filter(toolId => toolId == oldPanelId) + .length, + 0, + "Removed panel should not be listed in the visible additional tools" + ); + } else { + // Test that the extension devtools_page and panel are being created again when + // the related about:config preference has been set to true. + info( + "Turning on the extension devtools page from its about:config preference" + ); + Services.prefs.setBoolPref(extensionPrefName, true); + await extension.awaitMessage("devtools_panel_created"); + + let toolboxAdditionalTools = toolbox.getAdditionalTools(); + is( + toolboxAdditionalTools.length, + 1, + "Got one extension devtools panel registered" + ); + + let newPanelId = getPanelInfo(toolbox).id; + is( + toolbox.visibleAdditionalTools.filter(toolId => toolId == newPanelId) + .length, + 1, + "Extension panel is listed in the visible additional tools" + ); + } + }; + + // Wait that the devtools_page has created its devtools panel and retrieve its + // panel id. + let toolbox = await openToolboxForTab(tab); + await extension.awaitMessage("devtools_panel_created"); + let panelId = getPanelInfo(toolbox).id; + + info("Test panel show and hide - first cycle"); + await testPanelShowAndHide({ + tab, + panelId, + isFirstPanelLoad: true, + expectedResults: { + panelCreated: 1, + panelShown: 1, + panelHidden: 1, + }, + }); + + info("Test panel show and hide - second cycle"); + await testPanelShowAndHide({ + tab, + panelId, + isFirstPanelLoad: false, + expectedResults: { + panelCreated: 1, + panelShown: 2, + panelHidden: 2, + }, + }); + + // Go back to the extension devtools panel. + await gDevTools.showToolboxForTab(tab, { toolId: panelId }); + await extension.awaitMessage("devtools_panel_shown"); + + // Check that the aria-label has been set on the devtools panel. + const panelFrame = toolbox.doc.getElementById( + `toolbox-panel-iframe-${panelId}` + ); + const panelInfo = getPanelInfo(toolbox); + ok( + panelInfo.panelLabel && !!panelInfo.panelLabel.length, + "Expect the registered panel to include a non empty panelLabel property" + ); + is( + panelFrame && panelFrame.getAttribute("aria-label"), + panelInfo.panelLabel, + "Got the expected aria-label on the extension panel frame" + ); + + // Turn off the extension devtools page using the preference that enable/disable the + // devtools page for a given installed WebExtension. + await testExtensionDevToolsPref({ + toolbox, + prefValue: false, + oldPanelId: panelId, + }); + + // Close and Re-open the toolbox to verify that the toolbox doesn't load the + // devtools_page and the devtools panel. + info("Re-open the toolbox and expect no extension devtools panel"); + await closeToolboxForTab(tab); + toolbox = await openToolboxForTab(tab); + + let toolboxAdditionalTools = toolbox.getAdditionalTools(); + is( + toolboxAdditionalTools.length, + 0, + "Got no extension devtools panel on the opened toolbox as expected." + ); + + // Close and Re-open the toolbox to verify that the toolbox does load the + // devtools_page and the devtools panel again. + info("Restart the toolbox and enable the extension devtools panel"); + await closeToolboxForTab(tab); + toolbox = await openToolboxForTab(tab); + + // Turn the addon devtools panel back on using the preference that enable/disable the + // devtools page for a given installed WebExtension. + await testExtensionDevToolsPref({ + toolbox, + prefValue: true, + }); + + // Test devtools panel is loaded correctly after being toggled and + // devtools panel events has been fired as expected. + panelId = getPanelInfo(toolbox).id; + + info("Test panel show and hide - after disabling/enabling devtools_page"); + await testPanelShowAndHide({ + tab, + panelId, + isFirstPanelLoad: true, + expectedResults: { + panelCreated: 1, + panelShown: 1, + panelHidden: 1, + }, + }); + + await closeToolboxForTab(tab); + + await extension.unload(); + + // Verify that the extension preference branch has been removed once the extension + // has been uninstalled. + prefBranch = Services.prefs.getBranch(extensionPrefBranch); + is( + prefBranch.getPrefType("enabled"), + prefBranch.PREF_INVALID, + "The preference branch for the extension should have been removed" + ); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_devtools_page_panels_switch_toolbox_host() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://mochi.test:8888/" + ); + + function devtools_panel() { + const hasDevToolsAPINamespace = "devtools" in browser; + + browser.test.sendMessage("devtools_panel_loaded", { + hasDevToolsAPINamespace, + panelLoadedURL: window.location.href, + }); + } + + async function devtools_page() { + const panel = await browser.devtools.panels.create( + "Test Panel Switch Host", + "fake-icon.png", + "devtools_panel.html" + ); + + panel.onShown.addListener(panelWindow => { + browser.test.sendMessage( + "devtools_panel_shown", + panelWindow.location.href + ); + }); + + panel.onHidden.addListener(() => { + browser.test.sendMessage("devtools_panel_hidden"); + }); + + browser.test.sendMessage("devtools_panel_created"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + devtools_page: "devtools_page.html", + }, + files: { + "devtools_page.html": createPage("devtools_page.js"), + "devtools_page.js": devtools_page, + "devtools_panel.html": createPage("devtools_panel.js", "DEVTOOLS PANEL"), + "devtools_panel.js": devtools_panel, + }, + }); + + await extension.startup(); + + let toolbox = await openToolboxForTab(tab); + await extension.awaitMessage("devtools_panel_created"); + + const toolboxAdditionalTools = toolbox.getAdditionalTools(); + + is( + toolboxAdditionalTools.length, + 1, + "Got the expected number of toolbox specific panel registered." + ); + + const panelDef = toolboxAdditionalTools[0]; + const panelId = panelDef.id; + + info("Selecting the addon devtools panel"); + await gDevTools.showToolboxForTab(tab, { toolId: panelId }); + + info("Wait for the panel to show and load for the first time"); + const panelShownURL = await extension.awaitMessage("devtools_panel_shown"); + + const { panelLoadedURL, hasDevToolsAPINamespace } = + await extension.awaitMessage("devtools_panel_loaded"); + + is( + panelShownURL, + panelLoadedURL, + "Got the expected panel URL on the first load" + ); + ok( + hasDevToolsAPINamespace, + "The devtools panel has the devtools API on the first load" + ); + + const originalToolboxHostType = toolbox.hostType; + + info("Switch the toolbox from docked on bottom to docked on right"); + toolbox.switchHost("right"); + + info( + "Wait for the panel to emit hide, show and load messages once docked on side" + ); + await extension.awaitMessage("devtools_panel_hidden"); + const dockedOnSideShownURL = await extension.awaitMessage( + "devtools_panel_shown" + ); + + is( + dockedOnSideShownURL, + panelShownURL, + "Got the expected panel url once the panel shown event has been emitted on toolbox host changed" + ); + + const dockedOnSideLoaded = await extension.awaitMessage( + "devtools_panel_loaded" + ); + + is( + dockedOnSideLoaded.panelLoadedURL, + panelShownURL, + "Got the expected panel url once the panel has been reloaded on toolbox host changed" + ); + ok( + dockedOnSideLoaded.hasDevToolsAPINamespace, + "The devtools panel has the devtools API once the toolbox host has been changed" + ); + + info("Switch the toolbox from docked on bottom to the original dock mode"); + toolbox.switchHost(originalToolboxHostType); + + info( + "Wait for the panel test messages once toolbox dock mode has been restored" + ); + await extension.awaitMessage("devtools_panel_hidden"); + await extension.awaitMessage("devtools_panel_shown"); + await extension.awaitMessage("devtools_panel_loaded"); + + await closeToolboxForTab(tab); + + await extension.unload(); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_devtools_page_invalid_panel_urls() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://mochi.test:8888/" + ); + + async function devtools_page() { + const matchInvalidPanelURL = /must be a relative URL/; + const matchInvalidIconURL = + /be one of \[""\], or match the format "strictRelativeUrl"/; + + // Invalid panel urls (validated by the schema wrappers, throws on invalid urls). + const invalid_panels = [ + { + panel: "about:about", + icon: "icon.png", + expectError: matchInvalidPanelURL, + }, + { + panel: "about:addons", + icon: "icon.png", + expectError: matchInvalidPanelURL, + }, + { + panel: "http://mochi.test:8888", + icon: "icon.png", + expectError: matchInvalidPanelURL, + }, + // Invalid icon urls (validated inside the API method because of the empty icon string + // which have to be resolved to the default icon, reject the returned promise). + { + panel: "panel.html", + icon: "about:about", + expectError: matchInvalidIconURL, + }, + { + panel: "panel.html", + icon: "http://mochi.test:8888", + expectError: matchInvalidIconURL, + }, + ]; + + const valid_panels = [ + { panel: "panel.html", icon: "icon.png" }, + { panel: "./panel.html", icon: "icon.png" }, + { panel: "/panel.html", icon: "icon.png" }, + { panel: "/panel.html", icon: "" }, + ]; + + let valid_panels_length = valid_panels.length; + + const test_cases = [].concat(invalid_panels, valid_panels); + + browser.test.onMessage.addListener(async msg => { + if (msg !== "start_test_panel_create") { + return; + } + + for (let { panel, icon, expectError } of test_cases) { + browser.test.log( + `Testing devtools.panels.create for ${JSON.stringify({ + panel, + icon, + })}` + ); + + if (expectError) { + // Verify that invalid panel urls throw. + browser.test.assertThrows( + () => browser.devtools.panels.create("Test Panel", icon, panel), + expectError, + "Got the expected rejection on creating a devtools panel with " + + `panel url ${panel} and icon ${icon}` + ); + } else { + // Verify that with valid panel and icon urls the panel is created and loaded + // as expected. + try { + const pane = await browser.devtools.panels.create( + "Test Panel", + icon, + panel + ); + + valid_panels_length--; + + // Wait the panel to be loaded. + const oncePanelLoaded = new Promise(resolve => { + pane.onShown.addListener(paneWin => { + browser.test.assertTrue( + paneWin.location.href.endsWith("/panel.html"), + `The panel has loaded the expected extension URL with ${panel}` + ); + resolve(); + }); + }); + + // Ask the privileged code to select the last created panel. + const done = valid_panels_length === 0; + browser.test.sendMessage("select-devtools-panel", done); + await oncePanelLoaded; + } catch (err) { + browser.test.fail( + "Unexpected failure on creating a devtools panel with " + + `panel url ${panel} and icon ${icon}` + ); + throw err; + } + } + } + + browser.test.sendMessage("test_invalid_devtools_panel_urls_done"); + }); + + browser.test.sendMessage("devtools_page_ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + devtools_page: "devtools_page.html", + icons: { + 32: "icon.png", + }, + }, + files: { + "devtools_page.html": createPage("devtools_page.js"), + "devtools_page.js": devtools_page, + "panel.html": createPage("panel.js", "DEVTOOLS PANEL"), + "panel.js": "", + "icon.png": imageBuffer, + "default-icon.png": imageBuffer, + }, + }); + + await extension.startup(); + + let toolbox = await openToolboxForTab(tab); + info("developer toolbox opened"); + + await extension.awaitMessage("devtools_page_ready"); + + extension.sendMessage("start_test_panel_create"); + + let done = false; + + while (!done) { + info("Waiting test extension request to select the last created panel"); + done = await extension.awaitMessage("select-devtools-panel"); + + const toolboxAdditionalTools = toolbox.getAdditionalTools(); + const lastTool = toolboxAdditionalTools[toolboxAdditionalTools.length - 1]; + + gDevTools.showToolboxForTab(tab, { toolId: lastTool.id }); + info("Last created panel selected"); + } + + await extension.awaitMessage("test_invalid_devtools_panel_urls_done"); + + await closeToolboxForTab(tab); + + await extension.unload(); + + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_devtools_panels_elements.js b/browser/components/extensions/test/browser/browser_ext_devtools_panels_elements.js new file mode 100644 index 0000000000..37aa44cfc1 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_devtools_panels_elements.js @@ -0,0 +1,124 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +loadTestSubscript("head_devtools.js"); + +add_task(async function test_devtools_panels_elements_onSelectionChanged() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://mochi.test:8888/" + ); + + function devtools_page() { + browser.devtools.panels.elements.onSelectionChanged.addListener( + async () => { + const [evalResult, exceptionInfo] = + await browser.devtools.inspectedWindow.eval("$0 && $0.tagName"); + + if (exceptionInfo) { + browser.test.fail( + "Unexpected exceptionInfo on inspectedWindow.eval: " + + JSON.stringify(exceptionInfo) + ); + } + + browser.test.sendMessage("devtools_eval_result", evalResult); + } + ); + + browser.test.onMessage.addListener(msg => { + switch (msg) { + case "inspectedWindow_reload": { + // Force a reload to test that the expected onSelectionChanged events are sent + // while the page is navigating and once it has been fully reloaded. + browser.devtools.inspectedWindow.eval("window.location.reload();"); + break; + } + + default: { + browser.test.fail(`Received unexpected test.onMesssage: ${msg}`); + } + } + }); + + browser.test.sendMessage("devtools_page_loaded"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + devtools_page: "devtools_page.html", + }, + files: { + "devtools_page.html": `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + </head> + <body> + <script src="devtools_page.js"></script> + </body> + </html>`, + "devtools_page.js": devtools_page, + }, + }); + + await extension.startup(); + + const toolbox = await openToolboxForTab(tab); + + await extension.awaitMessage("devtools_page_loaded"); + + await toolbox.selectTool("inspector"); + + const inspector = toolbox.getPanel("inspector"); + + info( + "Waiting for the first onSelectionChanged event to be fired once the inspector is open" + ); + + const evalResult = await extension.awaitMessage("devtools_eval_result"); + is( + evalResult, + "BODY", + "Got the expected onSelectionChanged once the inspector is selected" + ); + + // Reload the inspected tab and wait for the inspector markup view to have been + // fully reloaded. + const onceMarkupReloaded = inspector.once("markuploaded"); + extension.sendMessage("inspectedWindow_reload"); + await onceMarkupReloaded; + + info( + "Waiting for the two onSelectionChanged events fired before and after the navigation" + ); + + // Expect the eval result to be undefined on the first onSelectionChanged event + // (fired when the page is navigating away, and so the current selection is undefined). + const evalResultNavigating = await extension.awaitMessage( + "devtools_eval_result" + ); + is( + evalResultNavigating, + undefined, + "Got the expected onSelectionChanged once the tab is navigating" + ); + + // Expect the eval result to be related to the body element on the second onSelectionChanged + // event (fired when the page have been navigated to the new page). + const evalResultOnceMarkupReloaded = await extension.awaitMessage( + "devtools_eval_result" + ); + is( + evalResultOnceMarkupReloaded, + "BODY", + "Got the expected onSelectionChanged once the tab has been completely reloaded" + ); + + await closeToolboxForTab(tab); + + await extension.unload(); + + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_devtools_panels_elements_sidebar.js b/browser/components/extensions/test/browser/browser_ext_devtools_panels_elements_sidebar.js new file mode 100644 index 0000000000..0abc8c44e7 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_devtools_panels_elements_sidebar.js @@ -0,0 +1,323 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +/* globals getExtensionSidebarActors, expectNoSuchActorIDs, testSetExpressionSidebarPanel */ + +// Import the shared test helpers from the related devtools tests. +loadTestSubscript("head_devtools.js"); +loadTestSubscript("head_devtools_inspector_sidebar.js"); + +function isActiveSidebarTabTitle(inspector, expectedTabTitle, message) { + const actualTabTitle = inspector.panelDoc.querySelector( + "#inspector-sidebar .tabs-menu-item.is-active" + ).innerText; + is(actualTabTitle, expectedTabTitle, message); +} + +function testSetObjectSidebarPanel(panel, expectedCellType, expectedTitle) { + is( + panel.querySelectorAll("table.treeTable").length, + 1, + "The sidebar panel contains a rendered TreeView component" + ); + + is( + panel.querySelectorAll(`table.treeTable .${expectedCellType}Cell`).length, + 1, + `The TreeView component contains the expected a cell of type ${expectedCellType}` + ); + + if (expectedTitle) { + const panelTree = panel.querySelector("table.treeTable"); + ok( + panelTree.innerText.includes(expectedTitle), + "The optional root object title has been included in the object tree" + ); + } +} + +async function testSidebarPanelSelect(extension, inspector, tabId, expected) { + const { sidebarShown, sidebarHidden, activeSidebarTabTitle } = expected; + + inspector.sidebar.show(tabId); + + const shown = await extension.awaitMessage("devtools_sidebar_shown"); + is( + shown, + sidebarShown, + "Got the shown event on the second extension sidebar" + ); + + if (sidebarHidden) { + const hidden = await extension.awaitMessage("devtools_sidebar_hidden"); + is( + hidden, + sidebarHidden, + "Got the hidden event on the first extension sidebar" + ); + } + + isActiveSidebarTabTitle( + inspector, + activeSidebarTabTitle, + "Got the expected title on the active sidebar tab" + ); +} + +add_task(async function test_devtools_panels_elements_sidebar() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://mochi.test:8888/" + ); + + async function devtools_page() { + const sidebar1 = await browser.devtools.panels.elements.createSidebarPane( + "Test Sidebar 1" + ); + const sidebar2 = await browser.devtools.panels.elements.createSidebarPane( + "Test Sidebar 2" + ); + const sidebar3 = await browser.devtools.panels.elements.createSidebarPane( + "Test Sidebar 3" + ); + const sidebar4 = await browser.devtools.panels.elements.createSidebarPane( + "Test Sidebar 4" + ); + + const onShownListener = (event, sidebarInstance) => { + browser.test.sendMessage(`devtools_sidebar_${event}`, sidebarInstance); + }; + + sidebar1.onShown.addListener(() => onShownListener("shown", "sidebar1")); + sidebar2.onShown.addListener(() => onShownListener("shown", "sidebar2")); + sidebar3.onShown.addListener(() => onShownListener("shown", "sidebar3")); + sidebar4.onShown.addListener(() => onShownListener("shown", "sidebar4")); + + sidebar1.onHidden.addListener(() => onShownListener("hidden", "sidebar1")); + sidebar2.onHidden.addListener(() => onShownListener("hidden", "sidebar2")); + sidebar3.onHidden.addListener(() => onShownListener("hidden", "sidebar3")); + sidebar4.onHidden.addListener(() => onShownListener("hidden", "sidebar4")); + + // Refresh the sidebar content on every inspector selection. + browser.devtools.panels.elements.onSelectionChanged.addListener(() => { + const expression = ` + var obj = Object.create(null); + obj.prop1 = 123; + obj[Symbol('sym1')] = 456; + obj.cyclic = obj; + obj; + `; + sidebar1.setExpression(expression, "sidebar.setExpression rootTitle"); + }); + + sidebar2.setObject({ anotherPropertyName: 123 }); + sidebar3.setObject( + { propertyName: "propertyValue" }, + "Optional Root Object Title" + ); + + sidebar4.setPage("sidebar.html"); + + browser.test.sendMessage("devtools_page_loaded"); + } + + function sidebar() { + browser.test.sendMessage("sidebar-loaded"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + devtools_page: "devtools_page.html", + }, + files: { + "devtools_page.html": `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + </head> + <body> + <script src="devtools_page.js"></script> + </body> + </html>`, + "devtools_page.js": devtools_page, + "sidebar.html": `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + </head> + <body> + sidebar panel + <script src="sidebar.js"></script> + </body> + </html>`, + "sidebar.js": sidebar, + }, + }); + + await extension.startup(); + + const toolbox = await openToolboxForTab(tab); + + await extension.awaitMessage("devtools_page_loaded"); + + const waitInspector = toolbox.once("inspector-selected"); + toolbox.selectTool("inspector"); + await waitInspector; + + const sidebarIds = Array.from(toolbox._inspectorExtensionSidebars.keys()); + + const inspector = await toolbox.getPanel("inspector"); + + info("Test extension inspector sidebar 1 (sidebar.setExpression)"); + + inspector.sidebar.show(sidebarIds[0]); + + const shownSidebarInstance = await extension.awaitMessage( + "devtools_sidebar_shown" + ); + + is( + shownSidebarInstance, + "sidebar1", + "Got the shown event on the first extension sidebar" + ); + + isActiveSidebarTabTitle( + inspector, + "Test Sidebar 1", + "Got the expected title on the active sidebar tab" + ); + + const sidebarPanel1 = inspector.sidebar.getTabPanel(sidebarIds[0]); + + ok( + sidebarPanel1, + "Got a rendered sidebar panel for the first registered extension sidebar" + ); + + info("Waiting for the first panel to be rendered"); + + // Verify that the panel contains an ObjectInspector, with the expected number of nodes + // and with the expected property names. + await testSetExpressionSidebarPanel(sidebarPanel1, { + nodesLength: 4, + propertiesNames: ["cyclic", "prop1", "Symbol(sym1)"], + rootTitle: "sidebar.setExpression rootTitle", + }); + + // Retrieve the actors currently rendered into the extension sidebars. + const actors = getExtensionSidebarActors(inspector); + + info( + "Test extension inspector sidebar 2 (sidebar.setObject without a root title)" + ); + + await testSidebarPanelSelect(extension, inspector, sidebarIds[1], { + sidebarShown: "sidebar2", + sidebarHidden: "sidebar1", + activeSidebarTabTitle: "Test Sidebar 2", + }); + + const sidebarPanel2 = inspector.sidebar.getTabPanel(sidebarIds[1]); + + ok( + sidebarPanel2, + "Got a rendered sidebar panel for the second registered extension sidebar" + ); + + testSetObjectSidebarPanel(sidebarPanel2, "number"); + + info( + "Test extension inspector sidebar 3 (sidebar.setObject with a root title)" + ); + + await testSidebarPanelSelect(extension, inspector, sidebarIds[2], { + sidebarShown: "sidebar3", + sidebarHidden: "sidebar2", + activeSidebarTabTitle: "Test Sidebar 3", + }); + + const sidebarPanel3 = inspector.sidebar.getTabPanel(sidebarIds[2]); + + ok( + sidebarPanel3, + "Got a rendered sidebar panel for the third registered extension sidebar" + ); + + testSetObjectSidebarPanel( + sidebarPanel3, + "string", + "Optional Root Object Title" + ); + + info( + "Unloading the extension and check that all the sidebar have been removed" + ); + + inspector.sidebar.show(sidebarIds[3]); + + const shownSidebarInstance4 = await extension.awaitMessage( + "devtools_sidebar_shown" + ); + const hiddenSidebarInstance3 = await extension.awaitMessage( + "devtools_sidebar_hidden" + ); + + is( + shownSidebarInstance4, + "sidebar4", + "Got the shown event on the third extension sidebar" + ); + is( + hiddenSidebarInstance3, + "sidebar3", + "Got the hidden event on the second extension sidebar" + ); + + isActiveSidebarTabTitle( + inspector, + "Test Sidebar 4", + "Got the expected title on the active sidebar tab" + ); + + await extension.awaitMessage("sidebar-loaded"); + + await extension.unload(); + + is( + Array.from(toolbox._inspectorExtensionSidebars.keys()).length, + 0, + "All the registered sidebars have been unregistered on extension unload" + ); + + is( + inspector.sidebar.getTabPanel(sidebarIds[0]), + null, + "The first registered sidebar has been removed" + ); + + is( + inspector.sidebar.getTabPanel(sidebarIds[1]), + null, + "The second registered sidebar has been removed" + ); + + is( + inspector.sidebar.getTabPanel(sidebarIds[2]), + null, + "The third registered sidebar has been removed" + ); + + is( + inspector.sidebar.getTabPanel(sidebarIds[3]), + null, + "The fourth registered sidebar has been removed" + ); + + await expectNoSuchActorIDs(toolbox.target.client, actors); + + await closeToolboxForTab(tab); + + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_find.js b/browser/components/extensions/test/browser/browser_ext_find.js new file mode 100644 index 0000000000..c8a4c10bd8 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_find.js @@ -0,0 +1,468 @@ +/* global browser */ +"use strict"; + +function frameScript() { + let docShell = content.docShell; + let controller = docShell + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsISelectionDisplay) + .QueryInterface(Ci.nsISelectionController); + let selection = controller.getSelection(controller.SELECTION_FIND); + if (!selection.rangeCount) { + return { + text: "", + }; + } + + let range = selection.getRangeAt(0); + const { FindContent } = ChromeUtils.importESModule( + "resource://gre/modules/FindContent.sys.mjs" + ); + let highlighter = new FindContent(docShell).highlighter; + let r1 = content.parent.frameElement.getBoundingClientRect(); + let f1 = highlighter._getFrameElementOffsets(content.parent); + let r2 = content.frameElement.getBoundingClientRect(); + let f2 = highlighter._getFrameElementOffsets(content); + let r3 = range.getBoundingClientRect(); + let rect = { + top: r1.top + r2.top + r3.top + f1.y + f2.y, + left: r1.left + r2.left + r3.left + f1.x + f2.x, + }; + return { + text: selection.toString(), + rect, + }; +} + +add_task(async function testFind() { + async function background() { + function awaitLoad(tabId, url) { + return new Promise(resolve => { + browser.tabs.onUpdated.addListener(function listener( + tabId_, + changed, + tab + ) { + if ( + tabId == tabId_ && + changed.status == "complete" && + tab.url == url + ) { + browser.tabs.onUpdated.removeListener(listener); + resolve(); + } + }); + }); + } + + let url = + "http://example.com/browser/browser/components/extensions/test/browser/file_find_frames.html"; + let tab = await browser.tabs.update({ url }); + await awaitLoad(tab.id, url); + + let data = await browser.find.find("banana", { includeRangeData: true }); + let rangeData = data.rangeData; + + browser.test.log("Test that `data.count` is the expected value."); + browser.test.assertEq( + 6, + data.count, + "The value returned from `data.count`" + ); + + browser.test.log("Test that `rangeData` has the proper number of values."); + browser.test.assertEq( + 6, + rangeData.length, + "The number of values held in `rangeData`" + ); + + browser.test.log( + "Test that the text found in the top window and nested frames corresponds to the proper position." + ); + let terms = ["Bánana", "bAnana", "baNana", "banAna", "banaNa", "bananA"]; + for (let i = 0; i < terms.length; i++) { + browser.test.assertEq( + terms[i], + rangeData[i].text, + `The text at range position ${i}:` + ); + } + + browser.test.log("Test that case sensitive match works properly."); + data = await browser.find.find("baNana", { + caseSensitive: true, + includeRangeData: true, + }); + browser.test.assertEq(1, data.count, "The number of matches found:"); + browser.test.assertEq("baNana", data.rangeData[0].text, "The text found:"); + + browser.test.log("Test that diacritic sensitive match works properly."); + data = await browser.find.find("bánana", { + matchDiacritics: true, + includeRangeData: true, + }); + browser.test.assertEq(1, data.count, "The number of matches found:"); + browser.test.assertEq("Bánana", data.rangeData[0].text, "The text found:"); + + browser.test.log("Test that case insensitive match works properly."); + data = await browser.find.find("banana", { caseSensitive: false }); + browser.test.assertEq(6, data.count, "The number of matches found:"); + + browser.test.log("Test that entire word match works properly."); + data = await browser.find.find("banana", { entireWord: true }); + browser.test.assertEq( + 4, + data.count, + 'The number of matches found, should skip 2 matches, "banaNaland" and "bananAland":' + ); + + let expectedRangeData = [ + { + framePos: 0, + text: "example", + startTextNodePos: 16, + startOffset: 11, + endTextNodePos: 16, + endOffset: 18, + }, + { + framePos: 0, + text: "example", + startTextNodePos: 16, + startOffset: 25, + endTextNodePos: 16, + endOffset: 32, + }, + { + framePos: 0, + text: "example", + startTextNodePos: 19, + startOffset: 6, + endTextNodePos: 19, + endOffset: 13, + }, + { + framePos: 0, + text: "example", + startTextNodePos: 21, + startOffset: 3, + endTextNodePos: 21, + endOffset: 10, + }, + { + framePos: 1, + text: "example", + startTextNodePos: 0, + startOffset: 0, + endTextNodePos: 0, + endOffset: 7, + }, + { + framePos: 2, + text: "example", + startTextNodePos: 0, + startOffset: 0, + endTextNodePos: 0, + endOffset: 7, + }, + ]; + + browser.test.log( + "Test that word found in the same node, different nodes and different frames returns the correct rangeData results." + ); + data = await browser.find.find("example", { includeRangeData: true }); + for (let i = 0; i < data.rangeData.length; i++) { + for (let name in data.rangeData[i]) { + browser.test.assertEq( + expectedRangeData[i][name], + data.rangeData[i][name], + `rangeData[${i}].${name}:` + ); + } + } + + browser.test.log( + "Test that `rangeData` is not returned if `includeRangeData` is false." + ); + data = await browser.find.find("banana", { + caseSensitive: false, + includeRangeData: false, + }); + browser.test.assertEq( + false, + !!data.rangeData, + "The boolean cast value of `rangeData`:" + ); + + browser.test.log( + "Test that `rectData` is not returned if `includeRectData` is false." + ); + data = await browser.find.find("banana", { + caseSensitive: false, + includeRectData: false, + }); + browser.test.assertEq( + false, + !!data.rectData, + "The boolean cast value of `rectData`:" + ); + + browser.test.log( + "Test that text spanning multiple inline elements is found." + ); + data = await browser.find.find("fruitcake"); + browser.test.assertEq(1, data.count, "The number of matches found:"); + + browser.test.log( + "Test that text spanning multiple block elements is not found." + ); + data = await browser.find.find("angelfood"); + browser.test.assertEq(0, data.count, "The number of matches found:"); + + browser.test.log( + "Test that `highlightResults` returns proper status code." + ); + await browser.find.find("banana"); + + await browser.test.assertRejects( + browser.find.highlightResults({ rangeIndex: 6 }), + /index supplied was out of range/, + "rejected Promise should pass the expected error" + ); + + data = await browser.find.find("xyz"); + await browser.test.assertRejects( + browser.find.highlightResults({ rangeIndex: 0 }), + /no search results to highlight/, + "rejected Promise should pass the expected error" + ); + + // Test highlightResults without any arguments, especially `rangeIndex`. + data = await browser.find.find("example"); + browser.test.assertEq(6, data.count, "The number of matches found:"); + await browser.find.highlightResults(); + + await browser.find.removeHighlighting(); + + data = await browser.find.find("banana", { includeRectData: true }); + await browser.find.highlightResults({ rangeIndex: 5 }); + + browser.test.sendMessage("test:find:WebExtensionFinished", data.rectData); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["find", "tabs"], + }, + background, + }); + + await extension.startup(); + let rectData = await extension.awaitMessage("test:find:WebExtensionFinished"); + let { top, left } = rectData[5].rectsAndTexts.rectList[0]; + await extension.unload(); + + let subFrameBrowsingContext = + gBrowser.selectedBrowser.browsingContext.children[0].children[1]; + let result = await SpecialPowers.spawn( + subFrameBrowsingContext, + [], + frameScript + ); + + info("Test that text was highlighted properly."); + is( + result.text, + "bananA", + `The text that was highlighted: - Expected: bananA, Actual: ${result.text}` + ); + + info( + "Test that rectangle data returned from the search matches the highlighted result." + ); + is( + result.rect.top, + top, + `rect.top: - Expected: ${result.rect.top}, Actual: ${top}` + ); + is( + result.rect.left, + left, + `rect.left: - Expected: ${result.rect.left}, Actual: ${left}` + ); +}); + +add_task(async function testRemoveHighlighting() { + async function background() { + function awaitLoad(tabId, url) { + return new Promise(resolve => { + browser.tabs.onUpdated.addListener(function listener( + tabId_, + changed, + tab + ) { + if ( + tabId == tabId_ && + changed.status == "complete" && + tab.url == url + ) { + browser.tabs.onUpdated.removeListener(listener); + resolve(); + } + }); + }); + } + + let url = + "http://example.com/browser/browser/components/extensions/test/browser/file_find_frames.html"; + let tab = await browser.tabs.update({ url }); + await awaitLoad(tab.id, url); + + let data = await browser.find.find("banana", { includeRangeData: true }); + + browser.test.log("Test that `data.count` is the expected value."); + browser.test.assertEq( + 6, + data.count, + "The value returned from `data.count`" + ); + + await browser.find.highlightResults({ rangeIndex: 5 }); + + browser.find.removeHighlighting(); + + browser.test.sendMessage("test:find:WebExtensionFinished"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["find", "tabs"], + }, + background, + }); + + await extension.startup(); + await extension.awaitMessage("test:find:WebExtensionFinished"); + await extension.unload(); + + let subFrameBrowsingContext = + gBrowser.selectedBrowser.browsingContext.children[0].children[1]; + let result = await SpecialPowers.spawn( + subFrameBrowsingContext, + [], + frameScript + ); + + info("Test that highlight was cleared properly."); + is( + result.text, + "", + `The text that was highlighted: - Expected: '', Actual: ${result.text}` + ); +}); + +add_task(async function testAboutFind() { + async function background() { + await browser.test.assertRejects( + browser.find.find("banana"), + /Unable to search:/, + "Should not be able to search about tabs" + ); + + browser.test.sendMessage("done"); + } + + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, "about:home"); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["find", "tabs"], + }, + background, + }); + + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function testIncognitoFind() { + async function background() { + await browser.test.assertRejects( + browser.find.find("banana"), + /Unable to search:/, + "Should not be able to search private window" + ); + await browser.test.assertRejects( + browser.find.highlightResults(), + /Unable to search:/, + "Should not be able to highlight in private window" + ); + await browser.test.assertRejects( + browser.find.removeHighlighting(), + /Invalid tab ID:/, + "Should not be able to remove highlight in private window" + ); + + browser.test.sendMessage("done"); + } + + let privateWin = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + BrowserTestUtils.startLoadingURIString( + privateWin.gBrowser.selectedBrowser, + "http://example.com" + ); + await BrowserTestUtils.browserLoaded(privateWin.gBrowser.selectedBrowser); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["find", "tabs"], + }, + background, + }); + + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); + await BrowserTestUtils.closeWindow(privateWin); +}); + +add_task(async function testIncognitoFindAllowed() { + // We're only testing we can make the calls in a private window, + // testFind above tests full functionality. + async function background() { + await browser.find.find("banana"); + await browser.find.highlightResults({ rangeIndex: 0 }); + await browser.find.removeHighlighting(); + + browser.test.sendMessage("done"); + } + + let url = + "http://example.com/browser/browser/components/extensions/test/browser/file_find_frames.html"; + let privateWin = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + BrowserTestUtils.startLoadingURIString( + privateWin.gBrowser.selectedBrowser, + url + ); + await BrowserTestUtils.browserLoaded(privateWin.gBrowser.selectedBrowser); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["find", "tabs"], + }, + background, + incognitoOverride: "spanning", + }); + + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); + await BrowserTestUtils.closeWindow(privateWin); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_getViews.js b/browser/components/extensions/test/browser/browser_ext_getViews.js new file mode 100644 index 0000000000..1af190e753 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_getViews.js @@ -0,0 +1,439 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +function genericChecker() { + let kind = window.location.search.slice(1) || "background"; + window.kind = kind; + + let bcGroupId = SpecialPowers.wrap(window).browsingContext.group.id; + + browser.test.onMessage.addListener((msg, ...args) => { + if (msg == kind + "-check-views") { + let counts = { + background: 0, + tab: 0, + popup: 0, + kind: 0, + sidebar: 0, + }; + if (kind !== "background") { + counts.kind = browser.extension.getViews({ type: kind }).length; + } + let views = browser.extension.getViews(); + let background; + for (let i = 0; i < views.length; i++) { + let view = views[i]; + browser.test.assertTrue(view.kind in counts, "view type is valid"); + counts[view.kind]++; + if (view.kind == "background") { + browser.test.assertTrue( + view === browser.extension.getBackgroundPage(), + "background page is correct" + ); + background = view; + } + + browser.test.assertEq( + bcGroupId, + SpecialPowers.wrap(view).browsingContext.group.id, + "browsing context group is correct" + ); + } + if (background) { + browser.runtime.getBackgroundPage().then(view => { + browser.test.assertEq( + background, + view, + "runtime.getBackgroundPage() is correct" + ); + browser.test.sendMessage("counts", counts); + }); + } else { + browser.test.sendMessage("counts", counts); + } + } else if (msg == kind + "-getViews-with-filter") { + let filter = args[0]; + let count = browser.extension.getViews(filter).length; + browser.test.sendMessage("getViews-count", count); + } else if (msg == kind + "-open-tab") { + let url = browser.runtime.getURL("page.html?tab"); + browser.tabs + .create({ windowId: args[0], url }) + .then(tab => browser.test.sendMessage("opened-tab", tab.id)); + } else if (msg == kind + "-close-tab") { + browser.tabs.query( + { + windowId: args[0], + }, + tabs => { + let tab = tabs.find(tab => tab.url.includes("page.html?tab")); + browser.tabs.remove(tab.id, () => { + browser.test.sendMessage("closed"); + }); + } + ); + } + }); + browser.test.sendMessage(kind + "-ready"); +} + +async function promiseBrowserContentUnloaded(browser) { + // Wait until the content has unloaded before resuming the test, to avoid + // calling extension.getViews too early (and having intermittent failures). + const MSG_WINDOW_DESTROYED = "Test:BrowserContentDestroyed"; + let unloadPromise = new Promise(resolve => { + Services.ppmm.addMessageListener(MSG_WINDOW_DESTROYED, function listener() { + Services.ppmm.removeMessageListener(MSG_WINDOW_DESTROYED, listener); + resolve(); + }); + }); + + await ContentTask.spawn( + browser, + MSG_WINDOW_DESTROYED, + MSG_WINDOW_DESTROYED => { + let innerWindowId = this.content.windowGlobalChild.innerWindowId; + let observer = subject => { + if ( + innerWindowId === subject.QueryInterface(Ci.nsISupportsPRUint64).data + ) { + Services.obs.removeObserver(observer, "inner-window-destroyed"); + + // Use process message manager to ensure that the message is delivered + // even after the <browser>'s message manager is disconnected. + Services.cpmm.sendAsyncMessage(MSG_WINDOW_DESTROYED); + } + }; + // Observe inner-window-destroyed, like ExtensionPageChild, to ensure that + // the ExtensionPageContextChild instance has been unloaded when we resolve + // the unloadPromise. + Services.obs.addObserver(observer, "inner-window-destroyed"); + } + ); + + // Return an object so that callers can use "await". + return { unloadPromise }; +} + +add_task(async function () { + let win1 = await BrowserTestUtils.openNewBrowserWindow(); + let win2 = await BrowserTestUtils.openNewBrowserWindow(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", // To automatically show sidebar on load. + manifest: { + permissions: ["tabs"], + + browser_action: { + default_popup: "page.html?popup", + default_area: "navbar", + }, + + sidebar_action: { + default_panel: "page.html?sidebar", + }, + }, + + files: { + "page.html": ` + <!DOCTYPE html> + <html> + <head><meta charset="utf-8"></head> + <body> + <script src="page.js"></script> + </body></html> + `, + + "page.js": genericChecker, + }, + + background: genericChecker, + }); + + await Promise.all([ + extension.startup(), + extension.awaitMessage("background-ready"), + ]); + + await extension.awaitMessage("sidebar-ready"); + await extension.awaitMessage("sidebar-ready"); + await extension.awaitMessage("sidebar-ready"); + + info("started"); + + const { + Management: { + global: { windowTracker }, + }, + } = ChromeUtils.importESModule("resource://gre/modules/Extension.sys.mjs"); + + let winId1 = windowTracker.getId(win1); + let winId2 = windowTracker.getId(win2); + + async function openTab(winId) { + extension.sendMessage("background-open-tab", winId); + await extension.awaitMessage("tab-ready"); + return extension.awaitMessage("opened-tab"); + } + + async function checkViews(kind, tabCount, popupCount, kindCount) { + extension.sendMessage(kind + "-check-views"); + let counts = await extension.awaitMessage("counts"); + if (kind === "sidebar") { + // We have 3 sidebars thaat will answer. + await extension.awaitMessage("counts"); + await extension.awaitMessage("counts"); + } + is(counts.background, 1, "background count correct"); + is(counts.tab, tabCount, "tab count correct"); + is(counts.popup, popupCount, "popup count correct"); + is(counts.kind, kindCount, "count for type correct"); + is(counts.sidebar, 3, "sidebar count is constant"); + } + + async function checkViewsWithFilter(filter, expectedCount) { + extension.sendMessage("background-getViews-with-filter", filter); + let count = await extension.awaitMessage("getViews-count"); + is(count, expectedCount, `count for ${JSON.stringify(filter)} correct`); + } + + await checkViews("background", 0, 0, 0); + await checkViews("sidebar", 0, 0, 3); + await checkViewsWithFilter({ windowId: -1 }, 1); + await checkViewsWithFilter({ windowId: 0 }, 0); + await checkViewsWithFilter({ tabId: -1 }, 4); + await checkViewsWithFilter({ tabId: 0 }, 0); + + let tabId1 = await openTab(winId1); + + await checkViews("background", 1, 0, 0); + await checkViews("sidebar", 1, 0, 3); + await checkViews("tab", 1, 0, 1); + await checkViewsWithFilter({ windowId: winId1 }, 2); + await checkViewsWithFilter({ tabId: tabId1 }, 1); + + let tabId2 = await openTab(winId2); + + await checkViews("background", 2, 0, 0); + await checkViews("sidebar", 2, 0, 3); + await checkViewsWithFilter({ windowId: winId2 }, 2); + await checkViewsWithFilter({ tabId: tabId2 }, 1); + + async function triggerPopup(win, callback) { + // Window needs focus to open popups. + await focusWindow(win); + await clickBrowserAction(extension, win); + let browser = await awaitExtensionPanel(extension, win); + + await extension.awaitMessage("popup-ready"); + + await callback(); + + let { unloadPromise } = await promiseBrowserContentUnloaded(browser); + closeBrowserAction(extension, win); + await unloadPromise; + } + + await triggerPopup(win1, async function () { + await checkViews("background", 2, 1, 0); + await checkViews("sidebar", 2, 1, 3); + await checkViews("popup", 2, 1, 1); + await checkViewsWithFilter({ windowId: winId1 }, 3); + await checkViewsWithFilter({ type: "popup", tabId: -1 }, 1); + }); + + await triggerPopup(win2, async function () { + await checkViews("background", 2, 1, 0); + await checkViews("sidebar", 2, 1, 3); + await checkViews("popup", 2, 1, 1); + await checkViewsWithFilter({ windowId: winId2 }, 3); + await checkViewsWithFilter({ type: "popup", tabId: -1 }, 1); + }); + + info("checking counts after popups"); + + await checkViews("background", 2, 0, 0); + await checkViews("sidebar", 2, 0, 3); + await checkViewsWithFilter({ windowId: winId1 }, 2); + await checkViewsWithFilter({ tabId: -1 }, 4); + + info("closing one tab"); + + let { unloadPromise } = await promiseBrowserContentUnloaded( + win1.gBrowser.selectedBrowser + ); + extension.sendMessage("background-close-tab", winId1); + await extension.awaitMessage("closed"); + await unloadPromise; + + info("one tab closed, one remains"); + + await checkViews("background", 1, 0, 0); + await checkViews("sidebar", 1, 0, 3); + + info("opening win1 popup"); + + await triggerPopup(win1, async function () { + await checkViews("background", 1, 1, 0); + await checkViews("sidebar", 1, 1, 3); + await checkViews("tab", 1, 1, 1); + await checkViews("popup", 1, 1, 1); + }); + + info("opening win2 popup"); + + await triggerPopup(win2, async function () { + await checkViews("background", 1, 1, 0); + await checkViews("sidebar", 1, 1, 3); + await checkViews("tab", 1, 1, 1); + await checkViews("popup", 1, 1, 1); + }); + + await checkViews("sidebar", 1, 0, 3); + + await extension.unload(); + + await BrowserTestUtils.closeWindow(win1); + await BrowserTestUtils.closeWindow(win2); +}); + +add_task(async function test_getViews_excludes_blocked_parsing_documents() { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_action: { + default_popup: "popup.html", + default_area: "navbar", + }, + }, + files: { + "popup.html": `<!DOCTYPE html> + <script src="popup.js"> + </script> + <h1>ExtensionPopup</h1> + `, + "popup.js": function () { + browser.test.sendMessage( + "browserActionPopup:loaded", + window.location.href + ); + }, + }, + background() { + browser.test.onMessage.addListener(msg => { + browser.test.assertEq("getViews", msg, "Got the expected test message"); + const views = browser.extension + .getViews() + .map(win => win.location?.href); + + browser.test.sendMessage("getViews:done", views); + }); + browser.test.sendMessage("bgpage:loaded", window.location.href); + }, + }); + + await extension.startup(); + const bgpageURL = await extension.awaitMessage("bgpage:loaded"); + extension.sendMessage("getViews"); + Assert.deepEqual( + await extension.awaitMessage("getViews:done"), + [bgpageURL], + "Expect only the background page to be initially listed in getViews" + ); + + const { + Management: { + global: { browserActionFor }, + }, + } = ChromeUtils.importESModule("resource://gre/modules/Extension.sys.mjs"); + + let ext = WebExtensionPolicy.getByID(extension.id)?.extension; + let browserAction = browserActionFor(ext); + + // Ensure the mouse is not initially hovering the browserAction widget. + EventUtils.synthesizeMouseAtCenter( + window.gURLBar.textbox, + { type: "mouseover" }, + window + ); + + let widget = await TestUtils.waitForCondition( + () => getBrowserActionWidget(extension).forWindow(window), + "Wait for browserAction widget" + ); + + await TestUtils.waitForCondition( + () => !browserAction.pendingPopup, + "Wait for no pending preloaded popup" + ); + + await TestUtils.waitForCondition(async () => { + // Trigger preload browserAction popup (by directly dispatching a MouseEvent + // to prevent intermittent failures that where often triggered in macos + // PGO builds when this was using EventUtils.synthesizeMouseAtCenter). + let mouseOverEvent = new MouseEvent("mouseover"); + widget.node.firstElementChild.dispatchEvent(mouseOverEvent); + + await TestUtils.waitForCondition( + () => browserAction.pendingPopup?.browser, + "Wait for pending preloaded popup browser" + ); + + return SpecialPowers.spawn( + browserAction.pendingPopup.browser, + [], + async () => { + const policy = this.content.WebExtensionPolicy.getByHostname( + this.content.location.hostname + ); + return policy?.weakExtension + ?.get() + ?.blockedParsingDocuments.has(this.content.document); + } + ).catch(err => { + // Tolerate errors triggered by SpecialPowers.spawn + // being aborted before we got a result back. + if (err.name === "AbortError") { + return false; + } + throw err; + }); + }, "Wait for preload browserAction document to be blocked on parsing"); + + extension.sendMessage("getViews"); + Assert.deepEqual( + await extension.awaitMessage("getViews:done"), + [bgpageURL], + "Expect preloaded browserAction popup to not be listed in getViews" + ); + + // Test browserAction popup is listed in getViews once document parser is unblocked. + EventUtils.synthesizeMouseAtCenter( + widget.node, + { type: "mousedown", button: 0 }, + window + ); + EventUtils.synthesizeMouseAtCenter( + widget.node, + { type: "mouseup", button: 0 }, + window + ); + + const popupURL = await extension.awaitMessage("browserActionPopup:loaded"); + + extension.sendMessage("getViews"); + Assert.deepEqual( + (await extension.awaitMessage("getViews:done")).sort(), + [bgpageURL, popupURL].sort(), + "Expect loaded browserAction popup to be listed in getViews" + ); + + // Ensure the mouse is not hovering the browserAction widget anymore when exiting the test case. + EventUtils.synthesizeMouseAtCenter( + window.gURLBar.textbox, + { type: "mouseover", button: 0 }, + window + ); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_history_redirect.js b/browser/components/extensions/test/browser/browser_ext_history_redirect.js new file mode 100644 index 0000000000..bbce498887 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_history_redirect.js @@ -0,0 +1,72 @@ +"use strict"; + +const BASE = + "http://mochi.test:8888/browser/browser/components/extensions/test/browser"; +const REDIRECT_URL = BASE + "/redirection.sjs"; +const REDIRECTED_URL = BASE + "/dummy_page.html"; + +add_task(async function history_redirect() { + function background() { + browser.test.onMessage.addListener(async (msg, url) => { + switch (msg) { + case "delete-all": { + let results = await browser.history.deleteAll(); + browser.test.sendMessage("delete-all-result", results); + break; + } + case "search": { + let results = await browser.history.search({ + text: url, + startTime: new Date(0), + }); + browser.test.sendMessage("search-result", results); + break; + } + case "get-visits": { + let results = await browser.history.getVisits({ url }); + browser.test.sendMessage("get-visits-result", results); + break; + } + } + }); + + browser.test.sendMessage("ready"); + } + + let extensionData = { + manifest: { + permissions: ["history"], + }, + background, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + + await Promise.all([extension.startup(), extension.awaitMessage("ready")]); + info("extension loaded"); + + extension.sendMessage("delete-all"); + await extension.awaitMessage("delete-all-result"); + + await BrowserTestUtils.withNewTab( + { gBrowser, url: REDIRECT_URL }, + async browser => { + is( + browser.currentURI.spec, + REDIRECTED_URL, + "redirected to the expected location" + ); + + extension.sendMessage("search", REDIRECT_URL); + let results = await extension.awaitMessage("search-result"); + is(results.length, 1, "search returned expected length of results"); + + extension.sendMessage("get-visits", REDIRECT_URL); + let visits = await extension.awaitMessage("get-visits-result"); + is(visits.length, 1, "getVisits returned expected length of visits"); + } + ); + + await extension.unload(); + info("extension unloaded"); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_identity_indication.js b/browser/components/extensions/test/browser/browser_ext_identity_indication.js new file mode 100644 index 0000000000..cae90fd2d0 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_identity_indication.js @@ -0,0 +1,141 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +function confirmDefaults() { + if (gURLBar.searchButton) { + is( + getComputedStyle(document.getElementById("identity-box")).display, + "none", + "Identity box should be hidden" + ); + } else { + is( + getComputedStyle(document.getElementById("identity-icon")).listStyleImage, + 'url("chrome://global/skin/icons/search-glass.svg")', + "Identity icon should be the search icon" + ); + } + + let label = document.getElementById("identity-icon-label"); + ok( + BrowserTestUtils.isHidden(label), + "No label should be used before the extension is started" + ); +} + +async function waitForIndentityBoxMutation({ expectExtensionIcon }) { + const el = document.getElementById("identity-box"); + await BrowserTestUtils.waitForMutationCondition( + el, + { + attributeFilter: ["class"], + }, + () => el.classList.contains("extensionPage") == expectExtensionIcon + ); +} + +function confirmExtensionPage() { + let identityIconEl = document.getElementById("identity-icon"); + + is( + getComputedStyle(identityIconEl).listStyleImage, + 'url("chrome://mozapps/skin/extensions/extension.svg")', + "Identity icon should be the default extension icon" + ); + + is( + identityIconEl.tooltipText, + "Loaded by extension: Test Extension", + "The correct tooltip should be used" + ); + + let label = document.getElementById("identity-icon-label"); + is( + label.value, + "Extension (Test Extension)", + "The correct label should be used" + ); + ok(BrowserTestUtils.isVisible(label), "No label should be visible"); +} + +add_task(async function testIdentityIndication() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.test.sendMessage("url", browser.runtime.getURL("icon.png")); + }, + manifest: { + name: "Test Extension", + }, + files: { + "icon.png": "", + }, + }); + + await extension.startup(); + + confirmDefaults(); + + let url = await extension.awaitMessage("url"); + + const promiseIdentityBoxExtension = waitForIndentityBoxMutation({ + expectExtensionIcon: true, + }); + await BrowserTestUtils.withNewTab({ gBrowser, url }, async function () { + await promiseIdentityBoxExtension; + confirmExtensionPage(); + }); + + const promiseIdentityBoxDefault = waitForIndentityBoxMutation({ + expectExtensionIcon: false, + }); + await extension.unload(); + await promiseIdentityBoxDefault; + + confirmDefaults(); +}); + +add_task(async function testIdentityIndicationNewTab() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.test.sendMessage("url", browser.runtime.getURL("newtab.html")); + }, + manifest: { + name: "Test Extension", + browser_specific_settings: { + gecko: { + id: "@newtab", + }, + }, + chrome_url_overrides: { + newtab: "newtab.html", + }, + }, + files: { + "newtab.html": "<h1>New tab!</h1>", + }, + useAddonManager: "temporary", + }); + + await extension.startup(); + + confirmDefaults(); + + let url = await extension.awaitMessage("url"); + const promiseIdentityBoxExtension = waitForIndentityBoxMutation({ + expectExtensionIcon: true, + }); + await BrowserTestUtils.withNewTab({ gBrowser, url }, async function () { + await promiseIdentityBoxExtension; + confirmExtensionPage(); + is(gURLBar.value, "", "The URL bar is blank"); + }); + + const promiseIdentityBoxDefault = waitForIndentityBoxMutation({ + expectExtensionIcon: false, + }); + await extension.unload(); + await promiseIdentityBoxDefault; + + confirmDefaults(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_incognito_popup.js b/browser/components/extensions/test/browser/browser_ext_incognito_popup.js new file mode 100644 index 0000000000..cbe6e68cdc --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_incognito_popup.js @@ -0,0 +1,209 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function testIncognitoPopup() { + let extension = ExtensionTestUtils.loadExtension({ + incognitoOverride: "spanning", + manifest: { + permissions: ["tabs"], + browser_action: { + default_popup: "popup.html", + default_area: "navbar", + }, + page_action: { + default_popup: "popup.html", + }, + }, + + background: async function () { + let resolveMessage; + browser.runtime.onMessage.addListener(msg => { + if (resolveMessage && msg.message == "popup-details") { + resolveMessage(msg); + } + }); + + const awaitPopup = windowId => { + return new Promise(resolve => { + resolveMessage = resolve; + }).then(msg => { + browser.test.assertEq( + windowId, + msg.windowId, + "Got popup message from correct window" + ); + return msg; + }); + }; + + const testWindow = async window => { + const [tab] = await browser.tabs.query({ + active: true, + windowId: window.id, + }); + + await browser.pageAction.show(tab.id); + browser.test.sendMessage("click-pageAction"); + + let msg = await awaitPopup(window.id); + browser.test.assertEq( + window.incognito, + msg.incognito, + "Correct incognito status in pageAction popup" + ); + + browser.test.sendMessage("click-browserAction"); + + msg = await awaitPopup(window.id); + browser.test.assertEq( + window.incognito, + msg.incognito, + "Correct incognito status in browserAction popup" + ); + }; + + const testNonPrivateWindow = async () => { + const window = await browser.windows.getCurrent(); + await testWindow(window); + }; + + const testPrivateWindow = async () => { + const URL = "https://example.com/incognito"; + const windowReady = new Promise(resolve => { + browser.tabs.onUpdated.addListener(function listener( + tabId, + changed, + tab + ) { + if (changed.status == "complete" && tab.url == URL) { + browser.tabs.onUpdated.removeListener(listener); + resolve(); + } + }); + }); + + const window = await browser.windows.create({ + incognito: true, + url: URL, + }); + await windowReady; + + await testWindow(window); + }; + + browser.test.onMessage.addListener(async msg => { + switch (msg) { + case "test-nonprivate-window": + await testNonPrivateWindow(); + break; + case "test-private-window": + await testPrivateWindow(); + break; + default: + browser.test.fail( + `Unexpected test message: ${JSON.stringify(msg)}` + ); + } + + browser.test.sendMessage(`${msg}:done`); + }); + + browser.test.sendMessage("bgscript:ready"); + }, + + files: { + "popup.html": + '<html><head><meta charset="utf-8"><script src="popup.js"></script></head></html>', + + "popup.js": async function () { + let win = await browser.windows.getCurrent(); + browser.runtime.sendMessage({ + message: "popup-details", + windowId: win.id, + incognito: browser.extension.inIncognitoContext, + }); + window.close(); + }, + }, + }); + + await extension.startup(); + await extension.awaitMessage("bgscript:ready"); + + info("Run test on non private window"); + extension.sendMessage("test-nonprivate-window"); + await extension.awaitMessage("click-pageAction"); + const win = Services.wm.getMostRecentWindow("navigator:browser"); + ok(!PrivateBrowsingUtils.isWindowPrivate(win), "Got a nonprivate window"); + await clickPageAction(extension, win); + + await extension.awaitMessage("click-browserAction"); + await clickBrowserAction(extension, win); + + await extension.awaitMessage("test-nonprivate-window:done"); + await closeBrowserAction(extension, win); + await closePageAction(extension, win); + + info("Run test on private window"); + extension.sendMessage("test-private-window"); + await extension.awaitMessage("click-pageAction"); + const privateWin = Services.wm.getMostRecentWindow("navigator:browser"); + ok(PrivateBrowsingUtils.isWindowPrivate(privateWin), "Got a private window"); + await clickPageAction(extension, privateWin); + + await extension.awaitMessage("click-browserAction"); + await clickBrowserAction(extension, privateWin); + + await extension.awaitMessage("test-private-window:done"); + // Wait for the private window chrome document to be flushed before + // closing the browserACtion, pageAction and the entire private window, + // to prevent intermittent failures. + await privateWin.promiseDocumentFlushed(() => {}); + + await closeBrowserAction(extension, privateWin); + await closePageAction(extension, privateWin); + await BrowserTestUtils.closeWindow(privateWin); + + await extension.unload(); +}); + +add_task(async function test_pageAction_incognito_not_allowed() { + const URL = "https://example.com/"; + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["*://example.com/*"], + page_action: { + show_matches: ["<all_urls>"], + pinned: true, + }, + }, + }); + + await extension.startup(); + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + URL, + true, + true + ); + let privateWindow = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + await BrowserTestUtils.openNewForegroundTab( + privateWindow.gBrowser, + URL, + true, + true + ); + + let elem = await getPageActionButton(extension, window); + ok(elem, "pageAction button state correct in non-PB"); + + elem = await getPageActionButton(extension, privateWindow); + ok(!elem, "pageAction button state correct in private window"); + + BrowserTestUtils.removeTab(tab); + await BrowserTestUtils.closeWindow(privateWindow); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_incognito_views.js b/browser/components/extensions/test/browser/browser_ext_incognito_views.js new file mode 100644 index 0000000000..8a7c3219b3 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_incognito_views.js @@ -0,0 +1,269 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_setup(async () => { + await SpecialPowers.pushPrefEnv({ + set: [["extensions.openPopupWithoutUserGesture.enabled", true]], + }); +}); + +add_task(async function testIncognitoViews() { + // Make sure the mouse isn't hovering over the browserAction widget. + EventUtils.synthesizeMouseAtCenter( + gURLBar.textbox, + { type: "mouseover" }, + window + ); + + let extension = ExtensionTestUtils.loadExtension({ + incognitoOverride: "spanning", + manifest: { + permissions: ["tabs"], + description: JSON.stringify({ + headless: Services.env.get("MOZ_HEADLESS"), + debug: AppConstants.DEBUG, + }), + browser_action: { + default_popup: "popup.html", + default_area: "navbar", + }, + }, + + background: async function () { + window.isBackgroundPage = true; + const { headless, debug } = JSON.parse( + browser.runtime.getManifest().description + ); + + class ConnectedPopup { + #msgPromise; + #disconnectPromise; + + static promiseNewPopup() { + return new Promise(resolvePort => { + browser.runtime.onConnect.addListener(function onConnect(port) { + browser.runtime.onConnect.removeListener(onConnect); + browser.test.assertEq("from-popup", port.name, "Port from popup"); + resolvePort(new ConnectedPopup(port)); + }); + }); + } + + constructor(port) { + this.port = port; + this.#msgPromise = new Promise(resolveMessage => { + // popup.js sends one message with the popup's metadata. + port.onMessage.addListener(resolveMessage); + }); + this.#disconnectPromise = new Promise(resolveDisconnect => { + port.onDisconnect.addListener(resolveDisconnect); + }); + } + + async getPromisedMessage() { + browser.test.log("Waiting for popup to send information"); + let msg = await this.#msgPromise; + browser.test.assertEq("popup-details", msg.message, "Got port msg"); + return msg; + } + + async promisePopupClosed(desc) { + browser.test.log(`Waiting for popup to be closed (${desc})`); + // There is currently no great way for extension to detect a closed + // popup. Extensions can observe the port.onDisconnect event for now. + await this.#disconnectPromise; + browser.test.log(`Popup was closed (${desc})`); + } + + async closePopup(desc) { + browser.test.log(`Closing popup (${desc})`); + this.port.postMessage("close_popup"); + return this.promisePopupClosed(desc); + } + } + + let testPopupForWindow = async window => { + let popupConnectionPromise = ConnectedPopup.promiseNewPopup(); + + await browser.browserAction.openPopup({ + windowId: window.id, + }); + + let connectedPopup = await popupConnectionPromise; + let msg = await connectedPopup.getPromisedMessage(); + browser.test.assertEq( + window.id, + msg.windowId, + "Got popup message from correct window" + ); + browser.test.assertEq( + window.incognito, + msg.incognito, + "Correct incognito status in browserAction popup" + ); + + return connectedPopup; + }; + + async function createPrivateWindow() { + const URL = "https://example.com/?dummy-incognito-window"; + let windowReady = new Promise(resolve => { + browser.tabs.onUpdated.addListener(function l(tabId, changed, tab) { + if (changed.status == "complete" && tab.url == URL) { + browser.tabs.onUpdated.removeListener(l); + resolve(); + } + }); + }); + let window = await browser.windows.create({ + incognito: true, + url: URL, + }); + await windowReady; + return window; + } + + function getNonPrivateViewCount() { + // The background context is in non-private browsing mode, so getViews() + // only returns the views that are not in private browsing mode. + return browser.extension.getViews({ type: "popup" }).length; + } + + try { + let nonPrivatePopup; + { + let window = await browser.windows.getCurrent(); + + nonPrivatePopup = await testPopupForWindow(window); + + browser.test.assertEq(1, getNonPrivateViewCount(), "popup is open"); + // ^ The popup will close when a new window is opened below. + if (headless) { + // ... except when --headless is used. For some reason, the popup + // does not close when another window is opened. Close manually. + await nonPrivatePopup.closePopup("Work-around for --headless bug"); + } + } + + { + let window = await createPrivateWindow(); + + let privatePopup = await testPopupForWindow(window); + + await nonPrivatePopup.promisePopupClosed("First popup closed by now"); + + browser.test.assertEq( + 0, + getNonPrivateViewCount(), + "First popup should have been closed when a new window was opened" + ); + + // TODO bug 1809000: On debug builds, a memory leak is reported when + // the popup is closed as part of closing a window. As a work-around, + // we explicitly close the popup here. + // TODO: Remove when bug 1809000 is fixed. + if (debug) { + await privatePopup.closePopup("Work-around for bug 1809000"); + } + + await browser.windows.remove(window.id); + // ^ This also closes the popup panel associated with the window. If + // it somehow does not close properly, errors may be reported, e.g. + // leakcheck failures in debug mode (like bug 1800100). + + // This check is not strictly necessary, but we're doing this to + // confirm that the private popup has indeed been closed. + await privatePopup.promisePopupClosed("Window closed = popup gone"); + } + + browser.test.notifyPass("incognito-views"); + } catch (error) { + browser.test.fail(`Error: ${error} :: ${error.stack}`); + browser.test.notifyFail("incognito-views"); + } + }, + + files: { + "popup.html": + '<html><head><meta charset="utf-8"><script src="popup.js"></script></head></html>', + + "popup.js": async function () { + let views = browser.extension.getViews(); + + if (browser.extension.inIncognitoContext) { + let bgPage = browser.extension.getBackgroundPage(); + browser.test.assertEq( + null, + bgPage, + "Should not be able to access background page in incognito context" + ); + + bgPage = await browser.runtime.getBackgroundPage(); + browser.test.assertEq( + null, + bgPage, + "Should not be able to access background page in incognito context" + ); + + browser.test.assertEq( + 1, + views.length, + "Should only see one view in incognito popup" + ); + browser.test.assertEq( + window, + views[0], + "This window should be the only view" + ); + } else { + let bgPage = browser.extension.getBackgroundPage(); + browser.test.assertEq( + true, + bgPage.isBackgroundPage, + "Should be able to access background page in non-incognito context" + ); + + bgPage = await browser.runtime.getBackgroundPage(); + browser.test.assertEq( + true, + bgPage.isBackgroundPage, + "Should be able to access background page in non-incognito context" + ); + + browser.test.assertEq( + 2, + views.length, + "Should only two views in non-incognito popup" + ); + browser.test.assertEq( + bgPage, + views[0], + "The background page should be the first view" + ); + browser.test.assertEq( + window, + views[1], + "This window should be the second view" + ); + } + + let win = await browser.windows.getCurrent(); + let port = browser.runtime.connect({ name: "from-popup" }); + port.onMessage.addListener(msg => { + browser.test.assertEq("close_popup", msg, "Close popup msg"); + window.close(); + }); + port.postMessage({ + message: "popup-details", + windowId: win.id, + incognito: browser.extension.inIncognitoContext, + }); + }, + }, + }); + + await extension.startup(); + await extension.awaitFinish("incognito-views"); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_lastError.js b/browser/components/extensions/test/browser/browser_ext_lastError.js new file mode 100644 index 0000000000..f7015f131b --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_lastError.js @@ -0,0 +1,61 @@ +"use strict"; + +async function sendMessage(options) { + function background(options) { + browser.runtime.sendMessage(result => { + browser.test.assertEq(undefined, result, "Argument value"); + if (options.checkLastError) { + browser.test.assertEq( + "runtime.sendMessage's message argument is missing", + browser.runtime.lastError?.message, + "lastError value" + ); + } + browser.test.sendMessage("done"); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background: `(${background})(${JSON.stringify(options)})`, + }); + + await extension.startup(); + + await extension.awaitMessage("done"); + + await extension.unload(); +} + +add_task(async function testLastError() { + // Not necessary in browser-chrome tests, but monitorConsole gripes + // if we don't call it. + SimpleTest.waitForExplicitFinish(); + + // Check that we have no unexpected console messages when lastError is + // checked. + let waitForConsole = new Promise(resolve => { + SimpleTest.monitorConsole(resolve, [ + { message: /message argument is missing/, forbid: true }, + ]); + }); + + await sendMessage({ checkLastError: true }); + + SimpleTest.endMonitorConsole(); + await waitForConsole; + + // Check that we do have a console message when lastError is not checked. + waitForConsole = new Promise(resolve => { + SimpleTest.monitorConsole(resolve, [ + { + message: + /Unchecked lastError value: Error: runtime.sendMessage's message argument is missing/, + }, + ]); + }); + + await sendMessage({}); + + SimpleTest.endMonitorConsole(); + await waitForConsole; +}); diff --git a/browser/components/extensions/test/browser/browser_ext_management.js b/browser/components/extensions/test/browser/browser_ext_management.js new file mode 100644 index 0000000000..e4776583a8 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_management.js @@ -0,0 +1,139 @@ +"use strict"; + +const { AddonTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/AddonTestUtils.sys.mjs" +); + +AddonTestUtils.initMochitest(this); + +const testServer = AddonTestUtils.createHttpServer(); + +add_task(async function test_management_install() { + await SpecialPowers.pushPrefEnv({ + set: [["xpinstall.signatures.required", false]], + }); + + registerCleanupFunction(async () => { + await SpecialPowers.popPrefEnv(); + }); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_action: { + browser_style: false, + default_area: "navbar", + }, + permissions: ["management"], + }, + background() { + let addons; + browser.test.onMessage.addListener((msg, init) => { + addons = init; + browser.test.sendMessage("ready"); + }); + browser.browserAction.onClicked.addListener(async () => { + try { + let { url, hash } = addons.shift(); + browser.test.log( + `Installing XPI from ${url} with hash ${hash || "missing"}` + ); + let { id } = await browser.management.install({ url, hash }); + let { type } = await browser.management.get(id); + browser.test.sendMessage("installed", { id, type }); + } catch (e) { + browser.test.log(`management.install() throws ${e}`); + browser.test.sendMessage("failed", e.message); + } + }); + }, + }); + + const themeXPIFile = AddonTestUtils.createTempWebExtensionFile({ + manifest: { + manifest_version: 2, + name: "Tigers Matter", + version: "1.0", + browser_specific_settings: { + gecko: { + id: "tiger@persona.beard", + }, + }, + theme: { + colors: { + frame: "orange", + }, + }, + }, + }); + + let themeXPIFileHash = await IOUtils.computeHexDigest( + themeXPIFile.path, + "sha256" + ); + + const otherXPIFile = AddonTestUtils.createTempWebExtensionFile({ + manifest: { + manifest_version: 2, + name: "Tigers Don't Matter", + version: "1.0", + browser_specific_settings: { + gecko: { + id: "other@web.extension", + }, + }, + }, + }); + + testServer.registerFile("/install_theme-1.0-fx.xpi", themeXPIFile); + testServer.registerFile("/install_other-1.0-fx.xpi", otherXPIFile); + + const { primaryHost, primaryPort } = testServer.identity; + const baseURL = `http://${primaryHost}:${primaryPort}`; + + let addons = [ + { + url: `${baseURL}/install_theme-1.0-fx.xpi`, + hash: `sha256:${themeXPIFileHash}`, + }, + { + url: `${baseURL}/install_other-1.0-fx.xpi`, + }, + ]; + + await extension.startup(); + extension.sendMessage("addons", addons); + await extension.awaitMessage("ready"); + + // Test installing a static WE theme. + clickBrowserAction(extension); + + let { id, type } = await extension.awaitMessage("installed"); + is(id, "tiger@persona.beard", "Static web extension theme installed"); + is(type, "theme", "Extension type is correct"); + + is( + getToolboxBackgroundColor(), + "rgb(255, 165, 0)", + "Background is the new black" + ); + + let addon = await AddonManager.getAddonByID("tiger@persona.beard"); + + Assert.deepEqual( + addon.installTelemetryInfo, + { + source: "extension", + method: "management-webext-api", + }, + "Got the expected telemetry info on the installed webext theme" + ); + + await addon.uninstall(); + + // Test installing a standard WE. + clickBrowserAction(extension); + let error = await extension.awaitMessage("failed"); + is(error, "Incompatible addon", "Standard web extension rejected"); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_menus.js b/browser/components/extensions/test/browser/browser_ext_menus.js new file mode 100644 index 0000000000..76ac3cf045 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_menus.js @@ -0,0 +1,458 @@ +/* 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 PAGE = + "http://mochi.test:8888/browser/browser/components/extensions/test/browser/context.html"; + +add_task(async function test_permissions() { + function background() { + browser.test.sendMessage("apis", { + menus: typeof browser.menus, + contextMenus: typeof browser.contextMenus, + menusInternal: typeof browser.menusInternal, + }); + } + + const first = ExtensionTestUtils.loadExtension({ + manifest: { permissions: ["menus"] }, + background, + }); + const second = ExtensionTestUtils.loadExtension({ + manifest: { permissions: ["contextMenus"] }, + background, + }); + + await first.startup(); + await second.startup(); + + const apis1 = await first.awaitMessage("apis"); + const apis2 = await second.awaitMessage("apis"); + + is(apis1.menus, "object", "browser.menus available with 'menus' permission"); + is( + apis1.contextMenus, + "undefined", + "browser.contextMenus unavailable with 'menus' permission" + ); + is( + apis1.menusInternal, + "undefined", + "browser.menusInternal is never available" + ); + + is( + apis2.menus, + "undefined", + "browser.menus unavailable with 'contextMenus' permission" + ); + is( + apis2.contextMenus, + "object", + "browser.contextMenus unavailable with 'contextMenus' permission" + ); + is( + apis2.menusInternal, + "undefined", + "browser.menusInternal is never available" + ); + + await first.unload(); + await second.unload(); +}); + +add_task(async function test_actionContextMenus() { + const manifest = { + page_action: {}, + browser_action: { + default_area: "navbar", + }, + permissions: ["menus"], + }; + + async function background() { + const contexts = ["page_action", "browser_action"]; + + const parentId = browser.menus.create({ contexts, title: "parent" }); + browser.menus.create({ parentId, title: "click A" }); + browser.menus.create({ parentId, title: "click B" }); + + for (let i = 1; i < 9; i++) { + browser.menus.create({ contexts, id: `${i}`, title: `click ${i}` }); + } + + browser.menus.onClicked.addListener((info, tab) => { + browser.test.sendMessage("click", { info, tab }); + }); + + const [tab] = await browser.tabs.query({ active: true }); + await browser.pageAction.show(tab.id); + browser.test.sendMessage("ready", tab.id); + } + + const extension = ExtensionTestUtils.loadExtension({ manifest, background }); + const tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.com/" + ); + + await extension.startup(); + const tabId = await extension.awaitMessage("ready"); + + for (const kind of ["page", "browser"]) { + const menu = await openActionContextMenu(extension, kind); + const [submenu, second, , , , last, separator] = menu.children; + + is(submenu.tagName, "menu", "Correct submenu type"); + is(submenu.label, "parent", "Correct submenu title"); + + const popup = await openSubmenu(submenu); + is(popup, submenu.menupopup, "Correct submenu opened"); + is(popup.children.length, 2, "Correct number of submenu items"); + + let idPrefix = `${makeWidgetId(extension.id)}-menuitem-_`; + + is(second.tagName, "menuitem", "Second menu item type is correct"); + is(second.label, "click 1", "Second menu item title is correct"); + is(second.id, `${idPrefix}1`, "Second menu item id is correct"); + + is(last.tagName, "menu", "Last menu item type is correct"); + is(last.label, "Generated extension", "Last menu item title is correct"); + is( + last.getAttribute("ext-type"), + "top-level-menu", + "Last menu ext-type is correct" + ); + is(separator.tagName, "menuseparator", "Separator after last menu item"); + + // Verify that menu items exceeding ACTION_MENU_TOP_LEVEL_LIMIT are moved into a submenu. + let overflowPopup = await openSubmenu(last); + is( + overflowPopup.children.length, + 4, + "Excess items should be moved into a submenu" + ); + is( + overflowPopup.firstElementChild.id, + `${idPrefix}5`, + "First submenu item ID is correct" + ); + is( + overflowPopup.lastElementChild.id, + `${idPrefix}8`, + "Last submenu item ID is correct" + ); + + await closeActionContextMenu(overflowPopup.firstElementChild, kind); + const { info, tab } = await extension.awaitMessage("click"); + is(info.pageUrl, "http://example.com/", "Click info pageUrl is correct"); + is(tab.id, tabId, "Click event tab ID is correct"); + } + + BrowserTestUtils.removeTab(tab); + await extension.unload(); +}); + +add_task(async function test_bookmarkContextMenu() { + const ext = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["menus", "bookmarks"], + }, + background() { + browser.menus.onShown.addListener(() => { + browser.test.sendMessage("hello"); + }); + browser.menus.create({ title: "blarg", contexts: ["bookmark"] }, () => { + browser.test.sendMessage("ready"); + }); + }, + }); + + await ext.startup(); + await ext.awaitMessage("ready"); + await toggleBookmarksToolbar(true); + + let menu = await openChromeContextMenu( + "placesContext", + "#PlacesToolbarItems .bookmark-item" + ); + let children = Array.from(menu.children); + let item = children[children.length - 1]; + is(item.label, "blarg", "Menu item label is correct"); + await ext.awaitMessage("hello"); // onShown listener fired + + closeChromeContextMenu("placesContext", item); + await ext.unload(); + await toggleBookmarksToolbar(false); +}); + +add_task(async function test_tabContextMenu() { + const first = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["menus"], + }, + async background() { + browser.menus.create({ + id: "alpha-beta-parent", + title: "alpha-beta parent", + contexts: ["tab"], + }); + + browser.menus.create({ parentId: "alpha-beta-parent", title: "alpha" }); + browser.menus.create({ parentId: "alpha-beta-parent", title: "beta" }); + + browser.menus.create({ title: "dummy", contexts: ["page"] }); + + browser.menus.onClicked.addListener((info, tab) => { + browser.test.sendMessage("click", { info, tab }); + }); + + const [tab] = await browser.tabs.query({ active: true }); + browser.test.sendMessage("ready", tab.id); + }, + }); + + const second = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["menus"], + }, + background() { + browser.menus.create({ + title: "invisible", + contexts: ["tab"], + documentUrlPatterns: ["http://does/not/match"], + }); + browser.menus.create( + { + title: "gamma", + contexts: ["tab"], + documentUrlPatterns: ["http://example.com/"], + }, + () => { + browser.test.sendMessage("ready"); + } + ); + }, + }); + + const tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.com/" + ); + await first.startup(); + await second.startup(); + + const tabId = await first.awaitMessage("ready"); + await second.awaitMessage("ready"); + + const menu = await openTabContextMenu(); + const [separator, submenu, gamma] = Array.from(menu.children).slice(-3); + is( + separator.tagName, + "menuseparator", + "Separator before first extension item" + ); + + is(submenu.tagName, "menu", "Correct submenu type"); + is(submenu.label, "alpha-beta parent", "Correct submenu title"); + + isnot( + gamma.label, + "dummy", + "`page` context menu item should not appear here" + ); + + is(gamma.tagName, "menuitem", "Third menu item type is correct"); + is(gamma.label, "gamma", "Third menu item label is correct"); + + const popup = await openSubmenu(submenu); + is(popup, submenu.menupopup, "Correct submenu opened"); + is(popup.children.length, 2, "Correct number of submenu items"); + + const [alpha, beta] = popup.children; + is(alpha.tagName, "menuitem", "First menu item type is correct"); + is(alpha.label, "alpha", "First menu item label is correct"); + is(beta.tagName, "menuitem", "Second menu item type is correct"); + is(beta.label, "beta", "Second menu item label is correct"); + + await closeTabContextMenu(beta); + const click = await first.awaitMessage("click"); + is( + click.info.pageUrl, + "http://example.com/", + "Click info pageUrl is correct" + ); + is(click.tab.id, tabId, "Click event tab ID is correct"); + is(click.info.frameId, undefined, "no frameId on chrome"); + + BrowserTestUtils.removeTab(tab); + await first.unload(); + await second.unload(); +}); + +add_task(async function test_onclick_frameid() { + const manifest = { + permissions: ["menus"], + }; + + function background() { + function onclick(info) { + browser.test.sendMessage("click", info); + } + browser.menus.create( + { contexts: ["frame", "page"], title: "modify", onclick }, + () => { + browser.test.sendMessage("ready"); + } + ); + } + + const extension = ExtensionTestUtils.loadExtension({ manifest, background }); + const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, PAGE); + + await extension.startup(); + await extension.awaitMessage("ready"); + + async function click(menu) { + const items = menu.getElementsByAttribute("label", "modify"); + is(items.length, 1, "found menu item"); + await closeExtensionContextMenu(items[0]); + return extension.awaitMessage("click"); + } + + let info = await click(await openContextMenu("body")); + is(info.frameId, 0, "top level click"); + info = await click(await openContextMenuInFrame()); + isnot(info.frameId, undefined, "frame click, frameId is not undefined"); + isnot(info.frameId, 0, "frame click, frameId probably okay"); + + BrowserTestUtils.removeTab(tab); + await extension.unload(); +}); + +add_task(async function test_multiple_contexts_init() { + const manifest = { + permissions: ["menus"], + }; + + function background() { + browser.menus.create({ id: "parent", title: "parent" }, () => { + browser.tabs.create({ url: "tab.html", active: false }); + }); + } + + const files = { + "tab.html": + "<!DOCTYPE html><meta charset=utf-8><script src=tab.js></script>", + "tab.js": function () { + browser.menus.onClicked.addListener(info => { + browser.test.sendMessage("click", info); + }); + browser.menus.create( + { parentId: "parent", id: "child", title: "child" }, + () => { + browser.test.sendMessage("ready"); + } + ); + }, + }; + + const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, PAGE); + const extension = ExtensionTestUtils.loadExtension({ + manifest, + background, + files, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + + const menu = await openContextMenu(); + const items = menu.getElementsByAttribute("label", "parent"); + + is(items.length, 1, "Found parent menu item"); + is(items[0].tagName, "menu", "And it has children"); + + const popup = await openSubmenu(items[0]); + is(popup.firstElementChild.label, "child", "Correct child menu item"); + await closeExtensionContextMenu(popup.firstElementChild); + + const info = await extension.awaitMessage("click"); + is(info.menuItemId, "child", "onClicked the correct item"); + + BrowserTestUtils.removeTab(tab); + await extension.unload(); +}); + +add_task(async function test_tools_menu() { + const first = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["menus"], + }, + background() { + browser.menus.create({ title: "alpha", contexts: ["tools_menu"] }); + browser.menus.create({ title: "beta", contexts: ["tools_menu"] }, () => { + browser.test.sendMessage("ready"); + }); + }, + }); + + const second = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["menus"], + }, + async background() { + browser.menus.create({ title: "gamma", contexts: ["tools_menu"] }); + browser.menus.onClicked.addListener((info, tab) => { + browser.test.sendMessage("click", { info, tab }); + }); + + const [tab] = await browser.tabs.query({ active: true }); + browser.test.sendMessage("ready", tab.id); + }, + }); + + const tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.com/" + ); + await first.startup(); + await second.startup(); + + await first.awaitMessage("ready"); + const tabId = await second.awaitMessage("ready"); + const menu = await openToolsMenu(); + + const [separator, submenu, gamma] = Array.from(menu.children).slice(-3); + is( + separator.tagName, + "menuseparator", + "Separator before first extension item" + ); + + is(submenu.tagName, "menu", "Correct submenu type"); + is( + submenu.getAttribute("label"), + "Generated extension", + "Correct submenu title" + ); + is(submenu.menupopup.children.length, 2, "Correct number of submenu items"); + + is(gamma.tagName, "menuitem", "Third menu item type is correct"); + is(gamma.getAttribute("label"), "gamma", "Third menu item label is correct"); + + closeToolsMenu(gamma); + + const click = await second.awaitMessage("click"); + is( + click.info.pageUrl, + "http://example.com/", + "Click info pageUrl is correct" + ); + is(click.tab.id, tabId, "Click event tab ID is correct"); + + BrowserTestUtils.removeTab(tab); + await first.unload(); + await second.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_menus_accesskey.js b/browser/components/extensions/test/browser/browser_ext_menus_accesskey.js new file mode 100644 index 0000000000..88c89902ac --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_menus_accesskey.js @@ -0,0 +1,209 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const PAGE = + "http://mochi.test:8888/browser/browser/components/extensions/test/browser/context.html"; + +add_task(async function accesskeys() { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, PAGE); + gBrowser.selectedTab = tab; + + async function background() { + // description is informative. + // title is passed to menus.create. + // label and key are compared with the actual values. + const TESTCASES = [ + { + description: "Amp at start", + title: "&accesskey", + label: "accesskey", + key: "a", + }, + { + description: "amp in between", + title: "A& b", + label: "A b", + key: " ", + }, + { + description: "lonely amp", + title: "&", + label: "", + key: "", + }, + { + description: "amp at end", + title: "End &", + label: "End ", + key: "", + }, + { + description: "escaped amp", + title: "A && B", + label: "A & B", + key: "", + }, + { + description: "amp before escaped amp", + title: "A &T&& before", + label: "A T& before", + key: "T", + }, + { + description: "amp after escaped amp", + title: "A &&&T after", + label: "A &T after", + key: "T", + }, + { + // Only the first amp should be used as the access key. + description: "amp, escaped amp, amp to ignore", + title: "First &1 comes && first &2 serves", + label: "First 1 comes & first 2 serves", + key: "1", + }, + { + description: "created with amp, updated without amp", + title: "temp with &X", // will be updated below. + label: "remove amp", + key: "", + }, + { + description: "created without amp, update with amp", + title: "temp without access key", // will be updated below. + label: "add ampY", + key: "Y", + }, + ]; + + let menuIds = TESTCASES.map(({ title }) => browser.menus.create({ title })); + + // Should clear the access key: + await browser.menus.update(menuIds[menuIds.length - 2], { + title: "remove amp", + }); + + // Should add an access key: + await browser.menus.update(menuIds[menuIds.length - 1], { + title: "add amp&Y", + }); + // Should not clear the access key because title is not set: + await browser.menus.update(menuIds[menuIds.length - 1], { enabled: true }); + + browser.test.sendMessage("testCases", TESTCASES); + } + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["menus"], + }, + background, + }); + + await extension.startup(); + + const TESTCASES = await extension.awaitMessage("testCases"); + let menu = await openExtensionContextMenu(); + let items = menu.getElementsByTagName("menuitem"); + is(items.length, TESTCASES.length, "Expected menu items for page"); + TESTCASES.forEach(({ description, label, key }, i) => { + is(items[i].label, label, `Label for item ${i} (${description})`); + is(items[i].accessKey, key, `Accesskey for item ${i} (${description})`); + }); + + await closeExtensionContextMenu(); + await extension.unload(); + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function accesskeys_selection() { + const PAGE_WITH_AMPS = "data:text/plain;charset=utf-8,PageSelection&Amp"; + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + PAGE_WITH_AMPS + ); + gBrowser.selectedTab = tab; + + async function background() { + const TESTCASES = [ + { + description: "Selection without amp", + title: "percent-s: %s.", + label: "percent-s: PageSelection&Amp.", + key: "", + }, + { + description: "Selection with amp after %s", + title: "percent-s: %s &A.", + label: "percent-s: PageSelection&Amp A.", + key: "A", + }, + { + description: "Selection with amp before %s", + title: "percent-s: &B %s.", + label: "percent-s: B PageSelection&Amp.", + key: "B", + }, + { + description: "Amp-percent", + title: "Amp-percent: &%.", + label: "Amp-percent: %.", + key: "%", + }, + { + // "&%s" should be treated as "%s", and "ignore this" with amps should be ignored. + description: "Selection with amp-percent-s", + title: "Amp-percent-s: &%s.&i&g&n&o&r&e& &t&h&i&s", + label: "Amp-percent-s: PageSelection&Amp.ignore this", + // Chrome uses the first character of the selection as access key. + // Let's not copy that behavior... + key: "", + }, + { + description: "Selection with amp before amp-percent-s", + title: "Amp-percent-s: &_ &%s.", + label: "Amp-percent-s: _ PageSelection&Amp.", + key: "_", + }, + ]; + + let lastMenuId; + for (let { title } of TESTCASES) { + lastMenuId = browser.menus.create({ contexts: ["selection"], title }); + } + // Round-trip to ensure that the menus have been registered. + await browser.menus.update(lastMenuId, {}); + browser.test.sendMessage("testCases", TESTCASES); + } + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["menus"], + }, + background, + }); + + await extension.startup(); + + // Select all + await SpecialPowers.spawn(gBrowser.selectedBrowser, [], function (arg) { + let doc = content.document; + let range = doc.createRange(); + let selection = content.getSelection(); + selection.removeAllRanges(); + range.selectNodeContents(doc.body); + selection.addRange(range); + }); + + const TESTCASES = await extension.awaitMessage("testCases"); + let menu = await openExtensionContextMenu(); + let items = menu.getElementsByTagName("menuitem"); + is(items.length, TESTCASES.length, "Expected menu items for page"); + TESTCASES.forEach(({ description, label, key }, i) => { + is(items[i].label, label, `Label for item ${i} (${description})`); + is(items[i].accessKey, key, `Accesskey for item ${i} (${description})`); + }); + + await closeExtensionContextMenu(); + await extension.unload(); + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_menus_activeTab.js b/browser/components/extensions/test/browser/browser_ext_menus_activeTab.js new file mode 100644 index 0000000000..3f3bfd6633 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_menus_activeTab.js @@ -0,0 +1,115 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +// Opens two tabs at the start of the tab strip and focuses the second tab. +// Then an extension menu is registered for the "tab" context and a menu is +// opened on the first tab and the extension menu item is clicked. +// This triggers the onTabMenuClicked handler. +async function openTwoTabsAndOpenTabMenu(onTabMenuClicked) { + const PAGE_URL = + "http://mochi.test:8888/browser/browser/components/extensions/test/browser/context.html"; + const OTHER_URL = + "http://127.0.0.1:8888/browser/browser/components/extensions/test/browser/context.html"; + + let tab1 = await BrowserTestUtils.openNewForegroundTab(gBrowser, PAGE_URL); + let tab2 = await BrowserTestUtils.openNewForegroundTab(gBrowser, OTHER_URL); + // Move the first tab to the start so that it can be found by the .tabbrowser-tab selector below. + gBrowser.moveTabTo(tab1, 0); + gBrowser.moveTabTo(tab2, 1); + + async function background(onTabMenuClicked) { + browser.menus.onClicked.addListener(async (info, tab) => { + await onTabMenuClicked(info, tab); + browser.test.sendMessage("onCommand_on_tab_click"); + }); + + browser.menus.create( + { + title: "menu item on tab", + contexts: ["tab"], + }, + () => { + browser.test.sendMessage("ready"); + } + ); + } + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["menus", "activeTab"], + }, + background: `(${background})(${onTabMenuClicked})`, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + + // Focus a selected tab to to make tabbrowser.js to load localization files, + // and thereby initialize document.l10n property. + gBrowser.selectedTab.focus(); + + // The .tabbrowser-tab selector matches the first tab (tab1). + let menu = await openChromeContextMenu( + "tabContextMenu", + ".tabbrowser-tab", + window + ); + let menuItem = menu.getElementsByAttribute("label", "menu item on tab")[0]; + await closeTabContextMenu(menuItem); + await extension.awaitMessage("onCommand_on_tab_click"); + + await extension.unload(); + + BrowserTestUtils.removeTab(tab1); + BrowserTestUtils.removeTab(tab2); +} + +add_task(async function activeTabForTabMenu() { + await openTwoTabsAndOpenTabMenu(async function onTabMenuClicked(info, tab) { + browser.test.assertEq(0, tab.index, "Expected a menu on the first tab."); + + try { + let [actualUrl] = await browser.tabs.executeScript(tab.id, { + code: "document.URL", + }); + browser.test.assertEq( + tab.url, + actualUrl, + "Content script to execute in the first tab" + ); + // (the activeTab permission should have been granted to the first tab.) + } catch (e) { + browser.test.fail( + `Unexpected error in executeScript: ${e} :: ${e.stack}` + ); + } + }); +}); + +add_task(async function noActiveTabForCurrentTab() { + await openTwoTabsAndOpenTabMenu(async function onTabMenuClicked(info, tab) { + const PAGE_URL = + "http://mochi.test:8888/browser/browser/components/extensions/test/browser/context.html"; + browser.test.assertEq(0, tab.index, "Expected a menu on the first tab."); + browser.test.assertEq( + PAGE_URL, + tab.url, + "Expected tab.url to be available for the first tab" + ); + + let [tab2] = await browser.tabs.query({ windowId: tab.windowId, index: 1 }); + browser.test.assertTrue(tab2.active, "The second tab should be focused."); + browser.test.assertEq( + undefined, + tab2.url, + "Expected tab.url to be unavailable for the second tab." + ); + + await browser.test.assertRejects( + browser.tabs.executeScript(tab2.id, { code: "document.URL" }), + /Missing host permission for the tab/, + "Content script should not run in the second tab" + ); + // (The activeTab permission was granted to the first tab, not tab2.) + }); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_menus_capture_secondary_click.js b/browser/components/extensions/test/browser/browser_ext_menus_capture_secondary_click.js new file mode 100644 index 0000000000..1b251ca262 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_menus_capture_secondary_click.js @@ -0,0 +1,140 @@ +// /* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +// /* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const PAGE = + "http://mochi.test:8888/browser/browser/components/extensions/test/browser/context.html"; + +add_task(async function test_buttons() { + const manifest = { + permissions: ["menus"], + }; + + function background() { + function onclick(info) { + browser.test.sendMessage("click", info); + } + browser.menus.create({ title: "modify", onclick }, () => { + browser.test.sendMessage("ready"); + }); + } + + const extension = ExtensionTestUtils.loadExtension({ manifest, background }); + const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, PAGE); + + await extension.startup(); + await extension.awaitMessage("ready"); + + for (let i of [0, 1, 2]) { + const menu = await openContextMenu(); + const items = menu.getElementsByAttribute("label", "modify"); + await closeExtensionContextMenu(items[0], { button: i }); + const info = await extension.awaitMessage("click"); + is(info.button, i, `Button value should be ${i}`); + } + + BrowserTestUtils.removeTab(tab); + await extension.unload(); +}); + +add_task(async function test_submenu() { + function background() { + browser.menus.onClicked.addListener(info => { + browser.test.assertEq("child", info.menuItemId, "expected menu item"); + browser.test.sendMessage("clicked_button", info.button); + }); + browser.menus.create({ + id: "parent", + title: "parent", + }); + browser.menus.create( + { + id: "child", + parentId: "parent", + title: "child", + }, + () => browser.test.sendMessage("ready") + ); + } + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["menus"], + }, + background, + }); + const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, PAGE); + + await extension.startup(); + await extension.awaitMessage("ready"); + + for (let button of [0, 1, 2]) { + const menu = await openContextMenu(); + const parentItem = menu.getElementsByAttribute("label", "parent")[0]; + const submenu = await openSubmenu(parentItem); + const childItem = submenu.firstElementChild; + // This should not trigger a click event, thus we intentionally turn off + // this a11y check as <menu> containers are not expected to be interactive. + AccessibilityUtils.setEnv({ + mustHaveAccessibleRule: false, + }); + await EventUtils.synthesizeMouseAtCenter(parentItem, { button }); + AccessibilityUtils.resetEnv(); + await closeExtensionContextMenu(childItem, { button }); + is( + await extension.awaitMessage("clicked_button"), + button, + "Expected button" + ); + } + + BrowserTestUtils.removeTab(tab); + await extension.unload(); +}); + +add_task(async function test_disabled_item() { + function background() { + browser.menus.onHidden.addListener(() => + browser.test.sendMessage("onHidden") + ); + browser.menus.create( + { + title: "disabled_item", + enabled: false, + onclick(info) { + browser.test.fail( + `Unexpected click on disabled_item, button=${info.button}` + ); + }, + }, + () => browser.test.sendMessage("ready") + ); + } + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["menus"], + }, + background, + }); + const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, PAGE); + + await extension.startup(); + await extension.awaitMessage("ready"); + + for (let button of [0, 1, 2]) { + const menu = await openContextMenu(); + const items = menu.getElementsByAttribute("label", "disabled_item"); + // We intentionally turn off this a11y check, because the following click + // is targeting a disabled control to confirm the click event won't come through. + // It is not meant to be interactive and is not expected to be accessible: + AccessibilityUtils.setEnv({ + mustBeEnabled: false, + }); + await EventUtils.synthesizeMouseAtCenter(items[0], { button }); + AccessibilityUtils.resetEnv(); + await closeContextMenu(); + await extension.awaitMessage("onHidden"); + } + + BrowserTestUtils.removeTab(tab); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_menus_errors.js b/browser/components/extensions/test/browser/browser_ext_menus_errors.js new file mode 100644 index 0000000000..1569644996 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_menus_errors.js @@ -0,0 +1,164 @@ +/* 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"; + +add_task(async function test_create_error() { + // lastError is the only means to communicate errors in the menus.create API, + // so make sure that a warning is logged to the console if the error is not + // checked. + let waitForConsole = new Promise(resolve => { + SimpleTest.waitForExplicitFinish(); + SimpleTest.monitorConsole(resolve, [ + // Callback exists, lastError is checked. Should not be logged. + { + message: /Unchecked lastError value: Error: ID already exists: some_id/, + forbid: true, + }, + // No callback, lastError not checked. Should be logged. + { + message: + /Unchecked lastError value: Error: Could not find any MenuItem with id: noCb/, + }, + // Callback exists, lastError not checked. Should be logged. + { + message: + /Unchecked lastError value: Error: Could not find any MenuItem with id: cbIgnoreError/, + }, + ]); + }); + + async function background() { + // Note: browser.menus.create returns the menu ID instead of a promise, so + // we have to use callbacks. + await new Promise(resolve => { + browser.menus.create({ id: "some_id", title: "menu item" }, () => { + browser.test.assertEq( + null, + browser.runtime.lastError, + "Expected no error" + ); + resolve(); + }); + }); + + // Callback exists, lastError is checked: + await new Promise(resolve => { + browser.menus.create({ id: "some_id", title: "menu item" }, () => { + browser.test.assertEq( + "ID already exists: some_id", + browser.runtime.lastError.message, + "Expected error" + ); + resolve(); + }); + }); + + // No callback, lastError not checked: + browser.menus.create({ id: "noCb", parentId: "noCb", title: "menu item" }); + + // Callback exists, lastError not checked: + await new Promise(resolve => { + browser.menus.create( + { id: "cbIgnoreError", parentId: "cbIgnoreError", title: "menu item" }, + () => { + resolve(); + } + ); + }); + + // Do another roundtrip with the menus API to ensure that any console + // error messages from the previous call are flushed. + await browser.menus.removeAll(); + + browser.test.sendMessage("done"); + } + + const extension = ExtensionTestUtils.loadExtension({ + manifest: { permissions: ["menus"] }, + background, + }); + await extension.startup(); + await extension.awaitMessage("done"); + + await extension.unload(); + + SimpleTest.endMonitorConsole(); + await waitForConsole; +}); + +add_task(async function test_update_error() { + async function background() { + const id = browser.menus.create({ title: "menu item" }); + + await browser.test.assertRejects( + browser.menus.update(id, { parentId: "bogus" }), + "Could not find any MenuItem with id: bogus", + "menus.update with invalid parentMenuId should fail" + ); + + await browser.test.assertRejects( + browser.menus.update(id, { parentId: id }), + "MenuItem cannot be an ancestor (or self) of its new parent.", + "menus.update cannot assign itself as the parent of a menu." + ); + + browser.test.sendMessage("done"); + } + + const extension = ExtensionTestUtils.loadExtension({ + manifest: { permissions: ["menus"] }, + background, + }); + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); +}); + +add_task(async function test_invalid_documentUrlPatterns() { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["menus"], + }, + async background() { + await new Promise(resolve => { + browser.menus.create( + { + title: "invalid url", + contexts: ["tab"], + documentUrlPatterns: ["test1"], + }, + () => { + browser.test.assertEq( + "Invalid url pattern: test1", + browser.runtime.lastError.message, + "Expected invalid match pattern" + ); + resolve(); + } + ); + }); + await new Promise(resolve => { + browser.menus.create( + { + title: "invalid url", + contexts: ["link"], + targetUrlPatterns: ["test2"], + }, + () => { + browser.test.assertEq( + "Invalid url pattern: test2", + browser.runtime.lastError.message, + "Expected invalid match pattern" + ); + resolve(); + } + ); + }); + browser.test.sendMessage("done"); + }, + }); + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_menus_event_order.js b/browser/components/extensions/test/browser/browser_ext_menus_event_order.js new file mode 100644 index 0000000000..4ec43c8cde --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_menus_event_order.js @@ -0,0 +1,87 @@ +/* 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 PAGE = + "http://mochi.test:8888/browser/browser/components/extensions/test/browser/context.html"; +add_task(async function test_menus_click_event_sequence() { + async function background() { + let events = []; + + browser.menus.onShown.addListener(() => { + events.push("onShown"); + }); + browser.menus.onHidden.addListener(() => { + events.push("onHidden"); + browser.test.sendMessage("event_sequence", events); + events.length = 0; + }); + + browser.menus.create({ + title: "item in page menu", + contexts: ["page"], + onclick() { + events.push("onclick parameter of page menu item"); + }, + }); + browser.menus.create( + { + title: "item in tools menu", + contexts: ["tools_menu"], + onclick() { + events.push("onclick parameter of tools_menu menu item"); + }, + }, + () => { + // The menus creation requests are expected to be handled in-order. + // So when the callback for the last menu creation request is called, + // we can assume that all menus have been registered. + browser.test.sendMessage("created menus"); + } + ); + } + + const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, PAGE); + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["menus"], + }, + }); + await extension.startup(); + info("Waiting for events and menu items to be registered"); + await extension.awaitMessage("created menus"); + + async function verifyResults(menuType) { + info("Getting menu event info..."); + let events = await extension.awaitMessage("event_sequence"); + Assert.deepEqual( + events, + ["onShown", `onclick parameter of ${menuType} menu item`, "onHidden"], + "Expected order of menus events" + ); + } + + { + info("Opening and closing page menu"); + const menu = await openContextMenu("body"); + const menuitem = menu.querySelector("menuitem[label='item in page menu']"); + ok(menuitem, "Page menu item should exist"); + await closeExtensionContextMenu(menuitem); + await verifyResults("page"); + } + + { + info("Opening and closing tools menu"); + const menu = await openToolsMenu(); + let menuitem = menu.querySelector("menuitem[label='item in tools menu']"); + ok(menuitem, "Tools menu item should exist"); + await closeToolsMenu(menuitem); + await verifyResults("tools_menu"); + } + + await extension.unload(); + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_menus_eventpage.js b/browser/components/extensions/test/browser/browser_ext_menus_eventpage.js new file mode 100644 index 0000000000..a1f6d8c81f --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_menus_eventpage.js @@ -0,0 +1,277 @@ +"use strict"; + +add_setup(async () => { + await SpecialPowers.pushPrefEnv({ + set: [["extensions.eventPages.enabled", true]], + }); +}); + +function getExtension(background, useAddonManager) { + return ExtensionTestUtils.loadExtension({ + useAddonManager, + manifest: { + browser_action: { + default_area: "navbar", + }, + permissions: ["menus"], + background: { persistent: false }, + }, + background, + }); +} + +add_task(async function test_menu_create_id() { + let waitForConsole = new Promise(resolve => { + SimpleTest.waitForExplicitFinish(); + SimpleTest.monitorConsole(resolve, [ + // Callback exists, lastError is checked, so we should not see this logged. + { + message: + /Unchecked lastError value: Error: menus.create requires an id for non-persistent background scripts./, + forbid: true, + }, + ]); + }); + + function background() { + // Event pages require ID + browser.menus.create( + { contexts: ["browser_action"], title: "parent" }, + () => { + browser.test.assertEq( + "menus.create requires an id for non-persistent background scripts.", + browser.runtime.lastError?.message, + "lastError message for missing id" + ); + browser.test.sendMessage("done"); + } + ); + } + const extension = getExtension(background); + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); + + SimpleTest.endMonitorConsole(); + await waitForConsole; +}); + +add_task(async function test_menu_onclick() { + async function background() { + const contexts = ["browser_action"]; + + const parentId = browser.menus.create({ + contexts, + title: "parent", + id: "test-parent", + }); + browser.menus.create({ parentId, title: "click A", id: "test-click" }); + + browser.menus.onClicked.addListener((info, tab) => { + browser.test.sendMessage("click", { info, tab }); + }); + browser.runtime.onSuspend.addListener(() => { + browser.test.sendMessage("suspended-test_menu_onclick"); + }); + } + + const extension = getExtension(background); + + await extension.startup(); + await extension.terminateBackground(); // Simulated suspend on idle. + await extension.awaitMessage("suspended-test_menu_onclick"); + + // The background is now suspended, test that a menu click starts it. + const kind = "browser"; + + const menu = await openActionContextMenu(extension, kind); + const [submenu] = menu.children; + const popup = await openSubmenu(submenu); + + await closeActionContextMenu(popup.firstElementChild, kind); + const clicked = await extension.awaitMessage("click"); + is(clicked.info.pageUrl, "about:blank", "Click info pageUrl is correct"); + Assert.greater(clicked.tab.id, -1, "Click event tab ID is correct"); + + await extension.unload(); +}); + +add_task(async function test_menu_onshown() { + async function background() { + const contexts = ["browser_action"]; + + const parentId = browser.menus.create({ + contexts, + title: "parent", + id: "test-parent", + }); + browser.menus.create({ parentId, title: "click A", id: "test-click" }); + + browser.menus.onClicked.addListener((info, tab) => { + browser.test.sendMessage("click", { info, tab }); + }); + browser.menus.onShown.addListener((info, tab) => { + browser.test.sendMessage("shown", { info, tab }); + }); + browser.menus.onHidden.addListener((info, tab) => { + browser.test.sendMessage("hidden", { info, tab }); + }); + browser.runtime.onSuspend.addListener(() => { + browser.test.sendMessage("suspended-test_menu_onshown"); + }); + } + + const extension = getExtension(background); + + await extension.startup(); + await extension.terminateBackground(); // Simulated suspend on idle. + await extension.awaitMessage("suspended-test_menu_onshown"); + + // The background is now suspended, test that showing a menu starts it. + const kind = "browser"; + + const menu = await openActionContextMenu(extension, kind); + const [submenu] = menu.children; + const popup = await openSubmenu(submenu); + await extension.awaitMessage("shown"); + + await closeActionContextMenu(popup.firstElementChild, kind); + await extension.awaitMessage("hidden"); + // The click still should work after the background was restarted. + const clicked = await extension.awaitMessage("click"); + is(clicked.info.pageUrl, "about:blank", "Click info pageUrl is correct"); + Assert.greater(clicked.tab.id, -1, "Click event tab ID is correct"); + + await extension.unload(); +}); + +add_task(async function test_actions_context_menu() { + function background() { + browser.contextMenus.create({ + id: "my_browser_action", + title: "open_browser_action", + contexts: ["all"], + command: "_execute_browser_action", + }); + browser.contextMenus.onClicked.addListener(() => { + browser.test.fail(`menu onClicked should not have been received`); + }); + browser.test.sendMessage("ready"); + } + + function testScript() { + window.onload = () => { + browser.test.sendMessage("test-opened", true); + }; + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + name: "contextMenus commands", + permissions: ["contextMenus"], + browser_action: { + default_title: "Test BrowserAction", + default_popup: "test.html", + default_area: "navbar", + browser_style: true, + }, + background: { persistent: false }, + }, + background, + files: { + "test.html": `<!DOCTYPE html><meta charset="utf-8"><script src="test.js"></script>`, + "test.js": testScript, + }, + }); + + async function testContext(id) { + const menu = await openContextMenu(); + const items = menu.getElementsByAttribute("label", id); + is(items.length, 1, `exactly one menu item found`); + await closeExtensionContextMenu(items[0]); + return extension.awaitMessage("test-opened"); + } + + await extension.startup(); + await extension.awaitMessage("ready"); + await extension.terminateBackground(); + + // open a page so context menu works + const PAGE = + "http://mochi.test:8888/browser/browser/components/extensions/test/browser/context.html?test=commands"; + const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, PAGE); + + ok( + await testContext("open_browser_action"), + "_execute_browser_action worked" + ); + + await BrowserTestUtils.removeTab(tab); + let ext = WebExtensionPolicy.getByID(extension.id).extension; + is(ext.backgroundState, "stopped", "background is not running"); + + await extension.unload(); +}); + +add_task(async function test_menu_create_id_reuse() { + let waitForConsole = new Promise(resolve => { + SimpleTest.waitForExplicitFinish(); + SimpleTest.monitorConsole(resolve, [ + // Callback exists, lastError is checked, so we should not see this logged. + { + message: + /Unchecked lastError value: Error: menus.create requires an id for non-persistent background scripts./, + forbid: true, + }, + ]); + }); + + function background() { + browser.menus.create( + { + contexts: ["browser_action"], + title: "click A", + id: "test-click", + }, + () => { + browser.test.sendMessage("create", browser.runtime.lastError?.message); + } + ); + browser.test.onMessage.addListener(msg => { + browser.test.assertEq("add-again", msg, "expected msg"); + browser.menus.create( + { + contexts: ["browser_action"], + title: "click A", + id: "test-click", + }, + () => { + browser.test.assertEq( + "The menu id test-click already exists in menus.create.", + browser.runtime.lastError?.message, + "lastError message for missing id" + ); + browser.test.sendMessage("done"); + } + ); + }); + } + const extension = getExtension(background, "temporary"); + await extension.startup(); + let lastError = await extension.awaitMessage("create"); + Assert.equal(lastError, undefined, "no error creating menu"); + extension.sendMessage("add-again"); + await extension.awaitMessage("done"); + await extension.terminateBackground(); + await extension.wakeupBackground(); + lastError = await extension.awaitMessage("create"); + Assert.equal( + lastError, + "The menu id test-click already exists in menus.create.", + "lastError using duplicate ID" + ); + await extension.unload(); + + SimpleTest.endMonitorConsole(); + await waitForConsole; +}); diff --git a/browser/components/extensions/test/browser/browser_ext_menus_events.js b/browser/components/extensions/test/browser/browser_ext_menus_events.js new file mode 100644 index 0000000000..0127c05007 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_menus_events.js @@ -0,0 +1,911 @@ +/* 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": `<!DOCTYPE html><meta charset="utf-8">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 + ); + Assert.notStrictEqual( + 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(); + }, + }); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_menus_events_after_context_destroy.js b/browser/components/extensions/test/browser/browser_ext_menus_events_after_context_destroy.js new file mode 100644 index 0000000000..317f9c4321 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_menus_events_after_context_destroy.js @@ -0,0 +1,64 @@ +/* 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"; + +// This test does verify that the menus API events are still emitted when +// there are extension context alive with subscribed listeners +// (See Bug 1602384). +add_task(async function test_subscribed_events_fired_after_context_destroy() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["menus"], + }, + files: { + "page.html": `<!DOCTYPE html> + <meta charset="utf-8"><script src="page.js"></script> + Extension Page + `, + "page.js": async function () { + browser.menus.onShown.addListener(() => { + browser.test.sendMessage("menu-onShown"); + }); + browser.menus.onHidden.addListener(() => { + browser.test.sendMessage("menu-onHidden"); + }); + // Call an API method implemented in the parent process + // to ensure the menu listeners are subscribed in the + // parent process. + await browser.runtime.getBrowserInfo(); + browser.test.sendMessage("page-loaded"); + }, + }, + }); + + await extension.startup(); + const pageURL = `moz-extension://${extension.uuid}/page.html`; + + info("Loading extension page in a tab"); + const tab1 = await BrowserTestUtils.openNewForegroundTab(gBrowser, pageURL); + await extension.awaitMessage("page-loaded"); + + info("Loading extension page in another tab"); + const tab2 = await BrowserTestUtils.openNewForegroundTab(gBrowser, pageURL); + await extension.awaitMessage("page-loaded"); + + info("Remove the first tab"); + BrowserTestUtils.removeTab(tab1); + + info("Open a context menu and expect menu.onShown to be fired"); + await openContextMenu("body"); + + await extension.awaitMessage("menu-onShown"); + + info("Close context menu and expect menu.onHidden to be fired"); + await closeExtensionContextMenu(); + await extension.awaitMessage("menu-onHidden"); + + ok(true, "The expected menu events have been fired"); + + BrowserTestUtils.removeTab(tab2); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_menus_incognito.js b/browser/components/extensions/test/browser/browser_ext_menus_incognito.js new file mode 100644 index 0000000000..397b140ab6 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_menus_incognito.js @@ -0,0 +1,155 @@ +"use strict"; + +// Make sure that we won't trigger events for a private window. +add_task(async function test_no_show_hide_for_private_window() { + function background() { + let events = []; + browser.menus.onShown.addListener(data => events.push(data)); + browser.menus.onHidden.addListener(() => events.push("onHidden")); + browser.test.onMessage.addListener(async (name, data) => { + if (name == "check-events") { + browser.test.sendMessage("events", events); + events = []; + } + if (name == "create-menu") { + let id = await new Promise(resolve => { + let mid = browser.menus.create(data, () => resolve(mid)); + }); + browser.test.sendMessage("menu-id", id); + } + }); + } + + let pb_extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + browser_specific_settings: { + gecko: { id: "@private-allowed" }, + }, + permissions: ["menus", "tabs"], + }, + incognitoOverride: "spanning", + }); + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + browser_specific_settings: { + gecko: { id: "@not-allowed" }, + }, + permissions: ["menus", "tabs"], + }, + }); + + async function testEvents(ext, expected) { + ext.sendMessage("check-events"); + let events = await ext.awaitMessage("events"); + Assert.deepEqual( + expected, + events, + `expected events received for ${ext.id}.` + ); + } + + await pb_extension.startup(); + await extension.startup(); + + extension.sendMessage("create-menu", { + title: "not_allowed", + contexts: ["all", "tools_menu"], + }); + let id1 = await extension.awaitMessage("menu-id"); + let extMenuId = `${makeWidgetId(extension.id)}-menuitem-${id1}`; + pb_extension.sendMessage("create-menu", { + title: "spanning_allowed", + contexts: ["all", "tools_menu"], + }); + let id2 = await pb_extension.awaitMessage("menu-id"); + let pb_extMenuId = `${makeWidgetId(pb_extension.id)}-menuitem-${id2}`; + + // Expected menu events + let baseShownEvent = { + contexts: ["page", "all"], + viewType: "tab", + frameId: 0, + editable: false, + }; + let publicShown = { menuIds: [id1], ...baseShownEvent }; + let privateShown = { menuIds: [id2], ...baseShownEvent }; + + baseShownEvent = { + contexts: ["tools_menu"], + viewType: undefined, + editable: false, + }; + let toolsShown = { menuIds: [id1], ...baseShownEvent }; + let privateToolsShown = { menuIds: [id2], ...baseShownEvent }; + + // Run tests in non-private window + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:robots" + ); + + // Open and close a menu on the public window. + await openContextMenu("body"); + + // We naturally expect both extensions here. + ok(document.getElementById(extMenuId), `menu exists ${extMenuId}`); + ok(document.getElementById(pb_extMenuId), `menu exists ${pb_extMenuId}`); + await closeContextMenu(); + + await testEvents(extension, [publicShown, "onHidden"]); + await testEvents(pb_extension, [privateShown, "onHidden"]); + + await openToolsMenu(); + ok(document.getElementById(extMenuId), `menu exists ${extMenuId}`); + ok(document.getElementById(pb_extMenuId), `menu exists ${pb_extMenuId}`); + await closeToolsMenu(); + + await testEvents(extension, [toolsShown, "onHidden"]); + await testEvents(pb_extension, [privateToolsShown, "onHidden"]); + + // Run tests on private window + + let privateWindow = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + + // Open and close a menu on the private window. + let menu = await openContextMenu("body div", privateWindow); + // We should not see the "not_allowed" extension here. + ok( + !privateWindow.document.getElementById(extMenuId), + `menu does not exist ${extMenuId} in private window` + ); + ok( + privateWindow.document.getElementById(pb_extMenuId), + `menu exists ${pb_extMenuId} in private window` + ); + await closeContextMenu(menu); + + await testEvents(extension, []); + await testEvents(pb_extension, [privateShown, "onHidden"]); + + await openToolsMenu(privateWindow); + // We should not see the "not_allowed" extension here. + ok( + !privateWindow.document.getElementById(extMenuId), + `menu does not exist ${extMenuId} in private window` + ); + ok( + privateWindow.document.getElementById(pb_extMenuId), + `menu exists ${pb_extMenuId} in private window` + ); + await closeToolsMenu(undefined, privateWindow); + + await testEvents(extension, []); + await testEvents(pb_extension, [privateToolsShown, "onHidden"]); + + await extension.unload(); + await pb_extension.unload(); + BrowserTestUtils.removeTab(tab); + await BrowserTestUtils.closeWindow(privateWindow); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_menus_refresh.js b/browser/components/extensions/test/browser/browser_ext_menus_refresh.js new file mode 100644 index 0000000000..836f370039 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_menus_refresh.js @@ -0,0 +1,438 @@ +/* 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 PAGE = + "http://mochi.test:8888/browser/browser/components/extensions/test/browser/context.html"; + +// Load an extension that has the "menus" permission. The returned Extension +// instance has a `callMenuApi` method to easily call a browser.menus method +// and wait for its result. It also emits the "onShown fired" message whenever +// the menus.onShown event is fired. +// The `getXULElementByMenuId` method returns the XUL element that corresponds +// to the menu item ID from the browser.menus API (if existent, null otherwise). +function loadExtensionWithMenusApi() { + async function background() { + function shownHandler() { + browser.test.sendMessage("onShown fired"); + } + + browser.menus.onShown.addListener(shownHandler); + browser.test.onMessage.addListener((method, ...params) => { + let result; + if (method === "* remove onShown listener") { + browser.menus.onShown.removeListener(shownHandler); + result = Promise.resolve(); + } else if (method === "create") { + result = new Promise(resolve => { + browser.menus.create(params[0], resolve); + }); + } else { + result = browser.menus[method](...params); + } + result.then(() => { + browser.test.sendMessage(`${method}-result`); + }); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + browser_action: { + default_area: "navbar", + }, + permissions: ["menus"], + }, + }); + + extension.callMenuApi = async function (method, ...params) { + info(`Calling ${method}(${JSON.stringify(params)})`); + extension.sendMessage(method, ...params); + return extension.awaitMessage(`${method}-result`); + }; + + extension.removeOnShownListener = async function () { + extension.callMenuApi("* remove onShown listener"); + }; + + extension.getXULElementByMenuId = id => { + // Same implementation as elementId getter in ext-menus.js + if (typeof id != "number") { + id = `_${id}`; + } + let xulId = `${makeWidgetId(extension.id)}-menuitem-${id}`; + return document.getElementById(xulId); + }; + + return extension; +} + +// Tests whether browser.menus.refresh works as expected with respect to the +// menu items that are added/updated/removed before/during/after opening a menu: +// - browser.refresh before a menu is shown should not have any effect. +// - browser.refresh while a menu is shown should update the menu. +// - browser.refresh after a menu is hidden should not have any effect. +async function testRefreshMenusWhileVisible({ + contexts, + doOpenMenu, + doCloseMenu, +}) { + let extension = loadExtensionWithMenusApi(); + await extension.startup(); + await extension.callMenuApi("create", { + id: "abc", + title: "first", + contexts, + }); + let elem = extension.getXULElementByMenuId("abc"); + is(elem, null, "Menu item should not be visible"); + + // Refresh before a menu is shown - should be noop. + await extension.callMenuApi("refresh"); + elem = extension.getXULElementByMenuId("abc"); + is(elem, null, "Menu item should still not be visible"); + + // Open menu and expect menu to be rendered. + await doOpenMenu(extension); + elem = extension.getXULElementByMenuId("abc"); + is(elem.getAttribute("label"), "first", "expected label"); + + await extension.awaitMessage("onShown fired"); + + // Add new menus, but don't expect them to be rendered yet. + await extension.callMenuApi("update", "abc", { title: "updated first" }); + await extension.callMenuApi("create", { + id: "def", + title: "second", + contexts, + }); + + elem = extension.getXULElementByMenuId("abc"); + is(elem.getAttribute("label"), "first", "expected unchanged label"); + elem = extension.getXULElementByMenuId("def"); + is(elem, null, "Second menu item should not be visible"); + + // Refresh while a menu is shown - should be updated. + await extension.callMenuApi("refresh"); + + elem = extension.getXULElementByMenuId("abc"); + is(elem.getAttribute("label"), "updated first", "expected updated label"); + elem = extension.getXULElementByMenuId("def"); + is(elem.getAttribute("label"), "second", "expected second label"); + + // Update the two menu items again. + await extension.callMenuApi("update", "abc", { enabled: false }); + await extension.callMenuApi("update", "def", { enabled: false }); + await extension.callMenuApi("refresh"); + elem = extension.getXULElementByMenuId("abc"); + is(elem.getAttribute("disabled"), "true", "1st menu item should be disabled"); + elem = extension.getXULElementByMenuId("def"); + is(elem.getAttribute("disabled"), "true", "2nd menu item should be disabled"); + + // Remove one. + await extension.callMenuApi("remove", "abc"); + await extension.callMenuApi("refresh"); + elem = extension.getXULElementByMenuId("def"); + is(elem.getAttribute("label"), "second", "other menu item should exist"); + elem = extension.getXULElementByMenuId("abc"); + is(elem, null, "removed menu item should be gone"); + + // Remove the last one. + await extension.callMenuApi("removeAll"); + await extension.callMenuApi("refresh"); + elem = extension.getXULElementByMenuId("def"); + is(elem, null, "all menu items should be gone"); + + // At this point all menu items have been removed. Create a new menu item so + // we can confirm that browser.menus.refresh() does not render the menu item + // after the menu has been hidden. + await extension.callMenuApi("create", { + // The menu item with ID "abc" was removed before, so re-using the ID should + // not cause any issues: + id: "abc", + title: "re-used", + contexts, + }); + await extension.callMenuApi("refresh"); + elem = extension.getXULElementByMenuId("abc"); + is(elem.getAttribute("label"), "re-used", "menu item should be created"); + + await doCloseMenu(); + + elem = extension.getXULElementByMenuId("abc"); + is(elem, null, "menu item must be gone"); + + // Refresh after menu was hidden - should be noop. + await extension.callMenuApi("refresh"); + elem = extension.getXULElementByMenuId("abc"); + is(elem, null, "menu item must still be gone"); + + await extension.unload(); +} + +// Check that one extension calling refresh() doesn't interfere with others. +// When expectOtherItems == false, the other extension's menu items should not +// show at all (e.g. for browserAction). +async function testRefreshOther({ + contexts, + doOpenMenu, + doCloseMenu, + expectOtherItems, +}) { + let extension = loadExtensionWithMenusApi(); + let other_extension = loadExtensionWithMenusApi(); + await extension.startup(); + await other_extension.startup(); + + await extension.callMenuApi("create", { + id: "action_item", + title: "visible menu item", + contexts: contexts, + }); + + await other_extension.callMenuApi("create", { + id: "action_item", + title: "other menu item", + contexts: contexts, + }); + + await doOpenMenu(extension); + await extension.awaitMessage("onShown fired"); + if (expectOtherItems) { + await other_extension.awaitMessage("onShown fired"); + } + + let elem = extension.getXULElementByMenuId("action_item"); + is(elem.getAttribute("label"), "visible menu item", "extension menu shown"); + elem = other_extension.getXULElementByMenuId("action_item"); + if (expectOtherItems) { + is( + elem.getAttribute("label"), + "other menu item", + "other extension's menu is also shown" + ); + } else { + is(elem, null, "other extension's menu should be hidden"); + } + + await extension.callMenuApi("update", "action_item", { title: "changed" }); + await other_extension.callMenuApi("update", "action_item", { title: "foo" }); + await other_extension.callMenuApi("refresh"); + + // refreshing the menu of an unrelated extension should not affect the menu + // of another extension. + elem = extension.getXULElementByMenuId("action_item"); + is(elem.getAttribute("label"), "visible menu item", "extension menu shown"); + elem = other_extension.getXULElementByMenuId("action_item"); + if (expectOtherItems) { + is(elem.getAttribute("label"), "foo", "other extension's item is updated"); + } else { + is(elem, null, "other extension's menu should still be hidden"); + } + + await doCloseMenu(); + await extension.unload(); + await other_extension.unload(); +} + +add_task(async function refresh_menus_with_browser_action() { + const args = { + contexts: ["browser_action"], + async doOpenMenu(extension) { + await openActionContextMenu(extension, "browser"); + }, + async doCloseMenu() { + await closeActionContextMenu(); + }, + }; + await testRefreshMenusWhileVisible(args); + args.expectOtherItems = false; + await testRefreshOther(args); +}); + +add_task(async function refresh_menus_with_tab() { + const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, PAGE); + const args = { + contexts: ["tab"], + async doOpenMenu() { + await openTabContextMenu(); + }, + async doCloseMenu() { + await closeTabContextMenu(); + }, + }; + await testRefreshMenusWhileVisible(args); + args.expectOtherItems = true; + await testRefreshOther(args); + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function refresh_menus_with_tools_menu() { + const args = { + contexts: ["tools_menu"], + async doOpenMenu() { + await openToolsMenu(); + }, + async doCloseMenu() { + await closeToolsMenu(); + }, + }; + await testRefreshMenusWhileVisible(args); + args.expectOtherItems = true; + await testRefreshOther(args); +}); + +add_task(async function refresh_menus_with_page() { + const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, PAGE); + const args = { + contexts: ["page"], + async doOpenMenu() { + await openContextMenu("body"); + }, + async doCloseMenu() { + await closeExtensionContextMenu(); + }, + }; + await testRefreshMenusWhileVisible(args); + args.expectOtherItems = true; + await testRefreshOther(args); + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function refresh_without_menus_at_onShown() { + const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, PAGE); + let extension = loadExtensionWithMenusApi(); + await extension.startup(); + + const doOpenMenu = () => openContextMenu("body"); + const doCloseMenu = () => closeExtensionContextMenu(); + + await doOpenMenu(); + await extension.awaitMessage("onShown fired"); + await extension.callMenuApi("create", { + id: "too late", + title: "created after shown", + }); + await extension.callMenuApi("refresh"); + let elem = extension.getXULElementByMenuId("too late"); + is( + elem.getAttribute("label"), + "created after shown", + "extension without visible menu items can add new items" + ); + + await extension.callMenuApi("update", "too late", { title: "the menu item" }); + await extension.callMenuApi("refresh"); + elem = extension.getXULElementByMenuId("too late"); + is(elem.getAttribute("label"), "the menu item", "label should change"); + + // The previously created menu item should be visible if the menu is closed + // and re-opened. + await doCloseMenu(); + await doOpenMenu(); + await extension.awaitMessage("onShown fired"); + elem = extension.getXULElementByMenuId("too late"); + is(elem.getAttribute("label"), "the menu item", "previously registered item"); + await doCloseMenu(); + + await extension.unload(); + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function refresh_without_onShown() { + const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, PAGE); + let extension = loadExtensionWithMenusApi(); + await extension.startup(); + await extension.removeOnShownListener(); + + const doOpenMenu = () => openContextMenu("body"); + const doCloseMenu = () => closeExtensionContextMenu(); + + await doOpenMenu(); + await extension.callMenuApi("create", { + id: "too late", + title: "created after shown", + }); + + is( + extension.getXULElementByMenuId("too late"), + null, + "item created after shown is not visible before refresh" + ); + + await extension.callMenuApi("refresh"); + let elem = extension.getXULElementByMenuId("too late"); + is( + elem.getAttribute("label"), + "created after shown", + "refresh updates the menu even without onShown" + ); + + await doCloseMenu(); + await extension.unload(); + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function refresh_menus_during_navigation() { + const tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + PAGE + "?1" + ); + let extension = loadExtensionWithMenusApi(); + await extension.startup(); + + await extension.callMenuApi("create", { + id: "item1", + title: "item1", + contexts: ["browser_action"], + documentUrlPatterns: ["*://*/*?1*"], + }); + + await extension.callMenuApi("create", { + id: "item2", + title: "item2", + contexts: ["browser_action"], + documentUrlPatterns: ["*://*/*?2*"], + }); + + await openActionContextMenu(extension, "browser"); + await extension.awaitMessage("onShown fired"); + + let elem = extension.getXULElementByMenuId("item1"); + is(elem.getAttribute("label"), "item1", "menu item 1 should be shown"); + elem = extension.getXULElementByMenuId("item2"); + is(elem, null, "menu item 2 should be hidden"); + + BrowserTestUtils.startLoadingURIString(tab.linkedBrowser, PAGE + "?2"); + await BrowserTestUtils.browserStopped(tab.linkedBrowser); + + await extension.callMenuApi("refresh"); + + // The menu items in a context menu are based on the context at the time of + // opening the menu. Menus are not updated if the context changes, e.g. as a + // result of navigation events after the menu was shown. + // So when refresh() is called during the onShown event, then the original + // URL (before navigation) should be used to determine whether to show a + // URL-specific menu item, and NOT the current URL (after navigation). + elem = extension.getXULElementByMenuId("item1"); + is(elem.getAttribute("label"), "item1", "menu item 1 should still be shown"); + elem = extension.getXULElementByMenuId("item2"); + is(elem, null, "menu item 2 should still be hidden"); + + await closeActionContextMenu(); + await openActionContextMenu(extension, "browser"); + await extension.awaitMessage("onShown fired"); + + // Now after closing and re-opening the menu, the latest contextual info + // should be used. + elem = extension.getXULElementByMenuId("item1"); + is(elem, null, "menu item 1 should be hidden"); + elem = extension.getXULElementByMenuId("item2"); + is(elem.getAttribute("label"), "item2", "menu item 2 should be shown"); + + await closeActionContextMenu(); + await extension.unload(); + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_menus_replace_menu.js b/browser/components/extensions/test/browser/browser_ext_menus_replace_menu.js new file mode 100644 index 0000000000..c430b6ad71 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_menus_replace_menu.js @@ -0,0 +1,525 @@ +/* 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"; + +function getVisibleChildrenIds(menuElem) { + return Array.from(menuElem.children) + .filter(elem => !elem.hidden) + .map(elem => (elem.tagName == "menuseparator" ? elem.tagName : elem.id)); +} + +function checkIsLinkMenuItemVisible(visibleMenuItemIds) { + // In most of this test file, we open a menu on a link. Assume that all + // relevant menu items are shown if one link-specific menu item is shown. + ok( + visibleMenuItemIds.includes("context-openlink"), + `The default 'Open Link in New Tab' menu item should be in ${visibleMenuItemIds}.` + ); +} + +// Tests the following: +// - Calling overrideContext({}) during oncontextmenu forces the menu to only +// show an extension's own items. +// - These menu items all appear in the root menu. +// - The usual extension filtering behavior (e.g. documentUrlPatterns and +// targetUrlPatterns) is still applied; some menu items are therefore hidden. +// - Calling overrideContext({showDefaults:true}) causes the default menu items +// to be shown, but only after the extension's. +// - overrideContext expires after the menu is opened once. +// - overrideContext can be called from shadow DOM. +add_task(async function overrideContext_in_extension_tab() { + await SpecialPowers.pushPrefEnv({ + set: [["security.allow_eval_with_system_principal", true]], + }); + + function extensionTabScript() { + document.addEventListener( + "contextmenu", + () => { + browser.menus.overrideContext({}); + browser.test.sendMessage("oncontextmenu_in_dom_part_1"); + }, + { once: true } + ); + + let shadowRoot = document + .getElementById("shadowHost") + .attachShadow({ mode: "open" }); + shadowRoot.innerHTML = `<a href="http://example.com/">Link</a>`; + shadowRoot.firstChild.addEventListener( + "contextmenu", + () => { + browser.menus.overrideContext({}); + browser.test.sendMessage("oncontextmenu_in_shadow_dom"); + }, + { once: true } + ); + + document.querySelector("p").addEventListener( + "contextmenu", + () => { + browser.menus.overrideContext({ showDefaults: true }); + }, + { once: true } + ); + + browser.menus.create({ + id: "tab_1", + title: "tab_1", + documentUrlPatterns: [document.URL], + onclick() { + document.addEventListener( + "contextmenu", + () => { + // Verifies that last call takes precedence. + browser.menus.overrideContext({ showDefaults: false }); + browser.menus.overrideContext({ showDefaults: true }); + browser.test.sendMessage("oncontextmenu_in_dom_part_2"); + }, + { once: true } + ); + browser.test.sendMessage("onClicked_tab_1"); + }, + }); + browser.menus.create( + { + id: "tab_2", + title: "tab_2", + onclick() { + browser.test.sendMessage("onClicked_tab_2"); + }, + }, + () => { + browser.test.sendMessage("menu-registered"); + } + ); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["menus", "menus.overrideContext"], + }, + files: { + "tab.html": ` + <!DOCTYPE html><meta charset="utf-8"> + <a href="http://example.com/">Link</a> + <p>Some text</p> + <div id="shadowHost"></div> + <script src="tab.js"></script> + `, + "tab.js": extensionTabScript, + }, + background() { + // Expected to match and thus be visible. + browser.menus.create({ id: "bg_1", title: "bg_1" }); + browser.menus.create({ + id: "bg_2", + title: "bg_2", + targetUrlPatterns: ["*://example.com/*"], + }); + + // Expected to not match and be hidden. + browser.menus.create({ + id: "bg_3", + title: "bg_3", + targetUrlPatterns: ["*://nomatch/*"], + }); + browser.menus.create({ + id: "bg_4", + title: "bg_4", + documentUrlPatterns: [document.URL], + }); + + browser.menus.onShown.addListener(info => { + browser.test.assertEq("tab", info.viewType, "Expected viewType"); + let sortedContexts = info.contexts.sort().join(","); + if (info.contexts.includes("link")) { + browser.test.assertEq( + "bg_1,bg_2,tab_1,tab_2", + info.menuIds.join(","), + "Expected menu items." + ); + browser.test.assertEq( + "all,link", + sortedContexts, + "Expected menu contexts" + ); + } else if (info.contexts.includes("page")) { + browser.test.assertEq( + "bg_1,tab_1,tab_2", + info.menuIds.join(","), + "Expected menu items." + ); + browser.test.assertEq( + "all,page", + sortedContexts, + "Expected menu contexts" + ); + } else { + browser.test.fail(`Unexpected menu context: ${sortedContexts}`); + } + browser.test.sendMessage("onShown"); + }); + + browser.tabs.create({ url: "tab.html" }); + }, + }); + + let otherExtension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["menus"], + }, + background() { + browser.menus.create( + { id: "other_extension_item", title: "other_extension_item" }, + () => { + browser.test.sendMessage("other_extension_item_created"); + } + ); + }, + }); + await otherExtension.startup(); + await otherExtension.awaitMessage("other_extension_item_created"); + + let extensionTabPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + null, + true + ); + await extension.startup(); + // Must wait for the tab to have loaded completely before calling openContextMenu. + await extensionTabPromise; + await extension.awaitMessage("menu-registered"); + + const EXPECTED_EXTENSION_MENU_IDS = [ + `${makeWidgetId(extension.id)}-menuitem-_bg_1`, + `${makeWidgetId(extension.id)}-menuitem-_bg_2`, + `${makeWidgetId(extension.id)}-menuitem-_tab_1`, + `${makeWidgetId(extension.id)}-menuitem-_tab_2`, + ]; + + const EXPECTED_EXTENSION_MENU_IDS_NOLINK = [ + `${makeWidgetId(extension.id)}-menuitem-_bg_1`, + `${makeWidgetId(extension.id)}-menuitem-_tab_1`, + `${makeWidgetId(extension.id)}-menuitem-_tab_2`, + ]; + const OTHER_EXTENSION_MENU_ID = `${makeWidgetId( + otherExtension.id + )}-menuitem-_other_extension_item`; + + { + // Tests overrideContext({}) + info("Expecting the menu to be replaced by overrideContext."); + let menu = await openContextMenu("a"); + await extension.awaitMessage("oncontextmenu_in_dom_part_1"); + await extension.awaitMessage("onShown"); + + Assert.deepEqual( + getVisibleChildrenIds(menu), + EXPECTED_EXTENSION_MENU_IDS, + "Expected only extension menu items" + ); + + let menuItems = menu.getElementsByAttribute("label", "tab_1"); + await closeExtensionContextMenu(menuItems[0]); + await extension.awaitMessage("onClicked_tab_1"); + } + + { + // Tests overrideContext({showDefaults:true})) + info( + "Expecting the menu to be replaced by overrideContext, including default menu items." + ); + let menu = await openContextMenu("a"); + await extension.awaitMessage("oncontextmenu_in_dom_part_2"); + await extension.awaitMessage("onShown"); + + let visibleMenuItemIds = getVisibleChildrenIds(menu); + Assert.deepEqual( + visibleMenuItemIds.slice(0, EXPECTED_EXTENSION_MENU_IDS.length), + EXPECTED_EXTENSION_MENU_IDS, + "Expected extension menu items at the start." + ); + + checkIsLinkMenuItemVisible(visibleMenuItemIds); + + is( + visibleMenuItemIds[visibleMenuItemIds.length - 1], + OTHER_EXTENSION_MENU_ID, + "Other extension menu item should be at the end." + ); + + let menuItems = menu.getElementsByAttribute("label", "tab_2"); + await closeExtensionContextMenu(menuItems[0]); + await extension.awaitMessage("onClicked_tab_2"); + } + + { + // Tests that previous overrideContext call has been forgotten, + // so the default behavior should occur (=move items into submenu). + info( + "Expecting the default menu to be used when overrideContext is not called." + ); + let menu = await openContextMenu("a"); + await extension.awaitMessage("onShown"); + + checkIsLinkMenuItemVisible(getVisibleChildrenIds(menu)); + + let menuItems = menu.getElementsByAttribute("ext-type", "top-level-menu"); + is(menuItems.length, 1, "Expected top-level menu element for extension."); + let topLevelExtensionMenuItem = menuItems[0]; + is( + topLevelExtensionMenuItem.nextSibling, + null, + "Extension menu should be the last element." + ); + + const submenu = await openSubmenu(topLevelExtensionMenuItem); + is(submenu, topLevelExtensionMenuItem.menupopup, "Correct submenu opened"); + + Assert.deepEqual( + getVisibleChildrenIds(submenu), + EXPECTED_EXTENSION_MENU_IDS, + "Extension menu items should be in the submenu by default." + ); + + await closeContextMenu(); + } + + { + // Tests that overrideContext({}) can be used from a listener inside shadow DOM. + let menu = await openContextMenu( + () => this.document.getElementById("shadowHost").shadowRoot.firstChild + ); + await extension.awaitMessage("oncontextmenu_in_shadow_dom"); + await extension.awaitMessage("onShown"); + + Assert.deepEqual( + getVisibleChildrenIds(menu), + EXPECTED_EXTENSION_MENU_IDS, + "Expected only extension menu items after overrideContext({}) in shadow DOM" + ); + + await closeContextMenu(); + } + + { + // Tests overrideContext({showDefaults:true}) on a non-link + info( + "Expecting overrideContext to insert items after the navigation group." + ); + let menu = await openContextMenu("p"); + await extension.awaitMessage("onShown"); + + let visibleMenuItemIds = getVisibleChildrenIds(menu); + if (AppConstants.platform == "macosx") { + // On mac, the items should be at the top: + Assert.deepEqual( + visibleMenuItemIds.slice(0, EXPECTED_EXTENSION_MENU_IDS_NOLINK.length), + EXPECTED_EXTENSION_MENU_IDS_NOLINK, + "Expected extension menu items at the start." + ); + } else { + // Elsewhere, they should be immediately after the navigation group: + Assert.deepEqual( + visibleMenuItemIds.slice( + 0, + 2 + EXPECTED_EXTENSION_MENU_IDS_NOLINK.length + ), + [ + "context-navigation", + "menuseparator", + ...EXPECTED_EXTENSION_MENU_IDS_NOLINK, + ], + "Expected extension menu items immmediately after navigation items." + ); + } + ok( + visibleMenuItemIds.includes("context-savepage"), + "Default menu items should be there." + ); + + is( + visibleMenuItemIds[visibleMenuItemIds.length - 1], + OTHER_EXTENSION_MENU_ID, + "Other extension menu item should be at the end." + ); + + await closeContextMenu(); + } + + // Unloading the extension will automatically close the extension's tab.html + await extension.unload(); + await otherExtension.unload(); +}); + +// Tests some edge cases: +// - overrideContext() is called without any menu registrations, +// followed by a menu registration + menus.refresh.. +// - overrideContext() is called and event.preventDefault() is also +// called to stop the menu from appearing. +// - Open menu again and verify that the default menu behavior occurs. +add_task(async function overrideContext_sidebar_edge_cases() { + function sidebarJs() { + const TIME_BEFORE_MENU_SHOWN = Date.now(); + let count = 0; + // eslint-disable-next-line mozilla/balanced-listeners + document.addEventListener("contextmenu", event => { + ++count; + if (count === 1) { + browser.menus.overrideContext({}); + } else if (count === 2) { + browser.menus.overrideContext({}); + event.preventDefault(); // Prevent menu from being shown. + + // We are not expecting a menu. Wait for the time it took to show and + // hide the previous menu, to check that no new menu appears. + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + setTimeout(() => { + browser.test.sendMessage( + "stop_waiting_for_menu_shown", + "timer_reached" + ); + }, Date.now() - TIME_BEFORE_MENU_SHOWN); + } else if (count === 3) { + // The overrideContext from the previous call should be forgotten. + // Use the default behavior, i.e. show the default menu. + } else { + browser.test.fail(`Unexpected menu count: ${count}`); + } + + browser.test.sendMessage("oncontextmenu_in_dom"); + }); + + browser.menus.onShown.addListener(info => { + browser.test.assertEq("sidebar", info.viewType, "Expected viewType"); + if (count === 1) { + browser.test.assertEq("", info.menuIds.join(","), "Expected no items"); + browser.menus.create({ id: "some_item", title: "some_item" }, () => { + browser.test.sendMessage("onShown_1_and_menu_item_created"); + }); + } else if (count === 2) { + browser.test.fail( + "onShown should not have fired when the menu is not shown." + ); + } else if (count === 3) { + browser.test.assertEq( + "some_item", + info.menuIds.join(","), + "Expected menu item" + ); + browser.test.sendMessage("onShown_3"); + } else { + browser.test.fail(`Unexpected onShown at count: ${count}`); + } + }); + + browser.test.onMessage.addListener(async msg => { + browser.test.assertEq("refresh_menus", msg, "Expected message"); + browser.test.assertEq(1, count, "Expected at first menu test"); + await browser.menus.refresh(); + browser.test.sendMessage("menus_refreshed"); + }); + + browser.menus.onHidden.addListener(() => { + browser.test.sendMessage("onHidden", count); + }); + + browser.test.sendMessage("sidebar_ready"); + } + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", // To automatically show sidebar on load. + manifest: { + permissions: ["menus", "menus.overrideContext"], + sidebar_action: { + default_panel: "sidebar.html", + }, + }, + files: { + "sidebar.html": ` + <!DOCTYPE html><meta charset="utf-8"> + <a href="http://example.com/">Link</a> + <script src="sidebar.js"></script> + `, + "sidebar.js": sidebarJs, + }, + background() { + browser.test.assertThrows( + () => { + browser.menus.overrideContext({ someInvalidParameter: true }); + }, + /Unexpected property "someInvalidParameter"/, + "overrideContext should be available and the parameters be validated." + ); + browser.test.sendMessage("bg_test_done"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("bg_test_done"); + await extension.awaitMessage("sidebar_ready"); + + const EXPECTED_EXTENSION_MENU_ID = `${makeWidgetId( + extension.id + )}-menuitem-_some_item`; + + { + // Checks that a menu can initially be empty and be updated. + info( + "Expecting menu without items to appear and be updated after menus.refresh()" + ); + let menu = await openContextMenuInSidebar("a"); + await extension.awaitMessage("oncontextmenu_in_dom"); + await extension.awaitMessage("onShown_1_and_menu_item_created"); + Assert.deepEqual( + getVisibleChildrenIds(menu), + [], + "Expected no items, initially" + ); + extension.sendMessage("refresh_menus"); + await extension.awaitMessage("menus_refreshed"); + Assert.deepEqual( + getVisibleChildrenIds(menu), + [EXPECTED_EXTENSION_MENU_ID], + "Expected updated menu" + ); + await closeContextMenu(menu); + is(await extension.awaitMessage("onHidden"), 1, "Menu hidden"); + } + + { + // Trigger a context menu. The page has prevented the menu from being + // shown, so the promise should not resolve. + info("Expecting menu to not appear because of event.preventDefault()"); + let popupShowingPromise = openContextMenuInSidebar("a"); + await extension.awaitMessage("oncontextmenu_in_dom"); + is( + await Promise.race([ + extension.awaitMessage("stop_waiting_for_menu_shown"), + popupShowingPromise.then(() => "popup_shown"), + ]), + "timer_reached", + "The menu should not be shown." + ); + } + + { + info( + "Expecting default menu to be shown when the menu is reopened after event.preventDefault()" + ); + let menu = await openContextMenuInSidebar("a"); + await extension.awaitMessage("oncontextmenu_in_dom"); + await extension.awaitMessage("onShown_3"); + let visibleMenuItemIds = getVisibleChildrenIds(menu); + checkIsLinkMenuItemVisible(visibleMenuItemIds); + ok( + visibleMenuItemIds.includes(EXPECTED_EXTENSION_MENU_ID), + "Expected extension menu item" + ); + await closeContextMenu(menu); + is(await extension.awaitMessage("onHidden"), 3, "Menu hidden"); + } + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_menus_replace_menu_context.js b/browser/components/extensions/test/browser/browser_ext_menus_replace_menu_context.js new file mode 100644 index 0000000000..d3180ea487 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_menus_replace_menu_context.js @@ -0,0 +1,475 @@ +/* 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"; + +function getVisibleChildrenIds(menuElem) { + return Array.from(menuElem.children) + .filter(elem => !elem.hidden) + .map(elem => (elem.tagName != "menuseparator" ? elem.id : elem.tagName)); +} + +function checkIsDefaultMenuItemVisible(visibleMenuItemIds) { + // In this whole test file, we open a menu on a link. Assume that all + // default menu items are shown if one link-specific menu item is shown. + ok( + visibleMenuItemIds.includes("context-openlink"), + `The default 'Open Link in New Tab' menu item should be in ${visibleMenuItemIds}.` + ); +} + +// Tests that the context of an extension menu can be changed to: +// - tab +// - bookmark +add_task(async function overrideContext_with_context() { + // Background script of the main test extension and the auxilary other extension. + function background() { + const HTTP_URL = "http://example.com/?SomeTab"; + browser.test.onMessage.addListener(async (msg, tabId) => { + browser.test.assertEq( + "testTabAccess", + msg, + `Expected message in ${browser.runtime.id}` + ); + let tab = await browser.tabs.get(tabId); + if (!tab.url) { + // tabs or activeTab not active. + browser.test.sendMessage("testTabAccessDone", "tab_no_url"); + return; + } + try { + let [url] = await browser.tabs.executeScript(tabId, { + code: "document.URL", + }); + browser.test.assertEq( + HTTP_URL, + url, + "Expected successful executeScript" + ); + browser.test.sendMessage("testTabAccessDone", "executeScript_ok"); + } catch (e) { + browser.test.assertEq( + "Missing host permission for the tab", + e.message, + "Expected error message" + ); + browser.test.sendMessage("testTabAccessDone", "executeScript_failed"); + } + }); + browser.menus.onShown.addListener((info, tab) => { + browser.test.assertEq( + "tab", + info.viewType, + "Expected viewType at onShown" + ); + browser.test.assertEq( + undefined, + info.linkUrl, + "Expected linkUrl at onShown" + ); + browser.test.assertEq( + undefined, + info.srckUrl, + "Expected srcUrl at onShown" + ); + browser.test.sendMessage("onShown", { + menuIds: info.menuIds.sort(), + contexts: info.contexts, + bookmarkId: info.bookmarkId, + pageUrl: info.pageUrl, + frameUrl: info.frameUrl, + tabId: tab && tab.id, + }); + }); + browser.menus.onClicked.addListener((info, tab) => { + browser.test.assertEq( + "tab", + info.viewType, + "Expected viewType at onClicked" + ); + browser.test.assertEq( + undefined, + info.linkUrl, + "Expected linkUrl at onClicked" + ); + browser.test.assertEq( + undefined, + info.srckUrl, + "Expected srcUrl at onClicked" + ); + browser.test.sendMessage("onClicked", { + menuItemId: info.menuItemId, + bookmarkId: info.bookmarkId, + pageUrl: info.pageUrl, + frameUrl: info.frameUrl, + tabId: tab && tab.id, + }); + }); + + // Minimal properties to define menu items for a specific context. + browser.menus.create({ + id: "tab_context", + title: "tab_context", + contexts: ["tab"], + }); + browser.menus.create({ + id: "bookmark_context", + title: "bookmark_context", + contexts: ["bookmark"], + }); + + // documentUrlPatterns in the tab context applies to the tab's URL. + browser.menus.create({ + id: "tab_context_http", + title: "tab_context_http", + contexts: ["tab"], + documentUrlPatterns: [HTTP_URL], + }); + browser.menus.create({ + id: "tab_context_moz_unexpected", + title: "tab_context_moz", + contexts: ["tab"], + documentUrlPatterns: ["moz-extension://*/tab.html"], + }); + // When viewTypes is present, the document's URL is matched instead. + browser.menus.create({ + id: "tab_context_viewType_http_unexpected", + title: "tab_context_viewType_http", + contexts: ["tab"], + viewTypes: ["tab"], + documentUrlPatterns: [HTTP_URL], + }); + browser.menus.create({ + id: "tab_context_viewType_moz", + title: "tab_context_viewType_moz", + contexts: ["tab"], + viewTypes: ["tab"], + documentUrlPatterns: ["moz-extension://*/tab.html"], + }); + + // documentUrlPatterns is not restricting bookmark menu items. + browser.menus.create({ + id: "bookmark_context_http", + title: "bookmark_context_http", + contexts: ["bookmark"], + documentUrlPatterns: [HTTP_URL], + }); + browser.menus.create({ + id: "bookmark_context_moz", + title: "bookmark_context_moz", + contexts: ["bookmark"], + documentUrlPatterns: ["moz-extension://*/tab.html"], + }); + // When viewTypes is present, the document's URL is matched instead. + browser.menus.create({ + id: "bookmark_context_viewType_http_unexpected", + title: "bookmark_context_viewType_http", + contexts: ["bookmark"], + viewTypes: ["tab"], + documentUrlPatterns: [HTTP_URL], + }); + browser.menus.create({ + id: "bookmark_context_viewType_moz", + title: "bookmark_context_viewType_moz", + contexts: ["bookmark"], + viewTypes: ["tab"], + documentUrlPatterns: ["moz-extension://*/tab.html"], + }); + + browser.menus.create({ id: "link_context", title: "link_context" }, () => { + browser.test.sendMessage("menu_items_registered"); + }); + + if (browser.runtime.id === "@menu-test-extension") { + browser.tabs.create({ url: "tab.html" }); + } + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { gecko: { id: "@menu-test-extension" } }, + permissions: ["menus", "menus.overrideContext", "tabs", "bookmarks"], + }, + files: { + "tab.html": ` + <!DOCTYPE html><meta charset="utf-8"> + <a href="http://example.com/">Link</a> + <script src="tab.js"></script> + `, + "tab.js": async () => { + let [tab] = await browser.tabs.query({ + url: "http://example.com/?SomeTab", + }); + let bookmark = await browser.bookmarks.create({ + title: "Bookmark for menu test", + url: "http://example.com/bookmark", + }); + let testCases = [ + { + context: "tab", + tabId: tab.id, + }, + { + context: "tab", + tabId: tab.id, + }, + { + context: "bookmark", + bookmarkId: bookmark.id, + }, + { + context: "tab", + tabId: 123456789, // Some invalid tabId. + }, + ]; + + // eslint-disable-next-line mozilla/balanced-listeners + document.addEventListener("contextmenu", () => { + browser.menus.overrideContext(testCases.shift()); + browser.test.sendMessage("oncontextmenu_in_dom"); + }); + + browser.test.sendMessage("setup_ready", { + bookmarkId: bookmark.id, + tabId: tab.id, + httpUrl: tab.url, + extensionUrl: document.URL, + }); + }, + }, + background, + }); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.com/?SomeTab" + ); + + let otherExtension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { gecko: { id: "@other-test-extension" } }, + permissions: ["menus", "bookmarks", "activeTab"], + }, + background, + }); + await otherExtension.startup(); + await otherExtension.awaitMessage("menu_items_registered"); + + await extension.startup(); + await extension.awaitMessage("menu_items_registered"); + + let { bookmarkId, tabId, httpUrl, extensionUrl } = + await extension.awaitMessage("setup_ready"); + info(`Set up test with tabId=${tabId} and bookmarkId=${bookmarkId}.`); + + { + // Test case 1: context=tab + let menu = await openContextMenu("a"); + await extension.awaitMessage("oncontextmenu_in_dom"); + for (let ext of [extension, otherExtension]) { + info(`Testing menu from ${ext.id} after changing context to tab`); + Assert.deepEqual( + await ext.awaitMessage("onShown"), + { + menuIds: [ + "tab_context", + "tab_context_http", + "tab_context_viewType_moz", + ], + contexts: ["tab"], + bookmarkId: undefined, + pageUrl: undefined, // because extension has no host permissions. + frameUrl: extensionUrl, + tabId, + }, + "Expected onShown details after changing context to tab" + ); + } + let topLevels = menu.getElementsByAttribute("ext-type", "top-level-menu"); + is(topLevels.length, 1, "Expected top-level menu for otherExtension"); + + Assert.deepEqual( + getVisibleChildrenIds(menu), + [ + `${makeWidgetId(extension.id)}-menuitem-_tab_context`, + `${makeWidgetId(extension.id)}-menuitem-_tab_context_http`, + `${makeWidgetId(extension.id)}-menuitem-_tab_context_viewType_moz`, + `menuseparator`, + topLevels[0].id, + ], + "Expected menu items after changing context to tab" + ); + + let submenu = await openSubmenu(topLevels[0]); + is(submenu, topLevels[0].menupopup, "Correct submenu opened"); + + Assert.deepEqual( + getVisibleChildrenIds(submenu), + [ + `${makeWidgetId(otherExtension.id)}-menuitem-_tab_context`, + `${makeWidgetId(otherExtension.id)}-menuitem-_tab_context_http`, + `${makeWidgetId(otherExtension.id)}-menuitem-_tab_context_viewType_moz`, + ], + "Expected menu items in submenu after changing context to tab" + ); + + extension.sendMessage("testTabAccess", tabId); + is( + await extension.awaitMessage("testTabAccessDone"), + "executeScript_failed", + "executeScript should fail due to the lack of permissions." + ); + + otherExtension.sendMessage("testTabAccess", tabId); + is( + await otherExtension.awaitMessage("testTabAccessDone"), + "tab_no_url", + "Other extension should not have activeTab permissions yet." + ); + + // Click on the menu item of the other extension to unlock host permissions. + let menuItems = menu.getElementsByAttribute("label", "tab_context"); + is( + menuItems.length, + 2, + "There are two menu items with label 'tab_context'" + ); + await closeExtensionContextMenu(menuItems[1]); + + Assert.deepEqual( + await otherExtension.awaitMessage("onClicked"), + { + menuItemId: "tab_context", + bookmarkId: undefined, + pageUrl: httpUrl, + frameUrl: extensionUrl, + tabId, + }, + "Expected onClicked details after changing context to tab" + ); + + extension.sendMessage("testTabAccess", tabId); + is( + await extension.awaitMessage("testTabAccessDone"), + "executeScript_failed", + "executeScript of extension that created the menu should still fail." + ); + + otherExtension.sendMessage("testTabAccess", tabId); + is( + await otherExtension.awaitMessage("testTabAccessDone"), + "executeScript_ok", + "Other extension should have activeTab permissions." + ); + } + + { + // Test case 2: context=tab, click on menu item of extension.. + let menu = await openContextMenu("a"); + await extension.awaitMessage("oncontextmenu_in_dom"); + + // The previous test has already verified the visible menu items, + // so we skip checking the onShown result and only test clicking. + await extension.awaitMessage("onShown"); + await otherExtension.awaitMessage("onShown"); + let menuItems = menu.getElementsByAttribute("label", "tab_context"); + is( + menuItems.length, + 2, + "There are two menu items with label 'tab_context'" + ); + await closeExtensionContextMenu(menuItems[0]); + + Assert.deepEqual( + await extension.awaitMessage("onClicked"), + { + menuItemId: "tab_context", + bookmarkId: undefined, + pageUrl: httpUrl, + frameUrl: extensionUrl, + tabId, + }, + "Expected onClicked details after changing context to tab" + ); + + extension.sendMessage("testTabAccess", tabId); + is( + await extension.awaitMessage("testTabAccessDone"), + "executeScript_failed", + "activeTab permission should not be available to the extension that created the menu." + ); + } + + { + // Test case 3: context=bookmark + let menu = await openContextMenu("a"); + await extension.awaitMessage("oncontextmenu_in_dom"); + for (let ext of [extension, otherExtension]) { + info(`Testing menu from ${ext.id} after changing context to bookmark`); + let shownInfo = await ext.awaitMessage("onShown"); + Assert.deepEqual( + shownInfo, + { + menuIds: [ + "bookmark_context", + "bookmark_context_http", + "bookmark_context_moz", + "bookmark_context_viewType_moz", + ], + contexts: ["bookmark"], + bookmarkId, + pageUrl: undefined, + frameUrl: extensionUrl, + tabId: undefined, + }, + "Expected onShown details after changing context to bookmark" + ); + } + let topLevels = menu.getElementsByAttribute("ext-type", "top-level-menu"); + is(topLevels.length, 1, "Expected top-level menu for otherExtension"); + + Assert.deepEqual( + getVisibleChildrenIds(menu), + [ + `${makeWidgetId(extension.id)}-menuitem-_bookmark_context`, + `${makeWidgetId(extension.id)}-menuitem-_bookmark_context_http`, + `${makeWidgetId(extension.id)}-menuitem-_bookmark_context_moz`, + `${makeWidgetId(extension.id)}-menuitem-_bookmark_context_viewType_moz`, + `menuseparator`, + topLevels[0].id, + ], + "Expected menu items after changing context to bookmark" + ); + + let submenu = await openSubmenu(topLevels[0]); + is(submenu, topLevels[0].menupopup, "Correct submenu opened"); + + Assert.deepEqual( + getVisibleChildrenIds(submenu), + [ + `${makeWidgetId(otherExtension.id)}-menuitem-_bookmark_context`, + `${makeWidgetId(otherExtension.id)}-menuitem-_bookmark_context_http`, + `${makeWidgetId(otherExtension.id)}-menuitem-_bookmark_context_moz`, + `${makeWidgetId( + otherExtension.id + )}-menuitem-_bookmark_context_viewType_moz`, + ], + "Expected menu items in submenu after changing context to bookmark" + ); + await closeContextMenu(menu); + } + + { + // Test case 4: context=tab, invalid tabId. + let menu = await openContextMenu("a"); + await extension.awaitMessage("oncontextmenu_in_dom"); + // When an invalid tabId is used, all extension menu logic is skipped and + // the default menu is shown. + checkIsDefaultMenuItemVisible(getVisibleChildrenIds(menu)); + await closeContextMenu(menu); + } + + await extension.unload(); + await otherExtension.unload(); + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_menus_replace_menu_permissions.js b/browser/components/extensions/test/browser/browser_ext_menus_replace_menu_permissions.js new file mode 100644 index 0000000000..c9627c5ae9 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_menus_replace_menu_permissions.js @@ -0,0 +1,220 @@ +/* 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"; + +add_task(async function auto_approve_optional_permissions() { + // Auto-approve optional permission requests, without UI. + await SpecialPowers.pushPrefEnv({ + set: [["extensions.webextOptionalPermissionPrompts", false]], + }); + // TODO: Consider an observer for "webextension-optional-permission-prompt" + // once bug 1493396 is fixed. +}); + +add_task(async function overrideContext_permissions() { + function sidebarJs() { + // If the extension has the right permissions, calling + // menus.overrideContext with one of the following should not throw. + const CONTEXT_OPTIONS_TAB = { context: "tab", tabId: 1 }; + const CONTEXT_OPTIONS_BOOKMARK = { context: "bookmark", bookmarkId: "x" }; + + const E_PERM_TAB = /The "tab" context requires the "tabs" permission/; + const E_PERM_BOOKMARK = + /The "bookmark" context requires the "bookmarks" permission/; + + function assertAllowed(contextOptions) { + try { + let result = browser.menus.overrideContext(contextOptions); + browser.test.assertEq( + undefined, + result, + `Allowed menu for context=${contextOptions.context}` + ); + } catch (e) { + browser.test.fail( + `Unexpected error for context=${contextOptions.context}: ${e}` + ); + } + } + + function assertNotAllowed(contextOptions, expectedError) { + browser.test.assertThrows( + () => { + browser.menus.overrideContext(contextOptions); + }, + expectedError, + `Expected error for context=${contextOptions.context}` + ); + } + + async function requestPermissions(permissions) { + try { + let permPromise; + window.withHandlingUserInputForPermissionRequestTest(() => { + permPromise = browser.permissions.request(permissions); + }); + browser.test.assertTrue( + await permPromise, + `Should have granted ${JSON.stringify(permissions)}` + ); + } catch (e) { + browser.test.fail( + `Failed to use permissions.request(${JSON.stringify( + permissions + )}): ${e}` + ); + } + } + + // The menus.overrideContext method can only be called during a + // "contextmenu" event. So we use a generator to run tests, and yield + // before we call overrideContext after an asynchronous operation. + let testGenerator = (async function* () { + browser.test.assertEq( + undefined, + browser.menus.overrideContext, + "menus.overrideContext requires the 'menus.overrideContext' permission" + ); + await requestPermissions({ permissions: ["menus.overrideContext"] }); + yield; + + // context without required property. + browser.test.assertThrows( + () => { + browser.menus.overrideContext({ context: "tab" }); + }, + /Property "tabId" is required for context "tab"/, + "Required property for context tab" + ); + browser.test.assertThrows( + () => { + browser.menus.overrideContext({ context: "bookmark" }); + }, + /Property "bookmarkId" is required for context "bookmark"/, + "Required property for context bookmarks" + ); + + // context with too many properties. + browser.test.assertThrows( + () => { + browser.menus.overrideContext({ + context: "bookmark", + bookmarkId: "x", + tabId: 1, + }); + }, + /Property "tabId" can only be used with context "tab"/, + "Invalid property for context bookmarks" + ); + browser.test.assertThrows( + () => { + browser.menus.overrideContext({ + context: "bookmark", + bookmarkId: "x", + showDefaults: true, + }); + }, + /Property "showDefaults" cannot be used with context "bookmark"/, + "showDefaults cannot be used with context bookmark" + ); + + // context with right properties, but missing permissions. + assertNotAllowed(CONTEXT_OPTIONS_BOOKMARK, E_PERM_BOOKMARK); + assertNotAllowed(CONTEXT_OPTIONS_TAB, E_PERM_TAB); + + await requestPermissions({ permissions: ["bookmarks"] }); + browser.test.log("Active permissions: bookmarks"); + yield; + + assertAllowed(CONTEXT_OPTIONS_BOOKMARK); + assertNotAllowed(CONTEXT_OPTIONS_TAB, E_PERM_TAB); + + await requestPermissions({ permissions: ["tabs"] }); + await browser.permissions.remove({ permissions: ["bookmarks"] }); + browser.test.log("Active permissions: tabs"); + yield; + + assertNotAllowed(CONTEXT_OPTIONS_BOOKMARK, E_PERM_BOOKMARK); + assertAllowed(CONTEXT_OPTIONS_TAB); + await browser.permissions.remove({ permissions: ["tabs"] }); + browser.test.log("Active permissions: none"); + yield; + + assertNotAllowed(CONTEXT_OPTIONS_TAB, E_PERM_TAB); + + await browser.permissions.remove({ + permissions: ["menus.overrideContext"], + }); + browser.test.assertEq( + undefined, + browser.menus.overrideContext, + "menus.overrideContext is unavailable after revoking the permission" + ); + })(); + + // eslint-disable-next-line mozilla/balanced-listeners + document.addEventListener("contextmenu", async event => { + event.preventDefault(); + try { + let { done } = await testGenerator.next(); + browser.test.sendMessage("continue_test", !done); + } catch (e) { + browser.test.fail(`Unexpected error: ${e} :: ${e.stack}`); + browser.test.sendMessage("continue_test", false); + } + }); + browser.test.sendMessage("sidebar_ready"); + } + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", // To automatically show sidebar on load. + manifest: { + permissions: ["menus"], + optional_permissions: ["menus.overrideContext", "tabs", "bookmarks"], + sidebar_action: { + default_panel: "sidebar.html", + }, + }, + files: { + "sidebar.html": ` + <!DOCTYPE html><meta charset="utf-8"> + <a href="http://example.com/">Link</a> + <script src="sidebar.js"></script> + `, + "sidebar.js": sidebarJs, + }, + }); + await extension.startup(); + await extension.awaitMessage("sidebar_ready"); + + // permissions.request requires user input, export helper. + await SpecialPowers.spawn( + SidebarUI.browser.contentDocument.getElementById("webext-panels-browser"), + [], + () => { + const { ExtensionCommon } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionCommon.sys.mjs" + ); + Cu.exportFunction( + fn => { + return ExtensionCommon.withHandlingUserInput(content, fn); + }, + content, + { + defineAs: "withHandlingUserInputForPermissionRequestTest", + } + ); + } + ); + + do { + info(`Going to trigger "contextmenu" event.`); + await BrowserTestUtils.synthesizeMouseAtCenter( + "a", + { type: "contextmenu" }, + SidebarUI.browser.contentDocument.getElementById("webext-panels-browser") + ); + } while (await extension.awaitMessage("continue_test")); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_menus_targetElement.js b/browser/components/extensions/test/browser/browser_ext_menus_targetElement.js new file mode 100644 index 0000000000..abfbf26a05 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_menus_targetElement.js @@ -0,0 +1,326 @@ +/* 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 PAGE = + "http://mochi.test:8888/browser/browser/components/extensions/test/browser/context.html"; + +// Loads an extension that records menu visibility events in the current tab. +// The returned extension has two helper functions "openContextMenu" and +// "checkIsValid" that are used to verify the behavior of targetElementId. +async function loadExtensionAndTab() { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, PAGE); + gBrowser.selectedTab = tab; + + function contentScript() { + browser.test.onMessage.addListener( + (msg, targetElementId, expectedSelector, description) => { + browser.test.assertEq("checkIsValid", msg, "Expected message"); + + let expected = expectedSelector + ? document.querySelector(expectedSelector) + : null; + let elem = browser.menus.getTargetElement(targetElementId); + browser.test.assertEq(expected, elem, description); + browser.test.sendMessage("checkIsValidDone"); + } + ); + } + + async function background() { + browser.menus.onShown.addListener(async (info, tab) => { + browser.test.sendMessage("onShownMenu", info.targetElementId); + }); + await browser.tabs.executeScript({ file: "contentScript.js" }); + browser.test.sendMessage("ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["menus", "http://mochi.test/*"], + }, + background, + files: { + "contentScript.js": contentScript, + }, + }); + + extension.openAndCloseMenu = async selector => { + await openContextMenu(selector); + let targetElementId = await extension.awaitMessage("onShownMenu"); + await closeContextMenu(); + return targetElementId; + }; + + extension.checkIsValid = async ( + targetElementId, + expectedSelector, + description + ) => { + extension.sendMessage( + "checkIsValid", + targetElementId, + expectedSelector, + description + ); + await extension.awaitMessage("checkIsValidDone"); + }; + + await extension.startup(); + await extension.awaitMessage("ready"); + return { extension, tab }; +} + +// Tests that info.targetElementId is only available with the right permissions. +add_task(async function required_permission() { + let { extension, tab } = await loadExtensionAndTab(); + + // Load another extension to verify that the permission from the first + // extension does not enable the "targetElementId" parameter. + function background() { + browser.contextMenus.onShown.addListener((info, tab) => { + browser.test.assertEq( + undefined, + info.targetElementId, + "targetElementId requires permission" + ); + browser.test.sendMessage("onShown"); + }); + browser.contextMenus.onClicked.addListener(async info => { + browser.test.assertEq( + undefined, + info.targetElementId, + "targetElementId requires permission" + ); + const code = ` + browser.test.assertEq(undefined, browser.menus, "menus API requires permission in content script"); + browser.test.assertEq(undefined, browser.contextMenus, "contextMenus API not available in content script."); + `; + await browser.tabs.executeScript({ code }); + browser.test.sendMessage("onClicked"); + }); + browser.contextMenus.create({ title: "menu for page" }, () => { + browser.test.sendMessage("ready"); + }); + } + let extension2 = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["contextMenus", "http://mochi.test/*"], + }, + background, + }); + await extension2.startup(); + await extension2.awaitMessage("ready"); + + let menu = await openContextMenu(); + await extension.awaitMessage("onShownMenu"); + let menuItem = menu.getElementsByAttribute("label", "menu for page")[0]; + await closeExtensionContextMenu(menuItem); + + await extension2.awaitMessage("onShown"); + await extension2.awaitMessage("onClicked"); + await extension2.unload(); + + await extension.unload(); + BrowserTestUtils.removeTab(tab); +}); + +// Tests that the basic functionality works as expected. +add_task(async function getTargetElement_in_page() { + let { extension, tab } = await loadExtensionAndTab(); + + for (let selector of ["#img1", "#link1", "#password"]) { + let targetElementId = await extension.openAndCloseMenu(selector); + ok( + Number.isInteger(targetElementId), + `targetElementId (${targetElementId}) should be an integer for ${selector}` + ); + + await extension.checkIsValid( + targetElementId, + selector, + `Expected target to match ${selector}` + ); + } + + await extension.unload(); + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function getTargetElement_in_frame() { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, PAGE); + gBrowser.selectedTab = tab; + + async function background() { + let targetElementId; + browser.menus.onShown.addListener(async (info, tab) => { + browser.test.assertTrue( + info.frameUrl.endsWith("context_frame.html"), + `Expected frame ${info.frameUrl}` + ); + targetElementId = info.targetElementId; + let elem = browser.menus.getTargetElement(targetElementId); + browser.test.assertEq( + null, + elem, + "should not find page element in extension's background" + ); + + await browser.tabs.executeScript(tab.id, { + code: `{ + let elem = browser.menus.getTargetElement(${targetElementId}); + browser.test.assertEq(null, elem, "should not find element from different frame"); + }`, + }); + + await browser.tabs.executeScript(tab.id, { + frameId: info.frameId, + code: `{ + let elem = browser.menus.getTargetElement(${targetElementId}); + browser.test.assertEq(document.body, elem, "should find the target element in the frame"); + }`, + }); + browser.test.sendMessage("pageAndFrameChecked"); + }); + + browser.menus.onClicked.addListener(info => { + browser.test.assertEq( + targetElementId, + info.targetElementId, + "targetElementId in onClicked must match onShown." + ); + browser.test.sendMessage("onClickedChecked"); + }); + + browser.menus.create( + { title: "menu for frame", contexts: ["frame"] }, + () => { + browser.test.sendMessage("ready"); + } + ); + } + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["menus", "http://mochi.test/*"], + }, + background, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + + let menu = await openContextMenuInFrame(); + await extension.awaitMessage("pageAndFrameChecked"); + let menuItem = menu.getElementsByAttribute("label", "menu for frame")[0]; + await closeExtensionContextMenu(menuItem); + await extension.awaitMessage("onClickedChecked"); + + await extension.unload(); + BrowserTestUtils.removeTab(tab); +}); + +// Test that getTargetElement does not return a detached element. +add_task(async function getTargetElement_after_removing_element() { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, PAGE); + + function background() { + function contentScript(targetElementId) { + let expectedElem = document.getElementById("edit-me"); + let { nextElementSibling } = expectedElem; + + let elem = browser.menus.getTargetElement(targetElementId); + browser.test.assertEq( + expectedElem, + elem, + "Expected target element before element removal" + ); + + expectedElem.remove(); + elem = browser.menus.getTargetElement(targetElementId); + browser.test.assertEq( + null, + elem, + "Expected no target element after element removal." + ); + + nextElementSibling.insertAdjacentElement("beforebegin", expectedElem); + elem = browser.menus.getTargetElement(targetElementId); + browser.test.assertEq( + expectedElem, + elem, + "Expected target element after element restoration." + ); + } + browser.menus.onClicked.addListener(async (info, tab) => { + const code = `(${contentScript})(${info.targetElementId})`; + browser.test.log(code); + await browser.tabs.executeScript(tab.id, { code }); + browser.test.sendMessage("checkedRemovedElement"); + }); + browser.menus.create({ title: "some menu item" }, () => { + browser.test.sendMessage("ready"); + }); + } + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["menus", "http://mochi.test/*"], + }, + background, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + let menu = await openContextMenu("#edit-me"); + let menuItem = menu.getElementsByAttribute("label", "some menu item")[0]; + await closeExtensionContextMenu(menuItem); + await extension.awaitMessage("checkedRemovedElement"); + await extension.unload(); + BrowserTestUtils.removeTab(tab); +}); + +// Tests whether targetElementId expires after opening a new menu. +add_task(async function expireTargetElement() { + let { extension, tab } = await loadExtensionAndTab(); + + // Open the menu once to get the first element ID. + let targetElementId = await extension.openAndCloseMenu("#longtext"); + + // Open another menu. The previous ID should expire. + await extension.openAndCloseMenu("#longtext"); + await extension.checkIsValid( + targetElementId, + null, + `Expected initial target ID to expire after opening another menu` + ); + + await extension.unload(); + BrowserTestUtils.removeTab(tab); +}); + +// Tests whether targetElementId of different tabs are independent. +add_task(async function independentMenusInDifferentTabs() { + let { extension, tab } = await loadExtensionAndTab(); + + let targetElementId = await extension.openAndCloseMenu("#longtext"); + + let tab2 = await BrowserTestUtils.openNewForegroundTab(gBrowser, PAGE + "?"); + gBrowser.selectedTab = tab2; + + let targetElementId2 = await extension.openAndCloseMenu("#editabletext"); + + await extension.checkIsValid( + targetElementId2, + null, + "targetElementId from different tab should not resolve." + ); + await extension.checkIsValid( + targetElementId, + "#longtext", + "Expected getTargetElement to work after closing a menu in another tab." + ); + + await extension.unload(); + BrowserTestUtils.removeTab(tab); + BrowserTestUtils.removeTab(tab2); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_menus_targetElement_extension.js b/browser/components/extensions/test/browser/browser_ext_menus_targetElement_extension.js new file mode 100644 index 0000000000..4712ab7b1d --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_menus_targetElement_extension.js @@ -0,0 +1,198 @@ +/* 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"; + +add_task(async function getTargetElement_in_extension_tab() { + async function background() { + browser.menus.onShown.addListener(info => { + let elem = browser.menus.getTargetElement(info.targetElementId); + browser.test.assertEq( + null, + elem, + "should not get element of tab content in background" + ); + + // By using getViews() here, we verify that the targetElementId can + // synchronously be mapped to a valid element in a different tab + // during the onShown event. + let [tabGlobal] = browser.extension.getViews({ type: "tab" }); + elem = tabGlobal.browser.menus.getTargetElement(info.targetElementId); + browser.test.assertEq( + "BUTTON", + elem.tagName, + "should get element in tab content" + ); + browser.test.sendMessage("elementChecked"); + }); + + browser.tabs.create({ url: "tab.html" }); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["menus"], + }, + files: { + "tab.html": `<!DOCTYPE html><meta charset="utf-8"><button>Button in tab</button>`, + }, + background, + }); + + let extensionTabPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + null, + true + ); + await extension.startup(); + // Must wait for the tab to have loaded completely before calling openContextMenu. + await extensionTabPromise; + await openContextMenu("button"); + await extension.awaitMessage("elementChecked"); + await closeContextMenu(); + + // Unloading the extension will automatically close the extension's tab.html + await extension.unload(); +}); + +add_task(async function getTargetElement_in_extension_tab_on_click() { + // Similar to getTargetElement_in_extension_tab, except we check whether + // calling getTargetElement in onClicked results in the expected behavior. + async function background() { + browser.menus.onClicked.addListener(info => { + let [tabGlobal] = browser.extension.getViews({ type: "tab" }); + let elem = tabGlobal.browser.menus.getTargetElement(info.targetElementId); + browser.test.assertEq( + "BUTTON", + elem.tagName, + "should get element in tab content on click" + ); + browser.test.sendMessage("elementClicked"); + }); + + browser.menus.create({ title: "click here" }, () => { + browser.tabs.create({ url: "tab.html" }); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["menus"], + }, + files: { + "tab.html": `<!DOCTYPE html><meta charset="utf-8"><button>Button in tab</button>`, + }, + background, + }); + + let extensionTabPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + null, + true + ); + await extension.startup(); + await extensionTabPromise; + let menu = await openContextMenu("button"); + let menuItem = menu.getElementsByAttribute("label", "click here")[0]; + await closeExtensionContextMenu(menuItem); + await extension.awaitMessage("elementClicked"); + + await extension.unload(); +}); + +add_task(async function getTargetElement_in_browserAction_popup() { + async function background() { + browser.menus.onShown.addListener(info => { + let elem = browser.menus.getTargetElement(info.targetElementId); + browser.test.assertEq( + null, + elem, + "should not get element of popup content in background" + ); + + let [popupGlobal] = browser.extension.getViews({ type: "popup" }); + elem = popupGlobal.browser.menus.getTargetElement(info.targetElementId); + browser.test.assertEq( + "BUTTON", + elem.tagName, + "should get element in popup content" + ); + browser.test.sendMessage("popupChecked"); + }); + + // Ensure that onShown is registered (workaround for bug 1300234): + await browser.menus.removeAll(); + browser.test.sendMessage("ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["menus"], + browser_action: { + default_popup: "popup.html", + default_area: "navbar", + }, + }, + files: { + "popup.html": `<!DOCTYPE html><meta charset="utf-8"><button>Button in popup</button>`, + }, + background, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + + await clickBrowserAction(extension); + await openContextMenuInPopup(extension, "button"); + await extension.awaitMessage("popupChecked"); + await closeContextMenu(); + await closeBrowserAction(extension); + + await extension.unload(); +}); + +add_task(async function getTargetElement_in_sidebar_panel() { + async function sidebarJs() { + browser.menus.onShown.addListener(info => { + let expected = document.querySelector("button"); + let elem = browser.menus.getTargetElement(info.targetElementId); + browser.test.assertEq( + expected, + elem, + "should get element in sidebar content" + ); + browser.test.sendMessage("done"); + }); + + // Ensure that onShown is registered (workaround for bug 1300234): + await browser.menus.removeAll(); + browser.test.sendMessage("ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", // To automatically show sidebar on load. + manifest: { + permissions: ["menus"], + sidebar_action: { + default_panel: "sidebar.html", + }, + }, + files: { + "sidebar.html": ` + <!DOCTYPE html><meta charset="utf-8"> + <button>Button in sidebar</button> + <script src="sidebar.js"></script> + `, + "sidebar.js": sidebarJs, + }, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + + let sidebarMenu = await openContextMenuInSidebar("button"); + await extension.awaitMessage("done"); + await closeContextMenu(sidebarMenu); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_menus_targetElement_shadow.js b/browser/components/extensions/test/browser/browser_ext_menus_targetElement_shadow.js new file mode 100644 index 0000000000..115f4fe96a --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_menus_targetElement_shadow.js @@ -0,0 +1,108 @@ +/* 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 PAGE = + "http://mochi.test:8888/browser/browser/components/extensions/test/browser/context.html"; + +add_task(async function menuInShadowDOM() { + Services.prefs.setBoolPref("security.allow_eval_with_system_principal", true); + registerCleanupFunction(() => { + Services.prefs.clearUserPref("security.allow_eval_with_system_principal"); + }); + + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, PAGE); + gBrowser.selectedTab = tab; + + async function background() { + browser.menus.onShown.addListener(async (info, tab) => { + browser.test.assertTrue( + Number.isInteger(info.targetElementId), + `${info.targetElementId} should be an integer` + ); + browser.test.assertEq( + "all,link", + info.contexts.sort().join(","), + "Expected context" + ); + browser.test.assertEq( + "http://example.com/?shadowlink", + info.linkUrl, + "Menu target should be a link in the shadow DOM" + ); + + let code = `{ + try { + let elem = browser.menus.getTargetElement(${info.targetElementId}); + browser.test.assertTrue(elem, "Shadow element must be found"); + browser.test.assertEq("http://example.com/?shadowlink", elem.href, "Element is a link in shadow DOM " - elem.outerHTML); + } catch (e) { + browser.test.fail("Unexpected error in getTargetElement: " + e); + } + }`; + await browser.tabs.executeScript(tab.id, { code }); + browser.test.sendMessage( + "onShownMenuAndCheckedInfo", + info.targetElementId + ); + }); + + // Ensure that onShown is registered (workaround for bug 1300234): + await browser.menus.removeAll(); + browser.test.sendMessage("ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["menus", "http://mochi.test/*"], + }, + background, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + + async function testShadowMenu(setupMenuTarget) { + await openContextMenu(setupMenuTarget); + await extension.awaitMessage("onShownMenuAndCheckedInfo"); + await closeContextMenu(); + } + + info("Clicking in open shadow root"); + await testShadowMenu(() => { + let doc = this.document; + doc.body.innerHTML = `<div></div>`; + let host = doc.body.firstElementChild.attachShadow({ mode: "open" }); + host.innerHTML = `<a href="http://example.com/?shadowlink">Test open</a>`; + this.document.testTarget = host.firstElementChild; + return this.document.testTarget; + }); + + info("Clicking in closed shadow root"); + await testShadowMenu(() => { + let doc = this.document; + doc.body.innerHTML = `<div></div>`; + let host = doc.body.firstElementChild.attachShadow({ mode: "closed" }); + host.innerHTML = `<a href="http://example.com/?shadowlink">Test closed</a>`; + this.document.testTarget = host.firstElementChild; + return this.document.testTarget; + }); + + info("Clicking in nested shadow DOM"); + await testShadowMenu(() => { + let doc = this.document; + let host; + for (let container = doc.body, i = 0; i < 10; ++i) { + container.innerHTML = `<div id="level"></div>`; + host = container.firstElementChild.attachShadow({ mode: "open" }); + container = host; + } + host.innerHTML = `<a href="http://example.com/?shadowlink">Test nested</a>`; + this.document.testTarget = host.firstElementChild; + return this.document.testTarget; + }); + + await extension.unload(); + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_menus_viewType.js b/browser/components/extensions/test/browser/browser_ext_menus_viewType.js new file mode 100644 index 0000000000..bb59e0fffd --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_menus_viewType.js @@ -0,0 +1,122 @@ +/* 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"; + +// browser_ext_menus_events.js provides some coverage for viewTypes in normal +// tabs and extension popups. +// This test provides coverage for extension tabs and sidebars, as well as +// using the viewTypes property in menus.create and menus.update. + +add_task(async function extension_tab_viewType() { + async function background() { + browser.menus.onShown.addListener(info => { + browser.test.assertEq( + "tabonly", + info.menuIds.join(","), + "Expected menu items" + ); + browser.test.sendMessage("shown"); + }); + browser.menus.onClicked.addListener(info => { + browser.test.assertEq("tab", info.viewType, "Expected viewType"); + browser.test.sendMessage("clicked"); + }); + + browser.menus.create({ + id: "sidebaronly", + title: "sidebar-only", + viewTypes: ["sidebar"], + }); + browser.menus.create( + { id: "tabonly", title: "click here", viewTypes: ["tab"] }, + () => { + browser.tabs.create({ url: "tab.html" }); + } + ); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["menus"], + }, + files: { + "tab.html": `<!DOCTYPE html><meta charset="utf-8"><script src="tab.js"></script>`, + "tab.js": `browser.test.sendMessage("ready");`, + }, + background, + }); + + let extensionTabPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + null, + true + ); + await extension.startup(); + await extension.awaitMessage("ready"); + await extensionTabPromise; + let menu = await openContextMenu(); + await extension.awaitMessage("shown"); + + let menuItem = menu.getElementsByAttribute("label", "click here")[0]; + await closeExtensionContextMenu(menuItem); + await extension.awaitMessage("clicked"); + + // Unloading the extension will automatically close the extension's tab.html + await extension.unload(); +}); + +add_task(async function sidebar_panel_viewType() { + async function sidebarJs() { + browser.menus.onShown.addListener(info => { + browser.test.assertEq( + "sidebaronly", + info.menuIds.join(","), + "Expected menu items" + ); + browser.test.assertEq("sidebar", info.viewType, "Expected viewType"); + browser.test.sendMessage("shown"); + }); + + // Create menus and change their viewTypes using menus.update. + browser.menus.create({ + id: "sidebaronly", + title: "sidebaronly", + viewTypes: ["tab"], + }); + browser.menus.create({ + id: "tabonly", + title: "tabonly", + viewTypes: ["sidebar"], + }); + await browser.menus.update("sidebaronly", { viewTypes: ["sidebar"] }); + await browser.menus.update("tabonly", { viewTypes: ["tab"] }); + browser.test.sendMessage("ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", // To automatically show sidebar on load. + manifest: { + permissions: ["menus"], + sidebar_action: { + default_panel: "sidebar.html", + }, + }, + files: { + "sidebar.html": ` + <!DOCTYPE html><meta charset="utf-8"> + <script src="sidebar.js"></script> + `, + "sidebar.js": sidebarJs, + }, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + + let sidebarMenu = await openContextMenuInSidebar(); + await extension.awaitMessage("shown"); + await closeContextMenu(sidebarMenu); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_menus_visible.js b/browser/components/extensions/test/browser/browser_ext_menus_visible.js new file mode 100644 index 0000000000..cf9718fddc --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_menus_visible.js @@ -0,0 +1,95 @@ +/* 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 PAGE = + "http://mochi.test:8888/browser/browser/components/extensions/test/browser/context.html"; + +add_task(async function visible_false() { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, PAGE); + + async function background() { + browser.menus.onShown.addListener(info => { + browser.test.assertEq( + "[]", + JSON.stringify(info.menuIds), + "Expected no menu items" + ); + browser.test.sendMessage("done"); + }); + browser.menus.create({ + id: "create-visible-false", + title: "invisible menu item", + visible: false, + }); + browser.menus.create({ + id: "update-without-params", + title: "invisible menu item", + visible: false, + }); + await browser.menus.update("update-without-params", {}); + browser.menus.create({ + id: "update-visible-to-false", + title: "initially visible menu item", + }); + await browser.menus.update("update-visible-to-false", { visible: false }); + browser.test.sendMessage("ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["menus"], + }, + background, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + await openContextMenu(); + await extension.awaitMessage("done"); + await closeContextMenu(); + + await extension.unload(); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function visible_true() { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, PAGE); + + async function background() { + browser.menus.onShown.addListener(info => { + browser.test.assertEq( + `["update-to-true"]`, + JSON.stringify(info.menuIds), + "Expected no menu items" + ); + browser.test.sendMessage("done"); + }); + browser.menus.create({ + id: "update-to-true", + title: "invisible menu item", + visible: false, + }); + await browser.menus.update("update-to-true", { visible: true }); + browser.test.sendMessage("ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["menus"], + }, + background, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + await openContextMenu(); + await extension.awaitMessage("done"); + await closeContextMenu(); + + await extension.unload(); + + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_mousewheel_zoom.js b/browser/components/extensions/test/browser/browser_ext_mousewheel_zoom.js new file mode 100644 index 0000000000..d558400a7e --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_mousewheel_zoom.js @@ -0,0 +1,186 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +// Extensions can be loaded in 3 ways: as a sidebar, as a browser action, +// or as a page action. We use these constants to alter the extension +// manifest, content script, background script, and setup conventions. +const TESTS = { + SIDEBAR: "sidebar", + BROWSER_ACTION: "browserAction", + PAGE_ACTION: "pageAction", +}; + +function promiseBrowserReflow(browser) { + return SpecialPowers.spawn(browser, [], async function () { + return new Promise(resolve => { + content.window.requestAnimationFrame(() => { + content.window.requestAnimationFrame(resolve); + }); + }); + }); +} + +async function promiseBrowserZoom(browser, extension) { + await promiseBrowserReflow(browser); + await BrowserTestUtils.synthesizeMouseAtCenter( + "html", + { type: "mousedown", button: 0 }, + browser + ); + return extension.awaitMessage("zoom"); +} + +async function test_mousewheel_zoom(test) { + info(`Starting test of ${test} extension.`); + let browser; + + // Scroll on Ctrl + mousewheel + SpecialPowers.pushPrefEnv({ set: [["mousewheel.with_control.action", 3]] }); + + function contentScript() { + // eslint-disable-next-line mozilla/balanced-listeners + document.addEventListener("mousedown", e => { + // Send the zoom level back as a "zoom" message. + const zoom = SpecialPowers.getFullZoom(window).toFixed(2); + browser.test.sendMessage("zoom", zoom); + }); + } + + function sidebarContentScript() { + // eslint-disable-next-line mozilla/balanced-listeners + document.addEventListener("mousedown", e => { + // Send the zoom level back as a "zoom" message. + const zoom = SpecialPowers.getFullZoom(window).toFixed(2); + browser.test.sendMessage("zoom", zoom); + }); + browser.test.sendMessage("content-loaded"); + } + + function pageActionBackgroundScript() { + browser.tabs.query({ active: true, currentWindow: true }, tabs => { + const tabId = tabs[0].id; + + browser.pageAction.show(tabId).then(() => { + browser.test.sendMessage("content-loaded"); + }); + }); + } + + let manifest; + if (test == TESTS.SIDEBAR) { + manifest = { + sidebar_action: { + default_panel: "panel.html", + }, + }; + } else if (test == TESTS.BROWSER_ACTION) { + manifest = { + browser_action: { + default_popup: "panel.html", + default_area: "navbar", + }, + }; + } else if (test == TESTS.PAGE_ACTION) { + manifest = { + page_action: { + default_popup: "panel.html", + }, + }; + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest, + useAddonManager: "temporary", + files: { + "panel.html": ` + <!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + <script src="panel.js"></script> + </head> + <body> + <h1>Please Zoom Me</h1> + </body> + </html> + `, + "panel.js": test == TESTS.SIDEBAR ? sidebarContentScript : contentScript, + }, + background: + test == TESTS.PAGE_ACTION ? pageActionBackgroundScript : undefined, + }); + + await extension.startup(); + info("Awaiting notification that extension has loaded."); + + if (test == TESTS.SIDEBAR) { + await extension.awaitMessage("content-loaded"); + + const sidebar = document.getElementById("sidebar-box"); + ok(!sidebar.hidden, "Sidebar box is visible"); + + browser = SidebarUI.browser.contentWindow.gBrowser.selectedBrowser; + } else if (test == TESTS.BROWSER_ACTION) { + browser = await openBrowserActionPanel(extension, undefined, true); + } else if (test == TESTS.PAGE_ACTION) { + await extension.awaitMessage("content-loaded"); + + clickPageAction(extension, window); + + browser = await awaitExtensionPanel(extension); + } + + info(`Requesting initial zoom from ${test} extension.`); + let initialZoom = await promiseBrowserZoom(browser, extension); + info(`Extension (${test}) initial zoom is ${initialZoom}.`); + + // Attempt to change the zoom of the extension with a mousewheel event. + await BrowserTestUtils.synthesizeMouseAtCenter( + "html", + { + wheel: true, + ctrlKey: true, + deltaY: -1, + deltaMode: WheelEvent.DOM_DELTA_LINE, + }, + browser + ); + + info(`Requesting changed zoom from ${test} extension.`); + let changedZoom = await promiseBrowserZoom(browser, extension); + info(`Extension (${test}) changed zoom is ${changedZoom}.`); + isnot( + changedZoom, + initialZoom, + `Extension (${test}) zoom was changed as expected.` + ); + + // Attempt to restore the zoom of the extension with a mousewheel event. + await BrowserTestUtils.synthesizeMouseAtCenter( + "html", + { + wheel: true, + ctrlKey: true, + deltaY: 1, + deltaMode: WheelEvent.DOM_DELTA_LINE, + }, + browser + ); + + info(`Requesting changed zoom from ${test} extension.`); + let finalZoom = await promiseBrowserZoom(browser, extension); + is( + finalZoom, + initialZoom, + `Extension (${test}) zoom was restored as expected.` + ); + + await extension.unload(); +} + +// Actually trigger the tests. Bind test_mousewheel_zoom each time so we +// capture the test type. +for (const t in TESTS) { + add_task(test_mousewheel_zoom.bind(this, TESTS[t])); +} diff --git a/browser/components/extensions/test/browser/browser_ext_nontab_process_switch.js b/browser/components/extensions/test/browser/browser_ext_nontab_process_switch.js new file mode 100644 index 0000000000..56511d1a7d --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_nontab_process_switch.js @@ -0,0 +1,154 @@ +"use strict"; + +add_task(async function process_switch_in_sidebars_popups() { + await SpecialPowers.pushPrefEnv({ + set: [["extensions.content_web_accessible.enabled", true]], + }); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", // To automatically show sidebar on load. + manifest: { + content_scripts: [ + { + matches: ["http://example.com/*"], + js: ["cs.js"], + }, + ], + + sidebar_action: { + default_panel: "page.html?sidebar", + }, + browser_action: { + default_popup: "page.html?popup", + default_area: "navbar", + }, + web_accessible_resources: ["page.html"], + }, + files: { + "page.html": `<!DOCTYPE html><meta charset=utf-8><script src=page.js></script>`, + async "page.js"() { + browser.test.sendMessage("extension_page", { + place: location.search, + pid: await SpecialPowers.spawnChrome([], () => { + return windowGlobalParent.osPid; + }), + }); + if (!location.search.endsWith("_back")) { + window.location.href = "http://example.com/" + location.search; + } + }, + + async "cs.js"() { + browser.test.sendMessage("content_script", { + url: location.href, + pid: await this.wrappedJSObject.SpecialPowers.spawnChrome([], () => { + return windowGlobalParent.osPid; + }), + }); + if (location.search === "?popup") { + window.location.href = + browser.runtime.getURL("page.html") + "?popup_back"; + } + }, + }, + }); + + await extension.startup(); + + let sidebar = await extension.awaitMessage("extension_page"); + is(sidebar.place, "?sidebar", "Message from the extension sidebar"); + + let cs1 = await extension.awaitMessage("content_script"); + is(cs1.url, "http://example.com/?sidebar", "CS on example.com in sidebar"); + isnot(sidebar.pid, cs1.pid, "Navigating to example.com changed process"); + + await clickBrowserAction(extension); + let popup = await extension.awaitMessage("extension_page"); + is(popup.place, "?popup", "Message from the extension popup"); + + let cs2 = await extension.awaitMessage("content_script"); + is(cs2.url, "http://example.com/?popup", "CS on example.com in popup"); + isnot(popup.pid, cs2.pid, "Navigating to example.com changed process"); + + let popup2 = await extension.awaitMessage("extension_page"); + is(popup2.place, "?popup_back", "Back at extension page in popup"); + is(popup.pid, popup2.pid, "Same process as original popup page"); + + is(sidebar.pid, popup.pid, "Sidebar and popup pages from the same process"); + + // There's no guarantee that two (independent) pages from the same domain will + // end up in the same process. + + await closeBrowserAction(extension); + await extension.unload(); +}); + +// Test that navigating the browserAction popup between extension pages doesn't keep the +// parser blocked (See Bug 1747813). +add_task( + async function test_navigate_browserActionPopups_shouldnot_block_parser() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_action: { + default_popup: "popup-1.html", + default_area: "navbar", + }, + }, + files: { + "popup-1.html": `<!DOCTYPE html><meta charset=utf-8><script src=popup-1.js></script><h1>Popup 1</h1>`, + "popup-2.html": `<!DOCTYPE html><meta charset=utf-8><script src=popup-2.js></script><h1>Popup 2</h1>`, + + "popup-1.js": function () { + browser.test.onMessage.addListener(msg => { + if (msg !== "navigate-popup") { + browser.test.fail(`Unexpected test message "${msg}"`); + return; + } + location.href = "/popup-2.html"; + }); + window.onload = () => browser.test.sendMessage("popup-page-1"); + }, + + "popup-2.js": function () { + window.onload = () => browser.test.sendMessage("popup-page-2"); + }, + }, + }); + + // Make sure the mouse isn't hovering over the browserAction widget. + EventUtils.synthesizeMouseAtCenter( + gURLBar.textbox, + { type: "mouseover" }, + window + ); + + await extension.startup(); + + // Triggers popup preload (otherwise we wouldn't be blocking the parser for the browserAction popup + // and the issue wouldn't be triggered, a real user on the contrary has a pretty high chance to trigger a + // preload while hovering the browserAction popup before opening the popup with a click). + let widget = getBrowserActionWidget(extension).forWindow(window); + EventUtils.synthesizeMouseAtCenter( + widget.node, + { type: "mouseover" }, + window + ); + await clickBrowserAction(extension); + + await extension.awaitMessage("popup-page-1"); + + extension.sendMessage("navigate-popup"); + + await extension.awaitMessage("popup-page-2"); + // If the bug is triggered (e.g. it did regress), the test will get stuck waiting for + // the test message "popup-page-2" (which will never be sent because the extension page + // script isn't executed while the parser is blocked). + ok( + true, + "Extension browserAction popup successfully navigated to popup-page-2.html" + ); + + await closeBrowserAction(extension); + await extension.unload(); + } +); diff --git a/browser/components/extensions/test/browser/browser_ext_omnibox.js b/browser/components/extensions/test/browser/browser_ext_omnibox.js new file mode 100644 index 0000000000..f7c27af14d --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_omnibox.js @@ -0,0 +1,504 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { UrlbarTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/UrlbarTestUtils.sys.mjs" +); + +const keyword = "VeryUniqueKeywordThatDoesNeverMatchAnyTestUrl"; + +// This test does a lot. To ease debugging, we'll sometimes print the lines. +function getCallerLines() { + const lines = Array.from( + new Error().stack.split("\n").slice(1), + line => /browser_ext_omnibox.js:(\d+):\d+$/.exec(line)?.[1] + ); + return "Caller lines: " + lines.filter(lineno => lineno != null).join(", "); +} + +add_setup(async () => { + // Override default timeout of 3000 ms, to make sure that the test progresses + // reasonably quickly. See comment in "function waitForResult" below. + // In this whole test, we respond ASAP to omnibox.onInputChanged events, so + // it should be safe to choose a relatively low timeout. + await SpecialPowers.pushPrefEnv({ + set: [["browser.urlbar.extension.omnibox.timeout", 500]], + }); +}); + +add_task(async function () { + // This keyword needs to be unique to prevent history entries from unrelated + // tests from appearing in the suggestions list. + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + omnibox: { + keyword: keyword, + }, + }, + + background: function () { + browser.omnibox.onInputStarted.addListener(() => { + browser.test.sendMessage("on-input-started-fired"); + }); + + let synchronous = true; + let suggestions = null; + let suggestCallback = null; + + browser.omnibox.onInputChanged.addListener((text, suggest) => { + if (synchronous && suggestions) { + suggest(suggestions); + } else { + suggestCallback = suggest; + } + browser.test.sendMessage("on-input-changed-fired", { text }); + }); + + browser.omnibox.onInputCancelled.addListener(() => { + browser.test.sendMessage("on-input-cancelled-fired"); + }); + + browser.omnibox.onInputEntered.addListener((text, disposition) => { + browser.test.sendMessage("on-input-entered-fired", { + text, + disposition, + }); + }); + + browser.omnibox.onDeleteSuggestion.addListener(text => { + browser.test.sendMessage("on-delete-suggestion-fired", { text }); + }); + + browser.test.onMessage.addListener((msg, data) => { + switch (msg) { + case "set-suggestions": + suggestions = data.suggestions; + browser.test.sendMessage("suggestions-set"); + break; + case "set-default-suggestion": + browser.omnibox.setDefaultSuggestion(data.suggestion); + browser.test.sendMessage("default-suggestion-set"); + break; + case "set-synchronous": + synchronous = data.synchronous; + browser.test.sendMessage("set-synchronous-set"); + break; + case "test-multiple-suggest-calls": + suggestions.forEach(suggestion => suggestCallback([suggestion])); + browser.test.sendMessage("test-ready"); + break; + case "test-suggestions-after-delay": + Promise.resolve().then(() => { + suggestCallback(suggestions); + browser.test.sendMessage("test-ready"); + }); + break; + } + }); + }, + }); + + async function expectEvent(event, expected) { + info(`Waiting for event: ${event} (${getCallerLines()})`); + let actual = await extension.awaitMessage(event); + if (!expected) { + ok(true, `Expected "${event} to have fired."`); + return; + } + if (expected.text != undefined) { + is( + actual.text, + expected.text, + `Expected "${event}" to have fired with text: "${expected.text}".` + ); + } + if (expected.disposition) { + is( + actual.disposition, + expected.disposition, + `Expected "${event}" to have fired with disposition: "${expected.disposition}".` + ); + } + } + + async function waitForResult(index) { + info(`waitForResult (${getCallerLines()})`); + // When omnibox.onInputChanged is triggered, the "startQuery" method in + // UrlbarProviderOmnibox.sys.mjs's startQuery will wait for a fixed amount + // of time before releasing the promise, which we observe by the call to + // UrlbarTestUtils here. + // + // To reduce the time that the test takes, we lower this in add_setup, by + // overriding the browser.urlbar.extension.omnibox.timeout preference. + // + // While this is not specific to the "waitForResult" test helper here, the + // issue is only observed in waitForResult because it is usually the first + // method called after observing "on-input-changed-fired". + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, index); + + // Ensure the addition is complete, for proper mouse events on the entries. + await new Promise(resolve => + window.requestIdleCallback(resolve, { timeout: 1000 }) + ); + return result; + } + + async function promiseClickOnItem(index, details) { + // The Address Bar panel is animated and updated on a timer, thus it may not + // yet be listening to events when we try to click on it. This uses a + // polling strategy to repeat the click, if it doesn't go through. + let clicked = false; + let element = await UrlbarTestUtils.waitForAutocompleteResultAt( + window, + index + ); + element.addEventListener( + "mousedown", + () => { + clicked = true; + }, + { once: true } + ); + while (!clicked) { + EventUtils.synthesizeMouseAtCenter(element, details); + await new Promise(r => window.requestIdleCallback(r, { timeout: 1000 })); + } + } + + let inputSessionSerial = 0; + async function startInputSession() { + gURLBar.focus(); + gURLBar.value = keyword; + EventUtils.sendString(" "); + await expectEvent("on-input-started-fired"); + // Always use a different input at every invokation, so that + // waitForResult can distinguish different cases. + let char = (inputSessionSerial++ % 10).toString(); + EventUtils.sendString(char); + + await expectEvent("on-input-changed-fired", { text: char }); + return char; + } + + async function testInputEvents() { + gURLBar.focus(); + + // Start an input session by typing in <keyword><space>. + EventUtils.sendString(keyword + " "); + await expectEvent("on-input-started-fired"); + + // Test canceling the input before any changed events fire. + EventUtils.synthesizeKey("KEY_Backspace"); + await expectEvent("on-input-cancelled-fired"); + + EventUtils.sendString(" "); + await expectEvent("on-input-started-fired"); + + // Test submitting the input before any changed events fire. + EventUtils.synthesizeKey("KEY_Enter"); + await expectEvent("on-input-entered-fired"); + + gURLBar.focus(); + + // Start an input session by typing in <keyword><space>. + EventUtils.sendString(keyword + " "); + await expectEvent("on-input-started-fired"); + + // We should expect input changed events now that the keyword is active. + EventUtils.sendString("b"); + await expectEvent("on-input-changed-fired", { text: "b" }); + + EventUtils.sendString("c"); + await expectEvent("on-input-changed-fired", { text: "bc" }); + + EventUtils.synthesizeKey("KEY_Backspace"); + await expectEvent("on-input-changed-fired", { text: "b" }); + + // Even though the input is <keyword><space> We should not expect an + // input started event to fire since the keyword is active. + EventUtils.synthesizeKey("KEY_Backspace"); + await expectEvent("on-input-changed-fired", { text: "" }); + + // Make the keyword inactive by hitting backspace. + EventUtils.synthesizeKey("KEY_Backspace"); + await expectEvent("on-input-cancelled-fired"); + + // Activate the keyword by typing a space. + // Expect onInputStarted to fire. + EventUtils.sendString(" "); + await expectEvent("on-input-started-fired"); + + // onInputChanged should fire even if a space is entered. + EventUtils.sendString(" "); + await expectEvent("on-input-changed-fired", { text: " " }); + + // The active session should cancel if the input blurs. + gURLBar.blur(); + await expectEvent("on-input-cancelled-fired"); + } + + async function testSuggestionDeletion() { + extension.sendMessage("set-suggestions", { + suggestions: [{ content: "a", description: "select a", deletable: true }], + }); + await extension.awaitMessage("suggestions-set"); + + gURLBar.focus(); + + EventUtils.sendString(keyword); + EventUtils.sendString(" select a"); + + await expectEvent("on-input-changed-fired"); + + // Select the suggestion + await EventUtils.synthesizeKey("KEY_ArrowDown"); + + // Delete the suggestion + await EventUtils.synthesizeKey("KEY_Delete", { shiftKey: true }); + + await expectEvent("on-delete-suggestion-fired", { text: "select a" }); + } + + async function testHeuristicResult(expectedText, setDefaultSuggestion) { + if (setDefaultSuggestion) { + extension.sendMessage("set-default-suggestion", { + suggestion: { + description: expectedText, + }, + }); + await extension.awaitMessage("default-suggestion-set"); + } + + let text = await startInputSession(); + let result = await waitForResult(0); + + Assert.equal( + result.displayed.title, + expectedText, + `Expected heuristic result to have title: "${expectedText}".` + ); + + Assert.equal( + result.displayed.action, + `${keyword} ${text}`, + `Expected heuristic result to have displayurl: "${keyword} ${text}".` + ); + + let promiseEvent = expectEvent("on-input-entered-fired", { + text, + disposition: "currentTab", + }); + await promiseClickOnItem(0, {}); + await promiseEvent; + } + + async function testDisposition( + suggestionIndex, + expectedDisposition, + expectedText + ) { + await startInputSession(); + await waitForResult(suggestionIndex); + + // Select the suggestion. + EventUtils.synthesizeKey("KEY_ArrowDown", { repeat: suggestionIndex }); + + let promiseEvent = expectEvent("on-input-entered-fired", { + text: expectedText, + disposition: expectedDisposition, + }); + + if (expectedDisposition == "currentTab") { + await promiseClickOnItem(suggestionIndex, {}); + } else if (expectedDisposition == "newForegroundTab") { + await promiseClickOnItem(suggestionIndex, { accelKey: true }); + } else if (expectedDisposition == "newBackgroundTab") { + await promiseClickOnItem(suggestionIndex, { + shiftKey: true, + accelKey: true, + }); + } + await promiseEvent; + } + + async function testSuggestions(info) { + extension.sendMessage("set-synchronous", { synchronous: false }); + await extension.awaitMessage("set-synchronous-set"); + + let text = await startInputSession(); + + extension.sendMessage(info.test); + await extension.awaitMessage("test-ready"); + + await waitForResult(info.suggestions.length - 1); + // Skip the heuristic result. + let index = 1; + for (let { content, description } of info.suggestions) { + let item = await UrlbarTestUtils.getDetailsOfResultAt(window, index); + Assert.equal( + item.displayed.title, + description, + `Expected suggestion to have title: "${description}".` + ); + Assert.equal( + item.displayed.action, + `${keyword} ${content}`, + `Expected suggestion to have displayurl: "${keyword} ${content}".` + ); + index++; + } + + let promiseEvent = expectEvent("on-input-entered-fired", { + text, + disposition: "currentTab", + }); + await promiseClickOnItem(0, {}); + await promiseEvent; + } + + await extension.startup(); + + await SimpleTest.promiseFocus(window); + + await testInputEvents(); + + await testSuggestionDeletion(); + + // Test the heuristic result with default suggestions. + await testHeuristicResult( + "Generated extension", + false /* setDefaultSuggestion */ + ); + await testHeuristicResult("hello world", true /* setDefaultSuggestion */); + await testHeuristicResult("foo bar", true /* setDefaultSuggestion */); + + let suggestions = [ + { content: "a", description: "select a" }, + { content: "b", description: "select b" }, + { content: "c", description: "select c" }, + ]; + + extension.sendMessage("set-suggestions", { suggestions }); + await extension.awaitMessage("suggestions-set"); + + // Test each suggestion and search disposition. + await testDisposition(1, "currentTab", suggestions[0].content); + await testDisposition(2, "newForegroundTab", suggestions[1].content); + await testDisposition(3, "newBackgroundTab", suggestions[2].content); + + extension.sendMessage("set-suggestions", { suggestions }); + await extension.awaitMessage("suggestions-set"); + + // Test adding suggestions asynchronously. + await testSuggestions({ + test: "test-multiple-suggest-calls", + suggestions, + }); + await testSuggestions({ + test: "test-suggestions-after-delay", + suggestions, + }); + + // When we're the first task to be added, `waitForExplicitFinish()` may not have + // been called yet. Let's just do that, otherwise the `monitorConsole` will make + // the test fail with a failing assertion. + SimpleTest.waitForExplicitFinish(); + // Start monitoring the console. + let waitForConsole = new Promise(resolve => { + SimpleTest.monitorConsole(resolve, [ + { + message: new RegExp( + `The keyword provided is already registered: "${keyword}"` + ), + }, + ]); + }); + + // Try registering another extension with the same keyword + let extension2 = ExtensionTestUtils.loadExtension({ + manifest: { + omnibox: { + keyword: keyword, + }, + }, + }); + + await extension2.startup(); + + // Stop monitoring the console and confirm the correct errors are logged. + SimpleTest.endMonitorConsole(); + await waitForConsole; + + await extension2.unload(); + await extension.unload(); +}); + +add_task(async function test_omnibox_event_page() { + await SpecialPowers.pushPrefEnv({ + set: [["extensions.eventPages.enabled", true]], + }); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + browser_specific_settings: { gecko: { id: "eventpage@omnibox" } }, + omnibox: { + keyword: keyword, + }, + background: { persistent: false }, + }, + background() { + browser.omnibox.onInputStarted.addListener(() => { + browser.test.sendMessage("onInputStarted"); + }); + browser.omnibox.onInputEntered.addListener(() => {}); + browser.omnibox.onInputChanged.addListener(() => {}); + browser.omnibox.onInputCancelled.addListener(() => {}); + browser.omnibox.onDeleteSuggestion.addListener(() => {}); + browser.test.sendMessage("ready"); + }, + }); + + const EVENTS = [ + "onInputStarted", + "onInputEntered", + "onInputChanged", + "onInputCancelled", + "onDeleteSuggestion", + ]; + + await extension.startup(); + await extension.awaitMessage("ready"); + for (let event of EVENTS) { + assertPersistentListeners(extension, "omnibox", event, { + primed: false, + }); + } + + // test events waken background + await extension.terminateBackground(); + for (let event of EVENTS) { + assertPersistentListeners(extension, "omnibox", event, { + primed: true, + }); + } + + // Activate the keyword by typing a space. + // Expect onInputStarted to fire. + gURLBar.focus(); + gURLBar.value = keyword; + EventUtils.sendString(" "); + + await extension.awaitMessage("ready"); + await extension.awaitMessage("onInputStarted"); + ok(true, "persistent event woke background"); + for (let event of EVENTS) { + assertPersistentListeners(extension, "omnibox", event, { + primed: false, + }); + } + + await extension.unload(); + await SpecialPowers.popPrefEnv(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_openPanel.js b/browser/components/extensions/test/browser/browser_ext_openPanel.js new file mode 100644 index 0000000000..105cdc834b --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_openPanel.js @@ -0,0 +1,152 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_openPopup_requires_user_interaction() { + async function backgroundScript() { + browser.tabs.onUpdated.addListener(async (tabId, changeInfo, tabInfo) => { + if (changeInfo.status != "complete") { + return; + } + await browser.pageAction.show(tabId); + + await browser.test.assertRejects( + browser.pageAction.openPopup(), + "pageAction.openPopup may only be called from a user input handler", + "The error is informative." + ); + await browser.test.assertRejects( + browser.sidebarAction.open(), + "sidebarAction.open may only be called from a user input handler", + "The error is informative." + ); + await browser.test.assertRejects( + browser.sidebarAction.close(), + "sidebarAction.close may only be called from a user input handler", + "The error is informative." + ); + await browser.test.assertRejects( + browser.sidebarAction.toggle(), + "sidebarAction.toggle may only be called from a user input handler", + "The error is informative." + ); + + browser.runtime.onMessage.addListener(async msg => { + browser.test.assertEq(msg, "from-panel", "correct message received"); + browser.test.sendMessage("panel-opened"); + }); + + browser.test.sendMessage("ready"); + }); + browser.tabs.create({ url: "tab.html" }); + } + + let extensionData = { + background: backgroundScript, + manifest: { + browser_action: { + default_popup: "panel.html", + }, + page_action: { + default_popup: "panel.html", + }, + sidebar_action: { + default_panel: "panel.html", + }, + }, + // We don't want the panel open automatically, so need a non-default reason. + startupReason: "APP_STARTUP", + + files: { + "tab.html": ` + <!DOCTYPE html> + <html><head><meta charset="utf-8"></head><body> + <button id="openPageAction">openPageAction</button> + <button id="openSidebarAction">openSidebarAction</button> + <button id="closeSidebarAction">closeSidebarAction</button> + <button id="toggleSidebarAction">toggleSidebarAction</button> + <script src="tab.js"></script> + </body></html> + `, + "panel.html": ` + <!DOCTYPE html> + <html><head><meta charset="utf-8"></head><body> + <script src="panel.js"></script> + </body></html> + `, + "tab.js": function () { + document.getElementById("openPageAction").addEventListener( + "click", + () => { + browser.pageAction.openPopup(); + }, + { once: true } + ); + document.getElementById("openSidebarAction").addEventListener( + "click", + () => { + browser.sidebarAction.open(); + }, + { once: true } + ); + document.getElementById("closeSidebarAction").addEventListener( + "click", + () => { + browser.sidebarAction.close(); + }, + { once: true } + ); + /* eslint-disable mozilla/balanced-listeners */ + document + .getElementById("toggleSidebarAction") + .addEventListener("click", () => { + browser.sidebarAction.toggle(); + }); + /* eslint-enable mozilla/balanced-listeners */ + }, + "panel.js": function () { + browser.runtime.sendMessage("from-panel"); + }, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + + async function click(id) { + let open = extension.awaitMessage("panel-opened"); + await BrowserTestUtils.synthesizeMouseAtCenter( + id, + {}, + gBrowser.selectedBrowser + ); + return open; + } + + await extension.startup(); + await extension.awaitMessage("ready"); + + await click("#openPageAction"); + closePageAction(extension); + await new Promise(resolve => setTimeout(resolve, 0)); + + await click("#openSidebarAction"); + + await BrowserTestUtils.synthesizeMouseAtCenter( + "#closeSidebarAction", + {}, + gBrowser.selectedBrowser + ); + await TestUtils.waitForCondition(() => !SidebarUI.isOpen); + + await click("#toggleSidebarAction"); + await TestUtils.waitForCondition(() => SidebarUI.isOpen); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#toggleSidebarAction", + {}, + gBrowser.selectedBrowser + ); + await TestUtils.waitForCondition(() => !SidebarUI.isOpen); + + BrowserTestUtils.removeTab(gBrowser.selectedTab); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_optionsPage_activity.js b/browser/components/extensions/test/browser/browser_ext_optionsPage_activity.js new file mode 100644 index 0000000000..44ca2509e1 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_optionsPage_activity.js @@ -0,0 +1,79 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function test_options_activity() { + async function backgroundScript() { + browser.runtime.openOptionsPage(); + } + + function optionsScript() { + browser.test.sendMessage("options-page:loaded", document.documentURI); + } + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + + manifest: { + options_ui: { + page: "options.html", + }, + }, + files: { + "options.html": `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + <script src="options.js" type="text/javascript"></script> + </head> + <body style="height: 100px;"> + <h1>Extensions Options</h1> + <a href="https://example.com/options-page-link">options page link</a> + </body> + </html>`, + "options.js": optionsScript, + }, + background: backgroundScript, + }); + + const aboutAddonsTab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:addons" + ); + + await extension.startup(); + + await extension.awaitMessage("options-page:loaded"); + + const optionsBrowser = getInlineOptionsBrowser(gBrowser.selectedBrowser); + + ok( + !optionsBrowser.ownerDocument.hidden, + "Parent should be active since it's in the foreground" + ); + ok( + optionsBrowser.docShellIsActive, + "Should be active since we're in the foreground" + ); + + let parentVisibilityChange = BrowserTestUtils.waitForEvent( + optionsBrowser.ownerDocument, + "visibilitychange" + ); + const aboutBlankTab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + + await parentVisibilityChange; + ok( + !optionsBrowser.docShellIsActive, + "Should become inactive since parent was backgrounded" + ); + + BrowserTestUtils.removeTab(aboutBlankTab); + BrowserTestUtils.removeTab(aboutAddonsTab); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_optionsPage_browser_style.js b/browser/components/extensions/test/browser/browser_ext_optionsPage_browser_style.js new file mode 100644 index 0000000000..9fbd4f7fd3 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_optionsPage_browser_style.js @@ -0,0 +1,155 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +requestLongerTimeout(2); + +async function testOptionsBrowserStyle(optionsUI, assertMessage) { + function optionsScript() { + browser.test.onMessage.addListener((msgName, optionsUI, assertMessage) => { + if (msgName !== "check-style") { + browser.test.notifyFail("options-ui-browser_style"); + } + + let browserStyle = + !("browser_style" in optionsUI) || optionsUI.browser_style; + + function verifyButton(buttonElement, expected) { + let buttonStyle = window.getComputedStyle(buttonElement); + let buttonBackgroundColor = buttonStyle.backgroundColor; + if (browserStyle && expected.hasBrowserStyleClass) { + browser.test.assertEq( + "rgb(9, 150, 248)", + buttonBackgroundColor, + assertMessage + ); + } else { + browser.test.assertTrue( + buttonBackgroundColor !== "rgb(9, 150, 248)", + assertMessage + ); + } + } + + function verifyCheckboxOrRadio(element, expected) { + let style = window.getComputedStyle(element); + let styledBackground = element.checked + ? "rgb(9, 150, 248)" + : "rgb(255, 255, 255)"; + if (browserStyle && expected.hasBrowserStyleClass) { + browser.test.assertEq( + styledBackground, + style.backgroundColor, + assertMessage + ); + } else { + browser.test.assertTrue( + style.backgroundColor != styledBackground, + assertMessage + ); + } + } + + let normalButton = document.getElementById("normalButton"); + let browserStyleButton = document.getElementById("browserStyleButton"); + verifyButton(normalButton, { hasBrowserStyleClass: false }); + verifyButton(browserStyleButton, { hasBrowserStyleClass: true }); + + let normalCheckbox1 = document.getElementById("normalCheckbox1"); + let normalCheckbox2 = document.getElementById("normalCheckbox2"); + let browserStyleCheckbox = document.getElementById( + "browserStyleCheckbox" + ); + verifyCheckboxOrRadio(normalCheckbox1, { hasBrowserStyleClass: false }); + verifyCheckboxOrRadio(normalCheckbox2, { hasBrowserStyleClass: false }); + verifyCheckboxOrRadio(browserStyleCheckbox, { + hasBrowserStyleClass: true, + }); + + let normalRadio1 = document.getElementById("normalRadio1"); + let normalRadio2 = document.getElementById("normalRadio2"); + let browserStyleRadio = document.getElementById("browserStyleRadio"); + verifyCheckboxOrRadio(normalRadio1, { hasBrowserStyleClass: false }); + verifyCheckboxOrRadio(normalRadio2, { hasBrowserStyleClass: false }); + verifyCheckboxOrRadio(browserStyleRadio, { hasBrowserStyleClass: true }); + + browser.test.notifyPass("options-ui-browser_style"); + }); + browser.test.sendMessage("options-ui-ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + + manifest: { + permissions: ["tabs"], + options_ui: optionsUI, + }, + files: { + "options.html": ` + <!DOCTYPE html> + <html> + <button id="normalButton" name="button" class="default">Default</button> + <button id="browserStyleButton" name="button" class="browser-style default">Default</button> + + <input id="normalCheckbox1" type="checkbox"/> + <input id="normalCheckbox2" type="checkbox"/><label>Checkbox</label> + <div class="browser-style"> + <input id="browserStyleCheckbox" type="checkbox"><label for="browserStyleCheckbox">Checkbox</label> + </div> + + <input id="normalRadio1" type="radio"/> + <input id="normalRadio2" type="radio"/><label>Radio</label> + <div class="browser-style"> + <input id="browserStyleRadio" checked="" type="radio"><label for="browserStyleRadio">Radio</label> + </div> + + <script src="options.js" type="text/javascript"></script> + </html>`, + "options.js": optionsScript, + }, + background() { + browser.runtime.openOptionsPage(); + }, + }); + + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + await extension.startup(); + await extension.awaitMessage("options-ui-ready"); + + extension.sendMessage("check-style", optionsUI, assertMessage); + await extension.awaitFinish("options-ui-browser_style"); + BrowserTestUtils.removeTab(tab); + + await extension.unload(); +} + +add_task(async function test_options_without_setting_browser_style() { + await testOptionsBrowserStyle( + { + page: "options.html", + }, + "Expected correct style when browser_style is excluded" + ); +}); + +add_task(async function test_options_with_browser_style_set_to_true() { + await testOptionsBrowserStyle( + { + page: "options.html", + browser_style: true, + }, + "Expected correct style when browser_style is set to `true`" + ); +}); + +add_task(async function test_options_with_browser_style_set_to_false() { + await testOptionsBrowserStyle( + { + page: "options.html", + browser_style: false, + }, + "Expected no style when browser_style is set to `false`" + ); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_optionsPage_links_open_in_tabs.js b/browser/components/extensions/test/browser/browser_ext_optionsPage_links_open_in_tabs.js new file mode 100644 index 0000000000..147754b344 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_optionsPage_links_open_in_tabs.js @@ -0,0 +1,68 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function test_options_links() { + async function backgroundScript() { + browser.runtime.openOptionsPage(); + } + + function optionsScript() { + browser.test.sendMessage("options-page:loaded", document.documentURI); + } + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + + manifest: { + options_ui: { + page: "options.html", + }, + }, + files: { + "options.html": `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + <script src="options.js" type="text/javascript"></script> + </head> + <body style="height: 100px;"> + <h1>Extensions Options</h1> + <a href="https://example.com/options-page-link">options page link</a> + </body> + </html>`, + "options.js": optionsScript, + }, + background: backgroundScript, + }); + + const aboutAddonsTab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:addons" + ); + + await extension.startup(); + + await extension.awaitMessage("options-page:loaded"); + + const optionsBrowser = getInlineOptionsBrowser(gBrowser.selectedBrowser); + + const promiseNewTabOpened = BrowserTestUtils.waitForNewTab( + gBrowser, + "https://example.com/options-page-link" + ); + await SpecialPowers.spawn(optionsBrowser, [], () => + content.document.querySelector("a").click() + ); + info( + "Expect a new tab to be opened when a link is clicked in the options_page embedded inside about:addons" + ); + const newTab = await promiseNewTabOpened; + ok(newTab, "Got a new tab created on the expected url"); + BrowserTestUtils.removeTab(newTab); + + BrowserTestUtils.removeTab(aboutAddonsTab); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_optionsPage_modals.js b/browser/components/extensions/test/browser/browser_ext_optionsPage_modals.js new file mode 100644 index 0000000000..809a5605c0 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_optionsPage_modals.js @@ -0,0 +1,100 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_tab_options_modals() { + function backgroundScript() { + browser.runtime.openOptionsPage(); + } + + function optionsScript() { + try { + alert("WebExtensions OptionsUI Page Modal"); + + browser.test.notifyPass("options-ui-modals"); + } catch (error) { + browser.test.log(`Error: ${error} :: ${error.stack}`); + browser.test.notifyFail("options-ui-modals"); + } + } + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + + manifest: { + permissions: ["tabs"], + options_ui: { + page: "options.html", + }, + }, + files: { + "options.html": `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + <script src="options.js" type="text/javascript"></script> + </head> + </html>`, + "options.js": optionsScript, + }, + background: backgroundScript, + }); + + await BrowserTestUtils.openNewForegroundTab(gBrowser, "about:addons"); + + await extension.startup(); + + const onceModalOpened = new Promise(resolve => { + const aboutAddonsBrowser = gBrowser.selectedBrowser; + + aboutAddonsBrowser.addEventListener( + "DOMWillOpenModalDialog", + function onModalDialog(event) { + // Wait for the next event tick to make sure the remaining part of the + // testcase runs after the dialog gets opened. + SimpleTest.executeSoon(resolve); + }, + { once: true, capture: true } + ); + }); + + info("Wait the options_ui modal to be opened"); + await onceModalOpened; + + const optionsBrowser = getInlineOptionsBrowser(gBrowser.selectedBrowser); + + // The stack that contains the tabmodalprompt elements is the parent of + // the extensions options_ui browser element. + let stack = optionsBrowser.parentNode; + + let dialogs = stack.querySelectorAll("tabmodalprompt"); + Assert.equal( + dialogs.length, + 1, + "Expect a tab modal opened for the about addons tab" + ); + + // Verify that the expected stylesheets have been applied on the + // tabmodalprompt element (See Bug 1550529). + const tabmodalStyle = dialogs[0].ownerGlobal.getComputedStyle(dialogs[0]); + is( + tabmodalStyle["background-color"], + "rgba(26, 26, 26, 0.5)", + "Got the expected styles applied to the tabmodalprompt" + ); + + info("Close the tab modal prompt"); + dialogs[0].querySelector(".tabmodalprompt-button0").click(); + + await extension.awaitFinish("options-ui-modals"); + + Assert.equal( + stack.querySelectorAll("tabmodalprompt").length, + 0, + "Expect the tab modal to be closed" + ); + + BrowserTestUtils.removeTab(gBrowser.selectedTab); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_optionsPage_popups.js b/browser/components/extensions/test/browser/browser_ext_optionsPage_popups.js new file mode 100644 index 0000000000..168a9c11b5 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_optionsPage_popups.js @@ -0,0 +1,249 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +async function openContextMenuInOptionsPage(optionsBrowser) { + let contentAreaContextMenu = document.getElementById( + "contentAreaContextMenu" + ); + let popupShownPromise = BrowserTestUtils.waitForEvent( + contentAreaContextMenu, + "popupshown" + ); + + info("Trigger context menu in the extension options page"); + + // Instead of BrowserTestUtils.synthesizeMouseAtCenter, we are dispatching a contextmenu + // event directly on the target element to prevent intermittent failures on debug builds + // (especially linux32-debug), see Bug 1519808 for a rationale. + SpecialPowers.spawn(optionsBrowser, [], () => { + let el = content.document.querySelector("a"); + el.dispatchEvent( + new content.MouseEvent("contextmenu", { + bubbles: true, + cancelable: true, + view: el.ownerGlobal, + }) + ); + }); + + info("Wait the context menu to be shown"); + await popupShownPromise; + + return contentAreaContextMenu; +} + +async function contextMenuClosed(contextMenu) { + info("Wait context menu popup to be closed"); + await closeContextMenu(contextMenu); + is(contextMenu.state, "closed", "The context menu popup has been closed"); +} + +add_task(async function test_tab_options_popups() { + async function backgroundScript() { + browser.menus.onShown.addListener(info => { + browser.test.sendMessage("extension-menus-onShown", info); + }); + + await browser.menus.create({ + id: "sidebaronly", + title: "sidebaronly", + viewTypes: ["sidebar"], + }); + await browser.menus.create({ + id: "tabonly", + title: "tabonly", + viewTypes: ["tab"], + }); + await browser.menus.create({ id: "anypage", title: "anypage" }); + + browser.runtime.openOptionsPage(); + } + + function optionsScript() { + browser.test.sendMessage("options-page:loaded", document.documentURI); + } + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + + manifest: { + permissions: ["tabs", "menus"], + options_ui: { + page: "options.html", + }, + }, + files: { + "options.html": `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + <script src="options.js" type="text/javascript"></script> + </head> + <body style="height: 100px;"> + <h1>Extensions Options</h1> + <a href="http://mochi.test:8888/">options page link</a> + </body> + </html>`, + "options.js": optionsScript, + }, + background: backgroundScript, + }); + + const aboutAddonsTab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:addons" + ); + + await extension.startup(); + + const pageUrl = await extension.awaitMessage("options-page:loaded"); + + const optionsBrowser = getInlineOptionsBrowser(gBrowser.selectedBrowser); + + const contentAreaContextMenu = await openContextMenuInOptionsPage( + optionsBrowser + ); + + let contextMenuItemIds = [ + "context-openlinkintab", + "context-openlinkprivate", + "context-copylink", + ]; + + // Test that the "open link in container" menu is available if the containers are enabled + // (which is the default on Nightly, but not on Beta). + if (Services.prefs.getBoolPref("privacy.userContext.enabled")) { + contextMenuItemIds.push("context-openlinkinusercontext-menu"); + } + + for (const itemID of contextMenuItemIds) { + const item = contentAreaContextMenu.querySelector(`#${itemID}`); + + ok(!item.hidden, `${itemID} should not be hidden`); + ok(!item.disabled, `${itemID} should not be disabled`); + } + + const menuDetails = await extension.awaitMessage("extension-menus-onShown"); + + isnot( + menuDetails.targetElementId, + undefined, + "Got a targetElementId in the menu details" + ); + delete menuDetails.targetElementId; + + Assert.deepEqual( + menuDetails, + { + menuIds: ["anypage"], + contexts: ["link", "all"], + viewType: undefined, + frameId: 0, + editable: false, + linkText: "options page link", + linkUrl: "http://mochi.test:8888/", + pageUrl, + }, + "Got the expected menu details from menus.onShown" + ); + + await contextMenuClosed(contentAreaContextMenu); + + BrowserTestUtils.removeTab(aboutAddonsTab); + + await extension.unload(); +}); + +add_task(async function overrideContext_in_options_page() { + function optionsScript() { + document.addEventListener( + "contextmenu", + () => { + browser.menus.overrideContext({}); + browser.test.sendMessage("contextmenu-overridden"); + }, + { once: true } + ); + browser.test.sendMessage("options-page:loaded"); + } + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + manifest: { + permissions: ["tabs", "menus", "menus.overrideContext"], + options_ui: { + page: "options.html", + }, + }, + files: { + "options.html": `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + <script src="options.js" type="text/javascript"></script> + </head> + <body style="height: 100px;"> + <h1>Extensions Options</h1> + <a href="http://mochi.test:8888/">options page link</a> + </body> + </html>`, + "options.js": optionsScript, + }, + async background() { + // Expected to match and be shown. + await new Promise(resolve => { + browser.menus.create({ id: "bg_1_1", title: "bg_1_1" }); + browser.menus.create({ id: "bg_1_2", title: "bg_1_2" }); + // Expected to not match and be hidden. + browser.menus.create( + { + id: "bg_1_3", + title: "bg_1_3", + targetUrlPatterns: ["*://nomatch/*"], + }, + // menus.create returns a number and gets a callback, the order + // is deterministic and so we just need to wait for the last one. + resolve + ); + }); + browser.runtime.openOptionsPage(); + }, + }); + + const aboutAddonsTab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:addons" + ); + + await extension.startup(); + await extension.awaitMessage("options-page:loaded"); + + const optionsBrowser = getInlineOptionsBrowser(gBrowser.selectedBrowser); + const contentAreaContextMenu = await openContextMenuInOptionsPage( + optionsBrowser + ); + + await extension.awaitMessage("contextmenu-overridden"); + + const allVisibleMenuItems = Array.from(contentAreaContextMenu.children) + .filter(elem => { + return !elem.hidden; + }) + .map(elem => elem.id); + + Assert.deepEqual( + allVisibleMenuItems, + [ + `${makeWidgetId(extension.id)}-menuitem-_bg_1_1`, + `${makeWidgetId(extension.id)}-menuitem-_bg_1_2`, + ], + "Expected only extension menu items" + ); + + await contextMenuClosed(contentAreaContextMenu); + + BrowserTestUtils.removeTab(aboutAddonsTab); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_optionsPage_privileges.js b/browser/components/extensions/test/browser/browser_ext_optionsPage_privileges.js new file mode 100644 index 0000000000..33ddc6db34 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_optionsPage_privileges.js @@ -0,0 +1,86 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_tab_options_privileges() { + function backgroundScript() { + browser.runtime.onMessage.addListener(async ({ msgName, tab }) => { + if (msgName == "removeTab") { + try { + const [activeTab] = await browser.tabs.query({ active: true }); + browser.test.assertEq( + tab.id, + activeTab.id, + "tabs.getCurrent has got the expected tabId" + ); + browser.test.assertEq( + tab.windowId, + activeTab.windowId, + "tabs.getCurrent has got the expected windowId" + ); + await browser.tabs.remove(tab.id); + + browser.test.notifyPass("options-ui-privileges"); + } catch (error) { + browser.test.log(`Error: ${error} :: ${error.stack}`); + browser.test.notifyFail("options-ui-privileges"); + } + } + }); + browser.runtime.openOptionsPage(); + } + + async function optionsScript() { + try { + let [tab] = await browser.tabs.query({ url: "http://example.com/" }); + browser.test.assertEq( + "http://example.com/", + tab.url, + "Got the expect tab" + ); + + tab = await browser.tabs.getCurrent(); + browser.runtime.sendMessage({ msgName: "removeTab", tab }); + } catch (error) { + browser.test.log(`Error: ${error} :: ${error.stack}`); + browser.test.notifyFail("options-ui-privileges"); + } + } + + const ID = "options_privileges@tests.mozilla.org"; + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + + manifest: { + browser_specific_settings: { gecko: { id: ID } }, + permissions: ["tabs"], + options_ui: { + page: "options.html", + }, + }, + files: { + "options.html": `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + <script src="options.js" type="text/javascript"></script> + </head> + </html>`, + "options.js": optionsScript, + }, + background: backgroundScript, + }); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.com/" + ); + + await extension.startup(); + + await extension.awaitFinish("options-ui-privileges"); + + await extension.unload(); + + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_originControls.js b/browser/components/extensions/test/browser/browser_ext_originControls.js new file mode 100644 index 0000000000..176eef08bc --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_originControls.js @@ -0,0 +1,867 @@ +"use strict"; + +const { AddonTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/AddonTestUtils.sys.mjs" +); + +const { ExtensionPermissions, QuarantinedDomains } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionPermissions.sys.mjs" +); + +AddonTestUtils.initMochitest(this); + +loadTestSubscript("head_unified_extensions.js"); + +const RED = + "iVBORw0KGgoAAAANSUhEUgAAANwAAADcCAYAAAAbWs+BAAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH4gIUARQAHY8+4wAAApBJREFUeNrt3cFqAjEUhlEjvv8rXzciiiBGk/He5JxdN2U649dY+KmnEwAAAAAv2uMXEeGOwERntwAEB4IDBAeCAwQHggPBAYIDwQGCA8GB4ADBgeAAwYHgAMGB4EBwgOCgpkuKq2it/r8Li2hbvGKqP6s/PycnHHv9YvSWEgQHCA4EBwgOBAeCAwQHggMEByXM+QRUE6D3suwuPafDn5MTDg50KXnVPSdxa54y/oYDwQGCA8EBggPBAYIDwYHggBE+X5rY3Y3Tey97Nn2eU+rnlGfaZa6Ft5SA4EBwgOBAcCA4QHAgOEBwIDjgZu60y1xrDPtIJxwgOBAcIDgQHAgOEBwIDhAcCA4EBwgOBAcIDgQHCA4EB4IDBAeCAwQHggPBAYIDwQGCA8GB4ADBgeAAwYHgAMGB4GADcz9y2McIgxMOBAeCAwQHggMEB4IDwQGCA8EBggPBATdP6+KIGPRdW7i1LCFi6ALfCQfeUoLgAMGB4ADBgeBAcIDgQHCA4CCdOVvK7quwveQgg7eRTjjwlhIQHAgOBAcIDgQHCA4EB4IDBAfl5dhSdl+17SX3F22rdLlOOBAcCA4QHAgOEBwIDgQHCA4EBwgO0qm5pez6Ce0uSym2jXTCgeAAwYHgQHCA4EBwgOBAcCA4QHBQ3vpbyu47Yns51OLbSCccCA4QHAgOBAcIDgQHCA4EB4ID5jDt+vkObjgFM9dywoHgAMGB4EBwgOBAcIDgQHAgOEBwsA5bysPveMLtpW2kEw4EBwgOBAcIDgQHggMEB4IDBAeCg33ZUqZ/Ql9sL20jnXCA4EBwIDhAcCA4QHAgOBAcIDgQHNOZai3DlhKccCA4QHAgOEBwIDgQHCA4AAAAAGA1VyxaWIohrgXFAAAAAElFTkSuQmCC"; + +const l10n = new Localization( + ["branding/brand.ftl", "browser/extensionsUI.ftl"], + true +); + +async function makeExtension({ + useAddonManager = "temporary", + manifest_version = 3, + id, + permissions, + host_permissions, + content_scripts, + granted, + default_area, +}) { + info( + `Loading extension ` + + JSON.stringify({ id, permissions, host_permissions, granted }) + ); + + let manifest = { + manifest_version, + browser_specific_settings: { gecko: { id } }, + permissions, + host_permissions, + content_scripts, + action: { + default_popup: "popup.html", + default_area: default_area || "navbar", + }, + icons: { + 16: "red.png", + }, + }; + if (manifest_version < 3) { + manifest.browser_action = manifest.action; + delete manifest.action; + } + + let ext = ExtensionTestUtils.loadExtension({ + manifest, + + useAddonManager, + + async background() { + browser.permissions.onAdded.addListener(({ origins }) => { + browser.test.sendMessage("granted", origins.join()); + }); + browser.permissions.onRemoved.addListener(({ origins }) => { + browser.test.sendMessage("revoked", origins.join()); + }); + + browser.runtime.onInstalled.addListener(async () => { + if (browser.menus) { + let submenu = browser.menus.create({ + id: "parent", + title: "submenu", + contexts: ["action"], + }); + browser.menus.create({ + id: "child1", + title: "child1", + parentId: submenu, + }); + await new Promise(resolve => { + browser.menus.create( + { + id: "child2", + title: "child2", + parentId: submenu, + }, + resolve + ); + }); + } + + browser.test.sendMessage("ready"); + }); + }, + + files: { + "red.png": imageBufferFromDataURI(RED), + "popup.html": `<!DOCTYPE html><meta charset=utf-8>Test Popup`, + }, + }); + + if (granted) { + info("Granting initial permissions."); + await ExtensionPermissions.add(id, { permissions: [], origins: granted }); + } + + await ext.startup(); + await ext.awaitMessage("ready"); + return ext; +} + +async function testQuarantinePopup(popup) { + let [title, line1, line2] = await l10n.formatMessages([ + { + id: "webext-quarantine-confirmation-title", + args: { addonName: "Generated extension" }, + }, + "webext-quarantine-confirmation-line-1", + "webext-quarantine-confirmation-line-2", + ]); + let [titleEl, , helpEl] = popup.querySelectorAll("description"); + + ok(popup.getAttribute("icon").endsWith("/red.png"), "Correct icon."); + + is(title.value, titleEl.textContent, "Correct title."); + is(line1.value + "\n\n" + line2.value, helpEl.textContent, "Correct lines."); +} + +async function testOriginControls( + extension, + { contextMenuId }, + { + items, + selected, + click, + granted, + revoked, + attention, + quarantined, + allowQuarantine, + } +) { + info( + `Testing ${extension.id} on ${gBrowser.currentURI.spec} with contextMenuId=${contextMenuId}.` + ); + + let buttonOrWidget; + let menu; + let nextMenuItemClassName; + + switch (contextMenuId) { + case "toolbar-context-menu": + let target = `#${CSS.escape(makeWidgetId(extension.id))}-BAP`; + buttonOrWidget = document.querySelector(target).parentElement; + menu = await openChromeContextMenu(contextMenuId, target); + nextMenuItemClassName = "customize-context-manageExtension"; + break; + + case "unified-extensions-context-menu": + await openExtensionsPanel(); + buttonOrWidget = getUnifiedExtensionsItem(extension.id); + menu = await openUnifiedExtensionsContextMenu(extension.id); + nextMenuItemClassName = "unified-extensions-context-menu-pin-to-toolbar"; + break; + + default: + throw new Error(`unexpected context menu "${contextMenuId}"`); + } + + let doc = menu.ownerDocument; + let visibleOriginItems = menu.querySelectorAll( + ":is(menuitem, menuseparator):not([hidden])" + ); + + info("Check expected menu items."); + for (let i = 0; i < items.length; i++) { + let l10n = doc.l10n.getAttributes(visibleOriginItems[i]); + Assert.deepEqual( + l10n, + items[i], + `Visible menu item ${i} has correct l10n attrs.` + ); + + let checked = visibleOriginItems[i].getAttribute("checked") === "true"; + is(i === selected, checked, `Expected checked value for item ${i}.`); + } + + if (items.length) { + is( + visibleOriginItems[items.length].nodeName, + "menuseparator", + "Found separator." + ); + is( + visibleOriginItems[items.length + 1].className, + nextMenuItemClassName, + "All items accounted for." + ); + } + + is( + buttonOrWidget.hasAttribute("attention"), + !!attention, + "Expected attention badge before clicking." + ); + + Assert.deepEqual( + document.l10n.getAttributes( + buttonOrWidget.querySelector(".unified-extensions-item-action-button") + ), + { + // eslint-disable-next-line no-nested-ternary + id: attention + ? quarantined + ? "origin-controls-toolbar-button-quarantined" + : "origin-controls-toolbar-button-permission-needed" + : "origin-controls-toolbar-button", + args: { + extensionTitle: "Generated extension", + }, + }, + "Correct l10n message." + ); + + let itemToClick; + if (click) { + itemToClick = visibleOriginItems[click]; + } + + let quarantinePopup; + if (itemToClick && quarantined) { + quarantinePopup = promisePopupNotificationShown("addon-webext-permissions"); + } + + // Clicking a menu item of the unified extensions context menu should close + // the unified extensions panel automatically. + let panelHidden = + itemToClick && contextMenuId === "unified-extensions-context-menu" + ? BrowserTestUtils.waitForEvent(document, "popuphidden", true) + : Promise.resolve(); + + await closeChromeContextMenu(contextMenuId, itemToClick); + await panelHidden; + + // When there is no menu item to close, we should manually close the unified + // extensions panel because simply closing the context menu will not close + // it. + if (!itemToClick && contextMenuId === "unified-extensions-context-menu") { + await closeExtensionsPanel(); + } + + if (granted) { + info("Waiting for the permissions.onAdded event."); + let host = await extension.awaitMessage("granted"); + is(host, granted.join(), "Expected host permission granted."); + } + if (revoked) { + info("Waiting for the permissions.onRemoved event."); + let host = await extension.awaitMessage("revoked"); + is(host, revoked.join(), "Expected host permission revoked."); + } + + if (quarantinePopup) { + let popup = await quarantinePopup; + await testQuarantinePopup(popup); + + if (allowQuarantine) { + popup.button.click(); + } else { + popup.secondaryButton.click(); + } + } +} + +// Move the widget to the toolbar or the addons panel (if Unified Extensions +// is enabled) or the overflow panel otherwise. +function moveWidget(ext, pinToToolbar = false) { + let area = pinToToolbar + ? CustomizableUI.AREA_NAVBAR + : CustomizableUI.AREA_ADDONS; + let widgetId = `${makeWidgetId(ext.id)}-browser-action`; + CustomizableUI.addWidgetToArea(widgetId, area); +} + +const originControlsInContextMenu = async options => { + // Has no permissions. + let ext1 = await makeExtension({ id: "ext1@test" }); + + // Has activeTab and (ungranted) example.com permissions. + let ext2 = await makeExtension({ + id: "ext2@test", + permissions: ["activeTab"], + host_permissions: ["*://example.com/*"], + useAddonManager: "permanent", + }); + + // Has ungranted <all_urls>, and granted example.com. + let ext3 = await makeExtension({ + id: "ext3@test", + host_permissions: ["<all_urls>"], + granted: ["*://example.com/*"], + useAddonManager: "permanent", + }); + + // Has granted <all_urls>. + let ext4 = await makeExtension({ + id: "ext4@test", + host_permissions: ["<all_urls>"], + granted: ["<all_urls>"], + useAddonManager: "permanent", + }); + + // MV2 extension with an <all_urls> content script and activeTab. + let ext5 = await makeExtension({ + manifest_version: 2, + id: "ext5@test", + permissions: ["activeTab"], + content_scripts: [ + { + matches: ["<all_urls>"], + css: [], + }, + ], + useAddonManager: "permanent", + }); + + // Add an extension always visible in the extensions panel. + let ext6 = await makeExtension({ + id: "ext6@test", + default_area: "menupanel", + }); + + let extensions = [ext1, ext2, ext3, ext4, ext5, ext6]; + + let unifiedButton; + if (options.contextMenuId === "unified-extensions-context-menu") { + moveWidget(ext1, false); + moveWidget(ext2, false); + moveWidget(ext3, false); + moveWidget(ext4, false); + moveWidget(ext5, false); + unifiedButton = document.querySelector("#unified-extensions-button"); + } else { + // TestVerify runs this again in the same Firefox instance, so move the + // widgets back to the toolbar for testing outside the unified extensions + // panel. + moveWidget(ext1, true); + moveWidget(ext2, true); + moveWidget(ext3, true); + moveWidget(ext4, true); + moveWidget(ext5, true); + } + + const NO_ACCESS = { id: "origin-controls-no-access", args: null }; + const QUARANTINED = { + id: "origin-controls-quarantined-status", + args: null, + }; + const ALLOW_QUARANTINED = { + id: "origin-controls-quarantined-allow", + args: null, + }; + const ACCESS_OPTIONS = { id: "origin-controls-options", args: null }; + const ALL_SITES = { id: "origin-controls-option-all-domains", args: null }; + const WHEN_CLICKED = { + id: "origin-controls-option-when-clicked", + args: null, + }; + + const UNIFIED_NO_ATTENTION = { id: "unified-extensions-button", args: null }; + const UNIFIED_ATTENTION = { + id: "unified-extensions-button-permissions-needed", + args: null, + }; + const UNIFIED_QUARANTINED = { + id: "unified-extensions-button-quarantined", + args: null, + }; + + await BrowserTestUtils.withNewTab("about:blank", async () => { + await testOriginControls(ext1, options, { items: [NO_ACCESS] }); + await testOriginControls(ext2, options, { items: [NO_ACCESS] }); + await testOriginControls(ext3, options, { items: [NO_ACCESS] }); + await testOriginControls(ext4, options, { items: [NO_ACCESS] }); + await testOriginControls(ext5, options, { items: [] }); + + if (unifiedButton) { + ok( + !unifiedButton.hasAttribute("attention"), + "No extension will have attention indicator on about:blank." + ); + Assert.deepEqual( + document.l10n.getAttributes(unifiedButton), + UNIFIED_NO_ATTENTION, + "Unified button has no permissions needed tooltip." + ); + } + }); + + await BrowserTestUtils.withNewTab("http://mochi.test:8888/", async () => { + const ALWAYS_ON = { + id: "origin-controls-option-always-on", + args: { domain: "mochi.test" }, + }; + + await testOriginControls(ext1, options, { items: [NO_ACCESS] }); + + // Has activeTab. + await testOriginControls(ext2, options, { + items: [ACCESS_OPTIONS, WHEN_CLICKED], + selected: 1, + attention: true, + }); + + // Could access mochi.test when clicked. + await testOriginControls(ext3, options, { + items: [ACCESS_OPTIONS, WHEN_CLICKED, ALWAYS_ON], + selected: 1, + attention: true, + }); + + // Has <all_urls> granted. + await testOriginControls(ext4, options, { + items: [ACCESS_OPTIONS, ALL_SITES], + selected: 1, + attention: false, + }); + + // MV2 extension, has no origin controls, and never flags for attention. + await testOriginControls(ext5, options, { items: [], attention: false }); + if (unifiedButton) { + ok( + unifiedButton.hasAttribute("attention"), + "Both ext2 and ext3 are WHEN_CLICKED for example.com, so show attention indicator." + ); + Assert.deepEqual( + document.l10n.getAttributes(unifiedButton), + UNIFIED_ATTENTION, + "UEB has permissions needed tooltip." + ); + } + }); + + info("Testing again with mochi.test now quarantined."); + await SpecialPowers.pushPrefEnv({ + set: [ + ["extensions.quarantinedDomains.enabled", true], + ["extensions.quarantinedDomains.list", "mochi.test"], + ], + }); + + // Reset quarantined state between test runs. + QuarantinedDomains.setUserAllowedAddonIdPref(ext2.id, false); + QuarantinedDomains.setUserAllowedAddonIdPref(ext4.id, false); + QuarantinedDomains.setUserAllowedAddonIdPref(ext5.id, false); + + await BrowserTestUtils.withNewTab("http://mochi.test:8888/", async () => { + await testOriginControls(ext1, options, { + items: [NO_ACCESS], + attention: false, + }); + + await testOriginControls(ext2, options, { + items: [QUARANTINED, ALLOW_QUARANTINED], + attention: true, + quarantined: true, + click: 1, + allowQuarantine: false, + }); + // Still quarantined. + await testOriginControls(ext2, options, { + items: [QUARANTINED, ALLOW_QUARANTINED], + attention: true, + quarantined: true, + click: 1, + allowQuarantine: true, + }); + // Not quarantined anymore. + await testOriginControls(ext2, options, { + items: [ACCESS_OPTIONS, WHEN_CLICKED], + selected: 1, + attention: true, + quarantined: false, + }); + + await testOriginControls(ext3, options, { + items: [QUARANTINED, ALLOW_QUARANTINED], + attention: true, + quarantined: true, + }); + + await testOriginControls(ext4, options, { + items: [QUARANTINED, ALLOW_QUARANTINED], + attention: true, + quarantined: true, + click: 1, + allowQuarantine: true, + }); + await testOriginControls(ext4, options, { + items: [ACCESS_OPTIONS, ALL_SITES], + selected: 1, + attention: false, + quarantined: false, + }); + + // MV2 normally don't have controls, but we always show for quarantined. + await testOriginControls(ext5, options, { + items: [QUARANTINED, ALLOW_QUARANTINED], + attention: true, + quarantined: true, + click: 1, + allowQuarantine: true, + }); + await testOriginControls(ext5, options, { + items: [], + attention: false, + quarantined: false, + }); + + if (unifiedButton) { + ok(unifiedButton.hasAttribute("attention"), "Expected attention UI"); + Assert.deepEqual( + document.l10n.getAttributes(unifiedButton), + UNIFIED_QUARANTINED, + "Expected attention tooltip text for quarantined domains" + ); + } + }); + + if (unifiedButton) { + extensions.forEach(extension => + moveWidget(extension, /* pinToToolbar */ true) + ); + + await BrowserTestUtils.withNewTab("http://mochi.test:8888/", async () => { + ok(unifiedButton.hasAttribute("attention"), "Expected attention UI"); + Assert.deepEqual( + document.l10n.getAttributes(unifiedButton), + UNIFIED_QUARANTINED, + "Expected attention tooltip text for quarantined domains" + ); + + await openExtensionsPanel(); + + const messages = getMessageBars(); + Assert.equal(messages.length, 1, "expected a message"); + const supportLink = messages[0].querySelector("a"); + Assert.equal( + supportLink.getAttribute("support-page"), + "quarantined-domains", + "Expected the correct support page ID" + ); + + await closeExtensionsPanel(); + }); + + extensions.forEach(extension => + moveWidget(extension, /* pinToToolbar */ false) + ); + } + + await SpecialPowers.popPrefEnv(); + + await BrowserTestUtils.withNewTab("http://example.com/", async () => { + const ALWAYS_ON = { + id: "origin-controls-option-always-on", + args: { domain: "example.com" }, + }; + + await testOriginControls(ext1, options, { items: [NO_ACCESS] }); + + // Click alraedy selected options, expect no permission changes. + await testOriginControls(ext2, options, { + items: [ACCESS_OPTIONS, WHEN_CLICKED, ALWAYS_ON], + selected: 1, + click: 1, + attention: true, + }); + await testOriginControls(ext3, options, { + items: [ACCESS_OPTIONS, WHEN_CLICKED, ALWAYS_ON], + selected: 2, + click: 2, + attention: false, + }); + await testOriginControls(ext4, options, { + items: [ACCESS_OPTIONS, ALL_SITES], + selected: 1, + click: 1, + attention: false, + }); + + await testOriginControls(ext5, options, { items: [], attention: false }); + + if (unifiedButton) { + ok( + unifiedButton.hasAttribute("attention"), + "ext2 is WHEN_CLICKED for example.com, show attention indicator." + ); + Assert.deepEqual( + document.l10n.getAttributes(unifiedButton), + UNIFIED_ATTENTION, + "UEB attention for only one extension." + ); + } + + // Click the other option, expect example.com permission granted/revoked. + await testOriginControls(ext2, options, { + items: [ACCESS_OPTIONS, WHEN_CLICKED, ALWAYS_ON], + selected: 1, + click: 2, + granted: ["*://example.com/*"], + attention: true, + }); + if (unifiedButton) { + ok( + !unifiedButton.hasAttribute("attention"), + "Bot ext2 and ext3 are ALWAYS_ON for example.com, so no attention indicator." + ); + Assert.deepEqual( + document.l10n.getAttributes(unifiedButton), + UNIFIED_NO_ATTENTION, + "Unified button has no permissions needed tooltip." + ); + } + + await testOriginControls(ext3, options, { + items: [ACCESS_OPTIONS, WHEN_CLICKED, ALWAYS_ON], + selected: 2, + click: 1, + revoked: ["*://example.com/*"], + attention: false, + }); + if (unifiedButton) { + ok( + unifiedButton.hasAttribute("attention"), + "ext3 is now WHEN_CLICKED for example.com, show attention indicator." + ); + Assert.deepEqual( + document.l10n.getAttributes(unifiedButton), + UNIFIED_ATTENTION, + "UEB attention for only one extension." + ); + } + + // Other option is now selected. + await testOriginControls(ext2, options, { + items: [ACCESS_OPTIONS, WHEN_CLICKED, ALWAYS_ON], + selected: 2, + attention: false, + }); + await testOriginControls(ext3, options, { + items: [ACCESS_OPTIONS, WHEN_CLICKED, ALWAYS_ON], + selected: 1, + attention: true, + }); + + if (unifiedButton) { + ok( + unifiedButton.hasAttribute("attention"), + "Still showing the attention indicator." + ); + Assert.deepEqual( + document.l10n.getAttributes(unifiedButton), + UNIFIED_ATTENTION, + "UEB attention for only one extension." + ); + } + }); + + // Regression test for Bug 1861002. + const addonListener = { + registered: false, + onPropertyChanged(addon, changedProps) { + ok( + addon, + `onPropertyChanged should not be called without an AddonWrapper for changed properties: ${changedProps}` + ); + }, + }; + AddonManager.addAddonListener(addonListener); + addonListener.registered = true; + const unregisterAddonListener = () => { + if (!addonListener.registered) { + return; + } + AddonManager.removeAddonListener(addonListener); + addonListener.registered = false; + }; + registerCleanupFunction(unregisterAddonListener); + + let { messages } = await AddonTestUtils.promiseConsoleOutput(async () => { + await Promise.all(extensions.map(e => e.unload())); + }); + + unregisterAddonListener(); + + AddonTestUtils.checkMessages( + messages, + { + forbidden: [ + { + message: + /AddonListener threw exception when calling onPropertyChanged/, + }, + ], + }, + "Expect no exception raised from AddonListener onPropertyChanged callbacks" + ); +}; + +add_task(async function originControls_in_browserAction_contextMenu() { + await originControlsInContextMenu({ contextMenuId: "toolbar-context-menu" }); +}); + +add_task(async function originControls_in_unifiedExtensions_contextMenu() { + await originControlsInContextMenu({ + contextMenuId: "unified-extensions-context-menu", + }); +}); + +add_task(async function test_attention_dot_when_pinning_extension() { + const extension = await makeExtension({ permissions: ["activeTab"] }); + await extension.startup(); + + const unifiedButton = document.querySelector("#unified-extensions-button"); + const extensionWidgetID = AppUiTestInternals.getBrowserActionWidgetId( + extension.id + ); + const extensionWidget = + CustomizableUI.getWidget(extensionWidgetID).forWindow(window).node; + + await BrowserTestUtils.withNewTab("http://mochi.test:8888/", async () => { + // The extensions should be placed in the navbar by default so we do not + // expect an attention dot on the Unifed Extensions Button (UEB), only on + // the extension (widget) itself. + ok( + !unifiedButton.hasAttribute("attention"), + "expected no attention attribute on the UEB" + ); + ok( + extensionWidget.hasAttribute("attention"), + "expected attention attribute on the extension widget" + ); + + // Open the context menu of the extension and unpin the extension. + let contextMenu = await openChromeContextMenu( + "toolbar-context-menu", + `#${CSS.escape(extensionWidgetID)}` + ); + let pinToToolbar = contextMenu.querySelector( + ".customize-context-pinToToolbar" + ); + ok(pinToToolbar, "expected a 'Pin to Toolbar' menu item"); + // Passing the `pinToToolbar` item to `closeChromeContextMenu()` will + // activate it before closing the context menu. + await closeChromeContextMenu(contextMenu.id, pinToToolbar); + + ok( + unifiedButton.hasAttribute("attention"), + "expected attention attribute on the UEB" + ); + // We still expect the attention dot on the extension. + ok( + extensionWidget.hasAttribute("attention"), + "expected attention attribute on the extension widget" + ); + + // Now let's open the unified extensions panel, and pin the same extension + // to the toolbar, which should hide the attention dot on the UEB again. + await openExtensionsPanel(); + contextMenu = await openUnifiedExtensionsContextMenu(extension.id); + pinToToolbar = contextMenu.querySelector( + ".unified-extensions-context-menu-pin-to-toolbar" + ); + ok(pinToToolbar, "expected a 'Pin to Toolbar' menu item"); + const hidden = BrowserTestUtils.waitForEvent( + gUnifiedExtensions.panel, + "popuphidden", + true + ); + contextMenu.activateItem(pinToToolbar); + await hidden; + + ok( + !unifiedButton.hasAttribute("attention"), + "expected no attention attribute on the UEB" + ); + // We still expect the attention dot on the extension. + ok( + extensionWidget.hasAttribute("attention"), + "expected attention attribute on the extension widget" + ); + }); + + await extension.unload(); +}); + +async function testWithSubmenu(menu, nextItemClassName) { + function expectMenuItems() { + info("Checking expected menu items."); + let [submenu, sep1, ocMessage, sep2, next] = menu.children; + + is(submenu.tagName, "menu", "First item is a submenu."); + is(submenu.label, "submenu", "Submenu has the expected label."); + is(sep1.tagName, "menuseparator", "Second item is a separator."); + + let l10n = menu.ownerDocument.l10n.getAttributes(ocMessage); + is(ocMessage.tagName, "menuitem", "Third is origin controls message."); + is(l10n.id, "origin-controls-no-access", "Expected l10n id."); + + is(sep2.tagName, "menuseparator", "Fourth item is a separator."); + is(next.className, nextItemClassName, "All items accounted for."); + } + + const [submenu] = menu.children; + const popup = submenu.querySelector("menupopup"); + + // Open and close the submenu repeatedly a few times. + for (let i = 0; i < 3; i++) { + expectMenuItems(); + + const popupShown = BrowserTestUtils.waitForEvent(popup, "popupshown"); + submenu.openMenu(true); + await popupShown; + + expectMenuItems(); + + const popupHidden = BrowserTestUtils.waitForEvent(popup, "popuphidden"); + submenu.openMenu(false); + await popupHidden; + } + + menu.hidePopup(); +} + +add_task(async function test_originControls_with_submenus() { + let extension = await makeExtension({ + id: "submenus@test", + permissions: ["menus"], + }); + + await BrowserTestUtils.withNewTab("about:blank", async () => { + info(`Testing with submenus.`); + moveWidget(extension, true); + let target = `#${CSS.escape(makeWidgetId(extension.id))}-BAP`; + + await testWithSubmenu( + await openChromeContextMenu("toolbar-context-menu", target), + "customize-context-manageExtension" + ); + + info(`Testing with submenus inside extensions panel.`); + moveWidget(extension, false); + await openExtensionsPanel(); + + await testWithSubmenu( + await openUnifiedExtensionsContextMenu(extension.id), + "unified-extensions-context-menu-pin-to-toolbar" + ); + + await closeExtensionsPanel(); + }); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_pageAction_activeTab.js b/browser/components/extensions/test/browser/browser_ext_pageAction_activeTab.js new file mode 100644 index 0000000000..4ce6b247bf --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_pageAction_activeTab.js @@ -0,0 +1,107 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function test_middle_click_with_activeTab() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "https://example.com/" + ); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + page_action: {}, + permissions: ["activeTab"], + }, + + async background() { + browser.pageAction.onClicked.addListener(async (tab, info) => { + browser.test.assertEq(1, info.button, "Expected button value"); + browser.test.assertEq( + "https://example.com/", + tab.url, + "tab.url has the expected url" + ); + await browser.tabs.insertCSS(tab.id, { + code: "body { border: 20px solid red; }", + }); + browser.test.sendMessage("onClick"); + }); + + let [tab] = await browser.tabs.query({ + active: true, + currentWindow: true, + }); + await browser.pageAction.show(tab.id); + browser.test.sendMessage("ready"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + + let ext = WebExtensionPolicy.getByID(extension.id).extension; + is( + ext.tabManager.hasActiveTabPermission(tab), + false, + "Active tab was not granted permission" + ); + + await clickPageAction(extension, window, { button: 1 }); + await extension.awaitMessage("onClick"); + + is( + ext.tabManager.hasActiveTabPermission(tab), + true, + "Active tab was granted permission" + ); + await extension.unload(); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_middle_click_without_activeTab() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "https://example.com/" + ); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + page_action: {}, + }, + + async background() { + browser.pageAction.onClicked.addListener(async (tab, info) => { + browser.test.assertEq(1, info.button, "Expected button value"); + browser.test.assertEq(tab.url, undefined, "tab.url is undefined"); + await browser.test.assertRejects( + browser.tabs.insertCSS(tab.id, { + code: "body { border: 20px solid red; }", + }), + "Missing host permission for the tab", + "expected failure of tabs.insertCSS without permission" + ); + browser.test.sendMessage("onClick"); + }); + + let [tab] = await browser.tabs.query({ + active: true, + currentWindow: true, + }); + await browser.pageAction.show(tab.id); + browser.test.sendMessage("ready"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + + await clickPageAction(extension, window, { button: 1 }); + await extension.awaitMessage("onClick"); + + await extension.unload(); + + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_pageAction_click_types.js b/browser/components/extensions/test/browser/browser_ext_pageAction_click_types.js new file mode 100644 index 0000000000..0168ea0ab2 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_pageAction_click_types.js @@ -0,0 +1,240 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_setup(async function () { + // The page action button is hidden by default. + // This tests the use of pageAction when the button is visible. + // + // TODO(Bug 1704171): this should technically be removed in a follow up + // and the tests in this file adapted to keep into account that: + // - The pageAction is pinned on the urlbar by default + // when shown, and hidden when is not available (same for the + // overflow menu when enabled) + BrowserPageActions.mainButtonNode.style.visibility = "visible"; + registerCleanupFunction(() => { + BrowserPageActions.mainButtonNode.style.removeProperty("visibility"); + }); +}); + +async function test_clickData(testAsNonPersistent = false) { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + page_action: {}, + background: { + persistent: !testAsNonPersistent, + scripts: ["background.js"], + }, + }, + + files: { + "background.js": async function background() { + function onClicked(_tab, info) { + let button = info.button; + let modifiers = info.modifiers; + browser.test.sendMessage("onClick", { button, modifiers }); + } + + browser.pageAction.onClicked.addListener(onClicked); + + let [tab] = await browser.tabs.query({ + active: true, + currentWindow: true, + }); + await browser.pageAction.show(tab.id); + browser.test.sendMessage("ready"); + }, + }, + }); + + const map = { + shiftKey: "Shift", + altKey: "Alt", + metaKey: "Command", + ctrlKey: "Ctrl", + }; + + function assertSingleModifier(info, modifier) { + if (modifier === "ctrlKey" && AppConstants.platform === "macosx") { + is( + info.modifiers.length, + 2, + `MacCtrl modifier with control click on Mac` + ); + is( + info.modifiers[1], + "MacCtrl", + `MacCtrl modifier with control click on Mac` + ); + } else { + is( + info.modifiers.length, + 1, + `No unnecessary modifiers for exactly one key on event` + ); + } + + is(info.modifiers[0], map[modifier], `Correct modifier on click event`); + } + + async function testClickPageAction(doClick, doEnterKey) { + for (let modifier of Object.keys(map)) { + for (let i = 0; i < 2; i++) { + let clickEventData = { button: i }; + clickEventData[modifier] = true; + await doClick(extension, window, clickEventData); + let info = await extension.awaitMessage("onClick"); + + is(info.button, i, `Correct button on click event`); + assertSingleModifier(info, modifier); + } + + let keypressEventData = {}; + keypressEventData[modifier] = true; + await doEnterKey(extension, keypressEventData); + let info = await extension.awaitMessage("onClick"); + + is(info.button, 0, `Key command emulates left click`); + assertSingleModifier(info, modifier); + } + } + + await extension.startup(); + await extension.awaitMessage("ready"); + + if (testAsNonPersistent) { + assertPersistentListeners(extension, "pageAction", "onClicked", { + primed: false, + }); + info("Terminating the background event page"); + await extension.terminateBackground(); + assertPersistentListeners(extension, "pageAction", "onClicked", { + primed: true, + }); + } + + info("Clicking the pageAction"); + await testClickPageAction(clickPageAction, triggerPageActionWithKeyboard); + + if (testAsNonPersistent) { + await extension.awaitMessage("ready"); + assertPersistentListeners(extension, "pageAction", "onClicked", { + primed: false, + }); + } + + await testClickPageAction( + clickPageActionInPanel, + triggerPageActionWithKeyboardInPanel + ); + + await extension.unload(); +} + +async function test_clickData_reset(testAsNonPersistent = false) { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_action: { + default_area: "navbar", + }, + page_action: {}, + background: { + persistent: !testAsNonPersistent, + scripts: ["background.js"], + }, + }, + + files: { + "background.js": async function background() { + function onBrowserActionClicked(tab, info) { + // openPopup requires user interaction, such as a browser action click. + browser.pageAction.openPopup(); + } + + function onPageActionClicked(tab, info) { + browser.test.sendMessage("onClick", info); + } + + browser.browserAction.onClicked.addListener(onBrowserActionClicked); + browser.pageAction.onClicked.addListener(onPageActionClicked); + + let [tab] = await browser.tabs.query({ + active: true, + currentWindow: true, + }); + await browser.pageAction.show(tab.id); + browser.test.sendMessage("ready"); + }, + }, + }); + + async function clickPageActionWithModifiers() { + await clickPageAction(extension, window, { button: 1, shiftKey: true }); + let info = await extension.awaitMessage("onClick"); + is(info.button, 1); + is(info.modifiers[0], "Shift"); + } + + function assertInfoReset(info) { + is(info.button, 0, `ClickData button reset properly`); + is(info.modifiers.length, 0, `ClickData modifiers reset properly`); + } + + await extension.startup(); + await extension.awaitMessage("ready"); + + if (testAsNonPersistent) { + assertPersistentListeners(extension, "pageAction", "onClicked", { + primed: false, + }); + info("Terminating the background event page"); + await extension.terminateBackground(); + assertPersistentListeners(extension, "pageAction", "onClicked", { + primed: true, + }); + } + + info("Clicking the pageAction"); + await clickPageActionWithModifiers(); + + if (testAsNonPersistent) { + await extension.awaitMessage("ready"); + assertPersistentListeners(extension, "pageAction", "onClicked", { + primed: false, + }); + } + + await clickBrowserAction(extension); + assertInfoReset(await extension.awaitMessage("onClick")); + + await clickPageActionWithModifiers(); + + await triggerPageActionWithKeyboard(extension); + assertInfoReset(await extension.awaitMessage("onClick")); + + await extension.unload(); +} + +add_task(function test_clickData_MV2() { + return test_clickData(/* testAsNonPersistent */ false); +}); + +add_task(async function test_clickData_MV2_eventPage() { + await SpecialPowers.pushPrefEnv({ + set: [["extensions.eventPages.enabled", true]], + }); + await test_clickData(/* testAsNonPersistent */ true); + await SpecialPowers.popPrefEnv(); +}); + +add_task(function test_clickData_reset_MV2() { + return test_clickData_reset(/* testAsNonPersistent */ false); +}); + +add_task(async function test_clickData_reset_MV2_eventPage() { + await SpecialPowers.pushPrefEnv({ + set: [["extensions.eventPages.enabled", true]], + }); + await test_clickData_reset(/* testAsNonPersistent */ true); + await SpecialPowers.popPrefEnv(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_pageAction_context.js b/browser/components/extensions/test/browser/browser_ext_pageAction_context.js new file mode 100644 index 0000000000..fde45cf2f5 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_pageAction_context.js @@ -0,0 +1,453 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +loadTestSubscript("head_pageAction.js"); + +add_task(async function testTabSwitchContext() { + await runTests({ + manifest: { + name: "Foo Extension", + + page_action: { + default_icon: "default.png", + default_popup: "__MSG_popup__", + default_title: "Default __MSG_title__ \u263a", + }, + + default_locale: "en", + + permissions: ["tabs"], + }, + + files: { + "_locales/en/messages.json": { + popup: { + message: "default.html", + description: "Popup", + }, + + title: { + message: "Title", + description: "Title", + }, + }, + + "_locales/es_ES/messages.json": { + popup: { + message: "default.html", + description: "Popup", + }, + + title: { + message: "T\u00edtulo", + description: "Title", + }, + }, + + "default.png": imageBuffer, + "1.png": imageBuffer, + "2.png": imageBuffer, + }, + + getTests: function (tabs) { + let defaultIcon = "chrome://mozapps/skin/extensions/extensionGeneric.svg"; + let details = [ + { + icon: browser.runtime.getURL("default.png"), + popup: browser.runtime.getURL("default.html"), + title: "Default T\u00edtulo \u263a", + }, + { + icon: browser.runtime.getURL("1.png"), + popup: browser.runtime.getURL("default.html"), + title: "Default T\u00edtulo \u263a", + }, + { + icon: browser.runtime.getURL("2.png"), + popup: browser.runtime.getURL("2.html"), + title: "Title 2", + }, + { icon: defaultIcon, popup: "", title: "" }, + ]; + + let promiseTabLoad = details => { + return new Promise(resolve => { + browser.tabs.onUpdated.addListener(function listener(tabId, changed) { + if (tabId == details.id && changed.url == details.url) { + browser.tabs.onUpdated.removeListener(listener); + resolve(); + } + }); + }); + }; + + return [ + expect => { + browser.test.log("Initial state. No icon visible."); + expect(null); + }, + async expect => { + browser.test.log( + "Show the icon on the first tab, expect default properties." + ); + await browser.pageAction.show(tabs[0]); + expect(details[0]); + }, + expect => { + browser.test.log( + "Change the icon. Expect default properties excluding the icon." + ); + browser.pageAction.setIcon({ tabId: tabs[0], path: "1.png" }); + expect(details[1]); + }, + async expect => { + browser.test.log("Create a new tab. No icon visible."); + let tab = await browser.tabs.create({ + active: true, + url: "about:blank?0", + }); + tabs.push(tab.id); + expect(null); + }, + async expect => { + browser.test.log("Await tab load. No icon visible."); + let promise = promiseTabLoad({ id: tabs[1], url: "about:blank?0" }); + let { url } = await browser.tabs.get(tabs[1]); + if (url === "about:blank") { + await promise; + } + expect(null); + }, + async expect => { + browser.test.log("Change properties. Expect new properties."); + let tabId = tabs[1]; + await browser.pageAction.show(tabId); + + browser.pageAction.setIcon({ tabId, path: "2.png" }); + browser.pageAction.setPopup({ tabId, popup: "2.html" }); + browser.pageAction.setTitle({ tabId, title: "Title 2" }); + + expect(details[2]); + }, + async expect => { + browser.test.log("Change the hash. Expect same properties."); + + let promise = promiseTabLoad({ + id: tabs[1], + url: "about:blank?0#ref", + }); + browser.tabs.update(tabs[1], { url: "about:blank?0#ref" }); + await promise; + + expect(details[2]); + }, + expect => { + browser.test.log( + "Set empty string values. Expect empty strings but default icon." + ); + browser.pageAction.setIcon({ tabId: tabs[1], path: "" }); + browser.pageAction.setPopup({ tabId: tabs[1], popup: "" }); + browser.pageAction.setTitle({ tabId: tabs[1], title: "" }); + + expect(details[3]); + }, + expect => { + browser.test.log("Clear the values. Expect default ones."); + browser.pageAction.setIcon({ tabId: tabs[1], path: null }); + browser.pageAction.setPopup({ tabId: tabs[1], popup: null }); + browser.pageAction.setTitle({ tabId: tabs[1], title: null }); + + expect(details[0]); + }, + async expect => { + browser.test.log("Navigate to a new page. Expect icon hidden."); + + // TODO: This listener should not be necessary, but the |tabs.update| + // callback currently fires too early in e10s windows. + let promise = promiseTabLoad({ id: tabs[1], url: "about:blank?1" }); + + browser.tabs.update(tabs[1], { url: "about:blank?1" }); + + await promise; + expect(null); + }, + async expect => { + browser.test.log("Show the icon. Expect default properties again."); + + await browser.pageAction.show(tabs[1]); + expect(details[0]); + }, + async expect => { + browser.test.log( + "Switch back to the first tab. Expect previously set properties." + ); + await browser.tabs.update(tabs[0], { active: true }); + expect(details[1]); + }, + async expect => { + browser.test.log( + "Hide the icon on tab 2. Switch back, expect hidden." + ); + await browser.pageAction.hide(tabs[1]); + + await browser.tabs.update(tabs[1], { active: true }); + expect(null); + }, + async expect => { + browser.test.log( + "Switch back to tab 1. Expect previous results again." + ); + await browser.tabs.remove(tabs[1]); + expect(details[1]); + }, + async expect => { + browser.test.log("Hide the icon. Expect hidden."); + + await browser.pageAction.hide(tabs[0]); + expect(null); + }, + async expect => { + browser.test.assertRejects( + browser.pageAction.setPopup({ + tabId: tabs[0], + popup: "about:addons", + }), + /Access denied for URL about:addons/, + "unable to set popup to about:addons" + ); + + expect(null); + }, + ]; + }, + }); +}); + +add_task(async function testMultipleWindows() { + // Disable newtab preloading, so that the tabs.create call below will always + // trigger a new load that can be detected by webNavigation.onCompleted. + await SpecialPowers.pushPrefEnv({ + set: [["browser.newtab.preload", false]], + }); + await runTests({ + manifest: { + page_action: { + default_icon: "default.png", + default_popup: "default.html", + default_title: "Default Title", + }, + permissions: ["webNavigation"], + }, + + files: { + "default.png": imageBuffer, + "tab.png": imageBuffer, + }, + + getTests: function (tabs, windows) { + let details = [ + { + icon: browser.runtime.getURL("default.png"), + popup: browser.runtime.getURL("default.html"), + title: "Default Title", + }, + { + icon: browser.runtime.getURL("tab.png"), + popup: browser.runtime.getURL("tab.html"), + title: "tab", + }, + ]; + + function promiseWebNavigationCompleted(url) { + return new Promise(resolve => { + // The pageAction visibility state is reset when the location changes. + // The webNavigation.onCompleted event is triggered when that happens. + browser.webNavigation.onCompleted.addListener( + function listener() { + browser.webNavigation.onCompleted.removeListener(listener); + resolve(); + }, + { + url: [{ urlEquals: url }], + } + ); + }); + } + + return [ + async expect => { + browser.test.log("Create a new tab, expect hidden pageAction."); + let promise = promiseWebNavigationCompleted("about:newtab"); + let tab = await browser.tabs.create({ active: true }); + await promise; + tabs.push(tab.id); + expect(null); + }, + async expect => { + browser.test.log("Show the pageAction, expect default values."); + await browser.pageAction.show(tabs[1]); + expect(details[0]); + }, + async expect => { + browser.test.log("Set tab-specific values, expect them."); + await browser.pageAction.setIcon({ tabId: tabs[1], path: "tab.png" }); + await browser.pageAction.setPopup({ + tabId: tabs[1], + popup: "tab.html", + }); + await browser.pageAction.setTitle({ tabId: tabs[1], title: "tab" }); + expect(details[1]); + }, + async expect => { + browser.test.log("Open a new window, expect hidden pageAction."); + let { id } = await browser.windows.create(); + windows.push(id); + expect(null); + }, + async expect => { + browser.test.log( + "Move tab from old window to the new one, expect old values." + ); + await browser.tabs.move(tabs[1], { windowId: windows[1], index: -1 }); + await browser.tabs.update(tabs[1], { active: true }); + expect(details[1]); + }, + async expect => { + browser.test.log("Close the initial tab of the new window."); + let [{ id }] = await browser.tabs.query({ + windowId: windows[1], + index: 0, + }); + await browser.tabs.remove(id); + expect(details[1]); + }, + async expect => { + browser.test.log( + "Move the previous tab to a 3rd window, the 2nd one will close." + ); + await browser.windows.create({ tabId: tabs[1] }); + expect(details[1]); + }, + async expect => { + browser.test.log("Close the tab, go back to the 1st window."); + await browser.tabs.remove(tabs[1]); + expect(null); + }, + ]; + }, + }); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function testNavigationClearsData() { + let url = "http://example.com/"; + let default_title = "Default title"; + let tab_title = "Tab title"; + + const { + Management: { + global: { tabTracker }, + }, + } = ChromeUtils.importESModule("resource://gre/modules/Extension.sys.mjs"); + let extension, + tabs = []; + async function addTab(...args) { + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, ...args); + tabs.push(tab); + return tab; + } + async function sendMessage(method, param, expect, msg) { + extension.sendMessage({ method, param, expect, msg }); + await extension.awaitMessage("done"); + } + async function expectTabSpecificData(tab, msg) { + let tabId = tabTracker.getId(tab); + await sendMessage("isShown", { tabId }, true, msg); + await sendMessage("getTitle", { tabId }, tab_title, msg); + } + async function expectDefaultData(tab, msg) { + let tabId = tabTracker.getId(tab); + await sendMessage("isShown", { tabId }, false, msg); + await sendMessage("getTitle", { tabId }, default_title, msg); + } + async function setTabSpecificData(tab) { + let tabId = tabTracker.getId(tab); + await expectDefaultData( + tab, + "Expect default data before setting tab-specific data." + ); + await sendMessage("show", tabId); + await sendMessage("setTitle", { tabId, title: tab_title }); + await expectTabSpecificData( + tab, + "Expect tab-specific data after setting it." + ); + } + + info("Load a tab before installing the extension"); + let tab1 = await addTab(url, true, true); + + extension = ExtensionTestUtils.loadExtension({ + manifest: { + page_action: { default_title }, + }, + background: function () { + browser.test.onMessage.addListener( + async ({ method, param, expect, msg }) => { + let result = await browser.pageAction[method](param); + if (expect !== undefined) { + browser.test.assertEq(expect, result, msg); + } + browser.test.sendMessage("done"); + } + ); + }, + }); + await extension.startup(); + + info("Set tab-specific data to the existing tab."); + await setTabSpecificData(tab1); + + info("Add a hash. Does not cause navigation."); + await navigateTab(tab1, url + "#hash"); + await expectTabSpecificData( + tab1, + "Adding a hash does not clear tab-specific data" + ); + + info("Remove the hash. Causes navigation."); + await navigateTab(tab1, url); + await expectDefaultData(tab1, "Removing hash clears tab-specific data"); + + info("Open a new tab, set tab-specific data to it."); + let tab2 = await addTab("about:newtab", false, false); + await setTabSpecificData(tab2); + + info("Load a page in that tab."); + await navigateTab(tab2, url); + await expectDefaultData(tab2, "Loading a page clears tab-specific data."); + + info("Set tab-specific data."); + await setTabSpecificData(tab2); + + info("Push history state. Does not cause navigation."); + await historyPushState(tab2, url + "/path"); + await expectTabSpecificData( + tab2, + "history.pushState() does not clear tab-specific data" + ); + + info("Navigate when the tab is not selected"); + gBrowser.selectedTab = tab1; + await navigateTab(tab2, url); + await expectDefaultData( + tab2, + "Navigating clears tab-specific data, even when not selected." + ); + + for (let tab of tabs) { + BrowserTestUtils.removeTab(tab); + } + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_pageAction_contextMenu.js b/browser/components/extensions/test/browser/browser_ext_pageAction_contextMenu.js new file mode 100644 index 0000000000..57ecc889ae --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_pageAction_contextMenu.js @@ -0,0 +1,128 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +let extData = { + manifest: { + permissions: ["contextMenus"], + page_action: { + default_popup: "popup.html", + }, + }, + useAddonManager: "temporary", + + files: { + "popup.html": ` + <!DOCTYPE html> + <html> + <head><meta charset="utf-8"/> + </head> + <body> + <span id="text">A Test Popup</span> + <img id="testimg" src="data:image/svg+xml,<svg></svg>" height="10" width="10"> + </body></html> + `, + }, + + background: function () { + browser.contextMenus.create({ + id: "clickme-page", + title: "Click me!", + contexts: ["all"], + }); + browser.tabs.query({ active: true, currentWindow: true }, tabs => { + const tabId = tabs[0].id; + + browser.pageAction.show(tabId).then(() => { + browser.test.sendMessage("action-shown"); + }); + }); + }, +}; + +let contextMenuItems = { + "context-sep-navigation": "hidden", + "context-viewsource": "", + "inspect-separator": "hidden", + "context-inspect": "hidden", + "context-inspect-a11y": "hidden", + "context-bookmarkpage": "hidden", +}; +if (AppConstants.platform == "macosx") { + contextMenuItems["context-back"] = "hidden"; + contextMenuItems["context-forward"] = "hidden"; + contextMenuItems["context-reload"] = "hidden"; + contextMenuItems["context-stop"] = "hidden"; +} else { + contextMenuItems["context-navigation"] = "hidden"; +} + +add_task(async function pageaction_popup_contextmenu() { + let extension = ExtensionTestUtils.loadExtension(extData); + await extension.startup(); + await extension.awaitMessage("action-shown"); + + await clickPageAction(extension, window); + + let contentAreaContextMenu = await openContextMenuInPopup(extension); + let item = contentAreaContextMenu.getElementsByAttribute( + "label", + "Click me!" + ); + is(item.length, 1, "contextMenu item for page was found"); + await closeContextMenu(contentAreaContextMenu); + + await extension.unload(); +}); + +add_task(async function pageaction_popup_contextmenu_hidden_items() { + let extension = ExtensionTestUtils.loadExtension(extData); + await extension.startup(); + await extension.awaitMessage("action-shown"); + + await clickPageAction(extension, window); + + let contentAreaContextMenu = await openContextMenuInPopup(extension, "#text"); + + let item, state; + for (const itemID in contextMenuItems) { + item = contentAreaContextMenu.querySelector(`#${itemID}`); + state = contextMenuItems[itemID]; + + if (state !== "") { + ok(item[state], `${itemID} is ${state}`); + + if (state !== "hidden") { + ok(!item.hidden, `Disabled ${itemID} is not hidden`); + } + } else { + ok(!item.hidden, `${itemID} is not hidden`); + ok(!item.disabled, `${itemID} is not disabled`); + } + } + + await closeContextMenu(contentAreaContextMenu); + + await extension.unload(); +}); + +add_task(async function pageaction_popup_image_contextmenu() { + let extension = ExtensionTestUtils.loadExtension(extData); + await extension.startup(); + await extension.awaitMessage("action-shown"); + + await clickPageAction(extension, window); + + let contentAreaContextMenu = await openContextMenuInPopup( + extension, + "#testimg" + ); + + let item = contentAreaContextMenu.querySelector("#context-copyimage"); + ok(!item.hidden); + ok(!item.disabled); + + await closeContextMenu(contentAreaContextMenu); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_pageAction_popup.js b/browser/components/extensions/test/browser/browser_ext_pageAction_popup.js new file mode 100644 index 0000000000..366827dd1b --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_pageAction_popup.js @@ -0,0 +1,305 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +PromiseTestUtils.allowMatchingRejectionsGlobally(/packaging errors/); + +function assertViewCount(extension, count) { + let ext = WebExtensionPolicy.getByID(extension.id).extension; + is( + ext.views.size, + count, + "Should have the expected number of extension views" + ); +} + +add_task(async function testPageActionPopup() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.com/" + ); + + let scriptPage = url => + `<html><head><meta charset="utf-8"><script src="${url}"></script></head></html>`; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + background: { + page: "data/background.html", + }, + page_action: { + default_popup: "popup-a.html", + }, + }, + + files: { + "popup-a.html": scriptPage("popup-a.js"), + "popup-a.js": function () { + window.onload = () => { + let background = window.getComputedStyle( + document.body + ).backgroundColor; + browser.test.assertEq("rgba(0, 0, 0, 0)", background); + browser.runtime.sendMessage("from-popup-a"); + }; + browser.runtime.onMessage.addListener(msg => { + if (msg == "close-popup") { + window.close(); + } + }); + }, + + "data/popup-b.html": scriptPage("popup-b.js"), + "data/popup-b.js": function () { + browser.runtime.sendMessage("from-popup-b"); + }, + + "data/background.html": scriptPage("background.js"), + + "data/background.js": async function () { + let tabId; + + let sendClick; + let tests = [ + () => { + sendClick({ expectEvent: false, expectPopup: "a" }); + }, + () => { + sendClick({ expectEvent: false, expectPopup: "a" }); + }, + () => { + browser.pageAction.setPopup({ tabId, popup: "popup-b.html" }); + sendClick({ expectEvent: false, expectPopup: "b" }); + }, + () => { + sendClick({ expectEvent: false, expectPopup: "b" }); + }, + () => { + sendClick({ + expectEvent: true, + expectPopup: "b", + middleClick: true, + }); + }, + () => { + browser.pageAction.setPopup({ tabId, popup: "" }); + sendClick({ expectEvent: true, expectPopup: null }); + }, + () => { + sendClick({ expectEvent: true, expectPopup: null }); + }, + () => { + browser.pageAction.setPopup({ tabId, popup: "/popup-a.html" }); + sendClick({ + expectEvent: false, + expectPopup: "a", + runNextTest: true, + }); + }, + () => { + browser.test.sendMessage("next-test", { expectClosed: true }); + }, + () => { + sendClick({ + expectEvent: false, + expectPopup: "a", + runNextTest: true, + }); + }, + () => { + browser.test.sendMessage("next-test", { closeOnTabSwitch: true }); + }, + ]; + + let expect = {}; + sendClick = ({ + expectEvent, + expectPopup, + runNextTest, + middleClick, + }) => { + expect = { event: expectEvent, popup: expectPopup, runNextTest }; + + browser.test.sendMessage("send-click", middleClick ? 1 : 0); + }; + + browser.runtime.onMessage.addListener(msg => { + if (msg == "close-popup") { + return; + } else if (expect.popup) { + browser.test.assertEq( + msg, + `from-popup-${expect.popup}`, + "expected popup opened" + ); + } else { + browser.test.fail(`unexpected popup: ${msg}`); + } + + expect.popup = null; + if (expect.runNextTest) { + expect.runNextTest = false; + tests.shift()(); + } else { + browser.test.sendMessage("next-test"); + } + }); + + browser.pageAction.onClicked.addListener((tab, info) => { + if (expect.event) { + browser.test.succeed("expected click event received"); + } else { + browser.test.fail("unexpected click event"); + } + expect.event = false; + + if (info.button == 1) { + browser.pageAction.openPopup(); + return; + } + + browser.test.sendMessage("next-test"); + }); + + browser.test.onMessage.addListener(msg => { + if (msg == "close-popup") { + browser.runtime.sendMessage("close-popup"); + return; + } + + if (msg != "next-test") { + browser.test.fail("Expecting 'next-test' message"); + } + + if (expect.event) { + browser.test.fail( + "Expecting click event before next test but none occurred" + ); + } + + if (expect.popup) { + browser.test.fail( + "Expecting popup before next test but none were shown" + ); + } + + if (tests.length) { + let test = tests.shift(); + test(); + } else { + browser.test.notifyPass("pageaction-tests-done"); + } + }); + + let [tab] = await browser.tabs.query({ + active: true, + currentWindow: true, + }); + tabId = tab.id; + + await browser.pageAction.show(tabId); + browser.test.sendMessage("next-test"); + }, + }, + }); + + extension.onMessage("send-click", button => { + clickPageAction(extension, window, { button }); + }); + + let pageActionId, panelId; + extension.onMessage("next-test", async function (expecting = {}) { + pageActionId = `${makeWidgetId(extension.id)}-page-action`; + panelId = `${makeWidgetId(extension.id)}-panel`; + let panel = document.getElementById(panelId); + if (expecting.expectClosed) { + ok(panel, "Expect panel to exist"); + await promisePopupShown(panel); + + extension.sendMessage("close-popup"); + + await promisePopupHidden(panel); + ok(true, `Panel is closed`); + } else if (expecting.closeOnTabSwitch) { + ok(panel, "Expect panel to exist"); + await promisePopupShown(panel); + + let oldTab = gBrowser.selectedTab; + Assert.notEqual( + oldTab, + gBrowser.tabs[0], + "Should have an inactive tab to switch to" + ); + + let hiddenPromise = promisePopupHidden(panel); + + gBrowser.selectedTab = gBrowser.tabs[0]; + await hiddenPromise; + info("Panel closed"); + + gBrowser.selectedTab = oldTab; + } else if (panel) { + await promisePopupShown(panel); + panel.hidePopup(); + } + + assertViewCount(extension, 1); + + if (panel) { + panel = document.getElementById(panelId); + is(panel, null, "panel successfully removed from document after hiding"); + } + + extension.sendMessage("next-test"); + }); + + await extension.startup(); + await extension.awaitFinish("pageaction-tests-done"); + + await extension.unload(); + + let node = document.getElementById(pageActionId); + is(node, null, "pageAction image removed from document"); + + let panel = document.getElementById(panelId); + is(panel, null, "pageAction panel removed from document"); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function testPageActionSecurity() { + const URL = "chrome://browser/content/browser.xhtml"; + + let apis = ["browser_action", "page_action"]; + + for (let api of apis) { + info(`TEST ${api} icon url: ${URL}`); + + let messages = [/Access to restricted URI denied/]; + + let waitForConsole = new Promise(resolve => { + // Not necessary in browser-chrome tests, but monitorConsole gripes + // if we don't call it. + SimpleTest.waitForExplicitFinish(); + + SimpleTest.monitorConsole(resolve, messages); + }); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + [api]: { default_popup: URL }, + }, + }); + + await Assert.rejects( + extension.startup(), + /startup failed/, + "Manifest rejected" + ); + + SimpleTest.endMonitorConsole(); + await waitForConsole; + } +}); + +add_task(forceGC); diff --git a/browser/components/extensions/test/browser/browser_ext_pageAction_popup_resize.js b/browser/components/extensions/test/browser/browser_ext_pageAction_popup_resize.js new file mode 100644 index 0000000000..a0b5b8377d --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_pageAction_popup_resize.js @@ -0,0 +1,192 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function testPageActionPopupResize() { + let browser; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + page_action: { + default_popup: "popup.html", + browser_style: true, + }, + }, + background: function () { + browser.tabs.query({ active: true, currentWindow: true }, tabs => { + const tabId = tabs[0].id; + + browser.pageAction.show(tabId).then(() => { + browser.test.sendMessage("action-shown"); + }); + }); + }, + + files: { + "popup.html": `<!DOCTYPE html><html><head><meta charset="utf-8"></head><body><div></div></body></html>`, + }, + }); + + await extension.startup(); + await extension.awaitMessage("action-shown"); + + clickPageAction(extension, window); + + browser = await awaitExtensionPanel(extension); + + async function checkSize(height, width) { + let dims = await promiseContentDimensions(browser); + let { body, root } = dims; + + is( + dims.window.innerHeight, + height, + `Panel window should be ${height}px tall` + ); + is( + body.clientHeight, + body.scrollHeight, + "Panel body should be tall enough to fit its contents" + ); + is( + root.clientHeight, + root.scrollHeight, + "Panel root should be tall enough to fit its contents" + ); + + if (width) { + is( + body.clientWidth, + body.scrollWidth, + "Panel body should be wide enough to fit its contents" + ); + + // Tolerate if it is 1px too wide, as that may happen with the current + // resizing method. + Assert.lessOrEqual( + Math.abs(dims.window.innerWidth - width), + 1, + `Panel window should be ${width}px wide` + ); + } + } + + function setSize(size) { + let elem = content.document.body.firstElementChild; + elem.style.height = `${size}px`; + elem.style.width = `${size}px`; + } + + function setHeight(height) { + content.document.body.style.overflow = "hidden"; + let elem = content.document.body.firstElementChild; + elem.style.height = `${height}px`; + } + + let sizes = [200, 400, 300]; + + for (let size of sizes) { + await alterContent(browser, setSize, size); + await checkSize(size, size); + } + + let dims = await alterContent(browser, setSize, 1400); + let { body, root } = dims; + + is(dims.window.innerWidth, 800, "Panel window width"); + Assert.lessOrEqual( + body.clientWidth, + 800, + `Panel body width ${body.clientWidth} is less than 800` + ); + is(body.scrollWidth, 1400, "Panel body scroll width"); + + is(dims.window.innerHeight, 600, "Panel window height"); + Assert.lessOrEqual( + root.clientHeight, + 600, + `Panel root height (${root.clientHeight}px) is less than 600px` + ); + is(root.scrollHeight, 1400, "Panel root scroll height"); + + for (let size of sizes) { + await alterContent(browser, setHeight, size); + await checkSize(size, null); + } + + await extension.unload(); +}); + +add_task(async function testPageActionPopupReflow() { + let browser; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + page_action: { + default_popup: "popup.html", + browser_style: true, + }, + }, + background: function () { + browser.tabs.query({ active: true, currentWindow: true }, tabs => { + const tabId = tabs[0].id; + + browser.pageAction.show(tabId).then(() => { + browser.test.sendMessage("action-shown"); + }); + }); + }, + + files: { + "popup.html": `<!DOCTYPE html><html><head><meta charset="utf-8"></head> + <body> + The quick mauve fox jumps over the opalescent toad, with its glowing + eyes, and its vantablack mouth, and its bottomless chasm where you + would hope to find a heart, that looks straight into the deepest + pits of hell. The fox shivers, and cowers, and tries to run, but + the toad is utterly without pity. It turns, ever so slightly... + </body> + </html>`, + }, + }); + + await extension.startup(); + await extension.awaitMessage("action-shown"); + + clickPageAction(extension, window); + + browser = await awaitExtensionPanel(extension); + + function setSize(size) { + content.document.body.style.fontSize = `${size}px`; + } + + let dims = await alterContent(browser, setSize, 18); + + is(dims.window.innerWidth, 800, "Panel window should be 800px wide"); + is(dims.body.clientWidth, 800, "Panel body should be 800px wide"); + is( + dims.body.clientWidth, + dims.body.scrollWidth, + "Panel body should be wide enough to fit its contents" + ); + + Assert.greater( + dims.window.innerHeight, + 36, + `Panel window height (${dims.window.innerHeight}px) should be taller than two lines of text.` + ); + + is( + dims.body.clientHeight, + dims.body.scrollHeight, + "Panel body should be tall enough to fit its contents" + ); + is( + dims.root.clientHeight, + dims.root.scrollHeight, + "Panel root should be tall enough to fit its contents" + ); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_pageAction_show_matches.js b/browser/components/extensions/test/browser/browser_ext_pageAction_show_matches.js new file mode 100644 index 0000000000..fd589acdbd --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_pageAction_show_matches.js @@ -0,0 +1,329 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +PromiseTestUtils.allowMatchingRejectionsGlobally(/packaging errors/); + +function getExtension(page_action) { + return ExtensionTestUtils.loadExtension({ + manifest: { + page_action, + }, + background: function () { + browser.test.onMessage.addListener( + async ({ method, param, expect, msg }) => { + let result = await browser.pageAction[method](param); + if (expect !== undefined) { + browser.test.assertEq(expect, result, msg); + } + browser.test.sendMessage("done"); + } + ); + }, + }); +} + +async function sendMessage(ext, method, param, expect, msg) { + ext.sendMessage({ method, param, expect, msg }); + await ext.awaitMessage("done"); +} + +let tests = [ + { + name: "Test shown for all_urls", + page_action: { + show_matches: ["<all_urls>"], + }, + shown: [true, true, false], + }, + { + name: "Test hide_matches overrides all_urls.", + page_action: { + show_matches: ["<all_urls>"], + hide_matches: ["*://mochi.test/*"], + }, + shown: [true, false, false], + }, + { + name: "Test shown only for show_matches.", + page_action: { + show_matches: ["*://mochi.test/*"], + }, + shown: [false, true, false], + }, +]; + +// For some reason about:rights and about:about used to behave differently (maybe +// because only the latter is privileged?) so both should be tested. about:about +// is used in the test as the base tab. +let urls = ["http://example.com/", "http://mochi.test:8888/", "about:rights"]; + +function getId(tab) { + const { + Management: { + global: { tabTracker }, + }, + } = ChromeUtils.importESModule("resource://gre/modules/Extension.sys.mjs"); + getId = tabTracker.getId.bind(tabTracker); // eslint-disable-line no-func-assign + return getId(tab); +} + +async function check(extension, tab, expected, msg) { + await promiseAnimationFrame(); + let widgetId = makeWidgetId(extension.id); + let pageActionId = BrowserPageActions.urlbarButtonNodeIDForActionID(widgetId); + is( + gBrowser.selectedTab, + tab, + `tab ${tab.linkedBrowser.currentURI.spec} is selected` + ); + let button = document.getElementById(pageActionId); + // Sometimes we're hidden, sometimes a parent is hidden via css (e.g. about pages) + let hidden = + button === null || + button.hidden || + window.getComputedStyle(button).display == "none"; + is(!hidden, expected, msg + " (computed)"); + await sendMessage( + extension, + "isShown", + { tabId: getId(tab) }, + expected, + msg + " (isShown)" + ); +} + +add_task(async function test_pageAction_default_show_tabs() { + info( + "Check show_matches and hide_matches are respected when opening a new tab or switching to an existing tab." + ); + let switchTab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:about", + true, + true + ); + for (let [i, test] of tests.entries()) { + info(`test ${i}: ${test.name}`); + let extension = getExtension(test.page_action); + await extension.startup(); + for (let [j, url] of urls.entries()) { + let expected = test.shown[j]; + let msg = `test ${i} url ${j}: page action is ${ + expected ? "shown" : "hidden" + } for ${url}`; + + info("Check new tab."); + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + url, + true, + true + ); + await check(extension, tab, expected, msg + " (new)"); + + info("Check switched tab."); + await BrowserTestUtils.switchTab(gBrowser, switchTab); + await check(extension, switchTab, false, msg + " (about:about)"); + await BrowserTestUtils.switchTab(gBrowser, tab); + await check(extension, tab, expected, msg + " (switched)"); + + BrowserTestUtils.removeTab(tab); + } + await extension.unload(); + } + BrowserTestUtils.removeTab(switchTab); +}); + +add_task(async function test_pageAction_default_show_install() { + info( + "Check show_matches and hide_matches are respected when installing the extension" + ); + for (let [i, test] of tests.entries()) { + info(`test ${i}: ${test.name}`); + for (let expected of [true, false]) { + let j = test.shown.indexOf(expected); + if (j === -1) { + continue; + } + let initialTab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + urls[j], + true, + true + ); + let installTab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + urls[j], + true, + true + ); + let extension = getExtension(test.page_action); + await extension.startup(); + let msg = `test ${i} url ${j}: page action is ${ + expected ? "shown" : "hidden" + } for ${urls[j]}`; + await check(extension, installTab, expected, msg + " (active)"); + + // initialTab has not been activated after installation, so we have not evaluated whether the page + // action should be shown in it. Check that pageAction.isShown works anyways. + await sendMessage( + extension, + "isShown", + { tabId: getId(initialTab) }, + expected, + msg + " (inactive)" + ); + + BrowserTestUtils.removeTab(initialTab); + BrowserTestUtils.removeTab(installTab); + await extension.unload(); + } + } +}); + +add_task(async function test_pageAction_history() { + info( + "Check match patterns are reevaluated when using history.pushState or navigating" + ); + let url1 = "http://example.com/"; + let url2 = url1 + "path/"; + let extension = getExtension({ + show_matches: [url1], + hide_matches: [url2], + }); + await extension.startup(); + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + url1, + true, + true + ); + await check(extension, tab, true, "page action is shown for " + url1); + + info("Use history.pushState to change the URL without navigating"); + await historyPushState(tab, url2); + await check(extension, tab, false, "page action is hidden for " + url2); + + info("Use hide()"); + await sendMessage(extension, "hide", getId(tab)); + await check(extension, tab, false, "page action is still hidden"); + + info("Use history.pushState to revert to first url"); + await historyPushState(tab, url1); + await check( + extension, + tab, + false, + "hide() has more precedence than pattern matching" + ); + + info("Select another tab"); + let tab2 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + url1, + true, + true + ); + + info("Perform navigation in the old tab"); + await navigateTab(tab, url1); + await sendMessage( + extension, + "isShown", + { tabId: getId(tab) }, + true, + "Navigating undoes hide(), even when the tab is not selected." + ); + + BrowserTestUtils.removeTab(tab); + BrowserTestUtils.removeTab(tab2); + await extension.unload(); +}); + +add_task(async function test_pageAction_all_urls() { + info("Check <all_urls> is not allowed in hide_matches"); + let extension = getExtension({ + show_matches: ["*://mochi.test/*"], + hide_matches: ["<all_urls>"], + }); + let rejects = await extension.startup().then( + () => false, + () => true + ); + is(rejects, true, "startup failed"); +}); + +add_task(async function test_pageAction_restrictScheme_false() { + info( + "Check restricted origins are allowed in show_matches for privileged extensions" + ); + let extension = ExtensionTestUtils.loadExtension({ + isPrivileged: true, + manifest: { + permissions: ["mozillaAddons", "tabs"], + page_action: { + show_matches: ["about:reader*"], + hide_matches: ["*://*/*"], + }, + }, + background: function () { + browser.tabs.onUpdated.addListener(async (tabId, changeInfo) => { + if (changeInfo.url && changeInfo.url.startsWith("about:reader")) { + browser.test.sendMessage("readerModeEntered"); + } + }); + + browser.test.onMessage.addListener(async msg => { + if (msg !== "enterReaderMode") { + browser.test.fail(`Received unexpected test message: ${msg}`); + return; + } + + browser.tabs.toggleReaderMode(); + }); + }, + }); + + async function expectPageAction(extension, tab, isShown) { + await promiseAnimationFrame(); + let widgetId = makeWidgetId(extension.id); + let pageActionId = + BrowserPageActions.urlbarButtonNodeIDForActionID(widgetId); + let iconEl = document.getElementById(pageActionId); + + if (isShown) { + ok(iconEl && !iconEl.hasAttribute("disabled"), "pageAction is shown"); + } else { + ok( + iconEl == null || iconEl.getAttribute("disabled") == "true", + "pageAction is hidden" + ); + } + } + + const baseUrl = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "http://example.com" + ); + const url = `${baseUrl}/readerModeArticle.html`; + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + url, + true, + true + ); + + await extension.startup(); + + await expectPageAction(extension, tab, false); + + extension.sendMessage("enterReaderMode"); + await extension.awaitMessage("readerModeEntered"); + + await expectPageAction(extension, tab, true); + + BrowserTestUtils.removeTab(tab); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_pageAction_simple.js b/browser/components/extensions/test/browser/browser_ext_pageAction_simple.js new file mode 100644 index 0000000000..dc323afd94 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_pageAction_simple.js @@ -0,0 +1,213 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const BASE = + "http://example.com/browser/browser/components/extensions/test/browser/"; + +add_task(async function test_pageAction_basic() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + page_action: { + default_popup: "popup.html", + unrecognized_property: "with-a-random-value", + }, + }, + + files: { + "popup.html": ` + <!DOCTYPE html> + <html><body> + <script src="popup.js"></script> + </body></html> + `, + + "popup.js": function () { + browser.runtime.sendMessage("from-popup"); + }, + }, + + background: function () { + browser.runtime.onMessage.addListener(msg => { + browser.test.assertEq(msg, "from-popup", "correct message received"); + browser.test.sendMessage("popup"); + }); + browser.tabs.query({ active: true, currentWindow: true }, tabs => { + let tabId = tabs[0].id; + + browser.pageAction.show(tabId).then(() => { + browser.test.sendMessage("page-action-shown"); + }); + }); + }, + }); + + SimpleTest.waitForExplicitFinish(); + let waitForConsole = new Promise(resolve => { + SimpleTest.monitorConsole(resolve, [ + { + message: + /Reading manifest: Warning processing page_action.unrecognized_property: An unexpected property was found/, + }, + ]); + }); + + ExtensionTestUtils.failOnSchemaWarnings(false); + await extension.startup(); + ExtensionTestUtils.failOnSchemaWarnings(true); + await extension.awaitMessage("page-action-shown"); + + let elem = await getPageActionButton(extension); + let parent = window.document.getElementById("page-action-buttons"); + is( + elem && elem.parentNode, + parent, + `pageAction pinned to urlbar ${elem.parentNode.getAttribute("id")}` + ); + + clickPageAction(extension); + + await extension.awaitMessage("popup"); + + await extension.unload(); + + SimpleTest.endMonitorConsole(); + await waitForConsole; +}); + +add_task(async function test_pageAction_pinned() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + page_action: { + default_popup: "popup.html", + pinned: false, + }, + }, + + files: { + "popup.html": ` + <!DOCTYPE html> + <html><body> + </body></html> + `, + }, + + background: function () { + browser.tabs.query({ active: true, currentWindow: true }, tabs => { + let tabId = tabs[0].id; + + browser.pageAction.show(tabId).then(() => { + browser.test.sendMessage("page-action-shown"); + }); + }); + }, + }); + + await extension.startup(); + await extension.awaitMessage("page-action-shown"); + + // There are plenty of tests for the main action button, we just verify + // that we've properly set the pinned value. + // This test used to check that the button was not pinned, but that is no + // longer supported. + // TODO bug 1703537: consider removal of the pinned property. + let action = PageActions.actionForID(makeWidgetId(extension.id)); + ok(action && action.pinnedToUrlbar, "Check pageAction pinning"); + + await extension.unload(); +}); + +add_task(async function test_pageAction_icon_on_subframe_navigation() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + page_action: { + default_popup: "popup.html", + }, + }, + + files: { + "popup.html": ` + <!DOCTYPE html> + <html><body> + </body></html> + `, + }, + + background: function () { + browser.tabs.query({ active: true, currentWindow: true }, tabs => { + let tabId = tabs[0].id; + + browser.pageAction.show(tabId).then(() => { + browser.test.sendMessage("page-action-shown"); + }); + }); + }, + }); + + await navigateTab( + gBrowser.selectedTab, + "data:text/html,<h1>Top Level Frame</h1>" + ); + + await extension.startup(); + await extension.awaitMessage("page-action-shown"); + + const pageActionId = BrowserPageActions.urlbarButtonNodeIDForActionID( + makeWidgetId(extension.id) + ); + + await BrowserTestUtils.waitForCondition(() => { + return document.getElementById(pageActionId); + }, "pageAction is initially visible"); + + info("Create a sub-frame"); + + let subframeURL = `${BASE}#subframe-url-1`; + await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [subframeURL], + async url => { + const iframe = this.content.document.createElement("iframe"); + iframe.setAttribute("id", "test-subframe"); + iframe.setAttribute("src", url); + iframe.setAttribute("style", "height: 200px; width: 200px"); + + // Await the initial url to be loaded in the subframe. + await new Promise(resolve => { + iframe.onload = resolve; + this.content.document.body.appendChild(iframe); + }); + } + ); + + await BrowserTestUtils.waitForCondition(() => { + return document.getElementById(pageActionId); + }, "pageAction should be visible when a subframe is created"); + + info("Navigating the sub-frame"); + + subframeURL = `${BASE}/file_dummy.html#subframe-url-2`; + await SpecialPowers.spawn( + gBrowser.selectedBrowser, + [subframeURL], + async url => { + const iframe = this.content.document.querySelector( + "iframe#test-subframe" + ); + + // Await the subframe navigation. + await new Promise(resolve => { + iframe.onload = resolve; + iframe.setAttribute("src", url); + }); + } + ); + + info("Subframe location changed"); + + await BrowserTestUtils.waitForCondition(() => { + return document.getElementById(pageActionId); + }, "pageAction should be visible after a subframe navigation"); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_pageAction_telemetry.js b/browser/components/extensions/test/browser/browser_ext_pageAction_telemetry.js new file mode 100644 index 0000000000..fe14a5e6ac --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_pageAction_telemetry.js @@ -0,0 +1,228 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const HISTOGRAM = "WEBEXT_PAGEACTION_POPUP_OPEN_MS"; +const HISTOGRAM_KEYED = "WEBEXT_PAGEACTION_POPUP_OPEN_MS_BY_ADDONID"; + +const EXTENSION_ID1 = "@test-extension1"; +const EXTENSION_ID2 = "@test-extension2"; + +function snapshotCountsSum(snapshot) { + return Object.values(snapshot.values).reduce((a, b) => a + b, 0); +} + +function histogramCountsSum(histogram) { + return snapshotCountsSum(histogram.snapshot()); +} + +function gleanMetricSamplesCount(gleanMetric) { + return snapshotCountsSum(gleanMetric.testGetValue() ?? { values: {} }); +} + +add_task(async function testPageActionTelemetry() { + let extensionOptions = { + manifest: { + page_action: { + default_popup: "popup.html", + browser_style: true, + }, + }, + background: function () { + browser.tabs.query({ active: true, currentWindow: true }, tabs => { + const tabId = tabs[0].id; + + browser.pageAction.show(tabId).then(() => { + browser.test.sendMessage("action-shown"); + }); + }); + }, + + files: { + "popup.html": `<!DOCTYPE html><html><head><meta charset="utf-8"></head><body><div></div></body></html>`, + }, + }; + let extension1 = ExtensionTestUtils.loadExtension({ + ...extensionOptions, + manifest: { + ...extensionOptions.manifest, + browser_specific_settings: { + gecko: { id: EXTENSION_ID1 }, + }, + }, + }); + let extension2 = ExtensionTestUtils.loadExtension({ + ...extensionOptions, + manifest: { + ...extensionOptions.manifest, + browser_specific_settings: { + gecko: { id: EXTENSION_ID2 }, + }, + }, + }); + + let histogram = Services.telemetry.getHistogramById(HISTOGRAM); + let histogramKeyed = + Services.telemetry.getKeyedHistogramById(HISTOGRAM_KEYED); + + histogram.clear(); + histogramKeyed.clear(); + Services.fog.testResetFOG(); + + is( + histogramCountsSum(histogram), + 0, + `No data recorded for histogram: ${HISTOGRAM}.` + ); + is( + Object.keys(histogramKeyed).length, + 0, + `No data recorded for histogram: ${HISTOGRAM_KEYED}.` + ); + Assert.deepEqual( + Glean.extensionsTiming.pageActionPopupOpen.testGetValue(), + undefined, + "No data recorded for glean metric extensionsTiming.pageActionPopupOpen" + ); + + await extension1.startup(); + await extension1.awaitMessage("action-shown"); + await extension2.startup(); + await extension2.awaitMessage("action-shown"); + + is( + histogramCountsSum(histogram), + 0, + `No data recorded for histogram after PageAction shown: ${HISTOGRAM}.` + ); + is( + Object.keys(histogramKeyed).length, + 0, + `No data recorded for histogram after PageAction shown: ${HISTOGRAM_KEYED}.` + ); + is( + gleanMetricSamplesCount(Glean.extensionsTiming.pageActionPopupOpen), + 0, + "No data recorded for glean metric extensionsTiming.pageActionPopupOpen" + ); + + clickPageAction(extension1, window); + await awaitExtensionPanel(extension1); + + is( + histogramCountsSum(histogram), + 1, + `Data recorded for first extension for histogram: ${HISTOGRAM}.` + ); + is( + gleanMetricSamplesCount(Glean.extensionsTiming.pageActionPopupOpen), + 1, + `Data recorded for first extension on Glean metric extensionsTiming.pageActionPopupOpen` + ); + + let keyedSnapshot = histogramKeyed.snapshot(); + Assert.deepEqual( + Object.keys(keyedSnapshot), + [EXTENSION_ID1], + `Data recorded for first extension histogram: ${HISTOGRAM_KEYED}.` + ); + is( + snapshotCountsSum(keyedSnapshot[EXTENSION_ID1]), + 1, + `Data recorded for first extension for histogram: ${HISTOGRAM_KEYED}.` + ); + + await closePageAction(extension1, window); + + clickPageAction(extension2, window); + await awaitExtensionPanel(extension2); + + is( + histogramCountsSum(histogram), + 2, + `Data recorded for second extension for histogram: ${HISTOGRAM}.` + ); + is( + gleanMetricSamplesCount(Glean.extensionsTiming.pageActionPopupOpen), + 2, + `Data recorded for second extension on Glean metric extensionsTiming.pageActionPopupOpen` + ); + + keyedSnapshot = histogramKeyed.snapshot(); + Assert.deepEqual( + Object.keys(keyedSnapshot).sort(), + [EXTENSION_ID1, EXTENSION_ID2], + `Data recorded for second extension histogram: ${HISTOGRAM_KEYED}.` + ); + is( + snapshotCountsSum(keyedSnapshot[EXTENSION_ID2]), + 1, + `Data recorded for second extension for histogram: ${HISTOGRAM_KEYED}.` + ); + is( + snapshotCountsSum(keyedSnapshot[EXTENSION_ID1]), + 1, + `Data recorded for first extension should not change for histogram: ${HISTOGRAM_KEYED}.` + ); + + await closePageAction(extension2, window); + + clickPageAction(extension2, window); + await awaitExtensionPanel(extension2); + + is( + histogramCountsSum(histogram), + 3, + `Data recorded for second opening of popup for histogram: ${HISTOGRAM}.` + ); + is( + gleanMetricSamplesCount(Glean.extensionsTiming.pageActionPopupOpen), + 3, + `Data recorded for second opening popup on Glean metric extensionsTiming.pageActionPopupOpen` + ); + + keyedSnapshot = histogramKeyed.snapshot(); + is( + snapshotCountsSum(keyedSnapshot[EXTENSION_ID2]), + 2, + `Data recorded for second opening of popup for histogram: ${HISTOGRAM_KEYED}.` + ); + is( + snapshotCountsSum(keyedSnapshot[EXTENSION_ID1]), + 1, + `Data recorded for first extension should not change for histogram: ${HISTOGRAM_KEYED}.` + ); + + await closePageAction(extension2, window); + + clickPageAction(extension1, window); + await awaitExtensionPanel(extension1); + + is( + histogramCountsSum(histogram), + 4, + `Data recorded for third opening of popup for histogram: ${HISTOGRAM}.` + ); + is( + gleanMetricSamplesCount(Glean.extensionsTiming.pageActionPopupOpen), + 4, + `Data recorded for third opening popup on Glean metric extensionsTiming.pageActionPopupOpen` + ); + + keyedSnapshot = histogramKeyed.snapshot(); + is( + snapshotCountsSum(keyedSnapshot[EXTENSION_ID1]), + 2, + `Data recorded for second opening of popup for histogram: ${HISTOGRAM_KEYED}.` + ); + is( + snapshotCountsSum(keyedSnapshot[EXTENSION_ID1]), + 2, + `Data recorded for second extension should not change for histogram: ${HISTOGRAM_KEYED}.` + ); + + await closePageAction(extension1, window); + + await extension1.unload(); + await extension2.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_pageAction_title.js b/browser/components/extensions/test/browser/browser_ext_pageAction_title.js new file mode 100644 index 0000000000..feeb0a1419 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_pageAction_title.js @@ -0,0 +1,275 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +loadTestSubscript("head_pageAction.js"); + +add_task(async function testTabSwitchContext() { + await runTests({ + manifest: { + name: "Foo Extension", + + page_action: { + default_icon: "default.png", + default_popup: "__MSG_popup__", + default_title: "Default __MSG_title__ \u263a", + }, + + default_locale: "en", + + permissions: ["tabs"], + }, + + files: { + "_locales/en/messages.json": { + popup: { + message: "default.html", + description: "Popup", + }, + + title: { + message: "Title", + description: "Title", + }, + }, + + "_locales/es_ES/messages.json": { + popup: { + message: "default.html", + description: "Popup", + }, + + title: { + message: "T\u00edtulo", + description: "Title", + }, + }, + + "default.png": imageBuffer, + "1.png": imageBuffer, + "2.png": imageBuffer, + }, + + getTests: function (tabs) { + let details = [ + { + icon: browser.runtime.getURL("default.png"), + popup: browser.runtime.getURL("default.html"), + title: "Default T\u00edtulo \u263a", + }, + { + icon: browser.runtime.getURL("1.png"), + popup: browser.runtime.getURL("default.html"), + title: "Default T\u00edtulo \u263a", + }, + { + icon: browser.runtime.getURL("2.png"), + popup: browser.runtime.getURL("2.html"), + title: "Title 2", + }, + { + icon: browser.runtime.getURL("2.png"), + popup: browser.runtime.getURL("2.html"), + title: "", + }, + { + icon: browser.runtime.getURL("2.png"), + popup: browser.runtime.getURL("2.html"), + title: "Default T\u00edtulo \u263a", + }, + ]; + + let promiseTabLoad = details => { + return new Promise(resolve => { + browser.tabs.onUpdated.addListener(function listener(tabId, changed) { + if (tabId == details.id && changed.url == details.url) { + browser.tabs.onUpdated.removeListener(listener); + resolve(); + } + }); + }); + }; + return [ + expect => { + browser.test.log("Initial state. No icon visible."); + expect(null); + }, + async expect => { + browser.test.log( + "Show the icon on the first tab, expect default properties." + ); + await browser.pageAction.show(tabs[0]); + expect(details[0]); + }, + expect => { + browser.test.log( + "Change the icon. Expect default properties excluding the icon." + ); + browser.pageAction.setIcon({ tabId: tabs[0], path: "1.png" }); + expect(details[1]); + }, + async expect => { + browser.test.log("Create a new tab. No icon visible."); + let tab = await browser.tabs.create({ + active: true, + url: "about:blank?0", + }); + tabs.push(tab.id); + expect(null); + }, + async expect => { + browser.test.log("Await tab load. No icon visible."); + let promise = promiseTabLoad({ id: tabs[1], url: "about:blank?0" }); + let { url } = await browser.tabs.get(tabs[1]); + if (url === "about:blank") { + await promise; + } + expect(null); + }, + async expect => { + browser.test.log("Change properties. Expect new properties."); + let tabId = tabs[1]; + + await browser.pageAction.show(tabId); + browser.pageAction.setIcon({ tabId, path: "2.png" }); + browser.pageAction.setPopup({ tabId, popup: "2.html" }); + browser.pageAction.setTitle({ tabId, title: "Title 2" }); + + expect(details[2]); + }, + async expect => { + browser.test.log("Change the hash. Expect same properties."); + + let promise = promiseTabLoad({ + id: tabs[1], + url: "about:blank?0#ref", + }); + + browser.tabs.update(tabs[1], { url: "about:blank?0#ref" }); + + await promise; + expect(details[2]); + }, + expect => { + browser.test.log("Set empty title. Expect empty title."); + browser.pageAction.setTitle({ tabId: tabs[1], title: "" }); + + expect(details[3]); + }, + expect => { + browser.test.log("Clear the title. Expect default title."); + browser.pageAction.setTitle({ tabId: tabs[1], title: null }); + + expect(details[4]); + }, + async expect => { + browser.test.log("Navigate to a new page. Expect icon hidden."); + + // TODO: This listener should not be necessary, but the |tabs.update| + // callback currently fires too early in e10s windows. + let promise = promiseTabLoad({ id: tabs[1], url: "about:blank?1" }); + + browser.tabs.update(tabs[1], { url: "about:blank?1" }); + + await promise; + expect(null); + }, + async expect => { + browser.test.log("Show the icon. Expect default properties again."); + await browser.pageAction.show(tabs[1]); + expect(details[0]); + }, + async expect => { + browser.test.log( + "Switch back to the first tab. Expect previously set properties." + ); + await browser.tabs.update(tabs[0], { active: true }); + expect(details[1]); + }, + async expect => { + browser.test.log( + "Hide the icon on tab 2. Switch back, expect hidden." + ); + await browser.pageAction.hide(tabs[1]); + await browser.tabs.update(tabs[1], { active: true }); + expect(null); + }, + async expect => { + browser.test.log( + "Switch back to tab 1. Expect previous results again." + ); + await browser.tabs.remove(tabs[1]); + expect(details[1]); + }, + async expect => { + browser.test.log("Hide the icon. Expect hidden."); + await browser.pageAction.hide(tabs[0]); + expect(null); + }, + ]; + }, + }); +}); + +add_task(async function testDefaultTitle() { + await runTests({ + manifest: { + name: "Foo Extension", + + page_action: { + default_icon: "icon.png", + }, + + permissions: ["tabs"], + }, + + files: { + "icon.png": imageBuffer, + }, + + getTests: function (tabs) { + let details = [ + { + title: "Foo Extension", + popup: "", + icon: browser.runtime.getURL("icon.png"), + }, + { + title: "Foo Title", + popup: "", + icon: browser.runtime.getURL("icon.png"), + }, + { title: "", popup: "", icon: browser.runtime.getURL("icon.png") }, + ]; + + return [ + expect => { + browser.test.log("Initial state. No icon visible."); + expect(null); + }, + async expect => { + browser.test.log( + "Show the icon on the first tab, expect extension title as default title." + ); + await browser.pageAction.show(tabs[0]); + expect(details[0]); + }, + expect => { + browser.test.log("Change the title. Expect new title."); + browser.pageAction.setTitle({ tabId: tabs[0], title: "Foo Title" }); + expect(details[1]); + }, + expect => { + browser.test.log("Set empty title. Expect empty title."); + browser.pageAction.setTitle({ tabId: tabs[0], title: "" }); + expect(details[2]); + }, + expect => { + browser.test.log("Clear the title. Expect extension title."); + browser.pageAction.setTitle({ tabId: tabs[0], title: null }); + expect(details[0]); + }, + ]; + }, + }); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_persistent_storage_permission_indication.js b/browser/components/extensions/test/browser/browser_ext_persistent_storage_permission_indication.js new file mode 100644 index 0000000000..e50a6af135 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_persistent_storage_permission_indication.js @@ -0,0 +1,131 @@ +/* -- Mode: indent-tabs-mode: nil; js-indent-level: 2 -- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { PermissionTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PermissionTestUtils.sys.mjs" +); + +function openPermissionPopup() { + let promise = BrowserTestUtils.waitForEvent( + window, + "popupshown", + true, + event => event.target == gPermissionPanel._permissionPopup + ); + gPermissionPanel._identityPermissionBox.click(); + info("Wait permission popup to be shown"); + return promise; +} + +function closePermissionPopup() { + let promise = BrowserTestUtils.waitForEvent( + gPermissionPanel._permissionPopup, + "popuphidden" + ); + gPermissionPanel._permissionPopup.hidePopup(); + info("Wait permission popup to be hidden"); + return promise; +} + +async function testPermissionPopup({ expectPermissionHidden }) { + await openPermissionPopup(); + + if (expectPermissionHidden) { + let permissionsList = document.getElementById( + "permission-popup-permission-list" + ); + is( + permissionsList.querySelectorAll( + ".permission-popup-permission-label-persistent-storage" + ).length, + 0, + "Persistent storage Permission should be hidden" + ); + } + + await closePermissionPopup(); + + // We need to test this after the popup has been closed. + // The permission icon will be shown as long as the popup is open, event if + // no permissions are set. + let permissionsGrantedIcon = document.getElementById( + "permissions-granted-icon" + ); + + if (expectPermissionHidden) { + ok( + BrowserTestUtils.isHidden(permissionsGrantedIcon), + "Permission Granted Icon is hidden" + ); + } else { + ok( + BrowserTestUtils.isVisible(permissionsGrantedIcon), + "Permission Granted Icon is visible" + ); + } +} + +add_task(async function testPersistentStoragePermissionHidden() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.test.sendMessage("url", browser.runtime.getURL("testpage.html")); + }, + manifest: { + name: "Test Extension", + permissions: ["unlimitedStorage"], + }, + files: { + "testpage.html": "<h1>Extension Test Page</h1>", + }, + }); + + await extension.startup(); + + let url = await extension.awaitMessage("url"); + await BrowserTestUtils.withNewTab("about:blank", async browser => { + // Wait the tab to be fully loade, then run the test on the permission prompt. + let loaded = BrowserTestUtils.browserLoaded(browser, false, url); + BrowserTestUtils.startLoadingURIString(browser, url); + await loaded; + await testPermissionPopup({ expectPermissionHidden: true }); + }); + + await extension.unload(); +}); + +add_task(async function testPersistentStoragePermissionVisible() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.test.sendMessage("url", browser.runtime.getURL("testpage.html")); + }, + manifest: { + name: "Test Extension", + }, + files: { + "testpage.html": "<h1>Extension Test Page</h1>", + }, + }); + + await extension.startup(); + + let url = await extension.awaitMessage("url"); + + let policy = WebExtensionPolicy.getByID(extension.id); + let principal = policy.extension.principal; + PermissionTestUtils.add( + principal, + "persistent-storage", + Services.perms.ALLOW_ACTION + ); + + await BrowserTestUtils.withNewTab("about:blank", async browser => { + // Wait the tab to be fully loade, then run the test on the permission prompt. + let loaded = BrowserTestUtils.browserLoaded(browser, false, url); + BrowserTestUtils.startLoadingURIString(browser, url); + await loaded; + await testPermissionPopup({ expectPermissionHidden: false }); + }); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_popup_api_injection.js b/browser/components/extensions/test/browser/browser_ext_popup_api_injection.js new file mode 100644 index 0000000000..63948ed232 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_popup_api_injection.js @@ -0,0 +1,113 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function testPageActionPopup() { + const BASE = + "http://example.com/browser/browser/components/extensions/test/browser"; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_action: { + default_popup: `${BASE}/file_popup_api_injection_a.html`, + default_area: "navbar", + }, + page_action: { + default_popup: `${BASE}/file_popup_api_injection_b.html`, + }, + }, + + files: { + "popup-a.html": `<html><head><meta charset="utf-8"> + <script type="application/javascript" src="popup-a.js"></script></head></html>`, + "popup-a.js": 'browser.test.sendMessage("from-popup-a");', + + "popup-b.html": `<html><head><meta charset="utf-8"> + <script type="application/javascript" src="popup-b.js"></script></head></html>`, + "popup-b.js": 'browser.test.sendMessage("from-popup-b");', + }, + + background: function () { + let tabId; + browser.tabs.query({ active: true, currentWindow: true }, tabs => { + tabId = tabs[0].id; + browser.pageAction.show(tabId).then(() => { + browser.test.sendMessage("ready"); + }); + }); + + browser.test.onMessage.addListener(() => { + browser.browserAction.setPopup({ popup: "/popup-a.html" }); + browser.pageAction.setPopup({ tabId, popup: "popup-b.html" }); + + browser.test.sendMessage("ok"); + }); + }, + }); + + let promiseConsoleMessage = pattern => + new Promise(resolve => { + Services.console.registerListener(function listener(msg) { + if (pattern.test(msg.message)) { + resolve(msg.message); + Services.console.unregisterListener(listener); + } + }); + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + + // Check that unprivileged documents don't get the API. + // BrowserAction: + let awaitMessage = promiseConsoleMessage( + /WebExt Privilege Escalation: BrowserAction/ + ); + SimpleTest.expectUncaughtException(); + await clickBrowserAction(extension); + await awaitExtensionPanel(extension); + + let message = await awaitMessage; + ok( + message.includes( + "WebExt Privilege Escalation: BrowserAction: typeof(browser) = undefined" + ), + `No BrowserAction API injection` + ); + + await closeBrowserAction(extension); + + // PageAction + awaitMessage = promiseConsoleMessage( + /WebExt Privilege Escalation: PageAction/ + ); + SimpleTest.expectUncaughtException(); + await clickPageAction(extension); + + message = await awaitMessage; + ok( + message.includes( + "WebExt Privilege Escalation: PageAction: typeof(browser) = undefined" + ), + `No PageAction API injection: ${message}` + ); + + await closePageAction(extension); + + SimpleTest.expectUncaughtException(false); + + // Check that privileged documents *do* get the API. + extension.sendMessage("next"); + await extension.awaitMessage("ok"); + + await clickBrowserAction(extension); + await awaitExtensionPanel(extension); + await extension.awaitMessage("from-popup-a"); + await closeBrowserAction(extension); + + await clickPageAction(extension); + await extension.awaitMessage("from-popup-b"); + await closePageAction(extension); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_popup_background.js b/browser/components/extensions/test/browser/browser_ext_popup_background.js new file mode 100644 index 0000000000..bf0f78b732 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_popup_background.js @@ -0,0 +1,160 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ +"use strict"; + +async function testPanel(browser, standAlone, background_check) { + let panel = getPanelForNode(browser); + + let checkBackground = (background = null) => { + if (!standAlone) { + return; + } + + is( + getComputedStyle(panel.panelContent).backgroundColor, + background, + "Content should have correct background" + ); + }; + + function getBackground(browser) { + return SpecialPowers.spawn(browser, [], async function () { + return content.windowUtils.canvasBackgroundColor; + }); + } + + let setBackground = color => { + content.document.body.style.backgroundColor = color; + }; + + await new Promise(resolve => setTimeout(resolve, 100)); + + info("Test that initial background color is applied"); + let initialBackground = await getBackground(browser); + checkBackground(initialBackground); + background_check(initialBackground); + + info("Test that dynamically-changed background color is applied"); + await alterContent(browser, setBackground, "black"); + checkBackground(await getBackground(browser)); + + info("Test that non-opaque background color results in default styling"); + await alterContent(browser, setBackground, "rgba(1, 2, 3, .9)"); +} + +add_task(async function testPopupBackground() { + let testCases = [ + { + browser_style: false, + popup: ` + <!doctype html> + <body style="width: 100px; height: 100px; background-color: green"> + </body> + `, + background_check: function (bg) { + is(bg, "rgb(0, 128, 0)", "Initial background should be green"); + }, + }, + { + browser_style: false, + popup: ` + <!doctype html> + <body style="width: 100px; height: 100px""> + </body> + `, + background_check: function (bg) { + is(bg, "rgb(255, 255, 255)", "Initial background should be white"); + }, + }, + { + browser_style: false, + popup: ` + <!doctype html> + <meta name=color-scheme content=light> + <body style="width: 100px; height: 100px;"> + </body> + `, + background_check: function (bg) { + is(bg, "rgb(255, 255, 255)", "Initial background should be white"); + }, + }, + { + browser_style: false, + popup: ` + <!doctype html> + <meta name=color-scheme content=dark> + <body style="width: 100px; height: 100px;"> + </body> + `, + background_check: function (bg) { + isnot( + bg, + "rgb(255, 255, 255)", + "Initial background should not be white" + ); + }, + }, + ]; + for (let { browser_style, popup, background_check } of testCases) { + info(`Testing browser_style: ${browser_style} popup: ${popup}`); + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.tabs.query({ active: true, currentWindow: true }, tabs => { + browser.pageAction.show(tabs[0].id); + }); + }, + + manifest: { + browser_action: { + default_popup: "popup.html", + default_area: "navbar", + browser_style, + }, + + page_action: { + default_popup: "popup.html", + browser_style, + }, + }, + + files: { + "popup.html": popup, + }, + }); + + await extension.startup(); + + { + info("Test stand-alone browserAction popup"); + + clickBrowserAction(extension); + let browser = await awaitExtensionPanel(extension); + await testPanel(browser, true, background_check); + await closeBrowserAction(extension); + } + + { + info("Test menu panel browserAction popup"); + + let widget = getBrowserActionWidget(extension); + CustomizableUI.addWidgetToArea(widget.id, getCustomizableUIPanelID()); + + clickBrowserAction(extension); + let browser = await awaitExtensionPanel(extension); + await testPanel(browser, false, background_check); + await closeBrowserAction(extension); + } + + { + info("Test pageAction popup"); + + clickPageAction(extension); + let browser = await awaitExtensionPanel(extension); + await testPanel(browser, true, background_check); + await closePageAction(extension); + } + + await extension.unload(); + } +}); diff --git a/browser/components/extensions/test/browser/browser_ext_popup_corners.js b/browser/components/extensions/test/browser/browser_ext_popup_corners.js new file mode 100644 index 0000000000..67a53f0e7e --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_popup_corners.js @@ -0,0 +1,165 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function testPopupBorderRadius() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.tabs.query({ active: true, currentWindow: true }, tabs => { + browser.pageAction.show(tabs[0].id); + }); + }, + + manifest: { + browser_action: { + default_popup: "popup.html", + default_area: "navbar", + browser_style: false, + }, + + page_action: { + default_popup: "popup.html", + browser_style: false, + }, + }, + + files: { + "popup.html": `<!DOCTYPE html> + <html> + <head><meta charset="utf-8"></head> + <body style="width: 100px; height: 100px;"></body> + </html>`, + }, + }); + + await extension.startup(); + + let widget = getBrowserActionWidget(extension); + // If the panel doesn't allows embedding in subview then + // radius will be 0, otherwise 8. In practice we always + // disallow subview. + let expectedRadius = widget.disallowSubView ? "8px" : "0px"; + + async function testPanel(browser, standAlone = true) { + let panel = getPanelForNode(browser); + let arrowContent = panel.panelContent; + + let panelStyle = getComputedStyle(arrowContent); + is(panelStyle.overflow, "clip", "overflow is clipped"); + + let stack = browser.parentNode; + let viewNode = stack.parentNode === panel ? browser : stack.parentNode; + let viewStyle = getComputedStyle(viewNode); + + let props = [ + "borderTopLeftRadius", + "borderTopRightRadius", + "borderBottomRightRadius", + "borderBottomLeftRadius", + ]; + + let bodyStyle = await SpecialPowers.spawn( + browser, + [props], + async function (props) { + let bodyStyle = content.getComputedStyle(content.document.body); + + return new Map(props.map(prop => [prop, bodyStyle[prop]])); + } + ); + + for (let prop of props) { + if (standAlone) { + is( + viewStyle[prop], + panelStyle[prop], + `Panel and view ${prop} should be the same` + ); + is( + bodyStyle.get(prop), + panelStyle[prop], + `Panel and body ${prop} should be the same` + ); + } else { + is(viewStyle[prop], expectedRadius, `View node ${prop} should be 0px`); + is( + bodyStyle.get(prop), + expectedRadius, + `Body node ${prop} should be 0px` + ); + } + } + } + + { + info("Test stand-alone browserAction popup"); + + clickBrowserAction(extension); + let browser = await awaitExtensionPanel(extension); + await testPanel(browser); + await closeBrowserAction(extension); + } + + { + info("Test overflowed browserAction popup"); + const kForceOverflowWidthPx = 450; + let overflowPanel = document.getElementById("widget-overflow"); + + let originalWindowWidth = window.outerWidth; + let navbar = document.getElementById(CustomizableUI.AREA_NAVBAR); + ok( + !navbar.hasAttribute("overflowing"), + "Should start with a non-overflowing toolbar." + ); + window.resizeTo(kForceOverflowWidthPx, window.outerHeight); + + await TestUtils.waitForCondition(() => navbar.hasAttribute("overflowing")); + ok( + navbar.hasAttribute("overflowing"), + "Should have an overflowing toolbar." + ); + + await window.gUnifiedExtensions.togglePanel(); + + clickBrowserAction(extension); + let browser = await awaitExtensionPanel(extension); + + is( + overflowPanel.state, + "closed", + "The widget overflow panel should not be open." + ); + + await testPanel(browser, false); + await closeBrowserAction(extension); + + window.resizeTo(originalWindowWidth, window.outerHeight); + await TestUtils.waitForCondition(() => !navbar.hasAttribute("overflowing")); + ok( + !navbar.hasAttribute("overflowing"), + "Should not have an overflowing toolbar." + ); + } + + { + info("Test menu panel browserAction popup"); + + CustomizableUI.addWidgetToArea(widget.id, getCustomizableUIPanelID()); + + clickBrowserAction(extension); + let browser = await awaitExtensionPanel(extension); + await testPanel(browser, false); + await closeBrowserAction(extension); + } + + { + info("Test pageAction popup"); + + clickPageAction(extension); + let browser = await awaitExtensionPanel(extension); + await testPanel(browser); + await closePageAction(extension); + } + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_popup_focus.js b/browser/components/extensions/test/browser/browser_ext_popup_focus.js new file mode 100644 index 0000000000..4cf46f2be5 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_popup_focus.js @@ -0,0 +1,88 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const DUMMY_PAGE = + "http://example.com/browser/browser/components/extensions/test/browser/file_dummy.html"; + +add_task(async function testPageActionFocus() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + page_action: { + default_popup: "popup.html", + show_matches: ["<all_urls>"], + }, + }, + files: { + "popup.html": `<!DOCTYPE html><html><head><meta charset="utf-8"> + <script src="popup.js"></script> + </head><body> + </body></html> + `, + "popup.js": function () { + window.addEventListener( + "focus", + event => { + browser.test.log("extension popup received focus event"); + browser.test.assertEq( + true, + document.hasFocus(), + "document should be focused" + ); + browser.test.notifyPass("focused"); + }, + { once: true } + ); + browser.test.log(`extension popup loaded`); + }, + }, + }); + + await extension.startup(); + + await BrowserTestUtils.withNewTab(DUMMY_PAGE, async () => { + await clickPageAction(extension); + await extension.awaitFinish("focused"); + await closePageAction(extension); + }); + + await extension.unload(); +}); + +add_task(async function testBrowserActionFocus() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_action: { default_popup: "popup.html" }, + }, + files: { + "popup.html": `<!DOCTYPE html><html><head><meta charset="utf-8"> + <script src="popup.js"></script> + </head><body> + </body></html> + `, + "popup.js": function () { + window.addEventListener( + "focus", + event => { + browser.test.log("extension popup received focus event"); + browser.test.assertEq( + true, + document.hasFocus(), + "document should be focused" + ); + browser.test.notifyPass("focused"); + }, + { once: true } + ); + browser.test.log(`extension popup loaded`); + }, + }, + }); + await extension.startup(); + + await clickBrowserAction(extension); + await extension.awaitFinish("focused"); + await closeBrowserAction(extension); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_popup_links_open_in_tabs.js b/browser/components/extensions/test/browser/browser_ext_popup_links_open_in_tabs.js new file mode 100644 index 0000000000..1e935e1d0d --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_popup_links_open_in_tabs.js @@ -0,0 +1,56 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function test_popup_links_open_tabs() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_action: { + default_popup: "popup.html", + }, + }, + + files: { + "popup.html": `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + <script src="popup.js" type="text/javascript"></script> + </head> + <body style="height: 100px;"> + <h1>Extension Popup</h1> + <a href="https://example.com/popup-page-link">popup page link</a> + </body> + </html>`, + "popup.js": function () { + window.onload = () => { + browser.test.sendMessage("from-popup", "popup-a"); + }; + }, + }, + }); + + await extension.startup(); + + let widget = getBrowserActionWidget(extension); + CustomizableUI.addWidgetToArea(widget.id, CustomizableUI.AREA_NAVBAR, 0); + + let promiseActionPopupBrowser = awaitExtensionPanel(extension); + clickBrowserAction(extension); + await extension.awaitMessage("from-popup"); + let popupBrowser = await promiseActionPopupBrowser; + const promiseNewTabOpened = BrowserTestUtils.waitForNewTab( + gBrowser, + "https://example.com/popup-page-link" + ); + await SpecialPowers.spawn(popupBrowser, [], () => + content.document.querySelector("a").click() + ); + const newTab = await promiseNewTabOpened; + ok(newTab, "Got a new tab created on the expected url"); + BrowserTestUtils.removeTab(newTab); + + await closeBrowserAction(extension); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_popup_requestPermission.js b/browser/components/extensions/test/browser/browser_ext_popup_requestPermission.js new file mode 100644 index 0000000000..657d525634 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_popup_requestPermission.js @@ -0,0 +1,67 @@ +"use strict"; + +const verifyRequestPermission = async (manifestProps, expectedIcon) => { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_action: { + default_popup: "popup.html", + }, + optional_permissions: ["<all_urls>"], + ...manifestProps, + }, + + files: { + "popup.html": `<meta charset="utf-8"><script src="popup.js"></script>`, + "popup.js": async () => { + const success = await new Promise(resolve => { + browser.test.withHandlingUserInput(() => { + resolve( + browser.permissions.request({ + origins: ["<all_urls>"], + }) + ); + }); + }); + browser.test.assertTrue( + success, + "browser.permissions.request promise resolves" + ); + browser.test.sendMessage("done"); + }, + }, + }); + + const requestPrompt = promisePopupNotificationShown( + "addon-webext-permissions" + ).then(panel => { + ok( + panel.getAttribute("icon").endsWith(`/${expectedIcon}`), + "expected the correct icon on the notification" + ); + + panel.button.click(); + }); + await extension.startup(); + await clickBrowserAction(extension); + await requestPrompt; + await extension.awaitMessage("done"); + await extension.unload(); +}; + +add_task(async function test_popup_requestPermission_resolve() { + await verifyRequestPermission({}, "extensionGeneric.svg"); +}); + +add_task(async function test_popup_requestPermission_resolve_custom_icon() { + let expectedIcon = "icon-32.png"; + + await verifyRequestPermission( + { + icons: { + 16: "icon-16.png", + 32: expectedIcon, + }, + }, + expectedIcon + ); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_popup_select.js b/browser/components/extensions/test/browser/browser_ext_popup_select.js new file mode 100644 index 0000000000..87bd945a53 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_popup_select.js @@ -0,0 +1,115 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function testPopupSelectPopup() { + let tab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: "https://example.com", + }); + + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.tabs.query({ active: true, currentWindow: true }, tabs => { + browser.pageAction.show(tabs[0].id); + }); + }, + + manifest: { + browser_action: { + default_popup: "popup.html", + browser_style: false, + }, + + page_action: { + default_popup: "popup.html", + browser_style: false, + }, + }, + + files: { + "popup.html": `<!DOCTYPE html> + <html> + <head><meta charset="utf-8"></head> + <body style="width: 300px; height: 300px;"> + <div style="text-align: center"> + <select id="select"> + <option>Foo</option> + <option>Bar</option> + <option>Baz</option> + </select> + </div> + </body> + </html>`, + }, + }); + + await extension.startup(); + + async function testPanel(browser) { + const popupPromise = BrowserTestUtils.waitForSelectPopupShown(window); + + // Wait the select element in the popup window to be ready before sending a + // mouse event to open the select popup. + await SpecialPowers.spawn(browser, [], async () => { + await ContentTaskUtils.waitForCondition(() => { + return content.document && content.document.querySelector("#select"); + }); + }); + BrowserTestUtils.synthesizeMouseAtCenter("#select", {}, browser); + + const selectPopup = await popupPromise; + + let elemRect = await SpecialPowers.spawn(browser, [], async function () { + let elem = content.document.getElementById("select"); + let r = elem.getBoundingClientRect(); + + return { left: r.left, bottom: r.bottom }; + }); + + let popupRect = selectPopup.getOuterScreenRect(); + let marginTop = parseFloat(getComputedStyle(selectPopup).marginTop); + let marginLeft = parseFloat(getComputedStyle(selectPopup).marginLeft); + + is( + Math.floor(browser.screenX + elemRect.left + marginLeft), + popupRect.left, + "Select popup has the correct x origin" + ); + + is( + Math.floor(browser.screenY + elemRect.bottom + marginTop), + popupRect.top, + "Select popup has the correct y origin" + ); + + // Close the select popup before proceeding to the next test. + const onPopupHidden = BrowserTestUtils.waitForEvent( + selectPopup, + "popuphidden" + ); + selectPopup.hidePopup(); + await onPopupHidden; + } + + { + info("Test browserAction popup"); + + clickBrowserAction(extension); + let browser = await awaitExtensionPanel(extension); + await testPanel(browser); + await closeBrowserAction(extension); + } + + { + info("Test pageAction popup"); + + clickPageAction(extension); + let browser = await awaitExtensionPanel(extension); + await testPanel(browser); + await closePageAction(extension); + } + + BrowserTestUtils.removeTab(tab); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_popup_select_in_oopif.js b/browser/components/extensions/test/browser/browser_ext_popup_select_in_oopif.js new file mode 100644 index 0000000000..fa2c414047 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_popup_select_in_oopif.js @@ -0,0 +1,131 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +// This test is based on browser_ext_popup_select.js. + +const iframeSrc = encodeURIComponent(` +<html> + <style> + html,body { + margin: 0; + padding: 0; + } + </style> + <select> + <option>Foo</option> + <option>Bar</option> + <option>Baz</option> + </select> +</html>`); + +add_task(async function testPopupSelectPopup() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_action: { + default_popup: "popup.html", + browser_style: false, + }, + }, + + files: { + "popup.html": `<!DOCTYPE html> + <html> + <head><meta charset="utf-8"></head> + <style> + html,body { + margin: 0; + padding: 0; + } + iframe { + border: none; + } + </style> + <body> + <iframe src="https://example.com/document-builder.sjs?html=${iframeSrc}"> + </iframe> + </body> + </html>`, + }, + }); + + await extension.startup(); + + const browserForPopup = await openBrowserActionPanel( + extension, + undefined, + true + ); + + const iframe = await SpecialPowers.spawn(browserForPopup, [], async () => { + await ContentTaskUtils.waitForCondition(() => { + return content.document && content.document.querySelector("iframe"); + }); + const iframeElement = content.document.querySelector("iframe"); + + await ContentTaskUtils.waitForCondition(() => { + return iframeElement.browsingContext; + }); + return iframeElement.browsingContext; + }); + + const selectRect = await SpecialPowers.spawn(iframe, [], async () => { + await ContentTaskUtils.waitForCondition(() => { + return content.document.querySelector("select"); + }); + const select = content.document.querySelector("select"); + const focusPromise = new Promise(resolve => { + select.addEventListener("focus", resolve, { once: true }); + }); + select.focus(); + await focusPromise; + + const r = select.getBoundingClientRect(); + + return { left: r.left, bottom: r.bottom }; + }); + + const popupPromise = BrowserTestUtils.waitForSelectPopupShown(window); + + BrowserTestUtils.synthesizeMouseAtCenter("select", {}, iframe); + + const selectPopup = await popupPromise; + + let popupRect = selectPopup.getOuterScreenRect(); + let popupMarginLeft = parseFloat(getComputedStyle(selectPopup).marginLeft); + let popupMarginTop = parseFloat(getComputedStyle(selectPopup).marginTop); + + const offsetToSelectedItem = + selectPopup.querySelector("menuitem[selected]").getBoundingClientRect() + .top - selectPopup.getBoundingClientRect().top; + info( + `Browser is at ${browserForPopup.screenY}, popup is at ${popupRect.top} with ${offsetToSelectedItem} to the selected item` + ); + + is( + Math.floor(browserForPopup.screenX + selectRect.left), + popupRect.left - popupMarginLeft, + "Select popup has the correct x origin" + ); + + // On Mac select popup window appears aligned to the selected option. + let expectedY = navigator.platform.includes("Mac") + ? Math.floor(browserForPopup.screenY - offsetToSelectedItem) + : Math.floor(browserForPopup.screenY + selectRect.bottom); + is( + expectedY, + popupRect.top - popupMarginTop, + "Select popup has the correct y origin" + ); + + const onPopupHidden = BrowserTestUtils.waitForEvent( + selectPopup, + "popuphidden" + ); + selectPopup.hidePopup(); + await onPopupHidden; + + await closeBrowserAction(extension); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_popup_sendMessage.js b/browser/components/extensions/test/browser/browser_ext_popup_sendMessage.js new file mode 100644 index 0000000000..632b929121 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_popup_sendMessage.js @@ -0,0 +1,135 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ +"use strict"; + +add_task(async function test_popup_sendMessage_reply() { + let scriptPage = url => + `<html><head><meta charset="utf-8"><script src="${url}"></script></head><body>${url}</body></html>`; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_action: { + default_popup: "popup.html", + browser_style: true, + }, + + page_action: { + default_popup: "popup.html", + browser_style: true, + }, + }, + + files: { + "popup.html": scriptPage("popup.js"), + "popup.js": async function () { + browser.runtime.onMessage.addListener(async msg => { + if (msg == "popup-ping") { + return "popup-pong"; + } + }); + + let response = await browser.runtime.sendMessage("background-ping"); + browser.test.sendMessage("background-ping-response", response); + }, + }, + + async background() { + browser.runtime.onMessage.addListener(async msg => { + if (msg == "background-ping") { + let response = await browser.runtime.sendMessage("popup-ping"); + + browser.test.sendMessage("popup-ping-response", response); + + await new Promise(resolve => { + // Wait long enough that we're relatively sure the docShells have + // been swapped. Note that this value is fairly arbitrary. The load + // event that triggers the swap should happen almost immediately + // after the message is sent. The extra quarter of a second gives us + // enough leeway that we can expect to respond after the swap in the + // vast majority of cases. + setTimeout(resolve, 250); + }); + + return "background-pong"; + } + }); + + let [tab] = await browser.tabs.query({ + active: true, + currentWindow: true, + }); + + await browser.pageAction.show(tab.id); + + browser.test.sendMessage("page-action-ready"); + }, + }); + + await extension.startup(); + + { + clickBrowserAction(extension); + + let pong = await extension.awaitMessage("background-ping-response"); + is(pong, "background-pong", "Got pong"); + + pong = await extension.awaitMessage("popup-ping-response"); + is(pong, "popup-pong", "Got pong"); + + await closeBrowserAction(extension); + } + + await extension.awaitMessage("page-action-ready"); + + { + clickPageAction(extension); + + let pong = await extension.awaitMessage("background-ping-response"); + is(pong, "background-pong", "Got pong"); + + pong = await extension.awaitMessage("popup-ping-response"); + is(pong, "popup-pong", "Got pong"); + + await closePageAction(extension); + } + + await extension.unload(); +}); + +add_task(async function test_popup_close_then_sendMessage() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_action: { + default_popup: "popup.html", + }, + }, + + files: { + "popup.html": `<meta charset="utf-8"><script src="popup.js" defer></script>ghost`, + "popup.js"() { + browser.tabs.query({ active: true }).then(() => { + // NOTE: the message will be sent _after_ the popup is closed below. + browser.runtime.sendMessage("sent-after-closed"); + }); + window.close(); + }, + }, + + async background() { + browser.runtime.onMessage.addListener(msg => { + browser.test.assertEq(msg, "sent-after-closed", "Message from popup."); + browser.test.sendMessage("done"); + }); + browser.test.sendMessage("ready"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + + clickBrowserAction(extension); + await extension.awaitMessage("done"); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_popup_shutdown.js b/browser/components/extensions/test/browser/browser_ext_popup_shutdown.js new file mode 100644 index 0000000000..246a83520e --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_popup_shutdown.js @@ -0,0 +1,80 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +let getExtension = () => { + return ExtensionTestUtils.loadExtension({ + background: async function () { + let [tab] = await browser.tabs.query({ + active: true, + currentWindow: true, + }); + await browser.pageAction.show(tab.id); + browser.test.sendMessage("pageAction ready"); + }, + + manifest: { + browser_action: { + default_popup: "popup.html", + browser_style: false, + }, + + page_action: { + default_popup: "popup.html", + browser_style: false, + }, + }, + + files: { + "popup.html": `<!DOCTYPE html> + <html><head><meta charset="utf-8"></head></html>`, + }, + }); +}; + +add_task(async function testStandaloneBrowserAction() { + info("Test stand-alone browserAction popup"); + + let extension = getExtension(); + await extension.startup(); + await extension.awaitMessage("pageAction ready"); + + clickBrowserAction(extension); + let browser = await awaitExtensionPanel(extension); + let panel = getPanelForNode(browser); + + await extension.unload(); + + is(panel.parentNode, null, "Panel should be removed from the document"); +}); + +add_task(async function testMenuPanelBrowserAction() { + let extension = getExtension(); + await extension.startup(); + await extension.awaitMessage("pageAction ready"); + + let widget = getBrowserActionWidget(extension); + CustomizableUI.addWidgetToArea(widget.id, getCustomizableUIPanelID()); + + clickBrowserAction(extension); + let browser = await awaitExtensionPanel(extension); + let panel = getPanelForNode(browser); + + await extension.unload(); + + is(panel.state, "closed", "Panel should be closed"); +}); + +add_task(async function testPageAction() { + let extension = getExtension(); + await extension.startup(); + await extension.awaitMessage("pageAction ready"); + + clickPageAction(extension); + let browser = await awaitExtensionPanel(extension); + let panel = getPanelForNode(browser); + + await extension.unload(); + + is(panel.parentNode, null, "Panel should be removed from the document"); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_port_disconnect_on_crash.js b/browser/components/extensions/test/browser/browser_ext_port_disconnect_on_crash.js new file mode 100644 index 0000000000..82ece1da3f --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_port_disconnect_on_crash.js @@ -0,0 +1,113 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function connect_from_tab_to_bg_and_crash_tab() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + js: ["contentscript.js"], + matches: ["http://example.com/?crashme"], + }, + ], + }, + + background() { + browser.runtime.onConnect.addListener(port => { + browser.test.assertEq("tab_to_bg", port.name, "expected port"); + browser.test.assertEq(port.sender.frameId, 0, "correct frameId"); + + port.onDisconnect.addListener(() => { + browser.test.assertEq( + null, + port.error, + "port should be disconnected without errors" + ); + browser.test.sendMessage("port_disconnected"); + }); + browser.test.sendMessage("bg_runtime_onConnect"); + }); + }, + + files: { + "contentscript.js": function () { + let port = browser.runtime.connect({ name: "tab_to_bg" }); + port.onDisconnect.addListener(() => { + browser.test.fail("Unexpected onDisconnect event in content script"); + }); + }, + }, + }); + + await extension.startup(); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.com/?crashme" + ); + await extension.awaitMessage("bg_runtime_onConnect"); + // Force the message manager to disconnect without giving the content a + // chance to send an "Extension:Port:Disconnect" message. + await BrowserTestUtils.crashFrame(tab.linkedBrowser); + await extension.awaitMessage("port_disconnected"); + BrowserTestUtils.removeTab(tab); + await extension.unload(); +}); + +add_task(async function connect_from_bg_to_tab_and_crash_tab() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + content_scripts: [ + { + js: ["contentscript.js"], + matches: ["http://example.com/?crashme"], + }, + ], + }, + + background() { + browser.runtime.onMessage.addListener((msg, sender) => { + browser.test.assertEq("contentscript_ready", msg, "expected message"); + let port = browser.tabs.connect(sender.tab.id, { name: "bg_to_tab" }); + port.onDisconnect.addListener(() => { + browser.test.assertEq( + null, + port.error, + "port should be disconnected without errors" + ); + browser.test.sendMessage("port_disconnected"); + }); + }); + }, + + files: { + "contentscript.js": function () { + browser.runtime.onConnect.addListener(port => { + browser.test.assertEq("bg_to_tab", port.name, "expected port"); + port.onDisconnect.addListener(() => { + browser.test.fail( + "Unexpected onDisconnect event in content script" + ); + }); + browser.test.sendMessage("tab_runtime_onConnect"); + }); + browser.runtime.sendMessage("contentscript_ready"); + }, + }, + }); + + await extension.startup(); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.com/?crashme" + ); + await extension.awaitMessage("tab_runtime_onConnect"); + // Force the message manager to disconnect without giving the content a + // chance to send an "Extension:Port:Disconnect" message. + await BrowserTestUtils.crashFrame(tab.linkedBrowser); + await extension.awaitMessage("port_disconnected"); + BrowserTestUtils.removeTab(tab); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_port_disconnect_on_window_close.js b/browser/components/extensions/test/browser/browser_ext_port_disconnect_on_window_close.js new file mode 100644 index 0000000000..84bc4a3ff0 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_port_disconnect_on_window_close.js @@ -0,0 +1,39 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +// Regression test for https://bugzil.la/1392067 . +add_task(async function connect_from_window_and_close() { + let extension = ExtensionTestUtils.loadExtension({ + background() { + browser.runtime.onConnect.addListener(port => { + browser.test.assertEq("page_to_bg", port.name, "expected port"); + port.onDisconnect.addListener(() => { + browser.test.assertEq( + null, + port.error, + "port should be disconnected without errors" + ); + browser.test.sendMessage("port_disconnected"); + }); + browser.windows.remove(port.sender.tab.windowId); + }); + + browser.windows.create({ url: "page.html" }); + }, + + files: { + "page.html": `<!DOCTYPE html><meta charset="utf-8"><script src="page.js"></script>`, + "page.js": function () { + let port = browser.runtime.connect({ name: "page_to_bg" }); + port.onDisconnect.addListener(() => { + browser.test.fail("Unexpected onDisconnect event in page"); + }); + }, + }, + }); + + await extension.startup(); + await extension.awaitMessage("port_disconnected"); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_reload_manifest_cache.js b/browser/components/extensions/test/browser/browser_ext_reload_manifest_cache.js new file mode 100644 index 0000000000..aefa8f42f5 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_reload_manifest_cache.js @@ -0,0 +1,72 @@ +"use strict"; + +const { ExtensionPermissions } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionPermissions.sys.mjs" +); + +add_task(async function test_reload_manifest_startupcache() { + const id = "id@tests.mozilla.org"; + + let ext = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { id }, + }, + options_ui: { + open_in_tab: true, + page: "options.html", + }, + optional_permissions: ["<all_urls>"], + }, + useAddonManager: "temporary", + files: { + "options.html": `lol`, + }, + background() { + browser.runtime.openOptionsPage(); + browser.permissions.onAdded.addListener(() => { + browser.runtime.openOptionsPage(); + }); + }, + }); + + async function waitOptionsTab() { + let tab = await BrowserTestUtils.waitForNewTab(gBrowser, url => + url.endsWith("options.html") + ); + BrowserTestUtils.removeTab(tab); + } + + // Open a non-blank tab to force options to open a new tab. + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "https://example.com/" + ); + let optionsTabPromise = waitOptionsTab(); + + await ext.startup(); + await optionsTabPromise; + + let disabledPromise = awaitEvent("shutdown", id); + let enabledPromise = awaitEvent("ready", id); + optionsTabPromise = waitOptionsTab(); + + let addon = await AddonManager.getAddonByID(id); + await addon.reload(); + + await Promise.all([disabledPromise, enabledPromise, optionsTabPromise]); + + optionsTabPromise = waitOptionsTab(); + ExtensionPermissions.add(id, { + permissions: [], + origins: ["<all_urls>"], + }); + await optionsTabPromise; + + let policy = WebExtensionPolicy.getByID(id); + let optionsUrl = policy.extension.manifest.options_ui.page; + ok(optionsUrl.includes(policy.mozExtensionHostname), "Normalized manifest."); + + await BrowserTestUtils.removeTab(tab); + await ext.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_request_permissions.js b/browser/components/extensions/test/browser/browser_ext_request_permissions.js new file mode 100644 index 0000000000..3ba58bccd5 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_request_permissions.js @@ -0,0 +1,121 @@ +"use strict"; + +// This test case verifies that `permissions.request()` resolves in the +// expected order. +add_task(async function test_permissions_prompt() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + optional_permissions: ["history", "bookmarks"], + }, + background: async () => { + let hiddenTab = await browser.tabs.create({ + url: browser.runtime.getURL("hidden.html"), + active: false, + }); + + await browser.tabs.create({ + url: browser.runtime.getURL("active.html"), + active: true, + }); + + browser.test.onMessage.addListener(async msg => { + if (msg === "activate-hiddenTab") { + await browser.tabs.update(hiddenTab.id, { active: true }); + + browser.test.sendMessage("activate-hiddenTab-ok"); + } + }); + }, + files: { + "active.html": `<!DOCTYPE html><script src="active.js"></script>`, + "active.js": async () => { + browser.test.onMessage.addListener(async msg => { + if (msg === "request-perms-activeTab") { + let granted = await new Promise(resolve => { + browser.test.withHandlingUserInput(() => { + resolve( + browser.permissions.request({ permissions: ["history"] }) + ); + }); + }); + browser.test.assertTrue(granted, "permission request succeeded"); + + browser.test.sendMessage("request-perms-activeTab-ok"); + } + }); + + browser.test.sendMessage("activeTab-ready"); + }, + "hidden.html": `<!DOCTYPE html><script src="hidden.js"></script>`, + "hidden.js": async () => { + let resolved = false; + + browser.test.onMessage.addListener(async msg => { + if (msg === "request-perms-hiddenTab") { + let granted = await new Promise(resolve => { + browser.test.withHandlingUserInput(() => { + resolve( + browser.permissions.request({ permissions: ["bookmarks"] }) + ); + }); + }); + browser.test.assertTrue(granted, "permission request succeeded"); + + resolved = true; + + browser.test.sendMessage("request-perms-hiddenTab-ok"); + } else if (msg === "hiddenTab-read-state") { + browser.test.sendMessage("hiddenTab-state-value", resolved); + } + }); + + browser.test.sendMessage("hiddenTab-ready"); + }, + }, + }); + await extension.startup(); + + await extension.awaitMessage("activeTab-ready"); + await extension.awaitMessage("hiddenTab-ready"); + + // Call request() on a hidden window. + extension.sendMessage("request-perms-hiddenTab"); + + let requestPromptForActiveTab = promisePopupNotificationShown( + "addon-webext-permissions" + ).then(panel => { + panel.button.click(); + }); + + // Call request() in the current window. + extension.sendMessage("request-perms-activeTab"); + await requestPromptForActiveTab; + await extension.awaitMessage("request-perms-activeTab-ok"); + + // Check that initial request() is still pending. + extension.sendMessage("hiddenTab-read-state"); + ok( + !(await extension.awaitMessage("hiddenTab-state-value")), + "initial request is pending" + ); + + let requestPromptForHiddenTab = promisePopupNotificationShown( + "addon-webext-permissions" + ).then(panel => { + panel.button.click(); + }); + + extension.sendMessage("activate-hiddenTab"); + await extension.awaitMessage("activate-hiddenTab-ok"); + await requestPromptForHiddenTab; + await extension.awaitMessage("request-perms-hiddenTab-ok"); + + extension.sendMessage("hiddenTab-read-state"); + ok( + await extension.awaitMessage("hiddenTab-state-value"), + "initial request is resolved" + ); + + // The extension tabs are automatically closed upon unload. + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_runtime_onPerformanceWarning.js b/browser/components/extensions/test/browser/browser_ext_runtime_onPerformanceWarning.js new file mode 100644 index 0000000000..b0f62e677a --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_runtime_onPerformanceWarning.js @@ -0,0 +1,144 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { + Management: { + global: { tabTracker }, + }, +} = ChromeUtils.importESModule("resource://gre/modules/Extension.sys.mjs"); + +const { + ExtensionUtils: { promiseObserved }, +} = ChromeUtils.importESModule("resource://gre/modules/ExtensionUtils.sys.mjs"); + +class TestHangReport { + constructor(addonId, scriptBrowser) { + this.addonId = addonId; + this.scriptBrowser = scriptBrowser; + this.QueryInterface = ChromeUtils.generateQI(["nsIHangReport"]); + } + + userCanceled() {} + terminateScript() {} + + isReportForBrowserOrChildren(frameLoader) { + return ( + !this.scriptBrowser || this.scriptBrowser.frameLoader === frameLoader + ); + } +} + +function dispatchHangReport(extensionId, scriptBrowser) { + const hangObserved = promiseObserved("process-hang-report"); + + Services.obs.notifyObservers( + new TestHangReport(extensionId, scriptBrowser), + "process-hang-report" + ); + + return hangObserved; +} + +function background() { + let onPerformanceWarningDetails = null; + + browser.runtime.onPerformanceWarning.addListener(details => { + onPerformanceWarningDetails = details; + }); + + browser.test.onMessage.addListener(message => { + if (message === "get-on-performance-warning-details") { + browser.test.sendMessage( + "on-performance-warning-details", + onPerformanceWarningDetails + ); + onPerformanceWarningDetails = null; + } + }); +} + +async function expectOnPerformanceWarningDetails( + extension, + expectedOnPerformanceWarningDetails +) { + extension.sendMessage("get-on-performance-warning-details"); + + let actualOnPerformanceWarningDetails = await extension.awaitMessage( + "on-performance-warning-details" + ); + Assert.deepEqual( + actualOnPerformanceWarningDetails, + expectedOnPerformanceWarningDetails, + expectedOnPerformanceWarningDetails + ? "runtime.onPerformanceWarning fired with correct details" + : "runtime.onPerformanceWarning didn't fire" + ); +} + +add_task(async function test_should_fire_on_process_hang_report() { + const description = + "Slow extension content script caused a page hang, user was warned."; + + const extension = ExtensionTestUtils.loadExtension({ background }); + await extension.startup(); + + const notificationPromise = BrowserTestUtils.waitForGlobalNotificationBar( + window, + "process-hang" + ); + + const tabs = await Promise.all([ + BrowserTestUtils.openNewForegroundTab(gBrowser), + BrowserTestUtils.openNewForegroundTab(gBrowser), + ]); + + // Warning event shouldn't have fired initially. + await expectOnPerformanceWarningDetails(extension, null); + + // Hang report fired for the extension and first tab. Warning event with first + // tab ID expected. + await dispatchHangReport(extension.id, tabs[0].linkedBrowser); + await expectOnPerformanceWarningDetails(extension, { + category: "content_script", + severity: "high", + description, + tabId: tabTracker.getId(tabs[0]), + }); + + // Hang report fired for different extension, no warning event expected. + await dispatchHangReport("wrong-addon-id", tabs[0].linkedBrowser); + await expectOnPerformanceWarningDetails(extension, null); + + // Non-extension hang report fired, no warning event expected. + await dispatchHangReport(null, tabs[0].linkedBrowser); + await expectOnPerformanceWarningDetails(extension, null); + + // Hang report fired for the extension and second tab. Warning event with + // second tab ID expected. + await dispatchHangReport(extension.id, tabs[1].linkedBrowser); + await expectOnPerformanceWarningDetails(extension, { + category: "content_script", + severity: "high", + description, + tabId: tabTracker.getId(tabs[1]), + }); + + // Hang report fired for the extension with no associated tab. Warning event + // with no tab ID expected. + await dispatchHangReport(extension.id, null); + await expectOnPerformanceWarningDetails(extension, { + category: "content_script", + severity: "high", + description, + }); + + await Promise.all(tabs.map(BrowserTestUtils.removeTab)); + await extension.unload(); + + // Wait for the process-hang warning bar to be displayed, then ensure it's + // cleared to avoid clobbering other tests. + const notification = await notificationPromise; + Assert.ok(notification.isConnected, "Notification still present"); + notification.buttonContainer.querySelector("[label='Stop']").click(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_runtime_openOptionsPage.js b/browser/components/extensions/test/browser/browser_ext_runtime_openOptionsPage.js new file mode 100644 index 0000000000..a4b01bc182 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_runtime_openOptionsPage.js @@ -0,0 +1,442 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +async function loadExtension(options) { + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + + manifest: Object.assign( + { + permissions: ["tabs"], + }, + options.manifest + ), + + files: { + "options.html": `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + <script src="options.js" type="text/javascript"></script> + </head> + </html>`, + + "options.js": function () { + window.iAmOption = true; + browser.runtime.sendMessage("options.html"); + browser.runtime.onMessage.addListener((msg, sender, respond) => { + if (msg == "ping") { + respond("pong"); + } else if (msg == "connect") { + let port = browser.runtime.connect(); + port.postMessage("ping-from-options-html"); + port.onMessage.addListener(msg => { + if (msg == "ping-from-bg") { + browser.test.log("Got outbound options.html pong"); + browser.test.sendMessage("options-html-outbound-pong"); + } + }); + } + }); + + browser.runtime.onConnect.addListener(port => { + browser.test.log("Got inbound options.html port"); + + port.postMessage("ping-from-options-html"); + port.onMessage.addListener(msg => { + if (msg == "ping-from-bg") { + browser.test.log("Got inbound options.html pong"); + browser.test.sendMessage("options-html-inbound-pong"); + } + }); + }); + }, + }, + + background: options.background, + }); + + await extension.startup(); + + return extension; +} + +add_task(async function run_test_inline_options() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.com/" + ); + + let extension = await loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { id: "inline_options@tests.mozilla.org" }, + }, + options_ui: { + page: "options.html", + }, + }, + + background: async function () { + let _optionsPromise; + let awaitOptions = () => { + browser.test.assertFalse( + _optionsPromise, + "Should not be awaiting options already" + ); + + return new Promise(resolve => { + _optionsPromise = { resolve }; + }); + }; + + browser.runtime.onMessage.addListener((msg, sender) => { + if (msg == "options.html") { + if (_optionsPromise) { + _optionsPromise.resolve(sender.tab); + _optionsPromise = null; + } else { + browser.test.fail("Saw unexpected options page load"); + } + } + }); + + try { + let [firstTab] = await browser.tabs.query({ + currentWindow: true, + active: true, + }); + + browser.test.log("Open options page. Expect fresh load."); + + let [, optionsTab] = await Promise.all([ + browser.runtime.openOptionsPage(), + awaitOptions(), + ]); + + browser.test.assertEq( + "about:addons", + optionsTab.url, + "Tab contains AddonManager" + ); + browser.test.assertTrue(optionsTab.active, "Tab is active"); + browser.test.assertTrue( + optionsTab.id != firstTab.id, + "Tab is a new tab" + ); + + browser.test.assertEq( + 0, + browser.extension.getViews({ type: "popup" }).length, + "viewType is not popup" + ); + browser.test.assertEq( + 1, + browser.extension.getViews({ type: "tab" }).length, + "viewType is tab" + ); + browser.test.assertEq( + 1, + browser.extension.getViews({ windowId: optionsTab.windowId }).length, + "windowId matches" + ); + + let views = browser.extension.getViews(); + browser.test.assertEq( + 2, + views.length, + "Expected the options page and the background page" + ); + browser.test.assertTrue( + views.includes(window), + "One of the views is the background page" + ); + browser.test.assertTrue( + views.some(w => w.iAmOption), + "One of the views is the options page" + ); + + browser.test.log("Switch tabs."); + await browser.tabs.update(firstTab.id, { active: true }); + + browser.test.log( + "Open options page again. Expect tab re-selected, no new load." + ); + + await browser.runtime.openOptionsPage(); + let [tab] = await browser.tabs.query({ + currentWindow: true, + active: true, + }); + + browser.test.assertEq( + optionsTab.id, + tab.id, + "Tab is the same as the previous options tab" + ); + browser.test.assertEq( + "about:addons", + tab.url, + "Tab contains AddonManager" + ); + + browser.test.log("Ping options page."); + let pong = await browser.runtime.sendMessage("ping"); + browser.test.assertEq("pong", pong, "Got pong."); + + let done = new Promise(resolve => { + browser.test.onMessage.addListener(msg => { + if (msg == "ports-done") { + resolve(); + } + }); + }); + + browser.runtime.onConnect.addListener(port => { + browser.test.log("Got inbound background port"); + + port.postMessage("ping-from-bg"); + port.onMessage.addListener(msg => { + if (msg == "ping-from-options-html") { + browser.test.log("Got inbound background pong"); + browser.test.sendMessage("bg-inbound-pong"); + } + }); + }); + + browser.runtime.sendMessage("connect"); + + let port = browser.runtime.connect(); + port.postMessage("ping-from-bg"); + port.onMessage.addListener(msg => { + if (msg == "ping-from-options-html") { + browser.test.log("Got outbound background pong"); + browser.test.sendMessage("bg-outbound-pong"); + } + }); + + await done; + + browser.test.log("Remove options tab."); + await browser.tabs.remove(optionsTab.id); + + browser.test.log("Open options page again. Expect fresh load."); + [, tab] = await Promise.all([ + browser.runtime.openOptionsPage(), + awaitOptions(), + ]); + browser.test.assertEq( + "about:addons", + tab.url, + "Tab contains AddonManager" + ); + browser.test.assertTrue(tab.active, "Tab is active"); + browser.test.assertTrue(tab.id != optionsTab.id, "Tab is a new tab"); + + await browser.tabs.remove(tab.id); + + browser.test.notifyPass("options-ui"); + } catch (error) { + browser.test.fail(`Error: ${error} :: ${error.stack}`); + browser.test.notifyFail("options-ui"); + } + }, + }); + + await Promise.all([ + extension.awaitMessage("options-html-inbound-pong"), + extension.awaitMessage("options-html-outbound-pong"), + extension.awaitMessage("bg-inbound-pong"), + extension.awaitMessage("bg-outbound-pong"), + ]); + + extension.sendMessage("ports-done"); + + await extension.awaitFinish("options-ui"); + + await extension.unload(); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_tab_options() { + info(`Test options opened in a tab`); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.com/" + ); + + let extension = await loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { id: "tab_options@tests.mozilla.org" }, + }, + options_ui: { + page: "options.html", + open_in_tab: true, + }, + }, + + background: async function () { + let _optionsPromise; + let awaitOptions = () => { + browser.test.assertFalse( + _optionsPromise, + "Should not be awaiting options already" + ); + + return new Promise(resolve => { + _optionsPromise = { resolve }; + }); + }; + + browser.runtime.onMessage.addListener((msg, sender) => { + if (msg == "options.html") { + if (_optionsPromise) { + _optionsPromise.resolve(sender.tab); + _optionsPromise = null; + } else { + browser.test.fail("Saw unexpected options page load"); + } + } + }); + + let optionsURL = browser.runtime.getURL("options.html"); + + try { + let [firstTab] = await browser.tabs.query({ + currentWindow: true, + active: true, + }); + + browser.test.log("Open options page. Expect fresh load."); + let [, optionsTab] = await Promise.all([ + browser.runtime.openOptionsPage(), + awaitOptions(), + ]); + browser.test.assertEq( + optionsURL, + optionsTab.url, + "Tab contains options.html" + ); + browser.test.assertTrue(optionsTab.active, "Tab is active"); + browser.test.assertTrue( + optionsTab.id != firstTab.id, + "Tab is a new tab" + ); + + browser.test.assertEq( + 0, + browser.extension.getViews({ type: "popup" }).length, + "viewType is not popup" + ); + browser.test.assertEq( + 1, + browser.extension.getViews({ type: "tab" }).length, + "viewType is tab" + ); + browser.test.assertEq( + 1, + browser.extension.getViews({ windowId: optionsTab.windowId }).length, + "windowId matches" + ); + + let views = browser.extension.getViews(); + browser.test.assertEq( + 2, + views.length, + "Expected the options page and the background page" + ); + browser.test.assertTrue( + views.includes(window), + "One of the views is the background page" + ); + browser.test.assertTrue( + views.some(w => w.iAmOption), + "One of the views is the options page" + ); + + browser.test.log("Switch tabs."); + await browser.tabs.update(firstTab.id, { active: true }); + + browser.test.log( + "Open options page again. Expect tab re-selected, no new load." + ); + + await browser.runtime.openOptionsPage(); + let [tab] = await browser.tabs.query({ + currentWindow: true, + active: true, + }); + + browser.test.assertEq( + optionsTab.id, + tab.id, + "Tab is the same as the previous options tab" + ); + browser.test.assertEq(optionsURL, tab.url, "Tab contains options.html"); + + // Unfortunately, we can't currently do this, since onMessage doesn't + // currently support responses when there are multiple listeners. + // + // browser.test.log("Ping options page."); + // return new Promise(resolve => browser.runtime.sendMessage("ping", resolve)); + + browser.test.log("Remove options tab."); + await browser.tabs.remove(optionsTab.id); + + browser.test.log("Open options page again. Expect fresh load."); + [, tab] = await Promise.all([ + browser.runtime.openOptionsPage(), + awaitOptions(), + ]); + browser.test.assertEq(optionsURL, tab.url, "Tab contains options.html"); + browser.test.assertTrue(tab.active, "Tab is active"); + browser.test.assertTrue(tab.id != optionsTab.id, "Tab is a new tab"); + + await browser.tabs.remove(tab.id); + + browser.test.notifyPass("options-ui-tab"); + } catch (error) { + browser.test.fail(`Error: ${error} :: ${error.stack}`); + browser.test.notifyFail("options-ui-tab"); + } + }, + }); + + await extension.awaitFinish("options-ui-tab"); + await extension.unload(); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_options_no_manifest() { + info(`Test with no manifest key`); + + let extension = await loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { id: "no_options@tests.mozilla.org" }, + }, + }, + + async background() { + browser.test.log( + "Try to open options page when not specified in the manifest." + ); + + await browser.test.assertRejects( + browser.runtime.openOptionsPage(), + /No `options_ui` declared/, + "Expected error from openOptionsPage()" + ); + + browser.test.notifyPass("options-no-manifest"); + }, + }); + + await extension.awaitFinish("options-no-manifest"); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_runtime_openOptionsPage_uninstall.js b/browser/components/extensions/test/browser/browser_ext_runtime_openOptionsPage_uninstall.js new file mode 100644 index 0000000000..ac9bbf1ed2 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_runtime_openOptionsPage_uninstall.js @@ -0,0 +1,122 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +async function loadExtension(options) { + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + + manifest: Object.assign( + { + permissions: ["tabs"], + }, + options.manifest + ), + + files: { + "options.html": `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + <script src="options.js" type="text/javascript"></script> + </head> + </html>`, + + "options.js": function () { + browser.runtime.sendMessage("options.html"); + browser.runtime.onMessage.addListener((msg, sender, respond) => { + if (msg == "ping") { + respond("pong"); + } + }); + }, + }, + + background: options.background, + }); + + await extension.startup(); + + return extension; +} + +add_task(async function test_inline_options_uninstall() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.com/" + ); + + let extension = await loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { id: "inline_options_uninstall@tests.mozilla.org" }, + }, + options_ui: { + page: "options.html", + }, + }, + + background: async function () { + let _optionsPromise; + let awaitOptions = () => { + browser.test.assertFalse( + _optionsPromise, + "Should not be awaiting options already" + ); + + return new Promise(resolve => { + _optionsPromise = { resolve }; + }); + }; + + browser.runtime.onMessage.addListener((msg, sender) => { + if (msg == "options.html") { + if (_optionsPromise) { + _optionsPromise.resolve(sender.tab); + _optionsPromise = null; + } else { + browser.test.fail("Saw unexpected options page load"); + } + } + }); + + try { + let [firstTab] = await browser.tabs.query({ + currentWindow: true, + active: true, + }); + + browser.test.log("Open options page. Expect fresh load."); + let [, tab] = await Promise.all([ + browser.runtime.openOptionsPage(), + awaitOptions(), + ]); + + browser.test.assertEq( + "about:addons", + tab.url, + "Tab contains AddonManager" + ); + browser.test.assertTrue(tab.active, "Tab is active"); + browser.test.assertTrue(tab.id != firstTab.id, "Tab is a new tab"); + + browser.test.sendMessage("options-ui-open"); + } catch (error) { + browser.test.fail(`Error: ${error} :: ${error.stack}`); + } + }, + }); + + await extension.awaitMessage("options-ui-open"); + await extension.unload(); + + is( + gBrowser.selectedBrowser.currentURI.spec, + "about:addons", + "Add-on manager tab should still be open" + ); + + BrowserTestUtils.removeTab(gBrowser.selectedTab); + + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_runtime_setUninstallURL.js b/browser/components/extensions/test/browser/browser_ext_runtime_setUninstallURL.js new file mode 100644 index 0000000000..2530c28a6d --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_runtime_setUninstallURL.js @@ -0,0 +1,134 @@ +"use strict"; + +// testing/mochitest/tests/SimpleTest/ExtensionTestUtils.js loads +// ExtensionTestCommon, and is slated as part of the SimpleTest +// environment in tools/lint/eslint/eslint-plugin-mozilla/lib/environments/simpletest.js +// However, nothing but the ExtensionTestUtils global gets put +// into the scope, and so although eslint thinks this global is +// available, it really isn't. +// eslint-disable-next-line mozilla/no-redeclare-with-import-autofix +let { ExtensionTestCommon } = ChromeUtils.importESModule( + "resource://testing-common/ExtensionTestCommon.sys.mjs" +); + +async function makeAndInstallXPI(id, backgroundScript, loadedURL) { + let xpi = ExtensionTestCommon.generateXPI({ + manifest: { browser_specific_settings: { gecko: { id } } }, + background: backgroundScript, + }); + SimpleTest.registerCleanupFunction(function cleanupXPI() { + Services.obs.notifyObservers(xpi, "flush-cache-entry"); + xpi.remove(false); + }); + + let loadPromise = BrowserTestUtils.waitForNewTab(gBrowser, loadedURL); + + info(`installing ${xpi.path}`); + let addon = await AddonManager.installTemporaryAddon(xpi); + info("installed"); + + // A WebExtension is started asynchronously, we have our test extension + // open a new tab to signal that the background script has executed. + let loadTab = await loadPromise; + BrowserTestUtils.removeTab(loadTab); + + return addon; +} + +add_task(async function test_setuninstallurl_badargs() { + async function background() { + await browser.test.assertRejects( + browser.runtime.setUninstallURL("this is not a url"), + /Invalid URL/, + "setUninstallURL with an invalid URL should fail" + ); + + await browser.test.assertRejects( + browser.runtime.setUninstallURL("file:///etc/passwd"), + /must have the scheme http or https/, + "setUninstallURL with an illegal URL should fail" + ); + + browser.test.notifyPass("setUninstallURL bad params"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + }); + await extension.startup(); + await extension.awaitFinish(); + await extension.unload(); +}); + +// Test the documented behavior of setUninstallURL() that passing an +// empty string is equivalent to not setting an uninstall URL +// (i.e., no new tab is opened upon uninstall) +add_task(async function test_setuninstall_empty_url() { + async function backgroundScript() { + await browser.runtime.setUninstallURL(""); + browser.tabs.create({ url: "http://example.com/addon_loaded" }); + } + + let addon = await makeAndInstallXPI( + "test_uinstallurl2@tests.mozilla.org", + backgroundScript, + "http://example.com/addon_loaded" + ); + + addon.uninstall(true); + info("uninstalled"); + + // no need to explicitly check for the absence of a new tab, + // BrowserTestUtils will eventually complain if one is opened. +}); + +// Test the documented behavior of setUninstallURL() that passing an +// empty string is equivalent to not setting an uninstall URL +// (i.e., no new tab is opened upon uninstall) +// here we pass a null value to string and test +add_task(async function test_setuninstall_null_url() { + async function backgroundScript() { + await browser.runtime.setUninstallURL(null); + browser.tabs.create({ url: "http://example.com/addon_loaded" }); + } + + let addon = await makeAndInstallXPI( + "test_uinstallurl2@tests.mozilla.org", + backgroundScript, + "http://example.com/addon_loaded" + ); + + addon.uninstall(true); + info("uninstalled"); + + // no need to explicitly check for the absence of a new tab, + // BrowserTestUtils will eventually complain if one is opened. +}); + +add_task(async function test_setuninstallurl() { + async function backgroundScript() { + await browser.runtime.setUninstallURL( + "http://example.com/addon_uninstalled" + ); + browser.tabs.create({ url: "http://example.com/addon_loaded" }); + } + + let addon = await makeAndInstallXPI( + "test_uinstallurl@tests.mozilla.org", + backgroundScript, + "http://example.com/addon_loaded" + ); + + // look for a new tab with the uninstall url. + let uninstallPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + "http://example.com/addon_uninstalled" + ); + + addon.uninstall(true); + info("uninstalled"); + + let uninstalledTab = await uninstallPromise; + isnot(uninstalledTab, null, "opened tab with uninstall url"); + BrowserTestUtils.removeTab(uninstalledTab); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_search.js b/browser/components/extensions/test/browser/browser_ext_search.js new file mode 100644 index 0000000000..c7dab1c9dc --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_search.js @@ -0,0 +1,351 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { AddonTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/AddonTestUtils.sys.mjs" +); + +const SEARCH_TERM = "test"; +const SEARCH_URL = "https://example.org/?q={searchTerms}"; + +AddonTestUtils.initMochitest(this); + +add_task(async function test_search() { + async function background(SEARCH_TERM) { + browser.test.onMessage.addListener(async (msg, tabIds) => { + if (msg !== "removeTabs") { + return; + } + + await browser.tabs.remove(tabIds); + browser.test.sendMessage("onTabsRemoved"); + }); + + function awaitSearchResult() { + return new Promise(resolve => { + async function listener(tabId, info, changedTab) { + if (changedTab.url == "about:blank") { + // Ignore events related to the initial tab open. + return; + } + + if (info.status === "complete") { + browser.tabs.onUpdated.removeListener(listener); + resolve({ tabId, url: changedTab.url }); + } + } + + browser.tabs.onUpdated.addListener(listener); + }); + } + + let engines = await browser.search.get(); + browser.test.sendMessage("engines", engines); + + // Search with no tabId + browser.search.search({ query: SEARCH_TERM + "1", engine: "Search Test" }); + let result = await awaitSearchResult(); + browser.test.sendMessage("searchLoaded", result); + + // Search with tabId + let tab = await browser.tabs.create({}); + browser.search.search({ + query: SEARCH_TERM + "2", + engine: "Search Test", + tabId: tab.id, + }); + result = await awaitSearchResult(); + browser.test.assertEq(result.tabId, tab.id, "Page loaded in right tab"); + browser.test.sendMessage("searchLoaded", result); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["search", "tabs"], + chrome_settings_overrides: { + search_provider: { + name: "Search Test", + search_url: SEARCH_URL, + }, + }, + }, + background: `(${background})("${SEARCH_TERM}")`, + useAddonManager: "temporary", + }); + await extension.startup(); + await AddonTestUtils.waitForSearchProviderStartup(extension); + + let addonEngines = await extension.awaitMessage("engines"); + let engines = (await Services.search.getEngines()).filter( + engine => !engine.hidden + ); + is(addonEngines.length, engines.length, "Engine lengths are the same."); + let defaultEngine = addonEngines.filter(engine => engine.isDefault === true); + is(defaultEngine.length, 1, "One default engine"); + is( + defaultEngine[0].name, + (await Services.search.getDefault()).name, + "Default engine is correct" + ); + + const result1 = await extension.awaitMessage("searchLoaded"); + is( + result1.url, + SEARCH_URL.replace("{searchTerms}", SEARCH_TERM + "1"), + "Loaded page matches search" + ); + await TestUtils.waitForCondition( + () => !gURLBar.focused, + "Wait for unfocusing the urlbar" + ); + info("The urlbar has no focus when searching without tabId"); + + const result2 = await extension.awaitMessage("searchLoaded"); + is( + result2.url, + SEARCH_URL.replace("{searchTerms}", SEARCH_TERM + "2"), + "Loaded page matches search" + ); + await TestUtils.waitForCondition( + () => !gURLBar.focused, + "Wait for unfocusing the urlbar" + ); + info("The urlbar has no focus when searching with tabId"); + + extension.sendMessage("removeTabs", [result1.tabId, result2.tabId]); + await extension.awaitMessage("onTabsRemoved"); + + await extension.unload(); +}); + +add_task(async function test_search_default_engine() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["search"], + }, + background() { + browser.test.onMessage.addListener((msg, tabId) => { + browser.test.assertEq(msg, "search"); + browser.search.search({ query: "searchTermForDefaultEngine", tabId }); + }); + browser.test.sendMessage("extension-origin", browser.runtime.getURL("/")); + }, + useAddonManager: "temporary", + }); + + // Use another extension to intercept and block the search request, + // so that there is no outbound network activity that would kill the test. + // This method also allows us to verify that: + // 1) the search appears as a normal request in the webRequest API. + // 2) the request is associated with the triggering extension. + let extensionWithObserver = ExtensionTestUtils.loadExtension({ + manifest: { permissions: ["webRequest", "webRequestBlocking", "*://*/*"] }, + async background() { + let tab = await browser.tabs.create({ url: "about:blank" }); + browser.webRequest.onBeforeRequest.addListener( + details => { + browser.test.log(`Intercepted request ${JSON.stringify(details)}`); + browser.tabs.remove(tab.id).then(() => { + browser.test.sendMessage("detectedSearch", details); + }); + return { cancel: true }; + }, + { + tabId: tab.id, + types: ["main_frame"], + urls: ["*://*/*"], + }, + ["blocking"] + ); + browser.test.sendMessage("ready", tab.id); + }, + }); + await extension.startup(); + const EXPECTED_ORIGIN = await extension.awaitMessage("extension-origin"); + + await extensionWithObserver.startup(); + let tabId = await extensionWithObserver.awaitMessage("ready"); + + extension.sendMessage("search", tabId); + let requestDetails = await extensionWithObserver.awaitMessage( + "detectedSearch" + ); + await extension.unload(); + await extensionWithObserver.unload(); + + ok( + requestDetails.url.includes("searchTermForDefaultEngine"), + `Expected search term in ${requestDetails.url}` + ); + is( + requestDetails.originUrl, + EXPECTED_ORIGIN, + "Search request's should be associated with the originating extension." + ); +}); + +add_task(async function test_search_disposition() { + async function background() { + let resolvers = {}; + + function tabListener(tabId, changeInfo, tab) { + if (tab.url == "about:blank") { + // Ignore events related to the initial tab open. + return; + } + + if (changeInfo.status === "complete") { + let query = new URL(tab.url).searchParams.get("q"); + let resolver = resolvers[query]; + browser.test.assertTrue(resolver, `Found resolver for ${tab.url}`); + browser.test.assertTrue( + resolver.resolve, + `${query} was not resolved yet` + ); + resolver.resolve({ + tabId, + windowId: tab.windowId, + }); + resolver.resolve = null; // resolve can be used only once. + } + } + browser.tabs.onUpdated.addListener(tabListener); + + async function awaitSearchResult(args) { + resolvers[args.query] = {}; + resolvers[args.query].promise = new Promise( + _resolve => (resolvers[args.query].resolve = _resolve) + ); + await browser.search.search({ ...args, engine: "Search Test" }); + let searchResult = await resolvers[args.query].promise; + return searchResult; + } + + const firstTab = await browser.tabs.create({ + active: true, + url: "about:blank", + }); + + // Search in new tab (testing default disposition) + let result = await awaitSearchResult({ + query: "DefaultDisposition", + }); + browser.test.assertFalse( + result.tabId === firstTab.id, + "Query ran in new tab" + ); + browser.test.assertEq( + result.windowId, + firstTab.windowId, + "Query ran in current window" + ); + await browser.tabs.remove(result.tabId); // Cleanup + + // Search in new tab + result = await awaitSearchResult({ + query: "NewTab", + disposition: "NEW_TAB", + }); + browser.test.assertFalse( + result.tabId === firstTab.id, + "Query ran in new tab" + ); + browser.test.assertEq( + result.windowId, + firstTab.windowId, + "Query ran in current window" + ); + await browser.tabs.remove(result.tabId); // Cleanup + + // Search in current tab + result = await awaitSearchResult({ + query: "CurrentTab", + disposition: "CURRENT_TAB", + }); + browser.test.assertDeepEq( + { + tabId: firstTab.id, + windowId: firstTab.windowId, + }, + result, + "Query ran in current tab in current window" + ); + + // Search in a specific tab + let newTab = await browser.tabs.create({ + active: false, + url: "about:blank", + }); + result = await awaitSearchResult({ + query: "SpecificTab", + tabId: newTab.id, + }); + browser.test.assertDeepEq( + { + tabId: newTab.id, + windowId: firstTab.windowId, + }, + result, + "Query ran in specific tab in current window" + ); + await browser.tabs.remove(newTab.id); // Cleanup + + // Search in a new window + result = await awaitSearchResult({ + query: "NewWindow", + disposition: "NEW_WINDOW", + }); + browser.test.assertFalse( + result.windowId === firstTab.windowId, + "Query ran in new window" + ); + await browser.windows.remove(result.windowId); // Cleanup + await browser.tabs.remove(firstTab.id); // Cleanup + + // Make sure tabId and disposition can't be used together + await browser.test.assertRejects( + browser.search.search({ + query: " ", + tabId: 1, + disposition: "NEW_WINDOW", + }), + "Cannot set both 'disposition' and 'tabId'", + "Should not be able to set both tabId and disposition" + ); + + // Make sure we reject if an invalid tabId is used + await browser.test.assertRejects( + browser.search.search({ + query: " ", + tabId: Number.MAX_SAFE_INTEGER, + }), + /Invalid tab ID/, + "Should not be able to set an invalid tabId" + ); + + browser.test.notifyPass("disposition"); + } + let searchExtension = ExtensionTestUtils.loadExtension({ + manifest: { + chrome_settings_overrides: { + search_provider: { + name: "Search Test", + search_url: "https://example.org/?q={searchTerms}", + }, + }, + }, + useAddonManager: "temporary", + }); + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["search", "tabs"], + }, + background, + }); + await searchExtension.startup(); + await extension.startup(); + await extension.awaitFinish("disposition"); + await extension.unload(); + await searchExtension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_search_favicon.js b/browser/components/extensions/test/browser/browser_ext_search_favicon.js new file mode 100644 index 0000000000..4e48dd55fa --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_search_favicon.js @@ -0,0 +1,184 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { AddonTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/AddonTestUtils.sys.mjs" +); +const { XPCShellContentUtils } = ChromeUtils.importESModule( + "resource://testing-common/XPCShellContentUtils.sys.mjs" +); + +AddonTestUtils.initMochitest(this); +XPCShellContentUtils.initMochitest(this); + +// Base64-encoded "Fake icon data". +const FAKE_ICON_DATA = "RmFrZSBpY29uIGRhdGE="; + +// Base64-encoded "HTTP icon data". +const HTTP_ICON_DATA = "SFRUUCBpY29uIGRhdGE="; +const HTTP_ICON_URL = "http://example.org/ico.png"; +const server = XPCShellContentUtils.createHttpServer({ + hosts: ["example.org"], +}); +server.registerPathHandler("/ico.png", (request, response) => { + response.write(atob(HTTP_ICON_DATA)); +}); + +function promiseEngineIconLoaded(engineName) { + return TestUtils.topicObserved( + "browser-search-engine-modified", + (engine, verb) => { + engine.QueryInterface(Ci.nsISearchEngine); + return ( + verb == "engine-changed" && + engine.name == engineName && + engine.getIconURL() + ); + } + ); +} + +add_task(async function test_search_favicon() { + let searchExt = ExtensionTestUtils.loadExtension({ + manifest: { + chrome_settings_overrides: { + search_provider: { + name: "Engine Only", + search_url: "https://example.com/", + favicon_url: "someFavicon.png", + }, + }, + }, + files: { + "someFavicon.png": atob(FAKE_ICON_DATA), + }, + useAddonManager: "temporary", + }); + + let searchExtWithBadIcon = ExtensionTestUtils.loadExtension({ + manifest: { + chrome_settings_overrides: { + search_provider: { + name: "Bad Icon", + search_url: "https://example.net/", + favicon_url: "iDoNotExist.png", + }, + }, + }, + useAddonManager: "temporary", + }); + + let searchExtWithHttpIcon = ExtensionTestUtils.loadExtension({ + manifest: { + chrome_settings_overrides: { + search_provider: { + name: "HTTP Icon", + search_url: "https://example.org/", + favicon_url: HTTP_ICON_URL, + }, + }, + }, + useAddonManager: "temporary", + }); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["search"], + chrome_settings_overrides: { + search_provider: { + name: "My Engine", + search_url: "https://example.org/", + favicon_url: "myFavicon.png", + }, + }, + }, + files: { + "myFavicon.png": imageBuffer, + }, + useAddonManager: "temporary", + async background() { + let engines = await browser.search.get(); + browser.test.sendMessage("engines", { + badEngine: engines.find(engine => engine.name === "Bad Icon"), + httpEngine: engines.find(engine => engine.name === "HTTP Icon"), + myEngine: engines.find(engine => engine.name === "My Engine"), + otherEngine: engines.find(engine => engine.name === "Engine Only"), + }); + }, + }); + + await searchExt.startup(); + await AddonTestUtils.waitForSearchProviderStartup(searchExt); + + await searchExtWithBadIcon.startup(); + await AddonTestUtils.waitForSearchProviderStartup(searchExtWithBadIcon); + + // TODO bug 1571718: browser.search.get should behave correctly (i.e return + // the icon) even if the icon did not finish loading when the API was called. + // Currently calling it too early returns undefined, so just wait until the + // icon has loaded before calling browser.search.get. + let httpIconLoaded = promiseEngineIconLoaded("HTTP Icon"); + await searchExtWithHttpIcon.startup(); + await AddonTestUtils.waitForSearchProviderStartup(searchExtWithHttpIcon); + await httpIconLoaded; + + await extension.startup(); + await AddonTestUtils.waitForSearchProviderStartup(extension); + + let engines = await extension.awaitMessage("engines"); + + // An extension's own icon can surely be accessed by the extension, so its + // favIconUrl can be the moz-extension:-URL itself. + Assert.deepEqual( + engines.myEngine, + { + name: "My Engine", + isDefault: false, + alias: undefined, + favIconUrl: `moz-extension://${extension.uuid}/myFavicon.png`, + }, + "browser.search.get result for own extension" + ); + + // favIconUrl of other engines need to be in base64-encoded form. + Assert.deepEqual( + engines.otherEngine, + { + name: "Engine Only", + isDefault: false, + alias: undefined, + favIconUrl: `data:image/png;base64,${FAKE_ICON_DATA}`, + }, + "browser.search.get result for other extension" + ); + + // HTTP URLs should be provided as-is. + Assert.deepEqual( + engines.httpEngine, + { + name: "HTTP Icon", + isDefault: false, + alias: undefined, + favIconUrl: `data:image/png;base64,${HTTP_ICON_DATA}`, + }, + "browser.search.get result for extension with HTTP icon URL" + ); + + // When the favicon does not exists, the favIconUrl must be unset. + Assert.deepEqual( + engines.badEngine, + { + name: "Bad Icon", + isDefault: false, + alias: undefined, + favIconUrl: undefined, + }, + "browser.search.get result for other extension with non-existing icon" + ); + + await extension.unload(); + await searchExt.unload(); + await searchExtWithBadIcon.unload(); + await searchExtWithHttpIcon.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_search_query.js b/browser/components/extensions/test/browser/browser_ext_search_query.js new file mode 100644 index 0000000000..5258b12605 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_search_query.js @@ -0,0 +1,174 @@ +"use strict"; + +add_task(async function test_query() { + async function background() { + let resolvers = {}; + + function tabListener(tabId, changeInfo, tab) { + if (tab.url == "about:blank") { + // Ignore events related to the initial tab open. + return; + } + + if (changeInfo.status === "complete") { + let query = new URL(tab.url).searchParams.get("q"); + let resolver = resolvers[query]; + browser.test.assertTrue(resolver, `Found resolver for ${tab.url}`); + browser.test.assertTrue( + resolver.resolve, + `${query} was not resolved yet` + ); + resolver.resolve({ + tabId, + windowId: tab.windowId, + }); + resolver.resolve = null; // resolve can be used only once. + } + } + browser.tabs.onUpdated.addListener(tabListener); + + async function awaitSearchResult(args) { + resolvers[args.text] = {}; + resolvers[args.text].promise = new Promise( + _resolve => (resolvers[args.text].resolve = _resolve) + ); + await browser.search.query(args); + let searchResult = await resolvers[args.text].promise; + return searchResult; + } + + const firstTab = await browser.tabs.create({ + active: true, + url: "about:blank", + }); + + browser.test.log("Search in current tab (testing default disposition)"); + let result = await awaitSearchResult({ + text: "DefaultDisposition", + }); + browser.test.assertDeepEq( + { + tabId: firstTab.id, + windowId: firstTab.windowId, + }, + result, + "Defaults to current tab in current window" + ); + + browser.test.log( + "Search in current tab (testing explicit disposition CURRENT_TAB)" + ); + result = await awaitSearchResult({ + text: "CurrentTab", + disposition: "CURRENT_TAB", + }); + browser.test.assertDeepEq( + { + tabId: firstTab.id, + windowId: firstTab.windowId, + }, + result, + "Query ran in current tab in current window" + ); + + browser.test.log("Search in new tab (testing disposition NEW_TAB)"); + result = await awaitSearchResult({ + text: "NewTab", + disposition: "NEW_TAB", + }); + browser.test.assertFalse( + result.tabId === firstTab.id, + "Query ran in new tab" + ); + browser.test.assertEq( + result.windowId, + firstTab.windowId, + "Query ran in current window" + ); + await browser.tabs.remove(result.tabId); // Cleanup + + browser.test.log("Search in a specific tab (testing property tabId)"); + let newTab = await browser.tabs.create({ + active: false, + url: "about:blank", + }); + result = await awaitSearchResult({ + text: "SpecificTab", + tabId: newTab.id, + }); + browser.test.assertDeepEq( + { + tabId: newTab.id, + windowId: firstTab.windowId, + }, + result, + "Query ran in specific tab in current window" + ); + await browser.tabs.remove(newTab.id); // Cleanup + + browser.test.log("Search in a new window (testing disposition NEW_WINDOW)"); + result = await awaitSearchResult({ + text: "NewWindow", + disposition: "NEW_WINDOW", + }); + browser.test.assertFalse( + result.windowId === firstTab.windowId, + "Query ran in new window" + ); + await browser.windows.remove(result.windowId); // Cleanup + await browser.tabs.remove(firstTab.id); // Cleanup + + browser.test.log("Make sure tabId and disposition can't be used together"); + await browser.test.assertRejects( + browser.search.query({ + text: " ", + tabId: 1, + disposition: "NEW_WINDOW", + }), + "Cannot set both 'disposition' and 'tabId'", + "Should not be able to set both tabId and disposition" + ); + + browser.test.log("Make sure we reject if an invalid tabId is used"); + await browser.test.assertRejects( + browser.search.query({ + text: " ", + tabId: Number.MAX_SAFE_INTEGER, + }), + /Invalid tab ID/, + "Should not be able to set an invalid tabId" + ); + + browser.test.notifyPass("disposition"); + } + const SEARCH_NAME = "Search Test"; + let searchExtension = ExtensionTestUtils.loadExtension({ + manifest: { + chrome_settings_overrides: { + search_provider: { + name: SEARCH_NAME, + search_url: "https://example.org/?q={searchTerms}", + }, + }, + }, + useAddonManager: "temporary", + }); + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["search", "tabs"], + }, + background, + }); + // We need to use a fake search engine because + // these tests aren't allowed to load actual + // webpages, like google.com for example. + await searchExtension.startup(); + await Services.search.setDefault( + Services.search.getEngineByName(SEARCH_NAME), + Ci.nsISearchService.CHANGE_REASON_ADDON_INSTALL + ); + await extension.startup(); + await extension.awaitFinish("disposition"); + await extension.unload(); + await searchExtension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_sessions_forgetClosedTab.js b/browser/components/extensions/test/browser/browser_ext_sessions_forgetClosedTab.js new file mode 100644 index 0000000000..c257cbd741 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_sessions_forgetClosedTab.js @@ -0,0 +1,145 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { TabStateFlusher } = ChromeUtils.importESModule( + "resource:///modules/sessionstore/TabStateFlusher.sys.mjs" +); + +function getExtension(incognitoOverride) { + function background() { + browser.test.onMessage.addListener((msg, windowId, sessionId) => { + if (msg === "check-sessions") { + browser.sessions.getRecentlyClosed().then(recentlyClosed => { + browser.test.sendMessage("recentlyClosed", recentlyClosed); + }); + } else if (msg === "forget-tab") { + browser.sessions.forgetClosedTab(windowId, sessionId).then( + () => { + browser.test.sendMessage("forgot-tab"); + }, + error => { + browser.test.sendMessage("forget-reject", error.message); + } + ); + } + }); + } + + return ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["sessions", "tabs"], + }, + background, + incognitoOverride, + }); +} + +async function openAndCloseTab(window, url) { + const tab = await BrowserTestUtils.openNewForegroundTab(window.gBrowser, url); + await TabStateFlusher.flush(tab.linkedBrowser); + const sessionUpdatePromise = BrowserTestUtils.waitForSessionStoreUpdate(tab); + BrowserTestUtils.removeTab(tab); + await sessionUpdatePromise; +} + +add_setup(async function prepare() { + // Clean up any session state left by previous tests that might impact this one + while (SessionStore.getClosedTabCountForWindow(window) > 0) { + SessionStore.forgetClosedTab(window, 0); + } + await TestUtils.waitForTick(); +}); + +add_task(async function test_sessions_forget_closed_tab() { + let extension = getExtension(); + await extension.startup(); + + let tabUrl = "http://example.com"; + await openAndCloseTab(window, tabUrl); + await openAndCloseTab(window, tabUrl); + + extension.sendMessage("check-sessions"); + let recentlyClosed = await extension.awaitMessage("recentlyClosed"); + let recentlyClosedLength = recentlyClosed.length; + let recentlyClosedTab = recentlyClosed[0].tab; + + // Check that forgetting a tab works properly + extension.sendMessage( + "forget-tab", + recentlyClosedTab.windowId, + recentlyClosedTab.sessionId + ); + await extension.awaitMessage("forgot-tab"); + extension.sendMessage("check-sessions"); + let remainingClosed = await extension.awaitMessage("recentlyClosed"); + is( + remainingClosed.length, + recentlyClosedLength - 1, + "One tab was forgotten." + ); + is( + remainingClosed[0].tab.sessionId, + recentlyClosed[1].tab.sessionId, + "The correct tab was forgotten." + ); + + // Check that re-forgetting the same tab fails properly + extension.sendMessage( + "forget-tab", + recentlyClosedTab.windowId, + recentlyClosedTab.sessionId + ); + let errormsg = await extension.awaitMessage("forget-reject"); + is( + errormsg, + `Could not find closed tab using sessionId ${recentlyClosedTab.sessionId}.` + ); + + extension.sendMessage("check-sessions"); + remainingClosed = await extension.awaitMessage("recentlyClosed"); + is( + remainingClosed.length, + recentlyClosedLength - 1, + "No extra tab was forgotten." + ); + is( + remainingClosed[0].tab.sessionId, + recentlyClosed[1].tab.sessionId, + "The correct tab remains." + ); + + await extension.unload(); +}); + +add_task(async function test_sessions_forget_closed_tab_private() { + let pb_extension = getExtension("spanning"); + await pb_extension.startup(); + let extension = getExtension(); + await extension.startup(); + + // Open a private browsing window. + let privateWin = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + + let tabUrl = "http://example.com"; + await openAndCloseTab(privateWin, tabUrl); + + pb_extension.sendMessage("check-sessions"); + let recentlyClosed = await pb_extension.awaitMessage("recentlyClosed"); + let recentlyClosedTab = recentlyClosed[0].tab; + + // Check that forgetting a tab works properly + extension.sendMessage( + "forget-tab", + recentlyClosedTab.windowId, + recentlyClosedTab.sessionId + ); + let errormsg = await extension.awaitMessage("forget-reject"); + ok(/Invalid window ID/.test(errormsg), "could not access window"); + + await BrowserTestUtils.closeWindow(privateWin); + await extension.unload(); + await pb_extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_sessions_forgetClosedWindow.js b/browser/components/extensions/test/browser/browser_ext_sessions_forgetClosedWindow.js new file mode 100644 index 0000000000..471d2f4440 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_sessions_forgetClosedWindow.js @@ -0,0 +1,121 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +function getExtension(incognitoOverride) { + function background() { + browser.test.onMessage.addListener((msg, sessionId) => { + if (msg === "check-sessions") { + browser.sessions.getRecentlyClosed().then(recentlyClosed => { + browser.test.sendMessage("recentlyClosed", recentlyClosed); + }); + } else if (msg === "forget-window") { + browser.sessions.forgetClosedWindow(sessionId).then( + () => { + browser.test.sendMessage("forgot-window"); + }, + error => { + browser.test.sendMessage("forget-reject", error.message); + } + ); + } + }); + } + + return ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["sessions", "tabs"], + }, + background, + incognitoOverride, + }); +} + +async function openAndCloseWindow(url = "http://example.com", privateWin) { + let win = await BrowserTestUtils.openNewBrowserWindow({ + private: privateWin, + }); + let tab = await BrowserTestUtils.openNewForegroundTab(win.gBrowser, url); + let sessionUpdatePromise = BrowserTestUtils.waitForSessionStoreUpdate(tab); + await BrowserTestUtils.closeWindow(win); + await sessionUpdatePromise; +} + +add_task(async function test_sessions_forget_closed_window() { + let extension = getExtension(); + await extension.startup(); + + await openAndCloseWindow("about:config"); + await openAndCloseWindow("about:robots"); + + extension.sendMessage("check-sessions"); + let recentlyClosed = await extension.awaitMessage("recentlyClosed"); + let recentlyClosedWindow = recentlyClosed[0].window; + + // Check that forgetting a window works properly + extension.sendMessage("forget-window", recentlyClosedWindow.sessionId); + await extension.awaitMessage("forgot-window"); + extension.sendMessage("check-sessions"); + let remainingClosed = await extension.awaitMessage("recentlyClosed"); + is( + remainingClosed.length, + recentlyClosed.length - 1, + "One window was forgotten." + ); + is( + remainingClosed[0].window.sessionId, + recentlyClosed[1].window.sessionId, + "The correct window was forgotten." + ); + + // Check that re-forgetting the same window fails properly + extension.sendMessage("forget-window", recentlyClosedWindow.sessionId); + let errMsg = await extension.awaitMessage("forget-reject"); + is( + errMsg, + `Could not find closed window using sessionId ${recentlyClosedWindow.sessionId}.` + ); + + extension.sendMessage("check-sessions"); + remainingClosed = await extension.awaitMessage("recentlyClosed"); + is( + remainingClosed.length, + recentlyClosed.length - 1, + "No extra window was forgotten." + ); + is( + remainingClosed[0].window.sessionId, + recentlyClosed[1].window.sessionId, + "The correct window remains." + ); + + await extension.unload(); +}); + +add_task(async function test_sessions_forget_closed_window_private() { + let pb_extension = getExtension("spanning"); + await pb_extension.startup(); + let extension = getExtension("not_allowed"); + await extension.startup(); + + await openAndCloseWindow("about:config", true); + await openAndCloseWindow("about:robots", true); + + pb_extension.sendMessage("check-sessions"); + let recentlyClosed = await pb_extension.awaitMessage("recentlyClosed"); + let recentlyClosedWindow = recentlyClosed[0].window; + + extension.sendMessage("forget-window", recentlyClosedWindow.sessionId); + await extension.awaitMessage("forgot-window"); + extension.sendMessage("check-sessions"); + let remainingClosed = await extension.awaitMessage("recentlyClosed"); + is( + remainingClosed.length, + recentlyClosed.length - 1, + "One window was forgotten." + ); + ok(!recentlyClosedWindow.incognito, "not an incognito window"); + + await extension.unload(); + await pb_extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_sessions_getRecentlyClosed.js b/browser/components/extensions/test/browser/browser_ext_sessions_getRecentlyClosed.js new file mode 100644 index 0000000000..7dd41ecfe9 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_sessions_getRecentlyClosed.js @@ -0,0 +1,216 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +requestLongerTimeout(2); + +loadTestSubscript("head_sessions.js"); + +add_task(async function test_sessions_get_recently_closed() { + async function openAndCloseWindow(url = "http://example.com", tabUrls) { + let win = await BrowserTestUtils.openNewBrowserWindow(); + BrowserTestUtils.startLoadingURIString(win.gBrowser.selectedBrowser, url); + await BrowserTestUtils.browserLoaded(win.gBrowser.selectedBrowser); + if (tabUrls) { + for (let url of tabUrls) { + await BrowserTestUtils.openNewForegroundTab(win.gBrowser, url); + } + } + await BrowserTestUtils.closeWindow(win); + } + + function background() { + Promise.all([ + browser.sessions.getRecentlyClosed(), + browser.tabs.query({ active: true, currentWindow: true }), + ]).then(([recentlyClosed, tabs]) => { + browser.test.sendMessage("initialData", { + recentlyClosed, + currentWindowId: tabs[0].windowId, + }); + }); + + browser.test.onMessage.addListener((msg, filter) => { + if (msg == "check-sessions") { + browser.sessions.getRecentlyClosed(filter).then(recentlyClosed => { + browser.test.sendMessage("recentlyClosed", recentlyClosed); + }); + } + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["sessions", "tabs"], + }, + background, + }); + + // Open and close a window that will be ignored, to prove that we are removing previous entries + await openAndCloseWindow(); + + await extension.startup(); + + let { recentlyClosed, currentWindowId } = await extension.awaitMessage( + "initialData" + ); + recordInitialTimestamps(recentlyClosed.map(item => item.lastModified)); + + await openAndCloseWindow(); + extension.sendMessage("check-sessions"); + recentlyClosed = await extension.awaitMessage("recentlyClosed"); + checkRecentlyClosed( + recentlyClosed.filter(onlyNewItemsFilter), + 1, + currentWindowId + ); + + await openAndCloseWindow("about:config", ["about:robots", "about:mozilla"]); + extension.sendMessage("check-sessions"); + recentlyClosed = await extension.awaitMessage("recentlyClosed"); + // Check for multiple tabs in most recently closed window + is( + recentlyClosed[0].window.tabs.length, + 3, + "most recently closed window has the expected number of tabs" + ); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.com" + ); + BrowserTestUtils.removeTab(tab); + + tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.com" + ); + BrowserTestUtils.removeTab(tab); + + await openAndCloseWindow(); + extension.sendMessage("check-sessions"); + recentlyClosed = await extension.awaitMessage("recentlyClosed"); + let finalResult = recentlyClosed.filter(onlyNewItemsFilter); + checkRecentlyClosed(finalResult, 5, currentWindowId); + + isnot(finalResult[0].window, undefined, "first item is a window"); + is(finalResult[0].tab, undefined, "first item is not a tab"); + isnot(finalResult[1].tab, undefined, "second item is a tab"); + is(finalResult[1].window, undefined, "second item is not a window"); + isnot(finalResult[2].tab, undefined, "third item is a tab"); + is(finalResult[2].window, undefined, "third item is not a window"); + isnot(finalResult[3].window, undefined, "fourth item is a window"); + is(finalResult[3].tab, undefined, "fourth item is not a tab"); + isnot(finalResult[4].window, undefined, "fifth item is a window"); + is(finalResult[4].tab, undefined, "fifth item is not a tab"); + + // test with filter + extension.sendMessage("check-sessions", { maxResults: 2 }); + recentlyClosed = await extension.awaitMessage("recentlyClosed"); + checkRecentlyClosed( + recentlyClosed.filter(onlyNewItemsFilter), + 2, + currentWindowId + ); + + await extension.unload(); +}); + +add_task(async function test_sessions_get_recently_closed_navigated() { + function background() { + browser.sessions + .getRecentlyClosed({ maxResults: 1 }) + .then(recentlyClosed => { + let tab = recentlyClosed[0].window.tabs[0]; + browser.test.assertEq( + "http://example.com/", + tab.url, + "Tab in closed window has the expected url." + ); + browser.test.assertTrue( + tab.title.includes("mochitest index"), + "Tab in closed window has the expected title." + ); + browser.test.notifyPass("getRecentlyClosed with navigation"); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["sessions", "tabs"], + }, + background, + }); + + // Test with a window with navigation history. + let win = await BrowserTestUtils.openNewBrowserWindow(); + for (let url of ["about:robots", "about:mozilla", "http://example.com/"]) { + BrowserTestUtils.startLoadingURIString(win.gBrowser.selectedBrowser, url); + await BrowserTestUtils.browserLoaded(win.gBrowser.selectedBrowser); + } + + await BrowserTestUtils.closeWindow(win); + + await extension.startup(); + await extension.awaitFinish(); + await extension.unload(); +}); + +add_task( + async function test_sessions_get_recently_closed_empty_history_in_closed_window() { + function background() { + browser.sessions + .getRecentlyClosed({ maxResults: 1 }) + .then(recentlyClosed => { + let win = recentlyClosed[0].window; + browser.test.assertEq( + 3, + win.tabs.length, + "The closed window has 3 tabs." + ); + browser.test.assertEq( + "about:blank", + win.tabs[0].url, + "The first tab is about:blank." + ); + browser.test.assertFalse( + "url" in win.tabs[1], + "The second tab with empty.xpi has no url field due to empty history." + ); + browser.test.assertEq( + "http://example.com/", + win.tabs[2].url, + "The third tab is example.com." + ); + browser.test.notifyPass("getRecentlyClosed with empty history"); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["sessions", "tabs"], + }, + background, + }); + + // Test with a window with empty history. + let xpi = + "http://example.com/browser/browser/components/extensions/test/browser/empty.xpi"; + let newWin = await BrowserTestUtils.openNewBrowserWindow(); + await BrowserTestUtils.openNewForegroundTab({ + gBrowser: newWin.gBrowser, + url: xpi, + // A tab with broken xpi file doesn't finish loading. + waitForLoad: false, + }); + await BrowserTestUtils.openNewForegroundTab({ + gBrowser: newWin.gBrowser, + url: "http://example.com/", + }); + await BrowserTestUtils.closeWindow(newWin); + + await extension.startup(); + await extension.awaitFinish(); + await extension.unload(); + } +); diff --git a/browser/components/extensions/test/browser/browser_ext_sessions_getRecentlyClosed_private.js b/browser/components/extensions/test/browser/browser_ext_sessions_getRecentlyClosed_private.js new file mode 100644 index 0000000000..45b1b34be1 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_sessions_getRecentlyClosed_private.js @@ -0,0 +1,93 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +SimpleTest.requestCompleteLog(); + +loadTestSubscript("head_sessions.js"); + +async function run_test_extension(incognitoOverride) { + function background() { + browser.test.onMessage.addListener((msg, filter) => { + if (msg == "check-sessions") { + browser.sessions.getRecentlyClosed(filter).then(recentlyClosed => { + browser.test.sendMessage("recentlyClosed", recentlyClosed); + }); + } + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["sessions", "tabs"], + }, + background, + incognitoOverride, + }); + + // Open a private browsing window. + let privateWin = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + + await extension.startup(); + + const { + Management: { + global: { windowTracker }, + }, + } = ChromeUtils.importESModule("resource://gre/modules/Extension.sys.mjs"); + let privateWinId = windowTracker.getId(privateWin); + + extension.sendMessage("check-sessions"); + let recentlyClosed = await extension.awaitMessage("recentlyClosed"); + recordInitialTimestamps(recentlyClosed.map(item => item.lastModified)); + + // Open and close two tabs in the private window + let tab = await BrowserTestUtils.openNewForegroundTab( + privateWin.gBrowser, + "http://example.com" + ); + BrowserTestUtils.removeTab(tab); + + tab = await BrowserTestUtils.openNewForegroundTab( + privateWin.gBrowser, + "http://example.com" + ); + let sessionPromise = BrowserTestUtils.waitForSessionStoreUpdate(tab); + BrowserTestUtils.removeTab(tab); + await sessionPromise; + + extension.sendMessage("check-sessions"); + recentlyClosed = await extension.awaitMessage("recentlyClosed"); + let expectedCount = + !incognitoOverride || incognitoOverride == "not_allowed" ? 0 : 2; + checkRecentlyClosed( + recentlyClosed.filter(onlyNewItemsFilter), + expectedCount, + privateWinId, + true + ); + + // Close the private window. + await BrowserTestUtils.closeWindow(privateWin); + + extension.sendMessage("check-sessions"); + recentlyClosed = await extension.awaitMessage("recentlyClosed"); + is( + recentlyClosed.filter(onlyNewItemsFilter).length, + 0, + "the closed private window info was not found in recently closed data" + ); + + await extension.unload(); +} + +add_task(async function test_sessions_get_recently_closed_default() { + await run_test_extension(); +}); + +add_task(async function test_sessions_get_recently_closed_private_incognito() { + await run_test_extension("spanning"); + await run_test_extension("not_allowed"); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_sessions_getRecentlyClosed_tabs.js b/browser/components/extensions/test/browser/browser_ext_sessions_getRecentlyClosed_tabs.js new file mode 100644 index 0000000000..4a513f5131 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_sessions_getRecentlyClosed_tabs.js @@ -0,0 +1,292 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +function expectedTabInfo(tab, window) { + let browser = tab.linkedBrowser; + return { + url: browser.currentURI.spec, + title: browser.contentTitle, + favIconUrl: window.gBrowser.getIcon(tab) || undefined, + // 'selected' is marked as unsupported in schema, so we've removed it. + // For more details, see bug 1337509 + selected: undefined, + }; +} + +function checkTabInfo(expected, actual) { + for (let prop in expected) { + is( + actual[prop], + expected[prop], + `Expected value found for ${prop} of tab object.` + ); + } +} + +add_task(async function test_sessions_get_recently_closed_tabs() { + // Below, the test makes assumptions about the last accessed time of tabs that are + // not true is we execute fast and reduce the timer precision enough + await SpecialPowers.pushPrefEnv({ + set: [ + ["privacy.reduceTimerPrecision", false], + ["browser.navigation.requireUserInteraction", false], + ], + }); + + async function background() { + browser.test.onMessage.addListener(async msg => { + if (msg == "check-sessions") { + let recentlyClosed = await browser.sessions.getRecentlyClosed(); + browser.test.sendMessage("recentlyClosed", recentlyClosed); + } + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["sessions", "tabs"], + }, + background, + }); + + let win = await BrowserTestUtils.openNewBrowserWindow(); + let tabBrowser = win.gBrowser.selectedBrowser; + for (let url of ["about:robots", "about:mozilla", "about:config"]) { + BrowserTestUtils.startLoadingURIString(tabBrowser, url); + await BrowserTestUtils.browserLoaded(tabBrowser, false, url); + } + + // Ensure that getRecentlyClosed returns correct results after the back + // button has been used. + let goBackPromise = BrowserTestUtils.waitForLocationChange( + win.gBrowser, + "about:mozilla" + ); + tabBrowser.goBack(); + await goBackPromise; + + let expectedTabs = []; + let tab = win.gBrowser.selectedTab; + // Because there is debounce logic in FaviconLoader.sys.mjs to reduce the + // favicon loads, we have to wait some time before checking that icon was + // stored properly. If that page doesn't have favicon links, let it timeout. + try { + await BrowserTestUtils.waitForCondition( + () => { + return gBrowser.getIcon(tab) != null; + }, + "wait for favicon load to finish", + 100, + 5 + ); + } catch (e) { + // This page doesn't have any favicon link, just continue. + } + expectedTabs.push(expectedTabInfo(tab, win)); + let lastAccessedTimes = new Map(); + lastAccessedTimes.set("about:mozilla", tab.lastAccessed); + + for (let url of ["about:robots", "about:buildconfig"]) { + tab = await BrowserTestUtils.openNewForegroundTab(win.gBrowser, url); + try { + await BrowserTestUtils.waitForCondition( + () => { + return gBrowser.getIcon(tab) != null; + }, + "wait for favicon load to finish", + 100, + 5 + ); + } catch (e) { + // This page doesn't have any favicon link, just continue. + } + expectedTabs.push(expectedTabInfo(tab, win)); + lastAccessedTimes.set(url, tab.lastAccessed); + } + + await extension.startup(); + + let sessionUpdatePromise = BrowserTestUtils.waitForSessionStoreUpdate(tab); + // Test with a closed tab. + BrowserTestUtils.removeTab(tab); + await sessionUpdatePromise; + + extension.sendMessage("check-sessions"); + let recentlyClosed = await extension.awaitMessage("recentlyClosed"); + let tabInfo = recentlyClosed[0].tab; + let expectedTab = expectedTabs.pop(); + checkTabInfo(expectedTab, tabInfo); + Assert.greater( + tabInfo.lastAccessed, + lastAccessedTimes.get(tabInfo.url), + "lastAccessed has been updated" + ); + + // Test with a closed window containing tabs. + await BrowserTestUtils.closeWindow(win); + + extension.sendMessage("check-sessions"); + recentlyClosed = await extension.awaitMessage("recentlyClosed"); + let tabInfos = recentlyClosed[0].window.tabs; + is(tabInfos.length, 2, "Expected number of tabs in closed window."); + for (let x = 0; x < tabInfos.length; x++) { + checkTabInfo(expectedTabs[x], tabInfos[x]); + Assert.greater( + tabInfos[x].lastAccessed, + lastAccessedTimes.get(tabInfos[x].url), + "lastAccessed has been updated" + ); + } + + await extension.unload(); + + // Test without tabs and host permissions. + extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["sessions"], + }, + background, + }); + + await extension.startup(); + + extension.sendMessage("check-sessions"); + recentlyClosed = await extension.awaitMessage("recentlyClosed"); + tabInfos = recentlyClosed[0].window.tabs; + is(tabInfos.length, 2, "Expected number of tabs in closed window."); + for (let tabInfo of tabInfos) { + for (let prop in expectedTabs[0]) { + is( + undefined, + tabInfo[prop], + `${prop} of tab object is undefined without tabs permission.` + ); + } + } + + await extension.unload(); + + // Test with host permission. + win = await BrowserTestUtils.openNewBrowserWindow(); + tabBrowser = win.gBrowser.selectedBrowser; + BrowserTestUtils.startLoadingURIString( + tabBrowser, + "http://example.com/testpage" + ); + await BrowserTestUtils.browserLoaded( + tabBrowser, + false, + "http://example.com/testpage" + ); + tab = win.gBrowser.getTabForBrowser(tabBrowser); + try { + await BrowserTestUtils.waitForCondition( + () => { + return gBrowser.getIcon(tab) != null; + }, + "wait for favicon load to finish", + 100, + 5 + ); + } catch (e) { + // This page doesn't have any favicon link, just continue. + } + expectedTab = expectedTabInfo(tab, win); + await BrowserTestUtils.closeWindow(win); + + extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["sessions", "http://example.com/*"], + }, + background, + }); + await extension.startup(); + + extension.sendMessage("check-sessions"); + recentlyClosed = await extension.awaitMessage("recentlyClosed"); + tabInfo = recentlyClosed[0].window.tabs[0]; + checkTabInfo(expectedTab, tabInfo); + + await extension.unload(); +}); + +add_task( + async function test_sessions_get_recently_closed_for_loading_non_web_controlled_blank_page() { + info("Prepare extension that calls browser.sessions.getRecentlyClosed()"); + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["sessions", "tabs"], + }, + background: async () => { + browser.test.onMessage.addListener(async msg => { + if (msg == "check-sessions") { + let recentlyClosed = await browser.sessions.getRecentlyClosed(); + browser.test.sendMessage("recentlyClosed", recentlyClosed); + } + }); + }, + }); + + info( + "Open a page having a link for non web controlled page in _blank target" + ); + const testRoot = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://example.com" + ); + let url = `${testRoot}file_has_non_web_controlled_blank_page_link.html`; + let win = await BrowserTestUtils.openNewBrowserWindow(); + BrowserTestUtils.startLoadingURIString(win.gBrowser.selectedBrowser, url); + await BrowserTestUtils.browserLoaded( + win.gBrowser.selectedBrowser, + false, + url + ); + + info("Open the non web controlled page in _blank target"); + let onNewTabOpened = new Promise(resolve => + win.gBrowser.addTabsProgressListener({ + onStateChange(browser, webProgress, request, stateFlags, status) { + if (stateFlags & Ci.nsIWebProgressListener.STATE_START) { + win.gBrowser.removeTabsProgressListener(this); + resolve(win.gBrowser.getTabForBrowser(browser)); + } + }, + }) + ); + let targetUrl = await SpecialPowers.spawn( + win.gBrowser.selectedBrowser, + [], + () => { + const target = content.document.querySelector("a"); + EventUtils.synthesizeMouseAtCenter(target, {}, content); + return target.href; + } + ); + let tab = await onNewTabOpened; + + info("Remove tab while loading to get getRecentlyClosed()"); + await extension.startup(); + let sessionUpdatePromise = BrowserTestUtils.waitForSessionStoreUpdate(tab); + BrowserTestUtils.removeTab(tab); + await sessionUpdatePromise; + + info("Check the result of getRecentlyClosed()"); + extension.sendMessage("check-sessions"); + let recentlyClosed = await extension.awaitMessage("recentlyClosed"); + checkTabInfo( + { + index: 1, + url: targetUrl, + title: targetUrl, + favIconUrl: undefined, + selected: undefined, + }, + recentlyClosed[0].tab + ); + + await extension.unload(); + await BrowserTestUtils.closeWindow(win); + } +); diff --git a/browser/components/extensions/test/browser/browser_ext_sessions_incognito.js b/browser/components/extensions/test/browser/browser_ext_sessions_incognito.js new file mode 100644 index 0000000000..aecad9e8ec --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_sessions_incognito.js @@ -0,0 +1,113 @@ +"use strict"; + +add_task(async function test_sessions_tab_value_private() { + Services.obs.notifyObservers(null, "browser:purge-session-history"); + is( + SessionStore.getClosedWindowCount(), + 0, + "No closed window sessions at start of test" + ); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { + id: "exampleextension@mozilla.org", + }, + }, + permissions: ["sessions"], + }, + background() { + browser.test.onMessage.addListener(async (msg, pbw) => { + if (msg == "value") { + await browser.test.assertRejects( + browser.sessions.setWindowValue(pbw.windowId, "foo", "bar"), + /Invalid window ID/, + "should not be able to set incognito window session data" + ); + await browser.test.assertRejects( + browser.sessions.getWindowValue(pbw.windowId, "foo"), + /Invalid window ID/, + "should not be able to get incognito window session data" + ); + await browser.test.assertRejects( + browser.sessions.removeWindowValue(pbw.windowId, "foo"), + /Invalid window ID/, + "should not be able to remove incognito window session data" + ); + await browser.test.assertRejects( + browser.sessions.setTabValue(pbw.tabId, "foo", "bar"), + /Invalid tab ID/, + "should not be able to set incognito tab session data" + ); + await browser.test.assertRejects( + browser.sessions.getTabValue(pbw.tabId, "foo"), + /Invalid tab ID/, + "should not be able to get incognito tab session data" + ); + await browser.test.assertRejects( + browser.sessions.removeTabValue(pbw.tabId, "foo"), + /Invalid tab ID/, + "should not be able to remove incognito tab session data" + ); + } + if (msg == "restore") { + await browser.test.assertRejects( + browser.sessions.restore(), + /Could not restore object/, + "should not be able to restore incognito last window session data" + ); + if (pbw) { + await browser.test.assertRejects( + browser.sessions.restore(pbw.sessionId), + /Could not restore object/, + `should not be able to restore incognito session ID ${pbw.sessionId} session data` + ); + } + } + browser.test.sendMessage("done"); + }); + }, + }); + + let winData = await getIncognitoWindow("http://mochi.test:8888/"); + await extension.startup(); + + // Test value set/get APIs on a private window and tab. + extension.sendMessage("value", winData.details); + await extension.awaitMessage("done"); + + // Test restoring a private tab. + let tab = await BrowserTestUtils.openNewForegroundTab( + winData.win.gBrowser, + "http://example.com" + ); + let sessionUpdatePromise = BrowserTestUtils.waitForSessionStoreUpdate(tab); + BrowserTestUtils.removeTab(tab); + await sessionUpdatePromise; + let closedTabData = SessionStore.getClosedTabDataForWindow(winData.win); + + extension.sendMessage("restore", { + sessionId: String(closedTabData[0].closedId), + }); + await extension.awaitMessage("done"); + + // Test restoring a private window. + sessionUpdatePromise = BrowserTestUtils.waitForSessionStoreUpdate( + winData.win.gBrowser.selectedTab + ); + await BrowserTestUtils.closeWindow(winData.win); + await sessionUpdatePromise; + + is( + SessionStore.getClosedWindowCount(), + 0, + "The closed window was added to Recently Closed Windows" + ); + + // If the window gets restored, test will fail with an unclosed window. + extension.sendMessage("restore"); + await extension.awaitMessage("done"); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_sessions_restore.js b/browser/components/extensions/test/browser/browser_ext_sessions_restore.js new file mode 100644 index 0000000000..39057519ed --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_sessions_restore.js @@ -0,0 +1,234 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +SimpleTest.requestCompleteLog(); + +ChromeUtils.defineESModuleGetters(this, { + SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs", + TabStateFlusher: "resource:///modules/sessionstore/TabStateFlusher.sys.mjs", +}); + +add_task(async function test_sessions_restore() { + function background() { + let notificationCount = 0; + browser.sessions.onChanged.addListener(() => { + notificationCount++; + browser.test.sendMessage("notificationCount", notificationCount); + }); + browser.test.onMessage.addListener((msg, data) => { + if (msg == "check-sessions") { + browser.sessions.getRecentlyClosed().then(recentlyClosed => { + browser.test.sendMessage("recentlyClosed", recentlyClosed); + }); + } else if (msg == "restore") { + browser.sessions.restore(data).then(sessions => { + browser.test.sendMessage("restored", sessions); + }); + } else if (msg == "restore-reject") { + browser.sessions.restore("not-a-valid-session-id").then( + sessions => { + browser.test.fail("restore rejected with an invalid sessionId"); + }, + error => { + browser.test.assertTrue( + error.message.includes( + "Invalid sessionId: not-a-valid-session-id." + ) + ); + browser.test.sendMessage("restore-rejected"); + } + ); + } + }); + browser.test.sendMessage("ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["sessions", "tabs"], + }, + background, + }); + + async function assertNotificationCount(expected) { + let notificationCount = await extension.awaitMessage("notificationCount"); + is( + notificationCount, + expected, + "the expected number of notifications was fired" + ); + } + + await extension.startup(); + + const { + Management: { + global: { windowTracker, tabTracker }, + }, + } = ChromeUtils.importESModule("resource://gre/modules/Extension.sys.mjs"); + + function checkLocalTab(tab, expectedUrl) { + let realTab = tabTracker.getTab(tab.id); + let tabState = JSON.parse(SessionStore.getTabState(realTab)); + is( + tabState.entries[0].url, + expectedUrl, + "restored tab has the expected url" + ); + } + + await extension.awaitMessage("ready"); + + let win = await BrowserTestUtils.openNewBrowserWindow(); + BrowserTestUtils.startLoadingURIString( + win.gBrowser.selectedBrowser, + "about:config" + ); + await BrowserTestUtils.browserLoaded(win.gBrowser.selectedBrowser); + for (let url of ["about:robots", "about:mozilla"]) { + await BrowserTestUtils.openNewForegroundTab(win.gBrowser, url); + } + await BrowserTestUtils.closeWindow(win); + await assertNotificationCount(1); + + extension.sendMessage("check-sessions"); + let recentlyClosed = await extension.awaitMessage("recentlyClosed"); + + // Check that our expected window is the most recently closed. + is( + recentlyClosed[0].window.tabs.length, + 3, + "most recently closed window has the expected number of tabs" + ); + + // Restore the window. + extension.sendMessage("restore"); + await assertNotificationCount(2); + let restored = await extension.awaitMessage("restored"); + + is( + restored.window.tabs.length, + 3, + "restore returned a window with the expected number of tabs" + ); + checkLocalTab(restored.window.tabs[0], "about:config"); + checkLocalTab(restored.window.tabs[1], "about:robots"); + checkLocalTab(restored.window.tabs[2], "about:mozilla"); + + // Close the window again. + let window = windowTracker.getWindow(restored.window.id); + await BrowserTestUtils.closeWindow(window); + await assertNotificationCount(3); + + // Restore the window using the sessionId. + extension.sendMessage("check-sessions"); + recentlyClosed = await extension.awaitMessage("recentlyClosed"); + extension.sendMessage("restore", recentlyClosed[0].window.sessionId); + await assertNotificationCount(4); + restored = await extension.awaitMessage("restored"); + + is( + restored.window.tabs.length, + 3, + "restore returned a window with the expected number of tabs" + ); + checkLocalTab(restored.window.tabs[0], "about:config"); + checkLocalTab(restored.window.tabs[1], "about:robots"); + checkLocalTab(restored.window.tabs[2], "about:mozilla"); + + // Close the window again. + window = windowTracker.getWindow(restored.window.id); + await BrowserTestUtils.closeWindow(window); + // notificationCount = yield extension.awaitMessage("notificationCount"); + await assertNotificationCount(5); + + // Open and close a tab. + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:robots" + ); + await TabStateFlusher.flush(tab.linkedBrowser); + BrowserTestUtils.removeTab(tab); + await assertNotificationCount(6); + + // Restore the most recently closed item. + extension.sendMessage("restore"); + await assertNotificationCount(7); + restored = await extension.awaitMessage("restored"); + + tab = restored.tab; + ok(tab, "restore returned a tab"); + checkLocalTab(tab, "about:robots"); + + // Close the tab again. + let realTab = tabTracker.getTab(tab.id); + BrowserTestUtils.removeTab(realTab); + await assertNotificationCount(8); + + // Restore the tab using the sessionId. + extension.sendMessage("check-sessions"); + recentlyClosed = await extension.awaitMessage("recentlyClosed"); + extension.sendMessage("restore", recentlyClosed[0].tab.sessionId); + await assertNotificationCount(9); + restored = await extension.awaitMessage("restored"); + + tab = restored.tab; + ok(tab, "restore returned a tab"); + checkLocalTab(tab, "about:robots"); + + // Close the tab again. + realTab = tabTracker.getTab(tab.id); + BrowserTestUtils.removeTab(realTab); + await assertNotificationCount(10); + + // Try to restore something with an invalid sessionId. + extension.sendMessage("restore-reject"); + restored = await extension.awaitMessage("restore-rejected"); + + await extension.unload(); +}); + +add_task(async function test_sessions_event_page() { + await SpecialPowers.pushPrefEnv({ + set: [["extensions.eventPages.enabled", true]], + }); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + browser_specific_settings: { gecko: { id: "eventpage@sessions" } }, + permissions: ["sessions", "tabs"], + background: { persistent: false }, + }, + background() { + browser.sessions.onChanged.addListener(() => { + browser.test.sendMessage("changed"); + }); + browser.test.sendMessage("ready"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + + // test events waken background + await extension.terminateBackground(); + let win = await BrowserTestUtils.openNewBrowserWindow(); + BrowserTestUtils.startLoadingURIString( + win.gBrowser.selectedBrowser, + "about:config" + ); + await BrowserTestUtils.browserLoaded(win.gBrowser.selectedBrowser); + for (let url of ["about:robots", "about:mozilla"]) { + await BrowserTestUtils.openNewForegroundTab(win.gBrowser, url); + } + await BrowserTestUtils.closeWindow(win); + + await extension.awaitMessage("ready"); + await extension.awaitMessage("changed"); + ok(true, "persistent event woke background"); + + await extension.unload(); + await SpecialPowers.popPrefEnv(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_sessions_restoreTab.js b/browser/components/extensions/test/browser/browser_ext_sessions_restoreTab.js new file mode 100644 index 0000000000..679e1fbd6c --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_sessions_restoreTab.js @@ -0,0 +1,137 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs", +}); + +/** + This test checks that after closing an extension made tab it restores correctly. + The tab is given an expanded triggering principal and we didn't use to serialize + these correctly into session history. + */ + +// Check that we can restore a tab modified by an extension. +add_task(async function test_restoringModifiedTab() { + function background() { + browser.tabs.create({ url: "http://example.com/" }); + browser.test.onMessage.addListener((msg, filter) => { + if (msg == "change-tab") { + browser.tabs.executeScript({ code: 'location.href += "?changedTab";' }); + } + }); + } + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs", "<all_urls>"], + }, + browser_action: { + default_title: "Navigate current tab via content script", + }, + background, + }); + + const contentScriptTabURL = "http://example.com/?changedTab"; + + let win = await BrowserTestUtils.openNewBrowserWindow({}); + + // Open and close a tabs. + let tabPromise = BrowserTestUtils.waitForNewTab( + win.gBrowser, + "http://example.com/", + true + ); + await extension.startup(); + let firstTab = await tabPromise; + let locationChange = BrowserTestUtils.waitForLocationChange( + win.gBrowser, + contentScriptTabURL + ); + extension.sendMessage("change-tab"); + await locationChange; + is( + firstTab.linkedBrowser.currentURI.spec, + contentScriptTabURL, + "Got expected URL" + ); + + let sessionPromise = BrowserTestUtils.waitForSessionStoreUpdate(firstTab); + BrowserTestUtils.removeTab(firstTab); + await sessionPromise; + + tabPromise = BrowserTestUtils.waitForNewTab( + win.gBrowser, + contentScriptTabURL, + true + ); + SessionStore.undoCloseTab(win, 0); + let restoredTab = await tabPromise; + ok(restoredTab, "We returned a tab here"); + is( + restoredTab.linkedBrowser.currentURI.spec, + contentScriptTabURL, + "Got expected URL" + ); + + await extension.unload(); + BrowserTestUtils.removeTab(restoredTab); + + // Close the window. + await BrowserTestUtils.closeWindow(win); +}); + +add_task(async function test_restoringClosedTabWithTooLargeIndex() { + function background() { + browser.test.onMessage.addListener(async (msg, filter) => { + if (msg != "restoreTab") { + return; + } + const recentlyClosed = await browser.sessions.getRecentlyClosed({ + maxResults: 2, + }); + let tabWithTooLargeIndex; + for (const info of recentlyClosed) { + if (info.tab && info.tab.index > 1) { + tabWithTooLargeIndex = info.tab; + break; + } + } + const onRestored = tab => { + browser.tabs.onCreated.removeListener(onRestored); + browser.test.sendMessage("restoredTab", tab); + }; + browser.tabs.onCreated.addListener(onRestored); + browser.sessions.restore(tabWithTooLargeIndex.sessionId); + }); + } + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs", "sessions"], + }, + background, + }); + + const win = await BrowserTestUtils.openNewBrowserWindow({}); + const tabs = await Promise.all([ + BrowserTestUtils.openNewForegroundTab(win.gBrowser, "about:blank?0"), + BrowserTestUtils.openNewForegroundTab(win.gBrowser, "about:blank?1"), + ]); + const promsiedSessionStored = Promise.all([ + BrowserTestUtils.waitForSessionStoreUpdate(tabs[0]), + BrowserTestUtils.waitForSessionStoreUpdate(tabs[1]), + ]); + // Close the rightmost tab at first + BrowserTestUtils.removeTab(tabs[1]); + BrowserTestUtils.removeTab(tabs[0]); + await promsiedSessionStored; + + await extension.startup(); + const promisedRestoredTab = extension.awaitMessage("restoredTab"); + extension.sendMessage("restoreTab"); + const restoredTab = await promisedRestoredTab; + is(restoredTab.index, 1, "Got valid index"); + + await extension.unload(); + await BrowserTestUtils.closeWindow(win); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_sessions_restore_private.js b/browser/components/extensions/test/browser/browser_ext_sessions_restore_private.js new file mode 100644 index 0000000000..2eda77be45 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_sessions_restore_private.js @@ -0,0 +1,236 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +SimpleTest.requestCompleteLog(); +loadTestSubscript("head_sessions.js"); +const { TabStateFlusher } = ChromeUtils.importESModule( + "resource:///modules/sessionstore/TabStateFlusher.sys.mjs" +); + +async function openAndCloseTab(window, url) { + let tab = BrowserTestUtils.addTab(window.gBrowser, url); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser, true, url); + await TabStateFlusher.flush(tab.linkedBrowser); + let sessionUpdatePromise = BrowserTestUtils.waitForSessionStoreUpdate(tab); + BrowserTestUtils.removeTab(tab); + await sessionUpdatePromise; +} + +async function run_test_extension(incognitoOverride, testData) { + const initialURL = gBrowser.selectedBrowser.currentURI.spec; + // We'll be closing tabs from a private window and a non-private window and attempting + // to call session.restore() at each step. The goal is to compare the actual and + // expected outcome when the extension is/isn't configured for incognito window use. + + function background() { + browser.test.onMessage.addListener(async (msg, sessionId) => { + let result; + try { + result = await browser.sessions.restore(sessionId); + } catch (e) { + result = { error: e.message }; + } + browser.test.sendMessage("result", result); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["sessions", "tabs"], + }, + background, + incognitoOverride, + }); + await extension.startup(); + + // Open a private browsing window and with a non-empty tab + // (so we dont end up closing the window when the close the other tab) + let privateWin = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + await BrowserTestUtils.openNewForegroundTab( + privateWin.gBrowser, + testData.private.initialTabURL + ); + + // open and close a tab in the non-private window + await openAndCloseTab(window, testData.notPrivate.tabToClose); + + let { closedId: nonPrivateClosedTabId, pos: nonPrivateIndex } = + SessionStore.getClosedTabDataForWindow(window)[0]; + if (!testData.notPrivate.expected.error) { + testData.notPrivate.expected.index = nonPrivateIndex; + } + + // open and close a tab in the private window + info( + "open & close a tab in the private window with URL: " + + testData.private.tabToClose + ); + await openAndCloseTab(privateWin, testData.private.tabToClose); + let { pos: privateIndex } = + SessionStore.getClosedTabDataForWindow(privateWin)[0]; + if (!testData.private.expected.error) { + testData.private.expected.index = privateIndex; + } + + // focus the non-private window to ensure the outcome isn't just a side-effect of the + // private window being the top window + await SimpleTest.promiseFocus(window); + + // Try to restore the last-closed tab - which was private. + // If incognito access is allowed, we should successfully restore it to the private window. + // We pass no closedId so it should just try to restore the last closed tab + info("Sending 'restore' to attempt restore the closed private tab"); + extension.sendMessage("restore"); + let sessionStoreChanged = TestUtils.topicObserved( + "sessionstore-closed-objects-changed" + ); + let extResult = await extension.awaitMessage("result"); + let result = {}; + if (extResult.tab) { + await sessionStoreChanged; + // session.restore() was returning "about:blank" as the tab.url, + // we'll wait to ensure the correct URL eventually loads in the restored tab + await BrowserTestUtils.browserLoaded( + privateWin.gBrowser.selectedTab.linkedBrowser, + true, + testData.private.tabToClose + ); + // only keep the properties we want to compare + for (let pname of Object.keys(testData.private.expected)) { + result[pname] = extResult.tab[pname]; + } + result.url = privateWin.gBrowser.selectedTab.linkedBrowser.currentURI.spec; + } else { + // Trim off the sessionId value so we can easily equality-match on the result + result.error = extResult.error.replace(/sessionId\s+\d+/, "sessionId"); + } + Assert.deepEqual( + result, + testData.private.expected, + "Restoring the private tab didn't match expected result" + ); + + await SimpleTest.promiseFocus(privateWin); + + // Try to restore the last-closed tab in the non-private window + info("Sending 'restore' to restore the non-private tab"); + extension.sendMessage("restore", String(nonPrivateClosedTabId)); + sessionStoreChanged = TestUtils.topicObserved( + "sessionstore-closed-objects-changed" + ); + extResult = await extension.awaitMessage("result"); + result = {}; + + if (extResult.tab) { + await sessionStoreChanged; + await BrowserTestUtils.browserLoaded( + window.gBrowser.selectedTab.linkedBrowser, + true, + testData.notPrivate.tabToClose + ); + // only keep the properties we want to compare + for (let pname of Object.keys(testData.notPrivate.expected)) { + result[pname] = extResult.tab[pname]; + } + result.url = window.gBrowser.selectedTab.linkedBrowser.currentURI.spec; + } else { + // Trim off the sessionId value so we can easily equality-match on the result + result.error = extResult.error.replace(/sessionId\s+\d+/, "sessionId"); + } + Assert.deepEqual( + result, + testData.notPrivate.expected, + "Restoring the non-private tab didn't match expected result" + ); + + // Close the private window and cleanup + await BrowserTestUtils.closeWindow(privateWin); + for (let tab of gBrowser.tabs.filter( + tab => !tab.hidden && tab.linkedBrowser.currentURI.spec !== initialURL + )) { + await BrowserTestUtils.removeTab(tab); + } + await extension.unload(); +} + +const spanningTestData = { + private: { + initialTabURL: "https://example.com/", + tabToClose: "https://example.org/?private", + // restore should succeed when incognito is allowed + expected: { + url: "https://example.org/?private", + incognito: true, + }, + }, + notPrivate: { + initialTabURL: "https://example.com/", + tabToClose: "https://example.org/?notprivate", + expected: { + url: "https://example.org/?notprivate", + incognito: false, + }, + }, +}; + +add_task( + async function test_sessions_get_recently_closed_private_incognito_spanning() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.sessionstore.closedTabsFromAllWindows", true]], + }); + await run_test_extension("spanning", spanningTestData); + SpecialPowers.popPrefEnv(); + } +); +add_task( + async function test_sessions_get_recently_closed_private_incognito_spanning_pref_off() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.sessionstore.closedTabsFromAllWindows", false]], + }); + await run_test_extension("spanning", spanningTestData); + SpecialPowers.popPrefEnv(); + } +); + +const notAllowedTestData = { + private: { + initialTabURL: "https://example.com/", + tabToClose: "https://example.org/?private", + // this is expected to fail when incognito is not_allowed + expected: { + error: "Could not restore object using sessionId.", + }, + }, + notPrivate: { + // we'll open tabs for each URL + initialTabURL: "https://example.com/", + tabToClose: "https://example.org/?notprivate", + expected: { + url: "https://example.org/?notprivate", + incognito: false, + }, + }, +}; + +add_task( + async function test_sessions_get_recently_closed_private_incognito_not_allowed() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.sessionstore.closedTabsFromAllWindows", true]], + }); + await run_test_extension("not_allowed", notAllowedTestData); + SpecialPowers.popPrefEnv(); + } +); + +add_task( + async function test_sessions_get_recently_closed_private_incognito_not_allowed_pref_off() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.sessionstore.closedTabsFromAllWindows", false]], + }); + await run_test_extension("not_allowed", notAllowedTestData); + SpecialPowers.popPrefEnv(); + } +); diff --git a/browser/components/extensions/test/browser/browser_ext_sessions_window_tab_value.js b/browser/components/extensions/test/browser/browser_ext_sessions_window_tab_value.js new file mode 100644 index 0000000000..b21b59fe8c --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_sessions_window_tab_value.js @@ -0,0 +1,398 @@ +"use strict"; + +add_task(async function test_sessions_tab_value() { + info("Testing set/get/deleteTabValue."); + + async function background() { + let tests = [ + { key: "tabkey1", value: "Tab Value" }, + { key: "tabkey2", value: 25 }, + { key: "tabkey3", value: { val: "Tab Value" } }, + { + key: "tabkey4", + value: function () { + return null; + }, + }, + ]; + + async function test(params) { + let { key, value } = params; + let tabs = await browser.tabs.query({ + currentWindow: true, + active: true, + }); + let currentTabId = tabs[0].id; + + browser.sessions.setTabValue(currentTabId, key, value); + + let testValue1 = await browser.sessions.getTabValue(currentTabId, key); + let valueType = typeof value; + + browser.test.log( + `Test that setting, getting and deleting tab value behaves properly when value is type "${valueType}"` + ); + + if (valueType == "string") { + browser.test.assertEq( + value, + testValue1, + `Value for key '${key}' should be '${value}'.` + ); + browser.test.assertEq( + "string", + typeof testValue1, + "typeof value should be '${valueType}'." + ); + } else if (valueType == "number") { + browser.test.assertEq( + value, + testValue1, + `Value for key '${key}' should be '${value}'.` + ); + browser.test.assertEq( + "number", + typeof testValue1, + "typeof value should be '${valueType}'." + ); + } else if (valueType == "object") { + let innerVal1 = value.val; + let innerVal2 = testValue1.val; + browser.test.assertEq( + innerVal1, + innerVal2, + `Value for key '${key}' should be '${innerVal1}'.` + ); + } else if (valueType == "function") { + browser.test.assertEq( + null, + testValue1, + `Value for key '${key}' is non-JSON-able and should be 'null'.` + ); + } + + // Remove the tab key/value. + browser.sessions.removeTabValue(currentTabId, key); + + // This should now return undefined. + testValue1 = await browser.sessions.getTabValue(currentTabId, key); + browser.test.assertEq( + undefined, + testValue1, + `Key has been deleted and value for key "${key}" should be 'undefined'.` + ); + } + + for (let params of tests) { + await test(params); + } + + // Attempt to remove a non-existent key, should not throw error. + let tabs = await browser.tabs.query({ currentWindow: true, active: true }); + await browser.sessions.removeTabValue(tabs[0].id, "non-existent-key"); + browser.test.succeed( + "Attempting to remove a non-existent key should not fail." + ); + + browser.test.sendMessage("testComplete"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { + id: "exampleextension@mozilla.org", + }, + }, + permissions: ["sessions", "tabs"], + }, + background, + }); + + await extension.startup(); + + await extension.awaitMessage("testComplete"); + ok(true, "Testing completed for set/get/deleteTabValue."); + + await extension.unload(); +}); + +add_task(async function test_sessions_tab_value_persistence() { + info("Testing for persistence of set tab values."); + + async function background() { + let key = "tabkey1"; + let value1 = "Tab Value 1a"; + let value2 = "Tab Value 1b"; + + browser.test.log( + "Test that two different tabs hold different values for a given key." + ); + + await browser.tabs.create({ url: "http://example.com" }); + + // Wait until the newly created tab has completed loading or it will still have + // about:blank url when it gets removed and will not appear in the removed tabs history. + browser.webNavigation.onCompleted.addListener( + async function newTabListener(details) { + browser.webNavigation.onCompleted.removeListener(newTabListener); + + let tabs = await browser.tabs.query({ currentWindow: true }); + + let tabId_1 = tabs[0].id; + let tabId_2 = tabs[1].id; + + browser.sessions.setTabValue(tabId_1, key, value1); + browser.sessions.setTabValue(tabId_2, key, value2); + + let testValue1 = await browser.sessions.getTabValue(tabId_1, key); + let testValue2 = await browser.sessions.getTabValue(tabId_2, key); + + browser.test.assertEq( + value1, + testValue1, + `Value for key '${key}' should be '${value1}'.` + ); + browser.test.assertEq( + value2, + testValue2, + `Value for key '${key}' should be '${value2}'.` + ); + + browser.test.log( + "Test that value is copied to duplicated tab for a given key." + ); + + let duptab = await browser.tabs.duplicate(tabId_2); + let tabId_3 = duptab.id; + + let testValue3 = await browser.sessions.getTabValue(tabId_3, key); + + browser.test.assertEq( + value2, + testValue3, + `Value for key '${key}' should be '${value2}'.` + ); + + browser.test.log( + "Test that restored tab still holds the value for a given key." + ); + + await browser.tabs.remove([tabId_3]); + + let sessions = await browser.sessions.getRecentlyClosed({ + maxResults: 1, + }); + + let sessionData = await browser.sessions.restore( + sessions[0].tab.sessionId + ); + let restoredId = sessionData.tab.id; + + let testValue = await browser.sessions.getTabValue(restoredId, key); + + browser.test.assertEq( + value2, + testValue, + `Value for key '${key}' should be '${value2}'.` + ); + + await browser.tabs.remove(tabId_2); + await browser.tabs.remove(restoredId); + + browser.test.sendMessage("testComplete"); + }, + { url: [{ hostContains: "example.com" }] } + ); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { + id: "exampleextension@mozilla.org", + }, + }, + permissions: ["sessions", "tabs", "webNavigation"], + }, + background, + }); + + await extension.startup(); + + await extension.awaitMessage("testComplete"); + ok(true, "Testing completed for persistance of set tab values."); + + await extension.unload(); +}); + +add_task(async function test_sessions_window_value() { + info("Testing set/get/deleteWindowValue."); + + async function background() { + let tests = [ + { key: "winkey1", value: "Window Value" }, + { key: "winkey2", value: 25 }, + { key: "winkey3", value: { val: "Window Value" } }, + { + key: "winkey4", + value: function () { + return null; + }, + }, + ]; + + async function test(params) { + let { key, value } = params; + let win = await browser.windows.getCurrent(); + let currentWinId = win.id; + + browser.sessions.setWindowValue(currentWinId, key, value); + + let testValue1 = await browser.sessions.getWindowValue(currentWinId, key); + let valueType = typeof value; + + browser.test.log( + `Test that setting, getting and deleting window value behaves properly when value is type "${valueType}"` + ); + + if (valueType == "string") { + browser.test.assertEq( + value, + testValue1, + `Value for key '${key}' should be '${value}'.` + ); + browser.test.assertEq( + "string", + typeof testValue1, + "typeof value should be '${valueType}'." + ); + } else if (valueType == "number") { + browser.test.assertEq( + value, + testValue1, + `Value for key '${key}' should be '${value}'.` + ); + browser.test.assertEq( + "number", + typeof testValue1, + "typeof value should be '${valueType}'." + ); + } else if (valueType == "object") { + let innerVal1 = value.val; + let innerVal2 = testValue1.val; + browser.test.assertEq( + innerVal1, + innerVal2, + `Value for key '${key}' should be '${innerVal1}'.` + ); + } else if (valueType == "function") { + browser.test.assertEq( + null, + testValue1, + `Value for key '${key}' is non-JSON-able and should be 'null'.` + ); + } + + // Remove the window key/value. + browser.sessions.removeWindowValue(currentWinId, key); + + // This should return undefined as the key no longer exists. + testValue1 = await browser.sessions.getWindowValue(currentWinId, key); + browser.test.assertEq( + undefined, + testValue1, + `Key has been deleted and value for key '${key}' should be 'undefined'.` + ); + } + + for (let params of tests) { + await test(params); + } + + // Attempt to remove a non-existent key, should not throw error. + let win = await browser.windows.getCurrent(); + await browser.sessions.removeWindowValue(win.id, "non-existent-key"); + browser.test.succeed( + "Attempting to remove a non-existent key should not fail." + ); + + browser.test.sendMessage("testComplete"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { + id: "exampleextension@mozilla.org", + }, + }, + permissions: ["sessions"], + }, + background, + }); + + await extension.startup(); + + await extension.awaitMessage("testComplete"); + ok(true, "Testing completed for set/get/deleteWindowValue."); + + await extension.unload(); +}); + +add_task(async function test_sessions_window_value_persistence() { + info( + "Testing that different values for the same key in different windows are persisted properly." + ); + + async function background() { + let key = "winkey1"; + let value1 = "Window Value 1a"; + let value2 = "Window Value 1b"; + + let window1 = await browser.windows.getCurrent(); + let window2 = await browser.windows.create({}); + + let window1Id = window1.id; + let window2Id = window2.id; + + browser.sessions.setWindowValue(window1Id, key, value1); + browser.sessions.setWindowValue(window2Id, key, value2); + + let testValue1 = await browser.sessions.getWindowValue(window1Id, key); + let testValue2 = await browser.sessions.getWindowValue(window2Id, key); + + browser.test.assertEq( + value1, + testValue1, + `Value for key '${key}' should be '${value1}'.` + ); + browser.test.assertEq( + value2, + testValue2, + `Value for key '${key}' should be '${value2}'.` + ); + + await browser.windows.remove(window2Id); + browser.test.sendMessage("testComplete"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { + id: "exampleextension@mozilla.org", + }, + }, + permissions: ["sessions"], + }, + background, + }); + + await extension.startup(); + + await extension.awaitMessage("testComplete"); + ok(true, "Testing completed for persistance of set window values."); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_settings_overrides_default_search.js b/browser/components/extensions/test/browser/browser_ext_settings_overrides_default_search.js new file mode 100644 index 0000000000..74eaa6e634 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_settings_overrides_default_search.js @@ -0,0 +1,881 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", + AddonTestUtils: "resource://testing-common/AddonTestUtils.sys.mjs", + SearchTestUtils: "resource://testing-common/SearchTestUtils.sys.mjs", + TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs", +}); + +const EXTENSION1_ID = "extension1@mozilla.com"; +const EXTENSION2_ID = "extension2@mozilla.com"; +const DEFAULT_SEARCH_STORE_TYPE = "default_search"; +const DEFAULT_SEARCH_SETTING_NAME = "defaultSearch"; + +AddonTestUtils.initMochitest(this); +SearchTestUtils.init(this); + +const DEFAULT_ENGINE = { + id: "basic", + name: "basic", + loadPath: "[addon]basic@search.mozilla.org", + submissionUrl: + "https://mochi.test:8888/browser/browser/components/search/test/browser/?search=&foo=1", +}; +const ALTERNATE_ENGINE = { + id: "simple", + name: "Simple Engine", + loadPath: "[addon]simple@search.mozilla.org", + submissionUrl: "https://example.com/?sourceId=Mozilla-search&search=", +}; +const ALTERNATE2_ENGINE = { + id: "simple", + name: "another", + loadPath: "", + submissionUrl: "", +}; + +async function restoreDefaultEngine() { + let engine = Services.search.getEngineByName(DEFAULT_ENGINE.name); + await Services.search.setDefault( + engine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); +} + +function clearTelemetry() { + Services.telemetry.clearEvents(); + Services.fog.testResetFOG(); +} + +async function checkTelemetry(source, prevEngine, newEngine) { + TelemetryTestUtils.assertEvents( + [ + { + object: "change_default", + value: source, + extra: { + prev_id: prevEngine.id, + new_id: newEngine.id, + new_name: newEngine.name, + new_load_path: newEngine.loadPath, + // Telemetry has a limit of 80 characters. + new_sub_url: newEngine.submissionUrl.slice(0, 80), + }, + }, + ], + { category: "search", method: "engine" } + ); + + let snapshot = await Glean.searchEngineDefault.changed.testGetValue(); + delete snapshot[0].timestamp; + Assert.deepEqual( + snapshot[0], + { + category: "search.engine.default", + name: "changed", + extra: { + change_source: source, + previous_engine_id: prevEngine.id, + new_engine_id: newEngine.id, + new_display_name: newEngine.name, + new_load_path: newEngine.loadPath, + new_submission_url: newEngine.submissionUrl, + }, + }, + "Should have received the correct event details" + ); +} + +add_setup(async function () { + let searchExtensions = getChromeDir(getResolvedURI(gTestPath)); + searchExtensions.append("search-engines"); + + await SearchTestUtils.useMochitestEngines(searchExtensions); + + SearchTestUtils.useMockIdleService(); + let response = await fetch(`resource://search-extensions/engines.json`); + let json = await response.json(); + await SearchTestUtils.updateRemoteSettingsConfig(json.data); + + registerCleanupFunction(async () => { + let settingsWritten = SearchTestUtils.promiseSearchNotification( + "write-settings-to-disk-complete" + ); + await SearchTestUtils.updateRemoteSettingsConfig(); + await settingsWritten; + }); +}); + +/* This tests setting a default engine. */ +add_task(async function test_extension_setting_default_engine() { + clearTelemetry(); + + let ext1 = ExtensionTestUtils.loadExtension({ + manifest: { + chrome_settings_overrides: { + search_provider: { + name: ALTERNATE_ENGINE.name, + search_url: "https://example.com/?q={searchTerms}", + is_default: true, + }, + }, + }, + useAddonManager: "temporary", + }); + + await ext1.startup(); + await AddonTestUtils.waitForSearchProviderStartup(ext1); + + is( + (await Services.search.getDefault()).name, + ALTERNATE_ENGINE.name, + `Default engine is ${ALTERNATE_ENGINE.name}` + ); + + await checkTelemetry("addon-install", DEFAULT_ENGINE, ALTERNATE_ENGINE); + + clearTelemetry(); + + await ext1.unload(); + + is( + (await Services.search.getDefault()).name, + DEFAULT_ENGINE.name, + `Default engine is ${DEFAULT_ENGINE.name}` + ); + + await checkTelemetry("addon-uninstall", ALTERNATE_ENGINE, DEFAULT_ENGINE); +}); + +/* This tests what happens when the engine you're setting it to is hidden. */ +add_task(async function test_extension_setting_default_engine_hidden() { + let engine = Services.search.getEngineByName(ALTERNATE_ENGINE.name); + engine.hidden = true; + + let ext1 = ExtensionTestUtils.loadExtension({ + manifest: { + chrome_settings_overrides: { + search_provider: { + name: ALTERNATE_ENGINE.name, + search_url: "https://example.com/?q={searchTerms}", + is_default: true, + }, + }, + }, + useAddonManager: "temporary", + }); + + await ext1.startup(); + await AddonTestUtils.waitForSearchProviderStartup(ext1); + + is( + (await Services.search.getDefault()).name, + DEFAULT_ENGINE.name, + "Default engine should have remained as the default" + ); + is( + ExtensionSettingsStore.getSetting("default_search", "defaultSearch"), + null, + "The extension should not have been recorded as having set the default search" + ); + + await ext1.unload(); + + is( + (await Services.search.getDefault()).name, + DEFAULT_ENGINE.name, + `Default engine is ${DEFAULT_ENGINE.name}` + ); + engine.hidden = false; +}); + +// Test the popup displayed when trying to add a non-built-in default +// search engine. +add_task(async function test_extension_setting_default_engine_external() { + const NAME = "Example Engine"; + + // Load an extension that tries to set the default engine, + // and wait for the ensuing prompt. + async function startExtension(win = window) { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + icons: { + 48: "icon.png", + 96: "icon@2x.png", + }, + browser_specific_settings: { + gecko: { + id: EXTENSION1_ID, + }, + }, + chrome_settings_overrides: { + search_provider: { + name: NAME, + search_url: "https://example.com/?q={searchTerms}", + is_default: true, + }, + }, + }, + files: { + "icon.png": "", + "icon@2x.png": "", + }, + useAddonManager: "temporary", + }); + + let [panel] = await Promise.all([ + promisePopupNotificationShown("addon-webext-defaultsearch", win), + extension.startup(), + ]); + + isnot( + panel, + null, + "Doorhanger was displayed for non-built-in default engine" + ); + + return { panel, extension }; + } + + // First time around, don't accept the default engine. + let { panel, extension } = await startExtension(); + ok( + panel.getAttribute("icon").endsWith("/icon.png"), + "expected custom icon set on the notification" + ); + + panel.secondaryButton.click(); + + await TestUtils.topicObserved("webextension-defaultsearch-prompt-response"); + + is( + (await Services.search.getDefault()).name, + DEFAULT_ENGINE.name, + "Default engine was not changed after rejecting prompt" + ); + + await extension.unload(); + + clearTelemetry(); + + // Do it again, this time accept the prompt. + ({ panel, extension } = await startExtension()); + + panel.button.click(); + await TestUtils.topicObserved("webextension-defaultsearch-prompt-response"); + + is( + (await Services.search.getDefault()).name, + NAME, + "Default engine was changed after accepting prompt" + ); + + await checkTelemetry("addon-install", DEFAULT_ENGINE, { + id: "other-Example Engine", + name: "Example Engine", + loadPath: "[addon]extension1@mozilla.com", + submissionUrl: "https://example.com/?q=", + }); + clearTelemetry(); + + // Do this twice to make sure we're definitely handling disable/enable + // correctly. Disabling and enabling the addon here like this also + // replicates the behavior when an addon is added then removed in the + // blocklist. + let disabledPromise = awaitEvent("shutdown", EXTENSION1_ID); + let addon = await AddonManager.getAddonByID(EXTENSION1_ID); + await addon.disable(); + await disabledPromise; + + is( + (await Services.search.getDefault()).name, + DEFAULT_ENGINE.name, + `Default engine is ${DEFAULT_ENGINE.name} after disabling` + ); + + await checkTelemetry( + "addon-uninstall", + { + id: "other-Example Engine", + name: "Example Engine", + loadPath: "[addon]extension1@mozilla.com", + submissionUrl: "https://example.com/?q=", + }, + DEFAULT_ENGINE + ); + clearTelemetry(); + + let opened = promisePopupNotificationShown( + "addon-webext-defaultsearch", + window + ); + await addon.enable(); + panel = await opened; + panel.button.click(); + await TestUtils.topicObserved("webextension-defaultsearch-prompt-response"); + + is( + (await Services.search.getDefault()).name, + NAME, + `Default engine is ${NAME} after enabling` + ); + + await checkTelemetry("addon-install", DEFAULT_ENGINE, { + id: "other-Example Engine", + name: "Example Engine", + loadPath: "[addon]extension1@mozilla.com", + submissionUrl: "https://example.com/?q=", + }); + + await extension.unload(); + + is( + (await Services.search.getDefault()).name, + DEFAULT_ENGINE.name, + "Default engine is reverted after uninstalling extension." + ); + + // One more time, this time close the window where the prompt + // appears instead of explicitly accepting or denying it. + let win = await BrowserTestUtils.openNewBrowserWindow(); + await BrowserTestUtils.openNewForegroundTab(win.gBrowser, "about:blank"); + + ({ extension } = await startExtension(win)); + + await BrowserTestUtils.closeWindow(win); + + is( + (await Services.search.getDefault()).name, + DEFAULT_ENGINE.name, + "Default engine is unchanged when prompt is dismissed" + ); + + await extension.unload(); +}); + +/* This tests that uninstalling add-ons maintains the proper + * search default. */ +add_task(async function test_extension_setting_multiple_default_engine() { + let ext1 = ExtensionTestUtils.loadExtension({ + manifest: { + chrome_settings_overrides: { + search_provider: { + name: ALTERNATE_ENGINE.name, + search_url: "https://example.com/?q={searchTerms}", + is_default: true, + }, + }, + }, + useAddonManager: "temporary", + }); + + let ext2 = ExtensionTestUtils.loadExtension({ + manifest: { + chrome_settings_overrides: { + search_provider: { + name: ALTERNATE2_ENGINE.name, + search_url: "https://example.com/?q={searchTerms}", + is_default: true, + }, + }, + }, + useAddonManager: "temporary", + }); + + await ext1.startup(); + await AddonTestUtils.waitForSearchProviderStartup(ext1); + + is( + (await Services.search.getDefault()).name, + ALTERNATE_ENGINE.name, + `Default engine is ${ALTERNATE_ENGINE.name}` + ); + + await ext2.startup(); + await AddonTestUtils.waitForSearchProviderStartup(ext2); + + is( + (await Services.search.getDefault()).name, + ALTERNATE2_ENGINE.name, + `Default engine is ${ALTERNATE2_ENGINE.name}` + ); + + await ext2.unload(); + + is( + (await Services.search.getDefault()).name, + ALTERNATE_ENGINE.name, + `Default engine is ${ALTERNATE_ENGINE.name}` + ); + + await ext1.unload(); + + is( + (await Services.search.getDefault()).name, + DEFAULT_ENGINE.name, + `Default engine is ${DEFAULT_ENGINE.name}` + ); +}); + +/* This tests that uninstalling add-ons in reverse order maintains the proper + * search default. */ +add_task( + async function test_extension_setting_multiple_default_engine_reversed() { + let ext1 = ExtensionTestUtils.loadExtension({ + manifest: { + chrome_settings_overrides: { + search_provider: { + name: ALTERNATE_ENGINE.name, + search_url: "https://example.com/?q={searchTerms}", + is_default: true, + }, + }, + }, + useAddonManager: "temporary", + }); + + let ext2 = ExtensionTestUtils.loadExtension({ + manifest: { + chrome_settings_overrides: { + search_provider: { + name: ALTERNATE2_ENGINE.name, + search_url: "https://example.com/?q={searchTerms}", + is_default: true, + }, + }, + }, + useAddonManager: "temporary", + }); + + await ext1.startup(); + await AddonTestUtils.waitForSearchProviderStartup(ext1); + + is( + (await Services.search.getDefault()).name, + ALTERNATE_ENGINE.name, + `Default engine is ${ALTERNATE_ENGINE.name}` + ); + + await ext2.startup(); + await AddonTestUtils.waitForSearchProviderStartup(ext2); + + is( + (await Services.search.getDefault()).name, + ALTERNATE2_ENGINE.name, + `Default engine is ${ALTERNATE2_ENGINE.name}` + ); + + await ext1.unload(); + + is( + (await Services.search.getDefault()).name, + ALTERNATE2_ENGINE.name, + `Default engine is ${ALTERNATE2_ENGINE.name}` + ); + + await ext2.unload(); + + is( + (await Services.search.getDefault()).name, + DEFAULT_ENGINE.name, + `Default engine is ${DEFAULT_ENGINE.name}` + ); + } +); + +/* This tests that when the user changes the search engine and the add-on + * is unistalled, search stays with the user's choice. */ +add_task(async function test_user_changing_default_engine() { + let ext1 = ExtensionTestUtils.loadExtension({ + manifest: { + chrome_settings_overrides: { + search_provider: { + name: ALTERNATE_ENGINE.name, + search_url: "https://example.com/?q={searchTerms}", + is_default: true, + }, + }, + }, + useAddonManager: "temporary", + }); + + await ext1.startup(); + await AddonTestUtils.waitForSearchProviderStartup(ext1); + + is( + (await Services.search.getDefault()).name, + ALTERNATE_ENGINE.name, + `Default engine is ${ALTERNATE_ENGINE.name}` + ); + + let engine = Services.search.getEngineByName(ALTERNATE2_ENGINE.name); + await Services.search.setDefault( + engine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + // This simulates the preferences UI when the setting is changed. + ExtensionSettingsStore.select( + ExtensionSettingsStore.SETTING_USER_SET, + DEFAULT_SEARCH_STORE_TYPE, + DEFAULT_SEARCH_SETTING_NAME + ); + + await ext1.unload(); + + is( + (await Services.search.getDefault()).name, + ALTERNATE2_ENGINE.name, + `Default engine is ${ALTERNATE2_ENGINE.name}` + ); + restoreDefaultEngine(); +}); + +/* This tests that when the user changes the search engine while it is + * disabled, user choice is maintained when the add-on is reenabled. */ +add_task(async function test_user_change_with_disabling() { + let ext1 = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { + id: EXTENSION1_ID, + }, + }, + chrome_settings_overrides: { + search_provider: { + name: ALTERNATE_ENGINE.name, + search_url: "https://example.com/?q={searchTerms}", + is_default: true, + }, + }, + }, + useAddonManager: "temporary", + }); + + await ext1.startup(); + await AddonTestUtils.waitForSearchProviderStartup(ext1); + + is( + (await Services.search.getDefault()).name, + ALTERNATE_ENGINE.name, + `Default engine is ${ALTERNATE_ENGINE.name}` + ); + + let engine = Services.search.getEngineByName(ALTERNATE2_ENGINE.name); + await Services.search.setDefault( + engine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + // This simulates the preferences UI when the setting is changed. + ExtensionSettingsStore.select( + ExtensionSettingsStore.SETTING_USER_SET, + DEFAULT_SEARCH_STORE_TYPE, + DEFAULT_SEARCH_SETTING_NAME + ); + + is( + (await Services.search.getDefault()).name, + ALTERNATE2_ENGINE.name, + `Default engine is ${ALTERNATE2_ENGINE.name}` + ); + + let disabledPromise = awaitEvent("shutdown", EXTENSION1_ID); + let addon = await AddonManager.getAddonByID(EXTENSION1_ID); + await addon.disable(); + await disabledPromise; + + is( + (await Services.search.getDefault()).name, + ALTERNATE2_ENGINE.name, + `Default engine is ${ALTERNATE2_ENGINE.name}` + ); + + let processedPromise = awaitEvent("searchEngineProcessed", EXTENSION1_ID); + await addon.enable(); + await processedPromise; + + is( + (await Services.search.getDefault()).name, + ALTERNATE2_ENGINE.name, + `Default engine is ${ALTERNATE2_ENGINE.name}` + ); + await ext1.unload(); + await restoreDefaultEngine(); +}); + +/* This tests that when two add-ons are installed that change default + * search and the first one is disabled, before the second one is installed, + * when the first one is reenabled, the second add-on keeps the search. */ +add_task(async function test_two_addons_with_first_disabled_before_second() { + let ext1 = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { + id: EXTENSION1_ID, + }, + }, + chrome_settings_overrides: { + search_provider: { + name: ALTERNATE_ENGINE.name, + search_url: "https://example.com/?q={searchTerms}", + is_default: true, + }, + }, + }, + useAddonManager: "temporary", + }); + + let ext2 = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { + id: EXTENSION2_ID, + }, + }, + chrome_settings_overrides: { + search_provider: { + name: ALTERNATE2_ENGINE.name, + search_url: "https://example.com/?q={searchTerms}", + is_default: true, + }, + }, + }, + useAddonManager: "temporary", + }); + + await ext1.startup(); + await AddonTestUtils.waitForSearchProviderStartup(ext1); + + is( + (await Services.search.getDefault()).name, + ALTERNATE_ENGINE.name, + `Default engine is ${ALTERNATE_ENGINE.name}` + ); + + let disabledPromise = awaitEvent("shutdown", EXTENSION1_ID); + let addon1 = await AddonManager.getAddonByID(EXTENSION1_ID); + await addon1.disable(); + await disabledPromise; + + is( + (await Services.search.getDefault()).name, + DEFAULT_ENGINE.name, + `Default engine is ${DEFAULT_ENGINE.name}` + ); + + await ext2.startup(); + await AddonTestUtils.waitForSearchProviderStartup(ext2); + + is( + (await Services.search.getDefault()).name, + ALTERNATE2_ENGINE.name, + `Default engine is ${ALTERNATE2_ENGINE.name}` + ); + + let enabledPromise = awaitEvent("ready", EXTENSION1_ID); + await addon1.enable(); + await enabledPromise; + + is( + (await Services.search.getDefault()).name, + ALTERNATE2_ENGINE.name, + `Default engine is ${ALTERNATE2_ENGINE.name}` + ); + await ext2.unload(); + await ext1.unload(); + + is( + (await Services.search.getDefault()).name, + DEFAULT_ENGINE.name, + `Default engine is ${DEFAULT_ENGINE.name}` + ); +}); + +/* This tests that when two add-ons are installed that change default + * search and the first one is disabled, the second one maintains + * the search. */ +add_task(async function test_two_addons_with_first_disabled() { + let ext1 = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { + id: EXTENSION1_ID, + }, + }, + chrome_settings_overrides: { + search_provider: { + name: ALTERNATE_ENGINE.name, + search_url: "https://example.com/?q={searchTerms}", + is_default: true, + }, + }, + }, + useAddonManager: "temporary", + }); + + let ext2 = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { + id: EXTENSION2_ID, + }, + }, + chrome_settings_overrides: { + search_provider: { + name: ALTERNATE2_ENGINE.name, + search_url: "https://example.com/?q={searchTerms}", + is_default: true, + }, + }, + }, + useAddonManager: "temporary", + }); + + await ext1.startup(); + await AddonTestUtils.waitForSearchProviderStartup(ext1); + + is( + (await Services.search.getDefault()).name, + ALTERNATE_ENGINE.name, + `Default engine is ${ALTERNATE_ENGINE.name}` + ); + + await ext2.startup(); + await AddonTestUtils.waitForSearchProviderStartup(ext2); + + is( + (await Services.search.getDefault()).name, + ALTERNATE2_ENGINE.name, + `Default engine is ${ALTERNATE2_ENGINE.name}` + ); + + let disabledPromise = awaitEvent("shutdown", EXTENSION1_ID); + let addon1 = await AddonManager.getAddonByID(EXTENSION1_ID); + await addon1.disable(); + await disabledPromise; + + is( + (await Services.search.getDefault()).name, + ALTERNATE2_ENGINE.name, + `Default engine is ${ALTERNATE2_ENGINE.name}` + ); + + let enabledPromise = awaitEvent("ready", EXTENSION1_ID); + await addon1.enable(); + await enabledPromise; + + is( + (await Services.search.getDefault()).name, + ALTERNATE2_ENGINE.name, + `Default engine is ${ALTERNATE2_ENGINE.name}` + ); + await ext2.unload(); + await ext1.unload(); + + is( + (await Services.search.getDefault()).name, + DEFAULT_ENGINE.name, + `Default engine is ${DEFAULT_ENGINE.name}` + ); +}); + +/* This tests that when two add-ons are installed that change default + * search and the second one is disabled, the first one properly + * gets the search. */ +add_task(async function test_two_addons_with_second_disabled() { + let ext1 = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { + id: EXTENSION1_ID, + }, + }, + chrome_settings_overrides: { + search_provider: { + name: ALTERNATE_ENGINE.name, + search_url: "https://example.com/?q={searchTerms}", + is_default: true, + }, + }, + }, + useAddonManager: "temporary", + }); + + let ext2 = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { + id: EXTENSION2_ID, + }, + }, + chrome_settings_overrides: { + search_provider: { + name: ALTERNATE2_ENGINE.name, + search_url: "https://example.com/?q={searchTerms}", + is_default: true, + }, + }, + }, + useAddonManager: "temporary", + }); + + await ext1.startup(); + await AddonTestUtils.waitForSearchProviderStartup(ext1); + + is( + (await Services.search.getDefault()).name, + ALTERNATE_ENGINE.name, + `Default engine is ${ALTERNATE_ENGINE.name}` + ); + + await ext2.startup(); + await AddonTestUtils.waitForSearchProviderStartup(ext2); + + is( + (await Services.search.getDefault()).name, + ALTERNATE2_ENGINE.name, + `Default engine is ${ALTERNATE2_ENGINE.name}` + ); + + let disabledPromise = awaitEvent("shutdown", EXTENSION2_ID); + let addon2 = await AddonManager.getAddonByID(EXTENSION2_ID); + await addon2.disable(); + await disabledPromise; + + is( + (await Services.search.getDefault()).name, + ALTERNATE_ENGINE.name, + `Default engine is ${ALTERNATE_ENGINE.name}` + ); + + let defaultPromise = SearchTestUtils.promiseSearchNotification( + "engine-default", + "browser-search-engine-modified" + ); + // No prompt, because this is switching to an app-provided engine. + await addon2.enable(); + await defaultPromise; + + is( + (await Services.search.getDefault()).name, + ALTERNATE2_ENGINE.name, + `Default engine is ${ALTERNATE2_ENGINE.name}` + ); + await ext2.unload(); + + is( + (await Services.search.getDefault()).name, + ALTERNATE_ENGINE.name, + `Default engine is ${ALTERNATE_ENGINE.name}` + ); + await ext1.unload(); + + is( + (await Services.search.getDefault()).name, + DEFAULT_ENGINE.name, + `Default engine is ${DEFAULT_ENGINE.name}` + ); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_sidebarAction.js b/browser/components/extensions/test/browser/browser_ext_sidebarAction.js new file mode 100644 index 0000000000..afc1e4f9b9 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_sidebarAction.js @@ -0,0 +1,268 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +requestLongerTimeout(2); + +let extData = { + manifest: { + sidebar_action: { + default_panel: "sidebar.html", + }, + }, + useAddonManager: "temporary", + + files: { + "sidebar.html": ` + <!DOCTYPE html> + <html> + <head><meta charset="utf-8"/> + <script src="sidebar.js"></script> + </head> + <body> + A Test Sidebar + </body></html> + `, + + "sidebar.js": function () { + window.onload = () => { + browser.test.sendMessage("sidebar"); + }; + }, + }, + + background: function () { + browser.test.onMessage.addListener(async ({ msg, data }) => { + if (msg === "set-panel") { + await browser.sidebarAction.setPanel({ panel: null }); + browser.test.assertEq( + await browser.sidebarAction.getPanel({}), + browser.runtime.getURL("sidebar.html"), + "Global panel can be reverted to the default." + ); + } else if (msg === "isOpen") { + let { arg = {}, result } = data; + let isOpen = await browser.sidebarAction.isOpen(arg); + browser.test.assertEq(result, isOpen, "expected value from isOpen"); + } + browser.test.sendMessage("done"); + }); + }, +}; + +function getExtData(manifestUpdates = {}) { + return { + ...extData, + manifest: { + ...extData.manifest, + ...manifestUpdates, + }, + }; +} + +async function sendMessage(ext, msg, data = undefined) { + ext.sendMessage({ msg, data }); + await ext.awaitMessage("done"); +} + +add_task(async function sidebar_initial_install() { + ok( + document.getElementById("sidebar-box").hidden, + "sidebar box is not visible" + ); + let extension = ExtensionTestUtils.loadExtension(getExtData()); + await extension.startup(); + await extension.awaitMessage("sidebar"); + + // Test sidebar is opened on install + ok(!document.getElementById("sidebar-box").hidden, "sidebar box is visible"); + + await extension.unload(); + // Test that the sidebar was closed on unload. + ok( + document.getElementById("sidebar-box").hidden, + "sidebar box is not visible" + ); +}); + +add_task(async function sidebar__install_closed() { + ok( + document.getElementById("sidebar-box").hidden, + "sidebar box is not visible" + ); + let tempExtData = getExtData(); + tempExtData.manifest.sidebar_action.open_at_install = false; + let extension = ExtensionTestUtils.loadExtension(tempExtData); + await extension.startup(); + + // Test sidebar is closed on install + ok(document.getElementById("sidebar-box").hidden, "sidebar box is hidden"); + + await extension.unload(); + // This is the default value + tempExtData.manifest.sidebar_action.open_at_install = true; +}); + +add_task(async function sidebar_two_sidebar_addons() { + let extension2 = ExtensionTestUtils.loadExtension(getExtData()); + await extension2.startup(); + // Test sidebar is opened on install + await extension2.awaitMessage("sidebar"); + ok(!document.getElementById("sidebar-box").hidden, "sidebar box is visible"); + + // Test second sidebar install opens new sidebar + let extension3 = ExtensionTestUtils.loadExtension(getExtData()); + await extension3.startup(); + // Test sidebar is opened on install + await extension3.awaitMessage("sidebar"); + ok(!document.getElementById("sidebar-box").hidden, "sidebar box is visible"); + await extension3.unload(); + + // We just close the sidebar on uninstall of the current sidebar. + ok( + document.getElementById("sidebar-box").hidden, + "sidebar box is not visible" + ); + + await extension2.unload(); +}); + +add_task(async function sidebar_empty_panel() { + let extension = ExtensionTestUtils.loadExtension(getExtData()); + await extension.startup(); + // Test sidebar is opened on install + await extension.awaitMessage("sidebar"); + ok( + !document.getElementById("sidebar-box").hidden, + "sidebar box is visible in first window" + ); + await sendMessage(extension, "set-panel"); + await extension.unload(); +}); + +add_task(async function sidebar_isOpen() { + info("Load extension1"); + let extension1 = ExtensionTestUtils.loadExtension(getExtData()); + await extension1.startup(); + + info("Test extension1's sidebar is opened on install"); + await extension1.awaitMessage("sidebar"); + await sendMessage(extension1, "isOpen", { result: true }); + let sidebar1ID = SidebarUI.currentID; + + info("Load extension2"); + let extension2 = ExtensionTestUtils.loadExtension(getExtData()); + await extension2.startup(); + + info("Test extension2's sidebar is opened on install"); + await extension2.awaitMessage("sidebar"); + await sendMessage(extension1, "isOpen", { result: false }); + await sendMessage(extension2, "isOpen", { result: true }); + + info("Switch back to extension1's sidebar"); + SidebarUI.show(sidebar1ID); + await extension1.awaitMessage("sidebar"); + await sendMessage(extension1, "isOpen", { result: true }); + await sendMessage(extension2, "isOpen", { result: false }); + + info("Test passing a windowId parameter"); + let windowId = window.docShell.outerWindowID; + let WINDOW_ID_CURRENT = -2; + await sendMessage(extension1, "isOpen", { arg: { windowId }, result: true }); + await sendMessage(extension2, "isOpen", { arg: { windowId }, result: false }); + await sendMessage(extension1, "isOpen", { + arg: { windowId: WINDOW_ID_CURRENT }, + result: true, + }); + await sendMessage(extension2, "isOpen", { + arg: { windowId: WINDOW_ID_CURRENT }, + result: false, + }); + + info("Open a new window"); + open("", "", "noopener"); + let newWin = Services.wm.getMostRecentWindow("navigator:browser"); + + info("The new window has no sidebar"); + await sendMessage(extension1, "isOpen", { result: false }); + await sendMessage(extension2, "isOpen", { result: false }); + + info("But the original window still does"); + await sendMessage(extension1, "isOpen", { arg: { windowId }, result: true }); + await sendMessage(extension2, "isOpen", { arg: { windowId }, result: false }); + + info("Close the new window"); + newWin.close(); + + info("Close the sidebar in the original window"); + SidebarUI.hide(); + await sendMessage(extension1, "isOpen", { result: false }); + await sendMessage(extension2, "isOpen", { result: false }); + + await extension1.unload(); + await extension2.unload(); +}); + +add_task(async function testShortcuts() { + function verifyShortcut(id, commandKey) { + // We're just testing the command key since the modifiers have different + // icons on different platforms. + let menuitem = document.getElementById( + `sidebarswitcher_menu_${makeWidgetId(id)}-sidebar-action` + ); + ok(menuitem.hasAttribute("key"), "The menu item has a key specified"); + let key = document.getElementById(menuitem.getAttribute("key")); + ok(key, "The key attribute finds the related key element"); + ok( + menuitem.getAttribute("acceltext").endsWith(commandKey), + "The shortcut has the right key" + ); + } + + let extension1 = ExtensionTestUtils.loadExtension( + getExtData({ + commands: { + _execute_sidebar_action: { + suggested_key: { + default: "Ctrl+Shift+I", + }, + }, + }, + }) + ); + let extension2 = ExtensionTestUtils.loadExtension( + getExtData({ + commands: { + _execute_sidebar_action: { + suggested_key: { + default: "Ctrl+Shift+E", + }, + }, + }, + }) + ); + + await extension1.startup(); + await extension1.awaitMessage("sidebar"); + + // Open and close the switcher panel to trigger shortcut content rendering. + let switcherPanelShown = promisePopupShown(SidebarUI._switcherPanel); + SidebarUI.showSwitcherPanel(); + await switcherPanelShown; + let switcherPanelHidden = promisePopupHidden(SidebarUI._switcherPanel); + SidebarUI.hideSwitcherPanel(); + await switcherPanelHidden; + + // Test that the key is set for the extension after the shortcuts are rendered. + verifyShortcut(extension1.id, "I"); + + await extension2.startup(); + await extension2.awaitMessage("sidebar"); + + // Once the switcher panel has been opened new shortcuts should be added + // automatically. + verifyShortcut(extension2.id, "E"); + + await extension1.unload(); + await extension2.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_sidebarAction_browser_style.js b/browser/components/extensions/test/browser/browser_ext_sidebarAction_browser_style.js new file mode 100644 index 0000000000..866d7a3b3d --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_sidebarAction_browser_style.js @@ -0,0 +1,90 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +async function testSidebarBrowserStyle(sidebarAction, assertMessage) { + function sidebarScript() { + browser.test.onMessage.addListener((msgName, info, assertMessage) => { + if (msgName !== "check-style") { + browser.test.notifyFail("sidebar-browser-style"); + } + + let style = window.getComputedStyle(document.getElementById("button")); + let buttonBackgroundColor = style.backgroundColor; + let browserStyleBackgroundColor = "rgb(9, 150, 248)"; + if (!("browser_style" in info) || info.browser_style) { + browser.test.assertEq( + browserStyleBackgroundColor, + buttonBackgroundColor, + assertMessage + ); + } else { + browser.test.assertTrue( + browserStyleBackgroundColor !== buttonBackgroundColor, + assertMessage + ); + } + + browser.test.notifyPass("sidebar-browser-style"); + }); + browser.test.sendMessage("sidebar-ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + sidebar_action: sidebarAction, + }, + useAddonManager: "temporary", + + files: { + "panel.html": ` + <!DOCTYPE html> + <html> + <button id="button" name="button" class="browser-style default">Default</button> + <script src="panel.js" type="text/javascript"></script> + </html>`, + "panel.js": sidebarScript, + }, + }); + + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + + await extension.startup(); + await extension.awaitMessage("sidebar-ready"); + + extension.sendMessage("check-style", sidebarAction, assertMessage); + await extension.awaitFinish("sidebar-browser-style"); + + await extension.unload(); + + BrowserTestUtils.removeTab(tab); +} + +add_task(async function test_sidebar_without_setting_browser_style() { + await testSidebarBrowserStyle( + { + default_panel: "panel.html", + }, + "Expected correct style when browser_style is excluded" + ); +}); + +add_task(async function test_sidebar_with_browser_style_set_to_true() { + await testSidebarBrowserStyle( + { + default_panel: "panel.html", + browser_style: true, + }, + "Expected correct style when browser_style is set to `true`" + ); +}); + +add_task(async function test_sidebar_with_browser_style_set_to_false() { + await testSidebarBrowserStyle( + { + default_panel: "panel.html", + browser_style: false, + }, + "Expected no style when browser_style is set to `false`" + ); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_sidebarAction_click.js b/browser/components/extensions/test/browser/browser_ext_sidebarAction_click.js new file mode 100644 index 0000000000..621d2d1180 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_sidebarAction_click.js @@ -0,0 +1,74 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_sidebar_click_isAppTab_behavior() { + function sidebarScript() { + browser.tabs.onUpdated.addListener(function onUpdated( + tabId, + changeInfo, + tab + ) { + if ( + changeInfo.status == "complete" && + tab.url == "http://mochi.test:8888/" + ) { + browser.tabs.remove(tab.id); + browser.test.notifyPass("sidebar-click"); + } + }); + window.addEventListener( + "load", + () => { + browser.test.sendMessage("sidebar-ready"); + }, + { once: true } + ); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + sidebar_action: { + default_panel: "panel.html", + browser_style: false, + }, + permissions: ["tabs"], + }, + useAddonManager: "temporary", + + files: { + "panel.html": ` + <!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + </head> + <script src="panel.js" type="text/javascript"></script> + <a id="testlink" href="http://mochi.test:8888">Bugzilla</a> + </html>`, + "panel.js": sidebarScript, + }, + }); + + await extension.startup(); + await extension.awaitMessage("sidebar-ready"); + + // This test fails if docShell.isAppTab has not been set to true. + let content = SidebarUI.browser.contentWindow; + + // Wait for the layout to be flushed, otherwise this test may + // fail intermittently if synthesizeMouseAtCenter is being called + // while the sidebar is still opening and the browser window layout + // being recomputed. + await content.promiseDocumentFlushed(() => {}); + + info("Clicking link in extension sidebar"); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#testlink", + {}, + content.gBrowser.selectedBrowser + ); + await extension.awaitFinish("sidebar-click"); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_sidebarAction_context.js b/browser/components/extensions/test/browser/browser_ext_sidebarAction_context.js new file mode 100644 index 0000000000..7057037a5e --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_sidebarAction_context.js @@ -0,0 +1,683 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +async function runTests(options) { + async function background(getTests) { + async function checkDetails(expecting, details) { + let title = await browser.sidebarAction.getTitle(details); + browser.test.assertEq( + expecting.title, + title, + "expected value from getTitle in " + JSON.stringify(details) + ); + + let panel = await browser.sidebarAction.getPanel(details); + browser.test.assertEq( + expecting.panel, + panel, + "expected value from getPanel in " + JSON.stringify(details) + ); + } + + let tabs = []; + let windows = []; + let tests = getTests(tabs, windows); + + { + let tabId = 0xdeadbeef; + let calls = [ + () => browser.sidebarAction.setTitle({ tabId, title: "foo" }), + () => browser.sidebarAction.setIcon({ tabId, path: "foo.png" }), + () => browser.sidebarAction.setPanel({ tabId, panel: "foo.html" }), + ]; + + for (let call of calls) { + await browser.test.assertRejects( + new Promise(resolve => resolve(call())), + RegExp(`Invalid tab ID: ${tabId}`), + "Expected invalid tab ID error" + ); + } + } + + // Runs the next test in the `tests` array, checks the results, + // and passes control back to the outer test scope. + function nextTest() { + let test = tests.shift(); + + test(async (expectTab, expectWindow, expectGlobal, expectDefault) => { + expectGlobal = { ...expectDefault, ...expectGlobal }; + expectWindow = { ...expectGlobal, ...expectWindow }; + expectTab = { ...expectWindow, ...expectTab }; + + // Check that the API returns the expected values, and then + // run the next test. + let [{ windowId, id: tabId }] = await browser.tabs.query({ + active: true, + currentWindow: true, + }); + await checkDetails(expectTab, { tabId }); + await checkDetails(expectWindow, { windowId }); + await checkDetails(expectGlobal, {}); + + // Check that the actual icon has the expected values, then + // run the next test. + browser.test.sendMessage("nextTest", expectTab, windowId, tests.length); + }); + } + + browser.test.onMessage.addListener(msg => { + if (msg != "runNextTest") { + browser.test.fail("Expecting 'runNextTest' message"); + } + + nextTest(); + }); + + let [{ id, windowId }] = await browser.tabs.query({ + active: true, + currentWindow: true, + }); + tabs.push(id); + windows.push(windowId); + + browser.test.sendMessage("background-page-ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: options.manifest, + useAddonManager: "temporary", + + files: options.files || {}, + + background: `(${background})(${options.getTests})`, + }); + + let sidebarActionId; + function checkDetails(details, windowId) { + let { document } = Services.wm.getOuterWindowWithId(windowId); + if (!sidebarActionId) { + sidebarActionId = `${makeWidgetId(extension.id)}-sidebar-action`; + } + + let menuId = `menubar_menu_${sidebarActionId}`; + let menu = document.getElementById(menuId); + ok(menu, "menu exists"); + + let title = details.title || options.manifest.name; + + is(getListStyleImage(menu), details.icon, "icon URL is correct"); + is(menu.getAttribute("label"), title, "image label is correct"); + } + + let awaitFinish = new Promise(resolve => { + extension.onMessage("nextTest", (expecting, windowId, testsRemaining) => { + checkDetails(expecting, windowId); + + if (testsRemaining) { + extension.sendMessage("runNextTest"); + } else { + resolve(); + } + }); + }); + + // Wait for initial sidebar load. + SidebarUI.browser.addEventListener( + "load", + async () => { + // Wait for the background page listeners to be ready and + // then start the tests. + await extension.awaitMessage("background-page-ready"); + extension.sendMessage("runNextTest"); + }, + { capture: true, once: true } + ); + + await extension.startup(); + + await awaitFinish; + await extension.unload(); +} + +let sidebar = ` + <!DOCTYPE html> + <html> + <head><meta charset="utf-8"/></head> + <body> + A Test Sidebar + </body></html> +`; + +add_task(async function testTabSwitchContext() { + await runTests({ + manifest: { + sidebar_action: { + default_icon: "default.png", + default_panel: "__MSG_panel__", + default_title: "Default __MSG_title__", + }, + + default_locale: "en", + + permissions: ["tabs"], + }, + + files: { + "default.html": sidebar, + "global.html": sidebar, + "2.html": sidebar, + + "_locales/en/messages.json": { + panel: { + message: "default.html", + description: "Panel", + }, + + title: { + message: "Title", + description: "Title", + }, + }, + + "default.png": imageBuffer, + "global.png": imageBuffer, + "1.png": imageBuffer, + "2.png": imageBuffer, + }, + + getTests: function (tabs) { + let details = [ + { + icon: browser.runtime.getURL("default.png"), + panel: browser.runtime.getURL("default.html"), + title: "Default Title", + }, + { icon: browser.runtime.getURL("1.png") }, + { + icon: browser.runtime.getURL("2.png"), + panel: browser.runtime.getURL("2.html"), + title: "Title 2", + }, + { + icon: browser.runtime.getURL("global.png"), + panel: browser.runtime.getURL("global.html"), + title: "Global Title", + }, + { + icon: browser.runtime.getURL("1.png"), + panel: browser.runtime.getURL("2.html"), + }, + ]; + + return [ + async expect => { + browser.test.log("Initial state, expect default properties."); + + expect(null, null, null, details[0]); + }, + async expect => { + browser.test.log( + "Change the icon in the current tab. Expect default properties excluding the icon." + ); + await browser.sidebarAction.setIcon({ + tabId: tabs[0], + path: "1.png", + }); + + expect(details[1], null, null, details[0]); + }, + async expect => { + browser.test.log("Create a new tab. Expect default properties."); + let tab = await browser.tabs.create({ + active: true, + url: "about:blank?0", + }); + tabs.push(tab.id); + + expect(null, null, null, details[0]); + }, + async expect => { + browser.test.log("Change properties. Expect new properties."); + let tabId = tabs[1]; + await Promise.all([ + browser.sidebarAction.setIcon({ tabId, path: "2.png" }), + browser.sidebarAction.setPanel({ tabId, panel: "2.html" }), + browser.sidebarAction.setTitle({ tabId, title: "Title 2" }), + ]); + expect(details[2], null, null, details[0]); + }, + expect => { + browser.test.log("Navigate to a new page. Expect no changes."); + + // TODO: This listener should not be necessary, but the |tabs.update| + // callback currently fires too early in e10s windows. + browser.tabs.onUpdated.addListener(function listener(tabId, changed) { + if (tabId == tabs[1] && changed.url) { + browser.tabs.onUpdated.removeListener(listener); + expect(details[2], null, null, details[0]); + } + }); + + browser.tabs.update(tabs[1], { url: "about:blank?1" }); + }, + async expect => { + browser.test.log( + "Switch back to the first tab. Expect previously set properties." + ); + await browser.tabs.update(tabs[0], { active: true }); + expect(details[1], null, null, details[0]); + }, + async expect => { + browser.test.log( + "Change global values, expect those changes reflected." + ); + await Promise.all([ + browser.sidebarAction.setIcon({ path: "global.png" }), + browser.sidebarAction.setPanel({ panel: "global.html" }), + browser.sidebarAction.setTitle({ title: "Global Title" }), + ]); + + expect(details[1], null, details[3], details[0]); + }, + async expect => { + browser.test.log( + "Switch back to tab 2. Expect former tab values, and new global values from previous step." + ); + await browser.tabs.update(tabs[1], { active: true }); + + expect(details[2], null, details[3], details[0]); + }, + async expect => { + browser.test.log( + "Delete tab, switch back to tab 1. Expect previous results again." + ); + await browser.tabs.remove(tabs[1]); + expect(details[1], null, details[3], details[0]); + }, + async expect => { + browser.test.log("Create a new tab. Expect new global properties."); + let tab = await browser.tabs.create({ + active: true, + url: "about:blank?2", + }); + tabs.push(tab.id); + expect(null, null, details[3], details[0]); + }, + async expect => { + browser.test.log("Delete tab."); + await browser.tabs.remove(tabs[2]); + expect(details[1], null, details[3], details[0]); + }, + async expect => { + browser.test.log("Change tab panel."); + let tabId = tabs[0]; + await browser.sidebarAction.setPanel({ tabId, panel: "2.html" }); + expect(details[4], null, details[3], details[0]); + }, + async expect => { + browser.test.log("Revert tab panel."); + let tabId = tabs[0]; + await browser.sidebarAction.setPanel({ tabId, panel: null }); + expect(details[1], null, details[3], details[0]); + }, + ]; + }, + }); +}); + +add_task(async function testDefaultTitle() { + await runTests({ + manifest: { + name: "Foo Extension", + + sidebar_action: { + default_icon: "icon.png", + default_panel: "sidebar.html", + }, + + permissions: ["tabs"], + }, + + files: { + "sidebar.html": sidebar, + "icon.png": imageBuffer, + }, + + getTests: function (tabs) { + let details = [ + { + title: "Foo Extension", + panel: browser.runtime.getURL("sidebar.html"), + icon: browser.runtime.getURL("icon.png"), + }, + { title: "Foo Title" }, + { title: "Bar Title" }, + ]; + + return [ + async expect => { + browser.test.log("Initial state. Expect default extension title."); + + expect(null, null, null, details[0]); + }, + async expect => { + browser.test.log("Change the tab title. Expect new title."); + browser.sidebarAction.setTitle({ + tabId: tabs[0], + title: "Foo Title", + }); + + expect(details[1], null, null, details[0]); + }, + async expect => { + browser.test.log("Change the global title. Expect same properties."); + browser.sidebarAction.setTitle({ title: "Bar Title" }); + + expect(details[1], null, details[2], details[0]); + }, + async expect => { + browser.test.log("Clear the tab title. Expect new global title."); + browser.sidebarAction.setTitle({ tabId: tabs[0], title: null }); + + expect(null, null, details[2], details[0]); + }, + async expect => { + browser.test.log("Clear the global title. Expect default title."); + browser.sidebarAction.setTitle({ title: null }); + + expect(null, null, null, details[0]); + }, + async expect => { + browser.test.assertRejects( + browser.sidebarAction.setPanel({ panel: "about:addons" }), + /Access denied for URL about:addons/, + "unable to set panel to about:addons" + ); + + expect(null, null, null, details[0]); + }, + ]; + }, + }); +}); + +add_task(async function testPropertyRemoval() { + await runTests({ + manifest: { + name: "Foo Extension", + + sidebar_action: { + default_icon: "default.png", + default_panel: "default.html", + default_title: "Default Title", + }, + + permissions: ["tabs"], + }, + + files: { + "default.html": sidebar, + "global.html": sidebar, + "global2.html": sidebar, + "window.html": sidebar, + "tab.html": sidebar, + "default.png": imageBuffer, + "global.png": imageBuffer, + "global2.png": imageBuffer, + "window.png": imageBuffer, + "tab.png": imageBuffer, + }, + + getTests: function (tabs, windows) { + let defaultIcon = "chrome://mozapps/skin/extensions/extensionGeneric.svg"; + let details = [ + { + icon: browser.runtime.getURL("default.png"), + panel: browser.runtime.getURL("default.html"), + title: "Default Title", + }, + { + icon: browser.runtime.getURL("global.png"), + panel: browser.runtime.getURL("global.html"), + title: "global", + }, + { + icon: browser.runtime.getURL("window.png"), + panel: browser.runtime.getURL("window.html"), + title: "window", + }, + { + icon: browser.runtime.getURL("tab.png"), + panel: browser.runtime.getURL("tab.html"), + title: "tab", + }, + { icon: defaultIcon, title: "" }, + { + icon: browser.runtime.getURL("global2.png"), + panel: browser.runtime.getURL("global2.html"), + title: "global2", + }, + ]; + + return [ + async expect => { + browser.test.log("Initial state, expect default properties."); + expect(null, null, null, details[0]); + }, + async expect => { + browser.test.log("Set global values, expect the new values."); + browser.sidebarAction.setIcon({ path: "global.png" }); + browser.sidebarAction.setPanel({ panel: "global.html" }); + browser.sidebarAction.setTitle({ title: "global" }); + expect(null, null, details[1], details[0]); + }, + async expect => { + browser.test.log("Set window values, expect the new values."); + let windowId = windows[0]; + browser.sidebarAction.setIcon({ windowId, path: "window.png" }); + browser.sidebarAction.setPanel({ windowId, panel: "window.html" }); + browser.sidebarAction.setTitle({ windowId, title: "window" }); + expect(null, details[2], details[1], details[0]); + }, + async expect => { + browser.test.log("Set tab values, expect the new values."); + let tabId = tabs[0]; + browser.sidebarAction.setIcon({ tabId, path: "tab.png" }); + browser.sidebarAction.setPanel({ tabId, panel: "tab.html" }); + browser.sidebarAction.setTitle({ tabId, title: "tab" }); + expect(details[3], details[2], details[1], details[0]); + }, + async expect => { + browser.test.log("Set empty tab values."); + let tabId = tabs[0]; + browser.sidebarAction.setIcon({ tabId, path: "" }); + browser.sidebarAction.setPanel({ tabId, panel: "" }); + browser.sidebarAction.setTitle({ tabId, title: "" }); + expect(details[4], details[2], details[1], details[0]); + }, + async expect => { + browser.test.log("Remove tab values, expect window values."); + let tabId = tabs[0]; + browser.sidebarAction.setIcon({ tabId, path: null }); + browser.sidebarAction.setPanel({ tabId, panel: null }); + browser.sidebarAction.setTitle({ tabId, title: null }); + expect(null, details[2], details[1], details[0]); + }, + async expect => { + browser.test.log("Remove window values, expect global values."); + let windowId = windows[0]; + browser.sidebarAction.setIcon({ windowId, path: null }); + browser.sidebarAction.setPanel({ windowId, panel: null }); + browser.sidebarAction.setTitle({ windowId, title: null }); + expect(null, null, details[1], details[0]); + }, + async expect => { + browser.test.log("Change global values, expect the new values."); + browser.sidebarAction.setIcon({ path: "global2.png" }); + browser.sidebarAction.setPanel({ panel: "global2.html" }); + browser.sidebarAction.setTitle({ title: "global2" }); + expect(null, null, details[5], details[0]); + }, + async expect => { + browser.test.log("Remove global values, expect defaults."); + browser.sidebarAction.setIcon({ path: null }); + browser.sidebarAction.setPanel({ panel: null }); + browser.sidebarAction.setTitle({ title: null }); + expect(null, null, null, details[0]); + }, + ]; + }, + }); +}); + +add_task(async function testMultipleWindows() { + await runTests({ + manifest: { + name: "Foo Extension", + + sidebar_action: { + default_icon: "default.png", + default_panel: "default.html", + default_title: "Default Title", + }, + + permissions: ["tabs"], + }, + + files: { + "default.html": sidebar, + "window1.html": sidebar, + "window2.html": sidebar, + "default.png": imageBuffer, + "window1.png": imageBuffer, + "window2.png": imageBuffer, + }, + + getTests: function (tabs, windows) { + let details = [ + { + icon: browser.runtime.getURL("default.png"), + panel: browser.runtime.getURL("default.html"), + title: "Default Title", + }, + { + icon: browser.runtime.getURL("window1.png"), + panel: browser.runtime.getURL("window1.html"), + title: "window1", + }, + { + icon: browser.runtime.getURL("window2.png"), + panel: browser.runtime.getURL("window2.html"), + title: "window2", + }, + { title: "tab" }, + ]; + + return [ + async expect => { + browser.test.log("Initial state, expect default properties."); + expect(null, null, null, details[0]); + }, + async expect => { + browser.test.log("Set window values, expect the new values."); + let windowId = windows[0]; + browser.sidebarAction.setIcon({ windowId, path: "window1.png" }); + browser.sidebarAction.setPanel({ windowId, panel: "window1.html" }); + browser.sidebarAction.setTitle({ windowId, title: "window1" }); + expect(null, details[1], null, details[0]); + }, + async expect => { + browser.test.log("Create a new tab, expect window values."); + let tab = await browser.tabs.create({ active: true }); + tabs.push(tab.id); + expect(null, details[1], null, details[0]); + }, + async expect => { + browser.test.log("Set a tab title, expect it."); + await browser.sidebarAction.setTitle({ + tabId: tabs[1], + title: "tab", + }); + expect(details[3], details[1], null, details[0]); + }, + async expect => { + browser.test.log("Open a new window, expect default values."); + let { id } = await browser.windows.create(); + windows.push(id); + expect(null, null, null, details[0]); + }, + async expect => { + browser.test.log("Set window values, expect the new values."); + let windowId = windows[1]; + browser.sidebarAction.setIcon({ windowId, path: "window2.png" }); + browser.sidebarAction.setPanel({ windowId, panel: "window2.html" }); + browser.sidebarAction.setTitle({ windowId, title: "window2" }); + expect(null, details[2], null, details[0]); + }, + async expect => { + browser.test.log( + "Move tab from old window to the new one. Tab-specific data" + + " is preserved but inheritance is from the new window" + ); + await browser.tabs.move(tabs[1], { windowId: windows[1], index: -1 }); + await browser.tabs.update(tabs[1], { active: true }); + expect(details[3], details[2], null, details[0]); + }, + async expect => { + browser.test.log("Close the initial tab of the new window."); + let [{ id }] = await browser.tabs.query({ + windowId: windows[1], + index: 0, + }); + await browser.tabs.remove(id); + expect(details[3], details[2], null, details[0]); + }, + async expect => { + browser.test.log( + "Move the previous tab to a 3rd window, the 2nd one will close." + ); + await browser.windows.create({ tabId: tabs[1] }); + expect(details[3], null, null, details[0]); + }, + async expect => { + browser.test.log("Close the tab, go back to the 1st window."); + await browser.tabs.remove(tabs[1]); + expect(null, details[1], null, details[0]); + }, + async expect => { + browser.test.log( + "Assert failures for bad parameters. Expect no change" + ); + + let calls = { + setIcon: { path: "default.png" }, + setPanel: { panel: "default.html" }, + setTitle: { title: "Default Title" }, + getPanel: {}, + getTitle: {}, + }; + for (let [method, arg] of Object.entries(calls)) { + browser.test.assertThrows( + () => browser.sidebarAction[method]({ ...arg, windowId: -3 }), + /-3 is too small \(must be at least -2\)/, + method + " with invalid windowId" + ); + await browser.test.assertRejects( + browser.sidebarAction[method]({ + ...arg, + tabId: tabs[0], + windowId: windows[0], + }), + /Only one of tabId and windowId can be specified/, + method + " with both tabId and windowId" + ); + } + + expect(null, details[1], null, details[0]); + }, + ]; + }, + }); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_sidebarAction_contextMenu.js b/browser/components/extensions/test/browser/browser_ext_sidebarAction_contextMenu.js new file mode 100644 index 0000000000..3317e6b7e0 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_sidebarAction_contextMenu.js @@ -0,0 +1,133 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +let extData = { + manifest: { + permissions: ["contextMenus"], + sidebar_action: { + default_panel: "sidebar.html", + }, + }, + useAddonManager: "temporary", + + files: { + "sidebar.html": ` + <!DOCTYPE html> + <html> + <head><meta charset="utf-8"/> + <script src="sidebar.js"></script> + </head> + <body> + <span id="text">A Test Sidebar</span> + <img id="testimg" src="data:image/svg+xml,<svg></svg>" height="10" width="10"> + </body></html> + `, + + "sidebar.js": function () { + window.onload = () => { + browser.test.sendMessage("sidebar"); + }; + }, + }, + + background: function () { + browser.contextMenus.create({ + id: "clickme-page", + title: "Click me!", + contexts: ["all"], + onclick(info, tab) { + browser.test.sendMessage("menu-click", tab); + }, + }); + }, +}; + +let contextMenuItems = { + "context-sep-navigation": "hidden", + "context-viewsource": "", + "inspect-separator": "hidden", + "context-inspect": "hidden", + "context-inspect-a11y": "hidden", + "context-bookmarkpage": "hidden", +}; +if (AppConstants.platform == "macosx") { + contextMenuItems["context-back"] = "hidden"; + contextMenuItems["context-forward"] = "hidden"; + contextMenuItems["context-reload"] = "hidden"; + contextMenuItems["context-stop"] = "hidden"; +} else { + contextMenuItems["context-navigation"] = "hidden"; +} + +add_task(async function sidebar_contextmenu() { + let extension = ExtensionTestUtils.loadExtension(extData); + await extension.startup(); + // Test sidebar is opened on install + await extension.awaitMessage("sidebar"); + + let contentAreaContextMenu = await openContextMenuInSidebar(); + let item = contentAreaContextMenu.getElementsByAttribute( + "label", + "Click me!" + ); + is(item.length, 1, "contextMenu item for page was found"); + + item[0].click(); + await closeContextMenu(contentAreaContextMenu); + let tab = await extension.awaitMessage("menu-click"); + is( + tab, + null, + "tab argument is optional, and missing in clicks from sidebars" + ); + + await extension.unload(); +}); + +add_task(async function sidebar_contextmenu_hidden_items() { + let extension = ExtensionTestUtils.loadExtension(extData); + await extension.startup(); + // Test sidebar is opened on install + await extension.awaitMessage("sidebar"); + + let contentAreaContextMenu = await openContextMenuInSidebar("#text"); + + let item, state; + for (const itemID in contextMenuItems) { + item = contentAreaContextMenu.querySelector(`#${itemID}`); + state = contextMenuItems[itemID]; + + if (state !== "") { + ok(item[state], `${itemID} is ${state}`); + + if (state !== "hidden") { + ok(!item.hidden, `Disabled ${itemID} is not hidden`); + } + } else { + ok(!item.hidden, `${itemID} is not hidden`); + ok(!item.disabled, `${itemID} is not disabled`); + } + } + + await closeContextMenu(contentAreaContextMenu); + + await extension.unload(); +}); + +add_task(async function sidebar_image_contextmenu() { + let extension = ExtensionTestUtils.loadExtension(extData); + await extension.startup(); + // Test sidebar is opened on install + await extension.awaitMessage("sidebar"); + + let contentAreaContextMenu = await openContextMenuInSidebar("#testimg"); + + let item = contentAreaContextMenu.querySelector("#context-copyimage"); + ok(!item.hidden); + ok(!item.disabled); + + await closeContextMenu(contentAreaContextMenu); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_sidebarAction_httpAuth.js b/browser/components/extensions/test/browser/browser_ext_sidebarAction_httpAuth.js new file mode 100644 index 0000000000..d50d96b822 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_sidebarAction_httpAuth.js @@ -0,0 +1,72 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const { PromptTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PromptTestUtils.sys.mjs" +); + +add_task(async function sidebar_httpAuthPrompt() { + let data = { + manifest: { + permissions: ["https://example.com/*"], + sidebar_action: { + default_panel: "sidebar.html", + }, + }, + useAddonManager: "temporary", + files: { + "sidebar.html": ` + <!DOCTYPE html> + <html> + <head><meta charset="utf-8"/> + <script src="sidebar.js"></script> + </head> + <body> + A Test Sidebar + </body></html> + `, + "sidebar.js": function () { + fetch( + "https://example.com/browser/browser/components/extensions/test/browser/authenticate.sjs?user=user&pass=pass", + { credentials: "include" } + ).then(response => { + browser.test.sendMessage("fetchResult", response.ok); + }); + }, + }, + }; + + // Wait for the http auth prompt and close it with accept button. + let promptPromise = PromptTestUtils.handleNextPrompt( + SidebarUI.browser.contentWindow, + { + modalType: Services.prompt.MODAL_TYPE_WINDOW, + promptType: "promptUserAndPass", + }, + { buttonNumClick: 0, loginInput: "user", passwordInput: "pass" } + ); + + let extension = ExtensionTestUtils.loadExtension(data); + await extension.startup(); + let fetchResultPromise = extension.awaitMessage("fetchResult"); + + await promptPromise; + ok(true, "Extension fetch should trigger auth prompt."); + + let responseOk = await fetchResultPromise; + ok(responseOk, "Login should succeed."); + + await extension.unload(); + + // Cleanup + await new Promise(resolve => + Services.clearData.deleteData( + Ci.nsIClearDataService.CLEAR_AUTH_CACHE, + resolve + ) + ); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_sidebarAction_incognito.js b/browser/components/extensions/test/browser/browser_ext_sidebarAction_incognito.js new file mode 100644 index 0000000000..221447cf2e --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_sidebarAction_incognito.js @@ -0,0 +1,139 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_sidebarAction_not_allowed() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + sidebar_action: { + default_panel: "sidebar.html", + }, + }, + background() { + browser.test.onMessage.addListener(async pbw => { + await browser.test.assertRejects( + browser.sidebarAction.setTitle({ + windowId: pbw.windowId, + title: "test", + }), + /Invalid window ID/, + "should not be able to set title with windowId" + ); + await browser.test.assertRejects( + browser.sidebarAction.setTitle({ + tabId: pbw.tabId, + title: "test", + }), + /Invalid tab ID/, + "should not be able to set title" + ); + await browser.test.assertRejects( + browser.sidebarAction.getTitle({ + windowId: pbw.windowId, + }), + /Invalid window ID/, + "should not be able to get title with windowId" + ); + await browser.test.assertRejects( + browser.sidebarAction.getTitle({ + tabId: pbw.tabId, + }), + /Invalid tab ID/, + "should not be able to get title with tabId" + ); + + await browser.test.assertRejects( + browser.sidebarAction.setIcon({ + windowId: pbw.windowId, + path: "test", + }), + /Invalid window ID/, + "should not be able to set icon with windowId" + ); + await browser.test.assertRejects( + browser.sidebarAction.setIcon({ + tabId: pbw.tabId, + path: "test", + }), + /Invalid tab ID/, + "should not be able to set icon with tabId" + ); + + await browser.test.assertRejects( + browser.sidebarAction.setPanel({ + windowId: pbw.windowId, + panel: "test", + }), + /Invalid window ID/, + "should not be able to set panel with windowId" + ); + await browser.test.assertRejects( + browser.sidebarAction.setPanel({ + tabId: pbw.tabId, + panel: "test", + }), + /Invalid tab ID/, + "should not be able to set panel with tabId" + ); + await browser.test.assertRejects( + browser.sidebarAction.getPanel({ + windowId: pbw.windowId, + }), + /Invalid window ID/, + "should not be able to get panel with windowId" + ); + await browser.test.assertRejects( + browser.sidebarAction.getPanel({ + tabId: pbw.tabId, + }), + /Invalid tab ID/, + "should not be able to get panel with tabId" + ); + + await browser.test.assertRejects( + browser.sidebarAction.isOpen({ + windowId: pbw.windowId, + }), + /Invalid window ID/, + "should not be able to determine openness with windowId" + ); + + browser.test.notifyPass("pass"); + }); + }, + files: { + "sidebar.html": ` + <!DOCTYPE html> + <html> + <head><meta charset="utf-8"/> + <script src="sidebar.js"></script> + </head> + <body> + A Test Sidebar + </body> + </html> + `, + + "sidebar.js": function () { + window.onload = () => { + browser.test.sendMessage("sidebar"); + }; + }, + }, + }); + + await extension.startup(); + let sidebarID = `${makeWidgetId(extension.id)}-sidebar-action`; + ok(SidebarUI.sidebars.has(sidebarID), "sidebar exists in non-private window"); + + let winData = await getIncognitoWindow(); + + let hasSidebar = winData.win.SidebarUI.sidebars.has(sidebarID); + ok(!hasSidebar, "sidebar does not exist in private window"); + // Test API access to private window data. + extension.sendMessage(winData.details); + await extension.awaitFinish("pass"); + + await BrowserTestUtils.closeWindow(winData.win); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_sidebarAction_runtime.js b/browser/components/extensions/test/browser/browser_ext_sidebarAction_runtime.js new file mode 100644 index 0000000000..55c83ee0b1 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_sidebarAction_runtime.js @@ -0,0 +1,76 @@ +"use strict"; + +function background() { + browser.runtime.onConnect.addListener(port => { + browser.test.assertEq(port.name, "ernie", "port name correct"); + port.onDisconnect.addListener(() => { + browser.test.assertEq( + null, + port.error, + "The port is implicitly closed without errors when the other context unloads" + ); + port.disconnect(); + browser.test.sendMessage("disconnected"); + }); + browser.test.sendMessage("connected"); + }); +} + +let extensionData = { + background, + manifest: { + sidebar_action: { + default_panel: "sidebar.html", + }, + }, + useAddonManager: "temporary", + + files: { + "sidebar.html": ` + <!DOCTYPE html> + <html> + <head><meta charset="utf-8"/> + <script src="sidebar.js"></script> + </head> + <body> + A Test Sidebar + </body></html> + `, + + "sidebar.js": function () { + window.onload = () => { + browser.runtime.connect({ name: "ernie" }); + }; + }, + }, +}; + +add_task(async function test_sidebar_disconnect() { + let extension = ExtensionTestUtils.loadExtension(extensionData); + let connected = extension.awaitMessage("connected"); + await extension.startup(); + await connected; + + // Bug 1445080 fixes currentURI, test to avoid future breakage. + let currentURI = window.SidebarUI.browser.contentDocument.getElementById( + "webext-panels-browser" + ).currentURI; + is(currentURI.scheme, "moz-extension", "currentURI is set correctly"); + + // switching sidebar to another extension + let extension2 = ExtensionTestUtils.loadExtension(extensionData); + let switched = Promise.all([ + extension.awaitMessage("disconnected"), + extension2.awaitMessage("connected"), + ]); + await extension2.startup(); + await switched; + + // switching sidebar to built-in sidebar + let disconnected = extension2.awaitMessage("disconnected"); + window.SidebarUI.show("viewBookmarksSidebar"); + await disconnected; + + await extension.unload(); + await extension2.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_sidebarAction_tabs.js b/browser/components/extensions/test/browser/browser_ext_sidebarAction_tabs.js new file mode 100644 index 0000000000..7af75cdc19 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_sidebarAction_tabs.js @@ -0,0 +1,48 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function sidebar_tab_query_bug_1340739() { + let data = { + manifest: { + permissions: ["tabs"], + sidebar_action: { + default_panel: "sidebar.html", + }, + }, + useAddonManager: "temporary", + files: { + "sidebar.html": ` + <!DOCTYPE html> + <html> + <head><meta charset="utf-8"/> + <script src="sidebar.js"></script> + </head> + <body> + A Test Sidebar + </body></html> + `, + "sidebar.js": function () { + Promise.all([ + browser.tabs.query({}).then(tabs => { + browser.test.assertEq( + 1, + tabs.length, + "got tab without currentWindow" + ); + }), + browser.tabs.query({ currentWindow: true }).then(tabs => { + browser.test.assertEq(1, tabs.length, "got tab with currentWindow"); + }), + ]).then(() => { + browser.test.sendMessage("sidebar"); + }); + }, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(data); + await extension.startup(); + await extension.awaitMessage("sidebar"); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_sidebarAction_windows.js b/browser/components/extensions/test/browser/browser_ext_sidebarAction_windows.js new file mode 100644 index 0000000000..58f2b07797 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_sidebarAction_windows.js @@ -0,0 +1,69 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +let extData = { + manifest: { + sidebar_action: { + default_panel: "sidebar.html", + }, + }, + useAddonManager: "temporary", + + files: { + "sidebar.html": ` + <!DOCTYPE html> + <html> + <head><meta charset="utf-8"/> + <script src="sidebar.js"></script> + </head> + <body> + A Test Sidebar + </body></html> + `, + + "sidebar.js": function () { + window.onload = () => { + browser.test.sendMessage("sidebar"); + }; + }, + }, +}; + +add_task(async function sidebar_windows() { + let extension = ExtensionTestUtils.loadExtension(extData); + await extension.startup(); + // Test sidebar is opened on install + await extension.awaitMessage("sidebar"); + ok( + !document.getElementById("sidebar-box").hidden, + "sidebar box is visible in first window" + ); + // Check that the menuitem has our image styling. + let elements = document.getElementsByClassName("webextension-menuitem"); + // ui is in flux, at time of writing we potentially have 3 menuitems, later + // it may be two or one, just make sure one is there. + ok(!!elements.length, "have a menuitem"); + let style = elements[0].getAttribute("style"); + ok(style.includes("webextension-menuitem-image"), "this menu has style"); + + let secondSidebar = extension.awaitMessage("sidebar"); + + // SidebarUI relies on window.opener being set, which is normal behavior when + // using menu or key commands to open a new browser window. + let win = await BrowserTestUtils.openNewBrowserWindow(); + + await secondSidebar; + ok( + !win.document.getElementById("sidebar-box").hidden, + "sidebar box is visible in second window" + ); + // Check that the menuitem has our image styling. + elements = win.document.getElementsByClassName("webextension-menuitem"); + ok(!!elements.length, "have a menuitem"); + style = elements[0].getAttribute("style"); + ok(style.includes("webextension-menuitem-image"), "this menu has style"); + + await extension.unload(); + await BrowserTestUtils.closeWindow(win); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_sidebar_requestPermission.js b/browser/components/extensions/test/browser/browser_ext_sidebar_requestPermission.js new file mode 100644 index 0000000000..393efcf99e --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_sidebar_requestPermission.js @@ -0,0 +1,43 @@ +"use strict"; + +add_task(async function test_sidebar_requestPermission_resolve() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + sidebar_action: { + default_panel: "panel.html", + browser_style: false, + }, + optional_permissions: ["tabs"], + }, + useAddonManager: "temporary", + files: { + "panel.html": `<meta charset="utf-8"><script src="panel.js"></script>`, + "panel.js": async () => { + const success = await new Promise(resolve => { + browser.test.withHandlingUserInput(() => { + resolve( + browser.permissions.request({ + permissions: ["tabs"], + }) + ); + }); + }); + browser.test.assertTrue( + success, + "browser.permissions.request promise resolves" + ); + browser.test.sendMessage("done"); + }, + }, + }); + + const requestPrompt = promisePopupNotificationShown( + "addon-webext-permissions" + ).then(panel => { + panel.button.click(); + }); + await extension.startup(); + await requestPrompt; + await extension.awaitMessage("done"); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_simple.js b/browser/components/extensions/test/browser/browser_ext_simple.js new file mode 100644 index 0000000000..4d9d7c73fa --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_simple.js @@ -0,0 +1,60 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_simple() { + let extensionData = { + manifest: { + name: "Simple extension test", + version: "1.0", + manifest_version: 2, + description: "", + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + info("load complete"); + await extension.startup(); + info("startup complete"); + await extension.unload(); + info("extension unloaded successfully"); +}); + +add_task(async function test_background() { + function backgroundScript() { + browser.test.log("running background script"); + + browser.test.onMessage.addListener((x, y) => { + browser.test.assertEq(x, 10, "x is 10"); + browser.test.assertEq(y, 20, "y is 20"); + + browser.test.notifyPass("background test passed"); + }); + + browser.test.sendMessage("running", 1); + } + + let extensionData = { + background: "(" + backgroundScript.toString() + ")()", + manifest: { + name: "Simple extension test", + version: "1.0", + manifest_version: 2, + description: "", + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + info("load complete"); + let [, x] = await Promise.all([ + extension.startup(), + extension.awaitMessage("running"), + ]); + is(x, 1, "got correct value from extension"); + info("startup complete"); + extension.sendMessage(10, 20); + await extension.awaitFinish(); + info("test complete"); + await extension.unload(); + info("extension unloaded successfully"); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_slow_script.js b/browser/components/extensions/test/browser/browser_ext_slow_script.js new file mode 100644 index 0000000000..bd9369a904 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_slow_script.js @@ -0,0 +1,72 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const DEFAULT_PROCESS_COUNT = Services.prefs + .getDefaultBranch(null) + .getIntPref("dom.ipc.processCount"); + +add_task(async function test_slow_content_script() { + // Make sure we get a new process for our tab, or our reportProcessHangs + // preferences value won't apply to it. + await SpecialPowers.pushPrefEnv({ + set: [ + ["dom.ipc.processCount", 1], + ["dom.ipc.keepProcessesAlive.web", 0], + ], + }); + await SpecialPowers.popPrefEnv(); + + await SpecialPowers.pushPrefEnv({ + set: [ + ["dom.ipc.processCount", DEFAULT_PROCESS_COUNT * 2], + ["dom.ipc.processPrelaunch.enabled", false], + ["dom.ipc.reportProcessHangs", true], + ["dom.max_script_run_time.require_critical_input", false], + ], + }); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + + manifest: { + name: "Slow Script Extension", + + content_scripts: [ + { + matches: ["http://example.com/"], + js: ["content.js"], + }, + ], + }, + + files: { + "content.js": function () { + while (true) { + // Busy wait. + } + }, + }, + }); + + await extension.startup(); + + let alert = BrowserTestUtils.waitForGlobalNotificationBar( + window, + "process-hang" + ); + + BrowserTestUtils.openNewForegroundTab(gBrowser, "http://example.com/"); + + let notification = await alert; + let text = notification.messageText.textContent; + + ok(text.includes("\u201cSlow Script Extension\u201d"), "Label is correct"); + + let stopButton = notification.buttonContainer.querySelector("[label='Stop']"); + stopButton.click(); + + BrowserTestUtils.removeTab(gBrowser.selectedTab); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tab_runtimeConnect.js b/browser/components/extensions/test/browser/browser_ext_tab_runtimeConnect.js new file mode 100644 index 0000000000..622916edda --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tab_runtimeConnect.js @@ -0,0 +1,100 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function () { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://mochi.test:8888/" + ); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + background: function () { + let messages_received = []; + + let tabId; + + browser.runtime.onConnect.addListener(port => { + browser.test.assertTrue(!!port, "tab to background port received"); + browser.test.assertEq( + "tab-connection-name", + port.name, + "port name should be defined and equal to connectInfo.name" + ); + browser.test.assertTrue( + !!port.sender.tab, + "port.sender.tab should be defined" + ); + browser.test.assertEq( + tabId, + port.sender.tab.id, + "port.sender.tab.id should be equal to the expected tabId" + ); + + port.onMessage.addListener(msg => { + messages_received.push(msg); + + if (messages_received.length == 1) { + browser.test.assertEq( + "tab to background port message", + msg, + "'tab to background' port message received" + ); + port.postMessage("background to tab port message"); + } + + if (messages_received.length == 2) { + browser.test.assertTrue( + !!msg.tabReceived, + "'background to tab' reply port message received" + ); + browser.test.assertEq( + "background to tab port message", + msg.tabReceived, + "reply port content contains the message received" + ); + + browser.test.notifyPass("tabRuntimeConnect.pass"); + } + }); + }); + + browser.tabs.create({ url: "tab.html" }, tab => { + tabId = tab.id; + }); + }, + + files: { + "tab.js": function () { + let port = browser.runtime.connect({ name: "tab-connection-name" }); + port.postMessage("tab to background port message"); + port.onMessage.addListener(msg => { + port.postMessage({ tabReceived: msg }); + }); + }, + "tab.html": ` + <!DOCTYPE html> + <html> + <head> + <title>test tab extension page</title> + <meta charset="utf-8"> + <script src="tab.js" async></script> + </head> + <body> + <h1>test tab extension page</h1> + </body> + </html> + `, + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabRuntimeConnect.pass"); + await extension.unload(); + + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_attention.js b/browser/components/extensions/test/browser/browser_ext_tabs_attention.js new file mode 100644 index 0000000000..0f267460f3 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_attention.js @@ -0,0 +1,64 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function tabsAttention() { + let tab1 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.com/?2", + true + ); + let tab2 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.com/?1", + true + ); + gBrowser.selectedTab = tab2; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs", "http://example.com/*"], + }, + + background: async function () { + function onActive(tabId, changeInfo, tab) { + browser.test.assertFalse( + changeInfo.attention, + "changeInfo.attention should be false" + ); + browser.test.assertFalse( + tab.attention, + "tab.attention should be false" + ); + browser.test.assertTrue(tab.active, "tab.active should be true"); + browser.test.notifyPass("tabsAttention"); + } + + function onUpdated(tabId, changeInfo, tab) { + browser.test.assertTrue( + changeInfo.attention, + "changeInfo.attention should be true" + ); + browser.test.assertTrue(tab.attention, "tab.attention should be true"); + browser.tabs.onUpdated.removeListener(onUpdated); + browser.tabs.onUpdated.addListener(onActive); + browser.tabs.update(tabId, { active: true }); + } + + browser.tabs.onUpdated.addListener(onUpdated, { + properties: ["attention"], + }); + const tabs = await browser.tabs.query({ index: 1 }); + browser.tabs.executeScript(tabs[0].id, { + code: `alert("tab attention")`, + }); + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabsAttention"); + await extension.unload(); + + BrowserTestUtils.removeTab(tab1); + BrowserTestUtils.removeTab(tab2); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_audio.js b/browser/components/extensions/test/browser/browser_ext_tabs_audio.js new file mode 100644 index 0000000000..978c3697c8 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_audio.js @@ -0,0 +1,261 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function () { + let tab1 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank?1" + ); + let tab2 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank?2" + ); + + gBrowser.selectedTab = tab1; + + async function background() { + function promiseUpdated(tabId, attr) { + return new Promise(resolve => { + let onUpdated = (tabId_, changeInfo, tab) => { + if (tabId == tabId_ && attr in changeInfo) { + browser.tabs.onUpdated.removeListener(onUpdated); + + resolve({ changeInfo, tab }); + } + }; + browser.tabs.onUpdated.addListener(onUpdated); + }); + } + + let deferred = {}; + browser.test.onMessage.addListener((message, tabId, result) => { + if (message == "change-tab-done" && deferred[tabId]) { + deferred[tabId].resolve(result); + } + }); + + function changeTab(tabId, attr, on) { + return new Promise((resolve, reject) => { + deferred[tabId] = { resolve, reject }; + browser.test.sendMessage("change-tab", tabId, attr, on); + }); + } + + try { + let tabs = await browser.tabs.query({ lastFocusedWindow: true }); + browser.test.assertEq(tabs.length, 3, "We have three tabs"); + + for (let tab of tabs) { + // Note: We want to check that these are actual boolean values, not + // just that they evaluate as false. + browser.test.assertEq(false, tab.mutedInfo.muted, "Tab is not muted"); + browser.test.assertEq( + undefined, + tab.mutedInfo.reason, + "Tab has no muted info reason" + ); + browser.test.assertEq(false, tab.audible, "Tab is not audible"); + } + + let windowId = tabs[0].windowId; + let tabIds = [tabs[1].id, tabs[2].id]; + + browser.test.log( + "Test initial queries for muted and audible return no tabs" + ); + let silent = await browser.tabs.query({ windowId, audible: false }); + let audible = await browser.tabs.query({ windowId, audible: true }); + let muted = await browser.tabs.query({ windowId, muted: true }); + let nonMuted = await browser.tabs.query({ windowId, muted: false }); + + browser.test.assertEq(3, silent.length, "Three silent tabs"); + browser.test.assertEq(0, audible.length, "No audible tabs"); + + browser.test.assertEq(0, muted.length, "No muted tabs"); + browser.test.assertEq(3, nonMuted.length, "Three non-muted tabs"); + + browser.test.log( + "Toggle muted and audible externally on one tab each, and check results" + ); + [muted, audible] = await Promise.all([ + promiseUpdated(tabIds[0], "mutedInfo"), + promiseUpdated(tabIds[1], "audible"), + changeTab(tabIds[0], "muted", true), + changeTab(tabIds[1], "audible", true), + ]); + + for (let obj of [muted.changeInfo, muted.tab]) { + browser.test.assertEq(true, obj.mutedInfo.muted, "Tab is muted"); + browser.test.assertEq( + "user", + obj.mutedInfo.reason, + "Tab was muted by the user" + ); + } + + browser.test.assertEq( + true, + audible.changeInfo.audible, + "Tab audible state changed" + ); + browser.test.assertEq(true, audible.tab.audible, "Tab is audible"); + + browser.test.log( + "Re-check queries. Expect one audible and one muted tab" + ); + silent = await browser.tabs.query({ windowId, audible: false }); + audible = await browser.tabs.query({ windowId, audible: true }); + muted = await browser.tabs.query({ windowId, muted: true }); + nonMuted = await browser.tabs.query({ windowId, muted: false }); + + browser.test.assertEq(2, silent.length, "Two silent tabs"); + browser.test.assertEq(1, audible.length, "One audible tab"); + + browser.test.assertEq(1, muted.length, "One muted tab"); + browser.test.assertEq(2, nonMuted.length, "Two non-muted tabs"); + + browser.test.assertEq(true, muted[0].mutedInfo.muted, "Tab is muted"); + browser.test.assertEq( + "user", + muted[0].mutedInfo.reason, + "Tab was muted by the user" + ); + + browser.test.assertEq(true, audible[0].audible, "Tab is audible"); + + browser.test.log( + "Toggle muted internally on two tabs, and check results" + ); + [nonMuted, muted] = await Promise.all([ + promiseUpdated(tabIds[0], "mutedInfo"), + promiseUpdated(tabIds[1], "mutedInfo"), + browser.tabs.update(tabIds[0], { muted: false }), + browser.tabs.update(tabIds[1], { muted: true }), + ]); + + for (let obj of [nonMuted.changeInfo, nonMuted.tab]) { + browser.test.assertEq(false, obj.mutedInfo.muted, "Tab is not muted"); + } + for (let obj of [muted.changeInfo, muted.tab]) { + browser.test.assertEq(true, obj.mutedInfo.muted, "Tab is muted"); + } + + for (let obj of [ + nonMuted.changeInfo, + nonMuted.tab, + muted.changeInfo, + muted.tab, + ]) { + browser.test.assertEq( + "extension", + obj.mutedInfo.reason, + "Mute state changed by extension" + ); + + browser.test.assertEq( + browser.runtime.id, + obj.mutedInfo.extensionId, + "Mute state changed by extension" + ); + } + + browser.test.log("Test that mutedInfo is preserved by sessionstore"); + let tab = await changeTab(tabIds[1], "duplicate").then(browser.tabs.get); + + browser.test.assertEq(true, tab.mutedInfo.muted, "Tab is muted"); + + browser.test.assertEq( + "extension", + tab.mutedInfo.reason, + "Mute state changed by extension" + ); + + browser.test.assertEq( + browser.runtime.id, + tab.mutedInfo.extensionId, + "Mute state changed by extension" + ); + + browser.test.log("Unmute externally, and check results"); + [nonMuted] = await Promise.all([ + promiseUpdated(tabIds[1], "mutedInfo"), + changeTab(tabIds[1], "muted", false), + browser.tabs.remove(tab.id), + ]); + + for (let obj of [nonMuted.changeInfo, nonMuted.tab]) { + browser.test.assertEq(false, obj.mutedInfo.muted, "Tab is not muted"); + browser.test.assertEq( + "user", + obj.mutedInfo.reason, + "Mute state changed by user" + ); + } + + browser.test.notifyPass("tab-audio"); + } catch (e) { + browser.test.fail(`${e} :: ${e.stack}`); + browser.test.notifyFail("tab-audio"); + } + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + background, + }); + + extension.onMessage("change-tab", (tabId, attr, on) => { + const { + Management: { + global: { tabTracker }, + }, + } = ChromeUtils.importESModule("resource://gre/modules/Extension.sys.mjs"); + + let tab = tabTracker.getTab(tabId); + + if (attr == "muted") { + // Ideally we'd simulate a click on the tab audio icon for this, but the + // handler relies on CSS :hover states, which are complicated and fragile + // to simulate. + if (tab.muted != on) { + tab.toggleMuteAudio(); + } + } else if (attr == "audible") { + let browser = tab.linkedBrowser; + if (on) { + browser.audioPlaybackStarted(); + } else { + browser.audioPlaybackStopped(); + } + } else if (attr == "duplicate") { + // This is a bit of a hack. It won't be necessary once we have + // `tabs.duplicate`. + let newTab = gBrowser.duplicateTab(tab); + BrowserTestUtils.waitForEvent(newTab, "SSTabRestored", () => true).then( + () => { + extension.sendMessage( + "change-tab-done", + tabId, + tabTracker.getId(newTab) + ); + } + ); + return; + } + + extension.sendMessage("change-tab-done", tabId); + }); + + await extension.startup(); + + await extension.awaitFinish("tab-audio"); + + await extension.unload(); + + BrowserTestUtils.removeTab(tab1); + BrowserTestUtils.removeTab(tab2); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_autoDiscardable.js b/browser/components/extensions/test/browser/browser_ext_tabs_autoDiscardable.js new file mode 100644 index 0000000000..74043e2a3a --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_autoDiscardable.js @@ -0,0 +1,177 @@ +"use strict"; + +add_task(async function test_autoDiscardable() { + let files = { + "schema.json": JSON.stringify([ + { + namespace: "experiments", + functions: [ + { + name: "unload", + type: "function", + async: true, + description: + "Unload the least recently used tab using Firefox's built-in tab unloader mechanism", + parameters: [], + }, + ], + }, + ]), + "parent.js": () => { + const { TabUnloader } = ChromeUtils.importESModule( + "resource:///modules/TabUnloader.sys.mjs" + ); + const { ExtensionError } = ExtensionUtils; + this.experiments = class extends ExtensionAPI { + getAPI(context) { + return { + experiments: { + async unload() { + try { + await TabUnloader.unloadLeastRecentlyUsedTab(null); + } catch (error) { + // We need to do this, otherwise failures won't bubble up to the test properly. + throw ExtensionError(error); + } + }, + }, + }; + } + }; + }, + }; + + async function background() { + let firstTab = await browser.tabs.create({ + active: false, + url: "https://example.org/", + }); + + // Make sure setting and getting works properly + browser.test.assertTrue( + firstTab.autoDiscardable, + "autoDiscardable should always be true by default" + ); + let result = await browser.tabs.update(firstTab.id, { + autoDiscardable: false, + }); + browser.test.assertFalse( + result.autoDiscardable, + "autoDiscardable should be false after setting it as such" + ); + result = await browser.tabs.update(firstTab.id, { + autoDiscardable: true, + }); + browser.test.assertTrue( + result.autoDiscardable, + "autoDiscardable should be true after setting it as such" + ); + result = await browser.tabs.update(firstTab.id, { + autoDiscardable: false, + }); + browser.test.assertFalse( + result.autoDiscardable, + "autoDiscardable should be false after setting it as such" + ); + + // Make sure the tab can't be unloaded when autoDiscardable is false + await browser.experiments.unload(); + result = await browser.tabs.get(firstTab.id); + browser.test.assertFalse( + result.discarded, + "Tab should not unload when autoDiscardable is false" + ); + + // Make sure the tab CAN be unloaded when autoDiscardable is true + await browser.tabs.update(firstTab.id, { + autoDiscardable: true, + }); + await browser.experiments.unload(); + result = await browser.tabs.get(firstTab.id); + browser.test.assertTrue( + result.discarded, + "Tab should unload when autoDiscardable is true" + ); + + // Make sure filtering for discardable tabs works properly + result = await browser.tabs.query({ autoDiscardable: true }); + browser.test.assertEq( + 2, + result.length, + "tabs.query should return 2 when autoDiscardable is true " + ); + await browser.tabs.update(firstTab.id, { + autoDiscardable: false, + }); + result = await browser.tabs.query({ autoDiscardable: true }); + browser.test.assertEq( + 1, + result.length, + "tabs.query should return 1 when autoDiscardable is false" + ); + + let onUpdatedPromise = {}; + onUpdatedPromise.promise = new Promise( + resolve => (onUpdatedPromise.resolve = resolve) + ); + + // Make sure onUpdated works + async function testOnUpdatedEvent(autoDiscardable) { + browser.test.log(`Testing autoDiscardable = ${autoDiscardable}`); + let onUpdated; + let promise = new Promise(resolve => { + onUpdated = (tabId, changeInfo, tabInfo) => { + browser.test.assertEq( + firstTab.id, + tabId, + "The updated tab's ID should match the correct tab" + ); + browser.test.assertDeepEq( + { autoDiscardable }, + changeInfo, + "The updated tab's changeInfo should be correct" + ); + browser.test.assertEq( + tabInfo.autoDiscardable, + autoDiscardable, + "The updated tab's tabInfo should be correct" + ); + resolve(); + }; + }); + browser.tabs.onUpdated.addListener(onUpdated, { + properties: ["autoDiscardable"], + }); + await browser.tabs.update(firstTab.id, { autoDiscardable }); + await promise; + browser.tabs.onUpdated.removeListener(onUpdated); + } + + await testOnUpdatedEvent(true); + await testOnUpdatedEvent(false); + + await browser.tabs.remove(firstTab.id); // Cleanup + browser.test.notifyPass("autoDiscardable"); + } + let extension = ExtensionTestUtils.loadExtension({ + isPrivileged: true, + manifest: { + permissions: ["tabs"], + experiment_apis: { + experiments: { + schema: "schema.json", + parent: { + scopes: ["addon_parent"], + script: "parent.js", + paths: [["experiments"]], + }, + }, + }, + }, + background, + files, + }); + await extension.startup(); + await extension.awaitFinish("autoDiscardable"); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_containerIsolation.js b/browser/components/extensions/test/browser/browser_ext_tabs_containerIsolation.js new file mode 100644 index 0000000000..1960366bb5 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_containerIsolation.js @@ -0,0 +1,360 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ +"use strict"; +const { XPCShellContentUtils } = ChromeUtils.importESModule( + "resource://testing-common/XPCShellContentUtils.sys.mjs" +); +XPCShellContentUtils.initMochitest(this); +const server = XPCShellContentUtils.createHttpServer({ + hosts: ["www.example.com"], +}); +server.registerPathHandler("/", (request, response) => { + response.setHeader("Content-Type", "text/html; charset=UTF-8", false); + response.write(`<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + </head> + <body> + This is example.com page content + </body> + </html> + `); +}); + +add_task(async function containerIsolation_restricted() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["extensions.userContextIsolation.enabled", true], + ["privacy.userContext.enabled", true], + ], + }); + + let helperExtension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs", "cookies"], + }, + + async background() { + browser.test.onMessage.addListener(async message => { + let tab; + if (message?.subject !== "createTab") { + browser.test.fail( + `Unexpected test message received: ${JSON.stringify(message)}` + ); + return; + } + + tab = await browser.tabs.create({ + url: message.data.url, + cookieStoreId: message.data.cookieStoreId, + }); + browser.test.sendMessage("tabCreated", tab.id); + browser.test.assertEq( + message.data.cookieStoreId, + tab.cookieStoreId, + "Created tab is associated with the expected cookieStoreId" + ); + }); + }, + }); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs", "cookies", "<all_urls>", "tabHide"], + }, + async background() { + const [restrictedTab, unrestrictedTab] = await new Promise(resolve => { + browser.test.onMessage.addListener(message => resolve(message)); + }); + + // Check that print preview method fails + await browser.test.assertRejects( + browser.tabs.printPreview(), + /Cannot access activeTab/, + "should refuse to print a preview of the tab for the container which doesn't have permission" + ); + + // Check that save As PDF method fails + await browser.test.assertRejects( + browser.tabs.saveAsPDF({}), + /Cannot access activeTab/, + "should refuse to save as PDF of the tab for the container which doesn't have permission" + ); + + // Check that create method fails + await browser.test.assertRejects( + browser.tabs.create({ cookieStoreId: "firefox-container-1" }), + /Cannot access firefox-container-1/, + "should refuse to create container tab for the container which doesn't have permission" + ); + + // Check that detect language method fails + await browser.test.assertRejects( + browser.tabs.detectLanguage(restrictedTab), + /Invalid tab ID/, + "should refuse to detect language of a tab for the container which doesn't have permission" + ); + + // Check that move tabs method fails + await browser.test.assertRejects( + browser.tabs.move(restrictedTab, { index: 1 }), + /Invalid tab ID/, + "should refuse to move tab for the container which doesn't have permission" + ); + + // Check that duplicate method fails. + await browser.test.assertRejects( + browser.tabs.duplicate(restrictedTab), + /Invalid tab ID:/, + "should refuse to duplicate tab for the container which doesn't have permission" + ); + + // Check that captureTab method fails. + await browser.test.assertRejects( + browser.tabs.captureTab(restrictedTab), + /Invalid tab ID/, + "should refuse to capture the tab for the container which doesn't have permission" + ); + + // Check that discard method fails. + await browser.test.assertRejects( + browser.tabs.discard([restrictedTab]), + /Invalid tab ID/, + "should refuse to discard the tab for the container which doesn't have permission " + ); + + // Check that get method fails. + await browser.test.assertRejects( + browser.tabs.get(restrictedTab), + /Invalid tab ID/, + "should refuse to get the tab for the container which doesn't have permissiond" + ); + + // Check that highlight method fails. + await browser.test.assertRejects( + browser.tabs.highlight({ populate: true, tabs: [restrictedTab] }), + `No tab at index: ${restrictedTab}`, + "should refuse to highlight the tab for the container which doesn't have permission" + ); + + // Test for moveInSuccession method of tab + + await browser.test.assertRejects( + browser.tabs.moveInSuccession([restrictedTab]), + /Invalid tab ID/, + "should refuse to moveInSuccession for the container which doesn't have permission" + ); + + // Check that executeScript method fails. + await browser.test.assertRejects( + browser.tabs.executeScript(restrictedTab, { frameId: 0 }), + /Invalid tab ID/, + "should refuse to execute a script of the tab for the container which doesn't have permission" + ); + + // Check that getZoom method fails. + + await browser.test.assertRejects( + browser.tabs.getZoom(restrictedTab), + /Invalid tab ID/, + "should refuse to zoom the tab for the container which doesn't have permission" + ); + + // Check that getZoomSetting method fails. + await browser.test.assertRejects( + browser.tabs.getZoomSettings(restrictedTab), + /Invalid tab ID/, + "should refuse the setting of zoom of the tab for the container which doesn't have permission" + ); + + //Test for hide method of tab + await browser.test.assertRejects( + browser.tabs.hide(restrictedTab), + /Invalid tab ID/, + "should refuse to hide a tab for the container which doesn't have permission" + ); + + // Check that insertCSS method fails. + await browser.test.assertRejects( + browser.tabs.insertCSS(restrictedTab, { frameId: 0 }), + /Invalid tab ID/, + "should refuse to insert a stylesheet to the tab for the container which doesn't have permission" + ); + + // Check that removeCSS method fails. + await browser.test.assertRejects( + browser.tabs.removeCSS(restrictedTab, { frameId: 0 }), + /Invalid tab ID/, + "should refuse to remove a stylesheet to the tab for the container which doesn't have permission" + ); + + //Test for show method of tab + await browser.test.assertRejects( + browser.tabs.show([restrictedTab]), + /Invalid tab ID/, + "should refuse to show the tab for the container which doesn't have permission" + ); + + // Check that toggleReaderMode method fails. + + await browser.test.assertRejects( + browser.tabs.toggleReaderMode(restrictedTab), + /Invalid tab ID/, + "should refuse to toggle reader mode in the tab for the container which doesn't have permission" + ); + + // Check that setZoom method fails. + await browser.test.assertRejects( + browser.tabs.setZoom(restrictedTab, 0), + /Invalid tab ID/, + "should refuse to set zoom of the tab for the container which doesn't have permission" + ); + + // Check that setZoomSettings method fails. + await browser.test.assertRejects( + browser.tabs.setZoomSettings(restrictedTab, { defaultZoomFactor: 1 }), + /Invalid tab ID/, + "should refuse to set zoom setting of the tab for the container which doesn't have permission" + ); + + // Check that goBack method fails. + + await browser.test.assertRejects( + browser.tabs.goBack(restrictedTab), + /Invalid tab ID/, + "should refuse to go back to the tab for the container which doesn't have permission" + ); + + // Check that goForward method fails. + + await browser.test.assertRejects( + browser.tabs.goForward(restrictedTab), + /Invalid tab ID/, + "should refuse to go forward to the tab for the container which doesn't have permission" + ); + + // Check that update method fails. + await browser.test.assertRejects( + browser.tabs.update(restrictedTab, { highlighted: true }), + /Invalid tab ID/, + "should refuse to update the tab for the container which doesn't have permission" + ); + + // Check that reload method fails. + await browser.test.assertRejects( + browser.tabs.reload(restrictedTab), + /Invalid tab ID/, + "should refuse to reload tab for the container which doesn't have permission" + ); + + //Test for warmup method of tab + await browser.test.assertRejects( + browser.tabs.warmup(restrictedTab), + /Invalid tab ID/, + "should refuse to warmup a tab for the container which doesn't have permission" + ); + + let gettab = await browser.tabs.get(unrestrictedTab); + browser.test.assertEq( + gettab.cookieStoreId, + "firefox-container-2", + "get tab should open" + ); + + let lang = await browser.tabs.detectLanguage(unrestrictedTab); + await browser.test.assertEq( + "en", + lang, + "English document should be detected" + ); + + let duptab = await browser.tabs.duplicate(unrestrictedTab); + + browser.test.assertEq( + duptab.cookieStoreId, + "firefox-container-2", + "duplicated tab should open" + ); + await browser.tabs.remove(duptab.id); + + let moved = await browser.tabs.move(unrestrictedTab, { + index: 0, + }); + browser.test.assertEq(moved.length, 1, "move() returned no moved tab"); + + //Test for query method of tab + let tabs = await browser.tabs.query({ + cookieStoreId: "firefox-container-1", + }); + await browser.test.assertEq( + 0, + tabs.length, + "should not return restricted container's tab" + ); + + tabs = await browser.tabs.query({}); + await browser.test.assertEq( + tabs + .map(tab => tab.cookieStoreId) + .sort() + .join(","), + "firefox-container-2,firefox-default", + "should return two tabs - firefox-default and firefox-container-2" + ); + + // Check that remove method fails. + + await browser.test.assertRejects( + browser.tabs.remove([restrictedTab]), + /Invalid tab ID/, + "should refuse to remove tab for the container which doesn't have permission" + ); + + let removedPromise = new Promise(resolve => { + browser.tabs.onRemoved.addListener(tabId => { + browser.test.assertEq(unrestrictedTab, tabId, "expected remove tab"); + resolve(); + }); + }); + await browser.tabs.remove(unrestrictedTab); + await removedPromise; + + browser.test.sendMessage("done"); + }, + }); + + await helperExtension.startup(); + + helperExtension.sendMessage({ + subject: "createTab", + data: { + url: "http://www.example.com/", + cookieStoreId: "firefox-container-2", + }, + }); + const unrestrictedTab = await helperExtension.awaitMessage("tabCreated"); + + helperExtension.sendMessage({ + subject: "createTab", + data: { + url: "http://www.example.com/", + cookieStoreId: "firefox-container-1", + }, + }); + const restrictedTab = await helperExtension.awaitMessage("tabCreated"); + + await SpecialPowers.pushPrefEnv({ + set: [["extensions.userContextIsolation.defaults.restricted", "[1]"]], + }); + + await extension.startup(); + extension.sendMessage([restrictedTab, unrestrictedTab]); + + await extension.awaitMessage("done"); + await extension.unload(); + await helperExtension.unload(); + BrowserTestUtils.removeTab(gBrowser.selectedTab); + await SpecialPowers.popPrefEnv(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_cookieStoreId.js b/browser/components/extensions/test/browser/browser_ext_tabs_cookieStoreId.js new file mode 100644 index 0000000000..27ea5d92bf --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_cookieStoreId.js @@ -0,0 +1,328 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_setup(async function () { + // make sure userContext is enabled. + return SpecialPowers.pushPrefEnv({ + set: [["privacy.userContext.enabled", true]], + }); +}); + +add_task(async function () { + info("Start testing tabs.create with cookieStoreId"); + + let testCases = [ + // No private window + { + privateTab: false, + cookieStoreId: null, + success: true, + expectedCookieStoreId: "firefox-default", + }, + { + privateTab: false, + cookieStoreId: "firefox-default", + success: true, + expectedCookieStoreId: "firefox-default", + }, + { + privateTab: false, + cookieStoreId: "firefox-container-1", + success: true, + expectedCookieStoreId: "firefox-container-1", + }, + { + privateTab: false, + cookieStoreId: "firefox-container-2", + success: true, + expectedCookieStoreId: "firefox-container-2", + }, + { + privateTab: false, + cookieStoreId: "firefox-container-42", + failure: "exist", + }, + { + privateTab: false, + cookieStoreId: "firefox-private", + failure: "defaultToPrivate", + }, + { privateTab: false, cookieStoreId: "wow", failure: "illegal" }, + + // Private window + { + privateTab: true, + cookieStoreId: null, + success: true, + expectedCookieStoreId: "firefox-private", + }, + { + privateTab: true, + cookieStoreId: "firefox-private", + success: true, + expectedCookieStoreId: "firefox-private", + }, + { + privateTab: true, + cookieStoreId: "firefox-default", + failure: "privateToDefault", + }, + { + privateTab: true, + cookieStoreId: "firefox-container-1", + failure: "privateToDefault", + }, + { privateTab: true, cookieStoreId: "wow", failure: "illegal" }, + ]; + + let extension = ExtensionTestUtils.loadExtension({ + incognitoOverride: "spanning", + manifest: { + permissions: ["tabs", "cookies"], + }, + + background: function () { + function testTab(data, tab) { + browser.test.assertTrue(data.success, "we want a success"); + browser.test.assertTrue(!!tab, "we have a tab"); + browser.test.assertEq( + data.expectedCookieStoreId, + tab.cookieStoreId, + "tab should have the correct cookieStoreId" + ); + } + + async function runTest(data) { + try { + // Tab Creation + let tab; + try { + tab = await browser.tabs.create({ + windowId: data.privateTab + ? this.privateWindowId + : this.defaultWindowId, + cookieStoreId: data.cookieStoreId, + }); + + browser.test.assertTrue(!data.failure, "we want a success"); + } catch (error) { + browser.test.assertTrue(!!data.failure, "we want a failure"); + + if (data.failure == "illegal") { + browser.test.assertEq( + `Illegal cookieStoreId: ${data.cookieStoreId}`, + error.message, + "runtime.lastError should report the expected error message" + ); + } else if (data.failure == "defaultToPrivate") { + browser.test.assertEq( + "Illegal to set private cookieStoreId in a non-private window", + error.message, + "runtime.lastError should report the expected error message" + ); + } else if (data.failure == "privateToDefault") { + browser.test.assertEq( + "Illegal to set non-private cookieStoreId in a private window", + error.message, + "runtime.lastError should report the expected error message" + ); + } else if (data.failure == "exist") { + browser.test.assertEq( + `No cookie store exists with ID ${data.cookieStoreId}`, + error.message, + "runtime.lastError should report the expected error message" + ); + } else { + browser.test.fail("The test is broken"); + } + + browser.test.sendMessage("test-done"); + return; + } + + // Tests for tab creation + testTab(data, tab); + + { + // Tests for tab querying + let [tab] = await browser.tabs.query({ + windowId: data.privateTab + ? this.privateWindowId + : this.defaultWindowId, + cookieStoreId: data.cookieStoreId, + }); + + browser.test.assertTrue(tab != undefined, "Tab found!"); + testTab(data, tab); + } + + let stores = await browser.cookies.getAllCookieStores(); + + let store = stores.find(store => store.id === tab.cookieStoreId); + browser.test.assertTrue(!!store, "We have a store for this tab."); + browser.test.assertTrue( + store.tabIds.includes(tab.id), + "tabIds includes this tab." + ); + + await browser.tabs.remove(tab.id); + + browser.test.sendMessage("test-done"); + } catch (e) { + browser.test.fail("An exception has been thrown"); + } + } + + async function initialize() { + let win = await browser.windows.create({ incognito: true }); + this.privateWindowId = win.id; + + win = await browser.windows.create({ incognito: false }); + this.defaultWindowId = win.id; + + browser.test.sendMessage("ready"); + } + + async function shutdown() { + await browser.windows.remove(this.privateWindowId); + await browser.windows.remove(this.defaultWindowId); + browser.test.sendMessage("gone"); + } + + // Waiting for messages + browser.test.onMessage.addListener((msg, data) => { + if (msg == "be-ready") { + initialize(); + } else if (msg == "test") { + runTest(data); + } else { + browser.test.assertTrue("finish", msg, "Shutting down"); + shutdown(); + } + }); + }, + }); + + await extension.startup(); + + info("Tests must be ready..."); + extension.sendMessage("be-ready"); + await extension.awaitMessage("ready"); + info("Tests are ready to run!"); + + for (let test of testCases) { + info(`test tab.create with cookieStoreId: "${test.cookieStoreId}"`); + extension.sendMessage("test", test); + await extension.awaitMessage("test-done"); + } + + info("Waiting for shutting down..."); + extension.sendMessage("finish"); + await extension.awaitMessage("gone"); + + await extension.unload(); +}); + +add_task(async function userContext_disabled() { + await SpecialPowers.pushPrefEnv({ + set: [["privacy.userContext.enabled", false]], + }); + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs", "cookies"], + }, + async background() { + await browser.test.assertRejects( + browser.tabs.create({ cookieStoreId: "firefox-container-1" }), + /Contextual identities are currently disabled/, + "should refuse to open container tab when contextual identities are disabled" + ); + browser.test.sendMessage("done"); + }, + }); + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function tabs_query_cookiestoreid_nocookiepermission() { + let extension = ExtensionTestUtils.loadExtension({ + async background() { + let tab = await browser.tabs.create({}); + browser.test.assertEq( + "firefox-default", + tab.cookieStoreId, + "Expecting cookieStoreId for new tab" + ); + let query = await browser.tabs.query({ + index: tab.index, + cookieStoreId: tab.cookieStoreId, + }); + browser.test.assertEq( + "firefox-default", + query[0].cookieStoreId, + "Expecting cookieStoreId for new tab through browser.tabs.query" + ); + await browser.tabs.remove(tab.id); + browser.test.sendMessage("done"); + }, + }); + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); +}); + +add_task(async function tabs_query_multiple_cookiestoreId() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["cookies"], + }, + + async background() { + let tab1 = await browser.tabs.create({ + cookieStoreId: "firefox-container-1", + }); + browser.test.log(`Tab created for cookieStoreId:${tab1.cookieStoreId}`); + + let tab2 = await browser.tabs.create({ + cookieStoreId: "firefox-container-2", + }); + browser.test.log(`Tab created for cookieStoreId:${tab2.cookieStoreId}`); + + let tab3 = await browser.tabs.create({ + cookieStoreId: "firefox-container-3", + }); + browser.test.log(`Tab created for cookieStoreId:${tab3.cookieStoreId}`); + + let tabs = await browser.tabs.query({ + cookieStoreId: ["firefox-container-1", "firefox-container-2"], + }); + + browser.test.assertEq( + 2, + tabs.length, + "Expecting tabs for firefox-container-1 and firefox-container-2" + ); + + browser.test.assertEq( + "firefox-container-1", + tabs[0].cookieStoreId, + "Expecting tab for firefox-container-1 cookieStoreId" + ); + + browser.test.assertEq( + "firefox-container-2", + tabs[1].cookieStoreId, + "Expecting tab forfirefox-container-2 cookieStoreId" + ); + + await browser.tabs.remove([tab1.id, tab2.id, tab3.id]); + browser.test.sendMessage("test-done"); + }, + }); + await extension.startup(); + await extension.awaitMessage("test-done"); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_cookieStoreId_private.js b/browser/components/extensions/test/browser/browser_ext_tabs_cookieStoreId_private.js new file mode 100644 index 0000000000..556aa78288 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_cookieStoreId_private.js @@ -0,0 +1,44 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function perma_private_browsing_mode() { + // make sure userContext is enabled. + await SpecialPowers.pushPrefEnv({ + set: [["privacy.userContext.enabled", true]], + }); + + Assert.equal( + Services.prefs.getBoolPref("browser.privatebrowsing.autostart"), + true, + "Permanent private browsing is enabled" + ); + + let extension = ExtensionTestUtils.loadExtension({ + incognitoOverride: "spanning", + manifest: { + permissions: ["tabs", "cookies"], + }, + async background() { + let win = await browser.windows.create({}); + browser.test.assertTrue( + win.incognito, + "New window should be private when perma-PBM is enabled." + ); + await browser.test.assertRejects( + browser.tabs.create({ + cookieStoreId: "firefox-container-1", + windowId: win.id, + }), + /Illegal to set non-private cookieStoreId in a private window/, + "should refuse to open container tab in private browsing window" + ); + await browser.windows.remove(win.id); + + browser.test.sendMessage("done"); + }, + }); + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_create.js b/browser/components/extensions/test/browser/browser_ext_tabs_create.js new file mode 100644 index 0000000000..a3b6e78331 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_create.js @@ -0,0 +1,299 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_create_options() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:robots" + ); + gBrowser.selectedTab = tab; + + // TODO: Multiple windows. + + await SpecialPowers.pushPrefEnv({ + set: [ + // Using pre-loaded new tab pages interferes with onUpdated events. + // It probably shouldn't. + ["browser.newtab.preload", false], + // Some test cases below load http and check the behavior of https-first. + ["dom.security.https_first", true], + ], + }); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + + background: { page: "bg/background.html" }, + }, + + files: { + "bg/blank.html": `<html><head><meta charset="utf-8"></head></html>`, + + "bg/background.html": `<html><head> + <meta charset="utf-8"> + <script src="background.js"></script> + </head></html>`, + + "bg/background.js": function () { + let activeTab; + let activeWindow; + + function runTests() { + const DEFAULTS = { + index: 2, + windowId: activeWindow, + active: true, + pinned: false, + url: "about:newtab", + // 'selected' is marked as unsupported in schema, so we've removed it. + // For more details, see bug 1337509 + selected: undefined, + mutedInfo: { + muted: false, + extensionId: undefined, + reason: undefined, + }, + }; + + let tests = [ + { + create: { url: "https://example.com/" }, + result: { url: "https://example.com/" }, + }, + { + create: { url: "view-source:https://example.com/" }, + result: { url: "view-source:https://example.com/" }, + }, + { + create: { url: "blank.html" }, + result: { url: browser.runtime.getURL("bg/blank.html") }, + }, + { + create: { url: "https://example.com/", openInReaderMode: true }, + result: { + url: `about:reader?url=${encodeURIComponent( + "https://example.com/" + )}`, + }, + }, + { + create: {}, + result: { url: "about:newtab" }, + }, + { + create: { active: false }, + result: { active: false }, + }, + { + create: { active: true }, + result: { active: true }, + }, + { + create: { pinned: true }, + result: { pinned: true, index: 0 }, + }, + { + create: { pinned: true, active: true }, + result: { pinned: true, active: true, index: 0 }, + }, + { + create: { pinned: true, active: false }, + result: { pinned: true, active: false, index: 0 }, + }, + { + create: { index: 1 }, + result: { index: 1 }, + }, + { + create: { index: 1, active: false }, + result: { index: 1, active: false }, + }, + { + create: { windowId: activeWindow }, + result: { windowId: activeWindow }, + }, + { + create: { index: 9999 }, + result: { index: 2 }, + }, + { + // https-first redirects http to https. + create: { url: "http://example.com/" }, + result: { url: "https://example.com/" }, + }, + { + // https-first redirects http to https. + create: { url: "view-source:http://example.com/" }, + result: { url: "view-source:https://example.com/" }, + }, + { + // Despite https-first, the about:reader URL does not change. + create: { url: "http://example.com/", openInReaderMode: true }, + result: { + url: `about:reader?url=${encodeURIComponent( + "http://example.com/" + )}`, + }, + }, + { + create: { muted: true }, + result: { + mutedInfo: { + muted: true, + extensionId: browser.runtime.id, + reason: "extension", + }, + }, + }, + { + create: { muted: false }, + result: { + mutedInfo: { + muted: false, + extensionId: undefined, + reason: undefined, + }, + }, + }, + ]; + + async function nextTest() { + if (!tests.length) { + browser.test.notifyPass("tabs.create"); + return; + } + + let test = tests.shift(); + let expected = Object.assign({}, DEFAULTS, test.result); + + browser.test.log( + `Testing tabs.create(${JSON.stringify( + test.create + )}), expecting ${JSON.stringify(test.result)}` + ); + + let updatedPromise = new Promise(resolve => { + let onUpdated = (changedTabId, changed) => { + if (changed.url) { + browser.tabs.onUpdated.removeListener(onUpdated); + resolve({ tabId: changedTabId, url: changed.url }); + } + }; + browser.tabs.onUpdated.addListener(onUpdated); + }); + + let createdPromise = new Promise(resolve => { + let onCreated = tab => { + browser.test.assertTrue( + "id" in tab, + `Expected tabs.onCreated callback to receive tab object` + ); + resolve(); + }; + browser.tabs.onCreated.addListener(onCreated); + }); + + let [tab] = await Promise.all([ + browser.tabs.create(test.create), + createdPromise, + ]); + let tabId = tab.id; + + for (let key of Object.keys(expected)) { + if (key === "url") { + // FIXME: This doesn't get updated until later in the load cycle. + continue; + } + + if (key === "mutedInfo") { + for (let key of Object.keys(expected.mutedInfo)) { + browser.test.assertEq( + expected.mutedInfo[key], + tab.mutedInfo[key], + `Expected value for tab.mutedInfo.${key}` + ); + } + } else { + browser.test.assertEq( + expected[key], + tab[key], + `Expected value for tab.${key}` + ); + } + } + + let updated = await updatedPromise; + browser.test.assertEq( + tabId, + updated.tabId, + `Expected value for tab.id` + ); + browser.test.assertEq( + expected.url, + updated.url, + `Expected value for tab.url` + ); + + await browser.tabs.remove(tabId); + await browser.tabs.update(activeTab, { active: true }); + + nextTest(); + } + + nextTest(); + } + + browser.tabs.query({ active: true, currentWindow: true }, tabs => { + activeTab = tabs[0].id; + activeWindow = tabs[0].windowId; + + runTests(); + }); + }, + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabs.create"); + await extension.unload(); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_create_with_popup() { + const extension = ExtensionTestUtils.loadExtension({ + async background() { + let normalWin = await browser.windows.create(); + let lastFocusedNormalWin = await browser.windows.getLastFocused({}); + browser.test.assertEq( + lastFocusedNormalWin.id, + normalWin.id, + "The normal window is the last focused window." + ); + let popupWin = await browser.windows.create({ type: "popup" }); + let lastFocusedPopupWin = await browser.windows.getLastFocused({}); + browser.test.assertEq( + lastFocusedPopupWin.id, + popupWin.id, + "The popup window is the last focused window." + ); + let newtab = await browser.tabs.create({}); + browser.test.assertEq( + normalWin.id, + newtab.windowId, + "New tab was created in last focused normal window." + ); + await Promise.all([ + browser.windows.remove(normalWin.id), + browser.windows.remove(popupWin.id), + ]); + browser.test.sendMessage("complete"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("complete"); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_create_invalid_url.js b/browser/components/extensions/test/browser/browser_ext_tabs_create_invalid_url.js new file mode 100644 index 0000000000..55bb33f26e --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_create_invalid_url.js @@ -0,0 +1,79 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const FILE_URL = Services.io.newFileURI( + new FileUtils.File(getTestFilePath("file_dummy.html")) +).spec; + +async function testTabsCreateInvalidURL(tabsCreateURL) { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + background: function () { + browser.test.sendMessage("ready"); + browser.test.onMessage.addListener((msg, tabsCreateURL) => { + browser.tabs.create({ url: tabsCreateURL }, tab => { + browser.test.assertEq( + undefined, + tab, + "on error tab should be undefined" + ); + browser.test.assertTrue( + /Illegal URL/.test(browser.runtime.lastError.message), + "runtime.lastError should report the expected error message" + ); + + // Remove the opened tab is any. + if (tab) { + browser.tabs.remove(tab.id); + } + browser.test.sendMessage("done"); + }); + }); + }, + }); + + await extension.startup(); + + await extension.awaitMessage("ready"); + + info(`test tab.create on invalid URL "${tabsCreateURL}"`); + + extension.sendMessage("start", tabsCreateURL); + await extension.awaitMessage("done"); + + await extension.unload(); +} + +add_task(async function () { + info("Start testing tabs.create on invalid URLs"); + + let dataURLPage = `data:text/html, + <!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + </head> + <body> + <h1>data url page</h1> + </body> + </html>`; + + let testCases = [ + { tabsCreateURL: "about:addons" }, + { + tabsCreateURL: "javascript:console.log('tabs.update execute javascript')", + }, + { tabsCreateURL: dataURLPage }, + { tabsCreateURL: FILE_URL }, + ]; + + for (let { tabsCreateURL } of testCases) { + await testTabsCreateInvalidURL(tabsCreateURL); + } + + info("done"); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_create_url.js b/browser/components/extensions/test/browser/browser_ext_tabs_create_url.js new file mode 100644 index 0000000000..91cafa6e7e --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_create_url.js @@ -0,0 +1,230 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +async function runWithDisabledPrivateBrowsing(callback) { + const { EnterprisePolicyTesting, PoliciesPrefTracker } = + ChromeUtils.importESModule( + "resource://testing-common/EnterprisePolicyTesting.sys.mjs" + ); + + PoliciesPrefTracker.start(); + await EnterprisePolicyTesting.setupPolicyEngineWithJson({ + policies: { DisablePrivateBrowsing: true }, + }); + + try { + await callback(); + } finally { + await EnterprisePolicyTesting.setupPolicyEngineWithJson(""); + EnterprisePolicyTesting.resetRunOnceState(); + PoliciesPrefTracker.stop(); + } +} + +add_task(async function test_urlbar_focus() { + // Disable preloaded new tab because the urlbar is automatically focused when + // a preloaded new tab is opened, while this test is supposed to test that the + // implementation of tabs.create automatically focuses the urlbar of new tabs. + await SpecialPowers.pushPrefEnv({ + set: [["browser.newtab.preload", false]], + }); + + const extension = ExtensionTestUtils.loadExtension({ + background() { + browser.tabs.onUpdated.addListener(function onUpdated(_, info, tab) { + if (info.status === "complete" && tab.url !== "about:blank") { + browser.test.sendMessage("complete"); + browser.tabs.onUpdated.removeListener(onUpdated); + } + }); + browser.test.onMessage.addListener(async (cmd, ...args) => { + const result = await browser.tabs[cmd](...args); + browser.test.sendMessage("result", result); + }); + }, + }); + + await extension.startup(); + + // Test content is focused after opening a regular url + extension.sendMessage("create", { url: "https://example.com" }); + const [tab1] = await Promise.all([ + extension.awaitMessage("result"), + extension.awaitMessage("complete"), + ]); + + is( + document.activeElement.tagName, + "browser", + "Content focused after opening a web page" + ); + + extension.sendMessage("remove", tab1.id); + await extension.awaitMessage("result"); + + // Test urlbar is focused after opening an empty tab + extension.sendMessage("create", {}); + const tab2 = await extension.awaitMessage("result"); + + const active = document.activeElement; + info( + `Active element: ${active.tagName}, id: ${active.id}, class: ${active.className}` + ); + + const parent = active.parentNode; + info( + `Parent element: ${parent.tagName}, id: ${parent.id}, class: ${parent.className}` + ); + + info(`After opening an empty tab, gURLBar.focused: ${gURLBar.focused}`); + + is(active.tagName, "html:input", "Input element focused"); + is(active.id, "urlbar-input", "Urlbar focused"); + + extension.sendMessage("remove", tab2.id); + await extension.awaitMessage("result"); + + await extension.unload(); +}); + +add_task(async function default_url() { + const extension = ExtensionTestUtils.loadExtension({ + incognitoOverride: "spanning", + manifest: { + permissions: ["tabs"], + }, + background() { + function promiseNonBlankTab() { + return new Promise(resolve => { + browser.tabs.onUpdated.addListener(function listener( + tabId, + changeInfo, + tab + ) { + if (changeInfo.status === "complete" && tab.url !== "about:blank") { + browser.tabs.onUpdated.removeListener(listener); + resolve(tab); + } + }); + }); + } + + browser.test.onMessage.addListener( + async (msg, { incognito, expectedNewWindowUrl, expectedNewTabUrl }) => { + browser.test.assertEq( + "start", + msg, + `Start test, incognito=${incognito}` + ); + + let tabPromise = promiseNonBlankTab(); + let win; + try { + win = await browser.windows.create({ incognito }); + browser.test.assertEq( + 1, + win.tabs.length, + "Expected one tab in the new window." + ); + } catch (e) { + browser.test.assertEq( + expectedNewWindowUrl, + e.message, + "Expected error" + ); + browser.test.sendMessage("done"); + return; + } + let tab = await tabPromise; + browser.test.assertEq( + expectedNewWindowUrl, + tab.url, + "Expected default URL of new window" + ); + + tabPromise = promiseNonBlankTab(); + await browser.tabs.create({ windowId: win.id }); + tab = await tabPromise; + browser.test.assertEq( + expectedNewTabUrl, + tab.url, + "Expected default URL of new tab" + ); + + await browser.windows.remove(win.id); + browser.test.sendMessage("done"); + } + ); + }, + }); + + await extension.startup(); + + extension.sendMessage("start", { + incognito: false, + expectedNewWindowUrl: "about:home", + expectedNewTabUrl: "about:newtab", + }); + await extension.awaitMessage("done"); + extension.sendMessage("start", { + incognito: true, + expectedNewWindowUrl: "about:privatebrowsing", + expectedNewTabUrl: "about:privatebrowsing", + }); + await extension.awaitMessage("done"); + + info("Testing with multiple homepages."); + await SpecialPowers.pushPrefEnv({ + set: [["browser.startup.homepage", "about:robots|about:blank|about:home"]], + }); + extension.sendMessage("start", { + incognito: false, + expectedNewWindowUrl: "about:robots", + expectedNewTabUrl: "about:newtab", + }); + await extension.awaitMessage("done"); + extension.sendMessage("start", { + incognito: true, + expectedNewWindowUrl: "about:privatebrowsing", + expectedNewTabUrl: "about:privatebrowsing", + }); + await extension.awaitMessage("done"); + await SpecialPowers.popPrefEnv(); + + info("Testing with perma-private browsing mode."); + await SpecialPowers.pushPrefEnv({ + set: [["browser.privatebrowsing.autostart", true]], + }); + extension.sendMessage("start", { + incognito: false, + expectedNewWindowUrl: "about:home", + expectedNewTabUrl: "about:newtab", + }); + await extension.awaitMessage("done"); + extension.sendMessage("start", { + incognito: true, + expectedNewWindowUrl: "about:home", + expectedNewTabUrl: "about:newtab", + }); + await extension.awaitMessage("done"); + await SpecialPowers.popPrefEnv(); + + info("Testing with disabled private browsing mode."); + await runWithDisabledPrivateBrowsing(async () => { + extension.sendMessage("start", { + incognito: false, + expectedNewWindowUrl: "about:home", + expectedNewTabUrl: "about:newtab", + }); + await extension.awaitMessage("done"); + extension.sendMessage("start", { + incognito: true, + expectedNewWindowUrl: + "`incognito` cannot be used if incognito mode is disabled", + }); + await extension.awaitMessage("done"); + }); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_discard.js b/browser/components/extensions/test/browser/browser_ext_tabs_discard.js new file mode 100644 index 0000000000..074a0f4ce1 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_discard.js @@ -0,0 +1,98 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* global gBrowser */ +"use strict"; + +add_task(async function test_discarded() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + background: async function () { + let tabs = await browser.tabs.query({ currentWindow: true }); + tabs.sort((tab1, tab2) => tab1.index - tab2.index); + + async function finishTest() { + try { + await browser.tabs.discard(tabs[0].id); + await browser.tabs.discard(tabs[2].id); + browser.test.succeed( + "attempting to discard an already discarded tab or the active tab should not throw error" + ); + } catch (e) { + browser.test.fail( + "attempting to discard an already discarded tab or the active tab should not throw error" + ); + } + let discardedTab = await browser.tabs.get(tabs[2].id); + browser.test.assertEq( + false, + discardedTab.discarded, + "attempting to discard the active tab should not have succeeded" + ); + + await browser.test.assertRejects( + browser.tabs.discard(999999999), + /Invalid tab ID/, + "attempt to discard invalid tabId should throw" + ); + await browser.test.assertRejects( + browser.tabs.discard([999999999, tabs[1].id]), + /Invalid tab ID/, + "attempt to discard a valid and invalid tabId should throw" + ); + discardedTab = await browser.tabs.get(tabs[1].id); + browser.test.assertEq( + false, + discardedTab.discarded, + "tab is still not discarded" + ); + + browser.test.notifyPass("test-finished"); + } + + browser.tabs.onUpdated.addListener(async function (tabId, updatedInfo) { + if ("discarded" in updatedInfo) { + browser.test.assertEq( + tabId, + tabs[0].id, + "discarding tab triggered onUpdated" + ); + let discardedTab = await browser.tabs.get(tabs[0].id); + browser.test.assertEq( + true, + discardedTab.discarded, + "discarded tab discard property" + ); + + await finishTest(); + } + }); + + browser.tabs.discard(tabs[0].id); + }, + }); + + BrowserTestUtils.startLoadingURIString( + gBrowser.browsers[0], + "http://example.com" + ); + await BrowserTestUtils.browserLoaded(gBrowser.browsers[0]); + let tab1 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.com" + ); + let tab2 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.com" + ); + + await extension.startup(); + + await extension.awaitFinish("test-finished"); + await extension.unload(); + + BrowserTestUtils.removeTab(tab1); + BrowserTestUtils.removeTab(tab2); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_discard_reversed.js b/browser/components/extensions/test/browser/browser_ext_tabs_discard_reversed.js new file mode 100644 index 0000000000..5fad30a6fb --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_discard_reversed.js @@ -0,0 +1,129 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function tabs_discarded_load_and_discard() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs", "webNavigation"], + }, + async background() { + browser.test.sendMessage("ready"); + const SHIP = await new Promise(resolve => + browser.test.onMessage.addListener((msg, data) => { + resolve(data); + }) + ); + + const PAGE_URL_BEFORE = "http://example.com/initiallyDiscarded"; + const PAGE_URL = + "http://example.com/browser/browser/components/extensions/test/browser/file_dummy.html"; + // Tabs without titles default to URLs without scheme, according to the + // logic of tabbrowser.js's setTabTitle/_setTabLabel. + // TODO bug 1695512: discarded tabs should also follow this logic instead + // of using the unmodified original URL. + const PAGE_TITLE_BEFORE = PAGE_URL_BEFORE; + const PAGE_TITLE_INITIAL = PAGE_URL.replace("http://", ""); + const PAGE_TITLE = "Dummy test page"; + + function assertDeepEqual(expected, actual, message) { + browser.test.assertDeepEq(expected, actual, message); + } + + let tab = await browser.tabs.create({ + url: PAGE_URL_BEFORE, + discarded: true, + }); + const TAB_ID = tab.id; + browser.test.assertTrue(tab.discarded, "Tab initially discarded"); + browser.test.assertEq(PAGE_URL_BEFORE, tab.url, "Initial URL"); + browser.test.assertEq(PAGE_TITLE_BEFORE, tab.title, "Initial title"); + + const observedChanges = { + discarded: [], + title: [], + url: [], + }; + function tabsOnUpdatedAfterLoad(tabId, changeInfo, tab) { + browser.test.assertEq(TAB_ID, tabId, "tabId for tabs.onUpdated"); + for (let [prop, value] of Object.entries(changeInfo)) { + observedChanges[prop].push(value); + } + } + browser.tabs.onUpdated.addListener(tabsOnUpdatedAfterLoad, { + properties: ["discarded", "url", "title"], + }); + + // Load new URL to transition from discarded:true to discarded:false. + await new Promise(resolve => { + browser.webNavigation.onCompleted.addListener(details => { + browser.test.assertEq(TAB_ID, details.tabId, "onCompleted for tab"); + browser.test.assertEq(PAGE_URL, details.url, "URL ater load"); + resolve(); + }); + browser.tabs.update(TAB_ID, { url: PAGE_URL }); + }); + assertDeepEqual( + [false], + observedChanges.discarded, + "changes to tab.discarded after update" + ); + // TODO bug 1669356: the tabs.onUpdated events should only see the + // requested URL and its title. However, the current implementation + // reports several events (including url/title "changes") as part of + // "restoring" the lazy browser prior to loading the requested URL. + + let expectedUrlChanges = [PAGE_URL_BEFORE, PAGE_URL]; + if (SHIP && observedChanges.url.length === 1) { + // Except when SHIP is enabled, which turns this into a race, + // so sometimes only the final URL is seen (see bug 1696815#c22). + expectedUrlChanges = [PAGE_URL]; + } + + assertDeepEqual( + expectedUrlChanges, + observedChanges.url, + "changes to tab.url after update" + ); + assertDeepEqual( + [PAGE_TITLE_INITIAL, PAGE_TITLE], + observedChanges.title, + "changes to tab.title after update" + ); + + tab = await browser.tabs.get(TAB_ID); + browser.test.assertFalse(tab.discarded, "tab.discarded after load"); + browser.test.assertEq(PAGE_URL, tab.url, "tab.url after load"); + browser.test.assertEq(PAGE_TITLE, tab.title, "tab.title after load"); + + // Reset counters. + observedChanges.discarded.length = 0; + observedChanges.title.length = 0; + observedChanges.url.length = 0; + + // Transition from discarded:false to discarded:true + await browser.tabs.discard(TAB_ID); + assertDeepEqual( + [true], + observedChanges.discarded, + "changes to tab.discarded after discard" + ); + assertDeepEqual([], observedChanges.url, "tab.url not changed"); + assertDeepEqual([], observedChanges.title, "tab.title not changed"); + + tab = await browser.tabs.get(TAB_ID); + browser.test.assertTrue(tab.discarded, "tab.discarded after discard"); + browser.test.assertEq(PAGE_URL, tab.url, "tab.url after discard"); + browser.test.assertEq(PAGE_TITLE, tab.title, "tab.title after discard"); + + await browser.tabs.remove(TAB_ID); + browser.test.sendMessage("done"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + extension.sendMessage("SHIP", Services.appinfo.sessionHistoryInParent); + await extension.awaitMessage("done"); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_discarded.js b/browser/components/extensions/test/browser/browser_ext_tabs_discarded.js new file mode 100644 index 0000000000..48c57b5a05 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_discarded.js @@ -0,0 +1,386 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* global gBrowser SessionStore */ +"use strict"; + +const triggeringPrincipal_base64 = E10SUtils.SERIALIZED_SYSTEMPRINCIPAL; + +let lazyTabState = { + entries: [ + { + url: "http://example.com/", + triggeringPrincipal_base64, + title: "Example Domain", + }, + ], +}; + +add_task(async function test_discarded() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs", "webNavigation"], + }, + + background() { + browser.webNavigation.onCompleted.addListener( + async details => { + browser.test.log(`webNav onCompleted received for ${details.tabId}`); + let updatedTab = await browser.tabs.get(details.tabId); + browser.test.assertEq( + false, + updatedTab.discarded, + "lazy to non-lazy update discard property" + ); + browser.test.notifyPass("test-finished"); + }, + { url: [{ hostContains: "example.com" }] } + ); + + browser.tabs.onCreated.addListener(function (tab) { + browser.test.assertEq( + true, + tab.discarded, + "non-lazy tab onCreated discard property" + ); + browser.tabs.update(tab.id, { active: true }); + }); + }, + }); + + await extension.startup(); + + let testTab = BrowserTestUtils.addTab(gBrowser, "about:blank", { + createLazyBrowser: true, + }); + SessionStore.setTabState(testTab, lazyTabState); + + await extension.awaitFinish("test-finished"); + await extension.unload(); + + BrowserTestUtils.removeTab(testTab); +}); + +// Regression test for Bug 1819794. +add_task(async function test_create_discarded_with_cookieStoreId() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["contextualIdentities", "cookies"], + }, + async background() { + const [{ cookieStoreId }] = await browser.contextualIdentities.query({}); + browser.test.assertEq( + "firefox-container-1", + cookieStoreId, + "Got expected cookieStoreId" + ); + await browser.tabs.create({ + url: `http://example.com/#${cookieStoreId}`, + cookieStoreId, + discarded: true, + }); + await browser.tabs.create({ + url: `http://example.com/#no-container`, + discarded: true, + }); + }, + // Needed by ExtensionSettingsStore (as a side-effect of contextualIdentities permission). + useAddonManager: "temporary", + }); + + const tabContainerPromise = BrowserTestUtils.waitForEvent( + window, + "TabOpen", + false, + evt => { + return evt.target.getAttribute("usercontextid", "1"); + } + ).then(evt => evt.target); + const tabDefaultPromise = BrowserTestUtils.waitForEvent( + window, + "TabOpen", + false, + evt => { + return !evt.target.hasAttribute("usercontextid"); + } + ).then(evt => evt.target); + + await extension.startup(); + + const tabContainer = await tabContainerPromise; + ok( + tabContainer.hasAttribute("pending"), + "new container tab should be discarded" + ); + const tabContainerState = SessionStore.getTabState(tabContainer); + is( + JSON.parse(tabContainerState).userContextId, + 1, + `Expect a userContextId associated to the new discarded container tab: ${tabContainerState}` + ); + + const tabDefault = await tabDefaultPromise; + ok( + tabDefault.hasAttribute("pending"), + "new non-container tab should be discarded" + ); + const tabDefaultState = SessionStore.getTabState(tabDefault); + is( + JSON.parse(tabDefaultState).userContextId, + 0, + `Expect userContextId 0 associated to the new discarded non-container tab: ${tabDefaultState}` + ); + + BrowserTestUtils.removeTab(tabContainer); + BrowserTestUtils.removeTab(tabDefault); + await extension.unload(); +}); + +// If discard is called immediately after creating a new tab, the new tab may not have loaded, +// and the sessionstore for that tab is not ready for discarding. The result was a corrupted +// sessionstore for the tab, which when the tab was activated, resulted in a tab with partial +// state. +add_task(async function test_create_then_discard() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs", "webNavigation"], + }, + + background: async function () { + let createdTab; + + browser.tabs.onUpdated.addListener((tabId, updatedInfo) => { + if (!updatedInfo.discarded) { + return; + } + + browser.webNavigation.onCompleted.addListener( + async details => { + browser.test.assertEq( + createdTab.id, + details.tabId, + "created tab navigation is completed" + ); + let activeTab = await browser.tabs.get(details.tabId); + browser.test.assertEq( + "http://example.com/", + details.url, + "created tab url is correct" + ); + browser.test.assertEq( + "http://example.com/", + activeTab.url, + "created tab url is correct" + ); + browser.tabs.remove(details.tabId); + browser.test.notifyPass("test-finished"); + }, + { url: [{ hostContains: "example.com" }] } + ); + + browser.tabs.update(tabId, { active: true }); + }); + + createdTab = await browser.tabs.create({ + url: "http://example.com/", + active: false, + }); + browser.tabs.discard(createdTab.id); + }, + }); + await extension.startup(); + await extension.awaitFinish("test-finished"); + await extension.unload(); +}); + +add_task(async function test_create_discarded() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs", "webNavigation"], + }, + + background() { + let tabOpts = { + url: "http://example.com/", + active: false, + discarded: true, + title: "discarded tab", + }; + + browser.webNavigation.onCompleted.addListener( + async details => { + let activeTab = await browser.tabs.get(details.tabId); + browser.test.assertEq( + tabOpts.url, + activeTab.url, + "restored tab url matches active tab url" + ); + browser.test.assertEq( + "mochitest index /", + activeTab.title, + "restored tab title is correct" + ); + browser.tabs.remove(details.tabId); + browser.test.notifyPass("test-finished"); + }, + { url: [{ hostContains: "example.com" }] } + ); + + browser.tabs.onCreated.addListener(tab => { + browser.test.assertEq( + tabOpts.active, + tab.active, + "lazy tab is not active" + ); + browser.test.assertEq( + tabOpts.discarded, + tab.discarded, + "lazy tab is discarded" + ); + browser.test.assertEq(tabOpts.url, tab.url, "lazy tab url is correct"); + browser.test.assertEq( + tabOpts.title, + tab.title, + "lazy tab title is correct" + ); + browser.tabs.update(tab.id, { active: true }); + }); + + browser.tabs.create(tabOpts); + }, + }); + await extension.startup(); + await extension.awaitFinish("test-finished"); + await extension.unload(); +}); + +add_task(async function test_discarded_private_tab_restored() { + let extension = ExtensionTestUtils.loadExtension({ + incognitoOverride: "spanning", + + background() { + let isDiscarding = false; + browser.tabs.onUpdated.addListener( + async function listener(tabId, changeInfo, tab) { + const { active, discarded, incognito } = tab; + if (!incognito || active || discarded || isDiscarding) { + return; + } + // Remove the onUpdated listener to prevent intermittent failure + // to be hit if the listener gets called again for unrelated + // tabs.onUpdated events that may get fired after the test case got + // the tab-discarded test message that was expecting. + isDiscarding = true; + browser.tabs.onUpdated.removeListener(listener); + browser.test.log( + `Test extension discarding ${tabId}: ${JSON.stringify(changeInfo)}` + ); + await browser.tabs.discard(tabId); + browser.test.sendMessage("tab-discarded"); + }, + { properties: ["status"] } + ); + }, + }); + + // Open a private browsing window. + const privateWin = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + + await extension.startup(); + + const newTab = await BrowserTestUtils.addTab( + privateWin.gBrowser, + "https://example.com/" + ); + await extension.awaitMessage("tab-discarded"); + is(newTab.getAttribute("pending"), "true", "private tab should be discarded"); + + const promiseTabLoaded = BrowserTestUtils.browserLoaded(newTab.linkedBrowser); + + info("Switching to the discarded background tab"); + await BrowserTestUtils.switchTab(privateWin.gBrowser, newTab); + + info("Wait for the restored tab to complete loading"); + await promiseTabLoaded; + is( + newTab.hasAttribute("pending"), + false, + "discarded private tab should have been restored" + ); + + is( + newTab.linkedBrowser.currentURI.spec, + "https://example.com/", + "Got the expected url on the restored tab" + ); + + await extension.unload(); + await BrowserTestUtils.closeWindow(privateWin); +}); + +add_task(async function test_update_discarded() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs", "<all_urls>"], + }, + + background() { + browser.test.onMessage.addListener(async msg => { + let [tab] = await browser.tabs.query({ url: "http://example.com/" }); + if (msg == "update") { + await browser.tabs.update(tab.id, { url: "https://example.com/" }); + } else { + browser.test.fail(`Unexpected message received: ${msg}`); + } + }); + browser.test.sendMessage("ready"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + + let lazyTab = BrowserTestUtils.addTab(gBrowser, "http://example.com/", { + createLazyBrowser: true, + lazyTabTitle: "Example Domain", + }); + + let tabBrowserInsertedPromise = BrowserTestUtils.waitForEvent( + lazyTab, + "TabBrowserInserted" + ); + + SimpleTest.waitForExplicitFinish(); + let waitForConsole = new Promise(resolve => { + SimpleTest.monitorConsole(resolve, [ + { + message: + /Lazy browser prematurely inserted via 'loadURI' property access:/, + forbid: true, + }, + ]); + }); + + extension.sendMessage("update"); + await tabBrowserInsertedPromise; + + await BrowserTestUtils.waitForBrowserStateChange( + lazyTab.linkedBrowser, + "https://example.com/", + stateFlags => { + return ( + stateFlags & Ci.nsIWebProgressListener.STATE_IS_WINDOW && + stateFlags & Ci.nsIWebProgressListener.STATE_STOP + ); + } + ); + + await TestUtils.waitForTick(); + BrowserTestUtils.removeTab(lazyTab); + + await extension.unload(); + + SimpleTest.endMonitorConsole(); + await waitForConsole; +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_duplicate.js b/browser/components/extensions/test/browser/browser_ext_tabs_duplicate.js new file mode 100644 index 0000000000..50c56ea796 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_duplicate.js @@ -0,0 +1,316 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function testDuplicateTab() { + await BrowserTestUtils.openNewForegroundTab(gBrowser, "http://example.net/"); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + background: async function () { + let [source] = await browser.tabs.query({ + lastFocusedWindow: true, + active: true, + }); + + let tab = await browser.tabs.duplicate(source.id); + + browser.test.assertEq( + "http://example.net/", + tab.url, + "duplicated tab should have the same URL as the source tab" + ); + browser.test.assertEq( + source.index + 1, + tab.index, + "duplicated tab should open next to the source tab" + ); + browser.test.assertTrue( + tab.active, + "duplicated tab should be active by default" + ); + + await browser.tabs.remove([source.id, tab.id]); + browser.test.notifyPass("tabs.duplicate"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabs.duplicate"); + await extension.unload(); +}); + +add_task(async function testDuplicateTabLazily() { + async function background() { + let tabLoadComplete = new Promise(resolve => { + browser.test.onMessage.addListener((message, tabId, result) => { + if (message == "duplicate-tab-done") { + resolve(tabId); + } + }); + }); + + function awaitLoad(tabId) { + return new Promise(resolve => { + browser.tabs.onUpdated.addListener(function listener( + tabId_, + changed, + tab + ) { + if (tabId == tabId_ && changed.status == "complete") { + browser.tabs.onUpdated.removeListener(listener); + resolve(); + } + }); + }); + } + + try { + let url = + "http://example.com/browser/browser/components/extensions/test/browser/file_dummy.html"; + let tab = await browser.tabs.create({ url }); + let startTabId = tab.id; + + await awaitLoad(startTabId); + browser.test.sendMessage("duplicate-tab", startTabId); + + let unloadedTabId = await tabLoadComplete; + let loadedtab = await browser.tabs.get(startTabId); + browser.test.assertEq( + "Dummy test page", + loadedtab.title, + "Title should be returned for loaded pages" + ); + browser.test.assertEq( + "complete", + loadedtab.status, + "Tab status should be complete for loaded pages" + ); + + let unloadedtab = await browser.tabs.get(unloadedTabId); + browser.test.assertEq( + "Dummy test page", + unloadedtab.title, + "Title should be returned after page has been unloaded" + ); + + await browser.tabs.remove([tab.id, unloadedTabId]); + browser.test.notifyPass("tabs.hasCorrectTabTitle"); + } catch (e) { + browser.test.fail(`${e} :: ${e.stack}`); + browser.test.notifyFail("tabs.hasCorrectTabTitle"); + } + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + background, + }); + + extension.onMessage("duplicate-tab", tabId => { + const { + Management: { + global: { tabTracker }, + }, + } = ChromeUtils.importESModule("resource://gre/modules/Extension.sys.mjs"); + + let tab = tabTracker.getTab(tabId); + // This is a bit of a hack to load a tab in the background. + let newTab = gBrowser.duplicateTab(tab, true, { skipLoad: true }); + + BrowserTestUtils.waitForEvent(newTab, "SSTabRestored", () => true).then( + () => { + extension.sendMessage("duplicate-tab-done", tabTracker.getId(newTab)); + } + ); + }); + + await extension.startup(); + await extension.awaitFinish("tabs.hasCorrectTabTitle"); + await extension.unload(); +}); + +add_task(async function testDuplicatePinnedTab() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.net/" + ); + gBrowser.pinTab(tab); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + background: async function () { + let [source] = await browser.tabs.query({ + lastFocusedWindow: true, + active: true, + }); + let tab = await browser.tabs.duplicate(source.id); + + browser.test.assertEq( + source.index + 1, + tab.index, + "duplicated tab should open next to the source tab" + ); + browser.test.assertFalse( + tab.pinned, + "duplicated tab should not be pinned by default, even if source tab is" + ); + + await browser.tabs.remove([source.id, tab.id]); + browser.test.notifyPass("tabs.duplicate.pinned"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabs.duplicate.pinned"); + await extension.unload(); +}); + +add_task(async function testDuplicateTabInBackground() { + await BrowserTestUtils.openNewForegroundTab(gBrowser, "http://example.net/"); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + background: async function () { + let tabs = await browser.tabs.query({ + lastFocusedWindow: true, + active: true, + }); + let tab = await browser.tabs.duplicate(tabs[0].id, { active: false }); + // Should not be the active tab + browser.test.assertFalse(tab.active); + + await browser.tabs.remove([tabs[0].id, tab.id]); + browser.test.notifyPass("tabs.duplicate.background"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabs.duplicate.background"); + await extension.unload(); +}); + +add_task(async function testDuplicateTabAtIndex() { + await BrowserTestUtils.openNewForegroundTab(gBrowser, "http://example.net/"); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + background: async function () { + let tabs = await browser.tabs.query({ + lastFocusedWindow: true, + active: true, + }); + let tab = await browser.tabs.duplicate(tabs[0].id, { index: 0 }); + browser.test.assertEq(0, tab.index); + + await browser.tabs.remove([tabs[0].id, tab.id]); + browser.test.notifyPass("tabs.duplicate.index"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabs.duplicate.index"); + await extension.unload(); +}); + +add_task(async function testDuplicatePinnedTabAtIncorrectIndex() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.net/" + ); + gBrowser.pinTab(tab); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + background: async function () { + let tabs = await browser.tabs.query({ + lastFocusedWindow: true, + active: true, + }); + let tab = await browser.tabs.duplicate(tabs[0].id, { index: 0 }); + browser.test.assertEq(1, tab.index); + browser.test.assertFalse( + tab.pinned, + "Duplicated tab should not be pinned" + ); + + await browser.tabs.remove([tabs[0].id, tab.id]); + browser.test.notifyPass("tabs.duplicate.incorrect_index"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabs.duplicate.incorrect_index"); + await extension.unload(); +}); + +add_task(async function testDuplicateResolvePromiseRightAway() { + await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://mochi.test:8888/browser/browser/components/extensions/test/browser/file_slowed_document.sjs" + ); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + // The host permission matches the above URL. No :8888 due to bug 1468162. + permissions: ["tabs", "http://mochi.test/"], + }, + + background: async function () { + let [source] = await browser.tabs.query({ + lastFocusedWindow: true, + active: true, + }); + + let resolvedRightAway = true; + browser.tabs.onUpdated.addListener( + (tabId, changeInfo, tab) => { + resolvedRightAway = false; + }, + { urls: [source.url] } + ); + + let tab = await browser.tabs.duplicate(source.id); + // if the promise is resolved before any onUpdated event has been fired, + // then the promise has been resolved before waiting for the tab to load + browser.test.assertTrue( + resolvedRightAway, + "tabs.duplicate() should resolve as soon as possible" + ); + + // Regression test for bug 1559216 + let code = "document.URL"; + let [result] = await browser.tabs.executeScript(tab.id, { code }); + browser.test.assertEq( + source.url, + result, + "APIs such as tabs.executeScript should be queued until tabs.duplicate has restored the tab" + ); + + await browser.tabs.remove([source.id, tab.id]); + browser.test.notifyPass("tabs.duplicate.resolvePromiseRightAway"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabs.duplicate.resolvePromiseRightAway"); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_events.js b/browser/components/extensions/test/browser/browser_ext_tabs_events.js new file mode 100644 index 0000000000..fe9317b4a6 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_events.js @@ -0,0 +1,794 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +// A single monitor for the tests. If it receives any +// incognito data in event listeners it will fail. +let monitor; +add_task(async function startup() { + monitor = await startIncognitoMonitorExtension(); +}); +registerCleanupFunction(async function finish() { + await monitor.unload(); +}); + +// Test tab events from private windows, the monitor above will fail +// if it receives any. +add_task(async function test_tab_events_incognito_monitored() { + async function background() { + let incognito = true; + let events = []; + let eventPromise; + let checkEvents = () => { + if (eventPromise && events.length >= eventPromise.names.length) { + eventPromise.resolve(); + } + }; + + browser.tabs.onCreated.addListener(tab => { + events.push({ type: "onCreated", tab }); + checkEvents(); + }); + + browser.tabs.onAttached.addListener((tabId, info) => { + events.push(Object.assign({ type: "onAttached", tabId }, info)); + checkEvents(); + }); + + browser.tabs.onDetached.addListener((tabId, info) => { + events.push(Object.assign({ type: "onDetached", tabId }, info)); + checkEvents(); + }); + + browser.tabs.onRemoved.addListener((tabId, info) => { + events.push(Object.assign({ type: "onRemoved", tabId }, info)); + checkEvents(); + }); + + browser.tabs.onMoved.addListener((tabId, info) => { + events.push(Object.assign({ type: "onMoved", tabId }, info)); + checkEvents(); + }); + + async function expectEvents(names) { + browser.test.log(`Expecting events: ${names.join(", ")}`); + + await new Promise(resolve => { + eventPromise = { names, resolve }; + checkEvents(); + }); + + browser.test.assertEq( + names.length, + events.length, + "Got expected number of events" + ); + for (let [i, name] of names.entries()) { + browser.test.assertEq( + name, + i in events && events[i].type, + `Got expected ${name} event` + ); + } + return events.splice(0); + } + + try { + let firstWindow = await browser.windows.create({ + url: "about:blank", + incognito, + }); + let otherWindow = await browser.windows.create({ + url: "about:blank", + incognito, + }); + + let windowId = firstWindow.id; + let otherWindowId = otherWindow.id; + + // Wait for a tab in each window + await expectEvents(["onCreated", "onCreated"]); + let initialTab = ( + await browser.tabs.query({ + active: true, + windowId: otherWindowId, + }) + )[0]; + + browser.test.log("Create tab in window 1"); + let tab = await browser.tabs.create({ + windowId, + index: 0, + url: "about:blank", + }); + let oldIndex = tab.index; + browser.test.assertEq(0, oldIndex, "Tab has the expected index"); + browser.test.assertEq(tab.incognito, incognito, "Tab is incognito"); + + let [created] = await expectEvents(["onCreated"]); + browser.test.assertEq(tab.id, created.tab.id, "Got expected tab ID"); + browser.test.assertEq( + oldIndex, + created.tab.index, + "Got expected tab index" + ); + + browser.test.log("Move tab to window 2"); + await browser.tabs.move([tab.id], { windowId: otherWindowId, index: 0 }); + + let [detached, attached] = await expectEvents([ + "onDetached", + "onAttached", + ]); + browser.test.assertEq( + tab.id, + detached.tabId, + "Expected onDetached tab ID" + ); + browser.test.assertEq( + oldIndex, + detached.oldPosition, + "Expected old index" + ); + browser.test.assertEq( + windowId, + detached.oldWindowId, + "Expected old window ID" + ); + + browser.test.assertEq( + tab.id, + attached.tabId, + "Expected onAttached tab ID" + ); + browser.test.assertEq(0, attached.newPosition, "Expected new index"); + browser.test.assertEq( + otherWindowId, + attached.newWindowId, + "Expected new window ID" + ); + + browser.test.log("Move tab within the same window"); + let [moved] = await browser.tabs.move([tab.id], { index: 1 }); + browser.test.assertEq(1, moved.index, "Expected new index"); + + [moved] = await expectEvents(["onMoved"]); + browser.test.assertEq(tab.id, moved.tabId, "Expected tab ID"); + browser.test.assertEq(0, moved.fromIndex, "Expected old index"); + browser.test.assertEq(1, moved.toIndex, "Expected new index"); + browser.test.assertEq( + otherWindowId, + moved.windowId, + "Expected window ID" + ); + + browser.test.log("Remove tab"); + await browser.tabs.remove(tab.id); + let [removed] = await expectEvents(["onRemoved"]); + + browser.test.assertEq( + tab.id, + removed.tabId, + "Expected removed tab ID for tabs.remove" + ); + browser.test.assertEq( + otherWindowId, + removed.windowId, + "Expected removed tab window ID" + ); + // Note: We want to test for the actual boolean value false here. + browser.test.assertEq( + false, + removed.isWindowClosing, + "Expected isWindowClosing value" + ); + + browser.test.log("Close second window"); + await browser.windows.remove(otherWindowId); + [removed] = await expectEvents(["onRemoved"]); + browser.test.assertEq( + initialTab.id, + removed.tabId, + "Expected removed tab ID for windows.remove" + ); + browser.test.assertEq( + otherWindowId, + removed.windowId, + "Expected removed tab window ID" + ); + browser.test.assertEq( + true, + removed.isWindowClosing, + "Expected isWindowClosing value" + ); + + browser.test.log("Create additional tab in window 1"); + tab = await browser.tabs.create({ windowId, url: "about:blank" }); + await expectEvents(["onCreated"]); + browser.test.assertEq(tab.incognito, incognito, "Tab is incognito"); + + browser.test.log("Create a new window, adopting the new tab"); + // We have to explicitly wait for the event here, since its timing is + // not predictable. + let promiseAttached = new Promise(resolve => { + browser.tabs.onAttached.addListener(function listener(tabId) { + browser.tabs.onAttached.removeListener(listener); + resolve(); + }); + }); + + let [window] = await Promise.all([ + browser.windows.create({ tabId: tab.id, incognito }), + promiseAttached, + ]); + + [detached, attached] = await expectEvents(["onDetached", "onAttached"]); + + browser.test.assertEq( + tab.id, + detached.tabId, + "Expected onDetached tab ID" + ); + browser.test.assertEq( + 1, + detached.oldPosition, + "Expected onDetached old index" + ); + browser.test.assertEq( + windowId, + detached.oldWindowId, + "Expected onDetached old window ID" + ); + + browser.test.assertEq( + tab.id, + attached.tabId, + "Expected onAttached tab ID" + ); + browser.test.assertEq( + 0, + attached.newPosition, + "Expected onAttached new index" + ); + browser.test.assertEq( + window.id, + attached.newWindowId, + "Expected onAttached new window id" + ); + + browser.test.log( + "Close the new window by moving the tab into former window" + ); + await browser.tabs.move(tab.id, { index: 1, windowId }); + [detached, attached] = await expectEvents(["onDetached", "onAttached"]); + + browser.test.assertEq( + tab.id, + detached.tabId, + "Expected onDetached tab ID" + ); + browser.test.assertEq( + 0, + detached.oldPosition, + "Expected onDetached old index" + ); + browser.test.assertEq( + window.id, + detached.oldWindowId, + "Expected onDetached old window ID" + ); + + browser.test.assertEq( + tab.id, + attached.tabId, + "Expected onAttached tab ID" + ); + browser.test.assertEq( + 1, + attached.newPosition, + "Expected onAttached new index" + ); + browser.test.assertEq( + windowId, + attached.newWindowId, + "Expected onAttached new window id" + ); + browser.test.assertEq(tab.incognito, incognito, "Tab is incognito"); + + browser.test.log("Remove the tab"); + await browser.tabs.remove(tab.id); + browser.windows.remove(windowId); + + browser.test.notifyPass("tabs-events"); + } catch (e) { + browser.test.fail(`${e} :: ${e.stack}`); + browser.test.notifyFail("tabs-events"); + } + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + background, + incognitoOverride: "spanning", + }); + + await extension.startup(); + await extension.awaitFinish("tabs-events"); + await extension.unload(); +}); + +add_task(async function testTabEventsSize() { + function background() { + function sendSizeMessages(tab, type) { + browser.test.sendMessage(`${type}-dims`, { + width: tab.width, + height: tab.height, + }); + } + + browser.tabs.onCreated.addListener(tab => { + sendSizeMessages(tab, "on-created"); + }); + + browser.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { + if (changeInfo.status == "complete") { + sendSizeMessages(tab, "on-updated"); + } + }); + + browser.test.onMessage.addListener(async (msg, arg) => { + if (msg === "create-tab") { + let tab = await browser.tabs.create({ url: "https://example.com/" }); + sendSizeMessages(tab, "create"); + browser.test.sendMessage("created-tab-id", tab.id); + } else if (msg === "update-tab") { + let tab = await browser.tabs.update(arg, { + url: "https://example.org/", + }); + sendSizeMessages(tab, "update"); + } else if (msg === "remove-tab") { + browser.tabs.remove(arg); + browser.test.sendMessage("tab-removed"); + } + }); + + browser.test.sendMessage("ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + background, + }); + + const RESOLUTION_PREF = "layout.css.devPixelsPerPx"; + registerCleanupFunction(() => { + SpecialPowers.clearUserPref(RESOLUTION_PREF); + }); + + function checkDimensions(dims, type) { + is( + dims.width, + gBrowser.selectedBrowser.clientWidth, + `tab from ${type} reports expected width` + ); + is( + dims.height, + gBrowser.selectedBrowser.clientHeight, + `tab from ${type} reports expected height` + ); + } + + await Promise.all([extension.startup(), extension.awaitMessage("ready")]); + + for (let resolution of [2, 1]) { + SpecialPowers.setCharPref(RESOLUTION_PREF, String(resolution)); + is( + window.devicePixelRatio, + resolution, + "window has the required resolution" + ); + + extension.sendMessage("create-tab"); + let tabId = await extension.awaitMessage("created-tab-id"); + + checkDimensions(await extension.awaitMessage("create-dims"), "create"); + checkDimensions( + await extension.awaitMessage("on-created-dims"), + "onCreated" + ); + checkDimensions( + await extension.awaitMessage("on-updated-dims"), + "onUpdated" + ); + + extension.sendMessage("update-tab", tabId); + + checkDimensions(await extension.awaitMessage("update-dims"), "update"); + checkDimensions( + await extension.awaitMessage("on-updated-dims"), + "onUpdated" + ); + + extension.sendMessage("remove-tab", tabId); + await extension.awaitMessage("tab-removed"); + } + + await extension.unload(); + SpecialPowers.clearUserPref(RESOLUTION_PREF); +}).skip(); // Bug 1614075 perma-fail comparing devicePixelRatio + +add_task(async function testTabRemovalEvent() { + async function background() { + let events = []; + + function awaitLoad(tabId) { + return new Promise(resolve => { + browser.tabs.onUpdated.addListener(function listener( + tabId_, + changed, + tab + ) { + if (tabId == tabId_ && changed.status == "complete") { + browser.tabs.onUpdated.removeListener(listener); + resolve(); + } + }); + }); + } + + chrome.tabs.onRemoved.addListener((tabId, info) => { + browser.test.assertEq( + 0, + events.length, + "No events recorded before onRemoved." + ); + events.push("onRemoved"); + browser.test.log( + "Make sure the removed tab is not available in the tabs.query callback." + ); + chrome.tabs.query({}, tabs => { + for (let tab of tabs) { + browser.test.assertTrue( + tab.id != tabId, + "Tab query should not include removed tabId" + ); + } + }); + }); + + try { + let url = + "https://example.com/browser/browser/components/extensions/test/browser/context.html"; + let tab = await browser.tabs.create({ url: url }); + await awaitLoad(tab.id); + + chrome.tabs.onActivated.addListener(info => { + browser.test.assertEq( + 1, + events.length, + "One event recorded before onActivated." + ); + events.push("onActivated"); + browser.test.assertEq( + "onRemoved", + events[0], + "onRemoved fired before onActivated." + ); + browser.test.notifyPass("tabs-events"); + }); + + await browser.tabs.remove(tab.id); + } catch (e) { + browser.test.fail(`${e} :: ${e.stack}`); + browser.test.notifyFail("tabs-events"); + } + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + background, + }); + + await extension.startup(); + await extension.awaitFinish("tabs-events"); + await extension.unload(); +}); + +add_task(async function testTabCreateRelated() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.tabs.opentabfor.middleclick", true], + ["browser.tabs.insertRelatedAfterCurrent", true], + ], + }); + + async function background() { + let created; + browser.tabs.onCreated.addListener(tab => { + browser.test.log(`tabs.onCreated, index=${tab.index}`); + browser.test.assertEq(1, tab.index, "expecting tab index of 1"); + created = tab.id; + }); + browser.tabs.onMoved.addListener((id, info) => { + browser.test.log( + `tabs.onMoved, from ${info.fromIndex} to ${info.toIndex}` + ); + browser.test.fail("tabMoved was received"); + }); + browser.tabs.onRemoved.addListener((tabId, info) => { + browser.test.assertEq(created, tabId, "removed id same as created"); + browser.test.sendMessage("tabRemoved"); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + background, + }); + + // Create a *opener* tab page which has a link to "example.com". + let pageURL = + "https://example.com/browser/browser/components/extensions/test/browser/file_dummy.html"; + let openerTab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + pageURL + ); + gBrowser.moveTabTo(openerTab, 0); + + await extension.startup(); + + let newTabPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + "https://example.com/#linkclick", + true + ); + await BrowserTestUtils.synthesizeMouseAtCenter( + "#link_to_example_com", + { button: 1 }, + gBrowser.selectedBrowser + ); + let openTab = await newTabPromise; + is( + openTab.linkedBrowser.currentURI.spec, + "https://example.com/#linkclick", + "Middle click should open site to correct url." + ); + BrowserTestUtils.removeTab(openTab); + + await extension.awaitMessage("tabRemoved"); + await extension.unload(); + + BrowserTestUtils.removeTab(openerTab); +}); + +add_task(async function testLastTabRemoval() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.tabs.closeWindowWithLastTab", false]], + }); + + async function background() { + let windowId; + browser.tabs.onCreated.addListener(tab => { + browser.test.assertEq( + windowId, + tab.windowId, + "expecting onCreated after onRemoved on the same window" + ); + browser.test.sendMessage("tabCreated", `${tab.width}x${tab.height}`); + }); + browser.tabs.onRemoved.addListener((tabId, info) => { + windowId = info.windowId; + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + background, + }); + + let newWin = await BrowserTestUtils.openNewBrowserWindow(); + await extension.startup(); + + const oldBrowser = newWin.gBrowser.selectedBrowser; + const expectedDims = `${oldBrowser.clientWidth}x${oldBrowser.clientHeight}`; + BrowserTestUtils.removeTab(newWin.gBrowser.selectedTab); + + const actualDims = await extension.awaitMessage("tabCreated"); + is( + actualDims, + expectedDims, + "created tab reports a size same to the removed last tab" + ); + + await extension.unload(); + await BrowserTestUtils.closeWindow(newWin); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function testTabActivationEvent() { + async function background() { + function makeExpectable() { + let expectation = null, + resolver = null; + const expectable = param => { + if (expectation === null) { + browser.test.fail("unexpected call to expectable"); + } else { + try { + resolver(expectation(param)); + } catch (e) { + resolver(Promise.reject(e)); + } finally { + expectation = null; + } + } + }; + expectable.expect = e => { + expectation = e; + return new Promise(r => { + resolver = r; + }); + }; + return expectable; + } + try { + const listener = makeExpectable(); + browser.tabs.onActivated.addListener(listener); + + const [ + , + { + tabs: [tab1], + }, + ] = await Promise.all([ + listener.expect(info => { + browser.test.assertEq( + undefined, + info.previousTabId, + "previousTabId should not be defined when window is first opened" + ); + }), + browser.windows.create({ url: "about:blank" }), + ]); + const [, tab2] = await Promise.all([ + listener.expect(info => { + browser.test.assertEq( + tab1.id, + info.previousTabId, + "Got expected previousTabId" + ); + }), + browser.tabs.create({ url: "about:blank" }), + ]); + + await Promise.all([ + listener.expect(info => { + browser.test.assertEq(tab1.id, info.tabId, "Got expected tabId"); + browser.test.assertEq( + tab2.id, + info.previousTabId, + "Got expected previousTabId" + ); + }), + browser.tabs.update(tab1.id, { active: true }), + ]); + + await Promise.all([ + listener.expect(info => { + browser.test.assertEq(tab2.id, info.tabId, "Got expected tabId"); + browser.test.assertEq( + undefined, + info.previousTabId, + "previousTabId should not be defined when previous tab was closed" + ); + }), + browser.tabs.remove(tab1.id), + ]); + + await browser.tabs.remove(tab2.id); + + browser.test.notifyPass("tabs-events"); + } catch (e) { + browser.test.fail(`${e} :: ${e.stack}`); + browser.test.notifyFail("tabs-events"); + } + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + background, + }); + + await extension.startup(); + await extension.awaitFinish("tabs-events"); + await extension.unload(); +}); + +add_task(async function test_tabs_event_page() { + await SpecialPowers.pushPrefEnv({ + set: [["extensions.eventPages.enabled", true]], + }); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + browser_specific_settings: { gecko: { id: "eventpage@tabs" } }, + permissions: ["tabs"], + background: { persistent: false }, + }, + background() { + const EVENTS = [ + "onActivated", + "onAttached", + "onDetached", + "onRemoved", + "onMoved", + "onHighlighted", + "onUpdated", + ]; + browser.tabs.onCreated.addListener(() => { + browser.test.sendMessage("onCreated"); + }); + for (let event of EVENTS) { + browser.tabs[event].addListener(() => {}); + } + browser.test.sendMessage("ready"); + }, + }); + + const EVENTS = [ + "onActivated", + "onAttached", + "onCreated", + "onDetached", + "onRemoved", + "onMoved", + "onHighlighted", + "onUpdated", + ]; + + await extension.startup(); + await extension.awaitMessage("ready"); + for (let event of EVENTS) { + assertPersistentListeners(extension, "tabs", event, { + primed: false, + }); + } + + // test events waken background + await extension.terminateBackground(); + for (let event of EVENTS) { + assertPersistentListeners(extension, "tabs", event, { + primed: true, + }); + } + + let win = await BrowserTestUtils.openNewBrowserWindow(); + + await extension.awaitMessage("ready"); + await extension.awaitMessage("onCreated"); + ok(true, "persistent event woke background"); + for (let event of EVENTS) { + assertPersistentListeners(extension, "tabs", event, { + primed: false, + }); + } + await BrowserTestUtils.closeWindow(win); + + await extension.unload(); + await SpecialPowers.popPrefEnv(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_events_order.js b/browser/components/extensions/test/browser/browser_ext_tabs_events_order.js new file mode 100644 index 0000000000..8e86d72c90 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_events_order.js @@ -0,0 +1,208 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ +"use strict"; + +add_task(async function testTabEvents() { + async function background() { + /** The list of active tab ID's */ + let tabIds = []; + + /** + * Stores the events that fire for each tab. + * + * events { + * tabId1: [event1, event2, ...], + * tabId2: [event1, event2, ...], + * } + */ + let events = {}; + + browser.tabs.onActivated.addListener(info => { + if (info.tabId in events) { + events[info.tabId].push("onActivated"); + } else { + events[info.tabId] = ["onActivated"]; + } + }); + + browser.tabs.onCreated.addListener(info => { + if (info.id in events) { + events[info.id].push("onCreated"); + } else { + events[info.id] = ["onCreated"]; + } + }); + + browser.tabs.onHighlighted.addListener(info => { + if (info.tabIds[0] in events) { + events[info.tabIds[0]].push("onHighlighted"); + } else { + events[info.tabIds[0]] = ["onHighlighted"]; + } + }); + + /** + * Asserts that the expected events are fired for the tab with id = tabId. + * The events associated to the specified tab are removed after this check is made. + * + * @param {number} tabId + * @param {Array<string>} expectedEvents + */ + async function expectEvents(tabId, expectedEvents) { + browser.test.log(`Expecting events: ${expectedEvents.join(", ")}`); + + // Wait up to 5000 ms for the expected number of events. + for ( + let i = 0; + i < 50 && + (!events[tabId] || events[tabId].length < expectedEvents.length); + i++ + ) { + await new Promise(resolve => setTimeout(resolve, 100)); + } + + browser.test.assertEq( + expectedEvents.length, + events[tabId].length, + `Got expected number of events for ${tabId}` + ); + + for (let name of expectedEvents) { + browser.test.assertTrue( + events[tabId].includes(name), + `Got expected ${name} event` + ); + } + + if (expectedEvents.includes("onCreated")) { + browser.test.assertEq( + events[tabId].indexOf("onCreated"), + 0, + "onCreated happened first" + ); + } + + delete events[tabId]; + } + + /** + * Opens a new tab and asserts that the correct events are fired. + * + * @param {number} windowId + */ + async function openTab(windowId) { + browser.test.assertEq( + 0, + Object.keys(events).length, + "No events remaining before testing openTab." + ); + + let tab = await browser.tabs.create({ windowId }); + + tabIds.push(tab.id); + browser.test.log(`Opened tab ${tab.id}`); + + await expectEvents(tab.id, ["onCreated", "onActivated", "onHighlighted"]); + } + + /** + * Opens a new window and asserts that the correct events are fired. + * + * @param {Array} urls A list of urls for which to open tabs in the new window. + */ + async function openWindow(urls) { + browser.test.assertEq( + 0, + Object.keys(events).length, + "No events remaining before testing openWindow." + ); + + let window = await browser.windows.create({ url: urls }); + browser.test.log(`Opened new window ${window.id}`); + + for (let [i] of urls.entries()) { + let tab = window.tabs[i]; + tabIds.push(tab.id); + + let expectedEvents = ["onCreated", "onActivated", "onHighlighted"]; + if (i !== 0) { + expectedEvents.splice(1); + } + await expectEvents(window.tabs[i].id, expectedEvents); + } + } + + /** + * Highlights an existing tab and asserts that the correct events are fired. + * + * @param {number} tabId + */ + async function highlightTab(tabId) { + browser.test.assertEq( + 0, + Object.keys(events).length, + "No events remaining before testing highlightTab." + ); + + browser.test.log(`Highlighting tab ${tabId}`); + let tab = await browser.tabs.update(tabId, { active: true }); + + browser.test.assertEq(tab.id, tabId, `Tab ${tab.id} highlighted`); + + await expectEvents(tab.id, ["onActivated", "onHighlighted"]); + } + + /** + * The main entry point to the tests. + */ + let tabs = await browser.tabs.query({ active: true, currentWindow: true }); + + let activeWindow = tabs[0].windowId; + await Promise.all([ + openTab(activeWindow), + openTab(activeWindow), + openTab(activeWindow), + ]); + + await Promise.all([ + highlightTab(tabIds[0]), + highlightTab(tabIds[1]), + highlightTab(tabIds[2]), + ]); + + // If we open these windows in parallel, there is a risk + // that a window will be occluded by the next one before + // it can finish first paint, which will prevent + // the firing of browser-delayed-startup-finished + await openWindow(["http://example.com"]); + await openWindow(["http://example.com", "http://example.org"]); + await openWindow([ + "http://example.com", + "http://example.org", + "http://example.net", + ]); + + browser.test.assertEq( + 0, + Object.keys(events).length, + "No events remaining after tests." + ); + + await Promise.all(tabIds.map(id => browser.tabs.remove(id))); + + browser.test.notifyPass("tabs.highlight"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + background, + }); + + await extension.startup(); + await extension.awaitFinish("tabs.highlight"); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_executeScript.js b/browser/components/extensions/test/browser/browser_ext_tabs_executeScript.js new file mode 100644 index 0000000000..c02aef3da9 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_executeScript.js @@ -0,0 +1,453 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function testExecuteScript() { + let { MessageChannel } = ChromeUtils.importESModule( + "resource://testing-common/MessageChannel.sys.mjs" + ); + + function countMM(messageManagerMap) { + let count = 0; + // List of permanent message managers in the main process. We should not + // count them in the test because MessageChannel unsubscribes when the + // message manager closes, which never happens to these, of course. + let globalMMs = [Services.mm, Services.ppmm, Services.ppmm.getChildAt(0)]; + for (let mm of messageManagerMap.keys()) { + if (!globalMMs.includes(mm)) { + ++count; + } + } + return count; + } + + let messageManagersSize = countMM(MessageChannel.messageManagers); + + const BASE = + "http://mochi.test:8888/browser/browser/components/extensions/test/browser/"; + const URL = BASE + "file_iframe_document.html"; + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, URL, true); + + async function background() { + try { + // This promise is meant to be resolved when browser.tabs.executeScript({file: "script.js"}) + // is called and the content script does message back, registering the runtime.onMessage + // listener here is meant to prevent intermittent failures due to a race on executing the + // array of promises passed to the `await Promise.all(...)` below. + const promiseRuntimeOnMessage = new Promise(resolve => { + browser.runtime.onMessage.addListener(message => { + browser.test.assertEq( + "script ran", + message, + "Expected runtime message" + ); + resolve(); + }); + }); + + let [tab] = await browser.tabs.query({ + active: true, + currentWindow: true, + }); + let frames = await browser.webNavigation.getAllFrames({ tabId: tab.id }); + browser.test.assertEq(3, frames.length, "Expect exactly three frames"); + browser.test.assertEq(0, frames[0].frameId, "Main frame has frameId:0"); + browser.test.assertTrue(frames[1].frameId > 0, "Subframe has a valid id"); + + browser.test.log( + `FRAMES: ${frames[1].frameId} ${JSON.stringify(frames)}\n` + ); + await Promise.all([ + browser.tabs + .executeScript({ + code: "42", + }) + .then(result => { + browser.test.assertEq( + 1, + result.length, + "Expected one callback result" + ); + browser.test.assertEq(42, result[0], "Expected callback result"); + }), + + browser.tabs + .executeScript({ + file: "script.js", + code: "42", + }) + .then( + result => { + browser.test.fail( + "Expected not to be able to execute a script with both file and code" + ); + }, + error => { + browser.test.assertTrue( + /a 'code' or a 'file' property, but not both/.test( + error.message + ), + "Got expected error" + ); + } + ), + + browser.tabs + .executeScript({ + file: "script.js", + }) + .then(result => { + browser.test.assertEq( + 1, + result.length, + "Expected one callback result" + ); + browser.test.assertEq( + undefined, + result[0], + "Expected callback result" + ); + }), + + browser.tabs + .executeScript({ + file: "script2.js", + }) + .then(result => { + browser.test.assertEq( + 1, + result.length, + "Expected one callback result" + ); + browser.test.assertEq(27, result[0], "Expected callback result"); + }), + + browser.tabs + .executeScript({ + code: "location.href;", + allFrames: true, + }) + .then(result => { + browser.test.assertTrue( + Array.isArray(result), + "Result is an array" + ); + + browser.test.assertEq( + 2, + result.length, + "Result has correct length" + ); + + browser.test.assertTrue( + /\/file_iframe_document\.html$/.test(result[0]), + "First result is correct" + ); + browser.test.assertEq( + "http://mochi.test:8888/", + result[1], + "Second result is correct" + ); + }), + + browser.tabs + .executeScript({ + code: "location.href;", + allFrames: true, + matchAboutBlank: true, + }) + .then(result => { + browser.test.assertTrue( + Array.isArray(result), + "Result is an array" + ); + + browser.test.assertEq( + 3, + result.length, + "Result has correct length" + ); + + browser.test.assertTrue( + /\/file_iframe_document\.html$/.test(result[0]), + "First result is correct" + ); + browser.test.assertEq( + "http://mochi.test:8888/", + result[1], + "Second result is correct" + ); + browser.test.assertEq( + "about:blank", + result[2], + "Thirds result is correct" + ); + }), + + browser.tabs + .executeScript({ + code: "location.href;", + runAt: "document_end", + }) + .then(result => { + browser.test.assertEq(1, result.length, "Expected callback result"); + browser.test.assertEq( + "string", + typeof result[0], + "Result is a string" + ); + + browser.test.assertTrue( + /\/file_iframe_document\.html$/.test(result[0]), + "Result is correct" + ); + }), + + browser.tabs + .executeScript({ + code: "window", + }) + .then( + result => { + browser.test.fail( + "Expected error when returning non-structured-clonable object" + ); + }, + error => { + browser.test.assertEq( + "<anonymous code>", + error.fileName, + "Got expected fileName" + ); + browser.test.assertEq( + "Script '<anonymous code>' result is non-structured-clonable data", + error.message, + "Got expected error" + ); + } + ), + + browser.tabs + .executeScript({ + code: "Promise.resolve(window)", + }) + .then( + result => { + browser.test.fail( + "Expected error when returning non-structured-clonable object" + ); + }, + error => { + browser.test.assertEq( + "<anonymous code>", + error.fileName, + "Got expected fileName" + ); + browser.test.assertEq( + "Script '<anonymous code>' result is non-structured-clonable data", + error.message, + "Got expected error" + ); + } + ), + + browser.tabs + .executeScript({ + file: "script3.js", + }) + .then( + result => { + browser.test.fail( + "Expected error when returning non-structured-clonable object" + ); + }, + error => { + const expected = + /Script '.*script3.js' result is non-structured-clonable data/; + browser.test.assertTrue( + expected.test(error.message), + "Got expected error" + ); + browser.test.assertTrue( + error.fileName.endsWith("script3.js"), + "Got expected fileName" + ); + } + ), + + browser.tabs + .executeScript({ + frameId: Number.MAX_SAFE_INTEGER, + code: "42", + }) + .then( + result => { + browser.test.fail( + "Expected error when specifying invalid frame ID" + ); + }, + error => { + browser.test.assertEq( + `Invalid frame IDs: [${Number.MAX_SAFE_INTEGER}].`, + error.message, + "Got expected error" + ); + } + ), + + browser.tabs + .create({ url: "http://example.net/", active: false }) + .then(async tab => { + await browser.tabs + .executeScript(tab.id, { + code: "42", + }) + .then( + result => { + browser.test.fail( + "Expected error when trying to execute on invalid domain" + ); + }, + error => { + browser.test.assertEq( + "Missing host permission for the tab", + error.message, + "Got expected error" + ); + } + ); + + await browser.tabs.remove(tab.id); + }), + + browser.tabs + .executeScript({ + code: "Promise.resolve(42)", + }) + .then(result => { + browser.test.assertEq( + 42, + result[0], + "Got expected promise resolution value as result" + ); + }), + + browser.tabs + .executeScript({ + code: "location.href;", + runAt: "document_end", + allFrames: true, + }) + .then(result => { + browser.test.assertTrue( + Array.isArray(result), + "Result is an array" + ); + + browser.test.assertEq( + 2, + result.length, + "Result has correct length" + ); + + browser.test.assertTrue( + /\/file_iframe_document\.html$/.test(result[0]), + "First result is correct" + ); + browser.test.assertEq( + "http://mochi.test:8888/", + result[1], + "Second result is correct" + ); + }), + + browser.tabs + .executeScript({ + code: "location.href;", + frameId: frames[0].frameId, + }) + .then(result => { + browser.test.assertEq(1, result.length, "Expected one result"); + browser.test.assertTrue( + /\/file_iframe_document\.html$/.test(result[0]), + `Result for main frame (frameId:0) is correct: ${result[0]}` + ); + }), + + browser.tabs + .executeScript({ + code: "location.href;", + frameId: frames[1].frameId, + }) + .then(result => { + browser.test.assertEq(1, result.length, "Expected one result"); + browser.test.assertEq( + "http://mochi.test:8888/", + result[0], + "Result for frameId[1] is correct" + ); + }), + + browser.tabs.create({ url: "http://example.com/" }).then(async tab => { + let result = await browser.tabs.executeScript(tab.id, { + code: "location.href", + }); + + browser.test.assertEq( + "http://example.com/", + result[0], + "Script executed correctly in new tab" + ); + + await browser.tabs.remove(tab.id); + }), + + promiseRuntimeOnMessage, + ]); + + browser.test.notifyPass("executeScript"); + } catch (e) { + browser.test.fail(`Error: ${e} :: ${e.stack}`); + browser.test.notifyFail("executeScript"); + } + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: [ + "http://mochi.test/", + "http://example.com/", + "webNavigation", + ], + }, + + background, + + files: { + "script.js": function () { + browser.runtime.sendMessage("script ran"); + }, + + "script2.js": "27", + + "script3.js": "window", + }, + }); + + await extension.startup(); + + await extension.awaitFinish("executeScript"); + + await extension.unload(); + + BrowserTestUtils.removeTab(tab); + + // Make sure that we're not holding on to references to closed message + // managers. + is( + countMM(MessageChannel.messageManagers), + messageManagersSize, + "Message manager count" + ); + is(MessageChannel.pendingResponses.size, 0, "Pending response count"); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_executeScript_about_blank.js b/browser/components/extensions/test/browser/browser_ext_tabs_executeScript_about_blank.js new file mode 100644 index 0000000000..34af25ff9b --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_executeScript_about_blank.js @@ -0,0 +1,33 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function testExecuteScript_at_about_blank() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + host_permissions: ["*://*/*"], // allows script in top-level about:blank. + }, + async background() { + try { + const tab = await browser.tabs.create({ url: "about:blank" }); + const result = await browser.tabs.executeScript(tab.id, { + code: "location.href", + matchAboutBlank: true, + }); + browser.test.assertEq( + "about:blank", + result[0], + "Script executed correctly in new tab" + ); + await browser.tabs.remove(tab.id); + browser.test.notifyPass("executeScript"); + } catch (e) { + browser.test.fail(`Error: ${e} :: ${e.stack}`); + browser.test.notifyFail("executeScript"); + } + }, + }); + await extension.startup(); + await extension.awaitFinish("executeScript"); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_executeScript_bad.js b/browser/components/extensions/test/browser/browser_ext_tabs_executeScript_bad.js new file mode 100644 index 0000000000..6b460243b0 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_executeScript_bad.js @@ -0,0 +1,361 @@ +"use strict"; + +async function testHasNoPermission(params) { + let contentSetup = params.contentSetup || (() => Promise.resolve()); + + async function background(contentSetup) { + browser.test.onMessage.addListener(async msg => { + browser.test.assertEq(msg, "execute-script"); + + await browser.test.assertRejects( + browser.tabs.executeScript({ + file: "script.js", + }), + /Missing host permission for the tab/ + ); + + browser.test.notifyPass("executeScript"); + }); + + await contentSetup(); + + browser.test.sendMessage("ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: params.manifest, + + background: `(${background})(${contentSetup})`, + + files: { + "script.js": function () { + browser.runtime.sendMessage("first script ran"); + }, + }, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + + if (params.setup) { + await params.setup(extension); + } + + extension.sendMessage("execute-script"); + + await extension.awaitFinish("executeScript"); + await extension.unload(); +} + +add_task(async function testBadPermissions() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://mochi.test:8888/" + ); + + info("Test no special permissions"); + await testHasNoPermission({ + manifest: { permissions: [] }, + }); + + info("Test tabs permissions"); + await testHasNoPermission({ + manifest: { permissions: ["tabs"] }, + }); + + info("Test no special permissions, commands, key press"); + await testHasNoPermission({ + manifest: { + permissions: [], + commands: { + "test-tabs-executeScript": { + suggested_key: { + default: "Alt+Shift+K", + }, + }, + }, + }, + contentSetup: function () { + browser.commands.onCommand.addListener(function (command) { + if (command == "test-tabs-executeScript") { + browser.test.sendMessage("tabs-command-key-pressed"); + } + }); + return Promise.resolve(); + }, + setup: async function (extension) { + await EventUtils.synthesizeKey("k", { altKey: true, shiftKey: true }); + await extension.awaitMessage("tabs-command-key-pressed"); + }, + }); + + info("Test no special permissions, _execute_browser_action command"); + await testHasNoPermission({ + manifest: { + permissions: [], + browser_action: {}, + commands: { + _execute_browser_action: { + suggested_key: { + default: "Alt+Shift+K", + }, + }, + }, + }, + contentSetup: function () { + browser.browserAction.onClicked.addListener(() => { + browser.test.sendMessage("tabs-command-key-pressed"); + }); + return Promise.resolve(); + }, + setup: async function (extension) { + await EventUtils.synthesizeKey("k", { altKey: true, shiftKey: true }); + await extension.awaitMessage("tabs-command-key-pressed"); + }, + }); + + info("Test no special permissions, _execute_page_action command"); + await testHasNoPermission({ + manifest: { + permissions: [], + page_action: {}, + commands: { + _execute_page_action: { + suggested_key: { + default: "Alt+Shift+K", + }, + }, + }, + }, + contentSetup: async function () { + browser.pageAction.onClicked.addListener(() => { + browser.test.sendMessage("tabs-command-key-pressed"); + }); + let [tab] = await browser.tabs.query({ + active: true, + currentWindow: true, + }); + await browser.pageAction.show(tab.id); + }, + setup: async function (extension) { + await EventUtils.synthesizeKey("k", { altKey: true, shiftKey: true }); + await extension.awaitMessage("tabs-command-key-pressed"); + }, + }); + + info("Test active tab, commands, no key press"); + await testHasNoPermission({ + manifest: { + permissions: ["activeTab"], + commands: { + "test-tabs-executeScript": { + suggested_key: { + default: "Alt+Shift+K", + }, + }, + }, + }, + }); + + info("Test active tab, browser action, no click"); + await testHasNoPermission({ + manifest: { + permissions: ["activeTab"], + browser_action: {}, + }, + }); + + info("Test active tab, page action, no click"); + await testHasNoPermission({ + manifest: { + permissions: ["activeTab"], + page_action: {}, + }, + contentSetup: async function () { + let [tab] = await browser.tabs.query({ + active: true, + currentWindow: true, + }); + await browser.pageAction.show(tab.id); + }, + }); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function testMatchDataURI() { + // allow top level data: URI navigations, otherwise + // window.location.href = data: would be blocked + await SpecialPowers.pushPrefEnv({ + set: [["security.data_uri.block_toplevel_data_uri_navigations", false]], + }); + + const target = ExtensionTestUtils.loadExtension({ + files: { + "page.html": `<!DOCTYPE html> + <meta charset="utf-8"> + <script src="page.js"></script> + <iframe id="inherited" src="data:text/html;charset=utf-8,inherited"></iframe> + `, + "page.js": function () { + browser.test.onMessage.addListener((msg, url) => { + if (msg !== "navigate") { + return; + } + window.location.href = url; + }); + }, + }, + background() { + browser.tabs.create({ + active: true, + url: browser.runtime.getURL("page.html"), + }); + }, + }); + + const scripts = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["<all_urls>", "webNavigation"], + }, + background() { + browser.webNavigation.onCompleted.addListener(({ url, frameId }) => { + browser.test.log(`Document loading complete: ${url}`); + if (frameId === 0) { + browser.test.sendMessage("tab-ready", url); + } + }); + + browser.test.onMessage.addListener(async msg => { + if (msg !== "execute") { + return; + } + await browser.test.assertRejects( + browser.tabs.executeScript({ + code: "location.href;", + allFrames: true, + }), + /Missing host permission/, + "Should not execute in `data:` frame" + ); + + browser.test.sendMessage("done"); + }); + }, + }); + + await scripts.startup(); + await target.startup(); + + // Test extension page with a data: iframe. + const page = await scripts.awaitMessage("tab-ready"); + ok(page.endsWith("page.html"), "Extension page loaded into a tab"); + + scripts.sendMessage("execute"); + await scripts.awaitMessage("done"); + + // Test extension tab navigated to a data: URI. + const data = "data:text/html;charset=utf-8,also-inherits"; + target.sendMessage("navigate", data); + + const url = await scripts.awaitMessage("tab-ready"); + is(url, data, "Extension tab navigated to a data: URI"); + + scripts.sendMessage("execute"); + await scripts.awaitMessage("done"); + + BrowserTestUtils.removeTab(gBrowser.selectedTab); + await scripts.unload(); + await target.unload(); +}); + +add_task(async function testBadURL() { + async function background() { + let promises = [ + new Promise(resolve => { + browser.tabs.executeScript( + { + file: "http://example.com/script.js", + }, + result => { + browser.test.assertEq(undefined, result, "Result value"); + + browser.test.assertTrue( + browser.runtime.lastError instanceof Error, + "runtime.lastError is Error" + ); + + browser.test.assertTrue( + browser.runtime.lastError instanceof Error, + "runtime.lastError is Error" + ); + + browser.test.assertEq( + "Files to be injected must be within the extension", + browser.runtime.lastError && browser.runtime.lastError.message, + "runtime.lastError value" + ); + + browser.test.assertEq( + "Files to be injected must be within the extension", + browser.runtime.lastError && browser.runtime.lastError.message, + "runtime.lastError value" + ); + + resolve(); + } + ); + }), + + browser.tabs + .executeScript({ + file: "http://example.com/script.js", + }) + .catch(error => { + browser.test.assertTrue(error instanceof Error, "Error is Error"); + + browser.test.assertEq( + null, + browser.runtime.lastError, + "runtime.lastError value" + ); + + browser.test.assertEq( + null, + browser.runtime.lastError, + "runtime.lastError value" + ); + + browser.test.assertEq( + "Files to be injected must be within the extension", + error && error.message, + "error value" + ); + }), + ]; + + await Promise.all(promises); + + browser.test.notifyPass("executeScript-lastError"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["<all_urls>"], + }, + + background, + }); + + await extension.startup(); + + await extension.awaitFinish("executeScript-lastError"); + + await extension.unload(); +}); + +// TODO bug 1435100: Test that |executeScript| fails if the tab has navigated +// to a new page, and no longer matches our expected state. This involves +// intentionally trying to trigger a race condition. + +add_task(forceGC); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_executeScript_file.js b/browser/components/extensions/test/browser/browser_ext_tabs_executeScript_file.js new file mode 100644 index 0000000000..a8ba389602 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_executeScript_file.js @@ -0,0 +1,93 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const FILE_URL = Services.io.newFileURI( + new FileUtils.File(getTestFilePath("file_dummy.html")) +).spec; + +add_task(async function testExecuteScript_at_file_url() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs", "file:///*"], + }, + background() { + browser.test.onMessage.addListener(async () => { + try { + const [tab] = await browser.tabs.query({ url: "file://*/*/*dummy*" }); + const result = await browser.tabs.executeScript(tab.id, { + code: "location.protocol", + }); + browser.test.assertEq( + "file:", + result[0], + "Script executed correctly in new tab" + ); + browser.test.notifyPass("executeScript"); + } catch (e) { + browser.test.fail(`Error: ${e} :: ${e.stack}`); + browser.test.notifyFail("executeScript"); + } + }); + }, + }); + await extension.startup(); + + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, FILE_URL); + + extension.sendMessage(); + await extension.awaitFinish("executeScript"); + + BrowserTestUtils.removeTab(tab); + + await extension.unload(); +}); + +add_task(async function testExecuteScript_at_file_url_with_activeTab() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["activeTab"], + browser_action: {}, + }, + background() { + browser.browserAction.onClicked.addListener(async tab => { + try { + const result = await browser.tabs.executeScript(tab.id, { + code: "location.protocol", + }); + browser.test.assertEq( + "file:", + result[0], + "Script executed correctly in active tab" + ); + browser.test.notifyPass("executeScript"); + } catch (e) { + browser.test.fail(`Error: ${e} :: ${e.stack}`); + browser.test.notifyFail("executeScript"); + } + }); + + browser.test.onMessage.addListener(async () => { + await browser.test.assertRejects( + browser.tabs.executeScript({ code: "location.protocol" }), + /Missing host permission for the tab/, + "activeTab not active yet, executeScript should be rejected" + ); + browser.test.sendMessage("next-step"); + }); + }, + }); + await extension.startup(); + + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, FILE_URL); + + extension.sendMessage(); + await extension.awaitMessage("next-step"); + + await clickBrowserAction(extension); + await extension.awaitFinish("executeScript"); + + BrowserTestUtils.removeTab(tab); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_executeScript_good.js b/browser/components/extensions/test/browser/browser_ext_tabs_executeScript_good.js new file mode 100644 index 0000000000..e9d008bf92 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_executeScript_good.js @@ -0,0 +1,190 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +requestLongerTimeout(2); + +async function testHasPermission(params) { + let contentSetup = params.contentSetup || (() => Promise.resolve()); + + async function background(contentSetup) { + browser.runtime.onMessage.addListener((msg, sender) => { + browser.test.assertEq(msg, "script ran", "script ran"); + browser.test.notifyPass("executeScript"); + }); + + browser.test.onMessage.addListener(msg => { + browser.test.assertEq(msg, "execute-script"); + + browser.tabs.executeScript({ + file: "script.js", + }); + }); + + await contentSetup(); + + browser.test.sendMessage("ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: params.manifest, + + background: `(${background})(${contentSetup})`, + + files: { + "panel.html": `<!DOCTYPE html> + <html> + <head><meta charset="utf-8"></head> + <body> + </body> + </html>`, + "script.js": function () { + browser.runtime.sendMessage("script ran"); + }, + }, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + + if (params.setup) { + await params.setup(extension); + } + + extension.sendMessage("execute-script"); + + await extension.awaitFinish("executeScript"); + + if (params.tearDown) { + await params.tearDown(extension); + } + + await extension.unload(); +} + +add_task(async function testGoodPermissions() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://mochi.test:8888/", + true + ); + + info("Test activeTab permission with a command key press"); + await testHasPermission({ + manifest: { + permissions: ["activeTab"], + commands: { + "test-tabs-executeScript": { + suggested_key: { + default: "Alt+Shift+K", + }, + }, + }, + }, + contentSetup: function () { + browser.commands.onCommand.addListener(function (command) { + if (command == "test-tabs-executeScript") { + browser.test.sendMessage("tabs-command-key-pressed"); + } + }); + return Promise.resolve(); + }, + setup: async function (extension) { + await EventUtils.synthesizeKey("k", { altKey: true, shiftKey: true }); + await extension.awaitMessage("tabs-command-key-pressed"); + }, + }); + + info("Test activeTab permission with _execute_browser_action command"); + await testHasPermission({ + manifest: { + permissions: ["activeTab"], + browser_action: {}, + commands: { + _execute_browser_action: { + suggested_key: { + default: "Alt+Shift+K", + }, + }, + }, + }, + contentSetup: function () { + browser.browserAction.onClicked.addListener(() => { + browser.test.sendMessage("tabs-command-key-pressed"); + }); + return Promise.resolve(); + }, + setup: async function (extension) { + await EventUtils.synthesizeKey("k", { altKey: true, shiftKey: true }); + await extension.awaitMessage("tabs-command-key-pressed"); + }, + }); + + info("Test activeTab permission with _execute_page_action command"); + await testHasPermission({ + manifest: { + permissions: ["activeTab"], + page_action: {}, + commands: { + _execute_page_action: { + suggested_key: { + default: "Alt+Shift+K", + }, + }, + }, + }, + contentSetup: async function () { + browser.pageAction.onClicked.addListener(() => { + browser.test.sendMessage("tabs-command-key-pressed"); + }); + let [tab] = await browser.tabs.query({ + active: true, + currentWindow: true, + }); + await browser.pageAction.show(tab.id); + }, + setup: async function (extension) { + await EventUtils.synthesizeKey("k", { altKey: true, shiftKey: true }); + await extension.awaitMessage("tabs-command-key-pressed"); + }, + }); + + info("Test activeTab permission with a context menu click"); + await testHasPermission({ + manifest: { + permissions: ["activeTab", "contextMenus"], + }, + contentSetup: function () { + browser.contextMenus.create({ title: "activeTab", contexts: ["all"] }); + return Promise.resolve(); + }, + setup: async function (extension) { + let contextMenu = document.getElementById("contentAreaContextMenu"); + let awaitPopupShown = BrowserTestUtils.waitForEvent( + contextMenu, + "popupshown" + ); + let awaitPopupHidden = BrowserTestUtils.waitForEvent( + contextMenu, + "popuphidden" + ); + + await BrowserTestUtils.synthesizeMouseAtCenter( + "a[href]", + { type: "contextmenu", button: 2 }, + gBrowser.selectedBrowser + ); + await awaitPopupShown; + + let item = contextMenu.querySelector("[label=activeTab]"); + + contextMenu.activateItem(item); + + await awaitPopupHidden; + }, + }); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(forceGC); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_executeScript_multiple.js b/browser/components/extensions/test/browser/browser_ext_tabs_executeScript_multiple.js new file mode 100644 index 0000000000..4e9cc907da --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_executeScript_multiple.js @@ -0,0 +1,61 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function testExecuteScript() { + const BASE = + "http://mochi.test:8888/browser/browser/components/extensions/test/browser/"; + const URL = BASE + "file_dummy.html"; + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, URL, true); + + async function background() { + try { + await browser.tabs.executeScript({ code: "this.foo = 'bar'" }); + await browser.tabs.executeScript({ file: "script.js" }); + + let [result1] = await browser.tabs.executeScript({ + code: "[this.foo, this.bar]", + }); + let [result2] = await browser.tabs.executeScript({ file: "script2.js" }); + + browser.test.assertEq( + "bar,baz", + String(result1), + "executeScript({code}) result" + ); + browser.test.assertEq( + "bar,baz", + String(result2), + "executeScript({file}) result" + ); + + browser.test.notifyPass("executeScript-multiple"); + } catch (e) { + browser.test.fail(`Error: ${e} :: ${e.stack}`); + browser.test.notifyFail("executeScript-multiple"); + } + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["http://mochi.test/"], + }, + + background, + + files: { + "script.js": function () { + this.bar = "baz"; + }, + + "script2.js": "[this.foo, this.bar]", + }, + }); + + await extension.startup(); + + await extension.awaitFinish("executeScript-multiple"); + + await extension.unload(); + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_executeScript_no_create.js b/browser/components/extensions/test/browser/browser_ext_tabs_executeScript_no_create.js new file mode 100644 index 0000000000..e8e1f1255f --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_executeScript_no_create.js @@ -0,0 +1,80 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function testExecuteScriptAtOnUpdated() { + const BASE = + "http://mochi.test:8888/browser/browser/components/extensions/test/browser/"; + const URL = BASE + "file_iframe_document.html"; + // This is a regression test for bug 1325830. + // The bug (executeScript not completing any more) occurred when executeScript + // was called early at the onUpdated event, unless the tabs.create method is + // called. So this test does not use tabs.create to open new tabs. + // Note that if this test is run together with other tests that do call + // tabs.create, then this test case does not properly test the conditions of + // the regression any more. To verify that the regression has been resolved, + // this test must be run in isolation. + + function background() { + // Using variables to prevent listeners from running more than once, instead + // of removing the listener. This is to minimize any IPC, since the bug that + // is being tested is sensitive to timing. + let ignore = false; + let url; + browser.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { + if (ignore) { + return; + } + if (url && changeInfo.status === "loading" && tab.url === url) { + ignore = true; + browser.tabs + .executeScript(tabId, { + code: "document.URL", + }) + .then( + results => { + browser.test.assertEq( + url, + results[0], + "Content script should run" + ); + browser.test.notifyPass("executeScript-at-onUpdated"); + }, + error => { + browser.test.fail(`Unexpected error: ${error} :: ${error.stack}`); + browser.test.notifyFail("executeScript-at-onUpdated"); + } + ); + // (running this log call after executeScript to minimize IPC between + // onUpdated and executeScript.) + browser.test.log(`Found expected navigation to ${url}`); + } else { + // The bug occurs when executeScript is called before a tab is + // initialized. + browser.tabs.executeScript(tabId, { code: "" }); + } + }); + browser.test.onMessage.addListener(testUrl => { + url = testUrl; + browser.test.sendMessage("open-test-tab"); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["http://mochi.test/", "tabs"], + }, + background, + }); + + await extension.startup(); + extension.sendMessage(URL); + await extension.awaitMessage("open-test-tab"); + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, URL, true); + + await extension.awaitFinish("executeScript-at-onUpdated"); + + await extension.unload(); + + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_executeScript_runAt.js b/browser/components/extensions/test/browser/browser_ext_tabs_executeScript_runAt.js new file mode 100644 index 0000000000..bab0182a3f --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_executeScript_runAt.js @@ -0,0 +1,134 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +/** + * These tests ensure that the runAt argument to tabs.executeScript delays + * script execution until the document has reached the correct state. + * + * Since tests of this nature are especially race-prone, it relies on a + * server-JS script to delay the completion of our test page's load cycle long + * enough for us to attempt to load our scripts in the earlies phase we support. + * + * And since we can't actually rely on that timing, it retries any attempts that + * fail to load as early as expected, but don't load at any illegal time. + */ + +add_task(async function testExecuteScript() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank", + true + ); + + async function background() { + let tab; + + const BASE = + "http://mochi.test:8888/browser/browser/components/extensions/test/browser/"; + const URL = BASE + "file_slowed_document.sjs"; + + const MAX_TRIES = 10; + + let onUpdatedPromise = (tabId, url, status) => { + return new Promise(resolve => { + browser.tabs.onUpdated.addListener(function listener(_, changed, tab) { + if (tabId == tab.id && changed.status == status && tab.url == url) { + browser.tabs.onUpdated.removeListener(listener); + resolve(); + } + }); + }); + }; + + try { + [tab] = await browser.tabs.query({ active: true, currentWindow: true }); + + let success = false; + for (let tries = 0; !success && tries < MAX_TRIES; tries++) { + let url = `${URL}?with-iframe&r=${Math.random()}`; + + let loadingPromise = onUpdatedPromise(tab.id, url, "loading"); + let completePromise = onUpdatedPromise(tab.id, url, "complete"); + + // TODO: Test allFrames and frameId. + + await browser.tabs.update({ url }); + await loadingPromise; + + let states = await Promise.all( + [ + // Send the executeScript requests in the reverse order that we expect + // them to execute in, to avoid them passing only because of timing + // races. + browser.tabs.executeScript({ + code: "document.readyState", + // Testing default `runAt`. + }), + browser.tabs.executeScript({ + code: "document.readyState", + runAt: "document_idle", + }), + browser.tabs.executeScript({ + code: "document.readyState", + runAt: "document_end", + }), + browser.tabs.executeScript({ + code: "document.readyState", + runAt: "document_start", + }), + ].reverse() + ); + + browser.test.log(`Got states: ${states}`); + + // Make sure that none of our scripts executed earlier than expected, + // regardless of retries. + browser.test.assertTrue( + states[1] == "interactive" || states[1] == "complete", + `document_end state is valid: ${states[1]}` + ); + browser.test.assertTrue( + states[2] == "interactive" || states[2] == "complete", + `document_idle state is valid: ${states[2]}` + ); + + // If we have the earliest valid states for each script, we're done. + // Otherwise, try again. + success = + states[0] == "loading" && + states[1] == "interactive" && + states[2] == "interactive" && + states[3] == "interactive"; + + await completePromise; + } + + browser.test.assertTrue( + success, + "Got the earliest expected states at least once" + ); + + browser.test.notifyPass("executeScript-runAt"); + } catch (e) { + browser.test.fail(`Error: ${e} :: ${e.stack}`); + browser.test.notifyFail("executeScript-runAt"); + } + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["http://mochi.test/", "tabs"], + }, + + background, + }); + + await extension.startup(); + + await extension.awaitFinish("executeScript-runAt"); + + await extension.unload(); + + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_getCurrent.js b/browser/components/extensions/test/browser/browser_ext_tabs_getCurrent.js new file mode 100644 index 0000000000..9304d3a5b4 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_getCurrent.js @@ -0,0 +1,86 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function () { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + + browser_action: { default_popup: "popup.html" }, + }, + + files: { + "tab.js": function () { + let url = document.location.href; + + browser.tabs.getCurrent(currentTab => { + browser.test.assertEq( + currentTab.url, + url, + "getCurrent in non-active background tab" + ); + + // Activate the tab. + browser.tabs.onActivated.addListener(function listener({ tabId }) { + if (tabId == currentTab.id) { + browser.tabs.onActivated.removeListener(listener); + + browser.tabs.getCurrent(currentTab => { + browser.test.assertEq( + currentTab.id, + tabId, + "in active background tab" + ); + browser.test.assertEq( + currentTab.url, + url, + "getCurrent in non-active background tab" + ); + + browser.test.sendMessage("tab-finished"); + }); + } + }); + browser.tabs.update(currentTab.id, { active: true }); + }); + }, + + "popup.js": function () { + browser.tabs.getCurrent(tab => { + browser.test.assertEq(tab, undefined, "getCurrent in popup script"); + browser.test.sendMessage("popup-finished"); + }); + }, + + "tab.html": `<head><meta charset="utf-8"><script src="tab.js"></script></head>`, + "popup.html": `<head><meta charset="utf-8"><script src="popup.js"></script></head>`, + }, + + background: function () { + browser.tabs.getCurrent(tab => { + browser.test.assertEq( + tab, + undefined, + "getCurrent in background script" + ); + browser.test.sendMessage("background-finished"); + }); + + browser.tabs.create({ url: "tab.html", active: false }); + }, + }); + + await extension.startup(); + + await extension.awaitMessage("background-finished"); + await extension.awaitMessage("tab-finished"); + + clickBrowserAction(extension); + await awaitExtensionPanel(extension); + await extension.awaitMessage("popup-finished"); + await closeBrowserAction(extension); + + // The extension tab is automatically closed when the extension unloads. + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_goBack_goForward.js b/browser/components/extensions/test/browser/browser_ext_tabs_goBack_goForward.js new file mode 100644 index 0000000000..2ab960699d --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_goBack_goForward.js @@ -0,0 +1,113 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_tabs_goBack_goForward() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.navigation.requireUserInteraction", false]], + }); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + files: { + "tab1.html": `<head> + <meta charset="utf-8"> + <title>tab1</title> + </head>`, + "tab2.html": `<head> + <meta charset="utf-8"> + <title>tab2</title> + </head>`, + }, + + async background() { + let tabUpdatedCount = 0; + let tab = {}; + + browser.tabs.onUpdated.addListener(async (tabId, changeInfo, tabInfo) => { + if (changeInfo.status !== "complete" || tabId !== tab.id) { + return; + } + + tabUpdatedCount++; + switch (tabUpdatedCount) { + case 1: + browser.test.assertEq( + "tab1", + tabInfo.title, + "tab1 is found as expected" + ); + browser.tabs.update(tabId, { url: "tab2.html" }); + break; + + case 2: + browser.test.assertEq( + "tab2", + tabInfo.title, + "tab2 is found as expected" + ); + browser.tabs.update(tabId, { url: "tab1.html" }); + break; + + case 3: + browser.test.assertEq( + "tab1", + tabInfo.title, + "tab1 is found as expected" + ); + browser.tabs.goBack(); + break; + + case 4: + browser.test.assertEq( + "tab2", + tabInfo.title, + "tab2 is found after navigating backward with empty parameter" + ); + browser.tabs.goBack(tabId); + break; + + case 5: + browser.test.assertEq( + "tab1", + tabInfo.title, + "tab1 is found after navigating backward with tabId as parameter" + ); + browser.tabs.goForward(); + break; + + case 6: + browser.test.assertEq( + "tab2", + tabInfo.title, + "tab2 is found after navigating forward with empty parameter" + ); + browser.tabs.goForward(tabId); + break; + + case 7: + browser.test.assertEq( + "tab1", + tabInfo.title, + "tab1 is found after navigating forward with tabId as parameter" + ); + await browser.tabs.remove(tabId); + browser.test.notifyPass("tabs.goBack.goForward"); + break; + + default: + break; + } + }); + + tab = await browser.tabs.create({ url: "tab1.html", active: true }); + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabs.goBack.goForward"); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_hide.js b/browser/components/extensions/test/browser/browser_ext_tabs_hide.js new file mode 100644 index 0000000000..89c50db692 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_hide.js @@ -0,0 +1,375 @@ +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + ExtensionControlledPopup: + "resource:///modules/ExtensionControlledPopup.sys.mjs", + SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs", + TabStateFlusher: "resource:///modules/sessionstore/TabStateFlusher.sys.mjs", +}); + +const triggeringPrincipal_base64 = E10SUtils.SERIALIZED_SYSTEMPRINCIPAL; + +async function doorhangerTest(testFn) { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs", "tabHide"], + icons: { + 48: "addon-icon.png", + }, + }, + background() { + browser.test.onMessage.addListener(async (msg, data) => { + let tabs = await browser.tabs.query(data); + await browser.tabs[msg](tabs.map(t => t.id)); + browser.test.sendMessage("done"); + }); + }, + useAddonManager: "temporary", + }); + + await extension.startup(); + + // Open some tabs so we can hide them. + let firstTab = gBrowser.selectedTab; + let tabs = [ + await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.com/?one", + true, + true + ), + await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.com/?two", + true, + true + ), + ]; + gBrowser.selectedTab = firstTab; + + await testFn(extension); + + BrowserTestUtils.removeTab(tabs[0]); + BrowserTestUtils.removeTab(tabs[1]); + + await extension.unload(); +} + +add_task(function test_doorhanger_keep() { + return doorhangerTest(async function (extension) { + is(gBrowser.visibleTabs.length, 3, "There are 3 visible tabs"); + + // Hide the first tab, expect the doorhanger. + let panel = ExtensionControlledPopup._getAndMaybeCreatePanel(document); + let popupShown = promisePopupShown(panel); + extension.sendMessage("hide", { url: "*://*/?one" }); + await extension.awaitMessage("done"); + await popupShown; + + is(gBrowser.visibleTabs.length, 2, "There are 2 visible tabs now"); + is( + panel.anchorNode.closest("toolbarbutton").id, + "alltabs-button", + "The doorhanger is anchored to the all tabs button" + ); + + // Click the Keep Tabs Hidden button. + let popupnotification = document.getElementById( + "extension-tab-hide-notification" + ); + let popupHidden = promisePopupHidden(panel); + popupnotification.button.click(); + await popupHidden; + + // Hide another tab and ensure the popup didn't open. + extension.sendMessage("hide", { url: "*://*/?two" }); + await extension.awaitMessage("done"); + is(panel.state, "closed", "The popup is still closed"); + is(gBrowser.visibleTabs.length, 1, "There's one visible tab now"); + + extension.sendMessage("show", {}); + await extension.awaitMessage("done"); + }); +}); + +add_task(function test_doorhanger_disable() { + return doorhangerTest(async function (extension) { + is(gBrowser.visibleTabs.length, 3, "There are 3 visible tabs"); + + // Hide the first tab, expect the doorhanger. + let panel = ExtensionControlledPopup._getAndMaybeCreatePanel(document); + let popupShown = promisePopupShown(panel); + extension.sendMessage("hide", { url: "*://*/?one" }); + await extension.awaitMessage("done"); + await popupShown; + + is(gBrowser.visibleTabs.length, 2, "There are 2 visible tabs now"); + is( + panel.anchorNode.closest("toolbarbutton").id, + "alltabs-button", + "The doorhanger is anchored to the all tabs button" + ); + + // verify the contents of the description. + let popupnotification = document.getElementById( + "extension-tab-hide-notification" + ); + let description = popupnotification.querySelector( + "#extension-tab-hide-notification-description" + ); + let addon = await AddonManager.getAddonByID(extension.id); + ok( + description.textContent.includes(addon.name), + "The extension name is in the description" + ); + let images = Array.from(description.querySelectorAll("image")); + is(images.length, 2, "There are two images"); + ok( + images.some(img => img.src.includes("addon-icon.png")), + "There's an icon for the extension" + ); + ok( + images.some(img => + getComputedStyle(img).backgroundImage.includes("arrow-down.svg") + ), + "There's an icon for the all tabs menu" + ); + + // Click the Disable Extension button. + let popupHidden = promisePopupHidden(panel); + popupnotification.secondaryButton.click(); + await popupHidden; + await new Promise(executeSoon); + + is(gBrowser.visibleTabs.length, 3, "There are 3 visible tabs again"); + is(addon.userDisabled, true, "The extension is now disabled"); + }); +}); + +add_task(async function test_tabs_showhide() { + async function background() { + browser.test.onMessage.addListener(async (msg, data) => { + switch (msg) { + case "hideall": { + let tabs = await browser.tabs.query({ hidden: false }); + browser.test.assertEq(tabs.length, 5, "got 5 tabs"); + let ids = tabs.map(tab => tab.id); + browser.test.log(`working with ids ${JSON.stringify(ids)}`); + + let hidden = await browser.tabs.hide(ids); + browser.test.assertEq(hidden.length, 3, "hid 3 tabs"); + tabs = await browser.tabs.query({ hidden: true }); + ids = tabs.map(tab => tab.id); + browser.test.assertEq( + JSON.stringify(hidden.sort()), + JSON.stringify(ids.sort()), + "hidden tabIds match" + ); + + browser.test.sendMessage("hidden", { hidden }); + break; + } + case "showall": { + let tabs = await browser.tabs.query({ hidden: true }); + for (let tab of tabs) { + browser.test.assertTrue(tab.hidden, "tab is hidden"); + } + let ids = tabs.map(tab => tab.id); + browser.tabs.show(ids); + browser.test.sendMessage("shown"); + break; + } + } + }); + } + + let extdata = { + manifest: { permissions: ["tabs", "tabHide"] }, + background, + useAddonManager: "temporary", // So the doorhanger can find the addon. + }; + let extension = ExtensionTestUtils.loadExtension(extdata); + await extension.startup(); + + let sessData = { + windows: [ + { + tabs: [ + { entries: [{ url: "about:blank", triggeringPrincipal_base64 }] }, + { + entries: [ + { url: "https://example.com/", triggeringPrincipal_base64 }, + ], + }, + { + entries: [ + { url: "https://mochi.test:8888/", triggeringPrincipal_base64 }, + ], + }, + ], + }, + { + tabs: [ + { entries: [{ url: "about:blank", triggeringPrincipal_base64 }] }, + { + entries: [ + { url: "http://test1.example.com/", triggeringPrincipal_base64 }, + ], + }, + ], + }, + ], + }; + + // Set up a test session with 2 windows and 5 tabs. + let oldState = SessionStore.getBrowserState(); + let restored = TestUtils.topicObserved("sessionstore-browser-state-restored"); + SessionStore.setBrowserState(JSON.stringify(sessData)); + await restored; + + if (!Services.prefs.getBoolPref("browser.tabs.tabmanager.enabled")) { + for (let win of BrowserWindowIterator()) { + let allTabsButton = win.document.getElementById("alltabs-button"); + is( + getComputedStyle(allTabsButton).display, + "none", + "The all tabs button is hidden" + ); + } + } + + // Attempt to hide all the tabs, however the active tab in each window cannot + // be hidden, so the result will be 3 hidden tabs. + extension.sendMessage("hideall"); + await extension.awaitMessage("hidden"); + + // We have 2 windows in this session. Otherwin is the non-current window. + // In each window, the first tab will be the selected tab and should not be + // hidden. The rest of the tabs should be hidden at this point. Hidden + // status was already validated inside the extension, this double checks + // from chrome code. + let otherwin; + for (let win of BrowserWindowIterator()) { + if (win != window) { + otherwin = win; + } + let tabs = Array.from(win.gBrowser.tabs); + ok(!tabs[0].hidden, "first tab not hidden"); + for (let i = 1; i < tabs.length; i++) { + ok(tabs[i].hidden, "tab hidden value is correct"); + let id = SessionStore.getCustomTabValue(tabs[i], "hiddenBy"); + is(id, extension.id, "tab hiddenBy value is correct"); + await TabStateFlusher.flush(tabs[i].linkedBrowser); + } + + let allTabsButton = win.document.getElementById("alltabs-button"); + isnot( + getComputedStyle(allTabsButton).display, + "none", + "The all tabs button is visible" + ); + } + + // Close the other window then restore it to test that the tabs are + // restored with proper hidden state, and the correct extension id. + await BrowserTestUtils.closeWindow(otherwin); + + otherwin = SessionStore.undoCloseWindow(0); + await BrowserTestUtils.waitForEvent(otherwin, "load"); + let tabs = Array.from(otherwin.gBrowser.tabs); + ok(!tabs[0].hidden, "first tab not hidden"); + for (let i = 1; i < tabs.length; i++) { + ok(tabs[i].hidden, "tab hidden value is correct"); + let id = SessionStore.getCustomTabValue(tabs[i], "hiddenBy"); + is(id, extension.id, "tab hiddenBy value is correct"); + } + + // Test closing the last visible tab, the next tab which is hidden should become + // the selectedTab and will be visible. + ok(!otherwin.gBrowser.selectedTab.hidden, "selected tab is not hidden"); + BrowserTestUtils.removeTab(otherwin.gBrowser.selectedTab); + ok(!otherwin.gBrowser.selectedTab.hidden, "tab was unhidden"); + + // Showall will unhide any remaining hidden tabs. + extension.sendMessage("showall"); + await extension.awaitMessage("shown"); + + // Check from chrome code that all tabs are visible again. + for (let win of BrowserWindowIterator()) { + let tabs = Array.from(win.gBrowser.tabs); + for (let i = 0; i < tabs.length; i++) { + ok(!tabs[i].hidden, "tab hidden value is correct"); + } + } + + // Close second window. + await BrowserTestUtils.closeWindow(otherwin); + + await extension.unload(); + + // Restore pre-test state. + restored = TestUtils.topicObserved("sessionstore-browser-state-restored"); + SessionStore.setBrowserState(oldState); + await restored; +}); + +// Test our shutdown handling. Currently this means any hidden tabs will be +// shown when a tabHide extension is shutdown. We additionally test the +// tabs.onUpdated listener gets called with hidden state changes. +add_task(async function test_tabs_shutdown() { + let tabs = [ + await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.com/", + true, + true + ), + await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://mochi.test:8888/", + true, + true + ), + ]; + + async function background() { + let tabs = await browser.tabs.query({ url: "http://example.com/" }); + let testTab = tabs[0]; + + browser.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { + if ("hidden" in changeInfo) { + browser.test.assertEq(tabId, testTab.id, "correct tab was hidden"); + browser.test.assertTrue(changeInfo.hidden, "tab is hidden"); + browser.test.assertEq(tab.url, testTab.url, "tab has correct URL"); + browser.test.sendMessage("changeInfo"); + } + }); + + let hidden = await browser.tabs.hide(testTab.id); + browser.test.assertEq(hidden[0], testTab.id, "tab was hidden"); + tabs = await browser.tabs.query({ hidden: true }); + browser.test.assertEq(tabs[0].id, testTab.id, "tab was hidden"); + browser.test.sendMessage("ready"); + } + + let extdata = { + manifest: { permissions: ["tabs", "tabHide"] }, + useAddonManager: "temporary", // For testing onShutdown. + background, + }; + let extension = ExtensionTestUtils.loadExtension(extdata); + await extension.startup(); + + // test onUpdated + await Promise.all([ + extension.awaitMessage("ready"), + extension.awaitMessage("changeInfo"), + ]); + Assert.ok(tabs[0].hidden, "Tab is hidden by extension"); + + await extension.unload(); + + Assert.ok(!tabs[0].hidden, "Tab is not hidden after unloading extension"); + BrowserTestUtils.removeTab(tabs[0]); + BrowserTestUtils.removeTab(tabs[1]); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_hide_update.js b/browser/components/extensions/test/browser/browser_ext_tabs_hide_update.js new file mode 100644 index 0000000000..7fbf185704 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_hide_update.js @@ -0,0 +1,146 @@ +"use strict"; + +const { AddonTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/AddonTestUtils.sys.mjs" +); + +AddonTestUtils.initMochitest(this); + +const ID = "@test-tabs-addon"; + +async function updateExtension(ID, options) { + let xpi = AddonTestUtils.createTempWebExtensionFile(options); + await Promise.all([ + AddonTestUtils.promiseWebExtensionStartup(ID), + AddonManager.installTemporaryAddon(xpi), + ]); +} + +async function disableExtension(ID) { + let disabledPromise = awaitEvent("shutdown", ID); + let addon = await AddonManager.getAddonByID(ID); + await addon.disable(); + await disabledPromise; +} + +function getExtension() { + async function background() { + let tabs = await browser.tabs.query({ url: "http://example.com/" }); + let testTab = tabs[0]; + + browser.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { + if ("hidden" in changeInfo) { + browser.test.assertEq(tabId, testTab.id, "correct tab was hidden"); + browser.test.assertTrue(changeInfo.hidden, "tab is hidden"); + browser.test.sendMessage("changeInfo"); + } + }); + + let hidden = await browser.tabs.hide(testTab.id); + browser.test.assertEq(hidden[0], testTab.id, "tabs.hide hide the tab"); + tabs = await browser.tabs.query({ hidden: true }); + browser.test.assertEq( + tabs[0].id, + testTab.id, + "tabs.query result was hidden" + ); + browser.test.sendMessage("ready"); + } + + let extdata = { + manifest: { + version: "1.0", + browser_specific_settings: { + gecko: { + id: ID, + }, + }, + permissions: ["tabs", "tabHide"], + }, + background, + useAddonManager: "temporary", + }; + return ExtensionTestUtils.loadExtension(extdata); +} + +// Test our update handling. Currently this means any hidden tabs will be +// shown when a tabHide extension is shutdown. We additionally test the +// tabs.onUpdated listener gets called with hidden state changes. +add_task(async function test_tabs_update() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.com/" + ); + await BrowserTestUtils.switchTab(gBrowser, gBrowser.tabs[0]); + + const extension = getExtension(); + await extension.startup(); + + // test onUpdated + await Promise.all([ + extension.awaitMessage("ready"), + extension.awaitMessage("changeInfo"), + ]); + Assert.ok(tab.hidden, "Tab is hidden by extension"); + + // Test that update doesn't hide tabs when tabHide permission is present. + let extdata = { + manifest: { + version: "2.0", + browser_specific_settings: { + gecko: { + id: ID, + }, + }, + permissions: ["tabs", "tabHide"], + }, + }; + await updateExtension(ID, extdata); + Assert.ok(tab.hidden, "Tab is hidden hidden after update"); + + // Test that update does hide tabs when tabHide permission is removed. + extdata.manifest = { + version: "3.0", + browser_specific_settings: { + gecko: { + id: ID, + }, + }, + permissions: ["tabs"], + }; + await updateExtension(ID, extdata); + Assert.ok(!tab.hidden, "Tab is not hidden hidden after update"); + + await extension.unload(); + + BrowserTestUtils.removeTab(tab); +}); + +// Test our update handling. Currently this means any hidden tabs will be +// shown when a tabHide extension is shutdown. We additionally test the +// tabs.onUpdated listener gets called with hidden state changes. +add_task(async function test_tabs_disable() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.com/" + ); + await BrowserTestUtils.switchTab(gBrowser, gBrowser.tabs[0]); + + const extension = getExtension(); + await extension.startup(); + + // test onUpdated + await Promise.all([ + extension.awaitMessage("ready"), + extension.awaitMessage("changeInfo"), + ]); + Assert.ok(tab.hidden, "Tab is hidden by extension"); + + // Test that disable does hide tabs. + await disableExtension(ID); + Assert.ok(!tab.hidden, "Tab is not hidden hidden after disable"); + + await extension.unload(); + + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_highlight.js b/browser/components/extensions/test/browser/browser_ext_tabs_highlight.js new file mode 100644 index 0000000000..d622b79e7a --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_highlight.js @@ -0,0 +1,118 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* global gBrowser */ +"use strict"; + +add_task(async function test_highlighted() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + background: async function () { + async function testHighlighted(activeIndex, highlightedIndices) { + let tabs = await browser.tabs.query({ currentWindow: true }); + for (let { index, active, highlighted } of tabs) { + browser.test.assertEq( + index == activeIndex, + active, + "Check Tab.active: " + index + ); + let expected = + highlightedIndices.includes(index) || index == activeIndex; + browser.test.assertEq( + expected, + highlighted, + "Check Tab.highlighted: " + index + ); + } + let highlightedTabs = await browser.tabs.query({ + currentWindow: true, + highlighted: true, + }); + browser.test.assertEq( + highlightedIndices + .concat(activeIndex) + .sort((a, b) => a - b) + .join(), + highlightedTabs.map(tab => tab.index).join(), + "Check tabs.query with highlighted:true provides the expected tabs" + ); + } + + browser.test.log( + "Check that last tab is active, and no other is highlighted" + ); + await testHighlighted(2, []); + + browser.test.log("Highlight first and second tabs"); + await browser.tabs.highlight({ tabs: [0, 1] }); + await testHighlighted(0, [1]); + + browser.test.log("Highlight second and first tabs"); + await browser.tabs.highlight({ tabs: [1, 0] }); + await testHighlighted(1, [0]); + + browser.test.log("Test that highlight fails for invalid data"); + await browser.test.assertRejects( + browser.tabs.highlight({ tabs: [] }), + /No highlighted tab/, + "Attempt to highlight no tab should throw" + ); + await browser.test.assertRejects( + browser.tabs.highlight({ windowId: 999999999, tabs: 0 }), + /Invalid window ID: 999999999/, + "Attempt to highlight tabs in invalid window should throw" + ); + await browser.test.assertRejects( + browser.tabs.highlight({ tabs: 999999999 }), + /No tab at index: 999999999/, + "Attempt to highlight invalid tab index should throw" + ); + await browser.test.assertRejects( + browser.tabs.highlight({ tabs: [2, 999999999] }), + /No tab at index: 999999999/, + "Attempt to highlight invalid tab index should throw" + ); + + browser.test.log( + "Highlighted tabs shouldn't be affected by failures above" + ); + await testHighlighted(1, [0]); + + browser.test.log("Highlight last tab"); + let window = await browser.tabs.highlight({ tabs: 2 }); + await testHighlighted(2, []); + + browser.test.assertEq( + 3, + window.tabs.length, + "Returned window should be populated" + ); + + window = await browser.tabs.highlight({ tabs: 2, populate: false }); + browser.test.assertFalse( + "tabs" in window, + "Returned window shouldn't be populated" + ); + + browser.test.notifyPass("test-finished"); + }, + }); + + let tab1 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.com/" + ); + let tab2 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.com/" + ); + + await extension.startup(); + await extension.awaitFinish("test-finished"); + await extension.unload(); + + BrowserTestUtils.removeTab(tab1); + BrowserTestUtils.removeTab(tab2); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_incognito_not_allowed.js b/browser/components/extensions/test/browser/browser_ext_tabs_incognito_not_allowed.js new file mode 100644 index 0000000000..e998f64afc --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_incognito_not_allowed.js @@ -0,0 +1,155 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function testExecuteScriptIncognitoNotAllowed() { + const url = + "http://mochi.test:8888/browser/browser/components/extensions/test/browser/file_iframe_document.html"; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + // captureTab requires all_urls permission + permissions: ["<all_urls>", "tabs", "tabHide"], + }, + background() { + browser.test.onMessage.addListener(async pbw => { + // expect one tab from the non-pb window + let tabs = await browser.tabs.query({ windowId: pbw.windowId }); + browser.test.assertEq( + 0, + tabs.length, + "unable to query tabs in private window" + ); + tabs = await browser.tabs.query({ active: true }); + browser.test.assertEq( + 1, + tabs.length, + "unable to query active tab in private window" + ); + browser.test.assertTrue( + tabs[0].windowId != pbw.windowId, + "unable to query active tab in private window" + ); + + // apis that take a tabId + let tabIdAPIs = [ + "captureTab", + "detectLanguage", + "duplicate", + "get", + "hide", + "reload", + "getZoomSettings", + "getZoom", + "toggleReaderMode", + ]; + for (let name of tabIdAPIs) { + await browser.test.assertRejects( + browser.tabs[name](pbw.tabId), + /Invalid tab ID/, + `should not be able to ${name}` + ); + } + await browser.test.assertRejects( + browser.tabs.captureVisibleTab(pbw.windowId), + /Invalid window ID/, + "should not be able to duplicate" + ); + await browser.test.assertRejects( + browser.tabs.create({ + windowId: pbw.windowId, + url: "http://mochi.test/", + }), + /Invalid window ID/, + "unable to create tab in private window" + ); + await browser.test.assertRejects( + browser.tabs.executeScript(pbw.tabId, { code: "document.URL" }), + /Invalid tab ID/, + "should not be able to executeScript" + ); + let currentTab = await browser.tabs.getCurrent(); + browser.test.assertTrue( + !currentTab, + "unable to get current tab in private window" + ); + await browser.test.assertRejects( + browser.tabs.highlight({ windowId: pbw.windowId, tabs: [pbw.tabId] }), + /Invalid window ID/, + "should not be able to highlight" + ); + await browser.test.assertRejects( + browser.tabs.insertCSS(pbw.tabId, { + code: "* { background: rgb(42, 42, 42) }", + }), + /Invalid tab ID/, + "should not be able to insertCSS" + ); + await browser.test.assertRejects( + browser.tabs.move(pbw.tabId, { + index: 0, + windowId: tabs[0].windowId, + }), + /Invalid tab ID/, + "unable to move tab to private window" + ); + await browser.test.assertRejects( + browser.tabs.move(tabs[0].id, { index: 0, windowId: pbw.windowId }), + /Invalid window ID/, + "unable to move tab to private window" + ); + await browser.test.assertRejects( + browser.tabs.printPreview(), + /Cannot access activeTab/, + "unable to printpreview tab" + ); + await browser.test.assertRejects( + browser.tabs.removeCSS(pbw.tabId, {}), + /Invalid tab ID/, + "unable to remove tab css" + ); + await browser.test.assertRejects( + browser.tabs.sendMessage(pbw.tabId, "test"), + /Could not establish connection/, + "unable to sendmessage" + ); + await browser.test.assertRejects( + browser.tabs.setZoomSettings(pbw.tabId, {}), + /Invalid tab ID/, + "should not be able to set zoom settings" + ); + await browser.test.assertRejects( + browser.tabs.setZoom(pbw.tabId, 3), + /Invalid tab ID/, + "should not be able to set zoom" + ); + await browser.test.assertRejects( + browser.tabs.update(pbw.tabId, {}), + /Invalid tab ID/, + "should not be able to update tab" + ); + await browser.test.assertRejects( + browser.tabs.moveInSuccession([pbw.tabId], tabs[0].id), + /Invalid tab ID/, + "should not be able to moveInSuccession" + ); + await browser.test.assertRejects( + browser.tabs.moveInSuccession([tabs[0].id], pbw.tabId), + /Invalid tab ID/, + "should not be able to moveInSuccession" + ); + + browser.test.notifyPass("pass"); + }); + }, + }); + + let winData = await getIncognitoWindow(url); + await extension.startup(); + + extension.sendMessage(winData.details); + + await extension.awaitFinish("pass"); + await BrowserTestUtils.closeWindow(winData.win); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_insertCSS.js b/browser/components/extensions/test/browser/browser_ext_tabs_insertCSS.js new file mode 100644 index 0000000000..1a4bbd0c74 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_insertCSS.js @@ -0,0 +1,312 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { AddonTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/AddonTestUtils.sys.mjs" +); + +AddonTestUtils.initMochitest(this); + +add_task(async function testExecuteScript() { + let { MessageChannel } = ChromeUtils.importESModule( + "resource://testing-common/MessageChannel.sys.mjs" + ); + + // When the first extension is started, ProxyMessenger.init adds MessageChannel + // listeners for Services.mm and Services.ppmm, and they are never unsubscribed. + // We have to exclude them after the extension has been unloaded to get an accurate + // test. + function getMessageManagersSize(messageManagers) { + return Array.from(messageManagers).filter(([mm]) => { + return ![Services.mm, Services.ppmm].includes(mm); + }).length; + } + + let messageManagersSize = getMessageManagersSize( + MessageChannel.messageManagers + ); + let responseManagersSize = MessageChannel.responseManagers.size; + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://mochi.test:8888/", + true + ); + + async function background() { + let tasks = [ + { + background: "rgba(0, 0, 0, 0)", + foreground: "rgb(0, 113, 4)", + promise: () => { + return browser.tabs.insertCSS({ + file: "file2.css", + }); + }, + }, + { + background: "rgb(42, 42, 42)", + foreground: "rgb(0, 113, 4)", + promise: () => { + return browser.tabs.insertCSS({ + code: "* { background: rgb(42, 42, 42) }", + }); + }, + }, + { + background: "rgb(43, 43, 43)", + foreground: "rgb(0, 113, 4)", + promise: () => { + return browser.tabs + .insertCSS({ + code: "* { background: rgb(100, 100, 100) !important }", + cssOrigin: "author", + }) + .then(r => + browser.tabs.insertCSS({ + code: "* { background: rgb(43, 43, 43) !important }", + cssOrigin: "author", + }) + ); + }, + }, + { + background: "rgb(100, 100, 100)", + foreground: "rgb(0, 113, 4)", + promise: () => { + // User has higher importance + return browser.tabs + .insertCSS({ + code: "* { background: rgb(100, 100, 100) !important }", + cssOrigin: "user", + }) + .then(r => + browser.tabs.insertCSS({ + code: "* { background: rgb(44, 44, 44) !important }", + cssOrigin: "author", + }) + ); + }, + }, + ]; + + function checkCSS() { + let computedStyle = window.getComputedStyle(document.body); + return [computedStyle.backgroundColor, computedStyle.color]; + } + + try { + for (let { promise, background, foreground } of tasks) { + let result = await promise(); + + browser.test.assertEq(undefined, result, "Expected callback result"); + + [result] = await browser.tabs.executeScript({ + code: `(${checkCSS})()`, + }); + + browser.test.assertEq( + background, + result[0], + "Expected background color" + ); + browser.test.assertEq( + foreground, + result[1], + "Expected foreground color" + ); + } + + browser.test.notifyPass("insertCSS"); + } catch (e) { + browser.test.fail(`Error: ${e} :: ${e.stack}`); + browser.test.notifyFail("insertCSS"); + } + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["http://mochi.test/"], + }, + + background, + + files: { + "file2.css": "* { color: rgb(0, 113, 4) }", + }, + }); + + await extension.startup(); + + await extension.awaitFinish("insertCSS"); + + await extension.unload(); + + BrowserTestUtils.removeTab(tab); + + // Make sure that we're not holding on to references to closed message + // managers. + is( + getMessageManagersSize(MessageChannel.messageManagers), + messageManagersSize, + "Message manager count" + ); + is( + MessageChannel.responseManagers.size, + responseManagersSize, + "Response manager count" + ); + is(MessageChannel.pendingResponses.size, 0, "Pending response count"); +}); + +add_task(async function testInsertCSS_cleanup() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://mochi.test:8888/", + true + ); + + async function background() { + await browser.tabs.insertCSS({ code: "* { background: rgb(42, 42, 42) }" }); + await browser.tabs.insertCSS({ file: "customize_fg_color.css" }); + + browser.test.notifyPass("insertCSS"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["http://mochi.test/"], + }, + background, + files: { + "customize_fg_color.css": `* { color: rgb(255, 0, 0) }`, + }, + }); + + await extension.startup(); + + await extension.awaitFinish("insertCSS"); + + const getTabContentComputedStyle = async () => { + let computedStyle = content.getComputedStyle(content.document.body); + return [computedStyle.backgroundColor, computedStyle.color]; + }; + + const appliedStyles = await SpecialPowers.spawn( + tab.linkedBrowser, + [], + getTabContentComputedStyle + ); + + is( + appliedStyles[0], + "rgb(42, 42, 42)", + "The injected CSS code has been applied as expected" + ); + is( + appliedStyles[1], + "rgb(255, 0, 0)", + "The injected CSS file has been applied as expected" + ); + + await extension.unload(); + + const unloadedStyles = await SpecialPowers.spawn( + tab.linkedBrowser, + [], + getTabContentComputedStyle + ); + + is( + unloadedStyles[0], + "rgba(0, 0, 0, 0)", + "The injected CSS code has been removed as expected" + ); + is( + unloadedStyles[1], + "rgb(0, 0, 0)", + "The injected CSS file has been removed as expected" + ); + + BrowserTestUtils.removeTab(tab); +}); + +// Verify that no removeSheet/removeSheetUsingURIString errors are logged while +// cleaning up css injected using a manifest content script or tabs.insertCSS. +add_task(async function test_csscode_cleanup_on_closed_windows() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["http://example.com/*"], + content_scripts: [ + { + matches: ["http://example.com/*"], + css: ["content.css"], + run_at: "document_start", + }, + ], + }, + + files: { + "content.css": "body { min-width: 15px; }", + }, + + async background() { + browser.runtime.onConnect.addListener(port => { + port.onDisconnect.addListener(() => { + browser.test.sendMessage("port-disconnected"); + }); + browser.test.sendMessage("port-connected"); + }); + + await browser.tabs.create({ + url: "http://example.com/", + active: true, + }); + + await browser.tabs.insertCSS({ + code: "body { max-width: 50px; }", + }); + + // Create a port, as a way to detect when the content script has been + // destroyed and any removeSheet error already collected (if it has been + // raised during the content scripts cleanup). + await browser.tabs.executeScript({ + code: `(${function () { + const { maxWidth, minWidth } = window.getComputedStyle(document.body); + browser.test.sendMessage("body-styles", { maxWidth, minWidth }); + browser.runtime.connect(); + }})();`, + }); + }, + }); + + await extension.startup(); + + let { messages } = await AddonTestUtils.promiseConsoleOutput(async () => { + info("Waiting for content scripts to be injected"); + + const { maxWidth, minWidth } = await extension.awaitMessage("body-styles"); + is(maxWidth, "50px", "tabs.insertCSS applied"); + is(minWidth, "15px", "manifest.content_scripts CSS applied"); + + await extension.awaitMessage("port-connected"); + const tab = gBrowser.selectedTab; + + info("Close tab and wait for content script port to be disconnected"); + BrowserTestUtils.removeTab(tab); + await extension.awaitMessage("port-disconnected"); + }); + + // Look for nsIDOMWindowUtils.removeSheet and + // nsIDOMWindowUtils.removeSheetUsingURIString errors. + AddonTestUtils.checkMessages( + messages, + { + forbidden: [{ errorMessage: /nsIDOMWindowUtils.removeSheet/ }], + }, + "Expect no remoteSheet errors" + ); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_lastAccessed.js b/browser/components/extensions/test/browser/browser_ext_tabs_lastAccessed.js new file mode 100644 index 0000000000..c4738d7f2e --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_lastAccessed.js @@ -0,0 +1,52 @@ +"use strict"; + +add_task(async function testLastAccessed() { + let past = Date.now(); + + for (let url of ["https://example.com/?1", "https://example.com/?2"]) { + let tab = BrowserTestUtils.addTab(gBrowser, url, { skipAnimation: true }); + await BrowserTestUtils.browserLoaded(tab.linkedBrowser, false, url); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + async background() { + browser.test.onMessage.addListener(async function (msg, past) { + let [tab1] = await browser.tabs.query({ + url: "https://example.com/?1", + }); + let [tab2] = await browser.tabs.query({ + url: "https://example.com/?2", + }); + + browser.test.assertTrue(tab1 && tab2, "Expected tabs were found"); + + let now = Date.now(); + + browser.test.assertTrue( + past <= tab1.lastAccessed, + "lastAccessed of tab 1 is later than the test start time." + ); + browser.test.assertTrue( + tab1.lastAccessed < tab2.lastAccessed, + "lastAccessed of tab 2 is later than lastAccessed of tab 1." + ); + browser.test.assertTrue( + tab2.lastAccessed <= now, + "lastAccessed of tab 2 is earlier than now." + ); + + await browser.tabs.remove([tab1.id, tab2.id]); + + browser.test.notifyPass("tabs.lastAccessed"); + }); + }, + }); + + await extension.startup(); + await extension.sendMessage("past", past); + await extension.awaitFinish("tabs.lastAccessed"); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_lazy.js b/browser/components/extensions/test/browser/browser_ext_tabs_lazy.js new file mode 100644 index 0000000000..68205089d5 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_lazy.js @@ -0,0 +1,49 @@ +"use strict"; + +const triggeringPrincipal_base64 = E10SUtils.SERIALIZED_SYSTEMPRINCIPAL; + +const SESSION = { + windows: [ + { + tabs: [ + { entries: [{ url: "about:blank", triggeringPrincipal_base64 }] }, + { + entries: [ + { url: "https://example.com/", triggeringPrincipal_base64 }, + ], + }, + ], + }, + ], +}; + +add_task(async function () { + SessionStore.setBrowserState(JSON.stringify(SESSION)); + await promiseWindowRestored(window); + const tab = gBrowser.tabs[1]; + + is(tab.getAttribute("pending"), "true", "The tab is pending restore"); + is(tab.linkedBrowser.isConnected, false, "The tab is lazy"); + + async function background() { + const [tab] = await browser.tabs.query({ url: "https://example.com/" }); + browser.test.assertRejects( + browser.tabs.sendMessage(tab.id, "void"), + /Could not establish connection. Receiving end does not exist/, + "No recievers in a tab pending restore." + ); + browser.test.notifyPass("lazy"); + } + + const manifest = { permissions: ["tabs"] }; + const extension = ExtensionTestUtils.loadExtension({ manifest, background }); + + await extension.startup(); + await extension.awaitFinish("lazy"); + await extension.unload(); + + is(tab.getAttribute("pending"), "true", "The tab is still pending restore"); + is(tab.linkedBrowser.isConnected, false, "The tab is still lazy"); + + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_move_array.js b/browser/components/extensions/test/browser/browser_ext_tabs_move_array.js new file mode 100644 index 0000000000..72b4adbc31 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_move_array.js @@ -0,0 +1,95 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function moveMultiple() { + let tabs = []; + for (let k of [1, 2, 3, 4]) { + let tab = await BrowserTestUtils.openNewForegroundTab( + window.gBrowser, + `https://example.com/?${k}` + ); + tabs.push(tab); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { permissions: ["tabs"] }, + background: async function () { + function num(url) { + return parseInt(url.slice(-1), 10); + } + + async function check(expected) { + let tabs = await browser.tabs.query({ url: "https://example.com/*" }); + let endings = tabs.map(tab => num(tab.url)); + browser.test.assertTrue( + expected.every((v, i) => v === endings[i]), + `Tab order should be ${expected}, got ${endings}.` + ); + } + + async function reset() { + let tabs = await browser.tabs.query({ url: "https://example.com/*" }); + await browser.tabs.move( + tabs.sort((a, b) => num(a.url) - num(b.url)).map(tab => tab.id), + { index: 0 } + ); + } + + async function move(moveIndexes, moveTo) { + let tabs = await browser.tabs.query({ url: "https://example.com/*" }); + await browser.tabs.move( + moveIndexes.map(e => tabs[e - 1].id), + { + index: moveTo, + } + ); + } + + let tests = [ + { move: [2], index: 0, result: [2, 1, 3, 4] }, + { move: [2], index: -1, result: [1, 3, 4, 2] }, + // Start -> After first tab -> After second tab + { move: [4, 3], index: 0, result: [4, 3, 1, 2] }, + // [1, 2, 3, 4] -> [1, 4, 2, 3] -> [1, 4, 3, 2] + { move: [4, 3], index: 1, result: [1, 4, 3, 2] }, + // [1, 2, 3, 4] -> [2, 3, 1, 4] -> [3, 1, 2, 4] + { move: [1, 2], index: 2, result: [3, 1, 2, 4] }, + // [1, 2, 3, 4] -> [1, 2, 4, 3] -> [2, 4, 1, 3] + { move: [4, 1], index: 2, result: [2, 4, 1, 3] }, + // [1, 2, 3, 4] -> [2, 3, 1, 4] -> [2, 3, 1, 4] + { move: [1, 4], index: 2, result: [2, 3, 1, 4] }, + ]; + + for (let test of tests) { + await reset(); + await move(test.move, test.index); + await check(test.result); + } + + let firstId = ( + await browser.tabs.query({ + url: "https://example.com/*", + }) + )[0].id; + // Assuming that tab.id of 12345 does not exist. + await browser.test.assertRejects( + browser.tabs.move([firstId, 12345], { index: -1 }), + /Invalid tab/, + "Should receive invalid tab error" + ); + // The first argument got moved, the second on failed. + await check([2, 3, 1, 4]); + + browser.test.notifyPass("tabs.move"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabs.move"); + await extension.unload(); + + for (let tab of tabs) { + BrowserTestUtils.removeTab(tab); + } +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_move_array_multiple_windows.js b/browser/components/extensions/test/browser/browser_ext_tabs_move_array_multiple_windows.js new file mode 100644 index 0000000000..484197cbc5 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_move_array_multiple_windows.js @@ -0,0 +1,160 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function moveMultipleWindows() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { permissions: ["tabs"] }, + background: async function () { + let numToId = new Map(); + let idToNum = new Map(); + let windowToInitialTabs = new Map(); + + async function createWindow(nums) { + let window = await browser.windows.create({ + url: nums.map(k => `https://example.com/?${k}`), + }); + let tabIds = window.tabs.map(tab => tab.id); + windowToInitialTabs.set(window.id, tabIds); + for (let i = 0; i < nums.length; ++i) { + numToId.set(nums[i], tabIds[i]); + idToNum.set(tabIds[i], nums[i]); + } + return window.id; + } + + let win1 = await createWindow([0, 1, 2, 3, 4]); + let win2 = await createWindow([5, 6, 7, 8, 9]); + + async function getNums(windowId) { + let tabs = await browser.tabs.query({ windowId }); + return tabs.map(tab => idToNum.get(tab.id)); + } + + async function check(msg, expected) { + let nums1 = getNums(win1); + let nums2 = getNums(win2); + browser.test.assertEq( + JSON.stringify(expected), + JSON.stringify({ win1: await nums1, win2: await nums2 }), + `Check ${msg}` + ); + } + + async function reset() { + for (let [windowId, tabIds] of windowToInitialTabs) { + await browser.tabs.move(tabIds, { index: 0, windowId }); + } + } + + async function move(nums, params) { + await browser.tabs.move( + nums.map(k => numToId.get(k)), + params + ); + } + + let tests = [ + { + move: [1, 6], + params: { index: 0 }, + result: { win1: [1, 0, 2, 3, 4], win2: [6, 5, 7, 8, 9] }, + }, + { + move: [6, 1], + params: { index: 0 }, + result: { win1: [1, 0, 2, 3, 4], win2: [6, 5, 7, 8, 9] }, + }, + { + move: [1, 6], + params: { index: 0, windowId: win2 }, + result: { win1: [0, 2, 3, 4], win2: [1, 6, 5, 7, 8, 9] }, + }, + { + move: [6, 1], + params: { index: 0, windowId: win2 }, + result: { win1: [0, 2, 3, 4], win2: [6, 1, 5, 7, 8, 9] }, + }, + { + move: [1, 6], + params: { index: -1 }, + result: { win1: [0, 2, 3, 4, 1], win2: [5, 7, 8, 9, 6] }, + }, + { + move: [6, 1], + params: { index: -1 }, + result: { win1: [0, 2, 3, 4, 1], win2: [5, 7, 8, 9, 6] }, + }, + { + move: [1, 6], + params: { index: -1, windowId: win2 }, + result: { win1: [0, 2, 3, 4], win2: [5, 7, 8, 9, 1, 6] }, + }, + { + move: [6, 1], + params: { index: -1, windowId: win2 }, + result: { win1: [0, 2, 3, 4], win2: [5, 7, 8, 9, 6, 1] }, + }, + { + move: [2, 1, 7, 6], + params: { index: 3 }, + result: { win1: [0, 3, 2, 1, 4], win2: [5, 8, 7, 6, 9] }, + }, + { + move: [1, 2, 3, 4], + params: { index: 0, windowId: win2 }, + result: { win1: [0], win2: [1, 2, 3, 4, 5, 6, 7, 8, 9] }, + }, + { + move: [0, 1, 2, 3], + params: { index: 5, windowId: win2 }, + result: { win1: [4], win2: [5, 6, 7, 8, 9, 0, 1, 2, 3] }, + }, + { + move: [1, 2, 3, 4, 5, 6, 7, 8, 9], + params: { index: 0, windowId: win2 }, + result: { win1: [0], win2: [1, 2, 3, 4, 5, 6, 7, 8, 9] }, + }, + { + move: [5, 6, 7, 8, 9, 0, 1, 2, 3], + params: { index: 0, windowId: win2 }, + result: { win1: [4], win2: [5, 6, 7, 8, 9, 0, 1, 2, 3] }, + }, + { + move: [5, 1, 6, 2, 7, 3, 8, 4, 9], + params: { index: 0, windowId: win2 }, + result: { win1: [0], win2: [5, 1, 6, 2, 7, 3, 8, 4, 9] }, + }, + { + move: [5, 1, 6, 2, 7, 3, 8, 4, 9], + params: { index: 1, windowId: win2 }, + result: { win1: [0], win2: [5, 1, 6, 2, 7, 3, 8, 4, 9] }, + }, + { + move: [5, 1, 6, 2, 7, 3, 8, 4, 9], + params: { index: 999, windowId: win2 }, + result: { win1: [0], win2: [5, 1, 6, 2, 7, 3, 8, 4, 9] }, + }, + ]; + + const initial = { win1: [0, 1, 2, 3, 4], win2: [5, 6, 7, 8, 9] }; + await check("initial", initial); + for (let test of tests) { + browser.test.log(JSON.stringify(test)); + await move(test.move, test.params); + await check("move", test.result); + await reset(); + await check("reset", initial); + } + + await browser.windows.remove(win1); + await browser.windows.remove(win2); + browser.test.notifyPass("tabs.move"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabs.move"); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_move_discarded.js b/browser/components/extensions/test/browser/browser_ext_tabs_move_discarded.js new file mode 100644 index 0000000000..ebee4fbc90 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_move_discarded.js @@ -0,0 +1,94 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function move_discarded_to_window() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { permissions: ["tabs"] }, + background: async function () { + // Create a discarded tab + let url = "https://example.com/"; + let tab = await browser.tabs.create({ url, discarded: true }); + browser.test.assertEq(true, tab.discarded, "Tab should be discarded"); + browser.test.assertEq(url, tab.url, "Tab URL should be correct"); + + // Create a new window + let { id: windowId } = await browser.windows.create(); + + // Move the tab into that window + [tab] = await browser.tabs.move(tab.id, { windowId, index: -1 }); + browser.test.assertTrue(tab.discarded, "Tab should still be discarded"); + browser.test.assertEq(url, tab.url, "Tab URL should still be correct"); + + await browser.windows.remove(windowId); + browser.test.notifyPass("tabs.move"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabs.move"); + await extension.unload(); +}); + +add_task(async function move_hidden_discarded_to_window() { + let extensionWithoutTabsPermission = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["https://example.com/"], + }, + background() { + browser.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { + if (changeInfo.hidden) { + browser.test.assertEq( + tab.url, + "https://example.com/?hideme", + "tab.url is correctly observed without tabs permission" + ); + browser.test.sendMessage("onUpdated_checked"); + } + }); + // Listener with "urls" filter, regression test for + // https://bugzilla.mozilla.org/show_bug.cgi?id=1695346 + browser.tabs.onUpdated.addListener( + (tabId, changeInfo, tab) => { + browser.test.assertTrue(changeInfo.hidden, "tab was hidden"); + browser.test.sendMessage("onUpdated_urls_filter"); + }, + { + properties: ["hidden"], + urls: ["https://example.com/?hideme"], + } + ); + }, + }); + await extensionWithoutTabsPermission.startup(); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { permissions: ["tabs", "tabHide"] }, + // ExtensionControlledPopup's populateDescription method requires an addon: + useAddonManager: "temporary", + async background() { + let url = "https://example.com/?hideme"; + let tab = await browser.tabs.create({ url, discarded: true }); + await browser.tabs.hide(tab.id); + + let { id: windowId } = await browser.windows.create(); + + // Move the tab into that window + [tab] = await browser.tabs.move(tab.id, { windowId, index: -1 }); + browser.test.assertTrue(tab.discarded, "Tab should still be discarded"); + browser.test.assertTrue(tab.hidden, "Tab should still be hidden"); + browser.test.assertEq(url, tab.url, "Tab URL should still be correct"); + + await browser.windows.remove(windowId); + browser.test.notifyPass("move_hidden_discarded_to_window"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("move_hidden_discarded_to_window"); + await extension.unload(); + + await extensionWithoutTabsPermission.awaitMessage("onUpdated_checked"); + await extensionWithoutTabsPermission.awaitMessage("onUpdated_urls_filter"); + await extensionWithoutTabsPermission.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_move_window.js b/browser/components/extensions/test/browser/browser_ext_tabs_move_window.js new file mode 100644 index 0000000000..56d82b3d3a --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_move_window.js @@ -0,0 +1,178 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function () { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + incognitoOverride: "spanning", + async background() { + const URL = "https://example.com/"; + let mainWindow = await browser.windows.getCurrent(); + let newWindow = await browser.windows.create({ + url: [URL, URL], + }); + let privateWindow = await browser.windows.create({ + incognito: true, + url: [URL, URL], + }); + + browser.tabs.onUpdated.addListener(() => { + // Bug 1398272: Adding onUpdated listener broke tab IDs across windows. + }); + + let tab = newWindow.tabs[0].id; + let privateTab = privateWindow.tabs[0].id; + + // Assuming that this windowId does not exist. + await browser.test.assertRejects( + browser.tabs.move(tab, { windowId: 123144576, index: 0 }), + /Invalid window/, + "Should receive invalid window error" + ); + + // Test that a tab cannot be moved to a private window. + let moved = await browser.tabs.move(tab, { + windowId: privateWindow.id, + index: 0, + }); + browser.test.assertEq( + moved.length, + 0, + "tab was not moved to private window" + ); + // Test that a private tab cannot be moved to a non-private window. + moved = await browser.tabs.move(privateTab, { + windowId: newWindow.id, + index: 0, + }); + browser.test.assertEq( + moved.length, + 0, + "tab was not moved from private window" + ); + + // Verify tabs did not move between windows via another query. + let windows = await browser.windows.getAll({ populate: true }); + let newWin2 = windows.find(w => w.id === newWindow.id); + browser.test.assertTrue(newWin2, "Found window"); + browser.test.assertEq( + newWin2.tabs.length, + 2, + "Window still has two tabs" + ); + for (let origTab of newWindow.tabs) { + browser.test.assertTrue( + newWin2.tabs.find(t => t.id === origTab.id), + `Window still has tab ${origTab.id}` + ); + } + + let privateWin2 = windows.find(w => w.id === privateWindow.id); + browser.test.assertTrue(privateWin2 !== null, "Found private window"); + browser.test.assertEq( + privateWin2.incognito, + true, + "Private window is still private" + ); + browser.test.assertEq( + privateWin2.tabs.length, + 2, + "Private window still has two tabs" + ); + for (let origTab of privateWindow.tabs) { + browser.test.assertTrue( + privateWin2.tabs.find(t => t.id === origTab.id), + `Private window still has tab ${origTab.id}` + ); + } + + // Move a tab from one non-private window to another + await browser.tabs.move(tab, { windowId: mainWindow.id, index: 0 }); + + mainWindow = await browser.windows.get(mainWindow.id, { populate: true }); + browser.test.assertTrue( + mainWindow.tabs.find(t => t.id === tab), + "Moved tab is in main window" + ); + + newWindow = await browser.windows.get(newWindow.id, { populate: true }); + browser.test.assertEq( + newWindow.tabs.length, + 1, + "New window has 1 tab left" + ); + browser.test.assertTrue( + newWindow.tabs[0].id != tab, + "Moved tab is no longer in original window" + ); + + await browser.windows.remove(newWindow.id); + await browser.windows.remove(privateWindow.id); + await browser.tabs.remove(tab); + + browser.test.notifyPass("tabs.move.window"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabs.move.window"); + await extension.unload(); +}); + +add_task(async function test_currentWindowAfterTabMoved() { + const files = { + "current.html": "<meta charset=utf-8><script src=current.js></script>", + "current.js": function () { + browser.test.onMessage.addListener(msg => { + if (msg === "current") { + browser.windows.getCurrent(win => { + browser.test.sendMessage("id", win.id); + }); + } + }); + browser.test.sendMessage("ready"); + }, + }; + + async function background() { + let tabId; + + const url = browser.runtime.getURL("current.html"); + + browser.test.onMessage.addListener(async msg => { + if (msg === "move") { + await browser.windows.create({ tabId }); + browser.test.sendMessage("moved"); + } else if (msg === "close") { + await browser.tabs.remove(tabId); + browser.test.sendMessage("done"); + } + }); + + let tab = await browser.tabs.create({ url }); + tabId = tab.id; + } + + const extension = ExtensionTestUtils.loadExtension({ files, background }); + + await extension.startup(); + await extension.awaitMessage("ready"); + + extension.sendMessage("current"); + const first = await extension.awaitMessage("id"); + + extension.sendMessage("move"); + await extension.awaitMessage("moved"); + + extension.sendMessage("current"); + const second = await extension.awaitMessage("id"); + + isnot(first, second, "current window id is different after moving the tab"); + + extension.sendMessage("close"); + await extension.awaitMessage("done"); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_move_window_multiple.js b/browser/components/extensions/test/browser/browser_ext_tabs_move_window_multiple.js new file mode 100644 index 0000000000..559cd7dd46 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_move_window_multiple.js @@ -0,0 +1,64 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function () { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + async background() { + const URL = "https://example.com/"; + let mainWin = await browser.windows.getCurrent(); + let tab1 = await browser.tabs.create({ url: URL }); + let tab2 = await browser.tabs.create({ url: URL }); + + let newWin = await browser.windows.create({ url: [URL, URL] }); + browser.test.assertEq(newWin.tabs.length, 2, "New window has 2 tabs"); + let [tab3, tab4] = newWin.tabs; + + // move tabs in both windows to index 0 in a single call + await browser.tabs.move([tab2.id, tab4.id], { index: 0 }); + + tab1 = await browser.tabs.get(tab1.id); + browser.test.assertEq( + tab1.windowId, + mainWin.id, + "tab 1 is still in main window" + ); + + tab2 = await browser.tabs.get(tab2.id); + browser.test.assertEq( + tab2.windowId, + mainWin.id, + "tab 2 is still in main window" + ); + browser.test.assertEq(tab2.index, 0, "tab 2 moved to index 0"); + + tab3 = await browser.tabs.get(tab3.id); + browser.test.assertEq( + tab3.windowId, + newWin.id, + "tab 3 is still in new window" + ); + + tab4 = await browser.tabs.get(tab4.id); + browser.test.assertEq( + tab4.windowId, + newWin.id, + "tab 4 is still in new window" + ); + browser.test.assertEq(tab4.index, 0, "tab 4 moved to index 0"); + + await browser.tabs.remove([tab1.id, tab2.id]); + await browser.windows.remove(newWin.id); + + browser.test.notifyPass("tabs.move.multiple"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabs.move.multiple"); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_move_window_pinned.js b/browser/components/extensions/test/browser/browser_ext_tabs_move_window_pinned.js new file mode 100644 index 0000000000..3898540fa0 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_move_window_pinned.js @@ -0,0 +1,44 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function () { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + async background() { + const URL = "https://example.com/"; + + let mainWin = await browser.windows.getCurrent(); + let tab = await browser.tabs.create({ url: URL }); + + let newWin = await browser.windows.create({ url: URL }); + let tab2 = newWin.tabs[0]; + await browser.tabs.update(tab2.id, { pinned: true }); + + // Try to move a tab before the pinned tab. The move should be ignored. + let moved = await browser.tabs.move(tab.id, { + windowId: newWin.id, + index: 0, + }); + browser.test.assertEq(moved.length, 0, "move() returned no moved tab"); + + tab = await browser.tabs.get(tab.id); + browser.test.assertEq( + tab.windowId, + mainWin.id, + "Tab stayed in its original window" + ); + + await browser.tabs.remove(tab.id); + await browser.windows.remove(newWin.id); + browser.test.notifyPass("tabs.move.pin"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabs.move.pin"); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_newtab_private.js b/browser/components/extensions/test/browser/browser_ext_tabs_newtab_private.js new file mode 100644 index 0000000000..19146fbe42 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_newtab_private.js @@ -0,0 +1,96 @@ +"use strict"; + +const { ExtensionPermissions } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionPermissions.sys.mjs" +); + +const NEWTAB_PRIVATE_ALLOWED = "browser.newtab.privateAllowed"; +const NEWTAB_EXTENSION_CONTROLLED = "browser.newtab.extensionControlled"; +const NEWTAB_URI = "webext-newtab-1.html"; + +function promisePrefChange(pref) { + return new Promise((resolve, reject) => { + Services.prefs.addObserver(pref, function observer() { + Services.prefs.removeObserver(pref, observer); + resolve(arguments); + }); + }); +} + +function verifyPrefSettings(controlled, allowed) { + is( + Services.prefs.getBoolPref(NEWTAB_EXTENSION_CONTROLLED, false), + controlled, + "newtab extension controlled" + ); + is( + Services.prefs.getBoolPref(NEWTAB_PRIVATE_ALLOWED, false), + allowed, + "newtab private permission after permission change" + ); + + if (controlled) { + ok( + AboutNewTab.newTabURL.endsWith(NEWTAB_URI), + "Newtab url is overridden by the extension." + ); + } + if (controlled && allowed) { + ok( + BROWSER_NEW_TAB_URL.endsWith(NEWTAB_URI), + "active newtab url is overridden by the extension." + ); + } else { + let expectednewTab = controlled ? "about:privatebrowsing" : "about:newtab"; + is(BROWSER_NEW_TAB_URL, expectednewTab, "active newtab url is default."); + } +} + +async function promiseUpdatePrivatePermission(allowed, extension) { + info(`update private allowed permission`); + let ext = WebExtensionPolicy.getByID(extension.id).extension; + await Promise.all([ + promisePrefChange(NEWTAB_PRIVATE_ALLOWED), + ExtensionPermissions[allowed ? "add" : "remove"]( + extension.id, + { permissions: ["internal:privateBrowsingAllowed"], origins: [] }, + ext + ), + ]); + + verifyPrefSettings(true, allowed); +} + +add_task(async function test_new_tab_private() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { + id: "@private-newtab", + }, + }, + chrome_url_overrides: { + newtab: NEWTAB_URI, + }, + }, + files: { + NEWTAB_URI: ` + <!DOCTYPE html> + <head> + <meta charset="utf-8"/></head> + <html> + <body> + </body> + </html> + `, + }, + useAddonManager: "permanent", + }); + await extension.startup(); + + verifyPrefSettings(true, false); + + await promiseUpdatePrivatePermission(true, extension); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_onCreated.js b/browser/components/extensions/test/browser/browser_ext_tabs_onCreated.js new file mode 100644 index 0000000000..b48047abde --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_onCreated.js @@ -0,0 +1,35 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ +"use strict"; + +add_task(async function test_onCreated_active() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + async background() { + browser.tabs.onCreated.addListener(tab => { + browser.tabs.remove(tab.id); + browser.test.sendMessage("onCreated", tab); + }); + browser.tabs.onUpdated.addListener((tabId, changes, tab) => { + browser.test.assertEq( + '["status"]', + JSON.stringify(Object.keys(changes)), + "Should get no update other than 'status' during tab creation." + ); + }); + browser.test.sendMessage("ready"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + BrowserOpenTab(); + + let tab = await extension.awaitMessage("onCreated"); + is(true, tab.active, "Tab should be active"); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_onHighlighted.js b/browser/components/extensions/test/browser/browser_ext_tabs_onHighlighted.js new file mode 100644 index 0000000000..a614dc6144 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_onHighlighted.js @@ -0,0 +1,130 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ +"use strict"; + +add_task(async function test_onHighlighted() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + background: async function () { + async function expectHighlighted(fn, action) { + let resolve; + let promise = new Promise(r => { + resolve = r; + }); + let expected; + let events = []; + let listener = highlightInfo => { + events.push(highlightInfo); + if (expected && expected.length >= events.length) { + resolve(); + } + }; + browser.tabs.onHighlighted.addListener(listener); + expected = (await fn()) || []; + if (events.length < expected.length) { + await promise; + } + let unexpected = events.splice(expected.length); + browser.test.assertEq( + JSON.stringify(expected), + JSON.stringify(events), + `Should get ${expected.length} expected onHighlighted events when ${action}` + ); + if (unexpected.length) { + browser.test.fail( + `${unexpected.length} unexpected onHighlighted events when ${action}: ` + + JSON.stringify(unexpected) + ); + } + browser.tabs.onHighlighted.removeListener(listener); + } + + let [{ id, windowId }] = await browser.tabs.query({ + active: true, + currentWindow: true, + }); + let windows = [windowId]; + let tabs = [id]; + + await expectHighlighted(async () => { + let tab = await browser.tabs.create({ + active: true, + url: "about:blank?1", + }); + tabs.push(tab.id); + return [{ tabIds: [tabs[1]], windowId: windows[0] }]; + }, "creating a new active tab"); + + await expectHighlighted(async () => { + await browser.tabs.update(tabs[0], { active: true }); + return [{ tabIds: [tabs[0]], windowId: windows[0] }]; + }, "selecting former tab"); + + await expectHighlighted(async () => { + await browser.tabs.highlight({ tabs: [0, 1] }); + return [{ tabIds: [tabs[0], tabs[1]], windowId: windows[0] }]; + }, "highlighting both tabs"); + + await expectHighlighted(async () => { + await browser.tabs.highlight({ tabs: [1, 0] }); + return [{ tabIds: [tabs[0], tabs[1]], windowId: windows[0] }]; + }, "highlighting same tabs but changing selected one"); + + await expectHighlighted(async () => { + let tab = await browser.tabs.create({ + active: false, + url: "about:blank?2", + }); + tabs.push(tab.id); + }, "create a new inactive tab"); + + await expectHighlighted(async () => { + await browser.tabs.highlight({ tabs: [2, 0, 1] }); + return [{ tabIds: [tabs[0], tabs[1], tabs[2]], windowId: windows[0] }]; + }, "highlighting all tabs"); + + await expectHighlighted(async () => { + await browser.tabs.move(tabs[1], { index: 0 }); + }, "reordering tabs"); + + await expectHighlighted(async () => { + await browser.tabs.highlight({ tabs: [0] }); + return [{ tabIds: [tabs[1]], windowId: windows[0] }]; + }, "highlighting moved tab"); + + await expectHighlighted(async () => { + await browser.tabs.highlight({ tabs: [0] }); + }, "highlighting again"); + + await expectHighlighted(async () => { + await browser.tabs.highlight({ tabs: [2, 1, 0] }); + return [{ tabIds: [tabs[1], tabs[0], tabs[2]], windowId: windows[0] }]; + }, "highlighting all tabs"); + + await expectHighlighted(async () => { + await browser.tabs.highlight({ tabs: [2, 0, 1] }); + }, "highlighting same tabs with different order"); + + await expectHighlighted(async () => { + let window = await browser.windows.create({ tabId: tabs[2] }); + windows.push(window.id); + // Bug 1481185: on Chrome it's [tabs[1], tabs[0]] instead of [tabs[0]] + return [ + { tabIds: [tabs[0]], windowId: windows[0] }, + { tabIds: [tabs[2]], windowId: windows[1] }, + ]; + }, "moving selected tab into a new window"); + + await browser.tabs.remove(tabs.slice(1)); + browser.test.notifyPass("test-finished"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("test-finished"); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_onUpdated.js b/browser/components/extensions/test/browser/browser_ext_tabs_onUpdated.js new file mode 100644 index 0000000000..a59fa21f8a --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_onUpdated.js @@ -0,0 +1,339 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +requestLongerTimeout(2); + +add_task(async function () { + let win1 = await BrowserTestUtils.openNewBrowserWindow(); + + await focusWindow(win1); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + content_scripts: [ + { + matches: ["http://mochi.test/*/context_tabs_onUpdated_page.html"], + js: ["content-script.js"], + run_at: "document_start", + }, + ], + }, + + background: function () { + let pageURL = + "http://mochi.test:8888/browser/browser/components/extensions/test/browser/context_tabs_onUpdated_page.html"; + + let expectedSequence = [ + { status: "loading" }, + { status: "loading", url: pageURL }, + { status: "complete" }, + ]; + let collectedSequence = []; + + browser.tabs.onUpdated.addListener(function (tabId, updatedInfo) { + // onUpdated also fires with updatedInfo.faviconUrl, so explicitly + // check for updatedInfo.status before recording the event. + if ("status" in updatedInfo) { + collectedSequence.push(updatedInfo); + } + }); + + browser.runtime.onMessage.addListener(function () { + if (collectedSequence.length !== expectedSequence.length) { + browser.test.assertEq( + JSON.stringify(expectedSequence), + JSON.stringify(collectedSequence), + "got unexpected number of updateInfo data" + ); + } else { + for (let i = 0; i < expectedSequence.length; i++) { + browser.test.assertEq( + expectedSequence[i].status, + collectedSequence[i].status, + "check updatedInfo status" + ); + if (expectedSequence[i].url || collectedSequence[i].url) { + browser.test.assertEq( + expectedSequence[i].url, + collectedSequence[i].url, + "check updatedInfo url" + ); + } + } + } + + browser.test.notifyPass("tabs.onUpdated"); + }); + + browser.tabs.create({ url: pageURL }); + }, + files: { + "content-script.js": ` + window.addEventListener("message", function(evt) { + if (evt.data == "frame-updated") { + browser.runtime.sendMessage("load-completed"); + } + }, true); + `, + }, + }); + + await Promise.all([ + extension.startup(), + extension.awaitFinish("tabs.onUpdated"), + ]); + + await extension.unload(); + + await BrowserTestUtils.closeWindow(win1); +}); + +async function do_test_update(background, withPermissions = true) { + let win1 = await BrowserTestUtils.openNewBrowserWindow(); + + await focusWindow(win1); + + let manifest = {}; + if (withPermissions) { + manifest.permissions = ["tabs", "http://mochi.test/"]; + } + let extension = ExtensionTestUtils.loadExtension({ manifest, background }); + + await extension.startup(); + await extension.awaitFinish("finish"); + + await extension.unload(); + + await BrowserTestUtils.closeWindow(win1); +} + +add_task(async function test_pinned() { + await do_test_update(function background() { + // Create a new tab for testing update. + browser.tabs.create({}, function (tab) { + browser.tabs.onUpdated.addListener(function onUpdated(tabId, changeInfo) { + // Check callback + browser.test.assertEq(tabId, tab.id, "Check tab id"); + browser.test.log("onUpdate: " + JSON.stringify(changeInfo)); + if ("pinned" in changeInfo) { + browser.test.assertTrue(changeInfo.pinned, "Check changeInfo.pinned"); + browser.tabs.onUpdated.removeListener(onUpdated); + // Remove created tab. + browser.tabs.remove(tabId); + browser.test.notifyPass("finish"); + } + }); + browser.tabs.update(tab.id, { pinned: true }); + }); + }); +}); + +add_task(async function test_unpinned() { + await do_test_update(function background() { + // Create a new tab for testing update. + browser.tabs.create({ pinned: true }, function (tab) { + browser.tabs.onUpdated.addListener(function onUpdated(tabId, changeInfo) { + // Check callback + browser.test.assertEq(tabId, tab.id, "Check tab id"); + browser.test.log("onUpdate: " + JSON.stringify(changeInfo)); + if ("pinned" in changeInfo) { + browser.test.assertFalse( + changeInfo.pinned, + "Check changeInfo.pinned" + ); + browser.tabs.onUpdated.removeListener(onUpdated); + // Remove created tab. + browser.tabs.remove(tabId); + browser.test.notifyPass("finish"); + } + }); + browser.tabs.update(tab.id, { pinned: false }); + }); + }); +}); + +add_task(async function test_url() { + await do_test_update(function background() { + // Create a new tab for testing update. + browser.tabs.create({ url: "about:blank?initial_url=1" }, function (tab) { + const expectedUpdatedURL = "about:blank?updated_url=1"; + + browser.tabs.onUpdated.addListener(function onUpdated(tabId, changeInfo) { + // Wait for the tabs.onUpdated events related to the updated url (because + // there is a good chance that we may still be receiving events related to + // the browser.tabs.create API call above before we are able to start + // loading the new url from the browser.tabs.update API call below). + if ("url" in changeInfo && changeInfo.url === expectedUpdatedURL) { + browser.test.assertEq( + expectedUpdatedURL, + changeInfo.url, + "Got tabs.onUpdated event for the expected url" + ); + browser.tabs.onUpdated.removeListener(onUpdated); + // Remove created tab. + browser.tabs.remove(tabId); + browser.test.notifyPass("finish"); + } + // Check callback + browser.test.assertEq(tabId, tab.id, "Check tab id"); + browser.test.log("onUpdate: " + JSON.stringify(changeInfo)); + }); + + browser.tabs.update(tab.id, { url: expectedUpdatedURL }); + }); + }); +}); + +add_task(async function test_title() { + await do_test_update(async function background() { + const url = + "http://mochi.test:8888/browser/browser/components/extensions/test/browser/context_tabs_onUpdated_page.html"; + const tab = await browser.tabs.create({ url }); + + browser.tabs.onUpdated.addListener(function onUpdated(tabId, changeInfo) { + browser.test.assertEq(tabId, tab.id, "Check tab id"); + browser.test.log(`onUpdated: ${JSON.stringify(changeInfo)}`); + if ("title" in changeInfo && changeInfo.title === "New Message (1)") { + browser.test.log("changeInfo.title is correct"); + browser.tabs.onUpdated.removeListener(onUpdated); + browser.tabs.remove(tabId); + browser.test.notifyPass("finish"); + } + }); + + browser.tabs.executeScript(tab.id, { + code: "document.title = 'New Message (1)'", + }); + }); +}); + +add_task(async function test_without_tabs_permission() { + await do_test_update(async function background() { + const url = + "http://mochi.test:8888/browser/browser/components/extensions/test/browser/context_tabs_onUpdated_page.html"; + let tab = null; + let count = 0; + + browser.tabs.onUpdated.addListener(function onUpdated(tabId, changeInfo) { + // An attention change can happen during tabs.create, so + // we can't compare against tab yet. + if (!("attention" in changeInfo)) { + browser.test.assertEq(tabId, tab.id, "Check tab id"); + } + browser.test.log(`onUpdated: ${JSON.stringify(changeInfo)}`); + + browser.test.assertFalse( + "url" in changeInfo, + "url should not be included without tabs permission" + ); + browser.test.assertFalse( + "favIconUrl" in changeInfo, + "favIconUrl should not be included without tabs permission" + ); + browser.test.assertFalse( + "title" in changeInfo, + "title should not be included without tabs permission" + ); + + if (changeInfo.status == "complete") { + count++; + if (count === 1) { + browser.tabs.reload(tabId); + } else { + browser.test.log("Reload complete"); + browser.tabs.onUpdated.removeListener(onUpdated); + browser.tabs.remove(tabId); + browser.test.notifyPass("finish"); + } + } + }); + + tab = await browser.tabs.create({ url }); + }, false /* withPermissions */); +}); + +add_task(async function test_onUpdated_after_onRemoved() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + async background() { + const url = + "http://mochi.test:8888/browser/browser/components/extensions/test/browser/context_tabs_onUpdated_page.html"; + let removed = false; + let tab; + + // If remove happens fast and we never receive onUpdated, that is ok, but + // we never want to receive onUpdated after onRemoved. + browser.tabs.onUpdated.addListener(function onUpdated(tabId, changeInfo) { + if (!tab || tab.id !== tabId) { + return; + } + browser.test.assertFalse( + removed, + "tab has not been removed before onUpdated" + ); + }); + + browser.tabs.onRemoved.addListener((tabId, removedInfo) => { + if (!tab || tab.id !== tabId) { + return; + } + removed = true; + browser.test.notifyPass("onRemoved"); + }); + + tab = await browser.tabs.create({ url }); + browser.tabs.remove(tab.id); + }, + }); + await extension.startup(); + await extension.awaitFinish("onRemoved"); + await extension.unload(); +}); + +// Regression test for Bug 1852391. +add_task(async function test_pin_discarded_tab() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + async background() { + const url = "http://mochi.test:8888"; + const newTab = await browser.tabs.create({ + url, + active: false, + discarded: true, + }); + browser.tabs.onUpdated.addListener( + async (tabId, changeInfo) => { + browser.test.assertEq( + tabId, + newTab.id, + "Expect onUpdated to be fired for the expected tab" + ); + browser.test.assertEq( + changeInfo.pinned, + true, + "Expect pinned to be set to true" + ); + await browser.tabs.remove(newTab.id); + browser.test.notifyPass("onPinned"); + }, + { properties: ["pinned"] } + ); + await browser.tabs.update(newTab.id, { pinned: true }).catch(err => { + browser.test.fail(`Got unexpected rejection from tabs.update: ${err}`); + browser.test.notifyFail("onPinned"); + }); + }, + }); + + await extension.startup(); + await extension.awaitFinish("onPinned"); + await extension.unload(); +}); + +add_task(forceGC); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_onUpdated_filter.js b/browser/components/extensions/test/browser/browser_ext_tabs_onUpdated_filter.js new file mode 100644 index 0000000000..83d305e491 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_onUpdated_filter.js @@ -0,0 +1,354 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_filter_url() { + let ext_fail = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + background() { + browser.tabs.onUpdated.addListener( + (tabId, changeInfo) => { + browser.test.fail( + `received unexpected onUpdated event ${JSON.stringify(changeInfo)}` + ); + }, + { urls: ["*://*.mozilla.org/*"] } + ); + }, + }); + await ext_fail.startup(); + + let ext_perm = ExtensionTestUtils.loadExtension({ + background() { + browser.tabs.onUpdated.addListener( + (tabId, changeInfo) => { + browser.test.fail( + `received unexpected onUpdated event without tabs permission` + ); + }, + { urls: ["*://mochi.test/*"] } + ); + }, + }); + await ext_perm.startup(); + + let ext_ok = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + background() { + browser.tabs.onUpdated.addListener( + (tabId, changeInfo) => { + browser.test.log(`got onUpdated ${JSON.stringify(changeInfo)}`); + if (changeInfo.status === "complete") { + browser.test.notifyPass("onUpdated"); + } + }, + { urls: ["*://mochi.test/*"] } + ); + }, + }); + await ext_ok.startup(); + let ok1 = ext_ok.awaitFinish("onUpdated"); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://mochi.test:8888/" + ); + await ok1; + + await ext_ok.unload(); + await ext_fail.unload(); + await ext_perm.unload(); + + await BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_filter_url_activeTab() { + let ext = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["activeTab"], + }, + background() { + browser.tabs.onUpdated.addListener( + (tabId, changeInfo) => { + browser.test.fail( + "should only have notification for activeTab, selectedTab is not activeTab" + ); + }, + { urls: ["*://mochi.test/*"] } + ); + }, + }); + await ext.startup(); + + let ext2 = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + background() { + browser.tabs.onUpdated.addListener( + (tabId, changeInfo) => { + if (changeInfo.status === "complete") { + browser.test.notifyPass("onUpdated"); + } + }, + { urls: ["*://mochi.test/*"] } + ); + }, + }); + await ext2.startup(); + let ok = ext2.awaitFinish("onUpdated"); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://mochi.test:8888/#foreground" + ); + await Promise.all([ok]); + + await ext.unload(); + await ext2.unload(); + await BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_filter_tabId() { + let ext_fail = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + background() { + browser.tabs.onUpdated.addListener( + (tabId, changeInfo) => { + browser.test.fail( + `received unexpected onUpdated event ${JSON.stringify(changeInfo)}` + ); + }, + { tabId: 12345 } + ); + }, + }); + await ext_fail.startup(); + + let ext_ok = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + background() { + browser.tabs.onUpdated.addListener((tabId, changeInfo) => { + if (changeInfo.status === "complete") { + browser.test.notifyPass("onUpdated"); + } + }); + }, + }); + await ext_ok.startup(); + let ok = ext_ok.awaitFinish("onUpdated"); + + let ext_ok2 = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + background() { + browser.tabs.onCreated.addListener(tab => { + browser.tabs.onUpdated.addListener( + (tabId, changeInfo) => { + if (changeInfo.status === "complete") { + browser.test.notifyPass("onUpdated"); + } + }, + { tabId: tab.id } + ); + browser.test.log(`Tab specific tab listener on tab ${tab.id}`); + }); + }, + }); + await ext_ok2.startup(); + let ok2 = ext_ok2.awaitFinish("onUpdated"); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://mochi.test:8888/" + ); + await Promise.all([ok, ok2]); + + await ext_ok.unload(); + await ext_ok2.unload(); + await ext_fail.unload(); + + await BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_filter_windowId() { + let ext_fail = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + background() { + browser.tabs.onUpdated.addListener( + (tabId, changeInfo) => { + browser.test.fail( + `received unexpected onUpdated event ${JSON.stringify(changeInfo)}` + ); + }, + { windowId: 12345 } + ); + }, + }); + await ext_fail.startup(); + + let ext_ok = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + background() { + browser.tabs.onUpdated.addListener( + (tabId, changeInfo) => { + if (changeInfo.status === "complete") { + browser.test.notifyPass("onUpdated"); + } + }, + { windowId: browser.windows.WINDOW_ID_CURRENT } + ); + }, + }); + await ext_ok.startup(); + let ok = ext_ok.awaitFinish("onUpdated"); + + let ext_ok2 = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + async background() { + let window = await browser.windows.getCurrent(); + browser.test.log(`Window specific tab listener on window ${window.id}`); + browser.tabs.onUpdated.addListener( + (tabId, changeInfo) => { + if (changeInfo.status === "complete") { + browser.test.notifyPass("onUpdated"); + } + }, + { windowId: window.id } + ); + browser.test.sendMessage("ready"); + }, + }); + await ext_ok2.startup(); + await ext_ok2.awaitMessage("ready"); + let ok2 = ext_ok2.awaitFinish("onUpdated"); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://mochi.test:8888/" + ); + await Promise.all([ok, ok2]); + + await ext_ok.unload(); + await ext_ok2.unload(); + await ext_fail.unload(); + + await BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_filter_isArticle() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + background() { + // We expect only status updates, anything else is a failure. + browser.tabs.onUpdated.addListener( + (tabId, changeInfo) => { + browser.test.log(`got onUpdated ${JSON.stringify(changeInfo)}`); + if ("isArticle" in changeInfo) { + browser.test.notifyPass("isArticle"); + } + }, + { properties: ["isArticle"] } + ); + }, + }); + await extension.startup(); + let ok = extension.awaitFinish("isArticle"); + + const baseUrl = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "http://mochi.test:8888/" + ); + const url = `${baseUrl}/readerModeArticle.html`; + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + await ok; + + await extension.unload(); + + await BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_filter_property() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + async background() { + // We expect only status updates, anything else is a failure. + let properties = new Set([ + "audible", + "discarded", + "favIconUrl", + "hidden", + "isArticle", + "mutedInfo", + "pinned", + "sharingState", + "title", + "url", + ]); + + // Test that updated only happens after created. + let created = false; + let tabIds = (await browser.tabs.query({})).map(t => t.id); + browser.tabs.onCreated.addListener(tab => { + created = tab.id; + }); + + browser.tabs.onUpdated.addListener( + (tabId, changeInfo) => { + // ignore tabs created prior to extension startup + if (tabIds.includes(tabId)) { + return; + } + browser.test.assertEq(created, tabId, "tab created before updated"); + + browser.test.log(`got onUpdated ${JSON.stringify(changeInfo)}`); + browser.test.assertTrue(!!changeInfo.status, "changeInfo has status"); + if (Object.keys(changeInfo).some(p => properties.has(p))) { + browser.test.fail( + `received unexpected onUpdated event ${JSON.stringify( + changeInfo + )}` + ); + } + if (changeInfo.status === "complete") { + browser.test.notifyPass("onUpdated"); + } + }, + { properties: ["status"] } + ); + browser.test.sendMessage("ready"); + }, + }); + await extension.startup(); + await extension.awaitMessage("ready"); + let ok = extension.awaitFinish("onUpdated"); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://mochi.test:8888/" + ); + await ok; + + await extension.unload(); + + await BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_opener.js b/browser/components/extensions/test/browser/browser_ext_tabs_opener.js new file mode 100644 index 0000000000..f5ea6c7a27 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_opener.js @@ -0,0 +1,130 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function () { + let tab1 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank?1" + ); + let tab2 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank?2" + ); + + gBrowser.selectedTab = tab1; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + background() { + let activeTab; + let tabId; + let tabIds; + browser.tabs + .query({ lastFocusedWindow: true }) + .then(tabs => { + browser.test.assertEq(3, tabs.length, "We have three tabs"); + + browser.test.assertTrue(tabs[1].active, "Tab 1 is active"); + activeTab = tabs[1]; + + tabIds = tabs.map(tab => tab.id); + + return browser.tabs.create({ + openerTabId: activeTab.id, + active: false, + }); + }) + .then(tab => { + browser.test.assertEq( + activeTab.id, + tab.openerTabId, + "Tab opener ID is correct" + ); + browser.test.assertEq( + activeTab.index + 1, + tab.index, + "Tab was inserted after the related current tab" + ); + + tabId = tab.id; + return browser.tabs.get(tabId); + }) + .then(tab => { + browser.test.assertEq( + activeTab.id, + tab.openerTabId, + "Tab opener ID is still correct" + ); + + return browser.tabs.update(tabId, { openerTabId: tabIds[0] }); + }) + .then(tab => { + browser.test.assertEq( + tabIds[0], + tab.openerTabId, + "Updated tab opener ID is correct" + ); + + return browser.tabs.get(tabId); + }) + .then(tab => { + browser.test.assertEq( + tabIds[0], + tab.openerTabId, + "Updated tab opener ID is still correct" + ); + + return browser.tabs.create({ openerTabId: tabId, active: false }); + }) + .then(tab => { + browser.test.assertEq( + tabId, + tab.openerTabId, + "New tab opener ID is correct" + ); + browser.test.assertEq( + tabIds.length, + tab.index, + "New tab was not inserted after the unrelated current tab" + ); + + let promise = browser.tabs.remove(tabId); + + tabId = tab.id; + return promise; + }) + .then(() => { + return browser.tabs.get(tabId); + }) + .then(tab => { + browser.test.assertEq( + undefined, + tab.openerTabId, + "Tab opener ID was cleared after opener tab closed" + ); + + return browser.tabs.remove(tabId); + }) + .then(() => { + browser.test.notifyPass("tab-opener"); + }) + .catch(e => { + browser.test.fail(`${e} :: ${e.stack}`); + browser.test.notifyFail("tab-opener"); + }); + }, + }); + + await extension.startup(); + + await extension.awaitFinish("tab-opener"); + + await extension.unload(); + + BrowserTestUtils.removeTab(tab1); + BrowserTestUtils.removeTab(tab2); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_printPreview.js b/browser/components/extensions/test/browser/browser_ext_tabs_printPreview.js new file mode 100644 index 0000000000..81dee72f93 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_printPreview.js @@ -0,0 +1,44 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function testPrintPreview() { + await BrowserTestUtils.openNewForegroundTab(gBrowser, "http://example.net/"); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + background: async function () { + await browser.tabs.printPreview(); + browser.test.assertTrue(true, "print preview entered"); + browser.test.notifyPass("tabs.printPreview"); + }, + }); + + is( + document.querySelector(".printPreviewBrowser"), + null, + "There shouldn't be any print preview browser" + ); + + await extension.startup(); + + // Ensure we're showing the preview... + await BrowserTestUtils.waitForCondition(() => { + let preview = document.querySelector(".printPreviewBrowser"); + return preview && BrowserTestUtils.isVisible(preview); + }); + + gBrowser.getTabDialogBox(gBrowser.selectedBrowser).abortAllDialogs(); + // Wait for the preview to go away + await BrowserTestUtils.waitForCondition( + () => !document.querySelector(".printPreviewBrowser") + ); + + await extension.awaitFinish("tabs.printPreview"); + + await extension.unload(); + BrowserTestUtils.removeTab(gBrowser.tabs[1]); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_query.js b/browser/components/extensions/test/browser/browser_ext_tabs_query.js new file mode 100644 index 0000000000..099588c701 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_query.js @@ -0,0 +1,468 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +requestLongerTimeout(2); + +add_task(async function () { + let tab1 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:robots" + ); + let tab2 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:config" + ); + + gBrowser.selectedTab = tab1; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + async background() { + let tabs = await browser.tabs.query({ lastFocusedWindow: true }); + browser.test.assertEq(tabs.length, 3, "should have three tabs"); + + tabs.sort((tab1, tab2) => tab1.index - tab2.index); + + browser.test.assertEq(tabs[0].url, "about:blank", "first tab blank"); + tabs.shift(); + + browser.test.assertTrue(tabs[0].active, "tab 0 active"); + browser.test.assertFalse(tabs[1].active, "tab 1 inactive"); + + browser.test.assertFalse(tabs[0].pinned, "tab 0 unpinned"); + browser.test.assertFalse(tabs[1].pinned, "tab 1 unpinned"); + + browser.test.assertEq(tabs[0].url, "about:robots", "tab 0 url correct"); + browser.test.assertEq(tabs[1].url, "about:config", "tab 1 url correct"); + + browser.test.assertEq(tabs[0].status, "complete", "tab 0 status correct"); + browser.test.assertEq(tabs[1].status, "complete", "tab 1 status correct"); + + browser.test.assertEq( + tabs[0].title, + "Gort! Klaatu barada nikto!", + "tab 0 title correct" + ); + + tabs = await browser.tabs.query({ url: "about:blank" }); + browser.test.assertEq(tabs.length, 1, "about:blank query finds one tab"); + browser.test.assertEq(tabs[0].url, "about:blank", "with the correct url"); + + browser.test.notifyPass("tabs.query"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabs.query"); + await extension.unload(); + + BrowserTestUtils.removeTab(tab1); + BrowserTestUtils.removeTab(tab2); + + tab1 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.com/" + ); + tab2 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.net/" + ); + let tab3 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://test1.example.org/MochiKit/" + ); + + // test simple queries + extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + background: function () { + browser.tabs.query( + { + url: "<all_urls>", + }, + function (tabs) { + browser.test.assertEq(tabs.length, 3, "should have three tabs"); + + tabs.sort((tab1, tab2) => tab1.index - tab2.index); + + browser.test.assertEq( + tabs[0].url, + "http://example.com/", + "tab 0 url correct" + ); + browser.test.assertEq( + tabs[1].url, + "http://example.net/", + "tab 1 url correct" + ); + browser.test.assertEq( + tabs[2].url, + "http://test1.example.org/MochiKit/", + "tab 2 url correct" + ); + + browser.test.notifyPass("tabs.query"); + } + ); + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabs.query"); + await extension.unload(); + + // match pattern + extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + background: function () { + browser.tabs.query( + { + url: "http://*/MochiKit*", + }, + function (tabs) { + browser.test.assertEq(tabs.length, 1, "should have one tab"); + + browser.test.assertEq( + tabs[0].url, + "http://test1.example.org/MochiKit/", + "tab 0 url correct" + ); + + browser.test.notifyPass("tabs.query"); + } + ); + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabs.query"); + await extension.unload(); + + // match array of patterns + extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + background: function () { + browser.tabs.query( + { + url: ["http://*/MochiKit*", "http://*.com/*"], + }, + function (tabs) { + browser.test.assertEq(tabs.length, 2, "should have two tabs"); + + tabs.sort((tab1, tab2) => tab1.index - tab2.index); + + browser.test.assertEq( + tabs[0].url, + "http://example.com/", + "tab 0 url correct" + ); + browser.test.assertEq( + tabs[1].url, + "http://test1.example.org/MochiKit/", + "tab 1 url correct" + ); + + browser.test.notifyPass("tabs.query"); + } + ); + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabs.query"); + await extension.unload(); + + // match title pattern + extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + async background() { + let tabs = await browser.tabs.query({ + title: "mochitest index /", + }); + + browser.test.assertEq(tabs.length, 2, "should have two tabs"); + + tabs.sort((tab1, tab2) => tab1.index - tab2.index); + + browser.test.assertEq( + tabs[0].title, + "mochitest index /", + "tab 0 title correct" + ); + browser.test.assertEq( + tabs[1].title, + "mochitest index /", + "tab 1 title correct" + ); + + tabs = await browser.tabs.query({ + title: "?ochitest index /*", + }); + + browser.test.assertEq(tabs.length, 3, "should have three tabs"); + + tabs.sort((tab1, tab2) => tab1.index - tab2.index); + + browser.test.assertEq( + tabs[0].title, + "mochitest index /", + "tab 0 title correct" + ); + browser.test.assertEq( + tabs[1].title, + "mochitest index /", + "tab 1 title correct" + ); + browser.test.assertEq( + tabs[2].title, + "mochitest index /MochiKit/", + "tab 2 title correct" + ); + + browser.test.notifyPass("tabs.query"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabs.query"); + await extension.unload(); + + // match highlighted + extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + background: async function () { + let tabs1 = await browser.tabs.query({ highlighted: false }); + browser.test.assertEq( + 3, + tabs1.length, + "should have three non-highlighted tabs" + ); + + let tabs2 = await browser.tabs.query({ highlighted: true }); + browser.test.assertEq(1, tabs2.length, "should have one highlighted tab"); + + for (let tab of [...tabs1, ...tabs2]) { + browser.test.assertEq( + tab.active, + tab.highlighted, + "highlighted and active are equal in tab " + tab.index + ); + } + + browser.test.notifyPass("tabs.query"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabs.query"); + await extension.unload(); + + // test width and height + extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + background: function () { + browser.test.onMessage.addListener(async msg => { + let tabs = await browser.tabs.query({ active: true }); + + browser.test.assertEq(tabs.length, 1, "should have one tab"); + browser.test.sendMessage("dims", { + width: tabs[0].width, + height: tabs[0].height, + }); + }); + browser.test.sendMessage("ready"); + }, + }); + + const RESOLUTION_PREF = "layout.css.devPixelsPerPx"; + registerCleanupFunction(() => { + Services.prefs.clearUserPref(RESOLUTION_PREF); + }); + + await Promise.all([extension.startup(), extension.awaitMessage("ready")]); + + for (let resolution of [2, 1]) { + Services.prefs.setCharPref(RESOLUTION_PREF, String(resolution)); + is( + window.devicePixelRatio, + resolution, + "window has the required resolution" + ); + + let { clientHeight, clientWidth } = gBrowser.selectedBrowser; + + extension.sendMessage("check-size"); + let dims = await extension.awaitMessage("dims"); + is(dims.width, clientWidth, "tab reports expected width"); + is(dims.height, clientHeight, "tab reports expected height"); + } + + await extension.unload(); + + BrowserTestUtils.removeTab(tab1); + BrowserTestUtils.removeTab(tab2); + BrowserTestUtils.removeTab(tab3); + Services.prefs.clearUserPref(RESOLUTION_PREF); +}); + +add_task(async function testQueryPermissions() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: [], + }, + + async background() { + try { + let tabs = await browser.tabs.query({ + currentWindow: true, + active: true, + }); + browser.test.assertEq(tabs.length, 1, "Expect query to return tabs"); + browser.test.notifyPass("queryPermissions"); + } catch (e) { + browser.test.notifyFail("queryPermissions"); + } + }, + }); + + await extension.startup(); + + await extension.awaitFinish("queryPermissions"); + + await extension.unload(); +}); + +add_task(async function testInvalidUrl() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + async background() { + await browser.test.assertRejects( + browser.tabs.query({ url: "http://test1.net" }), + "Invalid url pattern: http://test1.net", + "Expected url to match pattern" + ); + await browser.test.assertRejects( + browser.tabs.query({ url: ["test2"] }), + "Invalid url pattern: test2", + "Expected an array with an invalid match pattern" + ); + await browser.test.assertRejects( + browser.tabs.query({ url: ["http://www.bbc.com/", "test3"] }), + "Invalid url pattern: test3", + "Expected an array with an invalid match pattern" + ); + browser.test.notifyPass("testInvalidUrl"); + }, + }); + await extension.startup(); + await extension.awaitFinish("testInvalidUrl"); + await extension.unload(); +}); + +add_task(async function test_query_index() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + background: function () { + browser.tabs.onCreated.addListener(async function ({ + index, + windowId, + id, + }) { + browser.test.assertThrows( + () => browser.tabs.query({ index: -1 }), + /-1 is too small \(must be at least 0\)/, + "tab indices must be non-negative" + ); + + let tabs = await browser.tabs.query({ index, windowId }); + browser.test.assertEq(tabs.length, 1, `Got one tab at index ${index}`); + browser.test.assertEq(tabs[0].id, id, "The tab is the right one"); + + tabs = await browser.tabs.query({ index: 1e5, windowId }); + browser.test.assertEq(tabs.length, 0, "There is no tab at this index"); + + browser.test.notifyPass("tabs.query"); + }); + }, + }); + + await extension.startup(); + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.com/" + ); + await extension.awaitFinish("tabs.query"); + BrowserTestUtils.removeTab(tab); + await extension.unload(); +}); + +add_task(async function test_query_window() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + background: async function () { + let badWindowId = 0; + for (let { id } of await browser.windows.getAll()) { + badWindowId = Math.max(badWindowId, id + 1); + } + + let tabs = await browser.tabs.query({ windowId: badWindowId }); + browser.test.assertEq( + tabs.length, + 0, + "No tabs because there is no such window ID" + ); + + let { id: currentWindowId } = await browser.windows.getCurrent(); + tabs = await browser.tabs.query({ currentWindow: true }); + browser.test.assertEq( + tabs[0].windowId, + currentWindowId, + "Got tabs from the current window" + ); + + let { id: lastFocusedWindowId } = await browser.windows.getLastFocused(); + tabs = await browser.tabs.query({ lastFocusedWindow: true }); + browser.test.assertEq( + tabs[0].windowId, + lastFocusedWindowId, + "Got tabs from the last focused window" + ); + + browser.test.notifyPass("tabs.query"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabs.query"); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_readerMode.js b/browser/components/extensions/test/browser/browser_ext_tabs_readerMode.js new file mode 100644 index 0000000000..1b86094611 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_readerMode.js @@ -0,0 +1,138 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_reader_mode() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + async background() { + let tab; + let tabId; + let expected = { isInReaderMode: false }; + let testState = {}; + browser.test.onMessage.addListener(async (msg, ...args) => { + switch (msg) { + case "updateUrl": + expected.isArticle = args[0]; + expected.url = args[1]; + tab = await browser.tabs.update({ url: expected.url }); + tabId = tab.id; + break; + case "enterReaderMode": + expected.isArticle = !args[0]; + expected.isInReaderMode = true; + tab = await browser.tabs.get(tabId); + browser.test.assertEq( + false, + tab.isInReaderMode, + "The tab is not in reader mode." + ); + if (args[0]) { + browser.tabs.toggleReaderMode(tabId); + } else { + await browser.test.assertRejects( + browser.tabs.toggleReaderMode(tabId), + /The specified tab cannot be placed into reader mode/, + "Toggle fails with an unreaderable document." + ); + browser.test.assertEq( + false, + tab.isInReaderMode, + "The tab is still not in reader mode." + ); + browser.test.sendMessage("enterFailed"); + } + break; + case "leaveReaderMode": + expected.isInReaderMode = false; + tab = await browser.tabs.get(tabId); + browser.test.assertTrue( + tab.isInReaderMode, + "The tab is in reader mode." + ); + browser.tabs.toggleReaderMode(tabId); + break; + } + }); + + browser.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => { + if (changeInfo.status === "complete") { + testState.url = tab.url; + let urlOk = expected.isInReaderMode + ? testState.url.startsWith("about:reader") + : expected.url == testState.url; + if (urlOk && expected.isArticle == testState.isArticle) { + browser.test.sendMessage("tabUpdated", tab); + } + return; + } + if ( + changeInfo.isArticle == expected.isArticle && + changeInfo.isArticle != testState.isArticle + ) { + testState.isArticle = changeInfo.isArticle; + let urlOk = expected.isInReaderMode + ? testState.url.startsWith("about:reader") + : expected.url == testState.url; + if (urlOk && expected.isArticle == testState.isArticle) { + browser.test.sendMessage("isArticle", tab); + } + } + }); + }, + }); + + const TEST_PATH = getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "http://example.com" + ); + const READER_MODE_PREFIX = "about:reader"; + + await extension.startup(); + extension.sendMessage( + "updateUrl", + true, + `${TEST_PATH}readerModeArticle.html` + ); + let tab = await extension.awaitMessage("isArticle"); + + ok( + !tab.url.startsWith(READER_MODE_PREFIX), + "Tab url does not indicate reader mode." + ); + ok(tab.isArticle, "Tab is readerable."); + + extension.sendMessage("enterReaderMode", true); + tab = await extension.awaitMessage("tabUpdated"); + ok(tab.url.startsWith(READER_MODE_PREFIX), "Tab url indicates reader mode."); + ok(tab.isInReaderMode, "tab.isInReaderMode indicates reader mode."); + + extension.sendMessage("leaveReaderMode"); + tab = await extension.awaitMessage("tabUpdated"); + ok( + !tab.url.startsWith(READER_MODE_PREFIX), + "Tab url does not indicate reader mode." + ); + ok(!tab.isInReaderMode, "tab.isInReaderMode does not indicate reader mode."); + + extension.sendMessage( + "updateUrl", + false, + `${TEST_PATH}readerModeNonArticle.html` + ); + tab = await extension.awaitMessage("tabUpdated"); + ok( + !tab.url.startsWith(READER_MODE_PREFIX), + "Tab url does not indicate reader mode." + ); + ok(!tab.isArticle, "Tab is not readerable."); + ok(!tab.isInReaderMode, "tab.isInReaderMode does not indicate reader mode."); + + extension.sendMessage("enterReaderMode", false); + await extension.awaitMessage("enterFailed"); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_reload.js b/browser/components/extensions/test/browser/browser_ext_tabs_reload.js new file mode 100644 index 0000000000..aed4a3822c --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_reload.js @@ -0,0 +1,53 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function () { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + files: { + "tab.js": function () { + browser.runtime.sendMessage("tab-loaded"); + }, + "tab.html": `<head> + <meta charset="utf-8"> + <script src="tab.js"></script> + </head>`, + }, + + async background() { + let tabLoadedCount = 0; + + let tab = await browser.tabs.create({ url: "tab.html", active: true }); + + browser.runtime.onMessage.addListener(msg => { + if (msg == "tab-loaded") { + tabLoadedCount++; + + if (tabLoadedCount == 1) { + // Reload the tab once passing no arguments. + return browser.tabs.reload(); + } + + if (tabLoadedCount == 2) { + // Reload the tab again with explicit arguments. + return browser.tabs.reload(tab.id, { + bypassCache: false, + }); + } + + if (tabLoadedCount == 3) { + browser.test.notifyPass("tabs.reload"); + } + } + }); + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabs.reload"); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_reload_bypass_cache.js b/browser/components/extensions/test/browser/browser_ext_tabs_reload_bypass_cache.js new file mode 100644 index 0000000000..ed3d8c7a14 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_reload_bypass_cache.js @@ -0,0 +1,89 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function () { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs", "<all_urls>"], + }, + + async background() { + const BASE = + "http://mochi.test:8888/browser/browser/components/extensions/test/browser/"; + const URL = BASE + "file_bypass_cache.sjs"; + + let tabId = null; + let loadPromise, resolveLoad; + function resetLoad() { + loadPromise = new Promise(resolve => { + resolveLoad = resolve; + }); + } + function awaitLoad() { + return loadPromise.then(() => { + resetLoad(); + }); + } + resetLoad(); + + browser.tabs.onUpdated.addListener(function listener( + tabId_, + changed, + tab + ) { + if (tabId == tabId_ && changed.status == "complete" && tab.url == URL) { + resolveLoad(); + } + }); + + try { + let tab = await browser.tabs.create({ url: URL }); + tabId = tab.id; + await awaitLoad(); + + await browser.tabs.reload(tab.id, { bypassCache: false }); + await awaitLoad(); + + let [textContent] = await browser.tabs.executeScript(tab.id, { + code: "document.body.textContent", + }); + browser.test.assertEq( + "", + textContent, + "`textContent` should be empty when bypassCache=false" + ); + + await browser.tabs.reload(tab.id, { bypassCache: true }); + await awaitLoad(); + + [textContent] = await browser.tabs.executeScript(tab.id, { + code: "document.body.textContent", + }); + + let [pragma, cacheControl] = textContent.split(":"); + browser.test.assertEq( + "no-cache", + pragma, + "`pragma` should be set to `no-cache` when bypassCache is true" + ); + browser.test.assertEq( + "no-cache", + cacheControl, + "`cacheControl` should be set to `no-cache` when bypassCache is true" + ); + + await browser.tabs.remove(tab.id); + + browser.test.notifyPass("tabs.reload_bypass_cache"); + } catch (error) { + browser.test.fail(`${error} :: ${error.stack}`); + browser.test.notifyFail("tabs.reload_bypass_cache"); + } + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabs.reload_bypass_cache"); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_remove.js b/browser/components/extensions/test/browser/browser_ext_tabs_remove.js new file mode 100644 index 0000000000..8e51494ed1 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_remove.js @@ -0,0 +1,258 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function undoCloseAfterExtRemovesOneTab() { + let initialTab = gBrowser.selectedTab; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + background: async function () { + let tabs = await browser.tabs.query({}); + + browser.test.assertEq(3, tabs.length, "Should have 3 tabs"); + + let tabIdsToRemove = ( + await browser.tabs.query({ + url: "https://example.com/closeme/*", + }) + ).map(tab => tab.id); + + await browser.tabs.remove(tabIdsToRemove); + browser.test.sendMessage("removedtabs"); + }, + }); + + await Promise.all([ + BrowserTestUtils.openNewForegroundTab(gBrowser, "https://example.com/1"), + BrowserTestUtils.openNewForegroundTab( + gBrowser, + "https://example.com/closeme/2" + ), + ]); + + await extension.startup(); + await extension.awaitMessage("removedtabs"); + + is( + gBrowser.tabs.length, + 2, + "Once extension has closed a tab, there should be 2 tabs open" + ); + + // The tabs.remove API makes no promises about SessionStore's updates + // having been completed by the time it returns. So we need to wait separately + // for the closed tab count to be updated the correct value. This is OK because + // we can observe above that the tabs length has changed to reflect that + // some were closed. + await TestUtils.waitForCondition( + () => SessionStore.getLastClosedTabCount(window) == 1, + "SessionStore should know that one tab was closed" + ); + + undoCloseTab(); + + is( + gBrowser.tabs.length, + 3, + "All tabs should be restored for a total of 3 tabs" + ); + + await BrowserTestUtils.waitForEvent(gBrowser.tabs[2], "SSTabRestored"); + + is( + gBrowser.tabs[2].linkedBrowser.currentURI.spec, + "https://example.com/closeme/2", + "Restored tab at index 2 should have expected URL" + ); + + await extension.unload(); + gBrowser.removeAllTabsBut(initialTab); +}); + +add_task(async function undoCloseAfterExtRemovesMultipleTabs() { + let initialTab = gBrowser.selectedTab; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + background: async function () { + let tabIds = (await browser.tabs.query({})).map(tab => tab.id); + + browser.test.assertEq( + 8, + tabIds.length, + "Should have 8 total tabs (4 in each window: the initial blank tab and the 3 opened by this test)" + ); + + let tabIdsToRemove = ( + await browser.tabs.query({ + url: "https://example.com/closeme/*", + }) + ).map(tab => tab.id); + + await browser.tabs.remove(tabIdsToRemove); + + browser.test.sendMessage("removedtabs"); + }, + }); + + await Promise.all([ + BrowserTestUtils.openNewForegroundTab(gBrowser, "https://example.com/1"), + BrowserTestUtils.openNewForegroundTab( + gBrowser, + "https://example.com/closeme/2" + ), + BrowserTestUtils.openNewForegroundTab( + gBrowser, + "https://example.com/closeme/3" + ), + ]); + + let window2 = await BrowserTestUtils.openNewBrowserWindow(); + + await Promise.all([ + BrowserTestUtils.openNewForegroundTab( + window2.gBrowser, + "https://example.com/4" + ), + BrowserTestUtils.openNewForegroundTab( + window2.gBrowser, + "https://example.com/closeme/5" + ), + BrowserTestUtils.openNewForegroundTab( + window2.gBrowser, + "https://example.com/closeme/6" + ), + ]); + + await extension.startup(); + await extension.awaitMessage("removedtabs"); + + is( + gBrowser.tabs.length, + 2, + "Original window should have 2 tabs still open, after closing tabs" + ); + + is( + window2.gBrowser.tabs.length, + 2, + "Second window should have 2 tabs still open, after closing tabs" + ); + + // The tabs.remove API makes no promises about SessionStore's updates + // having been completed by the time it returns. So we need to wait separately + // for the closed tab count to be updated the correct value. This is OK because + // we can observe above that the tabs length has changed to reflect that + // some were closed. + await TestUtils.waitForCondition( + () => SessionStore.getLastClosedTabCount(window) == 2, + "Last closed tab count is 2" + ); + + await TestUtils.waitForCondition( + () => SessionStore.getLastClosedTabCount(window2) == 2, + "Last closed tab count is 2" + ); + + undoCloseTab(); + window2.undoCloseTab(); + + is( + gBrowser.tabs.length, + 4, + "All tabs in original window should be restored for a total of 4 tabs" + ); + + is( + window2.gBrowser.tabs.length, + 4, + "All tabs in second window should be restored for a total of 4 tabs" + ); + + await Promise.all([ + BrowserTestUtils.waitForEvent(gBrowser.tabs[2], "SSTabRestored"), + BrowserTestUtils.waitForEvent(gBrowser.tabs[3], "SSTabRestored"), + BrowserTestUtils.waitForEvent(window2.gBrowser.tabs[2], "SSTabRestored"), + BrowserTestUtils.waitForEvent(window2.gBrowser.tabs[3], "SSTabRestored"), + ]); + + is( + gBrowser.tabs[2].linkedBrowser.currentURI.spec, + "https://example.com/closeme/2", + "Original window restored tab at index 2 should have expected URL" + ); + + is( + gBrowser.tabs[3].linkedBrowser.currentURI.spec, + "https://example.com/closeme/3", + "Original window restored tab at index 3 should have expected URL" + ); + + is( + window2.gBrowser.tabs[2].linkedBrowser.currentURI.spec, + "https://example.com/closeme/5", + "Second window restored tab at index 2 should have expected URL" + ); + + is( + window2.gBrowser.tabs[3].linkedBrowser.currentURI.spec, + "https://example.com/closeme/6", + "Second window restored tab at index 3 should have expected URL" + ); + + await extension.unload(); + await BrowserTestUtils.closeWindow(window2); + gBrowser.removeAllTabsBut(initialTab); +}); + +add_task(async function closeWindowIfExtClosesAllTabs() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.tabs.closeWindowWithLastTab", true], + ["browser.tabs.warnOnClose", true], + ], + }); + + let extension = ExtensionTestUtils.loadExtension({ + background: async function () { + let tabsToRemove = await browser.tabs.query({ currentWindow: true }); + + let currentWindowId = tabsToRemove[0].windowId; + + browser.test.assertEq( + 2, + tabsToRemove.length, + "Current window should have 2 tabs to remove" + ); + + await browser.tabs.remove(tabsToRemove.map(tab => tab.id)); + + await browser.test.assertRejects( + browser.windows.get(currentWindowId), + RegExp(`Invalid window ID: ${currentWindowId}`), + "After closing tabs, 2nd window should be closed and querying for it should be rejected" + ); + + browser.test.notifyPass("done"); + }, + }); + + let window2 = await BrowserTestUtils.openNewBrowserWindow(); + + await BrowserTestUtils.openNewForegroundTab( + window2.gBrowser, + "https://example.com/" + ); + + await extension.startup(); + await extension.awaitFinish("done"); + await extension.unload(); + await SpecialPowers.popPrefEnv(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_removeCSS.js b/browser/components/extensions/test/browser/browser_ext_tabs_removeCSS.js new file mode 100644 index 0000000000..edaf2f61b4 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_removeCSS.js @@ -0,0 +1,151 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function testExecuteScript() { + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://mochi.test:8888/", + true + ); + + async function background() { + let tasks = [ + // Insert CSS file. + { + background: "rgba(0, 0, 0, 0)", + foreground: "rgb(0, 113, 4)", + promise: () => { + return browser.tabs.insertCSS({ + file: "file2.css", + }); + }, + }, + // Insert CSS code. + { + background: "rgb(42, 42, 42)", + foreground: "rgb(0, 113, 4)", + promise: () => { + return browser.tabs.insertCSS({ + code: "* { background: rgb(42, 42, 42) }", + }); + }, + }, + // Remove CSS code again. + { + background: "rgba(0, 0, 0, 0)", + foreground: "rgb(0, 113, 4)", + promise: () => { + return browser.tabs.removeCSS({ + code: "* { background: rgb(42, 42, 42) }", + }); + }, + }, + // Remove CSS file again. + { + background: "rgba(0, 0, 0, 0)", + foreground: "rgb(0, 0, 0)", + promise: () => { + return browser.tabs.removeCSS({ + file: "file2.css", + }); + }, + }, + // Insert CSS code. + { + background: "rgb(42, 42, 42)", + foreground: "rgb(0, 0, 0)", + promise: () => { + return browser.tabs.insertCSS({ + code: "* { background: rgb(42, 42, 42) }", + cssOrigin: "user", + }); + }, + }, + // Remove CSS code again. + { + background: "rgba(0, 0, 0, 0)", + foreground: "rgb(0, 0, 0)", + promise: () => { + return browser.tabs.removeCSS({ + code: "* { background: rgb(42, 42, 42) }", + cssOrigin: "user", + }); + }, + }, + ]; + + function checkCSS() { + let computedStyle = window.getComputedStyle(document.body); + return [computedStyle.backgroundColor, computedStyle.color]; + } + + try { + for (let { promise, background, foreground } of tasks) { + let result = await promise(); + browser.test.assertEq(undefined, result, "Expected callback result"); + + [result] = await browser.tabs.executeScript({ + code: `(${checkCSS})()`, + }); + browser.test.assertEq( + background, + result[0], + "Expected background color" + ); + browser.test.assertEq( + foreground, + result[1], + "Expected foreground color" + ); + } + + browser.test.notifyPass("removeCSS"); + } catch (e) { + browser.test.fail(`Error: ${e} :: ${e.stack}`); + browser.test.notifyFail("removeCSS"); + } + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["http://mochi.test/"], + }, + + background, + + files: { + "file2.css": "* { color: rgb(0, 113, 4) }", + }, + }); + + await extension.startup(); + + await extension.awaitFinish("removeCSS"); + + // Verify that scripts created by tabs.removeCSS are not added to the content scripts + // that requires cleanup (Bug 1464711). + await SpecialPowers.spawn(tab.linkedBrowser, [extension.id], async extId => { + const { ExtensionContent } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionContent.sys.mjs" + ); + + let contentScriptContext = ExtensionContent.getContextByExtensionId( + extId, + content.window + ); + + for (let script of contentScriptContext.scripts) { + if (script.matcher.removeCSS && script.requiresCleanup) { + throw new Error("tabs.removeCSS scripts should not require cleanup"); + } + } + }).catch(err => { + // Log the error so that it is easy to see where the failure is coming from. + ok(false, err); + }); + + await extension.unload(); + + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_saveAsPDF.js b/browser/components/extensions/test/browser/browser_ext_tabs_saveAsPDF.js new file mode 100644 index 0000000000..fdff1dddbf --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_saveAsPDF.js @@ -0,0 +1,197 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +async function testReturnStatus(expectedStatus) { + // Test that tabs.saveAsPDF() returns the correct status + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.net/" + ); + + let saveDir = FileUtils.getDir("TmpD", [`testSaveDir-${Math.random()}`]); + saveDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + + let saveFile = saveDir.clone(); + saveFile.append("testSaveFile.pdf"); + if (saveFile.exists()) { + saveFile.remove(false); + } + + if (expectedStatus == "replaced") { + // Create file that can be replaced + saveFile.create(Ci.nsIFile.NORMAL_FILE_TYPE, 0o666); + } else if (expectedStatus == "not_saved") { + // Create directory with same name as file - so that file cannot be saved + saveFile.create(Ci.nsIFile.DIRECTORY_TYPE, 0o666); + } else if (expectedStatus == "not_replaced") { + // Create file that cannot be replaced + saveFile.create(Ci.nsIFile.NORMAL_FILE_TYPE, 0o444); + } + + let MockFilePicker = SpecialPowers.MockFilePicker; + MockFilePicker.init(window); + + if (expectedStatus == "replaced" || expectedStatus == "not_replaced") { + MockFilePicker.returnValue = MockFilePicker.returnReplace; + } else if (expectedStatus == "canceled") { + MockFilePicker.returnValue = MockFilePicker.returnCancel; + } else { + MockFilePicker.returnValue = MockFilePicker.returnOK; + } + + MockFilePicker.displayDirectory = saveDir; + + MockFilePicker.showCallback = fp => { + MockFilePicker.setFiles([saveFile]); + MockFilePicker.filterIndex = 0; // *.* - all file extensions + }; + + let manifest = { + description: expectedStatus, + }; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: manifest, + + background: async function () { + let pageSettings = {}; + + let expected = chrome.runtime.getManifest().description; + + let status = await browser.tabs.saveAsPDF(pageSettings); + + browser.test.assertEq(expected, status, "Got expected status"); + + browser.test.notifyPass("tabs.saveAsPDF"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabs.saveAsPDF"); + await extension.unload(); + + if (expectedStatus == "saved" || expectedStatus == "replaced") { + // Check that first four bytes of saved PDF file are "%PDF" + let text = await IOUtils.read(saveFile.path, { maxBytes: 4 }); + text = new TextDecoder().decode(text); + is(text, "%PDF", "Got correct magic number - %PDF"); + } + + MockFilePicker.cleanup(); + + if (expectedStatus == "not_saved" || expectedStatus == "not_replaced") { + saveFile.permissions = 0o666; + } + + saveDir.remove(true); + + BrowserTestUtils.removeTab(tab); +} + +add_task(async function testSaveAsPDF_saved() { + await testReturnStatus("saved"); +}); + +add_task(async function testSaveAsPDF_replaced() { + await testReturnStatus("replaced"); +}); + +add_task(async function testSaveAsPDF_canceled() { + await testReturnStatus("canceled"); +}); + +add_task(async function testSaveAsPDF_not_saved() { + await testReturnStatus("not_saved"); +}); + +add_task(async function testSaveAsPDF_not_replaced() { + await testReturnStatus("not_replaced"); +}); + +async function testFileName(expectedFileName) { + // Test that tabs.saveAsPDF() saves with the correct filename + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.net/" + ); + + let saveDir = FileUtils.getDir("TmpD", [`testSaveDir-${Math.random()}`]); + saveDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY); + + let saveFile = saveDir.clone(); + saveFile.append(expectedFileName); + if (saveFile.exists()) { + saveFile.remove(false); + } + + let MockFilePicker = SpecialPowers.MockFilePicker; + MockFilePicker.init(window); + + MockFilePicker.returnValue = MockFilePicker.returnOK; + + MockFilePicker.displayDirectory = saveDir; + + MockFilePicker.showCallback = fp => { + is( + fp.defaultString, + expectedFileName, + "Got expected FilePicker defaultString" + ); + + is(fp.defaultExtension, "pdf", "Got expected FilePicker defaultExtension"); + + let file = saveDir.clone(); + file.append(fp.defaultString); + MockFilePicker.setFiles([file]); + }; + + let manifest = { + description: expectedFileName, + }; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: manifest, + + background: async function () { + let pageSettings = {}; + + let expected = chrome.runtime.getManifest().description; + + if (expected == "definedFileName") { + pageSettings.toFileName = expected; + } + + let status = await browser.tabs.saveAsPDF(pageSettings); + + browser.test.assertEq("saved", status, "Got expected status"); + + browser.test.notifyPass("tabs.saveAsPDF"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabs.saveAsPDF"); + await extension.unload(); + + // Check that first four bytes of saved PDF file are "%PDF" + let text = await IOUtils.read(saveFile.path, { maxBytes: 4 }); + text = new TextDecoder().decode(text); + is(text, "%PDF", "Got correct magic number - %PDF"); + + MockFilePicker.cleanup(); + + saveDir.remove(true); + + BrowserTestUtils.removeTab(tab); +} + +add_task(async function testSaveAsPDF_defined_filename() { + await testFileName("definedFileName"); +}); + +add_task(async function testSaveAsPDF_undefined_filename() { + // If pageSettings.toFileName is undefined, the expected filename will be + // the test page title "mochitest index /" with the "/" replaced by "_". + await testFileName("mochitest index _"); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_sendMessage.js b/browser/components/extensions/test/browser/browser_ext_tabs_sendMessage.js new file mode 100644 index 0000000000..8c420c2821 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_sendMessage.js @@ -0,0 +1,385 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function tabsSendMessageReply() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + + content_scripts: [ + { + matches: ["http://example.com/"], + js: ["content-script.js"], + run_at: "document_start", + }, + ], + }, + + background: async function () { + let firstTab; + let promiseResponse = new Promise(resolve => { + browser.runtime.onMessage.addListener((msg, sender, respond) => { + if (msg == "content-script-ready") { + let tabId = sender.tab.id; + + Promise.all([ + promiseResponse, + + browser.tabs.sendMessage(tabId, "respond-now"), + browser.tabs.sendMessage(tabId, "respond-now-2"), + new Promise(resolve => + browser.tabs.sendMessage(tabId, "respond-soon", resolve) + ), + browser.tabs.sendMessage(tabId, "respond-promise"), + browser.tabs.sendMessage(tabId, "respond-promise-false"), + browser.tabs.sendMessage(tabId, "respond-false"), + browser.tabs.sendMessage(tabId, "respond-never"), + new Promise(resolve => { + browser.runtime.sendMessage("respond-never", response => { + resolve(response); + }); + }), + + browser.tabs + .sendMessage(tabId, "respond-error") + .catch(error => Promise.resolve({ error })), + browser.tabs + .sendMessage(tabId, "throw-error") + .catch(error => Promise.resolve({ error })), + + browser.tabs + .sendMessage(tabId, "respond-uncloneable") + .catch(error => Promise.resolve({ error })), + browser.tabs + .sendMessage(tabId, "reject-uncloneable") + .catch(error => Promise.resolve({ error })), + browser.tabs + .sendMessage(tabId, "reject-undefined") + .catch(error => Promise.resolve({ error })), + browser.tabs + .sendMessage(tabId, "throw-undefined") + .catch(error => Promise.resolve({ error })), + + browser.tabs + .sendMessage(firstTab, "no-listener") + .catch(error => Promise.resolve({ error })), + ]) + .then( + ([ + response, + respondNow, + respondNow2, + respondSoon, + respondPromise, + respondPromiseFalse, + respondFalse, + respondNever, + respondNever2, + respondError, + throwError, + respondUncloneable, + rejectUncloneable, + rejectUndefined, + throwUndefined, + noListener, + ]) => { + browser.test.assertEq( + "expected-response", + response, + "Content script got the expected response" + ); + + browser.test.assertEq( + "respond-now", + respondNow, + "Got the expected immediate response" + ); + browser.test.assertEq( + "respond-now-2", + respondNow2, + "Got the expected immediate response from the second listener" + ); + browser.test.assertEq( + "respond-soon", + respondSoon, + "Got the expected delayed response" + ); + browser.test.assertEq( + "respond-promise", + respondPromise, + "Got the expected promise response" + ); + browser.test.assertEq( + false, + respondPromiseFalse, + "Got the expected false value as a promise result" + ); + browser.test.assertEq( + undefined, + respondFalse, + "Got the expected no-response when onMessage returns false" + ); + browser.test.assertEq( + undefined, + respondNever, + "Got the expected no-response resolution" + ); + browser.test.assertEq( + undefined, + respondNever2, + "Got the expected no-response resolution" + ); + + browser.test.assertEq( + "respond-error", + respondError.error.message, + "Got the expected error response" + ); + browser.test.assertEq( + "throw-error", + throwError.error.message, + "Got the expected thrown error response" + ); + + browser.test.assertEq( + "Could not establish connection. Receiving end does not exist.", + respondUncloneable.error.message, + "An uncloneable response should be ignored" + ); + browser.test.assertEq( + "An unexpected error occurred", + rejectUncloneable.error.message, + "Got the expected error for a rejection with an uncloneable value" + ); + browser.test.assertEq( + "An unexpected error occurred", + rejectUndefined.error.message, + "Got the expected error for a void rejection" + ); + browser.test.assertEq( + "An unexpected error occurred", + throwUndefined.error.message, + "Got the expected error for a void throw" + ); + + browser.test.assertEq( + "Could not establish connection. Receiving end does not exist.", + noListener.error.message, + "Got the expected no listener response" + ); + + return browser.tabs.remove(tabId); + } + ) + .then(() => { + browser.test.notifyPass("sendMessage"); + }); + + return Promise.resolve("expected-response"); + } else if (msg[0] == "got-response") { + resolve(msg[1]); + } + }); + }); + + let tabs = await browser.tabs.query({ + currentWindow: true, + active: true, + }); + firstTab = tabs[0].id; + browser.tabs.create({ url: "http://example.com/" }); + }, + + files: { + "content-script.js": async function () { + browser.runtime.onMessage.addListener((msg, sender, respond) => { + if (msg == "respond-now") { + respond(msg); + } else if (msg == "respond-soon") { + setTimeout(() => { + respond(msg); + }, 0); + return true; + } else if (msg == "respond-promise") { + return Promise.resolve(msg); + } else if (msg == "respond-promise-false") { + return Promise.resolve(false); + } else if (msg == "respond-false") { + // return false means that respond() is not expected to be called. + setTimeout(() => respond("should be ignored")); + return false; + } else if (msg == "respond-never") { + return undefined; + } else if (msg == "respond-error") { + return Promise.reject(new Error(msg)); + } else if (msg === "respond-uncloneable") { + return Promise.resolve(window); + } else if (msg === "reject-uncloneable") { + return Promise.reject(window); + } else if (msg == "reject-undefined") { + return Promise.reject(); + } else if (msg == "throw-undefined") { + throw undefined; // eslint-disable-line no-throw-literal + } else if (msg == "throw-error") { + throw new Error(msg); + } + }); + + browser.runtime.onMessage.addListener((msg, sender, respond) => { + if (msg == "respond-now") { + respond("hello"); + } else if (msg == "respond-now-2") { + respond(msg); + } + }); + + let response = await browser.runtime.sendMessage( + "content-script-ready" + ); + browser.runtime.sendMessage(["got-response", response]); + }, + }, + }); + + await extension.startup(); + + await extension.awaitFinish("sendMessage"); + + await extension.unload(); +}); + +add_task(async function tabsSendHidden() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + + content_scripts: [ + { + matches: ["http://example.com/content*"], + js: ["content-script.js"], + run_at: "document_start", + }, + ], + }, + + background: async function () { + let resolveContent; + browser.runtime.onMessage.addListener((msg, sender) => { + if (msg[0] == "content-ready") { + resolveContent(msg[1]); + } + }); + + let awaitContent = url => { + return new Promise(resolve => { + resolveContent = resolve; + }).then(result => { + browser.test.assertEq(url, result, "Expected content script URL"); + }); + }; + + try { + const URL1 = "http://example.com/content1.html"; + const URL2 = "http://example.com/content2.html"; + + let tab = await browser.tabs.create({ url: URL1 }); + await awaitContent(URL1); + + let url = await browser.tabs.sendMessage(tab.id, URL1); + browser.test.assertEq( + URL1, + url, + "Should get response from expected content window" + ); + + await browser.tabs.update(tab.id, { url: URL2 }); + await awaitContent(URL2); + + url = await browser.tabs.sendMessage(tab.id, URL2); + browser.test.assertEq( + URL2, + url, + "Should get response from expected content window" + ); + + // Repeat once just to be sure the first message was processed by all + // listeners before we exit the test. + url = await browser.tabs.sendMessage(tab.id, URL2); + browser.test.assertEq( + URL2, + url, + "Should get response from expected content window" + ); + + await browser.tabs.remove(tab.id); + + browser.test.notifyPass("contentscript-bfcache-window"); + } catch (error) { + browser.test.fail(`Error: ${error} :: ${error.stack}`); + browser.test.notifyFail("contentscript-bfcache-window"); + } + }, + + files: { + "content-script.js": function () { + // Store this in a local variable to make sure we don't touch any + // properties of the possibly-hidden content window. + let href = window.location.href; + + browser.runtime.onMessage.addListener((msg, sender) => { + browser.test.assertEq( + href, + msg, + "Should be in the expected content window" + ); + + return Promise.resolve(href); + }); + + browser.runtime.sendMessage(["content-ready", href]); + }, + }, + }); + + await extension.startup(); + + await extension.awaitFinish("contentscript-bfcache-window"); + + await extension.unload(); +}); + +add_task(async function tabsSendMessageNoExceptionOnNonExistentTab() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + async background() { + let url = + "http://example.com/mochitest/browser/browser/components/extensions/test/browser/file_dummy.html"; + let tab = await browser.tabs.create({ url }); + + await browser.test.assertRejects( + browser.tabs.sendMessage(tab.id, "message"), + /Could not establish connection. Receiving end does not exist./, + "exception should be raised on tabs.sendMessage to nonexistent tab" + ); + + await browser.test.assertRejects( + browser.tabs.sendMessage(tab.id + 100, "message"), + /Could not establish connection. Receiving end does not exist./, + "exception should be raised on tabs.sendMessage to nonexistent tab" + ); + + await browser.tabs.remove(tab.id); + + browser.test.notifyPass("tabs.sendMessage"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabs.sendMessage"); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_sharingState.js b/browser/components/extensions/test/browser/browser_ext_tabs_sharingState.js new file mode 100644 index 0000000000..47f2006307 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_sharingState.js @@ -0,0 +1,110 @@ +"use strict"; + +add_task(async function test_tabs_mediaIndicators() { + let initialTab = gBrowser.selectedTab; + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.com/#tab-sharing" + ); + + // Ensure that the tab to hide is not selected (otherwise + // it will not be hidden because it is selected). + gBrowser.selectedTab = initialTab; + + // updateBrowserSharing is called when a request for media icons occurs. We're + // just testing that extension tabs get the info and are updated when it is + // called. + gBrowser.updateBrowserSharing(tab.linkedBrowser, { + webRTC: { + sharing: "screen", + screen: "Window", + microphone: Ci.nsIMediaManagerService.STATE_CAPTURE_ENABLED, + camera: Ci.nsIMediaManagerService.STATE_CAPTURE_ENABLED, + }, + }); + + async function background() { + let tabs = await browser.tabs.query({ url: "http://example.com/*" }); + let testTab = tabs[0]; + + browser.test.assertEq( + testTab.url, + "http://example.com/#tab-sharing", + "Got the expected tab url" + ); + + browser.test.assertFalse(testTab.active, "test tab should not be selected"); + + let state = testTab.sharingState; + browser.test.assertTrue(state.camera, "sharing camera was turned on"); + browser.test.assertTrue(state.microphone, "sharing mic was turned on"); + browser.test.assertEq(state.screen, "Window", "sharing screen is window"); + + tabs = await browser.tabs.query({ screen: true }); + browser.test.assertEq(tabs.length, 1, "screen sharing tab was found"); + + tabs = await browser.tabs.query({ screen: "Window" }); + browser.test.assertEq( + tabs.length, + 1, + "screen sharing (window) tab was found" + ); + + tabs = await browser.tabs.query({ screen: "Screen" }); + browser.test.assertEq(tabs.length, 0, "screen sharing tab was not found"); + + // Verify we cannot hide a sharing tab. + let hidden = await browser.tabs.hide(testTab.id); + browser.test.assertEq(hidden.length, 0, "unable to hide sharing tab"); + tabs = await browser.tabs.query({ hidden: true }); + browser.test.assertEq(tabs.length, 0, "unable to hide sharing tab"); + + browser.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => { + if (testTab.id !== tabId) { + return; + } + let state = changeInfo.sharingState; + + // Ignore tab update events unrelated to the sharing state. + if (!state) { + return; + } + + browser.test.assertFalse(state.camera, "sharing camera was turned off"); + browser.test.assertFalse(state.microphone, "sharing mic was turned off"); + browser.test.assertFalse(state.screen, "sharing screen was turned off"); + + // Verify we can hide the tab once it is not shared over webRTC anymore. + let hidden = await browser.tabs.hide(testTab.id); + browser.test.assertEq(hidden.length, 1, "tab hidden successfully"); + tabs = await browser.tabs.query({ hidden: true }); + browser.test.assertEq(tabs.length, 1, "hidden tab found"); + + browser.test.notifyPass("done"); + }); + browser.test.sendMessage("ready"); + } + + let extdata = { + manifest: { permissions: ["tabs", "tabHide"] }, + useAddonManager: "temporary", + background, + }; + let extension = ExtensionTestUtils.loadExtension(extdata); + await extension.startup(); + + // Test that onUpdated is called after the sharing state is changed from + // chrome code. + await extension.awaitMessage("ready"); + + info("Updating browser sharing on the test tab"); + + // Clear only the webRTC part of the browser sharing state + // (used to test Bug 1577480 regression fix). + gBrowser.updateBrowserSharing(tab.linkedBrowser, { webRTC: null }); + + await extension.awaitFinish("done"); + await extension.unload(); + + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_successors.js b/browser/components/extensions/test/browser/browser_ext_tabs_successors.js new file mode 100644 index 0000000000..77549c44d5 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_successors.js @@ -0,0 +1,396 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +async function background(tabCount, testFn) { + try { + const { TAB_ID_NONE } = browser.tabs; + const tabIds = await Promise.all( + Array.from({ length: tabCount }, () => + browser.tabs.create({ url: "about:blank" }).then(t => t.id) + ) + ); + + const toTabIds = i => tabIds[i]; + + const setSuccessors = mapping => + Promise.all( + mapping.map((succ, i) => + browser.tabs.update(tabIds[i], { successorTabId: tabIds[succ] }) + ) + ); + + const verifySuccessors = async function (mapping, name) { + const promises = [], + expected = []; + for (let i = 0; i < mapping.length; i++) { + if (mapping[i] !== undefined) { + promises.push( + browser.tabs.get(tabIds[i]).then(t => t.successorTabId) + ); + expected.push( + mapping[i] === TAB_ID_NONE ? TAB_ID_NONE : tabIds[mapping[i]] + ); + } + } + const results = await Promise.all(promises); + for (let i = 0; i < results.length; i++) { + browser.test.assertEq( + expected[i], + results[i], + `${name}: successorTabId of tab ${i} in mapping should be ${expected[i]}` + ); + } + }; + + await testFn({ + TAB_ID_NONE, + tabIds, + toTabIds, + setSuccessors, + verifySuccessors, + }); + + browser.test.notifyPass("background-script"); + } catch (e) { + browser.test.fail(`${e} :: ${e.stack}`); + browser.test.notifyFail("background-script"); + } +} + +async function runTabTest(tabCount, testFn) { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + background: `(${background})(${tabCount}, ${testFn});`, + }); + + await extension.startup(); + await extension.awaitFinish("background-script"); + await extension.unload(); +} + +add_task(function testTabSuccessors() { + return runTabTest(3, async function ({ TAB_ID_NONE, tabIds }) { + const anotherWindow = await browser.windows.create({ url: "about:blank" }); + + browser.test.assertEq( + TAB_ID_NONE, + (await browser.tabs.get(tabIds[0])).successorTabId, + "Tabs default to an undefined successor" + ); + + // Basic getting and setting + + await browser.tabs.update(tabIds[0], { successorTabId: tabIds[1] }); + browser.test.assertEq( + tabIds[1], + (await browser.tabs.get(tabIds[0])).successorTabId, + "tabs.update assigned the correct successor" + ); + + await browser.tabs.update(tabIds[0], { + successorTabId: browser.tabs.TAB_ID_NONE, + }); + browser.test.assertEq( + TAB_ID_NONE, + (await browser.tabs.get(tabIds[0])).successorTabId, + "tabs.update cleared successor" + ); + + await browser.tabs.update(tabIds[0], { successorTabId: tabIds[1] }); + await browser.tabs.update(tabIds[0], { successorTabId: tabIds[0] }); + browser.test.assertEq( + TAB_ID_NONE, + (await browser.tabs.get(tabIds[0])).successorTabId, + "Setting a tab as its own successor clears the successor instead" + ); + + // Validation tests + + await browser.test.assertRejects( + browser.tabs.update(tabIds[0], { successorTabId: 1e8 }), + /Invalid successorTabId/, + "tabs.update should throw with an invalid successor tab ID" + ); + + await browser.test.assertRejects( + browser.tabs.update(tabIds[0], { + successorTabId: anotherWindow.tabs[0].id, + }), + /Successor tab must be in the same window as the tab being updated/, + "tabs.update should throw with a successor tab ID from another window" + ); + + // Make sure the successor is truly being assigned + + await browser.tabs.update(tabIds[0], { + successorTabId: tabIds[2], + active: true, + }); + await browser.tabs.remove(tabIds[0]); + browser.test.assertEq( + tabIds[2], + (await browser.tabs.query({ active: true }))[0].id + ); + + return browser.tabs.remove([ + tabIds[1], + tabIds[2], + anotherWindow.tabs[0].id, + ]); + }); +}); + +add_task(function testMoveInSuccession_appendFalse() { + return runTabTest( + 8, + async function ({ + TAB_ID_NONE, + tabIds, + toTabIds, + setSuccessors, + verifySuccessors, + }) { + await browser.tabs.moveInSuccession([1, 0].map(toTabIds), tabIds[0]); + await verifySuccessors([TAB_ID_NONE, 0], "scenario 1"); + + await browser.tabs.moveInSuccession( + [0, 1, 2, 3].map(toTabIds), + tabIds[0] + ); + await verifySuccessors([1, 2, 3, 0], "scenario 2"); + + await browser.tabs.moveInSuccession([1, 0].map(toTabIds), tabIds[0]); + await verifySuccessors( + [TAB_ID_NONE, 0], + "scenario 1 after tab 0 has a successor" + ); + + await browser.tabs.update(tabIds[7], { successorTabId: tabIds[0] }); + await browser.tabs.moveInSuccession([4, 5, 6, 7].map(toTabIds)); + await verifySuccessors( + new Array(4).concat([5, 6, 7, TAB_ID_NONE]), + "scenario 4" + ); + + await setSuccessors([7, 2, 3, 4, 3, 6, 7, 5]); + await browser.tabs.moveInSuccession( + [4, 6, 3, 2].map(toTabIds), + tabIds[7] + ); + await verifySuccessors([7, TAB_ID_NONE, 7, 2, 6, 7, 3, 5], "scenario 5"); + + await setSuccessors([7, 2, 3, 4, 3, 6, 7, 5]); + await browser.tabs.moveInSuccession( + [4, 6, 3, 2].map(toTabIds), + tabIds[7], + { + insert: true, + } + ); + await verifySuccessors( + [4, TAB_ID_NONE, 7, 2, 6, 4, 3, 5], + "insert = true" + ); + + await setSuccessors([1, 2, 3, 4, 0]); + await browser.tabs.moveInSuccession([3, 1, 2].map(toTabIds), tabIds[0], { + insert: true, + }); + await verifySuccessors([4, 2, 0, 1, 3], "insert = true, part 2"); + + await browser.tabs.moveInSuccession([ + tabIds[0], + tabIds[1], + 1e8, + tabIds[2], + ]); + await verifySuccessors([1, 2, TAB_ID_NONE], "unknown tab ID"); + + browser.test.assertTrue( + await browser.tabs.moveInSuccession([1e8]).then( + () => true, + () => false + ), + "When all tab IDs are unknown, tabs.moveInSuccession should not throw" + ); + + // Validation tests + + await browser.test.assertRejects( + browser.tabs.moveInSuccession([tabIds[0], tabIds[1], tabIds[0]]), + /IDs must not occur more than once in tabIds/, + "tabs.moveInSuccession should throw when a tab is referenced more than once in tabIds" + ); + + await browser.test.assertRejects( + browser.tabs.moveInSuccession([tabIds[0], tabIds[1]], tabIds[0], { + insert: true, + }), + /Value of tabId must not occur in tabIds if append or insert is true/, + "tabs.moveInSuccession should throw when tabId occurs in tabIds and insert is true" + ); + + return browser.tabs.remove(tabIds); + } + ); +}); + +add_task(function testMoveInSuccession_appendTrue() { + return runTabTest( + 8, + async function ({ + TAB_ID_NONE, + tabIds, + toTabIds, + setSuccessors, + verifySuccessors, + }) { + await browser.tabs.moveInSuccession([1].map(toTabIds), tabIds[0], { + append: true, + }); + await verifySuccessors([1, TAB_ID_NONE], "scenario 1"); + + await browser.tabs.update(tabIds[3], { successorTabId: tabIds[4] }); + await browser.tabs.moveInSuccession([1, 2, 3].map(toTabIds), tabIds[0], { + append: true, + }); + await verifySuccessors([1, 2, 3, TAB_ID_NONE], "scenario 2"); + + await browser.tabs.update(tabIds[0], { successorTabId: tabIds[1] }); + await browser.tabs.moveInSuccession([1e8], tabIds[0], { append: true }); + browser.test.assertEq( + TAB_ID_NONE, + (await browser.tabs.get(tabIds[0])).successorTabId, + "If no tabs get appended after the reference tab, it should lose its successor" + ); + + await setSuccessors([7, 2, 3, 4, 3, 6, 7, 5]); + await browser.tabs.moveInSuccession( + [4, 6, 3, 2].map(toTabIds), + tabIds[7], + { + append: true, + } + ); + await verifySuccessors( + [7, TAB_ID_NONE, TAB_ID_NONE, 2, 6, 7, 3, 4], + "scenario 3" + ); + + await setSuccessors([7, 2, 3, 4, 3, 6, 7, 5]); + await browser.tabs.moveInSuccession( + [4, 6, 3, 2].map(toTabIds), + tabIds[7], + { + append: true, + insert: true, + } + ); + await verifySuccessors( + [7, TAB_ID_NONE, 5, 2, 6, 7, 3, 4], + "insert = true" + ); + + await browser.tabs.moveInSuccession([0, 4].map(toTabIds), tabIds[7], { + append: true, + insert: true, + }); + await verifySuccessors( + [4, undefined, undefined, undefined, 6, undefined, undefined, 0], + "insert = true, part 2" + ); + + await setSuccessors([1, 2, 3, 4, 0]); + await browser.tabs.moveInSuccession([3, 1, 2].map(toTabIds), tabIds[0], { + append: true, + insert: true, + }); + await verifySuccessors([3, 2, 4, 1, 0], "insert = true, part 3"); + + await browser.tabs.update(tabIds[0], { successorTabId: tabIds[1] }); + await browser.tabs.moveInSuccession([1e8], tabIds[0], { + append: true, + insert: true, + }); + browser.test.assertEq( + tabIds[1], + (await browser.tabs.get(tabIds[0])).successorTabId, + "If no tabs get inserted after the reference tab, it should keep its successor" + ); + + // Validation tests + + await browser.test.assertRejects( + browser.tabs.moveInSuccession([tabIds[0], tabIds[1]], tabIds[0], { + append: true, + }), + /Value of tabId must not occur in tabIds if append or insert is true/, + "tabs.moveInSuccession should throw when tabId occurs in tabIds and insert is true" + ); + + return browser.tabs.remove(tabIds); + } + ); +}); + +add_task(function testMoveInSuccession_ignoreTabsInOtherWindows() { + return runTabTest( + 2, + async function ({ + TAB_ID_NONE, + tabIds, + toTabIds, + setSuccessors, + verifySuccessors, + }) { + const anotherWindow = await browser.windows.create({ + url: Array.from({ length: 3 }, () => "about:blank"), + }); + tabIds.push(...anotherWindow.tabs.map(t => t.id)); + + await setSuccessors([1, 0, 3, 4, 2]); + await browser.tabs.moveInSuccession([1, 3, 2].map(toTabIds), tabIds[4]); + await verifySuccessors( + [1, 0, 4, 2, TAB_ID_NONE], + "first tab in another window" + ); + + await setSuccessors([1, 0, 3, 4, 2]); + await browser.tabs.moveInSuccession([3, 1, 2].map(toTabIds), tabIds[4]); + await verifySuccessors( + [1, 0, 4, 2, TAB_ID_NONE], + "middle tab in another window" + ); + + await setSuccessors([1, 0, 3, 4, 2]); + await browser.tabs.moveInSuccession([3, 1, 2].map(toTabIds)); + await verifySuccessors( + [1, 0, TAB_ID_NONE, 2, TAB_ID_NONE], + "using the first tab to determine the window" + ); + + await setSuccessors([1, 0, 3, 4, 2]); + await browser.tabs.moveInSuccession([1, 3, 2].map(toTabIds), tabIds[4], { + append: true, + }); + await verifySuccessors( + [1, 0, TAB_ID_NONE, 2, 3], + "first tab in another window, appending" + ); + + await setSuccessors([1, 0, 3, 4, 2]); + await browser.tabs.moveInSuccession([3, 1, 2].map(toTabIds), tabIds[4], { + append: true, + }); + await verifySuccessors( + [1, 0, TAB_ID_NONE, 2, 3], + "middle tab in another window, appending" + ); + + return browser.tabs.remove(tabIds); + } + ); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_update.js b/browser/components/extensions/test/browser/browser_ext_tabs_update.js new file mode 100644 index 0000000000..7b16b24225 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_update.js @@ -0,0 +1,54 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function () { + let tab1 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:robots" + ); + let tab2 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:config" + ); + + gBrowser.selectedTab = tab1; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + background: function () { + browser.tabs.query( + { + lastFocusedWindow: true, + }, + function (tabs) { + browser.test.assertEq(tabs.length, 3, "should have three tabs"); + + tabs.sort((tab1, tab2) => tab1.index - tab2.index); + + browser.test.assertEq(tabs[0].url, "about:blank", "first tab blank"); + tabs.shift(); + + browser.test.assertTrue(tabs[0].active, "tab 0 active"); + browser.test.assertFalse(tabs[1].active, "tab 1 inactive"); + + browser.tabs.update(tabs[1].id, { active: true }, function () { + browser.test.sendMessage("check"); + }); + } + ); + }, + }); + + await Promise.all([extension.startup(), extension.awaitMessage("check")]); + + Assert.equal(gBrowser.selectedTab, tab2, "correct tab selected"); + + await extension.unload(); + + BrowserTestUtils.removeTab(tab1); + BrowserTestUtils.removeTab(tab2); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_update_highlighted.js b/browser/components/extensions/test/browser/browser_ext_tabs_update_highlighted.js new file mode 100644 index 0000000000..0adb05e827 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_update_highlighted.js @@ -0,0 +1,183 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_update_highlighted() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + background: async function () { + const trackedEvents = ["onActivated", "onHighlighted"]; + async function expectResults(fn, action) { + let resolve; + let reject; + let promise = new Promise((...args) => { + [resolve, reject] = args; + }); + let expectedEvents; + let events = []; + let listeners = {}; + for (let trackedEvent of trackedEvents) { + listeners[trackedEvent] = data => { + events.push([trackedEvent, data]); + if (expectedEvents && expectedEvents.length >= events.length) { + resolve(); + } + }; + browser.tabs[trackedEvent].addListener(listeners[trackedEvent]); + } + let expectedData = await fn(); + let expectedHighlighted = expectedData.highlighted; + let expectedActive = expectedData.active; + expectedEvents = expectedData.events; + if (events.length < expectedEvents.length) { + // Wait up to 1000 ms for the expected number of events. + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + setTimeout(reject, 1000); + await promise.catch(() => { + let numMissing = expectedEvents.length - events.length; + browser.test.fail(`${numMissing} missing events when ${action}`); + }); + } + let [{ id: active }] = await browser.tabs.query({ active: true }); + browser.test.assertEq( + expectedActive, + active, + `The expected tab is active when ${action}` + ); + let highlighted = (await browser.tabs.query({ highlighted: true })).map( + ({ id }) => id + ); + browser.test.assertEq( + JSON.stringify(expectedHighlighted), + JSON.stringify(highlighted), + `The expected tabs are highlighted when ${action}` + ); + let unexpectedEvents = events.splice(expectedEvents.length); + browser.test.assertEq( + JSON.stringify(expectedEvents), + JSON.stringify(events), + `Should get expected events when ${action}` + ); + if (unexpectedEvents.length) { + browser.test.fail( + `${unexpectedEvents.length} unexpected events when ${action}: ` + + JSON.stringify(unexpectedEvents) + ); + } + for (let trackedEvent of trackedEvents) { + browser.tabs[trackedEvent].removeListener(listeners[trackedEvent]); + } + } + + let { id: windowId } = await browser.windows.getCurrent(); + let { id: tab1 } = await browser.tabs.create({ url: "about:blank?1" }); + let { id: tab2 } = await browser.tabs.create({ + url: "about:blank?2", + active: true, + }); + + await expectResults(async () => { + await browser.tabs.update(tab2, { highlighted: true }); + return { active: tab2, highlighted: [tab2], events: [] }; + }, "highlighting active tab"); + + await expectResults(async () => { + await browser.tabs.update(tab2, { highlighted: false }); + return { active: tab2, highlighted: [tab2], events: [] }; + }, "unhighlighting active tab with no multiselection"); + + await expectResults(async () => { + await browser.tabs.update(tab1, { highlighted: true }); + return { + active: tab1, + highlighted: [tab1, tab2], + events: [ + ["onActivated", { tabId: tab1, previousTabId: tab2, windowId }], + ["onHighlighted", { tabIds: [tab1, tab2], windowId }], + ], + }; + }, "highlighting non-highlighted tab"); + + await expectResults(async () => { + await browser.tabs.update(tab2, { highlighted: true }); + return { active: tab1, highlighted: [tab1, tab2], events: [] }; + }, "highlighting inactive highlighted tab"); + + await expectResults(async () => { + await browser.tabs.update(tab1, { highlighted: false }); + return { + active: tab2, + highlighted: [tab2], + events: [ + ["onActivated", { tabId: tab2, previousTabId: tab1, windowId }], + ["onHighlighted", { tabIds: [tab2], windowId }], + ], + }; + }, "unhighlighting active tab with multiselection"); + + await expectResults(async () => { + await browser.tabs.update(tab1, { highlighted: true }); + return { + active: tab1, + highlighted: [tab1, tab2], + events: [ + ["onActivated", { tabId: tab1, previousTabId: tab2, windowId }], + ["onHighlighted", { tabIds: [tab1, tab2], windowId }], + ], + }; + }, "highlighting non-highlighted tab"); + + await expectResults(async () => { + await browser.tabs.update(tab2, { highlighted: false }); + return { + active: tab1, + highlighted: [tab1], + events: [["onHighlighted", { tabIds: [tab1], windowId }]], + }; + }, "unhighlighting inactive highlighted tab"); + + await expectResults(async () => { + await browser.tabs.update(tab2, { highlighted: true, active: false }); + return { + active: tab1, + highlighted: [tab1, tab2], + events: [["onHighlighted", { tabIds: [tab1, tab2], windowId }]], + }; + }, "highlighting without activating non-highlighted tab"); + + await expectResults(async () => { + await browser.tabs.update(tab2, { highlighted: true, active: true }); + return { + active: tab2, + highlighted: [tab2], + events: [ + ["onActivated", { tabId: tab2, previousTabId: tab1, windowId }], + ["onHighlighted", { tabIds: [tab2], windowId }], + ], + }; + }, "highlighting and activating inactive highlighted tab"); + + await expectResults(async () => { + await browser.tabs.update(tab1, { active: true, highlighted: true }); + return { + active: tab1, + highlighted: [tab1], + events: [ + ["onActivated", { tabId: tab1, previousTabId: tab2, windowId }], + ["onHighlighted", { tabIds: [tab1], windowId }], + ], + }; + }, "highlighting and activating non-highlighted tab"); + + await browser.tabs.remove([tab1, tab2]); + browser.test.notifyPass("test-finished"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("test-finished"); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_update_url.js b/browser/components/extensions/test/browser/browser_ext_tabs_update_url.js new file mode 100644 index 0000000000..f34a97c047 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_update_url.js @@ -0,0 +1,235 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs", + TabStateFlusher: "resource:///modules/sessionstore/TabStateFlusher.sys.mjs", +}); + +async function testTabsUpdateURL( + existentTabURL, + tabsUpdateURL, + isErrorExpected +) { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + files: { + "tab.html": ` + <!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + </head> + <body> + <h1>tab page</h1> + </body> + </html> + `.trim(), + }, + background: function () { + browser.test.sendMessage("ready", browser.runtime.getURL("tab.html")); + + browser.test.onMessage.addListener( + async (msg, tabsUpdateURL, isErrorExpected) => { + let tabs = await browser.tabs.query({ lastFocusedWindow: true }); + + try { + let tab = await browser.tabs.update(tabs[1].id, { + url: tabsUpdateURL, + }); + + browser.test.assertFalse( + isErrorExpected, + `tabs.update with URL ${tabsUpdateURL} should be rejected` + ); + browser.test.assertTrue( + tab, + "on success the tab should be defined" + ); + } catch (error) { + browser.test.assertTrue( + isErrorExpected, + `tabs.update with URL ${tabsUpdateURL} should not be rejected` + ); + browser.test.assertTrue( + /^Illegal URL/.test(error.message), + "tabs.update should be rejected with the expected error message" + ); + } + + browser.test.sendMessage("done"); + } + ); + }, + }); + + await extension.startup(); + + let mozExtTabURL = await extension.awaitMessage("ready"); + + if (tabsUpdateURL == "self") { + tabsUpdateURL = mozExtTabURL; + } + + info(`tab.update URL "${tabsUpdateURL}" on tab with URL "${existentTabURL}"`); + + let tab1 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + existentTabURL + ); + + extension.sendMessage("start", tabsUpdateURL, isErrorExpected); + await extension.awaitMessage("done"); + + BrowserTestUtils.removeTab(tab1); + await extension.unload(); +} + +add_task(async function () { + info("Start testing tabs.update on javascript URLs"); + + let dataURLPage = `data:text/html, + <!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + </head> + <body> + <h1>data url page</h1> + </body> + </html>`; + + let checkList = [ + { + tabsUpdateURL: "http://example.net", + isErrorExpected: false, + }, + { + tabsUpdateURL: "self", + isErrorExpected: false, + }, + { + tabsUpdateURL: "about:addons", + isErrorExpected: true, + }, + { + tabsUpdateURL: "javascript:console.log('tabs.update execute javascript')", + isErrorExpected: true, + }, + { + tabsUpdateURL: dataURLPage, + isErrorExpected: true, + }, + ]; + + let testCases = checkList.map(check => + Object.assign({}, check, { existentTabURL: "about:blank" }) + ); + + for (let { existentTabURL, tabsUpdateURL, isErrorExpected } of testCases) { + await testTabsUpdateURL(existentTabURL, tabsUpdateURL, isErrorExpected); + } + + info("done"); +}); + +add_task(async function test_update_reload() { + const URL = "https://example.com/"; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + background() { + browser.test.onMessage.addListener(async (cmd, ...args) => { + const result = await browser.tabs[cmd](...args); + browser.test.sendMessage("result", result); + }); + + const filter = { + properties: ["status"], + }; + + browser.tabs.onUpdated.addListener((tabId, changeInfo) => { + if (changeInfo.status === "complete") { + browser.test.sendMessage("historyAdded"); + } + }, filter); + }, + }); + + let win = await BrowserTestUtils.openNewBrowserWindow(); + let tabBrowser = win.gBrowser.selectedBrowser; + BrowserTestUtils.startLoadingURIString(tabBrowser, URL); + await BrowserTestUtils.browserLoaded(tabBrowser, false, URL); + let tab = win.gBrowser.selectedTab; + + async function getTabHistory() { + await TabStateFlusher.flush(tabBrowser); + return JSON.parse(SessionStore.getTabState(tab)); + } + + await extension.startup(); + extension.sendMessage("query", { url: URL }); + let tabs = await extension.awaitMessage("result"); + let tabId = tabs[0].id; + + let history = await getTabHistory(); + is( + history.entries.length, + 1, + "Tab history contains the expected number of entries." + ); + is( + history.entries[0].url, + URL, + `Tab history contains the expected entry: URL.` + ); + + extension.sendMessage("update", tabId, { url: `${URL}1/` }); + await Promise.all([ + extension.awaitMessage("result"), + extension.awaitMessage("historyAdded"), + ]); + + history = await getTabHistory(); + is( + history.entries.length, + 2, + "Tab history contains the expected number of entries." + ); + is( + history.entries[1].url, + `${URL}1/`, + `Tab history contains the expected entry: ${URL}1/.` + ); + + extension.sendMessage("update", tabId, { + url: `${URL}2/`, + loadReplace: true, + }); + await Promise.all([ + extension.awaitMessage("result"), + extension.awaitMessage("historyAdded"), + ]); + + history = await getTabHistory(); + is( + history.entries.length, + 2, + "Tab history contains the expected number of entries." + ); + is( + history.entries[1].url, + `${URL}2/`, + `Tab history contains the expected entry: ${URL}2/.` + ); + + await extension.unload(); + await BrowserTestUtils.closeWindow(win); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_warmup.js b/browser/components/extensions/test/browser/browser_ext_tabs_warmup.js new file mode 100644 index 0000000000..e9a5382de8 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_warmup.js @@ -0,0 +1,40 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function testWarmupTab() { + let tab1 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.net/" + ); + let tab2 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:blank" + ); + Assert.ok(!tab1.linkedBrowser.renderLayers, "tab is not warm yet"); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + + background: async function () { + let backgroundTab = ( + await browser.tabs.query({ + lastFocusedWindow: true, + url: "http://example.net/", + active: false, + }) + )[0]; + await browser.tabs.warmup(backgroundTab.id); + browser.test.notifyPass("tabs.warmup"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("tabs.warmup"); + Assert.ok(tab1.linkedBrowser.renderLayers, "tab has been warmed up"); + gBrowser.removeTab(tab1); + gBrowser.removeTab(tab2); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_tabs_zoom.js b/browser/components/extensions/test/browser/browser_ext_tabs_zoom.js new file mode 100644 index 0000000000..5884a9163a --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_tabs_zoom.js @@ -0,0 +1,346 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const SITE_SPECIFIC_PREF = "browser.zoom.siteSpecific"; +const FULL_ZOOM_PREF = "browser.content.full-zoom"; + +let gContentPrefs = Cc["@mozilla.org/content-pref/service;1"].getService( + Ci.nsIContentPrefService2 +); + +// A single monitor for the tests. If it receives any +// incognito data in event listeners it will fail. +let monitor; +add_task(async function startup() { + monitor = await startIncognitoMonitorExtension(); +}); +registerCleanupFunction(async function finish() { + await monitor.unload(); +}); + +add_task(async function test_zoom_api() { + async function background() { + function promiseUpdated(tabId, attr) { + return new Promise(resolve => { + let onUpdated = (tabId_, changeInfo, tab) => { + if (tabId == tabId_ && attr in changeInfo) { + browser.tabs.onUpdated.removeListener(onUpdated); + + resolve({ changeInfo, tab }); + } + }; + browser.tabs.onUpdated.addListener(onUpdated); + }); + } + + let deferred = {}; + browser.test.onMessage.addListener((message, msg, result) => { + if (message == "msg-done" && deferred[msg]) { + deferred[msg].resolve(result); + } + }); + + let _id = 0; + function msg(...args) { + return new Promise((resolve, reject) => { + let id = ++_id; + deferred[id] = { resolve, reject }; + browser.test.sendMessage("msg", id, ...args); + }); + } + + let lastZoomEvent = {}; + let promiseZoomEvents = {}; + browser.tabs.onZoomChange.addListener(info => { + lastZoomEvent[info.tabId] = info; + if (promiseZoomEvents[info.tabId]) { + promiseZoomEvents[info.tabId](); + promiseZoomEvents[info.tabId] = null; + } + }); + + let awaitZoom = async (tabId, newValue) => { + let listener; + + // eslint-disable-next-line no-async-promise-executor + await new Promise(async resolve => { + listener = info => { + if (info.tabId == tabId && info.newZoomFactor == newValue) { + resolve(); + } + }; + browser.tabs.onZoomChange.addListener(listener); + + let zoomFactor = await browser.tabs.getZoom(tabId); + if (zoomFactor == newValue) { + resolve(); + } + }); + + browser.tabs.onZoomChange.removeListener(listener); + }; + + let checkZoom = async (tabId, newValue, oldValue = null) => { + let awaitEvent; + if (oldValue != null && !lastZoomEvent[tabId]) { + awaitEvent = new Promise(resolve => { + promiseZoomEvents[tabId] = resolve; + }); + } + + let [apiZoom, realZoom] = await Promise.all([ + browser.tabs.getZoom(tabId), + msg("get-zoom", tabId), + awaitEvent, + ]); + + browser.test.assertEq( + newValue, + apiZoom, + `Got expected zoom value from API` + ); + browser.test.assertEq( + newValue, + realZoom, + `Got expected zoom value from parent` + ); + + if (oldValue != null) { + let event = lastZoomEvent[tabId]; + lastZoomEvent[tabId] = null; + browser.test.assertEq( + tabId, + event.tabId, + `Got expected zoom event tab ID` + ); + browser.test.assertEq( + newValue, + event.newZoomFactor, + `Got expected zoom event zoom factor` + ); + browser.test.assertEq( + oldValue, + event.oldZoomFactor, + `Got expected zoom event old zoom factor` + ); + + browser.test.assertEq( + 3, + Object.keys(event.zoomSettings).length, + `Zoom settings should have 3 keys` + ); + browser.test.assertEq( + "automatic", + event.zoomSettings.mode, + `Mode should be "automatic"` + ); + browser.test.assertEq( + "per-origin", + event.zoomSettings.scope, + `Scope should be "per-origin"` + ); + browser.test.assertEq( + 1, + event.zoomSettings.defaultZoomFactor, + `Default zoom should be 1` + ); + } + }; + + try { + let tabs = await browser.tabs.query({}); + browser.test.assertEq(tabs.length, 4, "We have 4 tabs"); + + let tabIds = tabs.splice(1).map(tab => tab.id); + await checkZoom(tabIds[0], 1); + + await browser.tabs.setZoom(tabIds[0], 2); + await checkZoom(tabIds[0], 2, 1); + + let zoomSettings = await browser.tabs.getZoomSettings(tabIds[0]); + browser.test.assertEq( + 3, + Object.keys(zoomSettings).length, + `Zoom settings should have 3 keys` + ); + browser.test.assertEq( + "automatic", + zoomSettings.mode, + `Mode should be "automatic"` + ); + browser.test.assertEq( + "per-origin", + zoomSettings.scope, + `Scope should be "per-origin"` + ); + browser.test.assertEq( + 1, + zoomSettings.defaultZoomFactor, + `Default zoom should be 1` + ); + + browser.test.log(`Switch to tab 2`); + await browser.tabs.update(tabIds[1], { active: true }); + await checkZoom(tabIds[1], 1); + + browser.test.log(`Navigate tab 2 to origin of tab 1`); + browser.tabs.update(tabIds[1], { url: "http://example.com" }); + await promiseUpdated(tabIds[1], "url"); + await checkZoom(tabIds[1], 2, 1); + + browser.test.log(`Update zoom in tab 2, expect changes in both tabs`); + await browser.tabs.setZoom(tabIds[1], 1.5); + await checkZoom(tabIds[1], 1.5, 2); + + browser.test.log(`Switch to tab 3, expect zoom to affect private window`); + await browser.tabs.setZoom(tabIds[2], 3); + await checkZoom(tabIds[2], 3, 1); + + browser.test.log( + `Switch to tab 1, expect asynchronous zoom change just after the switch` + ); + await Promise.all([ + awaitZoom(tabIds[0], 1.5), + browser.tabs.update(tabIds[0], { active: true }), + ]); + await checkZoom(tabIds[0], 1.5, 2); + + browser.test.log("Set zoom to 0, expect it set to 1"); + await browser.tabs.setZoom(tabIds[0], 0); + await checkZoom(tabIds[0], 1, 1.5); + + browser.test.log("Change zoom externally, expect changes reflected"); + await msg("enlarge"); + await checkZoom(tabIds[0], 1.1, 1); + + await Promise.all([ + browser.tabs.setZoom(tabIds[0], 0), + browser.tabs.setZoom(tabIds[1], 0), + browser.tabs.setZoom(tabIds[2], 0), + ]); + await Promise.all([ + checkZoom(tabIds[0], 1, 1.1), + checkZoom(tabIds[1], 1, 1.5), + checkZoom(tabIds[2], 1, 3), + ]); + + browser.test.log("Check that invalid zoom values throw an error"); + await browser.test.assertRejects( + browser.tabs.setZoom(tabIds[0], 42), + /Zoom value 42 out of range/, + "Expected an out of range error" + ); + + browser.test.log("Disable site-specific zoom, expect correct scope"); + await msg("site-specific", false); + zoomSettings = await browser.tabs.getZoomSettings(tabIds[0]); + + browser.test.assertEq( + "per-tab", + zoomSettings.scope, + `Scope should be "per-tab"` + ); + + await msg("site-specific", null); + + browser.test.onMessage.addListener(async msg => { + if (msg === "set-global-zoom-done") { + zoomSettings = await browser.tabs.getZoomSettings(tabIds[0]); + + browser.test.assertEq( + 5, + zoomSettings.defaultZoomFactor, + `Default zoom should be 5 after being changed` + ); + + browser.test.notifyPass("tab-zoom"); + } + }); + await msg("set-global-zoom"); + } catch (e) { + browser.test.fail(`Error: ${e} :: ${e.stack}`); + browser.test.notifyFail("tab-zoom"); + } + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + incognitoOverride: "spanning", + background, + }); + + extension.onMessage("msg", (id, msg, ...args) => { + const { + Management: { + global: { tabTracker }, + }, + } = ChromeUtils.importESModule("resource://gre/modules/Extension.sys.mjs"); + + let resp; + if (msg == "get-zoom") { + let tab = tabTracker.getTab(args[0]); + resp = ZoomManager.getZoomForBrowser(tab.linkedBrowser); + } else if (msg == "set-zoom") { + let tab = tabTracker.getTab(args[0]); + ZoomManager.setZoomForBrowser(tab.linkedBrowser); + } else if (msg == "set-global-zoom") { + resp = gContentPrefs.setGlobal( + FULL_ZOOM_PREF, + 5, + Cu.createLoadContext(), + { + handleCompletion() { + extension.sendMessage("set-global-zoom-done", id, resp); + }, + } + ); + } else if (msg == "enlarge") { + FullZoom.enlarge(); + } else if (msg == "site-specific") { + if (args[0] == null) { + SpecialPowers.clearUserPref(SITE_SPECIFIC_PREF); + } else { + SpecialPowers.setBoolPref(SITE_SPECIFIC_PREF, args[0]); + } + } + + extension.sendMessage("msg-done", id, resp); + }); + + let url = "https://example.com/"; + let tab1 = await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + let tab2 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://example.org/" + ); + + let privateWindow = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + let selectedBrowser = privateWindow.gBrowser.selectedBrowser; + BrowserTestUtils.startLoadingURIString(selectedBrowser, url); + await BrowserTestUtils.browserLoaded(selectedBrowser, false, url); + + gBrowser.selectedTab = tab1; + + await extension.startup(); + + await extension.awaitFinish("tab-zoom"); + + await extension.unload(); + + await new Promise(resolve => { + gContentPrefs.setGlobal(FULL_ZOOM_PREF, null, Cu.createLoadContext(), { + handleCompletion() { + resolve(); + }, + }); + }); + + privateWindow.close(); + BrowserTestUtils.removeTab(tab1); + BrowserTestUtils.removeTab(tab2); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_themes_validation.js b/browser/components/extensions/test/browser/browser_ext_themes_validation.js new file mode 100644 index 0000000000..c004363a6b --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_themes_validation.js @@ -0,0 +1,55 @@ +"use strict"; + +PromiseTestUtils.allowMatchingRejectionsGlobally(/packaging errors/); + +/** + * Helper function for testing a theme with invalid properties. + * + * @param {object} invalidProps The invalid properties to load the theme with. + */ +async function testThemeWithInvalidProperties(invalidProps) { + let manifest = { + theme: {}, + }; + + invalidProps.forEach(prop => { + // Some properties require additional information: + switch (prop) { + case "background": + manifest[prop] = { scripts: ["background.js"] }; + break; + case "permissions": + manifest[prop] = ["tabs"]; + break; + case "omnibox": + manifest[prop] = { keyword: "test" }; + break; + default: + manifest[prop] = {}; + } + }); + + let extension = ExtensionTestUtils.loadExtension({ manifest }); + await Assert.rejects( + extension.startup(), + /startup failed/, + "Theme should fail to load if it contains invalid properties" + ); +} + +add_task( + async function test_that_theme_with_invalid_properties_fails_to_load() { + let invalidProps = [ + "page_action", + "browser_action", + "background", + "permissions", + "omnibox", + "commands", + ]; + for (let prop in invalidProps) { + await testThemeWithInvalidProperties([prop]); + } + await testThemeWithInvalidProperties(invalidProps); + } +); diff --git a/browser/components/extensions/test/browser/browser_ext_topSites.js b/browser/components/extensions/test/browser/browser_ext_topSites.js new file mode 100644 index 0000000000..fe0edf2b8d --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_topSites.js @@ -0,0 +1,413 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { PlacesTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PlacesTestUtils.sys.mjs" +); + +const { + ExtensionUtils: { makeDataURI }, +} = ChromeUtils.importESModule("resource://gre/modules/ExtensionUtils.sys.mjs"); + +// A small 1x1 test png +const IMAGE_1x1 = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAACklEQVQI12NgAAAAAgAB4iG8MwAAAABJRU5ErkJggg=="; + +async function updateTopSites(condition) { + // Toggle the pref to clear the feed cache and force an update. + await SpecialPowers.pushPrefEnv({ + set: [ + ["browser.newtabpage.activity-stream.feeds.system.topsites", false], + ["browser.newtabpage.activity-stream.feeds.system.topsites", true], + ], + }); + + // Wait for the feed to be updated. + await TestUtils.waitForCondition(() => { + let sites = AboutNewTab.getTopSites(); + return condition(sites); + }, "Waiting for top sites to be updated"); +} + +async function loadExtension() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["topSites"], + }, + background() { + browser.test.onMessage.addListener(async options => { + let sites = await browser.topSites.get(options); + browser.test.sendMessage("sites", sites); + }); + }, + }); + await extension.startup(); + return extension; +} + +async function getSites(extension, options) { + extension.sendMessage(options); + return extension.awaitMessage("sites"); +} + +add_setup(async function () { + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + + await SpecialPowers.pushPrefEnv({ + set: [ + // The pref for TopSites is empty by default. + [ + "browser.newtabpage.activity-stream.default.sites", + "https://www.youtube.com/,https://www.facebook.com/,https://www.amazon.com/,https://www.reddit.com/,https://www.wikipedia.org/,https://twitter.com/", + ], + // Toggle the feed off and on as a workaround to read the new prefs. + ["browser.newtabpage.activity-stream.feeds.system.topsites", false], + ["browser.newtabpage.activity-stream.feeds.system.topsites", true], + [ + "browser.newtabpage.activity-stream.improvesearch.topSiteSearchShortcuts", + true, + ], + ], + }); + + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "about:newtab", + false + ); + registerCleanupFunction(() => { + BrowserTestUtils.removeTab(tab); + }); +}); + +// Tests newtab links with an empty history. +add_task(async function test_topSites_newtab_emptyHistory() { + let extension = await loadExtension(); + + let expectedResults = [ + { + type: "search", + url: "https://amazon.com", + title: "@amazon", + favicon: null, + }, + { + type: "url", + url: "https://www.youtube.com/", + title: "youtube", + favicon: null, + }, + { + type: "url", + url: "https://www.facebook.com/", + title: "facebook", + favicon: null, + }, + { + type: "url", + url: "https://www.reddit.com/", + title: "reddit", + favicon: null, + }, + { + type: "url", + url: "https://www.wikipedia.org/", + title: "wikipedia", + favicon: null, + }, + { + type: "url", + url: "https://twitter.com/", + title: "twitter", + favicon: null, + }, + ]; + + Assert.deepEqual( + expectedResults, + await getSites(extension, { newtab: true }), + "got topSites newtab links" + ); + + await extension.unload(); +}); + +// Tests newtab links with some visits. +add_task(async function test_topSites_newtab_visits() { + let extension = await loadExtension(); + + // Add some visits to a couple of URLs. We need to add at least two visits + // per URL for it to show up. Add some extra to be safe, and add one more to + // the first so that its frecency is larger. + for (let i = 0; i < 5; i++) { + await PlacesTestUtils.addVisits([ + "http://example-1.com/", + "http://example-2.com/", + ]); + } + await PlacesTestUtils.addVisits("http://example-1.com/"); + + // Wait for example-1.com to be listed second, after the Amazon search link. + await updateTopSites(sites => { + return sites && sites[1] && sites[1].url == "http://example-1.com/"; + }); + + let expectedResults = [ + { + type: "search", + url: "https://amazon.com", + title: "@amazon", + favicon: null, + }, + { + type: "url", + url: "http://example-1.com/", + title: "test visit for http://example-1.com/", + favicon: null, + }, + { + type: "url", + url: "http://example-2.com/", + title: "test visit for http://example-2.com/", + favicon: null, + }, + { + type: "url", + url: "https://www.youtube.com/", + title: "youtube", + favicon: null, + }, + { + type: "url", + url: "https://www.facebook.com/", + title: "facebook", + favicon: null, + }, + { + type: "url", + url: "https://www.reddit.com/", + title: "reddit", + favicon: null, + }, + { + type: "url", + url: "https://www.wikipedia.org/", + title: "wikipedia", + favicon: null, + }, + { + type: "url", + url: "https://twitter.com/", + title: "twitter", + favicon: null, + }, + ]; + + Assert.deepEqual( + expectedResults, + await getSites(extension, { newtab: true }), + "got topSites newtab links" + ); + + await extension.unload(); + await PlacesUtils.history.clear(); +}); + +// Tests that the newtab parameter is ignored if newtab Top Sites are disabled. +add_task(async function test_topSites_newtab_ignored() { + let extension = await loadExtension(); + // Add some visits to a couple of URLs. We need to add at least two visits + // per URL for it to show up. Add some extra to be safe, and add one more to + // the first so that its frecency is larger. + for (let i = 0; i < 5; i++) { + await PlacesTestUtils.addVisits([ + "http://example-1.com/", + "http://example-2.com/", + ]); + } + await PlacesTestUtils.addVisits("http://example-1.com/"); + + // Wait for example-1.com to be listed second, after the Amazon search link. + await updateTopSites(sites => { + return sites && sites[1] && sites[1].url == "http://example-1.com/"; + }); + + await SpecialPowers.pushPrefEnv({ + set: [["browser.newtabpage.activity-stream.feeds.system.topsites", false]], + }); + + let expectedResults = [ + { + type: "url", + url: "http://example-1.com/", + title: "test visit for http://example-1.com/", + favicon: null, + }, + { + type: "url", + url: "http://example-2.com/", + title: "test visit for http://example-2.com/", + favicon: null, + }, + ]; + + Assert.deepEqual( + expectedResults, + await getSites(extension, { newtab: true }), + "Got top-frecency links from Places" + ); + + await SpecialPowers.popPrefEnv(); + await extension.unload(); + await PlacesUtils.history.clear(); +}); + +// Tests newtab links with some visits and favicons. +add_task(async function test_topSites_newtab_visits_favicons() { + let extension = await loadExtension(); + + // Add some visits to a couple of URLs. We need to add at least two visits + // per URL for it to show up. Add some extra to be safe, and add one more to + // the first so that its frecency is larger. + for (let i = 0; i < 5; i++) { + await PlacesTestUtils.addVisits([ + "http://example-1.com/", + "http://example-2.com/", + ]); + } + await PlacesTestUtils.addVisits("http://example-1.com/"); + + // Give the first URL a favicon but not the second so that we can test links + // both with and without favicons. + let faviconData = new Map(); + faviconData.set("http://example-1.com", IMAGE_1x1); + await PlacesTestUtils.addFavicons(faviconData); + + // Wait for example-1.com to be listed second, after the Amazon search link. + await updateTopSites(sites => { + return sites && sites[1] && sites[1].url == "http://example-1.com/"; + }); + + let base = "chrome://activity-stream/content/data/content/tippytop/images/"; + + let expectedResults = [ + { + type: "search", + url: "https://amazon.com", + title: "@amazon", + favicon: await makeDataURI(`${base}amazon@2x.png`), + }, + { + type: "url", + url: "http://example-1.com/", + title: "test visit for http://example-1.com/", + favicon: IMAGE_1x1, + }, + { + type: "url", + url: "http://example-2.com/", + title: "test visit for http://example-2.com/", + favicon: null, + }, + { + type: "url", + url: "https://www.youtube.com/", + title: "youtube", + favicon: await makeDataURI(`${base}youtube-com@2x.png`), + }, + { + type: "url", + url: "https://www.facebook.com/", + title: "facebook", + favicon: await makeDataURI(`${base}facebook-com@2x.png`), + }, + { + type: "url", + url: "https://www.reddit.com/", + title: "reddit", + favicon: await makeDataURI(`${base}reddit-com@2x.png`), + }, + { + type: "url", + url: "https://www.wikipedia.org/", + title: "wikipedia", + favicon: await makeDataURI(`${base}wikipedia-org@2x.png`), + }, + { + type: "url", + url: "https://twitter.com/", + title: "twitter", + favicon: await makeDataURI(`${base}twitter-com@2x.png`), + }, + ]; + + Assert.deepEqual( + expectedResults, + await getSites(extension, { newtab: true, includeFavicon: true }), + "got topSites newtab links" + ); + + await extension.unload(); + await PlacesUtils.history.clear(); +}); + +// Tests newtab links with some visits, favicons, and the `limit` option. +add_task(async function test_topSites_newtab_visits_favicons_limit() { + let extension = await loadExtension(); + + // Add some visits to a couple of URLs. We need to add at least two visits + // per URL for it to show up. Add some extra to be safe, and add one more to + // the first so that its frecency is larger. + for (let i = 0; i < 5; i++) { + await PlacesTestUtils.addVisits([ + "http://example-1.com/", + "http://example-2.com/", + ]); + } + await PlacesTestUtils.addVisits("http://example-1.com/"); + + // Give the first URL a favicon but not the second so that we can test links + // both with and without favicons. + let faviconData = new Map(); + faviconData.set("http://example-1.com", IMAGE_1x1); + await PlacesTestUtils.addFavicons(faviconData); + + // Wait for example-1.com to be listed second, after the Amazon search link. + await updateTopSites(sites => { + return sites && sites[1] && sites[1].url == "http://example-1.com/"; + }); + + let expectedResults = [ + { + type: "search", + url: "https://amazon.com", + title: "@amazon", + favicon: await makeDataURI( + "chrome://activity-stream/content/data/content/tippytop/images/amazon@2x.png" + ), + }, + { + type: "url", + url: "http://example-1.com/", + title: "test visit for http://example-1.com/", + favicon: IMAGE_1x1, + }, + { + type: "url", + url: "http://example-2.com/", + title: "test visit for http://example-2.com/", + favicon: null, + }, + ]; + + Assert.deepEqual( + expectedResults, + await getSites(extension, { newtab: true, includeFavicon: true, limit: 3 }), + "got topSites newtab links" + ); + + await extension.unload(); + await PlacesUtils.history.clear(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_url_overrides_newtab.js b/browser/components/extensions/test/browser/browser_ext_url_overrides_newtab.js new file mode 100644 index 0000000000..988f44bd5d --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_url_overrides_newtab.js @@ -0,0 +1,794 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ + +"use strict"; + +requestLongerTimeout(4); + +ChromeUtils.defineESModuleGetters(this, { + AboutNewTab: "resource:///modules/AboutNewTab.sys.mjs", + ExtensionControlledPopup: + "resource:///modules/ExtensionControlledPopup.sys.mjs", + ExtensionSettingsStore: + "resource://gre/modules/ExtensionSettingsStore.sys.mjs", +}); + +function getNotificationSetting(extensionId) { + return ExtensionSettingsStore.getSetting("newTabNotification", extensionId); +} + +function getNewTabDoorhanger() { + ExtensionControlledPopup._getAndMaybeCreatePanel(document); + return document.getElementById("extension-new-tab-notification"); +} + +function clickKeepChanges(notification) { + notification.button.click(); +} + +function clickManage(notification) { + notification.secondaryButton.click(); +} + +async function promiseNewTab(expectUrl = AboutNewTab.newTabURL, win = window) { + let eventName = "browser-open-newtab-start"; + let newTabStartPromise = new Promise(resolve => { + async function observer(subject) { + Services.obs.removeObserver(observer, eventName); + resolve(subject.wrappedJSObject); + } + Services.obs.addObserver(observer, eventName); + }); + + let newtabShown = TestUtils.waitForCondition( + () => win.gBrowser.currentURI.spec == expectUrl, + `Should open correct new tab url ${expectUrl}.` + ); + + win.BrowserOpenTab(); + const newTabCreatedPromise = newTabStartPromise; + const browser = await newTabCreatedPromise; + await newtabShown; + const tab = win.gBrowser.selectedTab; + + Assert.deepEqual( + browser, + tab.linkedBrowser, + "browser-open-newtab-start notified with the created browser" + ); + return tab; +} + +function waitForAddonDisabled(addon) { + return new Promise(resolve => { + let listener = { + onDisabled(disabledAddon) { + if (disabledAddon.id == addon.id) { + resolve(); + AddonManager.removeAddonListener(listener); + } + }, + }; + AddonManager.addAddonListener(listener); + }); +} + +function waitForAddonEnabled(addon) { + return new Promise(resolve => { + let listener = { + onEnabled(enabledAddon) { + if (enabledAddon.id == addon.id) { + AddonManager.removeAddonListener(listener); + resolve(); + } + }, + }; + AddonManager.addAddonListener(listener); + }); +} + +// Default test extension data for newtab. +const extensionData = { + manifest: { + browser_specific_settings: { + gecko: { + id: "newtaburl@mochi.test", + }, + }, + chrome_url_overrides: { + newtab: "newtab.html", + }, + }, + files: { + "newtab.html": "<h1>New tab!</h1>", + }, + useAddonManager: "temporary", +}; + +add_task(async function test_new_tab_opens() { + let panel = getNewTabDoorhanger().closest("panel"); + let extension = ExtensionTestUtils.loadExtension(extensionData); + + await extension.startup(); + let extensionNewTabUrl = `moz-extension://${extension.uuid}/newtab.html`; + + // Simulate opening the newtab open as a user would. + let popupShown = promisePopupShown(panel); + let tab = await promiseNewTab(extensionNewTabUrl); + await popupShown; + + // This will show a confirmation doorhanger, make sure we don't leave it open. + let popupHidden = promisePopupHidden(panel); + panel.hidePopup(); + await popupHidden; + + BrowserTestUtils.removeTab(tab); + await extension.unload(); +}); + +add_task(async function test_new_tab_ignore_settings() { + await ExtensionSettingsStore.initialize(); + let notification = getNewTabDoorhanger(); + let panel = notification.closest("panel"); + let extensionId = "newtabignore@mochi.test"; + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { gecko: { id: extensionId } }, + browser_action: { + default_popup: "ignore.html", + default_area: "navbar", + }, + chrome_url_overrides: { newtab: "ignore.html" }, + }, + files: { "ignore.html": '<h1 id="extension-new-tab">New Tab!</h1>' }, + useAddonManager: "temporary", + }); + + Assert.notEqual( + panel.getAttribute("panelopen"), + "true", + "The notification panel is initially closed" + ); + + await extension.startup(); + + // Simulate opening the New Tab as a user would. + let popupShown = promisePopupShown(panel); + let tab = await promiseNewTab(); + await popupShown; + + // Ensure the doorhanger is shown and the setting isn't set yet. + is( + panel.getAttribute("panelopen"), + "true", + "The notification panel is open after opening New Tab" + ); + is(gURLBar.focused, false, "The URL bar is not focused with a doorhanger"); + is( + getNotificationSetting(extensionId), + null, + "The New Tab notification is not set for this extension" + ); + is( + panel.anchorNode.closest("toolbarbutton").id, + "newtabignore_mochi_test-BAP", + "The doorhanger is anchored to the browser action" + ); + + // Manually close the panel, as if the user ignored it. + let popupHidden = promisePopupHidden(panel); + panel.hidePopup(); + await popupHidden; + + // Ensure panel is closed and the setting still isn't set. + Assert.notEqual( + panel.getAttribute("panelopen"), + "true", + "The notification panel is closed" + ); + is( + getNotificationSetting(extensionId), + null, + "The New Tab notification is not set after ignoring the doorhanger" + ); + + // Close the first tab and open another new tab. + BrowserTestUtils.removeTab(tab); + tab = await promiseNewTab(); + + // Verify the doorhanger is not shown a second time. + Assert.notEqual( + panel.getAttribute("panelopen"), + "true", + "The notification panel doesn't open after ignoring the doorhanger" + ); + is(gURLBar.focused, true, "The URL bar is focused with no doorhanger"); + + BrowserTestUtils.removeTab(tab); + await extension.unload(); +}); + +add_task(async function test_new_tab_keep_settings() { + await ExtensionSettingsStore.initialize(); + let notification = getNewTabDoorhanger(); + let panel = notification.closest("panel"); + let extensionId = "newtabkeep@mochi.test"; + let manifest = { + version: "1.0", + name: "New Tab Add-on", + browser_specific_settings: { gecko: { id: extensionId } }, + chrome_url_overrides: { newtab: "newtab.html" }, + }; + let extension = ExtensionTestUtils.loadExtension({ + ...extensionData, + manifest, + useAddonManager: "permanent", + }); + + Assert.notEqual( + panel.getAttribute("panelopen"), + "true", + "The notification panel is initially closed" + ); + + await extension.startup(); + let extensionNewTabUrl = `moz-extension://${extension.uuid}/newtab.html`; + + // Simulate opening the New Tab as a user would. + let popupShown = promisePopupShown(panel); + let tab = await promiseNewTab(extensionNewTabUrl); + await popupShown; + + // Ensure the panel is open and the setting isn't saved yet. + is( + panel.getAttribute("panelopen"), + "true", + "The notification panel is open after opening New Tab" + ); + is( + getNotificationSetting(extensionId), + null, + "The New Tab notification is not set for this extension" + ); + is( + panel.anchorNode.closest("toolbarbutton").id, + "PanelUI-menu-button", + "The doorhanger is anchored to the menu icon" + ); + is( + panel.querySelector("#extension-new-tab-notification-description") + .textContent, + "An extension, New Tab Add-on, changed the page you see when you open a new tab.Learn more", + "The description includes the add-on name" + ); + + // Click the Keep Changes button. + let confirmationSaved = TestUtils.waitForCondition(() => { + return ExtensionSettingsStore.getSetting( + "newTabNotification", + extensionId, + extensionId + ).value; + }); + clickKeepChanges(notification); + await confirmationSaved; + + // Ensure panel is closed and setting is updated. + Assert.notEqual( + panel.getAttribute("panelopen"), + "true", + "The notification panel is closed after click" + ); + is( + getNotificationSetting(extensionId).value, + true, + "The New Tab notification is set after keeping the changes" + ); + + // Close the first tab and open another new tab. + BrowserTestUtils.removeTab(tab); + tab = await promiseNewTab(extensionNewTabUrl); + + // Verify the doorhanger is not shown a second time. + Assert.notEqual( + panel.getAttribute("panelopen"), + "true", + "The notification panel is not opened after keeping the changes" + ); + + BrowserTestUtils.removeTab(tab); + + let upgradedExtension = ExtensionTestUtils.loadExtension({ + ...extensionData, + manifest: Object.assign({}, manifest, { version: "2.0" }), + useAddonManager: "permanent", + }); + + await upgradedExtension.startup(); + extensionNewTabUrl = `moz-extension://${upgradedExtension.uuid}/newtab.html`; + + tab = await promiseNewTab(extensionNewTabUrl); + + // Ensure panel is closed and setting is still set. + Assert.notEqual( + panel.getAttribute("panelopen"), + "true", + "The notification panel is closed after click" + ); + is( + getNotificationSetting(extensionId).value, + true, + "The New Tab notification is set after keeping the changes" + ); + + BrowserTestUtils.removeTab(tab); + await upgradedExtension.unload(); + await extension.unload(); + + let confirmation = ExtensionSettingsStore.getSetting( + "newTabNotification", + extensionId, + extensionId + ); + is(confirmation, null, "The confirmation has been cleaned up"); +}); + +add_task(async function test_new_tab_restore_settings() { + await ExtensionSettingsStore.initialize(); + let notification = getNewTabDoorhanger(); + let panel = notification.closest("panel"); + let extensionId = "newtabrestore@mochi.test"; + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { gecko: { id: extensionId } }, + chrome_url_overrides: { newtab: "restore.html" }, + }, + files: { "restore.html": '<h1 id="extension-new-tab">New Tab!</h1>' }, + useAddonManager: "temporary", + }); + + Assert.notEqual( + panel.getAttribute("panelopen"), + "true", + "The notification panel is initially closed" + ); + is( + getNotificationSetting(extensionId), + null, + "The New Tab notification is not initially set for this extension" + ); + + await extension.startup(); + + // Simulate opening the newtab open as a user would. + let popupShown = promisePopupShown(panel); + let tab = await promiseNewTab(); + await popupShown; + + // Verify that the panel is open and add-on is enabled. + let addon = await AddonManager.getAddonByID(extensionId); + is(addon.userDisabled, false, "The add-on is enabled at first"); + is( + panel.getAttribute("panelopen"), + "true", + "The notification panel is open after opening New Tab" + ); + is( + getNotificationSetting(extensionId), + null, + "The New Tab notification is not set for this extension" + ); + + // Click the Manage button. + let preferencesShown = TestUtils.waitForCondition( + () => gBrowser.currentURI.spec == "about:preferences#home", + "Should open about:preferences." + ); + + let popupHidden = promisePopupHidden(panel); + clickManage(notification); + await popupHidden; + await preferencesShown; + + // Ensure panel is closed, settings haven't changed and add-on is disabled. + Assert.notEqual( + panel.getAttribute("panelopen"), + "true", + "The notification panel is closed after click" + ); + + is( + getNotificationSetting(extensionId), + null, + "The New Tab notification is not set after clicking manage" + ); + + // Reopen a browser tab and verify that there's no doorhanger. + BrowserTestUtils.removeTab(tab); + tab = await promiseNewTab(); + + Assert.notEqual( + panel.getAttribute("panelopen"), + "true", + "The notification panel is not opened after keeping the changes" + ); + + BrowserTestUtils.removeTab(tab); + await extension.unload(); +}); + +add_task(async function test_new_tab_restore_settings_multiple() { + await ExtensionSettingsStore.initialize(); + let notification = getNewTabDoorhanger(); + let panel = notification.closest("panel"); + let extensionOneId = "newtabrestoreone@mochi.test"; + let extensionOne = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { gecko: { id: extensionOneId } }, + chrome_url_overrides: { newtab: "restore-one.html" }, + }, + files: { + "restore-one.html": ` + <h1 id="extension-new-tab">New Tab!</h1> + `, + }, + useAddonManager: "temporary", + }); + let extensionTwoId = "newtabrestoretwo@mochi.test"; + let extensionTwo = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { gecko: { id: extensionTwoId } }, + chrome_url_overrides: { newtab: "restore-two.html" }, + }, + files: { "restore-two.html": '<h1 id="extension-new-tab">New Tab!</h1>' }, + useAddonManager: "temporary", + }); + + Assert.notEqual( + panel.getAttribute("panelopen"), + "true", + "The notification panel is initially closed" + ); + is( + getNotificationSetting(extensionOneId), + null, + "The New Tab notification is not initially set for this extension" + ); + is( + getNotificationSetting(extensionTwoId), + null, + "The New Tab notification is not initially set for this extension" + ); + + await extensionOne.startup(); + await extensionTwo.startup(); + + // Simulate opening the newtab open as a user would. + let popupShown = promisePopupShown(panel); + let tab1 = await promiseNewTab(); + await popupShown; + + // Verify that the panel is open and add-on is enabled. + let addonTwo = await AddonManager.getAddonByID(extensionTwoId); + is(addonTwo.userDisabled, false, "The add-on is enabled at first"); + is( + panel.getAttribute("panelopen"), + "true", + "The notification panel is open after opening New Tab" + ); + is( + getNotificationSetting(extensionTwoId), + null, + "The New Tab notification is not set for this extension" + ); + + // Click the Manage button. + let popupHidden = promisePopupHidden(panel); + let preferencesShown = TestUtils.waitForCondition( + () => gBrowser.currentURI.spec == "about:preferences#home", + "Should open about:preferences." + ); + clickManage(notification); + await popupHidden; + await preferencesShown; + + // Disable the second addon then refresh the new tab expect to see a new addon dropdown. + let addonDisabled = waitForAddonDisabled(addonTwo); + addonTwo.disable(); + await addonDisabled; + + // Ensure the panel opens again for the next add-on. + popupShown = promisePopupShown(panel); + let newtabShown = TestUtils.waitForCondition( + () => gBrowser.currentURI.spec == AboutNewTab.newTabURL, + "Should open correct new tab url." + ); + let tab2 = await promiseNewTab(); + await newtabShown; + await popupShown; + + is( + getNotificationSetting(extensionTwoId), + null, + "The New Tab notification is not set after restoring the settings" + ); + let addonOne = await AddonManager.getAddonByID(extensionOneId); + is( + addonOne.userDisabled, + false, + "The extension is enabled before making a choice" + ); + is( + getNotificationSetting(extensionOneId), + null, + "The New Tab notification is not set before making a choice" + ); + is( + panel.getAttribute("panelopen"), + "true", + "The notification panel is open after opening New Tab" + ); + is( + gBrowser.currentURI.spec, + AboutNewTab.newTabURL, + "The user is now on the next extension's New Tab page" + ); + + preferencesShown = TestUtils.waitForCondition( + () => gBrowser.currentURI.spec == "about:preferences#home", + "Should open about:preferences." + ); + popupHidden = promisePopupHidden(panel); + clickManage(notification); + await popupHidden; + await preferencesShown; + // remove the extra preferences tab. + BrowserTestUtils.removeTab(tab2); + + addonDisabled = waitForAddonDisabled(addonOne); + addonOne.disable(); + await addonDisabled; + tab2 = await promiseNewTab(); + + Assert.notEqual( + panel.getAttribute("panelopen"), + "true", + "The notification panel is closed after restoring the second time" + ); + is( + getNotificationSetting(extensionOneId), + null, + "The New Tab notification is not set after restoring the settings" + ); + is( + gBrowser.currentURI.spec, + "about:newtab", + "The user is now on the original New Tab URL since all extensions are disabled" + ); + + // Reopen a browser tab and verify that there's no doorhanger. + BrowserTestUtils.removeTab(tab2); + tab2 = await promiseNewTab(); + + Assert.notEqual( + panel.getAttribute("panelopen"), + "true", + "The notification panel is not opened after keeping the changes" + ); + + // FIXME: We need to enable the add-on so it gets cleared from the + // ExtensionSettingsStore for now. See bug 1408226. + let addonsEnabled = Promise.all([ + waitForAddonEnabled(addonOne), + waitForAddonEnabled(addonTwo), + ]); + await addonOne.enable(); + await addonTwo.enable(); + await addonsEnabled; + BrowserTestUtils.removeTab(tab1); + BrowserTestUtils.removeTab(tab2); + + await extensionOne.unload(); + await extensionTwo.unload(); +}); + +/** + * Ensure we don't show the extension URL in the URL bar temporarily in new tabs + * while we're switching remoteness (when the URL we're loading and the + * default content principal are different). + */ +add_task(async function dontTemporarilyShowAboutExtensionPath() { + await ExtensionSettingsStore.initialize(); + let extension = ExtensionTestUtils.loadExtension(extensionData); + + await extension.startup(); + let extensionNewTabUrl = `moz-extension://${extension.uuid}/newtab.html`; + + let wpl = { + onLocationChange() { + is(gURLBar.value, "", "URL bar value should stay empty."); + }, + }; + gBrowser.addProgressListener(wpl); + + let tab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + url: extensionNewTabUrl, + }); + + gBrowser.removeProgressListener(wpl); + is(gURLBar.value, "", "URL bar value should be empty."); + await SpecialPowers.spawn(tab.linkedBrowser, [], function () { + is( + content.document.body.textContent, + "New tab!", + "New tab page is loaded." + ); + }); + + BrowserTestUtils.removeTab(tab); + await extension.unload(); +}); + +add_task(async function test_overriding_newtab_incognito_not_allowed() { + let panel = getNewTabDoorhanger().closest("panel"); + + let extension = ExtensionTestUtils.loadExtension({ + ...extensionData, + useAddonManager: "permanent", + }); + + await extension.startup(); + let extensionNewTabUrl = `moz-extension://${extension.uuid}/newtab.html`; + + let popupShown = promisePopupShown(panel); + let tab = await promiseNewTab(extensionNewTabUrl); + await popupShown; + + // This will show a confirmation doorhanger, make sure we don't leave it open. + let popupHidden = promisePopupHidden(panel); + panel.hidePopup(); + await popupHidden; + + BrowserTestUtils.removeTab(tab); + + // Verify a private window does not open the extension page. We would + // get an extra notification that we don't listen for if it gets loaded. + let windowOpenedPromise = BrowserTestUtils.waitForNewWindow(); + let win = OpenBrowserWindow({ private: true }); + await windowOpenedPromise; + + await promiseNewTab("about:privatebrowsing", win); + + is(win.gURLBar.value, "", "newtab not used in private window"); + + // Verify setting the pref directly doesn't bypass permissions. + let origUrl = AboutNewTab.newTabURL; + AboutNewTab.newTabURL = extensionNewTabUrl; + await promiseNewTab("about:privatebrowsing", win); + + is(win.gURLBar.value, "", "directly set newtab not used in private window"); + + AboutNewTab.newTabURL = origUrl; + + await extension.unload(); + await BrowserTestUtils.closeWindow(win); +}); + +add_task(async function test_overriding_newtab_incognito_spanning() { + let extension = ExtensionTestUtils.loadExtension({ + ...extensionData, + useAddonManager: "permanent", + incognitoOverride: "spanning", + }); + + await extension.startup(); + let extensionNewTabUrl = `moz-extension://${extension.uuid}/newtab.html`; + + let windowOpenedPromise = BrowserTestUtils.waitForNewWindow(); + let win = OpenBrowserWindow({ private: true }); + await windowOpenedPromise; + let panel = ExtensionControlledPopup._getAndMaybeCreatePanel(win.document); + let popupShown = promisePopupShown(panel); + await promiseNewTab(extensionNewTabUrl, win); + await popupShown; + + // This will show a confirmation doorhanger, make sure we don't leave it open. + let popupHidden = promisePopupHidden(panel); + panel.hidePopup(); + await popupHidden; + + await extension.unload(); + await BrowserTestUtils.closeWindow(win); +}); + +// Test that prefs set by the newtab override code are +// properly unset when all newtab extensions are gone. +add_task(async function testNewTabPrefsReset() { + function isUndefinedPref(pref) { + try { + Services.prefs.getBoolPref(pref); + return false; + } catch (e) { + return true; + } + } + + ok( + isUndefinedPref("browser.newtab.extensionControlled"), + "extensionControlled pref is not set" + ); + ok( + isUndefinedPref("browser.newtab.privateAllowed"), + "privateAllowed pref is not set" + ); +}); + +// This test ensures that an extension provided newtab +// can be opened by another extension (e.g. tab manager) +// regardless of whether the newtab url is made available +// in web_accessible_resources. +add_task(async function test_newtab_from_extension() { + let panel = getNewTabDoorhanger().closest("panel"); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { + id: "newtaburl@mochi.test", + }, + }, + chrome_url_overrides: { + newtab: "newtab.html", + }, + }, + files: { + "newtab.html": `<h1>New tab!</h1><script src="newtab.js"></script>`, + "newtab.js": () => { + browser.test.sendMessage("newtab-loaded"); + }, + }, + useAddonManager: "temporary", + }); + + await extension.startup(); + let extensionNewTabUrl = `moz-extension://${extension.uuid}/newtab.html`; + + let popupShown = promisePopupShown(panel); + let tab = await promiseNewTab(extensionNewTabUrl); + await popupShown; + + // This will show a confirmation doorhanger, make sure we don't leave it open. + let popupHidden = promisePopupHidden(panel); + panel.hidePopup(); + await popupHidden; + + BrowserTestUtils.removeTab(tab); + + // extension to open the newtab + let opener = ExtensionTestUtils.loadExtension({ + async background() { + let newtab = await browser.tabs.create({}); + browser.test.assertTrue( + newtab.id !== browser.tabs.TAB_ID_NONE, + "New tab was created." + ); + await browser.tabs.remove(newtab.id); + browser.test.sendMessage("complete"); + }, + }); + + function listener(msg) { + Assert.ok(!/may not load or link to moz-extension/.test(msg.message)); + } + Services.console.registerListener(listener); + registerCleanupFunction(() => { + Services.console.unregisterListener(listener); + }); + + await opener.startup(); + await opener.awaitMessage("complete"); + await extension.awaitMessage("newtab-loaded"); + await opener.unload(); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_user_events.js b/browser/components/extensions/test/browser/browser_ext_user_events.js new file mode 100644 index 0000000000..4852ffd124 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_user_events.js @@ -0,0 +1,271 @@ +"use strict"; + +// Test that different types of events are all considered +// "handling user input". +add_task(async function testSources() { + let extension = ExtensionTestUtils.loadExtension({ + async background() { + async function request(perm) { + try { + let result = await browser.permissions.request({ + permissions: [perm], + }); + browser.test.sendMessage("request", { success: true, result, perm }); + } catch (err) { + browser.test.sendMessage("request", { + success: false, + errmsg: err.message, + perm, + }); + } + } + + let tabs = await browser.tabs.query({ + active: true, + currentWindow: true, + }); + await browser.pageAction.show(tabs[0].id); + + browser.pageAction.onClicked.addListener(() => request("bookmarks")); + browser.browserAction.onClicked.addListener(() => request("tabs")); + browser.commands.onCommand.addListener(() => request("downloads")); + + browser.test.onMessage.addListener(msg => { + if (msg === "contextMenus.update") { + browser.contextMenus.onClicked.addListener(() => + request("webNavigation") + ); + browser.contextMenus.update( + "menu", + { + title: "test user events in onClicked", + onclick: null, + }, + () => browser.test.sendMessage("contextMenus.update-done") + ); + } + if (msg === "openOptionsPage") { + browser.runtime.openOptionsPage(); + } + }); + + browser.contextMenus.create( + { + id: "menu", + title: "test user events in onclick", + contexts: ["page"], + onclick() { + request("cookies"); + }, + }, + () => { + browser.test.sendMessage("actions-ready"); + } + ); + }, + + files: { + "options.html": `<!DOCTYPE html> + <html lang="en"> + <head> + <meta charset="UTF-8"> + <script src="options.js"></script> + <script src="https://example.com/tests/SimpleTest/EventUtils.js"></script> + </head> + <body> + <a id="link" href="#">Link</a> + </body> + </html>`, + + "options.js"() { + addEventListener("load", async () => { + let link = document.getElementById("link"); + link.onclick = async event => { + link.onclick = null; + event.preventDefault(); + + browser.test.log("Calling permission.request from options page."); + + let perm = "history"; + try { + let result = await browser.permissions.request({ + permissions: [perm], + }); + browser.test.sendMessage("request", { + success: true, + result, + perm, + }); + } catch (err) { + browser.test.sendMessage("request", { + success: false, + errmsg: err.message, + perm, + }); + } + }; + + // Make a few trips through the event loop to make sure the + // options browser is fully visible. This is a bit dodgy, but + // we don't really have a reliable way to detect this from the + // options page side, and synthetic click events won't work + // until it is. + do { + browser.test.log( + "Waiting for the options browser to be visible..." + ); + await new Promise(resolve => setTimeout(resolve, 0)); + synthesizeMouseAtCenter(link, {}); + } while (link.onclick !== null); + }); + }, + }, + + manifest: { + browser_action: { + default_title: "test", + default_area: "navbar", + }, + page_action: { default_title: "test" }, + permissions: ["contextMenus"], + optional_permissions: [ + "bookmarks", + "tabs", + "webNavigation", + "history", + "cookies", + "downloads", + ], + options_ui: { page: "options.html" }, + content_security_policy: + "script-src 'self' https://example.com; object-src 'none';", + commands: { + command: { + suggested_key: { + default: "Alt+Shift+J", + }, + }, + }, + }, + + useAddonManager: "temporary", + }); + + async function testPermissionRequest( + { requestPermission, expectPrompt, perm }, + what + ) { + info(`check request permission from '${what}'`); + + let promptPromise = null; + if (expectPrompt) { + promptPromise = promisePopupNotificationShown( + "addon-webext-permissions" + ).then(panel => { + panel.button.click(); + }); + } + + await requestPermission(); + await promptPromise; + + let result = await extension.awaitMessage("request"); + ok(result.success, `request() did not throw when called from ${what}`); + is(result.result, true, `request() succeeded when called from ${what}`); + is(result.perm, perm, `requested permission ${what}`); + await promptPromise; + } + + // Remove Sidebar button to prevent pushing extension button to overflow menu + CustomizableUI.removeWidgetFromArea("sidebar-button"); + + await extension.startup(); + await extension.awaitMessage("actions-ready"); + + await testPermissionRequest( + { + requestPermission: () => clickPageAction(extension), + expectPrompt: true, + perm: "bookmarks", + }, + "page action click" + ); + + await testPermissionRequest( + { + requestPermission: () => clickBrowserAction(extension), + expectPrompt: true, + perm: "tabs", + }, + "browser action click" + ); + + let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser); + gBrowser.selectedTab = tab; + + await testPermissionRequest( + { + requestPermission: async () => { + let menu = await openContextMenu("body"); + let items = menu.getElementsByAttribute( + "label", + "test user events in onclick" + ); + is(items.length, 1, "Found context menu item"); + menu.activateItem(items[0]); + }, + expectPrompt: false, // cookies permission has no prompt. + perm: "cookies", + }, + "context menu in onclick" + ); + + extension.sendMessage("contextMenus.update"); + await extension.awaitMessage("contextMenus.update-done"); + + await testPermissionRequest( + { + requestPermission: async () => { + let menu = await openContextMenu("body"); + let items = menu.getElementsByAttribute( + "label", + "test user events in onClicked" + ); + is(items.length, 1, "Found context menu item again"); + menu.activateItem(items[0]); + }, + expectPrompt: true, + perm: "webNavigation", + }, + "context menu in onClicked" + ); + + await testPermissionRequest( + { + requestPermission: () => { + EventUtils.synthesizeKey("j", { altKey: true, shiftKey: true }); + }, + expectPrompt: true, + perm: "downloads", + }, + "commands shortcut" + ); + + await testPermissionRequest( + { + requestPermission: () => { + extension.sendMessage("openOptionsPage"); + }, + expectPrompt: true, + perm: "history", + }, + "options page link click" + ); + + await BrowserTestUtils.removeTab(gBrowser.selectedTab); + await BrowserTestUtils.removeTab(tab); + + await extension.unload(); + + registerCleanupFunction(() => CustomizableUI.reset()); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_webNavigation_containerIsolation.js b/browser/components/extensions/test/browser/browser_ext_webNavigation_containerIsolation.js new file mode 100644 index 0000000000..3bf849b518 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_webNavigation_containerIsolation.js @@ -0,0 +1,169 @@ +"use strict"; + +add_task(async function containerIsolation_restricted() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["extensions.userContextIsolation.enabled", true], + ["privacy.userContext.enabled", true], + ], + }); + + let helperExtension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs", "cookies", "webNavigation"], + }, + + async background() { + browser.webNavigation.onCompleted.addListener(details => { + browser.test.sendMessage("tabCreated", details.tabId); + }); + browser.test.onMessage.addListener(async message => { + switch (message.subject) { + case "createTab": { + await browser.tabs.create({ + url: message.data.url, + cookieStoreId: message.data.cookieStoreId, + }); + break; + } + } + }); + }, + }); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webNavigation"], + }, + + async background() { + let eventNames = [ + "onBeforeNavigate", + "onCommitted", + "onDOMContentLoaded", + "onCompleted", + ]; + + const initialEmptyTabs = await browser.tabs.query({ active: true }); + browser.test.assertEq( + 1, + initialEmptyTabs.length, + `Got one initial empty tab as expected: ${JSON.stringify( + initialEmptyTabs + )}` + ); + + for (let eventName of eventNames) { + browser.webNavigation[eventName].addListener(details => { + if (details.tabId === initialEmptyTabs[0].id) { + // Ignore webNavigation related to the initial about:blank tab, it may be technically + // still being loading when we start this test extension to run the test scenario. + return; + } + browser.test.assertEq( + "http://www.example.com/?allowed", + details.url, + `expected ${eventName} event` + ); + browser.test.sendMessage(eventName, details.tabId); + }); + } + + const [restrictedTab, unrestrictedTab, noContainerTab] = + await new Promise(resolve => { + browser.test.onMessage.addListener(message => resolve(message)); + }); + + await browser.test.assertRejects( + browser.webNavigation.getFrame({ + tabId: restrictedTab, + frameId: 0, + }), + `Invalid tab ID: ${restrictedTab}`, + "getFrame rejected Promise should pass the expected error" + ); + + await browser.test.assertRejects( + browser.webNavigation.getAllFrames({ tabId: restrictedTab }), + `Invalid tab ID: ${restrictedTab}`, + "getAllFrames rejected Promise should pass the expected error" + ); + + await browser.tabs.remove(unrestrictedTab); + await browser.tabs.remove(noContainerTab); + + browser.test.sendMessage("done"); + }, + }); + + await extension.startup(); + + await SpecialPowers.pushPrefEnv({ + set: [ + [`extensions.userContextIsolation.${extension.id}.restricted`, "[1]"], + ], + }); + + await helperExtension.startup(); + + helperExtension.sendMessage({ + subject: "createTab", + data: { + url: "http://www.example.com/?restricted", + cookieStoreId: "firefox-container-1", + }, + }); + + const restrictedTab = await helperExtension.awaitMessage("tabCreated"); + + helperExtension.sendMessage({ + subject: "createTab", + data: { + url: "http://www.example.com/?allowed", + cookieStoreId: "firefox-container-2", + }, + }); + + const unrestrictedTab = await helperExtension.awaitMessage("tabCreated"); + + helperExtension.sendMessage({ + subject: "createTab", + data: { + url: "http://www.example.com/?allowed", + }, + }); + + const noContainerTab = await helperExtension.awaitMessage("tabCreated"); + + let eventNames = [ + "onBeforeNavigate", + "onCommitted", + "onDOMContentLoaded", + "onCompleted", + ]; + for (let eventName of eventNames) { + let recTabId1 = await extension.awaitMessage(eventName); + let recTabId2 = await extension.awaitMessage(eventName); + + Assert.equal( + recTabId1, + unrestrictedTab, + `Expected unrestricted tab with tabId: ${unrestrictedTab} from ${eventName} event` + ); + + Assert.equal( + recTabId2, + noContainerTab, + `Expected noContainer tab with tabId: ${noContainerTab} from ${eventName} event` + ); + } + + extension.sendMessage([restrictedTab, unrestrictedTab, noContainerTab]); + + await extension.awaitMessage("done"); + + await extension.unload(); + await helperExtension.unload(); + await SpecialPowers.popPrefEnv(); + BrowserTestUtils.removeTab(gBrowser.selectedTab); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_webNavigation_frameId0.js b/browser/components/extensions/test/browser/browser_ext_webNavigation_frameId0.js new file mode 100644 index 0000000000..c5b2c72778 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_webNavigation_frameId0.js @@ -0,0 +1,43 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function webNavigation_getFrameId_of_existing_main_frame() { + const BASE = + "http://mochi.test:8888/browser/browser/components/extensions/test/browser/"; + const DUMMY_URL = BASE + "file_dummy.html"; + let tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + DUMMY_URL, + true + ); + + async function background(DUMMY_URL) { + let tabs = await browser.tabs.query({ active: true, currentWindow: true }); + let frames = await browser.webNavigation.getAllFrames({ + tabId: tabs[0].id, + }); + browser.test.assertEq(1, frames.length, "The dummy page has one frame"); + browser.test.assertEq(0, frames[0].frameId, "Main frame's ID must be 0"); + browser.test.assertEq( + DUMMY_URL, + frames[0].url, + "Main frame URL must match" + ); + browser.test.notifyPass("frameId checked"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webNavigation"], + }, + + background: `(${background})(${JSON.stringify(DUMMY_URL)});`, + }); + + await extension.startup(); + await extension.awaitFinish("frameId checked"); + await extension.unload(); + + BrowserTestUtils.removeTab(tab); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_webNavigation_getFrames.js b/browser/components/extensions/test/browser/browser_ext_webNavigation_getFrames.js new file mode 100644 index 0000000000..b60940bae7 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_webNavigation_getFrames.js @@ -0,0 +1,323 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function testWebNavigationGetNonExistentTab() { + let extension = ExtensionTestUtils.loadExtension({ + background: async function () { + // There is no "tabId = 0" because the id assigned by tabTracker (defined in ext-browser.js) + // starts from 1. + await browser.test.assertRejects( + browser.webNavigation.getAllFrames({ tabId: 0 }), + "Invalid tab ID: 0", + "getAllFrames rejected Promise should pass the expected error" + ); + + // There is no "tabId = 0" because the id assigned by tabTracker (defined in ext-browser.js) + // starts from 1, processId is currently marked as optional and it is ignored. + await browser.test.assertRejects( + browser.webNavigation.getFrame({ + tabId: 0, + frameId: 15, + processId: 20, + }), + "Invalid tab ID: 0", + "getFrame rejected Promise should pass the expected error" + ); + + browser.test.sendMessage("getNonExistentTab.done"); + }, + manifest: { + permissions: ["webNavigation"], + }, + }); + + await extension.startup(); + + await extension.awaitMessage("getNonExistentTab.done"); + + await extension.unload(); +}); + +add_task(async function testWebNavigationFrames() { + let extension = ExtensionTestUtils.loadExtension({ + background: async function () { + let tabId; + let collectedDetails = []; + + browser.webNavigation.onCompleted.addListener(async details => { + collectedDetails.push(details); + + if (details.frameId !== 0) { + // wait for the top level iframe to be complete + return; + } + + let getAllFramesDetails = await browser.webNavigation.getAllFrames({ + tabId, + }); + + let getFramePromises = getAllFramesDetails.map(({ frameId }) => { + // processId is currently marked as optional and it is ignored. + return browser.webNavigation.getFrame({ + tabId, + frameId, + processId: 0, + }); + }); + + let getFrameResults = await Promise.all(getFramePromises); + browser.test.sendMessage("webNavigationFrames.done", { + collectedDetails, + getAllFramesDetails, + getFrameResults, + }); + + // Pick a random frameId. + let nonExistentFrameId = Math.floor(Math.random() * 10000); + + // Increment the picked random nonExistentFrameId until it doesn't exists. + while ( + getAllFramesDetails.filter( + details => details.frameId == nonExistentFrameId + ).length + ) { + nonExistentFrameId += 1; + } + + // Check that getFrame Promise is rejected with the expected error message on nonexistent frameId. + await browser.test.assertRejects( + browser.webNavigation.getFrame({ + tabId, + frameId: nonExistentFrameId, + processId: 20, + }), + `No frame found with frameId: ${nonExistentFrameId}`, + "getFrame promise should be rejected with the expected error message on unexistent frameId" + ); + + await browser.tabs.remove(tabId); + browser.test.sendMessage("webNavigationFrames.done"); + }); + + let tab = await browser.tabs.create({ url: "tab.html" }); + tabId = tab.id; + }, + manifest: { + permissions: ["webNavigation", "tabs"], + }, + files: { + "tab.html": ` + <!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + </head> + <body> + <iframe src="subframe.html"></iframe> + <iframe src="subframe.html"></iframe> + </body> + </html> + `, + "subframe.html": ` + <!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + </head> + </html> + `, + }, + }); + + await extension.startup(); + + let { collectedDetails, getAllFramesDetails, getFrameResults } = + await extension.awaitMessage("webNavigationFrames.done"); + + is(getAllFramesDetails.length, 3, "expected number of frames found"); + is( + getAllFramesDetails.length, + collectedDetails.length, + "number of frames found should equal the number onCompleted events collected" + ); + + is( + getAllFramesDetails[0].frameId, + 0, + "the root frame has the expected frameId" + ); + is( + getAllFramesDetails[0].parentFrameId, + -1, + "the root frame has the expected parentFrameId" + ); + + // ordered by frameId + let sortByFrameId = (el1, el2) => { + let val1 = el1 ? el1.frameId : -1; + let val2 = el2 ? el2.frameId : -1; + return val1 - val2; + }; + + collectedDetails = collectedDetails.sort(sortByFrameId); + getAllFramesDetails = getAllFramesDetails.sort(sortByFrameId); + getFrameResults = getFrameResults.sort(sortByFrameId); + + info("check frame details content"); + + is( + getFrameResults.length, + getAllFramesDetails.length, + "getFrame and getAllFrames should return the same number of results" + ); + + Assert.deepEqual( + getFrameResults, + getAllFramesDetails, + "getFrame and getAllFrames should return the same results" + ); + + info(`check frame details collected and retrieved with getAllFrames`); + + for (let [i, collected] of collectedDetails.entries()) { + let getAllFramesDetail = getAllFramesDetails[i]; + + is(getAllFramesDetail.frameId, collected.frameId, "frameId"); + is( + getAllFramesDetail.parentFrameId, + collected.parentFrameId, + "parentFrameId" + ); + is(getAllFramesDetail.tabId, collected.tabId, "tabId"); + + // This can be uncommented once Bug 1246125 has been fixed + // is(getAllFramesDetail.url, collected.url, "url"); + } + + info("frame details content checked"); + + await extension.awaitMessage("webNavigationFrames.done"); + + await extension.unload(); +}); + +add_task(async function testWebNavigationGetFrameOnDiscardedTab() { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs", "webNavigation"], + }, + async background() { + let tabs = await browser.tabs.query({ currentWindow: true }); + browser.test.assertEq(2, tabs.length, "Expect 2 tabs open"); + + const tabId = tabs[1].id; + + await browser.tabs.discard(tabId); + let tab = await browser.tabs.get(tabId); + browser.test.assertEq(true, tab.discarded, "Expect a discarded tab"); + + const allFrames = await browser.webNavigation.getAllFrames({ tabId }); + browser.test.assertEq( + null, + allFrames, + "Expect null from calling getAllFrames on discarded tab" + ); + + tab = await browser.tabs.get(tabId); + browser.test.assertEq( + true, + tab.discarded, + "Expect tab to stay discarded" + ); + + const topFrame = await browser.webNavigation.getFrame({ + tabId, + frameId: 0, + }); + browser.test.assertEq( + null, + topFrame, + "Expect null from calling getFrame on discarded tab" + ); + + tab = await browser.tabs.get(tabId); + browser.test.assertEq( + true, + tab.discarded, + "Expect tab to stay discarded" + ); + + browser.test.sendMessage("get-frames-done"); + }, + }); + + const initialTab = gBrowser.selectedTab; + const tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + "http://mochi.test:8888/?toBeDiscarded=true" + ); + // Switch back to the initial tab to allow the new tab + // to be discarded. + await BrowserTestUtils.switchTab(gBrowser, initialTab); + + ok(!!tab.linkedPanel, "Tab not initially discarded"); + + await extension.startup(); + await extension.awaitMessage("get-frames-done"); + + ok(!tab.linkedPanel, "Tab should be discarded"); + + BrowserTestUtils.removeTab(tab); + + await extension.unload(); +}); + +add_task(async function testWebNavigationCrossOriginFrames() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["webNavigation"], + }, + async background() { + let url = + "http://mochi.test:8888/tests/toolkit/components/extensions/test/mochitest/file_contains_iframe.html"; + let tab = await browser.tabs.create({ url }); + + await new Promise(resolve => { + browser.webNavigation.onCompleted.addListener(details => { + if (details.tabId === tab.id && details.frameId === 0) { + resolve(); + } + }); + }); + + let frames = await browser.webNavigation.getAllFrames({ tabId: tab.id }); + browser.test.assertEq(frames[0].url, url, "Top is from mochi.test"); + + await browser.tabs.remove(tab.id); + browser.test.sendMessage("webNavigation.CrossOriginFrames", frames); + }, + }); + + await extension.startup(); + + let frames = await extension.awaitMessage("webNavigation.CrossOriginFrames"); + is(frames.length, 2, "getAllFrames() returns both frames."); + + is(frames[0].frameId, 0, "Top frame has correct frameId."); + is(frames[0].parentFrameId, -1, "Top parentFrameId is correct."); + + Assert.greater( + frames[1].frameId, + 0, + "Cross-origin iframe has non-zero frameId." + ); + is(frames[1].parentFrameId, 0, "Iframe parentFrameId is correct."); + is( + frames[1].url, + "http://example.org/tests/toolkit/components/extensions/test/mochitest/file_contains_img.html", + "Irame is from example.org" + ); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_webNavigation_onCreatedNavigationTarget.js b/browser/components/extensions/test/browser/browser_ext_webNavigation_onCreatedNavigationTarget.js new file mode 100644 index 0000000000..efe847c2b4 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_webNavigation_onCreatedNavigationTarget.js @@ -0,0 +1,194 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +loadTestSubscript("head_webNavigation.js"); + +SpecialPowers.pushPrefEnv({ + set: [["security.allow_eval_with_system_principal", true]], +}); + +async function background() { + const tabs = await browser.tabs.query({ active: true, currentWindow: true }); + const sourceTabId = tabs[0].id; + + const sourceTabFrames = await browser.webNavigation.getAllFrames({ + tabId: sourceTabId, + }); + + browser.webNavigation.onCreatedNavigationTarget.addListener(msg => { + browser.test.sendMessage("webNavOnCreated", msg); + }); + + browser.webNavigation.onCompleted.addListener(async msg => { + // NOTE: checking the url is currently necessary because of Bug 1252129 + // ( Filter out webNavigation events related to new window initialization phase). + if (msg.tabId !== sourceTabId && msg.url !== "about:blank") { + await browser.tabs.remove(msg.tabId); + browser.test.sendMessage("webNavOnCompleted", msg); + } + }); + + browser.tabs.onCreated.addListener(tab => { + browser.test.sendMessage("tabsOnCreated", tab.id); + }); + + browser.test.sendMessage("expectedSourceTab", { + sourceTabId, + sourceTabFrames, + }); +} + +add_task(async function test_on_created_navigation_target_from_mouse_click() { + const tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + SOURCE_PAGE + ); + + const extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["webNavigation"], + }, + }); + + await extension.startup(); + + const expectedSourceTab = await extension.awaitMessage("expectedSourceTab"); + + info("Open link in a new tab using Ctrl-click"); + + await runCreatedNavigationTargetTest({ + extension, + openNavTarget() { + BrowserTestUtils.synthesizeMouseAtCenter( + "#test-create-new-tab-from-mouse-click", + { ctrlKey: true, metaKey: true }, + tab.linkedBrowser + ); + }, + expectedWebNavProps: { + sourceTabId: expectedSourceTab.sourceTabId, + sourceFrameId: 0, + url: `${OPENED_PAGE}#new-tab-from-mouse-click`, + }, + }); + + info("Open link in a new window using Shift-click"); + + await runCreatedNavigationTargetTest({ + extension, + openNavTarget() { + BrowserTestUtils.synthesizeMouseAtCenter( + "#test-create-new-window-from-mouse-click", + { shiftKey: true }, + tab.linkedBrowser + ); + }, + expectedWebNavProps: { + sourceTabId: expectedSourceTab.sourceTabId, + sourceFrameId: 0, + url: `${OPENED_PAGE}#new-window-from-mouse-click`, + }, + }); + + info('Open link with target="_blank" in a new tab using click'); + + await runCreatedNavigationTargetTest({ + extension, + openNavTarget() { + BrowserTestUtils.synthesizeMouseAtCenter( + "#test-create-new-tab-from-targetblank-click", + {}, + tab.linkedBrowser + ); + }, + expectedWebNavProps: { + sourceTabId: expectedSourceTab.sourceTabId, + sourceFrameId: 0, + url: `${OPENED_PAGE}#new-tab-from-targetblank-click`, + }, + }); + + BrowserTestUtils.removeTab(tab); + + await extension.unload(); +}); + +add_task( + async function test_on_created_navigation_target_from_mouse_click_subframe() { + const tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + SOURCE_PAGE + ); + + const extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["webNavigation"], + }, + }); + + await extension.startup(); + + const expectedSourceTab = await extension.awaitMessage("expectedSourceTab"); + + info("Open a subframe link in a new tab using Ctrl-click"); + + await runCreatedNavigationTargetTest({ + extension, + openNavTarget() { + BrowserTestUtils.synthesizeMouseAtCenter( + "#test-create-new-tab-from-mouse-click-subframe", + { ctrlKey: true, metaKey: true }, + tab.linkedBrowser.browsingContext.children[0] + ); + }, + expectedWebNavProps: { + sourceTabId: expectedSourceTab.sourceTabId, + sourceFrameId: expectedSourceTab.sourceTabFrames[1].frameId, + url: `${OPENED_PAGE}#new-tab-from-mouse-click-subframe`, + }, + }); + + info("Open a subframe link in a new window using Shift-click"); + + await runCreatedNavigationTargetTest({ + extension, + openNavTarget() { + BrowserTestUtils.synthesizeMouseAtCenter( + "#test-create-new-window-from-mouse-click-subframe", + { shiftKey: true }, + tab.linkedBrowser.browsingContext.children[0] + ); + }, + expectedWebNavProps: { + sourceTabId: expectedSourceTab.sourceTabId, + sourceFrameId: expectedSourceTab.sourceTabFrames[1].frameId, + url: `${OPENED_PAGE}#new-window-from-mouse-click-subframe`, + }, + }); + + info('Open a subframe link with target="_blank" in a new tab using click'); + + await runCreatedNavigationTargetTest({ + extension, + openNavTarget() { + BrowserTestUtils.synthesizeMouseAtCenter( + "#test-create-new-tab-from-targetblank-click-subframe", + {}, + tab.linkedBrowser.browsingContext.children[0] + ); + }, + expectedWebNavProps: { + sourceTabId: expectedSourceTab.sourceTabId, + sourceFrameId: expectedSourceTab.sourceTabFrames[1].frameId, + url: `${OPENED_PAGE}#new-tab-from-targetblank-click-subframe`, + }, + }); + + BrowserTestUtils.removeTab(tab); + + await extension.unload(); + } +); diff --git a/browser/components/extensions/test/browser/browser_ext_webNavigation_onCreatedNavigationTarget_contextmenu.js b/browser/components/extensions/test/browser/browser_ext_webNavigation_onCreatedNavigationTarget_contextmenu.js new file mode 100644 index 0000000000..8fd94af4f1 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_webNavigation_onCreatedNavigationTarget_contextmenu.js @@ -0,0 +1,182 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +loadTestSubscript("head_webNavigation.js"); + +SpecialPowers.pushPrefEnv({ + set: [["security.allow_eval_with_system_principal", true]], +}); + +async function clickContextMenuItem({ + pageElementSelector, + contextMenuItemLabel, + frameIndex, +}) { + let contentAreaContextMenu; + if (frameIndex == null) { + contentAreaContextMenu = await openContextMenu(pageElementSelector); + } else { + contentAreaContextMenu = await openContextMenuInFrame( + pageElementSelector, + frameIndex + ); + } + const item = contentAreaContextMenu.getElementsByAttribute( + "label", + contextMenuItemLabel + ); + is(item.length, 1, `found contextMenu item for "${contextMenuItemLabel}"`); + const closed = promiseContextMenuClosed(contentAreaContextMenu); + contentAreaContextMenu.activateItem(item[0]); + await closed; +} + +async function background() { + const tabs = await browser.tabs.query({ active: true, currentWindow: true }); + const sourceTabId = tabs[0].id; + + const sourceTabFrames = await browser.webNavigation.getAllFrames({ + tabId: sourceTabId, + }); + + browser.webNavigation.onCreatedNavigationTarget.addListener(msg => { + browser.test.sendMessage("webNavOnCreated", msg); + }); + + browser.webNavigation.onCompleted.addListener(async msg => { + // NOTE: checking the url is currently necessary because of Bug 1252129 + // ( Filter out webNavigation events related to new window initialization phase). + if (msg.tabId !== sourceTabId && msg.url !== "about:blank") { + await browser.tabs.remove(msg.tabId); + browser.test.sendMessage("webNavOnCompleted", msg); + } + }); + + browser.tabs.onCreated.addListener(tab => { + browser.test.sendMessage("tabsOnCreated", tab.id); + }); + + browser.test.sendMessage("expectedSourceTab", { + sourceTabId, + sourceTabFrames, + }); +} + +add_task(async function test_on_created_navigation_target_from_context_menu() { + const tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + SOURCE_PAGE + ); + + const extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["webNavigation"], + }, + }); + + await extension.startup(); + + const expectedSourceTab = await extension.awaitMessage("expectedSourceTab"); + + info("Open link in a new tab from the context menu"); + + await runCreatedNavigationTargetTest({ + extension, + async openNavTarget() { + await clickContextMenuItem({ + pageElementSelector: "#test-create-new-tab-from-context-menu", + contextMenuItemLabel: "Open Link in New Tab", + }); + }, + expectedWebNavProps: { + sourceTabId: expectedSourceTab.sourceTabId, + sourceFrameId: 0, + url: `${OPENED_PAGE}#new-tab-from-context-menu`, + }, + }); + + info("Open link in a new window from the context menu"); + + await runCreatedNavigationTargetTest({ + extension, + async openNavTarget() { + await clickContextMenuItem({ + pageElementSelector: "#test-create-new-window-from-context-menu", + contextMenuItemLabel: "Open Link in New Window", + }); + }, + expectedWebNavProps: { + sourceTabId: expectedSourceTab.sourceTabId, + sourceFrameId: 0, + url: `${OPENED_PAGE}#new-window-from-context-menu`, + }, + }); + + BrowserTestUtils.removeTab(tab); + + await extension.unload(); +}); + +add_task( + async function test_on_created_navigation_target_from_context_menu_subframe() { + const tab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + SOURCE_PAGE + ); + + const extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["webNavigation"], + }, + }); + + await extension.startup(); + + const expectedSourceTab = await extension.awaitMessage("expectedSourceTab"); + + info("Open a subframe link in a new tab from the context menu"); + + await runCreatedNavigationTargetTest({ + extension, + async openNavTarget() { + await clickContextMenuItem({ + pageElementSelector: + "#test-create-new-tab-from-context-menu-subframe", + contextMenuItemLabel: "Open Link in New Tab", + frameIndex: 0, + }); + }, + expectedWebNavProps: { + sourceTabId: expectedSourceTab.sourceTabId, + sourceFrameId: expectedSourceTab.sourceTabFrames[1].frameId, + url: `${OPENED_PAGE}#new-tab-from-context-menu-subframe`, + }, + }); + + info("Open a subframe link in a new window from the context menu"); + + await runCreatedNavigationTargetTest({ + extension, + async openNavTarget() { + await clickContextMenuItem({ + pageElementSelector: + "#test-create-new-window-from-context-menu-subframe", + contextMenuItemLabel: "Open Link in New Window", + frameIndex: 0, + }); + }, + expectedWebNavProps: { + sourceTabId: expectedSourceTab.sourceTabId, + sourceFrameId: expectedSourceTab.sourceTabFrames[1].frameId, + url: `${OPENED_PAGE}#new-window-from-context-menu-subframe`, + }, + }); + + BrowserTestUtils.removeTab(tab); + + await extension.unload(); + } +); diff --git a/browser/components/extensions/test/browser/browser_ext_webNavigation_onCreatedNavigationTarget_named_window.js b/browser/components/extensions/test/browser/browser_ext_webNavigation_onCreatedNavigationTarget_named_window.js new file mode 100644 index 0000000000..3a0a950319 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_webNavigation_onCreatedNavigationTarget_named_window.js @@ -0,0 +1,100 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +loadTestSubscript("head_webNavigation.js"); + +async function background() { + const tabs = await browser.tabs.query({ active: true, currentWindow: true }); + const sourceTabId = tabs[0].id; + + const sourceTabFrames = await browser.webNavigation.getAllFrames({ + tabId: sourceTabId, + }); + + browser.webNavigation.onCreatedNavigationTarget.addListener(msg => { + browser.test.sendMessage("webNavOnCreated", msg); + }); + + browser.webNavigation.onCompleted.addListener(async msg => { + // NOTE: checking the url is currently necessary because of Bug 1252129 + // ( Filter out webNavigation events related to new window initialization phase). + if (msg.tabId !== sourceTabId && msg.url !== "about:blank") { + await browser.tabs.remove(msg.tabId); + browser.test.sendMessage("webNavOnCompleted", msg); + } + }); + + browser.tabs.onCreated.addListener(tab => { + browser.test.sendMessage("tabsOnCreated", tab.id); + }); + + browser.test.onMessage.addListener(({ type, code }) => { + if (type === "execute-contentscript") { + browser.tabs.executeScript(sourceTabId, { code: code }); + } + }); + + browser.test.sendMessage("expectedSourceTab", { + sourceTabId, + sourceTabFrames, + }); +} + +add_task(async function test_window_open_in_named_win() { + const tab1 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + SOURCE_PAGE + ); + + gBrowser.selectedTab = tab1; + + const extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["webNavigation", "tabs", "<all_urls>"], + }, + }); + + await extension.startup(); + + const expectedSourceTab = await extension.awaitMessage("expectedSourceTab"); + + info("open a url in a new named window from a window.open call"); + + await runCreatedNavigationTargetTest({ + extension, + openNavTarget() { + extension.sendMessage({ + type: "execute-contentscript", + code: `window.open("${OPENED_PAGE}#new-named-window-open", "TestWinName"); true;`, + }); + }, + expectedWebNavProps: { + sourceTabId: expectedSourceTab.sourceTabId, + sourceFrameId: 0, + url: `${OPENED_PAGE}#new-named-window-open`, + }, + }); + + info("open a url in an existent named window from a window.open call"); + + await runCreatedNavigationTargetTest({ + extension, + openNavTarget() { + extension.sendMessage({ + type: "execute-contentscript", + code: `window.open("${OPENED_PAGE}#existent-named-window-open", "TestWinName"); true;`, + }); + }, + expectedWebNavProps: { + sourceTabId: expectedSourceTab.sourceTabId, + sourceFrameId: 0, + url: `${OPENED_PAGE}#existent-named-window-open`, + }, + }); + + BrowserTestUtils.removeTab(tab1); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_webNavigation_onCreatedNavigationTarget_subframe_window_open.js b/browser/components/extensions/test/browser/browser_ext_webNavigation_onCreatedNavigationTarget_subframe_window_open.js new file mode 100644 index 0000000000..15439460a5 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_webNavigation_onCreatedNavigationTarget_subframe_window_open.js @@ -0,0 +1,168 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +loadTestSubscript("head_webNavigation.js"); + +async function background() { + const tabs = await browser.tabs.query({ active: true, currentWindow: true }); + const sourceTabId = tabs[0].id; + + const sourceTabFrames = await browser.webNavigation.getAllFrames({ + tabId: sourceTabId, + }); + + browser.webNavigation.onCreatedNavigationTarget.addListener(msg => { + browser.test.sendMessage("webNavOnCreated", msg); + }); + + browser.webNavigation.onCompleted.addListener(async msg => { + // NOTE: checking the url is currently necessary because of Bug 1252129 + // ( Filter out webNavigation events related to new window initialization phase). + if (msg.tabId !== sourceTabId && msg.url !== "about:blank") { + await browser.tabs.remove(msg.tabId); + browser.test.sendMessage("webNavOnCompleted", msg); + } + }); + + browser.tabs.onCreated.addListener(tab => { + browser.test.sendMessage("tabsOnCreated", tab.id); + }); + + browser.test.onMessage.addListener(({ type, code }) => { + if (type === "execute-contentscript") { + browser.tabs.executeScript(sourceTabId, { code: code }); + } + }); + + browser.test.sendMessage("expectedSourceTab", { + sourceTabId, + sourceTabFrames, + }); +} + +add_task(async function test_window_open_from_subframe() { + const tab1 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + SOURCE_PAGE + ); + + gBrowser.selectedTab = tab1; + + const extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["webNavigation", "tabs", "<all_urls>"], + }, + }); + + await extension.startup(); + + const expectedSourceTab = await extension.awaitMessage("expectedSourceTab"); + + info("open a url in a new tab from subframe window.open call"); + + await runCreatedNavigationTargetTest({ + extension, + openNavTarget() { + extension.sendMessage({ + type: "execute-contentscript", + code: `document.querySelector('iframe').contentWindow.open("${OPENED_PAGE}#new-tab-from-window-open-subframe"); true;`, + }); + }, + expectedWebNavProps: { + sourceTabId: expectedSourceTab.sourceTabId, + sourceFrameId: expectedSourceTab.sourceTabFrames[1].frameId, + url: `${OPENED_PAGE}#new-tab-from-window-open-subframe`, + }, + }); + + info("open a url in a new window from subframe window.open call"); + + await runCreatedNavigationTargetTest({ + extension, + openNavTarget() { + extension.sendMessage({ + type: "execute-contentscript", + code: `document.querySelector('iframe').contentWindow.open("${OPENED_PAGE}#new-win-from-window-open-subframe", "_blank", "toolbar=0"); true;`, + }); + }, + expectedWebNavProps: { + sourceTabId: expectedSourceTab.sourceTabId, + sourceFrameId: expectedSourceTab.sourceTabFrames[1].frameId, + url: `${OPENED_PAGE}#new-win-from-window-open-subframe`, + }, + }); + + BrowserTestUtils.removeTab(tab1); + + await extension.unload(); +}); + +add_task(async function test_window_open_close_from_browserAction_popup() { + const tab1 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + SOURCE_PAGE + ); + + gBrowser.selectedTab = tab1; + + function popup() { + window.open("", "_self").close(); + + browser.test.sendMessage("browserAction_popup_executed"); + } + + const extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + browser_action: { + default_popup: "popup.html", + default_area: "navbar", + }, + permissions: ["webNavigation", "tabs", "<all_urls>"], + }, + files: { + "popup.html": `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + </head> + <body> + <script src="popup.js"></script> + </body> + </html> + `, + "popup.js": popup, + }, + }); + + await extension.startup(); + + const expectedSourceTab = await extension.awaitMessage("expectedSourceTab"); + + clickBrowserAction(extension); + + await extension.awaitMessage("browserAction_popup_executed"); + + info("open a url in a new tab from a window.open call"); + + await runCreatedNavigationTargetTest({ + extension, + openNavTarget() { + extension.sendMessage({ + type: "execute-contentscript", + code: `window.open("${OPENED_PAGE}#new-tab-from-window-open"); true;`, + }); + }, + expectedWebNavProps: { + sourceTabId: expectedSourceTab.sourceTabId, + sourceFrameId: 0, + url: `${OPENED_PAGE}#new-tab-from-window-open`, + }, + }); + + BrowserTestUtils.removeTab(tab1); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_webNavigation_onCreatedNavigationTarget_window_open.js b/browser/components/extensions/test/browser/browser_ext_webNavigation_onCreatedNavigationTarget_window_open.js new file mode 100644 index 0000000000..8a1c5ee82d --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_webNavigation_onCreatedNavigationTarget_window_open.js @@ -0,0 +1,168 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +loadTestSubscript("head_webNavigation.js"); + +async function background() { + const tabs = await browser.tabs.query({ active: true, currentWindow: true }); + const sourceTabId = tabs[0].id; + + const sourceTabFrames = await browser.webNavigation.getAllFrames({ + tabId: sourceTabId, + }); + + browser.webNavigation.onCreatedNavigationTarget.addListener(msg => { + browser.test.sendMessage("webNavOnCreated", msg); + }); + + browser.webNavigation.onCompleted.addListener(async msg => { + // NOTE: checking the url is currently necessary because of Bug 1252129 + // ( Filter out webNavigation events related to new window initialization phase). + if (msg.tabId !== sourceTabId && msg.url !== "about:blank") { + await browser.tabs.remove(msg.tabId); + browser.test.sendMessage("webNavOnCompleted", msg); + } + }); + + browser.tabs.onCreated.addListener(tab => { + browser.test.sendMessage("tabsOnCreated", tab.id); + }); + + browser.test.onMessage.addListener(({ type, code }) => { + if (type === "execute-contentscript") { + browser.tabs.executeScript(sourceTabId, { code: code }); + } + }); + + browser.test.sendMessage("expectedSourceTab", { + sourceTabId, + sourceTabFrames, + }); +} + +add_task(async function test_window_open() { + const tab1 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + SOURCE_PAGE + ); + + gBrowser.selectedTab = tab1; + + const extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["webNavigation", "tabs", "<all_urls>"], + }, + }); + + await extension.startup(); + + const expectedSourceTab = await extension.awaitMessage("expectedSourceTab"); + + info("open a url in a new tab from a window.open call"); + + await runCreatedNavigationTargetTest({ + extension, + openNavTarget() { + extension.sendMessage({ + type: "execute-contentscript", + code: `window.open("${OPENED_PAGE}#new-tab-from-window-open"); true;`, + }); + }, + expectedWebNavProps: { + sourceTabId: expectedSourceTab.sourceTabId, + sourceFrameId: 0, + url: `${OPENED_PAGE}#new-tab-from-window-open`, + }, + }); + + info("open a url in a new window from a window.open call"); + + await runCreatedNavigationTargetTest({ + extension, + openNavTarget() { + extension.sendMessage({ + type: "execute-contentscript", + code: `window.open("${OPENED_PAGE}#new-win-from-window-open", "_blank", "toolbar=0"); true;`, + }); + }, + expectedWebNavProps: { + sourceTabId: expectedSourceTab.sourceTabId, + sourceFrameId: 0, + url: `${OPENED_PAGE}#new-win-from-window-open`, + }, + }); + + BrowserTestUtils.removeTab(tab1); + + await extension.unload(); +}); + +add_task(async function test_window_open_close_from_browserAction_popup() { + const tab1 = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + SOURCE_PAGE + ); + + gBrowser.selectedTab = tab1; + + function popup() { + window.open("", "_self").close(); + + browser.test.sendMessage("browserAction_popup_executed"); + } + + const extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + browser_action: { + default_popup: "popup.html", + default_area: "navbar", + }, + permissions: ["webNavigation", "tabs", "<all_urls>"], + }, + files: { + "popup.html": `<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + </head> + <body> + <script src="popup.js"></script> + </body> + </html> + `, + "popup.js": popup, + }, + }); + + await extension.startup(); + + const expectedSourceTab = await extension.awaitMessage("expectedSourceTab"); + + clickBrowserAction(extension); + + await extension.awaitMessage("browserAction_popup_executed"); + + info("open a url in a new tab from a window.open call"); + + await runCreatedNavigationTargetTest({ + extension, + openNavTarget() { + extension.sendMessage({ + type: "execute-contentscript", + code: `window.open("${OPENED_PAGE}#new-tab-from-window-open"); true;`, + }); + }, + expectedWebNavProps: { + sourceTabId: expectedSourceTab.sourceTabId, + sourceFrameId: 0, + url: `${OPENED_PAGE}#new-tab-from-window-open`, + }, + }); + + BrowserTestUtils.removeTab(tab1); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_webNavigation_urlbar_transitions.js b/browser/components/extensions/test/browser/browser_ext_webNavigation_urlbar_transitions.js new file mode 100644 index 0000000000..07c6e70a93 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_webNavigation_urlbar_transitions.js @@ -0,0 +1,314 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", + SearchTestUtils: "resource://testing-common/SearchTestUtils.sys.mjs", + UrlbarTestUtils: "resource://testing-common/UrlbarTestUtils.sys.mjs", +}); + +SearchTestUtils.init(this); + +const SUGGEST_URLBAR_PREF = "browser.urlbar.suggest.searches"; +const TEST_ENGINE_BASENAME = "searchSuggestionEngine.xml"; + +function promiseAutocompleteResultPopup(value) { + return UrlbarTestUtils.promiseAutocompleteResultPopup({ + window, + waitForFocus, + value, + }); +} + +async function addBookmark(bookmark) { + if (bookmark.keyword) { + await PlacesUtils.keywords.insert({ + keyword: bookmark.keyword, + url: bookmark.url, + }); + } + + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: bookmark.url, + title: bookmark.title, + }); + + registerCleanupFunction(async function () { + await PlacesUtils.bookmarks.eraseEverything(); + }); +} + +async function prepareSearchEngine() { + let suggestionsEnabled = Services.prefs.getBoolPref(SUGGEST_URLBAR_PREF); + Services.prefs.setBoolPref(SUGGEST_URLBAR_PREF, true); + await SearchTestUtils.promiseNewSearchEngine({ + url: getRootDirectory(gTestPath) + TEST_ENGINE_BASENAME, + setAsDefault: true, + }); + + registerCleanupFunction(async function () { + Services.prefs.setBoolPref(SUGGEST_URLBAR_PREF, suggestionsEnabled); + + // Make sure the popup is closed for the next test. + await UrlbarTestUtils.promisePopupClose(window); + + // Clicking suggestions causes visits to search results pages, so clear that + // history now. + await PlacesUtils.history.clear(); + }); +} + +add_task(async function test_webnavigation_urlbar_typed_transitions() { + function backgroundScript() { + browser.webNavigation.onCommitted.addListener(msg => { + browser.test.assertEq( + "http://example.com/?q=typed", + msg.url, + "Got the expected url" + ); + // assert from_address_bar transition qualifier + browser.test.assertTrue( + msg.transitionQualifiers && + msg.transitionQualifiers.includes("from_address_bar"), + "Got the expected from_address_bar transitionQualifier" + ); + browser.test.assertEq( + "typed", + msg.transitionType, + "Got the expected transitionType" + ); + browser.test.notifyPass("webNavigation.from_address_bar.typed"); + }); + + browser.test.sendMessage("ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background: backgroundScript, + manifest: { + permissions: ["webNavigation"], + }, + }); + + await extension.startup(); + await SimpleTest.promiseFocus(window); + + await extension.awaitMessage("ready"); + + gURLBar.focus(); + gURLBar.value = ""; + const inputValue = "http://example.com/?q=typed"; + await EventUtils.sendString(inputValue); + await EventUtils.synthesizeKey("VK_RETURN", { altKey: true }); + + await extension.awaitFinish("webNavigation.from_address_bar.typed"); + + await extension.unload(); +}); + +add_task( + async function test_webnavigation_urlbar_typed_closed_popup_transitions() { + function backgroundScript() { + browser.webNavigation.onCommitted.addListener(msg => { + browser.test.assertEq( + "http://example.com/?q=typedClosed", + msg.url, + "Got the expected url" + ); + // assert from_address_bar transition qualifier + browser.test.assertTrue( + msg.transitionQualifiers && + msg.transitionQualifiers.includes("from_address_bar"), + "Got the expected from_address_bar transitionQualifier" + ); + browser.test.assertEq( + "typed", + msg.transitionType, + "Got the expected transitionType" + ); + browser.test.notifyPass("webNavigation.from_address_bar.typed"); + }); + + browser.test.sendMessage("ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background: backgroundScript, + manifest: { + permissions: ["webNavigation"], + }, + }); + + await extension.startup(); + await SimpleTest.promiseFocus(window); + + await extension.awaitMessage("ready"); + await promiseAutocompleteResultPopup("http://example.com/?q=typedClosed"); + await UrlbarTestUtils.promiseSearchComplete(window); + // Closing the popup forces a different code route that handles no results + // being displayed. + await UrlbarTestUtils.promisePopupClose(window); + EventUtils.synthesizeKey("VK_RETURN", {}); + + await extension.awaitFinish("webNavigation.from_address_bar.typed"); + + await extension.unload(); + } +); + +add_task(async function test_webnavigation_urlbar_bookmark_transitions() { + function backgroundScript() { + browser.webNavigation.onCommitted.addListener(msg => { + browser.test.assertEq( + "http://example.com/?q=bookmark", + msg.url, + "Got the expected url" + ); + + // assert from_address_bar transition qualifier + browser.test.assertTrue( + msg.transitionQualifiers && + msg.transitionQualifiers.includes("from_address_bar"), + "Got the expected from_address_bar transitionQualifier" + ); + browser.test.assertEq( + "auto_bookmark", + msg.transitionType, + "Got the expected transitionType" + ); + browser.test.notifyPass("webNavigation.from_address_bar.auto_bookmark"); + }); + + browser.test.sendMessage("ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background: backgroundScript, + manifest: { + permissions: ["webNavigation"], + }, + }); + + await addBookmark({ + title: "Bookmark To Click", + url: "http://example.com/?q=bookmark", + }); + + await extension.startup(); + await SimpleTest.promiseFocus(window); + + await extension.awaitMessage("ready"); + + await promiseAutocompleteResultPopup("Bookmark To Click"); + + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 1); + EventUtils.synthesizeMouseAtCenter(result.element.row, {}); + await extension.awaitFinish("webNavigation.from_address_bar.auto_bookmark"); + + await extension.unload(); +}); + +add_task(async function test_webnavigation_urlbar_keyword_transition() { + function backgroundScript() { + browser.webNavigation.onCommitted.addListener(msg => { + browser.test.assertEq( + `http://example.com/?q=search`, + msg.url, + "Got the expected url" + ); + + // assert from_address_bar transition qualifier + browser.test.assertTrue( + msg.transitionQualifiers && + msg.transitionQualifiers.includes("from_address_bar"), + "Got the expected from_address_bar transitionQualifier" + ); + browser.test.assertEq( + "keyword", + msg.transitionType, + "Got the expected transitionType" + ); + browser.test.notifyPass("webNavigation.from_address_bar.keyword"); + }); + + browser.test.sendMessage("ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background: backgroundScript, + manifest: { + permissions: ["webNavigation"], + }, + }); + + await addBookmark({ + title: "Test Keyword", + url: "http://example.com/?q=%s", + keyword: "testkw", + }); + + await extension.startup(); + await SimpleTest.promiseFocus(window); + + await extension.awaitMessage("ready"); + + await promiseAutocompleteResultPopup("testkw search"); + + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + EventUtils.synthesizeMouseAtCenter(result.element.row, {}); + + await extension.awaitFinish("webNavigation.from_address_bar.keyword"); + + await extension.unload(); +}); + +add_task(async function test_webnavigation_urlbar_search_transitions() { + function backgroundScript() { + browser.webNavigation.onCommitted.addListener(msg => { + browser.test.assertEq( + "http://mochi.test:8888/", + msg.url, + "Got the expected url" + ); + + // assert from_address_bar transition qualifier + browser.test.assertTrue( + msg.transitionQualifiers && + msg.transitionQualifiers.includes("from_address_bar"), + "Got the expected from_address_bar transitionQualifier" + ); + browser.test.assertEq( + "generated", + msg.transitionType, + "Got the expected 'generated' transitionType" + ); + browser.test.notifyPass("webNavigation.from_address_bar.generated"); + }); + + browser.test.sendMessage("ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background: backgroundScript, + manifest: { + permissions: ["webNavigation"], + }, + }); + + await extension.startup(); + await SimpleTest.promiseFocus(window); + + await extension.awaitMessage("ready"); + + await prepareSearchEngine(); + await promiseAutocompleteResultPopup("foo"); + + let result = await UrlbarTestUtils.getDetailsOfResultAt(window, 0); + EventUtils.synthesizeMouseAtCenter(result.element.row, {}); + + await extension.awaitFinish("webNavigation.from_address_bar.generated"); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_webRequest.js b/browser/components/extensions/test/browser/browser_ext_webRequest.js new file mode 100644 index 0000000000..c2ff1d6c64 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_webRequest.js @@ -0,0 +1,142 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +/* import-globals-from ../../../../../toolkit/components/extensions/test/mochitest/head_webrequest.js */ +loadTestSubscript("head_webrequest.js"); + +const { HiddenFrame } = ChromeUtils.importESModule( + "resource://gre/modules/HiddenFrame.sys.mjs" +); +const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; + +SimpleTest.requestCompleteLog(); + +function createHiddenBrowser(url) { + let frame = new HiddenFrame(); + return new Promise(resolve => + frame.get().then(subframe => { + let doc = subframe.document; + let browser = doc.createElementNS(XUL_NS, "browser"); + browser.setAttribute("type", "content"); + browser.setAttribute("remote", "true"); + browser.setAttribute("disableglobalhistory", "true"); + browser.setAttribute("src", url); + + doc.documentElement.appendChild(browser); + resolve({ frame: frame, browser: browser }); + }) + ); +} + +let extension; +let dummy = + "http://mochi.test:8888/browser/browser/components/extensions/test/browser/file_dummy.html"; +let headers = { + request: { + add: { + "X-WebRequest-request": "text", + "X-WebRequest-request-binary": "binary", + }, + modify: { + "user-agent": "WebRequest", + }, + remove: ["accept-encoding"], + }, + response: { + add: { + "X-WebRequest-response": "text", + "X-WebRequest-response-binary": "binary", + }, + modify: { + server: "WebRequest", + "content-type": "text/html; charset=utf-8", + }, + remove: ["connection"], + }, +}; + +let urls = ["http://mochi.test/browser/*"]; +let events = { + onBeforeRequest: [{ urls }, ["blocking"]], + onBeforeSendHeaders: [{ urls }, ["blocking", "requestHeaders"]], + onSendHeaders: [{ urls }, ["requestHeaders"]], + onHeadersReceived: [{ urls }, ["blocking", "responseHeaders"]], + onCompleted: [{ urls }, ["responseHeaders"]], +}; + +add_setup(async function () { + extension = makeExtension(events); + await extension.startup(); +}); + +add_task(async function test_newWindow() { + let expect = { + "file_dummy.html": { + type: "main_frame", + headers, + }, + }; + // NOTE: When running solo, favicon will be loaded at some point during + // the tests in this file, so all tests ignore it. When running with + // other tests in this directory, favicon gets loaded at some point before + // we run, and we never see the request, thus it cannot be handled as part + // of expect above. + extension.sendMessage("set-expected", { expect, ignore: ["favicon.ico"] }); + await extension.awaitMessage("continue"); + + let openedWindow = await BrowserTestUtils.openNewBrowserWindow(); + await BrowserTestUtils.openNewForegroundTab( + openedWindow.gBrowser, + `${dummy}?newWindow=${Math.random()}` + ); + + await extension.awaitMessage("done"); + await BrowserTestUtils.closeWindow(openedWindow); +}); + +add_task(async function test_newTab() { + // again, in this window + let expect = { + "file_dummy.html": { + type: "main_frame", + headers, + }, + }; + extension.sendMessage("set-expected", { expect, ignore: ["favicon.ico"] }); + await extension.awaitMessage("continue"); + let tab = await BrowserTestUtils.openNewForegroundTab( + window.gBrowser, + `${dummy}?newTab=${Math.random()}` + ); + + await extension.awaitMessage("done"); + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function test_subframe() { + let expect = { + "file_dummy.html": { + type: "main_frame", + headers, + }, + }; + // test a content subframe attached to hidden window + extension.sendMessage("set-expected", { expect, ignore: ["favicon.ico"] }); + info("*** waiting to continue"); + await extension.awaitMessage("continue"); + info("*** creating hidden browser"); + let frameInfo = await createHiddenBrowser( + `${dummy}?subframe=${Math.random()}` + ); + info("*** waiting for finish"); + await extension.awaitMessage("done"); + info("*** destroying hidden browser"); + // cleanup + frameInfo.browser.remove(); + frameInfo.frame.destroy(); +}); + +add_task(async function teardown() { + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_webRequest_error_after_stopped_or_closed.js b/browser/components/extensions/test/browser/browser_ext_webRequest_error_after_stopped_or_closed.js new file mode 100644 index 0000000000..75d85dd3bf --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_webRequest_error_after_stopped_or_closed.js @@ -0,0 +1,110 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const SLOW_PAGE = + getRootDirectory(gTestPath).replace( + "chrome://mochitests/content", + "https://www.example.com" + ) + "file_slowed_document.sjs"; + +async function runTest(stopLoadFunc) { + async function background() { + let urls = ["https://www.example.com/*"]; + browser.webRequest.onCompleted.addListener( + details => { + browser.test.sendMessage("done", { + msg: "onCompleted", + requestId: details.requestId, + }); + }, + { urls } + ); + + browser.webRequest.onBeforeRequest.addListener( + details => { + browser.test.sendMessage("onBeforeRequest", { + requestId: details.requestId, + }); + }, + { urls }, + ["blocking"] + ); + + browser.webRequest.onErrorOccurred.addListener( + details => { + browser.test.sendMessage("done", { + msg: "onErrorOccurred", + requestId: details.requestId, + }); + }, + { urls } + ); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: [ + "webRequest", + "webRequestBlocking", + "https://www.example.com/*", + ], + }, + background, + }); + await extension.startup(); + + // Open a SLOW_PAGE and don't wait for it to load + let slowTab = await BrowserTestUtils.openNewForegroundTab( + gBrowser, + SLOW_PAGE, + false + ); + + stopLoadFunc(slowTab); + + // Retrieve the requestId from onBeforeRequest + let requestIdOnBeforeRequest = await extension.awaitMessage( + "onBeforeRequest" + ); + + // Now verify that we got the correct event and request id + let doneMessage = await extension.awaitMessage("done"); + + // We shouldn't get the onCompleted message here + is(doneMessage.msg, "onErrorOccurred", "received onErrorOccurred message"); + is( + requestIdOnBeforeRequest.requestId, + doneMessage.requestId, + "request Ids match" + ); + + BrowserTestUtils.removeTab(slowTab); + await extension.unload(); +} + +/** + * Check that after we cancel a slow page load, we get an error associated with + * our request. + */ +add_task(async function test_click_stop_button() { + await runTest(async slowTab => { + // Stop the load + let stopButton = document.getElementById("stop-button"); + await TestUtils.waitForCondition(() => { + return !stopButton.disabled; + }); + stopButton.click(); + }); +}); + +/** + * Check that after we close the tab corresponding to a slow page load, + * that we get an error associated with our request. + */ +add_task(async function test_remove_tab() { + await runTest(slowTab => { + // Remove the tab + BrowserTestUtils.removeTab(slowTab); + }); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_webrtc.js b/browser/components/extensions/test/browser/browser_ext_webrtc.js new file mode 100644 index 0000000000..520cb9cd69 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_webrtc.js @@ -0,0 +1,131 @@ +"use strict"; + +const { PermissionTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PermissionTestUtils.sys.mjs" +); + +add_setup(async function () { + await SpecialPowers.pushPrefEnv({ + set: [["media.navigator.permission.fake", true]], + }); +}); + +add_task(async function test_background_request() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: {}, + async background() { + browser.test.onMessage.addListener(async msg => { + if (msg.type != "testGUM") { + browser.test.fail("unknown message"); + } + + await browser.test.assertRejects( + navigator.mediaDevices.getUserMedia({ audio: true }), + /The request is not allowed/, + "Calling gUM in background pages throws an error" + ); + browser.test.notifyPass("done"); + }); + }, + }); + + await extension.startup(); + + let policy = WebExtensionPolicy.getByID(extension.id); + let principal = policy.extension.principal; + // Add a permission for the extension to make sure that we throw even + // if permission was given. + PermissionTestUtils.add(principal, "microphone", Services.perms.ALLOW_ACTION); + + let finished = extension.awaitFinish("done"); + extension.sendMessage({ type: "testGUM" }); + await finished; + + PermissionTestUtils.remove(principal, "microphone"); + await extension.unload(); +}); + +let scriptPage = url => + `<html><head><meta charset="utf-8"><script src="${url}"></script></head><body>${url}</body></html>`; + +add_task(async function test_popup_request() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_action: { + default_popup: "popup.html", + browser_style: true, + }, + }, + + files: { + "popup.html": scriptPage("popup.js"), + "popup.js": function () { + browser.test + .assertRejects( + navigator.mediaDevices.getUserMedia({ audio: true }), + /The request is not allowed/, + "Calling gUM in popup pages without permission throws an error" + ) + .then(function () { + browser.test.notifyPass("done"); + }); + }, + }, + }); + + await extension.startup(); + clickBrowserAction(extension); + await extension.awaitFinish("done"); + await extension.unload(); + + extension = ExtensionTestUtils.loadExtension({ + manifest: { + // Use the same url for background page and browserAction popup, + // to double-check that the page url is not being used to decide + // if webRTC requests should be allowed or not. + background: { page: "page.html" }, + browser_action: { + default_popup: "page.html", + browser_style: true, + }, + }, + + files: { + "page.html": scriptPage("page.js"), + "page.js": async function () { + const isBackgroundPage = + window == (await browser.runtime.getBackgroundPage()); + + if (isBackgroundPage) { + await browser.test.assertRejects( + navigator.mediaDevices.getUserMedia({ audio: true }), + /The request is not allowed/, + "Calling gUM in background pages throws an error" + ); + } else { + try { + await navigator.mediaDevices.getUserMedia({ audio: true }); + browser.test.notifyPass("done"); + } catch (err) { + browser.test.fail(`Failed with error ${err.message}`); + browser.test.notifyFail("done"); + } + } + }, + }, + }); + + // Add a permission for the extension to make sure that we throw even + // if permission was given. + await extension.startup(); + + let policy = WebExtensionPolicy.getByID(extension.id); + let principal = policy.extension.principal; + + PermissionTestUtils.add(principal, "microphone", Services.perms.ALLOW_ACTION); + clickBrowserAction(extension); + + await extension.awaitFinish("done"); + PermissionTestUtils.remove(principal, "microphone"); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_windows.js b/browser/components/extensions/test/browser/browser_ext_windows.js new file mode 100644 index 0000000000..8d6cff25b4 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_windows.js @@ -0,0 +1,348 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +// Since we apply title localization asynchronously, +// we'll use this helper to wait for the title to match +// the condition and then test against it. +async function verifyTitle(win, test, desc) { + await TestUtils.waitForCondition(test); + ok(true, desc); +} + +add_task(async function testWindowGetAll() { + let raisedWin = Services.ww.openWindow( + null, + AppConstants.BROWSER_CHROME_URL, + "_blank", + "chrome,dialog=no,all,alwaysRaised", + null + ); + + await TestUtils.topicObserved( + "browser-delayed-startup-finished", + subject => subject == raisedWin + ); + + let extension = ExtensionTestUtils.loadExtension({ + background: async function () { + let wins = await browser.windows.getAll(); + browser.test.assertEq(2, wins.length, "Expect two windows"); + + browser.test.assertEq( + false, + wins[0].alwaysOnTop, + "Expect first window not to be always on top" + ); + browser.test.assertEq( + true, + wins[1].alwaysOnTop, + "Expect first window to be always on top" + ); + + let win = await browser.windows.create({ + url: "http://example.com", + type: "popup", + }); + + wins = await browser.windows.getAll(); + browser.test.assertEq(3, wins.length, "Expect three windows"); + + wins = await browser.windows.getAll({ windowTypes: ["popup"] }); + browser.test.assertEq(1, wins.length, "Expect one window"); + browser.test.assertEq("popup", wins[0].type, "Expect type to be popup"); + + wins = await browser.windows.getAll({ windowTypes: ["normal"] }); + browser.test.assertEq(2, wins.length, "Expect two windows"); + browser.test.assertEq("normal", wins[0].type, "Expect type to be normal"); + browser.test.assertEq("normal", wins[1].type, "Expect type to be normal"); + + wins = await browser.windows.getAll({ windowTypes: ["popup", "normal"] }); + browser.test.assertEq(3, wins.length, "Expect three windows"); + + await browser.windows.remove(win.id); + + browser.test.notifyPass("getAll"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("getAll"); + await extension.unload(); + + await BrowserTestUtils.closeWindow(raisedWin); +}); + +add_task(async function testWindowTitle() { + const PREFACE1 = "My prefix1 - "; + const PREFACE2 = "My prefix2 - "; + const START_URL = + "http://example.com/browser/browser/components/extensions/test/browser/file_dummy.html"; + const START_TITLE = "Dummy test page"; + const NEW_URL = + "http://example.com/browser/browser/components/extensions/test/browser/file_title.html"; + const NEW_TITLE = "Different title test page"; + + async function background() { + browser.test.onMessage.addListener( + async (msg, options, windowId, expected) => { + if (msg === "create") { + let win = await browser.windows.create(options); + browser.test.sendMessage("created", win); + } + if (msg === "update") { + let win = await browser.windows.get(windowId); + browser.test.assertTrue( + win.title.startsWith(expected.before.preface), + "Window has the expected title preface before update." + ); + browser.test.assertTrue( + win.title.includes(expected.before.text), + "Window has the expected title text before update." + ); + win = await browser.windows.update(windowId, options); + browser.test.assertTrue( + win.title.startsWith(expected.after.preface), + "Window has the expected title preface after update." + ); + browser.test.assertTrue( + win.title.includes(expected.after.text), + "Window has the expected title text after update." + ); + browser.test.sendMessage("updated", win); + } + } + ); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["tabs"], + }, + }); + + await extension.startup(); + const { + Management: { + global: { windowTracker }, + }, + } = ChromeUtils.importESModule("resource://gre/modules/Extension.sys.mjs"); + + async function createApiWin(options) { + let promiseLoaded = BrowserTestUtils.waitForNewWindow({ url: START_URL }); + extension.sendMessage("create", options); + let apiWin = await extension.awaitMessage("created"); + let realWin = windowTracker.getWindow(apiWin.id); + await promiseLoaded; + let expectedPreface = options.titlePreface ? options.titlePreface : ""; + await verifyTitle( + realWin, + () => { + return ( + realWin.document.title.startsWith(expectedPreface || START_TITLE) && + realWin.document.title.includes(START_TITLE) + ); + }, + "Created window starts with the expected preface and includes the right title text." + ); + return apiWin; + } + + async function updateWindow(options, apiWin, expected) { + extension.sendMessage("update", options, apiWin.id, expected); + await extension.awaitMessage("updated"); + let realWin = windowTracker.getWindow(apiWin.id); + await verifyTitle( + realWin, + () => { + return ( + realWin.document.title.startsWith( + expected.after.preface || expected.after.text + ) && realWin.document.title.includes(expected.after.text) + ); + }, + "Updated window starts with the expected preface and includes the right title text." + ); + await BrowserTestUtils.closeWindow(realWin); + } + + // Create a window without a preface. + let apiWin = await createApiWin({ url: START_URL }); + + // Add a titlePreface to the window. + let expected = { + before: { + preface: "", + text: START_TITLE, + }, + after: { + preface: PREFACE1, + text: START_TITLE, + }, + }; + await updateWindow({ titlePreface: PREFACE1 }, apiWin, expected); + + // Create a window with a preface. + apiWin = await createApiWin({ url: START_URL, titlePreface: PREFACE1 }); + + // Navigate to a different url and check that title is reflected. + let realWin = windowTracker.getWindow(apiWin.id); + let promiseLoaded = BrowserTestUtils.browserLoaded( + realWin.gBrowser.selectedBrowser + ); + BrowserTestUtils.startLoadingURIString( + realWin.gBrowser.selectedBrowser, + NEW_URL + ); + await promiseLoaded; + await verifyTitle( + realWin, + () => { + return ( + realWin.document.title.startsWith(PREFACE1) && + realWin.document.title.includes(NEW_TITLE) + ); + }, + "Updated window starts with the expected preface and includes the expected title." + ); + + // Update the titlePreface of the window. + expected = { + before: { + preface: PREFACE1, + text: NEW_TITLE, + }, + after: { + preface: PREFACE2, + text: NEW_TITLE, + }, + }; + await updateWindow({ titlePreface: PREFACE2 }, apiWin, expected); + + // Create a window with a preface. + apiWin = await createApiWin({ url: START_URL, titlePreface: PREFACE1 }); + realWin = windowTracker.getWindow(apiWin.id); + + // Update the titlePreface of the window with an empty string. + expected = { + before: { + preface: PREFACE1, + text: START_TITLE, + }, + after: { + preface: "", + text: START_TITLE, + }, + }; + await verifyTitle( + realWin, + () => realWin.document.title.startsWith(expected.before.preface), + "Updated window has the expected title preface." + ); + await updateWindow({ titlePreface: "" }, apiWin, expected); + await verifyTitle( + realWin, + () => !realWin.document.title.startsWith(expected.before.preface), + "Updated window doesn't not contain the preface after update." + ); + + // Create a window with a preface. + apiWin = await createApiWin({ url: START_URL, titlePreface: PREFACE1 }); + realWin = windowTracker.getWindow(apiWin.id); + + // Update the window without a titlePreface. + expected = { + before: { + preface: PREFACE1, + text: START_TITLE, + }, + after: { + preface: PREFACE1, + text: START_TITLE, + }, + }; + await updateWindow({}, apiWin, expected); + + await extension.unload(); +}); + +// Test that the window title is only available with the correct tab +// permissions. +add_task(async function testWindowTitlePermissions() { + let tab = await BrowserTestUtils.openNewForegroundTab( + window.gBrowser, + "http://example.com/" + ); + + let extension = ExtensionTestUtils.loadExtension({ + async background() { + function awaitMessage(name) { + return new Promise(resolve => { + browser.test.onMessage.addListener(function listener(...msg) { + if (msg[0] === name) { + browser.test.onMessage.removeListener(listener); + resolve(msg[1]); + } + }); + }); + } + + let window = await browser.windows.getCurrent(); + + browser.test.assertEq( + undefined, + window.title, + "Window title should be null without tab permission" + ); + + browser.test.sendMessage("grant-activeTab"); + let expectedTitle = await awaitMessage("title"); + + window = await browser.windows.getCurrent(); + browser.test.assertEq( + expectedTitle, + window.title, + "Window should have the expected title with tab permission granted" + ); + + await browser.test.notifyPass("window-title-permissions"); + }, + manifest: { + permissions: ["activeTab"], + browser_action: { + default_area: "navbar", + }, + }, + }); + + await extension.startup(); + + await extension.awaitMessage("grant-activeTab"); + await clickBrowserAction(extension); + extension.sendMessage("title", document.title); + + await extension.awaitFinish("window-title-permissions"); + + await extension.unload(); + + BrowserTestUtils.removeTab(tab); +}); + +add_task(async function testInvalidWindowId() { + let extension = ExtensionTestUtils.loadExtension({ + async background() { + await browser.test.assertRejects( + // Assuming that this windowId does not exist. + browser.windows.get(123456789), + /Invalid window/, + "Should receive invalid window" + ); + browser.test.notifyPass("windows.get.invalid"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("windows.get.invalid"); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_windows_allowScriptsToClose.js b/browser/components/extensions/test/browser/browser_ext_windows_allowScriptsToClose.js new file mode 100644 index 0000000000..c89bcfce77 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_windows_allowScriptsToClose.js @@ -0,0 +1,69 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +// Tests allowScriptsToClose option +add_task(async function test_allowScriptsToClose() { + const files = { + "dummy.html": "<meta charset=utf-8><script src=close.js></script>", + "close.js": function () { + window.close(); + if (!window.closed) { + browser.test.sendMessage("close-failed"); + } + }, + }; + + function background() { + browser.test.onMessage.addListener((msg, options) => { + function listener(_, { status }, { url }) { + if (status == "complete" && url == options.url) { + browser.tabs.onUpdated.removeListener(listener); + browser.tabs.executeScript({ file: "close.js" }); + } + } + options.url = browser.runtime.getURL(options.url); + browser.windows.create(options); + if (msg === "create+execute") { + browser.tabs.onUpdated.addListener(listener); + } + }); + browser.test.notifyPass(); + } + + const example = "http://example.com/"; + const manifest = { permissions: ["tabs", example] }; + + const extension = ExtensionTestUtils.loadExtension({ + files, + background, + manifest, + }); + await SpecialPowers.pushPrefEnv({ + set: [["dom.allow_scripts_to_close_windows", false]], + }); + + await extension.startup(); + await extension.awaitFinish(); + + extension.sendMessage("create", { url: "dummy.html" }); + let win = await BrowserTestUtils.waitForNewWindow(); + await BrowserTestUtils.windowClosed(win); + info("script allowed to close the window"); + + extension.sendMessage("create+execute", { url: example }); + win = await BrowserTestUtils.waitForNewWindow(); + await BrowserTestUtils.windowClosed(win); + info("script allowed to close the window"); + + extension.sendMessage("create+execute", { + url: example, + allowScriptsToClose: true, + }); + win = await BrowserTestUtils.waitForNewWindow(); + await BrowserTestUtils.windowClosed(win); + info("script allowed to close the window"); + + await SpecialPowers.popPrefEnv(); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_windows_create.js b/browser/components/extensions/test/browser/browser_ext_windows_create.js new file mode 100644 index 0000000000..6c41abcd3e --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_windows_create.js @@ -0,0 +1,205 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function testWindowCreate() { + let extension = ExtensionTestUtils.loadExtension({ + async background() { + let _checkWindowPromise; + browser.test.onMessage.addListener(msg => { + if (msg == "checked-window") { + _checkWindowPromise.resolve(); + _checkWindowPromise = null; + } + }); + + let os; + + function checkWindow(expected) { + return new Promise(resolve => { + _checkWindowPromise = { resolve }; + browser.test.sendMessage("check-window", expected); + }); + } + + async function createWindow(params, expected, keep = false) { + let window = await browser.windows.create(...params); + // params is null when testing create without createData + params = params[0] || {}; + + // Prevent frequent intermittent failures on macos where the newly created window + // may have not always got into the fullscreen state before browser.window.create + // resolves the windows details. + if ( + os === "mac" && + params.state === "fullscreen" && + window.state !== params.state + ) { + browser.test.log( + "Wait for window.state for the newly create window to be set to fullscreen" + ); + while (window.state !== params.state) { + window = await browser.windows.get(window.id, { populate: true }); + } + browser.test.log( + "Newly created browser window got into fullscreen state" + ); + } + + for (let key of Object.keys(params)) { + if (key == "state" && os == "mac" && params.state == "normal") { + // OS-X doesn't have a hard distinction between "normal" and + // "maximized" states. + browser.test.assertTrue( + window.state == "normal" || window.state == "maximized", + `Expected window.state (currently ${window.state}) to be "normal" but will accept "maximized"` + ); + } else { + browser.test.assertEq( + params[key], + window[key], + `Got expected value for window.${key}` + ); + } + } + + browser.test.assertEq( + 1, + window.tabs.length, + "tabs property got populated" + ); + + await checkWindow(expected); + if (keep) { + return window; + } + + if (params.state == "fullscreen" && os == "win") { + // FIXME: Closing a fullscreen window causes a window leak in + // Windows tests. + await browser.windows.update(window.id, { state: "normal" }); + } + await browser.windows.remove(window.id); + } + + try { + ({ os } = await browser.runtime.getPlatformInfo()); + + // Set the current window to state: "normal" because the test is failing on Windows + // where the current window is maximized. + let currentWindow = await browser.windows.getCurrent(); + await browser.windows.update(currentWindow.id, { state: "normal" }); + + await createWindow([], { state: "STATE_NORMAL" }); + await createWindow([{ state: "maximized" }], { + state: "STATE_MAXIMIZED", + }); + await createWindow([{ state: "minimized" }], { + state: "STATE_MINIMIZED", + }); + await createWindow([{ state: "normal" }], { + state: "STATE_NORMAL", + hiddenChrome: [], + }); + await createWindow([{ state: "fullscreen" }], { + state: "STATE_FULLSCREEN", + }); + + let window = await createWindow( + [{ type: "popup" }], + { + hiddenChrome: [ + "menubar", + "toolbar", + "location", + "directories", + "status", + "extrachrome", + ], + chromeFlags: ["CHROME_OPENAS_DIALOG"], + }, + true + ); + + let tabs = await browser.tabs.query({ + windowType: "popup", + active: true, + }); + + browser.test.assertEq(1, tabs.length, "Expected only one popup"); + browser.test.assertEq( + window.id, + tabs[0].windowId, + "Expected new window to be returned in query" + ); + + await browser.windows.remove(window.id); + + browser.test.notifyPass("window-create"); + } catch (e) { + browser.test.fail(`${e} :: ${e.stack}`); + browser.test.notifyFail("window-create"); + } + }, + }); + + let latestWindow; + let windowListener = (window, topic) => { + if (topic == "domwindowopened") { + latestWindow = window; + } + }; + Services.ww.registerNotification(windowListener); + + extension.onMessage("check-window", expected => { + if (expected.state != null) { + let { windowState } = latestWindow; + if (latestWindow.fullScreen) { + windowState = latestWindow.STATE_FULLSCREEN; + } + + if (expected.state == "STATE_NORMAL") { + ok( + windowState == window.STATE_NORMAL || + windowState == window.STATE_MAXIMIZED, + `Expected windowState (currently ${windowState}) to be STATE_NORMAL but will accept STATE_MAXIMIZED` + ); + } else { + is( + windowState, + window[expected.state], + `Expected window state to be ${expected.state}` + ); + } + } + if (expected.hiddenChrome) { + let chromeHidden = + latestWindow.document.documentElement.getAttribute("chromehidden"); + is( + chromeHidden.trim().split(/\s+/).sort().join(" "), + expected.hiddenChrome.sort().join(" "), + "Got expected hidden chrome" + ); + } + if (expected.chromeFlags) { + let { chromeFlags } = latestWindow.docShell.treeOwner + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIAppWindow); + for (let flag of expected.chromeFlags) { + ok( + chromeFlags & Ci.nsIWebBrowserChrome[flag], + `Expected window to have the ${flag} flag` + ); + } + } + + extension.sendMessage("checked-window"); + }); + + await extension.startup(); + await extension.awaitFinish("window-create"); + await extension.unload(); + + Services.ww.unregisterNotification(windowListener); + latestWindow = null; +}); diff --git a/browser/components/extensions/test/browser/browser_ext_windows_create_cookieStoreId.js b/browser/components/extensions/test/browser/browser_ext_windows_create_cookieStoreId.js new file mode 100644 index 0000000000..5eda338fe5 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_windows_create_cookieStoreId.js @@ -0,0 +1,345 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function no_cookies_permission() { + await SpecialPowers.pushPrefEnv({ + set: [["privacy.userContext.enabled", true]], + }); + + let extension = ExtensionTestUtils.loadExtension({ + async background() { + await browser.test.assertRejects( + browser.windows.create({ cookieStoreId: "firefox-container-1" }), + /No permission for cookieStoreId/, + "cookieStoreId requires cookies permission" + ); + browser.test.sendMessage("done"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); +}); + +add_task(async function invalid_cookieStoreId() { + await SpecialPowers.pushPrefEnv({ + set: [["privacy.userContext.enabled", true]], + }); + + let extension = ExtensionTestUtils.loadExtension({ + incognitoOverride: "spanning", + manifest: { + permissions: ["cookies"], + }, + async background() { + await browser.test.assertRejects( + browser.windows.create({ cookieStoreId: "not-firefox-container-1" }), + /Illegal cookieStoreId/, + "cookieStoreId must be valid" + ); + + await browser.test.assertRejects( + browser.windows.create({ cookieStoreId: "firefox-private" }), + /Illegal to set private cookieStoreId in a non-private window/, + "cookieStoreId cannot be private in a non-private window" + ); + await browser.test.assertRejects( + browser.windows.create({ + cookieStoreId: "firefox-default", + incognito: true, + }), + /Illegal to set non-private cookieStoreId in a private window/, + "cookieStoreId cannot be non-private in an private window" + ); + + await browser.test.assertRejects( + browser.windows.create({ + cookieStoreId: "firefox-container-1", + incognito: true, + }), + /Illegal to set non-private cookieStoreId in a private window/, + "cookieStoreId cannot be a container tab ID in a private window" + ); + + browser.test.sendMessage("done"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); +}); + +add_task(async function perma_private_browsing_mode() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.privatebrowsing.autostart", true]], + }); + + let extension = ExtensionTestUtils.loadExtension({ + incognitoOverride: "spanning", + manifest: { + permissions: ["tabs", "cookies"], + }, + async background() { + await browser.test.assertRejects( + browser.windows.create({ cookieStoreId: "firefox-container-1" }), + /Contextual identities are unavailable in permanent private browsing mode/, + "cookieStoreId cannot be a container tab ID in perma-private browsing mode" + ); + + browser.test.sendMessage("done"); + }, + }); + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function userContext_disabled() { + await SpecialPowers.pushPrefEnv({ + set: [["privacy.userContext.enabled", false]], + }); + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs", "cookies"], + }, + async background() { + await browser.test.assertRejects( + browser.windows.create({ cookieStoreId: "firefox-container-1" }), + /Contextual identities are currently disabled/, + "cookieStoreId cannot be a container tab ID when contextual identities are disabled" + ); + browser.test.sendMessage("done"); + }, + }); + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function valid_cookieStoreId() { + await SpecialPowers.pushPrefEnv({ + set: [["privacy.userContext.enabled", true]], + }); + + const testCases = [ + { + description: "no explicit URL", + createParams: { + cookieStoreId: "firefox-container-1", + }, + expectedCookieStoreIds: ["firefox-container-1"], + expectedExecuteScriptResult: [ + // Default URL is about:home, and extensions cannot run scripts in it. + "Missing host permission for the tab", + ], + }, + { + description: "one URL", + createParams: { + url: "about:blank", + cookieStoreId: "firefox-container-1", + }, + expectedCookieStoreIds: ["firefox-container-1"], + expectedExecuteScriptResult: ["about:blank - null"], + }, + { + description: "one URL in an array", + createParams: { + url: ["about:blank"], + cookieStoreId: "firefox-container-1", + }, + expectedCookieStoreIds: ["firefox-container-1"], + expectedExecuteScriptResult: ["about:blank - null"], + }, + { + description: "two URLs in an array", + createParams: { + url: ["about:blank", "about:blank"], + cookieStoreId: "firefox-container-1", + }, + expectedCookieStoreIds: ["firefox-container-1", "firefox-container-1"], + expectedExecuteScriptResult: ["about:blank - null", "about:blank - null"], + }, + ]; + + async function background(testCases) { + let readyTabs = new Map(); + let tabReadyCheckers = new Set(); + browser.webNavigation.onCompleted.addListener(({ url, tabId, frameId }) => { + if (frameId === 0) { + readyTabs.set(tabId, url); + browser.test.log(`Detected navigation in tab ${tabId} to ${url}.`); + + for (let check of tabReadyCheckers) { + check(tabId, url); + } + } + }); + async function awaitTabReady(tabId, expectedUrl) { + if (readyTabs.get(tabId) === expectedUrl) { + browser.test.log(`Tab ${tabId} was ready with URL ${expectedUrl}.`); + return; + } + await new Promise(resolve => { + browser.test.log( + `Waiting for tab ${tabId} to load URL ${expectedUrl}...` + ); + tabReadyCheckers.add(function check(completedTabId, completedUrl) { + if (completedTabId === tabId && completedUrl === expectedUrl) { + tabReadyCheckers.delete(check); + resolve(); + } + }); + }); + browser.test.log(`Tab ${tabId} is ready with URL ${expectedUrl}.`); + } + + async function executeScriptAndGetResult(tabId) { + try { + return ( + await browser.tabs.executeScript(tabId, { + matchAboutBlank: true, + code: "`${document.URL} - ${origin}`", + }) + )[0]; + } catch (e) { + return e.message; + } + } + for (let { + description, + createParams, + expectedCookieStoreIds, + expectedExecuteScriptResult, + } of testCases) { + let win = await browser.windows.create(createParams); + + browser.test.assertEq( + expectedCookieStoreIds.length, + win.tabs.length, + "Expected number of tabs" + ); + + for (let [i, expectedCookieStoreId] of Object.entries( + expectedCookieStoreIds + )) { + browser.test.assertEq( + expectedCookieStoreId, + win.tabs[i].cookieStoreId, + `expected cookieStoreId for tab ${i} (${description})` + ); + } + + for (let [i, expectedResult] of Object.entries( + expectedExecuteScriptResult + )) { + // Wait until the the tab can process the tabs.executeScript calls. + // TODO: Remove this when bug 1418655 and bug 1397667 are fixed. + let expectedUrl = Array.isArray(createParams.url) + ? createParams.url[i] + : createParams.url || "about:home"; + await awaitTabReady(win.tabs[i].id, expectedUrl); + + let result = await executeScriptAndGetResult(win.tabs[i].id); + browser.test.assertEq( + expectedResult, + result, + `expected executeScript result for tab ${i} (${description})` + ); + } + + await browser.windows.remove(win.id); + } + browser.test.sendMessage("done"); + } + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + host_permissions: ["*://*/*"], // allows script in top-level about:blank. + permissions: ["cookies", "webNavigation"], + }, + background: `(${background})(${JSON.stringify(testCases)})`, + }); + + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); +}); + +add_task(async function cookieStoreId_and_tabId() { + await SpecialPowers.pushPrefEnv({ + set: [["privacy.userContext.enabled", true]], + }); + + let extension = ExtensionTestUtils.loadExtension({ + incognitoOverride: "spanning", + manifest: { + permissions: ["cookies"], + }, + async background() { + for (let cookieStoreId of ["firefox-default", "firefox-container-1"]) { + let { id: normalTabId } = await browser.tabs.create({ cookieStoreId }); + + await browser.test.assertRejects( + browser.windows.create({ + cookieStoreId: "firefox-private", + tabId: normalTabId, + }), + /`cookieStoreId` must match the tab's cookieStoreId/, + "Cannot use cookieStoreId for pre-existing tabs with a different cookieStoreId" + ); + + let win = await browser.windows.create({ + cookieStoreId, + tabId: normalTabId, + }); + browser.test.assertEq( + cookieStoreId, + win.tabs[0].cookieStoreId, + "Adopted tab" + ); + await browser.windows.remove(win.id); + } + + { + let privateWindow = await browser.windows.create({ incognito: true }); + let privateTabId = privateWindow.tabs[0].id; + + await browser.test.assertRejects( + browser.windows.create({ + cookieStoreId: "firefox-default", + tabId: privateTabId, + }), + /`cookieStoreId` must match the tab's cookieStoreId/, + "Cannot use cookieStoreId for pre-existing tab in a private window" + ); + let win = await browser.windows.create({ + cookieStoreId: "firefox-private", + tabId: privateTabId, + }); + browser.test.assertEq( + "firefox-private", + win.tabs[0].cookieStoreId, + "Adopted private tab" + ); + await browser.windows.remove(win.id); + + await browser.test.assertRejects( + browser.windows.remove(privateWindow.id), + /Invalid window ID:/, + "The original private window should have been closed when its only tab was adopted." + ); + } + + browser.test.sendMessage("done"); + }, + }); + + await extension.startup(); + await extension.awaitMessage("done"); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_windows_create_params.js b/browser/components/extensions/test/browser/browser_ext_windows_create_params.js new file mode 100644 index 0000000000..6d80085433 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_windows_create_params.js @@ -0,0 +1,249 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { AddonTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/AddonTestUtils.sys.mjs" +); + +AddonTestUtils.initMochitest(this); + +// Tests that incompatible parameters can't be used together. +add_task(async function testWindowCreateParams() { + let extension = ExtensionTestUtils.loadExtension({ + async background() { + try { + for (let state of ["minimized", "maximized", "fullscreen"]) { + for (let param of ["left", "top", "width", "height"]) { + let expected = `"state": "${state}" may not be combined with "left", "top", "width", or "height"`; + + await browser.test.assertRejects( + browser.windows.create({ state, [param]: 100 }), + RegExp(expected), + `Got expected error from create(${param}=100)` + ); + } + } + + browser.test.notifyPass("window-create-params"); + } catch (e) { + browser.test.fail(`${e} :: ${e.stack}`); + browser.test.notifyFail("window-create-params"); + } + }, + }); + + await extension.startup(); + await extension.awaitFinish("window-create-params"); + await extension.unload(); +}); + +// We do not support the focused param, however we do not want +// to fail despite an error when it is passed. This provides +// better code level compatibility with chrome. +add_task(async function testWindowCreateFocused() { + let extension = ExtensionTestUtils.loadExtension({ + async background() { + async function doWaitForWindow(createOpts, resolve) { + let created; + browser.windows.onFocusChanged.addListener(async function listener( + wid + ) { + if (wid == browser.windows.WINDOW_ID_NONE) { + return; + } + let win = await created; + if (win.id !== wid) { + return; + } + browser.windows.onFocusChanged.removeListener(listener); + // update the window object + let window = await browser.windows.get(wid); + resolve(window); + }); + created = browser.windows.create(createOpts); + } + async function awaitNewFocusedWindow(createOpts) { + return new Promise(resolve => { + // eslint doesn't like an async promise function, so + // we need to wrap it like this. + doWaitForWindow(createOpts, resolve); + }); + } + try { + let window = await awaitNewFocusedWindow({}); + browser.test.assertEq( + window.focused, + true, + "window is focused without focused param" + ); + browser.test.log("removeWindow"); + await browser.windows.remove(window.id); + window = await awaitNewFocusedWindow({ focused: true }); + browser.test.assertEq( + window.focused, + true, + "window is focused with focused: true" + ); + browser.test.log("removeWindow"); + await browser.windows.remove(window.id); + window = await awaitNewFocusedWindow({ focused: false }); + browser.test.assertEq( + window.focused, + true, + "window is focused with focused: false" + ); + browser.test.log("removeWindow"); + await browser.windows.remove(window.id); + browser.test.notifyPass("window-create-params"); + } catch (e) { + browser.test.fail(`${e} :: ${e.stack}`); + browser.test.notifyFail("window-create-params"); + } + }, + }); + + ExtensionTestUtils.failOnSchemaWarnings(false); + let { messages } = await AddonTestUtils.promiseConsoleOutput(async () => { + await extension.startup(); + await extension.awaitFinish("window-create-params"); + await extension.unload(); + }); + + AddonTestUtils.checkMessages( + messages, + { + expected: [ + { + message: + /Warning processing focused: Opening inactive windows is not supported/, + }, + ], + }, + "Expected warning processing focused" + ); + + ExtensionTestUtils.failOnSchemaWarnings(true); +}); + +add_task(async function testPopupTypeWithDimension() { + let extension = ExtensionTestUtils.loadExtension({ + async background() { + await browser.windows.create({ + type: "popup", + left: 123, + top: 123, + width: 151, + height: 152, + }); + await browser.windows.create({ + type: "popup", + left: 123, + width: 152, + height: 153, + }); + await browser.windows.create({ + type: "popup", + top: 123, + width: 153, + height: 154, + }); + await browser.windows.create({ + type: "popup", + left: screen.availWidth * 100, + top: screen.availHeight * 100, + width: 154, + height: 155, + }); + await browser.windows.create({ + type: "popup", + left: -screen.availWidth * 100, + top: -screen.availHeight * 100, + width: 155, + height: 156, + }); + browser.test.sendMessage("windows-created"); + }, + }); + + const baseWindow = await BrowserTestUtils.openNewBrowserWindow(); + baseWindow.resizeTo(150, 150); + baseWindow.moveTo(50, 50); + + let windows = []; + let windowListener = (window, topic) => { + if (topic == "domwindowopened") { + windows.push(window); + } + }; + Services.ww.registerNotification(windowListener); + + await extension.startup(); + await extension.awaitMessage("windows-created"); + await extension.unload(); + + const regularScreen = getScreenAt(0, 0, 150, 150); + const roundedX = roundCssPixcel(123, regularScreen); + const roundedY = roundCssPixcel(123, regularScreen); + + const availRectLarge = getCssAvailRect( + getScreenAt(screen.width * 100, screen.height * 100, 150, 150) + ); + const maxRight = availRectLarge.right; + const maxBottom = availRectLarge.bottom; + + const availRectSmall = getCssAvailRect( + getScreenAt(-screen.width * 100, -screen.height * 100, 150, 150150) + ); + const minLeft = availRectSmall.left; + const minTop = availRectSmall.top; + + const actualCoordinates = windows + .slice(0, 3) + .map(window => `${window.screenX},${window.screenY}`); + const offsetFromBase = 10; + const expectedCoordinates = [ + `${roundedX},${roundedY}`, + // Missing top should be +10 from the last browser window. + `${roundedX},${baseWindow.screenY + offsetFromBase}`, + // Missing left should be +10 from the last browser window. + `${baseWindow.screenX + offsetFromBase},${roundedY}`, + ]; + is( + actualCoordinates.join(" / "), + expectedCoordinates.join(" / "), + "expected popup type windows are opened at given coordinates" + ); + + const actualSizes = windows + .slice(0, 3) + .map(window => `${window.outerWidth}x${window.outerHeight}`); + const expectedSizes = [`151x152`, `152x153`, `153x154`]; + is( + actualSizes.join(" / "), + expectedSizes.join(" / "), + "expected popup type windows are opened with given size" + ); + + const actualRect = { + top: windows[4].screenY, + bottom: windows[3].screenY + windows[3].outerHeight, + left: windows[4].screenX, + right: windows[3].screenX + windows[3].outerWidth, + }; + const maxRect = { + top: minTop, + bottom: maxBottom, + left: minLeft, + right: maxRight, + }; + isRectContained(actualRect, maxRect); + + for (const window of windows) { + window.close(); + } + + Services.ww.unregisterNotification(windowListener); + windows = null; + await BrowserTestUtils.closeWindow(baseWindow); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_windows_create_tabId.js b/browser/components/extensions/test/browser/browser_ext_windows_create_tabId.js new file mode 100644 index 0000000000..83fda199b7 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_windows_create_tabId.js @@ -0,0 +1,387 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +function assertNoLeaksInTabTracker() { + // Check that no tabs have been leaked by the internal tabTracker helper class. + const { ExtensionParent } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionParent.sys.mjs" + ); + const { tabTracker } = ExtensionParent.apiManager.global; + + for (const [tabId, nativeTab] of tabTracker._tabIds) { + if (!nativeTab.ownerGlobal) { + ok( + false, + `A tab with tabId ${tabId} has been leaked in the tabTracker ("${nativeTab.title}")` + ); + } + } +} + +add_task(async function testWindowCreate() { + async function background() { + let promiseTabAttached = () => { + return new Promise(resolve => { + browser.tabs.onAttached.addListener(function listener() { + browser.tabs.onAttached.removeListener(listener); + resolve(); + }); + }); + }; + + let promiseTabUpdated = expected => { + return new Promise(resolve => { + browser.tabs.onUpdated.addListener(function listener( + tabId, + changeInfo, + tab + ) { + if (changeInfo.url === expected) { + browser.tabs.onUpdated.removeListener(listener); + resolve(); + } + }); + }); + }; + + try { + let window = await browser.windows.getCurrent(); + let windowId = window.id; + + browser.test.log("Create additional tab in window 1"); + let tab = await browser.tabs.create({ windowId, url: "about:blank" }); + let tabId = tab.id; + + browser.test.log("Create a new window, adopting the new tab"); + + // Note that we want to check against actual boolean values for + // all of the `incognito` property tests. + browser.test.assertEq(false, tab.incognito, "Tab is not private"); + + { + let [, window] = await Promise.all([ + promiseTabAttached(), + browser.windows.create({ tabId: tabId }), + ]); + browser.test.assertEq( + false, + window.incognito, + "New window is not private" + ); + browser.test.assertEq( + tabId, + window.tabs[0].id, + "tabs property populated correctly" + ); + + browser.test.log("Close the new window"); + await browser.windows.remove(window.id); + } + + { + browser.test.log("Create a new private window"); + let privateWindow = await browser.windows.create({ incognito: true }); + browser.test.assertEq( + true, + privateWindow.incognito, + "Private window is private" + ); + + browser.test.log("Create additional tab in private window"); + let privateTab = await browser.tabs.create({ + windowId: privateWindow.id, + }); + browser.test.assertEq( + true, + privateTab.incognito, + "Private tab is private" + ); + + browser.test.log("Create a new window, adopting the new private tab"); + let [, newWindow] = await Promise.all([ + promiseTabAttached(), + browser.windows.create({ tabId: privateTab.id }), + ]); + browser.test.assertEq( + true, + newWindow.incognito, + "New private window is private" + ); + + browser.test.log("Close the new private window"); + await browser.windows.remove(newWindow.id); + + browser.test.log("Close the private window"); + await browser.windows.remove(privateWindow.id); + } + + browser.test.log("Try to create a window with both a tab and a URL"); + [tab] = await browser.tabs.query({ windowId, active: true }); + await browser.test.assertRejects( + browser.windows.create({ tabId: tab.id, url: "http://example.com/" }), + /`tabId` may not be used in conjunction with `url`/, + "Create call failed as expected" + ); + + browser.test.log( + "Try to create a window with both a tab and an invalid incognito setting" + ); + await browser.test.assertRejects( + browser.windows.create({ tabId: tab.id, incognito: true }), + /`incognito` property must match the incognito state of tab/, + "Create call failed as expected" + ); + + browser.test.log("Try to create a window with an invalid tabId"); + await browser.test.assertRejects( + browser.windows.create({ tabId: 0 }), + /Invalid tab ID: 0/, + "Create call failed as expected" + ); + + browser.test.log("Try to create a window with two URLs"); + let readyPromise = Promise.all([ + // tabs.onUpdated can be invoked between the call of windows.create and + // the invocation of its callback/promise, so set up the listeners + // before creating the window. + promiseTabUpdated("http://example.com/"), + promiseTabUpdated("http://example.org/"), + ]); + + window = await browser.windows.create({ + url: ["http://example.com/", "http://example.org/"], + }); + await readyPromise; + + browser.test.assertEq( + 2, + window.tabs.length, + "2 tabs were opened in new window" + ); + browser.test.assertEq( + "about:blank", + window.tabs[0].url, + "about:blank, page not loaded yet" + ); + browser.test.assertEq( + "about:blank", + window.tabs[1].url, + "about:blank, page not loaded yet" + ); + + window = await browser.windows.get(window.id, { populate: true }); + + browser.test.assertEq( + 2, + window.tabs.length, + "2 tabs were opened in new window" + ); + browser.test.assertEq( + "http://example.com/", + window.tabs[0].url, + "Correct URL was loaded in tab 1" + ); + browser.test.assertEq( + "http://example.org/", + window.tabs[1].url, + "Correct URL was loaded in tab 2" + ); + + await browser.windows.remove(window.id); + + browser.test.notifyPass("window-create"); + } catch (e) { + browser.test.fail(`${e} :: ${e.stack}`); + browser.test.notifyFail("window-create"); + } + } + + let extension = ExtensionTestUtils.loadExtension({ + incognitoOverride: "spanning", + manifest: { + permissions: ["tabs"], + }, + + background, + }); + + await extension.startup(); + await extension.awaitFinish("window-create"); + await extension.unload(); + + assertNoLeaksInTabTracker(); +}); + +add_task(async function testWebNavigationOnWindowCreateTabId() { + async function background() { + const webNavEvents = []; + const onceTabsAttached = []; + + let promiseTabAttached = tab => { + return new Promise(resolve => { + browser.tabs.onAttached.addListener(function listener(tabId) { + if (tabId !== tab.id) { + return; + } + browser.tabs.onAttached.removeListener(listener); + resolve(); + }); + }); + }; + + // Listen to webNavigation.onCompleted events to ensure that + // it is not going to be fired when we move the existent tabs + // to new windows. + browser.webNavigation.onCompleted.addListener(data => { + webNavEvents.push(data); + }); + + // Wait for the list of urls needed to select the test tabs, + // and then move these tabs to a new window and assert that + // no webNavigation.onCompleted events should be received + // while the tabs are being adopted into the new windows. + browser.test.onMessage.addListener(async (msg, testTabURLs) => { + if (msg !== "testTabURLs") { + return; + } + + // Retrieve the tabs list and filter out the tabs that should + // not be moved into a new window. + let allTabs = await browser.tabs.query({}); + let testTabs = allTabs.filter(tab => { + return testTabURLs.includes(tab.url); + }); + + browser.test.assertEq( + 2, + testTabs.length, + "Got the expected number of test tabs" + ); + + for (let tab of testTabs) { + onceTabsAttached.push(promiseTabAttached(tab)); + await browser.windows.create({ tabId: tab.id }); + } + + // Wait the tabs to have been attached to the new window and then assert that no + // webNavigation.onCompleted event has been received. + browser.test.log("Waiting tabs move to new window to be attached"); + await Promise.all(onceTabsAttached); + + browser.test.assertEq( + "[]", + JSON.stringify(webNavEvents), + "No webNavigation.onCompleted event should have been received" + ); + + // Remove all the test tabs before exiting the test successfully. + for (let tab of testTabs) { + await browser.tabs.remove(tab.id); + } + + browser.test.notifyPass("webNavigation-on-window-create-tabId"); + }); + } + + const testURLs = ["http://example.com/", "http://example.org/"]; + + for (let url of testURLs) { + await BrowserTestUtils.openNewForegroundTab(gBrowser, url); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs", "webNavigation"], + }, + background, + }); + + await extension.startup(); + + await extension.sendMessage("testTabURLs", testURLs); + + await extension.awaitFinish("webNavigation-on-window-create-tabId"); + await extension.unload(); + + assertNoLeaksInTabTracker(); +}); + +add_task(async function testGetLastFocusedDoesNotLeakDuringTabAdoption() { + async function background() { + const allTabs = await browser.tabs.query({}); + + browser.test.onMessage.addListener(async (msg, testTabURL) => { + if (msg !== "testTabURL") { + return; + } + + let tab = allTabs.filter(tab => tab.url === testTabURL).pop(); + + // Keep calling getLastFocused while browser.windows.create is creating + // a new window to adopt the test tab, so that the test recreates + // conditions similar to the extension that has been triggered this leak + // (See Bug 1458918 for a rationale). + // The while loop is explicited exited right before the notifyPass + // (but unloading the extension will stop it in any case). + let stopGetLastFocusedLoop = false; + Promise.resolve().then(async () => { + while (!stopGetLastFocusedLoop) { + browser.windows.getLastFocused({ populate: true }); + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 50)); + } + }); + + // Create a new window which adopt an existent tab and wait the tab to + // be fully attached to the new window. + await Promise.all([ + new Promise(resolve => { + const listener = () => { + browser.tabs.onAttached.removeListener(listener); + resolve(); + }; + browser.tabs.onAttached.addListener(listener); + }), + browser.windows.create({ tabId: tab.id }), + ]); + + // Check that getLastFocused populate the tabs property once the tab adoption + // has been completed. + const lastFocusedPopulate = await browser.windows.getLastFocused({ + populate: true, + }); + browser.test.assertEq( + 1, + lastFocusedPopulate.tabs.length, + "Got the expected number of tabs from windows.getLastFocused" + ); + + // Remove the test tab. + await browser.tabs.remove(tab.id); + + stopGetLastFocusedLoop = true; + + browser.test.notifyPass("tab-adopted"); + }); + } + + await BrowserTestUtils.openNewForegroundTab(gBrowser, "http://example.com/"); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs", "webNavigation"], + }, + background, + }); + + await extension.startup(); + + extension.sendMessage("testTabURL", "http://example.com/"); + + await extension.awaitFinish("tab-adopted"); + + await extension.unload(); + + assertNoLeaksInTabTracker(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_windows_create_url.js b/browser/components/extensions/test/browser/browser_ext_windows_create_url.js new file mode 100644 index 0000000000..7c847382c5 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_windows_create_url.js @@ -0,0 +1,253 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function testWindowCreate() { + let pageExt = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { gecko: { id: "page@mochitest" } }, + protocol_handlers: [ + { + protocol: "ext+foo", + name: "a foo protocol handler", + uriTemplate: "page.html?val=%s", + }, + ], + }, + files: { + "page.html": `<html><head> + <meta charset="utf-8"> + </head></html>`, + }, + }); + await pageExt.startup(); + + async function background(OTHER_PAGE) { + browser.test.log(`== using ${OTHER_PAGE}`); + const EXTENSION_URL = browser.runtime.getURL("test.html"); + const EXT_PROTO = "ext+bar:foo"; + const OTHER_PROTO = "ext+foo:bar"; + + let windows = new (class extends Map { + // eslint-disable-line new-parens + get(id) { + if (!this.has(id)) { + let window = { + tabs: new Map(), + }; + window.promise = new Promise(resolve => { + window.resolvePromise = resolve; + }); + + this.set(id, window); + } + + return super.get(id); + } + })(); + + browser.tabs.onUpdated.addListener((tabId, changed, tab) => { + if (changed.status == "complete" && tab.url !== "about:blank") { + let window = windows.get(tab.windowId); + window.tabs.set(tab.index, tab); + + if (window.tabs.size === window.expectedTabs) { + browser.test.log("resolving a window load"); + window.resolvePromise(window); + } + } + }); + + async function create(options) { + browser.test.log(`creating window for ${options.url}`); + // Note: may reject + let window = await browser.windows.create(options); + let win = windows.get(window.id); + win.id = window.id; + + win.expectedTabs = Array.isArray(options.url) ? options.url.length : 1; + + return win.promise; + } + + let TEST_SETS = [ + { + name: "Single protocol URL in this extension", + url: EXT_PROTO, + expect: [`${EXTENSION_URL}?val=ext%2Bbar%3Afoo`], + }, + { + name: "Single, relative URL", + url: "test.html", + expect: [EXTENSION_URL], + }, + { + name: "Single, absolute, extension URL", + url: EXTENSION_URL, + expect: [EXTENSION_URL], + }, + { + // This is primarily for backwards-compatibility, to allow extensions + // to open other home pages. This test case opens the home page + // explicitly; the implicit case (windows.create({}) without URL) is at + // browser_ext_chrome_settings_overrides_home.js. + name: "Single, absolute, other extension URL", + url: OTHER_PAGE, + expect: [OTHER_PAGE], + }, + { + // This is oddly inconsistent with the non-array case, but here we are + // intentionally stricter because of lesser backwards-compatibility + // concerns. + name: "Array, absolute, other extension URL", + url: [OTHER_PAGE], + expectError: `Illegal URL: ${OTHER_PAGE}`, + }, + { + name: "Single protocol URL in other extension", + url: OTHER_PROTO, + expect: [`${OTHER_PAGE}?val=ext%2Bfoo%3Abar`], + }, + { + name: "Single, about:blank", + // Added "?" after "about:blank" because the test's tab load detection + // ignores about:blank. + url: "about:blank?", + expect: ["about:blank?"], + }, + { + name: "multiple urls", + url: [EXT_PROTO, "test.html", EXTENSION_URL, OTHER_PROTO], + expect: [ + `${EXTENSION_URL}?val=ext%2Bbar%3Afoo`, + EXTENSION_URL, + EXTENSION_URL, + `${OTHER_PAGE}?val=ext%2Bfoo%3Abar`, + ], + }, + { + name: "Reject array of own allowed URLs and other moz-extension:-URL", + url: [EXTENSION_URL, EXT_PROTO, "about:blank?#", OTHER_PAGE], + expectError: `Illegal URL: ${OTHER_PAGE}`, + }, + { + name: "Single, about:robots", + url: "about:robots", + expectError: "Illegal URL: about:robots", + }, + { + name: "Array containing about:robots", + url: ["about:robots"], + expectError: "Illegal URL: about:robots", + }, + ]; + async function checkCreateResult({ status, value, reason }, testCase) { + const window = status === "fulfilled" ? value : null; + try { + if (testCase.expectError) { + let error = reason?.message; + browser.test.assertEq(testCase.expectError, error, testCase.name); + } else { + let tabUrls = []; + for (let [tabIndex, tab] of window.tabs) { + tabUrls[tabIndex] = tab.url; + } + browser.test.assertDeepEq(testCase.expect, tabUrls, testCase.name); + } + } catch (e) { + browser.test.fail(`Unexpected failure in ${testCase.name} :: ${e}`); + } finally { + // Close opened windows, whether they were expected or not. + if (window) { + await browser.windows.remove(window.id); + } + } + } + try { + let promises = []; + for (let testSet of TEST_SETS) { + try { + let testPromise = create({ url: testSet.url }); + promises.push(testPromise); + // Bug 1869385 - we need to await each window opening sequentially. + // The events we check for depend on paint finishing, + // which won't happen if the window is occluded before it finishes + // loading. + await testPromise; + } catch (e) { + // Some of these calls are expected to fail, + // which we verify when calling checkCreateResult below. + } + } + const results = await Promise.allSettled(promises); + await Promise.all( + TEST_SETS.map((t, i) => checkCreateResult(results[i], t)) + ); + browser.test.notifyPass("window-create-url"); + } catch (e) { + browser.test.fail(`${e} :: ${e.stack}`); + browser.test.notifyFail("window-create-url"); + } + } + + // Watch for any permission prompts to show up and accept them. + let dialogCount = 0; + let windowObserver = window => { + // This listener will go away when the window is closed so there is no need + // to explicitely remove it. + // eslint-disable-next-line mozilla/balanced-listeners + window.addEventListener("dialogopen", event => { + dialogCount++; + let { dialog } = event.detail; + Assert.equal( + dialog?._openedURL, + "chrome://mozapps/content/handling/permissionDialog.xhtml", + "Should only ever see the permission dialog" + ); + let dialogEl = dialog._frame.contentDocument.querySelector("dialog"); + Assert.ok(dialogEl, "Dialog element should exist"); + dialogEl.setAttribute("buttondisabledaccept", false); + dialogEl.acceptDialog(); + }); + }; + Services.obs.addObserver(windowObserver, "browser-delayed-startup-finished"); + registerCleanupFunction(() => { + Services.obs.removeObserver( + windowObserver, + "browser-delayed-startup-finished" + ); + }); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + protocol_handlers: [ + { + protocol: "ext+bar", + name: "a bar protocol handler", + uriTemplate: "test.html?val=%s", + }, + ], + }, + + background: `(${background})("moz-extension://${pageExt.uuid}/page.html")`, + + files: { + "test.html": `<!DOCTYPE html><html><head><meta charset="utf-8"></head><body></body></html>`, + }, + }); + + await extension.startup(); + await extension.awaitFinish("window-create-url"); + await extension.unload(); + await pageExt.unload(); + + Assert.equal( + dialogCount, + 2, + "Expected to see the right number of permission prompts." + ); + + // Make sure windows have been released before finishing. + Cu.forceGC(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_windows_events.js b/browser/components/extensions/test/browser/browser_ext_windows_events.js new file mode 100644 index 0000000000..aa8a2655ce --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_windows_events.js @@ -0,0 +1,222 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +SimpleTest.requestCompleteLog(); + +add_task(async function test_windows_events_not_allowed() { + let monitor = await startIncognitoMonitorExtension(); + + function background() { + browser.windows.onCreated.addListener(window => { + browser.test.log(`onCreated: windowId=${window.id}`); + + browser.test.assertTrue( + Number.isInteger(window.id), + "Window object's id is an integer" + ); + browser.test.assertEq( + "normal", + window.type, + "Window object returned with the correct type" + ); + browser.test.sendMessage("window-created", window.id); + }); + + let lastWindowId; + browser.windows.onFocusChanged.addListener(async eventWindowId => { + browser.test.log( + `onFocusChange: windowId=${eventWindowId} lastWindowId=${lastWindowId}` + ); + + browser.test.assertTrue( + lastWindowId !== eventWindowId, + "onFocusChanged fired once for the given window" + ); + lastWindowId = eventWindowId; + + browser.test.assertTrue( + Number.isInteger(eventWindowId), + "windowId is an integer" + ); + let window = await browser.windows.getLastFocused(); + browser.test.sendMessage("window-focus-changed", { + winId: eventWindowId, + lastFocusedWindowId: window.id, + }); + }); + + browser.windows.onRemoved.addListener(windowId => { + browser.test.log(`onRemoved: windowId=${windowId}`); + + browser.test.assertTrue( + Number.isInteger(windowId), + "windowId is an integer" + ); + browser.test.sendMessage("window-removed", windowId); + browser.test.notifyPass("windows.events"); + }); + + browser.test.sendMessage("ready", browser.windows.WINDOW_ID_NONE); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: {}, + background, + incognitoOverride: "spanning", + }); + + await extension.startup(); + const WINDOW_ID_NONE = await extension.awaitMessage("ready"); + + async function awaitFocusChanged() { + let windowInfo = await extension.awaitMessage("window-focus-changed"); + if (windowInfo.winId === WINDOW_ID_NONE) { + info("Ignoring a superfluous WINDOW_ID_NONE (blur) event."); + windowInfo = await extension.awaitMessage("window-focus-changed"); + } + is( + windowInfo.winId, + windowInfo.lastFocusedWindowId, + "Last focused window has the correct id" + ); + return windowInfo.winId; + } + + const { + Management: { + global: { windowTracker }, + }, + } = ChromeUtils.importESModule("resource://gre/modules/Extension.sys.mjs"); + + let currentWindow = window; + let currentWindowId = windowTracker.getId(currentWindow); + info(`Current window ID: ${currentWindowId}`); + + info("Create browser window 1"); + let win1 = await BrowserTestUtils.openNewBrowserWindow(); + let win1Id = await extension.awaitMessage("window-created"); + info(`Window 1 ID: ${win1Id}`); + + // This shouldn't be necessary, but tests intermittently fail, so let's give + // it a try. + win1.focus(); + + let winId = await awaitFocusChanged(); + is(winId, win1Id, "Got focus change event for the correct window ID."); + + info("Create browser window 2"); + let win2 = await BrowserTestUtils.openNewBrowserWindow({ private: true }); + let win2Id = await extension.awaitMessage("window-created"); + info(`Window 2 ID: ${win2Id}`); + + win2.focus(); + + winId = await awaitFocusChanged(); + is(winId, win2Id, "Got focus change event for the correct window ID."); + + info("Focus browser window 1"); + await focusWindow(win1); + + winId = await awaitFocusChanged(); + is(winId, win1Id, "Got focus change event for the correct window ID."); + + info("Close browser window 2"); + await BrowserTestUtils.closeWindow(win2); + + winId = await extension.awaitMessage("window-removed"); + is(winId, win2Id, "Got removed event for the correct window ID."); + + info("Close browser window 1"); + await BrowserTestUtils.closeWindow(win1); + + currentWindow.focus(); + + winId = await extension.awaitMessage("window-removed"); + is(winId, win1Id, "Got removed event for the correct window ID."); + + winId = await awaitFocusChanged(); + is( + winId, + currentWindowId, + "Got focus change event for the correct window ID." + ); + + await extension.awaitFinish("windows.events"); + await extension.unload(); + await monitor.unload(); +}); + +add_task(async function test_windows_event_page() { + await SpecialPowers.pushPrefEnv({ + set: [["extensions.eventPages.enabled", true]], + }); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + browser_specific_settings: { gecko: { id: "eventpage@windows" } }, + background: { persistent: false }, + }, + background() { + let removed; + browser.windows.onCreated.addListener(window => { + browser.test.sendMessage("onCreated", window.id); + }); + browser.windows.onRemoved.addListener(wid => { + removed = wid; + browser.test.sendMessage("onRemoved", wid); + }); + browser.windows.onFocusChanged.addListener(wid => { + if (wid != browser.windows.WINDOW_ID_NONE && wid != removed) { + browser.test.sendMessage("onFocusChanged", wid); + } + }); + browser.test.sendMessage("ready"); + }, + }); + + const EVENTS = ["onCreated", "onRemoved", "onFocusChanged"]; + + await extension.startup(); + await extension.awaitMessage("ready"); + for (let event of EVENTS) { + assertPersistentListeners(extension, "windows", event, { + primed: false, + }); + } + + // test events waken background + await extension.terminateBackground(); + for (let event of EVENTS) { + assertPersistentListeners(extension, "windows", event, { + primed: true, + }); + } + + let win = await BrowserTestUtils.openNewBrowserWindow(); + + await extension.awaitMessage("ready"); + let windowId = await extension.awaitMessage("onCreated"); + ok(true, "persistent event woke background"); + for (let event of EVENTS) { + assertPersistentListeners(extension, "windows", event, { + primed: false, + }); + } + // focus returns the new window + let focusedId = await extension.awaitMessage("onFocusChanged"); + Assert.equal(windowId, focusedId, "new window was focused"); + await extension.terminateBackground(); + + await BrowserTestUtils.closeWindow(win); + await extension.awaitMessage("ready"); + let removedId = await extension.awaitMessage("onRemoved"); + Assert.equal(windowId, removedId, "window was removed"); + // focus returns the window focus was passed to + focusedId = await extension.awaitMessage("onFocusChanged"); + Assert.notEqual(windowId, focusedId, "old window was focused"); + + await extension.unload(); + await SpecialPowers.popPrefEnv(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_windows_incognito.js b/browser/components/extensions/test/browser/browser_ext_windows_incognito.js new file mode 100644 index 0000000000..ef6d8a8eae --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_windows_incognito.js @@ -0,0 +1,84 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_window_incognito() { + const url = + "http://mochi.test:8888/browser/browser/components/extensions/test/browser/file_iframe_document.html"; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["http://mochi.test/"], + }, + background() { + let lastFocusedWindowId = null; + // Catch focus change events to power the test below. + browser.windows.onFocusChanged.addListener(function listener( + eventWindowId + ) { + lastFocusedWindowId = eventWindowId; + browser.windows.onFocusChanged.removeListener(listener); + }); + + browser.test.onMessage.addListener(async pbw => { + browser.test.assertEq( + browser.windows.WINDOW_ID_NONE, + lastFocusedWindowId, + "Focus on private window sends the event, but doesn't reveal windowId (without permissions)" + ); + + await browser.test.assertRejects( + browser.windows.get(pbw.windowId), + /Invalid window ID/, + "should not be able to get incognito window" + ); + await browser.test.assertRejects( + browser.windows.remove(pbw.windowId), + /Invalid window ID/, + "should not be able to remove incognito window" + ); + await browser.test.assertRejects( + browser.windows.getCurrent(), + /Invalid window/, + "should not be able to get incognito top window" + ); + await browser.test.assertRejects( + browser.windows.getLastFocused(), + /Invalid window/, + "should not be able to get incognito focused window" + ); + await browser.test.assertRejects( + browser.windows.create({ incognito: true }), + /Extension does not have permission for incognito mode/, + "should not be able to create incognito window" + ); + await browser.test.assertRejects( + browser.windows.update(pbw.windowId, { focused: true }), + /Invalid window ID/, + "should not be able to update incognito window" + ); + + let windows = await browser.windows.getAll(); + browser.test.assertEq( + 1, + windows.length, + "unable to get incognito window" + ); + + browser.test.notifyPass("pass"); + }); + }, + }); + + await extension.startup(); + + // The tests expect the incognito window to be + // created after the extension is started, so think + // carefully when moving this line. + let winData = await getIncognitoWindow(url); + + extension.sendMessage(winData.details); + await extension.awaitFinish("pass"); + await BrowserTestUtils.closeWindow(winData.win); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_windows_remove.js b/browser/components/extensions/test/browser/browser_ext_windows_remove.js new file mode 100644 index 0000000000..455987a908 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_windows_remove.js @@ -0,0 +1,53 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function testWindowRemove() { + let extension = ExtensionTestUtils.loadExtension({ + async background() { + async function closeWindow(id) { + let window = await browser.windows.get(id); + return new Promise(function (resolve) { + browser.windows.onRemoved.addListener(async function listener( + windowId + ) { + browser.windows.onRemoved.removeListener(listener); + await browser.test.assertEq( + windowId, + window.id, + "The right window was closed" + ); + await browser.test.assertRejects( + browser.windows.get(windowId), + new RegExp(`Invalid window ID: ${windowId}`), + "The window was really closed." + ); + resolve(); + }); + browser.windows.remove(id); + }); + } + + browser.test.log("Create a new window and close it by its ID"); + let newWindow = await browser.windows.create(); + await closeWindow(newWindow.id); + + browser.test.log("Create a new window and close it by WINDOW_ID_CURRENT"); + await browser.windows.create(); + await closeWindow(browser.windows.WINDOW_ID_CURRENT); + + browser.test.log("Assert failure for bad parameter."); + await browser.test.assertThrows( + () => browser.windows.remove(-3), + /-3 is too small \(must be at least -2\)/, + "Invalid windowId throws" + ); + + browser.test.notifyPass("window-remove"); + }, + }); + + await extension.startup(); + await extension.awaitFinish("window-remove"); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/browser/browser_ext_windows_size.js b/browser/components/extensions/test/browser/browser_ext_windows_size.js new file mode 100644 index 0000000000..4a4f0d8a0c --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_windows_size.js @@ -0,0 +1,122 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +/* eslint-disable mozilla/no-arbitrary-setTimeout */ +"use strict"; + +add_task(async function testWindowCreate() { + let extension = ExtensionTestUtils.loadExtension({ + async background() { + let _checkWindowPromise; + browser.test.onMessage.addListener((msg, arg) => { + if (msg == "checked-window") { + _checkWindowPromise.resolve(arg); + _checkWindowPromise = null; + } + }); + + let getWindowSize = () => { + return new Promise(resolve => { + _checkWindowPromise = { resolve }; + browser.test.sendMessage("check-window"); + }); + }; + + const KEYS = ["left", "top", "width", "height"]; + function checkGeom(expected, actual) { + for (let key of KEYS) { + browser.test.assertEq( + expected[key], + actual[key], + `Expected '${key}' value` + ); + } + } + + let windowId; + async function checkWindow(expected, retries = 5) { + let geom = await getWindowSize(); + + if (retries && KEYS.some(key => expected[key] != geom[key])) { + browser.test.log( + `Got mismatched size (${JSON.stringify( + expected + )} != ${JSON.stringify(geom)}). Retrying after a short delay.` + ); + + await new Promise(resolve => setTimeout(resolve, 200)); + + return checkWindow(expected, retries - 1); + } + + browser.test.log(`Check actual window size`); + checkGeom(expected, geom); + + browser.test.log("Check API-reported window size"); + + geom = await browser.windows.get(windowId); + + checkGeom(expected, geom); + } + + try { + let geom = { left: 100, top: 100, width: 500, height: 300 }; + + let window = await browser.windows.create(geom); + windowId = window.id; + + await checkWindow(geom); + + let update = { left: 150, width: 600 }; + Object.assign(geom, update); + await browser.windows.update(windowId, update); + await checkWindow(geom); + + update = { top: 150, height: 400 }; + Object.assign(geom, update); + await browser.windows.update(windowId, update); + await checkWindow(geom); + + geom = { left: 200, top: 200, width: 800, height: 600 }; + await browser.windows.update(windowId, geom); + await checkWindow(geom); + + let platformInfo = await browser.runtime.getPlatformInfo(); + if (platformInfo.os != "linux") { + geom = { left: -50, top: -50, width: 800, height: 600 }; + await browser.windows.update(windowId, geom); + await checkWindow({ ...geom, left: 0, top: 0 }); + } + + await browser.windows.remove(windowId); + browser.test.notifyPass("window-size"); + } catch (e) { + browser.test.fail(`${e} :: ${e.stack}`); + browser.test.notifyFail("window-size"); + } + }, + }); + + let latestWindow; + let windowListener = (window, topic) => { + if (topic == "domwindowopened") { + latestWindow = window; + } + }; + Services.ww.registerNotification(windowListener); + + extension.onMessage("check-window", () => { + extension.sendMessage("checked-window", { + top: latestWindow.screenY, + left: latestWindow.screenX, + width: latestWindow.outerWidth, + height: latestWindow.outerHeight, + }); + }); + + await extension.startup(); + await extension.awaitFinish("window-size"); + await extension.unload(); + + Services.ww.unregisterNotification(windowListener); + latestWindow = null; +}); diff --git a/browser/components/extensions/test/browser/browser_ext_windows_update.js b/browser/components/extensions/test/browser/browser_ext_windows_update.js new file mode 100644 index 0000000000..7331b2c0cc --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_windows_update.js @@ -0,0 +1,390 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function () { + function promiseWaitForFocus(window) { + return new Promise(resolve => { + waitForFocus(function () { + Assert.strictEqual( + Services.focus.activeWindow, + window, + "correct window focused" + ); + resolve(); + }, window); + }); + } + + let window1 = window; + let window2 = await BrowserTestUtils.openNewBrowserWindow(); + + window2.focus(); + await promiseWaitForFocus(window2); + + let extension = ExtensionTestUtils.loadExtension({ + background: function () { + browser.windows.getAll(undefined, function (wins) { + browser.test.assertEq(wins.length, 2, "should have two windows"); + + // Sort the unfocused window to the lower index. + wins.sort(function (win1, win2) { + if (win1.focused === win2.focused) { + return 0; + } + + return win1.focused ? 1 : -1; + }); + + browser.windows.update(wins[0].id, { focused: true }, function () { + browser.test.sendMessage("check"); + }); + }); + }, + }); + + await Promise.all([extension.startup(), extension.awaitMessage("check")]); + + await promiseWaitForFocus(window1); + + await extension.unload(); + + await BrowserTestUtils.closeWindow(window2); +}); + +add_task(async function testWindowUpdate() { + let extension = ExtensionTestUtils.loadExtension({ + async background() { + let _checkWindowPromise; + browser.test.onMessage.addListener(msg => { + if (msg == "checked-window") { + _checkWindowPromise.resolve(); + _checkWindowPromise = null; + } + }); + + let os; + function checkWindow(expected) { + return new Promise(resolve => { + _checkWindowPromise = { resolve }; + browser.test.sendMessage("check-window", expected); + }); + } + + let currentWindowId; + async function updateWindow(windowId, params, expected, otherChecks) { + let window = await browser.windows.update(windowId, params); + + browser.test.assertEq( + currentWindowId, + window.id, + "Expected WINDOW_ID_CURRENT to refer to the same window" + ); + for (let key of Object.keys(params)) { + if (key == "state" && os == "mac" && params.state == "normal") { + // OS-X doesn't have a hard distinction between "normal" and + // "maximized" states. + browser.test.assertTrue( + window.state == "normal" || window.state == "maximized", + `Expected window.state (currently ${window.state}) to be "normal" but will accept "maximized"` + ); + } else { + browser.test.assertEq( + params[key], + window[key], + `Got expected value for window.${key}` + ); + } + } + if (otherChecks) { + for (let key of Object.keys(otherChecks)) { + browser.test.assertEq( + otherChecks[key], + window[key], + `Got expected value for window.${key}` + ); + } + } + + return checkWindow(expected); + } + + try { + let windowId = browser.windows.WINDOW_ID_CURRENT; + + ({ os } = await browser.runtime.getPlatformInfo()); + + let window = await browser.windows.getCurrent(); + currentWindowId = window.id; + + // Store current, "normal" width and height to compare against + // window width and height after updating to "normal" state. + let normalWidth = window.width; + let normalHeight = window.height; + + await updateWindow( + windowId, + { state: "maximized" }, + { state: "STATE_MAXIMIZED" } + ); + await updateWindow( + windowId, + { state: "normal" }, + { state: "STATE_NORMAL" }, + { width: normalWidth, height: normalHeight } + ); + await updateWindow( + windowId, + { state: "minimized" }, + { state: "STATE_MINIMIZED" } + ); + await updateWindow( + windowId, + { state: "normal" }, + { state: "STATE_NORMAL" }, + { width: normalWidth, height: normalHeight } + ); + await updateWindow( + windowId, + { state: "fullscreen" }, + { state: "STATE_FULLSCREEN" } + ); + await updateWindow( + windowId, + { state: "normal" }, + { state: "STATE_NORMAL" }, + { width: normalWidth, height: normalHeight } + ); + + browser.test.notifyPass("window-update"); + } catch (e) { + browser.test.fail(`${e} :: ${e.stack}`); + browser.test.notifyFail("window-update"); + } + }, + }); + + extension.onMessage("check-window", expected => { + if (expected.state != null) { + let { windowState } = window; + if (window.fullScreen) { + windowState = window.STATE_FULLSCREEN; + } + + // Temporarily accepting STATE_MAXIMIZED on Linux because of bug 1307759. + if ( + expected.state == "STATE_NORMAL" && + (AppConstants.platform == "macosx" || AppConstants.platform == "linux") + ) { + ok( + windowState == window.STATE_NORMAL || + windowState == window.STATE_MAXIMIZED, + `Expected windowState (currently ${windowState}) to be STATE_NORMAL but will accept STATE_MAXIMIZED` + ); + } else { + is( + windowState, + window[expected.state], + `Expected window state to be ${expected.state}` + ); + } + } + + extension.sendMessage("checked-window"); + }); + + await extension.startup(); + await extension.awaitFinish("window-update"); + await extension.unload(); +}); + +add_task(async function () { + let window2 = await BrowserTestUtils.openNewBrowserWindow(); + + let extension = ExtensionTestUtils.loadExtension({ + background: function () { + browser.windows.getAll(undefined, function (wins) { + browser.test.assertEq(wins.length, 2, "should have two windows"); + + let unfocused = wins.find(win => !win.focused); + browser.windows.update( + unfocused.id, + { drawAttention: true }, + function () { + browser.test.sendMessage("check"); + } + ); + }); + }, + }); + + await Promise.all([extension.startup(), extension.awaitMessage("check")]); + + await extension.unload(); + + await BrowserTestUtils.closeWindow(window2); +}); + +// Tests that incompatible parameters can't be used together. +add_task(async function testWindowUpdateParams() { + let extension = ExtensionTestUtils.loadExtension({ + async background() { + try { + for (let state of ["minimized", "maximized", "fullscreen"]) { + for (let param of ["left", "top", "width", "height"]) { + let expected = `"state": "${state}" may not be combined with "left", "top", "width", or "height"`; + + let windowId = browser.windows.WINDOW_ID_CURRENT; + await browser.test.assertRejects( + browser.windows.update(windowId, { state, [param]: 100 }), + RegExp(expected), + `Got expected error for create(${param}=100` + ); + } + } + + browser.test.notifyPass("window-update-params"); + } catch (e) { + browser.test.fail(`${e} :: ${e.stack}`); + browser.test.notifyFail("window-update-params"); + } + }, + }); + + await extension.startup(); + await extension.awaitFinish("window-update-params"); + await extension.unload(); +}); + +add_task(async function testPositionBoundaryCheck() { + const extension = ExtensionTestUtils.loadExtension({ + async background() { + function waitMessage() { + return new Promise((resolve, reject) => { + const onMessage = message => { + if (message == "continue") { + browser.test.onMessage.removeListener(onMessage); + resolve(); + } + }; + browser.test.onMessage.addListener(onMessage); + }); + } + const win = await browser.windows.create({ + type: "popup", + left: 50, + top: 50, + width: 150, + height: 150, + }); + await browser.test.sendMessage("ready"); + await waitMessage(); + await browser.windows.update(win.id, { + left: 123, + top: 123, + }); + await browser.test.sendMessage("regular"); + await waitMessage(); + await browser.windows.update(win.id, { + left: 123, + }); + await browser.test.sendMessage("only-left"); + await waitMessage(); + await browser.windows.update(win.id, { + top: 123, + }); + await browser.test.sendMessage("only-top"); + await waitMessage(); + await browser.windows.update(win.id, { + left: screen.availWidth * 100, + top: screen.availHeight * 100, + }); + await browser.test.sendMessage("too-large"); + await waitMessage(); + await browser.windows.update(win.id, { + left: -screen.availWidth * 100, + top: -screen.availHeight * 100, + }); + await browser.test.sendMessage("too-small"); + }, + }); + + const promisedWin = new Promise((resolve, reject) => { + const windowListener = (window, topic) => { + if (topic == "domwindowopened") { + Services.ww.unregisterNotification(windowListener); + resolve(window); + } + }; + Services.ww.registerNotification(windowListener); + }); + + await extension.startup(); + + const win = await promisedWin; + + const regularScreen = getScreenAt(0, 0, 150, 150); + const roundedX = roundCssPixcel(123, regularScreen); + const roundedY = roundCssPixcel(123, regularScreen); + + const availRectLarge = getCssAvailRect( + getScreenAt(screen.width * 100, screen.height * 100, 150, 150) + ); + const maxRight = availRectLarge.right; + const maxBottom = availRectLarge.bottom; + + const availRectSmall = getCssAvailRect( + getScreenAt(-screen.width * 100, -screen.height * 100, 150, 150) + ); + const minLeft = availRectSmall.left; + const minTop = availRectSmall.top; + + const expectedCoordinates = [ + `${roundedX},${roundedY}`, + `${roundedX},${win.screenY}`, + `${win.screenX},${roundedY}`, + ]; + + await extension.awaitMessage("ready"); + + const actualCoordinates = []; + extension.sendMessage("continue"); + await extension.awaitMessage("regular"); + actualCoordinates.push(`${win.screenX},${win.screenY}`); + win.moveTo(50, 50); + extension.sendMessage("continue"); + await extension.awaitMessage("only-left"); + actualCoordinates.push(`${win.screenX},${win.screenY}`); + win.moveTo(50, 50); + extension.sendMessage("continue"); + await extension.awaitMessage("only-top"); + actualCoordinates.push(`${win.screenX},${win.screenY}`); + is( + actualCoordinates.join(" / "), + expectedCoordinates.join(" / "), + "expected window is placed at given coordinates" + ); + + const actualRect = {}; + const maxRect = { + top: minTop, + bottom: maxBottom, + left: minLeft, + right: maxRight, + }; + + extension.sendMessage("continue"); + await extension.awaitMessage("too-large"); + actualRect.right = win.screenX + win.outerWidth; + actualRect.bottom = win.screenY + win.outerHeight; + + extension.sendMessage("continue"); + await extension.awaitMessage("too-small"); + actualRect.top = win.screenY; + actualRect.left = win.screenX; + + isRectContained(actualRect, maxRect); + + await extension.unload(); + await BrowserTestUtils.closeWindow(win); +}); diff --git a/browser/components/extensions/test/browser/browser_legacy_recent_tabs.toml b/browser/components/extensions/test/browser/browser_legacy_recent_tabs.toml new file mode 100644 index 0000000000..ae23831a0d --- /dev/null +++ b/browser/components/extensions/test/browser/browser_legacy_recent_tabs.toml @@ -0,0 +1,34 @@ +[DEFAULT] +# +# This manifest lists tests that use cover the session API and recently-closed tabs behavior with the legacy pref values +# +tags = "webextensions" +dupe-manifest = true +prefs = [ + "browser.sessionstore.closedTabsFromAllWindows=false", + "browser.sessionstore.closedTabsFromClosedWindows=false", +] +support-files = [ + "head.js", + "empty.xpi", +] + +["browser_ext_sessions_forgetClosedTab.js"] + +["browser_ext_sessions_forgetClosedWindow.js"] + +["browser_ext_sessions_getRecentlyClosed.js"] + +["browser_ext_sessions_getRecentlyClosed_private.js"] + +["browser_ext_sessions_getRecentlyClosed_tabs.js"] + +["browser_ext_sessions_incognito.js"] + +["browser_ext_sessions_restore.js"] + +["browser_ext_sessions_restoreTab.js"] + +["browser_ext_sessions_restore_private.js"] + +["browser_ext_sessions_window_tab_value.js"] diff --git a/browser/components/extensions/test/browser/browser_toolbar_prefers_color_scheme.js b/browser/components/extensions/test/browser/browser_toolbar_prefers_color_scheme.js new file mode 100644 index 0000000000..ae7f488f0a --- /dev/null +++ b/browser/components/extensions/test/browser/browser_toolbar_prefers_color_scheme.js @@ -0,0 +1,266 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +const kDark = 0; +const kLight = 1; +const kSystem = 2; + +// The above tests should be enough to make sure that the prefs behave as +// expected, the following ones test various edge cases in a simpler way. +async function testTheme(description, toolbar, content, themeManifestData) { + info(description); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { + id: "dummy@mochi.test", + }, + }, + ...themeManifestData, + }, + }); + + await Promise.all([ + TestUtils.topicObserved("lightweight-theme-styling-update"), + extension.startup(), + ]); + + is( + SpecialPowers.getIntPref("browser.theme.toolbar-theme"), + toolbar, + "Toolbar theme expected" + ); + is( + SpecialPowers.getIntPref("browser.theme.content-theme"), + content, + "Content theme expected" + ); + + await Promise.all([ + TestUtils.topicObserved("lightweight-theme-styling-update"), + extension.unload(), + ]); +} + +add_task(async function test_dark_toolbar_dark_text() { + // Bug 1743010 + await testTheme( + "Dark toolbar color, dark toolbar background", + kDark, + kSystem, + { + theme: { + colors: { + toolbar: "rgb(20, 17, 26)", + toolbar_text: "rgb(251, 29, 78)", + }, + }, + } + ); + + // Dark frame text is ignored as it might be overlaid with an image, + // see bug 1741931. + await testTheme("Dark frame is ignored", kLight, kSystem, { + theme: { + colors: { + frame: "#000000", + tab_background_text: "#000000", + }, + }, + }); + + await testTheme( + "Semi-transparent toolbar backgrounds are ignored.", + kLight, + kSystem, + { + theme: { + colors: { + toolbar: "rgba(0, 0, 0, .2)", + toolbar_text: "#000", + }, + }, + } + ); +}); + +add_task(async function dark_theme_presence_overrides_heuristics() { + const systemScheme = window.matchMedia("(-moz-system-dark-theme)").matches + ? kDark + : kLight; + await testTheme( + "darkTheme presence overrides heuristics", + systemScheme, + kSystem, + { + theme: { + colors: { + toolbar: "#000", + toolbar_text: "#fff", + }, + }, + dark_theme: { + colors: { + toolbar: "#000", + toolbar_text: "#fff", + }, + }, + } + ); +}); + +add_task(async function color_scheme_override() { + await testTheme( + "color_scheme overrides toolbar / toolbar_text pair (dark)", + kDark, + kDark, + { + theme: { + colors: { + toolbar: "#fff", + toolbar_text: "#000", + }, + properties: { + color_scheme: "dark", + }, + }, + } + ); + + await testTheme( + "color_scheme overrides toolbar / toolbar_text pair (light)", + kLight, + kLight, + { + theme: { + colors: { + toolbar: "#000", + toolbar_text: "#fff", + }, + properties: { + color_scheme: "light", + }, + }, + } + ); + + await testTheme( + "content_color_scheme overrides ntp_text / ntp_background (dark)", + kLight, + kDark, + { + theme: { + colors: { + toolbar: "#fff", + toolbar_text: "#000", + ntp_background: "#fff", + ntp_text: "#000", + }, + properties: { + content_color_scheme: "dark", + }, + }, + } + ); + + await testTheme( + "content_color_scheme overrides ntp_text / ntp_background (light)", + kLight, + kLight, + { + theme: { + colors: { + toolbar: "#fff", + toolbar_text: "#000", + ntp_background: "#000", + ntp_text: "#fff", + }, + properties: { + content_color_scheme: "light", + }, + }, + } + ); + + await testTheme( + "content_color_scheme overrides color_scheme only for content", + kLight, + kDark, + { + theme: { + colors: { + toolbar: "#fff", + toolbar_text: "#000", + ntp_background: "#fff", + ntp_text: "#000", + }, + properties: { + content_color_scheme: "dark", + }, + }, + } + ); + + await testTheme( + "content_color_scheme sytem overrides color_scheme only for content", + kLight, + kSystem, + { + theme: { + colors: { + toolbar: "#fff", + toolbar_text: "#000", + ntp_background: "#fff", + ntp_text: "#000", + }, + properties: { + content_color_scheme: "system", + }, + }, + } + ); + + await testTheme("color_scheme: sytem override", kSystem, kSystem, { + theme: { + colors: { + toolbar: "#fff", + toolbar_text: "#000", + ntp_background: "#fff", + ntp_text: "#000", + }, + properties: { + color_scheme: "system", + content_color_scheme: "system", + }, + }, + }); +}); + +add_task(async function unified_theme() { + await SpecialPowers.pushPrefEnv({ + set: [["browser.theme.unified-color-scheme", true]], + }); + + await testTheme("Dark toolbar color", kDark, kDark, { + theme: { + colors: { + toolbar: "rgb(20, 17, 26)", + toolbar_text: "rgb(251, 29, 78)", + }, + }, + }); + + await testTheme("Light toolbar color", kLight, kLight, { + theme: { + colors: { + toolbar: "white", + toolbar_text: "black", + }, + }, + }); + + await SpecialPowers.popPrefEnv(); +}); diff --git a/browser/components/extensions/test/browser/browser_unified_extensions.js b/browser/components/extensions/test/browser/browser_unified_extensions.js new file mode 100644 index 0000000000..7ab7753c0e --- /dev/null +++ b/browser/components/extensions/test/browser/browser_unified_extensions.js @@ -0,0 +1,1545 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +requestLongerTimeout(2); + +/* import-globals-from ../../../../../toolkit/mozapps/extensions/test/browser/head.js */ + +const { ExtensionPermissions } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionPermissions.sys.mjs" +); + +loadTestSubscript("head_unified_extensions.js"); + +const openCustomizationUI = async () => { + const customizationReady = BrowserTestUtils.waitForEvent( + gNavToolbox, + "customizationready" + ); + gCustomizeMode.enter(); + await customizationReady; + ok( + CustomizationHandler.isCustomizing(), + "expected customizing mode to be enabled" + ); +}; + +const closeCustomizationUI = async () => { + const afterCustomization = BrowserTestUtils.waitForEvent( + gNavToolbox, + "aftercustomization" + ); + gCustomizeMode.exit(); + await afterCustomization; + ok( + !CustomizationHandler.isCustomizing(), + "expected customizing mode to be disabled" + ); +}; + +add_setup(async function () { + // Make sure extension buttons added to the navbar will not overflow in the + // panel, which could happen when a previous test file resizes the current + // window. + await ensureMaximizedWindow(window); +}); + +add_task(async function test_button_enabled_by_pref() { + const { button } = gUnifiedExtensions; + is(button.hidden, false, "expected button to be visible"); + is( + document + .getElementById("nav-bar") + .getAttribute("unifiedextensionsbuttonshown"), + "true", + "expected attribute on nav-bar" + ); +}); + +add_task(async function test_open_panel_on_button_click() { + const extensions = createExtensions([ + { name: "Extension #1" }, + { name: "Another extension", icons: { 16: "test-icon-16.png" } }, + { + name: "Yet another extension with an icon", + icons: { + 32: "test-icon-32.png", + }, + }, + ]); + await Promise.all(extensions.map(extension => extension.startup())); + + await openExtensionsPanel(); + + let item = getUnifiedExtensionsItem(extensions[0].id); + is( + item.querySelector(".unified-extensions-item-name").textContent, + "Extension #1", + "expected name of the first extension" + ); + is( + item.querySelector(".unified-extensions-item-icon").src, + "chrome://mozapps/skin/extensions/extensionGeneric.svg", + "expected generic icon for the first extension" + ); + Assert.deepEqual( + document.l10n.getAttributes( + item.querySelector(".unified-extensions-item-menu-button") + ), + { + id: "unified-extensions-item-open-menu", + args: { extensionName: "Extension #1" }, + }, + "expected l10n attributes for the first extension" + ); + + item = getUnifiedExtensionsItem(extensions[1].id); + is( + item.querySelector(".unified-extensions-item-name").textContent, + "Another extension", + "expected name of the second extension" + ); + ok( + item + .querySelector(".unified-extensions-item-icon") + .src.endsWith("/test-icon-16.png"), + "expected custom icon for the second extension" + ); + Assert.deepEqual( + document.l10n.getAttributes( + item.querySelector(".unified-extensions-item-menu-button") + ), + { + id: "unified-extensions-item-open-menu", + args: { extensionName: "Another extension" }, + }, + "expected l10n attributes for the second extension" + ); + + item = getUnifiedExtensionsItem(extensions[2].id); + is( + item.querySelector(".unified-extensions-item-name").textContent, + "Yet another extension with an icon", + "expected name of the third extension" + ); + ok( + item + .querySelector(".unified-extensions-item-icon") + .src.endsWith("/test-icon-32.png"), + "expected custom icon for the third extension" + ); + Assert.deepEqual( + document.l10n.getAttributes( + item.querySelector(".unified-extensions-item-menu-button") + ), + { + id: "unified-extensions-item-open-menu", + args: { extensionName: "Yet another extension with an icon" }, + }, + "expected l10n attributes for the third extension" + ); + + await closeExtensionsPanel(); + + await Promise.all(extensions.map(extension => extension.unload())); +}); + +// Verify that the context click doesn't open the panel in addition to the +// context menu. +add_task(async function test_clicks_on_unified_extension_button() { + const extensions = createExtensions([{ name: "Extension #1" }]); + await Promise.all(extensions.map(extension => extension.startup())); + + const { button, panel } = gUnifiedExtensions; + ok(button, "expected button"); + ok(panel, "expected panel"); + + info("open panel with primary click"); + await openExtensionsPanel(); + Assert.strictEqual( + panel.getAttribute("panelopen"), + "true", + "expected panel to be visible" + ); + await closeExtensionsPanel(); + ok(!panel.hasAttribute("panelopen"), "expected panel to be hidden"); + + info("open context menu with non-primary click"); + const contextMenu = document.getElementById("toolbar-context-menu"); + const popupShownPromise = BrowserTestUtils.waitForEvent( + contextMenu, + "popupshown" + ); + EventUtils.synthesizeMouseAtCenter(button, { + type: "contextmenu", + button: 2, + }); + await popupShownPromise; + ok(!panel.hasAttribute("panelopen"), "expected panel to remain hidden"); + await closeChromeContextMenu(contextMenu.id, null); + + // On MacOS, ctrl-click shouldn't open the panel because this normally opens + // the context menu. We can't test anything on MacOS... + if (AppConstants.platform !== "macosx") { + info("open panel with ctrl-click"); + const listView = getListView(); + const viewShown = BrowserTestUtils.waitForEvent(listView, "ViewShown"); + EventUtils.synthesizeMouseAtCenter(button, { ctrlKey: true }); + await viewShown; + Assert.strictEqual( + panel.getAttribute("panelopen"), + "true", + "expected panel to be visible" + ); + await closeExtensionsPanel(); + ok(!panel.hasAttribute("panelopen"), "expected panel to be hidden"); + } + + await Promise.all(extensions.map(extension => extension.unload())); +}); + +add_task(async function test_item_shows_the_best_addon_icon() { + const extensions = createExtensions([ + { + name: "Extension with different icons", + icons: { + 16: "test-icon-16.png", + 32: "test-icon-32.png", + 64: "test-icon-64.png", + 96: "test-icon-96.png", + 128: "test-icon-128.png", + }, + }, + ]); + await Promise.all(extensions.map(extension => extension.startup())); + + for (const { resolution, expectedIcon } of [ + { resolution: 2, expectedIcon: "test-icon-64.png" }, + { resolution: 1, expectedIcon: "test-icon-32.png" }, + ]) { + await SpecialPowers.pushPrefEnv({ + set: [["layout.css.devPixelsPerPx", String(resolution)]], + }); + is( + window.devicePixelRatio, + resolution, + "window has the required resolution" + ); + + await openExtensionsPanel(); + + const item = getUnifiedExtensionsItem(extensions[0].id); + const iconSrc = item.querySelector(".unified-extensions-item-icon").src; + ok( + iconSrc.endsWith(expectedIcon), + `expected ${expectedIcon}, got: ${iconSrc}` + ); + + await closeExtensionsPanel(); + await SpecialPowers.popPrefEnv(); + } + + await Promise.all(extensions.map(extension => extension.unload())); +}); + +add_task(async function test_panel_has_a_manage_extensions_button() { + await BrowserTestUtils.withNewTab( + { gBrowser, url: "about:robots" }, + async () => { + await openExtensionsPanel(); + + const manageExtensionsButton = getListView().querySelector( + "#unified-extensions-manage-extensions" + ); + ok(manageExtensionsButton, "expected a 'manage extensions' button"); + + const tabPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + "about:addons", + true + ); + const popupHiddenPromise = BrowserTestUtils.waitForEvent( + document, + "popuphidden", + true + ); + + manageExtensionsButton.click(); + + const [tab] = await Promise.all([tabPromise, popupHiddenPromise]); + is( + gBrowser.currentURI.spec, + "about:addons", + "Manage opened about:addons" + ); + is( + gBrowser.selectedBrowser.contentWindow.gViewController.currentViewId, + "addons://list/extension", + "expected about:addons to show the list of extensions" + ); + BrowserTestUtils.removeTab(tab); + } + ); +}); + +add_task(async function test_list_active_extensions_only() { + const arrayOfManifestData = [ + { + name: "hidden addon", + browser_specific_settings: { gecko: { id: "ext1@test" } }, + hidden: true, + }, + { + name: "regular addon", + browser_specific_settings: { gecko: { id: "ext2@test" } }, + hidden: false, + }, + { + name: "disabled addon", + browser_specific_settings: { gecko: { id: "ext3@test" } }, + hidden: false, + }, + { + name: "regular addon with browser action", + browser_specific_settings: { gecko: { id: "ext4@test" } }, + hidden: false, + browser_action: { + default_area: "navbar", + }, + }, + { + manifest_version: 3, + name: "regular mv3 addon with browser action", + browser_specific_settings: { gecko: { id: "ext5@test" } }, + hidden: false, + action: { + default_area: "navbar", + }, + }, + { + name: "regular addon with page action", + browser_specific_settings: { gecko: { id: "ext6@test" } }, + hidden: false, + page_action: {}, + }, + ]; + const extensions = createExtensions(arrayOfManifestData, { + useAddonManager: "temporary", + // Allow all extensions in PB mode by default. + incognitoOverride: "spanning", + }); + // This extension is loaded with a different `incognitoOverride` value to + // make sure it won't show up in a private window. + extensions.push( + ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { gecko: { id: "ext7@test" } }, + name: "regular addon with private browsing disabled", + }, + useAddonManager: "temporary", + incognitoOverride: "not_allowed", + }) + ); + + await Promise.all(extensions.map(extension => extension.startup())); + + // Disable the "disabled addon". + let addon2 = await AddonManager.getAddonByID(extensions[2].id); + await addon2.disable(); + + for (const isPrivate of [false, true]) { + info( + `verifying extensions listed in the panel with private browsing ${ + isPrivate ? "enabled" : "disabled" + }` + ); + const aWin = await BrowserTestUtils.openNewBrowserWindow({ + private: isPrivate, + }); + // Make sure extension buttons added to the navbar will not overflow in the + // panel, which could happen when a previous test file resizes the current + // window. + await ensureMaximizedWindow(aWin); + + await openExtensionsPanel(aWin); + + ok( + aWin.gUnifiedExtensions._button.open, + "Expected unified extension panel to be open" + ); + + const hiddenAddonItem = getUnifiedExtensionsItem(extensions[0].id, aWin); + is(hiddenAddonItem, null, "didn't expect an item for a hidden add-on"); + + const regularAddonItem = getUnifiedExtensionsItem(extensions[1].id, aWin); + is( + regularAddonItem.querySelector(".unified-extensions-item-name") + .textContent, + "regular addon", + "expected an item for a regular add-on" + ); + + const disabledAddonItem = getUnifiedExtensionsItem(extensions[2].id, aWin); + is(disabledAddonItem, null, "didn't expect an item for a disabled add-on"); + + const browserActionItem = getUnifiedExtensionsItem(extensions[3].id, aWin); + is( + browserActionItem, + null, + "didn't expect an item for an add-on with browser action placed in the navbar" + ); + + const mv3BrowserActionItem = getUnifiedExtensionsItem( + extensions[4].id, + aWin + ); + is( + mv3BrowserActionItem, + null, + "didn't expect an item for a MV3 add-on with browser action placed in the navbar" + ); + + const pageActionItem = getUnifiedExtensionsItem(extensions[5].id, aWin); + is( + pageActionItem.querySelector(".unified-extensions-item-name").textContent, + "regular addon with page action", + "expected an item for a regular add-on with page action" + ); + + const privateBrowsingDisabledItem = getUnifiedExtensionsItem( + extensions[6].id, + aWin + ); + if (isPrivate) { + is( + privateBrowsingDisabledItem, + null, + "didn't expect an item for a regular add-on with private browsing enabled" + ); + } else { + is( + privateBrowsingDisabledItem.querySelector( + ".unified-extensions-item-name" + ).textContent, + "regular addon with private browsing disabled", + "expected an item for a regular add-on with private browsing disabled" + ); + } + + await closeExtensionsPanel(aWin); + + await BrowserTestUtils.closeWindow(aWin); + } + + await Promise.all(extensions.map(extension => extension.unload())); +}); + +add_task(async function test_button_opens_discopane_when_no_extension() { + // The test harness registers regular extensions so we need to mock the + // `getActivePolicies` extension to simulate zero extensions installed. + const origGetActivePolicies = gUnifiedExtensions.getActivePolicies; + gUnifiedExtensions.getActivePolicies = () => []; + + await BrowserTestUtils.withNewTab( + { gBrowser, url: "about:robots" }, + async () => { + const { button } = gUnifiedExtensions; + ok(button, "expected button"); + + // Primary click should open about:addons. + const tabPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + "about:addons", + true + ); + + button.click(); + + const tab = await tabPromise; + is( + gBrowser.currentURI.spec, + "about:addons", + "expected about:addons to be open" + ); + is( + gBrowser.selectedBrowser.contentWindow.gViewController.currentViewId, + "addons://discover/", + "expected about:addons to show the recommendations" + ); + BrowserTestUtils.removeTab(tab); + + // "Right-click" should open the context menu only. + const contextMenu = document.getElementById("toolbar-context-menu"); + const popupShownPromise = BrowserTestUtils.waitForEvent( + contextMenu, + "popupshown" + ); + EventUtils.synthesizeMouseAtCenter(button, { + type: "contextmenu", + button: 2, + }); + await popupShownPromise; + await closeChromeContextMenu(contextMenu.id, null); + } + ); + + gUnifiedExtensions.getActivePolicies = origGetActivePolicies; +}); + +add_task( + async function test_button_opens_extlist_when_no_extension_and_pane_disabled() { + // If extensions.getAddons.showPane is set to false, there is no "Recommended" tab, + // so we need to make sure we don't navigate to it. + + // The test harness registers regular extensions so we need to mock the + // `getActivePolicies` extension to simulate zero extensions installed. + const origGetActivePolicies = gUnifiedExtensions.getActivePolicies; + gUnifiedExtensions.getActivePolicies = () => []; + + await SpecialPowers.pushPrefEnv({ + set: [ + // Set this to another value to make sure not to "accidentally" land on the right page + ["extensions.ui.lastCategory", "addons://list/theme"], + ["extensions.getAddons.showPane", false], + ], + }); + + await BrowserTestUtils.withNewTab( + { gBrowser, url: "about:robots" }, + async () => { + const { button } = gUnifiedExtensions; + ok(button, "expected button"); + + // Primary click should open about:addons. + const tabPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + "about:addons", + true + ); + + button.click(); + + const tab = await tabPromise; + is( + gBrowser.currentURI.spec, + "about:addons", + "expected about:addons to be open" + ); + is( + gBrowser.selectedBrowser.contentWindow.gViewController.currentViewId, + "addons://list/extension", + "expected about:addons to show the extension list" + ); + BrowserTestUtils.removeTab(tab); + } + ); + + await SpecialPowers.popPrefEnv(); + + gUnifiedExtensions.getActivePolicies = origGetActivePolicies; + } +); + +add_task( + async function test_unified_extensions_panel_not_open_in_customization_mode() { + const listView = getListView(); + ok(listView, "expected list view"); + const throwIfExecuted = () => { + throw new Error("panel should not have been shown"); + }; + listView.addEventListener("ViewShown", throwIfExecuted); + + await openCustomizationUI(); + + const unifiedExtensionsButtonToggled = BrowserTestUtils.waitForEvent( + window, + "UnifiedExtensionsTogglePanel" + ); + const button = document.getElementById("unified-extensions-button"); + + button.click(); + await unifiedExtensionsButtonToggled; + + await closeCustomizationUI(); + + listView.removeEventListener("ViewShown", throwIfExecuted); + } +); + +const NO_ACCESS = { id: "origin-controls-state-no-access", args: null }; +const QUARANTINED = { id: "origin-controls-state-quarantined", args: null }; + +const ALWAYS_ON = { id: "origin-controls-state-always-on", args: null }; +const WHEN_CLICKED = { id: "origin-controls-state-when-clicked", args: null }; +const TEMP_ACCESS = { + id: "origin-controls-state-temporary-access", + args: null, +}; + +const HOVER_RUN_VISIT_ONLY = { + id: "origin-controls-state-hover-run-visit-only", + args: null, +}; +const HOVER_RUNNABLE_RUN_EXT = { + id: "origin-controls-state-runnable-hover-run", + args: null, +}; +const HOVER_RUNNABLE_OPEN_EXT = { + id: "origin-controls-state-runnable-hover-open", + args: null, +}; + +add_task(async function test_messages_origin_controls() { + const TEST_CASES = [ + { + title: "MV2 - no access", + manifest: { + manifest_version: 2, + }, + expectedDefaultMessage: NO_ACCESS, + expectedHoverMessage: NO_ACCESS, + expectedActionButtonDisabled: true, + }, + { + title: "MV2 - always on", + manifest: { + manifest_version: 2, + host_permissions: ["*://example.com/*"], + }, + expectedDefaultMessage: ALWAYS_ON, + expectedHoverMessage: ALWAYS_ON, + expectedActionButtonDisabled: true, + }, + { + title: "MV2 - content script", + manifest: { + manifest_version: 2, + content_scripts: [ + { + js: ["script.js"], + matches: ["*://example.com/*"], + }, + ], + }, + expectedDefaultMessage: ALWAYS_ON, + expectedHoverMessage: ALWAYS_ON, + expectedActionButtonDisabled: true, + }, + { + title: "MV2 - non-matching content script", + manifest: { + manifest_version: 2, + content_scripts: [ + { + js: ["script.js"], + matches: ["*://foobar.net/*"], + }, + ], + }, + expectedDefaultMessage: NO_ACCESS, + expectedHoverMessage: NO_ACCESS, + expectedActionButtonDisabled: true, + }, + { + title: "MV2 - all_urls content script", + manifest: { + manifest_version: 2, + content_scripts: [ + { + js: ["script.js"], + matches: ["<all_urls>"], + }, + ], + }, + expectedDefaultMessage: ALWAYS_ON, + expectedHoverMessage: ALWAYS_ON, + expectedActionButtonDisabled: true, + }, + { + title: "MV2 - activeTab without browser action", + manifest: { + manifest_version: 2, + permissions: ["activeTab"], + }, + expectedDefaultMessage: NO_ACCESS, + expectedHoverMessage: NO_ACCESS, + expectedActionButtonDisabled: true, + }, + { + title: "MV2 - when clicked: activeTab with browser action", + manifest: { + manifest_version: 2, + permissions: ["activeTab"], + browser_action: {}, + }, + expectedDefaultMessage: WHEN_CLICKED, + expectedHoverMessage: HOVER_RUN_VISIT_ONLY, + expectedActionButtonDisabled: false, + }, + { + title: "MV3 - when clicked: activeTab with action", + manifest: { + manifest_version: 3, + permissions: ["activeTab"], + action: {}, + }, + expectedDefaultMessage: WHEN_CLICKED, + expectedHoverMessage: HOVER_RUN_VISIT_ONLY, + expectedActionButtonDisabled: false, + }, + { + title: "MV2 - browser action - click event - always on", + manifest: { + manifest_version: 2, + browser_action: {}, + host_permissions: ["*://example.com/*"], + }, + expectedDefaultMessage: ALWAYS_ON, + expectedHoverMessage: HOVER_RUNNABLE_RUN_EXT, + expectedActionButtonDisabled: false, + }, + { + title: "MV2 - browser action - popup - always on", + manifest: { + manifest_version: 2, + browser_action: { + default_popup: "popup.html", + }, + host_permissions: ["*://example.com/*"], + }, + expectedDefaultMessage: ALWAYS_ON, + expectedHoverMessage: HOVER_RUNNABLE_OPEN_EXT, + expectedActionButtonDisabled: false, + }, + { + title: "MV2 - browser action - click event - content script", + manifest: { + manifest_version: 2, + browser_action: {}, + content_scripts: [ + { + js: ["script.js"], + matches: ["*://example.com/*"], + }, + ], + }, + expectedDefaultMessage: ALWAYS_ON, + expectedHoverMessage: HOVER_RUNNABLE_RUN_EXT, + expectedActionButtonDisabled: false, + }, + { + title: "MV2 - browser action - popup - content script", + manifest: { + manifest_version: 2, + browser_action: { + default_popup: "popup.html", + }, + content_scripts: [ + { + js: ["script.js"], + matches: ["*://example.com/*"], + }, + ], + }, + expectedDefaultMessage: ALWAYS_ON, + expectedHoverMessage: HOVER_RUNNABLE_OPEN_EXT, + expectedActionButtonDisabled: false, + }, + { + title: "no access", + manifest: { + manifest_version: 3, + }, + expectedDefaultMessage: NO_ACCESS, + expectedHoverMessage: NO_ACCESS, + expectedActionButtonDisabled: true, + }, + { + title: "when clicked with host permissions", + manifest: { + manifest_version: 3, + host_permissions: ["*://example.com/*"], + }, + expectedDefaultMessage: WHEN_CLICKED, + expectedHoverMessage: HOVER_RUN_VISIT_ONLY, + expectedActionButtonDisabled: false, + }, + { + title: "when clicked with host permissions already granted", + manifest: { + manifest_version: 3, + host_permissions: ["*://example.com/*"], + }, + expectedDefaultMessage: ALWAYS_ON, + expectedHoverMessage: ALWAYS_ON, + expectedActionButtonDisabled: true, + grantHostPermissions: true, + }, + { + title: "when clicked", + manifest: { + manifest_version: 3, + permissions: ["activeTab"], + }, + expectedDefaultMessage: WHEN_CLICKED, + expectedHoverMessage: HOVER_RUN_VISIT_ONLY, + expectedActionButtonDisabled: false, + }, + { + title: "page action - no access", + manifest: { + manifest_version: 3, + page_action: {}, + }, + expectedDefaultMessage: NO_ACCESS, + expectedHoverMessage: NO_ACCESS, + expectedActionButtonDisabled: true, + }, + { + title: "page action - when clicked with host permissions", + manifest: { + manifest_version: 3, + host_permissions: ["*://example.com/*"], + page_action: {}, + }, + expectedDefaultMessage: WHEN_CLICKED, + expectedHoverMessage: HOVER_RUN_VISIT_ONLY, + expectedActionButtonDisabled: false, + }, + { + title: "page action - when clicked with host permissions already granted", + manifest: { + manifest_version: 3, + host_permissions: ["*://example.com/*"], + page_action: {}, + }, + expectedDefaultMessage: ALWAYS_ON, + expectedHoverMessage: ALWAYS_ON, + expectedActionButtonDisabled: true, + grantHostPermissions: true, + }, + { + title: "page action - when clicked", + manifest: { + manifest_version: 3, + permissions: ["activeTab"], + page_action: {}, + }, + expectedDefaultMessage: WHEN_CLICKED, + expectedHoverMessage: HOVER_RUN_VISIT_ONLY, + expectedActionButtonDisabled: false, + }, + { + title: "browser action - click event - no access", + manifest: { + manifest_version: 3, + action: {}, + }, + expectedDefaultMessage: NO_ACCESS, + expectedHoverMessage: HOVER_RUNNABLE_RUN_EXT, + expectedActionButtonDisabled: false, + }, + { + title: "browser action - popup - no access", + manifest: { + manifest_version: 3, + action: { + default_popup: "popup.html", + }, + }, + expectedDefaultMessage: NO_ACCESS, + expectedHoverMessage: HOVER_RUNNABLE_OPEN_EXT, + expectedActionButtonDisabled: false, + }, + { + title: "browser action - click event - when clicked", + manifest: { + manifest_version: 3, + action: {}, + host_permissions: ["*://example.com/*"], + }, + expectedDefaultMessage: WHEN_CLICKED, + expectedHoverMessage: HOVER_RUN_VISIT_ONLY, + expectedActionButtonDisabled: false, + }, + { + title: "browser action - popup - when clicked", + manifest: { + manifest_version: 3, + action: { + default_popup: "popup.html", + }, + host_permissions: ["*://example.com/*"], + }, + expectedDefaultMessage: WHEN_CLICKED, + expectedHoverMessage: HOVER_RUN_VISIT_ONLY, + expectedActionButtonDisabled: false, + }, + { + title: + "browser action - click event - when clicked with host permissions already granted", + manifest: { + manifest_version: 3, + action: {}, + host_permissions: ["*://example.com/*"], + }, + expectedDefaultMessage: ALWAYS_ON, + expectedHoverMessage: HOVER_RUNNABLE_RUN_EXT, + expectedActionButtonDisabled: false, + grantHostPermissions: true, + }, + { + title: + "browser action - popup - when clicked with host permissions already granted", + manifest: { + manifest_version: 3, + action: { + default_popup: "popup.html", + }, + host_permissions: ["*://example.com/*"], + }, + expectedDefaultMessage: ALWAYS_ON, + expectedHoverMessage: HOVER_RUNNABLE_OPEN_EXT, + expectedActionButtonDisabled: false, + grantHostPermissions: true, + }, + ]; + + async function runTestCases(testCases) { + await BrowserTestUtils.withNewTab( + { gBrowser, url: "https://example.com/" }, + async () => { + let count = 0; + + for (const { + title, + manifest, + expectedDefaultMessage, + expectedHoverMessage, + expectedActionButtonDisabled, + grantHostPermissions, + } of testCases) { + info(`case: ${title}`); + + const id = `test-origin-controls-${count++}@ext`; + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + name: title, + browser_specific_settings: { gecko: { id } }, + ...manifest, + }, + files: { + "script.js": "", + "popup.html": "", + }, + useAddonManager: "permanent", + }); + + if (grantHostPermissions) { + info("Granting initial permissions."); + await ExtensionPermissions.add(id, { + permissions: [], + origins: manifest.host_permissions, + }); + } + + await extension.startup(); + + // Open the extension panel. + await openExtensionsPanel(); + + const item = getUnifiedExtensionsItem(extension.id); + ok(item, `expected item for ${extension.id}`); + + const messageDeck = item.querySelector( + ".unified-extensions-item-message-deck" + ); + ok(messageDeck, "expected a message deck element"); + + // 1. Verify the default message displayed below the extension's name. + const defaultMessage = item.querySelector( + ".unified-extensions-item-message-default" + ); + ok(defaultMessage, "expected a default message element"); + + Assert.deepEqual( + document.l10n.getAttributes(defaultMessage), + expectedDefaultMessage, + "expected l10n attributes for the default message" + ); + + is( + messageDeck.selectedIndex, + gUnifiedExtensions.MESSAGE_DECK_INDEX_DEFAULT, + "expected selected message in the deck to be the default message" + ); + + // 2. Verify the action button state. + const actionButton = item.querySelector( + ".unified-extensions-item-action-button" + ); + ok(actionButton, "expected an action button"); + is( + actionButton.disabled, + expectedActionButtonDisabled, + `expected action button to be ${ + expectedActionButtonDisabled ? "disabled" : "enabled" + }` + ); + + // 3. Verify the message displayed on hover but only when the action + // button isn't disabled to avoid some test failures. + if (!expectedActionButtonDisabled) { + const hovered = BrowserTestUtils.waitForEvent( + actionButton, + "mouseover" + ); + EventUtils.synthesizeMouseAtCenter(actionButton, { + type: "mouseover", + }); + await hovered; + + const hoverMessage = item.querySelector( + ".unified-extensions-item-message-hover" + ); + ok(hoverMessage, "expected a hover message element"); + + Assert.deepEqual( + document.l10n.getAttributes(hoverMessage), + expectedHoverMessage, + "expected l10n attributes for the message on hover" + ); + + is( + messageDeck.selectedIndex, + gUnifiedExtensions.MESSAGE_DECK_INDEX_HOVER, + "expected selected message in the deck to be the hover message" + ); + } + + await closeExtensionsPanel(); + + // Move cursor elsewhere to avoid issues with previous "hovering". + EventUtils.synthesizeMouseAtCenter(gURLBar.textbox, {}); + + await extension.unload(); + } + } + ); + } + + await runTestCases(TEST_CASES); + + info("Testing again with example.com quarantined."); + await SpecialPowers.pushPrefEnv({ + set: [["extensions.quarantinedDomains.list", "example.com"]], + }); + + await runTestCases([ + { + title: "MV2 - no access", + manifest: { + manifest_version: 2, + }, + expectedDefaultMessage: NO_ACCESS, + expectedHoverMessage: NO_ACCESS, + expectedActionButtonDisabled: true, + }, + { + title: "MV2 - host permission but quarantined", + manifest: { + manifest_version: 2, + host_permissions: ["*://example.com/*"], + }, + expectedDefaultMessage: QUARANTINED, + expectedHoverMessage: QUARANTINED, + expectedActionButtonDisabled: true, + }, + { + title: "MV2 - content script but quarantined", + manifest: { + manifest_version: 2, + content_scripts: [ + { + js: ["script.js"], + matches: ["*://example.com/*"], + }, + ], + }, + expectedDefaultMessage: QUARANTINED, + expectedHoverMessage: QUARANTINED, + expectedActionButtonDisabled: true, + }, + { + title: "MV2 - non-matching content script", + manifest: { + manifest_version: 2, + content_scripts: [ + { + js: ["script.js"], + matches: ["*://foobar.net/*"], + }, + ], + }, + expectedDefaultMessage: NO_ACCESS, + expectedHoverMessage: NO_ACCESS, + expectedActionButtonDisabled: true, + }, + { + title: "MV3 - content script but quarantined", + manifest: { + manifest_version: 2, + content_scripts: [ + { + js: ["script.js"], + matches: ["*://example.com/*"], + }, + ], + host_permissions: ["*://example.com/*"], + }, + expectedDefaultMessage: QUARANTINED, + expectedHoverMessage: QUARANTINED, + expectedActionButtonDisabled: true, + grantHostPermissions: true, + }, + { + title: "MV3 host permissions already granted but quarantined", + manifest: { + manifest_version: 3, + host_permissions: ["*://example.com/*"], + }, + expectedDefaultMessage: QUARANTINED, + expectedHoverMessage: QUARANTINED, + expectedActionButtonDisabled: true, + grantHostPermissions: true, + }, + { + title: "browser action, host permissions already granted, quarantined", + manifest: { + manifest_version: 3, + action: {}, + host_permissions: ["*://example.com/*"], + }, + expectedDefaultMessage: QUARANTINED, + expectedHoverMessage: HOVER_RUNNABLE_RUN_EXT, + expectedActionButtonDisabled: false, + grantHostPermissions: true, + }, + ]); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_hover_message_when_button_updates_itself() { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version: 3, + name: "an extension that refreshes its title", + action: {}, + }, + background() { + browser.test.onMessage.addListener(async msg => { + browser.test.assertEq( + "update-button", + msg, + "expected 'update-button' message" + ); + + browser.action.setTitle({ title: "a title" }); + + browser.test.sendMessage(`${msg}-done`); + }); + + browser.test.sendMessage("background-ready"); + }, + useAddonManager: "temporary", + }); + + await extension.startup(); + await extension.awaitMessage("background-ready"); + + await openExtensionsPanel(); + + const item = getUnifiedExtensionsItem(extension.id); + ok(item, "expected item in the panel"); + + const actionButton = item.querySelector( + ".unified-extensions-item-action-button" + ); + ok(actionButton, "expected an action button"); + + const menuButton = item.querySelector(".unified-extensions-item-menu-button"); + ok(menuButton, "expected a menu button"); + + const hovered = BrowserTestUtils.waitForEvent(actionButton, "mouseover"); + EventUtils.synthesizeMouseAtCenter(actionButton, { type: "mouseover" }); + await hovered; + + const messageDeck = item.querySelector( + ".unified-extensions-item-message-deck" + ); + ok(messageDeck, "expected a message deck element"); + + const hoverMessage = item.querySelector( + ".unified-extensions-item-message-hover" + ); + ok(hoverMessage, "expected a hover message element"); + + const expectedL10nAttributes = { + id: "origin-controls-state-runnable-hover-run", + args: null, + }; + Assert.deepEqual( + document.l10n.getAttributes(hoverMessage), + expectedL10nAttributes, + "expected l10n attributes for the hover message" + ); + + is( + messageDeck.selectedIndex, + gUnifiedExtensions.MESSAGE_DECK_INDEX_HOVER, + "expected selected message in the deck to be the hover message" + ); + + extension.sendMessage("update-button"); + await extension.awaitMessage("update-button-done"); + + is( + messageDeck.selectedIndex, + gUnifiedExtensions.MESSAGE_DECK_INDEX_HOVER, + "expected selected message in the deck to remain the same" + ); + + const menuButtonHovered = BrowserTestUtils.waitForEvent( + menuButton, + "mouseover" + ); + EventUtils.synthesizeMouseAtCenter(menuButton, { type: "mouseover" }); + await menuButtonHovered; + + await closeExtensionsPanel(); + + // Move cursor to the center of the entire browser UI to avoid issues with + // other focus/hover checks. We do this to avoid intermittent test failures. + EventUtils.synthesizeMouseAtCenter(document.documentElement, {}); + + await extension.unload(); +}); + +// Test the temporary access state messages and attention indicator. +add_task(async function test_temporary_access() { + const TEST_CASES = [ + { + title: "mv3 with active scripts and browser action", + manifest: { + manifest_version: 3, + action: {}, + content_scripts: [ + { + js: ["script.js"], + matches: ["*://example.com/*"], + }, + ], + }, + before: { + attention: true, + state: WHEN_CLICKED, + disabled: false, + }, + messages: ["action-onClicked", "cs-injected"], + after: { + attention: false, + state: TEMP_ACCESS, + disabled: false, + }, + }, + { + title: "mv3 with active scripts and no browser action", + manifest: { + manifest_version: 3, + content_scripts: [ + { + js: ["script.js"], + matches: ["*://example.com/*"], + }, + ], + }, + before: { + attention: true, + state: WHEN_CLICKED, + disabled: false, + }, + messages: ["cs-injected"], + after: { + attention: false, + state: TEMP_ACCESS, + // TODO: This will need updating for bug 1807835. + disabled: false, + }, + }, + { + title: "mv3 with browser action and host_permission", + manifest: { + manifest_version: 3, + action: {}, + host_permissions: ["*://example.com/*"], + }, + before: { + attention: true, + state: WHEN_CLICKED, + disabled: false, + }, + messages: ["action-onClicked"], + after: { + attention: false, + state: TEMP_ACCESS, + disabled: false, + }, + }, + { + title: "mv3 with browser action no host_permissions", + manifest: { + manifest_version: 3, + action: {}, + }, + before: { + attention: false, + state: NO_ACCESS, + disabled: false, + }, + messages: ["action-onClicked"], + after: { + attention: false, + state: NO_ACCESS, + disabled: false, + }, + }, + // MV2 tests. + { + title: "mv2 with content scripts and browser action", + manifest: { + manifest_version: 2, + browser_action: {}, + content_scripts: [ + { + js: ["script.js"], + matches: ["*://example.com/*"], + }, + ], + }, + before: { + attention: false, + state: ALWAYS_ON, + disabled: false, + }, + messages: ["action-onClicked", "cs-injected"], + after: { + attention: false, + state: ALWAYS_ON, + disabled: false, + }, + }, + { + title: "mv2 with content scripts and no browser action", + manifest: { + manifest_version: 2, + content_scripts: [ + { + js: ["script.js"], + matches: ["*://example.com/*"], + }, + ], + }, + before: { + attention: false, + state: ALWAYS_ON, + disabled: true, + }, + messages: ["cs-injected"], + after: { + attention: false, + state: ALWAYS_ON, + disabled: true, + }, + }, + { + title: "mv2 with browser action and host_permission", + manifest: { + manifest_version: 2, + browser_action: {}, + host_permissions: ["*://example.com/*"], + }, + before: { + attention: false, + state: ALWAYS_ON, + disabled: false, + }, + messages: ["action-onClicked"], + after: { + attention: false, + state: ALWAYS_ON, + disabled: false, + }, + }, + { + title: "mv2 with browser action no host_permissions", + manifest: { + manifest_version: 2, + browser_action: {}, + }, + before: { + attention: false, + state: NO_ACCESS, + disabled: false, + }, + messages: ["action-onClicked"], + after: { + attention: false, + state: NO_ACCESS, + disabled: false, + }, + }, + ]; + + let count = 1; + await Promise.all( + TEST_CASES.map(test => { + let id = `test-temp-access-${count++}@ext`; + test.extension = ExtensionTestUtils.loadExtension({ + manifest: { + name: test.title, + browser_specific_settings: { gecko: { id } }, + ...test.manifest, + }, + files: { + "popup.html": "", + "script.js"() { + browser.test.sendMessage("cs-injected"); + }, + }, + background() { + let action = browser.action ?? browser.browserAction; + action?.onClicked.addListener(() => { + browser.test.sendMessage("action-onClicked"); + }); + }, + useAddonManager: "temporary", + }); + + return test.extension.startup(); + }) + ); + + async function checkButton(extension, expect, click = false) { + await openExtensionsPanel(); + + let item = getUnifiedExtensionsItem(extension.id); + ok(item, `Expected item for ${extension.id}.`); + + let state = item.querySelector(".unified-extensions-item-message-default"); + ok(state, "Expected a default state message element."); + + is( + item.hasAttribute("attention"), + !!expect.attention, + "Expected attention badge." + ); + Assert.deepEqual( + document.l10n.getAttributes(state), + expect.state, + "Expected l10n attributes for the message." + ); + + let button = item.querySelector(".unified-extensions-item-action-button"); + is(button.disabled, !!expect.disabled, "Expect disabled item."); + + // If we should click, and button is not disabled. + if (click && !expect.disabled) { + let onClick = BrowserTestUtils.waitForEvent(button, "click"); + button.click(); + await onClick; + } else { + // Otherwise, just close the panel. + await closeExtensionsPanel(); + } + } + + await BrowserTestUtils.withNewTab( + { gBrowser, url: "https://example.com/" }, + async () => { + for (let { title, extension, before, messages, after } of TEST_CASES) { + info(`Test case: ${title}`); + await checkButton(extension, before, true); + + await Promise.all( + messages.map(msg => { + info(`Waiting for ${msg} from clicking the button.`); + return extension.awaitMessage(msg); + }) + ); + + await checkButton(extension, after); + await extension.unload(); + } + } + ); +}); + +add_task( + async function test_action_and_menu_buttons_css_class_with_new_window() { + const [extension] = createExtensions([ + { + name: "an extension placed in the extensions panel", + browser_action: { + default_area: "menupanel", + }, + }, + ]); + await extension.startup(); + + let aSecondWindow = await BrowserTestUtils.openNewBrowserWindow(); + await ensureMaximizedWindow(aSecondWindow); + + // Open and close the extensions panel in the newly created window to build + // the extensions panel and add the extension widget(s) to it. + await openExtensionsPanel(aSecondWindow); + await closeExtensionsPanel(aSecondWindow); + + for (const { title, win } of [ + { title: "current window", win: window }, + { title: "second window", win: aSecondWindow }, + ]) { + const node = CustomizableUI.getWidget( + AppUiTestInternals.getBrowserActionWidgetId(extension.id) + ).forWindow(win).node; + + let actionButton = node.querySelector( + ".unified-extensions-item-action-button" + ); + ok( + actionButton.classList.contains("subviewbutton"), + `${title} - expected .subviewbutton CSS class on the action button` + ); + ok( + !actionButton.classList.contains("toolbarbutton-1"), + `${title} - expected no .toolbarbutton-1 CSS class on the action button` + ); + let menuButton = node.querySelector( + ".unified-extensions-item-menu-button" + ); + ok( + menuButton.classList.contains("subviewbutton"), + `${title} - expected .subviewbutton CSS class on the menu button` + ); + ok( + !menuButton.classList.contains("toolbarbutton-1"), + `${title} - expected no .toolbarbutton-1 CSS class on the menu button` + ); + } + + await BrowserTestUtils.closeWindow(aSecondWindow); + + await extension.unload(); + } +); diff --git a/browser/components/extensions/test/browser/browser_unified_extensions_accessibility.js b/browser/components/extensions/test/browser/browser_unified_extensions_accessibility.js new file mode 100644 index 0000000000..44d861d97c --- /dev/null +++ b/browser/components/extensions/test/browser/browser_unified_extensions_accessibility.js @@ -0,0 +1,302 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +loadTestSubscript("head_unified_extensions.js"); + +add_task(async function test_keyboard_navigation_activeScript() { + const extension1 = ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version: 3, + name: "1", + content_scripts: [ + { + matches: ["*://*/*"], + js: ["script.js"], + }, + ], + }, + files: { + "script.js": () => { + browser.test.fail("this script should NOT have been executed"); + }, + }, + useAddonManager: "temporary", + }); + const extension2 = ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version: 3, + name: "2", + content_scripts: [ + { + matches: ["*://*/*"], + js: ["script.js"], + }, + ], + }, + files: { + "script.js": () => { + browser.test.sendMessage("script executed"); + }, + }, + useAddonManager: "temporary", + }); + + BrowserTestUtils.startLoadingURIString( + gBrowser.selectedBrowser, + "https://example.org/" + ); + await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser); + + await Promise.all([extension1.startup(), extension2.startup()]); + + // Open the extension panel. + await openExtensionsPanel(); + + let item = getUnifiedExtensionsItem(extension1.id); + ok(item, `expected item for ${extension1.id}`); + + info("moving focus to first item in the unified extensions panel"); + let actionButton = item.querySelector( + ".unified-extensions-item-action-button" + ); + let focused = BrowserTestUtils.waitForEvent(actionButton, "focus"); + EventUtils.synthesizeKey("VK_TAB", {}); + await focused; + is( + actionButton, + document.activeElement, + "expected action button of first extension item to be focused" + ); + + item = getUnifiedExtensionsItem(extension2.id); + ok(item, `expected item for ${extension2.id}`); + + info("moving focus to second item in the unified extensions panel"); + actionButton = item.querySelector(".unified-extensions-item-action-button"); + focused = BrowserTestUtils.waitForEvent(actionButton, "focus"); + EventUtils.synthesizeKey("KEY_ArrowDown", {}); + await focused; + is( + actionButton, + document.activeElement, + "expected action button of second extension item to be focused" + ); + + info("granting permission"); + const popupHidden = BrowserTestUtils.waitForEvent( + document, + "popuphidden", + true + ); + EventUtils.synthesizeKey(" ", {}); + await Promise.all([popupHidden, extension2.awaitMessage("script executed")]); + + await Promise.all([extension1.unload(), extension2.unload()]); +}); + +add_task(async function test_keyboard_navigation_opens_menu() { + const extension1 = ExtensionTestUtils.loadExtension({ + manifest: { + name: "1", + // activeTab and browser_action needed to enable the action button in mv2. + permissions: ["activeTab"], + browser_action: {}, + }, + useAddonManager: "temporary", + }); + const extension2 = ExtensionTestUtils.loadExtension({ + manifest: { + name: "2", + }, + useAddonManager: "temporary", + }); + const extension3 = ExtensionTestUtils.loadExtension({ + manifest: { + manifest_version: 3, + name: "3", + // activeTab enables the action button without a browser action in mv3. + permissions: ["activeTab"], + }, + useAddonManager: "temporary", + }); + + await extension1.startup(); + await extension2.startup(); + await extension3.startup(); + + // Open the extension panel. + await openExtensionsPanel(); + + let item = getUnifiedExtensionsItem(extension1.id); + ok(item, `expected item for ${extension1.id}`); + + let messageDeck = item.querySelector(".unified-extensions-item-message-deck"); + ok(messageDeck, "expected a message deck element"); + is( + messageDeck.selectedIndex, + gUnifiedExtensions.MESSAGE_DECK_INDEX_DEFAULT, + "expected selected message in the deck to be the default message" + ); + + info("moving focus to first item in the unified extensions panel"); + let actionButton = item.querySelector( + ".unified-extensions-item-action-button" + ); + let focused = BrowserTestUtils.waitForEvent(actionButton, "focus"); + EventUtils.synthesizeKey("VK_TAB", {}); + await focused; + is( + actionButton, + document.activeElement, + "expected action button of the first extension item to be focused" + ); + is( + messageDeck.selectedIndex, + gUnifiedExtensions.MESSAGE_DECK_INDEX_HOVER, + "expected selected message in the deck to be the hover message" + ); + + info( + "moving focus to menu button of the first item in the unified extensions panel" + ); + let menuButton = item.querySelector(".unified-extensions-item-menu-button"); + focused = BrowserTestUtils.waitForEvent(menuButton, "focus"); + ok(menuButton, "expected menu button"); + EventUtils.synthesizeKey("VK_TAB", {}); + await focused; + is( + menuButton, + document.activeElement, + "expected menu button in first extension item to be focused" + ); + is( + messageDeck.selectedIndex, + gUnifiedExtensions.MESSAGE_DECK_INDEX_MENU_HOVER, + "expected selected message in the deck to be the message when hovering the menu button" + ); + + info("opening menu of the first item"); + const contextMenu = document.getElementById( + "unified-extensions-context-menu" + ); + ok(contextMenu, "expected menu"); + const shown = BrowserTestUtils.waitForEvent(contextMenu, "popupshown"); + EventUtils.synthesizeKey(" ", {}); + await shown; + + await closeChromeContextMenu(contextMenu.id, null); + + info("moving focus back to the action button of the first item"); + focused = BrowserTestUtils.waitForEvent(actionButton, "focus"); + EventUtils.synthesizeKey("VK_TAB", { shiftKey: true }); + await focused; + is( + messageDeck.selectedIndex, + gUnifiedExtensions.MESSAGE_DECK_INDEX_HOVER, + "expected selected message in the deck to be the hover message" + ); + + // Moving to the third extension directly because the second extension cannot + // do anything on the current page and its action button is disabled. Note + // that this third extension does not have a browser action but it has + // "activeTab", which makes the extension "clickable". This allows us to + // verify the focus/blur behavior of custom elments. + info("moving focus to third item in the panel"); + item = getUnifiedExtensionsItem(extension3.id); + ok(item, `expected item for ${extension3.id}`); + actionButton = item.querySelector(".unified-extensions-item-action-button"); + ok(actionButton, `expected action button for ${extension3.id}`); + messageDeck = item.querySelector(".unified-extensions-item-message-deck"); + ok(messageDeck, `expected message deck for ${extension3.id}`); + is( + messageDeck.selectedIndex, + gUnifiedExtensions.MESSAGE_DECK_INDEX_DEFAULT, + "expected selected message in the deck to be the default message" + ); + // Now that we checked everything on this third extension, let's actually + // focus it with the arrow down key. + focused = BrowserTestUtils.waitForEvent(actionButton, "focus"); + EventUtils.synthesizeKey("KEY_ArrowDown", {}); + await focused; + is( + actionButton, + document.activeElement, + "expected action button of the third extension item to be focused" + ); + is( + messageDeck.selectedIndex, + gUnifiedExtensions.MESSAGE_DECK_INDEX_HOVER, + "expected selected message in the deck to be the hover message" + ); + + info( + "moving focus to menu button of the third item in the unified extensions panel" + ); + menuButton = item.querySelector(".unified-extensions-item-menu-button"); + focused = BrowserTestUtils.waitForEvent(menuButton, "focus"); + ok(menuButton, "expected menu button"); + EventUtils.synthesizeKey("VK_TAB", {}); + await focused; + is( + menuButton, + document.activeElement, + "expected menu button in third extension item to be focused" + ); + is( + messageDeck.selectedIndex, + gUnifiedExtensions.MESSAGE_DECK_INDEX_MENU_HOVER, + "expected selected message in the deck to be the message when hovering the menu button" + ); + + info("moving focus back to the action button of the third item"); + focused = BrowserTestUtils.waitForEvent(actionButton, "focus"); + EventUtils.synthesizeKey("VK_TAB", { shiftKey: true }); + await focused; + is( + messageDeck.selectedIndex, + gUnifiedExtensions.MESSAGE_DECK_INDEX_HOVER, + "expected selected message in the deck to be the hover message" + ); + + await closeExtensionsPanel(); + + await extension1.unload(); + await extension2.unload(); + await extension3.unload(); +}); + +add_task(async function test_open_panel_with_keyboard_navigation() { + const { button, panel } = gUnifiedExtensions; + ok(button, "expected button"); + ok(panel, "expected panel"); + + const listView = getListView(); + ok(listView, "expected list view"); + + // Force focus on the unified extensions button. + const forceFocusUnifiedExtensionsButton = () => { + button.setAttribute("tabindex", "-1"); + button.focus(); + button.removeAttribute("tabindex"); + }; + forceFocusUnifiedExtensionsButton(); + + // Use the "space" key to open the panel. + let viewShown = BrowserTestUtils.waitForEvent(listView, "ViewShown"); + EventUtils.synthesizeKey(" ", {}); + await viewShown; + + await closeExtensionsPanel(); + + // Force focus on the unified extensions button again. + forceFocusUnifiedExtensionsButton(); + + // Use the "return" key to open the panel. + viewShown = BrowserTestUtils.waitForEvent(listView, "ViewShown"); + EventUtils.synthesizeKey("KEY_Enter", {}); + await viewShown; + + await closeExtensionsPanel(); +}); diff --git a/browser/components/extensions/test/browser/browser_unified_extensions_context_menu.js b/browser/components/extensions/test/browser/browser_unified_extensions_context_menu.js new file mode 100644 index 0000000000..d04d85e535 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_unified_extensions_context_menu.js @@ -0,0 +1,1006 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +requestLongerTimeout(2); + +ChromeUtils.defineESModuleGetters(this, { + AbuseReporter: "resource://gre/modules/AbuseReporter.sys.mjs", +}); + +const { EnterprisePolicyTesting } = ChromeUtils.importESModule( + "resource://testing-common/EnterprisePolicyTesting.sys.mjs" +); +loadTestSubscript("head_unified_extensions.js"); + +// We expect this rejection when the abuse report dialog window is +// being forcefully closed as part of the related test task. +PromiseTestUtils.allowMatchingRejectionsGlobally(/report dialog closed/); + +const promiseExtensionUninstalled = extensionId => { + return new Promise(resolve => { + let listener = {}; + listener.onUninstalled = addon => { + if (addon.id == extensionId) { + AddonManager.removeAddonListener(listener); + resolve(); + } + }; + AddonManager.addAddonListener(listener); + }); +}; + +function waitClosedWindow(win) { + return new Promise(resolve => { + function onWindowClosed() { + if (win && !win.closed) { + // If a specific window reference has been passed, then check + // that the window is closed before resolving the promise. + return; + } + Services.obs.removeObserver(onWindowClosed, "xul-window-destroyed"); + resolve(); + } + Services.obs.addObserver(onWindowClosed, "xul-window-destroyed"); + }); +} + +function assertVisibleContextMenuItems(contextMenu, expected) { + let visibleItems = contextMenu.querySelectorAll( + ":is(menuitem, menuseparator):not([hidden])" + ); + is(visibleItems.length, expected, `expected ${expected} visible menu items`); +} + +function assertOrderOfWidgetsInPanel(extensions, win = window) { + const widgetIds = CustomizableUI.getWidgetIdsInArea( + CustomizableUI.AREA_ADDONS + ).filter( + widgetId => !!CustomizableUI.getWidget(widgetId).forWindow(win).node + ); + const widgetIdsFromExtensions = extensions.map(ext => + AppUiTestInternals.getBrowserActionWidgetId(ext.id) + ); + + Assert.deepEqual( + widgetIds, + widgetIdsFromExtensions, + "expected extensions to be ordered" + ); +} + +async function moveWidgetUp(extension, win = window) { + const contextMenu = await openUnifiedExtensionsContextMenu(extension.id, win); + const hidden = BrowserTestUtils.waitForEvent(contextMenu, "popuphidden"); + contextMenu.activateItem( + contextMenu.querySelector(".unified-extensions-context-menu-move-widget-up") + ); + await hidden; +} + +async function moveWidgetDown(extension, win = window) { + const contextMenu = await openUnifiedExtensionsContextMenu(extension.id, win); + const hidden = BrowserTestUtils.waitForEvent(contextMenu, "popuphidden"); + contextMenu.activateItem( + contextMenu.querySelector( + ".unified-extensions-context-menu-move-widget-down" + ) + ); + await hidden; +} + +async function pinToToolbar(extension, win = window) { + const contextMenu = await openUnifiedExtensionsContextMenu(extension.id, win); + const pinToToolbarItem = contextMenu.querySelector( + ".unified-extensions-context-menu-pin-to-toolbar" + ); + ok(pinToToolbarItem, "expected 'pin to toolbar' menu item"); + + const hidden = BrowserTestUtils.waitForEvent( + win.gUnifiedExtensions.panel, + "popuphidden", + true + ); + contextMenu.activateItem(pinToToolbarItem); + await hidden; +} + +async function assertMoveContextMenuItems( + ext, + { expectMoveUpHidden, expectMoveDownHidden, expectOrder }, + win = window +) { + const extName = WebExtensionPolicy.getByID(ext.id).name; + info(`Assert Move context menu items visibility for ${extName}`); + const contextMenu = await openUnifiedExtensionsContextMenu(ext.id, win); + const moveUp = contextMenu.querySelector( + ".unified-extensions-context-menu-move-widget-up" + ); + const moveDown = contextMenu.querySelector( + ".unified-extensions-context-menu-move-widget-down" + ); + ok(moveUp, "expected 'move up' item in the context menu"); + ok(moveDown, "expected 'move down' item in the context menu"); + + is( + BrowserTestUtils.isHidden(moveUp), + expectMoveUpHidden, + `expected 'move up' item to be ${expectMoveUpHidden ? "hidden" : "visible"}` + ); + is( + BrowserTestUtils.isHidden(moveDown), + expectMoveDownHidden, + `expected 'move down' item to be ${ + expectMoveDownHidden ? "hidden" : "visible" + }` + ); + const expectedVisibleItems = + 5 + (+(expectMoveUpHidden ? 0 : 1) + (expectMoveDownHidden ? 0 : 1)); + assertVisibleContextMenuItems(contextMenu, expectedVisibleItems); + if (expectOrder) { + assertOrderOfWidgetsInPanel(expectOrder, win); + } + await closeChromeContextMenu(contextMenu.id, null, win); +} + +add_setup(async () => { + await SpecialPowers.pushPrefEnv({ + set: [ + ["extensions.abuseReport.enabled", true], + [ + "extensions.abuseReport.amoFormURL", + "https://example.org/%LOCALE%/%APP%/feedback/addon/%addonID%/", + ], + ], + }); +}); + +add_task(async function test_context_menu() { + const [extension] = createExtensions([{ name: "an extension" }]); + await extension.startup(); + + // Open the extension panel. + await openExtensionsPanel(); + + // Get the menu button of the extension and verify the mouseover/mouseout + // behavior. We expect a help message (in the message deck) to be selected + // (and therefore displayed) when the menu button is hovered/focused. + const item = getUnifiedExtensionsItem(extension.id); + ok(item, "expected an item for the extension"); + + const messageDeck = item.querySelector( + ".unified-extensions-item-message-deck" + ); + ok(messageDeck, "expected message deck"); + is( + messageDeck.selectedIndex, + gUnifiedExtensions.MESSAGE_DECK_INDEX_DEFAULT, + "expected selected message in the deck to be the default message" + ); + + const hoverMenuButtonMessage = item.querySelector( + ".unified-extensions-item-message-hover-menu-button" + ); + Assert.deepEqual( + document.l10n.getAttributes(hoverMenuButtonMessage), + { id: "unified-extensions-item-message-manage", args: null }, + "expected correct l10n attributes for the hover message" + ); + + const menuButton = item.querySelector(".unified-extensions-item-menu-button"); + ok(menuButton, "expected menu button"); + + let hovered = BrowserTestUtils.waitForEvent(menuButton, "mouseover"); + EventUtils.synthesizeMouseAtCenter(menuButton, { type: "mouseover" }); + await hovered; + is( + messageDeck.selectedIndex, + gUnifiedExtensions.MESSAGE_DECK_INDEX_MENU_HOVER, + "expected selected message in the deck to be the message when hovering the menu button" + ); + + let notHovered = BrowserTestUtils.waitForEvent(menuButton, "mouseout"); + // Move mouse somewhere else... + EventUtils.synthesizeMouseAtCenter(item, { type: "mouseover" }); + await notHovered; + is( + messageDeck.selectedIndex, + gUnifiedExtensions.MESSAGE_DECK_INDEX_HOVER, + "expected selected message in the deck to be the hover message" + ); + + // Open the context menu for the extension. + const contextMenu = await openUnifiedExtensionsContextMenu(extension.id); + const doc = contextMenu.ownerDocument; + + const manageButton = contextMenu.querySelector( + ".unified-extensions-context-menu-manage-extension" + ); + ok(manageButton, "expected manage button"); + is(manageButton.hidden, false, "expected manage button to be visible"); + is(manageButton.disabled, false, "expected manage button to be enabled"); + Assert.deepEqual( + doc.l10n.getAttributes(manageButton), + { id: "unified-extensions-context-menu-manage-extension", args: null }, + "expected correct l10n attributes for manage button" + ); + + const removeButton = contextMenu.querySelector( + ".unified-extensions-context-menu-remove-extension" + ); + ok(removeButton, "expected remove button"); + is(removeButton.hidden, false, "expected remove button to be visible"); + is(removeButton.disabled, false, "expected remove button to be enabled"); + Assert.deepEqual( + doc.l10n.getAttributes(removeButton), + { id: "unified-extensions-context-menu-remove-extension", args: null }, + "expected correct l10n attributes for remove button" + ); + + const reportButton = contextMenu.querySelector( + ".unified-extensions-context-menu-report-extension" + ); + ok(reportButton, "expected report button"); + is(reportButton.hidden, false, "expected report button to be visible"); + is(reportButton.disabled, false, "expected report button to be enabled"); + Assert.deepEqual( + doc.l10n.getAttributes(reportButton), + { id: "unified-extensions-context-menu-report-extension", args: null }, + "expected correct l10n attributes for report button" + ); + + await closeChromeContextMenu(contextMenu.id, null); + await closeExtensionsPanel(); + + await extension.unload(); +}); + +add_task( + async function test_context_menu_report_button_hidden_when_abuse_report_disabled() { + await SpecialPowers.pushPrefEnv({ + set: [["extensions.abuseReport.enabled", false]], + }); + + const [extension] = createExtensions([{ name: "an extension" }]); + await extension.startup(); + + // Open the extension panel, then open the contextMenu for the extension. + await openExtensionsPanel(); + const contextMenu = await openUnifiedExtensionsContextMenu(extension.id); + + const reportButton = contextMenu.querySelector( + ".unified-extensions-context-menu-report-extension" + ); + ok(reportButton, "expected report button"); + is(reportButton.hidden, true, "expected report button to be hidden"); + + await closeChromeContextMenu(contextMenu.id, null); + await closeExtensionsPanel(); + + await extension.unload(); + + await SpecialPowers.popPrefEnv(); + } +); + +add_task( + async function test_context_menu_remove_button_disabled_when_extension_cannot_be_uninstalled() { + const [extension] = createExtensions([{ name: "an extension" }]); + await extension.startup(); + + await EnterprisePolicyTesting.setupPolicyEngineWithJson({ + policies: { + Extensions: { + Locked: [extension.id], + }, + }, + }); + + // Open the extension panel, then open the context menu for the extension. + await openExtensionsPanel(); + const contextMenu = await openUnifiedExtensionsContextMenu(extension.id); + + const removeButton = contextMenu.querySelector( + ".unified-extensions-context-menu-remove-extension" + ); + ok(removeButton, "expected remove button"); + is(removeButton.disabled, true, "expected remove button to be disabled"); + + await closeChromeContextMenu(contextMenu.id, null); + await closeExtensionsPanel(); + + await extension.unload(); + await EnterprisePolicyTesting.setupPolicyEngineWithJson(""); + } +); + +add_task(async function test_manage_extension() { + await BrowserTestUtils.withNewTab( + { gBrowser, url: "about:robots" }, + async () => { + const [extension] = createExtensions([{ name: "an extension" }]); + await extension.startup(); + + // Open the extension panel, then open the context menu for the extension. + await openExtensionsPanel(); + const contextMenu = await openUnifiedExtensionsContextMenu(extension.id); + + const manageButton = contextMenu.querySelector( + ".unified-extensions-context-menu-manage-extension" + ); + ok(manageButton, "expected manage button"); + + // Click the "manage extension" context menu item, and wait until the menu is + // closed and about:addons is open. + const hidden = BrowserTestUtils.waitForEvent(contextMenu, "popuphidden"); + const aboutAddons = BrowserTestUtils.waitForNewTab( + gBrowser, + "about:addons", + true + ); + contextMenu.activateItem(manageButton); + const [aboutAddonsTab] = await Promise.all([aboutAddons, hidden]); + + // Close the tab containing about:addons because we don't need it anymore. + BrowserTestUtils.removeTab(aboutAddonsTab); + + await extension.unload(); + } + ); +}); + +add_task(async function test_report_extension() { + function runReportTest(extension) { + return BrowserTestUtils.withNewTab({ gBrowser }, async () => { + // Open the extension panel, then open the context menu for the extension. + await openExtensionsPanel(); + const contextMenu = await openUnifiedExtensionsContextMenu(extension.id); + + const reportButton = contextMenu.querySelector( + ".unified-extensions-context-menu-report-extension" + ); + ok(reportButton, "expected report button"); + + // Click the "report extension" context menu item, and wait until the menu is + // closed and about:addons is open with the "abuse report dialog". + const hidden = BrowserTestUtils.waitForEvent(contextMenu, "popuphidden"); + + if (AbuseReporter.amoFormEnabled) { + const reportURL = Services.urlFormatter + .formatURLPref("extensions.abuseReport.amoFormURL") + .replace("%addonID%", extension.id); + + const promiseReportTab = BrowserTestUtils.waitForNewTab( + gBrowser, + reportURL, + /* waitForLoad */ false, + // Do not expect it to be the next tab opened + /* waitForAnyTab */ true + ); + contextMenu.activateItem(reportButton); + const [reportTab] = await Promise.all([promiseReportTab, hidden]); + // Remove the report tab and expect the selected tab + // to become the about:addons tab. + BrowserTestUtils.removeTab(reportTab); + if (AbuseReporter.amoFormEnabled) { + is( + gBrowser.selectedBrowser.currentURI.spec, + "about:blank", + "Expect about:addons tab to have not been opened (amoFormEnabled=true)" + ); + } else { + is( + gBrowser.selectedBrowser.currentURI.spec, + "about:addons", + "Got about:addons tab selected (amoFormEnabled=false)" + ); + } + return; + } + + const abuseReportOpen = BrowserTestUtils.waitForCondition( + () => AbuseReporter.getOpenDialog(), + "wait for the abuse report dialog to have been opened" + ); + contextMenu.activateItem(reportButton); + const [reportDialogWindow] = await Promise.all([abuseReportOpen, hidden]); + + const reportDialogParams = + reportDialogWindow.arguments[0].wrappedJSObject; + is( + reportDialogParams.report.addon.id, + extension.id, + "abuse report dialog has the expected addon id" + ); + is( + reportDialogParams.report.reportEntryPoint, + "unified_context_menu", + "abuse report dialog has the expected reportEntryPoint" + ); + + let promiseClosedWindow = waitClosedWindow(); + reportDialogWindow.close(); + // Wait for the report dialog window to be completely closed + // (to prevent an intermittent failure due to a race between + // the dialog window being closed and the test tasks that follows + // opening the unified extensions button panel to not lose the + // focus and be suddently closed before the task has done with + // its assertions, see Bug 1782304). + await promiseClosedWindow; + }); + } + + const [ext] = createExtensions([{ name: "an extension" }]); + await ext.startup(); + + info("Test report with amoFormEnabled=true"); + + await SpecialPowers.pushPrefEnv({ + set: [["extensions.abuseReport.amoFormEnabled", true]], + }); + await runReportTest(ext); + await SpecialPowers.popPrefEnv(); + + info("Test report with amoFormEnabled=false"); + + await SpecialPowers.pushPrefEnv({ + set: [["extensions.abuseReport.amoFormEnabled", false]], + }); + await runReportTest(ext); + await SpecialPowers.popPrefEnv(); + + await ext.unload(); +}); + +add_task(async function test_remove_extension() { + const [extension] = createExtensions([{ name: "an extension" }]); + await extension.startup(); + + // Open the extension panel, then open the context menu for the extension. + await openExtensionsPanel(); + const contextMenu = await openUnifiedExtensionsContextMenu(extension.id); + + const removeButton = contextMenu.querySelector( + ".unified-extensions-context-menu-remove-extension" + ); + ok(removeButton, "expected remove button"); + + // Set up a mock prompt service that returns 0 to indicate that the user + // pressed the OK button. + const { prompt } = Services; + const promptService = { + QueryInterface: ChromeUtils.generateQI(["nsIPromptService"]), + confirmEx() { + return 0; + }, + }; + Services.prompt = promptService; + registerCleanupFunction(() => { + Services.prompt = prompt; + }); + + // Click the "remove extension" context menu item, and wait until the menu is + // closed and the extension is uninstalled. + const uninstalled = promiseExtensionUninstalled(extension.id); + const hidden = BrowserTestUtils.waitForEvent(contextMenu, "popuphidden"); + contextMenu.activateItem(removeButton); + await Promise.all([uninstalled, hidden]); + + await extension.unload(); + // Restore prompt service. + Services.prompt = prompt; +}); + +add_task(async function test_remove_extension_cancelled() { + const [extension] = createExtensions([{ name: "an extension" }]); + await extension.startup(); + + // Open the extension panel, then open the context menu for the extension. + await openExtensionsPanel(); + const contextMenu = await openUnifiedExtensionsContextMenu(extension.id); + + const removeButton = contextMenu.querySelector( + ".unified-extensions-context-menu-remove-extension" + ); + ok(removeButton, "expected remove button"); + + // Set up a mock prompt service that returns 1 to indicate that the user + // refused to uninstall the extension. + const { prompt } = Services; + const promptService = { + QueryInterface: ChromeUtils.generateQI(["nsIPromptService"]), + confirmEx() { + return 1; + }, + }; + Services.prompt = promptService; + registerCleanupFunction(() => { + Services.prompt = prompt; + }); + + // Click the "remove extension" context menu item, and wait until the menu is + // closed. + const hidden = BrowserTestUtils.waitForEvent(contextMenu, "popuphidden"); + contextMenu.activateItem(removeButton); + await hidden; + + // Re-open the panel to make sure the extension is still there. + await openExtensionsPanel(); + const item = getUnifiedExtensionsItem(extension.id); + is( + item.querySelector(".unified-extensions-item-name").textContent, + "an extension", + "expected extension to still be listed" + ); + await closeExtensionsPanel(); + + await extension.unload(); + // Restore prompt service. + Services.prompt = prompt; +}); + +add_task(async function test_open_context_menu_on_click() { + const [extension] = createExtensions([{ name: "an extension" }]); + await extension.startup(); + + // Open the extension panel. + await openExtensionsPanel(); + + const button = getUnifiedExtensionsItem(extension.id).querySelector( + ".unified-extensions-item-menu-button" + ); + ok(button, "expected menu button"); + + const contextMenu = document.getElementById( + "unified-extensions-context-menu" + ); + ok(contextMenu, "expected menu"); + + // Open the context menu with a "right-click". + const shown = BrowserTestUtils.waitForEvent(contextMenu, "popupshown"); + EventUtils.synthesizeMouseAtCenter(button, { type: "contextmenu" }); + await shown; + + await closeChromeContextMenu(contextMenu.id, null); + await closeExtensionsPanel(); + + await extension.unload(); +}); + +add_task(async function test_open_context_menu_with_keyboard() { + const [extension] = createExtensions([{ name: "an extension" }]); + await extension.startup(); + + // Open the extension panel. + await openExtensionsPanel(); + + const button = getUnifiedExtensionsItem(extension.id).querySelector( + ".unified-extensions-item-menu-button" + ); + ok(button, "expected menu button"); + // Make this button focusable because those (toolbar) buttons are only made + // focusable when a user is navigating with the keyboard, which isn't exactly + // what we are doing in this test. + button.setAttribute("tabindex", "-1"); + + const contextMenu = document.getElementById( + "unified-extensions-context-menu" + ); + ok(contextMenu, "expected menu"); + + // Open the context menu by focusing the button and pressing the SPACE key. + let shown = BrowserTestUtils.waitForEvent(contextMenu, "popupshown"); + button.focus(); + is(button, document.activeElement, "expected button to be focused"); + EventUtils.synthesizeKey(" ", {}); + await shown; + + await closeChromeContextMenu(contextMenu.id, null); + + if (AppConstants.platform != "macosx") { + // Open the context menu by focusing the button and pressing the ENTER key. + // TODO(emilio): Maybe we should harmonize this behavior across platforms, + // we're inconsistent right now. + shown = BrowserTestUtils.waitForEvent(contextMenu, "popupshown"); + button.focus(); + is(button, document.activeElement, "expected button to be focused"); + EventUtils.synthesizeKey("KEY_Enter", {}); + await shown; + await closeChromeContextMenu(contextMenu.id, null); + } + + await closeExtensionsPanel(); + + await extension.unload(); +}); + +add_task(async function test_context_menu_without_browserActionFor_global() { + const { ExtensionParent } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionParent.sys.mjs" + ); + const { browserActionFor } = ExtensionParent.apiManager.global; + const cleanup = () => { + ExtensionParent.apiManager.global.browserActionFor = browserActionFor; + }; + registerCleanupFunction(cleanup); + // This is needed to simulate the case where the browserAction API hasn't + // been loaded yet (since it is lazy-loaded). That could happen when only + // extensions without browser actions are installed. In which case, the + // `global.browserActionFor()` function would not be defined yet. + delete ExtensionParent.apiManager.global.browserActionFor; + + const [extension] = createExtensions([{ name: "an extension" }]); + await extension.startup(); + + // Open the extension panel and then the context menu for the extension that + // has been loaded above. We expect the context menu to be displayed and no + // error caused by the lack of `global.browserActionFor()`. + await openExtensionsPanel(); + // This promise rejects with an error if the implementation does not handle + // the case where `global.browserActionFor()` is undefined. + const contextMenu = await openUnifiedExtensionsContextMenu(extension.id); + assertVisibleContextMenuItems(contextMenu, 3); + + await closeChromeContextMenu(contextMenu.id, null); + await closeExtensionsPanel(); + + await extension.unload(); + + cleanup(); +}); + +add_task(async function test_page_action_context_menu() { + const extWithMenuPageAction = ExtensionTestUtils.loadExtension({ + manifest: { + page_action: {}, + permissions: ["contextMenus"], + }, + background() { + browser.contextMenus.create( + { + id: "some-menu-id", + title: "Click me!", + contexts: ["all"], + }, + () => browser.test.sendMessage("menu-created") + ); + }, + useAddonManager: "temporary", + }); + const extWithoutMenu1 = ExtensionTestUtils.loadExtension({ + manifest: { + name: "extension without any menu", + }, + useAddonManager: "temporary", + }); + + const extensions = [extWithMenuPageAction, extWithoutMenu1]; + + await Promise.all(extensions.map(extension => extension.startup())); + + await extWithMenuPageAction.awaitMessage("menu-created"); + + await openExtensionsPanel(); + + info("extension with page action and a menu"); + // This extension declares a page action so its menu shouldn't be added to + // the unified extensions context menu. + let contextMenu = await openUnifiedExtensionsContextMenu( + extWithMenuPageAction.id + ); + assertVisibleContextMenuItems(contextMenu, 3); + await closeChromeContextMenu(contextMenu.id, null); + + info("extension with no browser action and no menu"); + // There is no context menu created by this extension, so there should only + // be 3 menu items corresponding to the default manage/remove/report items. + contextMenu = await openUnifiedExtensionsContextMenu(extWithoutMenu1.id); + assertVisibleContextMenuItems(contextMenu, 3); + await closeChromeContextMenu(contextMenu.id, null); + + await closeExtensionsPanel(); + + await Promise.all(extensions.map(extension => extension.unload())); +}); + +add_task(async function test_pin_to_toolbar() { + const [extension] = createExtensions([ + { name: "an extension", browser_action: {} }, + ]); + await extension.startup(); + + // Open the extension panel, then open the context menu for the extension and + // pin the extension to the toolbar. + await openExtensionsPanel(); + await pinToToolbar(extension); + + // Undo the 'pin to toolbar' action. + await CustomizableUI.reset(); + await extension.unload(); +}); + +add_task(async function test_contextmenu_command_closes_panel() { + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + name: "an extension", + browser_action: {}, + permissions: ["contextMenus"], + }, + background() { + browser.contextMenus.create( + { + id: "some-menu-id", + title: "Click me!", + contexts: ["all"], + }, + () => browser.test.sendMessage("menu-created") + ); + }, + useAddonManager: "temporary", + }); + await extension.startup(); + await extension.awaitMessage("menu-created"); + + await openExtensionsPanel(); + const contextMenu = await openUnifiedExtensionsContextMenu(extension.id); + + const firstMenuItem = contextMenu.querySelector("menuitem"); + is( + firstMenuItem?.getAttribute("label"), + "Click me!", + "expected custom menu item as first child" + ); + + const hidden = BrowserTestUtils.waitForEvent( + gUnifiedExtensions.panel, + "popuphidden", + true + ); + contextMenu.activateItem(firstMenuItem); + await hidden; + + await extension.unload(); +}); + +add_task(async function test_contextmenu_reorder_extensions() { + const [ext1, ext2, ext3] = createExtensions([ + { name: "ext1", browser_action: {} }, + { name: "ext2", browser_action: {} }, + { name: "ext3", browser_action: {} }, + ]); + // Start the test extensions in sequence to reduce chance of + // intermittent failures when asserting the order of the + // entries in the panel in the rest of this test task. + await ext1.startup(); + await ext2.startup(); + await ext3.startup(); + + await openExtensionsPanel(); + + // First extension in the list should only have "Move Down". + await assertMoveContextMenuItems(ext1, { + expectMoveUpHidden: true, + expectMoveDownHidden: false, + }); + + // Second extension in the list should have "Move Up" and "Move Down". + await assertMoveContextMenuItems(ext2, { + expectMoveUpHidden: false, + expectMoveDownHidden: false, + }); + + // Third extension in the list should only have "Move Up". + await assertMoveContextMenuItems(ext3, { + expectMoveUpHidden: false, + expectMoveDownHidden: true, + expectOrder: [ext1, ext2, ext3], + }); + + // Let's move some extensions now. We'll start by moving ext1 down until it + // is positioned at the end of the list. + info("Move down ext1 action to the bottom of the list"); + await moveWidgetDown(ext1); + assertOrderOfWidgetsInPanel([ext2, ext1, ext3]); + await moveWidgetDown(ext1); + + // Verify that the extension 1 has the right context menu items now that it + // is located at the end of the list. + await assertMoveContextMenuItems(ext1, { + expectMoveUpHidden: false, + expectMoveDownHidden: true, + expectOrder: [ext2, ext3, ext1], + }); + + info("Move up ext1 action to the top of the list"); + await moveWidgetUp(ext1); + assertOrderOfWidgetsInPanel([ext2, ext1, ext3]); + + await moveWidgetUp(ext1); + assertOrderOfWidgetsInPanel([ext1, ext2, ext3]); + + // Move the last extension up. + info("Move up ext3 action"); + await moveWidgetUp(ext3); + assertOrderOfWidgetsInPanel([ext1, ext3, ext2]); + + // Move the last extension up (again). + info("Move up ext2 action to the top of the list"); + await moveWidgetUp(ext2); + assertOrderOfWidgetsInPanel([ext1, ext2, ext3]); + + // Move the second extension up. + await moveWidgetUp(ext2); + assertOrderOfWidgetsInPanel([ext2, ext1, ext3]); + + // Pin an extension to the toolbar, which should remove it from the panel. + info("Pin ext1 action to the toolbar"); + await pinToToolbar(ext1); + await openExtensionsPanel(); + assertOrderOfWidgetsInPanel([ext2, ext3]); + await closeExtensionsPanel(); + + await Promise.all([ext1.unload(), ext2.unload(), ext3.unload()]); + await CustomizableUI.reset(); +}); + +add_task(async function test_contextmenu_only_one_widget() { + const [extension] = createExtensions([{ name: "ext1", browser_action: {} }]); + await extension.startup(); + + await openExtensionsPanel(); + await assertMoveContextMenuItems(extension, { + expectMoveUpHidden: true, + expectMoveDownHidden: true, + }); + await closeExtensionsPanel(); + + await extension.unload(); + await CustomizableUI.reset(); +}); + +add_task( + async function test_contextmenu_reorder_extensions_with_private_window() { + // We want a panel in private mode that looks like this one (ext2 is not + // allowed in PB mode): + // + // - ext1 + // - ext3 + // + // But if we ask CUI to list the widgets in the panel, it would list: + // + // - ext1 + // - ext2 + // - ext3 + // + const ext1 = ExtensionTestUtils.loadExtension({ + manifest: { + name: "ext1", + browser_specific_settings: { gecko: { id: "ext1@reorder-private" } }, + browser_action: {}, + }, + useAddonManager: "temporary", + incognitoOverride: "spanning", + }); + await ext1.startup(); + + const ext2 = ExtensionTestUtils.loadExtension({ + manifest: { + name: "ext2", + browser_specific_settings: { gecko: { id: "ext2@reorder-private" } }, + browser_action: {}, + }, + useAddonManager: "temporary", + incognitoOverride: "not_allowed", + }); + await ext2.startup(); + + const ext3 = ExtensionTestUtils.loadExtension({ + manifest: { + name: "ext3", + browser_specific_settings: { gecko: { id: "ext3@reorder-private" } }, + browser_action: {}, + }, + useAddonManager: "temporary", + incognitoOverride: "spanning", + }); + await ext3.startup(); + + // Make sure all extension widgets are in the correct order. + assertOrderOfWidgetsInPanel([ext1, ext2, ext3]); + + const privateWin = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + + await openExtensionsPanel(privateWin); + + // First extension in the list should only have "Move Down". + await assertMoveContextMenuItems( + ext1, + { + expectMoveUpHidden: true, + expectMoveDownHidden: false, + expectOrder: [ext1, ext3], + }, + privateWin + ); + + // Second extension in the list (which is ext3) should only have "Move Up". + await assertMoveContextMenuItems( + ext3, + { + expectMoveUpHidden: false, + expectMoveDownHidden: true, + expectOrder: [ext1, ext3], + }, + privateWin + ); + + // In private mode, we should only have two CUI widget nodes in the panel. + assertOrderOfWidgetsInPanel([ext1, ext3], privateWin); + + info("Move ext1 down"); + await moveWidgetDown(ext1, privateWin); + // The new order in a regular window should be: + assertOrderOfWidgetsInPanel([ext2, ext3, ext1]); + // ... while the order in the private window should be: + assertOrderOfWidgetsInPanel([ext3, ext1], privateWin); + + // Verify that the extension 1 has the right context menu items now that it + // is located at the end of the list in PB mode. + await assertMoveContextMenuItems( + ext1, + { + expectMoveUpHidden: false, + expectMoveDownHidden: true, + expectOrder: [ext3, ext1], + }, + privateWin + ); + + // Verify that the extension 3 has the right context menu items now that it + // is located at the top of the list in PB mode. + await assertMoveContextMenuItems( + ext3, + { + expectMoveUpHidden: true, + expectMoveDownHidden: false, + expectOrder: [ext3, ext1], + }, + privateWin + ); + + info("Move ext3 extension down"); + await moveWidgetDown(ext3, privateWin); + // The new order in a regular window should be: + assertOrderOfWidgetsInPanel([ext2, ext1, ext3]); + // ... while the order in the private window should be: + assertOrderOfWidgetsInPanel([ext1, ext3], privateWin); + + // Pin an extension to the toolbar, which should remove it from the panel. + info("Pin ext1 to the toolbar"); + await pinToToolbar(ext1, privateWin); + await openExtensionsPanel(privateWin); + + // The new order in a regular window should be: + assertOrderOfWidgetsInPanel([ext2, ext3]); + await assertMoveContextMenuItems( + ext3, + { + expectMoveUpHidden: true, + expectMoveDownHidden: true, + // ... while the order in the private window should be: + expectOrder: [ext3], + }, + privateWin + ); + + await closeExtensionsPanel(privateWin); + + await Promise.all([ext1.unload(), ext2.unload(), ext3.unload()]); + await CustomizableUI.reset(); + + await BrowserTestUtils.closeWindow(privateWin); + } +); diff --git a/browser/components/extensions/test/browser/browser_unified_extensions_cui.js b/browser/components/extensions/test/browser/browser_unified_extensions_cui.js new file mode 100644 index 0000000000..dc02623452 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_unified_extensions_cui.js @@ -0,0 +1,159 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +loadTestSubscript("head_unified_extensions.js"); + +/** + * Tests that if the addons panel is somehow open when customization mode is + * invoked, that the panel is hidden. + */ +add_task(async function test_hide_panel_when_customizing() { + await openExtensionsPanel(); + + let panel = gUnifiedExtensions.panel; + Assert.equal(panel.state, "open"); + + let panelHidden = BrowserTestUtils.waitForPopupEvent(panel, "hidden"); + CustomizableUI.dispatchToolboxEvent("customizationstarting", {}); + await panelHidden; + Assert.equal(panel.state, "closed"); + CustomizableUI.dispatchToolboxEvent("aftercustomization", {}); +}); + +/** + * Tests that if a browser action is in a collapsed toolbar area, like the + * bookmarks toolbar, that its DOM node is overflowed in the extensions panel. + */ +add_task(async function test_extension_in_collapsed_area() { + const extensions = createExtensions( + [ + { + name: "extension1", + browser_action: { default_area: "navbar", default_popup: "popup.html" }, + browser_specific_settings: { + gecko: { id: "unified-extensions-cui@ext-1" }, + }, + }, + { + name: "extension2", + browser_action: { default_area: "navbar" }, + browser_specific_settings: { + gecko: { id: "unified-extensions-cui@ext-2" }, + }, + }, + ], + { + files: { + "popup.html": `<!DOCTYPE html> + <html> + <body> + <h1>test popup</h1> + <script src="popup.js"></script> + <body> + </html> + `, + "popup.js": function () { + browser.test.sendMessage("test-popup-opened"); + }, + }, + } + ); + await Promise.all(extensions.map(extension => extension.startup())); + + await openExtensionsPanel(); + for (const extension of extensions) { + let item = getUnifiedExtensionsItem(extension.id); + Assert.ok( + !item, + `extension with ID=${extension.id} should not appear in the panel` + ); + } + await closeExtensionsPanel(); + + // Move an extension to the bookmarks toolbar. + const bookmarksToolbar = document.getElementById( + CustomizableUI.AREA_BOOKMARKS + ); + const firstExtensionWidgetID = AppUiTestInternals.getBrowserActionWidgetId( + extensions[0].id + ); + CustomizableUI.addWidgetToArea( + firstExtensionWidgetID, + CustomizableUI.AREA_BOOKMARKS + ); + + // Ensure that the toolbar is currently collapsed. + await promiseSetToolbarVisibility(bookmarksToolbar, false); + + await openExtensionsPanel(); + let item = getUnifiedExtensionsItem(extensions[0].id); + Assert.ok( + item, + "extension placed in the collapsed toolbar should appear in the panel" + ); + + // NOTE: ideally we would simply call `AppUiTestDelegate.clickBrowserAction()` + // but, unfortunately, that does internally call `showBrowserAction()`, which + // explicitly assert the group areaType that would hit a failure in this test + // because we are moving it to AREA_BOOKMARKS. + let widget = getBrowserActionWidget(extensions[0]).forWindow(window); + ok(widget, "Got a widget for the extension button overflowed into the panel"); + widget.node.firstElementChild.click(); + + const promisePanelBrowser = AppUiTestDelegate.awaitExtensionPanel( + window, + extensions[0].id, + true + ); + await extensions[0].awaitMessage("test-popup-opened"); + const extPanelBrowser = await promisePanelBrowser; + ok(extPanelBrowser, "Got a action panel browser"); + closeBrowserAction(extensions[0]); + + // Now, make the toolbar visible. + await promiseSetToolbarVisibility(bookmarksToolbar, true); + + await openExtensionsPanel(); + for (const extension of extensions) { + let item = getUnifiedExtensionsItem(extension.id); + Assert.ok( + !item, + `extension with ID=${extension.id} should not appear in the panel` + ); + } + await closeExtensionsPanel(); + + // Hide the bookmarks toolbar again. + await promiseSetToolbarVisibility(bookmarksToolbar, false); + + await openExtensionsPanel(); + item = getUnifiedExtensionsItem(extensions[0].id); + Assert.ok(item, "extension should reappear in the panel"); + await closeExtensionsPanel(); + + // We now empty the bookmarks toolbar but we keep the extension widget. + for (const widgetId of CustomizableUI.getWidgetIdsInArea( + CustomizableUI.AREA_BOOKMARKS + ).filter(widgetId => widgetId !== firstExtensionWidgetID)) { + CustomizableUI.removeWidgetFromArea(widgetId); + } + + // We make the bookmarks toolbar visible again. At this point, the extension + // widget should be re-inserted in this toolbar. + await promiseSetToolbarVisibility(bookmarksToolbar, true); + + await openExtensionsPanel(); + for (const extension of extensions) { + let item = getUnifiedExtensionsItem(extension.id); + Assert.ok( + !item, + `extension with ID=${extension.id} should not appear in the panel` + ); + } + await closeExtensionsPanel(); + + await Promise.all(extensions.map(extension => extension.unload())); + await CustomizableUI.reset(); +}); diff --git a/browser/components/extensions/test/browser/browser_unified_extensions_doorhangers.js b/browser/components/extensions/test/browser/browser_unified_extensions_doorhangers.js new file mode 100644 index 0000000000..8603928894 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_unified_extensions_doorhangers.js @@ -0,0 +1,116 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +loadTestSubscript("head_unified_extensions.js"); + +const verifyPermissionsPrompt = async expectedAnchorID => { + const ext = ExtensionTestUtils.loadExtension({ + useAddonManager: "temporary", + + manifest: { + chrome_settings_overrides: { + search_provider: { + name: "some search name", + search_url: "https://example.com/?q={searchTerms}", + is_default: true, + }, + }, + optional_permissions: ["history"], + }, + + background: () => { + browser.test.onMessage.addListener(async msg => { + if (msg !== "create-tab") { + return; + } + + await browser.tabs.create({ + url: browser.runtime.getURL("content.html"), + active: true, + }); + }); + }, + + files: { + "content.html": `<!DOCTYPE html><script src="content.js"></script>`, + "content.js": async () => { + browser.test.onMessage.addListener(async msg => { + browser.test.assertEq( + msg, + "grant-permission", + "expected message to grant permission" + ); + + const granted = await new Promise(resolve => { + browser.test.withHandlingUserInput(() => { + resolve( + browser.permissions.request({ permissions: ["history"] }) + ); + }); + }); + browser.test.assertTrue(granted, "permission request succeeded"); + + browser.test.sendMessage("ok"); + }); + + browser.test.sendMessage("ready"); + }, + }, + }); + + await BrowserTestUtils.withNewTab({ gBrowser }, async () => { + const defaultSearchPopupPromise = promisePopupNotificationShown( + "addon-webext-defaultsearch" + ); + let [panel] = await Promise.all([defaultSearchPopupPromise, ext.startup()]); + ok(panel, "expected panel"); + let notification = PopupNotifications.getNotification( + "addon-webext-defaultsearch" + ); + ok(notification, "expected notification"); + // We always want the defaultsearch popup to be anchored on the urlbar (the + // ID below) because the post-install popup would be displayed on top of + // this one otherwise, see Bug 1789407. + is( + notification?.anchorElement?.id, + "addons-notification-icon", + "expected the right anchor ID for the defaultsearch popup" + ); + // Accept to override the search. + panel.button.click(); + await TestUtils.topicObserved("webextension-defaultsearch-prompt-response"); + + ext.sendMessage("create-tab"); + await ext.awaitMessage("ready"); + + const popupPromise = promisePopupNotificationShown( + "addon-webext-permissions" + ); + ext.sendMessage("grant-permission"); + panel = await popupPromise; + ok(panel, "expected panel"); + notification = PopupNotifications.getNotification( + "addon-webext-permissions" + ); + ok(notification, "expected notification"); + is( + // We access the parent element because the anchor is on the icon (inside + // the button), not on the unified extensions button itself. + notification.anchorElement.id || + notification.anchorElement.parentElement.id, + expectedAnchorID, + "expected the right anchor ID" + ); + + panel.button.click(); + await ext.awaitMessage("ok"); + + await ext.unload(); + }); +}; + +add_task(async function test_permissions_prompt() { + await verifyPermissionsPrompt("unified-extensions-button"); +}); diff --git a/browser/components/extensions/test/browser/browser_unified_extensions_messages.js b/browser/components/extensions/test/browser/browser_unified_extensions_messages.js new file mode 100644 index 0000000000..10ff3c9c73 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_unified_extensions_messages.js @@ -0,0 +1,222 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +loadTestSubscript("head_unified_extensions.js"); + +const verifyMessageBar = message => { + Assert.equal( + message.getAttribute("type"), + "warning", + "expected warning message" + ); + Assert.ok( + !message.hasAttribute("dismissable"), + "expected message to not be dismissable" + ); + + const supportLink = message.querySelector("a"); + Assert.equal( + supportLink.getAttribute("support-page"), + "quarantined-domains", + "expected the correct support page ID" + ); + Assert.equal( + supportLink.getAttribute("aria-label"), + "Learn more: Some extensions are not allowed", + "expected the correct aria-labelledby value" + ); +}; + +add_task(async function test_quarantined_domain_message_disabled() { + const quarantinedDomain = "example.org"; + await SpecialPowers.pushPrefEnv({ + set: [ + ["extensions.quarantinedDomains.enabled", false], + ["extensions.quarantinedDomains.list", quarantinedDomain], + ], + }); + + // Load an extension that will have access to all domains, including the + // quarantined domain. + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["activeTab"], + browser_action: {}, + }, + }); + await extension.startup(); + + await BrowserTestUtils.withNewTab( + { gBrowser, url: `https://${quarantinedDomain}/` }, + async () => { + await openExtensionsPanel(); + Assert.equal(getMessageBars().length, 0, "expected no message"); + await closeExtensionsPanel(); + } + ); + + await extension.unload(); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_quarantined_domain_message() { + const quarantinedDomain = "example.org"; + await SpecialPowers.pushPrefEnv({ + set: [ + ["extensions.quarantinedDomains.enabled", true], + ["extensions.quarantinedDomains.list", quarantinedDomain], + ], + }); + + // Load an extension that will have access to all domains, including the + // quarantined domain. + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["activeTab"], + browser_action: {}, + }, + }); + await extension.startup(); + + await BrowserTestUtils.withNewTab( + { gBrowser, url: `https://${quarantinedDomain}/` }, + async () => { + await openExtensionsPanel(); + + const messages = getMessageBars(); + Assert.equal(messages.length, 1, "expected a message"); + + const [message] = messages; + verifyMessageBar(message); + + await closeExtensionsPanel(); + } + ); + + // Navigating to a different tab/domain shouldn't show any message. + await BrowserTestUtils.withNewTab( + { gBrowser, url: `http://mochi.test:8888/` }, + async () => { + await openExtensionsPanel(); + Assert.equal(getMessageBars().length, 0, "expected no message"); + await closeExtensionsPanel(); + } + ); + + // Back to a quarantined domain, if we update the list, we expect the message + // to be gone when we re-open the panel (and not before because we don't + // listen to the pref currently). + await BrowserTestUtils.withNewTab( + { gBrowser, url: `https://${quarantinedDomain}/` }, + async () => { + await openExtensionsPanel(); + + const messages = getMessageBars(); + Assert.equal(messages.length, 1, "expected a message"); + + const [message] = messages; + verifyMessageBar(message); + + await closeExtensionsPanel(); + + // Clear the list of quarantined domains. + Services.prefs.setStringPref("extensions.quarantinedDomains.list", ""); + + await openExtensionsPanel(); + Assert.equal(getMessageBars().length, 0, "expected no message"); + await closeExtensionsPanel(); + } + ); + + await extension.unload(); + await SpecialPowers.popPrefEnv(); +}); + +add_task(async function test_quarantined_domain_message_learn_more_link() { + const quarantinedDomain = "example.org"; + await SpecialPowers.pushPrefEnv({ + set: [ + ["extensions.quarantinedDomains.enabled", true], + ["extensions.quarantinedDomains.list", quarantinedDomain], + ], + }); + + // Load an extension that will have access to all domains, including the + // quarantined domain. + const extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["activeTab"], + browser_action: {}, + }, + }); + await extension.startup(); + + const expectedSupportURL = + Services.urlFormatter.formatURLPref("app.support.baseURL") + + "quarantined-domains"; + + // We expect the SUMO page to be open in a new tab and the panel to be closed + // when the user clicks on the "learn more" link. + await BrowserTestUtils.withNewTab( + { gBrowser, url: `https://${quarantinedDomain}/` }, + async () => { + await openExtensionsPanel(); + const messages = getMessageBars(); + Assert.equal(messages.length, 1, "expected a message"); + + const [message] = messages; + verifyMessageBar(message); + + const tabPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + expectedSupportURL + ); + const hidden = BrowserTestUtils.waitForEvent( + gUnifiedExtensions.panel, + "popuphidden", + true + ); + message.querySelector("a").click(); + const [tab] = await Promise.all([tabPromise, hidden]); + BrowserTestUtils.removeTab(tab); + } + ); + + // Same as above but with keyboard navigation. + await BrowserTestUtils.withNewTab( + { gBrowser, url: `https://${quarantinedDomain}/` }, + async () => { + await openExtensionsPanel(); + const messages = getMessageBars(); + Assert.equal(messages.length, 1, "expected a message"); + + const [message] = messages; + verifyMessageBar(message); + + const supportLink = message.querySelector("a"); + + // Focus the "learn more" (support) link. + const focused = BrowserTestUtils.waitForEvent(supportLink, "focus"); + EventUtils.synthesizeKey("VK_TAB", {}); + await focused; + + const tabPromise = BrowserTestUtils.waitForNewTab( + gBrowser, + expectedSupportURL + ); + const hidden = BrowserTestUtils.waitForEvent( + gUnifiedExtensions.panel, + "popuphidden", + true + ); + EventUtils.synthesizeKey("KEY_Enter", {}); + const [tab] = await Promise.all([tabPromise, hidden]); + BrowserTestUtils.removeTab(tab); + } + ); + + await extension.unload(); + await SpecialPowers.popPrefEnv(); +}); diff --git a/browser/components/extensions/test/browser/browser_unified_extensions_overflowable_toolbar.js b/browser/components/extensions/test/browser/browser_unified_extensions_overflowable_toolbar.js new file mode 100644 index 0000000000..9758a96636 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_unified_extensions_overflowable_toolbar.js @@ -0,0 +1,1389 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +/** + * This file tests the behaviour of the overflowable nav-bar with Unified + * Extensions enabled and disabled. + */ + +"use strict"; + +loadTestSubscript("head_unified_extensions.js"); + +requestLongerTimeout(2); + +const NUM_EXTENSIONS = 5; +const OVERFLOW_WINDOW_WIDTH_PX = 450; +const DEFAULT_WIDGET_IDS = [ + "home-button", + "library-button", + "zoom-controls", + "search-container", + "sidebar-button", +]; +const OVERFLOWED_EXTENSIONS_LIST_ID = "overflowed-extensions-list"; + +add_setup(async function () { + // To make it easier to control things that will overflow, we'll start by + // removing that's removable out of the nav-bar and adding just a fixed + // set of items (DEFAULT_WIDGET_IDS) at the end of the nav-bar. + let existingWidgetIDs = CustomizableUI.getWidgetIdsInArea( + CustomizableUI.AREA_NAVBAR + ); + for (let widgetID of existingWidgetIDs) { + if (CustomizableUI.isWidgetRemovable(widgetID)) { + CustomizableUI.removeWidgetFromArea(widgetID); + } + } + for (const widgetID of DEFAULT_WIDGET_IDS) { + CustomizableUI.addWidgetToArea(widgetID, CustomizableUI.AREA_NAVBAR); + } + + registerCleanupFunction(async () => { + await CustomizableUI.reset(); + }); +}); + +/** + * Returns the IDs of the children of parent. + * + * @param {Element} parent + * @returns {string[]} the IDs of the children + */ +function getChildrenIDs(parent) { + return Array.from(parent.children).map(child => child.id); +} + +/** + * Returns a NodeList of all non-hidden menu, menuitem and menuseparators + * that are direct descendants of popup. + * + * @param {Element} popup + * @returns {NodeList} the visible items. + */ +function getVisibleMenuItems(popup) { + return popup.querySelectorAll( + ":scope > :is(menu, menuitem, menuseparator):not([hidden])" + ); +} + +/** + * This helper function does most of the heavy lifting for these tests. + * It does the following in order: + * + * 1. Registers and enables NUM_EXTENSIONS test WebExtensions that add + * browser_action buttons to the nav-bar. + * 2. Resizes the window to force things after the URL bar to overflow. + * 3. Calls an async test function to analyze the overflow lists. + * 4. Restores the window's original width, ensuring that the IDs of the + * nav-bar match the original set. + * 5. Unloads all of the test WebExtensions + * + * @param {DOMWindow} win The browser window to perform the test on. + * @param {object} options Additional options when running this test. + * @param {Function} options.beforeOverflowed This optional async function will + * be run after the extensions are created and added to the toolbar, but + * before the toolbar overflows. The function is called with the following + * arguments: + * + * {string[]} extensionIDs: The IDs of the test WebExtensions. + * + * The return value of the function is ignored. + * @param {Function} options.whenOverflowed This optional async function will + * run once the window is in the overflow state. The function is called + * with the following arguments: + * + * {Element} defaultList: The DOM element that holds overflowed default + * items. + * {Element} unifiedExtensionList: The DOM element that holds overflowed + * WebExtension browser_actions when Unified Extensions is enabled. + * {string[]} extensionIDs: The IDs of the test WebExtensions. + * + * The return value of the function is ignored. + * @param {Function} options.afterUnderflowed This optional async function will + * be run after the window is expanded and the toolbar has underflowed, but + * before the extensions are removed. This function is not passed any + * arguments. The return value of the function is ignored. + * + */ +async function withWindowOverflowed( + win, + { + beforeOverflowed = async () => {}, + whenOverflowed = async () => {}, + afterUnderflowed = async () => {}, + } = {} +) { + const doc = win.document; + doc.documentElement.removeAttribute("persist"); + const navbar = doc.getElementById(CustomizableUI.AREA_NAVBAR); + + await ensureMaximizedWindow(win); + + // The OverflowableToolbar operates asynchronously at times, so we will + // poll a widget's overflowedItem attribute to detect whether or not the + // widgets have finished being moved. We'll use the first widget that + // we added to the nav-bar, as this should be the left-most item in the + // set that we added. + const signpostWidgetID = "home-button"; + // We'll also force the signpost widget to be extra-wide to ensure that it + // overflows after we shrink the window. + CustomizableUI.getWidget(signpostWidgetID).forWindow(win).node.style = + "width: 150px"; + + const extWithMenuBrowserAction = ExtensionTestUtils.loadExtension({ + manifest: { + name: "Extension #0", + browser_specific_settings: { + gecko: { id: "unified-extensions-overflowable-toolbar@ext-0" }, + }, + browser_action: { + default_area: "navbar", + }, + // We pass `activeTab` to have a different permission message when + // hovering the primary/action button. + permissions: ["activeTab", "contextMenus"], + }, + background() { + browser.contextMenus.create( + { + id: "some-menu-id", + title: "Click me!", + contexts: ["all"], + }, + () => browser.test.sendMessage("menu-created") + ); + }, + useAddonManager: "temporary", + }); + + const extWithSubMenuBrowserAction = ExtensionTestUtils.loadExtension({ + manifest: { + name: "Extension #1", + browser_specific_settings: { + gecko: { id: "unified-extensions-overflowable-toolbar@ext-1" }, + }, + browser_action: { + default_area: "navbar", + }, + permissions: ["contextMenus"], + }, + background() { + browser.contextMenus.create({ + id: "some-menu-id", + title: "Open sub-menu", + contexts: ["all"], + }); + browser.contextMenus.create( + { + id: "some-sub-menu-id", + parentId: "some-menu-id", + title: "Click me!", + contexts: ["all"], + }, + () => browser.test.sendMessage("menu-created") + ); + }, + useAddonManager: "temporary", + }); + + const manifests = []; + for (let i = 2; i < NUM_EXTENSIONS; ++i) { + manifests.push({ + name: `Extension #${i}`, + browser_action: { + default_area: "navbar", + }, + browser_specific_settings: { + gecko: { id: `unified-extensions-overflowable-toolbar@ext-${i}` }, + }, + }); + } + + const extensions = [ + extWithMenuBrowserAction, + extWithSubMenuBrowserAction, + ...createExtensions(manifests), + ]; + + // Adding browser actions is asynchronous, so this CustomizableUI listener + // is used to make sure that the browser action widgets have finished getting + // added. + let listener = { + _remainingBrowserActions: NUM_EXTENSIONS, + _deferred: Promise.withResolvers(), + + get promise() { + return this._deferred.promise; + }, + + onWidgetAdded(widgetID, area) { + if (widgetID.endsWith("-browser-action")) { + this._remainingBrowserActions--; + } + if (!this._remainingBrowserActions) { + this._deferred.resolve(); + } + }, + }; + CustomizableUI.addListener(listener); + // Start all the extensions sequentially. + for (const extension of extensions) { + await extension.startup(); + } + await Promise.all([ + extWithMenuBrowserAction.awaitMessage("menu-created"), + extWithSubMenuBrowserAction.awaitMessage("menu-created"), + ]); + await listener.promise; + CustomizableUI.removeListener(listener); + + const extensionIDs = extensions.map(extension => extension.id); + + try { + info("Running beforeOverflowed task"); + await beforeOverflowed(extensionIDs); + } finally { + // The beforeOverflowed task may have moved some items out from the navbar, + // so only listen for overflows for items still in there. + const browserActionIDs = extensionIDs.map(id => + AppUiTestInternals.getBrowserActionWidgetId(id) + ); + const browserActionsInNavBar = browserActionIDs.filter(widgetID => { + let placement = CustomizableUI.getPlacementOfWidget(widgetID); + return placement.area == CustomizableUI.AREA_NAVBAR; + }); + + let widgetOverflowListener = { + _remainingOverflowables: + browserActionsInNavBar.length + DEFAULT_WIDGET_IDS.length, + _deferred: Promise.withResolvers(), + + get promise() { + return this._deferred.promise; + }, + + onWidgetOverflow(widgetNode, areaNode) { + this._remainingOverflowables--; + if (!this._remainingOverflowables) { + this._deferred.resolve(); + } + }, + }; + CustomizableUI.addListener(widgetOverflowListener); + + win.resizeTo(OVERFLOW_WINDOW_WIDTH_PX, win.outerHeight); + await widgetOverflowListener.promise; + CustomizableUI.removeListener(widgetOverflowListener); + + Assert.ok( + navbar.hasAttribute("overflowing"), + "Should have an overflowing toolbar." + ); + + const defaultList = doc.getElementById( + navbar.getAttribute("default-overflowtarget") + ); + + const unifiedExtensionList = doc.getElementById( + navbar.getAttribute("addon-webext-overflowtarget") + ); + + try { + info("Running whenOverflowed task"); + await whenOverflowed(defaultList, unifiedExtensionList, extensionIDs); + } finally { + await ensureMaximizedWindow(win); + + // Notably, we don't wait for the nav-bar to not have the "overflowing" + // attribute. This is because we might be running in an environment + // where the nav-bar was overflowing to begin with. Let's just hope that + // our sign-post widget has stopped overflowing. + await TestUtils.waitForCondition(() => { + return !doc + .getElementById(signpostWidgetID) + .hasAttribute("overflowedItem"); + }); + + try { + info("Running afterUnderflowed task"); + await afterUnderflowed(); + } finally { + await Promise.all(extensions.map(extension => extension.unload())); + } + } + } +} + +async function verifyExtensionWidget(widget, win = window) { + Assert.ok(widget, "expected widget"); + + let actionButton = widget.querySelector( + ".unified-extensions-item-action-button" + ); + Assert.ok( + actionButton.classList.contains("unified-extensions-item-action-button"), + "expected action class on the button" + ); + ok( + actionButton.classList.contains("subviewbutton"), + "expected the .subviewbutton CSS class on the action button in the panel" + ); + ok( + !actionButton.classList.contains("toolbarbutton-1"), + "expected no .toolbarbutton-1 CSS class on the action button in the panel" + ); + + let menuButton = widget.lastElementChild; + Assert.ok( + menuButton.classList.contains("unified-extensions-item-menu-button"), + "expected class on the button" + ); + ok( + menuButton.classList.contains("subviewbutton"), + "expected the .subviewbutton CSS class on the menu button in the panel" + ); + ok( + !menuButton.classList.contains("toolbarbutton-1"), + "expected no .toolbarbutton-1 CSS class on the menu button in the panel" + ); + + let contents = actionButton.querySelector( + ".unified-extensions-item-contents" + ); + + Assert.ok(contents, "expected contents element"); + // This is needed to correctly position the contents (vbox) element in the + // toolbarbutton. + Assert.equal( + contents.getAttribute("move-after-stack"), + "true", + "expected move-after-stack attribute to be set" + ); + // Make sure the contents element is inserted after the stack one (which is + // automagically created by the toolbarbutton element). + Assert.deepEqual( + Array.from(actionButton.childNodes.values()).map( + child => child.classList[0] + ), + [ + // The stack (which contains the extension icon) should be the first + // child. + "toolbarbutton-badge-stack", + // This is the widget label, which is hidden with CSS. + "toolbarbutton-text", + // This is the contents element, which displays the extension name and + // messages. + "unified-extensions-item-contents", + ], + "expected the correct order for the children of the action button" + ); + + let name = contents.querySelector(".unified-extensions-item-name"); + Assert.ok(name, "expected name element"); + Assert.ok( + name.textContent.startsWith("Extension "), + "expected name to not be empty" + ); + Assert.ok( + contents.querySelector(".unified-extensions-item-message-default"), + "expected message default element" + ); + Assert.ok( + contents.querySelector(".unified-extensions-item-message-hover"), + "expected message hover element" + ); + + Assert.equal( + win.document.l10n.getAttributes(menuButton).id, + "unified-extensions-item-open-menu", + "expected l10n id attribute for the extension" + ); + Assert.deepEqual( + Object.keys(win.document.l10n.getAttributes(menuButton).args), + ["extensionName"], + "expected l10n args attribute for the extension" + ); + Assert.ok( + win.document.l10n + .getAttributes(menuButton) + .args.extensionName.startsWith("Extension "), + "expected l10n args attribute to start with the correct name" + ); + Assert.ok( + menuButton.getAttribute("aria-label") !== "", + "expected menu button to have non-empty localized content" + ); +} + +/** + * Tests that overflowed browser actions go to the Unified Extensions + * panel, and default toolbar items go into the default overflow + * panel. + */ +add_task(async function test_overflowable_toolbar() { + let win = await BrowserTestUtils.openNewBrowserWindow(); + let movedNode; + + await withWindowOverflowed(win, { + whenOverflowed: async (defaultList, unifiedExtensionList, extensionIDs) => { + // Ensure that there are 5 items in the Unified Extensions overflow + // list, and the default widgets should all be in the default overflow + // list (though there might be more items from the nav-bar in there that + // already existed in the nav-bar before we put the default widgets in + // there as well). + let defaultListIDs = getChildrenIDs(defaultList); + for (const widgetID of DEFAULT_WIDGET_IDS) { + Assert.ok( + defaultListIDs.includes(widgetID), + `Default overflow list should have ${widgetID}` + ); + } + + Assert.ok( + unifiedExtensionList.children.length, + "Should have items in the Unified Extension list." + ); + + for (const child of Array.from(unifiedExtensionList.children)) { + Assert.ok( + extensionIDs.includes(child.dataset.extensionid), + `Unified Extensions overflow list should have ${child.dataset.extensionid}` + ); + await verifyExtensionWidget(child, win); + } + + let extensionWidgetID = AppUiTestInternals.getBrowserActionWidgetId( + extensionIDs.at(-1) + ); + movedNode = + CustomizableUI.getWidget(extensionWidgetID).forWindow(win).node; + Assert.equal(movedNode.getAttribute("cui-areatype"), "toolbar"); + + CustomizableUI.addWidgetToArea( + extensionWidgetID, + CustomizableUI.AREA_ADDONS + ); + + Assert.equal( + movedNode.getAttribute("cui-areatype"), + "panel", + "The moved browser action button should have the right cui-areatype set." + ); + }, + afterUnderflowed: async () => { + // Ensure that the moved node's parent is still the add-ons panel. + Assert.equal( + movedNode.parentElement.id, + CustomizableUI.AREA_ADDONS, + "The browser action should still be in the addons panel" + ); + CustomizableUI.addWidgetToArea(movedNode.id, CustomizableUI.AREA_NAVBAR); + }, + }); + + await BrowserTestUtils.closeWindow(win); +}); + +add_task(async function test_context_menu() { + let win = await BrowserTestUtils.openNewBrowserWindow(); + + await withWindowOverflowed(win, { + whenOverflowed: async (defaultList, unifiedExtensionList, extensionIDs) => { + Assert.ok( + unifiedExtensionList.children.length, + "Should have items in the Unified Extension list." + ); + + // Open the extension panel. + await openExtensionsPanel(win); + + // Let's verify the context menus for the following extensions: + // + // - the first one defines a menu in the background script + // - the second one defines a menu with submenu + // - the third extension has no menu + + info("extension with browser action and a menu"); + const firstExtensionWidget = unifiedExtensionList.children[0]; + Assert.ok(firstExtensionWidget, "expected extension widget"); + let contextMenu = await openUnifiedExtensionsContextMenu( + firstExtensionWidget.dataset.extensionid, + win + ); + Assert.ok(contextMenu, "expected a context menu"); + let visibleItems = getVisibleMenuItems(contextMenu); + + // The context menu for the extension that declares a browser action menu + // should have the menu item created by the extension, a menu separator, the control + // for pinning the browser action to the toolbar, a menu separator and the 3 default menu items. + is( + visibleItems.length, + 7, + "expected a custom context menu item, a menu separator, the pin to " + + "toolbar menu item, a menu separator, and the 3 default menu items" + ); + + const [item, separator] = visibleItems; + is( + item.getAttribute("label"), + "Click me!", + "expected menu item as first child" + ); + is( + separator.tagName, + "menuseparator", + "expected separator after last menu item created by the extension" + ); + + await closeChromeContextMenu(contextMenu.id, null, win); + + info("extension with browser action and a menu with submenu"); + const secondExtensionWidget = unifiedExtensionList.children[1]; + Assert.ok(secondExtensionWidget, "expected extension widget"); + contextMenu = await openUnifiedExtensionsContextMenu( + secondExtensionWidget.dataset.extensionid, + win + ); + visibleItems = getVisibleMenuItems(contextMenu); + is(visibleItems.length, 7, "expected 7 menu items"); + const popup = await openSubmenu(visibleItems[0]); + is(popup.children.length, 1, "expected 1 submenu item"); + is( + popup.children[0].getAttribute("label"), + "Click me!", + "expected menu item" + ); + // The number of items in the (main) context menu should remain the same. + visibleItems = getVisibleMenuItems(contextMenu); + is(visibleItems.length, 7, "expected 7 menu items"); + await closeChromeContextMenu(contextMenu.id, null, win); + + info("extension with no browser action and no menu"); + // There is no context menu created by this extension, so there should + // only be 3 menu items corresponding to the default manage/remove/report + // items. + const thirdExtensionWidget = unifiedExtensionList.children[2]; + Assert.ok(thirdExtensionWidget, "expected extension widget"); + contextMenu = await openUnifiedExtensionsContextMenu( + thirdExtensionWidget.dataset.extensionid, + win + ); + Assert.ok(contextMenu, "expected a context menu"); + visibleItems = getVisibleMenuItems(contextMenu); + is(visibleItems.length, 5, "expected 5 menu items"); + + await closeChromeContextMenu(contextMenu.id, null, win); + + // We can close the unified extensions panel now. + await closeExtensionsPanel(win); + }, + }); + + await BrowserTestUtils.closeWindow(win); +}); + +add_task(async function test_message_deck() { + let win = await BrowserTestUtils.openNewBrowserWindow(); + + await withWindowOverflowed(win, { + whenOverflowed: async (defaultList, unifiedExtensionList, extensionIDs) => { + Assert.ok( + unifiedExtensionList.children.length, + "Should have items in the Unified Extension list." + ); + + const firstExtensionWidget = unifiedExtensionList.children[0]; + Assert.ok(firstExtensionWidget, "expected extension widget"); + Assert.ok( + firstExtensionWidget.dataset.extensionid, + "expected data attribute for extension ID" + ); + + // Navigate to a page where `activeTab` is useful. + await BrowserTestUtils.withNewTab( + { gBrowser: win.gBrowser, url: "https://example.com/" }, + async () => { + // Open the extension panel. + await openExtensionsPanel(win); + + info("verify message when focusing the action button"); + const item = getUnifiedExtensionsItem( + firstExtensionWidget.dataset.extensionid, + win + ); + Assert.ok(item, "expected an item for the extension"); + + const actionButton = item.querySelector( + ".unified-extensions-item-action-button" + ); + Assert.ok(actionButton, "expected action button"); + + const menuButton = item.querySelector( + ".unified-extensions-item-menu-button" + ); + Assert.ok(menuButton, "expected menu button"); + + const messageDeck = item.querySelector( + ".unified-extensions-item-message-deck" + ); + Assert.ok(messageDeck, "expected message deck"); + is( + messageDeck.selectedIndex, + win.gUnifiedExtensions.MESSAGE_DECK_INDEX_DEFAULT, + "expected selected message in the deck to be the default message" + ); + + const defaultMessage = item.querySelector( + ".unified-extensions-item-message-default" + ); + Assert.deepEqual( + win.document.l10n.getAttributes(defaultMessage), + { id: "origin-controls-state-when-clicked", args: null }, + "expected correct l10n attributes for the default message" + ); + Assert.ok( + defaultMessage.textContent !== "", + "expected default message to not be empty" + ); + + const hoverMessage = item.querySelector( + ".unified-extensions-item-message-hover" + ); + Assert.deepEqual( + win.document.l10n.getAttributes(hoverMessage), + { id: "origin-controls-state-hover-run-visit-only", args: null }, + "expected correct l10n attributes for the hover message" + ); + Assert.ok( + hoverMessage.textContent !== "", + "expected hover message to not be empty" + ); + + const hoverMenuButtonMessage = item.querySelector( + ".unified-extensions-item-message-hover-menu-button" + ); + Assert.deepEqual( + win.document.l10n.getAttributes(hoverMenuButtonMessage), + { id: "unified-extensions-item-message-manage", args: null }, + "expected correct l10n attributes for the message when hovering the menu button" + ); + Assert.ok( + hoverMenuButtonMessage.textContent !== "", + "expected message for when the menu button is hovered to not be empty" + ); + + // 1. Focus the action button of the first extension in the panel. + let focused = BrowserTestUtils.waitForEvent(actionButton, "focus"); + EventUtils.synthesizeKey("VK_TAB", {}, win); + await focused; + is( + actionButton, + win.document.activeElement, + "expected action button of the first extension item to be focused" + ); + is( + messageDeck.selectedIndex, + win.gUnifiedExtensions.MESSAGE_DECK_INDEX_HOVER, + "expected selected message in the deck to be the hover message" + ); + + // 2. Focus the menu button, causing the action button to lose focus. + focused = BrowserTestUtils.waitForEvent(menuButton, "focus"); + EventUtils.synthesizeKey("VK_TAB", {}, win); + await focused; + is( + menuButton, + win.document.activeElement, + "expected menu button of the first extension item to be focused" + ); + is( + messageDeck.selectedIndex, + win.gUnifiedExtensions.MESSAGE_DECK_INDEX_MENU_HOVER, + "expected selected message in the deck to be the message when focusing the menu button" + ); + + await closeExtensionsPanel(win); + + info("verify message when hovering the action button"); + await openExtensionsPanel(win); + + is( + messageDeck.selectedIndex, + win.gUnifiedExtensions.MESSAGE_DECK_INDEX_DEFAULT, + "expected selected message in the deck to be the default message" + ); + + // 1. Hover the action button of the first extension in the panel. + let hovered = BrowserTestUtils.waitForEvent( + actionButton, + "mouseover" + ); + EventUtils.synthesizeMouseAtCenter( + actionButton, + { type: "mouseover" }, + win + ); + await hovered; + is( + messageDeck.selectedIndex, + win.gUnifiedExtensions.MESSAGE_DECK_INDEX_HOVER, + "expected selected message in the deck to be the hover message" + ); + + // 2. Hover the menu button, causing the action button to no longer + // be hovered. + hovered = BrowserTestUtils.waitForEvent(menuButton, "mouseover"); + EventUtils.synthesizeMouseAtCenter( + menuButton, + { type: "mouseover" }, + win + ); + await hovered; + is( + messageDeck.selectedIndex, + win.gUnifiedExtensions.MESSAGE_DECK_INDEX_MENU_HOVER, + "expected selected message in the deck to be the message when hovering the menu button" + ); + + await closeExtensionsPanel(win); + } + ); + }, + }); + + await BrowserTestUtils.closeWindow(win); +}); + +/** + * Tests that if we pin a browser action button listed in the addons panel + * to the toolbar when that button would immediately overflow, that the + * button is put into the addons panel overflow list. + */ +add_task(async function test_pinning_to_toolbar_when_overflowed() { + let win = await BrowserTestUtils.openNewBrowserWindow(); + + let movedNode; + let extensionWidgetID; + let actionButton; + let menuButton; + + await withWindowOverflowed(win, { + beforeOverflowed: async extensionIDs => { + // Before we overflow the toolbar, let's move the last item to the addons + // panel. + extensionWidgetID = AppUiTestInternals.getBrowserActionWidgetId( + extensionIDs.at(-1) + ); + + movedNode = + CustomizableUI.getWidget(extensionWidgetID).forWindow(win).node; + + actionButton = movedNode.querySelector( + ".unified-extensions-item-action-button" + ); + ok( + actionButton.classList.contains("toolbarbutton-1"), + "expected .toolbarbutton-1 CSS class on the action button in the navbar" + ); + ok( + !actionButton.classList.contains("subviewbutton"), + "expected no .subviewbutton CSS class on the action button in the navbar" + ); + + menuButton = movedNode.querySelector( + ".unified-extensions-item-menu-button" + ); + ok( + menuButton.classList.contains("toolbarbutton-1"), + "expected .toolbarbutton-1 CSS class on the menu button in the navbar" + ); + ok( + !menuButton.classList.contains("subviewbutton"), + "expected no .subviewbutton CSS class on the menu button in the navbar" + ); + + CustomizableUI.addWidgetToArea( + extensionWidgetID, + CustomizableUI.AREA_ADDONS + ); + + ok( + actionButton.classList.contains("subviewbutton"), + "expected .subviewbutton CSS class on the action button in the panel" + ); + ok( + !actionButton.classList.contains("toolbarbutton-1"), + "expected no .toolbarbutton-1 CSS class on the action button in the panel" + ); + ok( + menuButton.classList.contains("subviewbutton"), + "expected .subviewbutton CSS class on the menu button in the panel" + ); + ok( + !menuButton.classList.contains("toolbarbutton-1"), + "expected no .toolbarbutton-1 CSS class on the menu button in the panel" + ); + }, + whenOverflowed: async (defaultList, unifiedExtensionList, extensionIDs) => { + ok( + actionButton.classList.contains("subviewbutton"), + "expected .subviewbutton CSS class on the action button in the panel" + ); + ok( + !actionButton.classList.contains("toolbarbutton-1"), + "expected no .toolbarbutton-1 CSS class on the action button in the panel" + ); + ok( + menuButton.classList.contains("subviewbutton"), + "expected .subviewbutton CSS class on the menu button in the panel" + ); + ok( + !menuButton.classList.contains("toolbarbutton-1"), + "expected no .toolbarbutton-1 CSS class on the menu button in the panel" + ); + + // Now that the window is overflowed, let's move the widget in the addons + // panel back to the navbar. This should cause the widget to overflow back + // into the addons panel. + CustomizableUI.addWidgetToArea( + extensionWidgetID, + CustomizableUI.AREA_NAVBAR + ); + await TestUtils.waitForCondition(() => { + return movedNode.hasAttribute("overflowedItem"); + }); + Assert.equal( + movedNode.parentElement, + unifiedExtensionList, + "Should have overflowed the extension button to the right list." + ); + + ok( + actionButton.classList.contains("subviewbutton"), + "expected .subviewbutton CSS class on the action button in the panel" + ); + ok( + !actionButton.classList.contains("toolbarbutton-1"), + "expected no .toolbarbutton-1 CSS class on the action button in the panel" + ); + ok( + menuButton.classList.contains("subviewbutton"), + "expected .subviewbutton CSS class on the menu button in the panel" + ); + ok( + !menuButton.classList.contains("toolbarbutton-1"), + "expected no .toolbarbutton-1 CSS class on the menu button in the panel" + ); + }, + }); + + await BrowserTestUtils.closeWindow(win); +}); + +/** + * This test verifies that, when an extension placed in the toolbar is + * overflowed into the addons panel and context-clicked, it shows the "Pin to + * Toolbar" item as checked, and that unchecking this menu item inserts the + * extension into the dedicated addons area of the panel, and that the item + * then does not underflow. + */ +add_task(async function test_unpin_overflowed_widget() { + let win = await BrowserTestUtils.openNewBrowserWindow(); + let extensionID; + + await withWindowOverflowed(win, { + whenOverflowed: async (defaultList, unifiedExtensionList, extensionIDs) => { + const firstExtensionWidget = unifiedExtensionList.children[0]; + Assert.ok(firstExtensionWidget, "expected an extension widget"); + extensionID = firstExtensionWidget.dataset.extensionid; + + let movedNode = CustomizableUI.getWidget( + firstExtensionWidget.id + ).forWindow(win).node; + Assert.equal( + movedNode.getAttribute("cui-areatype"), + "toolbar", + "expected extension widget to be in the toolbar" + ); + Assert.ok( + movedNode.hasAttribute("overflowedItem"), + "expected extension widget to be overflowed" + ); + let actionButton = movedNode.querySelector( + ".unified-extensions-item-action-button" + ); + ok( + actionButton.classList.contains("subviewbutton"), + "expected the .subviewbutton CSS class on the action button in the panel" + ); + ok( + !actionButton.classList.contains("toolbarbutton-1"), + "expected no .toolbarbutton-1 CSS class on the action button in the panel" + ); + let menuButton = movedNode.querySelector( + ".unified-extensions-item-menu-button" + ); + ok( + menuButton.classList.contains("subviewbutton"), + "expected the .subviewbutton CSS class on the menu button in the panel" + ); + ok( + !menuButton.classList.contains("toolbarbutton-1"), + "expected no .toolbarbutton-1 CSS class on the menu button in the panel" + ); + + // Open the panel, then the context menu of the extension widget, verify + // the 'Pin to Toolbar' menu item, then click on this menu item to + // uncheck it (i.e. unpin the extension). + await openExtensionsPanel(win); + const contextMenu = await openUnifiedExtensionsContextMenu( + extensionID, + win + ); + Assert.ok(contextMenu, "expected a context menu"); + + const pinToToolbar = contextMenu.querySelector( + ".unified-extensions-context-menu-pin-to-toolbar" + ); + Assert.ok(pinToToolbar, "expected a 'Pin to Toolbar' menu item"); + Assert.ok( + !pinToToolbar.hidden, + "expected 'Pin to Toolbar' to be visible" + ); + Assert.equal( + pinToToolbar.getAttribute("checked"), + "true", + "expected 'Pin to Toolbar' to be checked" + ); + + // Uncheck "Pin to Toolbar" menu item. Clicking a menu item in the + // context menu closes the unified extensions panel automatically. + const hidden = BrowserTestUtils.waitForEvent( + win.gUnifiedExtensions.panel, + "popuphidden", + true + ); + contextMenu.activateItem(pinToToolbar); + await hidden; + + // We expect the widget to no longer be overflowed. + await TestUtils.waitForCondition(() => { + return !movedNode.hasAttribute("overflowedItem"); + }); + + Assert.equal( + movedNode.parentElement.id, + CustomizableUI.AREA_ADDONS, + "expected extension widget to have been unpinned and placed in the addons area" + ); + Assert.equal( + movedNode.getAttribute("cui-areatype"), + "panel", + "expected extension widget to be in the unified extensions panel" + ); + }, + afterUnderflowed: async () => { + await openExtensionsPanel(win); + + const item = getUnifiedExtensionsItem(extensionID, win); + Assert.ok( + item, + "expected extension widget to be listed in the unified extensions panel" + ); + let actionButton = item.querySelector( + ".unified-extensions-item-action-button" + ); + ok( + actionButton.classList.contains("subviewbutton"), + "expected the .subviewbutton CSS class on the action button in the panel" + ); + ok( + !actionButton.classList.contains("toolbarbutton-1"), + "expected no .toolbarbutton-1 CSS class on the action button in the panel" + ); + let menuButton = item.querySelector( + ".unified-extensions-item-menu-button" + ); + ok( + menuButton.classList.contains("subviewbutton"), + "expected the .subviewbutton CSS class on the menu button in the panel" + ); + ok( + !menuButton.classList.contains("toolbarbutton-1"), + "expected no .toolbarbutton-1 CSS class on the menu button in the panel" + ); + + await closeExtensionsPanel(win); + }, + }); + + await BrowserTestUtils.closeWindow(win); +}); + +add_task(async function test_overflow_with_a_second_window() { + let win = await BrowserTestUtils.openNewBrowserWindow(); + // Open a second window that will stay maximized. We want to be sure that + // overflowing a widget in one window isn't going to affect the other window + // since we have an instance (of a CUI widget) per window. + let secondWin = await BrowserTestUtils.openNewBrowserWindow(); + await ensureMaximizedWindow(secondWin); + await BrowserTestUtils.openNewForegroundTab( + secondWin.gBrowser, + "https://example.com/" + ); + + // Make sure the first window is the active window. + let windowActivePromise = new Promise(resolve => { + if (Services.focus.activeWindow == win) { + resolve(); + } else { + win.addEventListener( + "activate", + () => { + resolve(); + }, + { once: true } + ); + } + }); + win.focus(); + await windowActivePromise; + + let extensionWidgetID; + let aNode; + let aNodeInSecondWindow; + + await withWindowOverflowed(win, { + beforeOverflowed: async extensionIDs => { + extensionWidgetID = AppUiTestInternals.getBrowserActionWidgetId( + extensionIDs.at(-1) + ); + + // This is the DOM node for the current window that is overflowed. + aNode = CustomizableUI.getWidget(extensionWidgetID).forWindow(win).node; + Assert.ok( + !aNode.hasAttribute("overflowedItem"), + "expected extension widget to NOT be overflowed" + ); + + let actionButton = aNode.querySelector( + ".unified-extensions-item-action-button" + ); + ok( + actionButton.classList.contains("toolbarbutton-1"), + "expected .toolbarbutton-1 CSS class on the action button" + ); + ok( + !actionButton.classList.contains("subviewbutton"), + "expected no .subviewbutton CSS class on the action button" + ); + + let menuButton = aNode.querySelector( + ".unified-extensions-item-menu-button" + ); + ok( + menuButton.classList.contains("toolbarbutton-1"), + "expected .toolbarbutton-1 CSS class on the menu button" + ); + ok( + !menuButton.classList.contains("subviewbutton"), + "expected no .subviewbutton CSS class on the menu button" + ); + + // This is the DOM node of the same CUI widget but in the maximized + // window opened before. + aNodeInSecondWindow = + CustomizableUI.getWidget(extensionWidgetID).forWindow(secondWin).node; + + let actionButtonInSecondWindow = aNodeInSecondWindow.querySelector( + ".unified-extensions-item-action-button" + ); + ok( + actionButtonInSecondWindow.classList.contains("toolbarbutton-1"), + "expected .toolbarbutton-1 CSS class on the action button in the second window" + ); + ok( + !actionButtonInSecondWindow.classList.contains("subviewbutton"), + "expected no .subviewbutton CSS class on the action button in the second window" + ); + + let menuButtonInSecondWindow = aNodeInSecondWindow.querySelector( + ".unified-extensions-item-menu-button" + ); + ok( + menuButtonInSecondWindow.classList.contains("toolbarbutton-1"), + "expected .toolbarbutton-1 CSS class on the menu button in the second window" + ); + ok( + !menuButtonInSecondWindow.classList.contains("subviewbutton"), + "expected no .subviewbutton CSS class on the menu button in the second window" + ); + }, + whenOverflowed: async (defaultList, unifiedExtensionList, extensionIDs) => { + // The DOM node should have been overflowed. + Assert.ok( + aNode.hasAttribute("overflowedItem"), + "expected extension widget to be overflowed" + ); + Assert.equal( + aNode.getAttribute("widget-id"), + extensionWidgetID, + "expected the CUI widget ID to be set on the DOM node" + ); + + // When the node is overflowed, we swap the CSS class on the action + // and menu buttons since the node is now placed in the extensions panel. + let actionButton = aNode.querySelector( + ".unified-extensions-item-action-button" + ); + ok( + actionButton.classList.contains("subviewbutton"), + "expected the .subviewbutton CSS class on the action button" + ); + ok( + !actionButton.classList.contains("toolbarbutton-1"), + "expected no .toolbarbutton-1 CSS class on the action button" + ); + let menuButton = aNode.querySelector( + ".unified-extensions-item-menu-button" + ); + ok( + menuButton.classList.contains("subviewbutton"), + "expected the .subviewbutton CSS class on the menu button" + ); + ok( + !menuButton.classList.contains("toolbarbutton-1"), + "expected no .toolbarbutton-1 CSS class on the menu button" + ); + + // The DOM node in the other window should not have been overflowed. + Assert.ok( + !aNodeInSecondWindow.hasAttribute("overflowedItem"), + "expected extension widget to NOT be overflowed in the other window" + ); + Assert.equal( + aNodeInSecondWindow.getAttribute("widget-id"), + extensionWidgetID, + "expected the CUI widget ID to be set on the DOM node" + ); + + // We expect no CSS class changes for the node in the other window. + let actionButtonInSecondWindow = aNodeInSecondWindow.querySelector( + ".unified-extensions-item-action-button" + ); + ok( + actionButtonInSecondWindow.classList.contains("toolbarbutton-1"), + "expected .toolbarbutton-1 CSS class on the action button in the second window" + ); + ok( + !actionButtonInSecondWindow.classList.contains("subviewbutton"), + "expected no .subviewbutton CSS class on the action button in the second window" + ); + let menuButtonInSecondWindow = aNodeInSecondWindow.querySelector( + ".unified-extensions-item-menu-button" + ); + ok( + menuButtonInSecondWindow.classList.contains("toolbarbutton-1"), + "expected .toolbarbutton-1 CSS class on the menu button in the second window" + ); + ok( + !menuButtonInSecondWindow.classList.contains("subviewbutton"), + "expected no .subviewbutton CSS class on the menu button in the second window" + ); + }, + afterUnderflowed: async () => { + // After underflow, we expect the CSS class on the action and menu + // buttons of the DOM node of the current window to be updated. + let actionButton = aNode.querySelector( + ".unified-extensions-item-action-button" + ); + ok( + actionButton.classList.contains("toolbarbutton-1"), + "expected .toolbarbutton-1 CSS class on the action button in the panel" + ); + ok( + !actionButton.classList.contains("subviewbutton"), + "expected no .subviewbutton CSS class on the action button in the panel" + ); + let menuButton = aNode.querySelector( + ".unified-extensions-item-menu-button" + ); + ok( + menuButton.classList.contains("toolbarbutton-1"), + "expected .toolbarbutton-1 CSS class on the menu button in the panel" + ); + ok( + !menuButton.classList.contains("subviewbutton"), + "expected no .subviewbutton CSS class on the menu button in the panel" + ); + + // The DOM node of the other window should not be changed. + let actionButtonInSecondWindow = aNodeInSecondWindow.querySelector( + ".unified-extensions-item-action-button" + ); + ok( + actionButtonInSecondWindow.classList.contains("toolbarbutton-1"), + "expected .toolbarbutton-1 CSS class on the action button in the second window" + ); + ok( + !actionButtonInSecondWindow.classList.contains("subviewbutton"), + "expected no .subviewbutton CSS class on the action button in the second window" + ); + let menuButtonInSecondWindow = aNodeInSecondWindow.querySelector( + ".unified-extensions-item-menu-button" + ); + ok( + menuButtonInSecondWindow.classList.contains("toolbarbutton-1"), + "expected .toolbarbutton-1 CSS class on the menu button in the second window" + ); + ok( + !menuButtonInSecondWindow.classList.contains("subviewbutton"), + "expected no .subviewbutton CSS class on the menu button in the second window" + ); + }, + }); + + await BrowserTestUtils.closeWindow(win); + await BrowserTestUtils.closeWindow(secondWin); +}); + +add_task(async function test_overflow_with_extension_in_collapsed_area() { + const win = await BrowserTestUtils.openNewBrowserWindow(); + const bookmarksToolbar = win.document.getElementById( + CustomizableUI.AREA_BOOKMARKS + ); + + let movedNode; + let extensionWidgetID; + let extensionWidgetPosition; + + await withWindowOverflowed(win, { + beforeOverflowed: async extensionIDs => { + // Before we overflow the toolbar, let's move the last item to the + // (visible) bookmarks toolbar. + extensionWidgetID = AppUiTestInternals.getBrowserActionWidgetId( + extensionIDs.at(-1) + ); + + movedNode = + CustomizableUI.getWidget(extensionWidgetID).forWindow(win).node; + + // Ensure that the toolbar is currently visible. + await promiseSetToolbarVisibility(bookmarksToolbar, true); + + // Move an extension to the bookmarks toolbar. + CustomizableUI.addWidgetToArea( + extensionWidgetID, + CustomizableUI.AREA_BOOKMARKS + ); + + Assert.equal( + movedNode.parentElement.id, + CustomizableUI.AREA_BOOKMARKS, + "expected extension widget to be in the bookmarks toolbar" + ); + Assert.ok( + !movedNode.hasAttribute("artificallyOverflowed"), + "expected node to not have any artificallyOverflowed prop" + ); + + extensionWidgetPosition = + CustomizableUI.getPlacementOfWidget(extensionWidgetID).position; + + // At this point we have an extension in the bookmarks toolbar, and this + // toolbar is visible. We are going to resize the window (width) AND + // collapse the toolbar to verify that the extension placed in the + // bookmarks toolbar is overflowed in the panel without any side effects. + }, + whenOverflowed: async () => { + // Ensure that the toolbar is currently collapsed. + await promiseSetToolbarVisibility(bookmarksToolbar, false); + + Assert.equal( + movedNode.parentElement.id, + OVERFLOWED_EXTENSIONS_LIST_ID, + "expected extension widget to be in the extensions panel" + ); + Assert.ok( + movedNode.getAttribute("artificallyOverflowed"), + "expected node to be artifically overflowed" + ); + + // At this point the extension is in the panel because it was overflowed + // after the bookmarks toolbar has been collapsed. The window is also + // narrow, but we are going to restore the initial window size. Since the + // visibility of the bookmarks toolbar hasn't changed, the extension + // should still be in the panel. + }, + afterUnderflowed: async () => { + Assert.equal( + movedNode.parentElement.id, + OVERFLOWED_EXTENSIONS_LIST_ID, + "expected extension widget to still be in the extensions panel" + ); + Assert.ok( + movedNode.getAttribute("artificallyOverflowed"), + "expected node to still be artifically overflowed" + ); + + // Ensure that the toolbar is visible again, which should move the + // extension back to where it was initially. + await promiseSetToolbarVisibility(bookmarksToolbar, true); + + Assert.equal( + movedNode.parentElement.id, + CustomizableUI.AREA_BOOKMARKS, + "expected extension widget to be in the bookmarks toolbar" + ); + Assert.ok( + !movedNode.hasAttribute("artificallyOverflowed"), + "expected node to not have any artificallyOverflowed prop" + ); + Assert.equal( + CustomizableUI.getPlacementOfWidget(extensionWidgetID).position, + extensionWidgetPosition, + "expected the extension to be back at the same position in the bookmarks toolbar" + ); + }, + }); + + await BrowserTestUtils.closeWindow(win); +}); + +add_task(async function test_overflowed_extension_cannot_be_moved() { + let win = await BrowserTestUtils.openNewBrowserWindow(); + let extensionID; + + await withWindowOverflowed(win, { + whenOverflowed: async (defaultList, unifiedExtensionList, extensionIDs) => { + const secondExtensionWidget = unifiedExtensionList.children[1]; + Assert.ok(secondExtensionWidget, "expected an extension widget"); + extensionID = secondExtensionWidget.dataset.extensionid; + + await openExtensionsPanel(win); + const contextMenu = await openUnifiedExtensionsContextMenu( + extensionID, + win + ); + Assert.ok(contextMenu, "expected a context menu"); + + const moveUp = contextMenu.querySelector( + ".unified-extensions-context-menu-move-widget-up" + ); + Assert.ok(moveUp, "expected 'move up' item in the context menu"); + Assert.ok(moveUp.hidden, "expected 'move up' item to be hidden"); + + const moveDown = contextMenu.querySelector( + ".unified-extensions-context-menu-move-widget-down" + ); + Assert.ok(moveDown, "expected 'move down' item in the context menu"); + Assert.ok(moveDown.hidden, "expected 'move down' item to be hidden"); + + await closeChromeContextMenu(contextMenu.id, null, win); + await closeExtensionsPanel(win); + }, + }); + + await BrowserTestUtils.closeWindow(win); +}); diff --git a/browser/components/extensions/test/browser/context.html b/browser/components/extensions/test/browser/context.html new file mode 100644 index 0000000000..cd1a3db904 --- /dev/null +++ b/browser/components/extensions/test/browser/context.html @@ -0,0 +1,44 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="utf-8"> + </head> + <body> + just some text 12345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890 + <img src="ctxmenu-image.png" id="img1"> + + <p> + <a href="some-link" id="link1">Some link</a> + </p> + + <p> + <a href="image-around-some-link"> + <img src="ctxmenu-image.png" id="img-wrapped-in-link"> + </a> + </p> + + <p> + <input type="text" id="edit-me"><br> + <input id="readonly-text" type="text" readonly > + <input id="call-me-maybe" type="tel" value="0123456789"> + <input id="number-input" type="number" value="123456789"><br> + <input type="password" id="password"> + <input id="noneditablepassword" type="password" readonly > + </p> + <iframe id="frame" src="context_frame.html"></iframe> + <p id="longtext">Sed ut perspiciatis unde omnis iste natus error sit + voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque + ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta + sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut + odit aut fugit, sed quia consequuntur magni dolores eos qui ratione voluptatem + sequi nesciunt. Neque porro quisquam est, qui dolorem ipsum quia dolor sit + amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora + incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad + minima veniam, quis nostrum exercitationem ullam corporis suscipit + laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis autem vel eum + iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae + consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?</p> + + <input id="editabletext" type="text" value="At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum deleniti atque corrupti quos dolores et quas molestias excepturi sint occaecati cupiditate non provident, similique sunt in culpa qui officia deserunt mollitia animi, id est laborum et dolorum fuga. Et harum quidem rerum facilis est et expedita distinctio. Nam libero tempore, cum soluta nobis est eligendi optio cumque nihil impedit quo minus id quod maxime placeat facere possimus, omnis voluptas assumenda est, omnis dolor repellendus. Temporibus autem quibusdam et aut officiis debitis aut rerum necessitatibus saepe eveniet ut et voluptates repudiandae sint et molestiae non recusandae. Itaque earum rerum hic tenetur a sapiente delectus, ut aut reiciendis voluptatibus maiores alias consequatur aut perferendis doloribus asperiores repellat." /> + </body> +</html> diff --git a/browser/components/extensions/test/browser/context_frame.html b/browser/components/extensions/test/browser/context_frame.html new file mode 100644 index 0000000000..39ed37674f --- /dev/null +++ b/browser/components/extensions/test/browser/context_frame.html @@ -0,0 +1,8 @@ +<html> + <head> + <meta charset="utf-8"> + </head> + <body> + Just some text + </body> +</html> diff --git a/browser/components/extensions/test/browser/context_tabs_onUpdated_iframe.html b/browser/components/extensions/test/browser/context_tabs_onUpdated_iframe.html new file mode 100644 index 0000000000..0e9b54b523 --- /dev/null +++ b/browser/components/extensions/test/browser/context_tabs_onUpdated_iframe.html @@ -0,0 +1,19 @@ +<html> + <body> + <h3>test iframe</h3> + <script> + "use strict"; + + window.onload = function() { + window.onhashchange = function() { + window.parent.postMessage("updated-iframe-url", "*"); + }; + // NOTE: without the this setTimeout the location change is not fired + // even without the "fire only for top level windows" fix + setTimeout(function() { + window.location.hash = "updated-iframe-url"; + }, 0); + }; + </script> + </body> +</html> diff --git a/browser/components/extensions/test/browser/context_tabs_onUpdated_page.html b/browser/components/extensions/test/browser/context_tabs_onUpdated_page.html new file mode 100644 index 0000000000..0f2ce1e8fe --- /dev/null +++ b/browser/components/extensions/test/browser/context_tabs_onUpdated_page.html @@ -0,0 +1,18 @@ +<html> + <body> + <h3>test page</h3> + <iframe src="about:blank"></iframe> + <script> + "use strict"; + + window.onmessage = function(evt) { + if (evt.data === "updated-iframe-url") { + window.postMessage("frame-updated", "*"); + } + }; + window.onload = function() { + document.querySelector("iframe").setAttribute("src", "context_tabs_onUpdated_iframe.html"); + }; + </script> + </body> +</html> diff --git a/browser/components/extensions/test/browser/context_with_redirect.html b/browser/components/extensions/test/browser/context_with_redirect.html new file mode 100644 index 0000000000..cbf676729b --- /dev/null +++ b/browser/components/extensions/test/browser/context_with_redirect.html @@ -0,0 +1,4 @@ +<!DOCTYPE html> +<meta charset="utf-8"> + +<img id="img_that_redirects" src="redirect_to.sjs?ctxmenu-image.png"> diff --git a/browser/components/extensions/test/browser/ctxmenu-image.png b/browser/components/extensions/test/browser/ctxmenu-image.png Binary files differnew file mode 100644 index 0000000000..4c3be50847 --- /dev/null +++ b/browser/components/extensions/test/browser/ctxmenu-image.png diff --git a/browser/components/extensions/test/browser/empty.xpi b/browser/components/extensions/test/browser/empty.xpi new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/browser/components/extensions/test/browser/empty.xpi diff --git a/browser/components/extensions/test/browser/file_bypass_cache.sjs b/browser/components/extensions/test/browser/file_bypass_cache.sjs new file mode 100644 index 0000000000..eed8a6ef49 --- /dev/null +++ b/browser/components/extensions/test/browser/file_bypass_cache.sjs @@ -0,0 +1,13 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80 ft=javascript: */ +"use strict"; + +function handleRequest(request, response) { + response.setHeader("Content-Type", "text/plain; charset=UTF-8", false); + + if (request.hasHeader("pragma") && request.hasHeader("cache-control")) { + response.write( + `${request.getHeader("pragma")}:${request.getHeader("cache-control")}` + ); + } +} diff --git a/browser/components/extensions/test/browser/file_dataTransfer_files.html b/browser/components/extensions/test/browser/file_dataTransfer_files.html new file mode 100644 index 0000000000..553196a942 --- /dev/null +++ b/browser/components/extensions/test/browser/file_dataTransfer_files.html @@ -0,0 +1,36 @@ +<!DOCTYPE html> +<html> + <head> + </head> + <body> + <div id="result-content-script"></div> + <div id="result-user-script"></div> + <div id="result-page-script"></div> + <script> + "use strict"; + + document.body.addEventListener('drop', function(e) { + const files = e.dataTransfer.files || []; + document.querySelector("#result-page-script").textContent = files[0]?.name; + window.testDone(); + }, { once: true }); + + const dataTransfer = new DataTransfer(); + dataTransfer.dropEffect = "move"; + dataTransfer.items.add(new File( + ['<b>test file</b>'], + "testfile.html", + {type: "text/html"} + )); + const event = document.createEvent("DragEvent"); + event.initDragEvent( + "drop", true, true, window, + 0, 0, 0, 0, 0, + false, false, false, false, + 0, document.body, + dataTransfer + ); + document.body.dispatchEvent(event); + </script> + </body> +</html> diff --git a/browser/components/extensions/test/browser/file_dummy.html b/browser/components/extensions/test/browser/file_dummy.html new file mode 100644 index 0000000000..966e0fd5d0 --- /dev/null +++ b/browser/components/extensions/test/browser/file_dummy.html @@ -0,0 +1,10 @@ +<html> +<head> +<title>Dummy test page</title> +<meta http-equiv="Content-Type" content="text/html;charset=utf-8"></meta> +</head> +<body> +<p>Dummy test page</p> +<a id="link_to_example_com" href="https://example.com/#linkclick">link</a> +</body> +</html> diff --git a/browser/components/extensions/test/browser/file_find_frames.html b/browser/components/extensions/test/browser/file_find_frames.html new file mode 100644 index 0000000000..cb93ae484e --- /dev/null +++ b/browser/components/extensions/test/browser/file_find_frames.html @@ -0,0 +1,19 @@ +<!DOCTYPE html> +<html><meta charset="utf-8"> +<body> + +<p>Bánana 0</p> +<iframe src="data:text/html,<p>baNana 2</p> + <iframe src='data:text/html,banaNaland 4' height='50' width='100%'></iframe> + <iframe src='data:text/html,bananAland 5' height='50' width='100%'></iframe> + <p>banAna 3</p>" height="250" width="100%"></iframe> +<p>bAnana 1</p> +<p><b>fru</b>it<i><b>ca</b>ke</i></p> +<p>ang<div>elf</div>ood</p> +<p>This is an example of an example in the same node.</p> +<p>This <span>is an example</span> of <span>an example</span> of ranges in separate nodes.</p> +<iframe src="data:text/html,<p>example within a frame.</p> + <iframe src='data:text/html,example within a sub-frame' height='50' width='100%'></iframe>" height="250" width="100%"> +</iframe> +</body> +</html> diff --git a/browser/components/extensions/test/browser/file_has_non_web_controlled_blank_page_link.html b/browser/components/extensions/test/browser/file_has_non_web_controlled_blank_page_link.html new file mode 100644 index 0000000000..6c118fdb85 --- /dev/null +++ b/browser/components/extensions/test/browser/file_has_non_web_controlled_blank_page_link.html @@ -0,0 +1,5 @@ +<style> +a { display: block; } +</style> + +<a href="wait-a-bit.sjs" target="_blank">wait-a-bit - _blank target</a> diff --git a/browser/components/extensions/test/browser/file_iframe_document.html b/browser/components/extensions/test/browser/file_iframe_document.html new file mode 100644 index 0000000000..7b65ce17cc --- /dev/null +++ b/browser/components/extensions/test/browser/file_iframe_document.html @@ -0,0 +1,11 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <title></title> +</head> +<body> + <iframe src="/"></iframe> + <iframe src="about:blank"></iframe> +</body> +</html> diff --git a/browser/components/extensions/test/browser/file_inspectedwindow_eval.html b/browser/components/extensions/test/browser/file_inspectedwindow_eval.html new file mode 100644 index 0000000000..04128d9ef3 --- /dev/null +++ b/browser/components/extensions/test/browser/file_inspectedwindow_eval.html @@ -0,0 +1,29 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="utf-8"> + <script> + "use strict"; + + // eslint-disable-next-line no-unused-vars + function test_inspect_function() { + // a function to inspect using `inspect(test_inspect_function)`, + // the related test case asserts that it is defined at line 9 + // of this file. + } + + function test_bound_target() { + // Another function to inspect, expected to be located at line 15. + } + + window.test_bound_function = test_bound_target.bind(window); + </script> + </head> + <body> + <div id="container"> + <span id="nested-container"> + <a id="link-to-inspect" href="#link-ref">link to inspect</a> + </span> + </div> + </body> +</html> diff --git a/browser/components/extensions/test/browser/file_inspectedwindow_reload_target.sjs b/browser/components/extensions/test/browser/file_inspectedwindow_reload_target.sjs new file mode 100644 index 0000000000..2a7a401360 --- /dev/null +++ b/browser/components/extensions/test/browser/file_inspectedwindow_reload_target.sjs @@ -0,0 +1,130 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80 ft=javascript: */ +"use strict"; + +Cu.importGlobalProperties(["URLSearchParams"]); + +function handleRequest(request, response) { + let params = new URLSearchParams(request.queryString); + + switch (params.get("test")) { + case "cache": + /* eslint-disable-next-line no-use-before-define */ + handleCacheTestRequest(request, response); + break; + + case "user-agent": + /* eslint-disable-next-line no-use-before-define */ + handleUserAgentTestRequest(request, response); + break; + + case "injected-script": + /* eslint-disable-next-line no-use-before-define */ + handleInjectedScriptTestRequest(request, response, params); + break; + } +} + +function handleCacheTestRequest(request, response) { + response.setHeader("Content-Type", "text/plain; charset=UTF-8", false); + + if (request.hasHeader("pragma") && request.hasHeader("cache-control")) { + response.write( + `${request.getHeader("pragma")}:${request.getHeader("cache-control")}` + ); + } else { + response.write("empty cache headers"); + } +} + +function handleUserAgentTestRequest(request, response) { + response.setHeader("Content-Type", "text/html", false); + + const userAgentHeader = request.hasHeader("user-agent") + ? request.getHeader("user-agent") + : null; + + const query = new URLSearchParams(request.queryString); + if (query.get("crossOriginIsolated") === "true") { + response.setHeader("Cross-Origin-Opener-Policy", "same-origin", false); + } + + const IFRAME_HTML = ` + <!doctype html> + <html> + <head> + <meta charset=utf8> + <script> + globalThis.initialUserAgent = navigator.userAgent; + </script> + </head> + <body> + <h1>Iframe</h1> + </body> + </html>`; + // We always want the iframe to have a different host from the top-level document. + const iframeHost = + request.host === "example.com" ? "example.org" : "example.com"; + const iframeOrigin = `${request.scheme}://${iframeHost}`; + const iframeUrl = `${iframeOrigin}/document-builder.sjs?html=${encodeURI( + IFRAME_HTML + )}`; + + const HTML = ` + <!doctype html> + <html> + <head> + <meta charset=utf8> + <title>test</title> + <script> + "use strict"; + /* + * Store the user agent very early in the document loading process + * so we can assert in tests that it is set early enough. + */ + globalThis.initialUserAgent = navigator.userAgent; + globalThis.userAgentHeader = ${JSON.stringify(userAgentHeader)}; + </script> + </head> + <body> + <h1>Top-level</h1> + <h2>${userAgentHeader ?? "no user-agent header"}</h2> + <iframe src='${iframeUrl}'></iframe> + </body> + </html>`; + + response.write(HTML); +} + +function handleInjectedScriptTestRequest(request, response, params) { + response.setHeader("Content-Type", "text/html; charset=UTF-8", false); + + let content = ""; + const frames = parseInt(params.get("frames"), 10); + if (frames > 0) { + // Output an iframe in seamless mode, so that there is an higher chance that in case + // of test failures we get a screenshot where the nested iframes are all visible. + content = `<iframe seamless src="?test=injected-script&frames=${ + frames - 1 + }"></iframe>`; + } + + response.write(`<!DOCTYPE html> + <html> + <head> + <meta charset="utf-8"> + <style> + iframe { width: 100%; height: ${frames * 150}px; } + </style> + </head> + <body> + <h1>IFRAME ${frames}</h1> + <pre>injected script NOT executed</pre> + <script type="text/javascript"> + window.pageScriptExecutedFirst = true; + </script> + ${content} + </body> + </html> + `); +} diff --git a/browser/components/extensions/test/browser/file_popup_api_injection_a.html b/browser/components/extensions/test/browser/file_popup_api_injection_a.html new file mode 100644 index 0000000000..750ff1db37 --- /dev/null +++ b/browser/components/extensions/test/browser/file_popup_api_injection_a.html @@ -0,0 +1,10 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <script type="application/javascript"> + "use strict"; + throw new Error(`WebExt Privilege Escalation: BrowserAction: typeof(browser) = ${typeof(browser)}`); + </script> +</head> +</html> diff --git a/browser/components/extensions/test/browser/file_popup_api_injection_b.html b/browser/components/extensions/test/browser/file_popup_api_injection_b.html new file mode 100644 index 0000000000..b8c287e55c --- /dev/null +++ b/browser/components/extensions/test/browser/file_popup_api_injection_b.html @@ -0,0 +1,10 @@ +<!DOCTYPE html> +<html> +<head> + <meta charset="utf-8"> + <script type="application/javascript"> + "use strict"; + throw new Error(`WebExt Privilege Escalation: PageAction: typeof(browser) = ${typeof(browser)}`); + </script> +</head> +</html> diff --git a/browser/components/extensions/test/browser/file_slowed_document.sjs b/browser/components/extensions/test/browser/file_slowed_document.sjs new file mode 100644 index 0000000000..8c42fcc966 --- /dev/null +++ b/browser/components/extensions/test/browser/file_slowed_document.sjs @@ -0,0 +1,49 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80 ft=javascript: */ +"use strict"; + +// This script slows the load of an HTML document so that we can reliably test +// all phases of the load cycle supported by the extension API. + +/* eslint-disable no-unused-vars */ + +const URL = "file_slowed_document.sjs"; + +const DELAY = 2 * 1000; // Delay two seconds before completing the request. + +let nsTimer = Components.Constructor( + "@mozilla.org/timer;1", + "nsITimer", + "initWithCallback" +); + +let timer; + +function handleRequest(request, response) { + response.processAsync(); + + response.setHeader("Content-Type", "text/html", false); + response.setHeader("Cache-Control", "no-cache", false); + response.write(`<!DOCTYPE html> + <html lang="en"> + <head> + <meta charset="UTF-8"> + <title></title> + </head> + <body> + `); + + // Note: We need to store a reference to the timer to prevent it from being + // canceled when it's GCed. + timer = new nsTimer( + () => { + if (request.queryString.includes("with-iframe")) { + response.write(`<iframe src="${URL}?r=${Math.random()}"></iframe>`); + } + response.write(`</body></html>`); + response.finish(); + }, + DELAY, + Ci.nsITimer.TYPE_ONE_SHOT + ); +} diff --git a/browser/components/extensions/test/browser/file_title.html b/browser/components/extensions/test/browser/file_title.html new file mode 100644 index 0000000000..2a5d0bca30 --- /dev/null +++ b/browser/components/extensions/test/browser/file_title.html @@ -0,0 +1,9 @@ +<html> +<head> +<title>Different title test page</title> +<meta http-equiv="Content-Type" content="text/html;charset=utf-8"></meta> +</head> +<body> +<p>A page with a different title</p> +</body> +</html> diff --git a/browser/components/extensions/test/browser/file_with_example_com_frame.html b/browser/components/extensions/test/browser/file_with_example_com_frame.html new file mode 100644 index 0000000000..a4263b3315 --- /dev/null +++ b/browser/components/extensions/test/browser/file_with_example_com_frame.html @@ -0,0 +1,5 @@ +<!DOCTYPE HTML> +<meta charset="utf-8"> + +Load an iframe from example.com <p> +<iframe src="https://example.com/browser/browser/components/extensions/test/browser/context_frame.html"></iframe> diff --git a/browser/components/extensions/test/browser/file_with_xorigin_frame.html b/browser/components/extensions/test/browser/file_with_xorigin_frame.html new file mode 100644 index 0000000000..cee430a387 --- /dev/null +++ b/browser/components/extensions/test/browser/file_with_xorigin_frame.html @@ -0,0 +1,5 @@ +<!DOCTYPE HTML> +<meta charset="utf-8"> + +Load a cross-origin iframe from example.net <p> +<iframe src="https://example.net/browser/browser/components/extensions/test/browser/file_with_example_com_frame.html"></iframe> diff --git a/browser/components/extensions/test/browser/head.js b/browser/components/extensions/test/browser/head.js new file mode 100644 index 0000000000..02f905d05c --- /dev/null +++ b/browser/components/extensions/test/browser/head.js @@ -0,0 +1,1046 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +/* exported CustomizableUI makeWidgetId focusWindow forceGC + * getBrowserActionWidget assertPersistentListeners + * clickBrowserAction clickPageAction clickPageActionInPanel + * triggerPageActionWithKeyboard triggerPageActionWithKeyboardInPanel + * triggerBrowserActionWithKeyboard + * getBrowserActionPopup getPageActionPopup getPageActionButton + * openBrowserActionPanel + * closeBrowserAction closePageAction + * promisePopupShown promisePopupHidden promisePopupNotificationShown + * toggleBookmarksToolbar + * openContextMenu closeContextMenu promiseContextMenuClosed + * openContextMenuInSidebar openContextMenuInPopup + * openExtensionContextMenu closeExtensionContextMenu + * openActionContextMenu openSubmenu closeActionContextMenu + * openTabContextMenu closeTabContextMenu + * openToolsMenu closeToolsMenu + * imageBuffer imageBufferFromDataURI + * getInlineOptionsBrowser + * getListStyleImage getRawListStyleImage getPanelForNode + * awaitExtensionPanel awaitPopupResize + * promiseContentDimensions alterContent + * promisePrefChangeObserved openContextMenuInFrame + * promiseAnimationFrame getCustomizableUIPanelID + * awaitEvent BrowserWindowIterator + * navigateTab historyPushState promiseWindowRestored + * getIncognitoWindow startIncognitoMonitorExtension + * loadTestSubscript awaitBrowserLoaded + * getScreenAt roundCssPixcel getCssAvailRect isRectContained + * getToolboxBackgroundColor + */ + +// There are shutdown issues for which multiple rejections are left uncaught. +// This bug should be fixed, but for the moment all tests in this directory +// allow various classes of promise rejections. +// +// NOTE: Allowing rejections on an entire directory should be avoided. +// Normally you should use "expectUncaughtRejection" to flag individual +// failures. +const { PromiseTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PromiseTestUtils.sys.mjs" +); +PromiseTestUtils.allowMatchingRejectionsGlobally( + /Message manager disconnected/ +); +PromiseTestUtils.allowMatchingRejectionsGlobally(/No matching message handler/); +PromiseTestUtils.allowMatchingRejectionsGlobally( + /Receiving end does not exist/ +); + +const { AppUiTestDelegate, AppUiTestInternals } = ChromeUtils.importESModule( + "resource://testing-common/AppUiTestDelegate.sys.mjs" +); + +const { Preferences } = ChromeUtils.importESModule( + "resource://gre/modules/Preferences.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + Management: "resource://gre/modules/Extension.sys.mjs", +}); + +var { makeWidgetId, promisePopupShown, getPanelForNode, awaitBrowserLoaded } = + AppUiTestInternals; + +// The extension tests can run a lot slower under ASAN. +if (AppConstants.ASAN) { + requestLongerTimeout(5); +} + +function loadTestSubscript(filePath) { + Services.scriptloader.loadSubScript(new URL(filePath, gTestPath).href, this); +} + +// Ensure when we turn off topsites in the next few lines, +// we don't hit any remote endpoints. +Services.prefs + .getDefaultBranch("browser.newtabpage.activity-stream.") + .setStringPref("discoverystream.endpointSpocsClear", ""); +// Leaving Top Sites enabled during these tests would create site screenshots +// and update pinned Top Sites unnecessarily. +Services.prefs + .getDefaultBranch("browser.newtabpage.activity-stream.") + .setBoolPref("feeds.topsites", false); +Services.prefs + .getDefaultBranch("browser.newtabpage.activity-stream.") + .setBoolPref("feeds.system.topsites", false); + +{ + // Touch the recipeParentPromise lazy getter so we don't get + // `this._recipeManager is undefined` errors during tests. + const { LoginManagerParent } = ChromeUtils.importESModule( + "resource://gre/modules/LoginManagerParent.sys.mjs" + ); + void LoginManagerParent.recipeParentPromise; +} + +// Persistent Listener test functionality +const { assertPersistentListeners } = ExtensionTestUtils.testAssertions; + +// Bug 1239884: Our tests occasionally hit a long GC pause at unpredictable +// times in debug builds, which results in intermittent timeouts. Until we have +// a better solution, we force a GC after certain strategic tests, which tend to +// accumulate a high number of unreaped windows. +function forceGC() { + if (AppConstants.DEBUG) { + Cu.forceGC(); + } +} + +var focusWindow = async function focusWindow(win) { + if (Services.focus.activeWindow == win) { + return; + } + + let promise = new Promise(resolve => { + win.addEventListener( + "focus", + function () { + resolve(); + }, + { capture: true, once: true } + ); + }); + + win.focus(); + await promise; +}; + +function imageBufferFromDataURI(encodedImageData) { + let decodedImageData = atob(encodedImageData); + return Uint8Array.from(decodedImageData, byte => byte.charCodeAt(0)).buffer; +} + +let img = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQImWNgYGBgAAAABQABh6FO1AAAAABJRU5ErkJggg=="; +var imageBuffer = imageBufferFromDataURI(img); + +function getInlineOptionsBrowser(aboutAddonsBrowser) { + let { contentDocument } = aboutAddonsBrowser; + return contentDocument.getElementById("addon-inline-options"); +} + +function getRawListStyleImage(button) { + // Ensure popups are initialized so that the elements are rendered and + // getComputedStyle works. + for ( + let popup = button.closest("panel,menupopup"); + popup; + popup = popup.parentElement?.closest("panel,menupopup") + ) { + popup.ensureInitialized(); + } + + return button.ownerGlobal.getComputedStyle(button).listStyleImage; +} + +function getListStyleImage(button) { + let match = /url\("([^"]*)"\)/.exec(getRawListStyleImage(button)); + return match && match[1]; +} + +function promiseAnimationFrame(win = window) { + return AppUiTestInternals.promiseAnimationFrame(win); +} + +function promisePopupHidden(popup) { + return new Promise(resolve => { + let onPopupHidden = event => { + popup.removeEventListener("popuphidden", onPopupHidden); + resolve(); + }; + popup.addEventListener("popuphidden", onPopupHidden); + }); +} + +/** + * Wait for the given PopupNotification to display + * + * @param {string} name + * The name of the notification to wait for. + * @param {Window} [win] + * The chrome window in which to wait for the notification. + * + * @returns {Promise} + * Resolves with the notification window. + */ +function promisePopupNotificationShown(name, win = window) { + return new Promise(resolve => { + function popupshown() { + let notification = win.PopupNotifications.getNotification(name); + if (!notification) { + return; + } + + ok(notification, `${name} notification shown`); + ok(win.PopupNotifications.isPanelOpen, "notification panel open"); + + win.PopupNotifications.panel.removeEventListener( + "popupshown", + popupshown + ); + resolve(win.PopupNotifications.panel.firstElementChild); + } + + win.PopupNotifications.panel.addEventListener("popupshown", popupshown); + }); +} + +function promisePossiblyInaccurateContentDimensions(browser) { + return SpecialPowers.spawn(browser, [], async function () { + function copyProps(obj, props) { + let res = {}; + for (let prop of props) { + res[prop] = obj[prop]; + } + return res; + } + + return { + window: copyProps(content, [ + "innerWidth", + "innerHeight", + "outerWidth", + "outerHeight", + "scrollX", + "scrollY", + "scrollMaxX", + "scrollMaxY", + ]), + body: copyProps(content.document.body, [ + "clientWidth", + "clientHeight", + "scrollWidth", + "scrollHeight", + ]), + root: copyProps(content.document.documentElement, [ + "clientWidth", + "clientHeight", + "scrollWidth", + "scrollHeight", + ]), + isStandards: content.document.compatMode !== "BackCompat", + }; + }); +} + +function delay(ms = 0) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +/** + * Retrieve the content dimensions (and wait until the content gets to the. + * size of the browser element they are loaded into, optionally tollerating + * size differences to prevent intermittent failures). + * + * @param {BrowserElement} browser + * The browser element where the content has been loaded. + * @param {number} [tolleratedWidthSizeDiff] + * width size difference to tollerate in pixels (defaults to 1). + * + * @returns {Promise<object>} + * An object with the dims retrieved from the content. + */ +async function promiseContentDimensions(browser, tolleratedWidthSizeDiff = 1) { + // For remote browsers, each resize operation requires an asynchronous + // round-trip to resize the content window. Since there's a certain amount of + // unpredictability in the timing, mainly due to the unpredictability of + // reflows, we need to wait until the content window dimensions match the + // <browser> dimensions before returning data. + + let dims = await promisePossiblyInaccurateContentDimensions(browser); + while ( + Math.abs(browser.clientWidth - dims.window.innerWidth) > + tolleratedWidthSizeDiff || + browser.clientHeight !== Math.round(dims.window.innerHeight) + ) { + const diffWidth = Math.abs(browser.clientWidth - dims.window.innerWidth); + const diffHeight = Math.abs(browser.clientHeight - dims.window.innerHeight); + info( + `Content dimension did not reached the expected size yet (diff: ${diffWidth}x${diffHeight}). Wait further.` + ); + await delay(50); + dims = await promisePossiblyInaccurateContentDimensions(browser); + } + + return dims; +} + +async function awaitPopupResize(browser) { + await BrowserTestUtils.waitForEvent( + browser, + "WebExtPopupResized", + event => event.detail === "delayed" + ); + + return promiseContentDimensions(browser); +} + +function alterContent(browser, task, arg = null) { + return Promise.all([ + SpecialPowers.spawn(browser, [arg], task), + awaitPopupResize(browser), + ]).then(([, dims]) => dims); +} + +async function focusButtonAndPressKey(key, elem, modifiers) { + let focused = BrowserTestUtils.waitForEvent(elem, "focus", true); + + elem.setAttribute("tabindex", "-1"); + elem.focus(); + elem.removeAttribute("tabindex"); + await focused; + + EventUtils.synthesizeKey(key, modifiers); + elem.blur(); +} + +var awaitExtensionPanel = function (extension, win = window, awaitLoad = true) { + return AppUiTestDelegate.awaitExtensionPanel(win, extension.id, awaitLoad); +}; + +function getCustomizableUIPanelID(win = window) { + return CustomizableUI.AREA_ADDONS; +} + +function getBrowserActionWidget(extension) { + return AppUiTestInternals.getBrowserActionWidget(extension.id); +} + +function getBrowserActionPopup(extension, win = window) { + let group = getBrowserActionWidget(extension); + + if (group.areaType == CustomizableUI.TYPE_TOOLBAR) { + return win.document.getElementById("customizationui-widget-panel"); + } + + return win.gUnifiedExtensions.panel; +} + +var showBrowserAction = function (extension, win = window) { + return AppUiTestInternals.showBrowserAction(win, extension.id); +}; + +function clickBrowserAction(extension, win = window, modifiers) { + return AppUiTestDelegate.clickBrowserAction(win, extension.id, modifiers); +} + +async function triggerBrowserActionWithKeyboard( + extension, + key = "KEY_Enter", + modifiers = {}, + win = window +) { + await promiseAnimationFrame(win); + await showBrowserAction(extension, win); + + let group = getBrowserActionWidget(extension); + let node = group.forWindow(win).node.firstElementChild; + + if (group.areaType == CustomizableUI.TYPE_TOOLBAR) { + await focusButtonAndPressKey(key, node, modifiers); + } else if (group.areaType == CustomizableUI.TYPE_PANEL) { + // Use key navigation so that the PanelMultiView doesn't ignore key events. + let panel = win.gUnifiedExtensions.panel; + while (win.document.activeElement != node) { + EventUtils.synthesizeKey("KEY_ArrowDown"); + ok( + panel.contains(win.document.activeElement), + "Focus is inside the panel" + ); + } + EventUtils.synthesizeKey(key, modifiers); + } +} + +function closeBrowserAction(extension, win = window) { + return AppUiTestDelegate.closeBrowserAction(win, extension.id); +} + +function openBrowserActionPanel(extension, win = window, awaitLoad = false) { + clickBrowserAction(extension, win); + + return awaitExtensionPanel(extension, win, awaitLoad); +} + +async function toggleBookmarksToolbar(visible = true) { + let bookmarksToolbar = document.getElementById("PersonalToolbar"); + // Third parameter is 'persist' and true is the default. + // Fourth parameter is 'animated' and we want no animation. + setToolbarVisibility(bookmarksToolbar, visible, true, false); + if (!visible) { + return BrowserTestUtils.waitForMutationCondition( + bookmarksToolbar, + { attributes: true }, + () => bookmarksToolbar.collapsed + ); + } + + return BrowserTestUtils.waitForEvent( + bookmarksToolbar, + "BookmarksToolbarVisibilityUpdated" + ); +} + +async function openContextMenuInPopup( + extension, + selector = "body", + win = window +) { + let doc = win.document; + let contentAreaContextMenu = doc.getElementById("contentAreaContextMenu"); + let browser = await awaitExtensionPanel(extension, win); + + // Ensure that the document layout has been flushed before triggering the mouse event + // (See Bug 1519808 for a rationale). + await browser.ownerGlobal.promiseDocumentFlushed(() => {}); + let popupShownPromise = BrowserTestUtils.waitForEvent( + contentAreaContextMenu, + "popupshown" + ); + await BrowserTestUtils.synthesizeMouseAtCenter( + selector, + { type: "mousedown", button: 2 }, + browser + ); + await BrowserTestUtils.synthesizeMouseAtCenter( + selector, + { type: "contextmenu" }, + browser + ); + await popupShownPromise; + return contentAreaContextMenu; +} + +async function openContextMenuInSidebar(selector = "body") { + let contentAreaContextMenu = SidebarUI.browser.contentDocument.getElementById( + "contentAreaContextMenu" + ); + let browser = SidebarUI.browser.contentDocument.getElementById( + "webext-panels-browser" + ); + let popupShownPromise = BrowserTestUtils.waitForEvent( + contentAreaContextMenu, + "popupshown" + ); + + // Wait for the layout to be flushed, otherwise this test may + // fail intermittently if synthesizeMouseAtCenter is being called + // while the sidebar is still opening and the browser window layout + // being recomputed. + await SidebarUI.browser.contentWindow.promiseDocumentFlushed(() => {}); + + info("Opening context menu in sidebarAction panel"); + await BrowserTestUtils.synthesizeMouseAtCenter( + selector, + { type: "mousedown", button: 2 }, + browser + ); + await BrowserTestUtils.synthesizeMouseAtCenter( + selector, + { type: "contextmenu" }, + browser + ); + await popupShownPromise; + return contentAreaContextMenu; +} + +// `selector` should refer to the content in the frame. If invalid the test can +// fail intermittently because the click could inadvertently be registered on +// the upper-left corner of the frame (instead of inside the frame). +async function openContextMenuInFrame(selector = "body", frameIndex = 0) { + let contentAreaContextMenu = document.getElementById( + "contentAreaContextMenu" + ); + let popupShownPromise = BrowserTestUtils.waitForEvent( + contentAreaContextMenu, + "popupshown" + ); + await BrowserTestUtils.synthesizeMouseAtCenter( + selector, + { type: "contextmenu" }, + gBrowser.selectedBrowser.browsingContext.children[frameIndex] + ); + await popupShownPromise; + return contentAreaContextMenu; +} + +async function openContextMenu(selector = "#img1", win = window) { + let contentAreaContextMenu = win.document.getElementById( + "contentAreaContextMenu" + ); + let popupShownPromise = BrowserTestUtils.waitForEvent( + contentAreaContextMenu, + "popupshown" + ); + await BrowserTestUtils.synthesizeMouseAtCenter( + selector, + { type: "mousedown", button: 2 }, + win.gBrowser.selectedBrowser + ); + await BrowserTestUtils.synthesizeMouseAtCenter( + selector, + { type: "contextmenu" }, + win.gBrowser.selectedBrowser + ); + await popupShownPromise; + return contentAreaContextMenu; +} + +async function promiseContextMenuClosed(contextMenu) { + let contentAreaContextMenu = + contextMenu || document.getElementById("contentAreaContextMenu"); + return BrowserTestUtils.waitForEvent(contentAreaContextMenu, "popuphidden"); +} + +async function closeContextMenu(contextMenu, win = window) { + let contentAreaContextMenu = + contextMenu || win.document.getElementById("contentAreaContextMenu"); + let closed = promiseContextMenuClosed(contentAreaContextMenu); + contentAreaContextMenu.hidePopup(); + await closed; +} + +async function openExtensionContextMenu(selector = "#img1") { + let contextMenu = await openContextMenu(selector); + let topLevelMenu = contextMenu.getElementsByAttribute( + "ext-type", + "top-level-menu" + ); + + // Return null if the extension only has one item and therefore no extension menu. + if (!topLevelMenu.length) { + return null; + } + + let extensionMenu = topLevelMenu[0]; + let popupShownPromise = BrowserTestUtils.waitForEvent( + contextMenu, + "popupshown" + ); + extensionMenu.openMenu(true); + await popupShownPromise; + return extensionMenu; +} + +async function closeExtensionContextMenu(itemToSelect, modifiers = {}) { + let contentAreaContextMenu = document.getElementById( + "contentAreaContextMenu" + ); + let popupHiddenPromise = promiseContextMenuClosed(contentAreaContextMenu); + if (itemToSelect) { + itemToSelect.closest("menupopup").activateItem(itemToSelect, modifiers); + } else { + contentAreaContextMenu.hidePopup(); + } + await popupHiddenPromise; + + // Bug 1351638: parent menu fails to close intermittently, make sure it does. + contentAreaContextMenu.hidePopup(); +} + +async function openToolsMenu(win = window) { + const node = win.document.getElementById("tools-menu"); + const menu = win.document.getElementById("menu_ToolsPopup"); + const shown = BrowserTestUtils.waitForEvent(menu, "popupshown"); + if (AppConstants.platform === "macosx") { + // We can't open menubar items on OSX, so mocking instead. + menu.dispatchEvent(new MouseEvent("popupshowing")); + menu.dispatchEvent(new MouseEvent("popupshown")); + } else { + node.open = true; + } + await shown; + return menu; +} + +function closeToolsMenu(itemToSelect, win = window) { + const menu = win.document.getElementById("menu_ToolsPopup"); + const hidden = BrowserTestUtils.waitForEvent(menu, "popuphidden"); + if (AppConstants.platform === "macosx") { + // Mocking on OSX, see above. + if (itemToSelect) { + itemToSelect.doCommand(); + } + menu.dispatchEvent(new MouseEvent("popuphiding")); + menu.dispatchEvent(new MouseEvent("popuphidden")); + } else if (itemToSelect) { + EventUtils.synthesizeMouseAtCenter(itemToSelect, {}, win); + } else { + menu.hidePopup(); + } + return hidden; +} + +async function openChromeContextMenu(menuId, target, win = window) { + const node = win.document.querySelector(target); + const menu = win.document.getElementById(menuId); + const shown = BrowserTestUtils.waitForEvent(menu, "popupshown"); + EventUtils.synthesizeMouseAtCenter(node, { type: "contextmenu" }, win); + await shown; + return menu; +} + +async function openSubmenu(submenuItem, win = window) { + const submenu = submenuItem.menupopup; + const shown = BrowserTestUtils.waitForEvent(submenu, "popupshown"); + submenuItem.openMenu(true); + await shown; + return submenu; +} + +function closeChromeContextMenu(menuId, itemToSelect, win = window) { + const menu = win.document.getElementById(menuId); + const hidden = BrowserTestUtils.waitForEvent(menu, "popuphidden"); + if (itemToSelect) { + itemToSelect.closest("menupopup").activateItem(itemToSelect); + } else { + menu.hidePopup(); + } + return hidden; +} + +async function openActionContextMenu(extension, kind, win = window) { + // See comment from getPageActionButton below. + win.gURLBar.setPageProxyState("valid"); + await promiseAnimationFrame(win); + let buttonID; + let menuID; + if (kind == "page") { + buttonID = + "#" + + BrowserPageActions.urlbarButtonNodeIDForActionID( + makeWidgetId(extension.id) + ); + menuID = "pageActionContextMenu"; + } else { + buttonID = `#${makeWidgetId(extension.id)}-${kind}-action`; + menuID = "toolbar-context-menu"; + } + return openChromeContextMenu(menuID, buttonID, win); +} + +function closeActionContextMenu(itemToSelect, kind, win = window) { + let menuID = + kind == "page" ? "pageActionContextMenu" : "toolbar-context-menu"; + return closeChromeContextMenu(menuID, itemToSelect, win); +} + +function openTabContextMenu(tab = gBrowser.selectedTab) { + // The TabContextMenu initializes its strings only on a focus or mouseover event. + // Calls focus event on the TabContextMenu before opening. + tab.focus(); + let indexOfTab = Array.prototype.indexOf.call(tab.parentNode.children, tab); + return openChromeContextMenu( + "tabContextMenu", + `.tabbrowser-tab:nth-child(${indexOfTab + 1})`, + tab.ownerGlobal + ); +} + +function closeTabContextMenu(itemToSelect, win = window) { + return closeChromeContextMenu("tabContextMenu", itemToSelect, win); +} + +function getPageActionPopup(extension, win = window) { + return AppUiTestInternals.getPageActionPopup(win, extension.id); +} + +function getPageActionButton(extension, win = window) { + return AppUiTestInternals.getPageActionButton(win, extension.id); +} + +function clickPageAction(extension, win = window, modifiers = {}) { + return AppUiTestDelegate.clickPageAction(win, extension.id, modifiers); +} + +// Shows the popup for the page action which for lists +// all available page actions +async function showPageActionsPanel(win = window) { + // See the comment at getPageActionButton + win.gURLBar.setPageProxyState("valid"); + await promiseAnimationFrame(win); + + let pageActionsPopup = win.document.getElementById("pageActionPanel"); + + let popupShownPromise = promisePopupShown(pageActionsPopup); + EventUtils.synthesizeMouseAtCenter( + win.document.getElementById("pageActionButton"), + {}, + win + ); + await popupShownPromise; + + return pageActionsPopup; +} + +async function clickPageActionInPanel(extension, win = window, modifiers = {}) { + let pageActionsPopup = await showPageActionsPanel(win); + + let pageActionId = BrowserPageActions.panelButtonNodeIDForActionID( + makeWidgetId(extension.id) + ); + + let popupHiddenPromise = promisePopupHidden(pageActionsPopup); + let widgetButton = win.document.getElementById(pageActionId); + EventUtils.synthesizeMouseAtCenter(widgetButton, modifiers, win); + if (widgetButton.disabled) { + pageActionsPopup.hidePopup(); + } + await popupHiddenPromise; + + return new Promise(SimpleTest.executeSoon); +} + +async function triggerPageActionWithKeyboard( + extension, + modifiers = {}, + win = window +) { + let elem = await getPageActionButton(extension, win); + await focusButtonAndPressKey("KEY_Enter", elem, modifiers); + return new Promise(SimpleTest.executeSoon); +} + +async function triggerPageActionWithKeyboardInPanel( + extension, + modifiers = {}, + win = window +) { + let pageActionsPopup = await showPageActionsPanel(win); + + let pageActionId = BrowserPageActions.panelButtonNodeIDForActionID( + makeWidgetId(extension.id) + ); + + let popupHiddenPromise = promisePopupHidden(pageActionsPopup); + let widgetButton = win.document.getElementById(pageActionId); + if (widgetButton.disabled) { + pageActionsPopup.hidePopup(); + return new Promise(SimpleTest.executeSoon); + } + + // Use key navigation so that the PanelMultiView doesn't ignore key events + while (win.document.activeElement != widgetButton) { + EventUtils.synthesizeKey("KEY_ArrowDown"); + ok( + pageActionsPopup.contains(win.document.activeElement), + "Focus is inside of the panel" + ); + } + EventUtils.synthesizeKey("KEY_Enter", modifiers); + await popupHiddenPromise; + + return new Promise(SimpleTest.executeSoon); +} + +function closePageAction(extension, win = window) { + return AppUiTestDelegate.closePageAction(win, extension.id); +} + +function promisePrefChangeObserved(pref) { + return new Promise((resolve, reject) => + Preferences.observe(pref, function prefObserver() { + Preferences.ignore(pref, prefObserver); + resolve(); + }) + ); +} + +function promiseWindowRestored(window) { + return new Promise(resolve => + window.addEventListener("SSWindowRestored", resolve, { once: true }) + ); +} + +function awaitEvent(eventName, id) { + return new Promise(resolve => { + let listener = (_eventName, ...args) => { + let extension = args[0]; + if (_eventName === eventName && extension.id == id) { + Management.off(eventName, listener); + resolve(); + } + }; + + Management.on(eventName, listener); + }); +} + +function* BrowserWindowIterator() { + for (let currentWindow of Services.wm.getEnumerator("navigator:browser")) { + if (!currentWindow.closed) { + yield currentWindow; + } + } +} + +async function locationChange(tab, url, task) { + let locationChanged = BrowserTestUtils.waitForLocationChange(gBrowser, url); + await SpecialPowers.spawn(tab.linkedBrowser, [url], task); + return locationChanged; +} + +function navigateTab(tab, url) { + return locationChange(tab, url, url => { + content.location.href = url; + }); +} + +function historyPushState(tab, url) { + return locationChange(tab, url, url => { + content.history.pushState(null, null, url); + }); +} + +// This monitor extension runs with incognito: not_allowed, if it receives any +// events with incognito data it fails. +async function startIncognitoMonitorExtension() { + function background() { + // Bug 1513220 - We're unable to get the tab during onRemoved, so we track + // valid tabs in "seen" so we can at least validate tabs that we have "seen" + // during onRemoved. This means that the monitor extension must be started + // prior to creating any tabs that will be removed. + + // Map<tabId -> tab> + let seenTabs = new Map(); + function getTabById(tabId) { + return seenTabs.has(tabId) + ? seenTabs.get(tabId) + : browser.tabs.get(tabId); + } + + async function testTab(tabOrId, eventName) { + let tab = tabOrId; + if (typeof tabOrId == "number") { + let tabId = tabOrId; + try { + tab = await getTabById(tabId); + } catch (e) { + browser.test.fail( + `tabs.${eventName} for id ${tabOrId} unexpected failure ${e}\n` + ); + return; + } + } + browser.test.assertFalse( + tab.incognito, + `tabs.${eventName} ${tab.id}: monitor extension got expected incognito value` + ); + seenTabs.set(tab.id, tab); + } + async function testTabInfo(tabInfo, eventName) { + if (typeof tabInfo == "number") { + await testTab(tabInfo, eventName); + } else if (typeof tabInfo == "object") { + if (tabInfo.id !== undefined) { + await testTab(tabInfo, eventName); + } else if (tabInfo.tab !== undefined) { + await testTab(tabInfo.tab, eventName); + } else if (tabInfo.tabIds !== undefined) { + await Promise.all( + tabInfo.tabIds.map(tabId => testTab(tabId, eventName)) + ); + } else if (tabInfo.tabId !== undefined) { + await testTab(tabInfo.tabId, eventName); + } + } + } + let tabEvents = [ + "onUpdated", + "onCreated", + "onAttached", + "onDetached", + "onRemoved", + "onMoved", + "onZoomChange", + "onHighlighted", + ]; + for (let eventName of tabEvents) { + browser.tabs[eventName].addListener(async details => { + await testTabInfo(details, eventName); + }); + } + browser.tabs.onReplaced.addListener(async (addedTabId, removedTabId) => { + await testTabInfo(addedTabId, "onReplaced (addedTabId)"); + await testTabInfo(removedTabId, "onReplaced (removedTabId)"); + }); + + // Map<windowId -> window> + let seenWindows = new Map(); + function getWindowById(windowId) { + return seenWindows.has(windowId) + ? seenWindows.get(windowId) + : browser.windows.get(windowId); + } + + browser.windows.onCreated.addListener(window => { + browser.test.assertFalse( + window.incognito, + `windows.onCreated monitor extension got expected incognito value` + ); + seenWindows.set(window.id, window); + }); + browser.windows.onRemoved.addListener(async windowId => { + let window; + try { + window = await getWindowById(windowId); + } catch (e) { + browser.test.fail( + `windows.onCreated for id ${windowId} unexpected failure ${e}\n` + ); + return; + } + browser.test.assertFalse( + window.incognito, + `windows.onRemoved ${window.id}: monitor extension got expected incognito value` + ); + }); + browser.windows.onFocusChanged.addListener(async windowId => { + if (windowId == browser.windows.WINDOW_ID_NONE) { + return; + } + // onFocusChanged will also fire for blur so check actual window.incognito value. + let window; + try { + window = await getWindowById(windowId); + } catch (e) { + browser.test.fail( + `windows.onFocusChanged for id ${windowId} unexpected failure ${e}\n` + ); + return; + } + browser.test.assertFalse( + window.incognito, + `windows.onFocusChanged ${window.id}: monitor extesion got expected incognito value` + ); + seenWindows.set(window.id, window); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + incognitoOverride: "not_allowed", + background, + }); + await extension.startup(); + return extension; +} + +async function getIncognitoWindow(url = "about:privatebrowsing") { + // Since events will be limited based on incognito, we need a + // spanning extension to get the tab id so we can test access failure. + + function background(expectUrl) { + browser.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { + if (changeInfo.status === "complete" && tab.url === expectUrl) { + browser.test.sendMessage("data", { tabId, windowId: tab.windowId }); + } + }); + } + + let windowWatcher = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["tabs"], + }, + background: `(${background})(${JSON.stringify(url)})`, + incognitoOverride: "spanning", + }); + + await windowWatcher.startup(); + let data = windowWatcher.awaitMessage("data"); + + let win = await BrowserTestUtils.openNewBrowserWindow({ private: true }); + BrowserTestUtils.startLoadingURIString(win.gBrowser.selectedBrowser, url); + + let details = await data; + await windowWatcher.unload(); + return { win, details }; +} + +function getScreenAt(left, top, width, height) { + const screenManager = Cc["@mozilla.org/gfx/screenmanager;1"].getService( + Ci.nsIScreenManager + ); + return screenManager.screenForRect(left, top, width, height); +} + +function roundCssPixcel(pixel, screen) { + return Math.floor( + Math.floor(pixel * screen.defaultCSSScaleFactor) / + screen.defaultCSSScaleFactor + ); +} + +function getCssAvailRect(screen) { + const availDeviceLeft = {}; + const availDeviceTop = {}; + const availDeviceWidth = {}; + const availDeviceHeight = {}; + screen.GetAvailRect( + availDeviceLeft, + availDeviceTop, + availDeviceWidth, + availDeviceHeight + ); + const factor = screen.defaultCSSScaleFactor; + const left = Math.floor(availDeviceLeft.value / factor); + const top = Math.floor(availDeviceTop.value / factor); + const width = Math.floor(availDeviceWidth.value / factor); + const height = Math.floor(availDeviceHeight.value / factor); + return { + left, + top, + width, + height, + right: left + width, + bottom: top + height, + }; +} + +function isRectContained(actualRect, maxRect) { + is( + `top=${actualRect.top >= maxRect.top},bottom=${ + actualRect.bottom <= maxRect.bottom + },left=${actualRect.left >= maxRect.left},right=${ + actualRect.right <= maxRect.right + }`, + "top=true,bottom=true,left=true,right=true", + `Dimension must be inside, top:${actualRect.top}>=${maxRect.top}, bottom:${actualRect.bottom}<=${maxRect.bottom}, left:${actualRect.left}>=${maxRect.left}, right:${actualRect.right}<=${maxRect.right}` + ); +} + +function getToolboxBackgroundColor() { + let toolbox = document.getElementById("navigator-toolbox"); + // Ignore any potentially ongoing transition. + toolbox.style.transitionProperty = "none"; + let color = window.getComputedStyle(toolbox).backgroundColor; + toolbox.style.transitionProperty = ""; + return color; +} diff --git a/browser/components/extensions/test/browser/head_browserAction.js b/browser/components/extensions/test/browser/head_browserAction.js new file mode 100644 index 0000000000..41fda8fc06 --- /dev/null +++ b/browser/components/extensions/test/browser/head_browserAction.js @@ -0,0 +1,368 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +/* exported testPopupSize */ + +// This file is imported into the same scope as head.js. + +/* import-globals-from head.js */ + +// A test helper that retrives an old and new value after a given delay +// and then check that calls an `isCompleted` callback to check that +// the value has reached the expected value. +function waitUntilValue({ + getValue, + isCompleted, + message, + delay: delayTime, + times = 1, +} = {}) { + let i = 0; + return BrowserTestUtils.waitForCondition(async () => { + const oldVal = await getValue(); + await delay(delayTime); + const newVal = await getValue(); + + const done = isCompleted(oldVal, newVal); + + // Reset the counter if the value wasn't the expected one. + if (!done) { + i = 0; + } + + return done && times === ++i; + }, message); +} + +async function testPopupSize( + standardsMode, + browserWin = window, + arrowSide = "top" +) { + let docType = standardsMode ? "<!DOCTYPE html>" : ""; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_action: { + default_popup: "popup.html", + default_area: "navbar", + browser_style: false, + }, + }, + + files: { + "popup.html": `${docType} + <html> + <head> + <meta charset="utf-8"> + <style type="text/css"> + body > span { + display: inline-block; + width: 10px; + height: 150px; + border: 2px solid black; + } + .big > span { + width: 300px; + height: 100px; + } + .bigger > span { + width: 150px; + height: 150px; + } + .huge > span { + height: ${2 * screen.height}px; + } + </style> + </head> + <body> + <span></span> + <span></span> + <span></span> + <span></span> + </body> + </html>`, + }, + }); + + await extension.startup(); + + if (arrowSide == "top") { + // Test the standalone panel for a toolbar button. + let browser = await openBrowserActionPanel(extension, browserWin, true); + + let dims = await promiseContentDimensions(browser); + + is( + dims.isStandards, + standardsMode, + "Document has the expected compat mode" + ); + + let { innerWidth, innerHeight } = dims.window; + + dims = await alterContent(browser, () => { + content.document.body.classList.add("bigger"); + }); + + let win = dims.window; + Assert.lessOrEqual( + Math.abs(win.innerHeight - innerHeight), + 1, + `Window height should not change (${win.innerHeight} ~= ${innerHeight})` + ); + Assert.greater( + win.innerWidth, + innerWidth, + `Window width should increase (${win.innerWidth} > ${innerWidth})` + ); + + dims = await alterContent(browser, () => { + content.document.body.classList.remove("bigger"); + }); + + win = dims.window; + + // The getContentSize calculation is not always reliable to single-pixel + // precision. + Assert.lessOrEqual( + Math.abs(win.innerHeight - innerHeight), + 1, + `Window height should return to approximately its original value (${win.innerHeight} ~= ${innerHeight})` + ); + Assert.lessOrEqual( + Math.abs(win.innerWidth - innerWidth), + 1, + `Window width should return to approximately its original value (${win.innerWidth} ~= ${innerWidth})` + ); + + await closeBrowserAction(extension, browserWin); + } + + // Test the PanelUI panel for a menu panel button. + let widget = getBrowserActionWidget(extension); + CustomizableUI.addWidgetToArea(widget.id, getCustomizableUIPanelID()); + + let panel = browserWin.gUnifiedExtensions.panel; + panel.setAttribute("animate", "false"); + + let shownPromise = Promise.resolve(); + + let browser = await openBrowserActionPanel(extension, browserWin); + + // Small changes if this is a fixed width window + let isFixedWidth = !widget.disallowSubView; + + // Wait long enough to make sure the initial popup positioning has been completed ( + // by waiting until the value stays the same for 20 times in a row). + await waitUntilValue({ + getValue: () => panel.getBoundingClientRect().top, + isCompleted: (oldVal, newVal) => { + return oldVal === newVal; + }, + times: 20, + message: "Wait the popup opening to be completed", + delay: 500, + }); + + let origPanelRect = panel.getBoundingClientRect(); + + // Check that the panel is still positioned as expected. + let checkPanelPosition = () => { + is( + panel.getAttribute("side"), + arrowSide, + "Panel arrow is positioned as expected" + ); + + let panelRect = panel.getBoundingClientRect(); + if (arrowSide == "top") { + is(panelRect.top, origPanelRect.top, "Panel has not moved downwards"); + Assert.greaterOrEqual( + panelRect.bottom, + origPanelRect.bottom, + `Panel has not shrunk from original size (${panelRect.bottom} >= ${origPanelRect.bottom})` + ); + + let screenBottom = + browserWin.screen.availTop + browserWin.screen.availHeight; + let panelBottom = browserWin.mozInnerScreenY + panelRect.bottom; + Assert.lessOrEqual( + Math.round(panelBottom), + screenBottom, + `Bottom of popup should be on-screen. (${panelBottom} <= ${screenBottom})` + ); + } else { + is(panelRect.bottom, origPanelRect.bottom, "Panel has not moved upwards"); + Assert.lessOrEqual( + panelRect.top, + origPanelRect.top, + `Panel has not shrunk from original size (${panelRect.top} <= ${origPanelRect.top})` + ); + + let panelTop = browserWin.mozInnerScreenY + panelRect.top; + Assert.greaterOrEqual( + panelTop, + browserWin.screen.availTop, + `Top of popup should be on-screen. (${panelTop} >= ${browserWin.screen.availTop})` + ); + } + }; + + await awaitBrowserLoaded(browser); + await shownPromise; + + // Wait long enough to make sure the initial resize debouncing timer has + // expired. + await waitUntilValue({ + getValue: () => promiseContentDimensions(browser), + isCompleted: (oldDims, newDims) => { + return ( + oldDims.window.innerWidth === newDims.window.innerWidth && + oldDims.window.innerHeight === newDims.window.innerHeight + ); + }, + message: "Wait the popup resize to be completed", + delay: 500, + }); + + let dims = await promiseContentDimensions(browser); + + is(dims.isStandards, standardsMode, "Document has the expected compat mode"); + + // If the browser's preferred height is smaller than the initial height of the + // panel, then it will still take up the full available vertical space. Even + // so, we need to check that we've gotten the preferred height calculation + // correct, so check that explicitly. + let getHeight = () => parseFloat(browser.style.height); + + let { innerWidth, innerHeight } = dims.window; + let height = getHeight(); + + let setClass = className => { + content.document.body.className = className; + }; + + info( + "Increase body children's width. " + + "Expect them to wrap, and the frame to grow vertically rather than widen." + ); + + dims = await alterContent(browser, setClass, "big"); + let win = dims.window; + + Assert.greater( + getHeight(), + height, + `Browser height should increase (${getHeight()} > ${height})` + ); + + if (isFixedWidth) { + is(win.innerWidth, innerWidth, "Window width should not change"); + } else { + Assert.greaterOrEqual( + win.innerWidth, + innerWidth, + `Window width should increase (${win.innerWidth} >= ${innerWidth})` + ); + } + Assert.greaterOrEqual( + win.innerHeight, + innerHeight, + `Window height should increase (${win.innerHeight} >= ${innerHeight})` + ); + Assert.lessOrEqual( + win.scrollMaxY, + 1, + "Document should not be vertically scrollable" + ); + + checkPanelPosition(); + + if (isFixedWidth) { + // Test a fixed width window grows in height when elements wrap + info( + "Increase body children's width and height. " + + "Expect them to wrap, and the frame to grow vertically rather than widen." + ); + + dims = await alterContent(browser, setClass, "bigger"); + win = dims.window; + + Assert.greater( + getHeight(), + height, + `Browser height should increase (${getHeight()} > ${height})` + ); + + is(win.innerWidth, innerWidth, "Window width should not change"); + Assert.greaterOrEqual( + win.innerHeight, + innerHeight, + `Window height should increase (${win.innerHeight} >= ${innerHeight})` + ); + Assert.lessOrEqual( + win.scrollMaxY, + 1, + "Document should not be vertically scrollable" + ); + + checkPanelPosition(); + } + + info( + "Increase body height beyond the height of the screen. " + + "Expect the panel to grow to accommodate, but not larger than the height of the screen." + ); + + dims = await alterContent(browser, setClass, "huge"); + win = dims.window; + + Assert.greater( + getHeight(), + height, + `Browser height should increase (${getHeight()} > ${height})` + ); + + is(win.innerWidth, innerWidth, "Window width should not change"); + Assert.greater( + win.innerHeight, + innerHeight, + `Window height should increase (${win.innerHeight} > ${innerHeight})` + ); + // Commented out check for the window height here which mysteriously breaks + // on infra but not locally. bug 1396843 covers re-enabling this. + // ok(win.innerHeight < screen.height, `Window height be less than the screen height (${win.innerHeight} < ${screen.height})`); + Assert.greater( + win.scrollMaxY, + 0, + `Document should be vertically scrollable (${win.scrollMaxY} > 0)` + ); + + checkPanelPosition(); + + info("Restore original styling. Expect original dimensions."); + dims = await alterContent(browser, setClass, ""); + win = dims.window; + + is(getHeight(), height, "Browser height should return to its original value"); + + is(win.innerWidth, innerWidth, "Window width should not change"); + is( + win.innerHeight, + innerHeight, + "Window height should return to its original value" + ); + Assert.lessOrEqual( + win.scrollMaxY, + 1, + "Document should not be vertically scrollable" + ); + + checkPanelPosition(); + + await closeBrowserAction(extension, browserWin); + + await extension.unload(); +} diff --git a/browser/components/extensions/test/browser/head_devtools.js b/browser/components/extensions/test/browser/head_devtools.js new file mode 100644 index 0000000000..934d4c8a80 --- /dev/null +++ b/browser/components/extensions/test/browser/head_devtools.js @@ -0,0 +1,162 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +/* exported + assertDevToolsExtensionEnabled, + closeToolboxForTab, + navigateToWithDevToolsOpen + openToolboxForTab, + registerBlankToolboxPanel, + TOOLBOX_BLANK_PANEL_ID, +*/ + +ChromeUtils.defineESModuleGetters(this, { + loader: "resource://devtools/shared/loader/Loader.sys.mjs", + DevToolsShim: "chrome://devtools-startup/content/DevToolsShim.sys.mjs", +}); +ChromeUtils.defineLazyGetter(this, "gDevTools", () => { + const { gDevTools } = loader.require("devtools/client/framework/devtools"); + return gDevTools; +}); + +const TOOLBOX_BLANK_PANEL_ID = "testBlankPanel"; + +// Register a blank custom tool so that we don't need to wait the webconsole +// to be fully loaded/unloaded to prevent intermittent failures (related +// to a webconsole that is still loading when the test has been completed). +async function registerBlankToolboxPanel() { + const testBlankPanel = { + id: TOOLBOX_BLANK_PANEL_ID, + url: "about:blank", + label: "Blank Tool", + isToolSupported() { + return true; + }, + build(iframeWindow, toolbox) { + return Promise.resolve({ + target: toolbox.target, + toolbox: toolbox, + isReady: true, + panelDoc: iframeWindow.document, + destroy() {}, + }); + }, + }; + + registerCleanupFunction(() => { + gDevTools.unregisterTool(testBlankPanel.id); + }); + + gDevTools.registerTool(testBlankPanel); +} + +async function openToolboxForTab(tab, panelId = TOOLBOX_BLANK_PANEL_ID) { + if ( + panelId == TOOLBOX_BLANK_PANEL_ID && + !gDevTools.getToolDefinition(panelId) + ) { + info(`Registering ${TOOLBOX_BLANK_PANEL_ID} tool to the developer tools`); + registerBlankToolboxPanel(); + } + + const toolbox = await gDevTools.showToolboxForTab(tab, { toolId: panelId }); + const { url, outerWindowID } = toolbox.target.form; + info( + `Developer toolbox opened on panel "${panelId}" for target ${JSON.stringify( + { url, outerWindowID } + )}` + ); + return toolbox; +} + +async function closeToolboxForTab(tab) { + await gDevTools.closeToolboxForTab(tab); + const tabUrl = tab.linkedBrowser.currentURI.spec; + info(`Developer toolbox closed for tab "${tabUrl}"`); +} + +function assertDevToolsExtensionEnabled(uuid, enabled) { + for (let toolbox of DevToolsShim.getToolboxes()) { + is( + enabled, + !!toolbox.isWebExtensionEnabled(uuid), + `extension is ${enabled ? "enabled" : "disabled"} on toolbox` + ); + } +} + +/** + * Navigate the currently selected tab to a new URL and wait for it to load. + * Also wait for the toolbox to attach to the new target, if we navigated + * to a new process. + * + * @param {object} tab The tab to redirect. + * @param {string} uri The url to be loaded in the current tab. + * @param {boolean} isErrorPage You may pass `true` is the URL is an error + * page. Otherwise BrowserTestUtils.browserLoaded will wait + * for 'load' event, which never fires for error pages. + * + * @returns {Promise} A promise that resolves when the page has fully loaded. + */ +async function navigateToWithDevToolsOpen(tab, uri, isErrorPage = false) { + const toolbox = gDevTools.getToolboxForTab(tab); + const target = toolbox.target; + + // If we're switching origins, we need to wait for the 'switched-target' + // event to make sure everything is ready. + // Navigating from/to pages loaded in the parent process, like about:robots, + // also spawn new targets. + // (If target switching is disabled, the toolbox will reboot) + const onTargetSwitched = + toolbox.commands.targetCommand.once("switched-target"); + // Otherwise, if we don't switch target, it is safe to wait for navigate event. + const onNavigate = target.once("navigate"); + + // If the current top-level target follows the window global lifecycle, a + // target switch will occur regardless of process changes. + const targetFollowsWindowLifecycle = + target.targetForm.followWindowGlobalLifeCycle; + + info(`Load document "${uri}"`); + const browser = gBrowser.selectedBrowser; + const currentPID = browser.browsingContext.currentWindowGlobal.osPid; + const currentBrowsingContextID = browser.browsingContext.id; + const onBrowserLoaded = BrowserTestUtils.browserLoaded( + browser, + false, + null, + isErrorPage + ); + BrowserTestUtils.startLoadingURIString(browser, uri); + + info(`Waiting for page to be loaded…`); + await onBrowserLoaded; + info(`→ page loaded`); + + // Compare the PIDs (and not the toolbox's targets) as PIDs are updated also immediately, + // while target may be updated slightly later. + const switchedToAnotherProcess = + currentPID !== browser.browsingContext.currentWindowGlobal.osPid; + const switchedToAnotherBrowsingContext = + currentBrowsingContextID !== browser.browsingContext.id; + + // If: + // - the tab navigated to another process, or, + // - the tab navigated to another browsing context, or, + // - if the old target follows the window lifecycle + // then, expect a target switching. + if ( + switchedToAnotherProcess || + targetFollowsWindowLifecycle || + switchedToAnotherBrowsingContext + ) { + info(`Waiting for target switch…`); + await onTargetSwitched; + info(`→ switched-target emitted`); + } else { + info(`Waiting for target 'navigate' event…`); + await onNavigate; + info(`→ 'navigate' emitted`); + } +} diff --git a/browser/components/extensions/test/browser/head_pageAction.js b/browser/components/extensions/test/browser/head_pageAction.js new file mode 100644 index 0000000000..f80a6d3c98 --- /dev/null +++ b/browser/components/extensions/test/browser/head_pageAction.js @@ -0,0 +1,232 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +/* exported runTests */ +// This file is imported into the same scope as head.js. +/* import-globals-from head.js */ + +{ + // At the moment extension language negotiation is tied to Firefox language + // negotiation result. That means that to test an extension in `es-ES`, we need + // to mock `es-ES` being available in Firefox and then request it. + // + // In the future, we should provide some way for tests to decouple their + // language selection from that of Firefox. + const avLocales = Services.locale.availableLocales; + + Services.locale.availableLocales = ["en-US", "es-ES"]; + registerCleanupFunction(() => { + Services.locale.availableLocales = avLocales; + }); +} + +async function runTests(options) { + function background(getTests) { + let tests; + + // Gets the current details of the page action, and returns a + // promise that resolves to an object containing them. + async function getDetails(tabId) { + return { + title: await browser.pageAction.getTitle({ tabId }), + popup: await browser.pageAction.getPopup({ tabId }), + isShown: await browser.pageAction.isShown({ tabId }), + }; + } + + // Runs the next test in the `tests` array, checks the results, + // and passes control back to the outer test scope. + function nextTest() { + let test = tests.shift(); + + test(async expecting => { + let [tab] = await browser.tabs.query({ + active: true, + currentWindow: true, + }); + let { id: tabId, windowId, url } = tab; + + browser.test.log(`Get details: tab={id: ${tabId}, url: ${url}}`); + + // Check that the API returns the expected values, and then + // run the next test. + let details = await getDetails(tabId); + if (expecting) { + browser.test.assertEq( + expecting.title, + details.title, + "expected value from getTitle" + ); + + browser.test.assertEq( + expecting.popup, + details.popup, + "expected value from getPopup" + ); + } + + browser.test.assertEq( + !!expecting, + details.isShown, + "expected value from isShown" + ); + + // Check that the actual icon has the expected values, then + // run the next test. + browser.test.sendMessage("nextTest", expecting, windowId, tests.length); + }); + } + + async function runTests() { + let tabs = []; + let windows = []; + tests = getTests(tabs, windows); + + let resultTabs = await browser.tabs.query({ + active: true, + currentWindow: true, + }); + + tabs[0] = resultTabs[0].id; + windows[0] = resultTabs[0].windowId; + + nextTest(); + } + + browser.test.onMessage.addListener(msg => { + if (msg == "runTests") { + runTests(); + } else if (msg == "runNextTest") { + nextTest(); + } else { + browser.test.fail(`Unexpected message: ${msg}`); + } + }); + + runTests(); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: options.manifest, + + files: options.files || {}, + + background: `(${background})(${options.getTests})`, + }); + + let pageActionId; + let currentWindow = window; + let windows = []; + + async function waitForDetails(details, windowId) { + function check() { + let { document } = Services.wm.getOuterWindowWithId(windowId); + let image = document.getElementById(pageActionId); + if (details == null) { + return image == null || image.getAttribute("disabled") == "true"; + } + let title = details.title || options.manifest.name; + return ( + !!image && + getListStyleImage(image) == details.icon && + image.getAttribute("tooltiptext") == title && + image.getAttribute("aria-label") == title + ); + // TODO: Popup URL. If this is updated, modify also checkDetails. + } + + // eslint-disable-next-line no-async-promise-executor + return new Promise(async resolve => { + let maxCounter = 10; + while (!check() && --maxCounter > 0) { + info("checks left: " + maxCounter); + await promiseAnimationFrame(currentWindow); + } + resolve(); + }); + } + + function checkDetails(details, windowId) { + let { document } = Services.wm.getOuterWindowWithId(windowId); + let image = document.getElementById(pageActionId); + if (details == null) { + ok( + image == null || image.getAttribute("disabled") == "true", + "image is disabled" + ); + } else { + ok(image, "image exists"); + + is(getListStyleImage(image), details.icon, "icon URL is correct"); + + let title = details.title || options.manifest.name; + is(image.getAttribute("tooltiptext"), title, "image title is correct"); + is( + image.getAttribute("aria-label"), + title, + "image aria-label is correct" + ); + // TODO: Popup URL. If this is updated, modify also waitForDetails. + } + } + + let testNewWindows = 1; + + let awaitFinish = new Promise(resolve => { + extension.onMessage( + "nextTest", + async (expecting, windowId, testsRemaining) => { + if (!pageActionId) { + pageActionId = BrowserPageActions.urlbarButtonNodeIDForActionID( + makeWidgetId(extension.id) + ); + } + + await waitForDetails(expecting, windowId); + + checkDetails(expecting, windowId); + + if (testsRemaining) { + extension.sendMessage("runNextTest"); + } else if (testNewWindows) { + testNewWindows--; + + BrowserTestUtils.openNewBrowserWindow() + .then(window => { + windows.push(window); + currentWindow = window; + return focusWindow(window); + }) + .then(() => { + extension.sendMessage("runTests"); + }); + } else { + resolve(); + } + } + ); + }); + + let reqLoc = Services.locale.requestedLocales; + Services.locale.requestedLocales = ["es-ES"]; + + await extension.startup(); + + await awaitFinish; + + await extension.unload(); + + Services.locale.requestedLocales = reqLoc; + + let node = document.getElementById(pageActionId); + is(node, null, "pageAction image removed from document"); + + currentWindow = null; + for (let win of windows.splice(0)) { + node = win.document.getElementById(pageActionId); + is(node, null, "pageAction image removed from second document"); + + await BrowserTestUtils.closeWindow(win); + } +} diff --git a/browser/components/extensions/test/browser/head_sessions.js b/browser/components/extensions/test/browser/head_sessions.js new file mode 100644 index 0000000000..db58c128c6 --- /dev/null +++ b/browser/components/extensions/test/browser/head_sessions.js @@ -0,0 +1,64 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +/* exported recordInitialTimestamps onlyNewItemsFilter checkRecentlyClosed */ + +let initialTimestamps = []; + +function recordInitialTimestamps(timestamps) { + initialTimestamps = timestamps; +} + +function onlyNewItemsFilter(item) { + return !initialTimestamps.includes(item.lastModified); +} + +function checkWindow(window) { + for (let prop of ["focused", "incognito", "alwaysOnTop"]) { + is(window[prop], false, `closed window has the expected value for ${prop}`); + } + for (let prop of ["state", "type"]) { + is( + window[prop], + "normal", + `closed window has the expected value for ${prop}` + ); + } +} + +function checkTab(tab, windowId, incognito) { + for (let prop of ["highlighted", "active", "pinned"]) { + is(tab[prop], false, `closed tab has the expected value for ${prop}`); + } + is(tab.windowId, windowId, "closed tab has the expected value for windowId"); + is( + tab.incognito, + incognito, + "closed tab has the expected value for incognito" + ); +} + +function checkRecentlyClosed( + recentlyClosed, + expectedCount, + windowId, + incognito = false +) { + let sessionIds = new Set(); + is( + recentlyClosed.length, + expectedCount, + "the expected number of closed tabs/windows was found" + ); + for (let item of recentlyClosed) { + if (item.window) { + sessionIds.add(item.window.sessionId); + checkWindow(item.window); + } else if (item.tab) { + sessionIds.add(item.tab.sessionId); + checkTab(item.tab, windowId, incognito); + } + } + is(sessionIds.size, expectedCount, "each item has a unique sessionId"); +} diff --git a/browser/components/extensions/test/browser/head_unified_extensions.js b/browser/components/extensions/test/browser/head_unified_extensions.js new file mode 100644 index 0000000000..941a00a5fb --- /dev/null +++ b/browser/components/extensions/test/browser/head_unified_extensions.js @@ -0,0 +1,199 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/* exported clickUnifiedExtensionsItem, + closeExtensionsPanel, + createExtensions, + ensureMaximizedWindow, + getMessageBars, + getUnifiedExtensionsItem, + openExtensionsPanel, + openUnifiedExtensionsContextMenu, + promiseSetToolbarVisibility +*/ + +const getListView = (win = window) => { + const { panel } = win.gUnifiedExtensions; + ok(panel, "expected panel to be created"); + return panel.querySelector("#unified-extensions-view"); +}; + +const openExtensionsPanel = async (win = window) => { + const { button } = win.gUnifiedExtensions; + ok(button, "expected button"); + + const listView = getListView(win); + ok(listView, "expected list view"); + + const viewShown = BrowserTestUtils.waitForEvent(listView, "ViewShown"); + button.click(); + await viewShown; +}; + +const closeExtensionsPanel = async (win = window) => { + const { button } = win.gUnifiedExtensions; + ok(button, "expected button"); + + const hidden = BrowserTestUtils.waitForEvent( + win.gUnifiedExtensions.panel, + "popuphidden", + true + ); + button.click(); + await hidden; +}; + +const getUnifiedExtensionsItem = (extensionId, win = window) => { + const view = getListView(win); + + // First try to find a CUI widget, otherwise a custom element when the + // extension does not have a browser action. + return ( + view.querySelector(`toolbaritem[data-extensionid="${extensionId}"]`) || + view.querySelector(`unified-extensions-item[extension-id="${extensionId}"]`) + ); +}; + +const openUnifiedExtensionsContextMenu = async (extensionId, win = window) => { + const item = getUnifiedExtensionsItem(extensionId, win); + ok(item, `expected item for extensionId=${extensionId}`); + const button = item.querySelector(".unified-extensions-item-menu-button"); + ok(button, "expected menu button"); + // Make sure the button is visible before clicking on it (below) since the + // list of extensions can have a scrollbar (when there are many extensions + // and/or the window is small-ish). + button.scrollIntoView({ block: "center" }); + + const menu = win.document.getElementById("unified-extensions-context-menu"); + ok(menu, "expected menu"); + + const shown = BrowserTestUtils.waitForEvent(menu, "popupshown"); + // Use primary button click to open the context menu. + EventUtils.synthesizeMouseAtCenter(button, {}, win); + await shown; + + return menu; +}; + +const clickUnifiedExtensionsItem = async ( + win, + extensionId, + forceEnableButton = false +) => { + // The panel should be closed automatically when we click an extension item. + await openExtensionsPanel(win); + + const item = getUnifiedExtensionsItem(extensionId, win); + ok(item, `expected item for ${extensionId}`); + + // The action button should be disabled when users aren't supposed to click + // on it but it might still be useful to re-enable it for testing purposes. + if (forceEnableButton) { + let actionButton = item.querySelector( + ".unified-extensions-item-action-button" + ); + actionButton.disabled = false; + ok(!actionButton.disabled, "action button was force-enabled"); + } + + // Similar to `openUnifiedExtensionsContextMenu()`, we make sure the item is + // visible before clicking on it to prevent intermittents. + item.scrollIntoView({ block: "center" }); + + const popupHidden = BrowserTestUtils.waitForEvent( + win.document, + "popuphidden", + true + ); + EventUtils.synthesizeMouseAtCenter(item, {}, win); + await popupHidden; +}; + +const createExtensions = ( + arrayOfManifestData, + { useAddonManager = true, incognitoOverride, files } = {} +) => { + return arrayOfManifestData.map(manifestData => + ExtensionTestUtils.loadExtension({ + manifest: { + name: "default-extension-name", + ...manifestData, + }, + useAddonManager: useAddonManager ? "temporary" : undefined, + incognitoOverride, + files, + }) + ); +}; + +/** + * Given a window, this test helper resizes it so that the window takes most of + * the available screen size (unless the window is already maximized). + */ +const ensureMaximizedWindow = async win => { + info("ensuring maximized window..."); + + // Make sure we wait for window position to have settled + // to avoid unexpected failures. + let samePositionTimes = 0; + let lastScreenTop = win.screen.top; + let lastScreenLeft = win.screen.left; + win.moveTo(0, 0); + await TestUtils.waitForCondition(() => { + let isSamePosition = + lastScreenTop === win.screen.top && lastScreenLeft === win.screen.left; + if (!isSamePosition) { + lastScreenTop = win.screen.top; + lastScreenLeft = win.screen.left; + } + samePositionTimes = isSamePosition ? samePositionTimes + 1 : 0; + return samePositionTimes === 10; + }, "Wait for the chrome window position to settle"); + + const widthDiff = Math.max(win.screen.availWidth - win.outerWidth, 0); + const heightDiff = Math.max(win.screen.availHeight - win.outerHeight, 0); + + if (widthDiff || heightDiff) { + info( + `resizing window... widthDiff=${widthDiff} - heightDiff=${heightDiff}` + ); + win.windowUtils.ensureDirtyRootFrame(); + win.resizeBy(widthDiff, heightDiff); + } else { + info(`not resizing window!`); + } + + // Make sure we wait for window size to have settled. + let lastOuterWidth = win.outerWidth; + let lastOuterHeight = win.outerHeight; + let sameSizeTimes = 0; + await TestUtils.waitForCondition(() => { + const isSameSize = + win.outerWidth === lastOuterWidth && win.outerHeight === lastOuterHeight; + if (!isSameSize) { + lastOuterWidth = win.outerWidth; + lastOuterHeight = win.outerHeight; + } + sameSizeTimes = isSameSize ? sameSizeTimes + 1 : 0; + return sameSizeTimes === 10; + }, "Wait for the chrome window size to settle"); +}; + +const promiseSetToolbarVisibility = (toolbar, visible) => { + const visibilityChanged = BrowserTestUtils.waitForMutationCondition( + toolbar, + { attributeFilter: ["collapsed"] }, + () => toolbar.collapsed != visible + ); + setToolbarVisibility(toolbar, visible, undefined, false); + return visibilityChanged; +}; + +const getMessageBars = (win = window) => { + const { panel } = win.gUnifiedExtensions; + return panel.querySelectorAll( + "#unified-extensions-messages-container > moz-message-bar" + ); +}; diff --git a/browser/components/extensions/test/browser/head_webNavigation.js b/browser/components/extensions/test/browser/head_webNavigation.js new file mode 100644 index 0000000000..314ddc9326 --- /dev/null +++ b/browser/components/extensions/test/browser/head_webNavigation.js @@ -0,0 +1,49 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +/* exported BASE_URL, SOURCE_PAGE, OPENED_PAGE, + runCreatedNavigationTargetTest */ + +const BASE_URL = + "http://mochi.test:8888/browser/browser/components/extensions/test/browser"; +const SOURCE_PAGE = `${BASE_URL}/webNav_createdTargetSource.html`; +const OPENED_PAGE = `${BASE_URL}/webNav_createdTarget.html`; + +async function runCreatedNavigationTargetTest({ + extension, + openNavTarget, + expectedWebNavProps, +}) { + await openNavTarget(); + + const webNavMsg = await extension.awaitMessage("webNavOnCreated"); + const createdTabId = await extension.awaitMessage("tabsOnCreated"); + const completedNavMsg = await extension.awaitMessage("webNavOnCompleted"); + + let { sourceTabId, sourceFrameId, url } = expectedWebNavProps; + + is(webNavMsg.tabId, createdTabId, "Got the expected tabId property"); + is( + webNavMsg.sourceTabId, + sourceTabId, + "Got the expected sourceTabId property" + ); + is( + webNavMsg.sourceFrameId, + sourceFrameId, + "Got the expected sourceFrameId property" + ); + is(webNavMsg.url, url, "Got the expected url property"); + + is( + completedNavMsg.tabId, + createdTabId, + "Got the expected webNavigation.onCompleted tabId property" + ); + is( + completedNavMsg.url, + url, + "Got the expected webNavigation.onCompleted url property" + ); +} diff --git a/browser/components/extensions/test/browser/redirect_to.sjs b/browser/components/extensions/test/browser/redirect_to.sjs new file mode 100644 index 0000000000..a07747efbe --- /dev/null +++ b/browser/components/extensions/test/browser/redirect_to.sjs @@ -0,0 +1,9 @@ +"use strict"; + +function handleRequest(request, response) { + // redirect_to.sjs?ctxmenu-image.png + // redirects to : ctxmenu-image.png + let redirectUrl = request.queryString; + response.setStatusLine(request.httpVersion, "302", "Found"); + response.setHeader("Location", redirectUrl, false); +} diff --git a/browser/components/extensions/test/browser/search-engines/another/manifest.json b/browser/components/extensions/test/browser/search-engines/another/manifest.json new file mode 100644 index 0000000000..0f78854853 --- /dev/null +++ b/browser/components/extensions/test/browser/search-engines/another/manifest.json @@ -0,0 +1,19 @@ +{ + "name": "another", + "manifest_version": 2, + "version": "1.0", + "description": "another", + "browser_specific_settings": { + "gecko": { + "id": "another@search.mozilla.org" + } + }, + "hidden": true, + "chrome_settings_overrides": { + "search_provider": { + "name": "another", + "search_url": "https://mochi.test:8888/browser/browser/components/search/test/browser/?search={searchTerms}&bar=1", + "suggest_url": "https://mochi.test:8888/browser/browser/modules/test/browser/usageTelemetrySearchSuggestions.sjs?{searchTerms}" + } + } +} diff --git a/browser/components/extensions/test/browser/search-engines/basic/manifest.json b/browser/components/extensions/test/browser/search-engines/basic/manifest.json new file mode 100644 index 0000000000..96b29935cf --- /dev/null +++ b/browser/components/extensions/test/browser/search-engines/basic/manifest.json @@ -0,0 +1,19 @@ +{ + "name": "basic", + "manifest_version": 2, + "version": "1.0", + "description": "basic", + "browser_specific_settings": { + "gecko": { + "id": "basic@search.mozilla.org" + } + }, + "hidden": true, + "chrome_settings_overrides": { + "search_provider": { + "name": "basic", + "search_url": "https://mochi.test:8888/browser/browser/components/search/test/browser/?search={searchTerms}&foo=1", + "suggest_url": "https://mochi.test:8888/browser/browser/modules/test/browser/usageTelemetrySearchSuggestions.sjs?{searchTerms}" + } + } +} diff --git a/browser/components/extensions/test/browser/search-engines/engines.json b/browser/components/extensions/test/browser/search-engines/engines.json new file mode 100644 index 0000000000..907aaa148f --- /dev/null +++ b/browser/components/extensions/test/browser/search-engines/engines.json @@ -0,0 +1,38 @@ +{ + "data": [ + { + "webExtension": { + "id": "basic@search.mozilla.org", + "name": "basic", + "search_url": "https://mochi.test:8888/browser/browser/components/search/test/browser/?search={searchTerms}&foo=1", + "suggest_url": "https://mochi.test:8888/browser/browser/modules/test/browser/usageTelemetrySearchSuggestions.sjs?{searchTerms}" + }, + "appliesTo": [ + { + "included": { "everywhere": true }, + "default": "yes" + } + ] + }, + { + "webExtension": { + "id": "simple@search.mozilla.org" + }, + "appliesTo": [ + { + "included": { "everywhere": true } + } + ] + }, + { + "webExtension": { + "id": "another@search.mozilla.org" + }, + "appliesTo": [ + { + "included": { "everywhere": true } + } + ] + } + ] +} diff --git a/browser/components/extensions/test/browser/search-engines/simple/manifest.json b/browser/components/extensions/test/browser/search-engines/simple/manifest.json new file mode 100644 index 0000000000..67d2974753 --- /dev/null +++ b/browser/components/extensions/test/browser/search-engines/simple/manifest.json @@ -0,0 +1,29 @@ +{ + "name": "Simple Engine", + "manifest_version": 2, + "version": "1.0", + "description": "Simple engine with a different name from the WebExtension id prefix", + "browser_specific_settings": { + "gecko": { + "id": "simple@search.mozilla.org" + } + }, + "hidden": true, + "chrome_settings_overrides": { + "search_provider": { + "name": "Simple Engine", + "search_url": "https://example.com", + "params": [ + { + "name": "sourceId", + "value": "Mozilla-search" + }, + { + "name": "search", + "value": "{searchTerms}" + } + ], + "suggest_url": "https://example.com?search={searchTerms}" + } + } +} diff --git a/browser/components/extensions/test/browser/searchSuggestionEngine.sjs b/browser/components/extensions/test/browser/searchSuggestionEngine.sjs new file mode 100644 index 0000000000..a356cbb1db --- /dev/null +++ b/browser/components/extensions/test/browser/searchSuggestionEngine.sjs @@ -0,0 +1,10 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ +"use strict"; + +function handleRequest(req, resp) { + let suffixes = ["foo", "bar"]; + let data = [req.queryString, suffixes.map(s => req.queryString + s)]; + resp.setHeader("Content-Type", "application/json", false); + resp.write(JSON.stringify(data)); +} diff --git a/browser/components/extensions/test/browser/searchSuggestionEngine.xml b/browser/components/extensions/test/browser/searchSuggestionEngine.xml new file mode 100644 index 0000000000..703d459256 --- /dev/null +++ b/browser/components/extensions/test/browser/searchSuggestionEngine.xml @@ -0,0 +1,9 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- Any copyright is dedicated to the Public Domain. + - http://creativecommons.org/publicdomain/zero/1.0/ --> + +<SearchPlugin xmlns="http://www.mozilla.org/2006/browser/search/"> +<ShortName>browser_searchSuggestionEngine searchSuggestionEngine.xml</ShortName> +<Url type="application/x-suggestions+json" method="GET" template="http://mochi.test:8888/browser/browser/components/extensions/test/browser/searchSuggestionEngine.sjs?{searchTerms}"/> +<Url type="text/html" method="GET" template="http://mochi.test:8888/" rel="searchform"/> +</SearchPlugin> diff --git a/browser/components/extensions/test/browser/silence.ogg b/browser/components/extensions/test/browser/silence.ogg Binary files differnew file mode 100644 index 0000000000..7bdd68ab27 --- /dev/null +++ b/browser/components/extensions/test/browser/silence.ogg diff --git a/browser/components/extensions/test/browser/wait-a-bit.sjs b/browser/components/extensions/test/browser/wait-a-bit.sjs new file mode 100644 index 0000000000..e90133d752 --- /dev/null +++ b/browser/components/extensions/test/browser/wait-a-bit.sjs @@ -0,0 +1,23 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// eslint-disable-next-line mozilla/no-redeclare-with-import-autofix +const { setTimeout } = ChromeUtils.importESModule( + "resource://gre/modules/Timer.sys.mjs" +); + +async function handleRequest(request, response) { + response.seizePower(); + + await new Promise(r => setTimeout(r, 2000)); + + response.write("HTTP/1.1 200 OK\r\n"); + const body = "<title>wait a bit</title><body>ok</body>"; + response.write("Content-Type: text/html\r\n"); + response.write(`Content-Length: ${body.length}\r\n`); + response.write("\r\n"); + response.write(body); + response.finish(); +} diff --git a/browser/components/extensions/test/browser/webNav_createdTarget.html b/browser/components/extensions/test/browser/webNav_createdTarget.html new file mode 100644 index 0000000000..e8a985ef28 --- /dev/null +++ b/browser/components/extensions/test/browser/webNav_createdTarget.html @@ -0,0 +1,10 @@ +<!DOCTYPE html> +<html> + <head> + <title>WebNavigatio onCreatedNavigationTarget target</title> + <meta charset="utf-8"> + </head> + <body> + <a id="other-link" href="webNav_createdTarget_source.html">Go back to the source page</a> + </body> +</html> diff --git a/browser/components/extensions/test/browser/webNav_createdTargetSource.html b/browser/components/extensions/test/browser/webNav_createdTargetSource.html new file mode 100644 index 0000000000..72d4aa56f5 --- /dev/null +++ b/browser/components/extensions/test/browser/webNav_createdTargetSource.html @@ -0,0 +1,45 @@ +<!DOCTYPE html> +<html> + <head> + <title>WebNavigatio onCreatedNavigationTarget source</title> + <meta charset="utf-8"> + </head> + <body> + <ul> + <li> + <a id="test-create-new-tab-from-context-menu" + href="webNav_createdTarget.html#new-tab-from-context-menu"> + Open a target page in a new tab from the context menu + </a> + </li> + <li> + <a id="test-create-new-window-from-context-menu" + href="webNav_createdTarget.html#new-window-from-context-menu"> + Open a target page in a new window from the context menu + </a> + </li> + <li> + <a id="test-create-new-tab-from-mouse-click" + href="webNav_createdTarget.html#new-tab-from-mouse-click"> + Open a target page in a new tab from mouse click + </a> + </li> + <li> + <a id="test-create-new-window-from-mouse-click" + href="webNav_createdTarget.html#new-window-from-mouse-click"> + Open a target page in a new window from mouse click + </a> + </li> + <li> + <a id="test-create-new-tab-from-targetblank-click" + href="webNav_createdTarget.html#new-tab-from-targetblank-click" + target="_blank" rel="opener"> + Open a target page in a new tab from click to link with target="_blank" + </a> + </li> + </ul> + + <iframe src="webNav_createdTargetSource_subframe.html" style="width: 100%; height: 100%;"> + </iframe> + </body> +</html> diff --git a/browser/components/extensions/test/browser/webNav_createdTargetSource_subframe.html b/browser/components/extensions/test/browser/webNav_createdTargetSource_subframe.html new file mode 100644 index 0000000000..7a9e9ebc4a --- /dev/null +++ b/browser/components/extensions/test/browser/webNav_createdTargetSource_subframe.html @@ -0,0 +1,42 @@ +<!DOCTYPE html> +<html> + <head> + <title>WebNavigatio onCreatedNavigationTarget source subframe</title> + <meta charset="utf-8"> + </head> + <body> + <ul> + <li> + <a id="test-create-new-tab-from-context-menu-subframe" + href="webNav_createdTarget.html#new-tab-from-context-menu-subframe"> + Open a target page in a new tab from the context menu (subframe) + </a> + </li> + <li> + <a id="test-create-new-window-from-context-menu-subframe" + href="webNav_createdTarget.html#new-window-from-context-menu-subframe"> + Open a target page in a new window from the context menu (subframe) + </a> + </li> + <li> + <a id="test-create-new-tab-from-mouse-click-subframe" + href="webNav_createdTarget.html#new-tab-from-mouse-click-subframe"> + Open a target page in a new tab from mouse click (subframe) + </a> + </li> + <li> + <a id="test-create-new-window-from-mouse-click-subframe" + href="webNav_createdTarget.html#new-window-from-mouse-click-subframe"> + Open a target page in a new window from mouse click (subframe) + </a> + </li> + <li> + <a id="test-create-new-tab-from-targetblank-click-subframe" + href="webNav_createdTarget.html#new-tab-from-targetblank-click-subframe" + target="_blank" rel="opener"> + Open a target page in a new tab from click to link with target="_blank" + </a> + </li> + </ul> + </body> +</html> diff --git a/browser/components/extensions/test/mochitest/.eslintrc.js b/browser/components/extensions/test/mochitest/.eslintrc.js new file mode 100644 index 0000000000..7802d13962 --- /dev/null +++ b/browser/components/extensions/test/mochitest/.eslintrc.js @@ -0,0 +1,8 @@ +"use strict"; + +module.exports = { + env: { + browser: true, + webextensions: true, + }, +}; diff --git a/browser/components/extensions/test/mochitest/mochitest.toml b/browser/components/extensions/test/mochitest/mochitest.toml new file mode 100644 index 0000000000..bc8bd0d40a --- /dev/null +++ b/browser/components/extensions/test/mochitest/mochitest.toml @@ -0,0 +1,14 @@ +[DEFAULT] +skip-if = ["os == 'android'"] # bug 1730213 +support-files = [ + "../../../../../toolkit/components/extensions/test/mochitest/test_ext_all_apis.js", + "../../../../../toolkit/components/extensions/test/mochitest/file_sample.html", +] +tags = "webextensions" +prefs = ["javascript.options.asyncstack_capture_debuggee_only=false"] + +["test_ext_all_apis.html"] +skip-if = [ + "http3", + "http2", +] diff --git a/browser/components/extensions/test/mochitest/test_ext_all_apis.html b/browser/components/extensions/test/mochitest/test_ext_all_apis.html new file mode 100644 index 0000000000..0433dc5b7e --- /dev/null +++ b/browser/components/extensions/test/mochitest/test_ext_all_apis.html @@ -0,0 +1,83 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>WebExtension test</title> + <meta charset="utf-8"> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script src="/tests/SimpleTest/ExtensionTestUtils.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"> +</head> +<body> +<script> +"use strict"; +/* exported expectedContentApisTargetSpecific, expectedBackgroundApisTargetSpecific */ +let expectedContentApisTargetSpecific = []; + +let expectedBackgroundApisTargetSpecific = [ + "tabs.MutedInfoReason", + "tabs.TAB_ID_NONE", + "tabs.TabStatus", + "tabs.UpdatePropertyName", + "tabs.WindowType", + "tabs.ZoomSettingsMode", + "tabs.ZoomSettingsScope", + "tabs.connect", + "tabs.create", + "tabs.detectLanguage", + "tabs.duplicate", + "tabs.discard", + "tabs.executeScript", + "tabs.get", + "tabs.getCurrent", + "tabs.getZoom", + "tabs.getZoomSettings", + "tabs.goBack", + "tabs.goForward", + "tabs.highlight", + "tabs.insertCSS", + "tabs.move", + "tabs.moveInSuccession", + "tabs.onActivated", + "tabs.onAttached", + "tabs.onCreated", + "tabs.onDetached", + "tabs.onHighlighted", + "tabs.onMoved", + "tabs.onRemoved", + "tabs.onReplaced", + "tabs.onUpdated", + "tabs.onZoomChange", + "tabs.print", + "tabs.printPreview", + "tabs.query", + "tabs.reload", + "tabs.remove", + "tabs.removeCSS", + "tabs.saveAsPDF", + "tabs.sendMessage", + "tabs.setZoom", + "tabs.setZoomSettings", + "tabs.toggleReaderMode", + "tabs.update", + "tabs.warmup", + "windows.CreateType", + "windows.WINDOW_ID_CURRENT", + "windows.WINDOW_ID_NONE", + "windows.WindowState", + "windows.WindowType", + "windows.create", + "windows.get", + "windows.getAll", + "windows.getCurrent", + "windows.getLastFocused", + "windows.onCreated", + "windows.onFocusChanged", + "windows.onRemoved", + "windows.remove", + "windows.update", +]; +</script> +<script src="test_ext_all_apis.js"></script> + +</body> +</html> diff --git a/browser/components/extensions/test/xpcshell/.eslintrc.js b/browser/components/extensions/test/xpcshell/.eslintrc.js new file mode 100644 index 0000000000..3622fff4f6 --- /dev/null +++ b/browser/components/extensions/test/xpcshell/.eslintrc.js @@ -0,0 +1,9 @@ +"use strict"; + +module.exports = { + env: { + // The tests in this folder are testing based on WebExtensions, so lets + // just define the webextensions environment here. + webextensions: true, + }, +}; diff --git a/browser/components/extensions/test/xpcshell/data/test/manifest.json b/browser/components/extensions/test/xpcshell/data/test/manifest.json new file mode 100644 index 0000000000..b14c90e9c4 --- /dev/null +++ b/browser/components/extensions/test/xpcshell/data/test/manifest.json @@ -0,0 +1,80 @@ +{ + "name": "MozParamsTest", + "manifest_version": 2, + "version": "1.0", + "browser_specific_settings": { + "gecko": { + "id": "test@search.mozilla.org" + } + }, + "description": "A test search engine (based on Google search)", + "chrome_settings_overrides": { + "search_provider": { + "name": "MozParamsTest", + "search_url": "https://example.com/?q={searchTerms}", + "params": [ + { + "name": "test-0", + "condition": "purpose", + "purpose": "contextmenu", + "value": "0" + }, + { + "name": "test-1", + "condition": "purpose", + "purpose": "searchbar", + "value": "1" + }, + { + "name": "test-2", + "condition": "purpose", + "purpose": "homepage", + "value": "2" + }, + { + "name": "test-3", + "condition": "purpose", + "purpose": "keyword", + "value": "3" + }, + { + "name": "test-4", + "condition": "purpose", + "purpose": "newtab", + "value": "4" + }, + { + "name": "simple", + "value": "5" + }, + { + "name": "term", + "value": "{searchTerms}" + }, + { + "name": "lang", + "value": "{language}" + }, + { + "name": "locale", + "value": "{moz:locale}" + }, + { + "name": "prefval", + "condition": "pref", + "pref": "code" + }, + { + "name": "experimenter-1", + "condition": "pref", + "pref": "nimbus-key-1" + }, + { + "name": "experimenter-2", + "condition": "pref", + "pref": "nimbus-key-2" + } + ] + } + } +} diff --git a/browser/components/extensions/test/xpcshell/data/test2/manifest.json b/browser/components/extensions/test/xpcshell/data/test2/manifest.json new file mode 100644 index 0000000000..197a993189 --- /dev/null +++ b/browser/components/extensions/test/xpcshell/data/test2/manifest.json @@ -0,0 +1,23 @@ +{ + "name": "MozParamsTest2", + "manifest_version": 2, + "version": "1.0", + "browser_specific_settings": { + "gecko": { + "id": "test2@search.mozilla.org" + } + }, + "description": "A second test search engine", + "chrome_settings_overrides": { + "search_provider": { + "name": "MozParamsTest2", + "search_url": "https://example.com/2/?q={searchTerms}", + "params": [ + { + "name": "simple2", + "value": "5" + } + ] + } + } +} diff --git a/browser/components/extensions/test/xpcshell/head.js b/browser/components/extensions/test/xpcshell/head.js new file mode 100644 index 0000000000..9ac33637ed --- /dev/null +++ b/browser/components/extensions/test/xpcshell/head.js @@ -0,0 +1,78 @@ +"use strict"; + +/* exported createHttpServer, promiseConsoleOutput, assertPersistentListeners */ + +const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); + +// eslint-disable-next-line no-unused-vars +ChromeUtils.defineESModuleGetters(this, { + Extension: "resource://gre/modules/Extension.sys.mjs", + ExtensionData: "resource://gre/modules/Extension.sys.mjs", + ExtensionTestUtils: + "resource://testing-common/ExtensionXPCShellUtils.sys.mjs", + FileUtils: "resource://gre/modules/FileUtils.sys.mjs", + HttpServer: "resource://testing-common/httpd.sys.mjs", + NetUtil: "resource://gre/modules/NetUtil.sys.mjs", + Schemas: "resource://gre/modules/Schemas.sys.mjs", + TestUtils: "resource://testing-common/TestUtils.sys.mjs", +}); + +ExtensionTestUtils.init(this); + +// Persistent Listener test functionality +const { assertPersistentListeners } = ExtensionTestUtils.testAssertions; + +/** + * Creates a new HttpServer for testing, and begins listening on the + * specified port. Automatically shuts down the server when the test + * unit ends. + * + * @param {integer} [port] + * The port to listen on. If omitted, listen on a random + * port. The latter is the preferred behavior. + * + * @returns {HttpServer} + */ +function createHttpServer(port = -1) { + let server = new HttpServer(); + server.start(port); + + registerCleanupFunction(() => { + return new Promise(resolve => { + server.stop(resolve); + }); + }); + + return server; +} + +var promiseConsoleOutput = async function (task) { + const DONE = `=== console listener ${Math.random()} done ===`; + + let listener; + let messages = []; + let awaitListener = new Promise(resolve => { + listener = msg => { + if (msg == DONE) { + resolve(); + } else { + void (msg instanceof Ci.nsIConsoleMessage); + messages.push(msg); + } + }; + }); + + Services.console.registerListener(listener); + try { + let result = await task(); + + Services.console.logStringMessage(DONE); + await awaitListener; + + return { messages, result }; + } finally { + Services.console.unregisterListener(listener); + } +}; diff --git a/browser/components/extensions/test/xpcshell/test_ext_bookmarks.js b/browser/components/extensions/test/xpcshell/test_ext_bookmarks.js new file mode 100644 index 0000000000..15d09d1163 --- /dev/null +++ b/browser/components/extensions/test/xpcshell/test_ext_bookmarks.js @@ -0,0 +1,1725 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { AddonTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/AddonTestUtils.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", +}); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "43" +); + +add_task(async function test_bookmarks() { + async function background() { + let unsortedId, ourId; + let initialBookmarkCount = 0; + let createdBookmarks = new Set(); + let createdFolderId; + let createdSeparatorId; + let collectedEvents = []; + const nonExistentId = "000000000000"; + const bookmarkGuids = { + menuGuid: "menu________", + toolbarGuid: "toolbar_____", + unfiledGuid: "unfiled_____", + rootGuid: "root________", + }; + + function checkOurBookmark(bookmark) { + browser.test.assertEq(ourId, bookmark.id, "Bookmark has the expected Id"); + browser.test.assertTrue( + "parentId" in bookmark, + "Bookmark has a parentId" + ); + browser.test.assertEq( + 0, + bookmark.index, + "Bookmark has the expected index" + ); // We assume there are no other bookmarks. + browser.test.assertEq( + "http://example.org/", + bookmark.url, + "Bookmark has the expected url" + ); + browser.test.assertEq( + "test bookmark", + bookmark.title, + "Bookmark has the expected title" + ); + browser.test.assertTrue( + "dateAdded" in bookmark, + "Bookmark has a dateAdded" + ); + browser.test.assertFalse( + "dateGroupModified" in bookmark, + "Bookmark does not have a dateGroupModified" + ); + browser.test.assertFalse( + "unmodifiable" in bookmark, + "Bookmark is not unmodifiable" + ); + browser.test.assertEq( + "bookmark", + bookmark.type, + "Bookmark is of type bookmark" + ); + } + + function checkBookmark(expected, bookmark) { + browser.test.assertEq( + expected.url, + bookmark.url, + "Bookmark has the expected url" + ); + browser.test.assertEq( + expected.title, + bookmark.title, + "Bookmark has the expected title" + ); + browser.test.assertEq( + expected.index, + bookmark.index, + "Bookmark has expected index" + ); + browser.test.assertEq( + "bookmark", + bookmark.type, + "Bookmark is of type bookmark" + ); + if ("parentId" in expected) { + browser.test.assertEq( + expected.parentId, + bookmark.parentId, + "Bookmark has the expected parentId" + ); + } + } + + function checkOnCreated( + id, + parentId, + index, + title, + url, + dateAdded, + type = "bookmark" + ) { + let createdData = collectedEvents.pop(); + browser.test.assertEq( + "onCreated", + createdData.event, + "onCreated was the last event received" + ); + browser.test.assertEq( + id, + createdData.id, + "onCreated event received the expected id" + ); + let bookmark = createdData.bookmark; + browser.test.assertEq( + id, + bookmark.id, + "onCreated event received the expected bookmark id" + ); + browser.test.assertEq( + parentId, + bookmark.parentId, + "onCreated event received the expected bookmark parentId" + ); + browser.test.assertEq( + index, + bookmark.index, + "onCreated event received the expected bookmark index" + ); + browser.test.assertEq( + title, + bookmark.title, + "onCreated event received the expected bookmark title" + ); + browser.test.assertEq( + url, + bookmark.url, + "onCreated event received the expected bookmark url" + ); + browser.test.assertEq( + dateAdded, + bookmark.dateAdded, + "onCreated event received the expected bookmark dateAdded" + ); + browser.test.assertEq( + type, + bookmark.type, + "onCreated event received the expected bookmark type" + ); + } + + function checkOnChanged(id, url, title) { + // If both url and title are changed, then url is fired last. + let changedData = collectedEvents.pop(); + browser.test.assertEq( + "onChanged", + changedData.event, + "onChanged was the last event received" + ); + browser.test.assertEq( + id, + changedData.id, + "onChanged event received the expected id" + ); + browser.test.assertEq( + url, + changedData.info.url, + "onChanged event received the expected url" + ); + // title is fired first. + changedData = collectedEvents.pop(); + browser.test.assertEq( + "onChanged", + changedData.event, + "onChanged was the last event received" + ); + browser.test.assertEq( + id, + changedData.id, + "onChanged event received the expected id" + ); + browser.test.assertEq( + title, + changedData.info.title, + "onChanged event received the expected title" + ); + } + + function checkOnMoved(id, parentId, oldParentId, index, oldIndex) { + let movedData = collectedEvents.pop(); + browser.test.assertEq( + "onMoved", + movedData.event, + "onMoved was the last event received" + ); + browser.test.assertEq( + id, + movedData.id, + "onMoved event received the expected id" + ); + let info = movedData.info; + browser.test.assertEq( + parentId, + info.parentId, + "onMoved event received the expected parentId" + ); + browser.test.assertEq( + oldParentId, + info.oldParentId, + "onMoved event received the expected oldParentId" + ); + browser.test.assertEq( + index, + info.index, + "onMoved event received the expected index" + ); + browser.test.assertEq( + oldIndex, + info.oldIndex, + "onMoved event received the expected oldIndex" + ); + } + + function checkOnRemoved(id, parentId, index, title, url, type = "folder") { + let removedData = collectedEvents.pop(); + browser.test.assertEq( + "onRemoved", + removedData.event, + "onRemoved was the last event received" + ); + browser.test.assertEq( + id, + removedData.id, + "onRemoved event received the expected id" + ); + let info = removedData.info; + browser.test.assertEq( + parentId, + removedData.info.parentId, + "onRemoved event received the expected parentId" + ); + browser.test.assertEq( + index, + removedData.info.index, + "onRemoved event received the expected index" + ); + let node = info.node; + browser.test.assertEq( + id, + node.id, + "onRemoved event received the expected node id" + ); + browser.test.assertEq( + parentId, + node.parentId, + "onRemoved event received the expected node parentId" + ); + browser.test.assertEq( + index, + node.index, + "onRemoved event received the expected node index" + ); + browser.test.assertEq( + url, + node.url, + "onRemoved event received the expected node url" + ); + browser.test.assertEq( + title, + node.title, + "onRemoved event received the expected node title" + ); + browser.test.assertEq( + type, + node.type, + "onRemoved event received the expected node type" + ); + } + + browser.bookmarks.onChanged.addListener((id, info) => { + collectedEvents.push({ event: "onChanged", id, info }); + }); + + browser.bookmarks.onCreated.addListener((id, bookmark) => { + collectedEvents.push({ event: "onCreated", id, bookmark }); + }); + + browser.bookmarks.onMoved.addListener((id, info) => { + collectedEvents.push({ event: "onMoved", id, info }); + }); + + browser.bookmarks.onRemoved.addListener((id, info) => { + collectedEvents.push({ event: "onRemoved", id, info }); + }); + + await browser.test.assertRejects( + browser.bookmarks.get(["not-a-bookmark-guid"]), + /Invalid value for property 'guid': "not-a-bookmark-guid"/, + "Expected error thrown when trying to get a bookmark using an invalid guid" + ); + + await browser.test + .assertRejects( + browser.bookmarks.get([nonExistentId]), + /Bookmark not found/, + "Expected error thrown when trying to get a bookmark using a non-existent Id" + ) + .then(() => { + return browser.bookmarks.search({}); + }) + .then(results => { + initialBookmarkCount = results.length; + return browser.bookmarks.create({ + title: "test bookmark", + url: "http://example.org", + type: "bookmark", + }); + }) + .then(result => { + ourId = result.id; + checkOurBookmark(result); + browser.test.assertEq( + 1, + collectedEvents.length, + "1 expected event received" + ); + checkOnCreated( + ourId, + bookmarkGuids.unfiledGuid, + 0, + "test bookmark", + "http://example.org/", + result.dateAdded + ); + + return browser.bookmarks.get(ourId); + }) + .then(results => { + browser.test.assertEq(results.length, 1); + checkOurBookmark(results[0]); + + unsortedId = results[0].parentId; + return browser.bookmarks.get(unsortedId); + }) + .then(results => { + let folder = results[0]; + browser.test.assertEq(1, results.length, "1 bookmark was returned"); + + browser.test.assertEq( + unsortedId, + folder.id, + "Folder has the expected id" + ); + browser.test.assertTrue("parentId" in folder, "Folder has a parentId"); + browser.test.assertTrue("index" in folder, "Folder has an index"); + browser.test.assertEq( + undefined, + folder.url, + "Folder does not have a url" + ); + browser.test.assertEq( + "Other Bookmarks", + folder.title, + "Folder has the expected title" + ); + browser.test.assertTrue( + "dateAdded" in folder, + "Folder has a dateAdded" + ); + browser.test.assertTrue( + "dateGroupModified" in folder, + "Folder has a dateGroupModified" + ); + browser.test.assertFalse( + "unmodifiable" in folder, + "Folder is not unmodifiable" + ); // TODO: Do we want to enable this? + browser.test.assertEq( + "folder", + folder.type, + "Folder has a type of folder" + ); + + return browser.bookmarks.getChildren(unsortedId); + }) + .then(async results => { + browser.test.assertEq(1, results.length, "The folder has one child"); + checkOurBookmark(results[0]); + + await browser.test.assertRejects( + browser.bookmarks.update(nonExistentId, { title: "new test title" }), + /No bookmarks found for the provided GUID/, + "Expected error thrown when trying to update a non-existent bookmark" + ); + return browser.bookmarks.update(ourId, { + title: "new test title", + url: "http://example.com/", + }); + }) + .then(async result => { + browser.test.assertEq( + "new test title", + result.title, + "Updated bookmark has the expected title" + ); + browser.test.assertEq( + "http://example.com/", + result.url, + "Updated bookmark has the expected URL" + ); + browser.test.assertEq( + ourId, + result.id, + "Updated bookmark has the expected id" + ); + browser.test.assertEq( + "bookmark", + result.type, + "Updated bookmark has a type of bookmark" + ); + + browser.test.assertEq( + 2, + collectedEvents.length, + "2 expected events received" + ); + checkOnChanged(ourId, "http://example.com/", "new test title"); + + await browser.test.assertRejects( + browser.bookmarks.update(ourId, { url: "this is not a valid url" }), + /Invalid bookmark:/, + "Expected error thrown when trying update with an invalid url" + ); + return browser.bookmarks.getTree(); + }) + .then(results => { + browser.test.assertEq(1, results.length, "getTree returns one result"); + let bookmark = results[0].children.find( + bookmarkItem => bookmarkItem.id == unsortedId + ); + browser.test.assertEq( + "Other Bookmarks", + bookmark.title, + "Folder returned from getTree has the expected title" + ); + browser.test.assertEq( + "folder", + bookmark.type, + "Folder returned from getTree has the expected type" + ); + + return browser.test.assertRejects( + browser.bookmarks.create({ parentId: "invalid" }), + error => + error.message.includes("Invalid bookmark") && + error.message.includes(`"parentGuid":"invalid"`), + "Expected error thrown when trying to create a bookmark with an invalid parentId" + ); + }) + .then(() => { + return browser.bookmarks.remove(ourId); + }) + .then(result => { + browser.test.assertEq( + undefined, + result, + "Removing a bookmark returns undefined" + ); + + browser.test.assertEq( + 1, + collectedEvents.length, + "1 expected events received" + ); + checkOnRemoved( + ourId, + bookmarkGuids.unfiledGuid, + 0, + "new test title", + "http://example.com/", + "bookmark" + ); + + return browser.test.assertRejects( + browser.bookmarks.get(ourId), + /Bookmark not found/, + "Expected error thrown when trying to get a removed bookmark" + ); + }) + .then(() => { + return browser.test.assertRejects( + browser.bookmarks.remove(nonExistentId), + /No bookmarks found for the provided GUID/, + "Expected error thrown when trying removed a non-existent bookmark" + ); + }) + .then(() => { + // test bookmarks.search + return Promise.all([ + browser.bookmarks.create({ + title: "Μοζιλλας", + url: "http://møzîllä.örg/", + }), + browser.bookmarks.create({ + title: "Example", + url: "http://example.org/", + }), + browser.bookmarks.create({ title: "Mozilla Folder", type: "folder" }), + browser.bookmarks.create({ title: "EFF", url: "http://eff.org/" }), + browser.bookmarks.create({ + title: "Menu Item", + url: "http://menu.org/", + parentId: bookmarkGuids.menuGuid, + }), + browser.bookmarks.create({ + title: "Toolbar Item", + url: "http://toolbar.org/", + parentId: bookmarkGuids.toolbarGuid, + }), + ]); + }) + .then(results => { + browser.test.assertEq( + 6, + collectedEvents.length, + "6 expected events received" + ); + checkOnCreated( + results[5].id, + bookmarkGuids.toolbarGuid, + 0, + "Toolbar Item", + "http://toolbar.org/", + results[5].dateAdded + ); + checkOnCreated( + results[4].id, + bookmarkGuids.menuGuid, + 0, + "Menu Item", + "http://menu.org/", + results[4].dateAdded + ); + checkOnCreated( + results[3].id, + bookmarkGuids.unfiledGuid, + 0, + "EFF", + "http://eff.org/", + results[3].dateAdded + ); + checkOnCreated( + results[2].id, + bookmarkGuids.unfiledGuid, + 0, + "Mozilla Folder", + undefined, + results[2].dateAdded, + "folder" + ); + checkOnCreated( + results[1].id, + bookmarkGuids.unfiledGuid, + 0, + "Example", + "http://example.org/", + results[1].dateAdded + ); + checkOnCreated( + results[0].id, + bookmarkGuids.unfiledGuid, + 0, + "Μοζιλλας", + "http://xn--mzll-ooa1dud.xn--rg-eka/", + results[0].dateAdded + ); + + for (let result of results) { + if (result.title !== "Mozilla Folder") { + createdBookmarks.add(result.id); + } + } + let folderResult = results[2]; + createdFolderId = folderResult.id; + return Promise.all([ + browser.bookmarks.create({ + title: "Mozilla", + url: "http://allizom.org/", + parentId: createdFolderId, + }), + browser.bookmarks.create({ + parentId: createdFolderId, + type: "separator", + }), + browser.bookmarks.create({ + title: "Mozilla Corporation", + url: "http://allizom.com/", + parentId: createdFolderId, + }), + browser.bookmarks.create({ + title: "Firefox", + url: "http://allizom.org/firefox/", + parentId: createdFolderId, + }), + ]) + .then(newBookmarks => { + browser.test.assertEq( + 4, + collectedEvents.length, + "4 expected events received" + ); + checkOnCreated( + newBookmarks[3].id, + createdFolderId, + 0, + "Firefox", + "http://allizom.org/firefox/", + newBookmarks[3].dateAdded + ); + checkOnCreated( + newBookmarks[2].id, + createdFolderId, + 0, + "Mozilla Corporation", + "http://allizom.com/", + newBookmarks[2].dateAdded + ); + checkOnCreated( + newBookmarks[1].id, + createdFolderId, + 0, + "", + "data:", + newBookmarks[1].dateAdded, + "separator" + ); + checkOnCreated( + newBookmarks[0].id, + createdFolderId, + 0, + "Mozilla", + "http://allizom.org/", + newBookmarks[0].dateAdded + ); + + return browser.bookmarks.create({ + title: "About Mozilla", + url: "http://allizom.org/about/", + parentId: createdFolderId, + index: 1, + }); + }) + .then(result => { + browser.test.assertEq( + 1, + collectedEvents.length, + "1 expected events received" + ); + checkOnCreated( + result.id, + createdFolderId, + 1, + "About Mozilla", + "http://allizom.org/about/", + result.dateAdded + ); + + // returns all items on empty object + return browser.bookmarks.search({}); + }) + .then(async bookmarksSearchResults => { + browser.test.assertTrue( + bookmarksSearchResults.length >= 10, + "At least as many bookmarks as added were returned by search({})" + ); + + await browser.test.assertRejects( + browser.bookmarks.remove(createdFolderId), + /Cannot remove a non-empty folder/, + "Expected error thrown when trying to remove a non-empty folder" + ); + return browser.bookmarks.getSubTree(createdFolderId); + }); + }) + .then(results => { + browser.test.assertEq( + 1, + results.length, + "Expected number of nodes returned by getSubTree" + ); + browser.test.assertEq( + "Mozilla Folder", + results[0].title, + "Folder has the expected title" + ); + browser.test.assertEq( + bookmarkGuids.unfiledGuid, + results[0].parentId, + "Folder has the expected parentId" + ); + browser.test.assertEq( + "folder", + results[0].type, + "Folder has the expected type" + ); + let children = results[0].children; + browser.test.assertEq( + 5, + children.length, + "Expected number of bookmarks returned by getSubTree" + ); + browser.test.assertEq( + "Firefox", + children[0].title, + "Bookmark has the expected title" + ); + browser.test.assertEq( + "bookmark", + children[0].type, + "Bookmark has the expected type" + ); + browser.test.assertEq( + "About Mozilla", + children[1].title, + "Bookmark has the expected title" + ); + browser.test.assertEq( + "bookmark", + children[1].type, + "Bookmark has the expected type" + ); + browser.test.assertEq( + 1, + children[1].index, + "Bookmark has the expected index" + ); + browser.test.assertEq( + "Mozilla Corporation", + children[2].title, + "Bookmark has the expected title" + ); + browser.test.assertEq( + "", + children[3].title, + "Separator has the expected title" + ); + browser.test.assertEq( + "data:", + children[3].url, + "Separator has the expected url" + ); + browser.test.assertEq( + "separator", + children[3].type, + "Separator has the expected type" + ); + browser.test.assertEq( + "Mozilla", + children[4].title, + "Bookmark has the expected title" + ); + + // throws an error for invalid query objects + browser.test.assertThrows( + () => browser.bookmarks.search(), + /Incorrect argument types for bookmarks.search/, + "Expected error thrown when trying to search with no arguments" + ); + + browser.test.assertThrows( + () => browser.bookmarks.search(null), + /Incorrect argument types for bookmarks.search/, + "Expected error thrown when trying to search with null as an argument" + ); + + browser.test.assertThrows( + () => browser.bookmarks.search(() => {}), + /Incorrect argument types for bookmarks.search/, + "Expected error thrown when trying to search with a function as an argument" + ); + + browser.test.assertThrows( + () => browser.bookmarks.search({ banana: "banana" }), + /an unexpected "banana" property/, + "Expected error thrown when trying to search with a banana as an argument" + ); + + browser.test.assertThrows( + () => browser.bookmarks.search({ url: "spider-man vs. batman" }), + /must match the format "url"/, + "Expected error thrown when trying to search with a illegally formatted URL" + ); + // queries the full url + return browser.bookmarks.search("http://example.org/"); + }) + .then(results => { + browser.test.assertEq( + 1, + results.length, + "Expected number of results returned for url search" + ); + checkBookmark( + { title: "Example", url: "http://example.org/", index: 2 }, + results[0] + ); + + // queries a partial url + return browser.bookmarks.search("example.org"); + }) + .then(results => { + browser.test.assertEq( + 1, + results.length, + "Expected number of results returned for url search" + ); + checkBookmark( + { title: "Example", url: "http://example.org/", index: 2 }, + results[0] + ); + + // queries the title + return browser.bookmarks.search("EFF"); + }) + .then(results => { + browser.test.assertEq( + 1, + results.length, + "Expected number of results returned for title search" + ); + checkBookmark( + { + title: "EFF", + url: "http://eff.org/", + index: 0, + parentId: bookmarkGuids.unfiledGuid, + }, + results[0] + ); + + // finds menu items + return browser.bookmarks.search("Menu Item"); + }) + .then(results => { + browser.test.assertEq( + 1, + results.length, + "Expected number of results returned for menu item search" + ); + checkBookmark( + { + title: "Menu Item", + url: "http://menu.org/", + index: 0, + parentId: bookmarkGuids.menuGuid, + }, + results[0] + ); + + // finds toolbar items + return browser.bookmarks.search("Toolbar Item"); + }) + .then(results => { + browser.test.assertEq( + 1, + results.length, + "Expected number of results returned for toolbar item search" + ); + checkBookmark( + { + title: "Toolbar Item", + url: "http://toolbar.org/", + index: 0, + parentId: bookmarkGuids.toolbarGuid, + }, + results[0] + ); + + // finds folders + return browser.bookmarks.search("Mozilla Folder"); + }) + .then(results => { + browser.test.assertEq( + 1, + results.length, + "Expected number of folders returned" + ); + browser.test.assertEq( + "Mozilla Folder", + results[0].title, + "Folder has the expected title" + ); + browser.test.assertEq( + "folder", + results[0].type, + "Folder has the expected type" + ); + + // is case-insensitive + return browser.bookmarks.search("corporation"); + }) + .then(results => { + browser.test.assertEq( + 1, + results.length, + "Expected number of results returnedfor case-insensitive search" + ); + browser.test.assertEq( + "Mozilla Corporation", + results[0].title, + "Bookmark has the expected title" + ); + + // is case-insensitive for non-ascii + return browser.bookmarks.search("ΜοΖΙΛΛΑς"); + }) + .then(results => { + browser.test.assertEq( + 1, + results.length, + "Expected number of results returned for non-ascii search" + ); + browser.test.assertEq( + "Μοζιλλας", + results[0].title, + "Bookmark has the expected title" + ); + + // returns multiple results + return browser.bookmarks.search("allizom"); + }) + .then(results => { + browser.test.assertEq( + 4, + results.length, + "Expected number of multiple results returned" + ); + browser.test.assertEq( + "Mozilla", + results[0].title, + "Bookmark has the expected title" + ); + browser.test.assertEq( + "Mozilla Corporation", + results[1].title, + "Bookmark has the expected title" + ); + browser.test.assertEq( + "Firefox", + results[2].title, + "Bookmark has the expected title" + ); + browser.test.assertEq( + "About Mozilla", + results[3].title, + "Bookmark has the expected title" + ); + + // accepts a url field + return browser.bookmarks.search({ url: "http://allizom.com/" }); + }) + .then(results => { + browser.test.assertEq( + 1, + results.length, + "Expected number of results returned for url field" + ); + checkBookmark( + { + title: "Mozilla Corporation", + url: "http://allizom.com/", + index: 2, + }, + results[0] + ); + + // normalizes urls + return browser.bookmarks.search({ url: "http://allizom.com" }); + }) + .then(results => { + browser.test.assertEq( + results.length, + 1, + "Expected number of results returned for normalized url field" + ); + checkBookmark( + { + title: "Mozilla Corporation", + url: "http://allizom.com/", + index: 2, + }, + results[0] + ); + + // normalizes urls even more + return browser.bookmarks.search({ url: "http:allizom.com" }); + }) + .then(results => { + browser.test.assertEq( + results.length, + 1, + "Expected number of results returned for normalized url field" + ); + checkBookmark( + { + title: "Mozilla Corporation", + url: "http://allizom.com/", + index: 2, + }, + results[0] + ); + + // accepts a title field + return browser.bookmarks.search({ title: "Mozilla" }); + }) + .then(results => { + browser.test.assertEq( + results.length, + 1, + "Expected number of results returned for title field" + ); + checkBookmark( + { title: "Mozilla", url: "http://allizom.org/", index: 4 }, + results[0] + ); + + // can combine title and query + return browser.bookmarks.search({ title: "Mozilla", query: "allizom" }); + }) + .then(results => { + browser.test.assertEq( + 1, + results.length, + "Expected number of results returned for title and query fields" + ); + checkBookmark( + { title: "Mozilla", url: "http://allizom.org/", index: 4 }, + results[0] + ); + + // uses AND conditions + return browser.bookmarks.search({ title: "EFF", query: "allizom" }); + }) + .then(results => { + browser.test.assertEq( + 0, + results.length, + "Expected number of results returned for non-matching title and query fields" + ); + + // returns an empty array on item not found + return browser.bookmarks.search("microsoft"); + }) + .then(results => { + browser.test.assertEq( + 0, + results.length, + "Expected number of results returned for non-matching search" + ); + + browser.test.assertThrows( + () => browser.bookmarks.getRecent(""), + /Incorrect argument types for bookmarks.getRecent/, + "Expected error thrown when calling getRecent with an empty string" + ); + }) + .then(() => { + browser.test.assertThrows( + () => browser.bookmarks.getRecent(1.234), + /Incorrect argument types for bookmarks.getRecent/, + "Expected error thrown when calling getRecent with a decimal number" + ); + }) + .then(() => { + return Promise.all([ + browser.bookmarks.search("corporation"), + browser.bookmarks.getChildren(bookmarkGuids.menuGuid), + ]); + }) + .then(results => { + let corporationBookmark = results[0][0]; + let childCount = results[1].length; + + browser.test.assertEq( + 2, + corporationBookmark.index, + "Bookmark has the expected index" + ); + + return browser.bookmarks + .move(corporationBookmark.id, { index: 0 }) + .then(result => { + browser.test.assertEq( + 0, + result.index, + "Bookmark has the expected index" + ); + + browser.test.assertEq( + 1, + collectedEvents.length, + "1 expected events received" + ); + checkOnMoved( + corporationBookmark.id, + createdFolderId, + createdFolderId, + 0, + 2 + ); + + return browser.bookmarks.move(corporationBookmark.id, { + parentId: bookmarkGuids.menuGuid, + }); + }) + .then(result => { + browser.test.assertEq( + bookmarkGuids.menuGuid, + result.parentId, + "Bookmark has the expected parent" + ); + browser.test.assertEq( + childCount, + result.index, + "Bookmark has the expected index" + ); + + browser.test.assertEq( + 1, + collectedEvents.length, + "1 expected events received" + ); + checkOnMoved( + corporationBookmark.id, + bookmarkGuids.menuGuid, + createdFolderId, + 1, + 0 + ); + + return browser.bookmarks.move(corporationBookmark.id, { index: 0 }); + }) + .then(result => { + browser.test.assertEq( + bookmarkGuids.menuGuid, + result.parentId, + "Bookmark has the expected parent" + ); + browser.test.assertEq( + 0, + result.index, + "Bookmark has the expected index" + ); + + browser.test.assertEq( + 1, + collectedEvents.length, + "1 expected events received" + ); + checkOnMoved( + corporationBookmark.id, + bookmarkGuids.menuGuid, + bookmarkGuids.menuGuid, + 0, + 1 + ); + + return browser.bookmarks.move(corporationBookmark.id, { + parentId: bookmarkGuids.toolbarGuid, + index: 1, + }); + }) + .then(result => { + browser.test.assertEq( + bookmarkGuids.toolbarGuid, + result.parentId, + "Bookmark has the expected parent" + ); + browser.test.assertEq( + 1, + result.index, + "Bookmark has the expected index" + ); + + browser.test.assertEq( + 1, + collectedEvents.length, + "1 expected events received" + ); + checkOnMoved( + corporationBookmark.id, + bookmarkGuids.toolbarGuid, + bookmarkGuids.menuGuid, + 1, + 0 + ); + + createdBookmarks.add(corporationBookmark.id); + }); + }) + .then(() => { + return browser.bookmarks.getRecent(4); + }) + .then(results => { + browser.test.assertEq( + 4, + results.length, + "Expected number of results returned by getRecent" + ); + let prevDate = results[0].dateAdded; + for (let bookmark of results) { + browser.test.assertTrue( + bookmark.dateAdded <= prevDate, + "The recent bookmarks are sorted by dateAdded" + ); + prevDate = bookmark.dateAdded; + } + let bookmarksByTitle = results.sort((a, b) => { + return a.title.localeCompare(b.title); + }); + browser.test.assertEq( + "About Mozilla", + bookmarksByTitle[0].title, + "Bookmark has the expected title" + ); + browser.test.assertEq( + "Firefox", + bookmarksByTitle[1].title, + "Bookmark has the expected title" + ); + browser.test.assertEq( + "Mozilla", + bookmarksByTitle[2].title, + "Bookmark has the expected title" + ); + browser.test.assertEq( + "Mozilla Corporation", + bookmarksByTitle[3].title, + "Bookmark has the expected title" + ); + + return browser.bookmarks.search({}); + }) + .then(results => { + let startBookmarkCount = results.length; + + return browser.bookmarks + .search({ title: "Mozilla Folder" }) + .then(result => { + return browser.bookmarks.removeTree(result[0].id); + }) + .then(() => { + browser.test.assertEq( + 1, + collectedEvents.length, + "1 expected events received" + ); + checkOnRemoved( + createdFolderId, + bookmarkGuids.unfiledGuid, + 1, + "Mozilla Folder" + ); + + return browser.bookmarks.search({}).then(searchResults => { + browser.test.assertEq( + startBookmarkCount - 5, + searchResults.length, + "Expected number of results returned after removeTree" + ); + }); + }); + }) + .then(() => { + return browser.bookmarks.create({ title: "Empty Folder" }); + }) + .then(result => { + createdFolderId = result.id; + + browser.test.assertEq( + 1, + collectedEvents.length, + "1 expected events received" + ); + checkOnCreated( + createdFolderId, + bookmarkGuids.unfiledGuid, + 3, + "Empty Folder", + undefined, + result.dateAdded, + "folder" + ); + + browser.test.assertEq( + "Empty Folder", + result.title, + "Folder has the expected title" + ); + browser.test.assertEq( + "folder", + result.type, + "Folder has the expected type" + ); + + return browser.bookmarks.create({ + parentId: createdFolderId, + type: "separator", + }); + }) + .then(result => { + createdSeparatorId = result.id; + browser.test.assertEq( + 1, + collectedEvents.length, + "1 expected events received" + ); + checkOnCreated( + createdSeparatorId, + createdFolderId, + 0, + "", + "data:", + result.dateAdded, + "separator" + ); + return browser.bookmarks.remove(createdSeparatorId); + }) + .then(() => { + browser.test.assertEq( + 1, + collectedEvents.length, + "1 expected events received" + ); + checkOnRemoved( + createdSeparatorId, + createdFolderId, + 0, + "", + "data:", + "separator" + ); + + return browser.bookmarks.remove(createdFolderId); + }) + .then(() => { + browser.test.assertEq( + 1, + collectedEvents.length, + "1 expected events received" + ); + checkOnRemoved( + createdFolderId, + bookmarkGuids.unfiledGuid, + 3, + "Empty Folder" + ); + + return browser.test.assertRejects( + browser.bookmarks.get(createdFolderId), + /Bookmark not found/, + "Expected error thrown when trying to get a removed folder" + ); + }) + .then(() => { + return browser.test.assertRejects( + browser.bookmarks.getChildren(nonExistentId), + /root is null/, + "Expected error thrown when trying to getChildren for a non-existent folder" + ); + }) + .then(() => { + return browser.test.assertRejects( + browser.bookmarks.move(nonExistentId, {}), + /No bookmarks found for the provided GUID/, + "Expected error thrown when calling move with a non-existent bookmark" + ); + }) + .then(() => { + return browser.test.assertRejects( + browser.bookmarks.create({ + title: "test root folder", + parentId: bookmarkGuids.rootGuid, + }), + "The bookmark root cannot be modified", + "Expected error thrown when creating bookmark folder at the root" + ); + }) + .then(() => { + return browser.test.assertRejects( + browser.bookmarks.update(bookmarkGuids.rootGuid, { + title: "test update title", + }), + "The bookmark root cannot be modified", + "Expected error thrown when updating root" + ); + }) + .then(() => { + return browser.test.assertRejects( + browser.bookmarks.remove(bookmarkGuids.rootGuid), + "The bookmark root cannot be modified", + "Expected error thrown when removing root" + ); + }) + .then(() => { + return browser.test.assertRejects( + browser.bookmarks.removeTree(bookmarkGuids.rootGuid), + "The bookmark root cannot be modified", + "Expected error thrown when removing root tree" + ); + }) + .then(() => { + return browser.bookmarks.create({ title: "Empty Folder" }); + }) + .then(async result => { + createdFolderId = result.id; + + browser.test.assertEq( + 1, + collectedEvents.length, + "1 expected events received" + ); + checkOnCreated( + createdFolderId, + bookmarkGuids.unfiledGuid, + 3, + "Empty Folder", + undefined, + result.dateAdded, + "folder" + ); + + await browser.test.assertRejects( + browser.bookmarks.move(createdFolderId, { + parentId: bookmarkGuids.rootGuid, + }), + "The bookmark root cannot be modified", + "Expected error thrown when moving bookmark folder to the root" + ); + + return browser.bookmarks.remove(createdFolderId); + }) + .then(() => { + browser.test.assertEq( + 1, + collectedEvents.length, + "1 expected events received" + ); + checkOnRemoved( + createdFolderId, + bookmarkGuids.unfiledGuid, + 3, + "Empty Folder", + undefined, + "folder" + ); + + return browser.test.assertRejects( + browser.bookmarks.get(createdFolderId), + "Bookmark not found", + "Expected error thrown when trying to get a removed folder" + ); + }) + .then(() => { + return browser.test.assertRejects( + browser.bookmarks.move(bookmarkGuids.rootGuid, { + parentId: bookmarkGuids.unfiledGuid, + }), + "The bookmark root cannot be modified", + "Expected error thrown when moving root" + ); + }) + .then(() => { + // remove all created bookmarks + let promises = Array.from(createdBookmarks, guid => + browser.bookmarks.remove(guid) + ); + return Promise.all(promises); + }) + .then(() => { + browser.test.assertEq( + createdBookmarks.size, + collectedEvents.length, + "expected number of events received" + ); + + return browser.bookmarks.search({}); + }) + .then(results => { + browser.test.assertEq( + initialBookmarkCount, + results.length, + "All created bookmarks have been removed" + ); + + return browser.test.notifyPass("bookmarks"); + }) + .catch(error => { + browser.test.fail(`Error: ${String(error)} :: ${error.stack}`); + browser.test.notifyFail("bookmarks"); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["bookmarks"], + }, + }); + + await extension.startup(); + await extension.awaitFinish("bookmarks"); + await extension.unload(); +}); + +add_task(async function test_get_recent_with_tag_and_query() { + function background() { + browser.bookmarks.getRecent(100).then(bookmarks => { + browser.test.sendMessage("bookmarks", bookmarks); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["bookmarks"], + }, + }); + + // Start with an empty bookmarks database. + await PlacesUtils.bookmarks.eraseEverything(); + + let createdBookmarks = []; + for (let i = 0; i < 3; i++) { + let bookmark = { + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url: `http://example.com/${i}`, + title: `My bookmark ${i}`, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }; + createdBookmarks.unshift(bookmark); + await PlacesUtils.bookmarks.insert(bookmark); + } + + // Add a tag to the most recent url to prove it doesn't get returned. + PlacesUtils.tagging.tagURI(NetUtil.newURI("http://example.com/${i}"), [ + "Test Tag", + ]); + + // Add a query bookmark. + let queryURL = `place:parent=${PlacesUtils.bookmarks.menuGuid}&queryType=1`; + await PlacesUtils.bookmarks.insert({ + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + url: queryURL, + title: "a test query", + }); + + await extension.startup(); + let receivedBookmarks = await extension.awaitMessage("bookmarks"); + + equal( + receivedBookmarks.length, + 3, + "The expected number of bookmarks was returned." + ); + for (let i = 0; i < 3; i++) { + let actual = receivedBookmarks[i]; + let expected = createdBookmarks[i]; + equal(actual.url, expected.url, "Bookmark has the expected url."); + equal(actual.title, expected.title, "Bookmark has the expected title."); + equal( + actual.parentId, + expected.parentGuid, + "Bookmark has the expected parentId." + ); + } + + await extension.unload(); +}); + +add_task(async function test_tree_with_empty_folder() { + async function background() { + await browser.bookmarks.create({ title: "Empty Folder" }); + let nonEmptyFolder = await browser.bookmarks.create({ + title: "Non-Empty Folder", + }); + await browser.bookmarks.create({ + title: "A bookmark", + url: "http://example.com", + parentId: nonEmptyFolder.id, + }); + + let tree = await browser.bookmarks.getSubTree(nonEmptyFolder.parentId); + browser.test.assertEq( + 0, + tree[0].children[0].children.length, + "The empty folder returns an empty array for children." + ); + browser.test.assertEq( + 1, + tree[0].children[1].children.length, + "The non-empty folder returns a single item array for children." + ); + + let children = await browser.bookmarks.getChildren(nonEmptyFolder.parentId); + // getChildren should only return immediate children. This is not tested in the + // monster test above. + for (let child of children) { + browser.test.assertEq( + undefined, + child.children, + "Child from getChildren does not contain any children." + ); + } + + browser.test.sendMessage("done"); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["bookmarks"], + }, + }); + + // Start with an empty bookmarks database. + await PlacesUtils.bookmarks.eraseEverything(); + + await extension.startup(); + await extension.awaitMessage("done"); + + await extension.unload(); +}); + +add_task( + { + pref_set: [["extensions.eventPages.enabled", true]], + }, + async function test_bookmarks_event_page() { + await AddonTestUtils.promiseStartupManager(); + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + browser_specific_settings: { gecko: { id: "eventpage@bookmarks" } }, + permissions: ["bookmarks"], + background: { persistent: false }, + }, + background() { + browser.bookmarks.onCreated.addListener(() => { + browser.test.sendMessage("onCreated"); + }); + browser.bookmarks.onRemoved.addListener(() => { + browser.test.sendMessage("onRemoved"); + }); + browser.bookmarks.onChanged.addListener(() => {}); + browser.bookmarks.onMoved.addListener(() => {}); + browser.test.sendMessage("ready"); + }, + }); + + const EVENTS = ["onCreated", "onRemoved", "onChanged", "onMoved"]; + await PlacesUtils.bookmarks.eraseEverything(); + + await extension.startup(); + await extension.awaitMessage("ready"); + for (let event of EVENTS) { + assertPersistentListeners(extension, "bookmarks", event, { + primed: false, + }); + } + + // test events waken background + await extension.terminateBackground(); + for (let event of EVENTS) { + assertPersistentListeners(extension, "bookmarks", event, { + primed: true, + }); + } + + let bookmark = { + type: PlacesUtils.bookmarks.TYPE_BOOKMARK, + url: `http://example.com/12345`, + title: `My bookmark 12345`, + parentGuid: PlacesUtils.bookmarks.unfiledGuid, + }; + await PlacesUtils.bookmarks.insert(bookmark); + + await extension.awaitMessage("ready"); + await extension.awaitMessage("onCreated"); + for (let event of EVENTS) { + assertPersistentListeners(extension, "bookmarks", event, { + primed: false, + }); + } + + await AddonTestUtils.promiseRestartManager(); + await extension.awaitStartup(); + + for (let event of EVENTS) { + assertPersistentListeners(extension, "bookmarks", event, { + primed: true, + }); + } + + await PlacesUtils.bookmarks.eraseEverything(); + await extension.awaitMessage("ready"); + await extension.awaitMessage("onRemoved"); + + await extension.unload(); + await AddonTestUtils.promiseShutdownManager(); + } +); diff --git a/browser/components/extensions/test/xpcshell/test_ext_browsingData_downloads.js b/browser/components/extensions/test/xpcshell/test_ext_browsingData_downloads.js new file mode 100644 index 0000000000..1257f23600 --- /dev/null +++ b/browser/components/extensions/test/xpcshell/test_ext_browsingData_downloads.js @@ -0,0 +1,126 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + Downloads: "resource://gre/modules/Downloads.sys.mjs", +}); + +const OLD_NAMES = { + [Downloads.PUBLIC]: "old-public", + [Downloads.PRIVATE]: "old-private", +}; +const RECENT_NAMES = { + [Downloads.PUBLIC]: "recent-public", + [Downloads.PRIVATE]: "recent-private", +}; +const REFERENCE_DATE = new Date(); +const OLD_DATE = new Date(Number(REFERENCE_DATE) - 10000); + +async function downloadExists(list, path) { + let listArray = await list.getAll(); + return listArray.some(i => i.target.path == path); +} + +async function checkDownloads( + expectOldExists = true, + expectRecentExists = true +) { + for (let listType of [Downloads.PUBLIC, Downloads.PRIVATE]) { + let downloadsList = await Downloads.getList(listType); + equal( + await downloadExists(downloadsList, OLD_NAMES[listType]), + expectOldExists, + `Fake old download ${expectOldExists ? "was found" : "was removed"}.` + ); + equal( + await downloadExists(downloadsList, RECENT_NAMES[listType]), + expectRecentExists, + `Fake recent download ${ + expectRecentExists ? "was found" : "was removed" + }.` + ); + } +} + +async function setupDownloads() { + let downloadsList = await Downloads.getList(Downloads.ALL); + await downloadsList.removeFinished(); + + for (let listType of [Downloads.PUBLIC, Downloads.PRIVATE]) { + downloadsList = await Downloads.getList(listType); + let download = await Downloads.createDownload({ + source: { + url: "https://bugzilla.mozilla.org/show_bug.cgi?id=1321303", + isPrivate: listType == Downloads.PRIVATE, + }, + target: OLD_NAMES[listType], + }); + download.startTime = OLD_DATE; + download.canceled = true; + await downloadsList.add(download); + + download = await Downloads.createDownload({ + source: { + url: "https://bugzilla.mozilla.org/show_bug.cgi?id=1321303", + isPrivate: listType == Downloads.PRIVATE, + }, + target: RECENT_NAMES[listType], + }); + download.startTime = REFERENCE_DATE; + download.canceled = true; + await downloadsList.add(download); + } + + // Confirm everything worked. + downloadsList = await Downloads.getList(Downloads.ALL); + equal((await downloadsList.getAll()).length, 4, "4 fake downloads added."); + checkDownloads(); +} + +add_task(async function testDownloads() { + function background() { + browser.test.onMessage.addListener(async (msg, options) => { + if (msg == "removeDownloads") { + await browser.browsingData.removeDownloads(options); + } else { + await browser.browsingData.remove(options, { downloads: true }); + } + browser.test.sendMessage("downloadsRemoved"); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["browsingData"], + }, + }); + + async function testRemovalMethod(method) { + // Clear downloads with no since value. + await setupDownloads(); + extension.sendMessage(method, {}); + await extension.awaitMessage("downloadsRemoved"); + await checkDownloads(false, false); + + // Clear downloads with recent since value. + await setupDownloads(); + extension.sendMessage(method, { since: REFERENCE_DATE }); + await extension.awaitMessage("downloadsRemoved"); + await checkDownloads(true, false); + + // Clear downloads with old since value. + await setupDownloads(); + extension.sendMessage(method, { since: REFERENCE_DATE - 100000 }); + await extension.awaitMessage("downloadsRemoved"); + await checkDownloads(false, false); + } + + await extension.startup(); + + await testRemovalMethod("removeDownloads"); + await testRemovalMethod("remove"); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/xpcshell/test_ext_browsingData_passwords.js b/browser/components/extensions/test/xpcshell/test_ext_browsingData_passwords.js new file mode 100644 index 0000000000..68a2c2cdc5 --- /dev/null +++ b/browser/components/extensions/test/xpcshell/test_ext_browsingData_passwords.js @@ -0,0 +1,96 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const REFERENCE_DATE = Date.now(); +const LOGIN_USERNAME = "username"; +const LOGIN_PASSWORD = "password"; +const OLD_HOST = "http://mozilla.org"; +const NEW_HOST = "http://mozilla.com"; +const FXA_HOST = "chrome://FirefoxAccounts"; + +async function checkLoginExists(origin, shouldExist) { + const logins = await Services.logins.searchLoginsAsync({ origin }); + equal( + logins.length, + shouldExist ? 1 : 0, + `Login for origin ${origin} should ${shouldExist ? "" : "not"} be found.` + ); +} + +async function addLogin(host, timestamp) { + await checkLoginExists(host, false); + let login = Cc["@mozilla.org/login-manager/loginInfo;1"].createInstance( + Ci.nsILoginInfo + ); + login.init(host, "", null, LOGIN_USERNAME, LOGIN_PASSWORD); + login.QueryInterface(Ci.nsILoginMetaInfo); + login.timePasswordChanged = timestamp; + await Services.logins.addLoginAsync(login); + await checkLoginExists(host, true); +} + +async function setupPasswords() { + // Remove all logins if any (included FxAccounts one in case one got captured in + // a conditioned profile, see Bug 1853617). + Services.logins.removeAllLogins(); + await addLogin(FXA_HOST, REFERENCE_DATE); + await addLogin(NEW_HOST, REFERENCE_DATE); + await addLogin(OLD_HOST, REFERENCE_DATE - 10000); +} + +add_task(async function testPasswords() { + function background() { + browser.test.onMessage.addListener(async (msg, options) => { + if (msg == "removeHistory") { + await browser.browsingData.removePasswords(options); + } else { + await browser.browsingData.remove(options, { passwords: true }); + } + browser.test.sendMessage("passwordsRemoved"); + }); + } + + const extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["browsingData"], + }, + }); + + async function testRemovalMethod(method) { + // Clear passwords with no since value. + await setupPasswords(); + extension.sendMessage(method, {}); + await extension.awaitMessage("passwordsRemoved"); + + await checkLoginExists(OLD_HOST, false); + await checkLoginExists(NEW_HOST, false); + await checkLoginExists(FXA_HOST, true); + + // Clear passwords with recent since value. + await setupPasswords(); + extension.sendMessage(method, { since: REFERENCE_DATE - 1000 }); + await extension.awaitMessage("passwordsRemoved"); + + await checkLoginExists(OLD_HOST, true); + await checkLoginExists(NEW_HOST, false); + await checkLoginExists(FXA_HOST, true); + + // Clear passwords with old since value. + await setupPasswords(); + extension.sendMessage(method, { since: REFERENCE_DATE - 20000 }); + await extension.awaitMessage("passwordsRemoved"); + + await checkLoginExists(OLD_HOST, false); + await checkLoginExists(NEW_HOST, false); + await checkLoginExists(FXA_HOST, true); + } + + await extension.startup(); + + await testRemovalMethod("removePasswords"); + await testRemovalMethod("remove"); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/xpcshell/test_ext_browsingData_settings.js b/browser/components/extensions/test/xpcshell/test_ext_browsingData_settings.js new file mode 100644 index 0000000000..9d2241895c --- /dev/null +++ b/browser/components/extensions/test/xpcshell/test_ext_browsingData_settings.js @@ -0,0 +1,147 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + Preferences: "resource://gre/modules/Preferences.sys.mjs", + Sanitizer: "resource:///modules/Sanitizer.sys.mjs", +}); + +const PREF_DOMAIN = "privacy.cpd."; +const SETTINGS_LIST = [ + "cache", + "cookies", + "history", + "formData", + "downloads", +].sort(); + +add_task(async function testSettingsProperties() { + function background() { + browser.test.onMessage.addListener(msg => { + browser.browsingData.settings().then(settings => { + browser.test.sendMessage("settings", settings); + }); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["browsingData"], + }, + }); + + await extension.startup(); + + extension.sendMessage("settings"); + let settings = await extension.awaitMessage("settings"); + + // Verify that we get the keys back we expect. + deepEqual( + Object.keys(settings.dataToRemove).sort(), + SETTINGS_LIST, + "dataToRemove contains expected properties." + ); + deepEqual( + Object.keys(settings.dataRemovalPermitted).sort(), + SETTINGS_LIST, + "dataToRemove contains expected properties." + ); + + let dataTypeSet = settings.dataToRemove; + for (let key of Object.keys(dataTypeSet)) { + equal( + Preferences.get(`${PREF_DOMAIN}${key.toLowerCase()}`), + dataTypeSet[key], + `${key} property of dataToRemove matches the expected pref.` + ); + } + + dataTypeSet = settings.dataRemovalPermitted; + for (let key of Object.keys(dataTypeSet)) { + equal( + true, + dataTypeSet[key], + `${key} property of dataRemovalPermitted is true.` + ); + } + + // Explicitly set a pref to both true and false and then check. + const SINGLE_OPTION = "cache"; + const SINGLE_PREF = "privacy.cpd.cache"; + + registerCleanupFunction(() => { + Preferences.reset(SINGLE_PREF); + }); + + Preferences.set(SINGLE_PREF, true); + + extension.sendMessage("settings"); + settings = await extension.awaitMessage("settings"); + equal( + settings.dataToRemove[SINGLE_OPTION], + true, + "Preference that was set to true returns true." + ); + + Preferences.set(SINGLE_PREF, false); + + extension.sendMessage("settings"); + settings = await extension.awaitMessage("settings"); + equal( + settings.dataToRemove[SINGLE_OPTION], + false, + "Preference that was set to false returns false." + ); + + await extension.unload(); +}); + +add_task(async function testSettingsSince() { + const TIMESPAN_PREF = "privacy.sanitize.timeSpan"; + const TEST_DATA = { + TIMESPAN_5MIN: Date.now() - 5 * 60 * 1000, + TIMESPAN_HOUR: Date.now() - 60 * 60 * 1000, + TIMESPAN_2HOURS: Date.now() - 2 * 60 * 60 * 1000, + TIMESPAN_EVERYTHING: 0, + }; + + function background() { + browser.test.onMessage.addListener(msg => { + browser.browsingData.settings().then(settings => { + browser.test.sendMessage("settings", settings); + }); + }); + } + + let extension = ExtensionTestUtils.loadExtension({ + background, + manifest: { + permissions: ["browsingData"], + }, + }); + + await extension.startup(); + + registerCleanupFunction(() => { + Preferences.reset(TIMESPAN_PREF); + }); + + for (let timespan in TEST_DATA) { + Preferences.set(TIMESPAN_PREF, Sanitizer[timespan]); + + extension.sendMessage("settings"); + let settings = await extension.awaitMessage("settings"); + + // Because it is based on the current timestamp, we cannot know the exact + // value to expect for since, so allow a 10s variance. + Assert.less( + Math.abs(settings.options.since - TEST_DATA[timespan]), + 10000, + "settings.options contains the expected since value." + ); + } + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/xpcshell/test_ext_chrome_settings_overrides_home.js b/browser/components/extensions/test/xpcshell/test_ext_chrome_settings_overrides_home.js new file mode 100644 index 0000000000..d61e5b6b5e --- /dev/null +++ b/browser/components/extensions/test/xpcshell/test_ext_chrome_settings_overrides_home.js @@ -0,0 +1,231 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { AddonTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/AddonTestUtils.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + HomePage: "resource:///modules/HomePage.sys.mjs", + RemoteSettings: "resource://services-settings/remote-settings.sys.mjs", + TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs", + sinon: "resource://testing-common/Sinon.sys.mjs", +}); + +function promisePrefChanged(expectedValue) { + return TestUtils.waitForPrefChange("browser.startup.homepage", value => + value.endsWith(expectedValue) + ); +} + +const HOMEPAGE_EXTENSION_CONTROLLED = + "browser.startup.homepage_override.extensionControlled"; + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); + +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +async function setupRemoteSettings() { + const settings = await RemoteSettings("hijack-blocklists"); + sinon.stub(settings, "get").returns([ + { + id: "homepage-urls", + matches: ["ignore=me"], + _status: "synced", + }, + ]); +} + +add_task(async function setup() { + await AddonTestUtils.promiseStartupManager(); + await setupRemoteSettings(); +}); + +add_task(async function test_overriding_with_ignored_url() { + // Manually poke into the ignore list a value to be ignored. + HomePage._ignoreList.push("ignore=me"); + Services.prefs.setBoolPref(HOMEPAGE_EXTENSION_CONTROLLED, false); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { + id: "ignore_homepage@example.com", + }, + }, + chrome_settings_overrides: { homepage: "https://example.com/?ignore=me" }, + name: "extension", + }, + useAddonManager: "temporary", + }); + + await extension.startup(); + + Assert.ok(HomePage.isDefault, "Should still have the default homepage"); + Assert.equal( + Services.prefs.getBoolPref( + "browser.startup.homepage_override.extensionControlled" + ), + false, + "Should not be extension controlled." + ); + TelemetryTestUtils.assertEvents( + [ + { + object: "ignore", + value: "set_blocked_extension", + extra: { webExtensionId: "ignore_homepage@example.com" }, + }, + ], + { + category: "homepage", + method: "preference", + } + ); + + await extension.unload(); + HomePage._ignoreList.pop(); +}); + +add_task(async function test_overriding_cancelled_after_ignore_update() { + const oldHomePageIgnoreList = HomePage._ignoreList; + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { + id: "ignore_homepage1@example.com", + }, + }, + chrome_settings_overrides: { + homepage: "https://example.com/?ignore1=me", + }, + name: "extension", + }, + useAddonManager: "temporary", + }); + + await extension.startup(); + + Assert.ok(!HomePage.isDefault, "Should have overriden the new homepage"); + Assert.equal( + Services.prefs.getBoolPref( + "browser.startup.homepage_override.extensionControlled" + ), + true, + "Should be extension controlled." + ); + + let prefChanged = TestUtils.waitForPrefChange( + "browser.startup.homepage_override.extensionControlled" + ); + + await HomePage._handleIgnoreListUpdated({ + data: { + current: [{ id: "homepage-urls", matches: ["ignore1=me"] }], + }, + }); + + await prefChanged; + + await TestUtils.waitForCondition( + () => + !Services.prefs.getBoolPref( + "browser.startup.homepage_override.extensionControlled", + false + ), + "Should not longer be extension controlled" + ); + + Assert.ok(HomePage.isDefault, "Should have reset the homepage"); + + TelemetryTestUtils.assertEvents( + [ + { + object: "ignore", + value: "saved_reset", + }, + ], + { + category: "homepage", + method: "preference", + } + ); + + await extension.unload(); + HomePage._ignoreList = oldHomePageIgnoreList; +}); + +add_task(async function test_overriding_homepage_locale() { + Services.locale.availableLocales = ["en-US", "es-ES"]; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { + id: "homepage@example.com", + }, + }, + chrome_settings_overrides: { + homepage: "/__MSG_homepage__", + }, + name: "extension", + default_locale: "en", + }, + useAddonManager: "permanent", + + files: { + "_locales/en/messages.json": { + homepage: { + message: "homepage.html", + description: "homepage", + }, + }, + + "_locales/es_ES/messages.json": { + homepage: { + message: "default.html", + description: "homepage", + }, + }, + }, + }); + + let prefPromise = promisePrefChanged("homepage.html"); + await extension.startup(); + await prefPromise; + + Assert.equal( + HomePage.get(), + `moz-extension://${extension.uuid}/homepage.html`, + "Should have overridden the new homepage" + ); + + // Set the new locale now, and disable the L10nRegistry reset + // when shutting down the addon mananger. This allows us to + // restart under a new locale without a lot of fuss. + let reqLoc = Services.locale.requestedLocales; + Services.locale.requestedLocales = ["es-ES"]; + + prefPromise = promisePrefChanged("default.html"); + await AddonTestUtils.promiseShutdownManager({ clearL10nRegistry: false }); + await AddonTestUtils.promiseStartupManager(); + await extension.awaitStartup(); + await prefPromise; + + Assert.equal( + HomePage.get(), + `moz-extension://${extension.uuid}/default.html`, + "Should have overridden the new homepage" + ); + + await extension.unload(); + + Services.locale.requestedLocales = reqLoc; +}); diff --git a/browser/components/extensions/test/xpcshell/test_ext_chrome_settings_overrides_update.js b/browser/components/extensions/test/xpcshell/test_ext_chrome_settings_overrides_update.js new file mode 100644 index 0000000000..aac00a8023 --- /dev/null +++ b/browser/components/extensions/test/xpcshell/test_ext_chrome_settings_overrides_update.js @@ -0,0 +1,794 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { AddonTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/AddonTestUtils.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", + HomePage: "resource:///modules/HomePage.sys.mjs", + RemoteSettings: "resource://services-settings/remote-settings.sys.mjs", + sinon: "resource://testing-common/Sinon.sys.mjs", +}); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); + +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "42" +); + +// Similar to TestUtils.topicObserved, but returns a deferred promise that +// can be resolved +function topicObservable(topic, checkFn) { + let deferred = Promise.withResolvers(); + function observer(subject, topic, data) { + try { + if (checkFn && !checkFn(subject, data)) { + return; + } + deferred.resolve([subject, data]); + } catch (ex) { + deferred.reject(ex); + } + } + deferred.promise.finally(() => { + Services.obs.removeObserver(observer, topic); + checkFn = null; + }); + Services.obs.addObserver(observer, topic); + + return deferred; +} + +async function setupRemoteSettings() { + const settings = await RemoteSettings("hijack-blocklists"); + sinon.stub(settings, "get").returns([ + { + id: "homepage-urls", + matches: ["ignore=me"], + _status: "synced", + }, + ]); +} + +function promisePrefChanged(expectedValue) { + return TestUtils.waitForPrefChange("browser.startup.homepage", value => + value.endsWith(expectedValue) + ); +} + +add_task(async function setup() { + await AddonTestUtils.promiseStartupManager(); + await setupRemoteSettings(); +}); + +add_task(async function test_overrides_update_removal() { + /* This tests the scenario where the manifest key for homepage and/or + * search_provider are removed between updates and therefore the + * settings are expected to revert. It also tests that an extension + * can make a builtin extension the default search without user + * interaction. */ + + const EXTENSION_ID = "test_overrides_update@tests.mozilla.org"; + const HOMEPAGE_URI = "webext-homepage-1.html"; + + let extensionInfo = { + useAddonManager: "permanent", + manifest: { + version: "1.0", + browser_specific_settings: { + gecko: { + id: EXTENSION_ID, + }, + }, + chrome_settings_overrides: { + homepage: HOMEPAGE_URI, + search_provider: { + name: "DuckDuckGo", + search_url: "https://example.com/?q={searchTerms}", + is_default: true, + }, + }, + }, + }; + let extension = ExtensionTestUtils.loadExtension(extensionInfo); + + let defaultHomepageURL = HomePage.get(); + let defaultEngineName = (await Services.search.getDefault()).name; + Assert.notStrictEqual( + defaultEngineName, + "DuckDuckGo", + "Default engine is not DuckDuckGo." + ); + + let prefPromise = promisePrefChanged(HOMEPAGE_URI); + + // When an addon is installed that overrides an app-provided engine (builtin) + // that is the default, we do not prompt for default. + let deferredPrompt = topicObservable( + "webextension-defaultsearch-prompt", + (subject, message) => { + if (subject.wrappedJSObject.id == extension.id) { + ok(false, "default override should not prompt"); + } + } + ); + + await Promise.race([extension.startup(), deferredPrompt.promise]); + deferredPrompt.resolve(); + await AddonTestUtils.waitForSearchProviderStartup(extension); + await prefPromise; + + equal( + extension.version, + "1.0", + "The installed addon has the expected version." + ); + ok( + HomePage.get().endsWith(HOMEPAGE_URI), + "Home page url is overridden by the extension." + ); + equal( + (await Services.search.getDefault()).name, + "DuckDuckGo", + "Builtin default engine was set default by extension" + ); + + extensionInfo.manifest = { + version: "2.0", + browser_specific_settings: { + gecko: { + id: EXTENSION_ID, + }, + }, + }; + + prefPromise = promisePrefChanged(defaultHomepageURL); + await extension.upgrade(extensionInfo); + await prefPromise; + + equal( + extension.version, + "2.0", + "The updated addon has the expected version." + ); + equal( + HomePage.get(), + defaultHomepageURL, + "Home page url reverted to the default after update." + ); + equal( + (await Services.search.getDefault()).name, + defaultEngineName, + "Default engine reverted to the default after update." + ); + + await extension.unload(); +}); + +add_task(async function test_overrides_update_adding() { + /* This tests the scenario where an addon adds support for + * a homepage or search service when upgrading. Neither + * should override existing entries for those when added + * in an upgrade. Also, a search_provider being added + * with is_default should not prompt the user or override + * the current default engine. */ + + const EXTENSION_ID = "test_overrides_update@tests.mozilla.org"; + const HOMEPAGE_URI = "webext-homepage-1.html"; + + let extensionInfo = { + useAddonManager: "permanent", + manifest: { + version: "1.0", + browser_specific_settings: { + gecko: { + id: EXTENSION_ID, + }, + }, + }, + }; + let extension = ExtensionTestUtils.loadExtension(extensionInfo); + + let defaultHomepageURL = HomePage.get(); + let defaultEngineName = (await Services.search.getDefault()).name; + Assert.notStrictEqual( + defaultEngineName, + "DuckDuckGo", + "Home page url is not DuckDuckGo." + ); + + await extension.startup(); + + equal( + extension.version, + "1.0", + "The installed addon has the expected version." + ); + equal( + HomePage.get(), + defaultHomepageURL, + "Home page url is the default after startup." + ); + equal( + (await Services.search.getDefault()).name, + defaultEngineName, + "Default engine is the default after startup." + ); + + extensionInfo.manifest = { + version: "2.0", + browser_specific_settings: { + gecko: { + id: EXTENSION_ID, + }, + }, + chrome_settings_overrides: { + homepage: HOMEPAGE_URI, + search_provider: { + name: "DuckDuckGo", + search_url: "https://example.com/?q={searchTerms}", + is_default: true, + }, + }, + }; + + let prefPromise = promisePrefChanged(HOMEPAGE_URI); + + let deferredUpgradePrompt = topicObservable( + "webextension-defaultsearch-prompt", + (subject, message) => { + if (subject.wrappedJSObject.id == extension.id) { + ok(false, "should not prompt on update"); + } + } + ); + + await Promise.race([ + extension.upgrade(extensionInfo), + deferredUpgradePrompt.promise, + ]); + deferredUpgradePrompt.resolve(); + await AddonTestUtils.waitForSearchProviderStartup(extension); + await prefPromise; + + equal( + extension.version, + "2.0", + "The updated addon has the expected version." + ); + ok( + HomePage.get().endsWith(HOMEPAGE_URI), + "Home page url is overridden by the extension during upgrade." + ); + // An upgraded extension adding a search engine cannot override + // the default engine. + equal( + (await Services.search.getDefault()).name, + defaultEngineName, + "Default engine is still the default after startup." + ); + + await extension.unload(); +}); + +add_task(async function test_overrides_update_homepage_change() { + /* This tests the scenario where an addon changes + * a homepage url when upgrading. */ + + const EXTENSION_ID = "test_overrides_update@tests.mozilla.org"; + const HOMEPAGE_URI = "webext-homepage-1.html"; + const HOMEPAGE_URI_2 = "webext-homepage-2.html"; + + let extensionInfo = { + useAddonManager: "permanent", + manifest: { + version: "1.0", + browser_specific_settings: { + gecko: { + id: EXTENSION_ID, + }, + }, + chrome_settings_overrides: { + homepage: HOMEPAGE_URI, + }, + }, + }; + let extension = ExtensionTestUtils.loadExtension(extensionInfo); + + let prefPromise = promisePrefChanged(HOMEPAGE_URI); + await extension.startup(); + await prefPromise; + + equal( + extension.version, + "1.0", + "The installed addon has the expected version." + ); + ok( + HomePage.get().endsWith(HOMEPAGE_URI), + "Home page url is the extension url after startup." + ); + + extensionInfo.manifest = { + version: "2.0", + browser_specific_settings: { + gecko: { + id: EXTENSION_ID, + }, + }, + chrome_settings_overrides: { + homepage: HOMEPAGE_URI_2, + }, + }; + + prefPromise = promisePrefChanged(HOMEPAGE_URI_2); + await extension.upgrade(extensionInfo); + await prefPromise; + + equal( + extension.version, + "2.0", + "The updated addon has the expected version." + ); + ok( + HomePage.get().endsWith(HOMEPAGE_URI_2), + "Home page url is by the extension after upgrade." + ); + + await extension.unload(); +}); + +async function withHandlingDefaultSearchPrompt({ extensionId, respond }, cb) { + const promptResponseHandled = TestUtils.topicObserved( + "webextension-defaultsearch-prompt-response" + ); + const prompted = TestUtils.topicObserved( + "webextension-defaultsearch-prompt", + (subject, message) => { + if (subject.wrappedJSObject.id == extensionId) { + return subject.wrappedJSObject.respond(respond); + } + } + ); + + await Promise.all([cb(), prompted, promptResponseHandled]); +} + +async function assertUpdateDoNotPrompt(extension, updateExtensionInfo) { + let deferredUpgradePrompt = topicObservable( + "webextension-defaultsearch-prompt", + (subject, message) => { + if (subject.wrappedJSObject.id == extension.id) { + ok(false, "should not prompt on update"); + } + } + ); + + await Promise.race([ + extension.upgrade(updateExtensionInfo), + deferredUpgradePrompt.promise, + ]); + deferredUpgradePrompt.resolve(); + + await AddonTestUtils.waitForSearchProviderStartup(extension); + + equal( + extension.version, + updateExtensionInfo.manifest.version, + "The updated addon has the expected version." + ); +} + +add_task(async function test_default_search_prompts() { + /* This tests the scenario where an addon did not gain + * default search during install, and later upgrades. + * The addon should not gain default in updates. + * If the addon is disabled, it should prompt again when + * enabled. + */ + + const EXTENSION_ID = "test_default_update@tests.mozilla.org"; + + let extensionInfo = { + useAddonManager: "permanent", + manifest: { + version: "1.0", + browser_specific_settings: { + gecko: { + id: EXTENSION_ID, + }, + }, + chrome_settings_overrides: { + search_provider: { + name: "Example", + search_url: "https://example.com/?q={searchTerms}", + is_default: true, + }, + }, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionInfo); + + let defaultEngineName = (await Services.search.getDefault()).name; + Assert.notStrictEqual(defaultEngineName, "Example", "Search is not Example."); + + // Mock a response from the default search prompt where we + // say no to setting this as the default when installing. + await withHandlingDefaultSearchPrompt( + { extensionId: EXTENSION_ID, respond: false }, + () => extension.startup() + ); + + equal( + extension.version, + "1.0", + "The installed addon has the expected version." + ); + equal( + (await Services.search.getDefault()).name, + defaultEngineName, + "Default engine is the default after startup." + ); + + info( + "Verify that updating the extension does not prompt and does not take over the default engine" + ); + + extensionInfo.manifest.version = "2.0"; + await assertUpdateDoNotPrompt(extension, extensionInfo); + equal( + (await Services.search.getDefault()).name, + defaultEngineName, + "Default engine is still the default after update." + ); + + info("Verify that disable/enable the extension does prompt the user"); + + let addon = await AddonManager.getAddonByID(EXTENSION_ID); + + await withHandlingDefaultSearchPrompt( + { extensionId: EXTENSION_ID, respond: false }, + async () => { + await addon.disable(); + await addon.enable(); + } + ); + + // we still said no. + equal( + (await Services.search.getDefault()).name, + defaultEngineName, + "Default engine is the default after being disabling/enabling." + ); + + await extension.unload(); +}); + +async function test_default_search_on_updating_addons_installed_before_bug1757760({ + builtinAsInitialDefault, +}) { + /* This tests covers a scenario similar to the previous test but with an extension-settings.json file + content like the one that would be available in the profile if the add-on was installed on firefox + versions that didn't include the changes from Bug 1757760 (See Bug 1767550). + */ + + const EXTENSION_ID = `test_old_addon@tests.mozilla.org`; + const EXTENSION_ID2 = `test_old_addon2@tests.mozilla.org`; + + const extensionInfo = { + useAddonManager: "permanent", + manifest: { + version: "1.1", + browser_specific_settings: { + gecko: { + id: EXTENSION_ID, + }, + }, + chrome_settings_overrides: { + search_provider: { + name: "Test SearchEngine", + search_url: "https://example.com/?q={searchTerms}", + is_default: true, + }, + }, + }, + }; + + const extensionInfo2 = { + useAddonManager: "permanent", + manifest: { + version: "1.2", + browser_specific_settings: { + gecko: { + id: EXTENSION_ID2, + }, + }, + chrome_settings_overrides: { + search_provider: { + name: "Test SearchEngine2", + search_url: "https://example.com/?q={searchTerms}", + is_default: true, + }, + }, + }, + }; + + const { ExtensionSettingsStore } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionSettingsStore.sys.mjs" + ); + + async function assertExtensionSettingsStore( + extensionInfo, + expectedLevelOfControl + ) { + const { id } = extensionInfo.manifest.browser_specific_settings.gecko; + info(`Asserting ExtensionSettingsStore for ${id}`); + const item = ExtensionSettingsStore.getSetting( + "default_search", + "defaultSearch", + id + ); + equal( + item.value, + extensionInfo.manifest.chrome_settings_overrides.search_provider.name, + "Got the expected item returned by ExtensionSettingsStore.getSetting" + ); + const control = await ExtensionSettingsStore.getLevelOfControl( + id, + "default_search", + "defaultSearch" + ); + equal( + control, + expectedLevelOfControl, + `Got expected levelOfControl for ${id}` + ); + } + + info("Install test extensions without opt-in to the related search engines"); + + let extension = ExtensionTestUtils.loadExtension(extensionInfo); + let extension2 = ExtensionTestUtils.loadExtension(extensionInfo2); + + // Mock a response from the default search prompt where we + // say no to setting this as the default when installing. + await withHandlingDefaultSearchPrompt( + { extensionId: EXTENSION_ID, respond: false }, + () => extension.startup() + ); + + equal( + extension.version, + "1.1", + "first installed addon has the expected version." + ); + + // Mock a response from the default search prompt where we + // say no to setting this as the default when installing. + await withHandlingDefaultSearchPrompt( + { extensionId: EXTENSION_ID2, respond: false }, + () => extension2.startup() + ); + + equal( + extension2.version, + "1.2", + "second installed addon has the expected version." + ); + + info("Setup preconditions (set the initial default search engine)"); + + // Sanity check to be sure the initial engine expected as precondition + // for the scenario covered by the current test case. + let initialEngine; + if (builtinAsInitialDefault) { + initialEngine = Services.search.appDefaultEngine; + } else { + initialEngine = Services.search.getEngineByName( + extensionInfo.manifest.chrome_settings_overrides.search_provider.name + ); + } + await Services.search.setDefault( + initialEngine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + + let defaultEngineName = (await Services.search.getDefault()).name; + Assert.equal( + defaultEngineName, + initialEngine.name, + `initial default search engine expected to be ${ + builtinAsInitialDefault ? "app-provided" : EXTENSION_ID + }` + ); + Assert.notEqual( + defaultEngineName, + extensionInfo2.manifest.chrome_settings_overrides.search_provider.name, + "initial default search engine name should not be the same as the second extension search_provider" + ); + + equal( + (await Services.search.getDefault()).name, + initialEngine.name, + `Default engine should still be set to the ${ + builtinAsInitialDefault ? "app-provided" : EXTENSION_ID + }.` + ); + + // Mock an update from settings stored as in an older Firefox version where Bug 1757760 was not landed yet. + info( + "Setup preconditions (inject mock extension-settings.json data and assert on the expected setting and levelOfControl)" + ); + + let addon = await AddonManager.getAddonByID(EXTENSION_ID); + let addon2 = await AddonManager.getAddonByID(EXTENSION_ID2); + + const extensionSettingsData = { + version: 2, + url_overrides: {}, + prefs: {}, + homepageNotification: {}, + tabHideNotification: {}, + default_search: { + defaultSearch: { + initialValue: Services.search.appDefaultEngine.name, + precedenceList: [ + { + id: EXTENSION_ID2, + // The install dates are used in ExtensionSettingsStore.getLevelOfControl + // and to recreate the expected preconditions the last extension installed + // should have a installDate timestamp > then the first one. + installDate: addon2.installDate.getTime() + 1000, + value: + extensionInfo2.manifest.chrome_settings_overrides.search_provider + .name, + // When an addon with a default search engine override is installed in Firefox versions + // without the changes landed from Bug 1757760, `enabled` will be set to true in all cases + // (Prompt never answered, or when No or Yes is selected by the user). + enabled: true, + }, + { + id: EXTENSION_ID, + installDate: addon.installDate.getTime(), + value: + extensionInfo.manifest.chrome_settings_overrides.search_provider + .name, + enabled: true, + }, + ], + }, + }, + newTabNotification: {}, + commands: {}, + }; + + const file = Services.dirsvc.get("ProfD", Ci.nsIFile); + file.append("extension-settings.json"); + + info(`writing mock settings data into ${file.path}`); + await IOUtils.writeJSON(file.path, extensionSettingsData); + await ExtensionSettingsStore._reloadFile(false); + + equal( + (await Services.search.getDefault()).name, + initialEngine.name, + "Default engine is still set to the initial one." + ); + + // The following assertions verify that the migration applied from ExtensionSettingsStore + // fixed the inconsistent state and kept the search engine unchanged. + // + // - With the fixed settings we expect both to be resolved to "controllable_by_this_extension". + // - Without the fix applied during the migration the levelOfControl resolved would be: + // - for the last installed: "controlled_by_this_extension" + // - for the first installed: "controlled_by_other_extensions" + await assertExtensionSettingsStore( + extensionInfo2, + "controlled_by_this_extension" + ); + await assertExtensionSettingsStore( + extensionInfo, + "controlled_by_other_extensions" + ); + + info( + "Verify that updating the extension does not prompt and does not take over the default engine" + ); + + extensionInfo2.manifest.version = "2.2"; + await assertUpdateDoNotPrompt(extension2, extensionInfo2); + + extensionInfo.manifest.version = "2.1"; + await assertUpdateDoNotPrompt(extension, extensionInfo); + + equal( + (await Services.search.getDefault()).name, + initialEngine.name, + "Default engine is still the same after updating both the test extensions." + ); + + // After both the extensions have been updated and their inconsistent state + // updated internally, both extensions should have levelOfControl "controllable_*". + await assertExtensionSettingsStore( + extensionInfo2, + "controllable_by_this_extension" + ); + await assertExtensionSettingsStore( + extensionInfo, + // We expect levelOfControl to be controlled_by_this_extension if the test case + // is expecting the third party extension to stay set as default. + builtinAsInitialDefault + ? "controllable_by_this_extension" + : "controlled_by_this_extension" + ); + + info("Verify that disable/enable the extension does prompt the user"); + + await withHandlingDefaultSearchPrompt( + { extensionId: EXTENSION_ID2, respond: false }, + async () => { + await addon2.disable(); + await addon2.enable(); + } + ); + + // we said no. + equal( + (await Services.search.getDefault()).name, + initialEngine.name, + `Default engine should still be the same after disabling/enabling ${EXTENSION_ID2}.` + ); + + await withHandlingDefaultSearchPrompt( + { extensionId: EXTENSION_ID, respond: false }, + async () => { + await addon.disable(); + await addon.enable(); + } + ); + + // we said no. + equal( + (await Services.search.getDefault()).name, + Services.search.appDefaultEngine.name, + `Default engine should be set to the app default after disabling/enabling ${EXTENSION_ID}.` + ); + + await withHandlingDefaultSearchPrompt( + { extensionId: EXTENSION_ID, respond: true }, + async () => { + await addon.disable(); + await addon.enable(); + } + ); + + // we responded yes. + equal( + (await Services.search.getDefault()).name, + extensionInfo.manifest.chrome_settings_overrides.search_provider.name, + "Default engine should be set to the one opted-in from the last prompt." + ); + + await extension.unload(); + await extension2.unload(); +} + +add_task(function test_builtin_default_search_after_updating_old_addons() { + return test_default_search_on_updating_addons_installed_before_bug1757760({ + builtinAsInitialDefault: true, + }); +}); + +add_task(function test_third_party_default_search_after_updating_old_addons() { + return test_default_search_on_updating_addons_installed_before_bug1757760({ + builtinAsInitialDefault: false, + }); +}); diff --git a/browser/components/extensions/test/xpcshell/test_ext_distribution_popup.js b/browser/components/extensions/test/xpcshell/test_ext_distribution_popup.js new file mode 100644 index 0000000000..030e0b27be --- /dev/null +++ b/browser/components/extensions/test/xpcshell/test_ext_distribution_popup.js @@ -0,0 +1,56 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ + +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + ExtensionControlledPopup: + "resource:///modules/ExtensionControlledPopup.sys.mjs", + ExtensionSettingsStore: + "resource://gre/modules/ExtensionSettingsStore.sys.mjs", +}); + +/* + * This function is a unit test for distributions disabling the ExtensionControlledPopup. + */ +add_task(async function testDistributionPopup() { + let distExtId = "ext-distribution@mochi.test"; + Services.prefs.setCharPref( + `extensions.installedDistroAddon.${distExtId}`, + true + ); + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { gecko: { id: distExtId } }, + name: "Ext Distribution", + }, + }); + + let userExtId = "ext-user@mochi.test"; + let userExtension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { gecko: { id: userExtId } }, + name: "Ext User Installed", + }, + }); + + await extension.startup(); + await userExtension.startup(); + await ExtensionSettingsStore.initialize(); + + let confirmedType = "extension-controlled-confirmed"; + equal( + new ExtensionControlledPopup({ confirmedType }).userHasConfirmed(distExtId), + true, + "The popup has been disabled." + ); + + equal( + new ExtensionControlledPopup({ confirmedType }).userHasConfirmed(userExtId), + false, + "The popup has not been disabled." + ); + + await extension.unload(); + await userExtension.unload(); +}); diff --git a/browser/components/extensions/test/xpcshell/test_ext_history.js b/browser/components/extensions/test/xpcshell/test_ext_history.js new file mode 100644 index 0000000000..c0f6c39be7 --- /dev/null +++ b/browser/components/extensions/test/xpcshell/test_ext_history.js @@ -0,0 +1,864 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { AddonTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/AddonTestUtils.sys.mjs" +); + +ChromeUtils.defineESModuleGetters(this, { + ExtensionCommon: "resource://gre/modules/ExtensionCommon.sys.mjs", + PlacesTestUtils: "resource://testing-common/PlacesTestUtils.sys.mjs", + PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs", +}); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "1", + "43" +); + +add_task(async function test_delete() { + function background() { + let historyClearedCount = 0; + let removedUrls = []; + + browser.history.onVisitRemoved.addListener(data => { + if (data.allHistory) { + historyClearedCount++; + browser.test.assertEq( + 0, + data.urls.length, + "onVisitRemoved received an empty urls array" + ); + } else { + removedUrls.push(...data.urls); + } + }); + + browser.test.onMessage.addListener((msg, arg) => { + if (msg === "delete-url") { + browser.history.deleteUrl({ url: arg }).then(result => { + browser.test.assertEq( + undefined, + result, + "browser.history.deleteUrl returns nothing" + ); + browser.test.sendMessage("url-deleted"); + }); + } else if (msg === "delete-range") { + browser.history.deleteRange(arg).then(result => { + browser.test.assertEq( + undefined, + result, + "browser.history.deleteRange returns nothing" + ); + browser.test.sendMessage("range-deleted", removedUrls); + }); + } else if (msg === "delete-all") { + browser.history.deleteAll().then(result => { + browser.test.assertEq( + undefined, + result, + "browser.history.deleteAll returns nothing" + ); + browser.test.sendMessage("history-cleared", [ + historyClearedCount, + removedUrls, + ]); + }); + } + }); + + browser.test.sendMessage("ready"); + } + + const BASE_URL = "http://mozilla.com/test_history/"; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["history"], + }, + background: `(${background})()`, + }); + + await extension.startup(); + await extension.awaitMessage("ready"); + await PlacesUtils.history.clear(); + + let historyClearedCount; + let visits = []; + let visitDate = new Date(1999, 9, 9, 9, 9).getTime(); + + function pushVisit(subvisits) { + visitDate += 1000; + subvisits.push({ date: new Date(visitDate) }); + } + + // Add 5 visits for one uri and 3 visits for 3 others + for (let i = 0; i < 4; ++i) { + let visit = { + url: `${BASE_URL}${i}`, + title: "visit " + i, + visits: [], + }; + if (i === 0) { + for (let j = 0; j < 5; ++j) { + pushVisit(visit.visits); + } + } else { + pushVisit(visit.visits); + } + visits.push(visit); + } + + await PlacesUtils.history.insertMany(visits); + equal( + await PlacesTestUtils.visitsInDB(visits[0].url), + 5, + "5 visits for uri found in history database" + ); + + let testUrl = visits[2].url; + ok( + await PlacesTestUtils.isPageInDB(testUrl), + "expected url found in history database" + ); + + extension.sendMessage("delete-url", testUrl); + await extension.awaitMessage("url-deleted"); + equal( + await PlacesTestUtils.isPageInDB(testUrl), + false, + "expected url not found in history database" + ); + + // delete 3 of the 5 visits for url 1 + let filter = { + startTime: visits[0].visits[0].date, + endTime: visits[0].visits[2].date, + }; + + extension.sendMessage("delete-range", filter); + let removedUrls = await extension.awaitMessage("range-deleted"); + ok( + !removedUrls.includes(visits[0].url), + `${visits[0].url} not received by onVisitRemoved` + ); + ok( + await PlacesTestUtils.isPageInDB(visits[0].url), + "expected uri found in history database" + ); + equal( + await PlacesTestUtils.visitsInDB(visits[0].url), + 2, + "2 visits for uri found in history database" + ); + ok( + await PlacesTestUtils.isPageInDB(visits[1].url), + "expected uri found in history database" + ); + equal( + await PlacesTestUtils.visitsInDB(visits[1].url), + 1, + "1 visit for uri found in history database" + ); + + // delete the rest of the visits for url 1, and the visit for url 2 + filter.startTime = visits[0].visits[0].date; + filter.endTime = visits[1].visits[0].date; + + extension.sendMessage("delete-range", filter); + await extension.awaitMessage("range-deleted"); + + equal( + await PlacesTestUtils.isPageInDB(visits[0].url), + false, + "expected uri not found in history database" + ); + equal( + await PlacesTestUtils.visitsInDB(visits[0].url), + 0, + "0 visits for uri found in history database" + ); + equal( + await PlacesTestUtils.isPageInDB(visits[1].url), + false, + "expected uri not found in history database" + ); + equal( + await PlacesTestUtils.visitsInDB(visits[1].url), + 0, + "0 visits for uri found in history database" + ); + + ok( + await PlacesTestUtils.isPageInDB(visits[3].url), + "expected uri found in history database" + ); + + extension.sendMessage("delete-all"); + [historyClearedCount, removedUrls] = await extension.awaitMessage( + "history-cleared" + ); + equal( + historyClearedCount, + 2, + "onVisitRemoved called for each clearing of history" + ); + equal( + removedUrls.length, + 3, + "onVisitRemoved called the expected number of times" + ); + for (let i = 1; i < 3; ++i) { + let url = visits[i].url; + ok(removedUrls.includes(url), `${url} received by onVisitRemoved`); + } + await extension.unload(); +}); + +const SINGLE_VISIT_URL = "http://example.com/"; +const DOUBLE_VISIT_URL = "http://example.com/2/"; +const MOZILLA_VISIT_URL = "http://mozilla.com/"; +const REFERENCE_DATE = new Date(); +// pages/visits to add via History.insert +const PAGE_INFOS = [ + { + url: SINGLE_VISIT_URL, + title: `test visit for ${SINGLE_VISIT_URL}`, + visits: [{ date: new Date(Number(REFERENCE_DATE) - 1000) }], + }, + { + url: DOUBLE_VISIT_URL, + title: `test visit for ${DOUBLE_VISIT_URL}`, + visits: [ + { date: REFERENCE_DATE }, + { date: new Date(Number(REFERENCE_DATE) - 2000) }, + ], + }, + { + url: MOZILLA_VISIT_URL, + title: `test visit for ${MOZILLA_VISIT_URL}`, + visits: [{ date: new Date(Number(REFERENCE_DATE) - 3000) }], + }, +]; + +add_task(async function test_search() { + function background(BGSCRIPT_REFERENCE_DATE) { + const futureTime = Date.now() + 24 * 60 * 60 * 1000; + + browser.test.onMessage.addListener(msg => { + browser.history + .search({ text: "" }) + .then(results => { + browser.test.sendMessage("empty-search", results); + return browser.history.search({ text: "mozilla.com" }); + }) + .then(results => { + browser.test.sendMessage("text-search", results); + return browser.history.search({ text: "example.com", maxResults: 1 }); + }) + .then(results => { + browser.test.sendMessage("max-results-search", results); + return browser.history.search({ + text: "", + startTime: BGSCRIPT_REFERENCE_DATE - 2000, + endTime: BGSCRIPT_REFERENCE_DATE - 1000, + }); + }) + .then(results => { + browser.test.sendMessage("date-range-search", results); + return browser.history.search({ text: "", startTime: futureTime }); + }) + .then(results => { + browser.test.assertEq( + 0, + results.length, + "no results returned for late start time" + ); + return browser.history.search({ text: "", endTime: 0 }); + }) + .then(results => { + browser.test.assertEq( + 0, + results.length, + "no results returned for early end time" + ); + return browser.history.search({ + text: "", + startTime: Date.now(), + endTime: 0, + }); + }) + .then( + results => { + browser.test.fail( + "history.search rejects with startTime that is after the endTime" + ); + }, + error => { + browser.test.assertEq( + "The startTime cannot be after the endTime", + error.message, + "history.search rejects with startTime that is after the endTime" + ); + } + ) + .then(() => { + browser.test.notifyPass("search"); + }); + }); + + browser.test.sendMessage("ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["history"], + }, + background: `(${background})(${Number(REFERENCE_DATE)})`, + }); + + function findResult(url, results) { + return results.find(r => r.url === url); + } + + function checkResult(results, url, expectedCount) { + let result = findResult(url, results); + notEqual(result, null, `history.search result was found for ${url}`); + equal( + result.visitCount, + expectedCount, + `history.search reports ${expectedCount} visit(s)` + ); + equal( + result.title, + `test visit for ${url}`, + "title for search result is correct" + ); + } + + await extension.startup(); + await extension.awaitMessage("ready"); + await PlacesUtils.history.clear(); + + await PlacesUtils.history.insertMany(PAGE_INFOS); + + extension.sendMessage("check-history"); + + let results = await extension.awaitMessage("empty-search"); + equal(results.length, 3, "history.search with empty text returned 3 results"); + checkResult(results, SINGLE_VISIT_URL, 1); + checkResult(results, DOUBLE_VISIT_URL, 2); + checkResult(results, MOZILLA_VISIT_URL, 1); + + results = await extension.awaitMessage("text-search"); + equal( + results.length, + 1, + "history.search with specific text returned 1 result" + ); + checkResult(results, MOZILLA_VISIT_URL, 1); + + results = await extension.awaitMessage("max-results-search"); + equal(results.length, 1, "history.search with maxResults returned 1 result"); + checkResult(results, DOUBLE_VISIT_URL, 2); + + results = await extension.awaitMessage("date-range-search"); + equal( + results.length, + 2, + "history.search with a date range returned 2 result" + ); + checkResult(results, DOUBLE_VISIT_URL, 2); + checkResult(results, SINGLE_VISIT_URL, 1); + + await extension.awaitFinish("search"); + await extension.unload(); + await PlacesUtils.history.clear(); +}); + +add_task(async function test_add_url() { + function background() { + const TEST_DOMAIN = "http://example.com/"; + + browser.test.onMessage.addListener((msg, testData) => { + let [details, type] = testData; + details.url = details.url || `${TEST_DOMAIN}${type}`; + if (msg === "add-url") { + details.title = `Title for ${type}`; + browser.history + .addUrl(details) + .then(() => { + return browser.history.search({ text: details.url }); + }) + .then(results => { + browser.test.assertEq( + 1, + results.length, + "1 result found when searching for added URL" + ); + browser.test.sendMessage("url-added", { + details, + result: results[0], + }); + }); + } else if (msg === "expect-failure") { + let expectedMsg = testData[2]; + browser.history.addUrl(details).then( + () => { + browser.test.fail(`Expected error thrown for ${type}`); + }, + error => { + browser.test.assertTrue( + error.message.includes(expectedMsg), + `"Expected error thrown when trying to add a URL with ${type}` + ); + browser.test.sendMessage("add-failed"); + } + ); + } + }); + + browser.test.sendMessage("ready"); + } + + let addTestData = [ + [{}, "default"], + [{ visitTime: new Date() }, "with_date"], + [{ visitTime: Date.now() }, "with_ms_number"], + [{ visitTime: new Date().toISOString() }, "with_iso_string"], + [{ transition: "typed" }, "valid_transition"], + ]; + + let failTestData = [ + [ + { transition: "generated" }, + "an invalid transition", + "|generated| is not a supported transition for history", + ], + [{ visitTime: Date.now() + 1000000 }, "a future date", "Invalid value"], + [{ url: "about.config" }, "an invalid url", "Invalid value"], + ]; + + async function checkUrl(results) { + ok( + await PlacesTestUtils.isPageInDB(results.details.url), + `${results.details.url} found in history database` + ); + ok( + PlacesUtils.isValidGuid(results.result.id), + "URL was added with a valid id" + ); + equal( + results.result.title, + results.details.title, + "URL was added with the correct title" + ); + if (results.details.visitTime) { + equal( + results.result.lastVisitTime, + Number(ExtensionCommon.normalizeTime(results.details.visitTime)), + "URL was added with the correct date" + ); + } + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["history"], + }, + background: `(${background})()`, + }); + + await PlacesUtils.history.clear(); + await extension.startup(); + await extension.awaitMessage("ready"); + + for (let data of addTestData) { + extension.sendMessage("add-url", data); + let results = await extension.awaitMessage("url-added"); + await checkUrl(results); + } + + for (let data of failTestData) { + extension.sendMessage("expect-failure", data); + await extension.awaitMessage("add-failed"); + } + + await extension.unload(); +}); + +add_task(async function test_get_visits() { + async function background() { + const TEST_DOMAIN = "http://example.com/"; + const FIRST_DATE = Date.now(); + const INITIAL_DETAILS = { + url: TEST_DOMAIN, + visitTime: FIRST_DATE, + transition: "link", + }; + + let visitIds = new Set(); + + async function checkVisit(visit, expected) { + visitIds.add(visit.visitId); + browser.test.assertEq( + expected.visitTime, + visit.visitTime, + "visit has the correct visitTime" + ); + browser.test.assertEq( + expected.transition, + visit.transition, + "visit has the correct transition" + ); + let results = await browser.history.search({ text: expected.url }); + // all results will have the same id, so we only need to use the first one + browser.test.assertEq( + results[0].id, + visit.id, + "visit has the correct id" + ); + } + + let details = Object.assign({}, INITIAL_DETAILS); + + await browser.history.addUrl(details); + let results = await browser.history.getVisits({ url: details.url }); + + browser.test.assertEq( + 1, + results.length, + "the expected number of visits were returned" + ); + await checkVisit(results[0], details); + + details.url = `${TEST_DOMAIN}/1/`; + await browser.history.addUrl(details); + + results = await browser.history.getVisits({ url: details.url }); + browser.test.assertEq( + 1, + results.length, + "the expected number of visits were returned" + ); + await checkVisit(results[0], details); + + details.visitTime = FIRST_DATE - 1000; + details.transition = "typed"; + await browser.history.addUrl(details); + results = await browser.history.getVisits({ url: details.url }); + + browser.test.assertEq( + 2, + results.length, + "the expected number of visits were returned" + ); + await checkVisit(results[0], INITIAL_DETAILS); + await checkVisit(results[1], details); + browser.test.assertEq(3, visitIds.size, "each visit has a unique visitId"); + await browser.test.notifyPass("get-visits"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["history"], + }, + background: `(${background})()`, + }); + + await PlacesUtils.history.clear(); + await extension.startup(); + + await extension.awaitFinish("get-visits"); + await extension.unload(); +}); + +add_task(async function test_transition_types() { + const VISIT_URL_PREFIX = "http://example.com/"; + const TRANSITIONS = [ + ["link", Ci.nsINavHistoryService.TRANSITION_LINK], + ["typed", Ci.nsINavHistoryService.TRANSITION_TYPED], + ["auto_bookmark", Ci.nsINavHistoryService.TRANSITION_BOOKMARK], + // Only session history contains TRANSITION_EMBED visits, + // So global history query cannot find them. + // ["auto_subframe", Ci.nsINavHistoryService.TRANSITION_EMBED], + // Redirects are not correctly tested here because History + // will not make redirect entries hidden. + ["link", Ci.nsINavHistoryService.TRANSITION_REDIRECT_PERMANENT], + ["link", Ci.nsINavHistoryService.TRANSITION_REDIRECT_TEMPORARY], + ["link", Ci.nsINavHistoryService.TRANSITION_DOWNLOAD], + ["manual_subframe", Ci.nsINavHistoryService.TRANSITION_FRAMED_LINK], + ["reload", Ci.nsINavHistoryService.TRANSITION_RELOAD], + ]; + + // pages/visits to add via History.insertMany + let pageInfos = []; + let visitDate = new Date(1999, 9, 9, 9, 9).getTime(); + for (let [, transitionType] of TRANSITIONS) { + pageInfos.push({ + url: VISIT_URL_PREFIX + transitionType + "/", + visits: [ + { transition: transitionType, date: new Date((visitDate -= 1000)) }, + ], + }); + } + + function background() { + browser.test.onMessage.addListener(async (msg, url) => { + switch (msg) { + case "search": { + let results = await browser.history.search({ + text: "", + startTime: new Date(0), + }); + browser.test.sendMessage("search-result", results); + break; + } + case "get-visits": { + let results = await browser.history.getVisits({ url }); + browser.test.sendMessage("get-visits-result", results); + break; + } + } + }); + + browser.test.sendMessage("ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["history"], + }, + background, + }); + + await PlacesUtils.history.clear(); + await extension.startup(); + await extension.awaitMessage("ready"); + + await PlacesUtils.history.insertMany(pageInfos); + + extension.sendMessage("search"); + let results = await extension.awaitMessage("search-result"); + equal( + results.length, + pageInfos.length, + "search returned expected length of results" + ); + for (let i = 0; i < pageInfos.length; ++i) { + equal(results[i].url, pageInfos[i].url, "search returned the expected url"); + + extension.sendMessage("get-visits", pageInfos[i].url); + let visits = await extension.awaitMessage("get-visits-result"); + equal(visits.length, 1, "getVisits returned expected length of visits"); + equal( + visits[0].transition, + TRANSITIONS[i][0], + "getVisits returned the expected transition" + ); + } + + await extension.unload(); +}); + +add_task(async function test_on_visited() { + const SINGLE_VISIT_URL = "http://example.com/1/"; + const DOUBLE_VISIT_URL = "http://example.com/2/"; + let visitDate = new Date(1999, 9, 9, 9, 9).getTime(); + + // pages/visits to add via History.insertMany + const PAGE_INFOS = [ + { + url: SINGLE_VISIT_URL, + title: `visit to ${SINGLE_VISIT_URL}`, + visits: [{ date: new Date(visitDate) }], + }, + { + url: DOUBLE_VISIT_URL, + title: `visit to ${DOUBLE_VISIT_URL}`, + visits: [ + { date: new Date((visitDate += 1000)) }, + { date: new Date((visitDate += 1000)) }, + ], + }, + { + url: SINGLE_VISIT_URL, + title: "Title Changed", + visits: [{ date: new Date(visitDate) }], + }, + ]; + + function background() { + let onVisitedData = []; + + browser.history.onVisited.addListener(data => { + if (data.url.includes("moz-extension")) { + return; + } + onVisitedData.push(data); + if (onVisitedData.length == 4) { + browser.test.sendMessage("on-visited-data", onVisitedData); + } + }); + + // Verifying onTitleChange Event along with onVisited event + browser.history.onTitleChanged.addListener(data => { + browser.test.sendMessage("on-title-changed-data", data); + }); + + browser.test.sendMessage("ready"); + } + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["history"], + }, + background: `(${background})()`, + }); + + await PlacesUtils.history.clear(); + await extension.startup(); + await extension.awaitMessage("ready"); + + await PlacesUtils.history.insertMany(PAGE_INFOS); + + let onVisitedData = await extension.awaitMessage("on-visited-data"); + + function checkOnVisitedData(index, expected) { + let onVisited = onVisitedData[index]; + ok(PlacesUtils.isValidGuid(onVisited.id), "onVisited received a valid id"); + equal(onVisited.url, expected.url, "onVisited received the expected url"); + equal( + onVisited.title, + expected.title, + "onVisited received the expected title" + ); + equal( + onVisited.lastVisitTime, + expected.time, + "onVisited received the expected time" + ); + equal( + onVisited.visitCount, + expected.visitCount, + "onVisited received the expected visitCount" + ); + } + + let expected = { + url: PAGE_INFOS[0].url, + title: PAGE_INFOS[0].title, + time: PAGE_INFOS[0].visits[0].date.getTime(), + visitCount: 1, + }; + checkOnVisitedData(0, expected); + + expected.url = PAGE_INFOS[1].url; + expected.title = PAGE_INFOS[1].title; + expected.time = PAGE_INFOS[1].visits[0].date.getTime(); + checkOnVisitedData(1, expected); + + expected.time = PAGE_INFOS[1].visits[1].date.getTime(); + expected.visitCount = 2; + checkOnVisitedData(2, expected); + + expected.url = PAGE_INFOS[2].url; + expected.title = PAGE_INFOS[2].title; + expected.time = PAGE_INFOS[2].visits[0].date.getTime(); + expected.visitCount = 2; + checkOnVisitedData(3, expected); + + let onTitleChangedData = await extension.awaitMessage( + "on-title-changed-data" + ); + Assert.deepEqual( + { + id: onVisitedData[3].id, + url: SINGLE_VISIT_URL, + title: "Title Changed", + }, + onTitleChangedData, + "expected event data for onTitleChanged" + ); + + await extension.unload(); +}); + +add_task( + { + pref_set: [["extensions.eventPages.enabled", true]], + }, + async function test_history_event_page() { + await AddonTestUtils.promiseStartupManager(); + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + browser_specific_settings: { gecko: { id: "eventpage@history" } }, + permissions: ["history"], + background: { persistent: false }, + }, + background() { + browser.history.onVisited.addListener(() => { + browser.test.sendMessage("onVisited"); + }); + browser.history.onVisitRemoved.addListener(() => { + browser.test.sendMessage("onVisitRemoved"); + }); + browser.history.onTitleChanged.addListener(() => {}); + browser.test.sendMessage("ready"); + }, + }); + + const EVENTS = ["onVisited", "onVisitRemoved", "onTitleChanged"]; + await PlacesUtils.history.clear(); + + await extension.startup(); + await extension.awaitMessage("ready"); + for (let event of EVENTS) { + assertPersistentListeners(extension, "history", event, { + primed: false, + }); + } + + // test events waken background + await extension.terminateBackground(); + for (let event of EVENTS) { + assertPersistentListeners(extension, "history", event, { + primed: true, + }); + } + + await PlacesUtils.history.insertMany(PAGE_INFOS); + + await extension.awaitMessage("ready"); + await extension.awaitMessage("onVisited"); + ok(true, "persistent event woke background"); + for (let event of EVENTS) { + assertPersistentListeners(extension, "history", event, { + primed: false, + }); + } + + await AddonTestUtils.promiseRestartManager(); + await extension.awaitStartup(); + + for (let event of EVENTS) { + assertPersistentListeners(extension, "history", event, { + primed: true, + }); + } + + await PlacesUtils.history.clear(); + await extension.awaitMessage("ready"); + await extension.awaitMessage("onVisitRemoved"); + + await extension.unload(); + await AddonTestUtils.promiseShutdownManager(); + } +); diff --git a/browser/components/extensions/test/xpcshell/test_ext_homepage_overrides_private.js b/browser/components/extensions/test/xpcshell/test_ext_homepage_overrides_private.js new file mode 100644 index 0000000000..2d2bccc1e2 --- /dev/null +++ b/browser/components/extensions/test/xpcshell/test_ext_homepage_overrides_private.js @@ -0,0 +1,134 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { AddonTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/AddonTestUtils.sys.mjs" +); +const { HomePage } = ChromeUtils.importESModule( + "resource:///modules/HomePage.sys.mjs" +); +const { ExtensionPermissions } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionPermissions.sys.mjs" +); + +const { createAppInfo, promiseShutdownManager, promiseStartupManager } = + AddonTestUtils; + +const EXTENSION_ID = "test_overrides@tests.mozilla.org"; +const HOMEPAGE_EXTENSION_CONTROLLED = + "browser.startup.homepage_override.extensionControlled"; +const HOMEPAGE_PRIVATE_ALLOWED = + "browser.startup.homepage_override.privateAllowed"; +const HOMEPAGE_URL_PREF = "browser.startup.homepage"; +const HOMEPAGE_URI = "webext-homepage-1.html"; + +Services.prefs.setBoolPref("browser.privatebrowsing.autostart", true); + +AddonTestUtils.init(this); +AddonTestUtils.usePrivilegedSignatures = false; +AddonTestUtils.overrideCertDB(); + +createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42"); + +function promisePrefChange(pref) { + return new Promise((resolve, reject) => { + Services.prefs.addObserver(pref, function observer() { + Services.prefs.removeObserver(pref, observer); + resolve(arguments); + }); + }); +} + +let defaultHomepageURL; + +function verifyPrefSettings(controlled, allowed) { + equal( + Services.prefs.getBoolPref(HOMEPAGE_EXTENSION_CONTROLLED, false), + controlled, + "homepage extension controlled" + ); + equal( + Services.prefs.getBoolPref(HOMEPAGE_PRIVATE_ALLOWED, false), + allowed, + "homepage private permission after permission change" + ); + + if (controlled && allowed) { + ok( + HomePage.get().endsWith(HOMEPAGE_URI), + "Home page url is overridden by the extension" + ); + } else { + equal(HomePage.get(), defaultHomepageURL, "Home page url is default."); + } +} + +async function promiseUpdatePrivatePermission(allowed, extension) { + info(`update private allowed permission`); + await Promise.all([ + promisePrefChange(HOMEPAGE_PRIVATE_ALLOWED), + ExtensionPermissions[allowed ? "add" : "remove"]( + extension.id, + { permissions: ["internal:privateBrowsingAllowed"], origins: [] }, + extension + ), + ]); + + verifyPrefSettings(true, allowed); +} + +add_task(async function test_overrides_private() { + await promiseStartupManager(); + + let extensionInfo = { + useAddonManager: "permanent", + manifest: { + version: "1.0", + browser_specific_settings: { + gecko: { + id: EXTENSION_ID, + }, + }, + chrome_settings_overrides: { + homepage: HOMEPAGE_URI, + }, + }, + }; + let extension = ExtensionTestUtils.loadExtension(extensionInfo); + + defaultHomepageURL = HomePage.get(); + + await extension.startup(); + + verifyPrefSettings(true, false); + + equal(HomePage.get(), defaultHomepageURL, "Home page url is default."); + + info("add permission to extension"); + await promiseUpdatePrivatePermission(true, extension.extension); + info("remove permission from extension"); + await promiseUpdatePrivatePermission(false, extension.extension); + // set back to true to test upgrade removing extension control + info("add permission back to prepare for upgrade test"); + await promiseUpdatePrivatePermission(true, extension.extension); + + extensionInfo.manifest = { + version: "2.0", + browser_specific_settings: { + gecko: { + id: EXTENSION_ID, + }, + }, + }; + + await Promise.all([ + promisePrefChange(HOMEPAGE_URL_PREF), + extension.upgrade(extensionInfo), + ]); + + verifyPrefSettings(false, false); + + await extension.unload(); + await promiseShutdownManager(); +}); diff --git a/browser/components/extensions/test/xpcshell/test_ext_manifest.js b/browser/components/extensions/test/xpcshell/test_ext_manifest.js new file mode 100644 index 0000000000..b978172ca2 --- /dev/null +++ b/browser/components/extensions/test/xpcshell/test_ext_manifest.js @@ -0,0 +1,105 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + +async function testManifest(manifest, expectedError) { + ExtensionTestUtils.failOnSchemaWarnings(false); + let normalized = await ExtensionTestUtils.normalizeManifest(manifest); + ExtensionTestUtils.failOnSchemaWarnings(true); + + if (expectedError) { + ok( + expectedError.test(normalized.error), + `Should have an error for ${JSON.stringify(manifest)}, got ${ + normalized.error + }` + ); + } else { + ok( + !normalized.error, + `Should not have an error ${JSON.stringify(manifest)}, ${ + normalized.error + }` + ); + } + return normalized.errors; +} + +const all_actions = [ + "action", + "browser_action", + "page_action", + "sidebar_action", +]; + +add_task(async function test_manifest() { + let badpaths = ["", " ", "\t", "http://foo.com/icon.png"]; + for (let path of badpaths) { + for (let action of all_actions) { + let manifest_version = action == "action" ? 3 : 2; + let manifest = { manifest_version }; + manifest[action] = { default_icon: path }; + let error = new RegExp(`Error processing ${action}.default_icon`); + await testManifest(manifest, error); + + manifest[action] = { default_icon: { 16: path } }; + await testManifest(manifest, error); + } + } + + let paths = [ + "icon.png", + "/icon.png", + "./icon.png", + "path to an icon.png", + " icon.png", + ]; + for (let path of paths) { + for (let action of all_actions) { + let manifest_version = action == "action" ? 3 : 2; + let manifest = { manifest_version }; + manifest[action] = { default_icon: path }; + if (action == "sidebar_action") { + // Sidebar requires panel. + manifest[action].default_panel = "foo.html"; + } + await testManifest(manifest); + + manifest[action] = { default_icon: { 16: path } }; + if (action == "sidebar_action") { + manifest[action].default_panel = "foo.html"; + } + await testManifest(manifest); + } + } +}); + +add_task(async function test_action_version() { + let warnings = await testManifest({ + manifest_version: 3, + browser_action: { + default_panel: "foo.html", + }, + }); + Assert.deepEqual( + warnings, + [`Property "browser_action" is unsupported in Manifest Version 3`], + `Manifest v3 with "browser_action" key logs an error.` + ); + + warnings = await testManifest({ + manifest_version: 2, + action: { + default_icon: "", + default_panel: "foo.html", + }, + }); + + Assert.deepEqual( + warnings, + [`Property "action" is unsupported in Manifest Version 2`], + `Manifest v2 with "action" key first warning is clear.` + ); +}); diff --git a/browser/components/extensions/test/xpcshell/test_ext_manifest_commands.js b/browser/components/extensions/test/xpcshell/test_ext_manifest_commands.js new file mode 100644 index 0000000000..8196ab0e24 --- /dev/null +++ b/browser/components/extensions/test/xpcshell/test_ext_manifest_commands.js @@ -0,0 +1,52 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_manifest_commands() { + const validShortcuts = [ + "Ctrl+Y", + "MacCtrl+Y", + "Command+Y", + "Alt+Shift+Y", + "Ctrl+Alt+Y", + "F1", + "MediaNextTrack", + ]; + const invalidShortcuts = ["Shift+Y", "Y", "Ctrl+Ctrl+Y", "Ctrl+Command+Y"]; + + async function validateShortcut(shortcut, isValid) { + let normalized = await ExtensionTestUtils.normalizeManifest({ + commands: { + "toggle-feature": { + suggested_key: { default: shortcut }, + description: "Send a 'toggle-feature' event to the extension", + }, + }, + }); + if (isValid) { + ok(!normalized.error, "There should be no manifest errors."); + } else { + let expectedError = + String.raw`Error processing commands.toggle-feature.suggested_key.default: Error: ` + + String.raw`Value "${shortcut}" must consist of ` + + String.raw`either a combination of one or two modifiers, including ` + + String.raw`a mandatory primary modifier and a key, separated by '+', ` + + String.raw`or a media key. For details see: ` + + String.raw`https://developer.mozilla.org/en-US/Add-ons/WebExtensions/manifest.json/commands#Key_combinations`; + + ok( + normalized.error.includes(expectedError), + `The manifest error ${JSON.stringify( + normalized.error + )} must contain ${JSON.stringify(expectedError)}` + ); + } + } + + for (let shortcut of validShortcuts) { + validateShortcut(shortcut, true); + } + for (let shortcut of invalidShortcuts) { + validateShortcut(shortcut, false); + } +}); diff --git a/browser/components/extensions/test/xpcshell/test_ext_manifest_omnibox.js b/browser/components/extensions/test/xpcshell/test_ext_manifest_omnibox.js new file mode 100644 index 0000000000..f81e7d3cb5 --- /dev/null +++ b/browser/components/extensions/test/xpcshell/test_ext_manifest_omnibox.js @@ -0,0 +1,62 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +async function testKeyword(params) { + let normalized = await ExtensionTestUtils.normalizeManifest({ + omnibox: { + keyword: params.keyword, + }, + }); + + if (params.expectError) { + let expectedError = + String.raw`omnibox.keyword: String "${params.keyword}" ` + + String.raw`must match /^[^?\s:][^\s:]*$/`; + ok( + normalized.error.includes(expectedError), + `The manifest error ${JSON.stringify(normalized.error)} ` + + `must contain ${JSON.stringify(expectedError)}` + ); + } else { + equal(normalized.error, undefined, "Should not have an error"); + equal(normalized.errors.length, 0, "Should not have warnings"); + } +} + +add_task(async function test_manifest_commands() { + // accepted single character keywords + await testKeyword({ keyword: "a", expectError: false }); + await testKeyword({ keyword: "-", expectError: false }); + await testKeyword({ keyword: "嗨", expectError: false }); + await testKeyword({ keyword: "*", expectError: false }); + await testKeyword({ keyword: "/", expectError: false }); + + // rejected single character keywords + await testKeyword({ keyword: "?", expectError: true }); + await testKeyword({ keyword: " ", expectError: true }); + await testKeyword({ keyword: ":", expectError: true }); + + // accepted multi-character keywords + await testKeyword({ keyword: "aa", expectError: false }); + await testKeyword({ keyword: "http", expectError: false }); + await testKeyword({ keyword: "f?a", expectError: false }); + await testKeyword({ keyword: "fa?", expectError: false }); + await testKeyword({ keyword: "f/x", expectError: false }); + await testKeyword({ keyword: "/fx", expectError: false }); + await testKeyword({ keyword: "fx/", expectError: false }); + + // rejected multi-character keywords + await testKeyword({ keyword: " a", expectError: true }); + await testKeyword({ keyword: "a ", expectError: true }); + await testKeyword({ keyword: " ", expectError: true }); + await testKeyword({ keyword: " a ", expectError: true }); + await testKeyword({ keyword: "?fx", expectError: true }); + await testKeyword({ keyword: "f:x", expectError: true }); + await testKeyword({ keyword: "fx:", expectError: true }); + await testKeyword({ keyword: "f x", expectError: true }); + + // miscellaneous tests + await testKeyword({ keyword: "こんにちは", expectError: false }); + await testKeyword({ keyword: "http://", expectError: true }); +}); diff --git a/browser/components/extensions/test/xpcshell/test_ext_manifest_permissions.js b/browser/components/extensions/test/xpcshell/test_ext_manifest_permissions.js new file mode 100644 index 0000000000..fed7af5d5b --- /dev/null +++ b/browser/components/extensions/test/xpcshell/test_ext_manifest_permissions.js @@ -0,0 +1,85 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +/* globals chrome */ + +Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + +async function testPermission(options) { + function background(bgOptions) { + browser.test.sendMessage("typeof-namespace", { + browser: typeof browser[bgOptions.namespace], + chrome: typeof chrome[bgOptions.namespace], + }); + } + + let extensionDetails = { + background: `(${background})(${JSON.stringify(options)})`, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionDetails); + + await extension.startup(); + + let types = await extension.awaitMessage("typeof-namespace"); + equal( + types.browser, + "undefined", + `Type of browser.${options.namespace} without manifest entry` + ); + equal( + types.chrome, + "undefined", + `Type of chrome.${options.namespace} without manifest entry` + ); + + await extension.unload(); + + extensionDetails.manifest = options.manifest; + extension = ExtensionTestUtils.loadExtension(extensionDetails); + + await extension.startup(); + + types = await extension.awaitMessage("typeof-namespace"); + equal( + types.browser, + "object", + `Type of browser.${options.namespace} with manifest entry` + ); + equal( + types.chrome, + "object", + `Type of chrome.${options.namespace} with manifest entry` + ); + + await extension.unload(); +} + +add_task(async function test_action() { + await testPermission({ + namespace: "action", + manifest: { + manifest_version: 3, + action: {}, + }, + }); +}); + +add_task(async function test_browserAction() { + await testPermission({ + namespace: "browserAction", + manifest: { + browser_action: {}, + }, + }); +}); + +add_task(async function test_pageAction() { + await testPermission({ + namespace: "pageAction", + manifest: { + page_action: {}, + }, + }); +}); diff --git a/browser/components/extensions/test/xpcshell/test_ext_menu_caller.js b/browser/components/extensions/test/xpcshell/test_ext_menu_caller.js new file mode 100644 index 0000000000..5aa04bbc78 --- /dev/null +++ b/browser/components/extensions/test/xpcshell/test_ext_menu_caller.js @@ -0,0 +1,53 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +add_task(async function test_create_menu_ext_error() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["menus"], + }, + async background() { + let { fileName } = new Error(); + browser.menus.create({ + id: "muted-tab", + title: "open link with Menu 1", + contexts: ["link"], + }); + await new Promise(resolve => { + browser.menus.create( + { + id: "muted-tab", + title: "open link with Menu 2", + contexts: ["link"], + }, + resolve + ); + }); + browser.test.sendMessage("fileName", fileName); + }, + }); + + let fileName; + const { messages } = await promiseConsoleOutput(async () => { + await extension.startup(); + fileName = await extension.awaitMessage("fileName"); + await extension.unload(); + }); + let [msg] = messages + .filter(m => m.message.includes("Unchecked lastError")) + .map(m => m.QueryInterface(Ci.nsIScriptError)); + equal(msg.sourceName, fileName, "Message source"); + + equal( + msg.errorMessage, + "Unchecked lastError value: Error: ID already exists: muted-tab", + "Message content" + ); + equal(msg.lineNumber, 9, "Message line"); + + let frame = msg.stack; + equal(frame.source, fileName, "Frame source"); + equal(frame.line, 9, "Frame line"); + equal(frame.column, 23, "Frame column"); +}); diff --git a/browser/components/extensions/test/xpcshell/test_ext_menu_startup.js b/browser/components/extensions/test/xpcshell/test_ext_menu_startup.js new file mode 100644 index 0000000000..aa019c6584 --- /dev/null +++ b/browser/components/extensions/test/xpcshell/test_ext_menu_startup.js @@ -0,0 +1,432 @@ +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + ExtensionParent: "resource://gre/modules/ExtensionParent.sys.mjs", + Management: "resource://gre/modules/Extension.sys.mjs", +}); + +const { AddonTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/AddonTestUtils.sys.mjs" +); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "42", + "42" +); + +Services.prefs.setBoolPref("extensions.eventPages.enabled", true); + +function getExtension(id, background, useAddonManager) { + return ExtensionTestUtils.loadExtension({ + useAddonManager, + manifest: { + browser_specific_settings: { gecko: { id } }, + permissions: ["menus"], + background: { persistent: false }, + }, + background, + }); +} + +async function expectCached(extension, expect) { + let { StartupCache } = ExtensionParent; + let cached = await StartupCache.menus.get(extension.id); + let createProperties = Array.from(cached.values()); + equal(cached.size, expect.length, "menus saved in cache"); + // The menus startupCache is a map and the order is significant + // for recreating menus on startup. Ensure that they are in + // the expected order. We only verify specific keys here rather + // than all menu properties. + for (let i in createProperties) { + Assert.deepEqual( + createProperties[i], + expect[i], + "expected cached properties exist" + ); + } +} + +function promiseExtensionEvent(wrapper, event) { + return new Promise(resolve => { + wrapper.extension.once(event, (kind, data) => { + resolve(data); + }); + }); +} + +add_setup(async () => { + await AddonTestUtils.promiseStartupManager(); +}); + +add_task(async function test_menu_onInstalled() { + async function background() { + browser.runtime.onInstalled.addListener(async () => { + const parentId = browser.menus.create({ + contexts: ["all"], + title: "parent", + id: "test-parent", + }); + browser.menus.create({ + parentId, + title: "click A", + id: "test-click-a", + }); + browser.menus.create( + { + parentId, + title: "click B", + id: "test-click-b", + }, + () => { + browser.test.sendMessage("onInstalled"); + } + ); + }); + browser.menus.create( + { + contexts: ["tab"], + title: "top-level", + id: "test-top-level", + }, + () => { + browser.test.sendMessage("create", browser.runtime.lastError?.message); + } + ); + + browser.test.onMessage.addListener(async msg => { + browser.test.log(`onMessage ${msg}`); + if (msg == "updatemenu") { + await browser.menus.update("test-click-a", { title: "click updated" }); + } else if (msg == "removemenu") { + await browser.menus.remove("test-click-b"); + } else if (msg == "removeall") { + await browser.menus.removeAll(); + } + browser.test.sendMessage("updated"); + }); + } + + const extension = getExtension( + "test-persist@mochitest", + background, + "permanent" + ); + + await extension.startup(); + let lastError = await extension.awaitMessage("create"); + Assert.equal(lastError, undefined, "no error creating menu"); + await extension.awaitMessage("onInstalled"); + await extension.terminateBackground(); + + await expectCached(extension, [ + { + contexts: ["tab"], + id: "test-top-level", + title: "top-level", + }, + { contexts: ["all"], id: "test-parent", title: "parent" }, + { + id: "test-click-a", + parentId: "test-parent", + title: "click A", + }, + { + id: "test-click-b", + parentId: "test-parent", + title: "click B", + }, + ]); + + await extension.wakeupBackground(); + lastError = await extension.awaitMessage("create"); + Assert.equal( + lastError, + "The menu id test-top-level already exists in menus.create.", + "correct error creating menu" + ); + + await AddonTestUtils.promiseRestartManager(); + await extension.awaitStartup(); + + // verify the startupcache + await expectCached(extension, [ + { + contexts: ["tab"], + id: "test-top-level", + title: "top-level", + }, + { contexts: ["all"], id: "test-parent", title: "parent" }, + { + id: "test-click-a", + parentId: "test-parent", + title: "click A", + }, + { + id: "test-click-b", + parentId: "test-parent", + title: "click B", + }, + ]); + + equal( + extension.extension.backgroundState, + "stopped", + "background is not running" + ); + await extension.wakeupBackground(); + lastError = await extension.awaitMessage("create"); + Assert.equal( + lastError, + "The menu id test-top-level already exists in menus.create.", + "correct error creating menu" + ); + + extension.sendMessage("updatemenu"); + await extension.awaitMessage("updated"); + await extension.terminateBackground(); + + // Title change is cached + await expectCached(extension, [ + { + contexts: ["tab"], + id: "test-top-level", + title: "top-level", + }, + { contexts: ["all"], id: "test-parent", title: "parent" }, + { + id: "test-click-a", + parentId: "test-parent", + title: "click updated", + }, + { + id: "test-click-b", + parentId: "test-parent", + title: "click B", + }, + ]); + + await extension.wakeupBackground(); + lastError = await extension.awaitMessage("create"); + Assert.equal( + lastError, + "The menu id test-top-level already exists in menus.create.", + "correct error creating menu" + ); + + extension.sendMessage("removemenu"); + await extension.awaitMessage("updated"); + await extension.terminateBackground(); + + // menu removed + await expectCached(extension, [ + { + contexts: ["tab"], + id: "test-top-level", + title: "top-level", + }, + { contexts: ["all"], id: "test-parent", title: "parent" }, + { + id: "test-click-a", + parentId: "test-parent", + title: "click updated", + }, + ]); + + await extension.wakeupBackground(); + lastError = await extension.awaitMessage("create"); + Assert.equal( + lastError, + "The menu id test-top-level already exists in menus.create.", + "correct error creating menu" + ); + + extension.sendMessage("removeall"); + await extension.awaitMessage("updated"); + await extension.terminateBackground(); + + // menus removed + await expectCached(extension, []); + + await extension.unload(); +}); + +add_task(async function test_menu_nested() { + async function background() { + browser.test.onMessage.addListener(async (action, properties) => { + browser.test.log(`onMessage ${action}`); + switch (action) { + case "create": + await new Promise(resolve => { + browser.menus.create(properties, resolve); + }); + break; + case "update": + { + let { id, ...update } = properties; + await browser.menus.update(id, update); + } + break; + case "remove": + { + let { id } = properties; + await browser.menus.remove(id); + } + break; + case "removeAll": + await browser.menus.removeAll(); + break; + } + browser.test.sendMessage("updated"); + }); + } + + const extension = getExtension( + "test-nesting@mochitest", + background, + "permanent" + ); + await extension.startup(); + + extension.sendMessage("create", { + id: "first", + contexts: ["all"], + title: "first", + }); + await extension.awaitMessage("updated"); + await expectCached(extension, [ + { contexts: ["all"], id: "first", title: "first" }, + ]); + + extension.sendMessage("create", { + id: "second", + contexts: ["all"], + title: "second", + }); + await extension.awaitMessage("updated"); + await expectCached(extension, [ + { contexts: ["all"], id: "first", title: "first" }, + { contexts: ["all"], id: "second", title: "second" }, + ]); + + extension.sendMessage("create", { + id: "third", + contexts: ["all"], + title: "third", + parentId: "first", + }); + await extension.awaitMessage("updated"); + await expectCached(extension, [ + { contexts: ["all"], id: "first", title: "first" }, + { contexts: ["all"], id: "second", title: "second" }, + { + contexts: ["all"], + id: "third", + parentId: "first", + title: "third", + }, + ]); + + extension.sendMessage("create", { + id: "fourth", + contexts: ["all"], + title: "fourth", + }); + await extension.awaitMessage("updated"); + await expectCached(extension, [ + { contexts: ["all"], id: "first", title: "first" }, + { contexts: ["all"], id: "second", title: "second" }, + { + contexts: ["all"], + id: "third", + parentId: "first", + title: "third", + }, + { contexts: ["all"], id: "fourth", title: "fourth" }, + ]); + + extension.sendMessage("update", { + id: "first", + parentId: "second", + }); + await extension.awaitMessage("updated"); + await expectCached(extension, [ + { contexts: ["all"], id: "second", title: "second" }, + { contexts: ["all"], id: "fourth", title: "fourth" }, + { + contexts: ["all"], + id: "first", + title: "first", + parentId: "second", + }, + { + contexts: ["all"], + id: "third", + parentId: "first", + title: "third", + }, + ]); + + await AddonTestUtils.promiseShutdownManager(); + // We need to attach an event listener before the + // startup event is emitted. Fortunately, we + // emit via Management before emitting on extension. + let promiseMenus; + Management.once("startup", (kind, ext) => { + info(`management ${kind} ${ext.id}`); + promiseMenus = promiseExtensionEvent( + { extension: ext }, + "webext-menus-created" + ); + }); + await AddonTestUtils.promiseStartupManager(); + await extension.awaitStartup(); + await extension.wakeupBackground(); + + await expectCached(extension, [ + { contexts: ["all"], id: "second", title: "second" }, + { contexts: ["all"], id: "fourth", title: "fourth" }, + { + contexts: ["all"], + id: "first", + title: "first", + parentId: "second", + }, + { + contexts: ["all"], + id: "third", + parentId: "first", + title: "third", + }, + ]); + // validate nesting + let menus = await promiseMenus; + equal(menus.get("first").parentId, "second", "menuitem parent is correct"); + equal( + menus.get("second").children.length, + 1, + "menuitem parent has correct number of children" + ); + equal( + menus.get("second").root.children.length, + 2, // second and forth + "menuitem root has correct number of children" + ); + + extension.sendMessage("remove", { + id: "second", + }); + await extension.awaitMessage("updated"); + await expectCached(extension, [ + { contexts: ["all"], id: "fourth", title: "fourth" }, + ]); + + extension.sendMessage("removeAll"); + await extension.awaitMessage("updated"); + await expectCached(extension, []); + + await extension.unload(); +}); diff --git a/browser/components/extensions/test/xpcshell/test_ext_normandyAddonStudy.js b/browser/components/extensions/test/xpcshell/test_ext_normandyAddonStudy.js new file mode 100644 index 0000000000..0a2a9dcd49 --- /dev/null +++ b/browser/components/extensions/test/xpcshell/test_ext_normandyAddonStudy.js @@ -0,0 +1,243 @@ +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", +}); + +const { AddonStudies } = ChromeUtils.importESModule( + "resource://normandy/lib/AddonStudies.sys.mjs" +); +const { NormandyTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/NormandyTestUtils.sys.mjs" +); +const { TestUtils } = ChromeUtils.importESModule( + "resource://testing-common/TestUtils.sys.mjs" +); +var { AddonTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/AddonTestUtils.sys.mjs" +); + +const { addonStudyFactory } = NormandyTestUtils.factories; + +AddonTestUtils.init(this); + +// All tests run privileged unless otherwise specified not to. +function createExtension(backgroundScript, permissions, isPrivileged = true) { + let extensionData = { + background: backgroundScript, + manifest: { + browser_specific_settings: { + gecko: { + id: "test@shield.mozilla.com", + }, + }, + permissions, + }, + isPrivileged, + }; + return ExtensionTestUtils.loadExtension(extensionData); +} + +async function run(test) { + let extension = createExtension( + test.backgroundScript, + test.permissions || ["normandyAddonStudy"], + test.isPrivileged + ); + const promiseValidation = test.validationScript + ? test.validationScript(extension) + : Promise.resolve(); + + await extension.startup(); + + await promiseValidation; + + if (test.doneSignal) { + await extension.awaitFinish(test.doneSignal); + } + + await extension.unload(); +} + +add_task(async function setup() { + await ExtensionTestUtils.startAddonManager(); +}); + +add_task( + async function test_normandyAddonStudy_without_normandyAddonStudy_permission_privileged() { + await run({ + backgroundScript: () => { + browser.test.assertTrue( + !browser.normandyAddonStudy, + "'normandyAddonStudy' permission is required" + ); + browser.test.notifyPass("normandyAddonStudy_permission"); + }, + permissions: [], + doneSignal: "normandyAddonStudy_permission", + }); + } +); + +add_task(async function test_normandyAddonStudy_without_privilege() { + await run({ + backgroundScript: () => { + browser.test.assertTrue( + !browser.normandyAddonStudy, + "Extension must be privileged" + ); + browser.test.notifyPass("normandyAddonStudy_permission"); + }, + isPrivileged: false, + doneSignal: "normandyAddonStudy_permission", + }); +}); + +add_task(async function test_normandyAddonStudy_temporary_without_privilege() { + let extension = ExtensionTestUtils.loadExtension({ + temporarilyInstalled: true, + isPrivileged: false, + manifest: { + permissions: ["normandyAddonStudy"], + }, + }); + ExtensionTestUtils.failOnSchemaWarnings(false); + let { messages } = await promiseConsoleOutput(async () => { + await Assert.rejects( + extension.startup(), + /Using the privileged permission/, + "Startup failed with privileged permission" + ); + }); + ExtensionTestUtils.failOnSchemaWarnings(true); + AddonTestUtils.checkMessages( + messages, + { + expected: [ + { + message: + /Using the privileged permission 'normandyAddonStudy' requires a privileged add-on/, + }, + ], + }, + true + ); +}); + +add_task(async function test_getStudy_works() { + const study = addonStudyFactory({ + addonId: "test@shield.mozilla.com", + }); + + const testWrapper = AddonStudies.withStudies([study]); + const test = testWrapper(async () => { + await run({ + backgroundScript: async () => { + const result = await browser.normandyAddonStudy.getStudy(); + browser.test.sendMessage("study", result); + }, + validationScript: async extension => { + let studyResult = await extension.awaitMessage("study"); + deepEqual( + studyResult, + study, + "normandyAddonStudy.getStudy returns the correct study" + ); + }, + }); + }); + + await test(); +}); + +add_task(async function test_endStudy_works() { + const study = addonStudyFactory({ + addonId: "test@shield.mozilla.com", + }); + + const testWrapper = AddonStudies.withStudies([study]); + const test = testWrapper(async () => { + await run({ + backgroundScript: async () => { + await browser.normandyAddonStudy.endStudy("test"); + }, + validationScript: async () => { + // Check that `AddonStudies.markAsEnded` was called + await TestUtils.topicObserved( + "shield-study-ended", + (subject, message) => { + return message === `${study.recipeId}`; + } + ); + + const addon = await AddonManager.getAddonByID(study.addonId); + equal(addon, undefined, "Addon should be uninstalled."); + }, + }); + }); + + await test(); +}); + +add_task(async function test_getClientMetadata_works() { + const study = addonStudyFactory({ + addonId: "test@shield.mozilla.com", + slug: "test-slug", + branch: "test-branch", + }); + + const testWrapper = AddonStudies.withStudies([study]); + const test = testWrapper(async () => { + await run({ + backgroundScript: async () => { + const metadata = await browser.normandyAddonStudy.getClientMetadata(); + browser.test.sendMessage("clientMetadata", metadata); + }, + validationScript: async extension => { + let clientMetadata = await extension.awaitMessage("clientMetadata"); + + Assert.strictEqual( + clientMetadata.updateChannel, + Services.appinfo.defaultUpdateChannel, + "clientMetadata contains correct updateChannel" + ); + + Assert.strictEqual( + clientMetadata.fxVersion, + Services.appinfo.version, + "clientMetadata contains correct fxVersion" + ); + + ok("clientID" in clientMetadata, "clientMetadata contains a clientID"); + }, + }); + }); + + await test(); +}); + +add_task(async function test_onUnenroll_works() { + const study = addonStudyFactory({ + addonId: "test@shield.mozilla.com", + }); + + const testWrapper = AddonStudies.withStudies([study]); + const test = testWrapper(async () => { + await run({ + backgroundScript: () => { + browser.normandyAddonStudy.onUnenroll.addListener(reason => { + browser.test.sendMessage("unenrollReason", reason); + }); + browser.test.sendMessage("bgpageReady"); + }, + validationScript: async extension => { + await extension.awaitMessage("bgpageReady"); + await AddonStudies.markAsEnded(study, "test"); + const unenrollReason = await extension.awaitMessage("unenrollReason"); + equal(unenrollReason, "test", "Unenroll listener should be called."); + }, + }); + }); + + await test(); +}); diff --git a/browser/components/extensions/test/xpcshell/test_ext_pageAction_shutdown.js b/browser/components/extensions/test/xpcshell/test_ext_pageAction_shutdown.js new file mode 100644 index 0000000000..bd462ec9b6 --- /dev/null +++ b/browser/components/extensions/test/xpcshell/test_ext_pageAction_shutdown.js @@ -0,0 +1,81 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +// Load lazy so we create the app info first. +ChromeUtils.defineESModuleGetters(this, { + PageActions: "resource:///modules/PageActions.sys.mjs", +}); + +const { AddonTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/AddonTestUtils.sys.mjs" +); + +const { createAppInfo, promiseShutdownManager, promiseStartupManager } = + AddonTestUtils; + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); + +createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "58"); + +// This is copied and pasted from ext-browser.js and used in ext-pageAction.js. +// It's used as the PageActions action ID. +function makeWidgetId(id) { + id = id.toLowerCase(); + // FIXME: This allows for collisions. + return id.replace(/[^a-z0-9_-]/g, "_"); +} + +// Tests that the pinnedToUrlbar property of the PageActions.Action object +// backing the extension's page action persists across app restarts. +add_task(async function testAppShutdown() { + let extensionData = { + useAddonManager: "permanent", + manifest: { + page_action: { + default_title: "test_ext_pageAction_shutdown.js", + browser_style: false, + }, + }, + }; + + // Simulate starting up the app. + PageActions.init(); + await promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension(extensionData); + await extension.startup(); + + // Get the PageAction.Action object. Its pinnedToUrlbar should have been + // initialized to true in ext-pageAction.js, when it's created. + let actionID = makeWidgetId(extension.id); + let action = PageActions.actionForID(actionID); + Assert.equal(action.pinnedToUrlbar, true); + + // Simulate restarting the app without first unloading the extension. + await promiseShutdownManager(); + PageActions._reset(); + await promiseStartupManager(); + await extension.awaitStartup(); + + // Get the action. Its pinnedToUrlbar should remain true. + action = PageActions.actionForID(actionID); + Assert.equal(action.pinnedToUrlbar, true); + + // Now set its pinnedToUrlbar to false. + action.pinnedToUrlbar = false; + + // Simulate restarting the app again without first unloading the extension. + await promiseShutdownManager(); + PageActions._reset(); + await promiseStartupManager(); + await extension.awaitStartup(); + + action = PageActions.actionForID(actionID); + Assert.equal(action.pinnedToUrlbar, true); + + // Now unload the extension and quit the app. + await extension.unload(); + await promiseShutdownManager(); +}); diff --git a/browser/components/extensions/test/xpcshell/test_ext_pkcs11_management.js b/browser/components/extensions/test/xpcshell/test_ext_pkcs11_management.js new file mode 100644 index 0000000000..8c713191cc --- /dev/null +++ b/browser/components/extensions/test/xpcshell/test_ext_pkcs11_management.js @@ -0,0 +1,300 @@ +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + MockRegistry: "resource://testing-common/MockRegistry.sys.mjs", + ctypes: "resource://gre/modules/ctypes.sys.mjs", +}); + +do_get_profile(); + +let tmpDir; +let baseDir; +let slug = + AppConstants.platform === "linux" ? "pkcs11-modules" : "PKCS11Modules"; + +add_task(async function setupTest() { + tmpDir = await IOUtils.createUniqueDirectory( + Services.dirsvc.get("TmpD", Ci.nsIFile).path, + "PKCS11" + ); + + baseDir = PathUtils.join(tmpDir, slug); + await IOUtils.makeDirectory(baseDir); +}); + +registerCleanupFunction(async () => { + await IOUtils.remove(tmpDir, { recursive: true }); +}); + +const testmodule = PathUtils.join( + PathUtils.parent(Services.dirsvc.get("CurWorkD", Ci.nsIFile).path, 5), + "security", + "manager", + "ssl", + "tests", + "unit", + "pkcs11testmodule", + ctypes.libraryName("pkcs11testmodule") +); + +// This function was inspired by the native messaging test under +// toolkit/components/extensions + +async function setupManifests(modules) { + async function writeManifest(module) { + let manifest = { + name: module.name, + description: module.description, + path: module.path, + type: "pkcs11", + allowed_extensions: [module.id], + }; + + let manifestPath = PathUtils.join(baseDir, `${module.name}.json`); + await IOUtils.writeJSON(manifestPath, manifest); + + return manifestPath; + } + + switch (AppConstants.platform) { + case "macosx": + case "linux": + let dirProvider = { + getFile(property) { + if ( + property == "XREUserNativeManifests" || + property == "XRESysNativeManifests" + ) { + return new FileUtils.File(tmpDir); + } + return null; + }, + }; + + Services.dirsvc.registerProvider(dirProvider); + registerCleanupFunction(() => { + Services.dirsvc.unregisterProvider(dirProvider); + }); + + for (let module of modules) { + await writeManifest(module); + } + break; + + case "win": + const REGKEY = String.raw`Software\Mozilla\PKCS11Modules`; + + let registry = new MockRegistry(); + registerCleanupFunction(() => { + registry.shutdown(); + }); + + for (let module of modules) { + let manifestPath = await writeManifest(module); + registry.setValue( + Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER, + `${REGKEY}\\${module.name}`, + "", + manifestPath + ); + } + break; + + default: + ok( + false, + `Loading of PKCS#11 modules is not supported on ${AppConstants.platform}` + ); + } +} + +add_task(async function test_pkcs11() { + async function background() { + try { + const { os } = await browser.runtime.getPlatformInfo(); + if (os !== "win") { + // Expect this call to not throw (explicitly cover regression fixed in Bug 1759162). + let isInstalledNonAbsolute = await browser.pkcs11.isModuleInstalled( + "testmoduleNonAbsolutePath" + ); + browser.test.assertFalse( + isInstalledNonAbsolute, + "PKCS#11 module with non absolute path expected to not be installed" + ); + } + let isInstalled = await browser.pkcs11.isModuleInstalled("testmodule"); + browser.test.assertFalse( + isInstalled, + "PKCS#11 module is not installed before we install it" + ); + await browser.pkcs11.installModule("testmodule", 0); + isInstalled = await browser.pkcs11.isModuleInstalled("testmodule"); + browser.test.assertTrue( + isInstalled, + "PKCS#11 module is installed after we install it" + ); + let slots = await browser.pkcs11.getModuleSlots("testmodule"); + browser.test.assertEq( + "Test PKCS11 Slot", + slots[0].name, + "The first slot name matches the expected name" + ); + browser.test.assertEq( + "Test PKCS11 Slot 二", + slots[1].name, + "The second slot name matches the expected name" + ); + browser.test.assertTrue(slots[1].token, "The second slot has a token"); + browser.test.assertFalse(slots[2].token, "The third slot has no token"); + browser.test.assertEq( + "Test PKCS11 Tokeñ 2 Label", + slots[1].token.name, + "The token name matches the expected name" + ); + browser.test.assertEq( + "Test PKCS11 Manufacturer ID", + slots[1].token.manufacturer, + "The token manufacturer matches the expected manufacturer" + ); + browser.test.assertEq( + "0.0", + slots[1].token.HWVersion, + "The token hardware version matches the expected version" + ); + browser.test.assertEq( + "0.0", + slots[1].token.FWVersion, + "The token firmware version matches the expected version" + ); + browser.test.assertEq( + "", + slots[1].token.serial, + "The token has no serial number" + ); + browser.test.assertFalse( + slots[1].token.isLoggedIn, + "The token is not logged in" + ); + await browser.pkcs11.uninstallModule("testmodule"); + isInstalled = await browser.pkcs11.isModuleInstalled("testmodule"); + browser.test.assertFalse( + isInstalled, + "PKCS#11 module is no longer installed after we uninstall it" + ); + await browser.pkcs11.installModule("testmodule"); + isInstalled = await browser.pkcs11.isModuleInstalled("testmodule"); + browser.test.assertTrue( + isInstalled, + "Installing the PKCS#11 module without flags parameter succeeds" + ); + await browser.pkcs11.uninstallModule("testmodule"); + await browser.test.assertRejects( + browser.pkcs11.isModuleInstalled("nonexistingmodule"), + /No such PKCS#11 module nonexistingmodule/, + "We cannot access modules if no JSON file exists" + ); + await browser.test.assertRejects( + browser.pkcs11.isModuleInstalled("othermodule"), + /No such PKCS#11 module othermodule/, + "We cannot access modules if we're not listed in the module's manifest file's allowed_extensions key" + ); + await browser.test.assertRejects( + browser.pkcs11.uninstallModule("internalmodule"), + /No such PKCS#11 module internalmodule/, + "We cannot uninstall the NSS Builtin Roots Module" + ); + await browser.test.assertRejects( + browser.pkcs11.installModule("osclientcerts", 0), + /No such PKCS#11 module osclientcerts/, + "installModule should not work on the built-in osclientcerts module" + ); + await browser.test.assertRejects( + browser.pkcs11.uninstallModule("osclientcerts"), + /No such PKCS#11 module osclientcerts/, + "uninstallModule should not work on the built-in osclientcerts module" + ); + await browser.test.assertRejects( + browser.pkcs11.isModuleInstalled("osclientcerts"), + /No such PKCS#11 module osclientcerts/, + "isModuleLoaded should not work on the built-in osclientcerts module" + ); + await browser.test.assertRejects( + browser.pkcs11.getModuleSlots("osclientcerts"), + /No such PKCS#11 module osclientcerts/, + "getModuleSlots should not work on the built-in osclientcerts module" + ); + await browser.test.assertRejects( + browser.pkcs11.installModule("ipcclientcerts", 0), + /No such PKCS#11 module ipcclientcerts/, + "installModule should not work on the built-in ipcclientcerts module" + ); + await browser.test.assertRejects( + browser.pkcs11.uninstallModule("ipcclientcerts"), + /No such PKCS#11 module ipcclientcerts/, + "uninstallModule should not work on the built-in ipcclientcerts module" + ); + await browser.test.assertRejects( + browser.pkcs11.isModuleInstalled("ipcclientcerts"), + /No such PKCS#11 module ipcclientcerts/, + "isModuleLoaded should not work on the built-in ipcclientcerts module" + ); + await browser.test.assertRejects( + browser.pkcs11.getModuleSlots("ipcclientcerts"), + /No such PKCS#11 module ipcclientcerts/, + "getModuleSlots should not work on the built-in ipcclientcerts module" + ); + browser.test.notifyPass("pkcs11"); + } catch (e) { + browser.test.fail(`Error: ${String(e)} :: ${e.stack}`); + browser.test.notifyFail("pkcs11 failed"); + } + } + + let libDir = FileUtils.getDir("GreBinD", []); + await setupManifests([ + { + name: "testmodule", + description: "PKCS#11 Test Module", + path: testmodule, + id: "pkcs11@tests.mozilla.org", + }, + { + name: "testmoduleNonAbsolutePath", + description: "PKCS#11 Test Module", + path: ctypes.libraryName("pkcs11testmodule"), + id: "pkcs11@tests.mozilla.org", + }, + { + name: "othermodule", + description: "PKCS#11 Test Module", + path: testmodule, + id: "other@tests.mozilla.org", + }, + { + name: "internalmodule", + description: "Builtin Roots Module", + path: PathUtils.join( + Services.dirsvc.get("CurWorkD", Ci.nsIFile).path, + ctypes.libraryName("nssckbi") + ), + id: "pkcs11@tests.mozilla.org", + }, + { + name: "osclientcerts", + description: "OS Client Cert Module", + path: PathUtils.join(libDir.path, ctypes.libraryName("osclientcerts")), + id: "pkcs11@tests.mozilla.org", + }, + ]); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["pkcs11"], + browser_specific_settings: { gecko: { id: "pkcs11@tests.mozilla.org" } }, + }, + background: background, + }); + await extension.startup(); + await extension.awaitFinish("pkcs11"); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/xpcshell/test_ext_settings_overrides_defaults.js b/browser/components/extensions/test/xpcshell/test_ext_settings_overrides_defaults.js new file mode 100644 index 0000000000..dd24be3aff --- /dev/null +++ b/browser/components/extensions/test/xpcshell/test_ext_settings_overrides_defaults.js @@ -0,0 +1,263 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ + +"use strict"; + +const { AddonTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/AddonTestUtils.sys.mjs" +); + +const { SearchTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/SearchTestUtils.sys.mjs" +); + +const { SearchUtils } = ChromeUtils.importESModule( + "resource://gre/modules/SearchUtils.sys.mjs" +); + +const { RemoteSettings } = ChromeUtils.importESModule( + "resource://services-settings/remote-settings.sys.mjs" +); + +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); + +const URLTYPE_SUGGEST_JSON = "application/x-suggestions+json"; + +AddonTestUtils.init(this); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "42", + "42" +); + +const kSearchEngineURL = "https://example.com/?q={searchTerms}&foo=myparams"; +const kSuggestURL = "https://example.com/fake/suggest/"; +const kSuggestURLParams = "q={searchTerms}&type=list2"; + +Services.prefs.setBoolPref("browser.search.log", true); + +add_task(async function setup() { + AddonTestUtils.usePrivilegedSignatures = false; + AddonTestUtils.overrideCertDB(); + await AddonTestUtils.promiseStartupManager(); + await SearchTestUtils.useTestEngines("data", null, [ + { + webExtension: { + id: "test@search.mozilla.org", + }, + appliesTo: [ + { + included: { everywhere: true }, + default: "yes", + }, + ], + }, + { + webExtension: { + id: "test2@search.mozilla.org", + }, + appliesTo: [ + { + included: { everywhere: true }, + }, + ], + }, + ]); + await Services.search.init(); + registerCleanupFunction(async () => { + await AddonTestUtils.promiseShutdownManager(); + }); +}); + +function assertEngineParameters({ + name, + searchURL, + suggestionURL, + messageSnippet, +}) { + let engine = Services.search.getEngineByName(name); + Assert.ok(engine, `Should have found ${name}`); + + Assert.equal( + engine.getSubmission("{searchTerms}").uri.spec, + encodeURI(searchURL), + `Should have ${messageSnippet} the suggestion url.` + ); + Assert.equal( + engine.getSubmission("{searchTerms}", URLTYPE_SUGGEST_JSON)?.uri.spec, + suggestionURL ? encodeURI(suggestionURL) : suggestionURL, + `Should ${messageSnippet} the submission URL.` + ); +} + +add_task(async function test_extension_changing_to_app_provided_default() { + let ext1 = ExtensionTestUtils.loadExtension({ + manifest: { + icons: { + 16: "foo.ico", + }, + chrome_settings_overrides: { + search_provider: { + is_default: true, + name: "MozParamsTest2", + keyword: "MozSearch", + search_url: kSearchEngineURL, + suggest_url: kSuggestURL, + suggest_url_get_params: kSuggestURLParams, + }, + }, + }, + useAddonManager: "temporary", + }); + + await ext1.startup(); + await AddonTestUtils.waitForSearchProviderStartup(ext1); + + Assert.equal( + Services.search.defaultEngine.name, + "MozParamsTest2", + "Should have switched the default engine." + ); + + assertEngineParameters({ + name: "MozParamsTest2", + searchURL: "https://example.com/2/?q={searchTerms}&simple2=5", + messageSnippet: "left unchanged", + }); + + let promiseDefaultBrowserChange = SearchTestUtils.promiseSearchNotification( + "engine-default", + "browser-search-engine-modified" + ); + await ext1.unload(); + await promiseDefaultBrowserChange; + + Assert.equal( + Services.search.defaultEngine.name, + "MozParamsTest", + "Should have reverted to the original default engine." + ); +}); + +add_task(async function test_extension_overriding_app_provided_default() { + const settings = await RemoteSettings(SearchUtils.SETTINGS_ALLOWLIST_KEY); + sinon.stub(settings, "get").returns([ + { + thirdPartyId: "test@thirdparty.example.com", + overridesId: "test2@search.mozilla.org", + urls: [ + { + search_url: "https://example.com/?q={searchTerms}&foo=myparams", + }, + ], + }, + ]); + + let ext1 = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { + id: "test@thirdparty.example.com", + }, + }, + icons: { + 16: "foo.ico", + }, + chrome_settings_overrides: { + search_provider: { + is_default: true, + name: "MozParamsTest2", + keyword: "MozSearch", + search_url: kSearchEngineURL, + suggest_url: kSuggestURL, + suggest_url_get_params: kSuggestURLParams, + }, + }, + }, + useAddonManager: "permanent", + }); + + info("startup"); + + await ext1.startup(); + await AddonTestUtils.waitForSearchProviderStartup(ext1); + + Assert.equal( + Services.search.defaultEngine.name, + "MozParamsTest2", + "Should have switched the default engine." + ); + assertEngineParameters({ + name: "MozParamsTest2", + searchURL: kSearchEngineURL, + suggestionURL: `${kSuggestURL}?${kSuggestURLParams}`, + messageSnippet: "changed", + }); + + info("disable"); + + let promiseDefaultBrowserChange = SearchTestUtils.promiseSearchNotification( + "engine-default", + "browser-search-engine-modified" + ); + await ext1.addon.disable(); + await promiseDefaultBrowserChange; + + Assert.equal( + Services.search.defaultEngine.name, + "MozParamsTest", + "Should have reverted to the original default engine." + ); + assertEngineParameters({ + name: "MozParamsTest2", + searchURL: "https://example.com/2/?q={searchTerms}&simple2=5", + messageSnippet: "reverted", + }); + + info("enable"); + + promiseDefaultBrowserChange = SearchTestUtils.promiseSearchNotification( + "engine-default", + "browser-search-engine-modified" + ); + await ext1.addon.enable(); + await promiseDefaultBrowserChange; + + Assert.equal( + Services.search.defaultEngine.name, + "MozParamsTest2", + "Should have switched the default engine." + ); + + assertEngineParameters({ + name: "MozParamsTest2", + searchURL: kSearchEngineURL, + suggestionURL: `${kSuggestURL}?${kSuggestURLParams}`, + messageSnippet: "changed", + }); + + info("unload"); + + promiseDefaultBrowserChange = SearchTestUtils.promiseSearchNotification( + "engine-default", + "browser-search-engine-modified" + ); + await ext1.unload(); + await promiseDefaultBrowserChange; + + Assert.equal( + Services.search.defaultEngine.name, + "MozParamsTest", + "Should have reverted to the original default engine." + ); + + assertEngineParameters({ + name: "MozParamsTest2", + searchURL: "https://example.com/2/?q={searchTerms}&simple2=5", + messageSnippet: "reverted", + }); + sinon.restore(); +}); diff --git a/browser/components/extensions/test/xpcshell/test_ext_settings_overrides_search.js b/browser/components/extensions/test/xpcshell/test_ext_settings_overrides_search.js new file mode 100644 index 0000000000..10fed4d36b --- /dev/null +++ b/browser/components/extensions/test/xpcshell/test_ext_settings_overrides_search.js @@ -0,0 +1,597 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ + +"use strict"; + +const { AddonTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/AddonTestUtils.sys.mjs" +); +const { setTimeout } = ChromeUtils.importESModule( + "resource://gre/modules/Timer.sys.mjs" +); + +let delay = () => new Promise(resolve => setTimeout(resolve, 0)); + +const kSearchFormURL = "https://example.com/searchform"; +const kSearchEngineURL = "https://example.com/?search={searchTerms}"; +const kSearchSuggestURL = "https://example.com/?suggest={searchTerms}"; +const kSearchTerm = "foo"; +const kSearchTermIntl = "日"; +const URLTYPE_SUGGEST_JSON = "application/x-suggestions+json"; + +AddonTestUtils.init(this); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "42", + "42" +); + +add_task(async function setup() { + await AddonTestUtils.promiseStartupManager(); + await Services.search.init(); +}); + +add_task(async function test_extension_adding_engine() { + let ext1 = ExtensionTestUtils.loadExtension({ + manifest: { + icons: { + 16: "foo.ico", + 32: "foo32.ico", + }, + chrome_settings_overrides: { + search_provider: { + name: "MozSearch", + keyword: "MozSearch", + search_form: kSearchFormURL, + search_url: kSearchEngineURL, + suggest_url: kSearchSuggestURL, + }, + }, + }, + useAddonManager: "temporary", + }); + + await ext1.startup(); + await AddonTestUtils.waitForSearchProviderStartup(ext1); + + let engine = Services.search.getEngineByName("MozSearch"); + ok(engine, "Engine should exist."); + + let { baseURI } = ext1.extension; + equal( + engine.getIconURL(), + baseURI.resolve("foo.ico"), + "16x16 icon path matches" + ); + equal( + engine.getIconURL(16), + baseURI.resolve("foo.ico"), + "16x16 icon path matches" + ); + // TODO: Bug 1871036 - Differently sized icons are currently incorrectly + // handled for add-ons. + // equal( + // engine.getIconURL(32), + // baseURI.resolve("foo32.ico"), + // "32x32 icon path matches" + // ); + + let expectedSuggestURL = kSearchSuggestURL.replace( + "{searchTerms}", + kSearchTerm + ); + let submissionSuggest = engine.getSubmission( + kSearchTerm, + URLTYPE_SUGGEST_JSON + ); + let encodedSubmissionURL = engine.getSubmission(kSearchTermIntl).uri.spec; + let testSubmissionURL = kSearchEngineURL.replace( + "{searchTerms}", + encodeURIComponent(kSearchTermIntl) + ); + equal( + encodedSubmissionURL, + testSubmissionURL, + "Encoded UTF-8 URLs should match" + ); + + equal( + submissionSuggest.uri.spec, + expectedSuggestURL, + "Suggest URLs should match" + ); + + equal(engine.searchForm, kSearchFormURL, "Search form URLs should match"); + await ext1.unload(); + await delay(); + + engine = Services.search.getEngineByName("MozSearch"); + ok(!engine, "Engine should not exist"); +}); + +add_task(async function test_extension_adding_engine_with_spaces() { + let ext1 = ExtensionTestUtils.loadExtension({ + manifest: { + chrome_settings_overrides: { + search_provider: { + name: "MozSearch ", + keyword: "MozSearch", + search_url: "https://example.com/?q={searchTerms}", + }, + }, + }, + useAddonManager: "temporary", + }); + + await ext1.startup(); + await AddonTestUtils.waitForSearchProviderStartup(ext1); + + let engine = Services.search.getEngineByName("MozSearch"); + ok(engine, "Engine should exist."); + + await ext1.unload(); + await delay(); + + engine = Services.search.getEngineByName("MozSearch"); + ok(!engine, "Engine should not exist"); +}); + +add_task(async function test_upgrade_default_position_engine() { + let ext1 = ExtensionTestUtils.loadExtension({ + manifest: { + chrome_settings_overrides: { + search_provider: { + name: "MozSearch", + keyword: "MozSearch", + search_url: "https://example.com/?q={searchTerms}", + }, + }, + browser_specific_settings: { + gecko: { + id: "testengine@mozilla.com", + }, + }, + version: "0.1", + }, + useAddonManager: "temporary", + }); + + await ext1.startup(); + await AddonTestUtils.waitForSearchProviderStartup(ext1); + + let engine = Services.search.getEngineByName("MozSearch"); + await Services.search.setDefault( + engine, + Ci.nsISearchService.CHANGE_REASON_UNKNOWN + ); + await Services.search.moveEngine(engine, 1); + + await ext1.upgrade({ + manifest: { + chrome_settings_overrides: { + search_provider: { + name: "MozSearch", + keyword: "MozSearch", + search_url: "https://example.com/?q={searchTerms}", + }, + }, + browser_specific_settings: { + gecko: { + id: "testengine@mozilla.com", + }, + }, + version: "0.2", + }, + useAddonManager: "temporary", + }); + await AddonTestUtils.waitForSearchProviderStartup(ext1); + + engine = Services.search.getEngineByName("MozSearch"); + equal( + Services.search.defaultEngine, + engine, + "Default engine should still be MozSearch" + ); + equal( + (await Services.search.getEngines()).map(e => e.name).indexOf(engine.name), + 1, + "Engine is in position 1" + ); + + await ext1.unload(); + await delay(); + + engine = Services.search.getEngineByName("MozSearch"); + ok(!engine, "Engine should not exist"); +}); + +add_task(async function test_extension_get_params() { + let ext1 = ExtensionTestUtils.loadExtension({ + manifest: { + chrome_settings_overrides: { + search_provider: { + name: "MozSearch", + keyword: "MozSearch", + search_url: kSearchEngineURL, + search_url_get_params: "foo=bar&bar=foo", + suggest_url: kSearchSuggestURL, + suggest_url_get_params: "foo=bar&bar=foo", + }, + }, + }, + useAddonManager: "temporary", + }); + + await ext1.startup(); + await AddonTestUtils.waitForSearchProviderStartup(ext1); + + let engine = Services.search.getEngineByName("MozSearch"); + ok(engine, "Engine should exist."); + + let url = engine.wrappedJSObject._getURLOfType("text/html"); + equal(url.method, "GET", "Search URLs method is GET"); + + let expectedURL = kSearchEngineURL.replace("{searchTerms}", kSearchTerm); + let submission = engine.getSubmission(kSearchTerm); + equal( + submission.uri.spec, + `${expectedURL}&foo=bar&bar=foo`, + "Search URLs should match" + ); + + let expectedSuggestURL = kSearchSuggestURL.replace( + "{searchTerms}", + kSearchTerm + ); + let submissionSuggest = engine.getSubmission( + kSearchTerm, + URLTYPE_SUGGEST_JSON + ); + equal( + submissionSuggest.uri.spec, + `${expectedSuggestURL}&foo=bar&bar=foo`, + "Suggest URLs should match" + ); + + await ext1.unload(); +}); + +add_task(async function test_extension_post_params() { + let ext1 = ExtensionTestUtils.loadExtension({ + manifest: { + chrome_settings_overrides: { + search_provider: { + name: "MozSearch", + keyword: "MozSearch", + search_url: kSearchEngineURL, + search_url_post_params: "foo=bar&bar=foo", + suggest_url: kSearchSuggestURL, + suggest_url_post_params: "foo=bar&bar=foo", + }, + }, + }, + useAddonManager: "temporary", + }); + + await ext1.startup(); + await AddonTestUtils.waitForSearchProviderStartup(ext1); + + let engine = Services.search.getEngineByName("MozSearch"); + ok(engine, "Engine should exist."); + + let url = engine.wrappedJSObject._getURLOfType("text/html"); + equal(url.method, "POST", "Search URLs method is POST"); + + let expectedURL = kSearchEngineURL.replace("{searchTerms}", kSearchTerm); + let submission = engine.getSubmission(kSearchTerm); + equal(submission.uri.spec, expectedURL, "Search URLs should match"); + // postData is a nsIMIMEInputStream which contains a nsIStringInputStream. + equal( + submission.postData.data.data, + "foo=bar&bar=foo", + "Search postData should match" + ); + + let expectedSuggestURL = kSearchSuggestURL.replace( + "{searchTerms}", + kSearchTerm + ); + let submissionSuggest = engine.getSubmission( + kSearchTerm, + URLTYPE_SUGGEST_JSON + ); + equal( + submissionSuggest.uri.spec, + expectedSuggestURL, + "Suggest URLs should match" + ); + equal( + submissionSuggest.postData.data.data, + "foo=bar&bar=foo", + "Suggest postData should match" + ); + + await ext1.unload(); +}); + +add_task(async function test_extension_no_query_params() { + const ext1 = ExtensionTestUtils.loadExtension({ + manifest: { + chrome_settings_overrides: { + search_provider: { + name: "MozSearch", + keyword: "MozSearch", + search_url: "https://example.com/{searchTerms}", + suggest_url: "https://example.com/suggest/{searchTerms}", + }, + }, + }, + useAddonManager: "temporary", + }); + + await ext1.startup(); + await AddonTestUtils.waitForSearchProviderStartup(ext1); + + let engine = Services.search.getEngineByName("MozSearch"); + ok(engine, "Engine should exist."); + + const encodedSubmissionURL = engine.getSubmission(kSearchTermIntl).uri.spec; + const testSubmissionURL = + "https://example.com/" + encodeURIComponent(kSearchTermIntl); + equal( + encodedSubmissionURL, + testSubmissionURL, + "Encoded UTF-8 URLs should match" + ); + + const expectedSuggestURL = "https://example.com/suggest/" + kSearchTerm; + let submissionSuggest = engine.getSubmission( + kSearchTerm, + URLTYPE_SUGGEST_JSON + ); + equal( + submissionSuggest.uri.spec, + expectedSuggestURL, + "Suggest URLs should match" + ); + + await ext1.unload(); + await delay(); + + engine = Services.search.getEngineByName("MozSearch"); + ok(!engine, "Engine should not exist"); +}); + +add_task(async function test_extension_empty_suggestUrl() { + let ext1 = ExtensionTestUtils.loadExtension({ + manifest: { + default_locale: "en", + chrome_settings_overrides: { + search_provider: { + name: "MozSearch", + keyword: "MozSearch", + search_url: kSearchEngineURL, + search_url_post_params: "foo=bar&bar=foo", + suggest_url: "__MSG_suggestUrl__", + suggest_url_get_params: "__MSG_suggestUrlGetParams__", + }, + }, + }, + useAddonManager: "temporary", + files: { + "_locales/en/messages.json": { + suggestUrl: { + message: "", + }, + suggestUrlGetParams: { + message: "", + }, + }, + }, + }); + + await ext1.startup(); + await AddonTestUtils.waitForSearchProviderStartup(ext1); + + let engine = Services.search.getEngineByName("MozSearch"); + ok(engine, "Engine should exist."); + + let url = engine.wrappedJSObject._getURLOfType("text/html"); + equal(url.method, "POST", "Search URLs method is POST"); + + let expectedURL = kSearchEngineURL.replace("{searchTerms}", kSearchTerm); + let submission = engine.getSubmission(kSearchTerm); + equal(submission.uri.spec, expectedURL, "Search URLs should match"); + // postData is a nsIMIMEInputStream which contains a nsIStringInputStream. + equal( + submission.postData.data.data, + "foo=bar&bar=foo", + "Search postData should match" + ); + + let submissionSuggest = engine.getSubmission( + kSearchTerm, + URLTYPE_SUGGEST_JSON + ); + ok(!submissionSuggest, "There should be no suggest URL."); + + await ext1.unload(); +}); + +add_task(async function test_extension_empty_suggestUrl_with_params() { + let ext1 = ExtensionTestUtils.loadExtension({ + manifest: { + default_locale: "en", + chrome_settings_overrides: { + search_provider: { + name: "MozSearch", + keyword: "MozSearch", + search_url: kSearchEngineURL, + search_url_post_params: "foo=bar&bar=foo", + suggest_url: "__MSG_suggestUrl__", + suggest_url_get_params: "__MSG_suggestUrlGetParams__", + }, + }, + }, + useAddonManager: "temporary", + files: { + "_locales/en/messages.json": { + suggestUrl: { + message: "", + }, + suggestUrlGetParams: { + message: "abc", + }, + }, + }, + }); + + await ext1.startup(); + await AddonTestUtils.waitForSearchProviderStartup(ext1); + + let engine = Services.search.getEngineByName("MozSearch"); + ok(engine, "Engine should exist."); + + let url = engine.wrappedJSObject._getURLOfType("text/html"); + equal(url.method, "POST", "Search URLs method is POST"); + + let expectedURL = kSearchEngineURL.replace("{searchTerms}", kSearchTerm); + let submission = engine.getSubmission(kSearchTerm); + equal(submission.uri.spec, expectedURL, "Search URLs should match"); + // postData is a nsIMIMEInputStream which contains a nsIStringInputStream. + equal( + submission.postData.data.data, + "foo=bar&bar=foo", + "Search postData should match" + ); + + let submissionSuggest = engine.getSubmission( + kSearchTerm, + URLTYPE_SUGGEST_JSON + ); + ok(!submissionSuggest, "There should be no suggest URL."); + + await ext1.unload(); +}); + +async function checkBadUrl(searchProviderKey, urlValue) { + let normalized = await ExtensionTestUtils.normalizeManifest({ + chrome_settings_overrides: { + search_provider: { + name: "MozSearch", + keyword: "MozSearch", + search_url: "https://example.com/", + [searchProviderKey]: urlValue, + }, + }, + }); + + ok( + /Error processing chrome_settings_overrides\.search_provider[^:]*: .* must match/.test( + normalized.error + ), + `Expected error for ${searchProviderKey}:${urlValue} "${normalized.error}"` + ); +} + +async function checkValidUrl(urlValue) { + let normalized = await ExtensionTestUtils.normalizeManifest({ + chrome_settings_overrides: { + search_provider: { + name: "MozSearch", + keyword: "MozSearch", + search_form: urlValue, + search_url: urlValue, + suggest_url: urlValue, + }, + }, + }); + equal(normalized.error, undefined, `Valid search_provider url: ${urlValue}`); +} + +add_task(async function test_extension_not_allow_http() { + await checkBadUrl("search_form", "http://example.com/{searchTerms}"); + await checkBadUrl("search_url", "http://example.com/{searchTerms}"); + await checkBadUrl("suggest_url", "http://example.com/{searchTerms}"); +}); + +add_task(async function test_manifest_disallows_http_localhost_prefix() { + await checkBadUrl("search_url", "http://localhost.example.com"); + await checkBadUrl("search_url", "http://localhost.example.com/"); + await checkBadUrl("search_url", "http://127.0.0.1.example.com/"); + await checkBadUrl("search_url", "http://localhost:1234@example.com/"); +}); + +add_task(async function test_manifest_allow_http_for_localhost() { + await checkValidUrl("http://localhost"); + await checkValidUrl("http://localhost/"); + await checkValidUrl("http://localhost:/"); + await checkValidUrl("http://localhost:1/"); + await checkValidUrl("http://localhost:65535/"); + + await checkValidUrl("http://127.0.0.1"); + await checkValidUrl("http://127.0.0.1:"); + await checkValidUrl("http://127.0.0.1:/"); + await checkValidUrl("http://127.0.0.1/"); + await checkValidUrl("http://127.0.0.1:80/"); + + await checkValidUrl("http://[::1]"); + await checkValidUrl("http://[::1]:"); + await checkValidUrl("http://[::1]:/"); + await checkValidUrl("http://[::1]/"); + await checkValidUrl("http://[::1]:80/"); +}); + +add_task(async function test_extension_allow_http_for_localhost() { + let ext1 = ExtensionTestUtils.loadExtension({ + manifest: { + chrome_settings_overrides: { + search_provider: { + name: "MozSearch", + keyword: "MozSearch", + search_url: "http://localhost/{searchTerms}", + suggest_url: "http://localhost/suggest/{searchTerms}", + }, + }, + }, + useAddonManager: "temporary", + }); + + await ext1.startup(); + await AddonTestUtils.waitForSearchProviderStartup(ext1); + + let engine = Services.search.getEngineByName("MozSearch"); + ok(engine, "Engine should exist."); + + await ext1.unload(); +}); + +add_task(async function test_search_favicon_mv3() { + Services.prefs.setBoolPref("extensions.manifestV3.enabled", true); + let normalized = await ExtensionTestUtils.normalizeManifest({ + manifest_version: 3, + chrome_settings_overrides: { + search_provider: { + name: "HTTP Icon in MV3", + search_url: "https://example.org/", + favicon_url: "https://example.org/icon.png", + }, + }, + }); + Assert.ok( + normalized.error.endsWith("must be a relative URL"), + "Should have an error" + ); + normalized = await ExtensionTestUtils.normalizeManifest({ + manifest_version: 3, + chrome_settings_overrides: { + search_provider: { + name: "HTTP Icon in MV3", + search_url: "https://example.org/", + favicon_url: "/icon.png", + }, + }, + }); + Assert.ok(!normalized.error, "Should not have an error"); +}); diff --git a/browser/components/extensions/test/xpcshell/test_ext_settings_overrides_search_mozParam.js b/browser/components/extensions/test/xpcshell/test_ext_settings_overrides_search_mozParam.js new file mode 100644 index 0000000000..3248c5cefa --- /dev/null +++ b/browser/components/extensions/test/xpcshell/test_ext_settings_overrides_search_mozParam.js @@ -0,0 +1,239 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ + +"use strict"; + +const { AddonTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/AddonTestUtils.sys.mjs" +); +const { SearchTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/SearchTestUtils.sys.mjs" +); +const { NimbusFeatures } = ChromeUtils.importESModule( + "resource://nimbus/ExperimentAPI.sys.mjs" +); +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "42", + "42" +); + +let { promiseShutdownManager, promiseStartupManager } = AddonTestUtils; + +// Note: these lists should be kept in sync with the lists in +// browser/components/extensions/test/xpcshell/data/test/manifest.json +// These params are conditional based on how search is initiated. +const mozParams = [ + { + name: "test-0", + condition: "purpose", + purpose: "contextmenu", + value: "0", + }, + { name: "test-1", condition: "purpose", purpose: "searchbar", value: "1" }, + { name: "test-2", condition: "purpose", purpose: "homepage", value: "2" }, + { name: "test-3", condition: "purpose", purpose: "keyword", value: "3" }, + { name: "test-4", condition: "purpose", purpose: "newtab", value: "4" }, +]; +// These params are always included. +const params = [ + { name: "simple", value: "5" }, + { name: "term", value: "{searchTerms}" }, + { name: "lang", value: "{language}" }, + { name: "locale", value: "{moz:locale}" }, + { name: "prefval", condition: "pref", pref: "code" }, +]; + +add_task(async function setup() { + let readyStub = sinon.stub(NimbusFeatures.search, "ready").resolves(); + let updateStub = sinon.stub(NimbusFeatures.search, "onUpdate"); + await promiseStartupManager(); + await SearchTestUtils.useTestEngines("data", null, [ + { + webExtension: { + id: "test@search.mozilla.org", + }, + appliesTo: [ + { + included: { everywhere: true }, + default: "yes", + }, + ], + }, + ]); + await Services.search.init(); + registerCleanupFunction(async () => { + await promiseShutdownManager(); + readyStub.restore(); + updateStub.restore(); + }); +}); + +/* This tests setting moz params. */ +add_task(async function test_extension_setting_moz_params() { + let defaultBranch = Services.prefs.getDefaultBranch("browser.search."); + defaultBranch.setCharPref("param.code", "good"); + + let engine = Services.search.getEngineByName("MozParamsTest"); + + let extraParams = []; + for (let p of params) { + if (p.condition == "pref") { + extraParams.push(`${p.name}=good`); + } else if (p.value == "{searchTerms}") { + extraParams.push(`${p.name}=test`); + } else if (p.value == "{language}") { + extraParams.push(`${p.name}=${Services.locale.requestedLocale || "*"}`); + } else if (p.value == "{moz:locale}") { + extraParams.push(`${p.name}=${Services.locale.requestedLocale}`); + } else { + extraParams.push(`${p.name}=${p.value}`); + } + } + let paramStr = extraParams.join("&"); + + for (let p of mozParams) { + let expectedURL = engine.getSubmission( + "test", + null, + p.condition == "purpose" ? p.purpose : null + ).uri.spec; + equal( + expectedURL, + `https://example.com/?q=test&${p.name}=${p.value}&${paramStr}`, + "search url is expected" + ); + } + + defaultBranch.setCharPref("param.code", ""); +}); + +add_task(async function test_nimbus_params() { + let sandbox = sinon.createSandbox(); + let stub = sandbox.stub(NimbusFeatures.search, "getVariable"); + // These values should match the nimbusParams below and the data/test/manifest.json + // search engine configuration + stub.withArgs("extraParams").returns([ + { + key: "nimbus-key-1", + value: "nimbus-value-1", + }, + { + key: "nimbus-key-2", + value: "nimbus-value-2", + }, + ]); + + Assert.ok( + NimbusFeatures.search.onUpdate.called, + "Called to initialize the cache" + ); + + // Populate the cache with the `getVariable` mock values + NimbusFeatures.search.onUpdate.firstCall.args[0](); + + let engine = Services.search.getEngineByName("MozParamsTest"); + + // Note: these lists should be kept in sync with the lists in + // browser/components/extensions/test/xpcshell/data/test/manifest.json + // These params are conditional based on how search is initiated. + const nimbusParams = [ + { name: "experimenter-1", condition: "pref", pref: "nimbus-key-1" }, + { name: "experimenter-2", condition: "pref", pref: "nimbus-key-2" }, + ]; + const experimentCache = { + "nimbus-key-1": "nimbus-value-1", + "nimbus-key-2": "nimbus-value-2", + }; + + let extraParams = []; + for (let p of params) { + if (p.value == "{searchTerms}") { + extraParams.push(`${p.name}=test`); + } else if (p.value == "{language}") { + extraParams.push(`${p.name}=${Services.locale.requestedLocale || "*"}`); + } else if (p.value == "{moz:locale}") { + extraParams.push(`${p.name}=${Services.locale.requestedLocale}`); + } else if (p.condition !== "pref") { + // Ignoring pref parameters + extraParams.push(`${p.name}=${p.value}`); + } + } + for (let p of nimbusParams) { + if (p.condition == "pref") { + extraParams.push(`${p.name}=${experimentCache[p.pref]}`); + } + } + let paramStr = extraParams.join("&"); + for (let p of mozParams) { + let expectedURL = engine.getSubmission( + "test", + null, + p.condition == "purpose" ? p.purpose : null + ).uri.spec; + equal( + expectedURL, + `https://example.com/?q=test&${p.name}=${p.value}&${paramStr}`, + "search url is expected" + ); + } + + sandbox.restore(); +}); + +add_task(async function test_extension_setting_moz_params_fail() { + // Ensure that the test infra does not automatically make + // this privileged. + AddonTestUtils.usePrivilegedSignatures = false; + Services.prefs.setCharPref( + "extensions.installedDistroAddon.test@mochitest", + "" + ); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { id: "test1@mochitest" }, + }, + chrome_settings_overrides: { + search_provider: { + name: "MozParamsTest1", + search_url: "https://example.com/", + params: [ + { + name: "testParam", + condition: "purpose", + purpose: "contextmenu", + value: "0", + }, + { name: "prefval", condition: "pref", pref: "code" }, + { name: "q", value: "{searchTerms}" }, + ], + }, + }, + }, + useAddonManager: "permanent", + }); + await extension.startup(); + await AddonTestUtils.waitForSearchProviderStartup(extension); + equal( + extension.extension.isPrivileged, + false, + "extension is not priviledged" + ); + let engine = Services.search.getEngineByName("MozParamsTest1"); + let expectedURL = engine.getSubmission("test", null, "contextmenu").uri.spec; + equal( + expectedURL, + "https://example.com/?q=test", + "engine cannot have conditional or pref params" + ); + await extension.unload(); +}); diff --git a/browser/components/extensions/test/xpcshell/test_ext_settings_overrides_shutdown.js b/browser/components/extensions/test/xpcshell/test_ext_settings_overrides_shutdown.js new file mode 100644 index 0000000000..851efd6b2a --- /dev/null +++ b/browser/components/extensions/test/xpcshell/test_ext_settings_overrides_shutdown.js @@ -0,0 +1,109 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ + +"use strict"; + +const { AddonTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/AddonTestUtils.sys.mjs" +); +// Lazily import ExtensionParent to allow AddonTestUtils.createAppInfo to +// override Services.appinfo. +ChromeUtils.defineESModuleGetters(this, { + ExtensionParent: "resource://gre/modules/ExtensionParent.sys.mjs", +}); + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "42", + "42" +); + +add_task(async function shutdown_during_search_provider_startup() { + await AddonTestUtils.promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + chrome_settings_overrides: { + search_provider: { + is_default: true, + name: "dummy name", + search_url: "https://example.com/", + }, + }, + }, + }); + + info("Starting up search extension"); + await extension.startup(); + let extStartPromise = AddonTestUtils.waitForSearchProviderStartup(extension, { + // Search provider registration is expected to be pending because the search + // service has not been initialized yet. + expectPending: true, + }); + + let initialized = false; + Services.search.promiseInitialized.then(() => { + initialized = true; + }); + + await extension.addon.disable(); + + info("Extension managed to shut down despite the uninitialized search"); + // Initialize search after extension shutdown to check that it does not cause + // any problems, and that the test can continue to test uninstall behavior. + Assert.ok(!initialized, "Search service should not have been initialized"); + + extension.addon.enable(); + await extension.awaitStartup(); + + // Check that uninstall is blocked until the search registration at startup + // has finished. This registration only finished once the search service is + // initialized. + let uninstallingPromise = new Promise(resolve => { + let Management = ExtensionParent.apiManager; + Management.on("uninstall", function listener(eventName, { id }) { + Management.off("uninstall", listener); + Assert.equal(id, extension.id, "Expected extension"); + resolve(); + }); + }); + + let extRestartPromise = AddonTestUtils.waitForSearchProviderStartup( + extension, + { + // Search provider registration is expected to be pending again, + // because the search service has still not been initialized yet. + expectPending: true, + } + ); + + let uninstalledPromise = extension.addon.uninstall(); + let uninstalled = false; + uninstalledPromise.then(() => { + uninstalled = true; + }); + + await uninstallingPromise; + Assert.ok(!uninstalled, "Uninstall should not be finished yet"); + Assert.ok(!initialized, "Search service should still be uninitialized"); + await Services.search.init(); + Assert.ok(initialized, "Search service should be initialized"); + + // After initializing the search service, the search provider registration + // promises should settle eventually. + + // Despite the interrupted startup, the promise should still resolve without + // an error. + await extStartPromise; + // The extension that is still active. The promise should just resolve. + await extRestartPromise; + + // After initializing the search service, uninstall should eventually finish. + await uninstalledPromise; + + await AddonTestUtils.promiseShutdownManager(); +}); diff --git a/browser/components/extensions/test/xpcshell/test_ext_settings_validate.js b/browser/components/extensions/test/xpcshell/test_ext_settings_validate.js new file mode 100644 index 0000000000..2f0d36f6e8 --- /dev/null +++ b/browser/components/extensions/test/xpcshell/test_ext_settings_validate.js @@ -0,0 +1,193 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { AddonTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/AddonTestUtils.sys.mjs" +); + +const { AddonManager } = ChromeUtils.importESModule( + "resource://gre/modules/AddonManager.sys.mjs" +); + +const { AboutNewTab } = ChromeUtils.importESModule( + "resource:///modules/AboutNewTab.sys.mjs" +); + +// Lazy load to avoid having Services.appinfo cached first. +ChromeUtils.defineESModuleGetters(this, { + ExtensionParent: "resource://gre/modules/ExtensionParent.sys.mjs", +}); + +const { HomePage } = ChromeUtils.importESModule( + "resource:///modules/HomePage.sys.mjs" +); + +AddonTestUtils.init(this); + +// Allow for unsigned addons. +AddonTestUtils.overrideCertDB(); + +AddonTestUtils.createAppInfo( + "xpcshell@tests.mozilla.org", + "XPCShell", + "42", + "42" +); + +add_task(async function test_settings_modules_not_loaded() { + await ExtensionParent.apiManager.lazyInit(); + // Test that no settings modules are loaded. + let modules = Array.from(ExtensionParent.apiManager.settingsModules); + ok(modules.length, "we have settings modules"); + for (let name of modules) { + ok( + !ExtensionParent.apiManager.getModule(name).loaded, + `${name} is not loaded` + ); + } +}); + +add_task(async function test_settings_validated() { + let defaultNewTab = AboutNewTab.newTabURL; + equal(defaultNewTab, "about:newtab", "Newtab url is default."); + let defaultHomepageURL = HomePage.get(); + equal(defaultHomepageURL, "about:home", "Home page url is default."); + + let xpi = await AddonTestUtils.createTempWebExtensionFile({ + manifest: { + version: "1.0", + browser_specific_settings: { gecko: { id: "test@mochi" } }, + chrome_url_overrides: { + newtab: "/newtab", + }, + chrome_settings_overrides: { + homepage: "https://example.com/", + }, + }, + }); + let extension = ExtensionTestUtils.expectExtension("test@mochi"); + let file = await AddonTestUtils.manuallyInstall(xpi); + await AddonTestUtils.promiseStartupManager(); + await extension.awaitStartup(); + + equal( + HomePage.get(), + "https://example.com/", + "Home page url is extension controlled." + ); + ok( + AboutNewTab.newTabURL.endsWith("/newtab"), + "newTabURL is extension controlled." + ); + + await AddonTestUtils.promiseShutdownManager(); + // After shutdown, delete the xpi file. + Services.obs.notifyObservers(xpi, "flush-cache-entry"); + try { + file.remove(true); + } catch (e) { + ok(false, e); + } + await AddonTestUtils.cleanupTempXPIs(); + + // Restart everything, the ExtensionAddonObserver should handle updating state. + let prefChanged = TestUtils.waitForPrefChange("browser.startup.homepage"); + await AddonTestUtils.promiseStartupManager(); + await prefChanged; + + equal(HomePage.get(), defaultHomepageURL, "Home page url is default."); + equal(AboutNewTab.newTabURL, defaultNewTab, "newTabURL is reset to default."); + await AddonTestUtils.promiseShutdownManager(); +}); + +add_task(async function test_settings_validated_safemode() { + let defaultNewTab = AboutNewTab.newTabURL; + equal(defaultNewTab, "about:newtab", "Newtab url is default."); + let defaultHomepageURL = HomePage.get(); + equal(defaultHomepageURL, "about:home", "Home page url is default."); + + function isDefaultSettings(postfix) { + equal( + HomePage.get(), + defaultHomepageURL, + `Home page url is default ${postfix}.` + ); + equal( + AboutNewTab.newTabURL, + defaultNewTab, + `newTabURL is default ${postfix}.` + ); + } + + function isExtensionSettings(postfix) { + equal( + HomePage.get(), + "https://example.com/", + `Home page url is extension controlled ${postfix}.` + ); + ok( + AboutNewTab.newTabURL.endsWith("/newtab"), + `newTabURL is extension controlled ${postfix}.` + ); + } + + async function switchSafeMode(inSafeMode) { + await AddonTestUtils.promiseShutdownManager(); + AddonTestUtils.appInfo.inSafeMode = inSafeMode; + await AddonTestUtils.promiseStartupManager(); + return AddonManager.getAddonByID("test@mochi"); + } + + let xpi = await AddonTestUtils.createTempWebExtensionFile({ + manifest: { + version: "1.0", + browser_specific_settings: { gecko: { id: "test@mochi" } }, + chrome_url_overrides: { + newtab: "/newtab", + }, + chrome_settings_overrides: { + homepage: "https://example.com/", + }, + }, + }); + let extension = ExtensionTestUtils.expectExtension("test@mochi"); + await AddonTestUtils.manuallyInstall(xpi); + await AddonTestUtils.promiseStartupManager(); + await extension.awaitStartup(); + + isExtensionSettings("on extension startup"); + + // Disable in safemode and verify settings are removed in normal mode. + let addon = await switchSafeMode(true); + await addon.disable(); + addon = await switchSafeMode(false); + isDefaultSettings("after disabling addon during safemode"); + + // Enable in safemode and verify settings are back in normal mode. + addon = await switchSafeMode(true); + await addon.enable(); + addon = await switchSafeMode(false); + isExtensionSettings("after enabling addon during safemode"); + + // Uninstall in safemode and verify settings are removed in normal mode. + addon = await switchSafeMode(true); + await addon.uninstall(); + addon = await switchSafeMode(false); + isDefaultSettings("after uninstalling addon during safemode"); + + await AddonTestUtils.promiseShutdownManager(); + await AddonTestUtils.cleanupTempXPIs(); +}); + +// There are more settings modules than used in this test file, they should have been +// loaded during the test extensions uninstall. Ensure that all settings modules have +// been loaded. +add_task(async function test_settings_modules_loaded() { + // Test that all settings modules are loaded. + let modules = Array.from(ExtensionParent.apiManager.settingsModules); + ok(modules.length, "we have settings modules"); + for (let name of modules) { + ok(ExtensionParent.apiManager.getModule(name).loaded, `${name} was loaded`); + } +}); diff --git a/browser/components/extensions/test/xpcshell/test_ext_topSites.js b/browser/components/extensions/test/xpcshell/test_ext_topSites.js new file mode 100644 index 0000000000..8064ade1e8 --- /dev/null +++ b/browser/components/extensions/test/xpcshell/test_ext_topSites.js @@ -0,0 +1,293 @@ +"use strict"; + +const { PlacesUtils } = ChromeUtils.importESModule( + "resource://gre/modules/PlacesUtils.sys.mjs" +); +const { NewTabUtils } = ChromeUtils.importESModule( + "resource://gre/modules/NewTabUtils.sys.mjs" +); +const { PlacesTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PlacesTestUtils.sys.mjs" +); + +const SEARCH_SHORTCUTS_EXPERIMENT_PREF = + "browser.newtabpage.activity-stream.improvesearch.topSiteSearchShortcuts"; + +// A small 1x1 test png +const IMAGE_1x1 = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAACklEQVQI12NgAAAAAgAB4iG8MwAAAABJRU5ErkJggg=="; + +add_task(async function test_topSites() { + Services.prefs.setBoolPref(SEARCH_SHORTCUTS_EXPERIMENT_PREF, false); + let visits = []; + const numVisits = 15; // To make sure we get frecency. + let visitDate = new Date(1999, 9, 9, 9, 9).getTime(); + + async function setVisit(visit) { + for (let j = 0; j < numVisits; ++j) { + visitDate -= 1000; + visit.visits.push({ date: new Date(visitDate) }); + } + visits.push(visit); + await PlacesUtils.history.insert(visit); + } + // Stick a couple sites into history. + for (let i = 0; i < 2; ++i) { + await setVisit({ + url: `http://example${i}.com/`, + title: `visit${i}`, + visits: [], + }); + await setVisit({ + url: `http://www.example${i}.com/foobar`, + title: `visit${i}-www`, + visits: [], + }); + } + NewTabUtils.init(); + + // Insert a favicon to show that favicons are not returned by default. + let faviconData = new Map(); + faviconData.set("http://example0.com", IMAGE_1x1); + await PlacesTestUtils.addFavicons(faviconData); + + // Ensure our links show up in activityStream. + let links = await NewTabUtils.activityStreamLinks.getTopSites({ + onePerDomain: false, + topsiteFrecency: 1, + }); + + equal( + links.length, + visits.length, + "Top sites has been successfully initialized" + ); + + // Drop the visits.visits for later testing. + visits = visits.map(v => { + return { url: v.url, title: v.title, favicon: undefined, type: "url" }; + }); + + // Test that results from all providers are returned by default. + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["topSites"], + }, + background() { + browser.test.onMessage.addListener(async options => { + let sites = await browser.topSites.get(options); + browser.test.sendMessage("sites", sites); + }); + }, + }); + + await extension.startup(); + + function getSites(options) { + extension.sendMessage(options); + return extension.awaitMessage("sites"); + } + + Assert.deepEqual( + [visits[0], visits[2]], + await getSites(), + "got topSites default" + ); + Assert.deepEqual( + visits, + await getSites({ onePerDomain: false }), + "got topSites all links" + ); + + NewTabUtils.activityStreamLinks.blockURL(visits[0]); + ok( + NewTabUtils.blockedLinks.isBlocked(visits[0]), + `link ${visits[0].url} is blocked` + ); + + Assert.deepEqual( + [visits[2], visits[1]], + await getSites(), + "got topSites with blocked links filtered out" + ); + Assert.deepEqual( + [visits[0], visits[2]], + await getSites({ includeBlocked: true }), + "got topSites with blocked links included" + ); + + // Test favicon result + let topSites = await getSites({ includeBlocked: true, includeFavicon: true }); + equal(topSites[0].favicon, IMAGE_1x1, "received favicon"); + + equal( + 1, + (await getSites({ limit: 1, includeBlocked: true })).length, + "limit 1 topSite" + ); + + NewTabUtils.uninit(); + await extension.unload(); + await PlacesUtils.history.clear(); + Services.prefs.clearUserPref(SEARCH_SHORTCUTS_EXPERIMENT_PREF); +}); + +// Test pinned likns and search shortcuts. +add_task(async function test_topSites_complete() { + Services.prefs.setBoolPref(SEARCH_SHORTCUTS_EXPERIMENT_PREF, true); + NewTabUtils.init(); + let time = new Date(); + let pinnedIndex = 0; + let entries = [ + { + url: `http://pinned1.com/`, + title: "pinned1", + type: "url", + pinned: pinnedIndex++, + visitDate: time, + }, + { + url: `http://search1.com/`, + title: "@search1", + type: "search", + pinned: pinnedIndex++, + visitDate: new Date(--time), + }, + { + url: `https://amazon.com`, + title: "@amazon", + type: "search", + visitDate: new Date(--time), + }, + { + url: `http://history1.com/`, + title: "history1", + type: "url", + visitDate: new Date(--time), + }, + { + url: `http://history2.com/`, + title: "history2", + type: "url", + visitDate: new Date(--time), + }, + { + url: `https://blocked1.com/`, + title: "blocked1", + type: "blocked", + visitDate: new Date(--time), + }, + ]; + + for (let entry of entries) { + // Build up frecency. + await PlacesUtils.history.insert({ + url: entry.url, + title: entry.title, + visits: new Array(15).fill({ + date: entry.visitDate, + transition: PlacesUtils.history.TRANSITIONS.LINK, + }), + }); + // Insert a favicon to show that favicons are not returned by default. + await PlacesTestUtils.addFavicons(new Map([[entry.url, IMAGE_1x1]])); + if (entry.pinned !== undefined) { + let info = + entry.type == "search" + ? { url: entry.url, label: entry.title, searchTopSite: true } + : { url: entry.url, title: entry.title }; + NewTabUtils.pinnedLinks.pin(info, entry.pinned); + } + if (entry.type == "blocked") { + NewTabUtils.activityStreamLinks.blockURL({ url: entry.url }); + } + } + + // Some transformation is necessary to match output data. + let expectedResults = entries + .filter(e => e.type != "blocked") + .map(e => { + e.favicon = undefined; + delete e.visitDate; + delete e.pinned; + return e; + }); + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + permissions: ["topSites"], + }, + background() { + browser.test.onMessage.addListener(async options => { + let sites = await browser.topSites.get(options); + browser.test.sendMessage("sites", sites); + }); + }, + }); + + await extension.startup(); + + // Test that results are returned by the API. + function getSites(options) { + extension.sendMessage(options); + return extension.awaitMessage("sites"); + } + + Assert.deepEqual( + expectedResults, + await getSites({ includePinned: true, includeSearchShortcuts: true }), + "got topSites all links" + ); + + // Test no shortcuts. + dump(JSON.stringify(await getSites({ includePinned: true })) + "\n"); + Assert.ok( + !(await getSites({ includePinned: true })).some( + link => link.type == "search" + ), + "should get no shortcuts" + ); + + // Test favicons. + let topSites = await getSites({ + includePinned: true, + includeSearchShortcuts: true, + includeFavicon: true, + }); + Assert.ok( + topSites.every(f => f.favicon == IMAGE_1x1), + "favicon is correct" + ); + + // Test options.limit. + Assert.equal( + 1, + ( + await getSites({ + includePinned: true, + includeSearchShortcuts: true, + limit: 1, + }) + ).length, + "limit to 1 topSite" + ); + + // Clear history for a pinned entry, then check results. + await PlacesUtils.history.remove("http://pinned1.com/"); + let links = await getSites({ includePinned: true }); + Assert.ok( + links.find(link => link.url == "http://pinned1.com/"), + "Check unvisited pinned links are returned." + ); + links = await getSites(); + Assert.ok( + !links.find(link => link.url == "http://pinned1.com/"), + "Check unvisited pinned links are not returned." + ); + + await extension.unload(); + NewTabUtils.uninit(); + await PlacesUtils.history.clear(); + await PlacesUtils.bookmarks.eraseEverything(); + Services.prefs.clearUserPref(SEARCH_SHORTCUTS_EXPERIMENT_PREF); +}); diff --git a/browser/components/extensions/test/xpcshell/test_ext_url_overrides_newtab.js b/browser/components/extensions/test/xpcshell/test_ext_url_overrides_newtab.js new file mode 100644 index 0000000000..9ea6c4eea6 --- /dev/null +++ b/browser/components/extensions/test/xpcshell/test_ext_url_overrides_newtab.js @@ -0,0 +1,340 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +ChromeUtils.defineESModuleGetters(this, { + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", + Management: "resource://gre/modules/Extension.sys.mjs", +}); + +const { AboutNewTab } = ChromeUtils.importESModule( + "resource:///modules/AboutNewTab.sys.mjs" +); + +const { AddonTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/AddonTestUtils.sys.mjs" +); + +const { + createAppInfo, + promiseRestartManager, + promiseShutdownManager, + promiseStartupManager, +} = AddonTestUtils; + +AddonTestUtils.init(this); +AddonTestUtils.overrideCertDB(); + +createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "42", "42"); + +function awaitEvent(eventName) { + return new Promise(resolve => { + Management.once(eventName, (e, ...args) => resolve(...args)); + }); +} + +const DEFAULT_NEW_TAB_URL = AboutNewTab.newTabURL; + +add_task(async function test_multiple_extensions_overriding_newtab_page() { + const NEWTAB_URI_2 = "webext-newtab-1.html"; + const NEWTAB_URI_3 = "webext-newtab-2.html"; + const EXT_2_ID = "ext2@tests.mozilla.org"; + const EXT_3_ID = "ext3@tests.mozilla.org"; + + const CONTROLLED_BY_THIS = "controlled_by_this_extension"; + const CONTROLLED_BY_OTHER = "controlled_by_other_extensions"; + const NOT_CONTROLLABLE = "not_controllable"; + + const NEW_TAB_PRIVATE_ALLOWED = "browser.newtab.privateAllowed"; + const NEW_TAB_EXTENSION_CONTROLLED = "browser.newtab.extensionControlled"; + + function background() { + browser.test.onMessage.addListener(async msg => { + switch (msg) { + case "checkNewTabPage": + let newTabPage = await browser.browserSettings.newTabPageOverride.get( + {} + ); + browser.test.sendMessage("newTabPage", newTabPage); + break; + case "trySet": + let setResult = await browser.browserSettings.newTabPageOverride.set({ + value: "foo", + }); + browser.test.assertFalse( + setResult, + "Calling newTabPageOverride.set returns false." + ); + browser.test.sendMessage("newTabPageSet"); + break; + case "tryClear": + let clearResult = + await browser.browserSettings.newTabPageOverride.clear({}); + browser.test.assertFalse( + clearResult, + "Calling newTabPageOverride.clear returns false." + ); + browser.test.sendMessage("newTabPageCleared"); + break; + } + }); + } + + async function checkNewTabPageOverride( + ext, + expectedValue, + expectedLevelOfControl + ) { + ext.sendMessage("checkNewTabPage"); + let newTabPage = await ext.awaitMessage("newTabPage"); + + ok( + newTabPage.value.endsWith(expectedValue), + `newTabPageOverride setting returns the expected value ending with: ${expectedValue}.` + ); + equal( + newTabPage.levelOfControl, + expectedLevelOfControl, + `newTabPageOverride setting returns the expected levelOfControl: ${expectedLevelOfControl}.` + ); + } + + function verifyNewTabSettings(ext, expectedLevelOfControl) { + if (expectedLevelOfControl !== NOT_CONTROLLABLE) { + // Verify the preferences are set as expected. + let policy = WebExtensionPolicy.getByID(ext.id); + equal( + policy && policy.privateBrowsingAllowed, + Services.prefs.getBoolPref(NEW_TAB_PRIVATE_ALLOWED), + "private browsing flag set correctly" + ); + ok( + Services.prefs.getBoolPref(NEW_TAB_EXTENSION_CONTROLLED), + `extension controlled flag set correctly` + ); + } else { + ok( + !Services.prefs.prefHasUserValue(NEW_TAB_PRIVATE_ALLOWED), + "controlled flag reset" + ); + ok( + !Services.prefs.prefHasUserValue(NEW_TAB_EXTENSION_CONTROLLED), + "controlled flag reset" + ); + } + } + + let extObj = { + manifest: { + chrome_url_overrides: {}, + permissions: ["browserSettings"], + }, + useAddonManager: "temporary", + background, + }; + + let ext1 = ExtensionTestUtils.loadExtension(extObj); + + extObj.manifest.chrome_url_overrides = { newtab: NEWTAB_URI_2 }; + extObj.manifest.browser_specific_settings = { gecko: { id: EXT_2_ID } }; + let ext2 = ExtensionTestUtils.loadExtension(extObj); + + extObj.manifest.chrome_url_overrides = { newtab: NEWTAB_URI_3 }; + extObj.manifest.browser_specific_settings.gecko.id = EXT_3_ID; + extObj.incognitoOverride = "spanning"; + let ext3 = ExtensionTestUtils.loadExtension(extObj); + + equal( + AboutNewTab.newTabURL, + DEFAULT_NEW_TAB_URL, + "newTabURL is set to the default." + ); + + await promiseStartupManager(); + + await ext1.startup(); + equal( + AboutNewTab.newTabURL, + DEFAULT_NEW_TAB_URL, + "newTabURL is still set to the default." + ); + + await checkNewTabPageOverride(ext1, AboutNewTab.newTabURL, NOT_CONTROLLABLE); + verifyNewTabSettings(ext1, NOT_CONTROLLABLE); + + await ext2.startup(); + ok( + AboutNewTab.newTabURL.endsWith(NEWTAB_URI_2), + "newTabURL is overridden by the second extension." + ); + await checkNewTabPageOverride(ext1, NEWTAB_URI_2, CONTROLLED_BY_OTHER); + verifyNewTabSettings(ext2, CONTROLLED_BY_THIS); + + // Verify that calling set and clear do nothing. + ext2.sendMessage("trySet"); + await ext2.awaitMessage("newTabPageSet"); + await checkNewTabPageOverride(ext1, NEWTAB_URI_2, CONTROLLED_BY_OTHER); + verifyNewTabSettings(ext2, CONTROLLED_BY_THIS); + + ext2.sendMessage("tryClear"); + await ext2.awaitMessage("newTabPageCleared"); + await checkNewTabPageOverride(ext1, NEWTAB_URI_2, CONTROLLED_BY_OTHER); + verifyNewTabSettings(ext2, CONTROLLED_BY_THIS); + + // Disable the second extension. + let addon = await AddonManager.getAddonByID(EXT_2_ID); + let disabledPromise = awaitEvent("shutdown"); + await addon.disable(); + await disabledPromise; + equal( + AboutNewTab.newTabURL, + DEFAULT_NEW_TAB_URL, + "newTabURL url is reset to the default after second extension is disabled." + ); + await checkNewTabPageOverride(ext1, AboutNewTab.newTabURL, NOT_CONTROLLABLE); + verifyNewTabSettings(ext1, NOT_CONTROLLABLE); + + // Re-enable the second extension. + let enabledPromise = awaitEvent("ready"); + await addon.enable(); + await enabledPromise; + ok( + AboutNewTab.newTabURL.endsWith(NEWTAB_URI_2), + "newTabURL is overridden by the second extension." + ); + await checkNewTabPageOverride(ext2, NEWTAB_URI_2, CONTROLLED_BY_THIS); + verifyNewTabSettings(ext2, CONTROLLED_BY_THIS); + + await ext1.unload(); + ok( + AboutNewTab.newTabURL.endsWith(NEWTAB_URI_2), + "newTabURL is still overridden by the second extension." + ); + await checkNewTabPageOverride(ext2, NEWTAB_URI_2, CONTROLLED_BY_THIS); + verifyNewTabSettings(ext2, CONTROLLED_BY_THIS); + + await ext3.startup(); + ok( + AboutNewTab.newTabURL.endsWith(NEWTAB_URI_3), + "newTabURL is overridden by the third extension." + ); + await checkNewTabPageOverride(ext2, NEWTAB_URI_3, CONTROLLED_BY_OTHER); + verifyNewTabSettings(ext3, CONTROLLED_BY_THIS); + + // Disable the second extension. + disabledPromise = awaitEvent("shutdown"); + await addon.disable(); + await disabledPromise; + ok( + AboutNewTab.newTabURL.endsWith(NEWTAB_URI_3), + "newTabURL is still overridden by the third extension." + ); + await checkNewTabPageOverride(ext3, NEWTAB_URI_3, CONTROLLED_BY_THIS); + verifyNewTabSettings(ext3, CONTROLLED_BY_THIS); + + // Re-enable the second extension. + enabledPromise = awaitEvent("ready"); + await addon.enable(); + await enabledPromise; + ok( + AboutNewTab.newTabURL.endsWith(NEWTAB_URI_3), + "newTabURL is still overridden by the third extension." + ); + await checkNewTabPageOverride(ext3, NEWTAB_URI_3, CONTROLLED_BY_THIS); + verifyNewTabSettings(ext3, CONTROLLED_BY_THIS); + + await ext3.unload(); + ok( + AboutNewTab.newTabURL.endsWith(NEWTAB_URI_2), + "newTabURL reverts to being overridden by the second extension." + ); + await checkNewTabPageOverride(ext2, NEWTAB_URI_2, CONTROLLED_BY_THIS); + verifyNewTabSettings(ext2, CONTROLLED_BY_THIS); + + await ext2.unload(); + equal( + AboutNewTab.newTabURL, + DEFAULT_NEW_TAB_URL, + "newTabURL url is reset to the default." + ); + ok( + !Services.prefs.prefHasUserValue(NEW_TAB_PRIVATE_ALLOWED), + "controlled flag reset" + ); + ok( + !Services.prefs.prefHasUserValue(NEW_TAB_EXTENSION_CONTROLLED), + "controlled flag reset" + ); + + await promiseShutdownManager(); +}); + +// Tests that we handle the upgrade/downgrade process correctly +// when an extension is installed temporarily on top of a permanently +// installed one. +add_task(async function test_temporary_installation() { + const ID = "newtab@tests.mozilla.org"; + const PAGE1 = "page1.html"; + const PAGE2 = "page2.html"; + + equal( + AboutNewTab.newTabURL, + DEFAULT_NEW_TAB_URL, + "newTabURL is set to the default." + ); + + await promiseStartupManager(); + + let permanent = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { id: ID }, + }, + chrome_url_overrides: { + newtab: PAGE1, + }, + }, + useAddonManager: "permanent", + }); + + await permanent.startup(); + ok( + AboutNewTab.newTabURL.endsWith(PAGE1), + "newTabURL is overridden by permanent extension." + ); + + let temporary = ExtensionTestUtils.loadExtension({ + manifest: { + browser_specific_settings: { + gecko: { id: ID }, + }, + chrome_url_overrides: { + newtab: PAGE2, + }, + }, + useAddonManager: "temporary", + }); + + await temporary.startup(); + ok( + AboutNewTab.newTabURL.endsWith(PAGE2), + "newTabURL is overridden by temporary extension." + ); + + await promiseRestartManager(); + await permanent.awaitStartup(); + + ok( + AboutNewTab.newTabURL.endsWith(PAGE1), + "newTabURL is back to the value set by permanent extension." + ); + + await permanent.unload(); + + equal( + AboutNewTab.newTabURL, + DEFAULT_NEW_TAB_URL, + "newTabURL is set back to the default." + ); + await promiseShutdownManager(); +}); diff --git a/browser/components/extensions/test/xpcshell/test_ext_url_overrides_newtab_update.js b/browser/components/extensions/test/xpcshell/test_ext_url_overrides_newtab_update.js new file mode 100644 index 0000000000..17ee81e5ef --- /dev/null +++ b/browser/components/extensions/test/xpcshell/test_ext_url_overrides_newtab_update.js @@ -0,0 +1,127 @@ +/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set sts=2 sw=2 et tw=80: */ +"use strict"; + +const { AboutNewTab } = ChromeUtils.importESModule( + "resource:///modules/AboutNewTab.sys.mjs" +); + +const { AddonTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/AddonTestUtils.sys.mjs" +); + +const { + createAppInfo, + createTempWebExtensionFile, + promiseCompleteAllInstalls, + promiseFindAddonUpdates, + promiseShutdownManager, + promiseStartupManager, +} = AddonTestUtils; + +AddonTestUtils.init(this); + +// Allow for unsigned addons. +AddonTestUtils.overrideCertDB(); + +createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "42", "42"); + +add_task(async function test_url_overrides_newtab_update() { + const EXTENSION_ID = "test_url_overrides_update@tests.mozilla.org"; + const NEWTAB_URI = "webext-newtab-1.html"; + const PREF_EM_CHECK_UPDATE_SECURITY = "extensions.checkUpdateSecurity"; + + const testServer = createHttpServer(); + const port = testServer.identity.primaryPort; + + // The test extension uses an insecure update url. + Services.prefs.setBoolPref(PREF_EM_CHECK_UPDATE_SECURITY, false); + + testServer.registerPathHandler("/test_update.json", (request, response) => { + response.write(`{ + "addons": { + "${EXTENSION_ID}": { + "updates": [ + { + "version": "2.0", + "update_link": "http://localhost:${port}/addons/test_url_overrides-2.0.xpi" + } + ] + } + } + }`); + }); + + let webExtensionFile = createTempWebExtensionFile({ + manifest: { + version: "2.0", + browser_specific_settings: { + gecko: { + id: EXTENSION_ID, + }, + }, + }, + }); + + testServer.registerFile( + "/addons/test_url_overrides-2.0.xpi", + webExtensionFile + ); + + await promiseStartupManager(); + + let extension = ExtensionTestUtils.loadExtension({ + useAddonManager: "permanent", + manifest: { + version: "1.0", + browser_specific_settings: { + gecko: { + id: EXTENSION_ID, + update_url: `http://localhost:${port}/test_update.json`, + }, + }, + chrome_url_overrides: { newtab: NEWTAB_URI }, + }, + }); + + let defaultNewTabURL = AboutNewTab.newTabURL; + equal( + AboutNewTab.newTabURL, + defaultNewTabURL, + `Default newtab url is ${defaultNewTabURL}.` + ); + + await extension.startup(); + + equal( + extension.version, + "1.0", + "The installed addon has the expected version." + ); + ok( + AboutNewTab.newTabURL.endsWith(NEWTAB_URI), + "Newtab url is overridden by the extension." + ); + + let update = await promiseFindAddonUpdates(extension.addon); + let install = update.updateAvailable; + + await promiseCompleteAllInstalls([install]); + + await extension.awaitStartup(); + + equal( + extension.version, + "2.0", + "The updated addon has the expected version." + ); + equal( + AboutNewTab.newTabURL, + defaultNewTabURL, + "Newtab url reverted to the default after update." + ); + + await extension.unload(); + + await promiseShutdownManager(); +}); diff --git a/browser/components/extensions/test/xpcshell/xpcshell.toml b/browser/components/extensions/test/xpcshell/xpcshell.toml new file mode 100644 index 0000000000..e98f696264 --- /dev/null +++ b/browser/components/extensions/test/xpcshell/xpcshell.toml @@ -0,0 +1,69 @@ +[DEFAULT] +skip-if = ["os == 'android'"] # bug 1730213 +head = "head.js" +firefox-appdir = "browser" +tags = "webextensions condprof" +dupe-manifest = "" + +["test_ext_bookmarks.js"] +skip-if = ["condprof"] # Bug 1769184 - by design for now + +["test_ext_browsingData_downloads.js"] + +["test_ext_browsingData_passwords.js"] +skip-if = ["tsan"] # Times out, bug 1612707 + +["test_ext_browsingData_settings.js"] + +["test_ext_chrome_settings_overrides_home.js"] + +["test_ext_chrome_settings_overrides_update.js"] + +["test_ext_distribution_popup.js"] + +["test_ext_history.js"] + +["test_ext_homepage_overrides_private.js"] + +["test_ext_manifest.js"] + +["test_ext_manifest_commands.js"] +run-sequentially = "very high failure rate in parallel" + +["test_ext_manifest_omnibox.js"] + +["test_ext_manifest_permissions.js"] + +["test_ext_menu_caller.js"] + +["test_ext_menu_startup.js"] + +["test_ext_normandyAddonStudy.js"] + +["test_ext_pageAction_shutdown.js"] + +["test_ext_pkcs11_management.js"] + +["test_ext_settings_overrides_defaults.js"] +skip-if = ["condprof"] # Bug 1776135 - by design, modifies search settings at start of test +support-files = [ + "data/test/manifest.json", + "data/test2/manifest.json", +] + +["test_ext_settings_overrides_search.js"] + +["test_ext_settings_overrides_search_mozParam.js"] +skip-if = ["condprof"] # Bug 1776652 +support-files = ["data/test/manifest.json"] + +["test_ext_settings_overrides_shutdown.js"] + +["test_ext_settings_validate.js"] + +["test_ext_topSites.js"] +skip-if = ["condprof"] # Bug 1769184 - by design for now + +["test_ext_url_overrides_newtab.js"] + +["test_ext_url_overrides_newtab_update.js"] |