From 26a029d407be480d791972afb5975cf62c9360a6 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Fri, 19 Apr 2024 02:47:55 +0200 Subject: Adding upstream version 124.0.1. Signed-off-by: Daniel Baumann --- .../extensions/test/AppUiTestDelegate.sys.mjs | 227 +++ .../extensions/test/browser/.eslintrc.js | 11 + .../extensions/test/browser/authenticate.sjs | 85 + .../extensions/test/browser/browser-private.toml | 11 + .../extensions/test/browser/browser.toml | 702 ++++++++ .../browser/browser_AMBrowserExtensionsImport.js | 286 ++++ .../browser/browser_ExtensionControlledPopup.js | 241 +++ .../browser_ext_action_popup_allowed_urls.js | 283 ++++ .../test/browser/browser_ext_activeScript.js | 480 ++++++ .../browser_ext_addon_debugging_netmonitor.js | 116 ++ .../test/browser/browser_ext_autocompletepopup.js | 90 + .../browser/browser_ext_autoplayInBackground.js | 52 + .../browser/browser_ext_browserAction_activeTab.js | 195 +++ .../test/browser/browser_ext_browserAction_area.js | 126 ++ .../browser_ext_browserAction_click_types.js | 269 +++ .../browser/browser_ext_browserAction_context.js | 1194 ++++++++++++++ .../browser_ext_browserAction_contextMenu.js | 880 ++++++++++ .../browser/browser_ext_browserAction_disabled.js | 101 ++ .../browser_ext_browserAction_experiment.js | 159 ++ .../browser_ext_browserAction_getUserSettings.js | 244 +++ .../browser/browser_ext_browserAction_incognito.js | 48 + .../browser/browser_ext_browserAction_keyclick.js | 68 + .../browser_ext_browserAction_pageAction_icon.js | 651 ++++++++ ...xt_browserAction_pageAction_icon_permissions.js | 239 +++ .../browser/browser_ext_browserAction_popup.js | 370 +++++ .../browser_ext_browserAction_popup_port.js | 56 + .../browser_ext_browserAction_popup_preload.js | 472 ++++++ ...er_ext_browserAction_popup_preload_smoketest.js | 194 +++ .../browser_ext_browserAction_popup_resize.js | 79 + ...rowser_ext_browserAction_popup_resize_bottom.js | 39 + .../browser/browser_ext_browserAction_simple.js | 105 ++ .../browser/browser_ext_browserAction_telemetry.js | 386 +++++ .../browser_ext_browserAction_theme_icons.js | 370 +++++ .../browser_ext_browsingData_cookieStoreId.js | 86 + .../browser/browser_ext_browsingData_formData.js | 175 ++ .../browser/browser_ext_browsingData_history.js | 123 ++ .../browser_ext_chrome_settings_overrides_home.js | 843 ++++++++++ .../browser_ext_commands_execute_browser_action.js | 194 +++ .../browser_ext_commands_execute_page_action.js | 204 +++ .../browser_ext_commands_execute_sidebar_action.js | 56 + .../test/browser/browser_ext_commands_getAll.js | 142 ++ .../test/browser/browser_ext_commands_onChanged.js | 59 + .../test/browser/browser_ext_commands_onCommand.js | 442 +++++ .../test/browser/browser_ext_commands_update.js | 428 +++++ .../browser/browser_ext_connect_and_move_tabs.js | 104 ++ .../browser/browser_ext_contentscript_animate.js | 135 ++ .../browser/browser_ext_contentscript_connect.js | 94 ++ ...er_ext_contentscript_cross_docGroup_adoption.js | 63 + ...xt_contentscript_cross_docGroup_adoption_xhr.js | 56 + ...browser_ext_contentscript_dataTransfer_files.js | 104 ++ .../browser/browser_ext_contentscript_in_parent.js | 101 ++ .../browser/browser_ext_contentscript_incognito.js | 42 + .../browser_ext_contentscript_nontab_connect.js | 116 ++ .../browser_ext_contentscript_sender_url.js | 67 + .../test/browser/browser_ext_contextMenus.js | 854 ++++++++++ .../browser/browser_ext_contextMenus_bookmarks.js | 115 ++ .../browser/browser_ext_contextMenus_checkboxes.js | 157 ++ .../browser/browser_ext_contextMenus_commands.js | 158 ++ .../test/browser/browser_ext_contextMenus_icons.js | 493 ++++++ .../browser/browser_ext_contextMenus_onclick.js | 297 ++++ .../browser_ext_contextMenus_radioGroups.js | 140 ++ .../browser_ext_contextMenus_srcUrl_redirect.js | 69 + .../browser_ext_contextMenus_targetUrlPatterns.js | 317 ++++ .../browser/browser_ext_contextMenus_uninstall.js | 114 ++ .../browser_ext_contextMenus_urlPatterns.js | 337 ++++ .../test/browser/browser_ext_currentWindow.js | 183 +++ .../browser_ext_devtools_inspectedWindow.js | 540 ++++++ ...r_ext_devtools_inspectedWindow_eval_bindings.js | 270 +++ ...owser_ext_devtools_inspectedWindow_eval_file.js | 54 + .../browser_ext_devtools_inspectedWindow_reload.js | 481 ++++++ ...er_ext_devtools_inspectedWindow_targetSwitch.js | 128 ++ .../test/browser/browser_ext_devtools_network.js | 298 ++++ .../browser_ext_devtools_network_targetSwitch.js | 74 + .../test/browser/browser_ext_devtools_optional.js | 169 ++ .../test/browser/browser_ext_devtools_page.js | 304 ++++ .../browser/browser_ext_devtools_page_incognito.js | 92 ++ .../test/browser/browser_ext_devtools_panel.js | 812 +++++++++ .../browser_ext_devtools_panels_elements.js | 124 ++ ...browser_ext_devtools_panels_elements_sidebar.js | 323 ++++ .../extensions/test/browser/browser_ext_find.js | 468 ++++++ .../test/browser/browser_ext_getViews.js | 439 +++++ .../test/browser/browser_ext_history_redirect.js | 72 + .../browser/browser_ext_identity_indication.js | 141 ++ .../test/browser/browser_ext_incognito_popup.js | 209 +++ .../test/browser/browser_ext_incognito_views.js | 269 +++ .../test/browser/browser_ext_lastError.js | 61 + .../test/browser/browser_ext_management.js | 139 ++ .../extensions/test/browser/browser_ext_menus.js | 458 ++++++ .../test/browser/browser_ext_menus_accesskey.js | 209 +++ .../test/browser/browser_ext_menus_activeTab.js | 115 ++ .../browser_ext_menus_capture_secondary_click.js | 140 ++ .../test/browser/browser_ext_menus_errors.js | 164 ++ .../test/browser/browser_ext_menus_event_order.js | 87 + .../test/browser/browser_ext_menus_eventpage.js | 277 ++++ .../test/browser/browser_ext_menus_events.js | 911 +++++++++++ ...owser_ext_menus_events_after_context_destroy.js | 64 + .../test/browser/browser_ext_menus_incognito.js | 155 ++ .../test/browser/browser_ext_menus_refresh.js | 438 +++++ .../test/browser/browser_ext_menus_replace_menu.js | 525 ++++++ .../browser_ext_menus_replace_menu_context.js | 475 ++++++ .../browser_ext_menus_replace_menu_permissions.js | 220 +++ .../browser/browser_ext_menus_targetElement.js | 326 ++++ .../browser_ext_menus_targetElement_extension.js | 198 +++ .../browser_ext_menus_targetElement_shadow.js | 108 ++ .../test/browser/browser_ext_menus_viewType.js | 122 ++ .../test/browser/browser_ext_menus_visible.js | 95 ++ .../test/browser/browser_ext_mousewheel_zoom.js | 186 +++ .../browser/browser_ext_nontab_process_switch.js | 154 ++ .../extensions/test/browser/browser_ext_omnibox.js | 504 ++++++ .../test/browser/browser_ext_openPanel.js | 152 ++ .../browser/browser_ext_optionsPage_activity.js | 79 + .../browser_ext_optionsPage_browser_style.js | 155 ++ .../browser_ext_optionsPage_links_open_in_tabs.js | 68 + .../test/browser/browser_ext_optionsPage_modals.js | 100 ++ .../test/browser/browser_ext_optionsPage_popups.js | 249 +++ .../browser/browser_ext_optionsPage_privileges.js | 86 + .../test/browser/browser_ext_originControls.js | 867 ++++++++++ .../browser/browser_ext_pageAction_activeTab.js | 107 ++ .../browser/browser_ext_pageAction_click_types.js | 240 +++ .../test/browser/browser_ext_pageAction_context.js | 453 +++++ .../browser/browser_ext_pageAction_contextMenu.js | 128 ++ .../test/browser/browser_ext_pageAction_popup.js | 305 ++++ .../browser/browser_ext_pageAction_popup_resize.js | 192 +++ .../browser/browser_ext_pageAction_show_matches.js | 329 ++++ .../test/browser/browser_ext_pageAction_simple.js | 213 +++ .../browser/browser_ext_pageAction_telemetry.js | 228 +++ .../test/browser/browser_ext_pageAction_title.js | 275 ++++ ...ext_persistent_storage_permission_indication.js | 131 ++ .../browser/browser_ext_popup_api_injection.js | 113 ++ .../test/browser/browser_ext_popup_background.js | 160 ++ .../test/browser/browser_ext_popup_corners.js | 165 ++ .../test/browser/browser_ext_popup_focus.js | 88 + .../browser_ext_popup_links_open_in_tabs.js | 56 + .../browser/browser_ext_popup_requestPermission.js | 67 + .../test/browser/browser_ext_popup_select.js | 115 ++ .../browser/browser_ext_popup_select_in_oopif.js | 131 ++ .../test/browser/browser_ext_popup_sendMessage.js | 135 ++ .../test/browser/browser_ext_popup_shutdown.js | 80 + .../browser_ext_port_disconnect_on_crash.js | 113 ++ .../browser_ext_port_disconnect_on_window_close.js | 39 + .../browser/browser_ext_reload_manifest_cache.js | 72 + .../browser/browser_ext_request_permissions.js | 121 ++ .../browser_ext_runtime_onPerformanceWarning.js | 144 ++ .../browser/browser_ext_runtime_openOptionsPage.js | 442 +++++ ...rowser_ext_runtime_openOptionsPage_uninstall.js | 122 ++ .../browser/browser_ext_runtime_setUninstallURL.js | 134 ++ .../extensions/test/browser/browser_ext_search.js | 351 ++++ .../test/browser/browser_ext_search_favicon.js | 184 +++ .../test/browser/browser_ext_search_query.js | 174 ++ .../browser_ext_sessions_forgetClosedTab.js | 145 ++ .../browser_ext_sessions_forgetClosedWindow.js | 121 ++ .../browser_ext_sessions_getRecentlyClosed.js | 216 +++ ...owser_ext_sessions_getRecentlyClosed_private.js | 93 ++ .../browser_ext_sessions_getRecentlyClosed_tabs.js | 292 ++++ .../test/browser/browser_ext_sessions_incognito.js | 113 ++ .../test/browser/browser_ext_sessions_restore.js | 234 +++ .../browser/browser_ext_sessions_restoreTab.js | 137 ++ .../browser_ext_sessions_restore_private.js | 236 +++ .../browser_ext_sessions_window_tab_value.js | 398 +++++ ...rowser_ext_settings_overrides_default_search.js | 881 ++++++++++ .../test/browser/browser_ext_sidebarAction.js | 268 +++ .../browser_ext_sidebarAction_browser_style.js | 90 + .../browser/browser_ext_sidebarAction_click.js | 74 + .../browser/browser_ext_sidebarAction_context.js | 683 ++++++++ .../browser_ext_sidebarAction_contextMenu.js | 133 ++ .../browser/browser_ext_sidebarAction_httpAuth.js | 72 + .../browser/browser_ext_sidebarAction_incognito.js | 139 ++ .../browser/browser_ext_sidebarAction_runtime.js | 76 + .../test/browser/browser_ext_sidebarAction_tabs.js | 48 + .../browser/browser_ext_sidebarAction_windows.js | 69 + .../browser_ext_sidebar_requestPermission.js | 43 + .../extensions/test/browser/browser_ext_simple.js | 60 + .../test/browser/browser_ext_slow_script.js | 72 + .../test/browser/browser_ext_tab_runtimeConnect.js | 100 ++ .../test/browser/browser_ext_tabs_attention.js | 64 + .../test/browser/browser_ext_tabs_audio.js | 261 +++ .../browser/browser_ext_tabs_autoDiscardable.js | 177 ++ .../browser/browser_ext_tabs_containerIsolation.js | 360 ++++ .../test/browser/browser_ext_tabs_cookieStoreId.js | 328 ++++ .../browser_ext_tabs_cookieStoreId_private.js | 44 + .../test/browser/browser_ext_tabs_create.js | 299 ++++ .../browser/browser_ext_tabs_create_invalid_url.js | 79 + .../test/browser/browser_ext_tabs_create_url.js | 230 +++ .../test/browser/browser_ext_tabs_discard.js | 98 ++ .../browser/browser_ext_tabs_discard_reversed.js | 129 ++ .../test/browser/browser_ext_tabs_discarded.js | 386 +++++ .../test/browser/browser_ext_tabs_duplicate.js | 316 ++++ .../test/browser/browser_ext_tabs_events.js | 794 +++++++++ .../test/browser/browser_ext_tabs_events_order.js | 208 +++ .../test/browser/browser_ext_tabs_executeScript.js | 453 +++++ .../browser_ext_tabs_executeScript_about_blank.js | 33 + .../browser/browser_ext_tabs_executeScript_bad.js | 361 ++++ .../browser/browser_ext_tabs_executeScript_file.js | 93 ++ .../browser/browser_ext_tabs_executeScript_good.js | 190 +++ .../browser_ext_tabs_executeScript_multiple.js | 61 + .../browser_ext_tabs_executeScript_no_create.js | 80 + .../browser_ext_tabs_executeScript_runAt.js | 134 ++ .../test/browser/browser_ext_tabs_getCurrent.js | 86 + .../browser/browser_ext_tabs_goBack_goForward.js | 113 ++ .../test/browser/browser_ext_tabs_hide.js | 375 +++++ .../test/browser/browser_ext_tabs_hide_update.js | 146 ++ .../test/browser/browser_ext_tabs_highlight.js | 118 ++ .../browser_ext_tabs_incognito_not_allowed.js | 155 ++ .../test/browser/browser_ext_tabs_insertCSS.js | 312 ++++ .../test/browser/browser_ext_tabs_lastAccessed.js | 52 + .../test/browser/browser_ext_tabs_lazy.js | 49 + .../test/browser/browser_ext_tabs_move_array.js | 95 ++ ...browser_ext_tabs_move_array_multiple_windows.js | 160 ++ .../browser/browser_ext_tabs_move_discarded.js | 94 ++ .../test/browser/browser_ext_tabs_move_window.js | 178 ++ .../browser_ext_tabs_move_window_multiple.js | 64 + .../browser/browser_ext_tabs_move_window_pinned.js | 44 + .../browser/browser_ext_tabs_newtab_private.js | 96 ++ .../test/browser/browser_ext_tabs_onCreated.js | 35 + .../test/browser/browser_ext_tabs_onHighlighted.js | 130 ++ .../test/browser/browser_ext_tabs_onUpdated.js | 339 ++++ .../browser/browser_ext_tabs_onUpdated_filter.js | 354 ++++ .../test/browser/browser_ext_tabs_opener.js | 130 ++ .../test/browser/browser_ext_tabs_printPreview.js | 44 + .../test/browser/browser_ext_tabs_query.js | 468 ++++++ .../test/browser/browser_ext_tabs_readerMode.js | 138 ++ .../test/browser/browser_ext_tabs_reload.js | 53 + .../browser_ext_tabs_reload_bypass_cache.js | 89 + .../test/browser/browser_ext_tabs_remove.js | 258 +++ .../test/browser/browser_ext_tabs_removeCSS.js | 151 ++ .../test/browser/browser_ext_tabs_saveAsPDF.js | 197 +++ .../test/browser/browser_ext_tabs_sendMessage.js | 385 +++++ .../test/browser/browser_ext_tabs_sharingState.js | 110 ++ .../test/browser/browser_ext_tabs_successors.js | 396 +++++ .../test/browser/browser_ext_tabs_update.js | 54 + .../browser/browser_ext_tabs_update_highlighted.js | 183 +++ .../test/browser/browser_ext_tabs_update_url.js | 235 +++ .../test/browser/browser_ext_tabs_warmup.js | 40 + .../test/browser/browser_ext_tabs_zoom.js | 346 ++++ .../test/browser/browser_ext_themes_validation.js | 55 + .../test/browser/browser_ext_topSites.js | 413 +++++ .../browser/browser_ext_url_overrides_newtab.js | 794 +++++++++ .../test/browser/browser_ext_user_events.js | 271 +++ ...browser_ext_webNavigation_containerIsolation.js | 169 ++ .../browser/browser_ext_webNavigation_frameId0.js | 43 + .../browser/browser_ext_webNavigation_getFrames.js | 323 ++++ ..._ext_webNavigation_onCreatedNavigationTarget.js | 194 +++ ...gation_onCreatedNavigationTarget_contextmenu.js | 182 +++ ...ation_onCreatedNavigationTarget_named_window.js | 100 ++ ...CreatedNavigationTarget_subframe_window_open.js | 168 ++ ...gation_onCreatedNavigationTarget_window_open.js | 168 ++ ...browser_ext_webNavigation_urlbar_transitions.js | 314 ++++ .../test/browser/browser_ext_webRequest.js | 142 ++ ...ext_webRequest_error_after_stopped_or_closed.js | 110 ++ .../extensions/test/browser/browser_ext_webrtc.js | 131 ++ .../extensions/test/browser/browser_ext_windows.js | 348 ++++ .../browser_ext_windows_allowScriptsToClose.js | 69 + .../test/browser/browser_ext_windows_create.js | 205 +++ .../browser_ext_windows_create_cookieStoreId.js | 345 ++++ .../browser/browser_ext_windows_create_params.js | 249 +++ .../browser/browser_ext_windows_create_tabId.js | 387 +++++ .../test/browser/browser_ext_windows_create_url.js | 253 +++ .../test/browser/browser_ext_windows_events.js | 222 +++ .../test/browser/browser_ext_windows_incognito.js | 84 + .../test/browser/browser_ext_windows_remove.js | 53 + .../test/browser/browser_ext_windows_size.js | 122 ++ .../test/browser/browser_ext_windows_update.js | 390 +++++ .../test/browser/browser_legacy_recent_tabs.toml | 34 + .../browser_toolbar_prefers_color_scheme.js | 266 +++ .../test/browser/browser_unified_extensions.js | 1545 ++++++++++++++++++ .../browser_unified_extensions_accessibility.js | 302 ++++ .../browser_unified_extensions_context_menu.js | 1006 ++++++++++++ .../test/browser/browser_unified_extensions_cui.js | 159 ++ .../browser_unified_extensions_doorhangers.js | 116 ++ .../browser/browser_unified_extensions_messages.js | 222 +++ ...wser_unified_extensions_overflowable_toolbar.js | 1389 ++++++++++++++++ .../extensions/test/browser/context.html | 44 + .../extensions/test/browser/context_frame.html | 8 + .../browser/context_tabs_onUpdated_iframe.html | 19 + .../test/browser/context_tabs_onUpdated_page.html | 18 + .../test/browser/context_with_redirect.html | 4 + .../extensions/test/browser/ctxmenu-image.png | Bin 0 -> 5401 bytes .../components/extensions/test/browser/empty.xpi | 0 .../extensions/test/browser/file_bypass_cache.sjs | 13 + .../test/browser/file_dataTransfer_files.html | 36 + .../extensions/test/browser/file_dummy.html | 10 + .../extensions/test/browser/file_find_frames.html | 19 + ...ile_has_non_web_controlled_blank_page_link.html | 5 + .../test/browser/file_iframe_document.html | 11 + .../test/browser/file_inspectedwindow_eval.html | 29 + .../browser/file_inspectedwindow_reload_target.sjs | 130 ++ .../test/browser/file_popup_api_injection_a.html | 10 + .../test/browser/file_popup_api_injection_b.html | 10 + .../test/browser/file_slowed_document.sjs | 49 + .../extensions/test/browser/file_title.html | 9 + .../test/browser/file_with_example_com_frame.html | 5 + .../test/browser/file_with_xorigin_frame.html | 5 + browser/components/extensions/test/browser/head.js | 1046 ++++++++++++ .../extensions/test/browser/head_browserAction.js | 368 +++++ .../extensions/test/browser/head_devtools.js | 162 ++ .../extensions/test/browser/head_pageAction.js | 232 +++ .../extensions/test/browser/head_sessions.js | 64 + .../test/browser/head_unified_extensions.js | 199 +++ .../extensions/test/browser/head_webNavigation.js | 49 + .../extensions/test/browser/redirect_to.sjs | 9 + .../browser/search-engines/another/manifest.json | 19 + .../browser/search-engines/basic/manifest.json | 19 + .../test/browser/search-engines/engines.json | 38 + .../browser/search-engines/simple/manifest.json | 29 + .../test/browser/searchSuggestionEngine.sjs | 10 + .../test/browser/searchSuggestionEngine.xml | 9 + .../components/extensions/test/browser/silence.ogg | Bin 0 -> 3557 bytes .../extensions/test/browser/wait-a-bit.sjs | 23 + .../test/browser/webNav_createdTarget.html | 10 + .../test/browser/webNav_createdTargetSource.html | 45 + .../webNav_createdTargetSource_subframe.html | 42 + .../extensions/test/mochitest/.eslintrc.js | 8 + .../extensions/test/mochitest/mochitest.toml | 14 + .../test/mochitest/test_ext_all_apis.html | 83 + .../extensions/test/xpcshell/.eslintrc.js | 9 + .../test/xpcshell/data/test/manifest.json | 80 + .../test/xpcshell/data/test2/manifest.json | 23 + .../components/extensions/test/xpcshell/head.js | 78 + .../extensions/test/xpcshell/test_ext_bookmarks.js | 1725 ++++++++++++++++++++ .../xpcshell/test_ext_browsingData_downloads.js | 126 ++ .../xpcshell/test_ext_browsingData_passwords.js | 96 ++ .../xpcshell/test_ext_browsingData_settings.js | 147 ++ .../test_ext_chrome_settings_overrides_home.js | 231 +++ .../test_ext_chrome_settings_overrides_update.js | 794 +++++++++ .../test/xpcshell/test_ext_distribution_popup.js | 56 + .../extensions/test/xpcshell/test_ext_history.js | 864 ++++++++++ .../test_ext_homepage_overrides_private.js | 134 ++ .../extensions/test/xpcshell/test_ext_manifest.js | 105 ++ .../test/xpcshell/test_ext_manifest_commands.js | 52 + .../test/xpcshell/test_ext_manifest_omnibox.js | 62 + .../test/xpcshell/test_ext_manifest_permissions.js | 85 + .../test/xpcshell/test_ext_menu_caller.js | 53 + .../test/xpcshell/test_ext_menu_startup.js | 432 +++++ .../test/xpcshell/test_ext_normandyAddonStudy.js | 243 +++ .../test/xpcshell/test_ext_pageAction_shutdown.js | 81 + .../test/xpcshell/test_ext_pkcs11_management.js | 300 ++++ .../test_ext_settings_overrides_defaults.js | 263 +++ .../xpcshell/test_ext_settings_overrides_search.js | 597 +++++++ .../test_ext_settings_overrides_search_mozParam.js | 239 +++ .../test_ext_settings_overrides_shutdown.js | 109 ++ .../test/xpcshell/test_ext_settings_validate.js | 193 +++ .../extensions/test/xpcshell/test_ext_topSites.js | 293 ++++ .../test/xpcshell/test_ext_url_overrides_newtab.js | 340 ++++ .../test_ext_url_overrides_newtab_update.js | 127 ++ .../extensions/test/xpcshell/xpcshell.toml | 69 + 345 files changed, 72962 insertions(+) create mode 100644 browser/components/extensions/test/AppUiTestDelegate.sys.mjs create mode 100644 browser/components/extensions/test/browser/.eslintrc.js create mode 100644 browser/components/extensions/test/browser/authenticate.sjs create mode 100644 browser/components/extensions/test/browser/browser-private.toml create mode 100644 browser/components/extensions/test/browser/browser.toml create mode 100644 browser/components/extensions/test/browser/browser_AMBrowserExtensionsImport.js create mode 100644 browser/components/extensions/test/browser/browser_ExtensionControlledPopup.js create mode 100644 browser/components/extensions/test/browser/browser_ext_action_popup_allowed_urls.js create mode 100644 browser/components/extensions/test/browser/browser_ext_activeScript.js create mode 100644 browser/components/extensions/test/browser/browser_ext_addon_debugging_netmonitor.js create mode 100644 browser/components/extensions/test/browser/browser_ext_autocompletepopup.js create mode 100644 browser/components/extensions/test/browser/browser_ext_autoplayInBackground.js create mode 100644 browser/components/extensions/test/browser/browser_ext_browserAction_activeTab.js create mode 100644 browser/components/extensions/test/browser/browser_ext_browserAction_area.js create mode 100644 browser/components/extensions/test/browser/browser_ext_browserAction_click_types.js create mode 100644 browser/components/extensions/test/browser/browser_ext_browserAction_context.js create mode 100644 browser/components/extensions/test/browser/browser_ext_browserAction_contextMenu.js create mode 100644 browser/components/extensions/test/browser/browser_ext_browserAction_disabled.js create mode 100644 browser/components/extensions/test/browser/browser_ext_browserAction_experiment.js create mode 100644 browser/components/extensions/test/browser/browser_ext_browserAction_getUserSettings.js create mode 100644 browser/components/extensions/test/browser/browser_ext_browserAction_incognito.js create mode 100644 browser/components/extensions/test/browser/browser_ext_browserAction_keyclick.js create mode 100644 browser/components/extensions/test/browser/browser_ext_browserAction_pageAction_icon.js create mode 100644 browser/components/extensions/test/browser/browser_ext_browserAction_pageAction_icon_permissions.js create mode 100644 browser/components/extensions/test/browser/browser_ext_browserAction_popup.js create mode 100644 browser/components/extensions/test/browser/browser_ext_browserAction_popup_port.js create mode 100644 browser/components/extensions/test/browser/browser_ext_browserAction_popup_preload.js create mode 100644 browser/components/extensions/test/browser/browser_ext_browserAction_popup_preload_smoketest.js create mode 100644 browser/components/extensions/test/browser/browser_ext_browserAction_popup_resize.js create mode 100644 browser/components/extensions/test/browser/browser_ext_browserAction_popup_resize_bottom.js create mode 100644 browser/components/extensions/test/browser/browser_ext_browserAction_simple.js create mode 100644 browser/components/extensions/test/browser/browser_ext_browserAction_telemetry.js create mode 100644 browser/components/extensions/test/browser/browser_ext_browserAction_theme_icons.js create mode 100644 browser/components/extensions/test/browser/browser_ext_browsingData_cookieStoreId.js create mode 100644 browser/components/extensions/test/browser/browser_ext_browsingData_formData.js create mode 100644 browser/components/extensions/test/browser/browser_ext_browsingData_history.js create mode 100644 browser/components/extensions/test/browser/browser_ext_chrome_settings_overrides_home.js create mode 100644 browser/components/extensions/test/browser/browser_ext_commands_execute_browser_action.js create mode 100644 browser/components/extensions/test/browser/browser_ext_commands_execute_page_action.js create mode 100644 browser/components/extensions/test/browser/browser_ext_commands_execute_sidebar_action.js create mode 100644 browser/components/extensions/test/browser/browser_ext_commands_getAll.js create mode 100644 browser/components/extensions/test/browser/browser_ext_commands_onChanged.js create mode 100644 browser/components/extensions/test/browser/browser_ext_commands_onCommand.js create mode 100644 browser/components/extensions/test/browser/browser_ext_commands_update.js create mode 100644 browser/components/extensions/test/browser/browser_ext_connect_and_move_tabs.js create mode 100644 browser/components/extensions/test/browser/browser_ext_contentscript_animate.js create mode 100644 browser/components/extensions/test/browser/browser_ext_contentscript_connect.js create mode 100644 browser/components/extensions/test/browser/browser_ext_contentscript_cross_docGroup_adoption.js create mode 100644 browser/components/extensions/test/browser/browser_ext_contentscript_cross_docGroup_adoption_xhr.js create mode 100644 browser/components/extensions/test/browser/browser_ext_contentscript_dataTransfer_files.js create mode 100644 browser/components/extensions/test/browser/browser_ext_contentscript_in_parent.js create mode 100644 browser/components/extensions/test/browser/browser_ext_contentscript_incognito.js create mode 100644 browser/components/extensions/test/browser/browser_ext_contentscript_nontab_connect.js create mode 100644 browser/components/extensions/test/browser/browser_ext_contentscript_sender_url.js create mode 100644 browser/components/extensions/test/browser/browser_ext_contextMenus.js create mode 100644 browser/components/extensions/test/browser/browser_ext_contextMenus_bookmarks.js create mode 100644 browser/components/extensions/test/browser/browser_ext_contextMenus_checkboxes.js create mode 100644 browser/components/extensions/test/browser/browser_ext_contextMenus_commands.js create mode 100644 browser/components/extensions/test/browser/browser_ext_contextMenus_icons.js create mode 100644 browser/components/extensions/test/browser/browser_ext_contextMenus_onclick.js create mode 100644 browser/components/extensions/test/browser/browser_ext_contextMenus_radioGroups.js create mode 100644 browser/components/extensions/test/browser/browser_ext_contextMenus_srcUrl_redirect.js create mode 100644 browser/components/extensions/test/browser/browser_ext_contextMenus_targetUrlPatterns.js create mode 100644 browser/components/extensions/test/browser/browser_ext_contextMenus_uninstall.js create mode 100644 browser/components/extensions/test/browser/browser_ext_contextMenus_urlPatterns.js create mode 100644 browser/components/extensions/test/browser/browser_ext_currentWindow.js create mode 100644 browser/components/extensions/test/browser/browser_ext_devtools_inspectedWindow.js create mode 100644 browser/components/extensions/test/browser/browser_ext_devtools_inspectedWindow_eval_bindings.js create mode 100644 browser/components/extensions/test/browser/browser_ext_devtools_inspectedWindow_eval_file.js create mode 100644 browser/components/extensions/test/browser/browser_ext_devtools_inspectedWindow_reload.js create mode 100644 browser/components/extensions/test/browser/browser_ext_devtools_inspectedWindow_targetSwitch.js create mode 100644 browser/components/extensions/test/browser/browser_ext_devtools_network.js create mode 100644 browser/components/extensions/test/browser/browser_ext_devtools_network_targetSwitch.js create mode 100644 browser/components/extensions/test/browser/browser_ext_devtools_optional.js create mode 100644 browser/components/extensions/test/browser/browser_ext_devtools_page.js create mode 100644 browser/components/extensions/test/browser/browser_ext_devtools_page_incognito.js create mode 100644 browser/components/extensions/test/browser/browser_ext_devtools_panel.js create mode 100644 browser/components/extensions/test/browser/browser_ext_devtools_panels_elements.js create mode 100644 browser/components/extensions/test/browser/browser_ext_devtools_panels_elements_sidebar.js create mode 100644 browser/components/extensions/test/browser/browser_ext_find.js create mode 100644 browser/components/extensions/test/browser/browser_ext_getViews.js create mode 100644 browser/components/extensions/test/browser/browser_ext_history_redirect.js create mode 100644 browser/components/extensions/test/browser/browser_ext_identity_indication.js create mode 100644 browser/components/extensions/test/browser/browser_ext_incognito_popup.js create mode 100644 browser/components/extensions/test/browser/browser_ext_incognito_views.js create mode 100644 browser/components/extensions/test/browser/browser_ext_lastError.js create mode 100644 browser/components/extensions/test/browser/browser_ext_management.js create mode 100644 browser/components/extensions/test/browser/browser_ext_menus.js create mode 100644 browser/components/extensions/test/browser/browser_ext_menus_accesskey.js create mode 100644 browser/components/extensions/test/browser/browser_ext_menus_activeTab.js create mode 100644 browser/components/extensions/test/browser/browser_ext_menus_capture_secondary_click.js create mode 100644 browser/components/extensions/test/browser/browser_ext_menus_errors.js create mode 100644 browser/components/extensions/test/browser/browser_ext_menus_event_order.js create mode 100644 browser/components/extensions/test/browser/browser_ext_menus_eventpage.js create mode 100644 browser/components/extensions/test/browser/browser_ext_menus_events.js create mode 100644 browser/components/extensions/test/browser/browser_ext_menus_events_after_context_destroy.js create mode 100644 browser/components/extensions/test/browser/browser_ext_menus_incognito.js create mode 100644 browser/components/extensions/test/browser/browser_ext_menus_refresh.js create mode 100644 browser/components/extensions/test/browser/browser_ext_menus_replace_menu.js create mode 100644 browser/components/extensions/test/browser/browser_ext_menus_replace_menu_context.js create mode 100644 browser/components/extensions/test/browser/browser_ext_menus_replace_menu_permissions.js create mode 100644 browser/components/extensions/test/browser/browser_ext_menus_targetElement.js create mode 100644 browser/components/extensions/test/browser/browser_ext_menus_targetElement_extension.js create mode 100644 browser/components/extensions/test/browser/browser_ext_menus_targetElement_shadow.js create mode 100644 browser/components/extensions/test/browser/browser_ext_menus_viewType.js create mode 100644 browser/components/extensions/test/browser/browser_ext_menus_visible.js create mode 100644 browser/components/extensions/test/browser/browser_ext_mousewheel_zoom.js create mode 100644 browser/components/extensions/test/browser/browser_ext_nontab_process_switch.js create mode 100644 browser/components/extensions/test/browser/browser_ext_omnibox.js create mode 100644 browser/components/extensions/test/browser/browser_ext_openPanel.js create mode 100644 browser/components/extensions/test/browser/browser_ext_optionsPage_activity.js create mode 100644 browser/components/extensions/test/browser/browser_ext_optionsPage_browser_style.js create mode 100644 browser/components/extensions/test/browser/browser_ext_optionsPage_links_open_in_tabs.js create mode 100644 browser/components/extensions/test/browser/browser_ext_optionsPage_modals.js create mode 100644 browser/components/extensions/test/browser/browser_ext_optionsPage_popups.js create mode 100644 browser/components/extensions/test/browser/browser_ext_optionsPage_privileges.js create mode 100644 browser/components/extensions/test/browser/browser_ext_originControls.js create mode 100644 browser/components/extensions/test/browser/browser_ext_pageAction_activeTab.js create mode 100644 browser/components/extensions/test/browser/browser_ext_pageAction_click_types.js create mode 100644 browser/components/extensions/test/browser/browser_ext_pageAction_context.js create mode 100644 browser/components/extensions/test/browser/browser_ext_pageAction_contextMenu.js create mode 100644 browser/components/extensions/test/browser/browser_ext_pageAction_popup.js create mode 100644 browser/components/extensions/test/browser/browser_ext_pageAction_popup_resize.js create mode 100644 browser/components/extensions/test/browser/browser_ext_pageAction_show_matches.js create mode 100644 browser/components/extensions/test/browser/browser_ext_pageAction_simple.js create mode 100644 browser/components/extensions/test/browser/browser_ext_pageAction_telemetry.js create mode 100644 browser/components/extensions/test/browser/browser_ext_pageAction_title.js create mode 100644 browser/components/extensions/test/browser/browser_ext_persistent_storage_permission_indication.js create mode 100644 browser/components/extensions/test/browser/browser_ext_popup_api_injection.js create mode 100644 browser/components/extensions/test/browser/browser_ext_popup_background.js create mode 100644 browser/components/extensions/test/browser/browser_ext_popup_corners.js create mode 100644 browser/components/extensions/test/browser/browser_ext_popup_focus.js create mode 100644 browser/components/extensions/test/browser/browser_ext_popup_links_open_in_tabs.js create mode 100644 browser/components/extensions/test/browser/browser_ext_popup_requestPermission.js create mode 100644 browser/components/extensions/test/browser/browser_ext_popup_select.js create mode 100644 browser/components/extensions/test/browser/browser_ext_popup_select_in_oopif.js create mode 100644 browser/components/extensions/test/browser/browser_ext_popup_sendMessage.js create mode 100644 browser/components/extensions/test/browser/browser_ext_popup_shutdown.js create mode 100644 browser/components/extensions/test/browser/browser_ext_port_disconnect_on_crash.js create mode 100644 browser/components/extensions/test/browser/browser_ext_port_disconnect_on_window_close.js create mode 100644 browser/components/extensions/test/browser/browser_ext_reload_manifest_cache.js create mode 100644 browser/components/extensions/test/browser/browser_ext_request_permissions.js create mode 100644 browser/components/extensions/test/browser/browser_ext_runtime_onPerformanceWarning.js create mode 100644 browser/components/extensions/test/browser/browser_ext_runtime_openOptionsPage.js create mode 100644 browser/components/extensions/test/browser/browser_ext_runtime_openOptionsPage_uninstall.js create mode 100644 browser/components/extensions/test/browser/browser_ext_runtime_setUninstallURL.js create mode 100644 browser/components/extensions/test/browser/browser_ext_search.js create mode 100644 browser/components/extensions/test/browser/browser_ext_search_favicon.js create mode 100644 browser/components/extensions/test/browser/browser_ext_search_query.js create mode 100644 browser/components/extensions/test/browser/browser_ext_sessions_forgetClosedTab.js create mode 100644 browser/components/extensions/test/browser/browser_ext_sessions_forgetClosedWindow.js create mode 100644 browser/components/extensions/test/browser/browser_ext_sessions_getRecentlyClosed.js create mode 100644 browser/components/extensions/test/browser/browser_ext_sessions_getRecentlyClosed_private.js create mode 100644 browser/components/extensions/test/browser/browser_ext_sessions_getRecentlyClosed_tabs.js create mode 100644 browser/components/extensions/test/browser/browser_ext_sessions_incognito.js create mode 100644 browser/components/extensions/test/browser/browser_ext_sessions_restore.js create mode 100644 browser/components/extensions/test/browser/browser_ext_sessions_restoreTab.js create mode 100644 browser/components/extensions/test/browser/browser_ext_sessions_restore_private.js create mode 100644 browser/components/extensions/test/browser/browser_ext_sessions_window_tab_value.js create mode 100644 browser/components/extensions/test/browser/browser_ext_settings_overrides_default_search.js create mode 100644 browser/components/extensions/test/browser/browser_ext_sidebarAction.js create mode 100644 browser/components/extensions/test/browser/browser_ext_sidebarAction_browser_style.js create mode 100644 browser/components/extensions/test/browser/browser_ext_sidebarAction_click.js create mode 100644 browser/components/extensions/test/browser/browser_ext_sidebarAction_context.js create mode 100644 browser/components/extensions/test/browser/browser_ext_sidebarAction_contextMenu.js create mode 100644 browser/components/extensions/test/browser/browser_ext_sidebarAction_httpAuth.js create mode 100644 browser/components/extensions/test/browser/browser_ext_sidebarAction_incognito.js create mode 100644 browser/components/extensions/test/browser/browser_ext_sidebarAction_runtime.js create mode 100644 browser/components/extensions/test/browser/browser_ext_sidebarAction_tabs.js create mode 100644 browser/components/extensions/test/browser/browser_ext_sidebarAction_windows.js create mode 100644 browser/components/extensions/test/browser/browser_ext_sidebar_requestPermission.js create mode 100644 browser/components/extensions/test/browser/browser_ext_simple.js create mode 100644 browser/components/extensions/test/browser/browser_ext_slow_script.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tab_runtimeConnect.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_attention.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_audio.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_autoDiscardable.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_containerIsolation.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_cookieStoreId.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_cookieStoreId_private.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_create.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_create_invalid_url.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_create_url.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_discard.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_discard_reversed.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_discarded.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_duplicate.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_events.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_events_order.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_executeScript.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_executeScript_about_blank.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_executeScript_bad.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_executeScript_file.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_executeScript_good.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_executeScript_multiple.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_executeScript_no_create.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_executeScript_runAt.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_getCurrent.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_goBack_goForward.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_hide.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_hide_update.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_highlight.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_incognito_not_allowed.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_insertCSS.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_lastAccessed.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_lazy.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_move_array.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_move_array_multiple_windows.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_move_discarded.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_move_window.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_move_window_multiple.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_move_window_pinned.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_newtab_private.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_onCreated.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_onHighlighted.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_onUpdated.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_onUpdated_filter.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_opener.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_printPreview.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_query.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_readerMode.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_reload.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_reload_bypass_cache.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_remove.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_removeCSS.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_saveAsPDF.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_sendMessage.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_sharingState.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_successors.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_update.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_update_highlighted.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_update_url.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_warmup.js create mode 100644 browser/components/extensions/test/browser/browser_ext_tabs_zoom.js create mode 100644 browser/components/extensions/test/browser/browser_ext_themes_validation.js create mode 100644 browser/components/extensions/test/browser/browser_ext_topSites.js create mode 100644 browser/components/extensions/test/browser/browser_ext_url_overrides_newtab.js create mode 100644 browser/components/extensions/test/browser/browser_ext_user_events.js create mode 100644 browser/components/extensions/test/browser/browser_ext_webNavigation_containerIsolation.js create mode 100644 browser/components/extensions/test/browser/browser_ext_webNavigation_frameId0.js create mode 100644 browser/components/extensions/test/browser/browser_ext_webNavigation_getFrames.js create mode 100644 browser/components/extensions/test/browser/browser_ext_webNavigation_onCreatedNavigationTarget.js create mode 100644 browser/components/extensions/test/browser/browser_ext_webNavigation_onCreatedNavigationTarget_contextmenu.js create mode 100644 browser/components/extensions/test/browser/browser_ext_webNavigation_onCreatedNavigationTarget_named_window.js create mode 100644 browser/components/extensions/test/browser/browser_ext_webNavigation_onCreatedNavigationTarget_subframe_window_open.js create mode 100644 browser/components/extensions/test/browser/browser_ext_webNavigation_onCreatedNavigationTarget_window_open.js create mode 100644 browser/components/extensions/test/browser/browser_ext_webNavigation_urlbar_transitions.js create mode 100644 browser/components/extensions/test/browser/browser_ext_webRequest.js create mode 100644 browser/components/extensions/test/browser/browser_ext_webRequest_error_after_stopped_or_closed.js create mode 100644 browser/components/extensions/test/browser/browser_ext_webrtc.js create mode 100644 browser/components/extensions/test/browser/browser_ext_windows.js create mode 100644 browser/components/extensions/test/browser/browser_ext_windows_allowScriptsToClose.js create mode 100644 browser/components/extensions/test/browser/browser_ext_windows_create.js create mode 100644 browser/components/extensions/test/browser/browser_ext_windows_create_cookieStoreId.js create mode 100644 browser/components/extensions/test/browser/browser_ext_windows_create_params.js create mode 100644 browser/components/extensions/test/browser/browser_ext_windows_create_tabId.js create mode 100644 browser/components/extensions/test/browser/browser_ext_windows_create_url.js create mode 100644 browser/components/extensions/test/browser/browser_ext_windows_events.js create mode 100644 browser/components/extensions/test/browser/browser_ext_windows_incognito.js create mode 100644 browser/components/extensions/test/browser/browser_ext_windows_remove.js create mode 100644 browser/components/extensions/test/browser/browser_ext_windows_size.js create mode 100644 browser/components/extensions/test/browser/browser_ext_windows_update.js create mode 100644 browser/components/extensions/test/browser/browser_legacy_recent_tabs.toml create mode 100644 browser/components/extensions/test/browser/browser_toolbar_prefers_color_scheme.js create mode 100644 browser/components/extensions/test/browser/browser_unified_extensions.js create mode 100644 browser/components/extensions/test/browser/browser_unified_extensions_accessibility.js create mode 100644 browser/components/extensions/test/browser/browser_unified_extensions_context_menu.js create mode 100644 browser/components/extensions/test/browser/browser_unified_extensions_cui.js create mode 100644 browser/components/extensions/test/browser/browser_unified_extensions_doorhangers.js create mode 100644 browser/components/extensions/test/browser/browser_unified_extensions_messages.js create mode 100644 browser/components/extensions/test/browser/browser_unified_extensions_overflowable_toolbar.js create mode 100644 browser/components/extensions/test/browser/context.html create mode 100644 browser/components/extensions/test/browser/context_frame.html create mode 100644 browser/components/extensions/test/browser/context_tabs_onUpdated_iframe.html create mode 100644 browser/components/extensions/test/browser/context_tabs_onUpdated_page.html create mode 100644 browser/components/extensions/test/browser/context_with_redirect.html create mode 100644 browser/components/extensions/test/browser/ctxmenu-image.png create mode 100644 browser/components/extensions/test/browser/empty.xpi create mode 100644 browser/components/extensions/test/browser/file_bypass_cache.sjs create mode 100644 browser/components/extensions/test/browser/file_dataTransfer_files.html create mode 100644 browser/components/extensions/test/browser/file_dummy.html create mode 100644 browser/components/extensions/test/browser/file_find_frames.html create mode 100644 browser/components/extensions/test/browser/file_has_non_web_controlled_blank_page_link.html create mode 100644 browser/components/extensions/test/browser/file_iframe_document.html create mode 100644 browser/components/extensions/test/browser/file_inspectedwindow_eval.html create mode 100644 browser/components/extensions/test/browser/file_inspectedwindow_reload_target.sjs create mode 100644 browser/components/extensions/test/browser/file_popup_api_injection_a.html create mode 100644 browser/components/extensions/test/browser/file_popup_api_injection_b.html create mode 100644 browser/components/extensions/test/browser/file_slowed_document.sjs create mode 100644 browser/components/extensions/test/browser/file_title.html create mode 100644 browser/components/extensions/test/browser/file_with_example_com_frame.html create mode 100644 browser/components/extensions/test/browser/file_with_xorigin_frame.html create mode 100644 browser/components/extensions/test/browser/head.js create mode 100644 browser/components/extensions/test/browser/head_browserAction.js create mode 100644 browser/components/extensions/test/browser/head_devtools.js create mode 100644 browser/components/extensions/test/browser/head_pageAction.js create mode 100644 browser/components/extensions/test/browser/head_sessions.js create mode 100644 browser/components/extensions/test/browser/head_unified_extensions.js create mode 100644 browser/components/extensions/test/browser/head_webNavigation.js create mode 100644 browser/components/extensions/test/browser/redirect_to.sjs create mode 100644 browser/components/extensions/test/browser/search-engines/another/manifest.json create mode 100644 browser/components/extensions/test/browser/search-engines/basic/manifest.json create mode 100644 browser/components/extensions/test/browser/search-engines/engines.json create mode 100644 browser/components/extensions/test/browser/search-engines/simple/manifest.json create mode 100644 browser/components/extensions/test/browser/searchSuggestionEngine.sjs create mode 100644 browser/components/extensions/test/browser/searchSuggestionEngine.xml create mode 100644 browser/components/extensions/test/browser/silence.ogg create mode 100644 browser/components/extensions/test/browser/wait-a-bit.sjs create mode 100644 browser/components/extensions/test/browser/webNav_createdTarget.html create mode 100644 browser/components/extensions/test/browser/webNav_createdTargetSource.html create mode 100644 browser/components/extensions/test/browser/webNav_createdTargetSource_subframe.html create mode 100644 browser/components/extensions/test/mochitest/.eslintrc.js create mode 100644 browser/components/extensions/test/mochitest/mochitest.toml create mode 100644 browser/components/extensions/test/mochitest/test_ext_all_apis.html create mode 100644 browser/components/extensions/test/xpcshell/.eslintrc.js create mode 100644 browser/components/extensions/test/xpcshell/data/test/manifest.json create mode 100644 browser/components/extensions/test/xpcshell/data/test2/manifest.json create mode 100644 browser/components/extensions/test/xpcshell/head.js create mode 100644 browser/components/extensions/test/xpcshell/test_ext_bookmarks.js create mode 100644 browser/components/extensions/test/xpcshell/test_ext_browsingData_downloads.js create mode 100644 browser/components/extensions/test/xpcshell/test_ext_browsingData_passwords.js create mode 100644 browser/components/extensions/test/xpcshell/test_ext_browsingData_settings.js create mode 100644 browser/components/extensions/test/xpcshell/test_ext_chrome_settings_overrides_home.js create mode 100644 browser/components/extensions/test/xpcshell/test_ext_chrome_settings_overrides_update.js create mode 100644 browser/components/extensions/test/xpcshell/test_ext_distribution_popup.js create mode 100644 browser/components/extensions/test/xpcshell/test_ext_history.js create mode 100644 browser/components/extensions/test/xpcshell/test_ext_homepage_overrides_private.js create mode 100644 browser/components/extensions/test/xpcshell/test_ext_manifest.js create mode 100644 browser/components/extensions/test/xpcshell/test_ext_manifest_commands.js create mode 100644 browser/components/extensions/test/xpcshell/test_ext_manifest_omnibox.js create mode 100644 browser/components/extensions/test/xpcshell/test_ext_manifest_permissions.js create mode 100644 browser/components/extensions/test/xpcshell/test_ext_menu_caller.js create mode 100644 browser/components/extensions/test/xpcshell/test_ext_menu_startup.js create mode 100644 browser/components/extensions/test/xpcshell/test_ext_normandyAddonStudy.js create mode 100644 browser/components/extensions/test/xpcshell/test_ext_pageAction_shutdown.js create mode 100644 browser/components/extensions/test/xpcshell/test_ext_pkcs11_management.js create mode 100644 browser/components/extensions/test/xpcshell/test_ext_settings_overrides_defaults.js create mode 100644 browser/components/extensions/test/xpcshell/test_ext_settings_overrides_search.js create mode 100644 browser/components/extensions/test/xpcshell/test_ext_settings_overrides_search_mozParam.js create mode 100644 browser/components/extensions/test/xpcshell/test_ext_settings_overrides_shutdown.js create mode 100644 browser/components/extensions/test/xpcshell/test_ext_settings_validate.js create mode 100644 browser/components/extensions/test/xpcshell/test_ext_topSites.js create mode 100644 browser/components/extensions/test/xpcshell/test_ext_url_overrides_newtab.js create mode 100644 browser/components/extensions/test/xpcshell/test_ext_url_overrides_newtab_update.js create mode 100644 browser/components/extensions/test/xpcshell/xpcshell.toml (limited to 'browser/components/extensions/test') 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(""); + response.write( + "

Login: " + + (requestAuth ? "FAIL" : "PASS") + + "

\n" + ); + response.write("

Auth: " + authHeader + "

\n"); + response.write("

User: " + actual_user + "

\n"); + response.write("

Pass: " + actual_pass + "

\n"); + response.write(""); +} 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: [""], + 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 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": ` + + + +
+ + + +
+ + `, + }, + }); + + 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": ``, + }, + + 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": ` + + + + + + A Test Popup + + + `, + }, + + 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": ``, + "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 = () => ""; + + 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": ``, + "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": "popup", + }, + }); + + 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 => + `${url}`; + +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 => + `${url}`; + +// Tests that message ports still function correctly after a browserAction popup +// 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 => + `${url}`; + +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": ``, + }, + }); + + 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 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": ``, + "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 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": ` + + + + + + + + + `, + "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": + '', + }, + }); + + 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": ` + + + + + `, + + "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": `
`, + }, + }; + 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": `
`, + }, + }; + 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": "

1

" }, + useAddonManager: "temporary", + }); + let ext2 = ExtensionTestUtils.loadExtension({ + manifest: { chrome_settings_overrides: { homepage: "ext2.html" } }, + files: { "ext2.html": "

2

" }, + 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": "

1

" }, + 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": "

2

" }, + 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": "

1

" }, + 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": "

1

" }, + 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": "

non-private home

" }, + }); + + 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": `

Home Page!

`,
+      "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": `
+        
+        
+          
+            
+            
+          
+        
+      `,
+
+      "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 =>
+  `Test Popup`;
+
+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: [""],
+        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": `
+        
+        
+          
+            
+            
+          
+        
+      `,
+
+      "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  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  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": `
+        
+        
+        
+        
+        
+        
+        A Test Sidebar
+        
+      `,
+
+      "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": `
+        
+        
+      `,
+      "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": "data",
+      "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(
+              "NodeAdopted",
+              "text/html"
+            );
+            let doc = iframe.contentDocument;
+
+            let node = document.adoptNode(bold.documentElement);
+            doc.replaceChild(node, doc.documentElement);
+
+            const expected =
+              "NodeAdopted";
+            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": "data",
+      "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: [""],
+        matchAboutBlank: true,
+        allFrames: true,
+        runAt: "document_start",
+      });
+
+      await browser.contentScripts.register({
+        js: [{ file: "content_script.js" }],
+        matches: [""],
+        matchAboutBlank: true,
+        allFrames: true,
+        runAt: "document_start",
+      });
+
+      browser.test.sendMessage("executed");
+    });
+  }
+
+  let extensionData = {
+    manifest: {
+      permissions: ["tabs", ""],
+      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": ``,
+      "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": ``,
+      "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": ``,
+      "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": ``,
+      "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": ``,
+      "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  matches unsupported schemes in Chromium,
+        // we have specified that  only matches all supported
+        // schemes. To match any scheme, an extension should not set the
+        // targetUrlPatterns field - this is checked below in subtest
+        // unsupportedSchemeWithoutTargetUrlPatterns.
+        "",
+        "agnet:*",
+        "magne:*",
+      ],
+    },
+    {
+      // Link to bookmarklet.
+      testUrl: "javascript:-URL",
+      matchingPatterns: ["javascript:*", "javascript:*URL", "javascript:-URL"],
+      nonmatchingPatterns: [
+        "",
+        "javascript://-URL",
+        "javascript:javascript:-URL",
+      ],
+    },
+    {
+      // Link to bookmarklet with comment.
+      testUrl: "javascript://-URL",
+      matchingPatterns: [
+        "javascript:*",
+        "javascript://-URL",
+        "javascript:*URL",
+      ],
+      nonmatchingPatterns: ["", "javascript:-URL"],
+    },
+    {
+      // Link to data-URI.
+      testUrl: "data:application/foo,bar",
+      matchingPatterns: [
+        "",
+        "data:application/foo,bar",
+        "data:*,*",
+        "data:*",
+      ],
+      nonmatchingPatterns: ["data:,bar", "data:application/foo,"],
+    },
+    {
+      // Extension page.
+      testUrl: "moz-extension://uuid/manifest.json",
+      matchingPatterns: ["moz-extension://*/*"],
+      nonmatchingPatterns: [
+        "",
+        "moz-extension://uuid/not/manifest.json*",
+      ],
+    },
+    {
+      // While the scheme is supported, the URL is invalid.
+      testUrl: "http://",
+      matchingPatterns: [],
+      nonmatchingPatterns: ["http://*/*", ""],
+    },
+  ];
+
+  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": `
+        
+        
+        Test link
+        
+        
+      `,
+    },
+  });
+
+  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": `
+        
+        Test link
+        
+      `,
+    },
+  });
+
+  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": `
+      
+      
+      
+      
+      `,
+
+      "page.js": genericChecker,
+
+      "popup.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": `
+      
+       
+         
+       
+       
+         
+         
+       
+      `,
+      "devtools_page.js": devtools_page,
+      "devtools_page_iframe.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": `
+      
+       
+         
+         
+       
+       
+       
+      `,
+      "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": `
+      
+       
+         
+         
+       
+       
+       
+      `,
+      "devtools_page.js": devtools_page,
+      "devtools_panel.html": `
+      
+       
+         
+       
+       
+         DEVTOOLS PANEL
+         
+       
+      `,
+      "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": `
+      
+       
+         
+         
+       
+       
+       
+      `,
+      "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": `
+      
+       
+         
+         
+       
+       
+       
+      `,
+      "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": `
+      
+       
+         
+         
+       
+      `,
+      "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", ""],
+    },
+    files: {
+      "devtools_page.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,content process page";
+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": `
+        
+          
+            
+          
+          
+            
+          
+        `,
+      "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": `
+      
+        
+          
+          
+        
+        
+        
+      `,
+    "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(""),
+    "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": `
+        
+          
+            
+          
+          
+            
+          
+        `,
+      "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": `
+        
+          
+            
+          
+          
+            
+          
+        `,
+      "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": `
+        
+          
+            
+          
+          
+            
+          
+        `,
+      "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": `
+        
+          
+            
+          
+          
+            

Extension Tab Page

+ + + `, + "extension_tab.js": extension_tab, + "devtools_page.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": ` + + + + + + + + `, + "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 ` + + + + + + ${bodyText} + + + `; +} + +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": ` + + + + + + + + `, + "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": ` + + + + + + + + `, + "devtools_page.js": devtools_page, + "sidebar.html": ` + + + + + + sidebar panel + + + `, + "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 '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": ` + + + + + + + `, + + "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": ` + +

ExtensionPopup

+ `, + "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": "

New tab!

", + }, + 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": + '', + + "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: [""], + 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": + '', + + "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": + "", + "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 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": ``, + "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": `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": ` + + 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 = `Link`; + 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": ` + + Link +

Some text

+
+ + `, + "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": ` + + Link + + `, + "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": ` + + Link + + `, + "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": ` + + Link + + `, + "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": ``, + }, + 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": ``, + }, + 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": ``, + }, + 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": ` + + + + `, + "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 = `
`; + let host = doc.body.firstElementChild.attachShadow({ mode: "open" }); + host.innerHTML = `Test open`; + this.document.testTarget = host.firstElementChild; + return this.document.testTarget; + }); + + info("Clicking in closed shadow root"); + await testShadowMenu(() => { + let doc = this.document; + doc.body.innerHTML = `
`; + let host = doc.body.firstElementChild.attachShadow({ mode: "closed" }); + host.innerHTML = `Test closed`; + 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 = `
`; + host = container.firstElementChild.attachShadow({ mode: "open" }); + container = host; + } + host.innerHTML = `Test nested`; + 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": ``, + "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": ` + + + `, + "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": ` + + + + + + + +

Please Zoom Me

+ + + `, + "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": ``, + 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": `

Popup 1

`, + "popup-2.html": `

Popup 2

`, + + "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 . + 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 . + 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 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": ` + + + + + + + + + `, + "panel.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": ` + + + + + + +

Extensions Options

+ options page link + + `, + "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": ` + + + + + + + +
+ +
+ + + +
+ +
+ + + `, + "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": ` + + + + + + +

Extensions Options

+ options page link + + `, + "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": ` + + + + + + `, + "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": ` + + + + + + +

Extensions Options

+ options page link + + `, + "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": ` + + + + + + +

Extensions Options

+ options page link + + `, + "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": ` + + + + + + `, + "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": `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 , and granted example.com. + let ext3 = await makeExtension({ + id: "ext3@test", + host_permissions: [""], + granted: ["*://example.com/*"], + useAddonManager: "permanent", + }); + + // Has granted . + let ext4 = await makeExtension({ + id: "ext4@test", + host_permissions: [""], + granted: [""], + useAddonManager: "permanent", + }); + + // MV2 extension with an content script and activeTab. + let ext5 = await makeExtension({ + manifest_version: 2, + id: "ext5@test", + permissions: ["activeTab"], + content_scripts: [ + { + matches: [""], + 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 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": ` + + + + + + A Test Popup + + + `, + }, + + 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 => + ``; + + 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": `
`, + }, + }); + + 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": ` + + 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... + + `, + }, + }); + + 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: [""], + }, + shown: [true, true, false], + }, + { + name: "Test hide_matches overrides all_urls.", + page_action: { + show_matches: [""], + 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 is not allowed in hide_matches"); + let extension = getExtension({ + show_matches: ["*://mochi.test/*"], + hide_matches: [""], + }); + 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": ` + + + + + `, + + "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": ` + + + + `, + }, + + 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": ` + + + + `, + }, + + 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,

Top Level Frame

" + ); + + 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": `
`, + }, + }; + 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": "

Extension Test Page

", + }, + }); + + 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": "

Extension Test Page

", + }, + }); + + 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": ` + `, + "popup-a.js": 'browser.test.sendMessage("from-popup-a");', + + "popup-b.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: ` + + + + `, + background_check: function (bg) { + is(bg, "rgb(0, 128, 0)", "Initial background should be green"); + }, + }, + { + browser_style: false, + popup: ` + + + + `, + background_check: function (bg) { + is(bg, "rgb(255, 255, 255)", "Initial background should be white"); + }, + }, + { + browser_style: false, + popup: ` + + + + + `, + background_check: function (bg) { + is(bg, "rgb(255, 255, 255)", "Initial background should be white"); + }, + }, + { + browser_style: false, + popup: ` + + + + + `, + 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": ` + + + + `, + }, + }); + + 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: [""], + }, + }, + files: { + "popup.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": ` + + + + `, + "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": ` + + + + + + +

Extension Popup

+ popup page link + + `, + "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: [""], + ...manifestProps, + }, + + files: { + "popup.html": ``, + "popup.js": async () => { + const success = await new Promise(resolve => { + browser.test.withHandlingUserInput(() => { + resolve( + browser.permissions.request({ + origins: [""], + }) + ); + }); + }); + 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": ` + + + +
+ +
+ + `, + }, + }); + + 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(` + + + +`); + +add_task(async function testPopupSelectPopup() { + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_action: { + default_popup: "popup.html", + browser_style: false, + }, + }, + + files: { + "popup.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 => + `${url}`; + + 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": `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": ` + `, + }, + }); +}; + +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": ``, + "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: [""], + }, + 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: [""], + }); + 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": ``, + "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": ``, + "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": ` + + + + + + `, + + "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": ` + + + + + + `, + + "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", ""], + }, + 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": ` + + + + + + + A Test Sidebar + + `, + + "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": ` + + + + + `, + "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": ` + + + + + + + Bugzilla + `, + "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 = ` + + + + + A Test Sidebar + +`; + +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": ` + + + + + + + A Test Sidebar + + + `, + + "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": ` + + + + + + + A Test Sidebar + + `, + "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": ` + + + + + + + A Test Sidebar + + + `, + + "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": ` + + + + + + + A Test Sidebar + + `, + + "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": ` + + + + + + + A Test Sidebar + + `, + "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": ` + + + + + + + A Test Sidebar + + `, + + "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": ``, + "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": ` + + + + test tab extension page + + + + +

test tab extension page

+ + + `, + }, + }); + + 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(` + + + + + + This is example.com page content + + + `); +}); + +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", "", "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": ``, + + "bg/background.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, + + + + + + +

data url page

+ + `; + + 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", ""], + }, + + 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} 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( + "", + error.fileName, + "Got expected fileName" + ); + browser.test.assertEq( + "Script '' 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( + "", + error.fileName, + "Got expected fileName" + ); + browser.test.assertEq( + "Script '' 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": ` + + + + `, + "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: ["", "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: [""], + }, + + 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": ` + + + + + `, + "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": ``, + "popup.html": ``, + }, + + 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": ` + + tab1 + `, + "tab2.html": ` + + tab2 + `, + }, + + 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: ["", "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": "", + "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: ` + + + + + + + + `, + }, + 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: "", + }, + 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": ` + + + `, + }, + + 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", ""], + }, + + 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": ` + + + + + + +

tab page

+ + + `.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, + + + + + + +

data url page

+ + `; + + 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": "

New tab!

", + }, + 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": '

New Tab!

' }, + 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": '

New Tab!

' }, + 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": ` +

New Tab!

+ `, + }, + 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": '

New Tab!

' }, + 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": `

New tab!

`, + "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": ` + + + + + + + + Link + + `, + + "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": ` + + + + + + + + + + + `, + "subframe.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", ""], + }, + }); + + 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", ""], + }, + }); + + 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", ""], + }, + files: { + "popup.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", ""], + }, + }); + + 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", ""], + }, + files: { + "popup.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 => + `${url}`; + +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": "", + "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": ` + + `, + }, + }); + 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": ``, + }, + }); + + 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: [""], + }, + ], + }, + 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": ` + + +

test popup

+ + + + `, + "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": ``, + "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 @@ + + + + + + + just some text 12345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890 + + +

+ Some link +

+ +

+ + + +

+ +

+
+ + +
+ + +

+ +

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?

+ + + + 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 @@ + + + + + + Just some text + + 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 @@ + + +

test iframe

+ + + 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 @@ + + +

test page

+ + + + 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 @@ + + + + diff --git a/browser/components/extensions/test/browser/ctxmenu-image.png b/browser/components/extensions/test/browser/ctxmenu-image.png new file mode 100644 index 0000000000..4c3be50847 Binary files /dev/null and b/browser/components/extensions/test/browser/ctxmenu-image.png differ 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 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 @@ + + + + + +
+
+
+ + + 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 @@ + + +Dummy test page + + + +

Dummy test page

+link + + 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 @@ + + + + +

Bánana 0

+ +

bAnana 1

+

fruitcake

+

ang

elf
ood

+

This is an example of an example in the same node.

+

This is an example of an example of ranges in separate nodes.

+ + + 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 @@ + + +wait-a-bit - _blank target 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 @@ + + + + + + + + + + + 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 @@ + + + + + + + +
+ + link to inspect + +
+ + 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 = ` + + + + + + + +

Iframe

+ + `; + // 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 = ` + + + + + test + + + +

Top-level

+

${userAgentHeader ?? "no user-agent header"}

+ + + `; + + 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 = ``; + } + + response.write(` + + + + + + +

IFRAME ${frames}

+
injected script NOT executed
+ + ${content} + + + `); +} 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 @@ + + + + + + + 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 @@ + + + + + + + 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(` + + + + + + + `); + + // 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(``); + } + response.write(``); + 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 @@ + + +Different title test page + + + +

A page with a different title

+ + 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 @@ + + + +Load an iframe from example.com

+ 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 @@ + + + +Load a cross-origin iframe from example.net

+ 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} + * 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 + // 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 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 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 ? "" : ""; + + let extension = ExtensionTestUtils.loadExtension({ + manifest: { + browser_action: { + default_popup: "popup.html", + default_area: "navbar", + browser_style: false, + }, + }, + + files: { + "popup.html": `${docType} + + + + + + + + + + + + `, + }, + }); + + 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 @@ + + + + +browser_searchSuggestionEngine searchSuggestionEngine.xml + + + diff --git a/browser/components/extensions/test/browser/silence.ogg b/browser/components/extensions/test/browser/silence.ogg new file mode 100644 index 0000000000..7bdd68ab27 Binary files /dev/null and b/browser/components/extensions/test/browser/silence.ogg differ 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 = "wait a bitok"; + 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 @@ + + + + WebNavigatio onCreatedNavigationTarget target + + + + Go back to the source page + + 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 @@ + + + + WebNavigatio onCreatedNavigationTarget source + + + + + + + + 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 @@ + + + + WebNavigatio onCreatedNavigationTarget source subframe + + + + + + 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 @@ + + + + WebExtension test + + + + + + + + + + + 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"] -- cgit v1.2.3