summaryrefslogtreecommitdiffstats
path: root/browser/base
diff options
context:
space:
mode:
Diffstat (limited to 'browser/base')
-rw-r--r--browser/base/content/aboutDialog-appUpdater.js300
-rw-r--r--browser/base/content/aboutDialog.css138
-rw-r--r--browser/base/content/aboutDialog.js105
-rw-r--r--browser/base/content/aboutDialog.xhtml156
-rw-r--r--browser/base/content/aboutFrameCrashed.html32
-rw-r--r--browser/base/content/aboutRestartRequired.js42
-rw-r--r--browser/base/content/aboutRestartRequired.xhtml59
-rw-r--r--browser/base/content/aboutRobots-icon.pngbin0 -> 7599 bytes
-rw-r--r--browser/base/content/aboutRobots.css7
-rw-r--r--browser/base/content/aboutRobots.js15
-rw-r--r--browser/base/content/aboutRobots.xhtml73
-rw-r--r--browser/base/content/aboutTabCrashed.css11
-rw-r--r--browser/base/content/aboutTabCrashed.js265
-rw-r--r--browser/base/content/aboutTabCrashed.xhtml113
-rw-r--r--browser/base/content/appmenu-viewcache.inc.xhtml690
-rw-r--r--browser/base/content/blanktab.html15
-rw-r--r--browser/base/content/blockedSite.js168
-rw-r--r--browser/base/content/blockedSite.xhtml83
-rw-r--r--browser/base/content/browser-a11yUtils.js80
-rw-r--r--browser/base/content/browser-addons.js1918
-rw-r--r--browser/base/content/browser-allTabsMenu.inc.xhtml43
-rw-r--r--browser/base/content/browser-allTabsMenu.js190
-rw-r--r--browser/base/content/browser-box.inc.xhtml28
-rw-r--r--browser/base/content/browser-captivePortal.js370
-rw-r--r--browser/base/content/browser-context.inc450
-rw-r--r--browser/base/content/browser-ctrlTab.js810
-rw-r--r--browser/base/content/browser-customization.js181
-rw-r--r--browser/base/content/browser-data-submission-info-bar.js122
-rw-r--r--browser/base/content/browser-development-helpers.js45
-rw-r--r--browser/base/content/browser-fullScreenAndPointerLock.js967
-rw-r--r--browser/base/content/browser-fullZoom.js737
-rw-r--r--browser/base/content/browser-gestureSupport.js993
-rw-r--r--browser/base/content/browser-graphics-utils.js59
-rw-r--r--browser/base/content/browser-menubar.inc514
-rw-r--r--browser/base/content/browser-pageActions.js1015
-rw-r--r--browser/base/content/browser-pagestyle.js125
-rw-r--r--browser/base/content/browser-places.js2268
-rw-r--r--browser/base/content/browser-safebrowsing.js75
-rw-r--r--browser/base/content/browser-sets.inc398
-rw-r--r--browser/base/content/browser-sidebar.js668
-rw-r--r--browser/base/content/browser-siteIdentity.js1326
-rw-r--r--browser/base/content/browser-sitePermissionPanel.js1049
-rw-r--r--browser/base/content/browser-siteProtections.js2644
-rw-r--r--browser/base/content/browser-sync.js1963
-rw-r--r--browser/base/content/browser-tabsintitlebar.js92
-rw-r--r--browser/base/content/browser-thumbnails.js224
-rw-r--r--browser/base/content/browser-toolbarKeyNav.js432
-rw-r--r--browser/base/content/browser-unified-extensions.js204
-rw-r--r--browser/base/content/browser-webrtc.js140
-rw-r--r--browser/base/content/browser.css1682
-rw-r--r--browser/base/content/browser.js9955
-rw-r--r--browser/base/content/browser.xhtml175
-rw-r--r--browser/base/content/contentTheme.js218
-rw-r--r--browser/base/content/default-bookmarks.html69
-rw-r--r--browser/base/content/docs/tabbrowser/async-tab-switcher.rst239
-rw-r--r--browser/base/content/docs/tabbrowser/index.rst35
-rw-r--r--browser/base/content/fullscreen-and-pointerlock.inc.xhtml30
-rw-r--r--browser/base/content/global-scripts.inc25
-rw-r--r--browser/base/content/hiddenWindowMac.xhtml33
-rw-r--r--browser/base/content/logos/etp-mobile.svg13
-rw-r--r--browser/base/content/logos/fxa-logo.svg6
-rw-r--r--browser/base/content/logos/lockwise.svg4
-rw-r--r--browser/base/content/logos/monitor.svg4
-rw-r--r--browser/base/content/logos/proxy-dark.svg4
-rw-r--r--browser/base/content/logos/proxy-light.svg4
-rw-r--r--browser/base/content/logos/relay.svg33
-rw-r--r--browser/base/content/logos/send.svg4
-rw-r--r--browser/base/content/logos/tracking-protection-dark-theme.svg4
-rw-r--r--browser/base/content/logos/tracking-protection.svg4
-rw-r--r--browser/base/content/logos/vpn-dark.svg6
-rw-r--r--browser/base/content/logos/vpn-light.svg6
-rw-r--r--browser/base/content/logos/vpn-promo-logo.svg4
-rw-r--r--browser/base/content/macWindow.inc.xhtml37
-rw-r--r--browser/base/content/main-popupset.inc.xhtml655
-rw-r--r--browser/base/content/metrics.yaml11
-rw-r--r--browser/base/content/moz.build182
-rw-r--r--browser/base/content/navigator-toolbox.inc.xhtml728
-rw-r--r--browser/base/content/nonbrowser-mac.js164
-rw-r--r--browser/base/content/nsContextMenu.js2586
-rw-r--r--browser/base/content/overrides/app-license.html8
-rw-r--r--browser/base/content/pageinfo/pageInfo.css89
-rw-r--r--browser/base/content/pageinfo/pageInfo.js1172
-rw-r--r--browser/base/content/pageinfo/pageInfo.xhtml411
-rw-r--r--browser/base/content/pageinfo/permissions.js240
-rw-r--r--browser/base/content/pageinfo/security.js426
-rw-r--r--browser/base/content/popup-notifications.inc245
-rw-r--r--browser/base/content/robot.icobin0 -> 1791 bytes
-rw-r--r--browser/base/content/safeMode.css7
-rw-r--r--browser/base/content/safeMode.js85
-rw-r--r--browser/base/content/safeMode.xhtml49
-rw-r--r--browser/base/content/sanitize.xhtml135
-rw-r--r--browser/base/content/sanitizeDialog.css73
-rw-r--r--browser/base/content/sanitizeDialog.js278
-rw-r--r--browser/base/content/spotlight.html30
-rw-r--r--browser/base/content/spotlight.js86
-rw-r--r--browser/base/content/static-robot.pngbin0 -> 224 bytes
-rw-r--r--browser/base/content/swipe-navigation.inc.xhtml33
-rw-r--r--browser/base/content/tabbrowser-tab.js670
-rw-r--r--browser/base/content/tabbrowser-tabs.js2172
-rw-r--r--browser/base/content/tabbrowser.css101
-rw-r--r--browser/base/content/tabbrowser.js7800
-rw-r--r--browser/base/content/test/about/POSTSearchEngine.xml6
-rw-r--r--browser/base/content/test/about/browser.ini59
-rw-r--r--browser/base/content/test/about/browser_aboutCertError.js548
-rw-r--r--browser/base/content/test/about/browser_aboutCertError_clockSkew.js153
-rw-r--r--browser/base/content/test/about/browser_aboutCertError_exception.js221
-rw-r--r--browser/base/content/test/about/browser_aboutCertError_mitm.js158
-rw-r--r--browser/base/content/test/about/browser_aboutCertError_noSubjectAltName.js67
-rw-r--r--browser/base/content/test/about/browser_aboutCertError_offlineSupport.js51
-rw-r--r--browser/base/content/test/about/browser_aboutCertError_telemetry.js164
-rw-r--r--browser/base/content/test/about/browser_aboutDialog_distribution.js66
-rw-r--r--browser/base/content/test/about/browser_aboutHome_search_POST.js104
-rw-r--r--browser/base/content/test/about/browser_aboutHome_search_composing.js110
-rw-r--r--browser/base/content/test/about/browser_aboutHome_search_searchbar.js44
-rw-r--r--browser/base/content/test/about/browser_aboutHome_search_suggestion.js78
-rw-r--r--browser/base/content/test/about/browser_aboutHome_search_telemetry.js101
-rw-r--r--browser/base/content/test/about/browser_aboutNetError.js245
-rw-r--r--browser/base/content/test/about/browser_aboutNetError_csp_iframe.js153
-rw-r--r--browser/base/content/test/about/browser_aboutNetError_native_fallback.js174
-rw-r--r--browser/base/content/test/about/browser_aboutNetError_trr.js189
-rw-r--r--browser/base/content/test/about/browser_aboutNetError_xfo_iframe.js139
-rw-r--r--browser/base/content/test/about/browser_aboutNewTab_bookmarksToolbar.js311
-rw-r--r--browser/base/content/test/about/browser_aboutNewTab_bookmarksToolbarEmpty.js158
-rw-r--r--browser/base/content/test/about/browser_aboutNewTab_bookmarksToolbarNewWindow.js82
-rw-r--r--browser/base/content/test/about/browser_aboutNewTab_bookmarksToolbarPrefs.js74
-rw-r--r--browser/base/content/test/about/browser_aboutStopReload.js169
-rw-r--r--browser/base/content/test/about/browser_aboutSupport.js146
-rw-r--r--browser/base/content/test/about/browser_aboutSupport_newtab_security_state.js19
-rw-r--r--browser/base/content/test/about/browser_aboutSupport_places.js45
-rw-r--r--browser/base/content/test/about/browser_bug435325.js58
-rw-r--r--browser/base/content/test/about/browser_bug633691.js32
-rw-r--r--browser/base/content/test/about/csp_iframe.sjs33
-rw-r--r--browser/base/content/test/about/dummy_page.html9
-rw-r--r--browser/base/content/test/about/head.js220
-rw-r--r--browser/base/content/test/about/iframe_page_csp.html16
-rw-r--r--browser/base/content/test/about/iframe_page_xfo.html16
-rw-r--r--browser/base/content/test/about/print_postdata.sjs25
-rw-r--r--browser/base/content/test/about/searchSuggestionEngine.sjs9
-rw-r--r--browser/base/content/test/about/searchSuggestionEngine.xml11
-rw-r--r--browser/base/content/test/about/slow_loading_page.sjs29
-rw-r--r--browser/base/content/test/about/xfo_iframe.sjs34
-rw-r--r--browser/base/content/test/alerts/browser.ini22
-rw-r--r--browser/base/content/test/alerts/browser_notification_close.js107
-rw-r--r--browser/base/content/test/alerts/browser_notification_do_not_disturb.js160
-rw-r--r--browser/base/content/test/alerts/browser_notification_open_settings.js80
-rw-r--r--browser/base/content/test/alerts/browser_notification_remove_permission.js86
-rw-r--r--browser/base/content/test/alerts/browser_notification_replace.js66
-rw-r--r--browser/base/content/test/alerts/browser_notification_tab_switching.js117
-rw-r--r--browser/base/content/test/alerts/file_dom_notifications.html39
-rw-r--r--browser/base/content/test/alerts/head.js73
-rw-r--r--browser/base/content/test/backforward/browser.ini2
-rw-r--r--browser/base/content/test/backforward/browser_history_menu.js175
-rw-r--r--browser/base/content/test/caps/browser.ini6
-rw-r--r--browser/base/content/test/caps/browser_principalSerialization_csp.js106
-rw-r--r--browser/base/content/test/caps/browser_principalSerialization_json.js161
-rw-r--r--browser/base/content/test/caps/browser_principalSerialization_version1.js159
-rw-r--r--browser/base/content/test/captivePortal/browser.ini12
-rw-r--r--browser/base/content/test/captivePortal/browser_CaptivePortalWatcher.js125
-rw-r--r--browser/base/content/test/captivePortal/browser_CaptivePortalWatcher_1.js108
-rw-r--r--browser/base/content/test/captivePortal/browser_captivePortalTabReference.js65
-rw-r--r--browser/base/content/test/captivePortal/browser_captivePortal_certErrorUI.js221
-rw-r--r--browser/base/content/test/captivePortal/browser_captivePortal_https_only.js73
-rw-r--r--browser/base/content/test/captivePortal/browser_closeCapPortalTabCanonicalURL.js152
-rw-r--r--browser/base/content/test/captivePortal/head.js260
-rw-r--r--browser/base/content/test/chrome/chrome.ini4
-rw-r--r--browser/base/content/test/chrome/test_aboutCrashed.xhtml77
-rw-r--r--browser/base/content/test/chrome/test_aboutRestartRequired.xhtml76
-rw-r--r--browser/base/content/test/contentTheme/browser.ini3
-rw-r--r--browser/base/content/test/contentTheme/browser_contentTheme_in_process_tab.js80
-rw-r--r--browser/base/content/test/contextMenu/browser.ini91
-rw-r--r--browser/base/content/test/contextMenu/browser_bug1798178.js89
-rw-r--r--browser/base/content/test/contextMenu/browser_contextmenu.js1943
-rw-r--r--browser/base/content/test/contextMenu/browser_contextmenu_badiframe.js182
-rw-r--r--browser/base/content/test/contextMenu/browser_contextmenu_contenteditable.js118
-rw-r--r--browser/base/content/test/contextMenu/browser_contextmenu_iframe.js73
-rw-r--r--browser/base/content/test/contextMenu/browser_contextmenu_input.js387
-rw-r--r--browser/base/content/test/contextMenu/browser_contextmenu_inspect.js61
-rw-r--r--browser/base/content/test/contextMenu/browser_contextmenu_keyword.js198
-rw-r--r--browser/base/content/test/contextMenu/browser_contextmenu_linkopen.js109
-rw-r--r--browser/base/content/test/contextMenu/browser_contextmenu_loadblobinnewtab.html56
-rw-r--r--browser/base/content/test/contextMenu/browser_contextmenu_loadblobinnewtab.js186
-rw-r--r--browser/base/content/test/contextMenu/browser_contextmenu_save_blocked.js78
-rw-r--r--browser/base/content/test/contextMenu/browser_contextmenu_share_macosx.js144
-rw-r--r--browser/base/content/test/contextMenu/browser_contextmenu_share_win.js77
-rw-r--r--browser/base/content/test/contextMenu/browser_contextmenu_shareurl.html2
-rw-r--r--browser/base/content/test/contextMenu/browser_contextmenu_spellcheck.js334
-rw-r--r--browser/base/content/test/contextMenu/browser_contextmenu_touch.js94
-rw-r--r--browser/base/content/test/contextMenu/browser_copy_image_link.js40
-rw-r--r--browser/base/content/test/contextMenu/browser_strip_on_share_link.js151
-rw-r--r--browser/base/content/test/contextMenu/browser_utilityOverlay.js78
-rw-r--r--browser/base/content/test/contextMenu/browser_utilityOverlayPrincipal.js72
-rw-r--r--browser/base/content/test/contextMenu/browser_view_image.js197
-rw-r--r--browser/base/content/test/contextMenu/bug1798178.sjs9
-rw-r--r--browser/base/content/test/contextMenu/contextmenu_common.js437
-rw-r--r--browser/base/content/test/contextMenu/ctxmenu-image.pngbin0 -> 5401 bytes
-rw-r--r--browser/base/content/test/contextMenu/doggy.pngbin0 -> 46876 bytes
-rw-r--r--browser/base/content/test/contextMenu/file_bug1798178.html5
-rw-r--r--browser/base/content/test/contextMenu/firebird.pngbin0 -> 16179 bytes
-rw-r--r--browser/base/content/test/contextMenu/firebird.png^headers^2
-rw-r--r--browser/base/content/test/contextMenu/subtst_contextmenu.html61
-rw-r--r--browser/base/content/test/contextMenu/subtst_contextmenu_input.html30
-rw-r--r--browser/base/content/test/contextMenu/subtst_contextmenu_keyword.html17
-rw-r--r--browser/base/content/test/contextMenu/subtst_contextmenu_webext.html12
-rw-r--r--browser/base/content/test/contextMenu/subtst_contextmenu_xul.xhtml9
-rw-r--r--browser/base/content/test/contextMenu/test_contextmenu_iframe.html11
-rw-r--r--browser/base/content/test/contextMenu/test_contextmenu_links.html14
-rw-r--r--browser/base/content/test/contextMenu/test_view_image_inline_svg.html15
-rw-r--r--browser/base/content/test/contextMenu/test_view_image_revoked_cached_blob.html40
-rw-r--r--browser/base/content/test/favicons/accept.html9
-rw-r--r--browser/base/content/test/favicons/accept.sjs15
-rw-r--r--browser/base/content/test/favicons/auth_test.html11
-rw-r--r--browser/base/content/test/favicons/auth_test.png0
-rw-r--r--browser/base/content/test/favicons/auth_test.png^headers^2
-rw-r--r--browser/base/content/test/favicons/blank.html6
-rw-r--r--browser/base/content/test/favicons/browser.ini113
-rw-r--r--browser/base/content/test/favicons/browser_bug408415.js34
-rw-r--r--browser/base/content/test/favicons/browser_bug550565.js35
-rw-r--r--browser/base/content/test/favicons/browser_favicon_accept.js30
-rw-r--r--browser/base/content/test/favicons/browser_favicon_auth.js27
-rw-r--r--browser/base/content/test/favicons/browser_favicon_cache.js50
-rw-r--r--browser/base/content/test/favicons/browser_favicon_change.js33
-rw-r--r--browser/base/content/test/favicons/browser_favicon_change_not_in_document.js55
-rw-r--r--browser/base/content/test/favicons/browser_favicon_credentials.js89
-rw-r--r--browser/base/content/test/favicons/browser_favicon_crossorigin.js61
-rw-r--r--browser/base/content/test/favicons/browser_favicon_load.js168
-rw-r--r--browser/base/content/test/favicons/browser_favicon_nostore.js169
-rw-r--r--browser/base/content/test/favicons/browser_favicon_referer.js62
-rw-r--r--browser/base/content/test/favicons/browser_favicon_store.js56
-rw-r--r--browser/base/content/test/favicons/browser_icon_discovery.js136
-rw-r--r--browser/base/content/test/favicons/browser_invalid_href_fallback.js29
-rw-r--r--browser/base/content/test/favicons/browser_missing_favicon.js36
-rw-r--r--browser/base/content/test/favicons/browser_mixed_content.js26
-rw-r--r--browser/base/content/test/favicons/browser_multiple_icons_in_short_timeframe.js37
-rw-r--r--browser/base/content/test/favicons/browser_oversized.js25
-rw-r--r--browser/base/content/test/favicons/browser_preferred_icons.js140
-rw-r--r--browser/base/content/test/favicons/browser_redirect.js20
-rw-r--r--browser/base/content/test/favicons/browser_rich_icons.js50
-rw-r--r--browser/base/content/test/favicons/browser_rooticon.js24
-rw-r--r--browser/base/content/test/favicons/browser_subframe_favicons_not_used.js22
-rw-r--r--browser/base/content/test/favicons/browser_title_flicker.js185
-rw-r--r--browser/base/content/test/favicons/cookie_favicon.html11
-rw-r--r--browser/base/content/test/favicons/cookie_favicon.sjs26
-rw-r--r--browser/base/content/test/favicons/credentials.pngbin0 -> 580 bytes
-rw-r--r--browser/base/content/test/favicons/credentials.png^headers^3
-rw-r--r--browser/base/content/test/favicons/credentials1.html10
-rw-r--r--browser/base/content/test/favicons/credentials2.html10
-rw-r--r--browser/base/content/test/favicons/crossorigin.html10
-rw-r--r--browser/base/content/test/favicons/crossorigin.pngbin0 -> 580 bytes
-rw-r--r--browser/base/content/test/favicons/crossorigin.png^headers^1
-rw-r--r--browser/base/content/test/favicons/datauri-favicon.html8
-rw-r--r--browser/base/content/test/favicons/discovery.html8
-rw-r--r--browser/base/content/test/favicons/file_bug970276_favicon1.icobin0 -> 1406 bytes
-rw-r--r--browser/base/content/test/favicons/file_bug970276_favicon2.icobin0 -> 1406 bytes
-rw-r--r--browser/base/content/test/favicons/file_bug970276_popup1.html14
-rw-r--r--browser/base/content/test/favicons/file_bug970276_popup2.html12
-rw-r--r--browser/base/content/test/favicons/file_favicon.html11
-rw-r--r--browser/base/content/test/favicons/file_favicon.pngbin0 -> 344 bytes
-rw-r--r--browser/base/content/test/favicons/file_favicon.png^headers^1
-rw-r--r--browser/base/content/test/favicons/file_favicon_change.html13
-rw-r--r--browser/base/content/test/favicons/file_favicon_change_not_in_document.html20
-rw-r--r--browser/base/content/test/favicons/file_favicon_no_referrer.html11
-rw-r--r--browser/base/content/test/favicons/file_favicon_redirect.html12
-rw-r--r--browser/base/content/test/favicons/file_favicon_redirect.ico0
-rw-r--r--browser/base/content/test/favicons/file_favicon_redirect.ico^headers^2
-rw-r--r--browser/base/content/test/favicons/file_favicon_thirdParty.html11
-rw-r--r--browser/base/content/test/favicons/file_generic_favicon.icobin0 -> 1406 bytes
-rw-r--r--browser/base/content/test/favicons/file_insecure_favicon.html11
-rw-r--r--browser/base/content/test/favicons/file_invalid_href.html12
-rw-r--r--browser/base/content/test/favicons/file_mask_icon.html11
-rw-r--r--browser/base/content/test/favicons/file_rich_icon.html12
-rw-r--r--browser/base/content/test/favicons/file_with_favicon.html12
-rw-r--r--browser/base/content/test/favicons/file_with_slow_favicon.html10
-rw-r--r--browser/base/content/test/favicons/head.js98
-rw-r--r--browser/base/content/test/favicons/icon.svg11
-rw-r--r--browser/base/content/test/favicons/large.pngbin0 -> 21237 bytes
-rw-r--r--browser/base/content/test/favicons/large_favicon.html12
-rw-r--r--browser/base/content/test/favicons/moz.pngbin0 -> 580 bytes
-rw-r--r--browser/base/content/test/favicons/no-store.html11
-rw-r--r--browser/base/content/test/favicons/no-store.pngbin0 -> 580 bytes
-rw-r--r--browser/base/content/test/favicons/no-store.png^headers^1
-rw-r--r--browser/base/content/test/favicons/rich_moz_1.pngbin0 -> 580 bytes
-rw-r--r--browser/base/content/test/favicons/rich_moz_2.pngbin0 -> 580 bytes
-rw-r--r--browser/base/content/test/forms/browser.ini21
-rw-r--r--browser/base/content/test/forms/browser_selectpopup.js913
-rw-r--r--browser/base/content/test/forms/browser_selectpopup_colors.js867
-rw-r--r--browser/base/content/test/forms/browser_selectpopup_dir.js21
-rw-r--r--browser/base/content/test/forms/browser_selectpopup_large.js338
-rw-r--r--browser/base/content/test/forms/browser_selectpopup_searchfocus.js36
-rw-r--r--browser/base/content/test/forms/browser_selectpopup_text_transform.js40
-rw-r--r--browser/base/content/test/forms/browser_selectpopup_toplevel.js19
-rw-r--r--browser/base/content/test/forms/browser_selectpopup_user_input.js90
-rw-r--r--browser/base/content/test/forms/browser_selectpopup_width.js49
-rw-r--r--browser/base/content/test/forms/browser_selectpopup_xhtml.js36
-rw-r--r--browser/base/content/test/forms/head.js51
-rw-r--r--browser/base/content/test/fullscreen/FullscreenFrame.sys.mjs103
-rw-r--r--browser/base/content/test/fullscreen/browser.ini31
-rw-r--r--browser/base/content/test/fullscreen/browser_bug1557041.js47
-rw-r--r--browser/base/content/test/fullscreen/browser_bug1620341.js108
-rw-r--r--browser/base/content/test/fullscreen/browser_fullscreen_api_fission.js252
-rw-r--r--browser/base/content/test/fullscreen/browser_fullscreen_context_menu.js142
-rw-r--r--browser/base/content/test/fullscreen/browser_fullscreen_cross_origin.js64
-rw-r--r--browser/base/content/test/fullscreen/browser_fullscreen_enterInUrlbar.js56
-rw-r--r--browser/base/content/test/fullscreen/browser_fullscreen_from_minimize.js82
-rw-r--r--browser/base/content/test/fullscreen/browser_fullscreen_keydown_reservation.js112
-rw-r--r--browser/base/content/test/fullscreen/browser_fullscreen_menus.js72
-rw-r--r--browser/base/content/test/fullscreen/browser_fullscreen_newtab.js55
-rw-r--r--browser/base/content/test/fullscreen/browser_fullscreen_newwindow.js83
-rw-r--r--browser/base/content/test/fullscreen/browser_fullscreen_permissions_prompt.js160
-rw-r--r--browser/base/content/test/fullscreen/browser_fullscreen_warning.js280
-rw-r--r--browser/base/content/test/fullscreen/browser_fullscreen_window_focus.js110
-rw-r--r--browser/base/content/test/fullscreen/browser_fullscreen_window_open.js102
-rw-r--r--browser/base/content/test/fullscreen/fullscreen.html12
-rw-r--r--browser/base/content/test/fullscreen/fullscreen_frame.html9
-rw-r--r--browser/base/content/test/fullscreen/head.js164
-rw-r--r--browser/base/content/test/fullscreen/open_and_focus_helper.html56
-rw-r--r--browser/base/content/test/general/alltabslistener.html8
-rw-r--r--browser/base/content/test/general/app_bug575561.html18
-rw-r--r--browser/base/content/test/general/app_subframe_bug575561.html12
-rw-r--r--browser/base/content/test/general/audio.oggbin0 -> 14293 bytes
-rw-r--r--browser/base/content/test/general/browser.ini416
-rw-r--r--browser/base/content/test/general/browser_accesskeys.js202
-rw-r--r--browser/base/content/test/general/browser_addCertException.js77
-rw-r--r--browser/base/content/test/general/browser_alltabslistener.js331
-rw-r--r--browser/base/content/test/general/browser_backButtonFitts.js40
-rw-r--r--browser/base/content/test/general/browser_beforeunload_duplicate_dialogs.js114
-rw-r--r--browser/base/content/test/general/browser_bug1261299.js112
-rw-r--r--browser/base/content/test/general/browser_bug1297539.js122
-rw-r--r--browser/base/content/test/general/browser_bug1299667.js70
-rw-r--r--browser/base/content/test/general/browser_bug321000.js91
-rw-r--r--browser/base/content/test/general/browser_bug356571.js100
-rw-r--r--browser/base/content/test/general/browser_bug380960.js18
-rw-r--r--browser/base/content/test/general/browser_bug406216.js64
-rw-r--r--browser/base/content/test/general/browser_bug417483.js50
-rw-r--r--browser/base/content/test/general/browser_bug424101.js72
-rw-r--r--browser/base/content/test/general/browser_bug427559.js41
-rw-r--r--browser/base/content/test/general/browser_bug431826.js56
-rw-r--r--browser/base/content/test/general/browser_bug432599.js109
-rw-r--r--browser/base/content/test/general/browser_bug455852.js27
-rw-r--r--browser/base/content/test/general/browser_bug462289.js144
-rw-r--r--browser/base/content/test/general/browser_bug462673.js66
-rw-r--r--browser/base/content/test/general/browser_bug479408.js23
-rw-r--r--browser/base/content/test/general/browser_bug479408_sample.html4
-rw-r--r--browser/base/content/test/general/browser_bug481560.js16
-rw-r--r--browser/base/content/test/general/browser_bug484315.js14
-rw-r--r--browser/base/content/test/general/browser_bug491431.js42
-rw-r--r--browser/base/content/test/general/browser_bug495058.js53
-rw-r--r--browser/base/content/test/general/browser_bug519216.js48
-rw-r--r--browser/base/content/test/general/browser_bug520538.js27
-rw-r--r--browser/base/content/test/general/browser_bug521216.js68
-rw-r--r--browser/base/content/test/general/browser_bug533232.js56
-rw-r--r--browser/base/content/test/general/browser_bug537013.js168
-rw-r--r--browser/base/content/test/general/browser_bug537474.js20
-rw-r--r--browser/base/content/test/general/browser_bug563588.js42
-rw-r--r--browser/base/content/test/general/browser_bug565575.js21
-rw-r--r--browser/base/content/test/general/browser_bug567306.js65
-rw-r--r--browser/base/content/test/general/browser_bug575561.js118
-rw-r--r--browser/base/content/test/general/browser_bug577121.js27
-rw-r--r--browser/base/content/test/general/browser_bug578534.js31
-rw-r--r--browser/base/content/test/general/browser_bug579872.js26
-rw-r--r--browser/base/content/test/general/browser_bug581253.js74
-rw-r--r--browser/base/content/test/general/browser_bug585785.js48
-rw-r--r--browser/base/content/test/general/browser_bug585830.js27
-rw-r--r--browser/base/content/test/general/browser_bug594131.js25
-rw-r--r--browser/base/content/test/general/browser_bug596687.js28
-rw-r--r--browser/base/content/test/general/browser_bug597218.js40
-rw-r--r--browser/base/content/test/general/browser_bug609700.js28
-rw-r--r--browser/base/content/test/general/browser_bug623893.js50
-rw-r--r--browser/base/content/test/general/browser_bug624734.js49
-rw-r--r--browser/base/content/test/general/browser_bug664672.js27
-rw-r--r--browser/base/content/test/general/browser_bug676619.js225
-rw-r--r--browser/base/content/test/general/browser_bug710878.js49
-rw-r--r--browser/base/content/test/general/browser_bug724239.js56
-rw-r--r--browser/base/content/test/general/browser_bug734076.js195
-rw-r--r--browser/base/content/test/general/browser_bug749738.js32
-rw-r--r--browser/base/content/test/general/browser_bug763468_perwindowpb.js57
-rw-r--r--browser/base/content/test/general/browser_bug767836_perwindowpb.js72
-rw-r--r--browser/base/content/test/general/browser_bug817947.js51
-rw-r--r--browser/base/content/test/general/browser_bug832435.js26
-rw-r--r--browser/base/content/test/general/browser_bug882977.js33
-rw-r--r--browser/base/content/test/general/browser_bug963945.js26
-rw-r--r--browser/base/content/test/general/browser_clipboard.js290
-rw-r--r--browser/base/content/test/general/browser_clipboard_pastefile.js133
-rw-r--r--browser/base/content/test/general/browser_contentAltClick.js205
-rw-r--r--browser/base/content/test/general/browser_ctrlTab.js464
-rw-r--r--browser/base/content/test/general/browser_datachoices_notification.js287
-rw-r--r--browser/base/content/test/general/browser_documentnavigation.js493
-rw-r--r--browser/base/content/test/general/browser_domFullscreen_fullscreenMode.js237
-rw-r--r--browser/base/content/test/general/browser_double_close_tab.js120
-rw-r--r--browser/base/content/test/general/browser_drag.js64
-rw-r--r--browser/base/content/test/general/browser_duplicateIDs.js11
-rw-r--r--browser/base/content/test/general/browser_findbarClose.js47
-rw-r--r--browser/base/content/test/general/browser_focusonkeydown.js34
-rw-r--r--browser/base/content/test/general/browser_fullscreen-window-open.js366
-rw-r--r--browser/base/content/test/general/browser_gestureSupport.js1132
-rw-r--r--browser/base/content/test/general/browser_hide_removing.js27
-rw-r--r--browser/base/content/test/general/browser_homeDrop.js117
-rw-r--r--browser/base/content/test/general/browser_invalid_uri_back_forward_manipulation.js48
-rw-r--r--browser/base/content/test/general/browser_lastAccessedTab.js62
-rw-r--r--browser/base/content/test/general/browser_menuButtonFitts.js69
-rw-r--r--browser/base/content/test/general/browser_middleMouse_noJSPaste.js49
-rw-r--r--browser/base/content/test/general/browser_minimize.js49
-rw-r--r--browser/base/content/test/general/browser_modifiedclick_inherit_principal.js42
-rw-r--r--browser/base/content/test/general/browser_newTabDrop.js221
-rw-r--r--browser/base/content/test/general/browser_newWindowDrop.js230
-rw-r--r--browser/base/content/test/general/browser_new_http_window_opened_from_file_tab.js63
-rw-r--r--browser/base/content/test/general/browser_newwindow_focus.js93
-rw-r--r--browser/base/content/test/general/browser_plainTextLinks.js237
-rw-r--r--browser/base/content/test/general/browser_printpreview.js43
-rw-r--r--browser/base/content/test/general/browser_private_browsing_window.js133
-rw-r--r--browser/base/content/test/general/browser_private_no_prompt.js12
-rw-r--r--browser/base/content/test/general/browser_refreshBlocker.js209
-rw-r--r--browser/base/content/test/general/browser_relatedTabs.js74
-rw-r--r--browser/base/content/test/general/browser_remoteTroubleshoot.js130
-rw-r--r--browser/base/content/test/general/browser_remoteWebNavigation_postdata.js53
-rw-r--r--browser/base/content/test/general/browser_restore_isAppTab.js87
-rw-r--r--browser/base/content/test/general/browser_save_link-perwindowpb.js214
-rw-r--r--browser/base/content/test/general/browser_save_link_when_window_navigates.js197
-rw-r--r--browser/base/content/test/general/browser_save_private_link_perwindowpb.js127
-rw-r--r--browser/base/content/test/general/browser_save_video.js99
-rw-r--r--browser/base/content/test/general/browser_save_video_frame.js103
-rw-r--r--browser/base/content/test/general/browser_selectTabAtIndex.js89
-rw-r--r--browser/base/content/test/general/browser_star_hsts.js87
-rw-r--r--browser/base/content/test/general/browser_star_hsts.sjs12
-rw-r--r--browser/base/content/test/general/browser_storagePressure_notification.js182
-rw-r--r--browser/base/content/test/general/browser_tabDrop.js207
-rw-r--r--browser/base/content/test/general/browser_tab_close_dependent_window.js35
-rw-r--r--browser/base/content/test/general/browser_tab_detach_restore.js54
-rw-r--r--browser/base/content/test/general/browser_tab_drag_drop_perwindow.js423
-rw-r--r--browser/base/content/test/general/browser_tab_dragdrop.js257
-rw-r--r--browser/base/content/test/general/browser_tab_dragdrop2.js65
-rw-r--r--browser/base/content/test/general/browser_tab_dragdrop2_frame1.xhtml158
-rw-r--r--browser/base/content/test/general/browser_tab_dragdrop_embed.html2
-rw-r--r--browser/base/content/test/general/browser_tabfocus.js811
-rw-r--r--browser/base/content/test/general/browser_tabs_close_beforeunload.js69
-rw-r--r--browser/base/content/test/general/browser_tabs_isActive.js235
-rw-r--r--browser/base/content/test/general/browser_tabs_owner.js40
-rw-r--r--browser/base/content/test/general/browser_testOpenNewRemoteTabsFromNonRemoteBrowsers.js144
-rw-r--r--browser/base/content/test/general/browser_typeAheadFind.js31
-rw-r--r--browser/base/content/test/general/browser_unknownContentType_title.js88
-rw-r--r--browser/base/content/test/general/browser_unloaddialogs.js40
-rw-r--r--browser/base/content/test/general/browser_viewSourceInTabOnViewSource.js60
-rw-r--r--browser/base/content/test/general/browser_visibleFindSelection.js62
-rw-r--r--browser/base/content/test/general/browser_visibleTabs.js125
-rw-r--r--browser/base/content/test/general/browser_visibleTabs_bookmarkAllPages.js35
-rw-r--r--browser/base/content/test/general/browser_visibleTabs_tabPreview.js52
-rw-r--r--browser/base/content/test/general/browser_windowactivation.js112
-rw-r--r--browser/base/content/test/general/browser_zbug569342.js77
-rw-r--r--browser/base/content/test/general/bug792517-2.html5
-rw-r--r--browser/base/content/test/general/bug792517.html5
-rw-r--r--browser/base/content/test/general/bug792517.sjs13
-rw-r--r--browser/base/content/test/general/clipboard_pastefile.html52
-rw-r--r--browser/base/content/test/general/close_beforeunload.html8
-rw-r--r--browser/base/content/test/general/close_beforeunload_opens_second_tab.html3
-rw-r--r--browser/base/content/test/general/download_page.html72
-rw-r--r--browser/base/content/test/general/download_page_1.txt1
-rw-r--r--browser/base/content/test/general/download_page_2.txt1
-rw-r--r--browser/base/content/test/general/download_with_content_disposition_header.sjs19
-rw-r--r--browser/base/content/test/general/dummy.ics13
-rw-r--r--browser/base/content/test/general/dummy.ics^headers^1
-rw-r--r--browser/base/content/test/general/dummy_page.html9
-rw-r--r--browser/base/content/test/general/file_documentnavigation_frameset.html12
-rw-r--r--browser/base/content/test/general/file_double_close_tab.html15
-rw-r--r--browser/base/content/test/general/file_fullscreen-window-open.html22
-rw-r--r--browser/base/content/test/general/file_window_activation.html4
-rw-r--r--browser/base/content/test/general/file_window_activation2.html1
-rw-r--r--browser/base/content/test/general/file_with_link_to_http.html9
-rw-r--r--browser/base/content/test/general/head.js347
-rw-r--r--browser/base/content/test/general/moz.pngbin0 -> 580 bytes
-rw-r--r--browser/base/content/test/general/navigating_window_with_download.html7
-rw-r--r--browser/base/content/test/general/print_postdata.sjs25
-rw-r--r--browser/base/content/test/general/redirect_download.sjs11
-rw-r--r--browser/base/content/test/general/refresh_header.sjs24
-rw-r--r--browser/base/content/test/general/refresh_meta.sjs36
-rw-r--r--browser/base/content/test/general/test_bug462673.html18
-rw-r--r--browser/base/content/test/general/test_bug628179.html9
-rw-r--r--browser/base/content/test/general/test_remoteTroubleshoot.html50
-rw-r--r--browser/base/content/test/general/title_test.svg59
-rw-r--r--browser/base/content/test/general/unknownContentType_file.pif1
-rw-r--r--browser/base/content/test/general/unknownContentType_file.pif^headers^1
-rw-r--r--browser/base/content/test/general/video.oggbin0 -> 285310 bytes
-rw-r--r--browser/base/content/test/general/web_video.html10
-rw-r--r--browser/base/content/test/general/web_video1.ogvbin0 -> 28942 bytes
-rw-r--r--browser/base/content/test/general/web_video1.ogv^headers^3
-rw-r--r--browser/base/content/test/gesture/browser.ini1
-rw-r--r--browser/base/content/test/gesture/browser_gesture_navigation.js233
-rw-r--r--browser/base/content/test/historySwipeAnimation/browser.ini1
-rw-r--r--browser/base/content/test/historySwipeAnimation/browser_historySwipeAnimation.js49
-rw-r--r--browser/base/content/test/keyboard/browser.ini19
-rw-r--r--browser/base/content/test/keyboard/browser_bookmarks_shortcut.js140
-rw-r--r--browser/base/content/test/keyboard/browser_cancel_caret_browsing_in_content.js91
-rw-r--r--browser/base/content/test/keyboard/browser_popup_keyNav.js50
-rw-r--r--browser/base/content/test/keyboard/browser_toolbarButtonKeyPress.js336
-rw-r--r--browser/base/content/test/keyboard/browser_toolbarKeyNav.js641
-rw-r--r--browser/base/content/test/keyboard/file_empty.html8
-rw-r--r--browser/base/content/test/keyboard/focusableContent.html1
-rw-r--r--browser/base/content/test/keyboard/head.js55
-rw-r--r--browser/base/content/test/menubar/browser.ini9
-rw-r--r--browser/base/content/test/menubar/browser_file_close_tabs.js60
-rw-r--r--browser/base/content/test/menubar/browser_file_menu_import_wizard.js27
-rw-r--r--browser/base/content/test/menubar/browser_file_share.js136
-rw-r--r--browser/base/content/test/menubar/file_shareurl.html2
-rw-r--r--browser/base/content/test/metaTags/bad_meta_tags.html14
-rw-r--r--browser/base/content/test/metaTags/browser.ini9
-rw-r--r--browser/base/content/test/metaTags/browser_bad_meta_tags.js37
-rw-r--r--browser/base/content/test/metaTags/browser_meta_tags.js57
-rw-r--r--browser/base/content/test/metaTags/head.js19
-rw-r--r--browser/base/content/test/metaTags/meta_tags.html29
-rw-r--r--browser/base/content/test/notificationbox/browser.ini3
-rw-r--r--browser/base/content/test/notificationbox/browser_notification_stacking.js78
-rw-r--r--browser/base/content/test/notificationbox/browser_notificationbar_telemetry.js219
-rw-r--r--browser/base/content/test/notificationbox/browser_tabnotificationbox_switch_tabs.js142
-rw-r--r--browser/base/content/test/outOfProcess/browser.ini15
-rw-r--r--browser/base/content/test/outOfProcess/browser_basic_outofprocess.js149
-rw-r--r--browser/base/content/test/outOfProcess/browser_controller.js127
-rw-r--r--browser/base/content/test/outOfProcess/browser_promisefocus.js262
-rw-r--r--browser/base/content/test/outOfProcess/file_base.html5
-rw-r--r--browser/base/content/test/outOfProcess/file_frame1.html5
-rw-r--r--browser/base/content/test/outOfProcess/file_frame2.html11
-rw-r--r--browser/base/content/test/outOfProcess/file_innerframe.html3
-rw-r--r--browser/base/content/test/outOfProcess/head.js85
-rw-r--r--browser/base/content/test/pageActions/browser.ini7
-rw-r--r--browser/base/content/test/pageActions/browser_PageActions_bookmark.js130
-rw-r--r--browser/base/content/test/pageActions/browser_PageActions_overflow.js257
-rw-r--r--browser/base/content/test/pageActions/browser_PageActions_removeExtension.js338
-rw-r--r--browser/base/content/test/pageActions/head.js163
-rw-r--r--browser/base/content/test/pageStyle/browser.ini16
-rw-r--r--browser/base/content/test/pageStyle/browser_disable_author_style_oop.js100
-rw-r--r--browser/base/content/test/pageStyle/browser_page_style_menu.js174
-rw-r--r--browser/base/content/test/pageStyle/browser_page_style_menu_update.js49
-rw-r--r--browser/base/content/test/pageStyle/head.js30
-rw-r--r--browser/base/content/test/pageStyle/page_style.html8
-rw-r--r--browser/base/content/test/pageStyle/page_style_only_alternates.html5
-rw-r--r--browser/base/content/test/pageStyle/page_style_sample.html45
-rw-r--r--browser/base/content/test/pageStyle/style.css1
-rw-r--r--browser/base/content/test/pageinfo/all_images.html15
-rw-r--r--browser/base/content/test/pageinfo/browser.ini27
-rw-r--r--browser/base/content/test/pageinfo/browser_pageinfo_firstPartyIsolation.js89
-rw-r--r--browser/base/content/test/pageinfo/browser_pageinfo_iframe_media.js31
-rw-r--r--browser/base/content/test/pageinfo/browser_pageinfo_image_info.js57
-rw-r--r--browser/base/content/test/pageinfo/browser_pageinfo_images.js93
-rw-r--r--browser/base/content/test/pageinfo/browser_pageinfo_permissions.js258
-rw-r--r--browser/base/content/test/pageinfo/browser_pageinfo_rtl.js28
-rw-r--r--browser/base/content/test/pageinfo/browser_pageinfo_security.js354
-rw-r--r--browser/base/content/test/pageinfo/browser_pageinfo_separate_private.js49
-rw-r--r--browser/base/content/test/pageinfo/browser_pageinfo_svg_image.js34
-rw-r--r--browser/base/content/test/pageinfo/iframes.html8
-rw-r--r--browser/base/content/test/pageinfo/image.html5
-rw-r--r--browser/base/content/test/pageinfo/svg_image.html11
-rw-r--r--browser/base/content/test/performance/PerfTestHelpers.sys.mjs79
-rw-r--r--browser/base/content/test/performance/StartupContentSubframe.sys.mjs55
-rw-r--r--browser/base/content/test/performance/browser.ini90
-rw-r--r--browser/base/content/test/performance/browser_appmenu.js129
-rw-r--r--browser/base/content/test/performance/browser_panel_vsync.js69
-rw-r--r--browser/base/content/test/performance/browser_preferences_usage.js282
-rw-r--r--browser/base/content/test/performance/browser_startup.js245
-rw-r--r--browser/base/content/test/performance/browser_startup_content.js196
-rw-r--r--browser/base/content/test/performance/browser_startup_content_mainthreadio.js438
-rw-r--r--browser/base/content/test/performance/browser_startup_content_subframe.js150
-rw-r--r--browser/base/content/test/performance/browser_startup_flicker.js85
-rw-r--r--browser/base/content/test/performance/browser_startup_hiddenwindow.js50
-rw-r--r--browser/base/content/test/performance/browser_startup_images.js136
-rw-r--r--browser/base/content/test/performance/browser_startup_mainthreadio.js881
-rw-r--r--browser/base/content/test/performance/browser_startup_syncIPC.js449
-rw-r--r--browser/base/content/test/performance/browser_tabclose.js108
-rw-r--r--browser/base/content/test/performance/browser_tabclose_grow.js91
-rw-r--r--browser/base/content/test/performance/browser_tabdetach.js118
-rw-r--r--browser/base/content/test/performance/browser_tabopen.js201
-rw-r--r--browser/base/content/test/performance/browser_tabopen_squeeze.js100
-rw-r--r--browser/base/content/test/performance/browser_tabstrip_overflow_underflow.js200
-rw-r--r--browser/base/content/test/performance/browser_tabswitch.js123
-rw-r--r--browser/base/content/test/performance/browser_toolbariconcolor_restyles.js65
-rw-r--r--browser/base/content/test/performance/browser_urlbar_keyed_search.js27
-rw-r--r--browser/base/content/test/performance/browser_urlbar_search.js27
-rw-r--r--browser/base/content/test/performance/browser_vsync_accessibility.js20
-rw-r--r--browser/base/content/test/performance/browser_window_resize.js132
-rw-r--r--browser/base/content/test/performance/browser_windowclose.js58
-rw-r--r--browser/base/content/test/performance/browser_windowopen.js182
-rw-r--r--browser/base/content/test/performance/file_empty.html1
-rw-r--r--browser/base/content/test/performance/head.js971
-rw-r--r--browser/base/content/test/performance/hidpi/browser.ini7
-rw-r--r--browser/base/content/test/performance/io/browser.ini33
-rw-r--r--browser/base/content/test/performance/lowdpi/browser.ini8
-rw-r--r--browser/base/content/test/performance/moz.build17
-rw-r--r--browser/base/content/test/performance/triage.json62
-rw-r--r--browser/base/content/test/perftest.ini1
-rw-r--r--browser/base/content/test/perftest_browser_xhtml_dom.js85
-rw-r--r--browser/base/content/test/permissions/browser.ini41
-rw-r--r--browser/base/content/test/permissions/browser_autoplay_blocked.html14
-rw-r--r--browser/base/content/test/permissions/browser_autoplay_blocked.js357
-rw-r--r--browser/base/content/test/permissions/browser_autoplay_blocked_slow.sjs36
-rw-r--r--browser/base/content/test/permissions/browser_autoplay_js.html16
-rw-r--r--browser/base/content/test/permissions/browser_autoplay_muted.html14
-rw-r--r--browser/base/content/test/permissions/browser_canvas_fingerprinting_resistance.js383
-rw-r--r--browser/base/content/test/permissions/browser_canvas_rfp_exclusion.js194
-rw-r--r--browser/base/content/test/permissions/browser_permission_delegate_geo.js279
-rw-r--r--browser/base/content/test/permissions/browser_permissions.js569
-rw-r--r--browser/base/content/test/permissions/browser_permissions_delegate_vibrate.js46
-rw-r--r--browser/base/content/test/permissions/browser_permissions_handling_user_input.js99
-rw-r--r--browser/base/content/test/permissions/browser_permissions_postPrompt.js104
-rw-r--r--browser/base/content/test/permissions/browser_reservedkey.js312
-rw-r--r--browser/base/content/test/permissions/browser_site_scoped_permissions.js106
-rw-r--r--browser/base/content/test/permissions/browser_temporary_permissions.js118
-rw-r--r--browser/base/content/test/permissions/browser_temporary_permissions_expiry.js208
-rw-r--r--browser/base/content/test/permissions/browser_temporary_permissions_navigation.js239
-rw-r--r--browser/base/content/test/permissions/browser_temporary_permissions_tabs.js148
-rw-r--r--browser/base/content/test/permissions/dummy.js1
-rw-r--r--browser/base/content/test/permissions/empty.html8
-rw-r--r--browser/base/content/test/permissions/head.js28
-rw-r--r--browser/base/content/test/permissions/permissions.html49
-rw-r--r--browser/base/content/test/permissions/temporary_permissions_frame.html12
-rw-r--r--browser/base/content/test/permissions/temporary_permissions_subframe.html11
-rw-r--r--browser/base/content/test/plugins/browser.ini14
-rw-r--r--browser/base/content/test/plugins/browser_bug797677.js45
-rw-r--r--browser/base/content/test/plugins/browser_enable_DRM_prompt.js232
-rw-r--r--browser/base/content/test/plugins/browser_globalplugin_crashinfobar.js63
-rw-r--r--browser/base/content/test/plugins/browser_private_browsing_eme_persistent_state.js59
-rw-r--r--browser/base/content/test/plugins/empty_file.html9
-rw-r--r--browser/base/content/test/plugins/head.js205
-rw-r--r--browser/base/content/test/plugins/plugin_bug797677.html5
-rw-r--r--browser/base/content/test/plugins/plugin_test.html9
-rw-r--r--browser/base/content/test/popupNotifications/browser.ini38
-rw-r--r--browser/base/content/test/popupNotifications/browser_displayURI.js159
-rw-r--r--browser/base/content/test/popupNotifications/browser_popupNotification.js394
-rw-r--r--browser/base/content/test/popupNotifications/browser_popupNotification_2.js315
-rw-r--r--browser/base/content/test/popupNotifications/browser_popupNotification_3.js377
-rw-r--r--browser/base/content/test/popupNotifications/browser_popupNotification_4.js290
-rw-r--r--browser/base/content/test/popupNotifications/browser_popupNotification_5.js501
-rw-r--r--browser/base/content/test/popupNotifications/browser_popupNotification_accesskey.js44
-rw-r--r--browser/base/content/test/popupNotifications/browser_popupNotification_checkbox.js248
-rw-r--r--browser/base/content/test/popupNotifications/browser_popupNotification_hide_after_identity_panel.js36
-rw-r--r--browser/base/content/test/popupNotifications/browser_popupNotification_hide_after_protections_panel.js44
-rw-r--r--browser/base/content/test/popupNotifications/browser_popupNotification_keyboard.js273
-rw-r--r--browser/base/content/test/popupNotifications/browser_popupNotification_learnmore.js64
-rw-r--r--browser/base/content/test/popupNotifications/browser_popupNotification_no_anchors.js288
-rw-r--r--browser/base/content/test/popupNotifications/browser_popupNotification_security_delay.js296
-rw-r--r--browser/base/content/test/popupNotifications/browser_popupNotification_selection_required.js57
-rw-r--r--browser/base/content/test/popupNotifications/browser_reshow_in_background.js72
-rw-r--r--browser/base/content/test/popupNotifications/head.js367
-rw-r--r--browser/base/content/test/popups/browser.ini69
-rw-r--r--browser/base/content/test/popups/browser_popupUI.js192
-rw-r--r--browser/base/content/test/popups/browser_popup_blocker.js155
-rw-r--r--browser/base/content/test/popups/browser_popup_blocker_frames.js100
-rw-r--r--browser/base/content/test/popups/browser_popup_blocker_identity_block.js242
-rw-r--r--browser/base/content/test/popups/browser_popup_blocker_iframes.js186
-rw-r--r--browser/base/content/test/popups/browser_popup_close_main_window.js84
-rw-r--r--browser/base/content/test/popups/browser_popup_frames.js128
-rw-r--r--browser/base/content/test/popups/browser_popup_inner_outer_size.js120
-rw-r--r--browser/base/content/test/popups/browser_popup_linux_move.js56
-rw-r--r--browser/base/content/test/popups/browser_popup_linux_resize.js53
-rw-r--r--browser/base/content/test/popups/browser_popup_move.js6
-rw-r--r--browser/base/content/test/popups/browser_popup_move_instant.js6
-rw-r--r--browser/base/content/test/popups/browser_popup_new_window_resize.js51
-rw-r--r--browser/base/content/test/popups/browser_popup_new_window_size.js90
-rw-r--r--browser/base/content/test/popups/browser_popup_resize.js6
-rw-r--r--browser/base/content/test/popups/browser_popup_resize_instant.js6
-rw-r--r--browser/base/content/test/popups/browser_popup_resize_repeat.js6
-rw-r--r--browser/base/content/test/popups/browser_popup_resize_repeat_instant.js6
-rw-r--r--browser/base/content/test/popups/browser_popup_resize_revert.js6
-rw-r--r--browser/base/content/test/popups/browser_popup_resize_revert_instant.js6
-rw-r--r--browser/base/content/test/popups/head.js574
-rw-r--r--browser/base/content/test/popups/popup_blocker.html13
-rw-r--r--browser/base/content/test/popups/popup_blocker2.html10
-rw-r--r--browser/base/content/test/popups/popup_blocker_10_popups.html14
-rw-r--r--browser/base/content/test/popups/popup_blocker_a.html1
-rw-r--r--browser/base/content/test/popups/popup_blocker_b.html1
-rw-r--r--browser/base/content/test/popups/popup_blocker_frame.html27
-rw-r--r--browser/base/content/test/popups/popup_size.html16
-rw-r--r--browser/base/content/test/protectionsUI/benignPage.html18
-rw-r--r--browser/base/content/test/protectionsUI/browser.ini63
-rw-r--r--browser/base/content/test/protectionsUI/browser_protectionsUI.js713
-rw-r--r--browser/base/content/test/protectionsUI/browser_protectionsUI_3.js224
-rw-r--r--browser/base/content/test/protectionsUI/browser_protectionsUI_background_tabs.js74
-rw-r--r--browser/base/content/test/protectionsUI/browser_protectionsUI_categories.js300
-rw-r--r--browser/base/content/test/protectionsUI/browser_protectionsUI_cookie_banner.js475
-rw-r--r--browser/base/content/test/protectionsUI/browser_protectionsUI_cookies_subview.js537
-rw-r--r--browser/base/content/test/protectionsUI/browser_protectionsUI_cryptominers.js306
-rw-r--r--browser/base/content/test/protectionsUI/browser_protectionsUI_email_trackers_subview.js179
-rw-r--r--browser/base/content/test/protectionsUI/browser_protectionsUI_fetch.js39
-rw-r--r--browser/base/content/test/protectionsUI/browser_protectionsUI_fingerprinters.js303
-rw-r--r--browser/base/content/test/protectionsUI/browser_protectionsUI_icon_state.js223
-rw-r--r--browser/base/content/test/protectionsUI/browser_protectionsUI_milestones.js95
-rw-r--r--browser/base/content/test/protectionsUI/browser_protectionsUI_open_preferences.js155
-rw-r--r--browser/base/content/test/protectionsUI/browser_protectionsUI_pbmode_exceptions.js175
-rw-r--r--browser/base/content/test/protectionsUI/browser_protectionsUI_report_breakage.js404
-rw-r--r--browser/base/content/test/protectionsUI/browser_protectionsUI_shield_visibility.js124
-rw-r--r--browser/base/content/test/protectionsUI/browser_protectionsUI_socialtracking.js321
-rw-r--r--browser/base/content/test/protectionsUI/browser_protectionsUI_state.js405
-rw-r--r--browser/base/content/test/protectionsUI/browser_protectionsUI_state_reset.js129
-rw-r--r--browser/base/content/test/protectionsUI/browser_protectionsUI_subview_shim.js403
-rw-r--r--browser/base/content/test/protectionsUI/browser_protectionsUI_telemetry.js89
-rw-r--r--browser/base/content/test/protectionsUI/browser_protectionsUI_trackers_subview.js134
-rw-r--r--browser/base/content/test/protectionsUI/containerPage.html6
-rw-r--r--browser/base/content/test/protectionsUI/cookiePage.html13
-rw-r--r--browser/base/content/test/protectionsUI/cookieServer.sjs24
-rw-r--r--browser/base/content/test/protectionsUI/cookieSetterPage.html6
-rw-r--r--browser/base/content/test/protectionsUI/emailTrackingPage.html12
-rw-r--r--browser/base/content/test/protectionsUI/embeddedPage.html6
-rw-r--r--browser/base/content/test/protectionsUI/file_protectionsUI_fetch.html17
-rw-r--r--browser/base/content/test/protectionsUI/file_protectionsUI_fetch.js2
-rw-r--r--browser/base/content/test/protectionsUI/file_protectionsUI_fetch.js^headers^1
-rw-r--r--browser/base/content/test/protectionsUI/head.js221
-rw-r--r--browser/base/content/test/protectionsUI/sandboxed.html12
-rw-r--r--browser/base/content/test/protectionsUI/sandboxed.html^headers^1
-rw-r--r--browser/base/content/test/protectionsUI/trackingAPI.js77
-rw-r--r--browser/base/content/test/protectionsUI/trackingPage.html13
-rw-r--r--browser/base/content/test/referrer/browser.ini35
-rw-r--r--browser/base/content/test/referrer/browser_referrer_click_pinned_tab.js82
-rw-r--r--browser/base/content/test/referrer/browser_referrer_middle_click.js25
-rw-r--r--browser/base/content/test/referrer/browser_referrer_middle_click_in_container.js33
-rw-r--r--browser/base/content/test/referrer/browser_referrer_open_link_in_container_tab.js80
-rw-r--r--browser/base/content/test/referrer/browser_referrer_open_link_in_container_tab2.js43
-rw-r--r--browser/base/content/test/referrer/browser_referrer_open_link_in_container_tab3.js81
-rw-r--r--browser/base/content/test/referrer/browser_referrer_open_link_in_private.js33
-rw-r--r--browser/base/content/test/referrer/browser_referrer_open_link_in_tab.js27
-rw-r--r--browser/base/content/test/referrer/browser_referrer_open_link_in_window.js28
-rw-r--r--browser/base/content/test/referrer/browser_referrer_open_link_in_window_in_container.js39
-rw-r--r--browser/base/content/test/referrer/browser_referrer_simple_click.js27
-rw-r--r--browser/base/content/test/referrer/file_referrer_policyserver.sjs41
-rw-r--r--browser/base/content/test/referrer/file_referrer_policyserver_attr.sjs41
-rw-r--r--browser/base/content/test/referrer/file_referrer_testserver.sjs30
-rw-r--r--browser/base/content/test/referrer/head.js311
-rw-r--r--browser/base/content/test/sanitize/browser.ini19
-rw-r--r--browser/base/content/test/sanitize/browser_cookiePermission.js1
-rw-r--r--browser/base/content/test/sanitize/browser_cookiePermission_aboutURL.js101
-rw-r--r--browser/base/content/test/sanitize/browser_cookiePermission_containers.js1
-rw-r--r--browser/base/content/test/sanitize/browser_cookiePermission_subDomains.js290
-rw-r--r--browser/base/content/test/sanitize/browser_purgehistory_clears_sh.js71
-rw-r--r--browser/base/content/test/sanitize/browser_sanitize-cookie-exceptions.js274
-rw-r--r--browser/base/content/test/sanitize/browser_sanitize-formhistory.js28
-rw-r--r--browser/base/content/test/sanitize/browser_sanitize-history.js132
-rw-r--r--browser/base/content/test/sanitize/browser_sanitize-offlineData.js255
-rw-r--r--browser/base/content/test/sanitize/browser_sanitize-passwordDisabledHosts.js28
-rw-r--r--browser/base/content/test/sanitize/browser_sanitize-sitepermissions.js37
-rw-r--r--browser/base/content/test/sanitize/browser_sanitize-timespans.js1194
-rw-r--r--browser/base/content/test/sanitize/browser_sanitizeDialog.js833
-rw-r--r--browser/base/content/test/sanitize/dummy.js0
-rw-r--r--browser/base/content/test/sanitize/dummy_page.html9
-rw-r--r--browser/base/content/test/sanitize/head.js329
-rw-r--r--browser/base/content/test/sidebar/browser.ini8
-rw-r--r--browser/base/content/test/sidebar/browser_sidebar_adopt.js74
-rw-r--r--browser/base/content/test/sidebar/browser_sidebar_app_locale_changed.js111
-rw-r--r--browser/base/content/test/sidebar/browser_sidebar_keys.js108
-rw-r--r--browser/base/content/test/sidebar/browser_sidebar_move.js72
-rw-r--r--browser/base/content/test/sidebar/browser_sidebar_persist.js37
-rw-r--r--browser/base/content/test/sidebar/browser_sidebar_switcher.js64
-rw-r--r--browser/base/content/test/siteIdentity/browser.ini152
-rw-r--r--browser/base/content/test/siteIdentity/browser_about_blank_same_document_tabswitch.js79
-rw-r--r--browser/base/content/test/siteIdentity/browser_bug1045809.js105
-rw-r--r--browser/base/content/test/siteIdentity/browser_bug822367.js254
-rw-r--r--browser/base/content/test/siteIdentity/browser_bug902156.js171
-rw-r--r--browser/base/content/test/siteIdentity/browser_bug906190.js340
-rw-r--r--browser/base/content/test/siteIdentity/browser_check_identity_state.js882
-rw-r--r--browser/base/content/test/siteIdentity/browser_check_identity_state_pdf.js77
-rw-r--r--browser/base/content/test/siteIdentity/browser_csp_block_all_mixedcontent.js60
-rw-r--r--browser/base/content/test/siteIdentity/browser_deprecatedTLSVersions.js94
-rw-r--r--browser/base/content/test/siteIdentity/browser_geolocation_indicator.js381
-rw-r--r--browser/base/content/test/siteIdentity/browser_getSecurityInfo.js35
-rw-r--r--browser/base/content/test/siteIdentity/browser_identityBlock_flicker.js52
-rw-r--r--browser/base/content/test/siteIdentity/browser_identityBlock_focus.js126
-rw-r--r--browser/base/content/test/siteIdentity/browser_identityIcon_img_url.js148
-rw-r--r--browser/base/content/test/siteIdentity/browser_identityPopup_HttpsOnlyMode.js191
-rw-r--r--browser/base/content/test/siteIdentity/browser_identityPopup_clearSiteData.js245
-rw-r--r--browser/base/content/test/siteIdentity/browser_identityPopup_clearSiteData_extensions.js80
-rw-r--r--browser/base/content/test/siteIdentity/browser_identityPopup_custom_roots.js82
-rw-r--r--browser/base/content/test/siteIdentity/browser_identityPopup_focus.js120
-rw-r--r--browser/base/content/test/siteIdentity/browser_identity_UI.js192
-rw-r--r--browser/base/content/test/siteIdentity/browser_iframe_navigation.js108
-rw-r--r--browser/base/content/test/siteIdentity/browser_ignore_same_page_navigation.js50
-rw-r--r--browser/base/content/test/siteIdentity/browser_mcb_redirect.js360
-rw-r--r--browser/base/content/test/siteIdentity/browser_mixedContentFramesOnHttp.js37
-rw-r--r--browser/base/content/test/siteIdentity/browser_mixedContentFromOnunload.js68
-rw-r--r--browser/base/content/test/siteIdentity/browser_mixed_content_cert_override.js69
-rw-r--r--browser/base/content/test/siteIdentity/browser_mixed_content_with_navigation.js131
-rw-r--r--browser/base/content/test/siteIdentity/browser_mixed_passive_content_indicator.js18
-rw-r--r--browser/base/content/test/siteIdentity/browser_mixedcontent_securityflags.js71
-rw-r--r--browser/base/content/test/siteIdentity/browser_navigation_failures.js166
-rw-r--r--browser/base/content/test/siteIdentity/browser_no_mcb_for_loopback.js88
-rw-r--r--browser/base/content/test/siteIdentity/browser_no_mcb_for_onions.js41
-rw-r--r--browser/base/content/test/siteIdentity/browser_no_mcb_on_http_site.js133
-rw-r--r--browser/base/content/test/siteIdentity/browser_secure_transport_insecure_scheme.js185
-rw-r--r--browser/base/content/test/siteIdentity/browser_session_store_pageproxystate.js92
-rw-r--r--browser/base/content/test/siteIdentity/browser_tab_sharing_state.js96
-rw-r--r--browser/base/content/test/siteIdentity/dummy_iframe_page.html10
-rw-r--r--browser/base/content/test/siteIdentity/dummy_page.html10
-rw-r--r--browser/base/content/test/siteIdentity/file_bug1045809_1.html7
-rw-r--r--browser/base/content/test/siteIdentity/file_bug1045809_2.html7
-rw-r--r--browser/base/content/test/siteIdentity/file_bug822367_1.html18
-rw-r--r--browser/base/content/test/siteIdentity/file_bug822367_1.js1
-rw-r--r--browser/base/content/test/siteIdentity/file_bug822367_2.html16
-rw-r--r--browser/base/content/test/siteIdentity/file_bug822367_3.html27
-rw-r--r--browser/base/content/test/siteIdentity/file_bug822367_4.html18
-rw-r--r--browser/base/content/test/siteIdentity/file_bug822367_4.js2
-rw-r--r--browser/base/content/test/siteIdentity/file_bug822367_4B.html18
-rw-r--r--browser/base/content/test/siteIdentity/file_bug822367_5.html23
-rw-r--r--browser/base/content/test/siteIdentity/file_bug822367_6.html16
-rw-r--r--browser/base/content/test/siteIdentity/file_bug902156.js6
-rw-r--r--browser/base/content/test/siteIdentity/file_bug902156_1.html15
-rw-r--r--browser/base/content/test/siteIdentity/file_bug902156_2.html17
-rw-r--r--browser/base/content/test/siteIdentity/file_bug902156_3.html15
-rw-r--r--browser/base/content/test/siteIdentity/file_bug906190.js6
-rw-r--r--browser/base/content/test/siteIdentity/file_bug906190.sjs18
-rw-r--r--browser/base/content/test/siteIdentity/file_bug906190_1.html15
-rw-r--r--browser/base/content/test/siteIdentity/file_bug906190_2.html15
-rw-r--r--browser/base/content/test/siteIdentity/file_bug906190_3_4.html14
-rw-r--r--browser/base/content/test/siteIdentity/file_bug906190_redirected.html15
-rw-r--r--browser/base/content/test/siteIdentity/file_csp_block_all_mixedcontent.html11
-rw-r--r--browser/base/content/test/siteIdentity/file_csp_block_all_mixedcontent.js3
-rw-r--r--browser/base/content/test/siteIdentity/file_mixedContentFramesOnHttp.html14
-rw-r--r--browser/base/content/test/siteIdentity/file_mixedContentFromOnunload.html18
-rw-r--r--browser/base/content/test/siteIdentity/file_mixedContentFromOnunload_test1.html14
-rw-r--r--browser/base/content/test/siteIdentity/file_mixedContentFromOnunload_test2.html15
-rw-r--r--browser/base/content/test/siteIdentity/file_mixedPassiveContent.html13
-rw-r--r--browser/base/content/test/siteIdentity/file_pdf.pdf12
-rw-r--r--browser/base/content/test/siteIdentity/file_pdf_blob.html18
-rw-r--r--browser/base/content/test/siteIdentity/head.js435
-rw-r--r--browser/base/content/test/siteIdentity/iframe_navigation.html44
-rw-r--r--browser/base/content/test/siteIdentity/insecure_opener.html9
-rw-r--r--browser/base/content/test/siteIdentity/open-self-from-frame.html6
-rw-r--r--browser/base/content/test/siteIdentity/simple_mixed_passive.html1
-rw-r--r--browser/base/content/test/siteIdentity/test-mixedcontent-securityerrors.html21
-rw-r--r--browser/base/content/test/siteIdentity/test_mcb_double_redirect_image.html23
-rw-r--r--browser/base/content/test/siteIdentity/test_mcb_redirect.html15
-rw-r--r--browser/base/content/test/siteIdentity/test_mcb_redirect.js5
-rw-r--r--browser/base/content/test/siteIdentity/test_mcb_redirect.sjs29
-rw-r--r--browser/base/content/test/siteIdentity/test_mcb_redirect_image.html23
-rw-r--r--browser/base/content/test/siteIdentity/test_no_mcb_for_loopback.html56
-rw-r--r--browser/base/content/test/siteIdentity/test_no_mcb_for_onions.html29
-rw-r--r--browser/base/content/test/siteIdentity/test_no_mcb_on_http_site_font.css11
-rw-r--r--browser/base/content/test/siteIdentity/test_no_mcb_on_http_site_font.html44
-rw-r--r--browser/base/content/test/siteIdentity/test_no_mcb_on_http_site_font2.css1
-rw-r--r--browser/base/content/test/siteIdentity/test_no_mcb_on_http_site_font2.html45
-rw-r--r--browser/base/content/test/siteIdentity/test_no_mcb_on_http_site_img.css3
-rw-r--r--browser/base/content/test/siteIdentity/test_no_mcb_on_http_site_img.html44
-rw-r--r--browser/base/content/test/startup/browser.ini2
-rw-r--r--browser/base/content/test/startup/browser_preXULSkeletonUIRegistry.js136
-rw-r--r--browser/base/content/test/static/browser.ini22
-rw-r--r--browser/base/content/test/static/browser_all_files_referenced.js1093
-rw-r--r--browser/base/content/test/static/browser_misused_characters_in_strings.js276
-rw-r--r--browser/base/content/test/static/browser_parsable_css.js590
-rw-r--r--browser/base/content/test/static/browser_parsable_script.js167
-rw-r--r--browser/base/content/test/static/browser_sentence_case_strings.js279
-rw-r--r--browser/base/content/test/static/browser_title_case_menus.js158
-rw-r--r--browser/base/content/test/static/bug1262648_string_with_newlines.dtd3
-rw-r--r--browser/base/content/test/static/dummy_page.html9
-rw-r--r--browser/base/content/test/static/head.js177
-rw-r--r--browser/base/content/test/statuspanel/browser.ini7
-rw-r--r--browser/base/content/test/statuspanel/browser_show_statuspanel_idn.js28
-rw-r--r--browser/base/content/test/statuspanel/browser_show_statuspanel_twice.js29
-rw-r--r--browser/base/content/test/statuspanel/head.js58
-rw-r--r--browser/base/content/test/sync/browser.ini13
-rw-r--r--browser/base/content/test/sync/browser_contextmenu_sendpage.js465
-rw-r--r--browser/base/content/test/sync/browser_contextmenu_sendtab.js362
-rw-r--r--browser/base/content/test/sync/browser_fxa_badge.js70
-rw-r--r--browser/base/content/test/sync/browser_fxa_web_channel.html158
-rw-r--r--browser/base/content/test/sync/browser_fxa_web_channel.js282
-rw-r--r--browser/base/content/test/sync/browser_sync.js751
-rw-r--r--browser/base/content/test/sync/browser_synced_tabs_view.js76
-rw-r--r--browser/base/content/test/sync/head.js34
-rw-r--r--browser/base/content/test/tabMediaIndicator/almostSilentAudioTrack.webmbin0 -> 1699661 bytes
-rw-r--r--browser/base/content/test/tabMediaIndicator/audio.oggbin0 -> 14293 bytes
-rw-r--r--browser/base/content/test/tabMediaIndicator/audioEndedDuringPlaying.webmbin0 -> 109366 bytes
-rw-r--r--browser/base/content/test/tabMediaIndicator/browser.ini33
-rw-r--r--browser/base/content/test/tabMediaIndicator/browser_destroy_iframe.js50
-rw-r--r--browser/base/content/test/tabMediaIndicator/browser_mediaPlayback.js42
-rw-r--r--browser/base/content/test/tabMediaIndicator/browser_mediaPlayback_mute.js118
-rw-r--r--browser/base/content/test/tabMediaIndicator/browser_mediaplayback_audibility_change.js258
-rw-r--r--browser/base/content/test/tabMediaIndicator/browser_mute.js19
-rw-r--r--browser/base/content/test/tabMediaIndicator/browser_mute2.js32
-rw-r--r--browser/base/content/test/tabMediaIndicator/browser_mute_webAudio.js75
-rw-r--r--browser/base/content/test/tabMediaIndicator/browser_sound_indicator_silent_video.js88
-rw-r--r--browser/base/content/test/tabMediaIndicator/browser_webAudio_hideSoundPlayingIcon.js60
-rw-r--r--browser/base/content/test/tabMediaIndicator/browser_webAudio_silentData.js57
-rw-r--r--browser/base/content/test/tabMediaIndicator/browser_webaudio_audibility_change.js172
-rw-r--r--browser/base/content/test/tabMediaIndicator/file_almostSilentAudioTrack.html18
-rw-r--r--browser/base/content/test/tabMediaIndicator/file_autoplay_media.html9
-rw-r--r--browser/base/content/test/tabMediaIndicator/file_empty.html8
-rw-r--r--browser/base/content/test/tabMediaIndicator/file_mediaPlayback.html9
-rw-r--r--browser/base/content/test/tabMediaIndicator/file_mediaPlayback2.html14
-rw-r--r--browser/base/content/test/tabMediaIndicator/file_mediaPlaybackFrame.html2
-rw-r--r--browser/base/content/test/tabMediaIndicator/file_mediaPlaybackFrame2.html2
-rw-r--r--browser/base/content/test/tabMediaIndicator/file_silentAudioTrack.html18
-rw-r--r--browser/base/content/test/tabMediaIndicator/file_webAudio.html29
-rw-r--r--browser/base/content/test/tabMediaIndicator/gizmo.mp4bin0 -> 455255 bytes
-rw-r--r--browser/base/content/test/tabMediaIndicator/head.js158
-rw-r--r--browser/base/content/test/tabMediaIndicator/noaudio.webmbin0 -> 105755 bytes
-rw-r--r--browser/base/content/test/tabMediaIndicator/silentAudioTrack.webmbin0 -> 224800 bytes
-rw-r--r--browser/base/content/test/tabPrompts/auth-route.sjs28
-rw-r--r--browser/base/content/test/tabPrompts/browser.ini30
-rw-r--r--browser/base/content/test/tabPrompts/browser_abort_when_in_modal_state.js60
-rw-r--r--browser/base/content/test/tabPrompts/browser_auth_spoofing_protection.js232
-rw-r--r--browser/base/content/test/tabPrompts/browser_auth_spoofing_url_copy.js95
-rw-r--r--browser/base/content/test/tabPrompts/browser_auth_spoofing_url_drag_and_drop.js93
-rw-r--r--browser/base/content/test/tabPrompts/browser_beforeunload_urlbar.js75
-rw-r--r--browser/base/content/test/tabPrompts/browser_closeTabSpecificPanels.js53
-rw-r--r--browser/base/content/test/tabPrompts/browser_confirmFolderUpload.js141
-rw-r--r--browser/base/content/test/tabPrompts/browser_contentOrigins.js217
-rw-r--r--browser/base/content/test/tabPrompts/browser_multiplePrompts.js171
-rw-r--r--browser/base/content/test/tabPrompts/browser_openPromptInBackgroundTab.js262
-rw-r--r--browser/base/content/test/tabPrompts/browser_promptFocus.js170
-rw-r--r--browser/base/content/test/tabPrompts/browser_prompt_closed_window.js40
-rw-r--r--browser/base/content/test/tabPrompts/browser_switchTabPermissionPrompt.js41
-rw-r--r--browser/base/content/test/tabPrompts/browser_windowPrompt.js259
-rw-r--r--browser/base/content/test/tabPrompts/file_beforeunload_stop.html8
-rw-r--r--browser/base/content/test/tabPrompts/openPromptOffTimeout.html10
-rw-r--r--browser/base/content/test/tabPrompts/redirect-crossDomain-tabTitle-update.html15
-rw-r--r--browser/base/content/test/tabPrompts/redirect-crossDomain.html13
-rw-r--r--browser/base/content/test/tabPrompts/redirect-sameDomain.html13
-rw-r--r--browser/base/content/test/tabcrashed/browser.ini21
-rw-r--r--browser/base/content/test/tabcrashed/browser_aboutRestartRequired.ini19
-rw-r--r--browser/base/content/test/tabcrashed/browser_aboutRestartRequired_basic.js31
-rw-r--r--browser/base/content/test/tabcrashed/browser_aboutRestartRequired_buildid_false-positive.js35
-rw-r--r--browser/base/content/test/tabcrashed/browser_aboutRestartRequired_buildid_mismatch.js56
-rw-r--r--browser/base/content/test/tabcrashed/browser_aboutRestartRequired_buildid_no-platform-ini.js50
-rw-r--r--browser/base/content/test/tabcrashed/browser_autoSubmitRequest.js183
-rw-r--r--browser/base/content/test/tabcrashed/browser_launchFail.js59
-rw-r--r--browser/base/content/test/tabcrashed/browser_multipleCrashedTabs.js136
-rw-r--r--browser/base/content/test/tabcrashed/browser_noPermanentKey.js41
-rw-r--r--browser/base/content/test/tabcrashed/browser_printpreview_crash.js83
-rw-r--r--browser/base/content/test/tabcrashed/browser_showForm.js44
-rw-r--r--browser/base/content/test/tabcrashed/browser_shown.js150
-rw-r--r--browser/base/content/test/tabcrashed/browser_shownRestartRequired.js121
-rw-r--r--browser/base/content/test/tabcrashed/browser_withoutDump.js42
-rw-r--r--browser/base/content/test/tabcrashed/file_contains_emptyiframe.html9
-rw-r--r--browser/base/content/test/tabcrashed/file_iframe.html9
-rw-r--r--browser/base/content/test/tabcrashed/head.js238
-rw-r--r--browser/base/content/test/tabdialogs/browser.ini19
-rw-r--r--browser/base/content/test/tabdialogs/browser_multiple_dialog_navigation.js61
-rw-r--r--browser/base/content/test/tabdialogs/browser_subdialog_esc.js122
-rw-r--r--browser/base/content/test/tabdialogs/browser_tabdialogbox_content_prompts.js179
-rw-r--r--browser/base/content/test/tabdialogs/browser_tabdialogbox_focus.js212
-rw-r--r--browser/base/content/test/tabdialogs/browser_tabdialogbox_navigation.js174
-rw-r--r--browser/base/content/test/tabdialogs/loadDelayedReply.sjs22
-rw-r--r--browser/base/content/test/tabdialogs/subdialog.xhtml46
-rw-r--r--browser/base/content/test/tabdialogs/test_page.html10
-rw-r--r--browser/base/content/test/tabs/204.sjs3
-rw-r--r--browser/base/content/test/tabs/blank.html2
-rw-r--r--browser/base/content/test/tabs/browser.ini211
-rw-r--r--browser/base/content/test/tabs/browser_addAdjacentNewTab.js55
-rw-r--r--browser/base/content/test/tabs/browser_addTab_index.js8
-rw-r--r--browser/base/content/test/tabs/browser_adoptTab_failure.js107
-rw-r--r--browser/base/content/test/tabs/browser_allow_process_switches_despite_related_browser.js40
-rw-r--r--browser/base/content/test/tabs/browser_audioTabIcon.js676
-rw-r--r--browser/base/content/test/tabs/browser_bfcache_exemption_about_pages.js176
-rw-r--r--browser/base/content/test/tabs/browser_bug580956.js25
-rw-r--r--browser/base/content/test/tabs/browser_bug_1387976_restore_lazy_tab_browser_muted_state.js56
-rw-r--r--browser/base/content/test/tabs/browser_close_during_beforeunload.js46
-rw-r--r--browser/base/content/test/tabs/browser_close_tab_by_dblclick.js35
-rw-r--r--browser/base/content/test/tabs/browser_contextmenu_openlink_after_tabnavigated.js60
-rw-r--r--browser/base/content/test/tabs/browser_dont_process_switch_204.js56
-rw-r--r--browser/base/content/test/tabs/browser_e10s_about_page_triggeringprincipal.js208
-rw-r--r--browser/base/content/test/tabs/browser_e10s_about_process.js174
-rw-r--r--browser/base/content/test/tabs/browser_e10s_chrome_process.js136
-rw-r--r--browser/base/content/test/tabs/browser_e10s_javascript.js19
-rw-r--r--browser/base/content/test/tabs/browser_e10s_mozillaweb_process.js52
-rw-r--r--browser/base/content/test/tabs/browser_e10s_switchbrowser.js490
-rw-r--r--browser/base/content/test/tabs/browser_file_to_http_named_popup.js60
-rw-r--r--browser/base/content/test/tabs/browser_file_to_http_script_closable.js43
-rw-r--r--browser/base/content/test/tabs/browser_hiddentab_contextmenu.js34
-rw-r--r--browser/base/content/test/tabs/browser_lazy_tab_browser_events.js157
-rw-r--r--browser/base/content/test/tabs/browser_link_in_tab_title_and_url_prefilled_blank_page.js139
-rw-r--r--browser/base/content/test/tabs/browser_link_in_tab_title_and_url_prefilled_new_window.js54
-rw-r--r--browser/base/content/test/tabs/browser_link_in_tab_title_and_url_prefilled_normal_page_blank_target.js199
-rw-r--r--browser/base/content/test/tabs/browser_link_in_tab_title_and_url_prefilled_normal_page_by_script.js84
-rw-r--r--browser/base/content/test/tabs/browser_link_in_tab_title_and_url_prefilled_normal_page_no_target.js86
-rw-r--r--browser/base/content/test/tabs/browser_link_in_tab_title_and_url_prefilled_normal_page_other_target.js156
-rw-r--r--browser/base/content/test/tabs/browser_long_data_url_label_truncation.js78
-rw-r--r--browser/base/content/test/tabs/browser_middle_click_new_tab_button_loads_clipboard.js255
-rw-r--r--browser/base/content/test/tabs/browser_multiselect_tabs_active_tab_selected_by_default.js52
-rw-r--r--browser/base/content/test/tabs/browser_multiselect_tabs_bookmark.js81
-rw-r--r--browser/base/content/test/tabs/browser_multiselect_tabs_clear_selection_when_tab_switch.js33
-rw-r--r--browser/base/content/test/tabs/browser_multiselect_tabs_close.js192
-rw-r--r--browser/base/content/test/tabs/browser_multiselect_tabs_close_other_tabs.js122
-rw-r--r--browser/base/content/test/tabs/browser_multiselect_tabs_close_tabs_to_the_left.js131
-rw-r--r--browser/base/content/test/tabs/browser_multiselect_tabs_close_tabs_to_the_right.js113
-rw-r--r--browser/base/content/test/tabs/browser_multiselect_tabs_close_using_shortcuts.js64
-rw-r--r--browser/base/content/test/tabs/browser_multiselect_tabs_copy_through_drag_and_drop.js51
-rw-r--r--browser/base/content/test/tabs/browser_multiselect_tabs_drag_to_bookmarks_toolbar.js74
-rw-r--r--browser/base/content/test/tabs/browser_multiselect_tabs_duplicate.js136
-rw-r--r--browser/base/content/test/tabs/browser_multiselect_tabs_event.js220
-rw-r--r--browser/base/content/test/tabs/browser_multiselect_tabs_move.js192
-rw-r--r--browser/base/content/test/tabs/browser_multiselect_tabs_move_to_another_window_drag.js118
-rw-r--r--browser/base/content/test/tabs/browser_multiselect_tabs_move_to_new_window_contextmenu.js129
-rw-r--r--browser/base/content/test/tabs/browser_multiselect_tabs_mute_unmute.js336
-rw-r--r--browser/base/content/test/tabs/browser_multiselect_tabs_open_related.js143
-rw-r--r--browser/base/content/test/tabs/browser_multiselect_tabs_pin_unpin.js75
-rw-r--r--browser/base/content/test/tabs/browser_multiselect_tabs_play.js254
-rw-r--r--browser/base/content/test/tabs/browser_multiselect_tabs_reload.js82
-rw-r--r--browser/base/content/test/tabs/browser_multiselect_tabs_reopen_in_container.js133
-rw-r--r--browser/base/content/test/tabs/browser_multiselect_tabs_reorder.js65
-rw-r--r--browser/base/content/test/tabs/browser_multiselect_tabs_using_Ctrl.js60
-rw-r--r--browser/base/content/test/tabs/browser_multiselect_tabs_using_Shift.js159
-rw-r--r--browser/base/content/test/tabs/browser_multiselect_tabs_using_Shift_and_Ctrl.js75
-rw-r--r--browser/base/content/test/tabs/browser_multiselect_tabs_using_keyboard.js147
-rw-r--r--browser/base/content/test/tabs/browser_multiselect_tabs_using_selectedTabs.js72
-rw-r--r--browser/base/content/test/tabs/browser_navigatePinnedTab.js71
-rw-r--r--browser/base/content/test/tabs/browser_navigate_home_focuses_addressbar.js21
-rw-r--r--browser/base/content/test/tabs/browser_navigate_through_urls_origin_attributes.js177
-rw-r--r--browser/base/content/test/tabs/browser_new_file_whitelisted_http_tab.js37
-rw-r--r--browser/base/content/test/tabs/browser_new_tab_in_privilegedabout_process_pref.js230
-rw-r--r--browser/base/content/test/tabs/browser_new_tab_insert_position.js288
-rw-r--r--browser/base/content/test/tabs/browser_new_tab_url.js29
-rw-r--r--browser/base/content/test/tabs/browser_newwindow_tabstrip_overflow.js41
-rw-r--r--browser/base/content/test/tabs/browser_open_newtab_start_observer_notification.js28
-rw-r--r--browser/base/content/test/tabs/browser_opened_file_tab_navigated_to_web.js56
-rw-r--r--browser/base/content/test/tabs/browser_origin_attrs_in_remote_type.js106
-rw-r--r--browser/base/content/test/tabs/browser_origin_attrs_rel.js281
-rw-r--r--browser/base/content/test/tabs/browser_originalURI.js181
-rw-r--r--browser/base/content/test/tabs/browser_overflowScroll.js111
-rw-r--r--browser/base/content/test/tabs/browser_paste_event_at_middle_click_on_link.js156
-rw-r--r--browser/base/content/test/tabs/browser_pinnedTabs.js97
-rw-r--r--browser/base/content/test/tabs/browser_pinnedTabs_clickOpen.js58
-rw-r--r--browser/base/content/test/tabs/browser_pinnedTabs_closeByKeyboard.js72
-rw-r--r--browser/base/content/test/tabs/browser_positional_attributes.js60
-rw-r--r--browser/base/content/test/tabs/browser_preloadedBrowser_zoom.js89
-rw-r--r--browser/base/content/test/tabs/browser_privilegedmozilla_process_pref.js212
-rw-r--r--browser/base/content/test/tabs/browser_progress_keyword_search_handling.js91
-rw-r--r--browser/base/content/test/tabs/browser_relatedTabs_reset.js81
-rw-r--r--browser/base/content/test/tabs/browser_reload_deleted_file.js36
-rw-r--r--browser/base/content/test/tabs/browser_removeTabsToTheEnd.js30
-rw-r--r--browser/base/content/test/tabs/browser_removeTabsToTheStart.js35
-rw-r--r--browser/base/content/test/tabs/browser_removeTabs_order.js40
-rw-r--r--browser/base/content/test/tabs/browser_removeTabs_skipPermitUnload.js115
-rw-r--r--browser/base/content/test/tabs/browser_replacewithwindow_commands.js42
-rw-r--r--browser/base/content/test/tabs/browser_switch_by_scrolling.js51
-rw-r--r--browser/base/content/test/tabs/browser_tabCloseProbes.js112
-rw-r--r--browser/base/content/test/tabs/browser_tabCloseSpacer.js91
-rw-r--r--browser/base/content/test/tabs/browser_tabContextMenu_keyboard.js64
-rw-r--r--browser/base/content/test/tabs/browser_tabReorder.js64
-rw-r--r--browser/base/content/test/tabs/browser_tabReorder_overflow.js62
-rw-r--r--browser/base/content/test/tabs/browser_tabSpinnerProbe.js101
-rw-r--r--browser/base/content/test/tabs/browser_tabSuccessors.js131
-rw-r--r--browser/base/content/test/tabs/browser_tab_a11y_description.js74
-rw-r--r--browser/base/content/test/tabs/browser_tab_label_during_reload.js41
-rw-r--r--browser/base/content/test/tabs/browser_tab_label_picture_in_picture.js30
-rw-r--r--browser/base/content/test/tabs/browser_tab_manager_close.js84
-rw-r--r--browser/base/content/test/tabs/browser_tab_manager_drag.js259
-rw-r--r--browser/base/content/test/tabs/browser_tab_manager_keyboard_access.js38
-rw-r--r--browser/base/content/test/tabs/browser_tab_manager_visibility.js55
-rw-r--r--browser/base/content/test/tabs/browser_tab_move_to_new_window_reload.js51
-rw-r--r--browser/base/content/test/tabs/browser_tab_play.js216
-rw-r--r--browser/base/content/test/tabs/browser_tab_tooltips.js108
-rw-r--r--browser/base/content/test/tabs/browser_tabswitch_contextmenu.js45
-rw-r--r--browser/base/content/test/tabs/browser_tabswitch_select.js63
-rw-r--r--browser/base/content/test/tabs/browser_tabswitch_updatecommands.js28
-rw-r--r--browser/base/content/test/tabs/browser_tabswitch_window_focus.js78
-rw-r--r--browser/base/content/test/tabs/browser_undo_close_tabs.js171
-rw-r--r--browser/base/content/test/tabs/browser_undo_close_tabs_at_start.js74
-rw-r--r--browser/base/content/test/tabs/browser_viewsource_of_data_URI_in_file_process.js53
-rw-r--r--browser/base/content/test/tabs/browser_visibleTabs_bookmarkAllTabs.js64
-rw-r--r--browser/base/content/test/tabs/browser_visibleTabs_contextMenu.js115
-rw-r--r--browser/base/content/test/tabs/common_link_in_tab_title_and_url_prefilled.js255
-rw-r--r--browser/base/content/test/tabs/dummy_page.html9
-rw-r--r--browser/base/content/test/tabs/file_about_child.html10
-rw-r--r--browser/base/content/test/tabs/file_about_parent.html10
-rw-r--r--browser/base/content/test/tabs/file_about_srcdoc.html9
-rw-r--r--browser/base/content/test/tabs/file_anchor_elements.html12
-rw-r--r--browser/base/content/test/tabs/file_mediaPlayback.html2
-rw-r--r--browser/base/content/test/tabs/file_new_tab_page.html9
-rw-r--r--browser/base/content/test/tabs/file_rel_opener_noopener.html12
-rw-r--r--browser/base/content/test/tabs/head.js564
-rw-r--r--browser/base/content/test/tabs/helper_origin_attrs_testing.js158
-rw-r--r--browser/base/content/test/tabs/link_in_tab_title_and_url_prefilled.html30
-rw-r--r--browser/base/content/test/tabs/open_window_in_new_tab.html15
-rw-r--r--browser/base/content/test/tabs/page_with_iframe.html12
-rw-r--r--browser/base/content/test/tabs/redirect_via_header.html9
-rw-r--r--browser/base/content/test/tabs/redirect_via_header.html^headers^2
-rw-r--r--browser/base/content/test/tabs/redirect_via_meta_tag.html13
-rw-r--r--browser/base/content/test/tabs/request-timeout.sjs8
-rw-r--r--browser/base/content/test/tabs/tab_that_closes.html15
-rw-r--r--browser/base/content/test/tabs/test_bug1358314.html10
-rw-r--r--browser/base/content/test/tabs/test_process_flags_chrome.html10
-rw-r--r--browser/base/content/test/tabs/wait-a-bit.sjs23
-rw-r--r--browser/base/content/test/touch/browser.ini4
-rw-r--r--browser/base/content/test/touch/browser_menu_touch.js198
-rw-r--r--browser/base/content/test/utilityOverlay/browser.ini2
-rw-r--r--browser/base/content/test/utilityOverlay/browser_openWebLinkIn.js185
-rw-r--r--browser/base/content/test/webextensions/.eslintrc.js7
-rw-r--r--browser/base/content/test/webextensions/browser.ini33
-rw-r--r--browser/base/content/test/webextensions/browser_aboutaddons_blanktab.js26
-rw-r--r--browser/base/content/test/webextensions/browser_extension_sideloading.js404
-rw-r--r--browser/base/content/test/webextensions/browser_extension_update_background.js282
-rw-r--r--browser/base/content/test/webextensions/browser_extension_update_background_noprompt.js121
-rw-r--r--browser/base/content/test/webextensions/browser_legacy_webext.xpibin0 -> 4243 bytes
-rw-r--r--browser/base/content/test/webextensions/browser_permissions_dismiss.js112
-rw-r--r--browser/base/content/test/webextensions/browser_permissions_installTrigger.js26
-rw-r--r--browser/base/content/test/webextensions/browser_permissions_local_file.js43
-rw-r--r--browser/base/content/test/webextensions/browser_permissions_mozAddonManager.js18
-rw-r--r--browser/base/content/test/webextensions/browser_permissions_optional.js52
-rw-r--r--browser/base/content/test/webextensions/browser_permissions_pointerevent.js53
-rw-r--r--browser/base/content/test/webextensions/browser_permissions_unsigned.js63
-rw-r--r--browser/base/content/test/webextensions/browser_update_checkForUpdates.js17
-rw-r--r--browser/base/content/test/webextensions/browser_update_interactive_noprompt.js77
-rw-r--r--browser/base/content/test/webextensions/browser_webext_nopermissions.xpibin0 -> 4273 bytes
-rw-r--r--browser/base/content/test/webextensions/browser_webext_permissions.xpibin0 -> 16602 bytes
-rw-r--r--browser/base/content/test/webextensions/browser_webext_unsigned.xpibin0 -> 12620 bytes
-rw-r--r--browser/base/content/test/webextensions/browser_webext_update.json70
-rw-r--r--browser/base/content/test/webextensions/browser_webext_update1.xpibin0 -> 4271 bytes
-rw-r--r--browser/base/content/test/webextensions/browser_webext_update2.xpibin0 -> 4291 bytes
-rw-r--r--browser/base/content/test/webextensions/browser_webext_update_icon1.xpibin0 -> 16545 bytes
-rw-r--r--browser/base/content/test/webextensions/browser_webext_update_icon2.xpibin0 -> 16564 bytes
-rw-r--r--browser/base/content/test/webextensions/browser_webext_update_origins1.xpibin0 -> 268 bytes
-rw-r--r--browser/base/content/test/webextensions/browser_webext_update_origins2.xpibin0 -> 275 bytes
-rw-r--r--browser/base/content/test/webextensions/browser_webext_update_perms1.xpibin0 -> 4273 bytes
-rw-r--r--browser/base/content/test/webextensions/browser_webext_update_perms2.xpibin0 -> 4282 bytes
-rw-r--r--browser/base/content/test/webextensions/file_install_extensions.html19
-rw-r--r--browser/base/content/test/webextensions/head.js650
-rw-r--r--browser/base/content/test/webrtc/browser.ini118
-rw-r--r--browser/base/content/test/webrtc/browser_WebrtcGlobalInformation.js484
-rw-r--r--browser/base/content/test/webrtc/browser_device_controls_menus.js55
-rw-r--r--browser/base/content/test/webrtc/browser_devices_get_user_media.js949
-rw-r--r--browser/base/content/test/webrtc/browser_devices_get_user_media_anim.js106
-rw-r--r--browser/base/content/test/webrtc/browser_devices_get_user_media_by_device_id.js82
-rw-r--r--browser/base/content/test/webrtc/browser_devices_get_user_media_default_permissions.js209
-rw-r--r--browser/base/content/test/webrtc/browser_devices_get_user_media_grace.js388
-rw-r--r--browser/base/content/test/webrtc/browser_devices_get_user_media_in_frame.js775
-rw-r--r--browser/base/content/test/webrtc/browser_devices_get_user_media_in_xorigin_frame.js798
-rw-r--r--browser/base/content/test/webrtc/browser_devices_get_user_media_in_xorigin_frame_chain.js251
-rw-r--r--browser/base/content/test/webrtc/browser_devices_get_user_media_multi_process.js517
-rw-r--r--browser/base/content/test/webrtc/browser_devices_get_user_media_paused.js999
-rw-r--r--browser/base/content/test/webrtc/browser_devices_get_user_media_queue_request.js383
-rw-r--r--browser/base/content/test/webrtc/browser_devices_get_user_media_screen.js949
-rw-r--r--browser/base/content/test/webrtc/browser_devices_get_user_media_screen_tab_close.js73
-rw-r--r--browser/base/content/test/webrtc/browser_devices_get_user_media_tear_off_tab.js100
-rw-r--r--browser/base/content/test/webrtc/browser_devices_get_user_media_unprompted_access.js666
-rw-r--r--browser/base/content/test/webrtc/browser_devices_get_user_media_unprompted_access_in_frame.js309
-rw-r--r--browser/base/content/test/webrtc/browser_devices_get_user_media_unprompted_access_queue_request.js47
-rw-r--r--browser/base/content/test/webrtc/browser_devices_get_user_media_unprompted_access_tear_off_tab.js108
-rw-r--r--browser/base/content/test/webrtc/browser_devices_select_audio_output.js233
-rw-r--r--browser/base/content/test/webrtc/browser_global_mute_toggles.js293
-rw-r--r--browser/base/content/test/webrtc/browser_indicator_popuphiding.js50
-rw-r--r--browser/base/content/test/webrtc/browser_notification_silencing.js231
-rw-r--r--browser/base/content/test/webrtc/browser_stop_sharing_button.js175
-rw-r--r--browser/base/content/test/webrtc/browser_stop_streams_on_indicator_close.js215
-rw-r--r--browser/base/content/test/webrtc/browser_tab_switch_warning.js538
-rw-r--r--browser/base/content/test/webrtc/browser_webrtc_hooks.js371
-rw-r--r--browser/base/content/test/webrtc/get_user_media.html124
-rw-r--r--browser/base/content/test/webrtc/get_user_media2.html107
-rw-r--r--browser/base/content/test/webrtc/get_user_media_in_frame.html98
-rw-r--r--browser/base/content/test/webrtc/get_user_media_in_xorigin_frame.html71
-rw-r--r--browser/base/content/test/webrtc/get_user_media_in_xorigin_frame_ancestor.html12
-rw-r--r--browser/base/content/test/webrtc/gracePeriod/browser.ini15
-rw-r--r--browser/base/content/test/webrtc/head.js1338
-rw-r--r--browser/base/content/test/webrtc/legacyIndicator/browser.ini63
-rw-r--r--browser/base/content/test/webrtc/peerconnection_connect.html39
-rw-r--r--browser/base/content/test/webrtc/single_peerconnection.html23
-rw-r--r--browser/base/content/test/zoom/browser.ini34
-rw-r--r--browser/base/content/test/zoom/browser_background_link_zoom_reset.js45
-rw-r--r--browser/base/content/test/zoom/browser_background_zoom.js115
-rw-r--r--browser/base/content/test/zoom/browser_default_zoom.js149
-rw-r--r--browser/base/content/test/zoom/browser_default_zoom_fission.js114
-rw-r--r--browser/base/content/test/zoom/browser_default_zoom_multitab.js190
-rw-r--r--browser/base/content/test/zoom/browser_default_zoom_multitab_002.js93
-rw-r--r--browser/base/content/test/zoom/browser_default_zoom_sitespecific.js108
-rw-r--r--browser/base/content/test/zoom/browser_image_zoom_tabswitch.js39
-rw-r--r--browser/base/content/test/zoom/browser_mousewheel_zoom.js72
-rw-r--r--browser/base/content/test/zoom/browser_sitespecific_background_pref.js35
-rw-r--r--browser/base/content/test/zoom/browser_sitespecific_image_zoom.js52
-rw-r--r--browser/base/content/test/zoom/browser_sitespecific_video_zoom.js128
-rw-r--r--browser/base/content/test/zoom/browser_subframe_textzoom.js52
-rw-r--r--browser/base/content/test/zoom/browser_tabswitch_zoom_flicker.js45
-rw-r--r--browser/base/content/test/zoom/browser_tooltip_zoom.js41
-rw-r--r--browser/base/content/test/zoom/browser_zoom_commands.js203
-rw-r--r--browser/base/content/test/zoom/head.js223
-rw-r--r--browser/base/content/test/zoom/zoom_test.html14
-rw-r--r--browser/base/content/titlebar-items.inc.xhtml24
-rw-r--r--browser/base/content/unified-extensions-viewcache.inc.xhtml40
-rw-r--r--browser/base/content/utilityOverlay.js611
-rw-r--r--browser/base/content/webext-panels.js184
-rw-r--r--browser/base/content/webext-panels.xhtml64
-rw-r--r--browser/base/content/webrtcIndicator.js603
-rw-r--r--browser/base/content/webrtcIndicator.xhtml86
-rw-r--r--browser/base/content/webrtcLegacyIndicator.js206
-rw-r--r--browser/base/content/webrtcLegacyIndicator.xhtml40
-rw-r--r--browser/base/jar.mn109
-rw-r--r--browser/base/moz.build91
1175 files changed, 170878 insertions, 0 deletions
diff --git a/browser/base/content/aboutDialog-appUpdater.js b/browser/base/content/aboutDialog-appUpdater.js
new file mode 100644
index 0000000000..21bf83bc42
--- /dev/null
+++ b/browser/base/content/aboutDialog-appUpdater.js
@@ -0,0 +1,300 @@
+/* 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/. */
+
+// Note: this file is included in aboutDialog.xhtml and preferences/advanced.xhtml
+// if MOZ_UPDATER is defined.
+
+/* import-globals-from aboutDialog.js */
+
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ AppUpdater: "resource://gre/modules/AppUpdater.sys.mjs",
+ DownloadUtils: "resource://gre/modules/DownloadUtils.sys.mjs",
+ UpdateUtils: "resource://gre/modules/UpdateUtils.sys.mjs",
+});
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "AUS",
+ "@mozilla.org/updates/update-service;1",
+ "nsIApplicationUpdateService"
+);
+
+var UPDATING_MIN_DISPLAY_TIME_MS = 1500;
+
+var gAppUpdater;
+
+function onUnload(aEvent) {
+ if (gAppUpdater) {
+ gAppUpdater.destroy();
+ gAppUpdater = null;
+ }
+}
+
+function appUpdater(options = {}) {
+ this._appUpdater = new AppUpdater();
+
+ this._appUpdateListener = (status, ...args) => {
+ this._onAppUpdateStatus(status, ...args);
+ };
+ this._appUpdater.addListener(this._appUpdateListener);
+
+ this.options = options;
+ this.updatingMinDisplayTimerId = null;
+ this.updateDeck = document.getElementById("updateDeck");
+
+ this.bundle = Services.strings.createBundle(
+ "chrome://browser/locale/browser.properties"
+ );
+
+ try {
+ let manualURL = new URL(
+ Services.urlFormatter.formatURLPref("app.update.url.manual")
+ );
+
+ for (const manualLink of document.querySelectorAll(".manualLink")) {
+ // Strip hash and search parameters for display text.
+ let displayUrl = manualURL.origin + manualURL.pathname;
+ manualLink.href = manualURL.href;
+ document.l10n.setArgs(manualLink.closest("[data-l10n-id]"), {
+ displayUrl,
+ });
+ }
+
+ document.getElementById("failedLink").href = manualURL.href;
+ } catch (e) {
+ console.error("Invalid manual update url.", e);
+ }
+
+ this._appUpdater.check();
+}
+
+appUpdater.prototype = {
+ destroy() {
+ this.stopCurrentCheck();
+ if (this.updatingMinDisplayTimerId) {
+ clearTimeout(this.updatingMinDisplayTimerId);
+ }
+ },
+
+ stopCurrentCheck() {
+ this._appUpdater.removeListener(this._appUpdateListener);
+ this._appUpdater.stop();
+ },
+
+ get update() {
+ return this._appUpdater.update;
+ },
+
+ get selectedPanel() {
+ return this.updateDeck.selectedPanel;
+ },
+
+ _onAppUpdateStatus(status, ...args) {
+ switch (status) {
+ case AppUpdater.STATUS.UPDATE_DISABLED_BY_POLICY:
+ this.selectPanel("policyDisabled");
+ break;
+ case AppUpdater.STATUS.READY_FOR_RESTART:
+ this.selectPanel("apply");
+ break;
+ case AppUpdater.STATUS.OTHER_INSTANCE_HANDLING_UPDATES:
+ this.selectPanel("otherInstanceHandlingUpdates");
+ break;
+ case AppUpdater.STATUS.DOWNLOADING: {
+ const downloadStatus = document.getElementById("downloading");
+ if (!args.length) {
+ // Very early in the DOWNLOADING state, `selectedPatch` may not be
+ // available yet. But this function will be called again when it is
+ // available. A `maxSize < 0` indicates that the max size is not yet
+ // available.
+ let maxSize = -1;
+ if (this.update.selectedPatch) {
+ maxSize = this.update.selectedPatch.size;
+ }
+ const transfer = DownloadUtils.getTransferTotal(0, maxSize);
+ document.l10n.setArgs(downloadStatus, { transfer });
+ this.selectPanel("downloading");
+ } else {
+ let [progress, max] = args;
+ const transfer = DownloadUtils.getTransferTotal(progress, max);
+ document.l10n.setArgs(downloadStatus, { transfer });
+ }
+ break;
+ }
+ case AppUpdater.STATUS.STAGING:
+ this.selectPanel("applying");
+ break;
+ case AppUpdater.STATUS.CHECKING: {
+ this.checkingForUpdatesDelayPromise = new Promise(resolve => {
+ this.updatingMinDisplayTimerId = setTimeout(
+ resolve,
+ UPDATING_MIN_DISPLAY_TIME_MS
+ );
+ });
+ if (Services.policies.isAllowed("appUpdate")) {
+ this.selectPanel("checkingForUpdates");
+ } else {
+ this.selectPanel("policyDisabled");
+ }
+ break;
+ }
+ case AppUpdater.STATUS.CHECKING_FAILED:
+ this.selectPanel("checkingFailed");
+ break;
+ case AppUpdater.STATUS.NO_UPDATES_FOUND:
+ this.checkingForUpdatesDelayPromise.then(() => {
+ if (Services.policies.isAllowed("appUpdate")) {
+ this.selectPanel("noUpdatesFound");
+ } else {
+ this.selectPanel("policyDisabled");
+ }
+ });
+ break;
+ case AppUpdater.STATUS.UNSUPPORTED_SYSTEM:
+ if (this.update.detailsURL) {
+ let unsupportedLink = document.getElementById("unsupportedLink");
+ unsupportedLink.href = this.update.detailsURL;
+ }
+ this.selectPanel("unsupportedSystem");
+ break;
+ case AppUpdater.STATUS.MANUAL_UPDATE:
+ this.selectPanel("manualUpdate");
+ break;
+ case AppUpdater.STATUS.DOWNLOAD_AND_INSTALL:
+ this.selectPanel("downloadAndInstall");
+ break;
+ case AppUpdater.STATUS.DOWNLOAD_FAILED:
+ this.selectPanel("downloadFailed");
+ break;
+ case AppUpdater.STATUS.INTERNAL_ERROR:
+ this.selectPanel("internalError");
+ break;
+ case AppUpdater.STATUS.NEVER_CHECKED:
+ this.selectPanel("checkForUpdates");
+ break;
+ case AppUpdater.STATUS.NO_UPDATER:
+ default:
+ this.selectPanel("noUpdater");
+ break;
+ }
+ },
+
+ /**
+ * Sets the panel of the updateDeck and the visibility of icons
+ * in the #icons element.
+ *
+ * @param aChildID
+ * The id of the deck's child to select, e.g. "apply".
+ */
+ selectPanel(aChildID) {
+ let panel = document.getElementById(aChildID);
+ let icons = document.getElementById("icons");
+ if (icons) {
+ icons.className = aChildID;
+ }
+
+ // Make sure to select the panel before potentially auto-focusing the button.
+ this.updateDeck.selectedPanel = panel;
+
+ let button = panel.querySelector("button");
+ if (button) {
+ if (aChildID == "downloadAndInstall") {
+ let updateVersion = gAppUpdater.update.displayVersion;
+ // Include the build ID if this is an "a#" (nightly or aurora) build
+ if (/a\d+$/.test(updateVersion)) {
+ let buildID = gAppUpdater.update.buildID;
+ let year = buildID.slice(0, 4);
+ let month = buildID.slice(4, 6);
+ let day = buildID.slice(6, 8);
+ updateVersion += ` (${year}-${month}-${day})`;
+ }
+ button.label = this.bundle.formatStringFromName(
+ "update.downloadAndInstallButton.label",
+ [updateVersion]
+ );
+ button.accessKey = this.bundle.GetStringFromName(
+ "update.downloadAndInstallButton.accesskey"
+ );
+ }
+ if (this.options.buttonAutoFocus) {
+ let promise = Promise.resolve();
+ if (document.readyState != "complete") {
+ promise = new Promise(resolve =>
+ window.addEventListener("load", resolve, { once: true })
+ );
+ }
+ promise.then(() => {
+ if (
+ !document.commandDispatcher.focusedElement || // don't steal the focus
+ // except from the other buttons
+ document.commandDispatcher.focusedElement.localName == "button"
+ ) {
+ button.focus();
+ }
+ });
+ }
+ }
+ },
+
+ /**
+ * Check for updates
+ */
+ checkForUpdates() {
+ this._appUpdater.check();
+ },
+
+ /**
+ * Handles oncommand for the "Restart to Update" button
+ * which is presented after the download has been downloaded.
+ */
+ buttonRestartAfterDownload() {
+ if (AUS.currentState != Ci.nsIApplicationUpdateService.STATE_PENDING) {
+ return;
+ }
+
+ gAppUpdater.selectPanel("restarting");
+
+ // Notify all windows that an application quit has been requested.
+ let cancelQuit = Cc["@mozilla.org/supports-PRBool;1"].createInstance(
+ Ci.nsISupportsPRBool
+ );
+ Services.obs.notifyObservers(
+ cancelQuit,
+ "quit-application-requested",
+ "restart"
+ );
+
+ // Something aborted the quit process.
+ if (cancelQuit.data) {
+ gAppUpdater.selectPanel("apply");
+ return;
+ }
+
+ // If already in safe mode restart in safe mode (bug 327119)
+ if (Services.appinfo.inSafeMode) {
+ Services.startup.restartInSafeMode(Ci.nsIAppStartup.eAttemptQuit);
+ return;
+ }
+
+ if (
+ !Services.startup.quit(
+ Ci.nsIAppStartup.eAttemptQuit | Ci.nsIAppStartup.eRestart
+ )
+ ) {
+ // Either the user or the hidden window aborted the quit process.
+ gAppUpdater.selectPanel("apply");
+ }
+ },
+
+ /**
+ * Starts the download of an update mar.
+ */
+ startDownload() {
+ this._appUpdater.allowUpdateDownload();
+ },
+};
diff --git a/browser/base/content/aboutDialog.css b/browser/base/content/aboutDialog.css
new file mode 100644
index 0000000000..f5fc290594
--- /dev/null
+++ b/browser/base/content/aboutDialog.css
@@ -0,0 +1,138 @@
+/* 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/. */
+@namespace html "http://www.w3.org/1999/xhtml";
+
+#aboutDialog {
+ /* Set an explicit line-height to avoid discrepancies in 'auto' spacing
+ across screens with different device DPI, which may cause font metrics
+ to round differently. */
+ line-height: 1.5;
+}
+
+#aboutDialogContainer {
+ flex: 1;
+}
+
+#rightBox {
+ background-image: url("chrome://branding/content/about-wordmark.svg");
+ background-repeat: no-repeat;
+ background-size: 288px auto;
+ /* padding-top creates room for the wordmark */
+ padding-top: 38px;
+ margin-top: 20px;
+}
+
+#rightBox:-moz-locale-dir(rtl) {
+ background-position: 100% 0;
+}
+
+#bottomBox {
+ padding: 15px 10px 0;
+ min-height: 52px;
+}
+
+#release {
+ font-weight: bold;
+ font-size: 125%;
+ margin-top: 10px;
+ margin-inline-start: 0;
+}
+
+#version {
+ font-weight: bold;
+ margin-inline-start: 0;
+ user-select: text;
+ -moz-user-focus: normal;
+ cursor: text;
+}
+
+#version.update {
+ font-weight: normal;
+}
+
+#distribution,
+#distributionId {
+ display: none;
+ margin-block: 0;
+}
+
+.text-blurb {
+ margin-bottom: 10px;
+ margin-inline-start: 0;
+ padding-inline-start: 0;
+}
+
+#updateDeck {
+ align-items: center;
+}
+
+#updateButton {
+ margin-inline-start: 0;
+ padding-inline-start: 0;
+}
+
+#updateDeck description {
+ margin: 0;
+}
+
+#rightBox {
+ /* We don't want this box to contribute arbitrarily to the intrinsic size of
+ * the dialog, so set the width to a reasonable size, but let it flex to take
+ * all available space. */
+ width: 430px;
+ flex: 1 auto;
+}
+
+.update-throbber {
+ width: 16px;
+ min-height: 16px;
+ margin-inline-end: 3px;
+ vertical-align: middle;
+ content: image-set(url("chrome://global/skin/icons/loading.png"), url("chrome://global/skin/icons/loading@2x.png") 2x);
+}
+
+description > .text-link {
+ margin: 0;
+ padding: 0;
+}
+
+#submit-feedback {
+ margin-inline-start: .9em;
+}
+
+.bottom-link {
+ text-align: center;
+ margin: 0 40px;
+}
+
+#currentChannel {
+ margin: 0;
+ padding: 0;
+ font-weight: bold;
+}
+
+#updateBox {
+ line-height: normal;
+}
+
+#icons > .icon {
+ -moz-context-properties: fill;
+ margin: 10px 5px;
+ width: 16px;
+ height: 16px;
+}
+
+#icons:not(.checkingForUpdates, .downloading, .applying, .restarting) > .update-throbber,
+#icons:not(.noUpdatesFound) > .noUpdatesFound,
+#icons:not(.apply) > .apply {
+ display: none;
+}
+
+#icons > .noUpdatesFound {
+ fill: #30e60b;
+}
+
+#icons > .apply {
+ fill: white;
+}
diff --git a/browser/base/content/aboutDialog.js b/browser/base/content/aboutDialog.js
new file mode 100644
index 0000000000..fc0252ad1b
--- /dev/null
+++ b/browser/base/content/aboutDialog.js
@@ -0,0 +1,105 @@
+/* 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";
+
+/* import-globals-from aboutDialog-appUpdater.js */
+
+// Services = object with smart getters for common XPCOM services
+var { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+if (AppConstants.MOZ_UPDATER) {
+ Services.scriptloader.loadSubScript(
+ "chrome://browser/content/aboutDialog-appUpdater.js",
+ this
+ );
+}
+
+function init() {
+ let defaults = Services.prefs.getDefaultBranch(null);
+ let distroId = defaults.getCharPref("distribution.id", "");
+ if (distroId) {
+ let distroAbout = defaults.getStringPref("distribution.about", "");
+ // If there is about text, we always show it.
+ if (distroAbout) {
+ let distroField = document.getElementById("distribution");
+ distroField.value = distroAbout;
+ distroField.style.display = "block";
+ }
+ // If it's not a mozilla distribution, show the rest,
+ // unless about text exists, then we always show.
+ if (!distroId.startsWith("mozilla-") || distroAbout) {
+ let distroVersion = defaults.getCharPref("distribution.version", "");
+ if (distroVersion) {
+ distroId += " - " + distroVersion;
+ }
+
+ let distroIdField = document.getElementById("distributionId");
+ distroIdField.value = distroId;
+ distroIdField.style.display = "block";
+ }
+ }
+
+ // Include the build ID and display warning if this is an "a#" (nightly or aurora) build
+ let versionId = "aboutDialog-version";
+ let versionAttributes = {
+ version: AppConstants.MOZ_APP_VERSION_DISPLAY,
+ bits: Services.appinfo.is64Bit ? 64 : 32,
+ };
+
+ let version = Services.appinfo.version;
+ if (/a\d+$/.test(version)) {
+ versionId = "aboutDialog-version-nightly";
+ let buildID = Services.appinfo.appBuildID;
+ let year = buildID.slice(0, 4);
+ let month = buildID.slice(4, 6);
+ let day = buildID.slice(6, 8);
+ versionAttributes.isodate = `${year}-${month}-${day}`;
+
+ document.getElementById("experimental").hidden = false;
+ document.getElementById("communityDesc").hidden = true;
+ }
+
+ // Use Fluent arguments for append version and the architecture of the build
+ let versionField = document.getElementById("version");
+
+ document.l10n.setAttributes(versionField, versionId, versionAttributes);
+
+ // Show a release notes link if we have a URL.
+ let relNotesLink = document.getElementById("releasenotes");
+ let relNotesPrefType = Services.prefs.getPrefType(
+ "app.releaseNotesURL.aboutDialog"
+ );
+ if (relNotesPrefType != Services.prefs.PREF_INVALID) {
+ let relNotesURL = Services.urlFormatter.formatURLPref(
+ "app.releaseNotesURL.aboutDialog"
+ );
+ if (relNotesURL != "about:blank") {
+ relNotesLink.href = relNotesURL;
+ relNotesLink.hidden = false;
+ }
+ }
+
+ if (AppConstants.MOZ_UPDATER) {
+ gAppUpdater = new appUpdater({ buttonAutoFocus: true });
+
+ let channelLabel = document.getElementById("currentChannelText");
+ let channelAttrs = document.l10n.getAttributes(channelLabel);
+ let channel = UpdateUtils.UpdateChannel;
+ document.l10n.setAttributes(channelLabel, channelAttrs.id, { channel });
+ if (
+ /^release($|\-)/.test(channel) ||
+ Services.sysinfo.getProperty("isPackagedApp")
+ ) {
+ channelLabel.hidden = true;
+ }
+ }
+
+ if (AppConstants.IS_ESR) {
+ document.getElementById("release").hidden = false;
+ }
+}
+
+init();
diff --git a/browser/base/content/aboutDialog.xhtml b/browser/base/content/aboutDialog.xhtml
new file mode 100644
index 0000000000..0824af6462
--- /dev/null
+++ b/browser/base/content/aboutDialog.xhtml
@@ -0,0 +1,156 @@
+<?xml version="1.0"?> <!-- -*- Mode: HTML -*- -->
+
+# 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/.
+
+<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?>
+<?xml-stylesheet href="chrome://browser/content/aboutDialog.css" type="text/css"?>
+<?xml-stylesheet href="chrome://branding/content/aboutDialog.css" type="text/css"?>
+
+<window xmlns:html="http://www.w3.org/1999/xhtml"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ id="aboutDialog"
+ windowtype="Browser:About"
+#ifdef MOZ_UPDATER
+ onunload="onUnload(event);"
+#endif
+#ifndef XP_MACOSX
+ data-l10n-id="aboutDialog-title"
+#endif
+ role="dialog"
+ aria-describedby="version distribution distributionId communityDesc contributeDesc trademark"
+ >
+#ifdef XP_MACOSX
+#include macWindow.inc.xhtml
+#else
+ <script src="chrome://browser/content/utilityOverlay.js"/>
+#endif
+
+ <linkset>
+ <html:link rel="localization" href="branding/brand.ftl"/>
+ <html:link rel="localization" href="browser/aboutDialog.ftl"/>
+ </linkset>
+
+ <html:div id="aboutDialogContainer">
+ <hbox id="clientBox">
+ <vbox id="leftBox" flex="1"/>
+ <vbox id="rightBox">
+ <label id="release" hidden="true">
+ <!-- This string is explicitly not translated -->
+ Extended Support Release
+ </label>
+#ifndef MOZ_UPDATER
+ <!-- This HBOX is duplicated below with class="update" -->
+ <hbox align="baseline">
+ <label id="version"/>
+ <label id="releasenotes" is="text-link" hidden="true" data-l10n-id="releaseNotes-link"/>
+ </hbox>
+#endif
+
+ <label id="distribution" class="text-blurb"/>
+ <label id="distributionId" class="text-blurb"/>
+
+ <vbox id="detailsBox">
+ <hbox id="updateBox">
+#ifdef MOZ_UPDATER
+ <html:div id="icons">
+ <html:img class="icon update-throbber" role="presentation"/>
+ <html:img class="icon noUpdatesFound" src="chrome://global/skin/icons/check.svg" role="presentation"/>
+ <html:img class="icon apply" src="chrome://global/skin/icons/reload.svg" role="presentation"/>
+ </html:div>
+ <vbox>
+ <deck id="updateDeck" orient="vertical">
+ <description id="checkForUpdates">
+ <button id="checkForUpdatesButton"
+ data-l10n-id="update-checkForUpdatesButton"
+ oncommand="gAppUpdater.checkForUpdates();"/>
+ </description>
+ <description id="downloadAndInstall">
+ <button id="downloadAndInstallButton"
+ oncommand="gAppUpdater.startDownload();"/>
+ <!-- label and accesskey will be filled by JS -->
+ </description>
+ <description id="apply">
+ <button id="updateButton"
+ data-l10n-id="update-updateButton"
+ oncommand="gAppUpdater.buttonRestartAfterDownload();"/>
+ </description>
+ <description id="checkingForUpdates" data-l10n-id="update-checkingForUpdates"/>
+ <description id="downloading" data-l10n-id="aboutdialog-update-downloading" data-l10n-args='{"transfer":""}'>
+ <label data-l10n-name="download-status"/>
+ </description>
+ <description id="applying" data-l10n-id="update-applying"/>
+ <description id="downloadFailed" data-l10n-id="update-failed">
+ <label id="failedLink" is="text-link" data-l10n-name="failed-link"/>
+ </description>
+ <description id="policyDisabled" data-l10n-id="update-adminDisabled"/>
+ <description id="noUpdatesFound" data-l10n-id="update-noUpdatesFound"/>
+ <description id="checkingFailed" data-l10n-id="aboutdialog-update-checking-failed"/>
+ <description id="otherInstanceHandlingUpdates" data-l10n-id="update-otherInstanceHandlingUpdates"/>
+ <description id="manualUpdate" data-l10n-id="aboutdialog-update-manual-with-link" data-l10n-args='{"displayUrl":""}'>
+ <label class="manualLink" is="text-link" data-l10n-name="manual-link"/>
+ </description>
+ <description id="unsupportedSystem" data-l10n-id="update-unsupported">
+ <label id="unsupportedLink" is="text-link" data-l10n-name="unsupported-link"/>
+ </description>
+ <description id="restarting" data-l10n-id="update-restarting"/>
+ <description id="internalError" data-l10n-id="update-internal-error2" data-l10n-args='{"displayUrl":""}'>
+ <label class="manualLink" is="text-link" data-l10n-name="manual-link"/>
+ </description>
+ <description id="noUpdater"/>
+ </deck>
+ <!-- This HBOX is duplicated above without class="update" -->
+ <hbox align="baseline">
+ <label id="version" class="update"/>
+ <label id="releasenotes" is="text-link" hidden="true" data-l10n-id="releaseNotes-link"/>
+ </hbox>
+ <description class="text-blurb">
+ <label is="text-link" onclick="openHelpLink('firefox-help')" data-l10n-id="aboutdialog-help-user"/>
+ <label id="submit-feedback" is="text-link" onclick="openFeedbackPage()" data-l10n-id="aboutdialog-submit-feedback"/>
+ </description>
+ </vbox>
+#endif
+ </hbox>
+
+#ifdef MOZ_UPDATER
+ <description class="text-blurb" id="currentChannelText"
+ data-l10n-id="aboutdialog-channel-description"
+ data-l10n-attrs="{&quot;channel&quot;: &quot;&quot;}">
+ <label id="currentChannel" data-l10n-name="current-channel"/>
+ </description>
+#endif
+ <vbox id="experimental" hidden="true">
+ <description class="text-blurb" id="warningDesc" data-l10n-id="warningDesc-version"></description>
+ <description class="text-blurb" id="communityExperimentalDesc" data-l10n-id="community-exp">
+ <label is="text-link" href="https://www.mozilla.org/?utm_source=firefox-browser&#38;utm_medium=firefox-desktop&#38;utm_campaign=about-dialog" data-l10n-name="community-exp-mozillaLink"/>
+ <label is="text-link" useoriginprincipal="true" href="about:credits" data-l10n-name="community-exp-creditsLink"/>
+ </description>
+ </vbox>
+ <description class="text-blurb" id="communityDesc" data-l10n-id="community-2">
+ <label is="text-link" href="https://www.mozilla.org/?utm_source=firefox-browser&#38;utm_medium=firefox-desktop&#38;utm_campaign=about-dialog" data-l10n-name="community-mozillaLink"/>
+ <label is="text-link" useoriginprincipal="true" href="about:credits" data-l10n-name="community-creditsLink"/>
+ </description>
+ <description class="text-blurb" id="contributeDesc" data-l10n-id="helpus">
+ <label is="text-link" href="https://donate.mozilla.org/?utm_source=firefox&#38;utm_medium=referral&#38;utm_campaign=firefox_about&#38;utm_content=firefox_about" data-l10n-name="helpus-donateLink"/>
+ <label is="text-link" href="https://www.mozilla.org/contribute/?utm_source=firefox-browser&#38;utm_medium=firefox-desktop&#38;utm_campaign=about-dialog" data-l10n-name="helpus-getInvolvedLink"/>
+ </description>
+ </vbox>
+ </vbox>
+ </hbox>
+ <vbox id="bottomBox">
+ <hbox pack="center">
+ <label is="text-link" class="bottom-link" useoriginprincipal="true" href="about:license" data-l10n-id="bottomLinks-license"/>
+ <label is="text-link" class="bottom-link" useoriginprincipal="true" href="about:rights" data-l10n-id="bottomLinks-rights"/>
+ <label is="text-link" class="bottom-link" href="https://www.mozilla.org/privacy/?utm_source=firefox-browser&#38;utm_medium=firefox-desktop&#38;utm_campaign=about-dialog" data-l10n-id="bottomLinks-privacy"/>
+ </hbox>
+ <description id="trademark" data-l10n-id="trademarkInfo"></description>
+ </vbox>
+ </html:div>
+
+ <keyset>
+ <key keycode="VK_ESCAPE" oncommand="window.close();"/>
+ </keyset>
+
+ <script src="chrome://browser/content/aboutDialog.js"/>
+</window>
diff --git a/browser/base/content/aboutFrameCrashed.html b/browser/base/content/aboutFrameCrashed.html
new file mode 100644
index 0000000000..532854f714
--- /dev/null
+++ b/browser/base/content/aboutFrameCrashed.html
@@ -0,0 +1,32 @@
+<!DOCTYPE html>
+
+<!-- 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/. -->
+
+<html data-l10n-id="crashed-subframe-title">
+ <head>
+ <meta
+ http-equiv="Content-Security-Policy"
+ content="default-src chrome:; object-src 'none'"
+ />
+ <meta charset="utf-8" />
+ <meta name="color-scheme" content="light dark" />
+ <link
+ rel="stylesheet"
+ type="text/css"
+ media="all"
+ href="chrome://global/skin/in-content/info-pages.css"
+ />
+ <link
+ rel="stylesheet"
+ type="text/css"
+ media="all"
+ href="chrome://browser/skin/aboutFrameCrashed.css"
+ />
+ <link rel="localization" href="branding/brand.ftl" />
+ <link rel="localization" href="browser/browser.ftl" />
+ <link rel="localization" href="browser/contentCrash.ftl" />
+ </head>
+ <body></body>
+</html>
diff --git a/browser/base/content/aboutRestartRequired.js b/browser/base/content/aboutRestartRequired.js
new file mode 100644
index 0000000000..d4d1194e0d
--- /dev/null
+++ b/browser/base/content/aboutRestartRequired.js
@@ -0,0 +1,42 @@
+/* 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/. */
+
+/* eslint-env mozilla/remote-page */
+
+var AboutRestartRequired = {
+ /* Only do autofocus if we're the toplevel frame; otherwise we
+ don't want to call attention to ourselves! The key part is
+ that autofocus happens on insertion into the tree, so we
+ can remove the button, add @autofocus, and reinsert the
+ button.
+ */
+ addAutofocus() {
+ if (window.top == window) {
+ var button = document.getElementById("restart");
+ var parent = button.parentNode;
+ button.remove();
+ button.setAttribute("autofocus", "true");
+ parent.insertAdjacentElement("afterbegin", button);
+ }
+ },
+ restart() {
+ Services.startup.quit(
+ Ci.nsIAppStartup.eRestart | Ci.nsIAppStartup.eAttemptQuit
+ );
+ },
+ init() {
+ this.addAutofocus();
+ },
+};
+
+AboutRestartRequired.init();
+
+let restartButton = document.getElementById("restart");
+restartButton.onclick = function () {
+ AboutRestartRequired.restart();
+};
+
+// Dispatch this event so tests can detect that we finished loading the page.
+let event = new CustomEvent("AboutRestartRequiredLoad", { bubbles: true });
+document.dispatchEvent(event);
diff --git a/browser/base/content/aboutRestartRequired.xhtml b/browser/base/content/aboutRestartRequired.xhtml
new file mode 100644
index 0000000000..bb41bfb8fa
--- /dev/null
+++ b/browser/base/content/aboutRestartRequired.xhtml
@@ -0,0 +1,59 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!-- 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/. -->
+<!DOCTYPE html>
+
+<html xmlns="http://www.w3.org/1999/xhtml">
+ <head>
+ <meta
+ http-equiv="Content-Security-Policy"
+ content="default-src chrome:; object-src 'none'"
+ />
+ <meta name="color-scheme" content="light dark" />
+ <title data-l10n-id="restart-required-title"></title>
+ <link
+ rel="stylesheet"
+ type="text/css"
+ media="all"
+ href="chrome://browser/skin/aboutRestartRequired.css"
+ />
+ <!-- If the location of the favicon is changed here, the
+ FAVICON_ERRORPAGE_URL symbol in
+ toolkit/components/places/src/nsFaviconService.h should be updated. -->
+ <link
+ rel="icon"
+ type="image/png"
+ id="favicon"
+ href="chrome://global/skin/icons/info.svg"
+ />
+ <link rel="localization" href="branding/brand.ftl" />
+ <link rel="localization" href="browser/aboutRestartRequired.ftl" />
+ </head>
+ <body>
+ <!-- PAGE CONTAINER (for styling purposes only) -->
+ <div id="errorPageContainer">
+ <div id="text-container">
+ <div id="title">
+ <h1 id="title-text" data-l10n-id="restart-required-heading" />
+ </div>
+ <div id="errorLongContent">
+ <div id="errorLongDesc">
+ <p data-l10n-id="restart-required-intro" />
+ <p data-l10n-id="window-restoration-info" />
+ </div>
+ </div>
+ </div>
+ <!-- Restart Button -->
+ <div id="restartButtonContainer" class="button-container">
+ <button
+ id="restart"
+ data-l10n-id="restart-button-label"
+ class="primary"
+ autocomplete="off"
+ ></button>
+ </div>
+ </div>
+ </body>
+ <script src="chrome://browser/content/aboutRestartRequired.js" />
+</html>
diff --git a/browser/base/content/aboutRobots-icon.png b/browser/base/content/aboutRobots-icon.png
new file mode 100644
index 0000000000..e94c4e3621
--- /dev/null
+++ b/browser/base/content/aboutRobots-icon.png
Binary files differ
diff --git a/browser/base/content/aboutRobots.css b/browser/base/content/aboutRobots.css
new file mode 100644
index 0000000000..7ef0a58848
--- /dev/null
+++ b/browser/base/content/aboutRobots.css
@@ -0,0 +1,7 @@
+/* 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/. */
+
+.title {
+ background-image: url("chrome://browser/content/aboutRobots-icon.png");
+}
diff --git a/browser/base/content/aboutRobots.js b/browser/base/content/aboutRobots.js
new file mode 100644
index 0000000000..ce82722c42
--- /dev/null
+++ b/browser/base/content/aboutRobots.js
@@ -0,0 +1,15 @@
+/* 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/. */
+
+var buttonClicked = false;
+var button = document.getElementById("errorTryAgain");
+button.onclick = function () {
+ if (buttonClicked) {
+ button.style.visibility = "hidden";
+ } else {
+ var newLabel = button.getAttribute("label2");
+ button.textContent = newLabel;
+ buttonClicked = true;
+ }
+};
diff --git a/browser/base/content/aboutRobots.xhtml b/browser/base/content/aboutRobots.xhtml
new file mode 100644
index 0000000000..91bfad6767
--- /dev/null
+++ b/browser/base/content/aboutRobots.xhtml
@@ -0,0 +1,73 @@
+<!-- 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/. -->
+
+<!DOCTYPE html>
+<html xmlns="http://www.w3.org/1999/xhtml">
+ <head>
+ <meta
+ http-equiv="Content-Security-Policy"
+ content="default-src chrome:; object-src 'none'"
+ />
+ <meta name="color-scheme" content="light dark" />
+ <title data-l10n-id="page-title"></title>
+ <link
+ rel="stylesheet"
+ href="chrome://global/skin/in-content/info-pages.css"
+ media="all"
+ />
+ <link
+ rel="icon"
+ type="image/png"
+ id="favicon"
+ href="chrome://browser/content/robot.ico"
+ />
+ <link rel="stylesheet" href="chrome://browser/content/aboutRobots.css" />
+ <linkset>
+ <link rel="localization" href="browser/aboutRobots.ftl" />
+ </linkset>
+ </head>
+
+ <body>
+ <!-- PAGE CONTAINER (for styling purposes only) -->
+ <div class="container">
+ <!-- Error Title -->
+ <div class="title">
+ <h1 class="title-text" data-l10n-id="error-title-text"></h1>
+ </div>
+
+ <!-- LONG CONTENT (the section most likely to require scrolling) -->
+ <div class="description">
+ <!-- Short Description -->
+ <div>
+ <p id="errorShortDescText" data-l10n-id="error-short-desc-text"></p>
+ </div>
+
+ <!-- Long Description -->
+ <div>
+ <ul>
+ <li data-l10n-id="error-long-desc1"></li>
+ <li data-l10n-id="error-long-desc2"></li>
+ <li data-l10n-id="error-long-desc3"></li>
+ <li data-l10n-id="error-long-desc4"></li>
+ </ul>
+ </div>
+
+ <!-- Short Description -->
+ <div>
+ <small data-l10n-id="error-trailer-desc-text"></small>
+ </div>
+ </div>
+
+ <!-- Button -->
+ <div class="button-container">
+ <button
+ id="errorTryAgain"
+ data-l10n-id="error-try-again"
+ data-l10n-attrs="label2"
+ ></button>
+ </div>
+ </div>
+ </body>
+ <script src="chrome://browser/content/aboutRobots.js" />
+</html>
diff --git a/browser/base/content/aboutTabCrashed.css b/browser/base/content/aboutTabCrashed.css
new file mode 100644
index 0000000000..1d7663c9d2
--- /dev/null
+++ b/browser/base/content/aboutTabCrashed.css
@@ -0,0 +1,11 @@
+/* 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/. */
+
+html:not(.crashDumpSubmitted) #reportSent,
+html:not(.crashDumpAvailable) #reportBox,
+.container[multiple="true"] > .offers > #offerHelpMessageSingle,
+.container[multiple="false"] > .offers > #offerHelpMessageMultiple,
+.container[multiple="false"] > .button-container > #restoreAll {
+ display: none;
+}
diff --git a/browser/base/content/aboutTabCrashed.js b/browser/base/content/aboutTabCrashed.js
new file mode 100644
index 0000000000..3ed15fa704
--- /dev/null
+++ b/browser/base/content/aboutTabCrashed.js
@@ -0,0 +1,265 @@
+/* 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/. */
+
+/* eslint-env mozilla/remote-page */
+
+var AboutTabCrashed = {
+ /**
+ * This can be set to true once this page receives a message from the
+ * parent saying whether or not a crash report is available.
+ */
+ hasReport: false,
+
+ /**
+ * The messages that we might receive from the parent.
+ */
+ MESSAGES: ["SetCrashReportAvailable", "CrashReportSent", "UpdateCount"],
+
+ /**
+ * Items for which we will listen for click events.
+ */
+ CLICK_TARGETS: ["closeTab", "restoreTab", "restoreAll", "sendReport"],
+
+ /**
+ * Returns information about this crashed tab.
+ *
+ * @return (Object) An object with the following properties:
+ * title (String):
+ * The title of the page that crashed.
+ * URL (String):
+ * The URL of the page that crashed.
+ */
+ get pageData() {
+ delete this.pageData;
+
+ let URL = document.documentURI;
+ let queryString = URL.replace(/^about:tabcrashed?e=tabcrashed/, "");
+
+ let titleMatch = queryString.match(/d=([^&]*)/);
+ let URLMatch = queryString.match(/u=([^&]*)/);
+
+ return (this.pageData = {
+ title:
+ titleMatch && titleMatch[1] ? decodeURIComponent(titleMatch[1]) : "",
+ URL: URLMatch && URLMatch[1] ? decodeURIComponent(URLMatch[1]) : "",
+ });
+ },
+
+ init() {
+ addEventListener("DOMContentLoaded", this);
+
+ document.title = this.pageData.title;
+ },
+
+ receiveMessage(message) {
+ switch (message.name) {
+ case "UpdateCount": {
+ this.setMultiple(message.data.count > 1);
+ break;
+ }
+ case "SetCrashReportAvailable": {
+ this.onSetCrashReportAvailable(message);
+ break;
+ }
+ case "CrashReportSent": {
+ this.onCrashReportSent();
+ break;
+ }
+ }
+ },
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "DOMContentLoaded": {
+ this.onDOMContentLoaded();
+ break;
+ }
+ case "click": {
+ this.onClick(event);
+ break;
+ }
+ }
+ },
+
+ onDOMContentLoaded() {
+ this.MESSAGES.forEach(msg =>
+ RPMAddMessageListener(msg, this.receiveMessage.bind(this))
+ );
+
+ this.CLICK_TARGETS.forEach(targetID => {
+ let el = document.getElementById(targetID);
+ el.addEventListener("click", this);
+ });
+
+ // Error pages are loaded as LOAD_BACKGROUND, so they don't get load events.
+ let event = new CustomEvent("AboutTabCrashedLoad", { bubbles: true });
+ document.dispatchEvent(event);
+
+ RPMSendAsyncMessage("Load");
+ },
+
+ onClick(event) {
+ switch (event.target.id) {
+ case "closeTab": {
+ this.sendMessage("closeTab");
+ break;
+ }
+
+ case "restoreTab": {
+ this.sendMessage("restoreTab");
+ break;
+ }
+
+ case "restoreAll": {
+ this.sendMessage("restoreAll");
+ break;
+ }
+
+ case "sendReport": {
+ this.showCrashReportUI(event.target.checked);
+ break;
+ }
+ }
+ },
+
+ /**
+ * After this page tells the parent that it has loaded, the parent
+ * will respond with whether or not a crash report is available. This
+ * method handles that message.
+ *
+ * @param message
+ * The message from the parent, which should contain a data
+ * Object property with the following properties:
+ *
+ * hasReport (bool):
+ * Whether or not there is a crash report.
+ *
+ * sendReport (bool):
+ * Whether or not the the user prefers to send the report
+ * by default.
+ *
+ * includeURL (bool):
+ * Whether or not the user prefers to send the URL of
+ * the tab that crashed.
+ *
+ * requestAutoSubmit (bool):
+ * Whether or not we should ask the user to automatically
+ * submit backlogged crash reports.
+ *
+ */
+ onSetCrashReportAvailable(message) {
+ let data = message.data;
+
+ if (data.hasReport) {
+ this.hasReport = true;
+ document.documentElement.classList.add("crashDumpAvailable");
+
+ document.getElementById("sendReport").checked = data.sendReport;
+ document.getElementById("includeURL").checked = data.includeURL;
+
+ this.showCrashReportUI(data.sendReport);
+ } else {
+ this.showCrashReportUI(false);
+ }
+
+ if (data.requestAutoSubmit) {
+ document.getElementById("requestAutoSubmit").hidden = false;
+ }
+
+ let event = new CustomEvent("AboutTabCrashedReady", { bubbles: true });
+ document.dispatchEvent(event);
+ },
+
+ /**
+ * Handler for when the parent reports that the crash report associated
+ * with this about:tabcrashed page has been sent.
+ */
+ onCrashReportSent() {
+ document.documentElement.classList.remove("crashDumpAvailable");
+ document.documentElement.classList.add("crashDumpSubmitted");
+ },
+
+ /**
+ * Toggles the display of the crash report form.
+ *
+ * @param shouldShow (bool)
+ * True if the crash report form should be shown
+ */
+ showCrashReportUI(shouldShow) {
+ let options = document.getElementById("options");
+ options.hidden = !shouldShow;
+ },
+
+ /**
+ * Toggles whether or not the page is one of several visible pages
+ * showing the crash reporter. This controls some of the language
+ * on the page, along with what the "primary" button is.
+ *
+ * @param hasMultiple (bool)
+ * True if there are multiple crash report pages being shown.
+ */
+ setMultiple(hasMultiple) {
+ let main = document.getElementById("main");
+ main.setAttribute("multiple", hasMultiple);
+
+ let restoreTab = document.getElementById("restoreTab");
+
+ // The "Restore All" button has the "primary" class by default, so
+ // we only need to modify the "Restore Tab" button.
+ if (hasMultiple) {
+ restoreTab.classList.remove("primary");
+ } else {
+ restoreTab.classList.add("primary");
+ }
+ },
+
+ /**
+ * Sends a message to the parent in response to the user choosing
+ * one of the actions available on the page. This might also send up
+ * crash report information if the user has chosen to submit a crash
+ * report.
+ *
+ * @param messageName (String)
+ * The message to send to the parent
+ */
+ sendMessage(messageName) {
+ let comments = "";
+ let URL = "";
+ let sendReport = false;
+ let includeURL = false;
+ let autoSubmit = false;
+
+ if (this.hasReport) {
+ sendReport = document.getElementById("sendReport").checked;
+ if (sendReport) {
+ comments = document.getElementById("comments").value.trim();
+
+ includeURL = document.getElementById("includeURL").checked;
+ if (includeURL) {
+ URL = this.pageData.URL.trim();
+ }
+ }
+ }
+
+ let requestAutoSubmit = document.getElementById("requestAutoSubmit");
+ if (requestAutoSubmit.hidden) {
+ // The checkbox is hidden if the user has already opted in to sending
+ // backlogged crash reports.
+ autoSubmit = true;
+ } else {
+ autoSubmit = document.getElementById("autoSubmit").checked;
+ }
+
+ RPMSendAsyncMessage(messageName, {
+ sendReport,
+ comments,
+ includeURL,
+ URL,
+ autoSubmit,
+ hasReport: this.hasReport,
+ });
+ },
+};
+
+AboutTabCrashed.init();
diff --git a/browser/base/content/aboutTabCrashed.xhtml b/browser/base/content/aboutTabCrashed.xhtml
new file mode 100644
index 0000000000..96fb6e06ea
--- /dev/null
+++ b/browser/base/content/aboutTabCrashed.xhtml
@@ -0,0 +1,113 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<!-- 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/. -->
+
+<!DOCTYPE html>
+
+<html xmlns="http://www.w3.org/1999/xhtml">
+ <head>
+ <meta
+ http-equiv="Content-Security-Policy"
+ content="default-src chrome:; object-src 'none'"
+ />
+ <meta name="color-scheme" content="light dark" />
+ <link
+ rel="stylesheet"
+ type="text/css"
+ media="all"
+ href="chrome://global/skin/in-content/info-pages.css"
+ />
+ <link
+ rel="stylesheet"
+ type="text/css"
+ media="all"
+ href="chrome://browser/content/aboutTabCrashed.css"
+ />
+ <link
+ rel="stylesheet"
+ type="text/css"
+ media="all"
+ href="chrome://browser/skin/aboutTabCrashed.css"
+ />
+ <link rel="localization" href="branding/brand.ftl" />
+ <link rel="localization" href="browser/aboutTabCrashed.ftl" />
+
+ <title data-l10n-id="crashed-title"></title>
+ </head>
+
+ <body>
+ <div id="main" class="container" multiple="false">
+ <div class="title">
+ <h1 class="title-text" data-l10n-id="crashed-header"></h1>
+ </div>
+
+ <div class="offers">
+ <h2 data-l10n-id="crashed-offer-help"></h2>
+ <p
+ id="offerHelpMessageSingle"
+ data-l10n-id="crashed-single-offer-help-message"
+ ></p>
+ <p
+ id="offerHelpMessageMultiple"
+ data-l10n-id="crashed-multiple-offer-help-message"
+ ></p>
+ </div>
+
+ <div id="reportBox">
+ <h2 data-l10n-id="crashed-request-help"></h2>
+ <p data-l10n-id="crashed-request-help-message"></p>
+
+ <h2 data-l10n-id="crashed-request-report-title"></h2>
+
+ <label class="toggle-container-with-text">
+ <input type="checkbox" id="sendReport" role="checkbox" />
+ <span data-l10n-id="crashed-send-report-2"></span>
+ </label>
+
+ <ul id="options">
+ <li>
+ <textarea
+ id="comments"
+ data-l10n-id="crashed-comment"
+ rows="4"
+ ></textarea>
+ </li>
+
+ <li>
+ <label class="toggle-container-with-text">
+ <input type="checkbox" id="includeURL" role="checkbox" />
+ <span data-l10n-id="crashed-include-URL-2"></span>
+ </label>
+ </li>
+ </ul>
+
+ <div id="requestAutoSubmit" hidden="true">
+ <h2 data-l10n-id="crashed-request-auto-submit-title"></h2>
+ <label class="toggle-container-with-text">
+ <input type="checkbox" id="autoSubmit" role="checkbox" />
+ <span data-l10n-id="crashed-auto-submit-checkbox-2"></span>
+ </label>
+ </div>
+ </div>
+
+ <p id="reportSent" data-l10n-id="crashed-report-sent"></p>
+
+ <div class="button-container">
+ <button id="closeTab" data-l10n-id="crashed-close-tab-button"></button>
+ <button
+ id="restoreTab"
+ class="primary"
+ data-l10n-id="crashed-restore-tab-button"
+ ></button>
+ <button
+ id="restoreAll"
+ autofocus="true"
+ data-l10n-id="crashed-restore-all-button"
+ />
+ </div>
+ </div>
+ </body>
+ <script src="chrome://browser/content/aboutTabCrashed.js" />
+</html>
diff --git a/browser/base/content/appmenu-viewcache.inc.xhtml b/browser/base/content/appmenu-viewcache.inc.xhtml
new file mode 100644
index 0000000000..1278fa35d2
--- /dev/null
+++ b/browser/base/content/appmenu-viewcache.inc.xhtml
@@ -0,0 +1,690 @@
+# 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/.
+
+<html:template id="appMenu-viewCache">
+ <panelview id="appMenu-mainView" class="PanelUI-subView">
+ <vbox class="panel-subview-body">
+ <toolbarbutton id="appMenu-whatsnew-button"
+ class="subviewbutton subviewbutton-iconic subviewbutton-nav"
+ hidden="true"
+ closemenu="none"
+ oncommand="PanelUI.showSubView('PanelUI-whatsNew', this)"/>
+ </vbox>
+ </panelview>
+
+ <!-- This is a placeholder app menu which should be replaced with the "real"
+ Proton app menu before the Proton pref starts getting enabled. -->
+ <panelview id="appMenu-protonMainView" class="PanelUI-subView"
+ lockpanelvertical="true">
+ <vbox class="panel-subview-body">
+ <vbox id="appMenu-proton-addon-banners"/>
+ <toolbarbutton id="appMenu-proton-update-banner" class="panel-banner-item subviewbutton"
+ oncommand="PanelUI._onBannerItemSelected(event)"
+ wrap="true"
+ hidden="true"/>
+ <toolbaritem id="appMenu-fxa-status2"
+ closemenu="none"
+ class="subviewbutton toolbaritem-combined-buttons">
+ <html:div id="appMenu-fxa-text" data-l10n-id="appmenu-fxa-sync-and-save-data2"/>
+ <toolbarbutton id="appMenu-fxa-label2"
+ class="subviewbutton"
+ oncommand="gSync.toggleAccountPanel(this, event)">
+ <vbox flex="1">
+ <label id="appMenu-header-title"
+ crop="end"/>
+ <label id="appMenu-header-description"
+ crop="end"/>
+ </vbox>
+ </toolbarbutton>
+ </toolbaritem>
+ <toolbarseparator id="appMenu-fxa-separator" class="proton-zap"/>
+ <toolbarbutton id="appMenu-new-tab-button2"
+ class="subviewbutton"
+ data-l10n-id="appmenuitem-new-tab"
+ key="key_newNavigatorTab"
+ command="cmd_newNavigatorTab"/>
+ <toolbarbutton id="appMenu-new-window-button2"
+ class="subviewbutton"
+ data-l10n-id="appmenuitem-new-window"
+ key="key_newNavigator"
+ command="cmd_newNavigator"/>
+ <toolbarbutton id="appMenu-new-private-window-button2"
+ class="subviewbutton"
+ data-l10n-id="appmenuitem-new-private-window"
+ key="key_privatebrowsing"
+ command="Tools:PrivateBrowsing"/>
+ <toolbarseparator/>
+ <toolbarbutton id="appMenu-bookmarks-button"
+ class="subviewbutton subviewbutton-nav"
+ data-l10n-id="library-bookmarks-menu"
+ closemenu="none"
+ oncommand="BookmarkingUI.showSubView(this);"/>
+ <toolbarbutton id="appMenu-history-button"
+ class="subviewbutton subviewbutton-nav"
+ data-l10n-id="appmenuitem-history"
+ closemenu="none"
+ oncommand="PanelUI.showSubView('PanelUI-history', this)"/>
+ <toolbarbutton id="appMenu-downloads-button"
+ class="subviewbutton"
+ data-l10n-id="appmenuitem-downloads"
+ key="key_openDownloads"
+ command="Tools:Downloads"/>
+ <toolbarbutton id="appMenu-passwords-button"
+ class="subviewbutton"
+ data-l10n-id="appmenuitem-passwords"
+ oncommand="LoginHelper.openPasswordManager(window, { entryPoint: 'mainmenu' })"
+ />
+ <toolbarbutton id="appMenu-extensions-themes-button"
+ class="subviewbutton"
+ data-l10n-id="appmenuitem-addons-and-themes"
+ key="key_openAddons"
+ command="Tools:Addons"
+ />
+ <toolbarseparator/>
+ <toolbarbutton id="appMenu-print-button2"
+ class="subviewbutton"
+ data-l10n-id="appmenuitem-print"
+ key="printKb"
+ command="cmd_print"
+ />
+ <toolbarbutton id="appMenu-save-file-button2"
+ class="subviewbutton"
+ data-l10n-id="appmenuitem-save-page"
+ key="key_savePage"
+ command="Browser:SavePage"/>
+ <toolbarbutton id="appMenu-find-button2"
+ class="subviewbutton"
+ data-l10n-id="appmenuitem-find-in-page"
+ key="key_find"
+ command="cmd_find"/>
+ <toolbarbutton id="appMenu-translate-button"
+ class="subviewbutton"
+ data-l10n-id="appmenuitem-translate"
+ command="cmd_translate"/>
+ <toolbaritem id="appMenu-zoom-controls" class="subviewbutton toolbaritem-combined-buttons" closemenu="none">
+ <label class="toolbarbutton-text" data-l10n-id="appmenuitem-zoom"/>
+ <toolbarbutton id="appMenu-zoomReduce-button2"
+ class="subviewbutton subviewbutton-iconic"
+ command="cmd_fullZoomReduce"
+ data-l10n-id="appmenuitem-zoom-reduce"
+ tooltip="dynamic-shortcut-tooltip"/>
+ <toolbarbutton id="appMenu-zoomReset-button2"
+ class="subviewbutton"
+ command="cmd_fullZoomReset"
+ tooltip="dynamic-shortcut-tooltip"/>
+ <toolbarbutton id="appMenu-zoomEnlarge-button2"
+ class="subviewbutton subviewbutton-iconic"
+ command="cmd_fullZoomEnlarge"
+ data-l10n-id="appmenuitem-zoom-enlarge"
+ tooltip="dynamic-shortcut-tooltip"/>
+ <toolbarbutton id="appMenu-fullscreen-button2"
+ class="subviewbutton subviewbutton-iconic"
+ data-l10n-id="appmenuitem-fullscreen"
+ type="checkbox"
+# Note that we're custom-handling this click to make sure the panel disappears
+# before entering fullscreen, as it does some odd moving about on the screen
+# in the middle of the fullscreen transition otherwise.
+ oncommand="
+ this.closest('panel').hidePopup();
+ setTimeout(() => BrowserFullScreen(), 0);
+ "
+ tooltip="dynamic-shortcut-tooltip">
+ <observes element="View:FullScreen" attribute="checked"/>
+ </toolbarbutton>
+ </toolbaritem>
+ <toolbarseparator/>
+ <toolbarbutton id="appMenu-settings-button"
+ class="subviewbutton"
+ data-l10n-id="appmenuitem-settings"
+#ifdef XP_MACOSX
+ key="key_preferencesCmdMac"
+#endif
+ oncommand="openPreferences()"/>
+ <toolbarbutton id="appMenu-more-button2"
+ class="subviewbutton subviewbutton-nav"
+ data-l10n-id="appmenuitem-more-tools"
+ closemenu="none"
+ oncommand="PanelUI.showMoreToolsPanel(this);"/>
+ <toolbarbutton id="appMenu-help-button2"
+ class="subviewbutton subviewbutton-nav"
+ data-l10n-id="appmenuitem-help"
+ closemenu="none"
+ oncommand="PanelUI.showSubView('PanelUI-helpView', this)"/>
+#ifndef XP_MACOSX
+ <toolbarseparator/>
+ <toolbarbutton id="appMenu-quit-button2"
+ class="subviewbutton"
+ data-l10n-id="appmenuitem-exit2"
+ key="key_quitApplication"
+ command="cmd_quitApplication"/>
+#endif
+ </vbox>
+ </panelview>
+
+ <panelview id="PanelUI-history">
+ <vbox class="panel-subview-body">
+ <toolbarbutton id="appMenuRecentlyClosedTabs"
+ data-l10n-id="appmenu-recently-closed-tabs"
+ class="subviewbutton subviewbutton-nav"
+ closemenu="none"
+ oncommand="PanelUI.showSubView('appMenu-library-recentlyClosedTabs', this)"/>
+ <toolbarbutton id="appMenuRecentlyClosedWindows"
+ data-l10n-id="appmenu-recently-closed-windows"
+ class="subviewbutton subviewbutton-nav"
+ closemenu="none"
+ oncommand="PanelUI.showSubView('appMenu-library-recentlyClosedWindows', this)"/>
+ <toolbarbutton id="appMenuSearchHistory"
+ data-l10n-id="appmenu-search-history"
+ class="subviewbutton"
+ oncommand="PlacesCommandHook.searchHistory()"/>
+ <toolbarbutton id="appMenu-restoreSession"
+ data-l10n-id="appmenu-restore-session"
+ class="subviewbutton"
+ command="Browser:RestoreLastSession"/>
+ <toolbarseparator/>
+ <toolbarbutton id="appMenuClearRecentHistory"
+ data-l10n-id="appmenu-clear-history"
+ class="subviewbutton"
+ command="Tools:Sanitize"/>
+ <toolbarseparator/>
+ <html:h2 id="panelMenu_recentHistory" class="subview-subheader" data-l10n-id="appmenu-recent-history-subheader"></html:h2>
+ <toolbaritem id="appMenu_historyMenu"
+ orient="vertical"
+ smoothscroll="false"
+ flatList="true"
+ tooltip="bhTooltip"
+ role="group"
+ aria-labelledby="panelMenu_recentHistory">
+ <!-- history menu items will go here -->
+ </toolbaritem>
+ </vbox>
+ <toolbarseparator/>
+ <toolbarbutton id="PanelUI-historyMore"
+ class="subviewbutton panel-subview-footer-button"
+ data-l10n-id="appmenu-manage-history"
+ oncommand="PlacesCommandHook.showPlacesOrganizer('History'); CustomizableUI.hidePanelForNode(this);"/>
+ </panelview>
+
+ <panelview id="appMenu-library-recentlyClosedTabs"/>
+ <panelview id="appMenu-library-recentlyClosedWindows"/>
+
+ <panelview id="PanelUI-containers">
+ <vbox id="PanelUI-containersItems"/>
+ </panelview>
+
+ <panelview id="PanelUI-helpView" class="PanelUI-subView" data-l10n-id="appmenu-help-header" data-l10n-attrs="title">
+ <vbox id="PanelUI-helpItems" class="panel-subview-body"/>
+ </panelview>
+
+ <panelview id="PanelUI-bookmarks" class="PanelUI-subView">
+ <vbox class="panel-subview-body">
+ <toolbarbutton id="panelMenuBookmarkThisPage"
+ class="subviewbutton"
+ command="Browser:AddBookmarkAs"
+ />
+ <toolbarbutton id="panelMenu_searchBookmarks"
+ data-l10n-id="bookmarks-search"
+ class="subviewbutton"
+ oncommand="PlacesCommandHook.searchBookmarks();"/>
+ <toolbarbutton id="panelMenu_viewBookmarksToolbar"
+ class="subviewbutton"
+ data-l10n-id="bookmarks-tools-toolbar-visibility-panel"
+ data-l10n-args='{ "isVisible": false }'
+ oncommand="BookmarkingUI.toggleBookmarksToolbar('bookmark-tools');"/>
+ <toolbarseparator/>
+ <html:h2 id="panelMenu_recentBookmarks"
+ data-l10n-id="bookmarks-recent-bookmarks-panel-subheader"
+ class="subview-subheader"/>
+ <toolbaritem id="panelMenu_bookmarksMenu"
+ orient="vertical"
+ smoothscroll="false"
+ flatList="true"
+ tooltip="bhTooltip"
+ role="group"
+ aria-labelledby="panelMenu_recentBookmarks">
+ <!-- bookmarks menu items will go here -->
+ </toolbaritem>
+ </vbox>
+ <toolbarseparator/>
+ <toolbarbutton id="panelMenu_showAllBookmarks"
+ data-l10n-id="bookmarks-manage-bookmarks"
+ class="subviewbutton panel-subview-footer-button"
+ command="Browser:ShowAllBookmarks"
+ />
+ </panelview>
+
+ <panelview id="PanelUI-profiler" showheader="true">
+ <vbox id="PanelUI-profiler-header" animationready="false">
+ <hbox id="PanelUI-profiler-header-bar" class="panel-header panel-header-with-info-button">
+ <html:h1>
+ <html:span data-l10n-id="profiler-popup-header-text"></html:span>
+ </html:h1>
+ <toolbarbutton id="PanelUI-profiler-info-button"
+ class="panel-info-button"
+ closemenu="none"
+ data-l10n-id="profiler-popup-reveal-description-button">
+ <image/>
+ </toolbarbutton>
+ </hbox>
+ <toolbarseparator />
+ <vbox id="PanelUI-profiler-info">
+ <hbox id="PanelUI-profiler-info-graphic" flex="1">
+ <spacer flex="1" />
+ <vbox>
+ <spacer flex="1" />
+ <image class="PanelUI-profiler-info-icon" />
+ </vbox>
+ </hbox>
+ <label class="PanelUI-profiler-description-title" data-l10n-id="profiler-popup-description-title" />
+ <description class="PanelUI-profiler-description" data-l10n-id="profiler-popup-description" />
+ <hbox>
+ <button id="PanelUI-profiler-learn-more"
+ class="PanelUI-profiler-button-link text-link"
+ tabindex="-1"
+ data-l10n-id="profiler-popup-learn-more-button" />
+ </hbox>
+ </vbox>
+ </vbox>
+ <vbox id="PanelUI-profiler-content">
+ <vbox id="PanelUI-profiler-content-settings">
+ <label class="PanelUI-profiler-content-label"
+ data-l10n-id="profiler-popup-settings" />
+ <menulist id="PanelUI-profiler-presets"
+ flex="1"
+ value="custom"
+ size="large">
+ <menupopup id="PanelUI-profiler-presets-menupopup" presetsbuilt="false">
+ <!-- The rest of the values get dynamically inserted. The "presetsbuilt"
+ attribute will get updated to "true" once the presets have been
+ built. -->
+ <menuitem id="PanelUI-profiler-presets-custom"
+ data-l10n-id="profiler-popup-presets-custom-label"
+ value="custom"/>
+ </menupopup>
+ </menulist>
+ <!-- The following description gets inserted dynamically. -->
+ <description id="PanelUI-profiler-content-description" />
+ <hbox>
+ <button id="PanelUI-profiler-content-edit-settings"
+ class="PanelUI-profiler-button-link text-link"
+ data-l10n-id="profiler-popup-edit-settings-button">
+ </button>
+ </hbox>
+ </vbox>
+ <hbox id="PanelUI-profiler-content-recording">
+ <image class="PanelUI-profiler-recording-icon" />
+ <label class="PanelUI-profiler-recording-label" data-l10n-id="profiler-popup-recording-screen" />
+ </hbox>
+ <hbox id="PanelUI-profiler-inactive" class="PanelUI-profiler-buttons">
+ <spacer flex="1" />
+ <vbox>
+ <button data-l10n-id="profiler-popup-start-recording-button"
+ id="PanelUI-profiler-startRecording"
+ class="PanelUI-profiler-button PanelUI-profiler-button-primary" />
+ <label class="PanelUI-profiler-shortcut"
+ data-l10n-id="profiler-popup-start-shortcut" />
+ </vbox>
+ <spacer flex="1" />
+ </hbox>
+ <hbox id="PanelUI-profiler-active" class="PanelUI-profiler-buttons">
+ <vbox flex="1">
+ <button data-l10n-id="profiler-popup-discard-button"
+ class="PanelUI-profiler-button"
+ id="PanelUI-profiler-stopAndDiscard" />
+ <label class="PanelUI-profiler-shortcut"
+ data-l10n-id="profiler-popup-start-shortcut" />
+ </vbox>
+ <vbox flex="1">
+ <button data-l10n-id="profiler-popup-capture-button"
+ class="PanelUI-profiler-button PanelUI-profiler-button-primary"
+ id="PanelUI-profiler-stopAndCapture" />
+ <label data-l10n-id="profiler-popup-capture-shortcut"
+ class="PanelUI-profiler-shortcut" />
+ </vbox>
+ </hbox>
+ </vbox>
+ </panelview>
+
+ <panelview id="PanelUI-panicView">
+ <vbox class="panel-subview-body">
+ <hbox id="PanelUI-panic-timeframe">
+ <image id="PanelUI-panic-timeframe-icon" alt=""/>
+ <vbox flex="1">
+ <description data-l10n-id="panic-main-timeframe-desc" id="PanelUI-panic-mainDesc"></description>
+ <radiogroup id="PanelUI-panic-timeSpan" aria-labelledby="PanelUI-panic-mainDesc" closemenu="none">
+ <radio id="PanelUI-panic-5min" data-l10n-id="panic-button-5min" selected="true"
+ value="5" class="subviewradio"/>
+ <radio id="PanelUI-panic-2hr" data-l10n-id="panic-button-2hr"
+ value="2" class="subviewradio"/>
+ <radio id="PanelUI-panic-day" data-l10n-id="panic-button-day"
+ value="6" class="subviewradio"/>
+ </radiogroup>
+ </vbox>
+ </hbox>
+ <vbox id="PanelUI-panic-explanations">
+ <label id="PanelUI-panic-actionlist-main-label" data-l10n-id="panic-button-action-desc"></label>
+
+ <label id="PanelUI-panic-actionlist-windows" class="PanelUI-panic-actionlist" data-l10n-id="panic-button-delete-tabs-and-windows"></label>
+ <label id="PanelUI-panic-actionlist-cookies" class="PanelUI-panic-actionlist" data-l10n-id="panic-button-delete-cookies"></label>
+ <label id="PanelUI-panic-actionlist-history" class="PanelUI-panic-actionlist" data-l10n-id="panic-button-delete-history"></label>
+ <label id="PanelUI-panic-actionlist-newwindow" class="PanelUI-panic-actionlist" data-l10n-id="panic-button-open-new-window"></label>
+
+ <label id="PanelUI-panic-warning" data-l10n-id="panic-button-undo-warning"></label>
+ </vbox>
+ <button id="PanelUI-panic-view-button"
+ data-l10n-id="panic-button-forget-button"/>
+ </vbox>
+ </panelview>
+
+ <panelview id="appmenu-moreTools" class="PanelUI-subView">
+ <vbox class="panel-subview-body">
+ <toolbarbutton id="appmenu-moreTools-button"
+ class="subviewbutton"
+ data-l10n-id="appmenu-customizetoolbar"
+ command="cmd_CustomizeToolbars"/>
+ <toolbarseparator/>
+ <html:h2 id="appmenu-developer-tools"
+ data-l10n-id="appmenu-developer-tools-subheader"
+ class="subview-subheader"/>
+ <vbox id="appmenu-developer-tools-view"
+ role="group"
+ aria-labelledby="appmenu-developer-tools">
+ <!-- Developer Tools menu items are inserted here -->
+ </vbox>
+ </vbox>
+ </panelview>
+
+ <panelview id="PanelUI-developer-tools" class="PanelUI-subview">
+ <vbox id="PanelUI-developer-tools-view"
+ class="panel-subview-body"
+ role="group">
+ <!-- Developer Tools menu items are inserted here -->
+ </vbox>
+ </panelview>
+
+ <panelview id="PanelUI-savetopocket"
+ class="PanelUI-subView"
+ remote="true"
+ neverhidden="true"
+ closemenu="none">
+ <vbox class="PanelUI-savetopocket-container">
+ </vbox>
+ </panelview>
+
+ <panelview id="PanelUI-remotetabs" class="PanelUI-subView">
+ <vbox class="panel-subview-body">
+ <!-- this widget has 3 boxes in the body, but only 1 is ever visible -->
+ <!-- When Sync is ready to sync -->
+ <vbox id="PanelUI-remotetabs-main" hidden="true">
+ <vbox id="PanelUI-remotetabs-buttons">
+ <toolbarbutton id="PanelUI-remotetabs-syncnow"
+ align="center"
+ class="subviewbutton"
+ oncommand="gSync.doSync();"
+ onmouseover="gSync.refreshSyncButtonsTooltip();"
+ closemenu="none">
+ <hbox flex="1">
+ <image class="syncNowBtn"/>
+ <label class="syncnow-label"
+ data-l10n-id="appmenuitem-fxa-toolbar-sync-now2"
+ sync-now-data-l10n-id="appmenuitem-fxa-toolbar-sync-now2"
+ syncing-data-l10n-id="fxa-toolbar-sync-syncing2"
+ flex="1"
+ crop="end"/>
+ </hbox>
+ </toolbarbutton>
+ <toolbarbutton id="PanelUI-remotetabs-view-managedevices"
+ class="subviewbutton"
+ data-l10n-id="appmenuitem-fxa-manage-account"
+ oncommand="gSync.openDevicesManagementPage('syncedtabs-menupanel');">
+ <observes element="sidebar-box" attribute="positionend"/>
+ </toolbarbutton>
+ <toolbarseparator id="PanelUI-remotetabs-separator"/>
+ </vbox>
+ <deck id="PanelUI-remotetabs-deck">
+ <!-- Sync is ready to Sync and the "tabs" engine is enabled -->
+ <vbox id="PanelUI-remotetabs-tabspane">
+ <vbox id="PanelUI-remotetabs-tabslist"
+ notabsforclientlabel="appmenu-remote-tabs-notabs"
+ />
+ </vbox>
+ <!-- Sync is ready to Sync but we are still fetching the tabs to show -->
+ <vbox id="PanelUI-remotetabs-fetching">
+ <!-- Show intentionally blank panel, see bug 1239845 -->
+ </vbox>
+ <!-- Sync is ready to Sync but the "tabs" engine isn't enabled-->
+ <hbox id="PanelUI-remotetabs-tabsdisabledpane" pack="center" flex="1">
+ <vbox class="PanelUI-remotetabs-instruction-box" align="center">
+ <hbox pack="center">
+ <image class="fxaSyncIllustrationIssue"/>
+ </hbox>
+ <label class="PanelUI-remotetabs-instruction-label" data-l10n-id="appmenu-remote-tabs-tabsnotsyncing"></label>
+ <hbox pack="center">
+ <toolbarbutton class="PanelUI-remotetabs-button"
+ id="PanelUI-remotetabs-tabsdisabledpane-button"
+ data-l10n-id="appmenu-remote-tabs-opensettings"
+ oncommand="gSync.openPrefs('synced-tabs');"/>
+ </hbox>
+ </vbox>
+ </hbox>
+ <!-- Sync has only 1 (ie, this) device connected -->
+ <hbox id="PanelUI-remotetabs-nodevicespane" pack="center" flex="1">
+ <vbox class="PanelUI-remotetabs-instruction-box" align="center">
+ <hbox pack="center">
+ <image class="fxaSyncIllustrationIssue"/>
+ </hbox>
+ <label class="PanelUI-remotetabs-instruction-label" data-l10n-id="appmenu-remote-tabs-noclients"></label>
+ <toolbarbutton id="PanelUI-remotetabs-connect-device-button"
+ class="PanelUI-remotetabs-button"
+ data-l10n-id="appmenu-remote-tabs-connectdevice"
+ oncommand="gSync.openConnectAnotherDevice('synced-tabs');"/>
+ </vbox>
+ </hbox>
+ </deck>
+ </vbox>
+ <!-- a box to ensure contained boxes are centered horizonally -->
+ <hbox pack="center" flex="1">
+ <!-- When Sync is not configured -->
+ <vbox id="PanelUI-remotetabs-setupsync"
+ flex="1"
+ align="center"
+ class="PanelUI-remotetabs-instruction-box"
+ hidden="true">
+ <image class="fxaSyncIllustration"/>
+ <label class="PanelUI-remotetabs-instruction-label" data-l10n-id="appmenu-remote-tabs-welcome"></label>
+ <toolbarbutton class="PanelUI-remotetabs-button"
+ id="PanelUI-remotetabs-setupsync-button"
+ data-l10n-id="appmenu-remote-tabs-sign-into-sync"
+ oncommand="gSync.openPrefs('synced-tabs');"/>
+ </vbox>
+ <!-- When Sync is not enabled -->
+ <vbox id="PanelUI-remotetabs-syncdisabled"
+ flex="1"
+ align="center"
+ class="PanelUI-remotetabs-instruction-box"
+ hidden="true">
+ <image class="fxaSyncIllustration"/>
+ <label class="PanelUI-remotetabs-instruction-label" data-l10n-id="appmenu-remote-tabs-welcome"></label>
+ <toolbarbutton class="PanelUI-remotetabs-button"
+ id="PanelUI-remotetabs-syncdisabled-button"
+ data-l10n-id="appmenu-remote-tabs-turn-on-sync"
+ oncommand="gSync.openPrefs('synced-tabs');"/>
+ </vbox>
+ <!-- When Sync needs re-authentication -->
+ <vbox id="PanelUI-remotetabs-reauthsync"
+ flex="1"
+ align="center"
+ class="PanelUI-remotetabs-instruction-box"
+ hidden="true">
+ <image class="fxaSyncIllustrationIssue"/>
+ <label class="PanelUI-remotetabs-instruction-label" data-l10n-id="appmenu-remote-tabs-welcome"></label>
+ <toolbarbutton class="PanelUI-remotetabs-button"
+ id="PanelUI-remotetabs-reauthsync-button"
+ data-l10n-id="appmenu-remote-tabs-sign-into-sync"
+ oncommand="gSync.openPrefs('synced-tabs');"/>
+ </vbox>
+ <!-- When Sync needs verification -->
+ <vbox id="PanelUI-remotetabs-unverified"
+ flex="1"
+ align="center"
+ class="PanelUI-remotetabs-instruction-box"
+ hidden="true">
+ <image class="fxaSyncIllustrationIssue"/>
+ <label class="PanelUI-remotetabs-instruction-label" data-l10n-id="appmenu-remote-tabs-unverified"></label>
+ <toolbarbutton class="PanelUI-remotetabs-button"
+ id="PanelUI-remotetabs-unverified-button"
+ data-l10n-id="appmenu-remote-tabs-opensettings"
+ oncommand="gSync.openPrefs('synced-tabs');"/>
+ </vbox>
+ </hbox>
+ </vbox>
+ </panelview>
+
+ <panelview id="PanelUI-fxa" class="PanelUI-subView">
+ <vbox id="PanelUI-fxa-menu" class="panel-subview-body">
+ <toolbarbutton id="fxa-manage-account-button"
+ align="center"
+ class="subviewbutton"
+ oncommand="gSync.clickFxAMenuHeaderButton(this);">
+ <vbox flex="1">
+ <label id="fxa-menu-header-title"
+ crop="end"
+ data-l10n-id="appmenuitem-fxa-sign-in"/>
+ <label id="fxa-menu-header-description"
+ crop="end"
+ data-l10n-id="fxa-menu-turn-on-sync"/>
+ </vbox>
+ </toolbarbutton>
+ <toolbarbutton id="PanelUI-fxa-menu-syncnow-button"
+ align="center"
+ class="subviewbutton"
+ oncommand="gSync.doSyncFromFxaMenu(this);"
+ onmouseover="gSync.refreshSyncButtonsTooltip();"
+ closemenu="none">
+ <hbox flex="1">
+ <image id="PanelUI-appMenu-fxa-image-last-synced"
+ class="syncNowBtn"/>
+ <label class="syncnow-label"
+ data-l10n-id="appmenuitem-fxa-toolbar-sync-now2"
+ sync-now-data-l10n-id="appmenuitem-fxa-toolbar-sync-now2"
+ syncing-data-l10n-id="fxa-toolbar-sync-syncing2"
+ flex="1"
+ crop="end"/>
+ </hbox>
+ </toolbarbutton>
+ <toolbarbutton id="PanelUI-fxa-menu-setup-sync-button"
+ class="subviewbutton"
+ data-l10n-id="appmenu-fxa-setup-sync"
+ oncommand="gSync.openPrefsFromFxaMenu('sync_settings', this);"/>
+ <!-- The `Connect Another Device` button is disabled by default until the user logs into Sync. -->
+ <toolbarbutton id="PanelUI-fxa-menu-connect-device-button"
+ class="subviewbutton"
+ data-l10n-id="fxa-menu-connect-another-device"
+ disabled="true"
+ oncommand="gSync.openConnectAnotherDeviceFromFxaMenu(this);"/>
+ <toolbarbutton id="PanelUI-fxa-menu-sendtab-button"
+ class="subviewbutton subviewbutton-nav"
+ data-l10n-id="fxa-menu-send-tab-to-device"
+ data-l10n-args='{"tabCount":1}'
+ closemenu="none"
+ oncommand="gSync.showSendToDeviceViewFromFxaMenu(this);"/>
+ <toolbarbutton id="PanelUI-fxa-menu-sync-prefs-button"
+ class="subviewbutton"
+ data-l10n-id="fxa-menu-sync-settings"
+ hidden="true"
+ oncommand="gSync.openPrefsFromFxaMenu('sync_settings', this);"/>
+ <toolbarseparator id="PanelUI-sign-out-separator"/>
+ <toolbarbutton id="PanelUI-fxa-menu-account-signout-button"
+ class="subviewbutton"
+ data-l10n-id="fxa-menu-sign-out"
+ oncommand="gSync.disconnect();"/>
+ <toolbarseparator id="PanelUI-remote-tabs-separator"/>
+ <deck id="PanelUI-fxa-remotetabs-deck">
+ <!-- Sync is ready to Sync and the "tabs" engine is enabled -->
+ <vbox id="PanelUI-fxa-remotetabs-tabspane">
+ <vbox id="PanelUI-fxa-remotetabs-tabslist"
+ notabsforclientlabel="appmenu-remote-tabs-notabs"
+ />
+ </vbox>
+ <!-- Sync is ready to Sync but we are still fetching the tabs to show -->
+ <vbox id="PanelUI-fxa-remotetabs-fetching">
+ <!-- Show intentionally blank panel, see bug 1239845 -->
+ </vbox>
+ </deck>
+ </vbox>
+ </panelview>
+
+ <!-- This panelview is used to contain the dynamically created buttons for send tab to devices -->
+ <panelview id="PanelUI-sendTabToDevice" class="PanelUI-subView">
+ <vbox class="panel-subview-body">
+ <toolbarbutton id="PanelUI-sendTabToDevice-syncingDevices" class="subviewbutton subviewbutton-iconic pageAction-sendToDevice-notReady"
+ data-l10n-id="fxa-menu-send-tab-to-device-syncnotready"
+ disabled="true"/>
+ </vbox>
+ </panelview>
+
+ <panelview id="PanelUI-fxa-menu-sendtab-not-configured" class="PanelUI-subView">
+ <vbox id="PanelUI-fxa-sendtab-not-configured" align="center" class="panel-subview-body">
+ <image class="fxaSendToDeviceLogo" role="presentation"/>
+ <label class="PanelUI-fxa-service-description-label" data-l10n-id="fxa-menu-send-tab-to-device-description"></label>
+ <toolbarbutton id="PanelUI-fxa-menu-sendtab-not-configured-button"
+ class="PanelUI-fxa-signin-button"
+ data-l10n-id="appmenuitem-fxa-sign-in"
+ oncommand="gSync.openPrefsFromFxaMenu('send_tab', this);"/>
+ </vbox>
+ </panelview>
+
+ <panelview id="PanelUI-fxa-menu-sendtab-no-devices" class="PanelUI-subView">
+ <vbox id="PanelUI-fxa-sendtab-no-devices" align="center" class="panel-subview-body">
+ <image class="fxaSendToDeviceLogo" role="presentation"/>
+ <label class="PanelUI-fxa-service-description-label" data-l10n-id="fxa-menu-send-tab-to-device-description"></label>
+ <toolbarbutton id="PanelUI-fxa-menu-sendtab-connect-device-button"
+ class="PanelUI-fxa-signin-button"
+ data-l10n-id="appmenu-remote-tabs-connectdevice"
+ oncommand="gSync.openConnectAnotherDeviceFromFxaMenu(this);"/>
+ </vbox>
+ </panelview>
+
+ <panelview id="appMenu-libraryView" class="PanelUI-subView">
+ <vbox class="panel-subview-body">
+ <toolbarbutton id="appMenu-library-bookmarks-button"
+ class="subviewbutton subviewbutton-nav"
+ data-l10n-id="library-bookmarks-menu"
+ closemenu="none"
+ oncommand="BookmarkingUI.showSubView(this);"/>
+ <toolbarbutton id="appMenu-library-history-button"
+ class="subviewbutton subviewbutton-nav"
+ data-l10n-id="appmenuitem-history"
+ closemenu="none"
+ oncommand="PanelUI.showSubView('PanelUI-history', this)"/>
+ <toolbarbutton id="appMenu-library-downloads-button"
+ class="subviewbutton"
+ data-l10n-id="appmenuitem-downloads"
+ oncommand="DownloadsPanel.showDownloadsHistory();"/>
+ </vbox>
+ </panelview>
+
+ <panelview id="PanelUI-whatsNew" class="PanelUI-subView" showheader="true">
+ <hbox id="PanelUI-whatsNew-title" class="panel-header">
+ <html:h1>
+ <html:span data-l10n-id="whatsnew-panel-header"></html:span>
+ </html:h1>
+ </hbox>
+ <toolbarseparator/>
+ <vbox class="panel-subview-body">
+ <toolbaritem id="PanelUI-whatsNew-content"
+ orient="vertical"
+ smoothscroll="false">
+ <html:div id="PanelUI-whatsNew-message-container" role="document">
+ <!-- What's New messages will be rendered here -->
+ </html:div>
+ </toolbaritem>
+ </vbox>
+ <toolbarseparator/>
+ <checkbox id="panelMenu-toggleWhatsNew"
+ class="panelMenu-toggleWhatsNew-checkbox"
+ onclick="ToolbarPanelHub.toggleWhatsNewPref(event)"
+ data-l10n-id="whatsnew-panel-footer-checkbox"/>
+ </panelview>
+</html:template>
diff --git a/browser/base/content/blanktab.html b/browser/base/content/blanktab.html
new file mode 100644
index 0000000000..39aa70d326
--- /dev/null
+++ b/browser/base/content/blanktab.html
@@ -0,0 +1,15 @@
+<!DOCTYPE html>
+
+<!-- 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/. -->
+
+<html>
+ <head>
+ <meta name="color-scheme" content="light dark" />
+ <meta
+ http-equiv="Content-Security-Policy"
+ content="default-src 'none'; style-src chrome:; object-src 'none'"
+ />
+ </head>
+</html>
diff --git a/browser/base/content/blockedSite.js b/browser/base/content/blockedSite.js
new file mode 100644
index 0000000000..dd2aa914fe
--- /dev/null
+++ b/browser/base/content/blockedSite.js
@@ -0,0 +1,168 @@
+/* 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/. */
+
+// Error url MUST be formatted like this:
+// about:blocked?e=error_code&u=url(&o=1)?
+// (o=1 when user overrides are allowed)
+
+// Note that this file uses document.documentURI to get
+// the URL (with the format from above). This is because
+// document.location.href gets the current URI off the docshell,
+// which is the URL displayed in the location bar, i.e.
+// the URI that the user attempted to load.
+
+function getErrorCode() {
+ var url = document.documentURI;
+ var error = url.search(/e\=/);
+ var duffUrl = url.search(/\&u\=/);
+ return decodeURIComponent(url.slice(error + 2, duffUrl));
+}
+
+function getURL() {
+ var url = document.documentURI;
+ var match = url.match(/&u=([^&]+)&/);
+
+ // match == null if not found; if so, return an empty string
+ // instead of what would turn out to be portions of the URI
+ if (!match) {
+ return "";
+ }
+
+ url = decodeURIComponent(match[1]);
+
+ // If this is a view-source page, then get then real URI of the page
+ if (url.startsWith("view-source:")) {
+ url = url.slice(12);
+ }
+ return url;
+}
+
+/**
+ * Check whether this warning page is overridable or not, in which case
+ * the "ignore the risk" suggestion in the error description
+ * should not be shown.
+ */
+function getOverride() {
+ var url = document.documentURI;
+ var match = url.match(/&o=1&/);
+ return !!match;
+}
+
+/**
+ * Attempt to get the hostname via document.location. Fail back
+ * to getURL so that we always return something meaningful.
+ */
+function getHostString() {
+ try {
+ return document.location.hostname;
+ } catch (e) {
+ return getURL();
+ }
+}
+
+function onClickSeeDetails() {
+ let details = document.getElementById("errorDescriptionContainer");
+ details.hidden = !details.hidden;
+}
+
+function initPage() {
+ var error = "";
+ switch (getErrorCode()) {
+ case "malwareBlocked":
+ error = "malware";
+ break;
+ case "deceptiveBlocked":
+ error = "phishing";
+ break;
+ case "unwantedBlocked":
+ error = "unwanted";
+ break;
+ case "harmfulBlocked":
+ error = "harmful";
+ break;
+ default:
+ return;
+ }
+
+ // Set page contents depending on type of blocked page
+ // Prepare the title and short description text
+ let titleText = document.getElementById("errorTitleText");
+ document.l10n.setAttributes(
+ titleText,
+ "safeb-blocked-" + error + "-page-title"
+ );
+ let shortDesc = document.getElementById("errorShortDescText");
+ document.l10n.setAttributes(
+ shortDesc,
+ "safeb-blocked-" + error + "-page-short-desc"
+ );
+
+ // Prepare the inner description, ensuring any redundant inner elements are removed.
+ let innerDesc = document.getElementById("errorInnerDescription");
+ let innerDescL10nID = "safeb-blocked-" + error + "-page-error-desc-";
+ if (!getOverride()) {
+ innerDescL10nID += "no-override";
+ document.getElementById("ignore_warning_link").remove();
+ } else {
+ innerDescL10nID += "override";
+ }
+ if (error == "unwanted" || error == "harmful") {
+ document.getElementById("report_detection").remove();
+ }
+
+ // Add the inner description:
+ // Map specific elements to a different message ID, to allow updates to
+ // existing labels
+ let descriptionMapping = {
+ malware: innerDescL10nID + "-sumo",
+ };
+ document.l10n.setAttributes(
+ innerDesc,
+ descriptionMapping[error] || innerDescL10nID,
+ {
+ sitename: getHostString(),
+ }
+ );
+
+ // Add the learn more content:
+ // Map specific elements to a different message ID, to allow updates to
+ // existing labels
+ let stringMapping = {
+ malware: "safeb-blocked-malware-page-learn-more-sumo",
+ };
+
+ let learnMore = document.getElementById("learn_more");
+ document.l10n.setAttributes(
+ learnMore,
+ stringMapping[error] || `safeb-blocked-${error}-page-learn-more`
+ );
+
+ // Set sitename to bold by adding class
+ let errorSitename = document.getElementById("error_desc_sitename");
+ errorSitename.setAttribute("class", "sitename");
+
+ let titleEl = document.createElement("title");
+ document.l10n.setAttributes(
+ titleEl,
+ "safeb-blocked-" + error + "-page-title"
+ );
+ document.head.appendChild(titleEl);
+
+ // Inform the test harness that we're done loading the page.
+ var event = new CustomEvent("AboutBlockedLoaded", {
+ bubbles: true,
+ detail: {
+ url: this.getURL(),
+ err: error,
+ },
+ });
+ document.dispatchEvent(event);
+}
+
+let seeDetailsButton = document.getElementById("seeDetailsButton");
+seeDetailsButton.addEventListener("click", onClickSeeDetails);
+// Note: It is important to run the script this way, instead of using
+// an onload handler. This is because error pages are loaded as
+// LOAD_BACKGROUND, which means that onload handlers will not be executed.
+initPage();
diff --git a/browser/base/content/blockedSite.xhtml b/browser/base/content/blockedSite.xhtml
new file mode 100644
index 0000000000..e4e74edd6a
--- /dev/null
+++ b/browser/base/content/blockedSite.xhtml
@@ -0,0 +1,83 @@
+<?xml version="1.0" encoding="UTF-8"?>
+
+<!DOCTYPE html>
+<!-- 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/. -->
+<html xmlns="http://www.w3.org/1999/xhtml">
+ <head>
+ <meta
+ http-equiv="Content-Security-Policy"
+ content="default-src chrome:; object-src 'none'"
+ />
+ <meta name="color-scheme" content="light dark" />
+ <link
+ rel="stylesheet"
+ href="chrome://browser/skin/blockedSite.css"
+ type="text/css"
+ media="all"
+ />
+ <link
+ rel="icon"
+ id="favicon"
+ href="chrome://global/skin/icons/blocked.svg"
+ />
+ <link rel="localization" href="branding/brand.ftl" />
+ <link rel="localization" href="browser/safebrowsing/blockedSite.ftl" />
+ </head>
+ <body>
+ <div id="errorPageContainer" class="container">
+ <!-- Error Title -->
+ <div id="errorTitle" class="title">
+ <h1 class="title-text" id="errorTitleText"></h1>
+ </div>
+
+ <div id="errorLongContent">
+ <!-- Short Description -->
+ <div id="errorShortDesc">
+ <p id="errorShortDescText"></p>
+ </div>
+
+ <!-- Advisory -->
+ <div id="advisoryDesc">
+ <p id="advisoryDescText">
+ <a id="advisory_provider" data-l10n-name="advisory_provider"></a>
+ </p>
+ </div>
+
+ <!-- Action buttons -->
+ <div id="buttons" class="button-container">
+ <!-- Commands handled in browser.js -->
+ <button
+ id="goBackButton"
+ class="primary"
+ data-l10n-id="safeb-palm-accept-label"
+ ></button>
+ <button
+ id="seeDetailsButton"
+ data-l10n-id="safeb-palm-see-details-label"
+ ></button>
+ </div>
+ </div>
+ <div id="errorDescriptionContainer" hidden="true">
+ <!-- Error Descriptions Handled in blockedSite.js -->
+ <div class="error-description" id="errorLongDesc">
+ <p id="errorInnerDescription">
+ <span id="error_desc_sitename" data-l10n-name="sitename"></span>
+ <a id="error_desc_link" data-l10n-name="error_desc_link"></a>
+ <a id="report_detection" data-l10n-name="report_detection"></a>
+ <a
+ id="ignore_warning_link"
+ data-l10n-name="ignore_warning_link"
+ ></a>
+ </p>
+ <p id="learn_more">
+ <a id="learn_more_link" data-l10n-name="learn_more_link"></a>
+ <a id="firefox_support" data-l10n-name="firefox_support"></a>
+ </p>
+ </div>
+ </div>
+ </div>
+ </body>
+ <script src="chrome://browser/content/blockedSite.js" />
+</html>
diff --git a/browser/base/content/browser-a11yUtils.js b/browser/base/content/browser-a11yUtils.js
new file mode 100644
index 0000000000..9bedb9238c
--- /dev/null
+++ b/browser/base/content/browser-a11yUtils.js
@@ -0,0 +1,80 @@
+/* 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/. */
+
+// This file is loaded into the browser window scope.
+/* eslint-env mozilla/browser-window */
+
+/**
+ * Utility functions for UI accessibility.
+ */
+
+var A11yUtils = {
+ /**
+ * Announce a message to the user.
+ * This should only be used when something happens that is important to the
+ * user and will be noticed visually, but is not related to the focused
+ * control and is not a pop-up such as a doorhanger.
+ * For example, this could be used to indicate that Reader View is available
+ * or that Firefox is making a recommendation via the toolbar.
+ * This must be used with caution, as it can create unwanted verbosity and
+ * can thus hinder rather than help users if used incorrectly.
+ * Please only use this after consultation with the Mozilla accessibility
+ * team.
+ * @param {string} [options.id] The Fluent id of the message to announce. The
+ * ftl file must already be included in browser.xhtml. This must be
+ * specified unless a raw message is specified instead.
+ * @param {object} [options.args] Arguments for the Fluent message.
+ * @param {string} [options.raw] The raw, already localized message to
+ * announce. You should generally prefer a Fluent id instead, but in
+ * rare cases, this might not be feasible.
+ * @param {Element} [options.source] The element with which the announcement
+ * is associated. This should generally be something the user can
+ * interact with to respond to the announcement. For example, for an
+ * announcement indicating that Reader View is available, this should
+ * be the Reader View button on the toolbar.
+ */
+ async announce({ id = null, args = {}, raw = null, source = document } = {}) {
+ if ((!id && !raw) || (id && raw)) {
+ throw new Error("One of raw or id must be specified.");
+ }
+
+ // Cancel a previous pending call if any.
+ if (this._cancelAnnounce) {
+ this._cancelAnnounce();
+ this._cancelAnnounce = null;
+ }
+
+ let message;
+ if (id) {
+ let cancel = false;
+ this._cancelAnnounce = () => (cancel = true);
+ message = await document.l10n.formatValue(id, args);
+ if (cancel) {
+ // announce() was called again while we were waiting for translation.
+ return;
+ }
+ // No more async operations from this point.
+ this._cancelAnnounce = null;
+ } else {
+ // We run fully synchronously if a raw message is provided.
+ message = raw;
+ }
+
+ // For now, we don't use source, but it might be useful in future.
+ // For example, we might use it when we support announcement events on
+ // more platforms or it could be used to have a keyboard shortcut which
+ // focuses the last element to announce a message.
+ let live = document.getElementById("a11y-announcement");
+ // We use role="alert" because JAWS doesn't support aria-live in browser
+ // chrome.
+ // Gecko a11y needs an insertion to trigger an alert event. This is why
+ // we can't just use aria-label on the alert.
+ if (live.firstChild) {
+ live.firstChild.remove();
+ }
+ let label = document.createElement("label");
+ label.setAttribute("aria-label", message);
+ live.appendChild(label);
+ },
+};
diff --git a/browser/base/content/browser-addons.js b/browser/base/content/browser-addons.js
new file mode 100644
index 0000000000..708f3af68f
--- /dev/null
+++ b/browser/base/content/browser-addons.js
@@ -0,0 +1,1918 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+ * 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/. */
+
+// This file is loaded into the browser window scope.
+/* eslint-env mozilla/browser-window */
+
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+const lazy = {};
+
+ChromeUtils.defineESModuleGetters(lazy, {
+ ExtensionParent: "resource://gre/modules/ExtensionParent.sys.mjs",
+ ExtensionPermissions: "resource://gre/modules/ExtensionPermissions.sys.mjs",
+ OriginControls: "resource://gre/modules/ExtensionPermissions.sys.mjs",
+ SITEPERMS_ADDON_TYPE:
+ "resource://gre/modules/addons/siteperms-addon-utils.sys.mjs",
+});
+XPCOMUtils.defineLazyGetter(lazy, "l10n", function () {
+ return new Localization(
+ ["browser/addonNotifications.ftl", "branding/brand.ftl"],
+ true
+ );
+});
+
+/**
+ * Mapping of error code -> [error-id, local-error-id]
+ *
+ * error-id is used for errors in DownloadedAddonInstall,
+ * local-error-id for errors in LocalAddonInstall.
+ *
+ * The error codes are defined in AddonManager's _errors Map.
+ * Not all error codes listed there are translated,
+ * since errors that are only triggered during updates
+ * will never reach this code.
+ */
+const ERROR_L10N_IDS = new Map([
+ [
+ -1,
+ [
+ "addon-install-error-network-failure",
+ "addon-local-install-error-network-failure",
+ ],
+ ],
+ [
+ -2,
+ [
+ "addon-install-error-incorrect-hash",
+ "addon-local-install-error-incorrect-hash",
+ ],
+ ],
+ [
+ -3,
+ [
+ "addon-install-error-corrupt-file",
+ "addon-local-install-error-corrupt-file",
+ ],
+ ],
+ [
+ -4,
+ [
+ "addon-install-error-file-access",
+ "addon-local-install-error-file-access",
+ ],
+ ],
+ [
+ -5,
+ ["addon-install-error-not-signed", "addon-local-install-error-not-signed"],
+ ],
+ [-8, ["addon-install-error-invalid-domain"]],
+]);
+
+customElements.define(
+ "addon-progress-notification",
+ class MozAddonProgressNotification extends customElements.get(
+ "popupnotification"
+ ) {
+ show() {
+ super.show();
+ this.progressmeter = document.getElementById(
+ "addon-progress-notification-progressmeter"
+ );
+
+ this.progresstext = document.getElementById(
+ "addon-progress-notification-progresstext"
+ );
+
+ if (!this.notification) {
+ return;
+ }
+
+ this.notification.options.installs.forEach(function (aInstall) {
+ aInstall.addListener(this);
+ }, this);
+
+ // Calling updateProgress can sometimes cause this notification to be
+ // removed in the middle of refreshing the notification panel which
+ // makes the panel get refreshed again. Just initialise to the
+ // undetermined state and then schedule a proper check at the next
+ // opportunity
+ this.setProgress(0, -1);
+ this._updateProgressTimeout = setTimeout(
+ this.updateProgress.bind(this),
+ 0
+ );
+ }
+
+ disconnectedCallback() {
+ this.destroy();
+ }
+
+ destroy() {
+ if (!this.notification) {
+ return;
+ }
+ this.notification.options.installs.forEach(function (aInstall) {
+ aInstall.removeListener(this);
+ }, this);
+
+ clearTimeout(this._updateProgressTimeout);
+ }
+
+ setProgress(aProgress, aMaxProgress) {
+ if (aMaxProgress == -1) {
+ this.progressmeter.removeAttribute("value");
+ } else {
+ this.progressmeter.setAttribute(
+ "value",
+ (aProgress * 100) / aMaxProgress
+ );
+ }
+
+ let now = Date.now();
+
+ if (!this.notification.lastUpdate) {
+ this.notification.lastUpdate = now;
+ this.notification.lastProgress = aProgress;
+ return;
+ }
+
+ let delta = now - this.notification.lastUpdate;
+ if (delta < 400 && aProgress < aMaxProgress) {
+ return;
+ }
+
+ // Set min. time delta to avoid division by zero in the upcoming speed calculation
+ delta = Math.max(delta, 400);
+ delta /= 1000;
+
+ // This algorithm is the same used by the downloads code.
+ let speed = (aProgress - this.notification.lastProgress) / delta;
+ if (this.notification.speed) {
+ speed = speed * 0.9 + this.notification.speed * 0.1;
+ }
+
+ this.notification.lastUpdate = now;
+ this.notification.lastProgress = aProgress;
+ this.notification.speed = speed;
+
+ let status = null;
+ [status, this.notification.last] = DownloadUtils.getDownloadStatus(
+ aProgress,
+ aMaxProgress,
+ speed,
+ this.notification.last
+ );
+ this.progresstext.setAttribute("value", status);
+ this.progresstext.setAttribute("tooltiptext", status);
+ }
+
+ cancel() {
+ let installs = this.notification.options.installs;
+ installs.forEach(function (aInstall) {
+ try {
+ aInstall.cancel();
+ } catch (e) {
+ // Cancel will throw if the download has already failed
+ }
+ }, this);
+
+ PopupNotifications.remove(this.notification);
+ }
+
+ updateProgress() {
+ if (!this.notification) {
+ return;
+ }
+
+ let downloadingCount = 0;
+ let progress = 0;
+ let maxProgress = 0;
+
+ this.notification.options.installs.forEach(function (aInstall) {
+ if (aInstall.maxProgress == -1) {
+ maxProgress = -1;
+ }
+ progress += aInstall.progress;
+ if (maxProgress >= 0) {
+ maxProgress += aInstall.maxProgress;
+ }
+ if (aInstall.state < AddonManager.STATE_DOWNLOADED) {
+ downloadingCount++;
+ }
+ });
+
+ if (downloadingCount == 0) {
+ this.destroy();
+ this.progressmeter.removeAttribute("value");
+ const status = lazy.l10n.formatValueSync("addon-download-verifying");
+ this.progresstext.setAttribute("value", status);
+ this.progresstext.setAttribute("tooltiptext", status);
+ } else {
+ this.setProgress(progress, maxProgress);
+ }
+ }
+
+ onDownloadProgress() {
+ this.updateProgress();
+ }
+
+ onDownloadFailed() {
+ this.updateProgress();
+ }
+
+ onDownloadCancelled() {
+ this.updateProgress();
+ }
+
+ onDownloadEnded() {
+ this.updateProgress();
+ }
+ }
+);
+
+// Removes a doorhanger notification if all of the installs it was notifying
+// about have ended in some way.
+function removeNotificationOnEnd(notification, installs) {
+ let count = installs.length;
+
+ function maybeRemove(install) {
+ install.removeListener(this);
+
+ if (--count == 0) {
+ // Check that the notification is still showing
+ let current = PopupNotifications.getNotification(
+ notification.id,
+ notification.browser
+ );
+ if (current === notification) {
+ notification.remove();
+ }
+ }
+ }
+
+ for (let install of installs) {
+ install.addListener({
+ onDownloadCancelled: maybeRemove,
+ onDownloadFailed: maybeRemove,
+ onInstallFailed: maybeRemove,
+ onInstallEnded: maybeRemove,
+ });
+ }
+}
+
+function buildNotificationAction(msg, callback) {
+ let label = "";
+ let accessKey = "";
+ for (let { name, value } of msg.attributes) {
+ switch (name) {
+ case "label":
+ label = value;
+ break;
+ case "accesskey":
+ accessKey = value;
+ break;
+ }
+ }
+ return { label, accessKey, callback };
+}
+
+var gXPInstallObserver = {
+ _findChildShell(aDocShell, aSoughtShell) {
+ if (aDocShell == aSoughtShell) {
+ return aDocShell;
+ }
+
+ var node = aDocShell.QueryInterface(Ci.nsIDocShellTreeItem);
+ for (var i = 0; i < node.childCount; ++i) {
+ var docShell = node.getChildAt(i);
+ docShell = this._findChildShell(docShell, aSoughtShell);
+ if (docShell == aSoughtShell) {
+ return docShell;
+ }
+ }
+ return null;
+ },
+
+ _getBrowser(aDocShell) {
+ for (let browser of gBrowser.browsers) {
+ if (this._findChildShell(browser.docShell, aDocShell)) {
+ return browser;
+ }
+ }
+ return null;
+ },
+
+ pendingInstalls: new WeakMap(),
+
+ showInstallConfirmation(browser, installInfo, height = undefined) {
+ // If the confirmation notification is already open cache the installInfo
+ // and the new confirmation will be shown later
+ if (
+ PopupNotifications.getNotification("addon-install-confirmation", browser)
+ ) {
+ let pending = this.pendingInstalls.get(browser);
+ if (pending) {
+ pending.push(installInfo);
+ } else {
+ this.pendingInstalls.set(browser, [installInfo]);
+ }
+ return;
+ }
+
+ let showNextConfirmation = () => {
+ // Make sure the browser is still alive.
+ if (!gBrowser.browsers.includes(browser)) {
+ return;
+ }
+
+ let pending = this.pendingInstalls.get(browser);
+ if (pending && pending.length) {
+ this.showInstallConfirmation(browser, pending.shift());
+ }
+ };
+
+ // If all installs have already been cancelled in some way then just show
+ // the next confirmation
+ if (
+ installInfo.installs.every(i => i.state != AddonManager.STATE_DOWNLOADED)
+ ) {
+ showNextConfirmation();
+ return;
+ }
+
+ // Make notifications persistent
+ var options = {
+ displayURI: installInfo.originatingURI,
+ persistent: true,
+ hideClose: true,
+ popupOptions: {
+ position: "bottomright topright",
+ },
+ };
+
+ let acceptInstallation = () => {
+ for (let install of installInfo.installs) {
+ install.install();
+ }
+ installInfo = null;
+
+ Services.telemetry
+ .getHistogramById("SECURITY_UI")
+ .add(
+ Ci.nsISecurityUITelemetry.WARNING_CONFIRM_ADDON_INSTALL_CLICK_THROUGH
+ );
+ };
+
+ let cancelInstallation = () => {
+ if (installInfo) {
+ for (let install of installInfo.installs) {
+ // The notification may have been closed because the add-ons got
+ // cancelled elsewhere, only try to cancel those that are still
+ // pending install.
+ if (install.state != AddonManager.STATE_CANCELLED) {
+ install.cancel();
+ }
+ }
+ }
+
+ showNextConfirmation();
+ };
+
+ let unsigned = installInfo.installs.filter(
+ i => i.addon.signedState <= AddonManager.SIGNEDSTATE_MISSING
+ );
+ let someUnsigned =
+ !!unsigned.length && unsigned.length < installInfo.installs.length;
+
+ options.eventCallback = aEvent => {
+ switch (aEvent) {
+ case "removed":
+ cancelInstallation();
+ break;
+ case "shown":
+ let addonList = document.getElementById(
+ "addon-install-confirmation-content"
+ );
+ while (addonList.firstChild) {
+ addonList.firstChild.remove();
+ }
+
+ for (let install of installInfo.installs) {
+ let container = document.createXULElement("hbox");
+
+ let name = document.createXULElement("label");
+ name.setAttribute("value", install.addon.name);
+ name.setAttribute("class", "addon-install-confirmation-name");
+ container.appendChild(name);
+
+ if (
+ someUnsigned &&
+ install.addon.signedState <= AddonManager.SIGNEDSTATE_MISSING
+ ) {
+ let unsignedLabel = document.createXULElement("label");
+ document.l10n.setAttributes(
+ unsignedLabel,
+ "popup-notification-addon-install-unsigned"
+ );
+ unsignedLabel.setAttribute(
+ "class",
+ "addon-install-confirmation-unsigned"
+ );
+ container.appendChild(unsignedLabel);
+ }
+
+ addonList.appendChild(container);
+ }
+ break;
+ }
+ };
+
+ options.learnMoreURL = Services.urlFormatter.formatURLPref(
+ "app.support.baseURL"
+ );
+
+ let msgId;
+ let notification = document.getElementById(
+ "addon-install-confirmation-notification"
+ );
+ if (unsigned.length == installInfo.installs.length) {
+ // None of the add-ons are verified
+ msgId = "addon-confirm-install-unsigned-message";
+ notification.setAttribute("warning", "true");
+ options.learnMoreURL += "unsigned-addons";
+ } else if (!unsigned.length) {
+ // All add-ons are verified or don't need to be verified
+ msgId = "addon-confirm-install-message";
+ notification.removeAttribute("warning");
+ options.learnMoreURL += "find-and-install-add-ons";
+ } else {
+ // Some of the add-ons are unverified, the list of names will indicate
+ // which
+ msgId = "addon-confirm-install-some-unsigned-message";
+ notification.setAttribute("warning", "true");
+ options.learnMoreURL += "unsigned-addons";
+ }
+ const addonCount = installInfo.installs.length;
+ const messageString = lazy.l10n.formatValueSync(msgId, { addonCount });
+
+ const [acceptMsg, cancelMsg] = lazy.l10n.formatMessagesSync([
+ "addon-install-accept-button",
+ "addon-install-cancel-button",
+ ]);
+ const action = buildNotificationAction(acceptMsg, acceptInstallation);
+ const secondaryAction = buildNotificationAction(cancelMsg, () => {});
+
+ if (height) {
+ notification.style.minHeight = height + "px";
+ }
+
+ let tab = gBrowser.getTabForBrowser(browser);
+ if (tab) {
+ gBrowser.selectedTab = tab;
+ }
+
+ let popup = PopupNotifications.show(
+ browser,
+ "addon-install-confirmation",
+ messageString,
+ gUnifiedExtensions.getPopupAnchorID(browser, window),
+ action,
+ [secondaryAction],
+ options
+ );
+
+ removeNotificationOnEnd(popup, installInfo.installs);
+
+ Services.telemetry
+ .getHistogramById("SECURITY_UI")
+ .add(Ci.nsISecurityUITelemetry.WARNING_CONFIRM_ADDON_INSTALL);
+ },
+
+ // IDs of addon install related notifications
+ NOTIFICATION_IDS: [
+ "addon-install-blocked",
+ "addon-install-confirmation",
+ "addon-install-failed",
+ "addon-install-origin-blocked",
+ "addon-install-webapi-blocked",
+ "addon-install-policy-blocked",
+ "addon-progress",
+ "addon-webext-permissions",
+ "xpinstall-disabled",
+ ],
+
+ /**
+ * Remove all opened addon installation notifications
+ *
+ * @param {*} browser - Browser to remove notifications for
+ * @returns {boolean} - true if notifications have been removed.
+ */
+ removeAllNotifications(browser) {
+ let notifications = this.NOTIFICATION_IDS.map(id =>
+ PopupNotifications.getNotification(id, browser)
+ ).filter(notification => notification != null);
+
+ PopupNotifications.remove(notifications, true);
+
+ return !!notifications.length;
+ },
+
+ logWarningFullScreenInstallBlocked() {
+ // If notifications have been removed, log a warning to the website console
+ let consoleMsg = Cc["@mozilla.org/scripterror;1"].createInstance(
+ Ci.nsIScriptError
+ );
+ const message = lazy.l10n.formatValueSync(
+ "addon-install-full-screen-blocked"
+ );
+ consoleMsg.initWithWindowID(
+ message,
+ gBrowser.currentURI.spec,
+ null,
+ 0,
+ 0,
+ Ci.nsIScriptError.warningFlag,
+ "FullScreen",
+ gBrowser.selectedBrowser.innerWindowID
+ );
+ Services.console.logMessage(consoleMsg);
+ },
+
+ async observe(aSubject, aTopic, aData) {
+ var installInfo = aSubject.wrappedJSObject;
+ var browser = installInfo.browser;
+
+ // Make sure the browser is still alive.
+ if (!browser || !gBrowser.browsers.includes(browser)) {
+ return;
+ }
+
+ // Make notifications persistent
+ var options = {
+ displayURI: installInfo.originatingURI,
+ persistent: true,
+ hideClose: true,
+ timeout: Date.now() + 30000,
+ popupOptions: {
+ position: "bottomright topright",
+ },
+ };
+
+ switch (aTopic) {
+ case "addon-install-disabled": {
+ let msgId, action, secondaryActions;
+ if (Services.prefs.prefIsLocked("xpinstall.enabled")) {
+ msgId = "xpinstall-disabled-locked";
+ action = null;
+ secondaryActions = null;
+ } else {
+ msgId = "xpinstall-disabled";
+ const [disabledMsg, cancelMsg] = await lazy.l10n.formatMessages([
+ "xpinstall-disabled-button",
+ "addon-install-cancel-button",
+ ]);
+ action = buildNotificationAction(disabledMsg, () => {
+ Services.prefs.setBoolPref("xpinstall.enabled", true);
+ });
+ secondaryActions = [buildNotificationAction(cancelMsg, () => {})];
+ }
+
+ PopupNotifications.show(
+ browser,
+ "xpinstall-disabled",
+ await lazy.l10n.formatValue(msgId),
+ gUnifiedExtensions.getPopupAnchorID(browser, window),
+ action,
+ secondaryActions,
+ options
+ );
+ break;
+ }
+ case "addon-install-fullscreen-blocked": {
+ // AddonManager denied installation because we are in DOM fullscreen
+ this.logWarningFullScreenInstallBlocked();
+ break;
+ }
+ case "addon-install-webapi-blocked":
+ case "addon-install-policy-blocked":
+ case "addon-install-origin-blocked": {
+ const msgId =
+ aTopic == "addon-install-policy-blocked"
+ ? "addon-domain-blocked-by-policy"
+ : "xpinstall-prompt";
+ let messageString = await lazy.l10n.formatValue(msgId);
+ if (Services.policies) {
+ let extensionSettings = Services.policies.getExtensionSettings("*");
+ if (
+ extensionSettings &&
+ "blocked_install_message" in extensionSettings
+ ) {
+ messageString += " " + extensionSettings.blocked_install_message;
+ }
+ }
+
+ options.removeOnDismissal = true;
+ options.persistent = false;
+
+ let secHistogram = Services.telemetry.getHistogramById("SECURITY_UI");
+ secHistogram.add(
+ Ci.nsISecurityUITelemetry.WARNING_ADDON_ASKING_PREVENTED
+ );
+ let popup = PopupNotifications.show(
+ browser,
+ aTopic,
+ messageString,
+ gUnifiedExtensions.getPopupAnchorID(browser, window),
+ null,
+ null,
+ options
+ );
+ removeNotificationOnEnd(popup, installInfo.installs);
+ break;
+ }
+ case "addon-install-blocked": {
+ await window.ensureCustomElements("moz-support-link");
+ // Dismiss the progress notification. Note that this is bad if
+ // there are multiple simultaneous installs happening, see
+ // bug 1329884 for a longer explanation.
+ let progressNotification = PopupNotifications.getNotification(
+ "addon-progress",
+ browser
+ );
+ if (progressNotification) {
+ progressNotification.remove();
+ }
+
+ // The informational content differs somewhat for site permission
+ // add-ons. AOM no longer supports installing multiple addons,
+ // so the array handling here is vestigial.
+ let isSitePermissionAddon = installInfo.installs.every(
+ ({ addon }) => addon?.type === lazy.SITEPERMS_ADDON_TYPE
+ );
+ let hasHost = false;
+ let headerId, msgId;
+ if (isSitePermissionAddon) {
+ // At present, WebMIDI is the only consumer of the site permission
+ // add-on infrastructure, and so we can hard-code a midi string here.
+ // If and when we use it for other things, we'll need to plumb that
+ // information through. See bug 1826747.
+ headerId = "site-permission-install-first-prompt-midi-header";
+ msgId = "site-permission-install-first-prompt-midi-message";
+ } else if (options.displayURI) {
+ // PopupNotifications.show replaces <> with options.name.
+ headerId = { id: "xpinstall-prompt-header", args: { host: "<>" } };
+ // BrowserUIUtils.getLocalizedFragment replaces %1$S with options.name.
+ msgId = { id: "xpinstall-prompt-message", args: { host: "%1$S" } };
+ options.name = options.displayURI.displayHost;
+ hasHost = true;
+ } else {
+ headerId = "xpinstall-prompt-header-unknown";
+ msgId = "xpinstall-prompt-message-unknown";
+ }
+ const [headerString, msgString] = await lazy.l10n.formatValues([
+ headerId,
+ msgId,
+ ]);
+
+ // displayURI becomes it's own label, so we unset it for this panel. It will become part of the
+ // messageString above.
+ let displayURI = options.displayURI;
+ options.displayURI = undefined;
+
+ options.eventCallback = topic => {
+ if (topic !== "showing") {
+ return;
+ }
+ let doc = browser.ownerDocument;
+ let message = doc.getElementById("addon-install-blocked-message");
+ // We must remove any prior use of this panel message in this window.
+ while (message.firstChild) {
+ message.firstChild.remove();
+ }
+
+ if (!hasHost) {
+ message.textContent = msgString;
+ } else {
+ let b = doc.createElementNS("http://www.w3.org/1999/xhtml", "b");
+ b.textContent = options.name;
+ let fragment = BrowserUIUtils.getLocalizedFragment(
+ doc,
+ msgString,
+ b
+ );
+ message.appendChild(fragment);
+ }
+
+ let article = isSitePermissionAddon
+ ? "site-permission-addons"
+ : "unlisted-extensions-risks";
+ let learnMore = doc.getElementById("addon-install-blocked-info");
+ learnMore.setAttribute("support-page", article);
+ };
+
+ let secHistogram = Services.telemetry.getHistogramById("SECURITY_UI");
+ secHistogram.add(
+ Ci.nsISecurityUITelemetry.WARNING_ADDON_ASKING_PREVENTED
+ );
+
+ const [
+ installMsg,
+ dontAllowMsg,
+ neverAllowMsg,
+ neverAllowAndReportMsg,
+ ] = await lazy.l10n.formatMessages([
+ "xpinstall-prompt-install",
+ "xpinstall-prompt-dont-allow",
+ "xpinstall-prompt-never-allow",
+ "xpinstall-prompt-never-allow-and-report",
+ ]);
+
+ const action = buildNotificationAction(installMsg, () => {
+ secHistogram.add(
+ Ci.nsISecurityUITelemetry
+ .WARNING_ADDON_ASKING_PREVENTED_CLICK_THROUGH
+ );
+ installInfo.install();
+ });
+
+ const neverAllowCallback = () => {
+ SitePermissions.setForPrincipal(
+ browser.contentPrincipal,
+ "install",
+ SitePermissions.BLOCK
+ );
+ for (let install of installInfo.installs) {
+ if (install.state != AddonManager.STATE_CANCELLED) {
+ install.cancel();
+ }
+ }
+ if (installInfo.cancel) {
+ installInfo.cancel();
+ }
+ };
+
+ const declineActions = [
+ buildNotificationAction(dontAllowMsg, () => {
+ for (let install of installInfo.installs) {
+ if (install.state != AddonManager.STATE_CANCELLED) {
+ install.cancel();
+ }
+ }
+ if (installInfo.cancel) {
+ installInfo.cancel();
+ }
+ }),
+ buildNotificationAction(neverAllowMsg, neverAllowCallback),
+ ];
+
+ if (isSitePermissionAddon) {
+ // Restrict this to site permission add-ons for now pending a decision
+ // from product about how to approach this for extensions.
+ declineActions.push(
+ buildNotificationAction(neverAllowAndReportMsg, () => {
+ AMTelemetry.recordEvent({
+ method: "reportSuspiciousSite",
+ object: "suspiciousSite",
+ value: displayURI?.displayHost ?? "(unknown)",
+ extra: {},
+ });
+ neverAllowCallback();
+ })
+ );
+ }
+
+ let popup = PopupNotifications.show(
+ browser,
+ aTopic,
+ headerString,
+ gUnifiedExtensions.getPopupAnchorID(browser, window),
+ action,
+ declineActions,
+ options
+ );
+ removeNotificationOnEnd(popup, installInfo.installs);
+ break;
+ }
+ case "addon-install-started": {
+ // If all installs have already been downloaded then there is no need to
+ // show the download progress
+ if (
+ installInfo.installs.every(
+ aInstall => aInstall.state == AddonManager.STATE_DOWNLOADED
+ )
+ ) {
+ return;
+ }
+
+ const messageString = lazy.l10n.formatValueSync(
+ "addon-downloading-and-verifying",
+ { addonCount: installInfo.installs.length }
+ );
+ options.installs = installInfo.installs;
+ options.contentWindow = browser.contentWindow;
+ options.sourceURI = browser.currentURI;
+ options.eventCallback = function (aEvent) {
+ switch (aEvent) {
+ case "removed":
+ options.contentWindow = null;
+ options.sourceURI = null;
+ break;
+ }
+ };
+
+ const [acceptMsg, cancelMsg] = lazy.l10n.formatMessagesSync([
+ "addon-install-accept-button",
+ "addon-install-cancel-button",
+ ]);
+
+ const action = buildNotificationAction(acceptMsg, () => {});
+ action.disabled = true;
+
+ const secondaryAction = buildNotificationAction(cancelMsg, () => {
+ for (let install of installInfo.installs) {
+ if (install.state != AddonManager.STATE_CANCELLED) {
+ install.cancel();
+ }
+ }
+ });
+
+ let notification = PopupNotifications.show(
+ browser,
+ "addon-progress",
+ messageString,
+ gUnifiedExtensions.getPopupAnchorID(browser, window),
+ action,
+ [secondaryAction],
+ options
+ );
+ notification._startTime = Date.now();
+
+ break;
+ }
+ case "addon-install-failed": {
+ options.removeOnDismissal = true;
+ options.persistent = false;
+
+ // TODO This isn't terribly ideal for the multiple failure case
+ for (let install of installInfo.installs) {
+ let host;
+ try {
+ host = options.displayURI.host;
+ } catch (e) {
+ // displayURI might be missing or 'host' might throw for non-nsStandardURL nsIURIs.
+ }
+
+ if (!host) {
+ host =
+ install.sourceURI instanceof Ci.nsIStandardURL &&
+ install.sourceURI.host;
+ }
+
+ let messageString;
+ if (
+ install.addon &&
+ !Services.policies.mayInstallAddon(install.addon)
+ ) {
+ messageString = lazy.l10n.formatValueSync(
+ "addon-install-blocked-by-policy",
+ { addonName: install.name, addonId: install.addon.id }
+ );
+ let extensionSettings = Services.policies.getExtensionSettings(
+ install.addon.id
+ );
+ if (
+ extensionSettings &&
+ "blocked_install_message" in extensionSettings
+ ) {
+ messageString += " " + extensionSettings.blocked_install_message;
+ }
+ } else {
+ // TODO bug 1834484: simplify computation of isLocal.
+ const isLocal = !host;
+ let errorId = ERROR_L10N_IDS.get(install.error)?.[isLocal ? 1 : 0];
+ const args = { addonName: install.name };
+ if (!errorId) {
+ if (
+ install.addon.blocklistState ==
+ Ci.nsIBlocklistService.STATE_BLOCKED
+ ) {
+ errorId = "addon-install-error-blocklisted";
+ } else {
+ errorId = "addon-install-error-incompatible";
+ args.appVersion = Services.appinfo.version;
+ }
+ }
+ messageString = lazy.l10n.formatValueSync(errorId, args);
+ }
+
+ // Add Learn More link when refusing to install an unsigned add-on
+ if (install.error == AddonManager.ERROR_SIGNEDSTATE_REQUIRED) {
+ options.learnMoreURL =
+ Services.urlFormatter.formatURLPref("app.support.baseURL") +
+ "unsigned-addons";
+ }
+
+ PopupNotifications.show(
+ browser,
+ aTopic,
+ messageString,
+ gUnifiedExtensions.getPopupAnchorID(browser, window),
+ null,
+ null,
+ options
+ );
+
+ // Can't have multiple notifications with the same ID, so stop here.
+ break;
+ }
+ this._removeProgressNotification(browser);
+ break;
+ }
+ case "addon-install-confirmation": {
+ let showNotification = () => {
+ let height = undefined;
+
+ if (PopupNotifications.isPanelOpen) {
+ let rect = document
+ .getElementById("addon-progress-notification")
+ .getBoundingClientRect();
+ height = rect.height;
+ }
+
+ this._removeProgressNotification(browser);
+ this.showInstallConfirmation(browser, installInfo, height);
+ };
+
+ let progressNotification = PopupNotifications.getNotification(
+ "addon-progress",
+ browser
+ );
+ if (progressNotification) {
+ let downloadDuration = Date.now() - progressNotification._startTime;
+ let securityDelay =
+ Services.prefs.getIntPref("security.dialog_enable_delay") -
+ downloadDuration;
+ if (securityDelay > 0) {
+ setTimeout(() => {
+ // The download may have been cancelled during the security delay
+ if (
+ PopupNotifications.getNotification("addon-progress", browser)
+ ) {
+ showNotification();
+ }
+ }, securityDelay);
+ break;
+ }
+ }
+ showNotification();
+ break;
+ }
+ }
+ },
+ _removeProgressNotification(aBrowser) {
+ let notification = PopupNotifications.getNotification(
+ "addon-progress",
+ aBrowser
+ );
+ if (notification) {
+ notification.remove();
+ }
+ },
+};
+
+var gExtensionsNotifications = {
+ initialized: false,
+ init() {
+ this.updateAlerts();
+ this.boundUpdate = this.updateAlerts.bind(this);
+ ExtensionsUI.on("change", this.boundUpdate);
+ this.initialized = true;
+ },
+
+ uninit() {
+ // uninit() can race ahead of init() in some cases, if that happens,
+ // we have no handler to remove.
+ if (!this.initialized) {
+ return;
+ }
+ ExtensionsUI.off("change", this.boundUpdate);
+ },
+
+ _createAddonButton(l10nId, addon, callback) {
+ let text = lazy.l10n.formatValueSync(l10nId, { addonName: addon.name });
+ let button = document.createXULElement("toolbarbutton");
+ button.setAttribute("wrap", "true");
+ button.setAttribute("label", text);
+ button.setAttribute("tooltiptext", text);
+ const DEFAULT_EXTENSION_ICON =
+ "chrome://mozapps/skin/extensions/extensionGeneric.svg";
+ button.setAttribute("image", addon.iconURL || DEFAULT_EXTENSION_ICON);
+ button.className = "addon-banner-item subviewbutton";
+
+ button.addEventListener("command", callback);
+ PanelUI.addonNotificationContainer.appendChild(button);
+ },
+
+ updateAlerts() {
+ let sideloaded = ExtensionsUI.sideloaded;
+ let updates = ExtensionsUI.updates;
+
+ let container = PanelUI.addonNotificationContainer;
+
+ while (container.firstChild) {
+ container.firstChild.remove();
+ }
+
+ let items = 0;
+ for (let update of updates) {
+ if (++items > 4) {
+ break;
+ }
+ this._createAddonButton(
+ "webext-perms-update-menu-item",
+ update.addon,
+ evt => {
+ ExtensionsUI.showUpdate(gBrowser, update);
+ }
+ );
+ }
+
+ for (let addon of sideloaded) {
+ if (++items > 4) {
+ break;
+ }
+ this._createAddonButton("webext-perms-sideload-menu-item", addon, evt => {
+ // We need to hide the main menu manually because the toolbarbutton is
+ // removed immediately while processing this event, and PanelUI is
+ // unable to identify which panel should be closed automatically.
+ PanelUI.hide();
+ ExtensionsUI.showSideloaded(gBrowser, addon);
+ });
+ }
+ },
+};
+
+var BrowserAddonUI = {
+ async promptRemoveExtension(addon) {
+ let { name } = addon;
+ let [title, btnTitle, message] = await lazy.l10n.formatValues([
+ { id: "addon-removal-title", args: { name } },
+ { id: "addon-removal-button" },
+ { id: "addon-removal-message", args: { name } },
+ ]);
+
+ if (Services.prefs.getBoolPref("prompts.windowPromptSubDialog", false)) {
+ message = null;
+ }
+
+ let {
+ BUTTON_TITLE_IS_STRING: titleString,
+ BUTTON_TITLE_CANCEL: titleCancel,
+ BUTTON_POS_0,
+ BUTTON_POS_1,
+ confirmEx,
+ } = Services.prompt;
+ let btnFlags = BUTTON_POS_0 * titleString + BUTTON_POS_1 * titleCancel;
+
+ // Enable abuse report checkbox in the remove extension dialog,
+ // if enabled by the about:config prefs and the addon type
+ // is currently supported.
+ let checkboxMessage = null;
+ if (
+ gAddonAbuseReportEnabled &&
+ ["extension", "theme"].includes(addon.type)
+ ) {
+ checkboxMessage = await lazy.l10n.formatValue(
+ "addon-removal-abuse-report-checkbox"
+ );
+ }
+
+ let checkboxState = { value: false };
+ let result = confirmEx(
+ window,
+ title,
+ message,
+ btnFlags,
+ btnTitle,
+ /* button1 */ null,
+ /* button2 */ null,
+ checkboxMessage,
+ checkboxState
+ );
+
+ return { remove: result === 0, report: checkboxState.value };
+ },
+
+ async reportAddon(addonId, reportEntryPoint) {
+ let addon = addonId && (await AddonManager.getAddonByID(addonId));
+ if (!addon) {
+ return;
+ }
+
+ const win = await BrowserOpenAddonsMgr("addons://list/extension");
+
+ win.openAbuseReport({ addonId, reportEntryPoint });
+ },
+
+ async removeAddon(addonId, eventObject) {
+ let addon = addonId && (await AddonManager.getAddonByID(addonId));
+ if (!addon || !(addon.permissions & AddonManager.PERM_CAN_UNINSTALL)) {
+ return;
+ }
+
+ let { remove, report } = await this.promptRemoveExtension(addon);
+
+ if (remove) {
+ // Leave the extension in pending uninstall if we are also reporting the
+ // add-on.
+ await addon.uninstall(report);
+
+ if (report) {
+ await this.reportAddon(addon.id, "uninstall");
+ }
+ }
+ },
+
+ async manageAddon(addonId, eventObject) {
+ let addon = addonId && (await AddonManager.getAddonByID(addonId));
+ if (!addon) {
+ return;
+ }
+
+ BrowserOpenAddonsMgr("addons://detail/" + encodeURIComponent(addon.id));
+ },
+};
+
+// We must declare `gUnifiedExtensions` using `var` below to avoid a
+// "redeclaration" syntax error.
+var gUnifiedExtensions = {
+ _initialized: false,
+
+ // We use a `<deck>` in the extension items to show/hide messages below each
+ // extension name. We have a default message for origin controls, and
+ // optionally a second message shown on hover, which describes the action
+ // (when clicking on the action button). We have another message shown when
+ // the menu button is hovered/focused. The constants below define the indexes
+ // of each message in the `<deck>`.
+ MESSAGE_DECK_INDEX_DEFAULT: 0,
+ MESSAGE_DECK_INDEX_HOVER: 1,
+ MESSAGE_DECK_INDEX_MENU_HOVER: 2,
+
+ init() {
+ if (this._initialized) {
+ return;
+ }
+
+ this._button = document.getElementById("unified-extensions-button");
+ // TODO: Bug 1778684 - Auto-hide button when there is no active extension.
+ this._button.hidden = false;
+
+ document
+ .getElementById("nav-bar")
+ .setAttribute("unifiedextensionsbuttonshown", true);
+
+ gBrowser.addTabsProgressListener(this);
+ window.addEventListener("TabSelect", () => this.updateAttention());
+ window.addEventListener("toolbarvisibilitychange", this);
+
+ this.permListener = () => this.updateAttention();
+ lazy.ExtensionPermissions.addListener(this.permListener);
+
+ gNavToolbox.addEventListener("customizationstarting", this);
+ CustomizableUI.addListener(this);
+
+ this._initialized = true;
+ },
+
+ uninit() {
+ if (!this._initialized) {
+ return;
+ }
+
+ window.removeEventListener("toolbarvisibilitychange", this);
+
+ lazy.ExtensionPermissions.removeListener(this.permListener);
+ this.permListener = null;
+
+ gNavToolbox.removeEventListener("customizationstarting", this);
+ CustomizableUI.removeListener(this);
+ },
+
+ onLocationChange(browser, webProgress, _request, _uri, flags) {
+ // Only update on top-level cross-document navigations in the selected tab.
+ if (
+ webProgress.isTopLevel &&
+ browser === gBrowser.selectedBrowser &&
+ !(flags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT)
+ ) {
+ this.updateAttention();
+ }
+ },
+
+ // Update the attention indicator for the whole unified extensions button.
+ updateAttention() {
+ let attention = false;
+ for (let policy of this.getActivePolicies()) {
+ let widget = this.browserActionFor(policy)?.widget;
+
+ // Only show for extensions which are not already visible in the toolbar.
+ if (!widget || widget.areaType !== CustomizableUI.TYPE_TOOLBAR) {
+ if (lazy.OriginControls.getAttention(policy, window)) {
+ attention = true;
+ break;
+ }
+ }
+ }
+ this.button.toggleAttribute("attention", attention);
+ this.button.ownerDocument.l10n.setAttributes(
+ this.button,
+ attention
+ ? "unified-extensions-button-permissions-needed"
+ : "unified-extensions-button"
+ );
+ },
+
+ getPopupAnchorID(aBrowser, aWindow) {
+ const anchorID = "unified-extensions-button";
+ const attr = anchorID + "popupnotificationanchor";
+
+ if (!aBrowser[attr]) {
+ // A hacky way of setting the popup anchor outside the usual url bar
+ // icon box, similar to how it was done for CFR.
+ // See: https://searchfox.org/mozilla-central/rev/c5c002f81f08a73e04868e0c2bf0eb113f200b03/toolkit/modules/PopupNotifications.sys.mjs#40
+ aBrowser[attr] = aWindow.document.getElementById(
+ anchorID
+ // Anchor on the toolbar icon to position the popup right below the
+ // button.
+ ).firstElementChild;
+ }
+
+ return anchorID;
+ },
+
+ get button() {
+ return this._button;
+ },
+
+ /**
+ * Gets a list of active WebExtensionPolicy instances of type "extension",
+ * sorted alphabetically based on add-on's names. Optionally, filter out
+ * extensions with browser action.
+ *
+ * @param {bool} all When set to true (the default), return the list of all
+ * active policies, including the ones that have a
+ * browser action. Otherwise, extensions with browser
+ * action are filtered out.
+ * @returns {Array<WebExtensionPolicy>} An array of active policies.
+ */
+ getActivePolicies(all = true) {
+ let policies = WebExtensionPolicy.getActiveExtensions();
+ policies = policies.filter(policy => {
+ let { extension } = policy;
+ if (!policy.active || extension?.type !== "extension") {
+ return false;
+ }
+
+ // Ignore hidden and extensions that cannot access the current window
+ // (because of PB mode when we are in a private window), since users
+ // cannot do anything with those extensions anyway.
+ if (extension.isHidden || !policy.canAccessWindow(window)) {
+ return false;
+ }
+
+ return all || !extension.hasBrowserActionUI;
+ });
+
+ policies.sort((a, b) => a.name.localeCompare(b.name));
+ return policies;
+ },
+
+ /**
+ * Returns true when there are active extensions listed/shown in the unified
+ * extensions panel, and false otherwise (e.g. when extensions are pinned in
+ * the toolbar OR there are 0 active extensions).
+ *
+ * @returns {boolean} Whether there are extensions listed in the panel.
+ */
+ hasExtensionsInPanel() {
+ const policies = this.getActivePolicies();
+
+ return !!policies
+ .map(policy => this.browserActionFor(policy)?.widget)
+ .filter(widget => {
+ return (
+ !widget ||
+ widget?.areaType !== CustomizableUI.TYPE_TOOLBAR ||
+ widget?.forWindow(window).overflowed
+ );
+ }).length;
+ },
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "ViewShowing":
+ this.onPanelViewShowing(event.target);
+ break;
+
+ case "ViewHiding":
+ this.onPanelViewHiding(event.target);
+ break;
+
+ case "customizationstarting":
+ this.panel.hidePopup();
+ break;
+
+ case "toolbarvisibilitychange":
+ this.onToolbarVisibilityChange(event.target.id, event.detail.visible);
+ break;
+ }
+ },
+
+ onPanelViewShowing(panelview) {
+ const list = panelview.querySelector(".unified-extensions-list");
+ // Only add extensions that do not have a browser action in this list since
+ // the extensions with browser action have CUI widgets and will appear in
+ // the panel (or toolbar) via the CUI mechanism.
+ for (const policy of this.getActivePolicies(/* all */ false)) {
+ const item = document.createElement("unified-extensions-item");
+ item.setExtension(policy.extension);
+ list.appendChild(item);
+ }
+
+ const isQuarantinedDomain = this.getActivePolicies().some(
+ policy =>
+ lazy.OriginControls.getState(policy, window.gBrowser.selectedTab)
+ .quarantined
+ );
+ const container = panelview.querySelector(
+ "#unified-extensions-messages-container"
+ );
+
+ if (isQuarantinedDomain) {
+ if (!this._messageBarQuarantinedDomain) {
+ this._messageBarQuarantinedDomain = this._makeMessageBar({
+ titleFluentId: "unified-extensions-mb-quarantined-domain-title",
+ messageFluentId: "unified-extensions-mb-quarantined-domain-message",
+ supportPage: "quarantined-domains",
+ dismissable: false,
+ });
+ this._messageBarQuarantinedDomain
+ .querySelector("a")
+ .addEventListener("click", () => {
+ this.togglePanel();
+ });
+ }
+
+ container.appendChild(this._messageBarQuarantinedDomain);
+ } else if (
+ !isQuarantinedDomain &&
+ this._messageBarQuarantinedDomain &&
+ container.contains(this._messageBarQuarantinedDomain)
+ ) {
+ container.removeChild(this._messageBarQuarantinedDomain);
+ }
+ },
+
+ onPanelViewHiding(panelview) {
+ if (window.closed) {
+ return;
+ }
+ const list = panelview.querySelector(".unified-extensions-list");
+ while (list.lastChild) {
+ list.lastChild.remove();
+ }
+ // If temporary access was granted, (maybe) clear attention indicator.
+ requestAnimationFrame(() => this.updateAttention());
+ },
+
+ onToolbarVisibilityChange(toolbarId, isVisible) {
+ // A list of extension widget IDs (possibly empty).
+ let widgetIDs;
+
+ try {
+ widgetIDs = CustomizableUI.getWidgetIdsInArea(toolbarId).filter(
+ CustomizableUI.isWebExtensionWidget
+ );
+ } catch {
+ // Do nothing if the area does not exist for some reason.
+ return;
+ }
+
+ // The list of overflowed extensions in the extensions panel.
+ const overflowedExtensionsList = this.panel.querySelector(
+ "#overflowed-extensions-list"
+ );
+
+ // We are going to move all the extension widgets via DOM manipulation
+ // *only* so that it looks like these widgets have moved (and users will
+ // see that) but CUI still thinks the widgets haven't been moved.
+ //
+ // We can move the extension widgets either from the toolbar to the
+ // extensions panel OR the other way around (when the toolbar becomes
+ // visible again).
+ for (const widgetID of widgetIDs) {
+ const widget = CustomizableUI.getWidget(widgetID);
+ if (!widget) {
+ continue;
+ }
+
+ if (isVisible) {
+ this._maybeMoveWidgetNodeBack(widget.id);
+ } else {
+ const { node } = widget.forWindow(window);
+ // Artificially overflow the extension widget in the extensions panel
+ // when the toolbar is hidden.
+ node.setAttribute("overflowedItem", true);
+ node.setAttribute("artificallyOverflowed", true);
+ // This attribute forces browser action popups to be anchored to the
+ // extensions button.
+ node.setAttribute("cui-anchorid", "unified-extensions-button");
+ overflowedExtensionsList.appendChild(node);
+
+ this._updateWidgetClassName(widgetID, /* inPanel */ true);
+ }
+ }
+ },
+
+ _maybeMoveWidgetNodeBack(widgetID) {
+ const widget = CustomizableUI.getWidget(widgetID);
+ if (!widget) {
+ return;
+ }
+
+ // We only want to move back widget nodes that have been manually moved
+ // previously via `onToolbarVisibilityChange()`.
+ const { node } = widget.forWindow(window);
+ if (!node.hasAttribute("artificallyOverflowed")) {
+ return;
+ }
+
+ const { area, position } = CustomizableUI.getPlacementOfWidget(widgetID);
+
+ // This is where we are going to re-insert the extension widgets (DOM
+ // nodes) but we need to account for some hidden DOM nodes already present
+ // in this container when determining where to put the nodes back.
+ const container = document.getElementById(area);
+
+ let moved = false;
+ let currentPosition = 0;
+
+ for (const child of container.childNodes) {
+ const isSkipToolbarset = child.getAttribute("skipintoolbarset") == "true";
+ if (isSkipToolbarset && child !== container.lastChild) {
+ continue;
+ }
+
+ if (currentPosition === position) {
+ child.before(node);
+ moved = true;
+ break;
+ }
+
+ if (child === container.lastChild) {
+ child.after(node);
+ moved = true;
+ break;
+ }
+
+ currentPosition++;
+ }
+
+ if (moved) {
+ // Remove the attribute set when we artificially overflow the widget.
+ node.removeAttribute("overflowedItem");
+ node.removeAttribute("artificallyOverflowed");
+ node.removeAttribute("cui-anchorid");
+
+ this._updateWidgetClassName(widgetID, /* inPanel */ false);
+ }
+ },
+
+ _panel: null,
+ get panel() {
+ // Lazy load the unified-extensions-panel panel the first time we need to
+ // display it.
+ if (!this._panel) {
+ let template = document.getElementById(
+ "unified-extensions-panel-template"
+ );
+ template.replaceWith(template.content);
+ this._panel = document.getElementById("unified-extensions-panel");
+ let customizationArea = this._panel.querySelector(
+ "#unified-extensions-area"
+ );
+ CustomizableUI.registerPanelNode(
+ customizationArea,
+ CustomizableUI.AREA_ADDONS
+ );
+ CustomizableUI.addPanelCloseListeners(this._panel);
+
+ // Lazy-load the l10n strings. Those strings are used for the CUI and
+ // non-CUI extensions in the unified extensions panel.
+ document
+ .getElementById("unified-extensions-context-menu")
+ .querySelectorAll("[data-lazy-l10n-id]")
+ .forEach(el => {
+ el.setAttribute("data-l10n-id", el.getAttribute("data-lazy-l10n-id"));
+ el.removeAttribute("data-lazy-l10n-id");
+ });
+ }
+ return this._panel;
+ },
+
+ async togglePanel(aEvent) {
+ if (!CustomizationHandler.isCustomizing()) {
+ if (aEvent) {
+ if (
+ // On MacOS, ctrl-click will send a context menu event from the
+ // widget, so we don't want to bring up the panel when ctrl key is
+ // pressed.
+ (aEvent.type == "mousedown" &&
+ (aEvent.button !== 0 ||
+ (AppConstants.platform === "macosx" && aEvent.ctrlKey))) ||
+ (aEvent.type === "keypress" &&
+ aEvent.charCode !== KeyEvent.DOM_VK_SPACE &&
+ aEvent.keyCode !== KeyEvent.DOM_VK_RETURN)
+ ) {
+ return;
+ }
+
+ // The button should directly open `about:addons` when the user does not
+ // have any active extensions listed in the unified extensions panel.
+ if (!this.hasExtensionsInPanel()) {
+ let viewID;
+ if (
+ Services.prefs.getBoolPref("extensions.getAddons.showPane", true)
+ ) {
+ viewID = "addons://discover/";
+ } else {
+ viewID = "addons://list/extension";
+ }
+ await BrowserOpenAddonsMgr(viewID);
+ return;
+ }
+ }
+
+ let panel = this.panel;
+
+ if (!this._listView) {
+ this._listView = PanelMultiView.getViewNode(
+ document,
+ "unified-extensions-view"
+ );
+ this._listView.addEventListener("ViewShowing", this);
+ this._listView.addEventListener("ViewHiding", this);
+ }
+
+ if (this._button.open) {
+ PanelMultiView.hidePopup(panel);
+ this._button.open = false;
+ } else {
+ // Overflow extensions placed in collapsed toolbars, if any.
+ for (const toolbarId of CustomizableUI.getCollapsedToolbarIds(window)) {
+ // We pass `false` because all these toolbars are collapsed.
+ this.onToolbarVisibilityChange(toolbarId, /* isVisible */ false);
+ }
+
+ panel.hidden = false;
+ PanelMultiView.openPopup(panel, this._button, {
+ position: "bottomright topright",
+ triggerEvent: aEvent,
+ });
+ }
+ }
+
+ // We always dispatch an event (useful for testing purposes).
+ window.dispatchEvent(new CustomEvent("UnifiedExtensionsTogglePanel"));
+ },
+
+ updateContextMenu(menu, event) {
+ // When the context menu is open, `onpopupshowing` is called when menu
+ // items open sub-menus. We don't want to update the context menu in this
+ // case.
+ if (event.target.id !== "unified-extensions-context-menu") {
+ return;
+ }
+
+ const id = this._getExtensionId(menu);
+ const widgetId = this._getWidgetId(menu);
+ const forBrowserAction = !!widgetId;
+
+ const pinButton = menu.querySelector(
+ ".unified-extensions-context-menu-pin-to-toolbar"
+ );
+ const removeButton = menu.querySelector(
+ ".unified-extensions-context-menu-remove-extension"
+ );
+ const reportButton = menu.querySelector(
+ ".unified-extensions-context-menu-report-extension"
+ );
+ const menuSeparator = menu.querySelector(
+ ".unified-extensions-context-menu-management-separator"
+ );
+ const moveUp = menu.querySelector(
+ ".unified-extensions-context-menu-move-widget-up"
+ );
+ const moveDown = menu.querySelector(
+ ".unified-extensions-context-menu-move-widget-down"
+ );
+
+ for (const element of [menuSeparator, pinButton, moveUp, moveDown]) {
+ element.hidden = !forBrowserAction;
+ }
+
+ reportButton.hidden = !gAddonAbuseReportEnabled;
+ // We use this syntax instead of async/await to not block this method that
+ // updates the context menu. This avoids the context menu to be out of sync
+ // on macOS.
+ AddonManager.getAddonByID(id).then(addon => {
+ removeButton.disabled = !(
+ addon.permissions & AddonManager.PERM_CAN_UNINSTALL
+ );
+ });
+
+ if (forBrowserAction) {
+ let area = CustomizableUI.getPlacementOfWidget(widgetId).area;
+ let inToolbar = area != CustomizableUI.AREA_ADDONS;
+ pinButton.setAttribute("checked", inToolbar);
+
+ const placement = CustomizableUI.getPlacementOfWidget(widgetId);
+ const notInPanel = placement?.area !== CustomizableUI.AREA_ADDONS;
+ // We rely on the DOM nodes because CUI widgets will always exist but
+ // not necessarily with DOM nodes created depending on the window. For
+ // example, in PB mode, not all extensions will be listed in the panel
+ // but the CUI widgets may be all created.
+ if (
+ notInPanel ||
+ document.querySelector("#unified-extensions-area > :first-child")
+ ?.id === widgetId
+ ) {
+ moveUp.hidden = true;
+ }
+
+ if (
+ notInPanel ||
+ document.querySelector("#unified-extensions-area > :last-child")?.id ===
+ widgetId
+ ) {
+ moveDown.hidden = true;
+ }
+ }
+
+ ExtensionsUI.originControlsMenu(menu, id);
+
+ const browserAction = this.browserActionFor(WebExtensionPolicy.getByID(id));
+ if (browserAction) {
+ browserAction.updateContextMenu(menu);
+ }
+ },
+
+ // This is registered on the top-level unified extensions context menu.
+ onContextMenuCommand(menu, event) {
+ // Do not close the extensions panel automatically when we move extension
+ // widgets.
+ const { classList } = event.target;
+ if (
+ classList.contains("unified-extensions-context-menu-move-widget-up") ||
+ classList.contains("unified-extensions-context-menu-move-widget-down")
+ ) {
+ return;
+ }
+
+ this.togglePanel();
+ },
+
+ browserActionFor(policy) {
+ // Ideally, we wouldn't do that because `browserActionFor()` will only be
+ // defined in `global` when at least one extension has required loading the
+ // `ext-browserAction` code.
+ let method = lazy.ExtensionParent.apiManager.global.browserActionFor;
+ return method?.(policy?.extension);
+ },
+
+ async manageExtension(menu) {
+ const id = this._getExtensionId(menu);
+
+ await BrowserAddonUI.manageAddon(id, "unifiedExtensions");
+ },
+
+ async removeExtension(menu) {
+ const id = this._getExtensionId(menu);
+
+ await BrowserAddonUI.removeAddon(id, "unifiedExtensions");
+ },
+
+ async reportExtension(menu) {
+ const id = this._getExtensionId(menu);
+
+ await BrowserAddonUI.reportAddon(id, "unified_context_menu");
+ },
+
+ _getExtensionId(menu) {
+ const { triggerNode } = menu;
+ return triggerNode.dataset.extensionid;
+ },
+
+ _getWidgetId(menu) {
+ const { triggerNode } = menu;
+ return triggerNode.closest(".unified-extensions-item")?.id;
+ },
+
+ async onPinToToolbarChange(menu, event) {
+ let shouldPinToToolbar = event.target.getAttribute("checked") == "true";
+ // Revert the checkbox back to its original state. This is because the
+ // addon context menu handlers are asynchronous, and there seems to be
+ // a race where the checkbox state won't get set in time to show the
+ // right state. So we err on the side of caution, and presume that future
+ // attempts to open this context menu on an extension button will show
+ // the same checked state that we started in.
+ event.target.setAttribute("checked", !shouldPinToToolbar);
+
+ let widgetId = this._getWidgetId(menu);
+ if (!widgetId) {
+ return;
+ }
+
+ // We artificially overflow extension widgets that are placed in collapsed
+ // toolbars and CUI does not know about it. For end users, these widgets
+ // appear in the list of overflowed extensions in the panel. When we unpin
+ // and then pin one of these extensions to the toolbar, we need to first
+ // move the DOM node back to where it was (i.e. in the collapsed toolbar)
+ // so that CUI can retrieve the DOM node and do the pinning correctly.
+ if (shouldPinToToolbar) {
+ this._maybeMoveWidgetNodeBack(widgetId);
+ }
+
+ this.pinToToolbar(widgetId, shouldPinToToolbar);
+ },
+
+ pinToToolbar(widgetId, shouldPinToToolbar) {
+ let newArea = shouldPinToToolbar
+ ? CustomizableUI.AREA_NAVBAR
+ : CustomizableUI.AREA_ADDONS;
+ let newPosition = shouldPinToToolbar ? undefined : 0;
+
+ CustomizableUI.addWidgetToArea(widgetId, newArea, newPosition);
+
+ this.updateAttention();
+ },
+
+ async moveWidget(menu, direction) {
+ // We'll move the widgets based on the DOM node positions. This is because
+ // in PB mode (for example), we might not have the same extensions listed
+ // in the panel but CUI does not know that. As far as CUI is concerned, all
+ // extensions will likely have widgets.
+ const node = menu.triggerNode.closest(".unified-extensions-item");
+
+ // Find the element that is before or after the current widget/node to
+ // move. `element` might be `null`, e.g. if the current node is the first
+ // one listed in the panel (though it shouldn't be possible to call this
+ // method in this case).
+ let element;
+ if (direction === "up" && node.previousElementSibling) {
+ element = node.previousElementSibling;
+ } else if (direction === "down" && node.nextElementSibling) {
+ element = node.nextElementSibling;
+ }
+
+ // Now we need to retrieve the position of the CUI placement.
+ const placement = CustomizableUI.getPlacementOfWidget(element?.id);
+ if (placement) {
+ let newPosition = placement.position;
+ // That, I am not sure why this is required but it looks like we need to
+ // always add one to the current position if we want to move a widget
+ // down in the list.
+ if (direction === "down") {
+ newPosition += 1;
+ }
+
+ CustomizableUI.moveWidgetWithinArea(node.id, newPosition);
+ }
+ },
+
+ onWidgetAdded(aWidgetId, aArea, aPosition) {
+ // When we pin a widget to the toolbar from a narrow window, the widget
+ // will be overflowed directly. In this case, we do not want to change the
+ // class name since it is going to be changed by `onWidgetOverflow()`
+ // below.
+ if (CustomizableUI.getWidget(aWidgetId)?.forWindow(window)?.overflowed) {
+ return;
+ }
+
+ const inPanel =
+ CustomizableUI.getAreaType(aArea) !== CustomizableUI.TYPE_TOOLBAR;
+
+ this._updateWidgetClassName(aWidgetId, inPanel);
+ },
+
+ onWidgetOverflow(aNode, aContainer) {
+ // We register a CUI listener for each window so we make sure that we
+ // handle the event for the right window here.
+ if (window !== aNode.ownerGlobal) {
+ return;
+ }
+
+ this._updateWidgetClassName(aNode.getAttribute("widget-id"), true);
+ },
+
+ onWidgetUnderflow(aNode, aContainer) {
+ // We register a CUI listener for each window so we make sure that we
+ // handle the event for the right window here.
+ if (window !== aNode.ownerGlobal) {
+ return;
+ }
+
+ this._updateWidgetClassName(aNode.getAttribute("widget-id"), false);
+ },
+
+ onAreaNodeRegistered(aArea, aContainer) {
+ // We register a CUI listener for each window so we make sure that we
+ // handle the event for the right window here.
+ if (window !== aContainer.ownerGlobal) {
+ return;
+ }
+
+ const inPanel =
+ CustomizableUI.getAreaType(aArea) !== CustomizableUI.TYPE_TOOLBAR;
+
+ for (const widgetId of CustomizableUI.getWidgetIdsInArea(aArea)) {
+ this._updateWidgetClassName(widgetId, inPanel);
+ }
+ },
+
+ // This internal method is used to change some CSS classnames on the action
+ // and menu buttons of an extension (CUI) widget. When the widget is placed
+ // in the panel, the action and menu buttons should have the `.subviewbutton`
+ // class and not the `.toolbarbutton-1` one. When NOT placed in the panel,
+ // it is the other way around.
+ _updateWidgetClassName(aWidgetId, inPanel) {
+ if (!CustomizableUI.isWebExtensionWidget(aWidgetId)) {
+ return;
+ }
+
+ const node = CustomizableUI.getWidget(aWidgetId)?.forWindow(window)?.node;
+ const actionButton = node?.querySelector(
+ ".unified-extensions-item-action-button"
+ );
+ if (actionButton) {
+ actionButton.classList.toggle("subviewbutton", inPanel);
+ actionButton.classList.toggle("subviewbutton-iconic", inPanel);
+ actionButton.classList.toggle("toolbarbutton-1", !inPanel);
+ }
+ const menuButton = node?.querySelector(
+ ".unified-extensions-item-menu-button"
+ );
+ if (menuButton) {
+ menuButton.classList.toggle("subviewbutton", inPanel);
+ menuButton.classList.toggle("subviewbutton-iconic", inPanel);
+ menuButton.classList.toggle("toolbarbutton-1", !inPanel);
+ }
+ },
+
+ _makeMessageBar({
+ messageFluentId,
+ titleFluentId = null,
+ supportPage = null,
+ type = "warning",
+ }) {
+ const messageBar = document.createElement("message-bar");
+ messageBar.setAttribute("type", type);
+ messageBar.classList.add("unified-extensions-message-bar");
+
+ if (titleFluentId) {
+ const titleEl = document.createElement("strong");
+ titleEl.setAttribute("id", titleFluentId);
+ document.l10n.setAttributes(titleEl, titleFluentId);
+ messageBar.append(titleEl);
+ }
+
+ const messageEl = document.createElement("span");
+ messageEl.setAttribute("id", messageFluentId);
+ document.l10n.setAttributes(messageEl, messageFluentId);
+ messageBar.append(messageEl);
+
+ if (supportPage) {
+ window.ensureCustomElements("moz-support-link");
+
+ const supportUrl = document.createElement("a", {
+ is: "moz-support-link",
+ });
+ supportUrl.setAttribute("support-page", supportPage);
+ if (titleFluentId) {
+ supportUrl.setAttribute("aria-labelledby", titleFluentId);
+ supportUrl.setAttribute("aria-describedby", messageFluentId);
+ } else {
+ supportUrl.setAttribute("aria-labelledby", messageFluentId);
+ }
+
+ messageBar.append(supportUrl);
+ }
+
+ return messageBar;
+ },
+};
diff --git a/browser/base/content/browser-allTabsMenu.inc.xhtml b/browser/base/content/browser-allTabsMenu.inc.xhtml
new file mode 100644
index 0000000000..d4bf26637c
--- /dev/null
+++ b/browser/base/content/browser-allTabsMenu.inc.xhtml
@@ -0,0 +1,43 @@
+<!-- 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/. -->
+
+<html:template id="allTabsMenu-container">
+ <panelview id="allTabsMenu-allTabsView" class="PanelUI-subView">
+ <vbox class="panel-subview-body">
+ <toolbarbutton id="allTabsMenu-searchTabs"
+ class="subviewbutton"
+ oncommand="gTabsPanel.searchTabs();"
+ data-l10n-id="all-tabs-menu-search-tabs"/>
+ <toolbarbutton id="allTabsMenu-containerTabsButton"
+ class="subviewbutton subviewbutton-nav"
+ closemenu="none"
+ oncommand="PanelUI.showSubView('allTabsMenu-containerTabsView', this);"
+ data-l10n-id="all-tabs-menu-new-user-context"/>
+ <toolbarseparator id="allTabsMenu-hiddenTabsSeparator"/>
+ <toolbarbutton id="allTabsMenu-hiddenTabsButton"
+ class="subviewbutton subviewbutton-nav"
+ closemenu="none"
+ oncommand="PanelUI.showSubView('allTabsMenu-hiddenTabsView', this);"
+ data-l10n-id="all-tabs-menu-hidden-tabs"/>
+ <toolbarseparator id="allTabsMenu-tabsSeparator"/>
+ <vbox id="allTabsMenu-dropIndicatorHolder">
+ <vbox id="allTabsMenu-dropIndicator" collapsed="true"/>
+ </vbox>
+ <vbox id="allTabsMenu-allTabsView-tabs" class="panel-subview-body"/>
+ </vbox>
+ </panelview>
+
+ <panelview id="allTabsMenu-hiddenTabsView" class="PanelUI-subView">
+ <vbox id="allTabsMenu-hiddenTabsView-tabs" class="panel-subview-body"/>
+ </panelview>
+
+ <panelview id="allTabsMenu-containerTabsView" class="PanelUI-subView">
+ <vbox class="panel-subview-body">
+ <toolbarseparator class="container-tabs-submenu-separator"/>
+ <toolbarbutton class="subviewbutton"
+ data-l10n-id="all-tabs-menu-manage-user-context"
+ command="Browser:OpenAboutContainers"/>
+ </vbox>
+ </panelview>
+</html:template>
diff --git a/browser/base/content/browser-allTabsMenu.js b/browser/base/content/browser-allTabsMenu.js
new file mode 100644
index 0000000000..324c5d95ab
--- /dev/null
+++ b/browser/base/content/browser-allTabsMenu.js
@@ -0,0 +1,190 @@
+/* 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/. */
+
+// This file is loaded into the browser window scope.
+/* eslint-env mozilla/browser-window */
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "TabsPanel",
+ "resource:///modules/TabsList.jsm"
+);
+
+var gTabsPanel = {
+ kElements: {
+ allTabsButton: "alltabs-button",
+ allTabsView: "allTabsMenu-allTabsView",
+ allTabsViewTabs: "allTabsMenu-allTabsView-tabs",
+ dropIndicator: "allTabsMenu-dropIndicator",
+ containerTabsView: "allTabsMenu-containerTabsView",
+ hiddenTabsButton: "allTabsMenu-hiddenTabsButton",
+ hiddenTabsView: "allTabsMenu-hiddenTabsView",
+ },
+ _initialized: false,
+ _initializedElements: false,
+
+ initElements() {
+ if (this._initializedElements) {
+ return;
+ }
+ let template = document.getElementById("allTabsMenu-container");
+ template.replaceWith(template.content);
+
+ for (let [name, id] of Object.entries(this.kElements)) {
+ this[name] = document.getElementById(id);
+ }
+ this._initializedElements = true;
+ },
+
+ init() {
+ if (this._initialized) {
+ return;
+ }
+
+ this.initElements();
+
+ this.hiddenAudioTabsPopup = new TabsPanel({
+ view: this.allTabsView,
+ insertBefore: document.getElementById("allTabsMenu-tabsSeparator"),
+ filterFn: tab => tab.hidden && tab.soundPlaying,
+ });
+ let showPinnedTabs = Services.prefs.getBoolPref(
+ "browser.tabs.tabmanager.enabled"
+ );
+ this.allTabsPanel = new TabsPanel({
+ view: this.allTabsView,
+ containerNode: this.allTabsViewTabs,
+ filterFn: tab =>
+ !tab.hidden && (!tab.pinned || (showPinnedTabs && tab.pinned)),
+ dropIndicator: this.dropIndicator,
+ });
+
+ this.allTabsView.addEventListener("ViewShowing", e => {
+ PanelUI._ensureShortcutsShown(this.allTabsView);
+
+ let containersEnabled =
+ Services.prefs.getBoolPref("privacy.userContext.enabled") &&
+ !PrivateBrowsingUtils.isWindowPrivate(window);
+ document.getElementById("allTabsMenu-containerTabsButton").hidden =
+ !containersEnabled;
+
+ let hasHiddenTabs = gBrowser.visibleTabs.length < gBrowser.tabs.length;
+ document.getElementById("allTabsMenu-hiddenTabsButton").hidden =
+ !hasHiddenTabs;
+ document.getElementById("allTabsMenu-hiddenTabsSeparator").hidden =
+ !hasHiddenTabs;
+ });
+
+ this.allTabsView.addEventListener("ViewShown", e =>
+ this.allTabsView
+ .querySelector(".all-tabs-item[selected]")
+ ?.scrollIntoView({ block: "center" })
+ );
+
+ let containerTabsMenuSeparator =
+ this.containerTabsView.querySelector("toolbarseparator");
+ this.containerTabsView.addEventListener("ViewShowing", e => {
+ let elements = [];
+ let frag = document.createDocumentFragment();
+
+ ContextualIdentityService.getPublicIdentities().forEach(identity => {
+ let menuitem = document.createXULElement("toolbarbutton");
+ menuitem.setAttribute("class", "subviewbutton subviewbutton-iconic");
+ menuitem.setAttribute(
+ "label",
+ ContextualIdentityService.getUserContextLabel(identity.userContextId)
+ );
+ // The styles depend on this.
+ menuitem.setAttribute("usercontextid", identity.userContextId);
+ // The command handler depends on this.
+ menuitem.setAttribute("data-usercontextid", identity.userContextId);
+ menuitem.classList.add("identity-icon-" + identity.icon);
+ menuitem.classList.add("identity-color-" + identity.color);
+
+ menuitem.setAttribute("command", "Browser:NewUserContextTab");
+
+ frag.appendChild(menuitem);
+ elements.push(menuitem);
+ });
+
+ e.target.addEventListener(
+ "ViewHiding",
+ () => {
+ for (let element of elements) {
+ element.remove();
+ }
+ },
+ { once: true }
+ );
+ containerTabsMenuSeparator.parentNode.insertBefore(
+ frag,
+ containerTabsMenuSeparator
+ );
+ });
+
+ this.hiddenTabsPopup = new TabsPanel({
+ view: this.hiddenTabsView,
+ filterFn: tab => tab.hidden,
+ });
+
+ this._initialized = true;
+ },
+
+ get canOpen() {
+ this.initElements();
+ return isElementVisible(this.allTabsButton);
+ },
+
+ showAllTabsPanel(event, entrypoint = "unknown") {
+ // Note that event may be null.
+
+ // Only space and enter should open the popup, ignore other keypresses:
+ if (event?.type == "keypress" && event.key != "Enter" && event.key != " ") {
+ return;
+ }
+ this.init();
+ if (this.canOpen) {
+ Services.telemetry.keyedScalarAdd(
+ "browser.ui.interaction.all_tabs_panel_entrypoint",
+ entrypoint,
+ 1
+ );
+ PanelUI.showSubView(
+ this.kElements.allTabsView,
+ this.allTabsButton,
+ event
+ );
+ }
+ },
+
+ hideAllTabsPanel() {
+ if (this.allTabsView) {
+ PanelMultiView.hidePopup(this.allTabsView.closest("panel"));
+ }
+ },
+
+ showHiddenTabsPanel(event, entrypoint = "unknown") {
+ this.init();
+ if (!this.canOpen) {
+ return;
+ }
+ this.allTabsView.addEventListener(
+ "ViewShown",
+ e => {
+ PanelUI.showSubView(
+ this.kElements.hiddenTabsView,
+ this.hiddenTabsButton
+ );
+ },
+ { once: true }
+ );
+ this.showAllTabsPanel(event, entrypoint);
+ },
+
+ searchTabs() {
+ gURLBar.search(UrlbarTokenizer.RESTRICT.OPENPAGE, {
+ searchModeEntry: "tabmenu",
+ });
+ },
+};
diff --git a/browser/base/content/browser-box.inc.xhtml b/browser/base/content/browser-box.inc.xhtml
new file mode 100644
index 0000000000..7e711c9d9a
--- /dev/null
+++ b/browser/base/content/browser-box.inc.xhtml
@@ -0,0 +1,28 @@
+# 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/.
+
+<hbox flex="1" id="browser">
+ <vbox id="sidebar-box" hidden="true" class="chromeclass-extrachrome">
+ <box id="sidebar-header" align="center">
+ <toolbarbutton id="sidebar-switcher-target" class="tabbable">
+ <image id="sidebar-icon" consumeanchor="sidebar-switcher-target"/>
+ <label id="sidebar-title" crop="end" control="sidebar"/>
+ <image id="sidebar-switcher-arrow"/>
+ </toolbarbutton>
+ <image id="sidebar-throbber"/>
+ <spacer id="sidebar-spacer"/>
+ <toolbarbutton id="sidebar-close" class="close-icon tabbable" data-l10n-id="sidebar-close-button" oncommand="SidebarUI.hide();"/>
+ </box>
+ <browser id="sidebar" autoscroll="false" disablehistory="true" disablefullscreen="true" tooltip="aHTMLTooltip"/>
+ </vbox>
+ <splitter id="sidebar-splitter" class="chromeclass-extrachrome sidebar-splitter" resizebefore="sibling" resizeafter="none" hidden="true"/>
+ <vbox id="appcontent" flex="1">
+ <!-- gNotificationBox will be added here lazily. -->
+ <tabbox id="tabbrowser-tabbox"
+ flex="1" tabcontainer="tabbrowser-tabs">
+ <tabpanels id="tabbrowser-tabpanels"
+ flex="1" selectedIndex="0"/>
+ </tabbox>
+ </vbox>
+</hbox>
diff --git a/browser/base/content/browser-captivePortal.js b/browser/base/content/browser-captivePortal.js
new file mode 100644
index 0000000000..23957124d5
--- /dev/null
+++ b/browser/base/content/browser-captivePortal.js
@@ -0,0 +1,370 @@
+/* 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/. */
+
+var CaptivePortalWatcher = {
+ // This is the value used to identify the captive portal notification.
+ PORTAL_NOTIFICATION_VALUE: "captive-portal-detected",
+
+ // This holds a weak reference to the captive portal tab so that we
+ // don't leak it if the user closes it.
+ _captivePortalTab: null,
+
+ /**
+ * If a portal is detected when we don't have focus, we first wait for focus
+ * and then add the tab if, after a recheck, the portal is still active. This
+ * is set to true while we wait so that in the unlikely event that we receive
+ * another notification while waiting, we don't do things twice.
+ */
+ _delayedCaptivePortalDetectedInProgress: false,
+
+ // In the situation above, this is set to true while we wait for the recheck.
+ // This flag exists so that tests can appropriately simulate a recheck.
+ _waitingForRecheck: false,
+
+ // This holds a weak reference to the captive portal tab so we can close the tab
+ // after successful login if we're redirected to the canonicalURL.
+ _previousCaptivePortalTab: null,
+
+ get _captivePortalNotification() {
+ return gNotificationBox.getNotificationWithValue(
+ this.PORTAL_NOTIFICATION_VALUE
+ );
+ },
+
+ get canonicalURL() {
+ return Services.prefs.getCharPref("captivedetect.canonicalURL");
+ },
+
+ get _browserBundle() {
+ delete this._browserBundle;
+ return (this._browserBundle = Services.strings.createBundle(
+ "chrome://browser/locale/browser.properties"
+ ));
+ },
+
+ init() {
+ Services.obs.addObserver(this, "captive-portal-login");
+ Services.obs.addObserver(this, "captive-portal-login-abort");
+ Services.obs.addObserver(this, "captive-portal-login-success");
+
+ this._cps = Cc["@mozilla.org/network/captive-portal-service;1"].getService(
+ Ci.nsICaptivePortalService
+ );
+
+ if (this._cps.state == this._cps.LOCKED_PORTAL) {
+ // A captive portal has already been detected.
+ this._captivePortalDetected();
+
+ // Automatically open a captive portal tab if there's no other browser window.
+ if (BrowserWindowTracker.windowCount == 1) {
+ this.ensureCaptivePortalTab();
+ }
+ } else if (this._cps.state == this._cps.UNKNOWN) {
+ // We trigger a portal check after delayed startup to avoid doing a network
+ // request before first paint.
+ this._delayedRecheckPending = true;
+ }
+
+ // This constant is chosen to be large enough for a portal recheck to complete,
+ // and small enough that the delay in opening a tab isn't too noticeable.
+ // Please see comments for _delayedCaptivePortalDetected for more details.
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "PORTAL_RECHECK_DELAY_MS",
+ "captivedetect.portalRecheckDelayMS",
+ 500
+ );
+ },
+
+ uninit() {
+ Services.obs.removeObserver(this, "captive-portal-login");
+ Services.obs.removeObserver(this, "captive-portal-login-abort");
+ Services.obs.removeObserver(this, "captive-portal-login-success");
+
+ this._cancelDelayedCaptivePortal();
+ },
+
+ delayedStartup() {
+ if (this._delayedRecheckPending) {
+ delete this._delayedRecheckPending;
+ this._cps.recheckCaptivePortal();
+ }
+ },
+
+ observe(aSubject, aTopic, aData) {
+ switch (aTopic) {
+ case "captive-portal-login":
+ this._captivePortalDetected();
+ break;
+ case "captive-portal-login-abort":
+ this._captivePortalGone(false);
+ break;
+ case "captive-portal-login-success":
+ this._captivePortalGone(true);
+ break;
+ case "delayed-captive-portal-handled":
+ this._cancelDelayedCaptivePortal();
+ break;
+ }
+ },
+
+ onLocationChange(browser) {
+ if (!this._previousCaptivePortalTab) {
+ return;
+ }
+
+ let tab = this._previousCaptivePortalTab.get();
+ if (!tab || !tab.linkedBrowser) {
+ return;
+ }
+
+ if (browser != tab.linkedBrowser) {
+ return;
+ }
+
+ // There is a race between the release of captive portal i.e.
+ // the time when success/abort events are fired and the time when
+ // the captive portal tab redirects to the canonicalURL. We check for
+ // both conditions to be true and also check that we haven't already removed
+ // the captive portal tab in the success/abort event handlers before we remove
+ // it in the callback below. A tick is added to avoid removing the tab before
+ // onLocationChange handlers across browser code are executed.
+ Services.tm.dispatchToMainThread(() => {
+ if (!this._previousCaptivePortalTab) {
+ return;
+ }
+
+ tab = this._previousCaptivePortalTab.get();
+ let canonicalURI = Services.io.newURI(this.canonicalURL);
+ if (
+ tab &&
+ tab.linkedBrowser.currentURI.equalsExceptRef(canonicalURI) &&
+ (this._cps.state == this._cps.UNLOCKED_PORTAL ||
+ this._cps.state == this._cps.UNKNOWN)
+ ) {
+ gBrowser.removeTab(tab);
+ }
+ });
+ },
+
+ _captivePortalDetected() {
+ if (this._delayedCaptivePortalDetectedInProgress) {
+ return;
+ }
+
+ // Add an explicit permission for the last detected URI such that https-only / https-first do not
+ // attempt to upgrade the URI to https when following the "open network login page" button.
+ // We set explicit permissions for regular and private browsing windows to keep permissions
+ // separate.
+ let canonicalURI = Services.io.newURI(this.canonicalURL);
+ let isPrivate = PrivateBrowsingUtils.isWindowPrivate(window);
+ let principal = Services.scriptSecurityManager.createContentPrincipal(
+ canonicalURI,
+ {
+ userContextId: gBrowser.contentPrincipal.userContextId,
+ privateBrowsingId: isPrivate ? 1 : 0,
+ }
+ );
+ Services.perms.addFromPrincipal(
+ principal,
+ "https-only-load-insecure",
+ Ci.nsIPermissionManager.ALLOW_ACTION,
+ Ci.nsIPermissionManager.EXPIRE_SESSION
+ );
+ let win = BrowserWindowTracker.getTopWindow();
+ // Used by tests: ignore the main test window in order to enable testing of
+ // the case where we have no open windows.
+ if (win.document.documentElement.getAttribute("ignorecaptiveportal")) {
+ win = null;
+ }
+
+ // If no browser window has focus, open and show the tab when we regain focus.
+ // This is so that if a different application was focused, when the user
+ // (re-)focuses a browser window, we open the tab immediately in that window
+ // so they can log in before continuing to browse.
+ if (win != Services.focus.activeWindow) {
+ this._delayedCaptivePortalDetectedInProgress = true;
+ window.addEventListener("activate", this, { once: true });
+ Services.obs.addObserver(this, "delayed-captive-portal-handled");
+ }
+
+ this._showNotification();
+ },
+
+ /**
+ * Called after we regain focus if we detect a portal while a browser window
+ * doesn't have focus. Triggers a portal recheck to reaffirm state, and adds
+ * the tab if needed after a short delay to allow the recheck to complete.
+ */
+ _delayedCaptivePortalDetected() {
+ if (!this._delayedCaptivePortalDetectedInProgress) {
+ return;
+ }
+
+ // Used by tests: ignore the main test window in order to enable testing of
+ // the case where we have no open windows.
+ if (window.document.documentElement.getAttribute("ignorecaptiveportal")) {
+ return;
+ }
+
+ Services.obs.notifyObservers(null, "delayed-captive-portal-handled");
+
+ // Trigger a portal recheck. The user may have logged into the portal via
+ // another client, or changed networks.
+ this._cps.recheckCaptivePortal();
+ this._waitingForRecheck = true;
+ let requestTime = Date.now();
+
+ let observer = () => {
+ let time = Date.now() - requestTime;
+ Services.obs.removeObserver(observer, "captive-portal-check-complete");
+ this._waitingForRecheck = false;
+ if (this._cps.state != this._cps.LOCKED_PORTAL) {
+ // We're free of the portal!
+ return;
+ }
+
+ if (time <= this.PORTAL_RECHECK_DELAY_MS) {
+ // The amount of time elapsed since we requested a recheck (i.e. since
+ // the browser window was focused) was small enough that we can add and
+ // focus a tab with the login page with no noticeable delay.
+ this.ensureCaptivePortalTab();
+ }
+ };
+ Services.obs.addObserver(observer, "captive-portal-check-complete");
+ },
+
+ _captivePortalGone(aSuccess) {
+ this._cancelDelayedCaptivePortal();
+ this._removeNotification();
+
+ if (!this._captivePortalTab) {
+ return;
+ }
+
+ let tab = this._captivePortalTab.get();
+ let canonicalURI = Services.io.newURI(this.canonicalURL);
+ if (
+ tab &&
+ tab.linkedBrowser &&
+ tab.linkedBrowser.currentURI.equalsExceptRef(canonicalURI)
+ ) {
+ this._previousCaptivePortalTab = null;
+ gBrowser.removeTab(tab);
+ }
+ this._captivePortalTab = null;
+ },
+
+ _cancelDelayedCaptivePortal() {
+ if (this._delayedCaptivePortalDetectedInProgress) {
+ this._delayedCaptivePortalDetectedInProgress = false;
+ Services.obs.removeObserver(this, "delayed-captive-portal-handled");
+ window.removeEventListener("activate", this);
+ }
+ },
+
+ handleEvent(aEvent) {
+ switch (aEvent.type) {
+ case "activate":
+ this._delayedCaptivePortalDetected();
+ break;
+ case "TabSelect":
+ if (!this._captivePortalTab || !this._captivePortalNotification) {
+ break;
+ }
+
+ let tab = this._captivePortalTab.get();
+ let n = this._captivePortalNotification;
+ if (!tab || !n) {
+ break;
+ }
+
+ let doc = tab.ownerDocument;
+ let button = n.buttonContainer.querySelector(
+ "button.notification-button"
+ );
+ if (doc.defaultView.gBrowser.selectedTab == tab) {
+ button.style.visibility = "hidden";
+ } else {
+ button.style.visibility = "visible";
+ }
+ break;
+ }
+ },
+
+ _showNotification() {
+ if (this._captivePortalNotification) {
+ return;
+ }
+
+ let buttons = [
+ {
+ label: this._browserBundle.GetStringFromName(
+ "captivePortal.showLoginPage2"
+ ),
+ callback: () => {
+ this.ensureCaptivePortalTab();
+
+ // Returning true prevents the notification from closing.
+ return true;
+ },
+ },
+ ];
+
+ let message = this._browserBundle.GetStringFromName(
+ "captivePortal.infoMessage3"
+ );
+
+ let closeHandler = aEventName => {
+ if (aEventName != "removed") {
+ return;
+ }
+ gBrowser.tabContainer.removeEventListener("TabSelect", this);
+ };
+
+ gNotificationBox.appendNotification(
+ this.PORTAL_NOTIFICATION_VALUE,
+ {
+ label: message,
+ priority: gNotificationBox.PRIORITY_INFO_MEDIUM,
+ eventCallback: closeHandler,
+ },
+ buttons
+ );
+
+ gBrowser.tabContainer.addEventListener("TabSelect", this);
+ },
+
+ _removeNotification() {
+ let n = this._captivePortalNotification;
+ if (!n || !n.parentNode) {
+ return;
+ }
+ n.close();
+ },
+
+ ensureCaptivePortalTab() {
+ let tab;
+ if (this._captivePortalTab) {
+ tab = this._captivePortalTab.get();
+ }
+
+ // If the tab is gone or going, we need to open a new one.
+ if (!tab || tab.closing || !tab.parentNode) {
+ tab = gBrowser.addWebTab(this.canonicalURL, {
+ ownerTab: gBrowser.selectedTab,
+ triggeringPrincipal: Services.scriptSecurityManager.createNullPrincipal(
+ {
+ userContextId: gBrowser.contentPrincipal.userContextId,
+ }
+ ),
+ disableTRR: true,
+ });
+ this._captivePortalTab = Cu.getWeakReference(tab);
+ this._previousCaptivePortalTab = Cu.getWeakReference(tab);
+ }
+
+ gBrowser.selectedTab = tab;
+ },
+};
diff --git a/browser/base/content/browser-context.inc b/browser/base/content/browser-context.inc
new file mode 100644
index 0000000000..6ca8d7ebaa
--- /dev/null
+++ b/browser/base/content/browser-context.inc
@@ -0,0 +1,450 @@
+# -*- Mode: Java; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*-
+# 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/.
+
+#ifdef XP_MACOSX
+ <menuitem id="context-back"
+ data-l10n-id="main-context-menu-back-mac"
+ command="Browser:BackOrBackDuplicate"/>
+ <menuitem id="context-forward"
+ data-l10n-id="main-context-menu-forward-mac"
+ command="Browser:ForwardOrForwardDuplicate"/>
+ <menuitem id="context-reload"
+ tooltip="dynamic-shortcut-tooltip"
+ data-l10n-id="main-context-menu-reload-mac"
+ command="Browser:ReloadOrDuplicate"/>
+ <menuitem id="context-stop"
+ tooltip="dynamic-shortcut-tooltip"
+ data-l10n-id="main-context-menu-stop-mac"
+ command="Browser:Stop"/>
+#else
+ <menugroup id="context-navigation">
+ <menuitem id="context-back"
+ data-l10n-id="main-context-menu-back-2"
+ data-l10n-args='{"shortcut":""}'
+ class="menuitem-iconic"
+ command="Browser:BackOrBackDuplicate"/>
+ <menuitem id="context-forward"
+ data-l10n-id="main-context-menu-forward-2"
+ data-l10n-args='{"shortcut":""}'
+ class="menuitem-iconic"
+ command="Browser:ForwardOrForwardDuplicate"/>
+ <menuitem id="context-reload"
+ class="menuitem-iconic"
+ tooltip="dynamic-shortcut-tooltip"
+ data-l10n-id="main-context-menu-reload"
+ command="Browser:ReloadOrDuplicate"/>
+ <menuitem id="context-stop"
+ class="menuitem-iconic"
+ tooltip="dynamic-shortcut-tooltip"
+ data-l10n-id="main-context-menu-stop"
+ command="Browser:Stop"/>
+ <menuitem id="context-bookmarkpage"
+ class="menuitem-iconic"
+ data-l10n-id="main-context-menu-bookmark-page"
+ oncommand="gContextMenu.bookmarkThisPage();"/>
+ </menugroup>
+#endif
+ <menuseparator id="context-sep-navigation"/>
+ <menuitem id="context-viewsource-goToLine"
+ oncommand="gViewSourceUtils.getPageActor(gContextMenu.browser).promptAndGoToLine()"/>
+ <menuitem id="context-viewsource-wrapLongLines"
+ type="checkbox"
+ oncommand="gViewSourceUtils.getPageActor(gContextMenu.browser).sendAsyncMessage('ViewSource:ToggleWrapping');"/>
+ <menuitem id="context-viewsource-highlightSyntax"
+ type="checkbox"
+ oncommand="gViewSourceUtils.getPageActor(gContextMenu.browser).sendAsyncMessage('ViewSource:ToggleSyntaxHighlighting');"/>
+ <menuseparator id="context-sep-viewsource-commands"/>
+ <menuitem id="spell-no-suggestions"
+ disabled="true"
+ data-l10n-id="text-action-spell-no-suggestions"/>
+ <menuitem id="spell-add-to-dictionary"
+ data-l10n-id="text-action-spell-add-to-dictionary"
+ oncommand="InlineSpellCheckerUI.addToDictionary();"/>
+ <menuitem id="spell-undo-add-to-dictionary"
+ data-l10n-id="text-action-spell-undo-add-to-dictionary"
+ oncommand="InlineSpellCheckerUI.undoAddToDictionary();" />
+ <menuseparator id="spell-suggestions-separator"/>
+ <menuitem id="context-openlinkincurrent"
+ class="context-menu-open-link"
+ data-l10n-id="main-context-menu-open-link"
+ oncommand="gContextMenu.openLinkInCurrent();"/>
+# label and data-usercontextid are dynamically set.
+ <menuitem id="context-openlinkincontainertab"
+ class="context-menu-open-link"
+ data-l10n-id="main-context-menu-open-link-in-container-tab"
+ data-l10n-args='{"containerName":""}'
+ oncommand="gContextMenu.openLinkInTab(event);"/>
+ <menuitem id="context-openlinkintab"
+ class="context-menu-open-link"
+ data-l10n-id="main-context-menu-open-link-new-tab"
+ data-usercontextid="0"
+ oncommand="gContextMenu.openLinkInTab(event);"/>
+
+ <menu id="context-openlinkinusercontext-menu"
+ class="context-menu-open-link"
+ data-l10n-id="main-context-menu-open-link-container-tab"
+ hidden="true">
+ <menupopup oncommand="gContextMenu.openLinkInTab(event);"
+ onpopupshowing="return gContextMenu.createContainerMenu(event);" />
+ </menu>
+
+ <menuitem id="context-openlink"
+ class="context-menu-open-link"
+ data-l10n-id="main-context-menu-open-link-new-window"
+ oncommand="gContextMenu.openLink();"/>
+ <menuitem id="context-openlinkprivate"
+ class="context-menu-open-link"
+ data-l10n-id="main-context-menu-open-link-new-private-window"
+ oncommand="gContextMenu.openLinkInPrivateWindow();"/>
+ <menuseparator id="context-sep-open"/>
+ <menuitem id="context-bookmarklink"
+ data-l10n-id="main-context-menu-bookmark-link-2"
+ oncommand="gContextMenu.bookmarkLink();"/>
+ <menuitem id="context-savelink"
+ data-l10n-id="main-context-menu-save-link"
+ oncommand="gContextMenu.saveLink();"/>
+ <menuitem id="context-savelinktopocket"
+ data-l10n-id="main-context-menu-save-link-to-pocket"
+ oncommand= "Pocket.savePage(gContextMenu.browser, gContextMenu.linkURL);"/>
+ <menuitem id="context-copyemail"
+ data-l10n-id="main-context-menu-copy-email"
+ oncommand="gContextMenu.copyEmail();"/>
+ <menuitem id="context-copyphone"
+ data-l10n-id="main-context-menu-copy-phone"
+ oncommand="gContextMenu.copyPhone();"/>
+ <menuitem id="context-copylink"
+ data-l10n-id="main-context-menu-copy-link-simple"
+ oncommand="gContextMenu.copyLink();"/>
+ <menuitem id="context-stripOnShareLink"
+ data-l10n-id="main-context-menu-strip-on-share-link"
+ hidden="true"
+ oncommand="gContextMenu.copyStrippedLink();"/>
+ <menu id="context-sendlinktodevice"
+ class="sync-ui-item"
+ data-l10n-id="main-context-menu-link-send-to-device"
+ hidden="true">
+ <menupopup id="context-sendlinktodevice-popup"
+ onpopupshowing="gSync.populateSendTabToDevicesMenu(event.target, gContextMenu.linkURI, gContextMenu.linkTextStr);"/>
+ </menu>
+ <menuseparator id="context-sep-sendlinktodevice" class="sync-ui-item"
+ hidden="true"/>
+ <menuseparator id="context-sep-copylink"/>
+ <menuitem id="context-media-play"
+ data-l10n-id="main-context-menu-media-play"
+ oncommand="gContextMenu.mediaCommand('play');"/>
+ <menuitem id="context-media-pause"
+ data-l10n-id="main-context-menu-media-pause"
+ oncommand="gContextMenu.mediaCommand('pause');"/>
+ <menuitem id="context-media-mute"
+ data-l10n-id="main-context-menu-media-mute"
+ oncommand="gContextMenu.mediaCommand('mute');"/>
+ <menuitem id="context-media-unmute"
+ data-l10n-id="main-context-menu-media-unmute"
+ oncommand="gContextMenu.mediaCommand('unmute');"/>
+ <menu id="context-media-playbackrate" data-l10n-id="main-context-menu-media-play-speed-2">
+ <menupopup>
+ <menuitem id="context-media-playbackrate-050x"
+ data-l10n-id="main-context-menu-media-play-speed-slow-2"
+ type="radio"
+ name="playbackrate"
+ oncommand="gContextMenu.mediaCommand('playbackRate', 0.5);"/>
+ <menuitem id="context-media-playbackrate-100x"
+ data-l10n-id="main-context-menu-media-play-speed-normal-2"
+ type="radio"
+ name="playbackrate"
+ checked="true"
+ oncommand="gContextMenu.mediaCommand('playbackRate', 1.0);"/>
+ <menuitem id="context-media-playbackrate-125x"
+ data-l10n-id="main-context-menu-media-play-speed-fast-2"
+ type="radio"
+ name="playbackrate"
+ oncommand="gContextMenu.mediaCommand('playbackRate', 1.25);"/>
+ <menuitem id="context-media-playbackrate-150x"
+ data-l10n-id="main-context-menu-media-play-speed-faster-2"
+ type="radio"
+ name="playbackrate"
+ oncommand="gContextMenu.mediaCommand('playbackRate', 1.5);"/>
+ <menuitem id="context-media-playbackrate-200x"
+ data-l10n-id="main-context-menu-media-play-speed-fastest-2"
+ type="radio"
+ name="playbackrate"
+ oncommand="gContextMenu.mediaCommand('playbackRate', 2.0);"/>
+ </menupopup>
+ </menu>
+ <menuitem id="context-media-loop"
+ data-l10n-id="main-context-menu-media-loop"
+ type="checkbox"
+ oncommand="gContextMenu.mediaCommand('loop');"/>
+ <menuitem id="context-leave-dom-fullscreen"
+ data-l10n-id="main-context-menu-media-video-leave-fullscreen"
+ oncommand="gContextMenu.leaveDOMFullScreen();"/>
+ <menuitem id="context-video-fullscreen"
+ data-l10n-id="main-context-menu-media-video-fullscreen"
+ oncommand="gContextMenu.mediaCommand('fullscreen');"/>
+ <menuitem id="context-media-hidecontrols"
+ data-l10n-id="main-context-menu-media-hide-controls"
+ oncommand="gContextMenu.mediaCommand('hidecontrols');"/>
+ <menuitem id="context-media-showcontrols"
+ data-l10n-id="main-context-menu-media-show-controls"
+ oncommand="gContextMenu.mediaCommand('showcontrols');"/>
+ <menuseparator id="context-media-sep-video-commands"/>
+ <menuitem id="context-viewvideo"
+ data-l10n-id="main-context-menu-video-view-new-tab"
+ oncommand="gContextMenu.viewMedia(event);"/>
+ <menuitem id="context-video-pictureinpicture"
+ data-l10n-id="main-context-menu-media-watch-pip"
+ type="checkbox"
+ oncommand="gContextMenu.mediaCommand('pictureinpicture');"/>
+ <menuseparator id="context-media-sep-commands"/>
+ <menuitem id="context-reloadimage"
+ data-l10n-id="main-context-menu-image-reload"
+ oncommand="gContextMenu.reloadImage();"/>
+ <menuitem id="context-viewimage"
+ data-l10n-id="main-context-menu-image-view-new-tab"
+ oncommand="gContextMenu.viewMedia(event);"/>
+ <menuitem id="context-saveimage"
+ data-l10n-id="main-context-menu-image-save-as"
+ oncommand="gContextMenu.saveMedia();"/>
+ <menuitem id="context-video-saveimage"
+ data-l10n-id="main-context-menu-video-take-snapshot"
+ oncommand="gContextMenu.saveVideoFrameAsImage();"/>
+ <menuitem id="context-savevideo"
+ data-l10n-id="main-context-menu-video-save-as"
+ oncommand="gContextMenu.saveMedia();"/>
+ <menuitem id="context-saveaudio"
+ data-l10n-id="main-context-menu-audio-save-as"
+ oncommand="gContextMenu.saveMedia();"/>
+#ifdef CONTEXT_COPY_IMAGE_CONTENTS
+ <menuitem id="context-copyimage-contents"
+ data-l10n-id="main-context-menu-image-copy"
+ oncommand="goDoCommand('cmd_copyImage');"/>
+#endif
+ <menuitem id="context-copyimage"
+ data-l10n-id="main-context-menu-image-copy-link"
+ oncommand="gContextMenu.copyMediaLocation();"/>
+ <menuitem id="context-copyvideourl"
+ data-l10n-id="main-context-menu-video-copy-link"
+ oncommand="gContextMenu.copyMediaLocation();"/>
+ <menuitem id="context-copyaudiourl"
+ data-l10n-id="main-context-menu-audio-copy-link"
+ oncommand="gContextMenu.copyMediaLocation();"/>
+ <menuitem id="context-sendimage"
+ data-l10n-id="main-context-menu-image-email"
+ oncommand="gContextMenu.sendMedia();"/>
+ <menuitem id="context-sendvideo"
+ data-l10n-id="main-context-menu-video-email"
+ oncommand="gContextMenu.sendMedia();"/>
+ <menuitem id="context-sendaudio"
+ data-l10n-id="main-context-menu-audio-email"
+ oncommand="gContextMenu.sendMedia();"/>
+ <menuitem id="context-imagetext"
+ data-l10n-id="main-context-menu-image-copy-text"
+ oncommand="gContextMenu.getImageText()"/>
+ <menuitem id="context-viewimageinfo"
+ hidden="true"
+ data-l10n-id="main-context-menu-image-info"
+ oncommand="gContextMenu.viewImageInfo();"/>
+ <menuitem id="context-viewimagedesc"
+ data-l10n-id="main-context-menu-image-desc"
+ oncommand="gContextMenu.viewImageDesc(event);"/>
+ <menuseparator id="context-sep-setbackground"/>
+ <menuitem id="context-setDesktopBackground"
+ data-l10n-id="main-context-menu-image-set-image-as-background"
+ oncommand="gContextMenu.setDesktopBackground();"/>
+ <menuseparator id="context-sep-sharing"/>
+#ifdef XP_MACOSX
+ <menuitem id="context-bookmarkpage"
+ data-l10n-id="main-context-menu-bookmark-page-mac"
+ oncommand="gContextMenu.bookmarkThisPage();"/>
+#endif
+ <menuitem id="context-savepage"
+ data-l10n-id="main-context-menu-page-save"
+ oncommand="gContextMenu.savePageAs();"/>
+ <menuitem id="context-pocket"
+ data-l10n-id="main-context-menu-save-to-pocket"
+ oncommand="Pocket.savePage(gContextMenu.browser, gContextMenu.browser.currentURI.spec, gContextMenu.browser.contentTitle);"/>
+ <menu id="context-sendpagetodevice"
+ class="sync-ui-item"
+ data-l10n-id="main-context-menu-send-to-device"
+ hidden="true">
+ <menupopup id="context-sendpagetodevice-popup"
+ onpopupshowing="(() => { gSync.populateSendTabToDevicesMenu(event.target, gBrowser.currentURI, gBrowser.contentTitle); })()"/>
+ </menu>
+ <menu id="fill-login" hidden="true">
+ <menupopup id="fill-login-popup" />
+ </menu>
+ <menuitem id="fill-login-generated-password"
+ data-l10n-id="main-context-menu-suggest-strong-password"
+ hidden="true"
+ oncommand="gContextMenu.useGeneratedPassword();"/>
+ <menuitem id="use-relay-mask"
+ data-l10n-id="main-context-menu-use-relay-mask"
+ hidden="true"
+ oncommand="gContextMenu.useRelayMask();"/>
+ <menuitem id="manage-saved-logins"
+ data-l10n-id="main-context-menu-manage-logins2"
+ hidden="true"
+ oncommand="gContextMenu.openPasswordManager();"/>
+ <menuseparator id="passwordmgr-items-separator"/>
+ <menuitem id="context-undo"
+ data-l10n-id="text-action-undo"
+ command="cmd_undo"/>
+ <menuitem id="context-redo"
+ data-l10n-id="text-action-redo"
+ command="cmd_redo"/>
+ <menuseparator id="context-sep-redo"/>
+ <menuitem id="context-cut"
+ data-l10n-id="text-action-cut"
+ command="cmd_cut"/>
+ <menuitem id="context-copy"
+ data-l10n-id="text-action-copy"
+ command="cmd_copy"/>
+ <menuitem id="context-paste"
+ data-l10n-id="text-action-paste"
+ command="cmd_paste"/>
+ <menuitem id="context-paste-no-formatting"
+ data-l10n-id="text-action-paste-no-formatting"
+ command="cmd_pasteNoFormatting"/>
+ <menuitem id="context-delete"
+ data-l10n-id="text-action-delete"
+ command="cmd_delete"/>
+ <menuitem id="context-selectall"
+ data-l10n-id="text-action-select-all"
+ command="cmd_selectAll"/>
+ <menuitem id="context-reveal-password"
+ type="checkbox"
+ data-l10n-id="main-context-menu-reveal-password"
+ oncommand="gContextMenu.toggleRevealPassword();"/>
+ <menuitem id="context-print-selection"
+ data-l10n-id="main-context-menu-print-selection-2"
+ oncommand="gContextMenu.printSelection();"/>
+ <menuseparator id="context-sep-selectall"/>
+
+ <menuitem id="context-pdfjs-undo"
+ data-l10n-id="text-action-undo"
+ oncommand="gContextMenu.pdfJSCmd('undo');"/>
+ <menuitem id="context-pdfjs-redo"
+ data-l10n-id="text-action-redo"
+ oncommand="gContextMenu.pdfJSCmd('redo');"/>
+ <menuseparator id="context-sep-pdfjs-redo"/>
+ <menuitem id="context-pdfjs-cut"
+ data-l10n-id="text-action-cut"
+ oncommand="gContextMenu.pdfJSCmd('cut');"/>
+ <menuitem id="context-pdfjs-copy"
+ data-l10n-id="text-action-copy"
+ oncommand="gContextMenu.pdfJSCmd('copy');"/>
+ <menuitem id="context-pdfjs-paste"
+ data-l10n-id="text-action-paste"
+ oncommand="gContextMenu.pdfJSCmd('paste');"/>
+ <menuitem id="context-pdfjs-delete"
+ data-l10n-id="text-action-delete"
+ oncommand="gContextMenu.pdfJSCmd('delete');"/>
+ <menuitem id="context-pdfjs-selectall"
+ data-l10n-id="text-action-select-all"
+ oncommand="gContextMenu.pdfJSCmd('selectAll');"/>
+ <menuseparator id="context-sep-pdfjs-selectall"/>
+
+ <menuitem id="context-take-screenshot"
+ data-l10n-id="main-context-menu-take-screenshot"
+ oncommand="gContextMenu.takeScreenshot();"/>
+ <menuseparator id="context-sep-screenshots"/>
+ <menuitem id="context-keywordfield"
+ data-l10n-id="main-context-menu-keyword"
+ oncommand="AddKeywordForSearchField();"/>
+ <menuitem id="context-searchselect"
+ oncommand="BrowserSearch.loadSearchFromContext(this.searchTerms, this.usePrivate, this.principal, this.csp, event);"/>
+ <menuitem id="context-searchselect-private"
+ oncommand="BrowserSearch.loadSearchFromContext(this.searchTerms, true, this.principal, this.csp, event);"/>
+
+ <menuseparator id="frame-sep"/>
+ <menu id="frame" data-l10n-id="main-context-menu-frame">
+ <menupopup>
+ <menuitem id="context-showonlythisframe"
+ data-l10n-id="main-context-menu-frame-show-this"
+ oncommand="gContextMenu.showOnlyThisFrame();"/>
+ <menuitem id="context-openframeintab"
+ data-l10n-id="main-context-menu-frame-open-tab"
+ oncommand="gContextMenu.openFrameInTab();"/>
+ <menuitem id="context-openframe"
+ data-l10n-id="main-context-menu-frame-open-window"
+ oncommand="gContextMenu.openFrame();"/>
+ <menuseparator id="open-frame-sep"/>
+ <menuitem id="context-reloadframe"
+ data-l10n-id="main-context-menu-frame-reload"
+ oncommand="gContextMenu.reloadFrame(event);"/>
+ <menuseparator/>
+ <menuitem id="context-bookmarkframe"
+ data-l10n-id="main-context-menu-frame-add-bookmark"
+ oncommand="gContextMenu.addBookmarkForFrame();"/>
+ <menuitem id="context-saveframe"
+ data-l10n-id="main-context-menu-frame-save-as"
+ oncommand="gContextMenu.saveFrame();"/>
+ <menuseparator/>
+ <menuitem id="context-printframe"
+ data-l10n-id="main-context-menu-frame-print"
+ oncommand="gContextMenu.printFrame();"/>
+ <menuseparator/>
+ <menuitem id="context-take-frame-screenshot"
+ data-l10n-id="main-context-menu-take-frame-screenshot"
+ oncommand="gContextMenu.takeScreenshot();"/>
+ <menuseparator id="context-sep-frame-screenshot"/>
+ <menuitem id="context-viewframesource"
+ data-l10n-id="main-context-menu-frame-view-source"
+ oncommand="gContextMenu.viewFrameSource();"/>
+ <menuitem id="context-viewframeinfo"
+ data-l10n-id="main-context-menu-frame-view-info"
+ oncommand="gContextMenu.viewFrameInfo();"/>
+#ifdef NIGHTLY_BUILD
+ <menuitem id="context-frameOsPid"
+ label="PID: Unknown"
+ disabled="true"/>
+#endif
+ </menupopup>
+ </menu>
+ <menuseparator id="spell-separator"/>
+ <menuitem id="spell-check-enabled"
+ data-l10n-id="text-action-spell-check-toggle"
+ type="checkbox"
+ oncommand="InlineSpellCheckerUI.toggleEnabled(window);"/>
+ <menuitem id="spell-add-dictionaries-main"
+ data-l10n-id="text-action-spell-add-dictionaries"
+ oncommand="gContextMenu.addDictionaries();"/>
+ <menu id="spell-dictionaries"
+ data-l10n-id="text-action-spell-dictionaries">
+ <menupopup id="spell-dictionaries-menu">
+ <menuseparator id="spell-language-separator"/>
+ <menuitem id="spell-add-dictionaries"
+ data-l10n-id="text-action-spell-add-dictionaries"
+ oncommand="gContextMenu.addDictionaries();"/>
+ </menupopup>
+ </menu>
+ <menuseparator hidden="true" id="context-sep-bidi"/>
+ <menuitem hidden="true" id="context-bidi-text-direction-toggle"
+ data-l10n-id="main-context-menu-bidi-switch-text"
+ command="cmd_switchTextDirection"/>
+ <menuitem hidden="true" id="context-bidi-page-direction-toggle"
+ data-l10n-id="main-context-menu-bidi-switch-page"
+ oncommand="gContextMenu.switchPageDirection();"/>
+ <menuseparator id="inspect-separator" hidden="true"/>
+ <menuitem id="context-viewpartialsource-selection"
+ data-l10n-id="main-context-menu-view-selection-source"
+ oncommand="gContextMenu.viewPartialSource();"/>
+ <menuitem id="context-viewsource"
+ data-l10n-id="main-context-menu-view-page-source"
+ oncommand="BrowserViewSource(gContextMenu.browser);"/>
+ <menuitem id="context-inspect-a11y"
+ hidden="true"
+ data-l10n-id="main-context-menu-inspect-a11y-properties"
+ oncommand="gContextMenu.inspectA11Y();"/>
+ <menuitem id="context-inspect"
+ hidden="true"
+ data-l10n-id="main-context-menu-inspect"
+ oncommand="gContextMenu.inspectNode();"/>
+ <menuseparator id="context-media-eme-separator" hidden="true"/>
+ <menuitem id="context-media-eme-learnmore"
+ class="menuitem-iconic"
+ hidden="true"
+ data-l10n-id="main-context-menu-eme-learn-more"
+ oncommand="gContextMenu.drmLearnMore(event);"/>
diff --git a/browser/base/content/browser-ctrlTab.js b/browser/base/content/browser-ctrlTab.js
new file mode 100644
index 0000000000..31617f13a3
--- /dev/null
+++ b/browser/base/content/browser-ctrlTab.js
@@ -0,0 +1,810 @@
+/* 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/. */
+
+// This file is loaded into the browser window scope.
+/* eslint-env mozilla/browser-window */
+
+/**
+ * Tab previews utility, produces thumbnails
+ */
+var tabPreviews = {
+ get aspectRatio() {
+ let { PageThumbUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/PageThumbUtils.sys.mjs"
+ );
+ let [width, height] = PageThumbUtils.getThumbnailSize(window);
+ delete this.aspectRatio;
+ return (this.aspectRatio = height / width);
+ },
+
+ /**
+ * Get the stored thumbnail URL for a given page URL and wait up to 1s for it
+ * to load. If the browser is discarded and there is no stored thumbnail, the
+ * image URL will fail to load and this method will return null after 1s.
+ * Callers should handle this case by doing nothing or using a fallback image.
+ * @param {String} uri The page URL.
+ * @returns {Promise<Image|null>}
+ */
+ loadImage: async function tabPreviews_loadImage(uri) {
+ let img = new Image();
+ img.src = PageThumbs.getThumbnailURL(uri);
+ if (img.complete && img.naturalWidth) {
+ return img;
+ }
+ return new Promise(resolve => {
+ const controller = new AbortController();
+ img.addEventListener(
+ "load",
+ () => {
+ clearTimeout(timeout);
+ controller.abort();
+ resolve(img);
+ },
+ { signal: controller.signal }
+ );
+ const timeout = setTimeout(() => {
+ controller.abort();
+ resolve(null);
+ }, 1000);
+ });
+ },
+
+ /**
+ * For a given tab, retrieve a preview thumbnail (a canvas or an image) from
+ * storage or capture a new one. If the tab's URL has changed since the
+ * previous call, the thumbnail will be regenerated.
+ * @param {MozTabbrowserTab} aTab The tab to get a preview for.
+ * @returns {Promise<HTMLCanvasElement|Image|null>} Resolves to...
+ * @resolves {HTMLCanvasElement} If a thumbnail can NOT be captured and stored
+ * for the tab, or if the tab is still loading, a snapshot is taken and
+ * returned as a canvas. It may be cached as a canvas (separately from
+ * thumbnail storage) in aTab.__thumbnail if the tab is finished loading. If
+ * the snapshot CAN be stored as a thumbnail, the snapshot is converted to a
+ * blob image and drawn in the returned canvas, but the image is added to
+ * thumbnail storage and cached in aTab.__thumbnail.
+ * @resolves {Image} A cached blob image from a previous thumbnail capture.
+ * e.g. <img src="moz-page-thumb://thumbnails/?url=foo.com&revision=bar">
+ * @resolves {null} If a thumbnail cannot be captured for any reason (e.g.
+ * because the tab is discarded) and there is no cached/stored thumbnail.
+ */
+ get: async function tabPreviews_get(aTab) {
+ let browser = aTab.linkedBrowser;
+ let uri = browser.currentURI.spec;
+
+ // Invalidate the cached thumbnail since the tab has changed.
+ if (aTab.__thumbnail_lastURI && aTab.__thumbnail_lastURI != uri) {
+ aTab.__thumbnail = null;
+ aTab.__thumbnail_lastURI = null;
+ }
+
+ // A cached thumbnail (not from thumbnail storage) is available.
+ if (aTab.__thumbnail) {
+ return aTab.__thumbnail;
+ }
+
+ // This means the browser is discarded. Try to load a stored thumbnail, and
+ // use a fallback style otherwise.
+ if (!browser.browsingContext) {
+ return this.loadImage(uri);
+ }
+
+ // Don't cache or store the thumbnail if the tab is still loading.
+ return this.capture(aTab, !aTab.hasAttribute("busy"));
+ },
+
+ /**
+ * For a given tab, capture a preview thumbnail (a canvas), optionally cache
+ * it in aTab.__thumbnail, and possibly store it in thumbnail storage.
+ * @param {MozTabbrowserTab} aTab The tab to capture a preview for.
+ * @param {Boolean} aShouldCache Cache/store the captured thumbnail?
+ * @returns {Promise<HTMLCanvasElement|null>} Resolves to...
+ * @resolves {HTMLCanvasElement} A snapshot of the tab's content. If the
+ * snapshot is safe for storage and aShouldCache is true, the snapshot is
+ * converted to a blob image, stored and cached, and drawn in the returned
+ * canvas. The thumbnail can then be recovered even if the browser is
+ * discarded. Otherwise, the canvas itself is cached in aTab.__thumbnail.
+ * @resolves {null} If a fatal exception occurred during thumbnail capture.
+ */
+ capture: async function tabPreviews_capture(aTab, aShouldCache) {
+ let browser = aTab.linkedBrowser;
+ let uri = browser.currentURI.spec;
+ let canvas = PageThumbs.createCanvas(window);
+ const doStore = await PageThumbs.shouldStoreThumbnail(browser);
+
+ if (doStore && aShouldCache) {
+ await PageThumbs.captureAndStore(browser);
+ let img = await this.loadImage(uri);
+ if (img) {
+ // Cache the stored blob image for future use.
+ aTab.__thumbnail = img;
+ aTab.__thumbnail_lastURI = uri;
+ // Draw the stored blob image in the canvas.
+ canvas.getContext("2d").drawImage(img, 0, 0);
+ } else {
+ canvas = null;
+ }
+ } else {
+ try {
+ await PageThumbs.captureToCanvas(browser, canvas);
+ if (aShouldCache) {
+ // Cache the canvas itself for future use.
+ aTab.__thumbnail = canvas;
+ aTab.__thumbnail_lastURI = uri;
+ }
+ } catch (error) {
+ console.error(error);
+ canvas = null;
+ }
+ }
+
+ return canvas;
+ },
+};
+
+var tabPreviewPanelHelper = {
+ opening(host) {
+ host.panel.hidden = false;
+
+ var handler = this._generateHandler(host);
+ host.panel.addEventListener("popupshown", handler);
+ host.panel.addEventListener("popuphiding", handler);
+
+ host._prevFocus = document.commandDispatcher.focusedElement;
+ },
+ _generateHandler(host) {
+ var self = this;
+ return function listener(event) {
+ if (event.target == host.panel) {
+ host.panel.removeEventListener(event.type, listener);
+ self["_" + event.type](host);
+ }
+ };
+ },
+ _popupshown(host) {
+ if ("setupGUI" in host) {
+ host.setupGUI();
+ }
+ },
+ _popuphiding(host) {
+ if ("suspendGUI" in host) {
+ host.suspendGUI();
+ }
+
+ if (host._prevFocus) {
+ Services.focus.setFocus(
+ host._prevFocus,
+ Ci.nsIFocusManager.FLAG_NOSCROLL
+ );
+ host._prevFocus = null;
+ } else {
+ gBrowser.selectedBrowser.focus();
+ }
+
+ if (host.tabToSelect) {
+ gBrowser.selectedTab = host.tabToSelect;
+ host.tabToSelect = null;
+ }
+ },
+};
+
+/**
+ * Ctrl-Tab panel
+ */
+var ctrlTab = {
+ maxTabPreviews: 7,
+ get panel() {
+ delete this.panel;
+ return (this.panel = document.getElementById("ctrlTab-panel"));
+ },
+ get showAllButton() {
+ delete this.showAllButton;
+ this.showAllButton = document.createXULElement("button");
+ this.showAllButton.id = "ctrlTab-showAll";
+ this.showAllButton.addEventListener("mouseover", this);
+ this.showAllButton.addEventListener("command", this);
+ this.showAllButton.addEventListener("click", this);
+ document
+ .getElementById("ctrlTab-showAll-container")
+ .appendChild(this.showAllButton);
+ return this.showAllButton;
+ },
+ get previews() {
+ delete this.previews;
+ this.previews = [];
+ let previewsContainer = document.getElementById("ctrlTab-previews");
+ for (let i = 0; i < this.maxTabPreviews; i++) {
+ let preview = this._makePreview();
+ previewsContainer.appendChild(preview);
+ this.previews.push(preview);
+ }
+ this.previews.push(this.showAllButton);
+ return this.previews;
+ },
+ get keys() {
+ var keys = {};
+ ["close", "find", "selectAll"].forEach(function (key) {
+ keys[key] = document
+ .getElementById("key_" + key)
+ .getAttribute("key")
+ .toLocaleLowerCase()
+ .charCodeAt(0);
+ });
+ delete this.keys;
+ return (this.keys = keys);
+ },
+ _selectedIndex: 0,
+ get selected() {
+ return this._selectedIndex < 0
+ ? document.activeElement
+ : this.previews[this._selectedIndex];
+ },
+ get isOpen() {
+ return (
+ this.panel.state == "open" || this.panel.state == "showing" || this._timer
+ );
+ },
+ get tabCount() {
+ return this.tabList.length;
+ },
+ get tabPreviewCount() {
+ return Math.min(this.maxTabPreviews, this.tabCount);
+ },
+
+ get tabList() {
+ return this._recentlyUsedTabs;
+ },
+
+ init: function ctrlTab_init() {
+ if (!this._recentlyUsedTabs) {
+ this._initRecentlyUsedTabs();
+ this._init(true);
+ }
+ },
+
+ uninit: function ctrlTab_uninit() {
+ if (this._recentlyUsedTabs) {
+ this._recentlyUsedTabs = null;
+ this._init(false);
+ }
+ },
+
+ prefName: "browser.ctrlTab.sortByRecentlyUsed",
+ readPref: function ctrlTab_readPref() {
+ var enable =
+ Services.prefs.getBoolPref(this.prefName) &&
+ !Services.prefs.getBoolPref(
+ "browser.ctrlTab.disallowForScreenReaders",
+ false
+ );
+
+ if (enable) {
+ this.init();
+ } else {
+ this.uninit();
+ }
+ },
+ observe(aSubject, aTopic, aPrefName) {
+ this.readPref();
+ },
+
+ _makePreview() {
+ let preview = document.createXULElement("button");
+ preview.className = "ctrlTab-preview";
+ preview.setAttribute("pack", "center");
+ preview.setAttribute("flex", "1");
+ preview.addEventListener("mouseover", this);
+ preview.addEventListener("command", this);
+ preview.addEventListener("click", this);
+
+ let previewInner = document.createXULElement("vbox");
+ previewInner.className = "ctrlTab-preview-inner";
+ preview.appendChild(previewInner);
+
+ let canvas = (preview._canvas = document.createXULElement("hbox"));
+ canvas.className = "ctrlTab-canvas";
+ previewInner.appendChild(canvas);
+
+ let faviconContainer = document.createXULElement("hbox");
+ faviconContainer.className = "ctrlTab-favicon-container";
+ previewInner.appendChild(faviconContainer);
+
+ let favicon = (preview._favicon = document.createXULElement("image"));
+ favicon.className = "ctrlTab-favicon";
+ faviconContainer.appendChild(favicon);
+
+ let label = (preview._label = document.createXULElement("label"));
+ label.className = "ctrlTab-label plain";
+ label.setAttribute("crop", "end");
+ previewInner.appendChild(label);
+
+ return preview;
+ },
+
+ updatePreviews: function ctrlTab_updatePreviews() {
+ for (let i = 0; i < this.previews.length; i++) {
+ this.updatePreview(this.previews[i], this.tabList[i]);
+ }
+
+ document.l10n.setAttributes(
+ this.showAllButton,
+ "tabbrowser-ctrl-tab-list-all-tabs",
+ { tabCount: this.tabCount }
+ );
+ this.showAllButton.hidden = !gTabsPanel.canOpen;
+ },
+
+ updatePreview: function ctrlTab_updatePreview(aPreview, aTab) {
+ if (aPreview == this.showAllButton) {
+ return;
+ }
+
+ aPreview._tab = aTab;
+
+ if (aTab) {
+ let canvas = aPreview._canvas;
+ let canvasWidth = this.canvasWidth;
+ let canvasHeight = this.canvasHeight;
+ canvas.setAttribute("width", canvasWidth);
+ canvas.style.minWidth = canvasWidth + "px";
+ canvas.style.maxWidth = canvasWidth + "px";
+ canvas.style.minHeight = canvasHeight + "px";
+ canvas.style.maxHeight = canvasHeight + "px";
+ tabPreviews
+ .get(aTab)
+ .then(img => {
+ switch (aPreview._tab) {
+ case aTab:
+ this._clearCanvas(canvas);
+ if (img) {
+ canvas.appendChild(img);
+ }
+ break;
+ case null:
+ // The preview panel is not open, so don't render anything.
+ this._clearCanvas(canvas);
+ break;
+ // If the tab exists but it has changed since updatePreview was
+ // called, the preview will likely be handled by a later
+ // updatePreview call, e.g. on TabAttrModified.
+ }
+ })
+ .catch(error => console.error(error));
+
+ aPreview._label.setAttribute("value", aTab.label);
+ aPreview.setAttribute("tooltiptext", aTab.label);
+ if (aTab.image) {
+ aPreview._favicon.setAttribute("src", aTab.image);
+ } else {
+ aPreview._favicon.removeAttribute("src");
+ }
+ aPreview.hidden = false;
+ } else {
+ this._clearCanvas(aPreview._canvas);
+ aPreview.hidden = true;
+ aPreview._label.removeAttribute("value");
+ aPreview.removeAttribute("tooltiptext");
+ aPreview._favicon.removeAttribute("src");
+ }
+ },
+
+ // Remove previous preview images from the canvas box.
+ _clearCanvas(canvas) {
+ while (canvas.firstElementChild) {
+ canvas.firstElementChild.remove();
+ }
+ },
+
+ advanceFocus: function ctrlTab_advanceFocus(aForward) {
+ let selectedIndex = this.previews.indexOf(this.selected);
+ do {
+ selectedIndex += aForward ? 1 : -1;
+ if (selectedIndex < 0) {
+ selectedIndex = this.previews.length - 1;
+ } else if (selectedIndex >= this.previews.length) {
+ selectedIndex = 0;
+ }
+ } while (this.previews[selectedIndex].hidden);
+
+ if (this._selectedIndex == -1) {
+ // Focus is already in the panel.
+ this.previews[selectedIndex].focus();
+ } else {
+ this._selectedIndex = selectedIndex;
+ }
+
+ if (this.previews[selectedIndex]._tab) {
+ gBrowser.warmupTab(this.previews[selectedIndex]._tab);
+ }
+
+ if (this._timer) {
+ clearTimeout(this._timer);
+ this._timer = null;
+ this._openPanel();
+ }
+ },
+
+ _mouseOverFocus: function ctrlTab_mouseOverFocus(aPreview) {
+ if (this._trackMouseOver) {
+ aPreview.focus();
+ }
+ },
+
+ pick: function ctrlTab_pick(aPreview) {
+ if (!this.tabCount) {
+ return;
+ }
+
+ var select = aPreview || this.selected;
+
+ if (select == this.showAllButton) {
+ this.showAllTabs("ctrltab-all-tabs-button");
+ } else {
+ this.close(select._tab);
+ }
+ },
+
+ showAllTabs: function ctrlTab_showAllTabs(aEntrypoint = "unknown") {
+ this.close();
+ gTabsPanel.showAllTabsPanel(null, aEntrypoint);
+ },
+
+ remove: function ctrlTab_remove(aPreview) {
+ if (aPreview._tab) {
+ gBrowser.removeTab(aPreview._tab);
+ }
+ },
+
+ attachTab: function ctrlTab_attachTab(aTab, aPos) {
+ // If the tab is hidden, don't add it to the list unless it's selected
+ // (Normally hidden tabs would be unhidden when selected, but that doesn't
+ // happen for Firefox View).
+ if (aTab.closing || (aTab.hidden && !aTab.selected)) {
+ return;
+ }
+
+ // If the tab is already in the list, remove it before re-inserting it.
+ this.detachTab(aTab);
+
+ if (aPos == 0) {
+ this._recentlyUsedTabs.unshift(aTab);
+ } else if (aPos) {
+ this._recentlyUsedTabs.splice(aPos, 0, aTab);
+ } else {
+ this._recentlyUsedTabs.push(aTab);
+ }
+ },
+
+ detachTab: function ctrlTab_detachTab(aTab) {
+ var i = this._recentlyUsedTabs.indexOf(aTab);
+ if (i >= 0) {
+ this._recentlyUsedTabs.splice(i, 1);
+ }
+ },
+
+ open: function ctrlTab_open() {
+ if (this.isOpen) {
+ return;
+ }
+
+ this.canvasWidth = Math.ceil(
+ (screen.availWidth * 0.85) / this.maxTabPreviews
+ );
+ this.canvasHeight = Math.round(this.canvasWidth * tabPreviews.aspectRatio);
+ this.updatePreviews();
+ this._selectedIndex = 1;
+ gBrowser.warmupTab(this.selected._tab);
+
+ // Add a slight delay before showing the UI, so that a quick
+ // "ctrl-tab" keypress just flips back to the MRU tab.
+ this._timer = setTimeout(() => {
+ this._timer = null;
+ this._openPanel();
+ }, 200);
+ },
+
+ _openPanel: function ctrlTab_openPanel() {
+ tabPreviewPanelHelper.opening(this);
+
+ let width = Math.min(
+ screen.availWidth * 0.99,
+ this.canvasWidth * 1.25 * this.tabPreviewCount
+ );
+ this.panel.style.width = width + "px";
+ var estimateHeight = this.canvasHeight * 1.25 + 75;
+ this.panel.openPopupAtScreen(
+ screen.availLeft + (screen.availWidth - width) / 2,
+ screen.availTop + (screen.availHeight - estimateHeight) / 2,
+ false
+ );
+ },
+
+ close: function ctrlTab_close(aTabToSelect) {
+ if (!this.isOpen) {
+ return;
+ }
+
+ if (this._timer) {
+ clearTimeout(this._timer);
+ this._timer = null;
+ this.suspendGUI();
+ if (aTabToSelect) {
+ gBrowser.selectedTab = aTabToSelect;
+ }
+ return;
+ }
+
+ this.tabToSelect = aTabToSelect;
+ this.panel.hidePopup();
+ },
+
+ setupGUI: function ctrlTab_setupGUI() {
+ this.selected.focus();
+ this._selectedIndex = -1;
+
+ // Track mouse movement after a brief delay so that the item that happens
+ // to be under the mouse pointer initially won't be selected unintentionally.
+ this._trackMouseOver = false;
+ setTimeout(
+ function (self) {
+ if (self.isOpen) {
+ self._trackMouseOver = true;
+ }
+ },
+ 0,
+ this
+ );
+ },
+
+ suspendGUI: function ctrlTab_suspendGUI() {
+ for (let preview of this.previews) {
+ this.updatePreview(preview, null);
+ }
+ },
+
+ onKeyDown(event) {
+ let action = ShortcutUtils.getSystemActionForEvent(event);
+ if (action != ShortcutUtils.CYCLE_TABS) {
+ return;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+
+ if (this.isOpen) {
+ this.advanceFocus(!event.shiftKey);
+ return;
+ }
+
+ if (event.shiftKey) {
+ this.showAllTabs("shift-tab");
+ return;
+ }
+
+ Services.els.addSystemEventListener(document, "keyup", this, false);
+
+ let tabs = gBrowser.visibleTabs;
+ if (tabs.length > 2) {
+ this.open();
+ } else if (tabs.length == 2) {
+ let index = tabs[0].selected ? 1 : 0;
+ gBrowser.selectedTab = tabs[index];
+ }
+ },
+
+ onKeyPress(event) {
+ if (!this.isOpen || !event.ctrlKey) {
+ return;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+
+ if (event.keyCode == event.DOM_VK_DELETE) {
+ this.remove(this.selected);
+ return;
+ }
+
+ switch (event.charCode) {
+ case this.keys.close:
+ this.remove(this.selected);
+ break;
+ case this.keys.find:
+ this.showAllTabs("ctrltab-key-find");
+ break;
+ case this.keys.selectAll:
+ this.showAllTabs("ctrltab-key-selectAll");
+ break;
+ }
+ },
+
+ removeClosingTabFromUI: function ctrlTab_removeClosingTabFromUI(aTab) {
+ if (this.tabCount == 2) {
+ this.close();
+ return;
+ }
+
+ this.updatePreviews();
+
+ if (this.selected.hidden) {
+ this.advanceFocus(false);
+ }
+ if (this.selected == this.showAllButton) {
+ this.advanceFocus(false);
+ }
+
+ // If the current tab is removed, another tab can steal our focus.
+ if (aTab.selected && this.panel.state == "open") {
+ setTimeout(
+ function (selected) {
+ selected.focus();
+ },
+ 0,
+ this.selected
+ );
+ }
+ },
+
+ handleEvent: function ctrlTab_handleEvent(event) {
+ switch (event.type) {
+ case "SSWindowRestored":
+ this._initRecentlyUsedTabs();
+ break;
+ case "TabAttrModified":
+ // tab attribute modified (i.e. label, busy, image)
+ // update preview only if tab attribute modified in the list
+ if (
+ event.detail.changed.some((elem, ind, arr) =>
+ ["label", "busy", "image"].includes(elem)
+ )
+ ) {
+ for (let i = this.previews.length - 1; i >= 0; i--) {
+ if (
+ this.previews[i]._tab &&
+ this.previews[i]._tab == event.target
+ ) {
+ this.updatePreview(this.previews[i], event.target);
+ break;
+ }
+ }
+ }
+ break;
+ case "TabSelect":
+ this.attachTab(event.target, 0);
+ // If the previous tab was hidden (e.g. Firefox View), remove it from
+ // the list when it's deselected.
+ let previousTab = event.detail.previousTab;
+ if (previousTab.hidden) {
+ this.detachTab(previousTab);
+ }
+ break;
+ case "TabOpen":
+ this.attachTab(event.target, 1);
+ break;
+ case "TabClose":
+ this.detachTab(event.target);
+ if (this.isOpen) {
+ this.removeClosingTabFromUI(event.target);
+ }
+ break;
+ case "TabHide":
+ this.detachTab(event.target);
+ break;
+ case "TabShow":
+ this.attachTab(event.target);
+ this._sortRecentlyUsedTabs();
+ break;
+ case "keydown":
+ this.onKeyDown(event);
+ break;
+ case "keypress":
+ this.onKeyPress(event);
+ break;
+ case "keyup":
+ // During cycling tabs, we avoid sending keyup event to content document.
+ event.preventDefault();
+ event.stopPropagation();
+
+ if (event.keyCode === event.DOM_VK_CONTROL) {
+ Services.els.removeSystemEventListener(
+ document,
+ "keyup",
+ this,
+ false
+ );
+
+ if (this.isOpen) {
+ this.pick();
+ }
+ }
+ break;
+ case "popupshowing":
+ if (event.target.id == "menu_viewPopup") {
+ document.getElementById("menu_showAllTabs").hidden =
+ !gTabsPanel.canOpen;
+ }
+ break;
+ case "mouseover":
+ this._mouseOverFocus(event.currentTarget);
+ break;
+ case "command":
+ this.pick(event.currentTarget);
+ break;
+ case "click":
+ if (event.button == 1) {
+ this.remove(event.currentTarget);
+ } else if (AppConstants.platform == "macosx" && event.button == 2) {
+ // Control+click is a right click on macOS, but in this case we want
+ // to handle it like a left click.
+ this.pick(event.currentTarget);
+ }
+ break;
+ }
+ },
+
+ filterForThumbnailExpiration(aCallback) {
+ // Save a few more thumbnails than we actually display, so that when tabs
+ // are closed, the previews we add instead still get thumbnails.
+ const extraThumbnails = 3;
+ const thumbnailCount = Math.min(
+ this.tabPreviewCount + extraThumbnails,
+ this.tabCount
+ );
+
+ let urls = [];
+ for (let i = 0; i < thumbnailCount; i++) {
+ urls.push(this.tabList[i].linkedBrowser.currentURI.spec);
+ }
+
+ aCallback(urls);
+ },
+ _sortRecentlyUsedTabs() {
+ this._recentlyUsedTabs.sort(
+ (tab1, tab2) => tab2.lastAccessed - tab1.lastAccessed
+ );
+ },
+ _initRecentlyUsedTabs() {
+ this._recentlyUsedTabs = Array.prototype.filter.call(
+ gBrowser.tabs,
+ tab => !tab.closing && !tab.hidden
+ );
+ this._sortRecentlyUsedTabs();
+ },
+
+ _init: function ctrlTab__init(enable) {
+ var toggleEventListener = enable
+ ? "addEventListener"
+ : "removeEventListener";
+
+ window[toggleEventListener]("SSWindowRestored", this);
+
+ var tabContainer = gBrowser.tabContainer;
+ tabContainer[toggleEventListener]("TabOpen", this);
+ tabContainer[toggleEventListener]("TabAttrModified", this);
+ tabContainer[toggleEventListener]("TabSelect", this);
+ tabContainer[toggleEventListener]("TabClose", this);
+ tabContainer[toggleEventListener]("TabHide", this);
+ tabContainer[toggleEventListener]("TabShow", this);
+
+ if (enable) {
+ Services.els.addSystemEventListener(document, "keydown", this, false);
+ } else {
+ Services.els.removeSystemEventListener(document, "keydown", this, false);
+ }
+ document[toggleEventListener]("keypress", this);
+ gBrowser.tabbox.handleCtrlTab = !enable;
+
+ if (enable) {
+ PageThumbs.addExpirationFilter(this);
+ } else {
+ PageThumbs.removeExpirationFilter(this);
+ }
+
+ // If we're not running, hide the "Show All Tabs" menu item,
+ // as Shift+Ctrl+Tab will be handled by the tab bar.
+ document.getElementById("menu_showAllTabs").hidden = !enable;
+ document
+ .getElementById("menu_viewPopup")
+ [toggleEventListener]("popupshowing", this);
+ },
+};
diff --git a/browser/base/content/browser-customization.js b/browser/base/content/browser-customization.js
new file mode 100644
index 0000000000..624a98f70d
--- /dev/null
+++ b/browser/base/content/browser-customization.js
@@ -0,0 +1,181 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+ * 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/. */
+
+// This file is loaded into the browser window scope.
+/* eslint-env mozilla/browser-window */
+
+/**
+ * Customization handler prepares this browser window for entering and exiting
+ * customization mode by handling customizationstarting and aftercustomization
+ * events.
+ */
+var CustomizationHandler = {
+ handleEvent(aEvent) {
+ switch (aEvent.type) {
+ case "customizationstarting":
+ this._customizationStarting();
+ break;
+ case "aftercustomization":
+ this._afterCustomization();
+ break;
+ }
+ },
+
+ isCustomizing() {
+ return document.documentElement.hasAttribute("customizing");
+ },
+
+ _customizationStarting() {
+ // Disable the toolbar context menu items
+ let menubar = document.getElementById("main-menubar");
+ for (let childNode of menubar.children) {
+ childNode.setAttribute("disabled", true);
+ }
+
+ UpdateUrlbarSearchSplitterState();
+
+ PlacesToolbarHelper.customizeStart();
+ },
+
+ _afterCustomization() {
+ // Update global UI elements that may have been added or removed
+ if (AppConstants.platform != "macosx") {
+ updateEditUIVisibility();
+ }
+
+ PlacesToolbarHelper.customizeDone();
+
+ XULBrowserWindow.asyncUpdateUI();
+ // Re-enable parts of the UI we disabled during the dialog
+ let menubar = document.getElementById("main-menubar");
+ for (let childNode of menubar.children) {
+ childNode.setAttribute("disabled", false);
+ }
+
+ gBrowser.selectedBrowser.focus();
+
+ // Update the urlbar
+ gURLBar.setURI();
+ UpdateUrlbarSearchSplitterState();
+ },
+};
+
+var AutoHideMenubar = {
+ get _node() {
+ delete this._node;
+ return (this._node = document.getElementById("toolbar-menubar"));
+ },
+
+ _contextMenuListener: {
+ contextMenu: null,
+
+ get active() {
+ return !!this.contextMenu;
+ },
+
+ init(event) {
+ // Ignore mousedowns in <menupopup>s.
+ if (event.target.closest("menupopup")) {
+ return;
+ }
+
+ let contextMenuId = AutoHideMenubar._node.getAttribute("context");
+ this.contextMenu = document.getElementById(contextMenuId);
+ this.contextMenu.addEventListener("popupshown", this);
+ this.contextMenu.addEventListener("popuphiding", this);
+ AutoHideMenubar._node.addEventListener("mousemove", this);
+ },
+ handleEvent(event) {
+ switch (event.type) {
+ case "popupshown":
+ AutoHideMenubar._node.removeEventListener("mousemove", this);
+ break;
+ case "popuphiding":
+ case "mousemove":
+ AutoHideMenubar._setInactiveAsync();
+ AutoHideMenubar._node.removeEventListener("mousemove", this);
+ this.contextMenu.removeEventListener("popuphiding", this);
+ this.contextMenu.removeEventListener("popupshown", this);
+ this.contextMenu = null;
+ break;
+ }
+ },
+ },
+
+ init() {
+ this._node.addEventListener("toolbarvisibilitychange", this);
+ if (this._node.getAttribute("autohide") == "true") {
+ this._enable();
+ }
+ },
+
+ _updateState() {
+ if (this._node.getAttribute("autohide") == "true") {
+ this._enable();
+ } else {
+ this._disable();
+ }
+ },
+
+ _events: [
+ "DOMMenuBarInactive",
+ "DOMMenuBarActive",
+ "popupshowing",
+ "mousedown",
+ ],
+ _enable() {
+ this._node.setAttribute("inactive", "true");
+ for (let event of this._events) {
+ this._node.addEventListener(event, this);
+ }
+ },
+
+ _disable() {
+ this._setActive();
+ for (let event of this._events) {
+ this._node.removeEventListener(event, this);
+ }
+ },
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "toolbarvisibilitychange":
+ this._updateState();
+ break;
+ case "popupshowing":
+ // fall through
+ case "DOMMenuBarActive":
+ this._setActive();
+ break;
+ case "mousedown":
+ if (event.button == 2) {
+ this._contextMenuListener.init(event);
+ }
+ break;
+ case "DOMMenuBarInactive":
+ if (!this._contextMenuListener.active) {
+ this._setInactiveAsync();
+ }
+ break;
+ }
+ },
+
+ _setInactiveAsync() {
+ this._inactiveTimeout = setTimeout(() => {
+ if (this._node.getAttribute("autohide") == "true") {
+ this._inactiveTimeout = null;
+ this._node.setAttribute("inactive", "true");
+ }
+ }, 0);
+ },
+
+ _setActive() {
+ if (this._inactiveTimeout) {
+ clearTimeout(this._inactiveTimeout);
+ this._inactiveTimeout = null;
+ }
+ this._node.removeAttribute("inactive");
+ },
+};
diff --git a/browser/base/content/browser-data-submission-info-bar.js b/browser/base/content/browser-data-submission-info-bar.js
new file mode 100644
index 0000000000..896542796c
--- /dev/null
+++ b/browser/base/content/browser-data-submission-info-bar.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/. */
+
+/**
+ * Represents an info bar that shows a data submission notification.
+ */
+var gDataNotificationInfoBar = {
+ _OBSERVERS: [
+ "datareporting:notify-data-policy:request",
+ "datareporting:notify-data-policy:close",
+ ],
+
+ _DATA_REPORTING_NOTIFICATION: "data-reporting",
+
+ get _log() {
+ let { Log } = ChromeUtils.importESModule(
+ "resource://gre/modules/Log.sys.mjs"
+ );
+ delete this._log;
+ return (this._log = Log.repository.getLoggerWithMessagePrefix(
+ "Toolkit.Telemetry",
+ "DataNotificationInfoBar::"
+ ));
+ },
+
+ init() {
+ window.addEventListener("unload", () => {
+ for (let o of this._OBSERVERS) {
+ Services.obs.removeObserver(this, o);
+ }
+ });
+
+ for (let o of this._OBSERVERS) {
+ Services.obs.addObserver(this, o, true);
+ }
+ },
+
+ _getDataReportingNotification(name = this._DATA_REPORTING_NOTIFICATION) {
+ return gNotificationBox.getNotificationWithValue(name);
+ },
+
+ _displayDataPolicyInfoBar(request) {
+ if (this._getDataReportingNotification()) {
+ return;
+ }
+
+ this._actionTaken = false;
+
+ let buttons = [
+ {
+ "l10n-id": "data-reporting-notification-button",
+ popup: null,
+ callback: () => {
+ this._actionTaken = true;
+ window.openPreferences("privacy-reports");
+ },
+ },
+ ];
+
+ this._log.info("Creating data reporting policy notification.");
+ gNotificationBox.appendNotification(
+ this._DATA_REPORTING_NOTIFICATION,
+ {
+ label: {
+ "l10n-id": "data-reporting-notification-message",
+ },
+ priority: gNotificationBox.PRIORITY_INFO_HIGH,
+ eventCallback: event => {
+ if (event == "removed") {
+ Services.obs.notifyObservers(
+ null,
+ "datareporting:notify-data-policy:close"
+ );
+ }
+ },
+ },
+ buttons
+ );
+ // It is important to defer calling onUserNotifyComplete() until we're
+ // actually sure the notification was displayed. If we ever called
+ // onUserNotifyComplete() without showing anything to the user, that
+ // would be very good for user choice. It may also have legal impact.
+ request.onUserNotifyComplete();
+ },
+
+ _clearPolicyNotification() {
+ let notification = this._getDataReportingNotification();
+ if (notification) {
+ this._log.debug("Closing notification.");
+ notification.close();
+ }
+ },
+
+ observe(subject, topic, data) {
+ switch (topic) {
+ case "datareporting:notify-data-policy:request":
+ let request = subject.wrappedJSObject.object;
+ try {
+ this._displayDataPolicyInfoBar(request);
+ } catch (ex) {
+ request.onUserNotifyFailed(ex);
+ }
+ break;
+
+ case "datareporting:notify-data-policy:close":
+ // If this observer fires, it means something else took care of
+ // responding. Therefore, we don't need to do anything. So, we
+ // act like we took action and clear state.
+ this._actionTaken = true;
+ this._clearPolicyNotification();
+ break;
+
+ default:
+ }
+ },
+
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIObserver",
+ "nsISupportsWeakReference",
+ ]),
+};
diff --git a/browser/base/content/browser-development-helpers.js b/browser/base/content/browser-development-helpers.js
new file mode 100644
index 0000000000..5155b280b8
--- /dev/null
+++ b/browser/base/content/browser-development-helpers.js
@@ -0,0 +1,45 @@
+/* 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/. */
+
+/**
+ * Extra features for local development. This file isn't loaded in
+ * non-local builds.
+ */
+
+var DevelopmentHelpers = {
+ init() {
+ this.quickRestart = this.quickRestart.bind(this);
+ this.addRestartShortcut();
+ },
+
+ quickRestart() {
+ Services.obs.notifyObservers(null, "startupcache-invalidate");
+ Services.env.set("MOZ_DISABLE_SAFE_MODE_KEY", "1");
+
+ Services.startup.quit(
+ Ci.nsIAppStartup.eAttemptQuit | Ci.nsIAppStartup.eRestart
+ );
+ },
+
+ addRestartShortcut() {
+ let command = document.createXULElement("command");
+ command.setAttribute("id", "cmd_quickRestart");
+ command.addEventListener("command", this.quickRestart, true);
+ document.getElementById("mainCommandSet").prepend(command);
+
+ let key = document.createXULElement("key");
+ key.setAttribute("id", "key_quickRestart");
+ key.setAttribute("key", "r");
+ key.setAttribute("modifiers", "accel,alt");
+ key.setAttribute("command", "cmd_quickRestart");
+ document.getElementById("mainKeyset").prepend(key);
+
+ let menuitem = document.createXULElement("menuitem");
+ menuitem.setAttribute("id", "menu_FileRestartItem");
+ menuitem.setAttribute("key", "key_quickRestart");
+ menuitem.setAttribute("label", "Restart (Developer)");
+ menuitem.addEventListener("command", this.quickRestart, true);
+ document.getElementById("menu_FilePopup").appendChild(menuitem);
+ },
+};
diff --git a/browser/base/content/browser-fullScreenAndPointerLock.js b/browser/base/content/browser-fullScreenAndPointerLock.js
new file mode 100644
index 0000000000..29183138c7
--- /dev/null
+++ b/browser/base/content/browser-fullScreenAndPointerLock.js
@@ -0,0 +1,967 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+ * 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/. */
+
+// This file is loaded into the browser window scope.
+/* eslint-env mozilla/browser-window */
+
+var PointerlockFsWarning = {
+ _element: null,
+ _origin: null,
+
+ /**
+ * Timeout object for managing timeout request. If it is started when
+ * the previous call hasn't finished, it would automatically cancelled
+ * the previous one.
+ */
+ Timeout: class {
+ constructor(func, delay) {
+ this._id = 0;
+ this._func = func;
+ this._delay = delay;
+ }
+ start() {
+ this.cancel();
+ this._id = setTimeout(() => this._handle(), this._delay);
+ }
+ cancel() {
+ if (this._id) {
+ clearTimeout(this._id);
+ this._id = 0;
+ }
+ }
+ _handle() {
+ this._id = 0;
+ this._func();
+ }
+ get delay() {
+ return this._delay;
+ }
+ },
+
+ showPointerLock(aOrigin) {
+ if (!document.fullscreen) {
+ let timeout = Services.prefs.getIntPref(
+ "pointer-lock-api.warning.timeout"
+ );
+ this.show(aOrigin, "pointerlock-warning", timeout, 0);
+ }
+ },
+
+ showFullScreen(aOrigin) {
+ let timeout = Services.prefs.getIntPref("full-screen-api.warning.timeout");
+ let delay = Services.prefs.getIntPref("full-screen-api.warning.delay");
+ this.show(aOrigin, "fullscreen-warning", timeout, delay);
+ },
+
+ // Shows a warning that the site has entered fullscreen or
+ // pointer lock for a short duration.
+ show(aOrigin, elementId, timeout, delay) {
+ if (!this._element) {
+ this._element = document.getElementById(elementId);
+ // Setup event listeners
+ this._element.addEventListener("transitionend", this);
+ this._element.addEventListener("transitioncancel", this);
+ window.addEventListener("mousemove", this, true);
+ // If the user explicitly disables the prompt, there's no need to detect
+ // activation.
+ if (timeout > 0) {
+ window.addEventListener("activate", this);
+ window.addEventListener("deactivate", this);
+ }
+ // The timeout to hide the warning box after a while.
+ this._timeoutHide = new this.Timeout(() => {
+ window.removeEventListener("activate", this);
+ window.removeEventListener("deactivate", this);
+ this._state = "hidden";
+ }, timeout);
+ // The timeout to show the warning box when the pointer is at the top
+ this._timeoutShow = new this.Timeout(() => {
+ this._state = "ontop";
+ this._timeoutHide.start();
+ }, delay);
+ }
+
+ // Set the strings on the warning UI.
+ if (aOrigin) {
+ this._origin = aOrigin;
+ }
+ let uri = Services.io.newURI(this._origin);
+ let host = null;
+ // Make an exception for PDF.js - we'll show "This document" instead.
+ if (this._origin != "resource://pdf.js") {
+ try {
+ host = uri.host;
+ } catch (e) {}
+ }
+ let textElem = this._element.querySelector(
+ ".pointerlockfswarning-domain-text"
+ );
+ if (!host) {
+ textElem.hidden = true;
+ } else {
+ textElem.removeAttribute("hidden");
+ // Document's principal's URI has a host. Display a warning including it.
+ let { DownloadUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/DownloadUtils.sys.mjs"
+ );
+ let displayHost = DownloadUtils.getURIHost(uri.spec)[0];
+ let l10nString = {
+ "fullscreen-warning": "fullscreen-warning-domain",
+ "pointerlock-warning": "pointerlock-warning-domain",
+ }[elementId];
+ document.l10n.setAttributes(textElem, l10nString, {
+ domain: displayHost,
+ });
+ }
+
+ this._element.dataset.identity =
+ gIdentityHandler.pointerlockFsWarningClassName;
+
+ // User should be allowed to explicitly disable
+ // the prompt if they really want.
+ if (this._timeoutHide.delay <= 0) {
+ return;
+ }
+
+ if (Services.focus.activeWindow == window) {
+ this._state = "onscreen";
+ this._timeoutHide.start();
+ }
+ },
+
+ /**
+ * Close the full screen or pointerlock warning.
+ * @param {('fullscreen-warning'|'pointerlock-warning')} elementId - Id of the
+ * warning element to close. If the id does not match the currently shown
+ * warning this is a no-op.
+ */
+ close(elementId) {
+ if (!elementId) {
+ throw new Error("Must pass id of warning element to close");
+ }
+ if (!this._element || this._element.id != elementId) {
+ return;
+ }
+ // Cancel any pending timeout
+ this._timeoutHide.cancel();
+ this._timeoutShow.cancel();
+ // Reset state of the warning box
+ this._state = "hidden";
+ // Reset state of the text so we don't persist or retranslate it.
+ this._element
+ .querySelector(".pointerlockfswarning-domain-text")
+ .removeAttribute("data-l10n-id");
+ this._element.hidden = true;
+ // Remove all event listeners
+ this._element.removeEventListener("transitionend", this);
+ this._element.removeEventListener("transitioncancel", this);
+ window.removeEventListener("mousemove", this, true);
+ window.removeEventListener("activate", this);
+ window.removeEventListener("deactivate", this);
+ // Clear fields
+ this._element = null;
+ this._timeoutHide = null;
+ this._timeoutShow = null;
+
+ // Ensure focus switches away from the (now hidden) warning box.
+ // If the user clicked buttons in the warning box, it would have
+ // been focused, and any key events would be directed at the (now
+ // hidden) chrome document instead of the target document.
+ gBrowser.selectedBrowser.focus();
+ },
+
+ // State could be one of "onscreen", "ontop", "hiding", and
+ // "hidden". Setting the state to "onscreen" and "ontop" takes
+ // effect immediately, while setting it to "hidden" actually
+ // turns the state to "hiding" before the transition finishes.
+ _lastState: null,
+ _STATES: ["hidden", "ontop", "onscreen"],
+ get _state() {
+ for (let state of this._STATES) {
+ if (this._element.hasAttribute(state)) {
+ return state;
+ }
+ }
+ return "hiding";
+ },
+ set _state(newState) {
+ let currentState = this._state;
+ if (currentState == newState) {
+ return;
+ }
+ if (currentState != "hiding") {
+ this._lastState = currentState;
+ this._element.removeAttribute(currentState);
+ }
+ if (newState != "hidden") {
+ if (currentState != "hidden") {
+ this._element.setAttribute(newState, "");
+ } else {
+ // When the previous state is hidden, the display was none,
+ // thus no box was constructed. We need to wait for the new
+ // display value taking effect first, otherwise, there won't
+ // be any transition. Since requestAnimationFrame callback is
+ // generally triggered before any style flush and layout, we
+ // should wait for the second animation frame.
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ if (this._element) {
+ this._element.setAttribute(newState, "");
+ }
+ });
+ });
+ }
+ }
+ },
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "mousemove": {
+ let state = this._state;
+ if (state == "hidden") {
+ // If the warning box is currently hidden, show it after
+ // a short delay if the pointer is at the top.
+ if (event.clientY != 0) {
+ this._timeoutShow.cancel();
+ } else if (this._timeoutShow.delay >= 0) {
+ this._timeoutShow.start();
+ }
+ } else if (state != "onscreen") {
+ let elemRect = this._element.getBoundingClientRect();
+ if (state == "hiding" && this._lastState != "hidden") {
+ // If we are on the hiding transition, and the pointer
+ // moved near the box, restore to the previous state.
+ if (event.clientY <= elemRect.bottom + 50) {
+ this._state = this._lastState;
+ this._timeoutHide.start();
+ }
+ } else if (state == "ontop" || this._lastState != "hidden") {
+ // State being "ontop" or the previous state not being
+ // "hidden" indicates this current warning box is shown
+ // in response to user's action. Hide it immediately when
+ // the pointer leaves that area.
+ if (event.clientY > elemRect.bottom + 50) {
+ this._state = "hidden";
+ this._timeoutHide.cancel();
+ }
+ }
+ }
+ break;
+ }
+ case "transitionend":
+ case "transitioncancel": {
+ if (this._state == "hiding") {
+ this._element.hidden = true;
+ }
+ break;
+ }
+ case "activate": {
+ this._state = "onscreen";
+ this._timeoutHide.start();
+ break;
+ }
+ case "deactivate": {
+ this._state = "hidden";
+ this._timeoutHide.cancel();
+ break;
+ }
+ }
+ },
+};
+
+var PointerLock = {
+ entered(originNoSuffix) {
+ PointerlockFsWarning.showPointerLock(originNoSuffix);
+ },
+
+ exited() {
+ PointerlockFsWarning.close("pointerlock-warning");
+ },
+};
+
+var FullScreen = {
+ init() {
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "permissionsFullScreenAllowed",
+ "permissions.fullscreen.allowed"
+ );
+
+ // Called when the Firefox window go into fullscreen.
+ addEventListener("fullscreen", this, true);
+
+ // Called only when fullscreen is requested
+ // by the parent (eg: via the browser-menu).
+ // Should not be called when the request comes from
+ // the content.
+ addEventListener("willenterfullscreen", this, true);
+ addEventListener("willexitfullscreen", this, true);
+ addEventListener("MacFullscreenMenubarRevealUpdate", this, true);
+
+ if (window.fullScreen) {
+ this.toggle();
+ }
+ },
+
+ uninit() {
+ this.cleanup();
+ },
+
+ willToggle(aWillEnterFullscreen) {
+ if (aWillEnterFullscreen) {
+ document.documentElement.setAttribute("inFullscreen", true);
+ } else {
+ document.documentElement.removeAttribute("inFullscreen");
+ }
+ },
+
+ get fullScreenToggler() {
+ delete this.fullScreenToggler;
+ return (this.fullScreenToggler =
+ document.getElementById("fullscr-toggler"));
+ },
+
+ toggle() {
+ var enterFS = window.fullScreen;
+
+ // Toggle the View:FullScreen command, which controls elements like the
+ // fullscreen menuitem, and menubars.
+ let fullscreenCommand = document.getElementById("View:FullScreen");
+ if (enterFS) {
+ fullscreenCommand.setAttribute("checked", enterFS);
+ } else {
+ fullscreenCommand.removeAttribute("checked");
+ }
+
+ if (AppConstants.platform == "macosx") {
+ // Make sure the menu items are adjusted.
+ document.getElementById("enterFullScreenItem").hidden = enterFS;
+ document.getElementById("exitFullScreenItem").hidden = !enterFS;
+ this.shiftMacToolbarDown(0);
+ }
+
+ let fstoggler = this.fullScreenToggler;
+ fstoggler.addEventListener("mouseover", this._expandCallback);
+ fstoggler.addEventListener("dragenter", this._expandCallback);
+ fstoggler.addEventListener("touchmove", this._expandCallback, {
+ passive: true,
+ });
+
+ if (enterFS) {
+ gNavToolbox.setAttribute("inFullscreen", true);
+ document.documentElement.setAttribute("inFullscreen", true);
+ let alwaysUsesNativeFullscreen =
+ AppConstants.platform == "macosx" &&
+ Services.prefs.getBoolPref("full-screen-api.macos-native-full-screen");
+ if (
+ (alwaysUsesNativeFullscreen || !document.fullscreenElement) &&
+ AppConstants.platform == "macosx"
+ ) {
+ document.documentElement.setAttribute("macOSNativeFullscreen", true);
+ }
+ } else {
+ gNavToolbox.removeAttribute("inFullscreen");
+ document.documentElement.removeAttribute("inFullscreen");
+ document.documentElement.removeAttribute("macOSNativeFullscreen");
+ }
+
+ if (!document.fullscreenElement) {
+ this._updateToolbars(enterFS);
+ }
+
+ if (enterFS) {
+ document.addEventListener("keypress", this._keyToggleCallback);
+ document.addEventListener("popupshown", this._setPopupOpen);
+ document.addEventListener("popuphidden", this._setPopupOpen);
+ gURLBar.controller.addQueryListener(this);
+
+ // In DOM fullscreen mode, we hide toolbars with CSS
+ if (!document.fullscreenElement) {
+ this.hideNavToolbox(true);
+ }
+ } else {
+ this.showNavToolbox(false);
+ // This is needed if they use the context menu to quit fullscreen
+ this._isPopupOpen = false;
+ this.cleanup();
+ }
+ this._toggleShortcutKeys();
+ },
+
+ exitDomFullScreen() {
+ if (document.fullscreen) {
+ document.exitFullscreen();
+ }
+ },
+
+ /**
+ * Shifts the browser toolbar down when it is moused over on macOS in
+ * fullscreen.
+ * @param {number} shiftSize
+ * A distance, in pixels, by which to shift the browser toolbar down.
+ */
+ shiftMacToolbarDown(shiftSize) {
+ if (typeof shiftSize !== "number") {
+ console.error("Tried to shift the toolbar by a non-numeric distance.");
+ return;
+ }
+
+ // shiftSize is sent from Cocoa widget code as a very precise double. We
+ // don't need that kind of precision in our CSS.
+ shiftSize = shiftSize.toFixed(2);
+ let toolbox = document.getElementById("navigator-toolbox");
+ if (shiftSize > 0) {
+ toolbox.style.setProperty("transform", `translateY(${shiftSize}px)`);
+ toolbox.style.setProperty("z-index", "2");
+ } else {
+ toolbox.style.removeProperty("transform");
+ toolbox.style.removeProperty("z-index");
+ }
+ },
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "willenterfullscreen":
+ this.willToggle(true);
+ break;
+ case "willexitfullscreen":
+ this.willToggle(false);
+ break;
+ case "fullscreen":
+ this.toggle();
+ break;
+ case "MacFullscreenMenubarRevealUpdate":
+ this.shiftMacToolbarDown(event.detail);
+ break;
+ }
+ },
+
+ _logWarningPermissionPromptFS(actionStringKey) {
+ let consoleMsg = Cc["@mozilla.org/scripterror;1"].createInstance(
+ Ci.nsIScriptError
+ );
+ let message = gBrowserBundle.GetStringFromName(
+ `permissions.fullscreen.${actionStringKey}`
+ );
+ consoleMsg.initWithWindowID(
+ message,
+ gBrowser.currentURI.spec,
+ null,
+ 0,
+ 0,
+ Ci.nsIScriptError.warningFlag,
+ "FullScreen",
+ gBrowser.selectedBrowser.innerWindowID
+ );
+ Services.console.logMessage(consoleMsg);
+ },
+
+ _handlePermPromptShow() {
+ if (
+ !FullScreen.permissionsFullScreenAllowed &&
+ window.fullScreen &&
+ PopupNotifications.getNotification(
+ this._permissionNotificationIDs
+ ).filter(n => !n.dismissed).length
+ ) {
+ this.exitDomFullScreen();
+ this._logWarningPermissionPromptFS("fullScreenCanceled");
+ }
+ },
+
+ enterDomFullscreen(aBrowser, aActor) {
+ if (!document.fullscreenElement) {
+ aActor.requestOrigin = null;
+ return;
+ }
+
+ // If we have a current pointerlock warning shown then hide it
+ // before transition.
+ PointerlockFsWarning.close("pointerlock-warning");
+
+ // If it is a remote browser, send a message to ask the content
+ // to enter fullscreen state. We don't need to do so if it is an
+ // in-process browser, since all related document should have
+ // entered fullscreen state at this point.
+ // Additionally, in Fission world, we may need to notify the
+ // frames in the middle (content frames that embbed the oop iframe where
+ // the element requesting fullscreen lives) to enter fullscreen
+ // first.
+ // This should be done before the active tab check below to ensure
+ // that the content document handles the pending request. Doing so
+ // before the check is fine since we also check the activeness of
+ // the requesting document in content-side handling code.
+ if (this._isRemoteBrowser(aBrowser)) {
+ // The cached message recipient in actor is used for fullscreen state
+ // cleanup, we should not use it while entering fullscreen.
+ let [targetActor, inProcessBC] = this._getNextMsgRecipientActor(
+ aActor,
+ false /* aUseCache */
+ );
+ if (!targetActor) {
+ // If there is no appropriate actor to send the message we have
+ // no way to complete the transition and should abort by exiting
+ // fullscreen.
+ this._abortEnterFullscreen();
+ return;
+ }
+ // Record that the actor is waiting for its child to enter
+ // fullscreen so that if it dies we can abort.
+ targetActor.waitingForChildEnterFullscreen = true;
+ targetActor.sendAsyncMessage("DOMFullscreen:Entered", {
+ remoteFrameBC: inProcessBC,
+ });
+
+ if (inProcessBC) {
+ // We aren't messaging the request origin yet, skip this time.
+ return;
+ }
+ }
+
+ // If we've received a fullscreen notification, we have to ensure that the
+ // element that's requesting fullscreen belongs to the browser that's currently
+ // active. If not, we exit fullscreen since the "full-screen document" isn't
+ // actually visible now.
+ if (
+ !aBrowser ||
+ gBrowser.selectedBrowser != aBrowser ||
+ // The top-level window has lost focus since the request to enter
+ // full-screen was made. Cancel full-screen.
+ Services.focus.activeWindow != window
+ ) {
+ this._abortEnterFullscreen();
+ return;
+ }
+
+ // Remove permission prompts when entering full-screen.
+ if (!FullScreen.permissionsFullScreenAllowed) {
+ let notifications = PopupNotifications.getNotification(
+ this._permissionNotificationIDs
+ ).filter(n => !n.dismissed);
+ PopupNotifications.remove(notifications, true);
+ if (notifications.length) {
+ this._logWarningPermissionPromptFS("promptCanceled");
+ }
+ }
+ document.documentElement.setAttribute("inDOMFullscreen", true);
+
+ if (gFindBarInitialized) {
+ gFindBar.close(true);
+ }
+
+ // Exit DOM full-screen mode when switching to a different tab.
+ gBrowser.tabContainer.addEventListener("TabSelect", this.exitDomFullScreen);
+
+ // Addon installation should be cancelled when entering DOM fullscreen for security and usability reasons.
+ // Installation prompts in fullscreen can trick the user into installing unwanted addons.
+ // In fullscreen the notification box does not have a clear visual association with its parent anymore.
+ if (gXPInstallObserver.removeAllNotifications(aBrowser)) {
+ // If notifications have been removed, log a warning to the website console
+ gXPInstallObserver.logWarningFullScreenInstallBlocked();
+ }
+
+ PopupNotifications.panel.addEventListener(
+ "popupshowing",
+ () => this._handlePermPromptShow(),
+ true
+ );
+ },
+
+ cleanup() {
+ if (!window.fullScreen) {
+ MousePosTracker.removeListener(this);
+ document.removeEventListener("keypress", this._keyToggleCallback);
+ document.removeEventListener("popupshown", this._setPopupOpen);
+ document.removeEventListener("popuphidden", this._setPopupOpen);
+ gURLBar.controller.removeQueryListener(this);
+ }
+ },
+
+ _toggleShortcutKeys() {
+ const kEnterKeyIds = [
+ "key_enterFullScreen",
+ "key_enterFullScreen_old",
+ "key_enterFullScreen_compat",
+ ];
+ const kExitKeyIds = [
+ "key_exitFullScreen",
+ "key_exitFullScreen_old",
+ "key_exitFullScreen_compat",
+ ];
+ for (let id of window.fullScreen ? kEnterKeyIds : kExitKeyIds) {
+ document.getElementById(id)?.setAttribute("disabled", "true");
+ }
+ for (let id of window.fullScreen ? kExitKeyIds : kEnterKeyIds) {
+ document.getElementById(id)?.removeAttribute("disabled");
+ }
+ },
+
+ /**
+ * Clean up full screen, starting from the request origin's first ancestor
+ * frame that is OOP.
+ *
+ * If there are OOP ancestor frames, we notify the first of those and then bail to
+ * be called again in that process when it has dealt with the change. This is
+ * repeated until all ancestor processes have been updated. Once that has happened
+ * we remove our handlers and attributes and notify the request origin to complete
+ * the cleanup.
+ */
+ cleanupDomFullscreen(aActor) {
+ let needToWaitForChildExit = false;
+ // Use the message recipient cached in the actor if possible, especially for
+ // the case that actor is destroyed, which we are unable to find it by
+ // walking up the browsing context tree.
+ let [target, inProcessBC] = this._getNextMsgRecipientActor(
+ aActor,
+ true /* aUseCache */
+ );
+ if (target) {
+ needToWaitForChildExit = true;
+ // Record that the actor is waiting for its child to exit fullscreen so
+ // that if it dies we can continue cleanup.
+ target.waitingForChildExitFullscreen = true;
+ target.sendAsyncMessage("DOMFullscreen:CleanUp", {
+ remoteFrameBC: inProcessBC,
+ });
+ if (inProcessBC) {
+ return needToWaitForChildExit;
+ }
+ }
+
+ PopupNotifications.panel.removeEventListener(
+ "popupshowing",
+ () => this._handlePermPromptShow(),
+ true
+ );
+
+ PointerlockFsWarning.close("fullscreen-warning");
+ gBrowser.tabContainer.removeEventListener(
+ "TabSelect",
+ this.exitDomFullScreen
+ );
+
+ document.documentElement.removeAttribute("inDOMFullscreen");
+
+ return needToWaitForChildExit;
+ },
+
+ _abortEnterFullscreen() {
+ // This function is called synchronously in fullscreen change, so
+ // we have to avoid calling exitFullscreen synchronously here.
+ //
+ // This could reject if we're not currently in fullscreen
+ // so just ignore rejection.
+ setTimeout(() => document.exitFullscreen().catch(() => {}), 0);
+ if (TelemetryStopwatch.running("FULLSCREEN_CHANGE_MS")) {
+ // Cancel the stopwatch for any fullscreen change to avoid
+ // errors if it is started again.
+ TelemetryStopwatch.cancel("FULLSCREEN_CHANGE_MS");
+ }
+ },
+
+ /**
+ * Search for the first ancestor of aActor that lives in a different process.
+ * If found, that ancestor actor and the browsing context for its child which
+ * was in process are returned. Otherwise [request origin, null].
+ *
+ *
+ * @param {JSWindowActorParent} aActor
+ * The actor that called this function.
+ * @param {bool} aUseCache
+ * Use the recipient cached in the aActor if available.
+ *
+ * @return {[JSWindowActorParent, BrowsingContext]}
+ * The parent actor which should be sent the next msg and the
+ * in process browsing context which is its child. Will be
+ * [null, null] if there is no OOP parent actor and request origin
+ * is unset. [null, null] is also returned if the intended actor or
+ * the calling actor has been destroyed or its associated
+ * WindowContext is in BFCache.
+ */
+ _getNextMsgRecipientActor(aActor, aUseCache) {
+ // Walk up the cached nextMsgRecipient to find the next available actor if
+ // any.
+ if (aUseCache && aActor.nextMsgRecipient) {
+ let nextMsgRecipient = aActor.nextMsgRecipient;
+ while (nextMsgRecipient) {
+ let [actor] = nextMsgRecipient;
+ if (
+ !actor.hasBeenDestroyed() &&
+ actor.windowContext &&
+ !actor.windowContext.isInBFCache
+ ) {
+ return nextMsgRecipient;
+ }
+ nextMsgRecipient = actor.nextMsgRecipient;
+ }
+ }
+
+ if (aActor.hasBeenDestroyed()) {
+ return [null, null];
+ }
+
+ let childBC = aActor.browsingContext;
+ let parentBC = childBC.parent;
+
+ // Walk up the browsing context tree from aActor's browsing context
+ // to find the first ancestor browsing context that's in a different process.
+ while (parentBC) {
+ if (!childBC.currentWindowGlobal || !parentBC.currentWindowGlobal) {
+ break;
+ }
+ let childPid = childBC.currentWindowGlobal.osPid;
+ let parentPid = parentBC.currentWindowGlobal.osPid;
+
+ if (childPid == parentPid) {
+ childBC = parentBC;
+ parentBC = childBC.parent;
+ } else {
+ break;
+ }
+ }
+
+ let target = null;
+ let inProcessBC = null;
+
+ if (parentBC && parentBC.currentWindowGlobal) {
+ target = parentBC.currentWindowGlobal.getActor("DOMFullscreen");
+ inProcessBC = childBC;
+ aActor.nextMsgRecipient = [target, inProcessBC];
+ } else {
+ target = aActor.requestOrigin;
+ }
+
+ if (
+ !target ||
+ target.hasBeenDestroyed() ||
+ target.windowContext?.isInBFCache
+ ) {
+ return [null, null];
+ }
+ return [target, inProcessBC];
+ },
+
+ _isRemoteBrowser(aBrowser) {
+ return gMultiProcessBrowser && aBrowser.getAttribute("remote") == "true";
+ },
+
+ getMouseTargetRect() {
+ return this._mouseTargetRect;
+ },
+
+ // Event callbacks
+ _expandCallback() {
+ FullScreen.showNavToolbox();
+ },
+
+ onMouseEnter() {
+ this.hideNavToolbox();
+ },
+
+ _keyToggleCallback(aEvent) {
+ // if we can use the keyboard (eg Ctrl+L or Ctrl+E) to open the toolbars, we
+ // should provide a way to collapse them too.
+ if (aEvent.keyCode == aEvent.DOM_VK_ESCAPE) {
+ FullScreen.hideNavToolbox();
+ } else if (aEvent.keyCode == aEvent.DOM_VK_F6) {
+ // F6 is another shortcut to the address bar, but its not covered in OpenLocation()
+ FullScreen.showNavToolbox();
+ }
+ },
+
+ // Checks whether we are allowed to collapse the chrome
+ _isPopupOpen: false,
+ _isChromeCollapsed: false,
+
+ _setPopupOpen(aEvent) {
+ // Popups should only veto chrome collapsing if they were opened when the chrome was not collapsed.
+ // Otherwise, they would not affect chrome and the user would expect the chrome to go away.
+ // e.g. we wouldn't want the autoscroll icon firing this event, so when the user
+ // toggles chrome when moving mouse to the top, it doesn't go away again.
+ let target = aEvent.originalTarget;
+ if (target.localName == "tooltip") {
+ return;
+ }
+ if (
+ aEvent.type == "popupshown" &&
+ !FullScreen._isChromeCollapsed &&
+ target.getAttribute("nopreventnavboxhide") != "true"
+ ) {
+ FullScreen._isPopupOpen = true;
+ } else if (aEvent.type == "popuphidden") {
+ FullScreen._isPopupOpen = false;
+ // Try again to hide toolbar when we close the popup.
+ FullScreen.hideNavToolbox(true);
+ }
+ },
+
+ // UrlbarController listener method
+ onViewOpen() {
+ if (!this._isChromeCollapsed) {
+ this._isPopupOpen = true;
+ }
+ },
+
+ // UrlbarController listener method
+ onViewClose() {
+ this._isPopupOpen = false;
+ this.hideNavToolbox(true);
+ },
+
+ get navToolboxHidden() {
+ return this._isChromeCollapsed;
+ },
+
+ // Autohide helpers for the context menu item
+ updateAutohideMenuitem(aItem) {
+ aItem.setAttribute(
+ "checked",
+ Services.prefs.getBoolPref("browser.fullscreen.autohide")
+ );
+ },
+ setAutohide() {
+ Services.prefs.setBoolPref(
+ "browser.fullscreen.autohide",
+ !Services.prefs.getBoolPref("browser.fullscreen.autohide")
+ );
+ // Try again to hide toolbar when we change the pref.
+ FullScreen.hideNavToolbox(true);
+ },
+
+ showNavToolbox(trackMouse = true) {
+ if (BrowserHandler.kiosk) {
+ return;
+ }
+ this.fullScreenToggler.hidden = true;
+ gNavToolbox.removeAttribute("fullscreenShouldAnimate");
+ gNavToolbox.style.marginTop = "";
+
+ if (!this._isChromeCollapsed) {
+ return;
+ }
+
+ // Track whether mouse is near the toolbox
+ if (trackMouse) {
+ let rect = gBrowser.tabpanels.getBoundingClientRect();
+ this._mouseTargetRect = {
+ top: rect.top + 50,
+ bottom: rect.bottom,
+ left: rect.left,
+ right: rect.right,
+ };
+ MousePosTracker.addListener(this);
+ }
+
+ this._isChromeCollapsed = false;
+ Services.obs.notifyObservers(null, "fullscreen-nav-toolbox", "shown");
+ },
+
+ hideNavToolbox(aAnimate = false) {
+ if (this._isChromeCollapsed) {
+ return;
+ }
+ if (!Services.prefs.getBoolPref("browser.fullscreen.autohide")) {
+ return;
+ }
+ // a popup menu is open in chrome: don't collapse chrome
+ if (this._isPopupOpen) {
+ return;
+ }
+
+ // a textbox in chrome is focused (location bar anyone?): don't collapse chrome
+ // unless we are kiosk mode
+ let focused = document.commandDispatcher.focusedElement;
+ if (
+ focused &&
+ focused.ownerDocument == document &&
+ focused.localName == "input" &&
+ !BrowserHandler.kiosk
+ ) {
+ // But try collapse the chrome again when anything happens which can make
+ // it lose the focus. We cannot listen on "blur" event on focused here
+ // because that event can be triggered by "mousedown", and hiding chrome
+ // would cause the content to move. This combination may split a single
+ // click into two actionless halves.
+ let retryHideNavToolbox = () => {
+ // Wait for at least a frame to give it a chance to be passed down to
+ // the content.
+ requestAnimationFrame(() => {
+ setTimeout(() => {
+ // In the meantime, it's possible that we exited fullscreen somehow,
+ // so only hide the toolbox if we're still in fullscreen mode.
+ if (window.fullScreen) {
+ this.hideNavToolbox(aAnimate);
+ }
+ }, 0);
+ });
+ window.removeEventListener("keydown", retryHideNavToolbox);
+ window.removeEventListener("click", retryHideNavToolbox);
+ };
+ window.addEventListener("keydown", retryHideNavToolbox);
+ window.addEventListener("click", retryHideNavToolbox);
+ return;
+ }
+
+ if (!BrowserHandler.kiosk) {
+ this.fullScreenToggler.hidden = false;
+ }
+
+ if (
+ aAnimate &&
+ window.matchMedia("(prefers-reduced-motion: no-preference)").matches &&
+ !BrowserHandler.kiosk
+ ) {
+ gNavToolbox.setAttribute("fullscreenShouldAnimate", true);
+ }
+
+ gNavToolbox.style.marginTop =
+ -gNavToolbox.getBoundingClientRect().height + "px";
+ this._isChromeCollapsed = true;
+ Services.obs.notifyObservers(null, "fullscreen-nav-toolbox", "hidden");
+
+ MousePosTracker.removeListener(this);
+ },
+
+ _updateToolbars(aEnterFS) {
+ for (let el of document.querySelectorAll(
+ "toolbar[fullscreentoolbar=true]"
+ )) {
+ // Set the inFullscreen attribute to allow specific styling
+ // in fullscreen mode
+ if (aEnterFS) {
+ el.setAttribute("inFullscreen", true);
+ } else {
+ el.removeAttribute("inFullscreen");
+ }
+ }
+
+ ToolbarIconColor.inferFromText("fullscreen", aEnterFS);
+ },
+};
+
+XPCOMUtils.defineLazyGetter(FullScreen, "_permissionNotificationIDs", () => {
+ let { PermissionUI } = ChromeUtils.importESModule(
+ "resource:///modules/PermissionUI.sys.mjs"
+ );
+ return (
+ Object.values(PermissionUI)
+ .filter(value => {
+ let returnValue;
+ try {
+ returnValue = value.prototype.notificationID;
+ } catch (err) {
+ if (err.message === "Not implemented.") {
+ returnValue = false;
+ } else {
+ throw err;
+ }
+ }
+ return returnValue;
+ })
+ .map(value => value.prototype.notificationID)
+ // Additionally include webRTC permission prompt which does not use PermissionUI
+ .concat(["webRTC-shareDevices"])
+ );
+});
diff --git a/browser/base/content/browser-fullZoom.js b/browser/base/content/browser-fullZoom.js
new file mode 100644
index 0000000000..7790df3f41
--- /dev/null
+++ b/browser/base/content/browser-fullZoom.js
@@ -0,0 +1,737 @@
+/* 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/. */
+
+// This file is loaded into the browser window scope.
+/* eslint-env mozilla/browser-window */
+
+/**
+ * Controls the "full zoom" setting and its site-specific preferences.
+ */
+var FullZoom = {
+ // Identifies the setting in the content prefs database.
+ name: "browser.content.full-zoom",
+
+ // browser.zoom.siteSpecific preference cache
+ _siteSpecificPref: undefined,
+
+ // browser.zoom.updateBackgroundTabs preference cache
+ updateBackgroundTabs: undefined,
+
+ // This maps the browser to monotonically increasing integer
+ // tokens. _browserTokenMap[browser] is increased each time the zoom is
+ // changed in the browser. See _getBrowserToken and _ignorePendingZoomAccesses.
+ _browserTokenMap: new WeakMap(),
+
+ // Stores initial locations if we receive onLocationChange
+ // events before we're initialized.
+ _initialLocations: new WeakMap(),
+
+ get siteSpecific() {
+ if (this._siteSpecificPref === undefined) {
+ this._siteSpecificPref = Services.prefs.getBoolPref(
+ "browser.zoom.siteSpecific"
+ );
+ }
+ return this._siteSpecificPref;
+ },
+
+ // nsISupports
+
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIObserver",
+ "nsIContentPrefObserver",
+ "nsISupportsWeakReference",
+ ]),
+
+ // Initialization & Destruction
+
+ init: function FullZoom_init() {
+ gBrowser.addEventListener("DoZoomEnlargeBy10", this);
+ gBrowser.addEventListener("DoZoomReduceBy10", this);
+ window.addEventListener("MozScaleGestureComplete", this);
+
+ // Register ourselves with the service so we know when our pref changes.
+ this._cps2 = Cc["@mozilla.org/content-pref/service;1"].getService(
+ Ci.nsIContentPrefService2
+ );
+ this._cps2.addObserverForName(this.name, this);
+
+ this.updateBackgroundTabs = Services.prefs.getBoolPref(
+ "browser.zoom.updateBackgroundTabs"
+ );
+
+ // Listen for changes to the browser.zoom branch so we can enable/disable
+ // updating background tabs and per-site saving and restoring of zoom levels.
+ Services.prefs.addObserver("browser.zoom.", this, true);
+
+ // If we received onLocationChange events for any of the current browsers
+ // before we were initialized we want to replay those upon initialization.
+ for (let browser of gBrowser.browsers) {
+ if (this._initialLocations.has(browser)) {
+ this.onLocationChange(...this._initialLocations.get(browser), browser);
+ }
+ }
+
+ // This should be nulled after initialization.
+ this._initialLocations = null;
+ },
+
+ destroy: function FullZoom_destroy() {
+ Services.prefs.removeObserver("browser.zoom.", this);
+ this._cps2.removeObserverForName(this.name, this);
+ gBrowser.removeEventListener("DoZoomEnlargeBy10", this);
+ gBrowser.removeEventListener("DoZoomReduceBy10", this);
+ window.removeEventListener("MozScaleGestureComplete", this);
+ },
+
+ // Event Handlers
+
+ // EventListener
+
+ handleEvent: function FullZoom_handleEvent(event) {
+ switch (event.type) {
+ case "DoZoomEnlargeBy10":
+ this.changeZoomBy(this._getTargetedBrowser(event), 0.1);
+ break;
+ case "DoZoomReduceBy10":
+ this.changeZoomBy(this._getTargetedBrowser(event), -0.1);
+ break;
+ case "MozScaleGestureComplete": {
+ let nonDefaultScalingZoom = event.detail != 1.0;
+ this.updateCommands(nonDefaultScalingZoom);
+ break;
+ }
+ }
+ },
+
+ // nsIObserver
+
+ observe(aSubject, aTopic, aData) {
+ switch (aTopic) {
+ case "nsPref:changed":
+ switch (aData) {
+ case "browser.zoom.siteSpecific":
+ // Invalidate pref cache.
+ this._siteSpecificPref = undefined;
+ break;
+ case "browser.zoom.updateBackgroundTabs":
+ this.updateBackgroundTabs = Services.prefs.getBoolPref(
+ "browser.zoom.updateBackgroundTabs"
+ );
+ break;
+ case "browser.zoom.full": {
+ this.updateCommands();
+ break;
+ }
+ }
+ break;
+ }
+ },
+
+ // nsIContentPrefObserver
+
+ onContentPrefSet: function FullZoom_onContentPrefSet(
+ aGroup,
+ aName,
+ aValue,
+ aIsPrivate
+ ) {
+ this._onContentPrefChanged(aGroup, aValue, aIsPrivate);
+ },
+
+ onContentPrefRemoved: function FullZoom_onContentPrefRemoved(
+ aGroup,
+ aName,
+ aIsPrivate
+ ) {
+ this._onContentPrefChanged(aGroup, undefined, aIsPrivate);
+ },
+
+ /**
+ * Appropriately updates the zoom level after a content preference has
+ * changed.
+ *
+ * @param aGroup The group of the changed preference.
+ * @param aValue The new value of the changed preference. Pass undefined to
+ * indicate the preference's removal.
+ */
+ _onContentPrefChanged: function FullZoom__onContentPrefChanged(
+ aGroup,
+ aValue,
+ aIsPrivate
+ ) {
+ if (this._isNextContentPrefChangeInternal) {
+ // Ignore changes that FullZoom itself makes. This works because the
+ // content pref service calls callbacks before notifying observers, and it
+ // does both in the same turn of the event loop.
+ delete this._isNextContentPrefChangeInternal;
+ return;
+ }
+
+ let browser = gBrowser.selectedBrowser;
+ if (!browser.currentURI) {
+ return;
+ }
+
+ if (this._isPDFViewer(browser)) {
+ return;
+ }
+
+ let ctxt = this._loadContextFromBrowser(browser);
+ let domain = this._cps2.extractDomain(browser.currentURI.spec);
+ if (aGroup) {
+ if (aGroup == domain && ctxt.usePrivateBrowsing == aIsPrivate) {
+ this._applyPrefToZoom(aValue, browser);
+ }
+ return;
+ }
+
+ // If the current page doesn't have a site-specific preference, then its
+ // zoom should be set to the new global preference now that the global
+ // preference has changed.
+ let hasPref = false;
+ let token = this._getBrowserToken(browser);
+ this._cps2.getByDomainAndName(browser.currentURI.spec, this.name, ctxt, {
+ handleResult() {
+ hasPref = true;
+ },
+ handleCompletion: () => {
+ if (!hasPref && token.isCurrent) {
+ this._applyPrefToZoom(undefined, browser);
+ }
+ },
+ });
+ },
+
+ // location change observer
+
+ /**
+ * Called when the location of a tab changes.
+ * When that happens, we need to update the current zoom level if appropriate.
+ *
+ * @param aURI
+ * A URI object representing the new location.
+ * @param aIsTabSwitch
+ * Whether this location change has happened because of a tab switch.
+ * @param aBrowser
+ * (optional) browser object displaying the document
+ */
+ onLocationChange: function FullZoom_onLocationChange(
+ aURI,
+ aIsTabSwitch,
+ aBrowser
+ ) {
+ let browser = aBrowser || gBrowser.selectedBrowser;
+
+ // If we haven't been initialized yet but receive an onLocationChange
+ // notification then let's store and replay it upon initialization.
+ if (this._initialLocations) {
+ this._initialLocations.set(browser, [aURI, aIsTabSwitch]);
+ return;
+ }
+
+ // Ignore all pending async zoom accesses in the browser. Pending accesses
+ // that started before the location change will be prevented from applying
+ // to the new location.
+ this._ignorePendingZoomAccesses(browser);
+
+ if (!aURI || (aIsTabSwitch && !this._isSiteSpecific(browser))) {
+ this._notifyOnLocationChange(browser);
+ return;
+ }
+
+ if (aURI.spec == "about:blank") {
+ if (
+ !browser.contentPrincipal ||
+ browser.contentPrincipal.isNullPrincipal
+ ) {
+ // For an about:blank with a null principal, zooming any amount does not
+ // make any sense - so simply do 100%.
+ this._applyPrefToZoom(
+ 1,
+ browser,
+ this._notifyOnLocationChange.bind(this, browser)
+ );
+ } else {
+ // If it's not a null principal, there may be content loaded into it,
+ // so use the global pref. This will avoid a cps2 roundtrip if we've
+ // already loaded the global pref once. Really, this should probably
+ // use the contentPrincipal's origin if it's an http(s) principal.
+ // (See bug 1457597)
+ this._applyPrefToZoom(
+ undefined,
+ browser,
+ this._notifyOnLocationChange.bind(this, browser)
+ );
+ }
+ return;
+ }
+
+ // Media documents should always start at 1, and are not affected by prefs.
+ if (!aIsTabSwitch && browser.isSyntheticDocument) {
+ ZoomManager.setZoomForBrowser(browser, 1);
+ // _ignorePendingZoomAccesses already called above, so no need here.
+ this._notifyOnLocationChange(browser);
+ return;
+ }
+
+ // The PDF viewer zooming isn't handled by `ZoomManager`, ensure that the
+ // browser zoom level always gets reset to 100% on load (to prevent the
+ // UI elements of the PDF viewer from being zoomed in/out on load).
+ if (this._isPDFViewer(browser)) {
+ this._applyPrefToZoom(
+ 1,
+ browser,
+ this._notifyOnLocationChange.bind(this, browser)
+ );
+ return;
+ }
+
+ // See if the zoom pref is cached.
+ let ctxt = this._loadContextFromBrowser(browser);
+ let pref = this._cps2.getCachedByDomainAndName(aURI.spec, this.name, ctxt);
+ if (pref) {
+ this._applyPrefToZoom(
+ pref.value,
+ browser,
+ this._notifyOnLocationChange.bind(this, browser)
+ );
+ return;
+ }
+
+ // It's not cached, so we have to asynchronously fetch it.
+ let value = undefined;
+ let token = this._getBrowserToken(browser);
+ this._cps2.getByDomainAndName(aURI.spec, this.name, ctxt, {
+ handleResult(resultPref) {
+ value = resultPref.value;
+ },
+ handleCompletion: () => {
+ if (!token.isCurrent) {
+ this._notifyOnLocationChange(browser);
+ return;
+ }
+ this._applyPrefToZoom(
+ value,
+ browser,
+ this._notifyOnLocationChange.bind(this, browser)
+ );
+ },
+ });
+ },
+
+ // update state of zoom menu items
+
+ /**
+ * Updates the current windows Zoom commands for zooming in, zooming out
+ * and resetting the zoom level.
+ *
+ * @param {boolean} [forceResetEnabled=false]
+ * Set to true if the zoom reset command should be enabled regardless of
+ * whether or not the ZoomManager.zoom level is at 1.0. This is specifically
+ * for when using scaling zoom via the pinch gesture which doesn't cause
+ * the ZoomManager.zoom level to change.
+ * @returns Promise
+ * @resolves undefined
+ */
+ updateCommands: async function FullZoom_updateCommands(
+ forceResetEnabled = false
+ ) {
+ let zoomLevel = ZoomManager.zoom;
+ let defaultZoomLevel = await ZoomUI.getGlobalValue();
+ let reduceCmd = document.getElementById("cmd_fullZoomReduce");
+ if (zoomLevel == ZoomManager.MIN) {
+ reduceCmd.setAttribute("disabled", "true");
+ } else {
+ reduceCmd.removeAttribute("disabled");
+ }
+
+ let enlargeCmd = document.getElementById("cmd_fullZoomEnlarge");
+ if (zoomLevel == ZoomManager.MAX) {
+ enlargeCmd.setAttribute("disabled", "true");
+ } else {
+ enlargeCmd.removeAttribute("disabled");
+ }
+
+ let resetCmd = document.getElementById("cmd_fullZoomReset");
+ if (zoomLevel == defaultZoomLevel && !forceResetEnabled) {
+ resetCmd.setAttribute("disabled", "true");
+ } else {
+ resetCmd.removeAttribute("disabled");
+ }
+
+ let fullZoomCmd = document.getElementById("cmd_fullZoomToggle");
+ if (!ZoomManager.useFullZoom) {
+ fullZoomCmd.setAttribute("checked", "true");
+ } else {
+ fullZoomCmd.setAttribute("checked", "false");
+ }
+ },
+
+ // Setting & Pref Manipulation
+
+ sendMessageToPDFViewer(browser, name) {
+ try {
+ browser.sendMessageToActor(name, {}, "Pdfjs");
+ } catch (ex) {
+ console.error(ex);
+ }
+ },
+
+ /**
+ * If browser in reader mode sends message to reader in order to decrease font size,
+ * Otherwise reduces the zoom level of the page in the current browser.
+ */
+ async reduce() {
+ let browser = gBrowser.selectedBrowser;
+ if (browser.currentURI.spec.startsWith("about:reader")) {
+ browser.sendMessageToActor("Reader:ZoomOut", {}, "AboutReader");
+ } else if (this._isPDFViewer(browser)) {
+ this.sendMessageToPDFViewer(browser, "PDFJS:ZoomOut");
+ } else {
+ ZoomManager.reduce();
+ this._ignorePendingZoomAccesses(browser);
+ await this._applyZoomToPref(browser);
+ }
+ },
+
+ /**
+ * If browser in reader mode sends message to reader in order to increase font size,
+ * Otherwise enlarges the zoom level of the page in the current browser.
+ */
+ async enlarge() {
+ let browser = gBrowser.selectedBrowser;
+ if (browser.currentURI.spec.startsWith("about:reader")) {
+ browser.sendMessageToActor("Reader:ZoomIn", {}, "AboutReader");
+ } else if (this._isPDFViewer(browser)) {
+ this.sendMessageToPDFViewer(browser, "PDFJS:ZoomIn");
+ } else {
+ ZoomManager.enlarge();
+ this._ignorePendingZoomAccesses(browser);
+ await this._applyZoomToPref(browser);
+ }
+ },
+
+ /**
+ * If browser in reader mode sends message to reader in order to increase font size,
+ * Otherwise enlarges the zoom level of the page in the current browser.
+ * This function is not async like reduce/enlarge, because it is invoked by our
+ * event handler. This means that the call to _applyZoomToPref is not awaited and
+ * will happen asynchronously.
+ */
+ changeZoomBy(aBrowser, aValue) {
+ if (aBrowser.currentURI.spec.startsWith("about:reader")) {
+ const message = aValue > 0 ? "Reader::ZoomIn" : "Reader:ZoomOut";
+ aBrowser.sendMessageToActor(message, {}, "AboutReader");
+ return;
+ } else if (this._isPDFViewer(aBrowser)) {
+ const message = aValue > 0 ? "PDFJS::ZoomIn" : "PDFJS:ZoomOut";
+ this.sendMessageToPDFViewer(aBrowser, message);
+ return;
+ }
+ let zoom = ZoomManager.getZoomForBrowser(aBrowser);
+ zoom += aValue;
+ if (zoom < ZoomManager.MIN) {
+ zoom = ZoomManager.MIN;
+ } else if (zoom > ZoomManager.MAX) {
+ zoom = ZoomManager.MAX;
+ }
+ ZoomManager.setZoomForBrowser(aBrowser, zoom);
+ this._ignorePendingZoomAccesses(aBrowser);
+ this._applyZoomToPref(aBrowser);
+ },
+
+ /**
+ * Sets the zoom level for the given browser to the given floating
+ * point value, where 1 is the default zoom level.
+ */
+ setZoom(value, browser = gBrowser.selectedBrowser) {
+ if (this._isPDFViewer(browser)) {
+ return;
+ }
+ ZoomManager.setZoomForBrowser(browser, value);
+ this._ignorePendingZoomAccesses(browser);
+ this._applyZoomToPref(browser);
+ },
+
+ /**
+ * Sets the zoom level of the page in the given browser to the global zoom
+ * level.
+ *
+ * @return A promise which resolves when the zoom reset has been applied.
+ */
+ reset: function FullZoom_reset(browser = gBrowser.selectedBrowser) {
+ let forceValue;
+ if (browser.currentURI.spec.startsWith("about:reader")) {
+ browser.sendMessageToActor("Reader:ResetZoom", {}, "AboutReader");
+ } else if (this._isPDFViewer(browser)) {
+ this.sendMessageToPDFViewer(browser, "PDFJS:ZoomReset");
+ // Ensure that the UI elements of the PDF viewer won't be zoomed in/out
+ // on reset, even if/when browser default zoom value is not set to 100%.
+ forceValue = 1;
+ }
+ let token = this._getBrowserToken(browser);
+ let result = ZoomUI.getGlobalValue().then(value => {
+ if (token.isCurrent) {
+ ZoomManager.setZoomForBrowser(browser, forceValue || value);
+ this._ignorePendingZoomAccesses(browser);
+ }
+ });
+ this._removePref(browser);
+ return result;
+ },
+
+ resetScalingZoom: function FullZoom_resetScaling(
+ browser = gBrowser.selectedBrowser
+ ) {
+ browser.browsingContext?.resetScalingZoom();
+ },
+
+ /**
+ * Set the zoom level for a given browser.
+ *
+ * Per nsPresContext::setFullZoom, we can set the zoom to its current value
+ * without significant impact on performance, as the setting is only applied
+ * if it differs from the current setting. In fact getting the zoom and then
+ * checking ourselves if it differs costs more.
+ *
+ * And perhaps we should always set the zoom even if it was more expensive,
+ * since nsDocumentViewer::SetTextZoom claims that child documents can have
+ * a different text zoom (although it would be unusual), and it implies that
+ * those child text zooms should get updated when the parent zoom gets set,
+ * and perhaps the same is true for full zoom
+ * (although nsDocumentViewer::SetFullZoom doesn't mention it).
+ *
+ * So when we apply new zoom values to the browser, we simply set the zoom.
+ * We don't check first to see if the new value is the same as the current
+ * one.
+ *
+ * @param aValue The zoom level value.
+ * @param aBrowser The zoom is set in this browser. Required.
+ * @param aCallback If given, it's asynchronously called when complete.
+ */
+ _applyPrefToZoom: function FullZoom__applyPrefToZoom(
+ aValue,
+ aBrowser,
+ aCallback
+ ) {
+ // The browser is sometimes half-destroyed because this method is called
+ // by content pref service callbacks, which themselves can be called at any
+ // time, even after browsers are closed.
+ if (
+ !aBrowser.mInitialized ||
+ aBrowser.isSyntheticDocument ||
+ (!this._isSiteSpecific(aBrowser) && aBrowser.tabHasCustomZoom)
+ ) {
+ this._executeSoon(aCallback);
+ return;
+ }
+
+ if (aValue !== undefined && this._isSiteSpecific(aBrowser)) {
+ ZoomManager.setZoomForBrowser(aBrowser, this._ensureValid(aValue));
+ this._ignorePendingZoomAccesses(aBrowser);
+ this._executeSoon(aCallback);
+ return;
+ }
+
+ // Above, we check if site-specific zoom is enabled before setting
+ // the tab browser zoom, however global zoom should work independent
+ // of the site-specific pref, so we do no checks here.
+ let token = this._getBrowserToken(aBrowser);
+ ZoomUI.getGlobalValue().then(value => {
+ if (token.isCurrent) {
+ ZoomManager.setZoomForBrowser(aBrowser, value);
+ this._ignorePendingZoomAccesses(aBrowser);
+ }
+ this._executeSoon(aCallback);
+ });
+ },
+
+ /**
+ * Saves the zoom level of the page in the given browser to the content
+ * prefs store.
+ *
+ * @param browser The zoom of this browser will be saved. Required.
+ */
+ _applyZoomToPref: function FullZoom__applyZoomToPref(browser) {
+ if (!this._isSiteSpecific(browser) || browser.isSyntheticDocument) {
+ // If site-specific zoom is disabled, we have called this function
+ // to adjust our tab's zoom level. It is now considered "custom"
+ // and we mark that here.
+ browser.tabHasCustomZoom = !this._isSiteSpecific(browser);
+ return null;
+ }
+
+ return new Promise(resolve => {
+ this._cps2.set(
+ browser.currentURI.spec,
+ this.name,
+ ZoomManager.getZoomForBrowser(browser),
+ this._loadContextFromBrowser(browser),
+ {
+ handleCompletion: () => {
+ this._isNextContentPrefChangeInternal = true;
+ resolve();
+ },
+ }
+ );
+ });
+ },
+
+ /**
+ * Removes from the content prefs store the zoom level of the given browser.
+ *
+ * @param browser The zoom of this browser will be removed. Required.
+ */
+ _removePref: function FullZoom__removePref(browser) {
+ if (browser.isSyntheticDocument) {
+ return;
+ }
+ let ctxt = this._loadContextFromBrowser(browser);
+ this._cps2.removeByDomainAndName(browser.currentURI.spec, this.name, ctxt, {
+ handleCompletion: () => {
+ this._isNextContentPrefChangeInternal = true;
+ },
+ });
+ },
+
+ // Utilities
+
+ /**
+ * Returns the zoom change token of the given browser. Asynchronous
+ * operations that access the given browser's zoom should use this method to
+ * capture the token before starting and use token.isCurrent to determine if
+ * it's safe to access the zoom when done. If token.isCurrent is false, then
+ * after the async operation started, either the browser's zoom was changed or
+ * the browser was destroyed, and depending on what the operation is doing, it
+ * may no longer be safe to set and get its zoom.
+ *
+ * @param browser The token of this browser will be returned.
+ * @return An object with an "isCurrent" getter.
+ */
+ _getBrowserToken: function FullZoom__getBrowserToken(browser) {
+ let map = this._browserTokenMap;
+ if (!map.has(browser)) {
+ map.set(browser, 0);
+ }
+ return {
+ token: map.get(browser),
+ get isCurrent() {
+ // At this point, the browser may have been destructed and unbound but
+ // its outer ID not removed from the map because outer-window-destroyed
+ // hasn't been received yet. In that case, the browser is unusable, it
+ // has no properties, so return false. Check for this case by getting a
+ // property, say, docShell.
+ return map.get(browser) === this.token && browser.mInitialized;
+ },
+ };
+ },
+
+ /**
+ * Returns the browser that the supplied zoom event is associated with.
+ * @param event The zoom event.
+ * @return The associated browser element, if one exists, otherwise null.
+ */
+ _getTargetedBrowser: function FullZoom__getTargetedBrowser(event) {
+ let target = event.originalTarget;
+
+ // With remote content browsers, the event's target is the browser
+ // we're looking for.
+ const XUL_NS =
+ "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
+ if (
+ window.XULElement.isInstance(target) &&
+ target.localName == "browser" &&
+ target.namespaceURI == XUL_NS
+ ) {
+ return target;
+ }
+
+ // With in-process content browsers, the event's target is the content
+ // document.
+ if (target.nodeType == Node.DOCUMENT_NODE) {
+ return target.ownerGlobal.docShell.chromeEventHandler;
+ }
+
+ throw new Error("Unexpected zoom event source");
+ },
+
+ /**
+ * Increments the zoom change token for the given browser so that pending
+ * async operations know that it may be unsafe to access they zoom when they
+ * finish.
+ *
+ * @param browser Pending accesses in this browser will be ignored.
+ */
+ _ignorePendingZoomAccesses: function FullZoom__ignorePendingZoomAccesses(
+ browser
+ ) {
+ let map = this._browserTokenMap;
+ map.set(browser, (map.get(browser) || 0) + 1);
+ },
+
+ _ensureValid: function FullZoom__ensureValid(aValue) {
+ // Note that undefined is a valid value for aValue that indicates a known-
+ // not-to-exist value.
+ if (isNaN(aValue)) {
+ return 1;
+ }
+
+ if (aValue < ZoomManager.MIN) {
+ return ZoomManager.MIN;
+ }
+
+ if (aValue > ZoomManager.MAX) {
+ return ZoomManager.MAX;
+ }
+
+ return aValue;
+ },
+
+ // Whether to remember the site specific zoom level for this browser.
+ // This returns false when `browser.zoom.siteSpecific` is false or
+ // the browser has content loaded that should resist fingerprinting.
+ _isSiteSpecific(aBrowser) {
+ if (!this.siteSpecific) {
+ return false;
+ }
+ return !aBrowser?.browsingContext?.topWindowContext
+ .shouldResistFingerprinting;
+ },
+
+ /**
+ * Gets the load context from the given Browser.
+ *
+ * @param Browser The Browser whose load context will be returned.
+ * @return The nsILoadContext of the given Browser.
+ */
+ _loadContextFromBrowser: function FullZoom__loadContextFromBrowser(browser) {
+ return browser.loadContext;
+ },
+
+ /**
+ * Asynchronously broadcasts "browser-fullZoom:location-change" so that
+ * listeners can be notified when the zoom levels on those pages change.
+ * The notification is always asynchronous so that observers are guaranteed a
+ * consistent behavior.
+ */
+ _notifyOnLocationChange: function FullZoom__notifyOnLocationChange(browser) {
+ this._executeSoon(function () {
+ Services.obs.notifyObservers(browser, "browser-fullZoom:location-change");
+ });
+ },
+
+ _executeSoon: function FullZoom__executeSoon(callback) {
+ if (!callback) {
+ return;
+ }
+ Services.tm.dispatchToMainThread(callback);
+ },
+
+ _isPDFViewer(browser) {
+ return !!(
+ browser.contentPrincipal.spec == "resource://pdf.js/web/viewer.html"
+ );
+ },
+};
diff --git a/browser/base/content/browser-gestureSupport.js b/browser/base/content/browser-gestureSupport.js
new file mode 100644
index 0000000000..f9a094c499
--- /dev/null
+++ b/browser/base/content/browser-gestureSupport.js
@@ -0,0 +1,993 @@
+/* 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/. */
+
+// This file is loaded into the browser window scope.
+/* eslint-env mozilla/browser-window */
+
+// Simple gestures support
+//
+// As per bug #412486, web content must not be allowed to receive any
+// simple gesture events. Multi-touch gesture APIs are in their
+// infancy and we do NOT want to be forced into supporting an API that
+// will probably have to change in the future. (The current Mac OS X
+// API is undocumented and was reverse-engineered.) Until support is
+// implemented in the event dispatcher to keep these events as
+// chrome-only, we must listen for the simple gesture events during
+// the capturing phase and call stopPropagation on every event.
+
+var gGestureSupport = {
+ _currentRotation: 0,
+ _lastRotateDelta: 0,
+ _rotateMomentumThreshold: 0.75,
+
+ /**
+ * Add or remove mouse gesture event listeners
+ *
+ * @param aAddListener
+ * True to add/init listeners and false to remove/uninit
+ */
+ init: function GS_init(aAddListener) {
+ const gestureEvents = [
+ "SwipeGestureMayStart",
+ "SwipeGestureStart",
+ "SwipeGestureUpdate",
+ "SwipeGestureEnd",
+ "SwipeGesture",
+ "MagnifyGestureStart",
+ "MagnifyGestureUpdate",
+ "MagnifyGesture",
+ "RotateGestureStart",
+ "RotateGestureUpdate",
+ "RotateGesture",
+ "TapGesture",
+ "PressTapGesture",
+ ];
+
+ for (let event of gestureEvents) {
+ if (aAddListener) {
+ gBrowser.tabbox.addEventListener("Moz" + event, this, true);
+ } else {
+ gBrowser.tabbox.removeEventListener("Moz" + event, this, true);
+ }
+ }
+ },
+
+ /**
+ * Dispatch events based on the type of mouse gesture event. For now, make
+ * sure to stop propagation of every gesture event so that web content cannot
+ * receive gesture events.
+ *
+ * @param aEvent
+ * The gesture event to handle
+ */
+ handleEvent: function GS_handleEvent(aEvent) {
+ if (
+ !Services.prefs.getBoolPref(
+ "dom.debug.propagate_gesture_events_through_content"
+ )
+ ) {
+ aEvent.stopPropagation();
+ }
+
+ // Create a preference object with some defaults
+ let def = (aThreshold, aLatched) => ({
+ threshold: aThreshold,
+ latched: !!aLatched,
+ });
+
+ switch (aEvent.type) {
+ case "MozSwipeGestureMayStart":
+ if (this._shouldDoSwipeGesture(aEvent)) {
+ aEvent.preventDefault();
+ }
+ break;
+ case "MozSwipeGestureStart":
+ aEvent.preventDefault();
+ this._setupSwipeGesture();
+ break;
+ case "MozSwipeGestureUpdate":
+ aEvent.preventDefault();
+ this._doUpdate(aEvent);
+ break;
+ case "MozSwipeGestureEnd":
+ aEvent.preventDefault();
+ this._doEnd(aEvent);
+ break;
+ case "MozSwipeGesture":
+ aEvent.preventDefault();
+ this.onSwipe(aEvent);
+ break;
+ case "MozMagnifyGestureStart":
+ aEvent.preventDefault();
+ this._setupGesture(aEvent, "pinch", def(25, 0), "out", "in");
+ break;
+ case "MozRotateGestureStart":
+ aEvent.preventDefault();
+ this._setupGesture(aEvent, "twist", def(25, 0), "right", "left");
+ break;
+ case "MozMagnifyGestureUpdate":
+ case "MozRotateGestureUpdate":
+ aEvent.preventDefault();
+ this._doUpdate(aEvent);
+ break;
+ case "MozTapGesture":
+ aEvent.preventDefault();
+ this._doAction(aEvent, ["tap"]);
+ break;
+ case "MozRotateGesture":
+ aEvent.preventDefault();
+ this._doAction(aEvent, ["twist", "end"]);
+ break;
+ /* case "MozPressTapGesture":
+ break; */
+ }
+ },
+
+ /**
+ * Called at the start of "pinch" and "twist" gestures to setup all of the
+ * information needed to process the gesture
+ *
+ * @param aEvent
+ * The continual motion start event to handle
+ * @param aGesture
+ * Name of the gesture to handle
+ * @param aPref
+ * Preference object with the names of preferences and defaults
+ * @param aInc
+ * Command to trigger for increasing motion (without gesture name)
+ * @param aDec
+ * Command to trigger for decreasing motion (without gesture name)
+ */
+ _setupGesture: function GS__setupGesture(
+ aEvent,
+ aGesture,
+ aPref,
+ aInc,
+ aDec
+ ) {
+ // Try to load user-set values from preferences
+ for (let [pref, def] of Object.entries(aPref)) {
+ aPref[pref] = this._getPref(aGesture + "." + pref, def);
+ }
+
+ // Keep track of the total deltas and latching behavior
+ let offset = 0;
+ let latchDir = aEvent.delta > 0 ? 1 : -1;
+ let isLatched = false;
+
+ // Create the update function here to capture closure state
+ this._doUpdate = function GS__doUpdate(updateEvent) {
+ // Update the offset with new event data
+ offset += updateEvent.delta;
+
+ // Check if the cumulative deltas exceed the threshold
+ if (Math.abs(offset) > aPref.threshold) {
+ // Trigger the action if we don't care about latching; otherwise, make
+ // sure either we're not latched and going the same direction of the
+ // initial motion; or we're latched and going the opposite way
+ let sameDir = (latchDir ^ offset) >= 0;
+ if (!aPref.latched || isLatched ^ sameDir) {
+ this._doAction(updateEvent, [aGesture, offset > 0 ? aInc : aDec]);
+
+ // We must be getting latched or leaving it, so just toggle
+ isLatched = !isLatched;
+ }
+
+ // Reset motion counter to prepare for more of the same gesture
+ offset = 0;
+ }
+ };
+
+ // The start event also contains deltas, so handle an update right away
+ this._doUpdate(aEvent);
+ },
+
+ /**
+ * Checks whether a swipe gesture event can navigate the browser history or
+ * not.
+ *
+ * @param aEvent
+ * The swipe gesture event.
+ * @return true if the swipe event may navigate the history, false othwerwise.
+ */
+ _swipeNavigatesHistory: function GS__swipeNavigatesHistory(aEvent) {
+ return (
+ this._getCommand(aEvent, ["swipe", "left"]) ==
+ "Browser:BackOrBackDuplicate" &&
+ this._getCommand(aEvent, ["swipe", "right"]) ==
+ "Browser:ForwardOrForwardDuplicate"
+ );
+ },
+
+ /**
+ * Checks whether we want to start a swipe for aEvent and sets
+ * aEvent.allowedDirections to the right values.
+ *
+ * @param aEvent
+ * The swipe gesture "MayStart" event.
+ * @return true if we're willing to start a swipe for this event, false
+ * otherwise.
+ */
+ _shouldDoSwipeGesture: function GS__shouldDoSwipeGesture(aEvent) {
+ if (!this._swipeNavigatesHistory(aEvent)) {
+ return false;
+ }
+
+ let isVerticalSwipe = false;
+ if (aEvent.direction == aEvent.DIRECTION_UP) {
+ if (gMultiProcessBrowser || window.content.pageYOffset > 0) {
+ return false;
+ }
+ isVerticalSwipe = true;
+ } else if (aEvent.direction == aEvent.DIRECTION_DOWN) {
+ if (
+ gMultiProcessBrowser ||
+ window.content.pageYOffset < window.content.scrollMaxY
+ ) {
+ return false;
+ }
+ isVerticalSwipe = true;
+ }
+ if (isVerticalSwipe) {
+ // Vertical overscroll has been temporarily disabled until bug 939480 is
+ // fixed.
+ return false;
+ }
+
+ let canGoBack = gHistorySwipeAnimation.canGoBack();
+ let canGoForward = gHistorySwipeAnimation.canGoForward();
+ let isLTR = gHistorySwipeAnimation.isLTR;
+
+ if (canGoBack) {
+ aEvent.allowedDirections |= isLTR
+ ? aEvent.DIRECTION_LEFT
+ : aEvent.DIRECTION_RIGHT;
+ }
+ if (canGoForward) {
+ aEvent.allowedDirections |= isLTR
+ ? aEvent.DIRECTION_RIGHT
+ : aEvent.DIRECTION_LEFT;
+ }
+
+ return canGoBack || canGoForward;
+ },
+
+ /**
+ * Sets up swipe gestures. This includes setting up swipe animations for the
+ * gesture, if enabled.
+ *
+ * @param aEvent
+ * The swipe gesture start event.
+ * @return true if swipe gestures could successfully be set up, false
+ * othwerwise.
+ */
+ _setupSwipeGesture: function GS__setupSwipeGesture() {
+ gHistorySwipeAnimation.startAnimation();
+
+ this._doUpdate = function GS__doUpdate(aEvent) {
+ gHistorySwipeAnimation.updateAnimation(aEvent.delta);
+ };
+
+ this._doEnd = function GS__doEnd(aEvent) {
+ gHistorySwipeAnimation.swipeEndEventReceived();
+
+ this._doUpdate = function () {};
+ this._doEnd = function () {};
+ };
+ },
+
+ /**
+ * Generator producing the powerset of the input array where the first result
+ * is the complete set and the last result (before StopIteration) is empty.
+ *
+ * @param aArray
+ * Source array containing any number of elements
+ * @yield Array that is a subset of the input array from full set to empty
+ */
+ _power: function* GS__power(aArray) {
+ // Create a bitmask based on the length of the array
+ let num = 1 << aArray.length;
+ while (--num >= 0) {
+ // Only select array elements where the current bit is set
+ yield aArray.reduce(function (aPrev, aCurr, aIndex) {
+ if (num & (1 << aIndex)) {
+ aPrev.push(aCurr);
+ }
+ return aPrev;
+ }, []);
+ }
+ },
+
+ /**
+ * Determine what action to do for the gesture based on which keys are
+ * pressed and which commands are set, and execute the command.
+ *
+ * @param aEvent
+ * The original gesture event to convert into a fake click event
+ * @param aGesture
+ * Array of gesture name parts (to be joined by periods)
+ * @return Name of the executed command. Returns null if no command is
+ * found.
+ */
+ _doAction: function GS__doAction(aEvent, aGesture) {
+ let command = this._getCommand(aEvent, aGesture);
+ return command && this._doCommand(aEvent, command);
+ },
+
+ /**
+ * Determine what action to do for the gesture based on which keys are
+ * pressed and which commands are set
+ *
+ * @param aEvent
+ * The original gesture event to convert into a fake click event
+ * @param aGesture
+ * Array of gesture name parts (to be joined by periods)
+ */
+ _getCommand: function GS__getCommand(aEvent, aGesture) {
+ // Create an array of pressed keys in a fixed order so that a command for
+ // "meta" is preferred over "ctrl" when both buttons are pressed (and a
+ // command for both don't exist)
+ let keyCombos = [];
+ for (let key of ["shift", "alt", "ctrl", "meta"]) {
+ if (aEvent[key + "Key"]) {
+ keyCombos.push(key);
+ }
+ }
+
+ // Try each combination of key presses in decreasing order for commands
+ for (let subCombo of this._power(keyCombos)) {
+ // Convert a gesture and pressed keys into the corresponding command
+ // action where the preference has the gesture before "shift" before
+ // "alt" before "ctrl" before "meta" all separated by periods
+ let command;
+ try {
+ command = this._getPref(aGesture.concat(subCombo).join("."));
+ } catch (e) {}
+
+ if (command) {
+ return command;
+ }
+ }
+ return null;
+ },
+
+ /**
+ * Execute the specified command.
+ *
+ * @param aEvent
+ * The original gesture event to convert into a fake click event
+ * @param aCommand
+ * Name of the command found for the event's keys and gesture.
+ */
+ _doCommand: function GS__doCommand(aEvent, aCommand) {
+ let node = document.getElementById(aCommand);
+ if (node) {
+ if (node.getAttribute("disabled") != "true") {
+ let cmdEvent = document.createEvent("xulcommandevent");
+ cmdEvent.initCommandEvent(
+ "command",
+ true,
+ true,
+ window,
+ 0,
+ aEvent.ctrlKey,
+ aEvent.altKey,
+ aEvent.shiftKey,
+ aEvent.metaKey,
+ 0,
+ aEvent,
+ aEvent.mozInputSource
+ );
+ node.dispatchEvent(cmdEvent);
+ }
+ } else {
+ goDoCommand(aCommand);
+ }
+ },
+
+ /**
+ * Handle continual motion events. This function will be set by
+ * _setupGesture or _setupSwipe.
+ *
+ * @param aEvent
+ * The continual motion update event to handle
+ */
+ _doUpdate(aEvent) {},
+
+ /**
+ * Handle gesture end events. This function will be set by _setupSwipe.
+ *
+ * @param aEvent
+ * The gesture end event to handle
+ */
+ _doEnd(aEvent) {},
+
+ /**
+ * Convert the swipe gesture into a browser action based on the direction.
+ *
+ * @param aEvent
+ * The swipe event to handle
+ */
+ onSwipe: function GS_onSwipe(aEvent) {
+ // Figure out which one (and only one) direction was triggered
+ for (let dir of ["UP", "RIGHT", "DOWN", "LEFT"]) {
+ if (aEvent.direction == aEvent["DIRECTION_" + dir]) {
+ this._coordinateSwipeEventWithAnimation(aEvent, dir);
+ break;
+ }
+ }
+ },
+
+ /**
+ * Process a swipe event based on the given direction.
+ *
+ * @param aEvent
+ * The swipe event to handle
+ * @param aDir
+ * The direction for the swipe event
+ */
+ processSwipeEvent: function GS_processSwipeEvent(aEvent, aDir) {
+ let dir = aDir.toLowerCase();
+ // This is a bit of a hack. Ideally we would like our pref names to not
+ // associate a direction (eg left) with a history action (eg back), and
+ // instead name them something like HistoryLeft/Right and then intercept
+ // that in this file and turn it into the back or forward command, but
+ // that involves sending whether we are in LTR or not into _doAction and
+ // _getCommand and then having them recognize that these command needs to
+ // be interpreted differently for rtl/ltr (but not other commands), which
+ // seems more brittle (have to keep all the places in sync) and more code.
+ // So we'll just live with presenting the wrong semantics in the prefs.
+ if (!gHistorySwipeAnimation.isLTR) {
+ if (dir == "right") {
+ dir = "left";
+ } else if (dir == "left") {
+ dir = "right";
+ }
+ }
+ this._doAction(aEvent, ["swipe", dir]);
+ },
+
+ /**
+ * Coordinates the swipe event with the swipe animation, if any.
+ * If an animation is currently running, the swipe event will be
+ * processed once the animation stops. This will guarantee a fluid
+ * motion of the animation.
+ *
+ * @param aEvent
+ * The swipe event to handle
+ * @param aDir
+ * The direction for the swipe event
+ */
+ _coordinateSwipeEventWithAnimation:
+ function GS__coordinateSwipeEventWithAnimation(aEvent, aDir) {
+ gHistorySwipeAnimation.stopAnimation();
+ this.processSwipeEvent(aEvent, aDir);
+ },
+
+ /**
+ * Get a gesture preference or use a default if it doesn't exist
+ *
+ * @param aPref
+ * Name of the preference to load under the gesture branch
+ * @param aDef
+ * Default value if the preference doesn't exist
+ */
+ _getPref: function GS__getPref(aPref, aDef) {
+ // Preferences branch under which all gestures preferences are stored
+ const branch = "browser.gesture.";
+
+ try {
+ // Determine what type of data to load based on default value's type
+ let type = typeof aDef;
+ let getFunc = "Char";
+ if (type == "boolean") {
+ getFunc = "Bool";
+ } else if (type == "number") {
+ getFunc = "Int";
+ }
+ return Services.prefs["get" + getFunc + "Pref"](branch + aPref);
+ } catch (e) {
+ return aDef;
+ }
+ },
+
+ /**
+ * Perform rotation for ImageDocuments
+ *
+ * @param aEvent
+ * The MozRotateGestureUpdate event triggering this call
+ */
+ rotate(aEvent) {
+ if (!ImageDocument.isInstance(window.content.document)) {
+ return;
+ }
+
+ let contentElement = window.content.document.body.firstElementChild;
+ if (!contentElement) {
+ return;
+ }
+ // If we're currently snapping, cancel that snap
+ if (contentElement.classList.contains("completeRotation")) {
+ this._clearCompleteRotation();
+ }
+
+ this.rotation = Math.round(this.rotation + aEvent.delta);
+ contentElement.style.transform = "rotate(" + this.rotation + "deg)";
+ this._lastRotateDelta = aEvent.delta;
+ },
+
+ /**
+ * Perform a rotation end for ImageDocuments
+ */
+ rotateEnd() {
+ if (!ImageDocument.isInstance(window.content.document)) {
+ return;
+ }
+
+ let contentElement = window.content.document.body.firstElementChild;
+ if (!contentElement) {
+ return;
+ }
+
+ let transitionRotation = 0;
+
+ // The reason that 360 is allowed here is because when rotating between
+ // 315 and 360, setting rotate(0deg) will cause it to rotate the wrong
+ // direction around--spinning wildly.
+ if (this.rotation <= 45) {
+ transitionRotation = 0;
+ } else if (this.rotation > 45 && this.rotation <= 135) {
+ transitionRotation = 90;
+ } else if (this.rotation > 135 && this.rotation <= 225) {
+ transitionRotation = 180;
+ } else if (this.rotation > 225 && this.rotation <= 315) {
+ transitionRotation = 270;
+ } else {
+ transitionRotation = 360;
+ }
+
+ // If we're going fast enough, and we didn't already snap ahead of rotation,
+ // then snap ahead of rotation to simulate momentum
+ if (
+ this._lastRotateDelta > this._rotateMomentumThreshold &&
+ this.rotation > transitionRotation
+ ) {
+ transitionRotation += 90;
+ } else if (
+ this._lastRotateDelta < -1 * this._rotateMomentumThreshold &&
+ this.rotation < transitionRotation
+ ) {
+ transitionRotation -= 90;
+ }
+
+ // Only add the completeRotation class if it is is necessary
+ if (transitionRotation != this.rotation) {
+ contentElement.classList.add("completeRotation");
+ contentElement.addEventListener(
+ "transitionend",
+ this._clearCompleteRotation
+ );
+ }
+
+ contentElement.style.transform = "rotate(" + transitionRotation + "deg)";
+ this.rotation = transitionRotation;
+ },
+
+ /**
+ * Gets the current rotation for the ImageDocument
+ */
+ get rotation() {
+ return this._currentRotation;
+ },
+
+ /**
+ * Sets the current rotation for the ImageDocument
+ *
+ * @param aVal
+ * The new value to take. Can be any value, but it will be bounded to
+ * 0 inclusive to 360 exclusive.
+ */
+ set rotation(aVal) {
+ this._currentRotation = aVal % 360;
+ if (this._currentRotation < 0) {
+ this._currentRotation += 360;
+ }
+ },
+
+ /**
+ * When the location/tab changes, need to reload the current rotation for the
+ * image
+ */
+ restoreRotationState() {
+ // Bug 1108553 - Cannot rotate images in stand-alone image documents with e10s
+ if (gMultiProcessBrowser) {
+ return;
+ }
+
+ if (!ImageDocument.isInstance(window.content.document)) {
+ return;
+ }
+
+ let contentElement = window.content.document.body.firstElementChild;
+ let transformValue =
+ window.content.window.getComputedStyle(contentElement).transform;
+
+ if (transformValue == "none") {
+ this.rotation = 0;
+ return;
+ }
+
+ // transformValue is a rotation matrix--split it and do mathemagic to
+ // obtain the real rotation value
+ transformValue = transformValue.split("(")[1].split(")")[0].split(",");
+ this.rotation = Math.round(
+ Math.atan2(transformValue[1], transformValue[0]) * (180 / Math.PI)
+ );
+ },
+
+ /**
+ * Removes the transition rule by removing the completeRotation class
+ */
+ _clearCompleteRotation() {
+ let contentElement =
+ window.content.document &&
+ ImageDocument.isInstance(window.content.document) &&
+ window.content.document.body &&
+ window.content.document.body.firstElementChild;
+ if (!contentElement) {
+ return;
+ }
+ contentElement.classList.remove("completeRotation");
+ contentElement.removeEventListener(
+ "transitionend",
+ this._clearCompleteRotation
+ );
+ },
+};
+
+// History Swipe Animation Support (bug 678392)
+var gHistorySwipeAnimation = {
+ active: false,
+ isLTR: false,
+
+ /**
+ * Initializes the support for history swipe animations, if it is supported
+ * by the platform/configuration.
+ */
+ init: function HSA_init() {
+ this.isLTR = document.documentElement.matches(":-moz-locale-dir(ltr)");
+ this._isStoppingAnimation = false;
+
+ if (!this._isSupported()) {
+ return;
+ }
+
+ if (
+ Services.prefs.getBoolPref(
+ "browser.history_swipe_animation.disabled",
+ false
+ )
+ ) {
+ return;
+ }
+
+ this._icon = document.getElementById("swipe-nav-icon");
+ this._initPrefValues();
+ this._addPrefObserver();
+ this.active = true;
+ },
+
+ /**
+ * Uninitializes the support for history swipe animations.
+ */
+ uninit: function HSA_uninit() {
+ this._removePrefObserver();
+ this.active = false;
+ this.isLTR = false;
+ this._icon = null;
+ this._removeBoxes();
+ },
+
+ /**
+ * Starts the swipe animation.
+ *
+ * @param aIsVerticalSwipe
+ * Whether we're dealing with a vertical swipe or not.
+ */
+ startAnimation: function HSA_startAnimation() {
+ // old boxes can still be around (if completing fade out for example), we
+ // always want to remove them and recreate them because they can be
+ // attached to an old browser stack that's no longer in use.
+ this._removeBoxes();
+ this._isStoppingAnimation = false;
+ this._canGoBack = this.canGoBack();
+ this._canGoForward = this.canGoForward();
+ if (this.active) {
+ this._addBoxes();
+ }
+ this.updateAnimation(0);
+ },
+
+ /**
+ * Stops the swipe animation.
+ */
+ stopAnimation: function HSA_stopAnimation() {
+ if (!this.isAnimationRunning() || this._isStoppingAnimation) {
+ return;
+ }
+
+ let box = null;
+ if (!this._prevBox.collapsed) {
+ box = this._prevBox;
+ } else if (!this._nextBox.collapsed) {
+ box = this._nextBox;
+ }
+ if (box != null) {
+ this._isStoppingAnimation = true;
+ box.style.transition = "opacity 0.35s 0.35s cubic-bezier(.25,.1,0.25,1)";
+ box.addEventListener("transitionend", this, true);
+ box.style.opacity = 0;
+ window.getComputedStyle(box).opacity;
+ } else {
+ this._isStoppingAnimation = false;
+ this._removeBoxes();
+ }
+ },
+
+ _willGoBack: function HSA_willGoBack(aVal) {
+ return (
+ ((aVal > 0 && this.isLTR) || (aVal < 0 && !this.isLTR)) && this._canGoBack
+ );
+ },
+
+ _willGoForward: function HSA_willGoForward(aVal) {
+ return (
+ ((aVal > 0 && !this.isLTR) || (aVal < 0 && this.isLTR)) &&
+ this._canGoForward
+ );
+ },
+
+ /**
+ * Updates the animation between two pages in history.
+ *
+ * @param aVal
+ * A floating point value that represents the progress of the
+ * swipe gesture. History navigation will be triggered if the absolute
+ * value of this `aVal` is greater than or equal to 0.25.
+ */
+ updateAnimation: function HSA_updateAnimation(aVal) {
+ if (!this.isAnimationRunning() || this._isStoppingAnimation) {
+ return;
+ }
+
+ // Convert `aVal` into [0, 1] range.
+ // Note that absolute values of 0.25 (or greater) trigger history
+ // navigation, hence we multiply the value by 4 here.
+ const progress = Math.min(Math.abs(aVal) * 4, 1.0);
+
+ // Compute the icon position based on preferences.
+ let translate =
+ this.translateStartPosition +
+ progress * (this.translateEndPosition - this.translateStartPosition);
+ if (!this.isLTR) {
+ translate = -translate;
+ }
+
+ // Compute the icon radius based on preferences.
+ const radius =
+ this.minRadius + progress * (this.maxRadius - this.minRadius);
+ if (this._willGoBack(aVal)) {
+ this._prevBox.collapsed = false;
+ this._nextBox.collapsed = true;
+ this._prevBox.style.translate = `${translate}px 0px`;
+ if (radius >= 0) {
+ this._prevBox
+ .querySelectorAll("circle")[1]
+ .setAttribute("r", `${radius}`);
+ }
+
+ if (Math.abs(aVal) >= 0.25) {
+ // If `aVal` goes above 0.25, it means history navigation will be
+ // triggered once after the user lifts their fingers, it's time to
+ // trigger __indicator__ animations by adding `will-navigate` class.
+ this._prevBox.querySelector("svg").classList.add("will-navigate");
+ } else {
+ this._prevBox.querySelector("svg").classList.remove("will-navigate");
+ }
+ } else if (this._willGoForward(aVal)) {
+ // The intention is to go forward.
+ this._nextBox.collapsed = false;
+ this._prevBox.collapsed = true;
+ this._nextBox.style.translate = `${-translate}px 0px`;
+ if (radius >= 0) {
+ this._nextBox
+ .querySelectorAll("circle")[1]
+ .setAttribute("r", `${radius}`);
+ }
+
+ if (Math.abs(aVal) >= 0.25) {
+ // Same as above "go back" case.
+ this._nextBox.querySelector("svg").classList.add("will-navigate");
+ } else {
+ this._nextBox.querySelector("svg").classList.remove("will-navigate");
+ }
+ } else {
+ this._prevBox.collapsed = true;
+ this._nextBox.collapsed = true;
+ this._prevBox.style.translate = "none";
+ this._nextBox.style.translate = "none";
+ }
+ },
+
+ /**
+ * Checks whether the history swipe animation is currently running or not.
+ *
+ * @return true if the animation is currently running, false otherwise.
+ */
+ isAnimationRunning: function HSA_isAnimationRunning() {
+ return !!this._container;
+ },
+
+ /**
+ * Checks if there is a page in the browser history to go back to.
+ *
+ * @return true if there is a previous page in history, false otherwise.
+ */
+ canGoBack: function HSA_canGoBack() {
+ return gBrowser.webNavigation.canGoBack;
+ },
+
+ /**
+ * Checks if there is a page in the browser history to go forward to.
+ *
+ * @return true if there is a next page in history, false otherwise.
+ */
+ canGoForward: function HSA_canGoForward() {
+ return gBrowser.webNavigation.canGoForward;
+ },
+
+ /**
+ * Used to notify the history swipe animation that the OS sent a swipe end
+ * event and that we should navigate to the page that the user swiped to, if
+ * any. This will also result in the animation overlay to be torn down.
+ */
+ swipeEndEventReceived: function HSA_swipeEndEventReceived() {
+ this.stopAnimation();
+ },
+
+ /**
+ * Checks to see if history swipe animations are supported by this
+ * platform/configuration.
+ *
+ * return true if supported, false otherwise.
+ */
+ _isSupported: function HSA__isSupported() {
+ return window.matchMedia("(-moz-swipe-animation-enabled)").matches;
+ },
+
+ handleEvent: function HSA_handleEvent(aEvent) {
+ switch (aEvent.type) {
+ case "transitionend":
+ this._completeFadeOut();
+ break;
+ }
+ },
+
+ _completeFadeOut: function HSA__completeFadeOut(aEvent) {
+ if (!this._isStoppingAnimation) {
+ // The animation was restarted in the middle of our stopping fade out
+ // tranistion, so don't do anything.
+ return;
+ }
+ this._isStoppingAnimation = false;
+ gHistorySwipeAnimation._removeBoxes();
+ },
+
+ /**
+ * Adds the boxes that contain the arrows used during the swipe animation.
+ */
+ _addBoxes: function HSA__addBoxes() {
+ let browserStack = gBrowser.getPanel().querySelector(".browserStack");
+ this._container = this._createElement(
+ "historySwipeAnimationContainer",
+ "stack"
+ );
+ browserStack.appendChild(this._container);
+
+ this._prevBox = this._createElement(
+ "historySwipeAnimationPreviousArrow",
+ "box"
+ );
+ this._prevBox.collapsed = true;
+ this._container.appendChild(this._prevBox);
+ let icon = this._icon.cloneNode(true);
+ icon.classList.add("swipe-nav-icon");
+ this._prevBox.appendChild(icon);
+
+ this._nextBox = this._createElement(
+ "historySwipeAnimationNextArrow",
+ "box"
+ );
+ this._nextBox.collapsed = true;
+ this._container.appendChild(this._nextBox);
+ icon = this._icon.cloneNode(true);
+ icon.classList.add("swipe-nav-icon");
+ this._nextBox.appendChild(icon);
+ },
+
+ /**
+ * Removes the boxes.
+ */
+ _removeBoxes: function HSA__removeBoxes() {
+ this._prevBox = null;
+ this._nextBox = null;
+ if (this._container) {
+ this._container.remove();
+ }
+ this._container = null;
+ },
+
+ /**
+ * Creates an element with a given identifier and tag name.
+ *
+ * @param aID
+ * An identifier to create the element with.
+ * @param aTagName
+ * The name of the tag to create the element for.
+ * @return the newly created element.
+ */
+ _createElement: function HSA__createElement(aID, aTagName) {
+ let element = document.createXULElement(aTagName);
+ element.id = aID;
+ return element;
+ },
+
+ observe(subj, topic, data) {
+ switch (topic) {
+ case "nsPref:changed":
+ this._initPrefValues();
+ }
+ },
+
+ _initPrefValues: function HSA__initPrefValues() {
+ this.translateStartPosition = Services.prefs.getIntPref(
+ "browser.swipe.navigation-icon-start-position",
+ 0
+ );
+ this.translateEndPosition = Services.prefs.getIntPref(
+ "browser.swipe.navigation-icon-end-position",
+ 0
+ );
+ this.minRadius = Services.prefs.getIntPref(
+ "browser.swipe.navigation-icon-min-radius",
+ -1
+ );
+ this.maxRadius = Services.prefs.getIntPref(
+ "browser.swipe.navigation-icon-max-radius",
+ -1
+ );
+ },
+
+ _addPrefObserver: function HSA__addPrefObserver() {
+ [
+ "browser.swipe.navigation-icon-start-position",
+ "browser.swipe.navigation-icon-end-position",
+ "browser.swipe.navigation-icon-min-radius",
+ "browser.swipe.navigation-icon-max-radius",
+ ].forEach(pref => {
+ Services.prefs.addObserver(pref, this);
+ });
+ },
+
+ _removePrefObserver: function HSA__removePrefObserver() {
+ [
+ "browser.swipe.navigation-icon-start-position",
+ "browser.swipe.navigation-icon-end-position",
+ "browser.swipe.navigation-icon-min-radius",
+ "browser.swipe.navigation-icon-max-radius",
+ ].forEach(pref => {
+ Services.prefs.removeObserver(pref, this);
+ });
+ },
+};
diff --git a/browser/base/content/browser-graphics-utils.js b/browser/base/content/browser-graphics-utils.js
new file mode 100644
index 0000000000..4c802409e4
--- /dev/null
+++ b/browser/base/content/browser-graphics-utils.js
@@ -0,0 +1,59 @@
+/* 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/. */
+
+// This file is loaded into the browser window scope.
+/* eslint-env mozilla/browser-window */
+
+/**
+ * Global browser interface with graphics utilities.
+ */
+var gGfxUtils = {
+ _isRecording: false,
+ _isTransactionLogging: false,
+ _isCapturingFrames: false,
+
+ init() {
+ if (Services.prefs.getBoolPref("gfx.webrender.debug.enable-capture")) {
+ document.getElementById("wrCaptureCmd").removeAttribute("disabled");
+ document
+ .getElementById("wrToggleCaptureSequenceCmd")
+ .removeAttribute("disabled");
+ }
+ },
+
+ /**
+ * Toggle composition recording for the current window.
+ */
+ toggleWindowRecording() {
+ window.windowUtils.setCompositionRecording(!this._isRecording);
+ this._isRecording = !this._isRecording;
+ },
+ /**
+ * Trigger a WebRender capture of the current state into a local folder.
+ */
+ webrenderCapture() {
+ window.windowUtils.wrCapture();
+ },
+
+ captureSequencePath: "wr-capture-sequence",
+ captureSequenceFlags:
+ window.windowUtils.WR_CAPTURE_SCENE |
+ window.windowUtils.WR_CAPTURE_EXTERNALS,
+
+ /**
+ * Trigger a WebRender capture of the current state and future state
+ * into a local folder. If called again, it will stop capturing.
+ */
+ toggleWebrenderCaptureSequence() {
+ this._isCapturingFrames = !this._isCapturingFrames;
+ if (this._isCapturingFrames) {
+ window.windowUtils.wrStartCaptureSequence(
+ this.captureSequencePath,
+ this.captureSequenceFlags
+ );
+ } else {
+ window.windowUtils.wrStopCaptureSequence();
+ }
+ },
+};
diff --git a/browser/base/content/browser-menubar.inc b/browser/base/content/browser-menubar.inc
new file mode 100644
index 0000000000..6add92b0ac
--- /dev/null
+++ b/browser/base/content/browser-menubar.inc
@@ -0,0 +1,514 @@
+# -*- Mode: Java; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*-
+# 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/.
+
+ <menubar id="main-menubar"
+# On macOS, we don't track whether activation of the native menubar happened
+# with the keyboard.
+#ifndef XP_MACOSX
+ onpopupshowing="if (event.target.parentNode.parentNode == this)
+ this.setAttribute('openedwithkey',
+ event.target.parentNode.openedWithKey);"
+#endif
+ >
+ <menu id="file-menu" data-l10n-id="menu-file">
+ <menupopup id="menu_FilePopup"
+ onpopupshowing="gFileMenu.onPopupShowing(event);">
+ <menuitem id="menu_newNavigatorTab"
+ command="cmd_newNavigatorTab"
+ key="key_newNavigatorTab" data-l10n-id="menu-file-new-tab"/>
+ <menu id="menu_newUserContext"
+ hidden="true" data-l10n-id="menu-file-new-container-tab">
+ <menupopup onpopupshowing="return createUserContextMenu(event);" />
+ </menu>
+ <menuitem id="menu_newNavigator"
+ key="key_newNavigator"
+ command="cmd_newNavigator" data-l10n-id="menu-file-new-window"/>
+ <menuitem id="menu_newPrivateWindow"
+ command="Tools:PrivateBrowsing"
+ key="key_privatebrowsing" data-l10n-id="menu-file-new-private-window"/>
+ <menuitem id="menu_openLocation"
+ hidden="true"
+ command="Browser:OpenLocation"
+ key="focusURLBar" data-l10n-id="menu-file-open-location"/>
+ <menuitem id="menu_openFile"
+ command="Browser:OpenFile"
+ key="openFileKb" data-l10n-id="menu-file-open-file"/>
+ <menuitem id="menu_close"
+ class="show-only-for-keyboard"
+ key="key_close"
+ command="cmd_close" data-l10n-id="menu-file-close-tab" data-l10n-args='{"tabCount": 1}'/>
+ <menuitem id="menu_closeWindow"
+ class="show-only-for-keyboard"
+ hidden="true"
+ command="cmd_closeWindow"
+ key="key_closeWindow" data-l10n-id="menu-file-close-window"/>
+ <menuseparator/>
+ <menuitem id="menu_savePage"
+ key="key_savePage"
+ command="Browser:SavePage" data-l10n-id="menu-file-save-page"/>
+#if !defined(XP_MACOSX) || defined(MOZ_PROXY_BYPASS_PROTECTION)
+ <menuitem id="menu_sendLink"
+ command="Browser:SendLink" data-l10n-id="menu-file-email-link"/>
+#endif
+ <menuseparator/>
+#if !defined(MOZ_WIDGET_GTK)
+ <menuitem id="menu_printSetup"
+ command="cmd_pageSetup" data-l10n-id="menu-file-print-setup" hidden="true"/>
+#endif
+ <menuitem id="menu_print"
+ key="printKb"
+ command="cmd_print" data-l10n-id="menu-file-print"/>
+ <menuseparator/>
+ <menuitem id="menu_importFromAnotherBrowser"
+ command="cmd_file_importFromAnotherBrowser" data-l10n-id="menu-file-import-from-another-browser"/>
+ <menuseparator/>
+ <menuitem id="goOfflineMenuitem"
+ type="checkbox"
+ command="cmd_toggleOfflineStatus" data-l10n-id="menu-file-go-offline"/>
+ <menuitem id="menu_FileQuitItem"
+#ifdef XP_MACOSX
+ data-l10n-id="menu-quit-mac"
+#else
+ data-l10n-id="menu-quit"
+#endif
+ key="key_quitApplication"
+ command="cmd_quitApplication"/>
+ </menupopup>
+ </menu>
+
+ <menu id="edit-menu" data-l10n-id="menu-edit">
+ <menupopup id="menu_EditPopup"
+ onpopupshowing="updateEditUIVisibility()"
+ onpopuphidden="updateEditUIVisibility()">
+ <menuitem id="menu_undo"
+ key="key_undo"
+ command="cmd_undo" data-l10n-id="text-action-undo"/>
+ <menuitem id="menu_redo"
+ key="key_redo"
+ command="cmd_redo" data-l10n-id="text-action-redo"/>
+ <menuseparator/>
+ <menuitem id="menu_cut"
+ key="key_cut"
+ command="cmd_cut" data-l10n-id="text-action-cut"/>
+ <menuitem id="menu_copy"
+ key="key_copy"
+ command="cmd_copy" data-l10n-id="text-action-copy"/>
+ <menuitem id="menu_paste"
+ key="key_paste"
+ command="cmd_paste" data-l10n-id="text-action-paste"/>
+ <menuitem id="menu_delete"
+ key="key_delete"
+ command="cmd_delete" data-l10n-id="text-action-delete"/>
+ <menuseparator/>
+ <menuitem id="menu_selectAll"
+ key="key_selectAll"
+ command="cmd_selectAll" data-l10n-id="text-action-select-all"/>
+ <menuseparator/>
+ <menuitem id="menu_find"
+ key="key_find"
+ command="cmd_find" data-l10n-id="menu-edit-find-in-page"/>
+ <menuitem id="menu_findAgain"
+ class="show-only-for-keyboard"
+ key="key_findAgain"
+ command="cmd_findAgain" data-l10n-id="menu-edit-find-again"/>
+ <menuseparator hidden="true" id="textfieldDirection-separator"/>
+ <menuitem id="textfieldDirection-swap"
+ command="cmd_switchTextDirection"
+ key="key_switchTextDirection"
+ hidden="true" data-l10n-id="menu-edit-bidi-switch-text-direction"/>
+#ifdef XP_UNIX
+#ifndef XP_MACOSX
+ <menuseparator/>
+ <menuitem id="menu_preferences"
+ oncommand="openPreferences(undefined);"
+ data-l10n-id="menu-settings"
+ />
+#endif
+#endif
+ </menupopup>
+ </menu>
+
+ <menu id="view-menu" data-l10n-id="menu-view">
+ <menupopup id="menu_viewPopup">
+ <menu id="viewToolbarsMenu" data-l10n-id="menu-view-toolbars-menu">
+ <menupopup id="view-menu-popup" onpopupshowing="onViewToolbarsPopupShowing(event);">
+ <menuseparator/>
+ <menuitem id="menu_customizeToolbars"
+ command="cmd_CustomizeToolbars" data-l10n-id="menu-view-customize-toolbar2"/>
+ </menupopup>
+ </menu>
+ <menu id="viewSidebarMenuMenu" data-l10n-id="menu-view-sidebar">
+ <menupopup id="viewSidebarMenu">
+ <menuitem id="menu_bookmarksSidebar"
+ type="checkbox"
+ key="viewBookmarksSidebarKb"
+ oncommand="SidebarUI.toggle('viewBookmarksSidebar');" data-l10n-id="menu-view-bookmarks"/>
+ <menuitem id="menu_historySidebar"
+ type="checkbox"
+ key="key_gotoHistory"
+ oncommand="SidebarUI.toggle('viewHistorySidebar');" data-l10n-id="menu-view-history-button"/>
+ <menuitem id="menu_tabsSidebar"
+ type="checkbox"
+ class="sync-ui-item"
+ oncommand="SidebarUI.toggle('viewTabsSidebar');" data-l10n-id="menu-view-synced-tabs-sidebar"/>
+ </menupopup>
+ </menu>
+ <menuseparator/>
+ <menu id="viewFullZoomMenu" data-l10n-id="menu-view-full-zoom">
+ <menupopup>
+ <menuitem id="menu_zoomEnlarge"
+ key="key_fullZoomEnlarge"
+ command="cmd_fullZoomEnlarge" data-l10n-id="menu-view-full-zoom-enlarge"/>
+ <menuitem id="menu_zoomReduce"
+ key="key_fullZoomReduce"
+ command="cmd_fullZoomReduce" data-l10n-id="menu-view-full-zoom-reduce"/>
+ <menuseparator/>
+ <menuitem id="menu_zoomReset"
+ key="key_fullZoomReset"
+ command="cmd_fullZoomReset" data-l10n-id="menu-view-full-zoom-actual-size"/>
+ <menuseparator/>
+ <menuitem id="toggle_zoom"
+ type="checkbox"
+ command="cmd_fullZoomToggle"
+ checked="false" data-l10n-id="menu-view-full-zoom-toggle"/>
+ </menupopup>
+ </menu>
+ <menu id="pageStyleMenu" data-l10n-id="menu-view-page-style-menu">
+ <menupopup onpopupshowing="gPageStyleMenu.fillPopup(this);">
+ <menuitem id="menu_pageStyleNoStyle"
+ oncommand="gPageStyleMenu.disableStyle();"
+ type="radio" data-l10n-id="menu-view-page-style-no-style"/>
+ <menuitem id="menu_pageStylePersistentOnly"
+ oncommand="gPageStyleMenu.switchStyleSheet(null);"
+ type="radio"
+ checked="true" data-l10n-id="menu-view-page-basic-style"/>
+ <menuseparator/>
+ </menupopup>
+ </menu>
+ <menuitem id="repair-text-encoding"
+ disabled="true"
+ oncommand="BrowserForceEncodingDetection();"
+ data-l10n-id="menu-view-repair-text-encoding"/>
+ <menuseparator/>
+#ifdef XP_MACOSX
+ <menuitem id="enterFullScreenItem"
+ key="key_enterFullScreen" data-l10n-id="menu-view-enter-full-screen">
+ <observes element="View:FullScreen" attribute="oncommand"/>
+ </menuitem>
+ <menuitem id="exitFullScreenItem"
+ key="key_exitFullScreen"
+ hidden="true" data-l10n-id="menu-view-exit-full-screen">
+ <observes element="View:FullScreen" attribute="oncommand"/>
+ </menuitem>
+#else
+ <menuitem id="fullScreenItem"
+ key="key_enterFullScreen"
+ type="checkbox"
+ observes="View:FullScreen" data-l10n-id="menu-view-full-screen"/>
+#endif
+ <menuitem id="menu_readerModeItem"
+ observes="View:ReaderView"
+ key="key_toggleReaderMode"
+ data-l10n-id="menu-view-enter-readerview"
+ hidden="true"/>
+ <menuitem id="menu_showAllTabs"
+ hidden="true"
+ command="Browser:ShowAllTabs"
+ key="key_showAllTabs" data-l10n-id="menu-view-show-all-tabs"/>
+ <menuseparator hidden="true" id="documentDirection-separator"/>
+ <menuitem id="documentDirection-swap"
+ hidden="true"
+ oncommand="gBrowser.selectedBrowser.sendMessageToActor('SwitchDocumentDirection', {}, 'SwitchDocumentDirection', 'roots');" data-l10n-id="menu-view-bidi-switch-page-direction"/>
+ </menupopup>
+ </menu>
+
+ <menu id="history-menu" data-l10n-id="menu-history">
+ <menupopup id="historyMenuPopup"
+#ifndef XP_MACOSX
+ placespopup="true"
+ is="places-popup"
+#endif
+ oncommand="this.parentNode._placesView._onCommand(event);"
+ onpopupshowing="if (!this.parentNode._placesView)
+ new HistoryMenu(event);"
+ tooltip="bhTooltip"
+ popupsinherittooltip="true">
+ <menuitem id="menu_showAllHistory"
+ key="showAllHistoryKb"
+ command="Browser:ShowAllHistory" data-l10n-id="menu-history-show-all-history"/>
+ <menuitem id="sanitizeItem"
+ key="key_sanitize"
+ command="Tools:Sanitize" data-l10n-id="menu-history-clear-recent-history"/>
+ <menuseparator id="sanitizeSeparator"/>
+ <menuitem id="sync-tabs-menuitem"
+ oncommand="gSync.openSyncedTabsPanel();"
+ hidden="true" data-l10n-id="menu-history-synced-tabs"/>
+ <menuitem id="historyRestoreLastSession"
+ command="Browser:RestoreLastSession" data-l10n-id="menu-history-restore-last-session"/>
+ <menuitem id="hiddenTabsMenu"
+ oncommand="gTabsPanel.showHiddenTabsPanel(event, 'hidden-tabs-menuitem');"
+ hidden="true" data-l10n-id="menu-history-hidden-tabs"/>
+ <menu id="historyUndoMenu"
+ disabled="true" data-l10n-id="menu-history-undo-menu">
+ <menupopup id="historyUndoPopup"
+#ifndef XP_MACOSX
+ placespopup="true"
+ is="places-popup"
+#endif
+ onpopupshowing="document.getElementById('history-menu')._placesView.populateUndoSubmenu();"/>
+ </menu>
+ <menu id="historyUndoWindowMenu"
+ disabled="true" data-l10n-id="menu-history-undo-window-menu">
+ <menupopup id="historyUndoWindowPopup"
+#ifndef XP_MACOSX
+ placespopup="true"
+ is="places-popup"
+#endif
+ onpopupshowing="document.getElementById('history-menu')._placesView.populateUndoWindowSubmenu();">
+#ifdef HIDDEN_WINDOW
+# This entry is never visible. It's here to make the cmd-shift-n
+# shortcut work in the hidden window when the last window is closed.
+# If the menu is actually opened, we'll clear this out and replace
+# it with a "real" entry.
+# See bug 492320 for the nasty details.
+ <menuitem key="key_undoCloseWindow"
+ oncommand="undoCloseWindow(0)"
+ />
+#endif
+ </menupopup>
+ </menu>
+ <menuseparator id="startHistorySeparator"
+ class="hide-if-empty-places-result"/>
+ </menupopup>
+ </menu>
+
+ <menu id="bookmarksMenu"
+ ondragenter="PlacesMenuDNDHandler.onDragEnter(event);"
+ ondragover="PlacesMenuDNDHandler.onDragOver(event);"
+ ondrop="PlacesMenuDNDHandler.onDrop(event);"
+ data-l10n-id="menu-bookmarks-menu">
+ <menupopup id="bookmarksMenuPopup"
+#ifndef XP_MACOSX
+ placespopup="true"
+ is="places-popup"
+#endif
+ context="placesContext"
+ openInTabs="children"
+ onmouseup="BookmarksEventHandler.onMouseUp(event);"
+ oncommand="BookmarksEventHandler.onCommand(event);"
+ onclick="BookmarksEventHandler.onClick(event, this.parentNode._placesView);"
+ onpopupshowing="BookmarkingUI.onMainMenuPopupShowing(event);
+ if (!this.parentNode._placesView)
+ new PlacesMenu(event, `place:parent=${PlacesUtils.bookmarks.menuGuid}`);"
+ tooltip="bhTooltip" popupsinherittooltip="true">
+ <menuitem id="bookmarksShowAll"
+ command="Browser:ShowAllBookmarks"
+ key="manBookmarkKb"
+ data-l10n-id="menu-bookmarks-manage"/>
+ <menuseparator id="organizeBookmarksSeparator"/>
+ <menuitem id="menu_bookmarkThisPage"
+ command="Browser:AddBookmarkAs"
+ key="addBookmarkAsKb"
+ data-l10n-id="menu-bookmark-tab"/>
+ <menuitem id="menu_bookmarkAllTabs"
+ class="show-only-for-keyboard"
+ command="Browser:BookmarkAllTabs"
+ key="bookmarkAllTabsKb"
+ data-l10n-id="menu-bookmarks-all-tabs"/>
+ <menuseparator id="bookmarksToolbarSeparator"/>
+ <menu id="bookmarksToolbarFolderMenu"
+ class="menu-iconic bookmark-item"
+ container="true"
+ data-l10n-id="menu-bookmarks-toolbar">
+ <menupopup id="bookmarksToolbarFolderPopup"
+#ifndef XP_MACOSX
+ placespopup="true"
+ is="places-popup"
+#endif
+ context="placesContext"
+ onpopupshowing="if (!this.parentNode._placesView)
+ new PlacesMenu(event, `place:parent=${PlacesUtils.bookmarks.toolbarGuid}`);"/>
+ </menu>
+ <menu id="menu_unsortedBookmarks"
+ class="menu-iconic bookmark-item"
+ container="true"
+ data-l10n-id="menu-bookmarks-other">
+ <menupopup id="otherBookmarksFolderPopup"
+#ifndef XP_MACOSX
+ placespopup="true"
+ is="places-popup"
+#endif
+ context="placesContext"
+ onpopupshowing="if (!this.parentNode._placesView)
+ new PlacesMenu(event, `place:parent=${PlacesUtils.bookmarks.unfiledGuid}`);"/>
+ </menu>
+ <menu id="menu_mobileBookmarks"
+ class="menu-iconic bookmark-item"
+ hidden="true"
+ container="true"
+ data-l10n-id="menu-bookmarks-mobile">
+ <menupopup id="mobileBookmarksFolderPopup"
+#ifndef XP_MACOSX
+ placespopup="true"
+ is="places-popup"
+#endif
+ context="placesContext"
+ onpopupshowing="if (!this.parentNode._placesView)
+ new PlacesMenu(event, `place:parent=${PlacesUtils.bookmarks.mobileGuid}`);"/>
+ </menu>
+ <menuseparator id="bookmarksMenuItemsSeparator"/>
+ <!-- Bookmarks menu items -->
+ </menupopup>
+ </menu>
+
+ <menu id="tools-menu" data-l10n-id="menu-tools">
+ <menupopup id="menu_ToolsPopup">
+ <menuitem id="menu_openDownloads"
+ key="key_openDownloads"
+ command="Tools:Downloads" data-l10n-id="menu-tools-downloads"/>
+ <menuitem id="menu_openAddons"
+ key="key_openAddons"
+ command="Tools:Addons" data-l10n-id="menu-tools-addons-and-themes"/>
+
+ <!-- only one of sync-setup, sync-enable, sync-unverifieditem, sync-syncnowitem or sync-reauthitem will be showing at once -->
+ <menuitem id="sync-setup"
+ class="sync-ui-item"
+ hidden="true"
+ oncommand="gSync.openPrefs('menubar')" data-l10n-id="menu-tools-fxa-sign-in2"/>
+ <menuitem id="sync-enable"
+ class="sync-ui-item"
+ hidden="true"
+ oncommand="gSync.openPrefs('menubar')" data-l10n-id="menu-tools-turn-on-sync2"/>
+ <menuitem id="sync-unverifieditem"
+ class="sync-ui-item"
+ hidden="true"
+ oncommand="gSync.openPrefs('menubar')" data-l10n-id="menu-tools-fxa-sign-in2"/>
+ <menuitem id="sync-syncnowitem"
+ class="sync-ui-item"
+ hidden="true"
+ oncommand="gSync.doSync(event);" data-l10n-id="menu-tools-sync-now"/>
+ <menuitem id="sync-reauthitem"
+ class="sync-ui-item"
+ hidden="true"
+ oncommand="gSync.openSignInAgainPage('menubar');" data-l10n-id="menu-tools-fxa-re-auth"/>
+
+ <menuitem id="menu_openFirefoxView"
+ oncommand="FirefoxViewHandler.openTab();" data-l10n-id="menu-tools-firefox-view"/>
+ <menuseparator id="devToolsSeparator"/>
+ <menu id="browserToolsMenu" data-l10n-id="menu-tools-browser-tools">
+ <menupopup id="menuWebDeveloperPopup">
+ <menuitem id="menu_taskManager"
+ key="key_aboutProcesses"
+ command="View:AboutProcesses"
+ data-l10n-id="menu-tools-task-manager"/>
+ <menuitem id="menu_pageSource"
+ key="key_viewSource"
+ command="View:PageSource" data-l10n-id="menu-tools-page-source"/>
+ </menupopup>
+ </menu>
+ <menuitem id="menu_pageInfo"
+ key="key_viewInfo"
+ command="View:PageInfo" data-l10n-id="menu-tools-page-info"/>
+#ifndef XP_UNIX
+ <menuseparator id="prefSep"/>
+ <menuitem id="menu_preferences"
+ data-l10n-id="menu-settings"
+ oncommand="openPreferences(undefined);"/>
+#endif
+#ifdef MOZ_DEBUG
+ <menuitem id="menu_layout_debugger"
+ data-l10n-id="menu-tools-layout-debugger"
+ oncommand="toOpenWindowByType('mozapp:layoutdebug',
+ 'chrome://layoutdebug/content/layoutdebug.xhtml');"/>
+#endif
+#ifdef XP_MACOSX
+<!-- nsMenuBarX hides these and uses them to build the Application menu. -->
+ <menuitem id="menu_preferences"
+ data-l10n-id="menu-application-preferences"
+ key="key_preferencesCmdMac" oncommand="openPreferences(undefined);"/>
+ <menuitem id="menu_mac_services" data-l10n-id="menu-application-services"/>
+ <menuitem id="menu_mac_hide_app" data-l10n-id="menu-application-hide-this" key="key_hideThisAppCmdMac"/>
+ <menuitem id="menu_mac_hide_others" data-l10n-id="menu-application-hide-other" key="key_hideOtherAppsCmdMac"/>
+ <menuitem id="menu_mac_show_all" data-l10n-id="menu-application-show-all"/>
+ <menuitem id="menu_mac_touch_bar" data-l10n-id="menu-application-touch-bar"/>
+#endif
+ </menupopup>
+ </menu>
+#ifdef XP_MACOSX
+<!-- Do not dynamically modify the Window menu and be careful when making static changes to it.
+ macOS adds extra functionality to this menu, such as a list of windows, which can break when
+ modifying this menu. See bug 1642138 and bug 1807697 for example. -->
+ <menu id="windowMenu"
+ data-l10n-id="menu-window-menu">
+ <menupopup id="windowPopup">
+ <menuseparator/>
+ <menuitem command="minimizeWindow" key="key_minimizeWindow"/>
+ <menuitem command="zoomWindow"/>
+ <!-- decomment when "BringAllToFront" is implemented
+ <menuseparator/>
+ <menuitem disabled="true" data-l10n-id="menu-window-bring-all-to-front"/> -->
+ <menuseparator id="sep-window-list"/>
+ </menupopup>
+ </menu>
+#endif
+ <menu id="helpMenu" data-l10n-id="menu-help">
+ <menupopup id="menu_HelpPopup" onpopupshowing="buildHelpMenu();">
+<!-- Note: Items under here are cloned to the AppMenu Help submenu. The cloned items
+ have their strings defined by appmenu-data-l10n-id. -->
+ <menuitem id="menu_openHelp"
+ oncommand="openHelpLink('firefox-help')"
+ data-l10n-id="menu-get-help"
+ appmenu-data-l10n-id="appmenu-get-help"
+#ifdef XP_MACOSX
+ key="key_openHelpMac"/>
+#else
+ />
+#endif
+ <menuitem id="feedbackPage"
+ oncommand="openFeedbackPage()"
+ data-l10n-id="menu-help-share-ideas"
+ appmenu-data-l10n-id="appmenu-help-share-ideas"/>
+ <menuitem id="helpSafeMode"
+ oncommand="safeModeRestart();"
+ data-l10n-id="menu-help-enter-troubleshoot-mode2"
+ appmenu-data-l10n-id="appmenu-help-enter-troubleshoot-mode2"/>
+ <menuitem id="troubleShooting"
+ oncommand="openTroubleshootingPage()"
+ data-l10n-id="menu-help-more-troubleshooting-info"
+ appmenu-data-l10n-id="appmenu-help-more-troubleshooting-info"/>
+ <menuitem id="help_reportSiteIssue"
+ oncommand="ReportSiteIssue();"
+ data-l10n-id="menu-help-report-site-issue"
+ appmenu-data-l10n-id="appmenu-help-report-site-issue"
+ hidden="true"/>
+ <menuitem id="menu_HelpPopup_reportPhishingtoolmenu"
+ disabled="true"
+ oncommand="openUILink(gSafeBrowsing.getReportURL('Phish'), event, {triggeringPrincipal: Services.scriptSecurityManager.createNullPrincipal({})});"
+ hidden="true"
+ data-l10n-id="menu-help-report-deceptive-site"
+ appmenu-data-l10n-id="appmenu-help-report-deceptive-site"/>
+ <menuitem id="menu_HelpPopup_reportPhishingErrortoolmenu"
+ disabled="true"
+ oncommand="ReportFalseDeceptiveSite();"
+ data-l10n-id="menu-help-not-deceptive"
+ appmenu-data-l10n-id="appmenu-help-not-deceptive"
+ hidden="true"/>
+ <menuitem id="helpSwitchDevice"
+ oncommand="openSwitchingDevicesPage();"
+ data-l10n-id="menu-help-switch-device"
+ appmenu-data-l10n-id="appmenu-help-switch-device"/>
+ <menuseparator id="aboutSeparator"/>
+ <menuitem id="aboutName"
+ oncommand="openAboutDialog();"
+ data-l10n-id="menu-about"
+ appmenu-data-l10n-id="appmenu-about"/>
+ <menuseparator id="helpPolicySeparator"
+ hidden="true"/>
+ <menuitem id="helpPolicySupport"
+ hidden="true"
+ oncommand="openTrustedLinkIn(Services.policies.getSupportMenu().URL.href, 'tab');"/>
+ </menupopup>
+ </menu>
+ </menubar>
diff --git a/browser/base/content/browser-pageActions.js b/browser/base/content/browser-pageActions.js
new file mode 100644
index 0000000000..1cc895434d
--- /dev/null
+++ b/browser/base/content/browser-pageActions.js
@@ -0,0 +1,1015 @@
+/* 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/. */
+
+ChromeUtils.defineESModuleGetters(this, {
+ SearchUIUtils: "resource:///modules/SearchUIUtils.sys.mjs",
+});
+
+var BrowserPageActions = {
+ _panelNode: null,
+ /**
+ * The main page action button in the urlbar (DOM node)
+ */
+ get mainButtonNode() {
+ delete this.mainButtonNode;
+ return (this.mainButtonNode = document.getElementById("pageActionButton"));
+ },
+
+ /**
+ * The main page action panel DOM node (DOM node)
+ */
+ get panelNode() {
+ // Lazy load the page action panel the first time we need to display it
+ if (!this._panelNode) {
+ this.initializePanel();
+ }
+ delete this.panelNode;
+ return (this.panelNode = this._panelNode);
+ },
+
+ /**
+ * The panelmultiview node in the main page action panel (DOM node)
+ */
+ get multiViewNode() {
+ delete this.multiViewNode;
+ return (this.multiViewNode = document.getElementById(
+ "pageActionPanelMultiView"
+ ));
+ },
+
+ /**
+ * The main panelview node in the main page action panel (DOM node)
+ */
+ get mainViewNode() {
+ delete this.mainViewNode;
+ return (this.mainViewNode = document.getElementById(
+ "pageActionPanelMainView"
+ ));
+ },
+
+ /**
+ * The vbox body node in the main panelview node (DOM node)
+ */
+ get mainViewBodyNode() {
+ delete this.mainViewBodyNode;
+ return (this.mainViewBodyNode = this.mainViewNode.querySelector(
+ ".panel-subview-body"
+ ));
+ },
+
+ /**
+ * Inits. Call to init.
+ */
+ init() {
+ this.placeAllActionsInUrlbar();
+ this._onPanelShowing = this._onPanelShowing.bind(this);
+ },
+
+ _onPanelShowing() {
+ this.initializePanel();
+ for (let action of PageActions.actionsInPanel(window)) {
+ let buttonNode = this.panelButtonNodeForActionID(action.id);
+ action.onShowingInPanel(buttonNode);
+ }
+ },
+
+ placeLazyActionsInPanel() {
+ let actions = this._actionsToLazilyPlaceInPanel;
+ this._actionsToLazilyPlaceInPanel = [];
+ for (let action of actions) {
+ this._placeActionInPanelNow(action);
+ }
+ },
+
+ // Actions placed in the panel aren't actually placed until the panel is
+ // subsequently opened.
+ _actionsToLazilyPlaceInPanel: [],
+
+ /**
+ * Places all registered actions in the urlbar.
+ */
+ placeAllActionsInUrlbar() {
+ let urlbarActions = PageActions.actionsInUrlbar(window);
+ for (let action of urlbarActions) {
+ this.placeActionInUrlbar(action);
+ }
+ this._updateMainButtonAttributes();
+ },
+
+ /**
+ * Initializes the panel if necessary.
+ */
+ initializePanel() {
+ // Lazy load the page action panel the first time we need to display it
+ if (!this._panelNode) {
+ let template = document.getElementById("pageActionPanelTemplate");
+ template.replaceWith(template.content);
+ this._panelNode = document.getElementById("pageActionPanel");
+ this._panelNode.addEventListener("popupshowing", this._onPanelShowing);
+ }
+
+ for (let action of PageActions.actionsInPanel(window)) {
+ this.placeActionInPanel(action);
+ }
+ this.placeLazyActionsInPanel();
+ },
+
+ /**
+ * Adds or removes as necessary DOM nodes for the given action.
+ *
+ * @param action (PageActions.Action, required)
+ * The action to place.
+ */
+ placeAction(action) {
+ this.placeActionInPanel(action);
+ this.placeActionInUrlbar(action);
+ this._updateMainButtonAttributes();
+ },
+
+ /**
+ * Adds or removes as necessary DOM nodes for the action in the panel.
+ *
+ * @param action (PageActions.Action, required)
+ * The action to place.
+ */
+ placeActionInPanel(action) {
+ if (this._panelNode && this.panelNode.state != "closed") {
+ this._placeActionInPanelNow(action);
+ } else {
+ // This method may be called for the same action more than once
+ // (e.g. when an extension does call pageAction.show/hidden to
+ // enable or disable its own pageAction and we will have to
+ // update the urlbar overflow panel accordingly).
+ //
+ // Ensure we don't add the same actions more than once (otherwise we will
+ // not remove all the entries in _removeActionFromPanel).
+ if (
+ this._actionsToLazilyPlaceInPanel.findIndex(a => a.id == action.id) >= 0
+ ) {
+ return;
+ }
+ // Lazily place the action in the panel the next time it opens.
+ this._actionsToLazilyPlaceInPanel.push(action);
+ }
+ },
+
+ _placeActionInPanelNow(action) {
+ if (action.shouldShowInPanel(window)) {
+ this._addActionToPanel(action);
+ } else {
+ this._removeActionFromPanel(action);
+ }
+ },
+
+ _addActionToPanel(action) {
+ let id = this.panelButtonNodeIDForActionID(action.id);
+ let node = document.getElementById(id);
+ if (node) {
+ return;
+ }
+ this._maybeNotifyBeforePlacedInWindow(action);
+ node = this._makePanelButtonNodeForAction(action);
+ node.id = id;
+ let insertBeforeNode = this._getNextNode(action, false);
+ this.mainViewBodyNode.insertBefore(node, insertBeforeNode);
+ this.updateAction(action, null, {
+ panelNode: node,
+ });
+ this._updateActionDisabledInPanel(action, node);
+ action.onPlacedInPanel(node);
+ this._addOrRemoveSeparatorsInPanel();
+ },
+
+ _removeActionFromPanel(action) {
+ let lazyIndex = this._actionsToLazilyPlaceInPanel.findIndex(
+ a => a.id == action.id
+ );
+ if (lazyIndex >= 0) {
+ this._actionsToLazilyPlaceInPanel.splice(lazyIndex, 1);
+ }
+ let node = this.panelButtonNodeForActionID(action.id);
+ if (!node) {
+ return;
+ }
+ node.remove();
+ if (action.getWantsSubview(window)) {
+ let panelViewNodeID = this._panelViewNodeIDForActionID(action.id, false);
+ let panelViewNode = document.getElementById(panelViewNodeID);
+ if (panelViewNode) {
+ panelViewNode.remove();
+ }
+ }
+ this._addOrRemoveSeparatorsInPanel();
+ },
+
+ _addOrRemoveSeparatorsInPanel() {
+ let actions = PageActions.actionsInPanel(window);
+ let ids = [
+ PageActions.ACTION_ID_BUILT_IN_SEPARATOR,
+ PageActions.ACTION_ID_TRANSIENT_SEPARATOR,
+ ];
+ for (let id of ids) {
+ let sep = actions.find(a => a.id == id);
+ if (sep) {
+ this._addActionToPanel(sep);
+ } else {
+ let node = this.panelButtonNodeForActionID(id);
+ if (node) {
+ node.remove();
+ }
+ }
+ }
+ },
+
+ _updateMainButtonAttributes() {
+ this.mainButtonNode.toggleAttribute(
+ "multiple-children",
+ PageActions.actions.length > 1
+ );
+ },
+
+ /**
+ * Returns the node before which an action's node should be inserted.
+ *
+ * @param action (PageActions.Action, required)
+ * The action that will be inserted.
+ * @param forUrlbar (bool, required)
+ * True if you're inserting into the urlbar, false if you're inserting
+ * into the panel.
+ * @return (DOM node, maybe null) The DOM node before which to insert the
+ * given action. Null if the action should be inserted at the end.
+ */
+ _getNextNode(action, forUrlbar) {
+ let actions = forUrlbar
+ ? PageActions.actionsInUrlbar(window)
+ : PageActions.actionsInPanel(window);
+ let index = actions.findIndex(a => a.id == action.id);
+ if (index < 0) {
+ return null;
+ }
+ for (let i = index + 1; i < actions.length; i++) {
+ let node = forUrlbar
+ ? this.urlbarButtonNodeForActionID(actions[i].id)
+ : this.panelButtonNodeForActionID(actions[i].id);
+ if (node) {
+ return node;
+ }
+ }
+ return null;
+ },
+
+ _maybeNotifyBeforePlacedInWindow(action) {
+ if (!this._isActionPlacedInWindow(action)) {
+ action.onBeforePlacedInWindow(window);
+ }
+ },
+
+ _isActionPlacedInWindow(action) {
+ if (this.panelButtonNodeForActionID(action.id)) {
+ return true;
+ }
+ let urlbarNode = this.urlbarButtonNodeForActionID(action.id);
+ return urlbarNode && !urlbarNode.hidden;
+ },
+
+ _makePanelButtonNodeForAction(action) {
+ if (action.__isSeparator) {
+ let node = document.createXULElement("toolbarseparator");
+ return node;
+ }
+ let buttonNode = document.createXULElement("toolbarbutton");
+ buttonNode.classList.add(
+ "subviewbutton",
+ "subviewbutton-iconic",
+ "pageAction-panel-button"
+ );
+ if (action.isBadged) {
+ buttonNode.setAttribute("badged", "true");
+ }
+ buttonNode.setAttribute("actionid", action.id);
+ buttonNode.addEventListener("command", event => {
+ this.doCommandForAction(action, event, buttonNode);
+ });
+ return buttonNode;
+ },
+
+ _makePanelViewNodeForAction(action, forUrlbar) {
+ let panelViewNode = document.createXULElement("panelview");
+ panelViewNode.id = this._panelViewNodeIDForActionID(action.id, forUrlbar);
+ panelViewNode.classList.add("PanelUI-subView");
+ let bodyNode = document.createXULElement("vbox");
+ bodyNode.id = panelViewNode.id + "-body";
+ bodyNode.classList.add("panel-subview-body");
+ panelViewNode.appendChild(bodyNode);
+ return panelViewNode;
+ },
+
+ /**
+ * Shows or hides a panel for an action. You can supply your own panel;
+ * otherwise one is created.
+ *
+ * @param action (PageActions.Action, required)
+ * The action for which to toggle the panel. If the action is in the
+ * urlbar, then the panel will be anchored to it. Otherwise, a
+ * suitable anchor will be used.
+ * @param panelNode (DOM node, optional)
+ * The panel to use. This method takes a hands-off approach with
+ * regard to your panel in terms of attributes, styling, etc.
+ * @param event (DOM event, optional)
+ * The event which triggered this panel.
+ */
+ togglePanelForAction(action, panelNode = null, event = null) {
+ let aaPanelNode = this.activatedActionPanelNode;
+ if (panelNode) {
+ // Note that this particular code path will not prevent the panel from
+ // opening later if PanelMultiView.showPopup was called but the panel has
+ // not been opened yet.
+ if (panelNode.state != "closed") {
+ PanelMultiView.hidePopup(panelNode);
+ return;
+ }
+ if (aaPanelNode) {
+ PanelMultiView.hidePopup(aaPanelNode);
+ }
+ } else if (aaPanelNode) {
+ PanelMultiView.hidePopup(aaPanelNode);
+ return;
+ } else {
+ panelNode = this._makeActivatedActionPanelForAction(action);
+ }
+
+ // Hide the main panel before showing the action's panel.
+ PanelMultiView.hidePopup(this.panelNode);
+
+ let anchorNode = this.panelAnchorNodeForAction(action);
+ PanelMultiView.openPopup(panelNode, anchorNode, {
+ position: "bottomright topright",
+ triggerEvent: event,
+ }).catch(console.error);
+ },
+
+ _makeActivatedActionPanelForAction(action) {
+ let panelNode = document.createXULElement("panel");
+ panelNode.id = this._activatedActionPanelID;
+ panelNode.classList.add("cui-widget-panel", "panel-no-padding");
+ panelNode.setAttribute("actionID", action.id);
+ panelNode.setAttribute("role", "group");
+ panelNode.setAttribute("type", "arrow");
+ panelNode.setAttribute("flip", "slide");
+ panelNode.setAttribute("noautofocus", "true");
+ panelNode.setAttribute("tabspecific", "true");
+
+ let panelViewNode = null;
+ let iframeNode = null;
+
+ if (action.getWantsSubview(window)) {
+ let multiViewNode = document.createXULElement("panelmultiview");
+ panelViewNode = this._makePanelViewNodeForAction(action, true);
+ multiViewNode.setAttribute("mainViewId", panelViewNode.id);
+ multiViewNode.appendChild(panelViewNode);
+ panelNode.appendChild(multiViewNode);
+ } else if (action.wantsIframe) {
+ iframeNode = document.createXULElement("iframe");
+ iframeNode.setAttribute("type", "content");
+ panelNode.appendChild(iframeNode);
+ }
+
+ let popupSet = document.getElementById("mainPopupSet");
+ popupSet.appendChild(panelNode);
+ panelNode.addEventListener(
+ "popuphidden",
+ () => {
+ PanelMultiView.removePopup(panelNode);
+ },
+ { once: true }
+ );
+
+ if (iframeNode) {
+ panelNode.addEventListener(
+ "popupshowing",
+ () => {
+ action.onIframeShowing(iframeNode, panelNode);
+ },
+ { once: true }
+ );
+ panelNode.addEventListener(
+ "popupshown",
+ () => {
+ iframeNode.focus();
+ },
+ { once: true }
+ );
+ panelNode.addEventListener(
+ "popuphiding",
+ () => {
+ action.onIframeHiding(iframeNode, panelNode);
+ },
+ { once: true }
+ );
+ panelNode.addEventListener(
+ "popuphidden",
+ () => {
+ action.onIframeHidden(iframeNode, panelNode);
+ },
+ { once: true }
+ );
+ }
+
+ if (panelViewNode) {
+ action.onSubviewPlaced(panelViewNode);
+ panelNode.addEventListener(
+ "popupshowing",
+ () => {
+ action.onSubviewShowing(panelViewNode);
+ },
+ { once: true }
+ );
+ }
+
+ return panelNode;
+ },
+
+ /**
+ * Returns the node in the urlbar to which popups for the given action should
+ * be anchored. If the action is null, a sensible anchor is returned.
+ *
+ * @param action (PageActions.Action, optional)
+ * The action you want to anchor.
+ * @param event (DOM event, optional)
+ * This is used to display the feedback panel on the right node when
+ * the command can be invoked from both the main panel and another
+ * location, such as an activated action panel or a button.
+ * @return (DOM node) The node to which the action should be anchored.
+ */
+ panelAnchorNodeForAction(action, event) {
+ if (event && event.target.closest("panel") == this.panelNode) {
+ return this.mainButtonNode;
+ }
+
+ // Try each of the following nodes in order, using the first that's visible.
+ let potentialAnchorNodeIDs = [
+ action && action.anchorIDOverride,
+ action && this.urlbarButtonNodeIDForActionID(action.id),
+ this.mainButtonNode.id,
+ "identity-icon",
+ "urlbar-search-button",
+ ];
+ for (let id of potentialAnchorNodeIDs) {
+ if (id) {
+ let node = document.getElementById(id);
+ if (node && !node.hidden) {
+ let bounds = window.windowUtils.getBoundsWithoutFlushing(node);
+ if (bounds.height > 0 && bounds.width > 0) {
+ return node;
+ }
+ }
+ }
+ }
+ let id = action ? action.id : "<no action>";
+ throw new Error(`PageActions: No anchor node for ${id}`);
+ },
+
+ get activatedActionPanelNode() {
+ return document.getElementById(this._activatedActionPanelID);
+ },
+
+ get _activatedActionPanelID() {
+ return "pageActionActivatedActionPanel";
+ },
+
+ /**
+ * Adds or removes as necessary a DOM node for the given action in the urlbar.
+ *
+ * @param action (PageActions.Action, required)
+ * The action to place.
+ */
+ placeActionInUrlbar(action) {
+ let id = this.urlbarButtonNodeIDForActionID(action.id);
+ let node = document.getElementById(id);
+
+ if (!action.shouldShowInUrlbar(window)) {
+ if (node) {
+ if (action.__urlbarNodeInMarkup) {
+ node.hidden = true;
+ } else {
+ node.remove();
+ }
+ }
+ return;
+ }
+
+ let newlyPlaced = false;
+ if (action.__urlbarNodeInMarkup) {
+ this._maybeNotifyBeforePlacedInWindow(action);
+ // Allow the consumer to add the node in response to the
+ // onBeforePlacedInWindow notification.
+ node = document.getElementById(id);
+ if (!node) {
+ return;
+ }
+ newlyPlaced = node.hidden;
+ node.hidden = false;
+ } else if (!node) {
+ newlyPlaced = true;
+ this._maybeNotifyBeforePlacedInWindow(action);
+ node = this._makeUrlbarButtonNode(action);
+ node.id = id;
+ }
+
+ if (!newlyPlaced) {
+ return;
+ }
+
+ let insertBeforeNode = this._getNextNode(action, true);
+ this.mainButtonNode.parentNode.insertBefore(node, insertBeforeNode);
+ this.updateAction(action, null, {
+ urlbarNode: node,
+ });
+ action.onPlacedInUrlbar(node);
+ },
+
+ _makeUrlbarButtonNode(action) {
+ let buttonNode = document.createXULElement("hbox");
+ buttonNode.classList.add("urlbar-page-action");
+ if (action.extensionID) {
+ buttonNode.classList.add("urlbar-addon-page-action");
+ }
+ buttonNode.setAttribute("actionid", action.id);
+ buttonNode.setAttribute("role", "button");
+ let commandHandler = event => {
+ this.doCommandForAction(action, event, buttonNode);
+ };
+ buttonNode.addEventListener("click", commandHandler);
+ buttonNode.addEventListener("keypress", commandHandler);
+
+ let imageNode = document.createXULElement("image");
+ imageNode.classList.add("urlbar-icon");
+ buttonNode.appendChild(imageNode);
+ return buttonNode;
+ },
+
+ /**
+ * Removes all the DOM nodes of the given action.
+ *
+ * @param action (PageActions.Action, required)
+ * The action to remove.
+ */
+ removeAction(action) {
+ this._removeActionFromPanel(action);
+ this._removeActionFromUrlbar(action);
+ action.onRemovedFromWindow(window);
+ this._updateMainButtonAttributes();
+ },
+
+ _removeActionFromUrlbar(action) {
+ let node = this.urlbarButtonNodeForActionID(action.id);
+ if (node) {
+ node.remove();
+ }
+ },
+
+ /**
+ * Updates the DOM nodes of an action to reflect either a changed property or
+ * all properties.
+ *
+ * @param action (PageActions.Action, required)
+ * The action to update.
+ * @param propertyName (string, optional)
+ * The name of the property to update. If not given, then DOM nodes
+ * will be updated to reflect the current values of all properties.
+ * @param opts (object, optional)
+ * - panelNode: The action's node in the panel to update.
+ * - urlbarNode: The action's node in the urlbar to update.
+ * - value: If a property name is passed, this argument may contain
+ * its current value, in order to prevent a further look-up.
+ */
+ updateAction(action, propertyName = null, opts = {}) {
+ let anyNodeGiven = "panelNode" in opts || "urlbarNode" in opts;
+ let panelNode = anyNodeGiven
+ ? opts.panelNode || null
+ : this.panelButtonNodeForActionID(action.id);
+ let urlbarNode = anyNodeGiven
+ ? opts.urlbarNode || null
+ : this.urlbarButtonNodeForActionID(action.id);
+ let value = opts.value || undefined;
+ if (propertyName) {
+ this[this._updateMethods[propertyName]](
+ action,
+ panelNode,
+ urlbarNode,
+ value
+ );
+ } else {
+ for (let name of ["iconURL", "title", "tooltip", "wantsSubview"]) {
+ this[this._updateMethods[name]](action, panelNode, urlbarNode, value);
+ }
+ }
+ },
+
+ _updateMethods: {
+ disabled: "_updateActionDisabled",
+ iconURL: "_updateActionIconURL",
+ title: "_updateActionLabeling",
+ tooltip: "_updateActionTooltip",
+ wantsSubview: "_updateActionWantsSubview",
+ },
+
+ _updateActionDisabled(
+ action,
+ panelNode,
+ urlbarNode,
+ disabled = action.getDisabled(window)
+ ) {
+ // Extension page actions should behave like a transient action,
+ // and be hidden from the urlbar overflow menu if they
+ // are disabled (as in the urlbar when the overflow menu isn't available)
+ //
+ // TODO(Bug 1704139): as a follow up we may look into just set on all
+ // extension pageActions `_transient: true`, at least once we sunset
+ // the proton preference and we don't need the pre-Proton behavior anymore,
+ // and remove this special case.
+ const isProtonExtensionAction = action.extensionID;
+
+ if (action.__transient || isProtonExtensionAction) {
+ this.placeActionInPanel(action);
+ } else {
+ this._updateActionDisabledInPanel(action, panelNode, disabled);
+ }
+ this.placeActionInUrlbar(action);
+ },
+
+ _updateActionDisabledInPanel(
+ action,
+ panelNode,
+ disabled = action.getDisabled(window)
+ ) {
+ if (panelNode) {
+ if (disabled) {
+ panelNode.setAttribute("disabled", "true");
+ } else {
+ panelNode.removeAttribute("disabled");
+ }
+ }
+ },
+
+ _updateActionIconURL(
+ action,
+ panelNode,
+ urlbarNode,
+ properties = action.getIconProperties(window)
+ ) {
+ for (let [prop, value] of Object.entries(properties)) {
+ if (panelNode) {
+ panelNode.style.setProperty(prop, value);
+ }
+ if (urlbarNode) {
+ urlbarNode.style.setProperty(prop, value);
+ }
+ }
+ },
+
+ _updateActionLabeling(
+ action,
+ panelNode,
+ urlbarNode,
+ title = action.getTitle(window)
+ ) {
+ if (panelNode) {
+ panelNode.setAttribute("label", title);
+ }
+ if (urlbarNode) {
+ urlbarNode.setAttribute("aria-label", title);
+ // tooltiptext falls back to the title, so update it too if necessary.
+ let tooltip = action.getTooltip(window);
+ if (!tooltip) {
+ urlbarNode.setAttribute("tooltiptext", title);
+ }
+ }
+ },
+
+ _updateActionTooltip(
+ action,
+ panelNode,
+ urlbarNode,
+ tooltip = action.getTooltip(window)
+ ) {
+ if (urlbarNode) {
+ if (!tooltip) {
+ tooltip = action.getTitle(window);
+ }
+ if (tooltip) {
+ urlbarNode.setAttribute("tooltiptext", tooltip);
+ }
+ }
+ },
+
+ _updateActionWantsSubview(
+ action,
+ panelNode,
+ urlbarNode,
+ wantsSubview = action.getWantsSubview(window)
+ ) {
+ if (!panelNode) {
+ return;
+ }
+ let panelViewID = this._panelViewNodeIDForActionID(action.id, false);
+ let panelViewNode = document.getElementById(panelViewID);
+ panelNode.classList.toggle("subviewbutton-nav", wantsSubview);
+ if (!wantsSubview) {
+ if (panelViewNode) {
+ panelViewNode.remove();
+ }
+ return;
+ }
+ if (!panelViewNode) {
+ panelViewNode = this._makePanelViewNodeForAction(action, false);
+ this.multiViewNode.appendChild(panelViewNode);
+ action.onSubviewPlaced(panelViewNode);
+ }
+ },
+
+ doCommandForAction(action, event, buttonNode) {
+ if (event && event.type == "click" && event.button != 0) {
+ return;
+ }
+ if (event && event.type == "keypress") {
+ if (event.key != " " && event.key != "Enter") {
+ return;
+ }
+ event.stopPropagation();
+ }
+ // If we're in the panel, open a subview inside the panel:
+ // Note that we can't use this.panelNode.contains(buttonNode) here
+ // because of XBL boundaries breaking Element.contains.
+ if (
+ action.getWantsSubview(window) &&
+ buttonNode &&
+ buttonNode.closest("panel") == this.panelNode
+ ) {
+ let panelViewNodeID = this._panelViewNodeIDForActionID(action.id, false);
+ let panelViewNode = document.getElementById(panelViewNodeID);
+ action.onSubviewShowing(panelViewNode);
+ this.multiViewNode.showSubView(panelViewNode, buttonNode);
+ return;
+ }
+ // Otherwise, hide the main popup in case it was open:
+ PanelMultiView.hidePopup(this.panelNode);
+
+ let aaPanelNode = this.activatedActionPanelNode;
+ if (!aaPanelNode || aaPanelNode.getAttribute("actionID") != action.id) {
+ action.onCommand(event, buttonNode);
+ }
+ if (action.getWantsSubview(window) || action.wantsIframe) {
+ this.togglePanelForAction(action, null, event);
+ }
+ },
+
+ /**
+ * Returns the action for a node.
+ *
+ * @param node (DOM node, required)
+ * A button DOM node, either one that's shown in the page action panel
+ * or the urlbar.
+ * @return (PageAction.Action) If the node has a related action and the action
+ * is not a separator, then the action is returned. Otherwise null is
+ * returned.
+ */
+ actionForNode(node) {
+ if (!node) {
+ return null;
+ }
+ let actionID = this._actionIDForNodeID(node.id);
+ let action = PageActions.actionForID(actionID);
+ if (!action) {
+ // When a page action is clicked, `node` will be an ancestor of
+ // a node corresponding to an action. `node` will be the page action node
+ // itself when a page action is selected with the keyboard. That's because
+ // the semantic meaning of page action is on an hbox that contains an
+ // <image>.
+ for (let n = node.parentNode; n && !action; n = n.parentNode) {
+ if (n.id == "page-action-buttons" || n.localName == "panelview") {
+ // We reached the page-action-buttons or panelview container.
+ // Stop looking; no action was found.
+ break;
+ }
+ actionID = this._actionIDForNodeID(n.id);
+ action = PageActions.actionForID(actionID);
+ }
+ }
+ return action && !action.__isSeparator ? action : null;
+ },
+
+ /**
+ * The given action's top-level button in the main panel.
+ *
+ * @param actionID (string, required)
+ * The action ID.
+ * @return (DOM node) The action's button in the main panel.
+ */
+ panelButtonNodeForActionID(actionID) {
+ return document.getElementById(this.panelButtonNodeIDForActionID(actionID));
+ },
+
+ /**
+ * The ID of the given action's top-level button in the main panel.
+ *
+ * @param actionID (string, required)
+ * The action ID.
+ * @return (string) The ID of the action's button in the main panel.
+ */
+ panelButtonNodeIDForActionID(actionID) {
+ return `pageAction-panel-${actionID}`;
+ },
+
+ /**
+ * The given action's button in the urlbar.
+ *
+ * @param actionID (string, required)
+ * The action ID.
+ * @return (DOM node) The action's urlbar button node.
+ */
+ urlbarButtonNodeForActionID(actionID) {
+ return document.getElementById(
+ this.urlbarButtonNodeIDForActionID(actionID)
+ );
+ },
+
+ /**
+ * The ID of the given action's button in the urlbar.
+ *
+ * @param actionID (string, required)
+ * The action ID.
+ * @return (string) The ID of the action's urlbar button node.
+ */
+ urlbarButtonNodeIDForActionID(actionID) {
+ let action = PageActions.actionForID(actionID);
+ if (action && action.urlbarIDOverride) {
+ return action.urlbarIDOverride;
+ }
+ return `pageAction-urlbar-${actionID}`;
+ },
+
+ // The ID of the given action's panelview.
+ _panelViewNodeIDForActionID(actionID, forUrlbar) {
+ let placementID = forUrlbar ? "urlbar" : "panel";
+ return `pageAction-${placementID}-${actionID}-subview`;
+ },
+
+ // The ID of the action corresponding to the given top-level button in the
+ // panel or button in the urlbar.
+ _actionIDForNodeID(nodeID) {
+ if (!nodeID) {
+ return null;
+ }
+ let match = nodeID.match(/^pageAction-(?:panel|urlbar)-(.+)$/);
+ if (match) {
+ return match[1];
+ }
+ // Check all the urlbar ID overrides.
+ for (let action of PageActions.actions) {
+ if (action.urlbarIDOverride && action.urlbarIDOverride == nodeID) {
+ return action.id;
+ }
+ }
+ return null;
+ },
+
+ /**
+ * Call this when the main page action button in the urlbar is activated.
+ *
+ * @param event (DOM event, required)
+ * The click or whatever event.
+ */
+ mainButtonClicked(event) {
+ event.stopPropagation();
+ if (
+ // On mac, ctrl-click will send a context menu event from the widget, so
+ // we don't want to bring up the panel when ctrl key is pressed.
+ (event.type == "mousedown" &&
+ (event.button != 0 ||
+ (AppConstants.platform == "macosx" && event.ctrlKey))) ||
+ (event.type == "keypress" &&
+ event.charCode != KeyEvent.DOM_VK_SPACE &&
+ event.keyCode != KeyEvent.DOM_VK_RETURN)
+ ) {
+ return;
+ }
+
+ // If the activated-action panel is open and anchored to the main button,
+ // close it.
+ let panelNode = this.activatedActionPanelNode;
+ if (panelNode && panelNode.anchorNode.id == this.mainButtonNode.id) {
+ PanelMultiView.hidePopup(panelNode);
+ return;
+ }
+
+ if (this.panelNode.state == "open") {
+ PanelMultiView.hidePopup(this.panelNode);
+ } else if (this.panelNode.state == "closed") {
+ this.showPanel(event);
+ }
+ },
+
+ /**
+ * Show the page action panel
+ *
+ * @param event (DOM event, optional)
+ * The event that triggers showing the panel. (such as a mouse click,
+ * if the user clicked something to open the panel)
+ */
+ showPanel(event = null) {
+ this.panelNode.hidden = false;
+ PanelMultiView.openPopup(this.panelNode, this.mainButtonNode, {
+ position: "bottomright topright",
+ triggerEvent: event,
+ }).catch(console.error);
+ },
+
+ /**
+ * Call this on the context menu's popupshowing event.
+ *
+ * @param event (DOM event, required)
+ * The popupshowing event.
+ * @param popup (DOM node, required)
+ * The context menu popup DOM node.
+ */
+ async onContextMenuShowing(event, popup) {
+ if (event.target != popup) {
+ return;
+ }
+
+ let action = this.actionForNode(popup.triggerNode);
+ // Only extension actions provide a context menu.
+ if (!action?.extensionID) {
+ this._contextAction = null;
+ event.preventDefault();
+ return;
+ }
+ this._contextAction = action;
+
+ let removeExtension = popup.querySelector(".removeExtensionItem");
+ let { extensionID } = this._contextAction;
+ let addon = extensionID && (await AddonManager.getAddonByID(extensionID));
+ removeExtension.hidden = !addon;
+ if (addon) {
+ removeExtension.disabled = !(
+ addon.permissions & AddonManager.PERM_CAN_UNINSTALL
+ );
+ }
+ },
+
+ /**
+ * Call this from the menu item in the context menu that opens about:addons.
+ */
+ openAboutAddonsForContextAction() {
+ if (!this._contextAction) {
+ return;
+ }
+ let action = this._contextAction;
+ this._contextAction = null;
+
+ let viewID = "addons://detail/" + encodeURIComponent(action.extensionID);
+ window.BrowserOpenAddonsMgr(viewID);
+ },
+
+ /**
+ * Call this from the menu item in the context menu that removes an add-on.
+ */
+ removeExtensionForContextAction() {
+ if (!this._contextAction) {
+ return;
+ }
+ let action = this._contextAction;
+ this._contextAction = null;
+
+ BrowserAddonUI.removeAddon(action.extensionID, "pageAction");
+ },
+
+ _contextAction: null,
+
+ /**
+ * Call this on tab switch or when the current <browser>'s location changes.
+ */
+ onLocationChange() {
+ for (let action of PageActions.actions) {
+ action.onLocationChange(window);
+ }
+ },
+};
+
+// built-in actions below //////////////////////////////////////////////////////
+
+// bookmark
+BrowserPageActions.bookmark = {
+ onShowingInPanel(buttonNode) {
+ if (buttonNode.label == "null") {
+ BookmarkingUI.updateBookmarkPageMenuItem();
+ }
+ },
+
+ onCommand(event, buttonNode) {
+ PanelMultiView.hidePopup(BrowserPageActions.panelNode);
+ BookmarkingUI.onStarCommand(event);
+ },
+};
diff --git a/browser/base/content/browser-pagestyle.js b/browser/base/content/browser-pagestyle.js
new file mode 100644
index 0000000000..4266ffd69e
--- /dev/null
+++ b/browser/base/content/browser-pagestyle.js
@@ -0,0 +1,125 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+ * 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/. */
+
+/* eslint-env mozilla/browser-window */
+
+var gPageStyleMenu = {
+ _getStyleSheetInfo(browser) {
+ let actor =
+ browser.browsingContext.currentWindowGlobal?.getActor("PageStyle");
+ let styleSheetInfo;
+ if (actor) {
+ styleSheetInfo = actor.getSheetInfo();
+ } else {
+ // Fallback if the actor is missing or we don't have a window global.
+ // It's unlikely things will work well but let's be optimistic,
+ // rather than throwing exceptions immediately.
+ styleSheetInfo = {
+ filteredStyleSheets: [],
+ preferredStyleSheetSet: true,
+ };
+ }
+ return styleSheetInfo;
+ },
+
+ fillPopup(menuPopup) {
+ let styleSheetInfo = this._getStyleSheetInfo(gBrowser.selectedBrowser);
+ var noStyle = menuPopup.firstElementChild;
+ var persistentOnly = noStyle.nextElementSibling;
+ var sep = persistentOnly.nextElementSibling;
+ while (sep.nextElementSibling) {
+ menuPopup.removeChild(sep.nextElementSibling);
+ }
+
+ let styleSheets = styleSheetInfo.filteredStyleSheets;
+ var currentStyleSheets = {};
+ var styleDisabled =
+ !!gBrowser.selectedBrowser.browsingContext?.authorStyleDisabledDefault;
+ var haveAltSheets = false;
+ var altStyleSelected = false;
+
+ for (let currentStyleSheet of styleSheets) {
+ if (!currentStyleSheet.disabled) {
+ altStyleSelected = true;
+ }
+
+ haveAltSheets = true;
+
+ let lastWithSameTitle = null;
+ if (currentStyleSheet.title in currentStyleSheets) {
+ lastWithSameTitle = currentStyleSheets[currentStyleSheet.title];
+ }
+
+ if (!lastWithSameTitle) {
+ let menuItem = document.createXULElement("menuitem");
+ menuItem.setAttribute("type", "radio");
+ menuItem.setAttribute("label", currentStyleSheet.title);
+ menuItem.setAttribute("data", currentStyleSheet.title);
+ menuItem.setAttribute(
+ "checked",
+ !currentStyleSheet.disabled && !styleDisabled
+ );
+ menuItem.setAttribute(
+ "oncommand",
+ "gPageStyleMenu.switchStyleSheet(this.getAttribute('data'));"
+ );
+ menuPopup.appendChild(menuItem);
+ currentStyleSheets[currentStyleSheet.title] = menuItem;
+ } else if (currentStyleSheet.disabled) {
+ lastWithSameTitle.removeAttribute("checked");
+ }
+ }
+
+ noStyle.setAttribute("checked", styleDisabled);
+ persistentOnly.setAttribute("checked", !altStyleSelected && !styleDisabled);
+ persistentOnly.hidden = styleSheetInfo.preferredStyleSheetSet
+ ? haveAltSheets
+ : false;
+ sep.hidden = (noStyle.hidden && persistentOnly.hidden) || !haveAltSheets;
+ },
+
+ /**
+ * Send a message to all PageStyleParents by walking the BrowsingContext tree.
+ * @param message
+ * The string message to send to each PageStyleChild.
+ * @param data
+ * The data to send to each PageStyleChild within the message.
+ */
+ _sendMessageToAll(message, data) {
+ let contextsToVisit = [gBrowser.selectedBrowser.browsingContext];
+ while (contextsToVisit.length) {
+ let currentContext = contextsToVisit.pop();
+ let global = currentContext.currentWindowGlobal;
+
+ if (!global) {
+ continue;
+ }
+
+ let actor = global.getActor("PageStyle");
+ actor.sendAsyncMessage(message, data);
+
+ contextsToVisit.push(...currentContext.children);
+ }
+ },
+
+ /**
+ * Switch the stylesheet of all documents in the current browser.
+ * @param title The title of the stylesheet to switch to.
+ */
+ switchStyleSheet(title) {
+ let sheetData = this._getStyleSheetInfo(gBrowser.selectedBrowser);
+ for (let sheet of sheetData.filteredStyleSheets) {
+ sheet.disabled = sheet.title !== title;
+ }
+ this._sendMessageToAll("PageStyle:Switch", { title });
+ },
+
+ /**
+ * Disable all stylesheets. Called with View > Page Style > No Style.
+ */
+ disableStyle() {
+ this._sendMessageToAll("PageStyle:Disable", {});
+ },
+};
diff --git a/browser/base/content/browser-places.js b/browser/base/content/browser-places.js
new file mode 100644
index 0000000000..1b9cfb9b76
--- /dev/null
+++ b/browser/base/content/browser-places.js
@@ -0,0 +1,2268 @@
+/* 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/. */
+
+// This file is loaded into the browser window scope.
+/* eslint-env mozilla/browser-window */
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "NEWTAB_ENABLED",
+ "browser.newtabpage.enabled",
+ false
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "SHOW_OTHER_BOOKMARKS",
+ "browser.toolbars.bookmarks.showOtherBookmarks",
+ true,
+ (aPref, aPrevVal, aNewVal) => {
+ BookmarkingUI.maybeShowOtherBookmarksFolder().then(() => {
+ document
+ .getElementById("PlacesToolbar")
+ ?._placesView?.updateNodesVisibility();
+ }, console.error);
+ }
+);
+ChromeUtils.defineESModuleGetters(this, {
+ PanelMultiView: "resource:///modules/PanelMultiView.sys.mjs",
+ RecentlyClosedTabsAndWindowsMenuUtils:
+ "resource:///modules/sessionstore/RecentlyClosedTabsAndWindowsMenuUtils.sys.mjs",
+});
+
+var StarUI = {
+ _itemGuids: null,
+ _isNewBookmark: false,
+ _isComposing: false,
+ _autoCloseTimer: 0,
+ // The autoclose timer is diasbled if the user interacts with the
+ // popup, such as making a change through typing or clicking on
+ // the popup.
+ _autoCloseTimerEnabled: true,
+ // The autoclose timeout length. 3500ms matches the timeout that Pocket uses
+ // in browser/components/pocket/content/panels/js/saved.js.
+ _autoCloseTimeout: 3500,
+ _removeBookmarksOnPopupHidden: false,
+
+ _element(aID) {
+ return document.getElementById(aID);
+ },
+
+ // Edit-bookmark panel
+ get panel() {
+ delete this.panel;
+ this._createPanelIfNeeded();
+ var element = this._element("editBookmarkPanel");
+ // initially the panel is hidden
+ // to avoid impacting startup / new window performance
+ element.hidden = false;
+ element.addEventListener("keypress", this, { mozSystemGroup: true });
+ element.addEventListener("mousedown", this);
+ element.addEventListener("mouseout", this);
+ element.addEventListener("mousemove", this);
+ element.addEventListener("compositionstart", this);
+ element.addEventListener("compositionend", this);
+ element.addEventListener("input", this);
+ element.addEventListener("popuphidden", this);
+ element.addEventListener("popupshown", this);
+ return (this.panel = element);
+ },
+
+ // nsIDOMEventListener
+ handleEvent(aEvent) {
+ switch (aEvent.type) {
+ case "mousemove":
+ clearTimeout(this._autoCloseTimer);
+ // The autoclose timer is not disabled on generic mouseout
+ // because the user may not have actually interacted with the popup.
+ break;
+ case "popuphidden": {
+ clearTimeout(this._autoCloseTimer);
+ if (aEvent.originalTarget == this.panel) {
+ this._handlePopupHiddenEvent().catch(console.error);
+ }
+ break;
+ }
+ case "keypress":
+ clearTimeout(this._autoCloseTimer);
+ this._autoCloseTimerEnabled = false;
+
+ if (aEvent.defaultPrevented) {
+ // The event has already been consumed inside of the panel.
+ break;
+ }
+
+ switch (aEvent.keyCode) {
+ case KeyEvent.DOM_VK_ESCAPE:
+ if (this._isNewBookmark) {
+ this._removeBookmarksOnPopupHidden = true;
+ }
+ this.panel.hidePopup();
+ break;
+ case KeyEvent.DOM_VK_RETURN:
+ if (
+ aEvent.target.classList.contains("expander-up") ||
+ aEvent.target.classList.contains("expander-down") ||
+ aEvent.target.id == "editBMPanel_newFolderButton" ||
+ aEvent.target.id == "editBookmarkPanelRemoveButton"
+ ) {
+ // XXX Why is this necessary? The defaultPrevented check should
+ // be enough.
+ break;
+ }
+ this.panel.hidePopup();
+ break;
+ // This case is for catching character-generating keypresses
+ case 0:
+ let accessKey = document.getElementById("key_close");
+ if (eventMatchesKey(aEvent, accessKey)) {
+ this.panel.hidePopup();
+ }
+ break;
+ }
+ break;
+ case "compositionend":
+ // After composition is committed, "mouseout" or something can set
+ // auto close timer.
+ this._isComposing = false;
+ break;
+ case "compositionstart":
+ if (aEvent.defaultPrevented) {
+ // If the composition was canceled, nothing to do here.
+ break;
+ }
+ this._isComposing = true;
+ // Explicit fall-through, during composition, panel shouldn't be hidden automatically.
+ case "input":
+ // Might have edited some text without keyboard events nor composition
+ // events. Fall-through to cancel auto close in such case.
+ case "mousedown":
+ clearTimeout(this._autoCloseTimer);
+ this._autoCloseTimerEnabled = false;
+ break;
+ case "mouseout":
+ if (!this._autoCloseTimerEnabled) {
+ // Don't autoclose the popup if the user has made a selection
+ // or keypress and then subsequently mouseout.
+ break;
+ }
+ // Explicit fall-through
+ case "popupshown":
+ // Don't handle events for descendent elements.
+ if (aEvent.target != aEvent.currentTarget) {
+ break;
+ }
+ // auto-close if new and not interacted with
+ if (this._isNewBookmark && !this._isComposing) {
+ let delay = this._autoCloseTimeout;
+ if (this._closePanelQuickForTesting) {
+ delay /= 10;
+ }
+ clearTimeout(this._autoCloseTimer);
+ this._autoCloseTimer = setTimeout(() => {
+ if (!this.panel.matches(":hover")) {
+ this.panel.hidePopup(true);
+ }
+ }, delay);
+ this._autoCloseTimerEnabled = true;
+ }
+ break;
+ }
+ },
+
+ /**
+ * Handle popup hidden event.
+ */
+ async _handlePopupHiddenEvent() {
+ const { bookmarkState, didChangeFolder, selectedFolderGuid } =
+ gEditItemOverlay;
+ gEditItemOverlay.uninitPanel(true);
+
+ // Capture _removeBookmarksOnPopupHidden and _itemGuids values. Reset them
+ // before we handle the next popup.
+ const removeBookmarksOnPopupHidden = this._removeBookmarksOnPopupHidden;
+ this._removeBookmarksOnPopupHidden = false;
+ const guidsForRemoval = this._itemGuids;
+ this._itemGuids = null;
+
+ if (removeBookmarksOnPopupHidden && guidsForRemoval) {
+ if (!this._isNewBookmark) {
+ // Remove all bookmarks for the bookmark's url, this also removes
+ // the tags for the url.
+ await PlacesTransactions.Remove(guidsForRemoval).transact();
+ } else {
+ BookmarkingUI.star.removeAttribute("starred");
+ }
+ return;
+ }
+
+ await this._storeRecentlyUsedFolder(selectedFolderGuid, didChangeFolder);
+ await bookmarkState.save();
+ if (this._isNewBookmark) {
+ this.showConfirmation();
+ }
+ },
+
+ async showEditBookmarkPopup(aNode, aIsNewBookmark, aUrl) {
+ // Slow double-clicks (not true double-clicks) shouldn't
+ // cause the panel to flicker.
+ if (this.panel.state != "closed") {
+ return;
+ }
+
+ this._isNewBookmark = aIsNewBookmark;
+ this._itemGuids = null;
+
+ let titleL10nID = this._isNewBookmark
+ ? "bookmarks-add-bookmark"
+ : "bookmarks-edit-bookmark";
+ document.l10n.setAttributes(
+ this._element("editBookmarkPanelTitle"),
+ titleL10nID
+ );
+
+ this._element("editBookmarkPanel_showForNewBookmarks").checked =
+ this.showForNewBookmarks;
+
+ this._itemGuids = [];
+ await PlacesUtils.bookmarks.fetch({ url: aUrl }, bookmark =>
+ this._itemGuids.push(bookmark.guid)
+ );
+
+ let removeButton = this._element("editBookmarkPanelRemoveButton");
+ if (this._isNewBookmark) {
+ document.l10n.setAttributes(removeButton, "bookmark-panel-cancel");
+ } else {
+ // The label of the remove button differs if the URI is bookmarked
+ // multiple times.
+ document.l10n.setAttributes(removeButton, "bookmark-panel-remove", {
+ count: this._itemGuids.length,
+ });
+ }
+
+ this._setIconAndPreviewImage();
+
+ let onPanelReady = fn => {
+ let target = this.panel;
+ if (target.parentNode) {
+ // By targeting the panel's parent and using a capturing listener, we
+ // can have our listener called before others waiting for the panel to
+ // be shown (which probably expect the panel to be fully initialized)
+ target = target.parentNode;
+ }
+ target.addEventListener(
+ "popupshown",
+ function (event) {
+ fn();
+ },
+ { capture: true, once: true }
+ );
+ };
+ await gEditItemOverlay.initPanel({
+ node: aNode,
+ onPanelReady,
+ hiddenRows: ["location", "keyword"],
+ focusedElement: "preferred",
+ isNewBookmark: this._isNewBookmark,
+ });
+
+ this.panel.openPopup(BookmarkingUI.anchor, "bottomright topright");
+ },
+
+ _createPanelIfNeeded() {
+ // Lazy load the editBookmarkPanel the first time we need to display it.
+ if (!this._element("editBookmarkPanel")) {
+ MozXULElement.insertFTLIfNeeded("browser/editBookmarkOverlay.ftl");
+ let template = this._element("editBookmarkPanelTemplate");
+ let clone = template.content.cloneNode(true);
+ template.replaceWith(clone);
+ }
+ },
+
+ _setIconAndPreviewImage() {
+ let faviconImage = this._element("editBookmarkPanelFavicon");
+ faviconImage.removeAttribute("iconloadingprincipal");
+ faviconImage.removeAttribute("src");
+
+ let tab = gBrowser.selectedTab;
+ if (tab.hasAttribute("image") && !tab.hasAttribute("busy")) {
+ faviconImage.setAttribute(
+ "iconloadingprincipal",
+ tab.getAttribute("iconloadingprincipal")
+ );
+ faviconImage.setAttribute("src", tab.getAttribute("image"));
+ }
+
+ let canvas = PageThumbs.createCanvas(window);
+ PageThumbs.captureToCanvas(gBrowser.selectedBrowser, canvas).catch(e =>
+ console.error(e)
+ );
+ document.mozSetImageElement("editBookmarkPanelImageCanvas", canvas);
+ },
+
+ removeBookmarkButtonCommand: function SU_removeBookmarkButtonCommand() {
+ this._removeBookmarksOnPopupHidden = true;
+ this.panel.hidePopup();
+ },
+
+ async _storeRecentlyUsedFolder(selectedFolderGuid, didChangeFolder) {
+ if (!selectedFolderGuid) {
+ return;
+ }
+
+ // If we're changing where a bookmark gets saved, persist that location.
+ if (didChangeFolder) {
+ Services.prefs.setCharPref(
+ "browser.bookmarks.defaultLocation",
+ selectedFolderGuid
+ );
+ }
+
+ // Don't store folders that are always displayed in "Recent Folders".
+ if (PlacesUtils.bookmarks.userContentRoots.includes(selectedFolderGuid)) {
+ return;
+ }
+
+ // List of recently used folders:
+ let lastUsedFolderGuids = await PlacesUtils.metadata.get(
+ PlacesUIUtils.LAST_USED_FOLDERS_META_KEY,
+ []
+ );
+
+ let index = lastUsedFolderGuids.indexOf(selectedFolderGuid);
+ if (index > 1) {
+ // The guid is in the array but not the most recent.
+ lastUsedFolderGuids.splice(index, 1);
+ lastUsedFolderGuids.unshift(selectedFolderGuid);
+ } else if (index == -1) {
+ lastUsedFolderGuids.unshift(selectedFolderGuid);
+ }
+ while (lastUsedFolderGuids.length > PlacesUIUtils.maxRecentFolders) {
+ lastUsedFolderGuids.pop();
+ }
+
+ await PlacesUtils.metadata.set(
+ PlacesUIUtils.LAST_USED_FOLDERS_META_KEY,
+ lastUsedFolderGuids
+ );
+ },
+
+ onShowForNewBookmarksCheckboxCommand() {
+ Services.prefs.setBoolPref(
+ "browser.bookmarks.editDialog.showForNewBookmarks",
+ this._element("editBookmarkPanel_showForNewBookmarks").checked
+ );
+ },
+
+ showConfirmation() {
+ // Show the "Saved to bookmarks" hint for the first three times
+ const HINT_COUNT_PREF =
+ "browser.bookmarks.editDialog.confirmationHintShowCount";
+ const HINT_COUNT = Services.prefs.getIntPref(HINT_COUNT_PREF, 0);
+
+ if (HINT_COUNT >= 3) {
+ return;
+ }
+ Services.prefs.setIntPref(HINT_COUNT_PREF, HINT_COUNT + 1);
+
+ let anchor;
+ if (window.toolbar.visible) {
+ for (let id of ["library-button", "bookmarks-menu-button"]) {
+ let element = document.getElementById(id);
+ if (
+ element &&
+ element.getAttribute("cui-areatype") != "panel" &&
+ element.getAttribute("overflowedItem") != "true"
+ ) {
+ anchor = element;
+ break;
+ }
+ }
+ }
+ if (!anchor) {
+ anchor = document.getElementById("PanelUI-menu-button");
+ }
+ ConfirmationHint.show(anchor, "confirmation-hint-page-bookmarked");
+ },
+};
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ StarUI,
+ "showForNewBookmarks",
+ "browser.bookmarks.editDialog.showForNewBookmarks"
+);
+
+var PlacesCommandHook = {
+ /**
+ * Adds a bookmark to the page loaded in the current browser.
+ */
+ async bookmarkPage() {
+ let browser = gBrowser.selectedBrowser;
+ let url = URL.fromURI(browser.currentURI);
+ let info = await PlacesUtils.bookmarks.fetch({ url });
+ let isNewBookmark = !info;
+ let showEditUI = !isNewBookmark || StarUI.showForNewBookmarks;
+ if (isNewBookmark) {
+ // This is async because we have to validate the guid
+ // coming from prefs.
+ let parentGuid = await PlacesUIUtils.defaultParentGuid;
+ info = { url, parentGuid };
+ // Bug 1148838 - Make this code work for full page plugins.
+ let charset = null;
+
+ let isErrorPage = false;
+ if (browser.documentURI) {
+ isErrorPage = /^about:(neterror|certerror|blocked)/.test(
+ browser.documentURI.spec
+ );
+ }
+
+ try {
+ if (isErrorPage) {
+ let entry = await PlacesUtils.history.fetch(browser.currentURI);
+ if (entry) {
+ info.title = entry.title;
+ }
+ } else {
+ info.title = browser.contentTitle;
+ }
+ info.title = info.title || url.href;
+ charset = browser.characterSet;
+ } catch (e) {
+ console.error(e);
+ }
+
+ if (!StarUI.showForNewBookmarks) {
+ info.guid = await PlacesTransactions.NewBookmark(info).transact();
+ } else {
+ info.guid = PlacesUtils.bookmarks.unsavedGuid;
+ BookmarkingUI.star.setAttribute("starred", "true");
+ }
+
+ if (charset) {
+ PlacesUIUtils.setCharsetForPage(url, charset, window).catch(
+ console.error
+ );
+ }
+ }
+
+ // Revert the contents of the location bar
+ gURLBar.handleRevert();
+
+ // If it was not requested to open directly in "edit" mode, we are done.
+ if (!showEditUI) {
+ StarUI.showConfirmation();
+ return;
+ }
+
+ let node = await PlacesUIUtils.promiseNodeLikeFromFetchInfo(info);
+
+ await StarUI.showEditBookmarkPopup(node, isNewBookmark, url);
+ },
+
+ /**
+ * Adds a bookmark to the page targeted by a link.
+ * @param url (string)
+ * the address of the link target
+ * @param title
+ * The link text
+ */
+ async bookmarkLink(url, title) {
+ let bm = await PlacesUtils.bookmarks.fetch({ url });
+ if (bm) {
+ let node = await PlacesUIUtils.promiseNodeLikeFromFetchInfo(bm);
+ await PlacesUIUtils.showBookmarkDialog(
+ { action: "edit", node },
+ window.top
+ );
+ return;
+ }
+
+ let parentGuid = await PlacesUIUtils.defaultParentGuid;
+ let defaultInsertionPoint = new PlacesInsertionPoint({
+ parentGuid,
+ });
+ await PlacesUIUtils.showBookmarkDialog(
+ {
+ action: "add",
+ type: "bookmark",
+ uri: Services.io.newURI(url),
+ title,
+ defaultInsertionPoint,
+ hiddenRows: ["location", "keyword"],
+ },
+ window.top
+ );
+ },
+
+ /**
+ * List of nsIURI objects characterizing tabs given in param.
+ * Duplicates are discarded.
+ */
+ getUniquePages(tabs) {
+ let uniquePages = {};
+ let URIs = [];
+
+ tabs.forEach(tab => {
+ let browser = tab.linkedBrowser;
+ let uri = browser.currentURI;
+ let title = browser.contentTitle || tab.label;
+ let spec = uri.spec;
+ if (!(spec in uniquePages)) {
+ uniquePages[spec] = null;
+ URIs.push({ uri, title });
+ }
+ });
+ return URIs;
+ },
+
+ /**
+ * List of nsIURI objects characterizing the tabs currently open in the
+ * browser, modulo pinned tabs. The URIs will be in the order in which their
+ * corresponding tabs appeared and duplicates are discarded.
+ */
+ get uniqueCurrentPages() {
+ let visibleUnpinnedTabs = gBrowser.visibleTabs.filter(tab => !tab.pinned);
+ return this.getUniquePages(visibleUnpinnedTabs);
+ },
+
+ /**
+ * List of nsIURI objects characterizing the tabs currently
+ * selected in the window. Duplicates are discarded.
+ */
+ get uniqueSelectedPages() {
+ return this.getUniquePages(gBrowser.selectedTabs);
+ },
+
+ /**
+ * Opens the Places Organizer.
+ * @param {String} item The item to select in the organizer window,
+ * options are (case sensitive):
+ * BookmarksMenu, BookmarksToolbar, UnfiledBookmarks,
+ * AllBookmarks, History, Downloads.
+ */
+ showPlacesOrganizer(item) {
+ var organizer = Services.wm.getMostRecentWindow("Places:Organizer");
+ // Due to bug 528706, getMostRecentWindow can return closed windows.
+ if (!organizer || organizer.closed) {
+ // No currently open places window, so open one with the specified mode.
+ openDialog(
+ "chrome://browser/content/places/places.xhtml",
+ "",
+ "chrome,toolbar=yes,dialog=no,resizable",
+ item
+ );
+ } else {
+ organizer.PlacesOrganizer.selectLeftPaneContainerByHierarchy(item);
+ organizer.focus();
+ }
+ },
+
+ searchBookmarks() {
+ gURLBar.search(UrlbarTokenizer.RESTRICT.BOOKMARK, {
+ searchModeEntry: "bookmarkmenu",
+ });
+ },
+
+ searchHistory() {
+ gURLBar.search(UrlbarTokenizer.RESTRICT.HISTORY, {
+ searchModeEntry: "historymenu",
+ });
+ },
+};
+
+// View for the history menu.
+class HistoryMenu extends PlacesMenu {
+ constructor(aPopupShowingEvent) {
+ super(aPopupShowingEvent, "place:sort=4&maxResults=15");
+ }
+
+ // Called by the base class (PlacesViewBase) so we can initialize some
+ // element references before the several superclass constructors call our
+ // methods which depend on these.
+ _init() {
+ super._init();
+ let elements = {
+ undoTabMenu: "historyUndoMenu",
+ hiddenTabsMenu: "hiddenTabsMenu",
+ undoWindowMenu: "historyUndoWindowMenu",
+ syncTabsMenuitem: "sync-tabs-menuitem",
+ };
+ for (let [key, elemId] of Object.entries(elements)) {
+ this[key] = document.getElementById(elemId);
+ }
+ }
+
+ _getClosedTabCount() {
+ try {
+ return SessionStore.getClosedTabCountForWindow(window);
+ } catch (ex) {
+ // SessionStore doesn't track the hidden window, so just return zero then.
+ return 0;
+ }
+ }
+
+ toggleHiddenTabs() {
+ const isShown =
+ window.gBrowser && gBrowser.visibleTabs.length < gBrowser.tabs.length;
+ this.hiddenTabsMenu.hidden = !isShown;
+ }
+
+ toggleRecentlyClosedTabs() {
+ // enable/disable the Recently Closed Tabs sub menu
+ // no restorable tabs, so disable menu
+ if (this._getClosedTabCount() == 0) {
+ this.undoTabMenu.setAttribute("disabled", true);
+ } else {
+ this.undoTabMenu.removeAttribute("disabled");
+ }
+ }
+
+ /**
+ * Populate when the history menu is opened
+ */
+ populateUndoSubmenu() {
+ var undoPopup = this.undoTabMenu.menupopup;
+
+ // remove existing menu items
+ while (undoPopup.hasChildNodes()) {
+ undoPopup.firstChild.remove();
+ }
+
+ // no restorable tabs, so make sure menu is disabled, and return
+ if (this._getClosedTabCount() == 0) {
+ this.undoTabMenu.setAttribute("disabled", true);
+ return;
+ }
+
+ // enable menu
+ this.undoTabMenu.removeAttribute("disabled");
+
+ // populate menu
+ let tabsFragment = RecentlyClosedTabsAndWindowsMenuUtils.getTabsFragment(
+ window,
+ "menuitem",
+ /* aPrefixRestoreAll = */ false
+ );
+ undoPopup.appendChild(tabsFragment);
+ }
+
+ toggleRecentlyClosedWindows() {
+ // enable/disable the Recently Closed Windows sub menu
+ // no restorable windows, so disable menu
+ if (SessionStore.getClosedWindowCount() == 0) {
+ this.undoWindowMenu.setAttribute("disabled", true);
+ } else {
+ this.undoWindowMenu.removeAttribute("disabled");
+ }
+ }
+
+ /**
+ * Populate when the history menu is opened
+ */
+ populateUndoWindowSubmenu() {
+ let undoPopup = this.undoWindowMenu.menupopup;
+
+ // remove existing menu items
+ while (undoPopup.hasChildNodes()) {
+ undoPopup.firstChild.remove();
+ }
+
+ // no restorable windows, so make sure menu is disabled, and return
+ if (SessionStore.getClosedWindowCount() == 0) {
+ this.undoWindowMenu.setAttribute("disabled", true);
+ return;
+ }
+
+ // enable menu
+ this.undoWindowMenu.removeAttribute("disabled");
+
+ // populate menu
+ let windowsFragment =
+ RecentlyClosedTabsAndWindowsMenuUtils.getWindowsFragment(
+ window,
+ "menuitem",
+ /* aPrefixRestoreAll = */ false
+ );
+ undoPopup.appendChild(windowsFragment);
+ }
+
+ toggleTabsFromOtherComputers() {
+ // Enable/disable the Tabs From Other Computers menu. Some of the menus handled
+ // by HistoryMenu do not have this menuitem.
+ if (!this.syncTabsMenuitem) {
+ return;
+ }
+
+ if (!PlacesUIUtils.shouldShowTabsFromOtherComputersMenuitem()) {
+ this.syncTabsMenuitem.hidden = true;
+ return;
+ }
+
+ this.syncTabsMenuitem.hidden = false;
+ }
+
+ _onPopupShowing(aEvent) {
+ super._onPopupShowing(aEvent);
+
+ // Don't handle events for submenus.
+ if (aEvent.target != aEvent.currentTarget) {
+ return;
+ }
+
+ this.toggleHiddenTabs();
+ this.toggleRecentlyClosedTabs();
+ this.toggleRecentlyClosedWindows();
+ this.toggleTabsFromOtherComputers();
+ }
+
+ _onCommand(aEvent) {
+ aEvent = getRootEvent(aEvent);
+ let placesNode = aEvent.target._placesNode;
+ if (placesNode) {
+ if (!PrivateBrowsingUtils.isWindowPrivate(window)) {
+ PlacesUIUtils.markPageAsTyped(placesNode.uri);
+ }
+ openUILink(placesNode.uri, aEvent, {
+ ignoreAlt: true,
+ triggeringPrincipal:
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ });
+ }
+ }
+}
+
+/**
+ * Functions for handling events in the Bookmarks Toolbar and menu.
+ */
+var BookmarksEventHandler = {
+ /**
+ * Handler for click event for an item in the bookmarks toolbar or menu.
+ * Menus and submenus from the folder buttons bubble up to this handler.
+ * Left-click is handled in the onCommand function.
+ * When items are middle-clicked (or clicked with modifier), open in tabs.
+ * If the click came through a menu, close the menu.
+ * @param aEvent
+ * DOMEvent for the click
+ * @param aView
+ * The places view which aEvent should be associated with.
+ */
+
+ onMouseUp(aEvent) {
+ // Handles middle-click or left-click with modifier if not browser.bookmarks.openInTabClosesMenu.
+ if (aEvent.button == 2 || PlacesUIUtils.openInTabClosesMenu) {
+ return;
+ }
+ let target = aEvent.originalTarget;
+ if (target.tagName != "menuitem") {
+ return;
+ }
+ let modifKey =
+ AppConstants.platform === "macosx" ? aEvent.metaKey : aEvent.ctrlKey;
+ if (modifKey || aEvent.button == 1) {
+ target.setAttribute("closemenu", "none");
+ var menupopup = target.parentNode;
+ menupopup.addEventListener(
+ "popuphidden",
+ () => {
+ target.removeAttribute("closemenu");
+ },
+ { once: true }
+ );
+ } else {
+ // Handles edge case where same menuitem was opened previously
+ // while menu was kept open, but now menu should close.
+ target.removeAttribute("closemenu");
+ }
+ },
+
+ onClick: function BEH_onClick(aEvent, aView) {
+ // Only handle middle-click or left-click with modifiers.
+ let modifKey;
+ if (AppConstants.platform == "macosx") {
+ modifKey = aEvent.metaKey || aEvent.shiftKey;
+ } else {
+ modifKey = aEvent.ctrlKey || aEvent.shiftKey;
+ }
+
+ if (aEvent.button == 2 || (aEvent.button == 0 && !modifKey)) {
+ return;
+ }
+
+ var target = aEvent.originalTarget;
+ // If this event bubbled up from a menu or menuitem,
+ // close the menus if browser.bookmarks.openInTabClosesMenu.
+ var tag = target.tagName;
+ if (
+ PlacesUIUtils.openInTabClosesMenu &&
+ (tag == "menuitem" || tag == "menu")
+ ) {
+ closeMenus(aEvent.target);
+ }
+
+ if (target._placesNode && PlacesUtils.nodeIsContainer(target._placesNode)) {
+ // Don't open the root folder in tabs when the empty area on the toolbar
+ // is middle-clicked or when a non-bookmark item (except for Open in Tabs)
+ // in a bookmarks menupopup is middle-clicked.
+ if (target.localName == "menu" || target.localName == "toolbarbutton") {
+ PlacesUIUtils.openMultipleLinksInTabs(
+ target._placesNode,
+ aEvent,
+ aView
+ );
+ }
+ } else if (aEvent.button == 1 && !(tag == "menuitem" || tag == "menu")) {
+ // Call onCommand in the cases where it's not called automatically:
+ // Middle-clicks outside of menus.
+ this.onCommand(aEvent);
+ }
+ },
+
+ /**
+ * Handler for command event for an item in the bookmarks toolbar.
+ * Menus and submenus from the folder buttons bubble up to this handler.
+ * Opens the item.
+ * @param aEvent
+ * DOMEvent for the command
+ */
+ onCommand: function BEH_onCommand(aEvent) {
+ var target = aEvent.originalTarget;
+ if (target._placesNode) {
+ PlacesUIUtils.openNodeWithEvent(target._placesNode, aEvent);
+ // Only record interactions through the Bookmarks Toolbar
+ if (target.closest("#PersonalToolbar")) {
+ Services.telemetry.scalarAdd(
+ "browser.engagement.bookmarks_toolbar_bookmark_opened",
+ 1
+ );
+ }
+ }
+ },
+
+ fillInBHTooltip: function BEH_fillInBHTooltip(aTooltip, aEvent) {
+ var node;
+ var cropped = false;
+ var targetURI;
+
+ if (aTooltip.triggerNode.localName == "treechildren") {
+ var tree = aTooltip.triggerNode.parentNode;
+ var cell = tree.getCellAt(aEvent.clientX, aEvent.clientY);
+ if (cell.row == -1) {
+ return false;
+ }
+ node = tree.view.nodeForTreeIndex(cell.row);
+ cropped = tree.isCellCropped(cell.row, cell.col);
+ } else {
+ // Check whether the tooltipNode is a Places node.
+ // In such a case use it, otherwise check for targetURI attribute.
+ var tooltipNode = aTooltip.triggerNode;
+ if (tooltipNode._placesNode) {
+ node = tooltipNode._placesNode;
+ } else {
+ // This is a static non-Places node.
+ targetURI = tooltipNode.getAttribute("targetURI");
+ }
+ }
+
+ if (!node && !targetURI) {
+ return false;
+ }
+
+ // Show node.label as tooltip's title for non-Places nodes.
+ var title = node ? node.title : tooltipNode.label;
+
+ // Show URL only for Places URI-nodes or nodes with a targetURI attribute.
+ var url;
+ if (targetURI || PlacesUtils.nodeIsURI(node)) {
+ url = targetURI || node.uri;
+ }
+
+ // Show tooltip for containers only if their title is cropped.
+ if (!cropped && !url) {
+ return false;
+ }
+
+ let tooltipTitle = aEvent.target.querySelector(".places-tooltip-title");
+ tooltipTitle.hidden = !title || title == url;
+ if (!tooltipTitle.hidden) {
+ tooltipTitle.textContent = title;
+ }
+
+ let tooltipUrl = aEvent.target.querySelector(".places-tooltip-uri");
+ tooltipUrl.hidden = !url;
+ if (!tooltipUrl.hidden) {
+ // Use `value` instead of `textContent` so cropping will apply
+ tooltipUrl.value = url;
+ }
+
+ // Show tooltip.
+ return true;
+ },
+};
+
+// Handles special drag and drop functionality for Places menus that are not
+// part of a Places view (e.g. the bookmarks menu in the menubar).
+var PlacesMenuDNDHandler = {
+ _springLoadDelayMs: 350,
+ _closeDelayMs: 500,
+ _loadTimer: null,
+ _closeTimer: null,
+ _closingTimerNode: null,
+
+ /**
+ * Called when the user enters the <menu> element during a drag.
+ * @param event
+ * The DragEnter event that spawned the opening.
+ */
+ onDragEnter: function PMDH_onDragEnter(event) {
+ // Opening menus in a Places popup is handled by the view itself.
+ if (!this._isStaticContainer(event.target)) {
+ return;
+ }
+
+ // If we re-enter the same menu or anchor before the close timer runs out,
+ // we should ensure that we do not close:
+ if (this._closeTimer && this._closingTimerNode === event.currentTarget) {
+ this._closeTimer.cancel();
+ this._closingTimerNode = null;
+ this._closeTimer = null;
+ }
+
+ PlacesControllerDragHelper.currentDropTarget = event.target;
+ let popup = event.target.menupopup;
+ if (
+ this._loadTimer ||
+ popup.state === "showing" ||
+ popup.state === "open"
+ ) {
+ return;
+ }
+
+ this._loadTimer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+ this._loadTimer.initWithCallback(
+ () => {
+ this._loadTimer = null;
+ popup.setAttribute("autoopened", "true");
+ popup.openPopup();
+ },
+ this._springLoadDelayMs,
+ Ci.nsITimer.TYPE_ONE_SHOT
+ );
+ event.preventDefault();
+ event.stopPropagation();
+ },
+
+ /**
+ * Handles dragleave on the <menu> element.
+ */
+ onDragLeave: function PMDH_onDragLeave(event) {
+ // Handle menu-button separate targets.
+ if (
+ event.relatedTarget === event.currentTarget ||
+ (event.relatedTarget &&
+ event.relatedTarget.parentNode === event.currentTarget)
+ ) {
+ return;
+ }
+
+ // Closing menus in a Places popup is handled by the view itself.
+ if (!this._isStaticContainer(event.target)) {
+ return;
+ }
+
+ PlacesControllerDragHelper.currentDropTarget = null;
+ let popup = event.target.menupopup;
+
+ if (this._loadTimer) {
+ this._loadTimer.cancel();
+ this._loadTimer = null;
+ }
+ this._closeTimer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+ this._closingTimerNode = event.currentTarget;
+ this._closeTimer.initWithCallback(
+ function () {
+ this._closeTimer = null;
+ this._closingTimerNode = null;
+ let node = PlacesControllerDragHelper.currentDropTarget;
+ let inHierarchy = false;
+ while (node && !inHierarchy) {
+ inHierarchy = node == event.target;
+ node = node.parentNode;
+ }
+ if (!inHierarchy && popup && popup.hasAttribute("autoopened")) {
+ popup.removeAttribute("autoopened");
+ popup.hidePopup();
+ }
+ },
+ this._closeDelayMs,
+ Ci.nsITimer.TYPE_ONE_SHOT
+ );
+ },
+
+ /**
+ * Determines if a XUL element represents a static container.
+ * @returns true if the element is a container element (menu or
+ *` menu-toolbarbutton), false otherwise.
+ */
+ _isStaticContainer: function PMDH__isContainer(node) {
+ let isMenu =
+ node.localName == "menu" ||
+ (node.localName == "toolbarbutton" &&
+ node.getAttribute("type") == "menu");
+ let isStatic =
+ !("_placesNode" in node) &&
+ node.menupopup &&
+ node.menupopup.hasAttribute("placespopup") &&
+ !node.parentNode.hasAttribute("placespopup");
+ return isMenu && isStatic;
+ },
+
+ /**
+ * Called when the user drags over the <menu> element.
+ * @param event
+ * The DragOver event.
+ */
+ onDragOver: function PMDH_onDragOver(event) {
+ PlacesControllerDragHelper.currentDropTarget = event.target;
+ let ip = new PlacesInsertionPoint({
+ parentGuid: PlacesUtils.bookmarks.menuGuid,
+ });
+ if (ip && PlacesControllerDragHelper.canDrop(ip, event.dataTransfer)) {
+ event.preventDefault();
+ }
+
+ event.stopPropagation();
+ },
+
+ /**
+ * Called when the user drops on the <menu> element.
+ * @param event
+ * The Drop event.
+ */
+ onDrop: function PMDH_onDrop(event) {
+ // Put the item at the end of bookmark menu.
+ let ip = new PlacesInsertionPoint({
+ parentGuid: PlacesUtils.bookmarks.menuGuid,
+ });
+ PlacesControllerDragHelper.onDrop(ip, event.dataTransfer);
+ PlacesControllerDragHelper.currentDropTarget = null;
+ event.stopPropagation();
+ },
+};
+
+/**
+ * This object handles the initialization and uninitialization of the bookmarks
+ * toolbar. It also has helper functions for the managed bookmarks button.
+ */
+var PlacesToolbarHelper = {
+ get _viewElt() {
+ return document.getElementById("PlacesToolbar");
+ },
+
+ /**
+ * Initialize. This will check whether we've finished startup and can
+ * show toolbars.
+ */
+ async init() {
+ await PlacesUIUtils.canLoadToolbarContentPromise;
+ this._realInit();
+ },
+
+ /**
+ * Actually initialize the places view (if needed; we might still no-op).
+ */
+ _realInit() {
+ let viewElt = this._viewElt;
+ if (!viewElt || viewElt._placesView || window.closed) {
+ return;
+ }
+
+ // CustomizableUI.addListener is idempotent, so we can safely
+ // call this multiple times.
+ CustomizableUI.addListener(this);
+
+ if (!this._isObservingToolbars) {
+ this._isObservingToolbars = true;
+ window.addEventListener("toolbarvisibilitychange", this);
+ }
+
+ // If the bookmarks toolbar item is:
+ // - not in a toolbar, or;
+ // - the toolbar is collapsed, or;
+ // - the toolbar is hidden some other way:
+ // don't initialize. Also, there is no need to initialize the toolbar if
+ // customizing, because that will happen when the customization is done.
+ let toolbar = this._getParentToolbar(viewElt);
+ if (
+ !toolbar ||
+ toolbar.collapsed ||
+ this._isCustomizing ||
+ getComputedStyle(toolbar, "").display == "none"
+ ) {
+ return;
+ }
+
+ new PlacesToolbar(
+ `place:parent=${PlacesUtils.bookmarks.toolbarGuid}`,
+ document.getElementById("PlacesToolbarItems"),
+ viewElt
+ );
+
+ if (
+ toolbar.id == "PersonalToolbar" &&
+ !toolbar.hasAttribute("initialized")
+ ) {
+ toolbar.setAttribute("initialized", "true");
+ BookmarkingUI.updateEmptyToolbarMessage().catch(console.error);
+ }
+ },
+
+ async getIsEmpty() {
+ if (!this._viewElt._placesView) {
+ return true;
+ }
+ await this._viewElt._placesView.promiseRebuilt();
+ return !document.getElementById("PlacesToolbarItems").hasChildNodes();
+ },
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "toolbarvisibilitychange":
+ if (event.target == this._getParentToolbar(this._viewElt)) {
+ this._resetView();
+ }
+ break;
+ }
+ },
+
+ /**
+ * This is a no-op if we haven't been initialized.
+ */
+ uninit: function PTH_uninit() {
+ if (this._isObservingToolbars) {
+ delete this._isObservingToolbars;
+ window.removeEventListener("toolbarvisibilitychange", this);
+ }
+ CustomizableUI.removeListener(this);
+ },
+
+ customizeStart: function PTH_customizeStart() {
+ try {
+ let viewElt = this._viewElt;
+ if (viewElt && viewElt._placesView) {
+ viewElt._placesView.uninit();
+ }
+ } finally {
+ this._isCustomizing = true;
+ }
+ },
+
+ customizeDone: function PTH_customizeDone() {
+ this._isCustomizing = false;
+ this.init();
+ },
+
+ onPlaceholderCommand() {
+ let widgetGroup = CustomizableUI.getWidget("personal-bookmarks");
+ let widget = widgetGroup.forWindow(window);
+ if (
+ widget.overflowed ||
+ widgetGroup.areaType == CustomizableUI.TYPE_PANEL
+ ) {
+ PlacesCommandHook.showPlacesOrganizer("BookmarksToolbar");
+ }
+ },
+
+ _getParentToolbar(element) {
+ while (element) {
+ if (element.localName == "toolbar") {
+ return element;
+ }
+ element = element.parentNode;
+ }
+ return null;
+ },
+
+ onWidgetUnderflow(aNode, aContainer) {
+ // The view gets broken by being removed and reinserted by the overflowable
+ // toolbar, so we have to force an uninit and reinit.
+ let win = aNode.ownerGlobal;
+ if (aNode.id == "personal-bookmarks" && win == window) {
+ this._resetView();
+ }
+ },
+
+ onWidgetAdded(aWidgetId, aArea, aPosition) {
+ if (aWidgetId == "personal-bookmarks" && !this._isCustomizing) {
+ // It's possible (with the "Add to Menu", "Add to Toolbar" context
+ // options) that the Places Toolbar Items have been moved without
+ // letting us prepare and handle it with with customizeStart and
+ // customizeDone. If that's the case, we need to reset the views
+ // since they're probably broken from the DOM reparenting.
+ this._resetView();
+ }
+ },
+
+ _resetView() {
+ if (this._viewElt) {
+ // It's possible that the placesView might not exist, and we need to
+ // do a full init. This could happen if the Bookmarks Toolbar Items are
+ // moved to the Menu Panel, and then to the toolbar with the "Add to Toolbar"
+ // context menu option, outside of customize mode.
+ if (this._viewElt._placesView) {
+ this._viewElt._placesView.uninit();
+ }
+ this.init();
+ }
+ },
+
+ async populateManagedBookmarks(popup) {
+ if (popup.hasChildNodes()) {
+ return;
+ }
+ // Show item's uri in the status bar when hovering, and clear on exit
+ popup.addEventListener("DOMMenuItemActive", function (event) {
+ XULBrowserWindow.setOverLink(event.target.link);
+ });
+ popup.addEventListener("DOMMenuItemInactive", function () {
+ XULBrowserWindow.setOverLink("");
+ });
+ let fragment = document.createDocumentFragment();
+ await this.addManagedBookmarks(
+ fragment,
+ Services.policies.getActivePolicies().ManagedBookmarks
+ );
+ popup.appendChild(fragment);
+ },
+
+ async addManagedBookmarks(menu, children) {
+ for (let i = 0; i < children.length; i++) {
+ let entry = children[i];
+ if (entry.children) {
+ // It's a folder.
+ let submenu = document.createXULElement("menu");
+ if (entry.name) {
+ submenu.setAttribute("label", entry.name);
+ } else {
+ submenu.setAttribute("data-l10n-id", "managed-bookmarks-subfolder");
+ }
+ submenu.setAttribute("container", "true");
+ submenu.setAttribute(
+ "class",
+ "menu-iconic bookmark-item subviewbutton"
+ );
+ let submenupopup = document.createXULElement("menupopup");
+ submenupopup.setAttribute("placespopup", "true");
+ submenu.appendChild(submenupopup);
+ menu.appendChild(submenu);
+ this.addManagedBookmarks(submenupopup, entry.children);
+ } else if (entry.name && entry.url) {
+ // It's bookmark.
+ let { preferredURI } = Services.uriFixup.getFixupURIInfo(entry.url);
+ let menuitem = document.createXULElement("menuitem");
+ menuitem.setAttribute("label", entry.name);
+ menuitem.setAttribute("image", "page-icon:" + preferredURI.spec);
+ menuitem.setAttribute(
+ "class",
+ "menuitem-iconic bookmark-item menuitem-with-favicon subviewbutton"
+ );
+ menuitem.link = preferredURI.spec;
+ menu.appendChild(menuitem);
+ }
+ }
+ },
+
+ openManagedBookmark(event) {
+ openUILink(event.target.link, event, {
+ triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
+ });
+ },
+
+ onDragStartManaged(event) {
+ if (!event.target.link) {
+ return;
+ }
+
+ let dt = event.dataTransfer;
+
+ let node = {};
+ node.type = 0;
+ node.title = event.target.label;
+ node.uri = event.target.link;
+
+ function addData(type, index) {
+ let wrapNode = PlacesUtils.wrapNode(node, type);
+ dt.mozSetDataAt(type, wrapNode, index);
+ }
+
+ addData(PlacesUtils.TYPE_X_MOZ_URL, 0);
+ addData(PlacesUtils.TYPE_PLAINTEXT, 0);
+ addData(PlacesUtils.TYPE_HTML, 0);
+ },
+};
+
+/**
+ * Handles the bookmarks menu-button in the toolbar.
+ */
+
+var BookmarkingUI = {
+ STAR_ID: "star-button",
+ STAR_BOX_ID: "star-button-box",
+ BOOKMARK_BUTTON_ID: "bookmarks-menu-button",
+ BOOKMARK_BUTTON_SHORTCUT: "addBookmarkAsKb",
+ get button() {
+ delete this.button;
+ let widgetGroup = CustomizableUI.getWidget(this.BOOKMARK_BUTTON_ID);
+ return (this.button = widgetGroup.forWindow(window).node);
+ },
+
+ get star() {
+ delete this.star;
+ return (this.star = document.getElementById(this.STAR_ID));
+ },
+
+ get starBox() {
+ delete this.starBox;
+ return (this.starBox = document.getElementById(this.STAR_BOX_ID));
+ },
+
+ get anchor() {
+ let action = PageActions.actionForID(PageActions.ACTION_ID_BOOKMARK);
+ return BrowserPageActions.panelAnchorNodeForAction(action);
+ },
+
+ get stringbundleset() {
+ delete this.stringbundleset;
+ return (this.stringbundleset = document.getElementById("stringbundleset"));
+ },
+
+ get toolbar() {
+ delete this.toolbar;
+ return (this.toolbar = document.getElementById("PersonalToolbar"));
+ },
+
+ STATUS_UPDATING: -1,
+ STATUS_UNSTARRED: 0,
+ STATUS_STARRED: 1,
+ get status() {
+ if (this._pendingUpdate) {
+ return this.STATUS_UPDATING;
+ }
+ return this.star.hasAttribute("starred")
+ ? this.STATUS_STARRED
+ : this.STATUS_UNSTARRED;
+ },
+
+ onPopupShowing: function BUI_onPopupShowing(event) {
+ // Don't handle events for submenus.
+ if (event.target != event.currentTarget) {
+ return;
+ }
+
+ // On non-photon, this code should never be reached. However, if you click
+ // the outer button's border, some cpp code for the menu button's XBL
+ // binding decides to open the popup even though the dropmarker is invisible.
+ //
+ // Separately, in Photon, if the button is in the dynamic portion of the
+ // overflow panel, we want to show a subview instead.
+ if (
+ this.button.getAttribute("cui-areatype") == CustomizableUI.TYPE_PANEL ||
+ this.button.hasAttribute("overflowedItem")
+ ) {
+ this._showSubView();
+ event.preventDefault();
+ event.stopPropagation();
+ return;
+ }
+
+ let widget = CustomizableUI.getWidget(this.BOOKMARK_BUTTON_ID).forWindow(
+ window
+ );
+ if (widget.overflowed) {
+ // Don't open a popup in the overflow popup, rather just open the Library.
+ event.preventDefault();
+ widget.node.removeAttribute("closemenu");
+ PlacesCommandHook.showPlacesOrganizer("BookmarksMenu");
+ return;
+ }
+
+ this._initMobileBookmarks(document.getElementById("BMB_mobileBookmarks"));
+
+ this.updateLabel(
+ "BMB_viewBookmarksSidebar",
+ SidebarUI.currentID == "viewBookmarksSidebar"
+ );
+ this.updateLabel("BMB_viewBookmarksToolbar", !this.toolbar.collapsed);
+ },
+
+ updateLabel(elementId, visible) {
+ let element = PanelMultiView.getViewNode(document, elementId);
+ let l10nID = element.getAttribute("data-l10n-id");
+ document.l10n.setAttributes(element, l10nID, { isVisible: !!visible });
+ },
+
+ toggleBookmarksToolbar(reason) {
+ let newState = this.toolbar.collapsed ? "always" : "never";
+ Services.prefs.setCharPref(
+ "browser.toolbars.bookmarks.visibility",
+ // See firefox.js for possible values
+ newState
+ );
+
+ CustomizableUI.setToolbarVisibility(this.toolbar.id, newState, false);
+ BrowserUsageTelemetry.recordToolbarVisibility(
+ this.toolbar.id,
+ newState,
+ reason
+ );
+ },
+
+ isOnNewTabPage({ currentURI }) {
+ // Prevent loading AboutNewTab.jsm during startup path if it
+ // is only the newTabURL getter we are interested in.
+ let newTabURL = Cu.isModuleLoaded("resource:///modules/AboutNewTab.jsm")
+ ? AboutNewTab.newTabURL
+ : "about:newtab";
+ // Don't treat a custom "about:blank" new tab URL as the "New Tab Page"
+ // due to about:blank being used in different contexts and the
+ // difficulty in determining if the eventual page load is
+ // about:blank or if the about:blank load is just temporary.
+ if (newTabURL == "about:blank") {
+ newTabURL = "about:newtab";
+ }
+ let newTabURLs = [newTabURL, "about:home"];
+ if (PrivateBrowsingUtils.isWindowPrivate(window)) {
+ newTabURLs.push("about:privatebrowsing");
+ }
+ return newTabURLs.some(uri => currentURI?.spec.startsWith(uri));
+ },
+
+ buildBookmarksToolbarSubmenu(toolbar) {
+ let alwaysShowMenuItem = document.createXULElement("menuitem");
+ let alwaysHideMenuItem = document.createXULElement("menuitem");
+ let showOnNewTabMenuItem = document.createXULElement("menuitem");
+ let menuPopup = document.createXULElement("menupopup");
+ menuPopup.append(
+ alwaysShowMenuItem,
+ showOnNewTabMenuItem,
+ alwaysHideMenuItem
+ );
+ let menu = document.createXULElement("menu");
+ menu.appendChild(menuPopup);
+
+ menu.setAttribute("label", toolbar.getAttribute("toolbarname"));
+ menu.setAttribute("id", "toggle_" + toolbar.id);
+ menu.setAttribute("accesskey", toolbar.getAttribute("accesskey"));
+ menu.setAttribute("toolbarId", toolbar.id);
+
+ // Used by the Places context menu in the Bookmarks Toolbar
+ // when nothing is selected
+ menu.setAttribute("selection-type", "none|single");
+
+ MozXULElement.insertFTLIfNeeded("browser/toolbarContextMenu.ftl");
+ let menuItems = [
+ [
+ showOnNewTabMenuItem,
+ "toolbar-context-menu-bookmarks-toolbar-on-new-tab-2",
+ "newtab",
+ ],
+ [
+ alwaysShowMenuItem,
+ "toolbar-context-menu-bookmarks-toolbar-always-show-2",
+ "always",
+ ],
+ [
+ alwaysHideMenuItem,
+ "toolbar-context-menu-bookmarks-toolbar-never-show-2",
+ "never",
+ ],
+ ];
+ menuItems.map(([menuItem, l10nId, visibilityEnum]) => {
+ document.l10n.setAttributes(menuItem, l10nId);
+ menuItem.setAttribute("type", "radio");
+ // The persisted state of the PersonalToolbar is stored in
+ // "browser.toolbars.bookmarks.visibility".
+ menuItem.setAttribute(
+ "checked",
+ gBookmarksToolbarVisibility == visibilityEnum
+ );
+ // Identify these items for "onViewToolbarCommand" so
+ // we know to check the visibilityEnum value.
+ menuItem.dataset.bookmarksToolbarVisibility = true;
+ menuItem.dataset.visibilityEnum = visibilityEnum;
+ menuItem.addEventListener("command", onViewToolbarCommand);
+ });
+ let menuItemForNextStateFromKbShortcut =
+ gBookmarksToolbarVisibility == "never"
+ ? alwaysShowMenuItem
+ : alwaysHideMenuItem;
+ menuItemForNextStateFromKbShortcut.setAttribute(
+ "key",
+ "viewBookmarksToolbarKb"
+ );
+
+ return menu;
+ },
+
+ /**
+ * Check if we need to make the empty toolbar message `hidden`.
+ * We'll have it unhidden during startup, to make sure the toolbar
+ * has height, and we'll unhide it if there is nothing else on the toolbar.
+ * We hide it in customize mode, unless there's nothing on the toolbar.
+ */
+ async updateEmptyToolbarMessage() {
+ let checkNumBookmarksOnToolbar = false;
+ let hasVisibleChildren = (() => {
+ // Do we have visible kids?
+ if (
+ this.toolbar.querySelector(
+ `:scope > toolbarpaletteitem > toolbarbutton:not([hidden]),
+ :scope > toolbarpaletteitem > toolbaritem:not([hidden], #personal-bookmarks),
+ :scope > toolbarbutton:not([hidden]),
+ :scope > toolbaritem:not([hidden], #personal-bookmarks)`
+ )
+ ) {
+ return true;
+ }
+ if (!this.toolbar.hasAttribute("initialized") && !this._isCustomizing) {
+ // If the bookmarks are here but it's early in startup, show the
+ // message. It'll get made visibility: hidden early in startup anyway -
+ // it's just to ensure the toolbar has height.
+ return false;
+ }
+ // Hmm, apparently not. Check for bookmarks or customize mode:
+ let bookmarksToolbarItemsPlacement =
+ CustomizableUI.getPlacementOfWidget("personal-bookmarks");
+ let bookmarksItemInToolbar =
+ bookmarksToolbarItemsPlacement?.area == CustomizableUI.AREA_BOOKMARKS;
+ if (!bookmarksItemInToolbar) {
+ return false;
+ }
+ if (this._isCustomizing) {
+ return true;
+ }
+ // Check visible bookmark nodes.
+ if (
+ this.toolbar.querySelector(
+ `#PlacesToolbarItems > toolbarseparator,
+ #PlacesToolbarItems > toolbarbutton`
+ )
+ ) {
+ return true;
+ }
+ checkNumBookmarksOnToolbar = true;
+ return false;
+ })();
+
+ if (checkNumBookmarksOnToolbar) {
+ hasVisibleChildren = !(await PlacesToolbarHelper.getIsEmpty());
+ }
+
+ let emptyMsg = document.getElementById("personal-toolbar-empty");
+ emptyMsg.hidden = hasVisibleChildren;
+ emptyMsg.toggleAttribute("nowidth", !hasVisibleChildren);
+ },
+
+ openLibraryIfLinkClicked(event) {
+ if (
+ ((event.type == "click" && event.button == 0) ||
+ (event.type == "keydown" && event.keyCode == KeyEvent.DOM_VK_RETURN)) &&
+ event.target.localName == "a"
+ ) {
+ PlacesCommandHook.showPlacesOrganizer("BookmarksToolbar");
+ }
+ },
+
+ // Set by sync after syncing bookmarks successfully once.
+ MOBILE_BOOKMARKS_PREF: "browser.bookmarks.showMobileBookmarks",
+
+ _shouldShowMobileBookmarks() {
+ return Services.prefs.getBoolPref(this.MOBILE_BOOKMARKS_PREF, false);
+ },
+
+ _initMobileBookmarks(mobileMenuItem) {
+ mobileMenuItem.hidden = !this._shouldShowMobileBookmarks();
+ },
+
+ _uninitView: function BUI__uninitView() {
+ // When an element with a placesView attached is removed and re-inserted,
+ // XBL reapplies the binding causing any kind of issues and possible leaks,
+ // so kill current view and let popupshowing generate a new one.
+ if (this.button._placesView) {
+ this.button._placesView.uninit();
+ }
+ // Also uninit the main menubar placesView, since it would have the same
+ // issues.
+ let menubar = document.getElementById("bookmarksMenu");
+ if (menubar && menubar._placesView) {
+ menubar._placesView.uninit();
+ }
+
+ // We have to do the same thing for the "special" views underneath the
+ // the bookmarks menu.
+ const kSpecialViewNodeIDs = [
+ "BMB_bookmarksToolbar",
+ "BMB_unsortedBookmarks",
+ ];
+ for (let viewNodeID of kSpecialViewNodeIDs) {
+ let elem = document.getElementById(viewNodeID);
+ if (elem && elem._placesView) {
+ elem._placesView.uninit();
+ }
+ }
+ },
+
+ onCustomizeStart: function BUI_customizeStart(aWindow) {
+ if (aWindow == window) {
+ this._uninitView();
+ this._isCustomizing = true;
+
+ this.updateEmptyToolbarMessage().catch(console.error);
+
+ let isVisible =
+ Services.prefs.getCharPref(
+ "browser.toolbars.bookmarks.visibility",
+ "newtab"
+ ) != "never";
+ // Temporarily show the bookmarks toolbar in Customize mode if
+ // the toolbar isn't set to Never. We don't have to worry about
+ // hiding when leaving customize mode since the toolbar will
+ // hide itself on location change.
+ setToolbarVisibility(this.toolbar, isVisible, false);
+ }
+ },
+
+ onWidgetAdded: function BUI_widgetAdded(aWidgetId, aArea) {
+ if (aWidgetId == this.BOOKMARK_BUTTON_ID) {
+ this._onWidgetWasMoved();
+ }
+ if (aArea == CustomizableUI.AREA_BOOKMARKS) {
+ this.updateEmptyToolbarMessage().catch(console.error);
+ }
+ },
+
+ onWidgetRemoved: function BUI_widgetRemoved(aWidgetId, aOldArea) {
+ if (aWidgetId == this.BOOKMARK_BUTTON_ID) {
+ this._onWidgetWasMoved();
+ }
+ if (aOldArea == CustomizableUI.AREA_BOOKMARKS) {
+ this.updateEmptyToolbarMessage().catch(console.error);
+ }
+ },
+
+ onWidgetReset: function BUI_widgetReset(aNode, aContainer) {
+ if (aNode == this.button) {
+ this._onWidgetWasMoved();
+ }
+ },
+
+ onWidgetUndoMove: function BUI_undoWidgetUndoMove(aNode, aContainer) {
+ if (aNode == this.button) {
+ this._onWidgetWasMoved();
+ }
+ },
+
+ onWidgetBeforeDOMChange: function BUI_onWidgetBeforeDOMChange(
+ aNode,
+ aNextNode,
+ aContainer,
+ aIsRemoval
+ ) {
+ if (aNode.id == "import-button") {
+ this._updateImportButton(aNode, aIsRemoval ? null : aContainer);
+ }
+ },
+
+ _updateImportButton: function BUI_updateImportButton(aNode, aContainer) {
+ // The import button behaves like a bookmark item when in the bookmarks
+ // toolbar, otherwise like a regular toolbar button.
+ let isBookmarkItem = aContainer == this.toolbar;
+ aNode.classList.toggle("toolbarbutton-1", !isBookmarkItem);
+ aNode.classList.toggle("bookmark-item", isBookmarkItem);
+ },
+
+ _onWidgetWasMoved: function BUI_widgetWasMoved() {
+ // If we're moved outside of customize mode, we need to uninit
+ // our view so it gets reconstructed.
+ if (!this._isCustomizing) {
+ this._uninitView();
+ }
+ },
+
+ onCustomizeEnd: function BUI_customizeEnd(aWindow) {
+ if (aWindow == window) {
+ this._isCustomizing = false;
+ this.updateEmptyToolbarMessage().catch(console.error);
+ }
+ },
+
+ init() {
+ CustomizableUI.addListener(this);
+ let importButton = document.getElementById("import-button");
+ if (importButton) {
+ this._updateImportButton(importButton, importButton.parentNode);
+ }
+ this.updateEmptyToolbarMessage().catch(console.error);
+ },
+
+ _hasBookmarksObserver: false,
+ _itemGuids: new Set(),
+ uninit: function BUI_uninit() {
+ this.updateBookmarkPageMenuItem(true);
+ CustomizableUI.removeListener(this);
+
+ this._uninitView();
+
+ if (this._hasBookmarksObserver) {
+ PlacesUtils.observers.removeListener(
+ [
+ "bookmark-added",
+ "bookmark-removed",
+ "bookmark-moved",
+ "bookmark-url-changed",
+ ],
+ this.handlePlacesEvents
+ );
+ }
+
+ if (this._pendingUpdate) {
+ delete this._pendingUpdate;
+ }
+ },
+
+ onLocationChange: function BUI_onLocationChange() {
+ if (this._uri && gBrowser.currentURI.equals(this._uri)) {
+ return;
+ }
+ this.updateStarState();
+ },
+
+ updateStarState: function BUI_updateStarState() {
+ this._uri = gBrowser.currentURI;
+ this._itemGuids.clear();
+ let guids = new Set();
+
+ // those objects are use to check if we are in the current iteration before
+ // returning any result.
+ let pendingUpdate = (this._pendingUpdate = {});
+
+ PlacesUtils.bookmarks
+ .fetch({ url: this._uri }, b => guids.add(b.guid), { concurrent: true })
+ .catch(console.error)
+ .then(() => {
+ if (pendingUpdate != this._pendingUpdate) {
+ return;
+ }
+
+ // It's possible that "bookmark-added" gets called before the async statement
+ // calls back. For such an edge case, retain all unique entries from the
+ // array.
+ if (this._itemGuids.size > 0) {
+ this._itemGuids = new Set(...this._itemGuids, ...guids);
+ } else {
+ this._itemGuids = guids;
+ }
+
+ this._updateStar();
+
+ // Start observing bookmarks if needed.
+ if (!this._hasBookmarksObserver) {
+ try {
+ this.handlePlacesEvents = this.handlePlacesEvents.bind(this);
+ PlacesUtils.observers.addListener(
+ [
+ "bookmark-added",
+ "bookmark-removed",
+ "bookmark-moved",
+ "bookmark-url-changed",
+ ],
+ this.handlePlacesEvents
+ );
+ this._hasBookmarksObserver = true;
+ } catch (ex) {
+ console.error(
+ "BookmarkingUI failed adding a bookmarks observer: ",
+ ex
+ );
+ }
+ }
+
+ delete this._pendingUpdate;
+ });
+ },
+
+ _updateStar: function BUI__updateStar() {
+ let starred = this._itemGuids.size > 0;
+
+ // Update the image for all elements.
+ for (let element of [
+ this.star,
+ document.getElementById("context-bookmarkpage"),
+ PanelMultiView.getViewNode(document, "panelMenuBookmarkThisPage"),
+ document.getElementById("pageAction-panel-bookmark"),
+ ]) {
+ if (!element) {
+ // The page action panel element may not have been created yet.
+ continue;
+ }
+ if (starred) {
+ element.setAttribute("starred", "true");
+ } else {
+ element.removeAttribute("starred");
+ }
+ }
+
+ if (!this.starBox) {
+ // The BOOKMARK_BUTTON_SHORTCUT exists only in browser.xhtml.
+ // Return early if we're not in this context, but still reset the
+ // Bookmark Page items.
+ this.updateBookmarkPageMenuItem(true);
+ return;
+ }
+
+ // Update the tooltip for elements that require it.
+ let shortcut = document.getElementById(this.BOOKMARK_BUTTON_SHORTCUT);
+ let l10nArgs = {
+ shortcut: ShortcutUtils.prettifyShortcut(shortcut),
+ };
+ document.l10n.setAttributes(
+ this.starBox,
+ starred ? "urlbar-star-edit-bookmark" : "urlbar-star-add-bookmark",
+ l10nArgs
+ );
+
+ // Update the Bookmark Page menuitem when bookmarked state changes.
+ this.updateBookmarkPageMenuItem();
+
+ Services.obs.notifyObservers(
+ null,
+ "bookmark-icon-updated",
+ starred ? "starred" : "unstarred"
+ );
+ },
+
+ /**
+ * Update the "Bookmark Page…" menuitems on the menubar, panels, context
+ * menu and page actions.
+ * @param {boolean} [forceReset] passed when we're destroyed and the label
+ * should go back to the default (Bookmark Page), for MacOS.
+ */
+ updateBookmarkPageMenuItem(forceReset = false) {
+ let isStarred = !forceReset && this._itemGuids.size > 0;
+ // Define the l10n id which will be used to localize elements
+ // that only require a label using the menubar.ftl messages.
+ let menuItemL10nId = isStarred ? "menu-edit-bookmark" : "menu-bookmark-tab";
+ let menuItem = document.getElementById("menu_bookmarkThisPage");
+ if (menuItem) {
+ // Localize the menubar item.
+ document.l10n.setAttributes(menuItem, menuItemL10nId);
+ }
+
+ let panelMenuItemL10nId = isStarred
+ ? "bookmarks-subview-edit-bookmark"
+ : "bookmarks-subview-bookmark-tab";
+ let panelMenuToolbarButton = PanelMultiView.getViewNode(
+ document,
+ "panelMenuBookmarkThisPage"
+ );
+ if (panelMenuToolbarButton) {
+ document.l10n.setAttributes(panelMenuToolbarButton, panelMenuItemL10nId);
+ }
+
+ // Localize the context menu item element.
+ let contextItem = document.getElementById("context-bookmarkpage");
+ // On macOS regular menuitems are used and the shortcut isn't added
+ if (contextItem) {
+ if (AppConstants.platform == "macosx") {
+ let contextItemL10nId = isStarred
+ ? "main-context-menu-edit-bookmark-mac"
+ : "main-context-menu-bookmark-page-mac";
+ document.l10n.setAttributes(contextItem, contextItemL10nId);
+ } else {
+ let shortcutElem = document.getElementById(
+ this.BOOKMARK_BUTTON_SHORTCUT
+ );
+ if (shortcutElem) {
+ let shortcut = ShortcutUtils.prettifyShortcut(shortcutElem);
+ let contextItemL10nId = isStarred
+ ? "main-context-menu-edit-bookmark-with-shortcut"
+ : "main-context-menu-bookmark-page-with-shortcut";
+ let l10nArgs = { shortcut };
+ document.l10n.setAttributes(contextItem, contextItemL10nId, l10nArgs);
+ } else {
+ let contextItemL10nId = isStarred
+ ? "main-context-menu-edit-bookmark"
+ : "main-context-menu-bookmark-page";
+ document.l10n.setAttributes(contextItem, contextItemL10nId);
+ }
+ }
+ }
+
+ // Update Page Actions.
+ if (document.getElementById("page-action-buttons")) {
+ // Fetch the label attribute value of the message and
+ // apply it on the star title.
+ //
+ // Note: This should be updated once bug 1608198 is fixed.
+ this._latestMenuItemL10nId = menuItemL10nId;
+ document.l10n.formatMessages([{ id: menuItemL10nId }]).then(l10n => {
+ // It's possible for this promise to be scheduled multiple times.
+ // In such a case, we'd like to avoid setting the title if there's
+ // a newer l10n id pending to be set.
+ if (this._latestMenuItemL10nId != menuItemL10nId) {
+ return;
+ }
+
+ // We assume that menuItemL10nId has a single attribute.
+ let label = l10n[0].attributes[0].value;
+
+ // Update the label for the page action panel.
+ let panelButton = BrowserPageActions.panelButtonNodeForActionID(
+ PageActions.ACTION_ID_BOOKMARK
+ );
+ if (panelButton) {
+ panelButton.setAttribute("label", label);
+ }
+ });
+ }
+ },
+
+ onMainMenuPopupShowing: function BUI_onMainMenuPopupShowing(event) {
+ // Don't handle events for submenus.
+ if (event.target != event.currentTarget) {
+ return;
+ }
+
+ this._initMobileBookmarks(document.getElementById("menu_mobileBookmarks"));
+ },
+
+ showSubView(anchor) {
+ this._showSubView(null, anchor);
+ },
+
+ _showSubView(
+ event,
+ anchor = document.getElementById(this.BOOKMARK_BUTTON_ID)
+ ) {
+ let view = PanelMultiView.getViewNode(document, "PanelUI-bookmarks");
+ view.addEventListener("ViewShowing", this);
+ view.addEventListener("ViewHiding", this);
+ anchor.setAttribute("closemenu", "none");
+ this.updateLabel("panelMenu_viewBookmarksToolbar", !this.toolbar.collapsed);
+ PanelUI.showSubView("PanelUI-bookmarks", anchor, event);
+ },
+
+ onCommand: function BUI_onCommand(aEvent) {
+ if (aEvent.target != aEvent.currentTarget) {
+ return;
+ }
+
+ // Handle special case when the button is in the panel.
+ if (this.button.getAttribute("cui-areatype") == CustomizableUI.TYPE_PANEL) {
+ this._showSubView(aEvent);
+ return;
+ }
+ let widget = CustomizableUI.getWidget(this.BOOKMARK_BUTTON_ID).forWindow(
+ window
+ );
+ if (widget.overflowed) {
+ // Close the overflow panel because the Edit Bookmark panel will appear.
+ widget.node.removeAttribute("closemenu");
+ }
+ this.onStarCommand(aEvent);
+ },
+
+ onStarCommand(aEvent) {
+ // Ignore non-left clicks on the star, or if we are updating its state.
+ if (
+ !this._pendingUpdate &&
+ (aEvent.type != "click" || aEvent.button == 0)
+ ) {
+ PlacesCommandHook.bookmarkPage();
+ }
+ },
+
+ handleEvent: function BUI_handleEvent(aEvent) {
+ switch (aEvent.type) {
+ case "ViewShowing":
+ this.onPanelMenuViewShowing(aEvent);
+ break;
+ case "ViewHiding":
+ this.onPanelMenuViewHiding(aEvent);
+ break;
+ }
+ },
+
+ onPanelMenuViewShowing: function BUI_onViewShowing(aEvent) {
+ let panelview = aEvent.target;
+
+ // Get all statically placed buttons to supply them with keyboard shortcuts.
+ let staticButtons = panelview.getElementsByTagName("toolbarbutton");
+ for (let i = 0, l = staticButtons.length; i < l; ++i) {
+ CustomizableUI.addShortcut(staticButtons[i]);
+ }
+
+ // Setup the Places view.
+ // We restrict the amount of results to 42. Not 50, but 42. Why? Because 42.
+ let query =
+ "place:queryType=" +
+ Ci.nsINavHistoryQueryOptions.QUERY_TYPE_BOOKMARKS +
+ "&sort=" +
+ Ci.nsINavHistoryQueryOptions.SORT_BY_DATEADDED_DESCENDING +
+ "&maxResults=42&excludeQueries=1";
+
+ this._panelMenuView = new PlacesPanelview(
+ query,
+ document.getElementById("panelMenu_bookmarksMenu"),
+ panelview
+ );
+ panelview.removeEventListener("ViewShowing", this);
+ },
+
+ onPanelMenuViewHiding: function BUI_onViewHiding(aEvent) {
+ this._panelMenuView.uninit();
+ delete this._panelMenuView;
+ aEvent.target.removeEventListener("ViewHiding", this);
+ },
+
+ handlePlacesEvents(aEvents) {
+ let isStarUpdateNeeded = false;
+ let affectsOtherBookmarksFolder = false;
+ let affectsBookmarksToolbarFolder = false;
+
+ for (let ev of aEvents) {
+ switch (ev.type) {
+ case "bookmark-added":
+ // Only need to update the UI if it wasn't marked as starred before:
+ if (this._itemGuids.size == 0) {
+ if (ev.url && ev.url == this._uri.spec) {
+ // If a new bookmark has been added to the tracked uri, register it.
+ if (!this._itemGuids.has(ev.guid)) {
+ this._itemGuids.add(ev.guid);
+ isStarUpdateNeeded = true;
+ }
+ }
+ }
+
+ if (ev.parentGuid === PlacesUtils.bookmarks.toolbarGuid) {
+ Services.telemetry.scalarAdd(
+ "browser.engagement.bookmarks_toolbar_bookmark_added",
+ 1
+ );
+ }
+ break;
+ case "bookmark-removed":
+ // If one of the tracked bookmarks has been removed, unregister it.
+ if (this._itemGuids.has(ev.guid)) {
+ this._itemGuids.delete(ev.guid);
+ // Only need to update the UI if the page is no longer starred
+ if (this._itemGuids.size == 0) {
+ isStarUpdateNeeded = true;
+ }
+ }
+
+ // Reset the default location if it is equal to the folder
+ // being deleted. Just check the preference directly since we
+ // do not want to do a asynchronous db lookup.
+ PlacesUIUtils.defaultParentGuid.then(parentGuid => {
+ if (
+ ev.itemType == PlacesUtils.bookmarks.TYPE_FOLDER &&
+ ev.guid == parentGuid
+ ) {
+ Services.prefs.setCharPref(
+ "browser.bookmarks.defaultLocation",
+ PlacesUtils.bookmarks.toolbarGuid
+ );
+ }
+ });
+ break;
+ case "bookmark-moved":
+ if (
+ ev.parentGuid === PlacesUtils.bookmarks.unfiledGuid ||
+ ev.oldParentGuid === PlacesUtils.bookmarks.unfiledGuid
+ ) {
+ affectsOtherBookmarksFolder = true;
+ }
+
+ if (
+ ev.parentGuid == PlacesUtils.bookmarks.toolbarGuid ||
+ ev.oldParentGuid == PlacesUtils.bookmarks.toolbarGuid
+ ) {
+ affectsBookmarksToolbarFolder = true;
+ if (ev.oldParentGuid != PlacesUtils.bookmarks.toolbarGuid) {
+ Services.telemetry.scalarAdd(
+ "browser.engagement.bookmarks_toolbar_bookmark_added",
+ 1
+ );
+ }
+ }
+ break;
+ case "bookmark-url-changed":
+ // If the changed bookmark was tracked, check if it is now pointing to
+ // a different uri and unregister it.
+ if (this._itemGuids.has(ev.guid) && ev.url != this._uri.spec) {
+ this._itemGuids.delete(ev.guid);
+ // Only need to update the UI if the page is no longer starred
+ if (this._itemGuids.size == 0) {
+ this._updateStar();
+ }
+ } else if (
+ !this._itemGuids.has(ev.guid) &&
+ ev.url == this._uri.spec
+ ) {
+ // If another bookmark is now pointing to the tracked uri, register it.
+ this._itemGuids.add(ev.guid);
+ // Only need to update the UI if it wasn't marked as starred before:
+ if (this._itemGuids.size == 1) {
+ this._updateStar();
+ }
+ }
+
+ break;
+ }
+
+ if (ev.parentGuid == PlacesUtils.bookmarks.unfiledGuid) {
+ affectsOtherBookmarksFolder = true;
+ } else if (ev.parentGuid == PlacesUtils.bookmarks.toolbarGuid) {
+ affectsBookmarksToolbarFolder = true;
+ }
+ }
+
+ if (isStarUpdateNeeded) {
+ this._updateStar();
+ }
+
+ // Run after the notification has been handled by the views.
+ Services.tm.dispatchToMainThread(() => {
+ if (affectsOtherBookmarksFolder) {
+ this.maybeShowOtherBookmarksFolder().catch(console.error);
+ }
+ if (affectsBookmarksToolbarFolder) {
+ this.updateEmptyToolbarMessage().catch(console.error);
+ }
+ });
+ },
+
+ onWidgetUnderflow(aNode, aContainer) {
+ let win = aNode.ownerGlobal;
+ if (aNode.id != this.BOOKMARK_BUTTON_ID || win != window) {
+ return;
+ }
+
+ // The view gets broken by being removed and reinserted. Uninit
+ // here so popupshowing will generate a new one:
+ this._uninitView();
+ },
+
+ async maybeShowOtherBookmarksFolder() {
+ // PlacesToolbar._placesView can be undefined if the toolbar isn't initialized,
+ // collapsed, or hidden in some other way.
+ let toolbar = document.getElementById("PlacesToolbar");
+ if (!toolbar?._placesView) {
+ return;
+ }
+
+ let placement = CustomizableUI.getPlacementOfWidget("personal-bookmarks");
+ let otherBookmarks = document.getElementById("OtherBookmarks");
+ if (
+ !SHOW_OTHER_BOOKMARKS ||
+ placement?.area != CustomizableUI.AREA_BOOKMARKS
+ ) {
+ if (otherBookmarks) {
+ otherBookmarks.hidden = true;
+ }
+ return;
+ }
+
+ let instance = (this._showOtherBookmarksInstance = {});
+ let unfiledGuid = PlacesUtils.bookmarks.unfiledGuid;
+ let numberOfBookmarks = (await PlacesUtils.bookmarks.fetch(unfiledGuid))
+ .childCount;
+ if (instance != this._showOtherBookmarksInstance) {
+ return;
+ }
+
+ if (numberOfBookmarks > 0) {
+ // Build the "Other Bookmarks" button if it doesn't exist.
+ if (!otherBookmarks) {
+ const node = PlacesUtils.getFolderContents(unfiledGuid).root;
+ otherBookmarks = this.buildOtherBookmarksFolder(node);
+ }
+ otherBookmarks.hidden = false;
+ } else if (otherBookmarks) {
+ otherBookmarks.hidden = true;
+ }
+ },
+
+ buildShowOtherBookmarksMenuItem() {
+ // Building this only if there's bookmarks in unfiled would cause
+ // synchronous IO, thus we just add it as disabled and enable it once the
+ // information is available.
+ let menuItem = document.createXULElement("menuitem");
+
+ menuItem.setAttribute("id", "show-other-bookmarks_PersonalToolbar");
+ menuItem.setAttribute("toolbarId", "PersonalToolbar");
+ menuItem.setAttribute("type", "checkbox");
+ menuItem.setAttribute("checked", SHOW_OTHER_BOOKMARKS);
+ menuItem.setAttribute("selection-type", "none|single");
+ menuItem.setAttribute("start-disabled", "true");
+
+ MozXULElement.insertFTLIfNeeded("browser/toolbarContextMenu.ftl");
+ document.l10n.setAttributes(
+ menuItem,
+ "toolbar-context-menu-bookmarks-show-other-bookmarks"
+ );
+ menuItem.addEventListener("command", () => {
+ Services.prefs.setBoolPref(
+ "browser.toolbars.bookmarks.showOtherBookmarks",
+ !SHOW_OTHER_BOOKMARKS
+ );
+ });
+ // Enable the menuItem if there's unfiled bookmarks
+ PlacesUtils.bookmarks.fetch(PlacesUtils.bookmarks.unfiledGuid).then(bm => {
+ if (bm.childCount) {
+ menuItem.disabled = false;
+ }
+ });
+
+ return menuItem;
+ },
+
+ buildOtherBookmarksFolder(node) {
+ let otherBookmarksButton = document.createXULElement("toolbarbutton");
+ otherBookmarksButton.setAttribute("type", "menu");
+ otherBookmarksButton.setAttribute("container", "true");
+ otherBookmarksButton.setAttribute(
+ "onpopupshowing",
+ "document.getElementById('PlacesToolbar')._placesView._onOtherBookmarksPopupShowing(event);"
+ );
+ otherBookmarksButton.id = "OtherBookmarks";
+ otherBookmarksButton.className = "bookmark-item";
+ otherBookmarksButton.hidden = "true";
+
+ MozXULElement.insertFTLIfNeeded("browser/places.ftl");
+ document.l10n.setAttributes(otherBookmarksButton, "other-bookmarks-folder");
+
+ let otherBookmarksPopup = document.createXULElement("menupopup", {
+ is: "places-popup",
+ });
+ otherBookmarksPopup.setAttribute("placespopup", "true");
+ otherBookmarksPopup.setAttribute("type", "arrow");
+ otherBookmarksPopup.setAttribute("context", "placesContext");
+ otherBookmarksPopup.id = "OtherBookmarksPopup";
+
+ otherBookmarksPopup._placesNode = PlacesUtils.asContainer(node);
+ otherBookmarksButton._placesNode = PlacesUtils.asContainer(node);
+
+ otherBookmarksButton.appendChild(otherBookmarksPopup);
+
+ let chevronButton = document.getElementById("PlacesChevron");
+ chevronButton.parentNode.append(otherBookmarksButton);
+
+ let placesToolbar = document.getElementById("PlacesToolbar");
+ placesToolbar._placesView._otherBookmarks = otherBookmarksButton;
+ placesToolbar._placesView._otherBookmarksPopup = otherBookmarksPopup;
+ return otherBookmarksButton;
+ },
+};
diff --git a/browser/base/content/browser-safebrowsing.js b/browser/base/content/browser-safebrowsing.js
new file mode 100644
index 0000000000..323887f0c5
--- /dev/null
+++ b/browser/base/content/browser-safebrowsing.js
@@ -0,0 +1,75 @@
+/* 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/. */
+
+// This file is loaded into the browser window scope.
+/* eslint-env mozilla/browser-window */
+
+var gSafeBrowsing = {
+ setReportPhishingMenu() {
+ // In order to detect whether or not we're at the phishing warning
+ // page, we have to check the documentURI instead of the currentURI.
+ // This is because when the DocShell loads an error page, the
+ // currentURI stays at the original target, while the documentURI
+ // will point to the internal error page we loaded instead.
+ var docURI = gBrowser.selectedBrowser.documentURI;
+ var isPhishingPage =
+ docURI && docURI.spec.startsWith("about:blocked?e=deceptiveBlocked");
+
+ // Show/hide the appropriate menu item.
+ const reportMenu = document.getElementById(
+ "menu_HelpPopup_reportPhishingtoolmenu"
+ );
+ reportMenu.hidden = isPhishingPage;
+ const reportErrorMenu = document.getElementById(
+ "menu_HelpPopup_reportPhishingErrortoolmenu"
+ );
+ reportErrorMenu.hidden = !isPhishingPage;
+
+ // Now look at the currentURI to learn which page we were trying
+ // to browse to.
+ const uri = gBrowser.currentURI;
+ const isReportablePage =
+ uri && (uri.schemeIs("http") || uri.schemeIs("https"));
+
+ const disabledByPolicy = !Services.policies.isAllowed("feedbackCommands");
+
+ if (disabledByPolicy || isPhishingPage || !isReportablePage) {
+ reportMenu.setAttribute("disabled", "true");
+ } else {
+ reportMenu.removeAttribute("disabled");
+ }
+
+ if (disabledByPolicy || !isPhishingPage || !isReportablePage) {
+ reportErrorMenu.setAttribute("disabled", "true");
+ } else {
+ reportErrorMenu.removeAttribute("disabled");
+ }
+ },
+
+ /**
+ * Used to report a phishing page or a false positive
+ *
+ * @param name
+ * String One of "PhishMistake", "MalwareMistake", or "Phish"
+ * @param info
+ * Information about the reasons for blocking the resource.
+ * In the case false positive, it may contain SafeBrowsing
+ * matching list and provider of the list
+ * @return String the report phishing URL.
+ */
+ getReportURL(name, info) {
+ let reportInfo = info;
+ if (!reportInfo) {
+ let pageUri = gBrowser.currentURI;
+
+ // Remove the query to avoid including potentially sensitive data
+ if (pageUri instanceof Ci.nsIURL) {
+ pageUri = pageUri.mutate().setQuery("").finalize();
+ }
+
+ reportInfo = { uri: pageUri.asciiSpec };
+ }
+ return SafeBrowsing.getReportURL(name, reportInfo);
+ },
+};
diff --git a/browser/base/content/browser-sets.inc b/browser/base/content/browser-sets.inc
new file mode 100644
index 0000000000..671c786b2a
--- /dev/null
+++ b/browser/base/content/browser-sets.inc
@@ -0,0 +1,398 @@
+# -*- Mode: Java; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*-
+# 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/.
+
+#ifdef XP_UNIX
+#ifndef XP_MACOSX
+#define XP_GNOME 1
+#endif
+#endif
+
+ <stringbundleset id="stringbundleset">
+ <stringbundle id="bundle_brand" src="chrome://branding/locale/brand.properties"/>
+ <stringbundle id="bundle_shell" src="chrome://browser/locale/shellservice.properties"/>
+ </stringbundleset>
+
+ <commandset id="mainCommandSet">
+ <command id="cmd_newNavigator" oncommand="OpenBrowserWindow()"/>
+ <command id="cmd_handleBackspace" oncommand="BrowserHandleBackspace();" />
+ <command id="cmd_handleShiftBackspace" oncommand="BrowserHandleShiftBackspace();" />
+
+ <command id="cmd_newNavigatorTab" oncommand="BrowserOpenTab({ event });"/>
+ <command id="cmd_newNavigatorTabNoEvent" oncommand="BrowserOpenTab();"/>
+ <command id="Browser:OpenFile" oncommand="BrowserOpenFileWindow();"/>
+ <command id="Browser:SavePage" oncommand="saveBrowser(gBrowser.selectedBrowser);"/>
+
+ <command id="Browser:SendLink"
+ oncommand="MailIntegration.sendLinkForBrowser(gBrowser.selectedBrowser);"/>
+
+ <command id="cmd_pageSetup" oncommand="PrintUtils.showPageSetup();"/>
+ <command id="cmd_print" oncommand="PrintUtils.startPrintWindow(gBrowser.selectedBrowser.browsingContext);"/>
+ <command id="cmd_printPreviewToggle" oncommand="PrintUtils.togglePrintPreview(gBrowser.selectedBrowser.browsingContext);"/>
+ <command id="cmd_file_importFromAnotherBrowser" oncommand="MigrationUtils.showMigrationWizard(window, { entrypoint: MigrationUtils.MIGRATION_ENTRYPOINTS.FILE_MENU });"/>
+ <command id="cmd_help_importFromAnotherBrowser" oncommand="MigrationUtils.showMigrationWizard(window, { entrypoint: MigrationUtils.MIGRATION_ENTRYPOINTS.HELP_MENU });"/>
+ <command id="cmd_close" oncommand="BrowserCloseTabOrWindow(event);"/>
+ <command id="cmd_closeWindow" oncommand="BrowserTryToCloseWindow(event)"/>
+ <command id="cmd_toggleMute" oncommand="gBrowser.toggleMuteAudioOnMultiSelectedTabs(gBrowser.selectedTab)"/>
+ <command id="cmd_CustomizeToolbars" oncommand="gCustomizeMode.enter()"/>
+ <command id="cmd_toggleOfflineStatus" oncommand="BrowserOffline.toggleOfflineStatus();"/>
+ <command id="cmd_quitApplication" oncommand="goQuitApplication(event)"/>
+
+ <command id="View:AboutProcesses" oncommand="switchToTabHavingURI('about:processes', true)"/>
+ <command id="View:PageSource" oncommand="BrowserViewSource(window.gBrowser.selectedBrowser);"/>
+ <command id="View:PageInfo" oncommand="BrowserPageInfo();"/>
+ <command id="View:FullScreen" oncommand="BrowserFullScreen();"/>
+ <command id="View:ReaderView" oncommand="AboutReaderParent.toggleReaderMode(event);"/>
+ <command id="View:PictureInPicture" oncommand="PictureInPicture.onCommand(event);"/>
+ <command id="cmd_find" oncommand="gLazyFindCommand('onFindCommand')"/>
+ <command id="cmd_findAgain" oncommand="gLazyFindCommand('onFindAgainCommand', false)"/>
+ <command id="cmd_findPrevious" oncommand="gLazyFindCommand('onFindAgainCommand', true)"/>
+#ifdef XP_MACOSX
+ <command id="cmd_findSelection" oncommand="gLazyFindCommand('onFindSelectionCommand')"/>
+#endif
+ <command id="cmd_translate" oncommand="TranslationsPanel.open(event);"/>
+ <!-- work-around bug 392512 -->
+ <command id="Browser:AddBookmarkAs"
+ oncommand="PlacesCommandHook.bookmarkPage();"/>
+ <command id="Browser:BookmarkAllTabs"
+ oncommand="PlacesUIUtils.showBookmarkPagesDialog(PlacesCommandHook.uniqueCurrentPages);"/>
+ <command id="Browser:Back" oncommand="BrowserBack();" disabled="true"/>
+ <command id="Browser:BackOrBackDuplicate" oncommand="BrowserBack(event);" disabled="true">
+ <observes element="Browser:Back" attribute="disabled"/>
+ </command>
+ <command id="Browser:Forward" oncommand="BrowserForward();" disabled="true"/>
+ <command id="Browser:ForwardOrForwardDuplicate" oncommand="BrowserForward(event);" disabled="true">
+ <observes element="Browser:Forward" attribute="disabled"/>
+ </command>
+ <command id="Browser:Stop" oncommand="BrowserStop();" disabled="true"/>
+ <command id="Browser:Reload" oncommand="if (event.shiftKey) BrowserReloadSkipCache(); else BrowserReload()" disabled="true"/>
+ <command id="Browser:ReloadOrDuplicate" oncommand="BrowserReloadOrDuplicate(event)" disabled="true">
+ <observes element="Browser:Reload" attribute="disabled"/>
+ </command>
+ <command id="Browser:ReloadSkipCache" oncommand="BrowserReloadSkipCache()" disabled="true">
+ <observes element="Browser:Reload" attribute="disabled"/>
+ </command>
+ <command id="Browser:NextTab" oncommand="gBrowser.tabContainer.advanceSelectedTab(1, true);"/>
+ <command id="Browser:PrevTab" oncommand="gBrowser.tabContainer.advanceSelectedTab(-1, true);"/>
+ <command id="Browser:ShowAllTabs" oncommand="gTabsPanel.showAllTabsPanel();"/>
+ <command id="cmd_fullZoomReduce" oncommand="FullZoom.reduce()"/>
+ <command id="cmd_fullZoomEnlarge" oncommand="FullZoom.enlarge()"/>
+ <command id="cmd_fullZoomReset" oncommand="FullZoom.reset(); FullZoom.resetScalingZoom();"/>
+ <command id="cmd_fullZoomToggle" oncommand="ZoomManager.toggleZoom();"/>
+ <command id="cmd_gestureRotateLeft" oncommand="gGestureSupport.rotate(event.sourceEvent)"/>
+ <command id="cmd_gestureRotateRight" oncommand="gGestureSupport.rotate(event.sourceEvent)"/>
+ <command id="cmd_gestureRotateEnd" oncommand="gGestureSupport.rotateEnd()"/>
+ <command id="Browser:OpenLocation" oncommand="openLocation(event);"/>
+ <command id="Browser:RestoreLastSession" oncommand="SessionStore.restoreLastSession();" disabled="true"/>
+ <command id="Browser:NewUserContextTab" oncommand="openNewUserContextTab(event.sourceEvent);"/>
+ <command id="Browser:OpenAboutContainers" oncommand="openPreferences('paneContainers');"/>
+ <command id="Tools:Search" oncommand="BrowserSearch.webSearch();"/>
+ <command id="Tools:Downloads" oncommand="BrowserDownloadsUI();"/>
+ <command id="Tools:Addons" oncommand="BrowserOpenAddonsMgr();"/>
+ <command id="Tools:Sanitize" oncommand="Sanitizer.showUI(window);"/>
+ <command id="Tools:PrivateBrowsing"
+ oncommand="OpenBrowserWindow({private: true});"/>
+ <command id="Browser:Screenshot" oncommand="ScreenshotsUtils.notify(window, 'shortcut')"/>
+ <command id="History:UndoCloseTab" oncommand="undoCloseTab();" data-l10n-args='{"tabCount": 1}'/>
+ <command id="History:UndoCloseWindow" oncommand="undoCloseWindow();"/>
+
+ <command id="wrCaptureCmd" oncommand="gGfxUtils.webrenderCapture();" disabled="true"/>
+ <command id="wrToggleCaptureSequenceCmd" oncommand="gGfxUtils.toggleWebrenderCaptureSequence();" disabled="true"/>
+#ifdef NIGHTLY_BUILD
+ <command id="windowRecordingCmd" oncommand="gGfxUtils.toggleWindowRecording();"/>
+#endif
+#ifdef XP_MACOSX
+ <command id="minimizeWindow"
+ data-l10n-id="window-minimize-command"
+ oncommand="window.minimize();" />
+ <command id="zoomWindow"
+ data-l10n-id="window-zoom-command"
+ oncommand="zoomWindow();" />
+#endif
+ </commandset>
+
+#include ../../components/places/content/placesCommands.inc.xhtml
+
+ <keyset id="mainKeyset">
+ <key id="key_newNavigator"
+ data-l10n-id="window-new-shortcut"
+ command="cmd_newNavigator"
+ modifiers="accel" reserved="true"/>
+ <key id="key_newNavigatorTab" data-l10n-id="tab-new-shortcut" modifiers="accel"
+ command="cmd_newNavigatorTabNoEvent" reserved="true"/>
+ <key id="focusURLBar" data-l10n-id="location-open-shortcut" command="Browser:OpenLocation"
+ modifiers="accel"/>
+#ifndef XP_MACOSX
+ <key id="focusURLBar2" data-l10n-id="location-open-shortcut-alt" command="Browser:OpenLocation"
+ modifiers="alt"/>
+#endif
+
+#
+# Search Command Key Logic works like this:
+#
+# Unix: Ctrl+K (cross platform binding)
+# Ctrl+J (in case of emacs Ctrl-K conflict)
+# Mac: Cmd+K (cross platform binding)
+# Cmd+Opt+F (platform convention)
+# Win: Ctrl+K (cross platform binding)
+# Ctrl+E (IE compat)
+#
+# We support Ctrl+K on all platforms now and advertise it in the menu since it is
+# our standard - it is a "safe" choice since it is near no harmful keys like "W" as
+# "E" is. People mourning the loss of Ctrl+K for emacs compat can switch their GTK
+# system setting to use emacs emulation, and we should respect it. Focus-Search-Box
+# is a fundamental keybinding and we are maintaining a XP binding so that it is easy
+# for people to switch to Linux.
+#
+ <key id="key_search" data-l10n-id="search-focus-shortcut" command="Tools:Search" modifiers="accel"/>
+ <key id="key_search2"
+#ifdef XP_MACOSX
+ data-l10n-id="find-shortcut"
+ modifiers="accel,alt"
+#else
+ data-l10n-id="search-focus-shortcut-alt"
+ modifiers="accel"
+#endif
+ command="Tools:Search"/>
+ <key id="key_openDownloads"
+ data-l10n-id="downloads-shortcut"
+#ifdef XP_GNOME
+ modifiers="accel,shift"
+#else
+ modifiers="accel"
+#endif
+ command="Tools:Downloads"/>
+ <key id="key_openAddons" data-l10n-id="addons-shortcut" command="Tools:Addons" modifiers="accel,shift"/>
+ <key id="openFileKb" data-l10n-id="file-open-shortcut" command="Browser:OpenFile" modifiers="accel"/>
+ <key id="key_savePage" data-l10n-id="save-page-shortcut" command="Browser:SavePage" modifiers="accel"/>
+ <key id="printKb" data-l10n-id="print-shortcut" command="cmd_print" modifiers="accel"/>
+ <key id="key_close" data-l10n-id="close-shortcut" command="cmd_close" modifiers="accel" reserved="true"/>
+ <key id="key_closeWindow" data-l10n-id="close-shortcut" command="cmd_closeWindow" modifiers="accel,shift" reserved="true"/>
+ <key id="key_toggleMute" data-l10n-id="mute-toggle-shortcut" command="cmd_toggleMute" modifiers="control"/>
+ <key id="key_undo"
+ data-l10n-id="text-action-undo-shortcut"
+ modifiers="accel"
+ internal="true"/>
+ <key id="key_redo"
+#ifdef XP_UNIX
+ data-l10n-id="text-action-undo-shortcut"
+ modifiers="accel,shift"
+#else
+ data-l10n-id="text-action-redo-shortcut"
+ modifiers="accel"
+#endif
+ internal="true"/>
+ <key id="key_cut"
+ data-l10n-id="text-action-cut-shortcut"
+ modifiers="accel"
+ internal="true"/>
+ <key id="key_copy"
+ data-l10n-id="text-action-copy-shortcut"
+ modifiers="accel"
+ internal="true"/>
+ <key id="key_paste"
+ data-l10n-id="text-action-paste-shortcut"
+ modifiers="accel"
+ internal="true"/>
+ <key id="key_delete" keycode="VK_DELETE" command="cmd_delete" reserved="false"/>
+ <key id="key_selectAll" data-l10n-id="text-action-select-all-shortcut" modifiers="accel" internal="true"/>
+
+ <key keycode="VK_BACK" command="cmd_handleBackspace" reserved="false"/>
+ <key keycode="VK_BACK" command="cmd_handleShiftBackspace" modifiers="shift" reserved="false"/>
+#ifndef XP_MACOSX
+ <key id="goBackKb" keycode="VK_LEFT" command="Browser:Back" modifiers="alt"/>
+ <key id="goForwardKb" keycode="VK_RIGHT" command="Browser:Forward" modifiers="alt"/>
+#else
+ <key id="goBackKb" keycode="VK_LEFT" command="Browser:Back" modifiers="accel" />
+ <key id="goForwardKb" keycode="VK_RIGHT" command="Browser:Forward" modifiers="accel" />
+#endif
+#ifdef XP_UNIX
+ <key id="goBackKb2" data-l10n-id="nav-back-shortcut-alt" command="Browser:Back" modifiers="accel"/>
+ <key id="goForwardKb2" data-l10n-id="nav-fwd-shortcut-alt" command="Browser:Forward" modifiers="accel"/>
+#endif
+ <key id="goHome" keycode="VK_HOME" oncommand="BrowserHome();" modifiers="alt"/>
+ <key keycode="VK_F5" command="Browser:Reload"/>
+#ifndef XP_MACOSX
+ <key id="showAllHistoryKb" data-l10n-id="history-show-all-shortcut" command="Browser:ShowAllHistory" modifiers="accel,shift"/>
+ <key keycode="VK_F5" command="Browser:ReloadSkipCache" modifiers="accel"/>
+ <key id="key_enterFullScreen" keycode="VK_F11" command="View:FullScreen"/>
+ <key id="key_exitFullScreen" keycode="VK_F11" command="View:FullScreen" reserved="true" disabled="true"/>
+#else
+ <key id="showAllHistoryKb" data-l10n-id="history-show-all-shortcut-mac" command="Browser:ShowAllHistory" modifiers="accel"/>
+ <key id="key_enterFullScreen" data-l10n-id="full-screen-shortcut" command="View:FullScreen" modifiers="accel,control"/>
+ <key id="key_enterFullScreen_old" data-l10n-id="full-screen-shortcut" command="View:FullScreen" modifiers="accel,shift"/>
+ <key id="key_enterFullScreen_compat" keycode="VK_F11" command="View:FullScreen"/>
+ <key id="key_exitFullScreen" data-l10n-id="full-screen-shortcut" command="View:FullScreen" modifiers="accel,control" reserved="true" disabled="true"/>
+ <key id="key_exitFullScreen_old" data-l10n-id="full-screen-shortcut" command="View:FullScreen" modifiers="accel,shift" reserved="true" disabled="true"/>
+ <key id="key_exitFullScreen_compat" keycode="VK_F11" command="View:FullScreen" reserved="true" disabled="true"/>
+#endif
+ <key id="key_toggleReaderMode"
+ command="View:ReaderView"
+#ifdef XP_WIN
+ data-l10n-id="reader-mode-toggle-shortcut-windows"
+#else
+ data-l10n-id="reader-mode-toggle-shortcut-other"
+ modifiers="accel,alt"
+#endif
+ disabled="true"/>
+
+#ifndef XP_MACOSX
+ <key id="key_togglePictureInPicture" data-l10n-id="picture-in-picture-toggle-shortcut" command="View:PictureInPicture" modifiers="accel,shift"/>
+ <key data-l10n-id="picture-in-picture-toggle-shortcut-alt" command="View:PictureInPicture" modifiers="accel,shift"/>
+#else
+ <key id="key_togglePictureInPicture" data-l10n-id="picture-in-picture-toggle-shortcut-mac" command="View:PictureInPicture" modifiers="accel,alt,shift"/>
+ <key data-l10n-id="picture-in-picture-toggle-shortcut-mac-alt" command="View:PictureInPicture" modifiers="accel,alt,shift"/>
+#endif
+
+ <key data-l10n-id="nav-reload-shortcut" command="Browser:Reload" modifiers="accel" id="key_reload"/>
+ <key data-l10n-id="nav-reload-shortcut" command="Browser:ReloadSkipCache" modifiers="accel,shift" id="key_reload_skip_cache"/>
+ <key id="key_aboutProcesses" command="View:AboutProcesses" keycode="VK_ESCAPE" modifiers="shift"/>
+ <key id="key_viewSource" data-l10n-id="page-source-shortcut" command="View:PageSource" modifiers="accel"/>
+#ifdef XP_MACOSX
+ <key id="key_viewSourceSafari" data-l10n-id="page-source-shortcut-safari" command="View:PageSource" modifiers="accel,alt"/>
+#endif
+ <key id="key_viewInfo" data-l10n-id="page-info-shortcut" command="View:PageInfo" modifiers="accel"/>
+ <key id="key_find" data-l10n-id="find-shortcut" command="cmd_find" modifiers="accel"/>
+ <key id="key_findAgain" data-l10n-id="search-find-again-shortcut" command="cmd_findAgain" modifiers="accel"/>
+ <key id="key_findPrevious" data-l10n-id="search-find-again-shortcut" command="cmd_findPrevious" modifiers="accel,shift"/>
+#ifdef XP_MACOSX
+ <key id="key_findSelection" data-l10n-id="search-find-selection-shortcut" command="cmd_findSelection" modifiers="accel"/>
+#endif
+ <key data-l10n-id="search-find-again-shortcut-alt" command="cmd_findAgain"/>
+ <key data-l10n-id="search-find-again-shortcut-alt" command="cmd_findPrevious" modifiers="shift"/>
+
+ <key id="addBookmarkAsKb" data-l10n-id="bookmark-this-page-shortcut" command="Browser:AddBookmarkAs" modifiers="accel"/>
+ <key id="bookmarkAllTabsKb"
+ data-l10n-id="bookmark-this-page-shortcut"
+ oncommand="PlacesUIUtils.showBookmarkPagesDialog(PlacesCommandHook.uniqueCurrentPages);"
+ modifiers="accel,shift"/>
+ <key id="manBookmarkKb" data-l10n-id="bookmark-show-library-shortcut" command="Browser:ShowAllBookmarks" modifiers="accel,shift"/>
+ <key id="viewBookmarksSidebarKb"
+ data-l10n-id="bookmark-show-sidebar-shortcut"
+ modifiers="accel"
+ oncommand="SidebarUI.toggle('viewBookmarksSidebar');"/>
+ <key id="viewBookmarksToolbarKb"
+ data-l10n-id="bookmark-show-toolbar-shortcut"
+ oncommand="BookmarkingUI.toggleBookmarksToolbar('shortcut');"
+ modifiers="accel,shift"/>
+
+ <key id="key_stop" keycode="VK_ESCAPE" command="Browser:Stop"/>
+
+#ifdef XP_MACOSX
+ <key id="key_stop_mac" modifiers="accel" data-l10n-id="nav-stop-shortcut" command="Browser:Stop"/>
+#endif
+
+ <key id="key_gotoHistory"
+ data-l10n-id="history-sidebar-shortcut"
+#ifdef XP_MACOSX
+ modifiers="accel,shift"
+#else
+ modifiers="accel"
+#endif
+ oncommand="SidebarUI.toggle('viewHistorySidebar');"/>
+
+ <key id="key_fullZoomReduce" data-l10n-id="full-zoom-reduce-shortcut" command="cmd_fullZoomReduce" modifiers="accel"/>
+ <key data-l10n-id="full-zoom-reduce-shortcut-alt-a" command="cmd_fullZoomReduce" modifiers="accel"/>
+ <key data-l10n-id="full-zoom-reduce-shortcut-alt-b" command="cmd_fullZoomReduce" modifiers="accel"/>
+ <key id="key_fullZoomEnlarge" data-l10n-id="full-zoom-enlarge-shortcut" command="cmd_fullZoomEnlarge" modifiers="accel"/>
+ <key data-l10n-id="full-zoom-enlarge-shortcut-alt" command="cmd_fullZoomEnlarge" modifiers="accel"/>
+ <key data-l10n-id="full-zoom-enlarge-shortcut-alt2" command="cmd_fullZoomEnlarge" modifiers="accel"/>
+ <key id="key_fullZoomReset" data-l10n-id="full-zoom-reset-shortcut" command="cmd_fullZoomReset" modifiers="accel"/>
+ <key data-l10n-id="full-zoom-reset-shortcut-alt" command="cmd_fullZoomReset" modifiers="accel"/>
+
+ <key id="key_showAllTabs" keycode="VK_TAB" modifiers="control,shift"/>
+
+ <key id="key_switchTextDirection" data-l10n-id="bidi-switch-direction-shortcut" command="cmd_switchTextDirection" modifiers="accel,shift" />
+
+ <key id="key_privatebrowsing" command="Tools:PrivateBrowsing" data-l10n-id="private-browsing-shortcut"
+ modifiers="accel,shift" reserved="true"/>
+ <key id="key_screenshot" data-l10n-id="screenshot-shortcut" command="Browser:Screenshot" modifiers="accel,shift"/>
+ <key id="key_sanitize" command="Tools:Sanitize" keycode="VK_DELETE" modifiers="accel,shift"/>
+#ifdef XP_MACOSX
+ <key id="key_sanitize_mac" command="Tools:Sanitize" keycode="VK_BACK" modifiers="accel,shift"/>
+#endif
+ <key id="key_quitApplication" data-l10n-id="quit-app-shortcut"
+#ifdef XP_WIN
+ modifiers="accel,shift"
+#else
+ modifiers="accel"
+#endif
+# On OS X, dark voodoo magic invokes the quit code for this key.
+# So we're not adding the attribute on OSX because of backwards/add-on compat.
+# See bug 1369909 for background on this.
+#ifdef XP_MACOSX
+ internal="true"
+#else
+ command="cmd_quitApplication"
+#endif
+ reserved="true"/>
+
+ <key id="key_undoCloseTab" command="History:UndoCloseTab" data-l10n-id="tab-new-shortcut" modifiers="accel,shift"/>
+ <key id="key_undoCloseWindow" command="History:UndoCloseWindow" data-l10n-id="window-new-shortcut" modifiers="accel,shift"/>
+
+#ifdef XP_GNOME
+#define NUM_SELECT_TAB_MODIFIER alt
+#else
+#define NUM_SELECT_TAB_MODIFIER accel
+#endif
+
+#expand <key id="key_selectTab1" oncommand="gBrowser.selectTabAtIndex(0, event);" key="1" modifiers="__NUM_SELECT_TAB_MODIFIER__"/>
+#expand <key id="key_selectTab2" oncommand="gBrowser.selectTabAtIndex(1, event);" key="2" modifiers="__NUM_SELECT_TAB_MODIFIER__"/>
+#expand <key id="key_selectTab3" oncommand="gBrowser.selectTabAtIndex(2, event);" key="3" modifiers="__NUM_SELECT_TAB_MODIFIER__"/>
+#expand <key id="key_selectTab4" oncommand="gBrowser.selectTabAtIndex(3, event);" key="4" modifiers="__NUM_SELECT_TAB_MODIFIER__"/>
+#expand <key id="key_selectTab5" oncommand="gBrowser.selectTabAtIndex(4, event);" key="5" modifiers="__NUM_SELECT_TAB_MODIFIER__"/>
+#expand <key id="key_selectTab6" oncommand="gBrowser.selectTabAtIndex(5, event);" key="6" modifiers="__NUM_SELECT_TAB_MODIFIER__"/>
+#expand <key id="key_selectTab7" oncommand="gBrowser.selectTabAtIndex(6, event);" key="7" modifiers="__NUM_SELECT_TAB_MODIFIER__"/>
+#expand <key id="key_selectTab8" oncommand="gBrowser.selectTabAtIndex(7, event);" key="8" modifiers="__NUM_SELECT_TAB_MODIFIER__"/>
+#expand <key id="key_selectLastTab" oncommand="gBrowser.selectTabAtIndex(-1, event);" key="9" modifiers="__NUM_SELECT_TAB_MODIFIER__"/>
+
+ <key id="key_wrCaptureCmd"
+#ifdef XP_MACOSX
+ key="3" modifiers="control,shift"
+#else
+ key="#" modifiers="control"
+#endif
+ command="wrCaptureCmd"/>
+ <key id="key_wrToggleCaptureSequenceCmd"
+#ifdef XP_MACOSX
+ key="6" modifiers="control,shift"
+#else
+ key="^" modifiers="control"
+#endif
+ command="wrToggleCaptureSequenceCmd"/>
+#ifdef NIGHTLY_BUILD
+ <key id="key_windowRecordingCmd"
+#ifdef XP_MACOSX
+ key="4" modifiers="control,shift"
+#else
+ key="$" modifiers="control"
+#endif
+ command="windowRecordingCmd"/>
+#endif
+#ifdef XP_MACOSX
+ <key id="key_minimizeWindow"
+ command="minimizeWindow"
+ data-l10n-id="window-minimize-shortcut"
+ modifiers="accel"
+ internal="true"/>
+ <key id="key_openHelpMac"
+ oncommand="openHelpLink('firefox-osxkey');"
+ data-l10n-id="help-shortcut"
+ modifiers="accel"
+ internal="true"/>
+ <!-- These are used to build the Application menu -->
+ <key id="key_preferencesCmdMac"
+ data-l10n-id="preferences-shortcut"
+ modifiers="accel"
+ internal="true"/>
+ <key id="key_hideThisAppCmdMac"
+ data-l10n-id="hide-app-shortcut"
+ modifiers="accel"
+ internal="true"/>
+ <key id="key_hideOtherAppsCmdMac"
+ data-l10n-id="hide-other-apps-shortcut"
+ modifiers="accel,alt"
+ internal="true"/>
+#endif
+ </keyset>
diff --git a/browser/base/content/browser-sidebar.js b/browser/base/content/browser-sidebar.js
new file mode 100644
index 0000000000..d2b36db203
--- /dev/null
+++ b/browser/base/content/browser-sidebar.js
@@ -0,0 +1,668 @@
+/* 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/. */
+
+/**
+ * SidebarUI controls showing and hiding the browser sidebar.
+ */
+var SidebarUI = {
+ get sidebars() {
+ if (this._sidebars) {
+ return this._sidebars;
+ }
+
+ function makeSidebar({ elementId, ...rest }) {
+ return {
+ get sourceL10nEl() {
+ return document.getElementById(elementId);
+ },
+ get title() {
+ return document.getElementById(elementId).getAttribute("label");
+ },
+ ...rest,
+ };
+ }
+
+ return (this._sidebars = new Map([
+ [
+ "viewBookmarksSidebar",
+ makeSidebar({
+ elementId: "sidebar-switcher-bookmarks",
+ url: "chrome://browser/content/places/bookmarksSidebar.xhtml",
+ menuId: "menu_bookmarksSidebar",
+ }),
+ ],
+ [
+ "viewHistorySidebar",
+ makeSidebar({
+ elementId: "sidebar-switcher-history",
+ url: "chrome://browser/content/places/historySidebar.xhtml",
+ menuId: "menu_historySidebar",
+ triggerButtonId: "appMenuViewHistorySidebar",
+ }),
+ ],
+ [
+ "viewTabsSidebar",
+ makeSidebar({
+ elementId: "sidebar-switcher-tabs",
+ url: "chrome://browser/content/syncedtabs/sidebar.xhtml",
+ menuId: "menu_tabsSidebar",
+ }),
+ ],
+ ]));
+ },
+
+ // Avoid getting the browser element from init() to avoid triggering the
+ // <browser> constructor during startup if the sidebar is hidden.
+ get browser() {
+ if (this._browser) {
+ return this._browser;
+ }
+ return (this._browser = document.getElementById("sidebar"));
+ },
+ POSITION_START_PREF: "sidebar.position_start",
+ DEFAULT_SIDEBAR_ID: "viewBookmarksSidebar",
+
+ // lastOpenedId is set in show() but unlike currentID it's not cleared out on hide
+ // and isn't persisted across windows
+ lastOpenedId: null,
+
+ _box: null,
+ // The constructor of this label accesses the browser element due to the
+ // control="sidebar" attribute, so avoid getting this label during startup.
+ get _title() {
+ if (this.__title) {
+ return this.__title;
+ }
+ return (this.__title = document.getElementById("sidebar-title"));
+ },
+ _splitter: null,
+ _reversePositionButton: null,
+ _switcherPanel: null,
+ _switcherTarget: null,
+ _switcherArrow: null,
+ _inited: false,
+
+ /**
+ * @type {MutationObserver | null}
+ */
+ _observer: null,
+
+ _initDeferred: PromiseUtils.defer(),
+
+ get promiseInitialized() {
+ return this._initDeferred.promise;
+ },
+
+ get initialized() {
+ return this._inited;
+ },
+
+ init() {
+ this._box = document.getElementById("sidebar-box");
+ this._splitter = document.getElementById("sidebar-splitter");
+ this._reversePositionButton = document.getElementById(
+ "sidebar-reverse-position"
+ );
+ this._switcherPanel = document.getElementById("sidebarMenu-popup");
+ this._switcherTarget = document.getElementById("sidebar-switcher-target");
+ this._switcherArrow = document.getElementById("sidebar-switcher-arrow");
+
+ this._switcherTarget.addEventListener("command", () => {
+ this.toggleSwitcherPanel();
+ });
+
+ this._inited = true;
+
+ Services.obs.addObserver(this, "intl:app-locales-changed");
+
+ this._initDeferred.resolve();
+ },
+
+ uninit() {
+ // If this is the last browser window, persist various values that should be
+ // remembered for after a restart / reopening a browser window.
+ let enumerator = Services.wm.getEnumerator("navigator:browser");
+ if (!enumerator.hasMoreElements()) {
+ let xulStore = Services.xulStore;
+ xulStore.persist(this._box, "sidebarcommand");
+
+ if (this._box.hasAttribute("positionend")) {
+ xulStore.persist(this._box, "positionend");
+ } else {
+ xulStore.removeValue(
+ document.documentURI,
+ "sidebar-box",
+ "positionend"
+ );
+ }
+ if (this._box.hasAttribute("checked")) {
+ xulStore.persist(this._box, "checked");
+ } else {
+ xulStore.removeValue(document.documentURI, "sidebar-box", "checked");
+ }
+
+ xulStore.persist(this._box, "style");
+ xulStore.persist(this._title, "value");
+ }
+
+ Services.obs.removeObserver(this, "intl:app-locales-changed");
+
+ if (this._observer) {
+ this._observer.disconnect();
+ this._observer = null;
+ }
+ },
+
+ /**
+ * The handler for Services.obs.addObserver.
+ **/
+ observe(_subject, topic, _data) {
+ switch (topic) {
+ case "intl:app-locales-changed": {
+ if (this.isOpen) {
+ // The <tree> component used in history and bookmarks, but it does not
+ // support live switching the app locale. Reload the entire sidebar to
+ // invalidate any old text.
+ this.hide();
+ this.showInitially(this.lastOpenedId);
+ break;
+ }
+ }
+ }
+ },
+
+ /**
+ * Ensure the title stays in sync with the source element, which updates for
+ * l10n changes.
+ *
+ * @param {HTMLElement} [element]
+ */
+ observeTitleChanges(element) {
+ if (!element) {
+ return;
+ }
+ let observer = this._observer;
+ if (!observer) {
+ observer = new MutationObserver(() => {
+ this.title = this.sidebars.get(this.lastOpenedId).title;
+ });
+ // Re-use the observer.
+ this._observer = observer;
+ }
+ observer.disconnect();
+ observer.observe(element, {
+ attributes: true,
+ attributeFilter: ["label"],
+ });
+ },
+
+ /**
+ * Opens the switcher panel if it's closed, or closes it if it's open.
+ */
+ toggleSwitcherPanel() {
+ if (
+ this._switcherPanel.state == "open" ||
+ this._switcherPanel.state == "showing"
+ ) {
+ this.hideSwitcherPanel();
+ } else if (this._switcherPanel.state == "closed") {
+ this.showSwitcherPanel();
+ }
+ },
+
+ hideSwitcherPanel() {
+ this._switcherPanel.hidePopup();
+ },
+
+ showSwitcherPanel() {
+ this._ensureShortcutsShown();
+ this._switcherPanel.addEventListener(
+ "popuphiding",
+ () => {
+ this._switcherTarget.classList.remove("active");
+ },
+ { once: true }
+ );
+
+ // Combine start/end position with ltr/rtl to set the label in the popup appropriately.
+ let label =
+ this._positionStart == RTL_UI
+ ? gNavigatorBundle.getString("sidebar.moveToLeft")
+ : gNavigatorBundle.getString("sidebar.moveToRight");
+ this._reversePositionButton.setAttribute("label", label);
+
+ this._switcherPanel.hidden = false;
+ this._switcherPanel.openPopup(this._switcherTarget);
+ this._switcherTarget.classList.add("active");
+ },
+
+ updateShortcut({ button, key }) {
+ // If the shortcuts haven't been rendered yet then it will be set correctly
+ // on the first render so there's nothing to do now.
+ if (!this._addedShortcuts) {
+ return;
+ }
+ if (key) {
+ let keyId = key.getAttribute("id");
+ button = this._switcherPanel.querySelector(`[key="${keyId}"]`);
+ } else if (button) {
+ let keyId = button.getAttribute("key");
+ key = document.getElementById(keyId);
+ }
+ if (!button || !key) {
+ return;
+ }
+ button.setAttribute("shortcut", ShortcutUtils.prettifyShortcut(key));
+ },
+
+ _addedShortcuts: false,
+ _ensureShortcutsShown() {
+ if (this._addedShortcuts) {
+ return;
+ }
+ this._addedShortcuts = true;
+ for (let button of this._switcherPanel.querySelectorAll(
+ "toolbarbutton[key]"
+ )) {
+ this.updateShortcut({ button });
+ }
+ },
+
+ /**
+ * Change the pref that will trigger a call to setPosition
+ */
+ reversePosition() {
+ Services.prefs.setBoolPref(this.POSITION_START_PREF, !this._positionStart);
+ },
+
+ /**
+ * Read the positioning pref and position the sidebar and the splitter
+ * appropriately within the browser container.
+ */
+ setPosition() {
+ // First reset all ordinals to match DOM ordering.
+ let browser = document.getElementById("browser");
+ [...browser.children].forEach((node, i) => {
+ node.style.order = i + 1;
+ });
+
+ if (!this._positionStart) {
+ // DOM ordering is: | sidebar-box | splitter | appcontent |
+ // Want to display as: | appcontent | splitter | sidebar-box |
+ // So we just swap box and appcontent ordering
+ let appcontent = document.getElementById("appcontent");
+ let boxOrdinal = this._box.style.order;
+ this._box.style.order = appcontent.style.order;
+ appcontent.style.order = boxOrdinal;
+ // Indicate we've switched ordering to the box
+ this._box.setAttribute("positionend", true);
+ } else {
+ this._box.removeAttribute("positionend");
+ }
+
+ this.hideSwitcherPanel();
+
+ let content = SidebarUI.browser.contentWindow;
+ if (content && content.updatePosition) {
+ content.updatePosition();
+ }
+ },
+
+ /**
+ * Try and adopt the status of the sidebar from another window.
+ * @param {Window} sourceWindow - Window to use as a source for sidebar status.
+ * @return true if we adopted the state, or false if the caller should
+ * initialize the state itself.
+ */
+ adoptFromWindow(sourceWindow) {
+ // If the opener had a sidebar, open the same sidebar in our window.
+ // The opener can be the hidden window too, if we're coming from the state
+ // where no windows are open, and the hidden window has no sidebar box.
+ let sourceUI = sourceWindow.SidebarUI;
+ if (!sourceUI || !sourceUI._box) {
+ // no source UI or no _box means we also can't adopt the state.
+ return false;
+ }
+
+ // Set sidebar command even if hidden, so that we keep the same sidebar
+ // even if it's currently closed.
+ let commandID = sourceUI._box.getAttribute("sidebarcommand");
+ if (commandID) {
+ this._box.setAttribute("sidebarcommand", commandID);
+ }
+
+ if (sourceUI._box.hidden) {
+ // just hidden means we have adopted the hidden state.
+ return true;
+ }
+
+ // dynamically generated sidebars will fail this check, but we still
+ // consider it adopted.
+ if (!this.sidebars.has(commandID)) {
+ return true;
+ }
+
+ this._box.style.width = sourceUI._box.getBoundingClientRect().width + "px";
+ this.showInitially(commandID);
+
+ return true;
+ },
+
+ windowPrivacyMatches(w1, w2) {
+ return (
+ PrivateBrowsingUtils.isWindowPrivate(w1) ===
+ PrivateBrowsingUtils.isWindowPrivate(w2)
+ );
+ },
+
+ /**
+ * If loading a sidebar was delayed on startup, start the load now.
+ */
+ startDelayedLoad() {
+ let sourceWindow = window.opener;
+ // No source window means this is the initial window. If we're being
+ // opened from another window, check that it is one we might open a sidebar
+ // for.
+ if (sourceWindow) {
+ if (
+ sourceWindow.closed ||
+ sourceWindow.location.protocol != "chrome:" ||
+ !this.windowPrivacyMatches(sourceWindow, window)
+ ) {
+ return;
+ }
+ // Try to adopt the sidebar state from the source window
+ if (this.adoptFromWindow(sourceWindow)) {
+ return;
+ }
+ }
+
+ // If we're not adopting settings from a parent window, set them now.
+ let wasOpen = this._box.getAttribute("checked");
+ if (!wasOpen) {
+ return;
+ }
+
+ let commandID = this._box.getAttribute("sidebarcommand");
+ if (commandID && this.sidebars.has(commandID)) {
+ this.showInitially(commandID);
+ } else {
+ this._box.removeAttribute("checked");
+ // Remove the |sidebarcommand| attribute, because the element it
+ // refers to no longer exists, so we should assume this sidebar
+ // panel has been uninstalled. (249883)
+ // We use setAttribute rather than removeAttribute so it persists
+ // correctly.
+ this._box.setAttribute("sidebarcommand", "");
+ // On a startup in which the startup cache was invalidated (e.g. app update)
+ // extensions will not be started prior to delayedLoad, thus the
+ // sidebarcommand element will not exist yet. Store the commandID so
+ // extensions may reopen if necessary. A startup cache invalidation
+ // can be forced (for testing) by deleting compatibility.ini from the
+ // profile.
+ this.lastOpenedId = commandID;
+ }
+ },
+
+ /**
+ * Fire a "SidebarShown" event on the sidebar to give any interested parties
+ * a chance to update the button or whatever.
+ */
+ _fireShowEvent() {
+ let event = new CustomEvent("SidebarShown", { bubbles: true });
+ this._switcherTarget.dispatchEvent(event);
+ },
+
+ /**
+ * Fire a "SidebarFocused" event on the sidebar's |window| to give the sidebar
+ * a chance to adjust focus as needed. An additional event is needed, because
+ * we don't want to focus the sidebar when it's opened on startup or in a new
+ * window, only when the user opens the sidebar.
+ */
+ _fireFocusedEvent() {
+ let event = new CustomEvent("SidebarFocused", { bubbles: true });
+ this.browser.contentWindow.dispatchEvent(event);
+ },
+
+ /**
+ * True if the sidebar is currently open.
+ */
+ get isOpen() {
+ return !this._box.hidden;
+ },
+
+ /**
+ * The ID of the current sidebar.
+ */
+ get currentID() {
+ return this.isOpen ? this._box.getAttribute("sidebarcommand") : "";
+ },
+
+ get title() {
+ return this._title.value;
+ },
+
+ set title(value) {
+ this._title.value = value;
+ },
+
+ /**
+ * Toggle the visibility of the sidebar. If the sidebar is hidden or is open
+ * with a different commandID, then the sidebar will be opened using the
+ * specified commandID. Otherwise the sidebar will be hidden.
+ *
+ * @param {string} commandID ID of the sidebar.
+ * @param {DOMNode} [triggerNode] Node, usually a button, that triggered the
+ * visibility toggling of the sidebar.
+ * @return {Promise}
+ */
+ toggle(commandID = this.lastOpenedId, triggerNode) {
+ if (
+ CustomizationHandler.isCustomizing() ||
+ CustomizationHandler.isExitingCustomizeMode
+ ) {
+ return Promise.resolve();
+ }
+ // First priority for a default value is this.lastOpenedId which is set during show()
+ // and not reset in hide(), unlike currentID. If show() hasn't been called and we don't
+ // have a persisted command either, or the command doesn't exist anymore, then
+ // fallback to a default sidebar.
+ if (!commandID) {
+ commandID = this._box.getAttribute("sidebarcommand");
+ }
+ if (!commandID || !this.sidebars.has(commandID)) {
+ commandID = this.DEFAULT_SIDEBAR_ID;
+ }
+
+ if (this.isOpen && commandID == this.currentID) {
+ this.hide(triggerNode);
+ return Promise.resolve();
+ }
+ return this.show(commandID, triggerNode);
+ },
+
+ _loadSidebarExtension(commandID) {
+ let sidebar = this.sidebars.get(commandID);
+ let { extensionId } = sidebar;
+ if (extensionId) {
+ SidebarUI.browser.contentWindow.loadPanel(
+ extensionId,
+ sidebar.panel,
+ sidebar.browserStyle
+ );
+ }
+ },
+
+ /**
+ * Show the sidebar.
+ *
+ * This wraps the internal method, including a ping to telemetry.
+ *
+ * @param {string} commandID ID of the sidebar to use.
+ * @param {DOMNode} [triggerNode] Node, usually a button, that triggered the
+ * showing of the sidebar.
+ * @return {Promise<boolean>}
+ */
+ async show(commandID, triggerNode) {
+ let panelType = commandID.substring(4, commandID.length - 7);
+ Services.telemetry.keyedScalarAdd("sidebar.opened", panelType, 1);
+
+ // Extensions without private window access wont be in the
+ // sidebars map.
+ if (!this.sidebars.has(commandID)) {
+ return false;
+ }
+ return this._show(commandID).then(() => {
+ this._loadSidebarExtension(commandID);
+
+ if (triggerNode) {
+ updateToggleControlLabel(triggerNode);
+ }
+
+ this._fireFocusedEvent();
+ return true;
+ });
+ },
+
+ /**
+ * Show the sidebar, without firing the focused event or logging telemetry.
+ * This is intended to be used when the sidebar is opened automatically
+ * when a window opens (not triggered by user interaction).
+ *
+ * @param {string} commandID ID of the sidebar.
+ * @return {Promise<boolean>}
+ */
+ async showInitially(commandID) {
+ let panelType = commandID.substring(4, commandID.length - 7);
+ Services.telemetry.keyedScalarAdd("sidebar.opened", panelType, 1);
+
+ // Extensions without private window access wont be in the
+ // sidebars map.
+ if (!this.sidebars.has(commandID)) {
+ return false;
+ }
+ return this._show(commandID).then(() => {
+ this._loadSidebarExtension(commandID);
+ return true;
+ });
+ },
+
+ /**
+ * Implementation for show. Also used internally for sidebars that are shown
+ * when a window is opened and we don't want to ping telemetry.
+ *
+ * @param {string} commandID ID of the sidebar.
+ * @return {Promise<void>}
+ */
+ _show(commandID) {
+ return new Promise(resolve => {
+ this.selectMenuItem(commandID);
+
+ this._box.hidden = this._splitter.hidden = false;
+ this.setPosition();
+
+ this.hideSwitcherPanel();
+
+ this._box.setAttribute("checked", "true");
+ this._box.setAttribute("sidebarcommand", commandID);
+ this.lastOpenedId = commandID;
+
+ let { url, title, sourceL10nEl } = this.sidebars.get(commandID);
+ this.title = title;
+ // Keep the title element in sync with any l10n changes.
+ this.observeTitleChanges(sourceL10nEl);
+ this.browser.setAttribute("src", url); // kick off async load
+
+ if (this.browser.contentDocument.location.href != url) {
+ this.browser.addEventListener(
+ "load",
+ event => {
+ // We're handling the 'load' event before it bubbles up to the usual
+ // (non-capturing) event handlers. Let it bubble up before resolving.
+ setTimeout(() => {
+ resolve();
+
+ // Now that the currentId is updated, fire a show event.
+ this._fireShowEvent();
+ }, 0);
+ },
+ { capture: true, once: true }
+ );
+ } else {
+ resolve();
+
+ // Now that the currentId is updated, fire a show event.
+ this._fireShowEvent();
+ }
+ });
+ },
+
+ /**
+ * Hide the sidebar.
+ *
+ * @param {DOMNode} [triggerNode] Node, usually a button, that triggered the
+ * hiding of the sidebar.
+ */
+ hide(triggerNode) {
+ if (!this.isOpen) {
+ return;
+ }
+
+ this.hideSwitcherPanel();
+
+ this.selectMenuItem("");
+
+ // Replace the document currently displayed in the sidebar with about:blank
+ // so that we can free memory by unloading the page. We need to explicitly
+ // create a new content viewer because the old one doesn't get destroyed
+ // until about:blank has loaded (which does not happen as long as the
+ // element is hidden).
+ this.browser.setAttribute("src", "about:blank");
+ this.browser.docShell.createAboutBlankContentViewer(null, null);
+
+ this._box.removeAttribute("checked");
+ this._box.hidden = this._splitter.hidden = true;
+
+ let selBrowser = gBrowser.selectedBrowser;
+ selBrowser.focus();
+ if (triggerNode) {
+ updateToggleControlLabel(triggerNode);
+ }
+ },
+
+ /**
+ * Sets the checked state only on the menu items of the specified sidebar, or
+ * none if the argument is an empty string.
+ */
+ selectMenuItem(commandID) {
+ for (let [id, { menuId, triggerButtonId }] of this.sidebars) {
+ let menu = document.getElementById(menuId);
+ let triggerbutton =
+ triggerButtonId && document.getElementById(triggerButtonId);
+ if (id == commandID) {
+ menu.setAttribute("checked", "true");
+ if (triggerbutton) {
+ triggerbutton.setAttribute("checked", "true");
+ updateToggleControlLabel(triggerbutton);
+ }
+ } else {
+ menu.removeAttribute("checked");
+ if (triggerbutton) {
+ triggerbutton.removeAttribute("checked");
+ updateToggleControlLabel(triggerbutton);
+ }
+ }
+ }
+ },
+};
+
+// Add getters related to the position here, since we will want them
+// available for both startDelayedLoad and init.
+XPCOMUtils.defineLazyPreferenceGetter(
+ SidebarUI,
+ "_positionStart",
+ SidebarUI.POSITION_START_PREF,
+ true,
+ SidebarUI.setPosition.bind(SidebarUI)
+);
diff --git a/browser/base/content/browser-siteIdentity.js b/browser/base/content/browser-siteIdentity.js
new file mode 100644
index 0000000000..fb243dcb8f
--- /dev/null
+++ b/browser/base/content/browser-siteIdentity.js
@@ -0,0 +1,1326 @@
+/* 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/. */
+
+/* eslint-env mozilla/browser-window */
+
+/**
+ * Utility object to handle manipulations of the identity indicators in the UI
+ */
+var gIdentityHandler = {
+ /**
+ * nsIURI for which the identity UI is displayed. This has been already
+ * processed by createExposableURI.
+ */
+ _uri: null,
+
+ /**
+ * We only know the connection type if this._uri has a defined "host" part.
+ *
+ * These URIs, like "about:", "file:" and "data:" URIs, will usually be treated as a
+ * an unknown connection.
+ */
+ _uriHasHost: false,
+
+ /**
+ * If this tab belongs to a WebExtension, contains its WebExtensionPolicy.
+ */
+ _pageExtensionPolicy: null,
+
+ /**
+ * Whether this._uri refers to an internally implemented browser page.
+ *
+ * Note that this is set for some "about:" pages, but general "chrome:" URIs
+ * are not included in this category by default.
+ */
+ _isSecureInternalUI: false,
+
+ /**
+ * Whether the content window is considered a "secure context". This
+ * includes "potentially trustworthy" origins such as file:// URLs or localhost.
+ * https://w3c.github.io/webappsec-secure-contexts/#is-origin-trustworthy
+ */
+ _isSecureContext: false,
+
+ /**
+ * nsITransportSecurityInfo metadata provided by gBrowser.securityUI the last
+ * time the identity UI was updated, or null if the connection is not secure.
+ */
+ _secInfo: null,
+
+ /**
+ * Bitmask provided by nsIWebProgressListener.onSecurityChange.
+ */
+ _state: 0,
+
+ /**
+ * Whether the established HTTPS connection is considered "broken".
+ * This could have several reasons, such as mixed content or weak
+ * cryptography. If this is true, _isSecureConnection is false.
+ */
+ get _isBrokenConnection() {
+ return this._state & Ci.nsIWebProgressListener.STATE_IS_BROKEN;
+ },
+
+ /**
+ * Whether the connection to the current site was done via secure
+ * transport. Note that this attribute is not true in all cases that
+ * the site was accessed via HTTPS, i.e. _isSecureConnection will
+ * be false when _isBrokenConnection is true, even though the page
+ * was loaded over HTTPS.
+ */
+ get _isSecureConnection() {
+ // If a <browser> is included within a chrome document, then this._state
+ // will refer to the security state for the <browser> and not the top level
+ // document. In this case, don't upgrade the security state in the UI
+ // with the secure state of the embedded <browser>.
+ return (
+ !this._isURILoadedFromFile &&
+ this._state & Ci.nsIWebProgressListener.STATE_IS_SECURE
+ );
+ },
+
+ get _isEV() {
+ // If a <browser> is included within a chrome document, then this._state
+ // will refer to the security state for the <browser> and not the top level
+ // document. In this case, don't upgrade the security state in the UI
+ // with the EV state of the embedded <browser>.
+ return (
+ !this._isURILoadedFromFile &&
+ this._state & Ci.nsIWebProgressListener.STATE_IDENTITY_EV_TOPLEVEL
+ );
+ },
+
+ get _isMixedActiveContentLoaded() {
+ return (
+ this._state & Ci.nsIWebProgressListener.STATE_LOADED_MIXED_ACTIVE_CONTENT
+ );
+ },
+
+ get _isMixedActiveContentBlocked() {
+ return (
+ this._state & Ci.nsIWebProgressListener.STATE_BLOCKED_MIXED_ACTIVE_CONTENT
+ );
+ },
+
+ get _isMixedPassiveContentLoaded() {
+ return (
+ this._state & Ci.nsIWebProgressListener.STATE_LOADED_MIXED_DISPLAY_CONTENT
+ );
+ },
+
+ get _isContentHttpsOnlyModeUpgraded() {
+ return (
+ this._state & Ci.nsIWebProgressListener.STATE_HTTPS_ONLY_MODE_UPGRADED
+ );
+ },
+
+ get _isContentHttpsOnlyModeUpgradeFailed() {
+ return (
+ this._state &
+ Ci.nsIWebProgressListener.STATE_HTTPS_ONLY_MODE_UPGRADE_FAILED
+ );
+ },
+
+ get _isCertUserOverridden() {
+ return this._state & Ci.nsIWebProgressListener.STATE_CERT_USER_OVERRIDDEN;
+ },
+
+ get _isCertErrorPage() {
+ let { documentURI } = gBrowser.selectedBrowser;
+ if (documentURI?.scheme != "about") {
+ return false;
+ }
+
+ return (
+ documentURI.filePath == "certerror" ||
+ (documentURI.filePath == "neterror" &&
+ new URLSearchParams(documentURI.query).get("e") == "nssFailure2")
+ );
+ },
+
+ get _isAboutNetErrorPage() {
+ let { documentURI } = gBrowser.selectedBrowser;
+ return documentURI?.scheme == "about" && documentURI.filePath == "neterror";
+ },
+
+ get _isAboutHttpsOnlyErrorPage() {
+ let { documentURI } = gBrowser.selectedBrowser;
+ return (
+ documentURI?.scheme == "about" && documentURI.filePath == "httpsonlyerror"
+ );
+ },
+
+ get _isPotentiallyTrustworthy() {
+ return (
+ !this._isBrokenConnection &&
+ (this._isSecureContext ||
+ gBrowser.selectedBrowser.documentURI?.scheme == "chrome")
+ );
+ },
+
+ get _isAboutBlockedPage() {
+ let { documentURI } = gBrowser.selectedBrowser;
+ return documentURI?.scheme == "about" && documentURI.filePath == "blocked";
+ },
+
+ _popupInitialized: false,
+ _initializePopup() {
+ window.ensureCustomElements("moz-support-link");
+ if (!this._popupInitialized) {
+ let wrapper = document.getElementById("template-identity-popup");
+ wrapper.replaceWith(wrapper.content);
+ this._popupInitialized = true;
+ }
+ },
+
+ hidePopup() {
+ if (this._popupInitialized) {
+ PanelMultiView.hidePopup(this._identityPopup);
+ }
+ },
+
+ // smart getters
+ get _identityPopup() {
+ if (!this._popupInitialized) {
+ return null;
+ }
+ delete this._identityPopup;
+ return (this._identityPopup = document.getElementById("identity-popup"));
+ },
+ get _identityBox() {
+ delete this._identityBox;
+ return (this._identityBox = document.getElementById("identity-box"));
+ },
+ get _identityIconBox() {
+ delete this._identityIconBox;
+ return (this._identityIconBox =
+ document.getElementById("identity-icon-box"));
+ },
+ get _identityPopupMultiView() {
+ delete this._identityPopupMultiView;
+ return (this._identityPopupMultiView = document.getElementById(
+ "identity-popup-multiView"
+ ));
+ },
+ get _identityPopupMainView() {
+ delete this._identityPopupMainView;
+ return (this._identityPopupMainView = document.getElementById(
+ "identity-popup-mainView"
+ ));
+ },
+ get _identityPopupMainViewHeaderLabel() {
+ delete this._identityPopupMainViewHeaderLabel;
+ return (this._identityPopupMainViewHeaderLabel = document.getElementById(
+ "identity-popup-mainView-panel-header-span"
+ ));
+ },
+ get _identityPopupSecurityView() {
+ delete this._identityPopupSecurityView;
+ return (this._identityPopupSecurityView = document.getElementById(
+ "identity-popup-securityView"
+ ));
+ },
+ get _identityPopupHttpsOnlyModeMenuList() {
+ delete this._identityPopupHttpsOnlyModeMenuList;
+ return (this._identityPopupHttpsOnlyModeMenuList = document.getElementById(
+ "identity-popup-security-httpsonlymode-menulist"
+ ));
+ },
+ get _identityPopupHttpsOnlyModeMenuListTempItem() {
+ delete this._identityPopupHttpsOnlyModeMenuListTempItem;
+ return (this._identityPopupHttpsOnlyModeMenuListTempItem =
+ document.getElementById("identity-popup-security-menulist-tempitem"));
+ },
+ get _identityPopupSecurityEVContentOwner() {
+ delete this._identityPopupSecurityEVContentOwner;
+ return (this._identityPopupSecurityEVContentOwner = document.getElementById(
+ "identity-popup-security-ev-content-owner"
+ ));
+ },
+ get _identityPopupContentOwner() {
+ delete this._identityPopupContentOwner;
+ return (this._identityPopupContentOwner = document.getElementById(
+ "identity-popup-content-owner"
+ ));
+ },
+ get _identityPopupContentSupp() {
+ delete this._identityPopupContentSupp;
+ return (this._identityPopupContentSupp = document.getElementById(
+ "identity-popup-content-supplemental"
+ ));
+ },
+ get _identityPopupContentVerif() {
+ delete this._identityPopupContentVerif;
+ return (this._identityPopupContentVerif = document.getElementById(
+ "identity-popup-content-verifier"
+ ));
+ },
+ get _identityPopupCustomRootLearnMore() {
+ delete this._identityPopupCustomRootLearnMore;
+ return (this._identityPopupCustomRootLearnMore = document.getElementById(
+ "identity-popup-custom-root-learn-more"
+ ));
+ },
+ get _identityPopupMixedContentLearnMore() {
+ delete this._identityPopupMixedContentLearnMore;
+ return (this._identityPopupMixedContentLearnMore = [
+ ...document.querySelectorAll(".identity-popup-mcb-learn-more"),
+ ]);
+ },
+
+ get _identityIconLabel() {
+ delete this._identityIconLabel;
+ return (this._identityIconLabel = document.getElementById(
+ "identity-icon-label"
+ ));
+ },
+ get _overrideService() {
+ delete this._overrideService;
+ return (this._overrideService = Cc[
+ "@mozilla.org/security/certoverride;1"
+ ].getService(Ci.nsICertOverrideService));
+ },
+ get _identityIcon() {
+ delete this._identityIcon;
+ return (this._identityIcon = document.getElementById("identity-icon"));
+ },
+ get _clearSiteDataFooter() {
+ delete this._clearSiteDataFooter;
+ return (this._clearSiteDataFooter = document.getElementById(
+ "identity-popup-clear-sitedata-footer"
+ ));
+ },
+
+ get _insecureConnectionIconEnabled() {
+ delete this._insecureConnectionIconEnabled;
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "_insecureConnectionIconEnabled",
+ "security.insecure_connection_icon.enabled"
+ );
+ return this._insecureConnectionIconEnabled;
+ },
+ get _insecureConnectionIconPBModeEnabled() {
+ delete this._insecureConnectionIconPBModeEnabled;
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "_insecureConnectionIconPBModeEnabled",
+ "security.insecure_connection_icon.pbmode.enabled"
+ );
+ return this._insecureConnectionIconPBModeEnabled;
+ },
+ get _insecureConnectionTextEnabled() {
+ delete this._insecureConnectionTextEnabled;
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "_insecureConnectionTextEnabled",
+ "security.insecure_connection_text.enabled"
+ );
+ return this._insecureConnectionTextEnabled;
+ },
+ get _insecureConnectionTextPBModeEnabled() {
+ delete this._insecureConnectionTextPBModeEnabled;
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "_insecureConnectionTextPBModeEnabled",
+ "security.insecure_connection_text.pbmode.enabled"
+ );
+ return this._insecureConnectionTextPBModeEnabled;
+ },
+ get _protectionsPanelEnabled() {
+ delete this._protectionsPanelEnabled;
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "_protectionsPanelEnabled",
+ "browser.protections_panel.enabled",
+ false
+ );
+ return this._protectionsPanelEnabled;
+ },
+ get _httpsOnlyModeEnabled() {
+ delete this._httpsOnlyModeEnabled;
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "_httpsOnlyModeEnabled",
+ "dom.security.https_only_mode"
+ );
+ return this._httpsOnlyModeEnabled;
+ },
+ get _httpsOnlyModeEnabledPBM() {
+ delete this._httpsOnlyModeEnabledPBM;
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "_httpsOnlyModeEnabledPBM",
+ "dom.security.https_only_mode_pbm"
+ );
+ return this._httpsOnlyModeEnabledPBM;
+ },
+
+ /**
+ * Handles clicks on the "Clear Cookies and Site Data" button.
+ */
+ async clearSiteData(event) {
+ if (!this._uriHasHost) {
+ return;
+ }
+
+ // Hide the popup before showing the removal prompt, to
+ // avoid a pretty ugly transition. Also hide it even
+ // if the update resulted in no site data, to keep the
+ // illusion that clicking the button had an effect.
+ let hidden = new Promise(c => {
+ this._identityPopup.addEventListener("popuphidden", c, { once: true });
+ });
+ PanelMultiView.hidePopup(this._identityPopup);
+ await hidden;
+
+ let baseDomain = SiteDataManager.getBaseDomainFromHost(this._uri.host);
+ if (SiteDataManager.promptSiteDataRemoval(window, [baseDomain])) {
+ SiteDataManager.remove(baseDomain);
+ }
+
+ event.stopPropagation();
+ },
+
+ /**
+ * Handler for mouseclicks on the "More Information" button in the
+ * "identity-popup" panel.
+ */
+ handleMoreInfoClick(event) {
+ displaySecurityInfo();
+ event.stopPropagation();
+ PanelMultiView.hidePopup(this._identityPopup);
+ },
+
+ showSecuritySubView() {
+ this._identityPopupMultiView.showSubView(
+ "identity-popup-securityView",
+ document.getElementById("identity-popup-security-button")
+ );
+
+ // Elements of hidden views have -moz-user-focus:ignore but setting that
+ // per CSS selector doesn't blur a focused element in those hidden views.
+ Services.focus.clearFocus(window);
+ },
+
+ disableMixedContentProtection() {
+ // Use telemetry to measure how often unblocking happens
+ const kMIXED_CONTENT_UNBLOCK_EVENT = 2;
+ let histogram = Services.telemetry.getHistogramById(
+ "MIXED_CONTENT_UNBLOCK_COUNTER"
+ );
+ histogram.add(kMIXED_CONTENT_UNBLOCK_EVENT);
+
+ SitePermissions.setForPrincipal(
+ gBrowser.contentPrincipal,
+ "mixed-content",
+ SitePermissions.ALLOW,
+ SitePermissions.SCOPE_SESSION
+ );
+
+ // Reload the page with the content unblocked
+ BrowserReloadWithFlags(Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CACHE);
+ if (this._popupInitialized) {
+ PanelMultiView.hidePopup(this._identityPopup);
+ }
+ },
+
+ // This is needed for some tests which need the permission reset, but which
+ // then reuse the browser and would race between the reload and the next
+ // load.
+ enableMixedContentProtectionNoReload() {
+ this.enableMixedContentProtection(false);
+ },
+
+ enableMixedContentProtection(reload = true) {
+ SitePermissions.removeFromPrincipal(
+ gBrowser.contentPrincipal,
+ "mixed-content"
+ );
+ if (reload) {
+ BrowserReload();
+ }
+ if (this._popupInitialized) {
+ PanelMultiView.hidePopup(this._identityPopup);
+ }
+ },
+
+ removeCertException() {
+ if (!this._uriHasHost) {
+ console.error(
+ "Trying to revoke a cert exception on a URI without a host?"
+ );
+ return;
+ }
+ let host = this._uri.host;
+ let port = this._uri.port > 0 ? this._uri.port : 443;
+ this._overrideService.clearValidityOverride(
+ host,
+ port,
+ gBrowser.contentPrincipal.originAttributes
+ );
+ BrowserReloadSkipCache();
+ if (this._popupInitialized) {
+ PanelMultiView.hidePopup(this._identityPopup);
+ }
+ },
+
+ /**
+ * Gets the current HTTPS-Only mode permission for the current page.
+ * Values are the same as in #identity-popup-security-httpsonlymode-menulist
+ */
+ _getHttpsOnlyPermission() {
+ const { state } = SitePermissions.getForPrincipal(
+ gBrowser.contentPrincipal,
+ "https-only-load-insecure"
+ );
+ switch (state) {
+ case Ci.nsIHttpsOnlyModePermission.LOAD_INSECURE_ALLOW_SESSION:
+ return 2; // Off temporarily
+ case Ci.nsIHttpsOnlyModePermission.LOAD_INSECURE_ALLOW:
+ return 1; // Off
+ default:
+ return 0; // On
+ }
+ },
+
+ /**
+ * Sets/removes HTTPS-Only Mode exception and possibly reloads the page.
+ */
+ changeHttpsOnlyPermission() {
+ // Get the new value from the menulist and the current value
+ // Note: value and permission association is laid out
+ // in _getHttpsOnlyPermission
+ const oldValue = this._getHttpsOnlyPermission();
+ let newValue = parseInt(
+ this._identityPopupHttpsOnlyModeMenuList.selectedItem.value,
+ 10
+ );
+
+ // If nothing changed, just return here
+ if (newValue === oldValue) {
+ return;
+ }
+
+ // Permissions set in PMB get deleted anyway, but to make sure, let's make
+ // the permission session-only.
+ if (newValue === 1 && PrivateBrowsingUtils.isWindowPrivate(window)) {
+ newValue = 2;
+ }
+
+ // Usually we want to set the permission for the current site and therefore
+ // the current principal...
+ let principal = gBrowser.contentPrincipal;
+ // ...but if we're on the HTTPS-Only error page, the content-principal is
+ // for HTTPS but. We always want to set the exception for HTTP. (Code should
+ // be almost identical to the one in AboutHttpsOnlyErrorParent.sys.mjs)
+ let newURI;
+ if (this._isAboutHttpsOnlyErrorPage) {
+ newURI = gBrowser.currentURI.mutate().setScheme("http").finalize();
+ principal = Services.scriptSecurityManager.createContentPrincipal(
+ newURI,
+ gBrowser.contentPrincipal.originAttributes
+ );
+ }
+
+ // Set or remove the permission
+ if (newValue === 0) {
+ SitePermissions.removeFromPrincipal(
+ principal,
+ "https-only-load-insecure"
+ );
+ } else if (newValue === 1) {
+ SitePermissions.setForPrincipal(
+ principal,
+ "https-only-load-insecure",
+ Ci.nsIHttpsOnlyModePermission.LOAD_INSECURE_ALLOW,
+ SitePermissions.SCOPE_PERSISTENT
+ );
+ } else {
+ SitePermissions.setForPrincipal(
+ principal,
+ "https-only-load-insecure",
+ Ci.nsIHttpsOnlyModePermission.LOAD_INSECURE_ALLOW_SESSION,
+ SitePermissions.SCOPE_SESSION
+ );
+ }
+
+ // If we're on the error-page, we have to redirect the user
+ // from HTTPS to HTTP. Otherwise we can just reload the page.
+ if (this._isAboutHttpsOnlyErrorPage) {
+ gBrowser.loadURI(newURI, {
+ triggeringPrincipal:
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ loadFlags: Ci.nsIWebNavigation.LOAD_FLAGS_REPLACE_HISTORY,
+ });
+ if (this._popupInitialized) {
+ PanelMultiView.hidePopup(this._identityPopup);
+ }
+ return;
+ }
+ // The page only needs to reload if we switch between allow and block
+ // Because "off" is 1 and "off temporarily" is 2, we can just check if the
+ // sum of newValue and oldValue is 3.
+ if (newValue + oldValue !== 3) {
+ BrowserReloadSkipCache();
+ if (this._popupInitialized) {
+ PanelMultiView.hidePopup(this._identityPopup);
+ }
+ return;
+ }
+ // Otherwise we just refresh the interface
+ this.refreshIdentityPopup();
+ },
+
+ /**
+ * Helper to parse out the important parts of _secInfo (of the SSL cert in
+ * particular) for use in constructing identity UI strings
+ */
+ getIdentityData() {
+ var result = {};
+ var cert = this._secInfo.serverCert;
+
+ // Human readable name of Subject
+ result.subjectOrg = cert.organization;
+
+ // SubjectName fields, broken up for individual access
+ if (cert.subjectName) {
+ result.subjectNameFields = {};
+ cert.subjectName.split(",").forEach(function (v) {
+ var field = v.split("=");
+ this[field[0]] = field[1];
+ }, result.subjectNameFields);
+
+ // Call out city, state, and country specifically
+ result.city = result.subjectNameFields.L;
+ result.state = result.subjectNameFields.ST;
+ result.country = result.subjectNameFields.C;
+ }
+
+ // Human readable name of Certificate Authority
+ result.caOrg = cert.issuerOrganization || cert.issuerCommonName;
+ result.cert = cert;
+
+ return result;
+ },
+
+ _getIsSecureContext() {
+ if (gBrowser.contentPrincipal?.originNoSuffix != "resource://pdf.js") {
+ return gBrowser.securityUI.isSecureContext;
+ }
+
+ // For PDF viewer pages (pdf.js) we can't rely on the isSecureContext field.
+ // The backend will return isSecureContext = true, because the content
+ // principal has a resource:// URI. Instead use the URI of the selected
+ // browser to perform the isPotentiallyTrustWorthy check.
+
+ let principal;
+ try {
+ principal = Services.scriptSecurityManager.createContentPrincipal(
+ gBrowser.selectedBrowser.documentURI,
+ {}
+ );
+ return principal.isOriginPotentiallyTrustworthy;
+ } catch (error) {
+ console.error(
+ "Error while computing isPotentiallyTrustWorthy for pdf viewer page: ",
+ error
+ );
+ return false;
+ }
+ },
+
+ /**
+ * Update the identity user interface for the page currently being displayed.
+ *
+ * This examines the SSL certificate metadata, if available, as well as the
+ * connection type and other security-related state information for the page.
+ *
+ * @param state
+ * Bitmask provided by nsIWebProgressListener.onSecurityChange.
+ * @param uri
+ * nsIURI for which the identity UI should be displayed, already
+ * processed by createExposableURI.
+ */
+ updateIdentity(state, uri) {
+ let shouldHidePopup = this._uri && this._uri.spec != uri.spec;
+ this._state = state;
+
+ // Firstly, populate the state properties required to display the UI. See
+ // the documentation of the individual properties for details.
+ this.setURI(uri);
+ this._secInfo = gBrowser.securityUI.secInfo;
+ this._isSecureContext = this._getIsSecureContext();
+
+ // Then, update the user interface with the available data.
+ this.refreshIdentityBlock();
+ // Handle a location change while the Control Center is focused
+ // by closing the popup (bug 1207542)
+ if (shouldHidePopup) {
+ this.hidePopup();
+ gPermissionPanel.hidePopup();
+ }
+
+ // NOTE: We do NOT update the identity popup (the control center) when
+ // we receive a new security state on the existing page (i.e. from a
+ // subframe). If the user opened the popup and looks at the provided
+ // information we don't want to suddenly change the panel contents.
+ },
+
+ /**
+ * Attempt to provide proper IDN treatment for host names
+ */
+ getEffectiveHost() {
+ if (!this._IDNService) {
+ this._IDNService = Cc["@mozilla.org/network/idn-service;1"].getService(
+ Ci.nsIIDNService
+ );
+ }
+ try {
+ return this._IDNService.convertToDisplayIDN(this._uri.host, {});
+ } catch (e) {
+ // If something goes wrong (e.g. host is an IP address) just fail back
+ // to the full domain.
+ return this._uri.host;
+ }
+ },
+
+ getHostForDisplay() {
+ let host = "";
+
+ try {
+ host = this.getEffectiveHost();
+ } catch (e) {
+ // Some URIs might have no hosts.
+ }
+
+ if (this._uri.schemeIs("about")) {
+ // For example in about:certificate the original URL is
+ // about:certificate?cert=<large base64 encoded data>&cert=<large base64 encoded data>&cert=...
+ // So, instead of showing that large string in the identity panel header, we are just showing
+ // about:certificate now. For the other about pages we are just showing about:<page>
+ host = "about:" + this._uri.filePath;
+ }
+
+ if (this._uri.schemeIs("chrome")) {
+ host = this._uri.spec;
+ }
+
+ let readerStrippedURI = ReaderMode.getOriginalUrlObjectForDisplay(
+ this._uri.displaySpec
+ );
+ if (readerStrippedURI) {
+ host = readerStrippedURI.host;
+ }
+
+ if (this._pageExtensionPolicy) {
+ host = this._pageExtensionPolicy.name;
+ }
+
+ // Fallback for special protocols.
+ if (!host) {
+ host = this._uri.specIgnoringRef;
+ }
+
+ return host;
+ },
+
+ /**
+ * Return the CSS class name to set on the "fullscreen-warning" element to
+ * display information about connection security in the notification shown
+ * when a site enters the fullscreen mode.
+ */
+ get pointerlockFsWarningClassName() {
+ // Note that the fullscreen warning does not handle _isSecureInternalUI.
+ if (this._uriHasHost && this._isSecureConnection) {
+ return "verifiedDomain";
+ }
+ return "unknownIdentity";
+ },
+
+ /**
+ * Returns whether the issuer of the current certificate chain is
+ * built-in (returns false) or imported (returns true).
+ */
+ _hasCustomRoot() {
+ let issuerCert = null;
+ issuerCert =
+ this._secInfo.succeededCertChain[
+ this._secInfo.succeededCertChain.length - 1
+ ];
+
+ return !issuerCert.isBuiltInRoot;
+ },
+
+ /**
+ * Returns whether the current URI results in an "invalid"
+ * URL bar state, which effectively means hidden security
+ * indicators.
+ */
+ _hasInvalidPageProxyState() {
+ return (
+ !this._uriHasHost &&
+ this._uri &&
+ isBlankPageURL(this._uri.spec) &&
+ !this._uri.schemeIs("moz-extension")
+ );
+ },
+
+ /**
+ * Updates the security identity in the identity block.
+ */
+ _refreshIdentityIcons() {
+ let icon_label = "";
+ let tooltip = "";
+
+ if (this._isSecureInternalUI) {
+ // This is a secure internal Firefox page.
+ this._identityBox.className = "chromeUI";
+ let brandBundle = document.getElementById("bundle_brand");
+ icon_label = brandBundle.getString("brandShorterName");
+ } else if (this._pageExtensionPolicy) {
+ // This is a WebExtension page.
+ this._identityBox.className = "extensionPage";
+ let extensionName = this._pageExtensionPolicy.name;
+ icon_label = gNavigatorBundle.getFormattedString(
+ "identity.extension.label",
+ [extensionName]
+ );
+ } else if (this._uriHasHost && this._isSecureConnection) {
+ // This is a secure connection.
+ this._identityBox.className = "verifiedDomain";
+ if (this._isMixedActiveContentBlocked) {
+ this._identityBox.classList.add("mixedActiveBlocked");
+ }
+ if (!this._isCertUserOverridden) {
+ // It's a normal cert, verifier is the CA Org.
+ tooltip = gNavigatorBundle.getFormattedString(
+ "identity.identified.verifier",
+ [this.getIdentityData().caOrg]
+ );
+ }
+ } else if (this._isBrokenConnection) {
+ // This is a secure connection, but something is wrong.
+ this._identityBox.className = "unknownIdentity";
+
+ if (this._isMixedActiveContentLoaded) {
+ this._identityBox.classList.add("mixedActiveContent");
+ } else if (this._isMixedActiveContentBlocked) {
+ this._identityBox.classList.add(
+ "mixedDisplayContentLoadedActiveBlocked"
+ );
+ } else if (this._isMixedPassiveContentLoaded) {
+ this._identityBox.classList.add("mixedDisplayContent");
+ } else {
+ this._identityBox.classList.add("weakCipher");
+ }
+ } else if (this._isCertErrorPage) {
+ // We show a warning lock icon for certificate errors, and
+ // show the "Not Secure" text.
+ this._identityBox.className = "certErrorPage notSecureText";
+ icon_label = gNavigatorBundle.getString("identity.notSecure.label");
+ tooltip = gNavigatorBundle.getString("identity.notSecure.tooltip");
+ } else if (this._isAboutHttpsOnlyErrorPage) {
+ // We show a not secure lock icon for 'about:httpsonlyerror' page.
+ this._identityBox.className = "httpsOnlyErrorPage";
+ } else if (this._isAboutNetErrorPage || this._isAboutBlockedPage) {
+ // Network errors and blocked pages get a more neutral icon
+ this._identityBox.className = "unknownIdentity";
+ } else if (this._isPotentiallyTrustworthy) {
+ // This is a local resource (and shouldn't be marked insecure).
+ this._identityBox.className = "localResource";
+ } else {
+ // This is an insecure connection.
+ let warnOnInsecure =
+ this._insecureConnectionIconEnabled ||
+ (this._insecureConnectionIconPBModeEnabled &&
+ PrivateBrowsingUtils.isWindowPrivate(window));
+ let className = warnOnInsecure ? "notSecure" : "unknownIdentity";
+ this._identityBox.className = className;
+ tooltip = warnOnInsecure
+ ? gNavigatorBundle.getString("identity.notSecure.tooltip")
+ : "";
+
+ let warnTextOnInsecure =
+ this._insecureConnectionTextEnabled ||
+ (this._insecureConnectionTextPBModeEnabled &&
+ PrivateBrowsingUtils.isWindowPrivate(window));
+ if (warnTextOnInsecure) {
+ icon_label = gNavigatorBundle.getString("identity.notSecure.label");
+ this._identityBox.classList.add("notSecureText");
+ }
+ }
+
+ if (this._isCertUserOverridden) {
+ this._identityBox.classList.add("certUserOverridden");
+ // Cert is trusted because of a security exception, verifier is a special string.
+ tooltip = gNavigatorBundle.getString(
+ "identity.identified.verified_by_you"
+ );
+ }
+
+ // Push the appropriate strings out to the UI
+ this._identityIcon.setAttribute("tooltiptext", tooltip);
+
+ if (this._pageExtensionPolicy) {
+ let extensionName = this._pageExtensionPolicy.name;
+ this._identityIcon.setAttribute(
+ "tooltiptext",
+ gNavigatorBundle.getFormattedString("identity.extension.tooltip", [
+ extensionName,
+ ])
+ );
+ }
+
+ this._identityIconLabel.setAttribute("tooltiptext", tooltip);
+ this._identityIconLabel.setAttribute("value", icon_label);
+ this._identityIconLabel.collapsed = !icon_label;
+ },
+
+ /**
+ * Updates the identity block user interface with the data from this object.
+ */
+ refreshIdentityBlock() {
+ if (!this._identityBox) {
+ return;
+ }
+
+ this._refreshIdentityIcons();
+
+ // If this condition is true, the URL bar will have an "invalid"
+ // pageproxystate, so we should hide the permission icons.
+ if (this._hasInvalidPageProxyState()) {
+ gPermissionPanel.hidePermissionIcons();
+ } else {
+ gPermissionPanel.refreshPermissionIcons();
+ }
+
+ // Hide the shield icon if it is a chrome page.
+ gProtectionsHandler._trackingProtectionIconContainer.classList.toggle(
+ "chromeUI",
+ this._isSecureInternalUI
+ );
+ },
+
+ /**
+ * Set up the title and content messages for the identity message popup,
+ * based on the specified mode, and the details of the SSL cert, where
+ * applicable
+ */
+ refreshIdentityPopup() {
+ // Update cookies and site data information and show the
+ // "Clear Site Data" button if the site is storing local data, and
+ // if the page is not controlled by a WebExtension.
+ this._clearSiteDataFooter.hidden = true;
+ let identityPopupPanelView = document.getElementById(
+ "identity-popup-mainView"
+ );
+ identityPopupPanelView.removeAttribute("footerVisible");
+ if (this._uriHasHost && !this._pageExtensionPolicy) {
+ SiteDataManager.hasSiteData(this._uri.asciiHost).then(hasData => {
+ this._clearSiteDataFooter.hidden = !hasData;
+ identityPopupPanelView.setAttribute("footerVisible", hasData);
+ });
+ }
+
+ let customRoot = false;
+
+ // Determine connection security information.
+ let connection = "not-secure";
+ if (this._isSecureInternalUI) {
+ connection = "chrome";
+ } else if (this._pageExtensionPolicy) {
+ connection = "extension";
+ } else if (this._isURILoadedFromFile) {
+ connection = "file";
+ } else if (this._isEV) {
+ connection = "secure-ev";
+ } else if (this._isCertUserOverridden) {
+ connection = "secure-cert-user-overridden";
+ } else if (this._isSecureConnection) {
+ connection = "secure";
+ customRoot = this._hasCustomRoot();
+ } else if (this._isCertErrorPage) {
+ connection = "cert-error-page";
+ } else if (this._isAboutHttpsOnlyErrorPage) {
+ connection = "https-only-error-page";
+ } else if (this._isAboutBlockedPage) {
+ connection = "not-secure";
+ } else if (this._isAboutNetErrorPage) {
+ connection = "net-error-page";
+ } else if (this._isPotentiallyTrustworthy) {
+ connection = "file";
+ }
+
+ let securityButtonNode = document.getElementById(
+ "identity-popup-security-button"
+ );
+
+ let disableSecurityButton = ![
+ "not-secure",
+ "secure",
+ "secure-ev",
+ "secure-cert-user-overridden",
+ "cert-error-page",
+ "net-error-page",
+ "https-only-error-page",
+ ].includes(connection);
+ if (disableSecurityButton) {
+ securityButtonNode.disabled = true;
+ securityButtonNode.classList.remove("subviewbutton-nav");
+ } else {
+ securityButtonNode.disabled = false;
+ securityButtonNode.classList.add("subviewbutton-nav");
+ }
+
+ // Determine the mixed content state.
+ let mixedcontent = [];
+ if (this._isMixedPassiveContentLoaded) {
+ mixedcontent.push("passive-loaded");
+ }
+ if (this._isMixedActiveContentLoaded) {
+ mixedcontent.push("active-loaded");
+ } else if (this._isMixedActiveContentBlocked) {
+ mixedcontent.push("active-blocked");
+ }
+ mixedcontent = mixedcontent.join(" ");
+
+ // We have no specific flags for weak ciphers (yet). If a connection is
+ // broken and we can't detect any mixed content loaded then it's a weak
+ // cipher.
+ let ciphers = "";
+ if (
+ this._isBrokenConnection &&
+ !this._isMixedActiveContentLoaded &&
+ !this._isMixedPassiveContentLoaded
+ ) {
+ ciphers = "weak";
+ }
+
+ // If HTTPS-Only Mode is enabled, check the permission status
+ const privateBrowsingWindow = PrivateBrowsingUtils.isWindowPrivate(window);
+ let httpsOnlyStatus = "";
+ if (
+ this._httpsOnlyModeEnabled ||
+ (privateBrowsingWindow && this._httpsOnlyModeEnabledPBM)
+ ) {
+ // Note: value and permission association is laid out
+ // in _getHttpsOnlyPermission
+ let value = this._getHttpsOnlyPermission();
+
+ // Because everything in PBM is temporary anyway, we don't need to make the distinction
+ if (privateBrowsingWindow) {
+ if (value === 2) {
+ value = 1;
+ }
+ // Hide "off temporarily" option
+ this._identityPopupHttpsOnlyModeMenuListTempItem.style.display = "none";
+ } else {
+ this._identityPopupHttpsOnlyModeMenuListTempItem.style.display = "";
+ }
+
+ this._identityPopupHttpsOnlyModeMenuList.value = value;
+
+ if (value > 0) {
+ httpsOnlyStatus = "exception";
+ } else if (this._isAboutHttpsOnlyErrorPage) {
+ httpsOnlyStatus = "failed-top";
+ } else if (this._isContentHttpsOnlyModeUpgradeFailed) {
+ httpsOnlyStatus = "failed-sub";
+ } else if (this._isContentHttpsOnlyModeUpgraded) {
+ httpsOnlyStatus = "upgraded";
+ }
+ }
+
+ // Update all elements.
+ let elementIDs = [
+ "identity-popup",
+ "identity-popup-securityView-extended-info",
+ ];
+
+ for (let id of elementIDs) {
+ let element = document.getElementById(id);
+ this._updateAttribute(element, "connection", connection);
+ this._updateAttribute(element, "ciphers", ciphers);
+ this._updateAttribute(element, "mixedcontent", mixedcontent);
+ this._updateAttribute(element, "isbroken", this._isBrokenConnection);
+ this._updateAttribute(element, "customroot", customRoot);
+ this._updateAttribute(element, "httpsonlystatus", httpsOnlyStatus);
+ }
+
+ // Initialize the optional strings to empty values
+ let supplemental = "";
+ let verifier = "";
+ let host = this.getHostForDisplay();
+ let owner = "";
+
+ // Fill in the CA name if we have a valid TLS certificate.
+ if (this._isSecureConnection || this._isCertUserOverridden) {
+ verifier = this._identityIconLabel.tooltipText;
+ }
+
+ // Fill in organization information if we have a valid EV certificate.
+ if (this._isEV) {
+ let iData = this.getIdentityData();
+ owner = iData.subjectOrg;
+ verifier = this._identityIconLabel.tooltipText;
+
+ // Build an appropriate supplemental block out of whatever location data we have
+ if (iData.city) {
+ supplemental += iData.city + "\n";
+ }
+ if (iData.state && iData.country) {
+ supplemental += gNavigatorBundle.getFormattedString(
+ "identity.identified.state_and_country",
+ [iData.state, iData.country]
+ );
+ } else if (iData.state) {
+ // State only
+ supplemental += iData.state;
+ } else if (iData.country) {
+ // Country only
+ supplemental += iData.country;
+ }
+ }
+
+ // Push the appropriate strings out to the UI.
+ document.l10n.setAttributes(
+ this._identityPopupMainViewHeaderLabel,
+ "identity-site-information",
+ {
+ host,
+ }
+ );
+
+ document.l10n.setAttributes(
+ this._identityPopupSecurityView,
+ "identity-header-security-with-host",
+ {
+ host,
+ }
+ );
+
+ this._identityPopupSecurityEVContentOwner.textContent =
+ gNavigatorBundle.getFormattedString("identity.ev.contentOwner2", [owner]);
+
+ this._identityPopupContentOwner.textContent = owner;
+ this._identityPopupContentSupp.textContent = supplemental;
+ this._identityPopupContentVerif.textContent = verifier;
+ },
+
+ setURI(uri) {
+ if (uri.schemeIs("view-source")) {
+ uri = Services.io.newURI(uri.spec.replace(/^view-source:/i, ""));
+ }
+ this._uri = uri;
+
+ try {
+ // Account for file: urls and catch when "" is the value
+ this._uriHasHost = !!this._uri.host;
+ } catch (ex) {
+ this._uriHasHost = false;
+ }
+
+ if (uri.schemeIs("about")) {
+ let module = E10SUtils.getAboutModule(uri);
+ if (module) {
+ let flags = module.getURIFlags(uri);
+ this._isSecureInternalUI = !!(
+ flags & Ci.nsIAboutModule.IS_SECURE_CHROME_UI
+ );
+ }
+ } else {
+ this._isSecureInternalUI = false;
+ }
+ this._pageExtensionPolicy = WebExtensionPolicy.getByURI(uri);
+
+ // Create a channel for the sole purpose of getting the resolved URI
+ // of the request to determine if it's loaded from the file system.
+ this._isURILoadedFromFile = false;
+ let chanOptions = { uri: this._uri, loadUsingSystemPrincipal: true };
+ let resolvedURI;
+ try {
+ resolvedURI = NetUtil.newChannel(chanOptions).URI;
+ if (resolvedURI.schemeIs("jar")) {
+ // Given a URI "jar:<jar-file-uri>!/<jar-entry>"
+ // create a new URI using <jar-file-uri>!/<jar-entry>
+ resolvedURI = NetUtil.newURI(resolvedURI.pathQueryRef);
+ }
+ // Check the URI again after resolving.
+ this._isURILoadedFromFile = resolvedURI.schemeIs("file");
+ } catch (ex) {
+ // NetUtil's methods will throw for malformed URIs and the like
+ }
+ },
+
+ /**
+ * Click handler for the identity-box element in primary chrome.
+ */
+ handleIdentityButtonEvent(event) {
+ event.stopPropagation();
+
+ if (
+ (event.type == "click" && event.button != 0) ||
+ (event.type == "keypress" &&
+ event.charCode != KeyEvent.DOM_VK_SPACE &&
+ event.keyCode != KeyEvent.DOM_VK_RETURN)
+ ) {
+ return; // Left click, space or enter only
+ }
+
+ // Don't allow left click, space or enter if the location has been modified.
+ if (gURLBar.getAttribute("pageproxystate") != "valid") {
+ return;
+ }
+
+ this._openPopup(event);
+ },
+
+ _openPopup(event) {
+ // Make the popup available.
+ this._initializePopup();
+
+ // Update the popup strings
+ this.refreshIdentityPopup();
+
+ // Check the panel state of other panels. Hide them if needed.
+ let openPanels = Array.from(document.querySelectorAll("panel[openpanel]"));
+ for (let panel of openPanels) {
+ PanelMultiView.hidePopup(panel);
+ }
+
+ // Now open the popup, anchored off the primary chrome element
+ PanelMultiView.openPopup(this._identityPopup, this._identityIconBox, {
+ position: "bottomleft topleft",
+ triggerEvent: event,
+ }).catch(console.error);
+ },
+
+ onPopupShown(event) {
+ if (event.target == this._identityPopup) {
+ PopupNotifications.suppressWhileOpen(this._identityPopup);
+ window.addEventListener("focus", this, true);
+ }
+ },
+
+ onPopupHidden(event) {
+ if (event.target == this._identityPopup) {
+ window.removeEventListener("focus", this, true);
+ }
+ },
+
+ handleEvent(event) {
+ let elem = document.activeElement;
+ let position = elem.compareDocumentPosition(this._identityPopup);
+
+ if (
+ !(
+ position &
+ (Node.DOCUMENT_POSITION_CONTAINS | Node.DOCUMENT_POSITION_CONTAINED_BY)
+ ) &&
+ !this._identityPopup.hasAttribute("noautohide")
+ ) {
+ // Hide the panel when focusing an element that is
+ // neither an ancestor nor descendant unless the panel has
+ // @noautohide (e.g. for a tour).
+ PanelMultiView.hidePopup(this._identityPopup);
+ }
+ },
+
+ observe(subject, topic, data) {
+ switch (topic) {
+ case "perm-changed": {
+ // Exclude permissions which do not appear in the UI in order to avoid
+ // doing extra work here.
+ if (!subject) {
+ return;
+ }
+ let { type } = subject.QueryInterface(Ci.nsIPermission);
+ if (SitePermissions.isSitePermission(type)) {
+ this.refreshIdentityBlock();
+ }
+ break;
+ }
+ }
+ },
+
+ onDragStart(event) {
+ const TEXT_SIZE = 14;
+ const IMAGE_SIZE = 16;
+ const SPACING = 5;
+
+ if (gURLBar.getAttribute("pageproxystate") != "valid") {
+ return;
+ }
+
+ let value = gBrowser.currentURI.displaySpec;
+ let urlString = value + "\n" + gBrowser.contentTitle;
+ let htmlString = '<a href="' + value + '">' + value + "</a>";
+
+ let scale = window.devicePixelRatio;
+ let canvas = document.createElementNS(
+ "http://www.w3.org/1999/xhtml",
+ "canvas"
+ );
+ canvas.width = 550 * scale;
+ let ctx = canvas.getContext("2d");
+ ctx.font = `${TEXT_SIZE * scale}px sans-serif`;
+ let tabIcon = gBrowser.selectedTab.iconImage;
+ let image = new Image();
+ image.src = tabIcon.src;
+ let textWidth = ctx.measureText(value).width / scale;
+ let textHeight = parseInt(ctx.font, 10) / scale;
+ let imageHorizontalOffset, imageVerticalOffset;
+ imageHorizontalOffset = imageVerticalOffset = SPACING;
+ let textHorizontalOffset = image.width ? IMAGE_SIZE + SPACING * 2 : SPACING;
+ let textVerticalOffset = textHeight + SPACING - 1;
+ let backgroundColor = "white";
+ let textColor = "black";
+ let totalWidth = image.width
+ ? textWidth + IMAGE_SIZE + 3 * SPACING
+ : textWidth + 2 * SPACING;
+ let totalHeight = image.width
+ ? IMAGE_SIZE + 2 * SPACING
+ : textHeight + 2 * SPACING;
+ ctx.fillStyle = backgroundColor;
+ ctx.fillRect(0, 0, totalWidth * scale, totalHeight * scale);
+ ctx.fillStyle = textColor;
+ ctx.fillText(
+ `${value}`,
+ textHorizontalOffset * scale,
+ textVerticalOffset * scale
+ );
+ try {
+ ctx.drawImage(
+ image,
+ imageHorizontalOffset * scale,
+ imageVerticalOffset * scale,
+ IMAGE_SIZE * scale,
+ IMAGE_SIZE * scale
+ );
+ } catch (e) {
+ // Sites might specify invalid data URIs favicons that
+ // will result in errors when trying to draw, we can
+ // just ignore this case and not paint any favicon.
+ }
+
+ let dt = event.dataTransfer;
+ dt.setData("text/x-moz-url", urlString);
+ dt.setData("text/uri-list", value);
+ dt.setData("text/plain", value);
+ dt.setData("text/html", htmlString);
+ dt.setDragImage(canvas, 16, 16);
+
+ // Don't cover potential drop targets on the toolbars or in content.
+ gURLBar.view.close();
+ },
+
+ _updateAttribute(elem, attr, value) {
+ if (value) {
+ elem.setAttribute(attr, value);
+ } else {
+ elem.removeAttribute(attr);
+ }
+ },
+};
diff --git a/browser/base/content/browser-sitePermissionPanel.js b/browser/base/content/browser-sitePermissionPanel.js
new file mode 100644
index 0000000000..53c236ef02
--- /dev/null
+++ b/browser/base/content/browser-sitePermissionPanel.js
@@ -0,0 +1,1049 @@
+/* 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/. */
+
+/* eslint-env mozilla/browser-window */
+
+/**
+ * Utility object to handle manipulations of the identity permission indicators
+ * in the UI.
+ */
+var gPermissionPanel = {
+ _popupInitialized: false,
+ _initializePopup() {
+ if (!this._popupInitialized) {
+ let wrapper = document.getElementById("template-permission-popup");
+ wrapper.replaceWith(wrapper.content);
+
+ window.ensureCustomElements("moz-support-link");
+
+ this._popupInitialized = true;
+ }
+ },
+
+ hidePopup() {
+ if (this._popupInitialized) {
+ PanelMultiView.hidePopup(this._permissionPopup);
+ }
+ },
+
+ /**
+ * _popupAnchorNode will be set by setAnchor if an outside consumer
+ * of this object wants to override the default anchor for the panel.
+ * If there is no override, this remains null, and the _identityPermissionBox
+ * will be used as the anchor.
+ */
+ _popupAnchorNode: null,
+ _popupPosition: "bottomleft topleft",
+ setAnchor(anchorNode, popupPosition) {
+ this._popupAnchorNode = anchorNode;
+ this._popupPosition = popupPosition;
+ },
+
+ // smart getters
+ get _popupAnchor() {
+ if (this._popupAnchorNode) {
+ return this._popupAnchorNode;
+ }
+ return this._identityPermissionBox;
+ },
+ get _identityPermissionBox() {
+ delete this._identityPermissionBox;
+ return (this._identityPermissionBox = document.getElementById(
+ "identity-permission-box"
+ ));
+ },
+ get _permissionGrantedIcon() {
+ delete this._permissionGrantedIcon;
+ return (this._permissionGrantedIcon = document.getElementById(
+ "permissions-granted-icon"
+ ));
+ },
+ get _permissionPopup() {
+ if (!this._popupInitialized) {
+ return null;
+ }
+ delete this._permissionPopup;
+ return (this._permissionPopup =
+ document.getElementById("permission-popup"));
+ },
+ get _permissionPopupMainView() {
+ delete this._permissionPopupPopupMainView;
+ return (this._permissionPopupPopupMainView = document.getElementById(
+ "permission-popup-mainView"
+ ));
+ },
+ get _permissionPopupMainViewHeaderLabel() {
+ delete this._permissionPopupMainViewHeaderLabel;
+ return (this._permissionPopupMainViewHeaderLabel = document.getElementById(
+ "permission-popup-mainView-panel-header-span"
+ ));
+ },
+ get _permissionList() {
+ delete this._permissionList;
+ return (this._permissionList = document.getElementById(
+ "permission-popup-permission-list"
+ ));
+ },
+ get _defaultPermissionAnchor() {
+ delete this._defaultPermissionAnchor;
+ return (this._defaultPermissionAnchor = document.getElementById(
+ "permission-popup-permission-list-default-anchor"
+ ));
+ },
+ get _permissionReloadHint() {
+ delete this._permissionReloadHint;
+ return (this._permissionReloadHint = document.getElementById(
+ "permission-popup-permission-reload-hint"
+ ));
+ },
+ get _permissionAnchors() {
+ delete this._permissionAnchors;
+ let permissionAnchors = {};
+ for (let anchor of document.getElementById("blocked-permissions-container")
+ .children) {
+ permissionAnchors[anchor.getAttribute("data-permission-id")] = anchor;
+ }
+ return (this._permissionAnchors = permissionAnchors);
+ },
+
+ get _geoSharingIcon() {
+ delete this._geoSharingIcon;
+ return (this._geoSharingIcon = document.getElementById("geo-sharing-icon"));
+ },
+
+ get _xrSharingIcon() {
+ delete this._xrSharingIcon;
+ return (this._xrSharingIcon = document.getElementById("xr-sharing-icon"));
+ },
+
+ get _webRTCSharingIcon() {
+ delete this._webRTCSharingIcon;
+ return (this._webRTCSharingIcon = document.getElementById(
+ "webrtc-sharing-icon"
+ ));
+ },
+
+ /**
+ * Refresh the contents of the permission popup. This includes the headline
+ * and the list of permissions.
+ */
+ _refreshPermissionPopup() {
+ let host = gIdentityHandler.getHostForDisplay();
+
+ // Update header label
+ this._permissionPopupMainViewHeaderLabel.textContent =
+ gNavigatorBundle.getFormattedString("permissions.header", [host]);
+
+ // Refresh the permission list
+ this.updateSitePermissions();
+ },
+
+ /**
+ * Called by gIdentityHandler to hide permission icons for invalid proxy
+ * state.
+ */
+ hidePermissionIcons() {
+ this._identityPermissionBox.removeAttribute("hasPermissions");
+ },
+
+ /**
+ * Updates the permissions icons in the identity block.
+ * We show icons for blocked permissions / popups.
+ */
+ refreshPermissionIcons() {
+ let permissionAnchors = this._permissionAnchors;
+
+ // hide all permission icons
+ for (let icon of Object.values(permissionAnchors)) {
+ icon.removeAttribute("showing");
+ }
+
+ // keeps track if we should show an indicator that there are active permissions
+ let hasPermissions = false;
+
+ // show permission icons
+ let permissions = SitePermissions.getAllForBrowser(
+ gBrowser.selectedBrowser
+ );
+ for (let permission of permissions) {
+ if (permission.state != SitePermissions.UNKNOWN) {
+ hasPermissions = true;
+
+ if (
+ permission.state == SitePermissions.BLOCK ||
+ permission.state == SitePermissions.AUTOPLAY_BLOCKED_ALL
+ ) {
+ let icon = permissionAnchors[permission.id];
+ if (icon) {
+ icon.setAttribute("showing", "true");
+ }
+ }
+ }
+ }
+
+ // Show blocked popup icon in the identity-box if popups are blocked
+ // irrespective of popup permission capability value.
+ if (gBrowser.selectedBrowser.popupBlocker.getBlockedPopupCount()) {
+ let icon = permissionAnchors.popup;
+ icon.setAttribute("showing", "true");
+ hasPermissions = true;
+ }
+
+ this._identityPermissionBox.toggleAttribute(
+ "hasPermissions",
+ hasPermissions
+ );
+ },
+
+ /**
+ * Shows the permission popup.
+ * @param {Event} event - Event which caused the popup to show.
+ */
+ openPopup(event) {
+ // If we are in DOM fullscreen, exit it before showing the permission popup
+ // (see bug 1557041)
+ if (document.fullscreen) {
+ // Open the identity popup after DOM fullscreen exit
+ // We need to wait for the exit event and after that wait for the fullscreen exit transition to complete
+ // If we call openPopup before the fullscreen transition ends it can get cancelled
+ // Only waiting for painted is not sufficient because we could still be in the fullscreen enter transition.
+ this._exitedEventReceived = false;
+ this._event = event;
+ Services.obs.addObserver(this, "fullscreen-painted");
+ window.addEventListener(
+ "MozDOMFullscreen:Exited",
+ () => {
+ this._exitedEventReceived = true;
+ },
+ { once: true }
+ );
+ document.exitFullscreen();
+ return;
+ }
+
+ // Make the popup available.
+ this._initializePopup();
+
+ // Remove the reload hint that we show after a user has cleared a permission.
+ this._permissionReloadHint.hidden = true;
+
+ // Update the popup strings
+ this._refreshPermissionPopup();
+
+ // Check the panel state of other panels. Hide them if needed.
+ let openPanels = Array.from(document.querySelectorAll("panel[openpanel]"));
+ for (let panel of openPanels) {
+ PanelMultiView.hidePopup(panel);
+ }
+
+ // Now open the popup, anchored off the primary chrome element
+ PanelMultiView.openPopup(this._permissionPopup, this._popupAnchor, {
+ position: this._popupPosition,
+ triggerEvent: event,
+ }).catch(console.error);
+ },
+
+ /**
+ * Update identity permission indicators based on sharing state of the
+ * selected tab. This should be called externally whenever the sharing state
+ * of the selected tab changes.
+ */
+ updateSharingIndicator() {
+ let tab = gBrowser.selectedTab;
+ this._sharingState = tab._sharingState;
+
+ this._webRTCSharingIcon.removeAttribute("paused");
+ this._webRTCSharingIcon.removeAttribute("sharing");
+ this._geoSharingIcon.removeAttribute("sharing");
+ this._xrSharingIcon.removeAttribute("sharing");
+
+ let hasSharingIcon = false;
+
+ if (this._sharingState) {
+ if (this._sharingState.webRTC) {
+ if (this._sharingState.webRTC.sharing) {
+ this._webRTCSharingIcon.setAttribute(
+ "sharing",
+ this._sharingState.webRTC.sharing
+ );
+ hasSharingIcon = true;
+
+ if (this._sharingState.webRTC.paused) {
+ this._webRTCSharingIcon.setAttribute("paused", "true");
+ }
+ } else {
+ // Reflect any active permission grace periods
+ let { micGrace, camGrace } = hasMicCamGracePeriodsSolely(
+ gBrowser.selectedBrowser
+ );
+ if (micGrace || camGrace) {
+ // Reuse the "paused sharing" indicator to warn about grace periods
+ this._webRTCSharingIcon.setAttribute(
+ "sharing",
+ camGrace ? "camera" : "microphone"
+ );
+ hasSharingIcon = true;
+ this._webRTCSharingIcon.setAttribute("paused", "true");
+ }
+ }
+ }
+
+ if (this._sharingState.geo) {
+ this._geoSharingIcon.setAttribute("sharing", this._sharingState.geo);
+ hasSharingIcon = true;
+ }
+
+ if (this._sharingState.xr) {
+ this._xrSharingIcon.setAttribute("sharing", this._sharingState.xr);
+ hasSharingIcon = true;
+ }
+ }
+
+ this._identityPermissionBox.toggleAttribute(
+ "hasSharingIcon",
+ hasSharingIcon
+ );
+
+ if (this._popupInitialized && this._permissionPopup.state != "closed") {
+ this.updateSitePermissions();
+ }
+ },
+
+ /**
+ * Click handler for the permission-box element in primary chrome.
+ */
+ handleIdentityButtonEvent(event) {
+ event.stopPropagation();
+
+ if (
+ (event.type == "click" && event.button != 0) ||
+ (event.type == "keypress" &&
+ event.charCode != KeyEvent.DOM_VK_SPACE &&
+ event.keyCode != KeyEvent.DOM_VK_RETURN)
+ ) {
+ return; // Left click, space or enter only
+ }
+
+ // Don't allow left click, space or enter if the location has been modified,
+ // so long as we're not sharing any devices.
+ // If we are sharing a device, the identity block is prevented by CSS from
+ // being focused (and therefore, interacted with) by the user. However, we
+ // want to allow opening the identity popup from the device control menu,
+ // which calls click() on the identity button, so we don't return early.
+ if (
+ !this._sharingState &&
+ gURLBar.getAttribute("pageproxystate") != "valid"
+ ) {
+ return;
+ }
+
+ this.openPopup(event);
+ },
+
+ onPopupShown(event) {
+ if (event.target == this._permissionPopup) {
+ window.addEventListener("focus", this, true);
+ }
+ },
+
+ onPopupHidden(event) {
+ if (event.target == this._permissionPopup) {
+ window.removeEventListener("focus", this, true);
+ }
+ },
+
+ handleEvent(event) {
+ let elem = document.activeElement;
+ let position = elem.compareDocumentPosition(this._permissionPopup);
+
+ if (
+ !(
+ position &
+ (Node.DOCUMENT_POSITION_CONTAINS | Node.DOCUMENT_POSITION_CONTAINED_BY)
+ ) &&
+ !this._permissionPopup.hasAttribute("noautohide")
+ ) {
+ // Hide the panel when focusing an element that is
+ // neither an ancestor nor descendant unless the panel has
+ // @noautohide (e.g. for a tour).
+ PanelMultiView.hidePopup(this._permissionPopup);
+ }
+ },
+
+ observe(subject, topic, data) {
+ switch (topic) {
+ case "fullscreen-painted": {
+ if (subject != window || !this._exitedEventReceived) {
+ return;
+ }
+ Services.obs.removeObserver(this, "fullscreen-painted");
+ this.openPopup(this._event);
+ delete this._event;
+ break;
+ }
+ }
+ },
+
+ onLocationChange() {
+ if (this._popupInitialized && this._permissionPopup.state != "closed") {
+ this._permissionReloadHint.hidden = true;
+ }
+ },
+
+ /**
+ * Updates the permission list in the permissions popup.
+ */
+ updateSitePermissions() {
+ let permissionItemSelector = [
+ ".permission-popup-permission-item, .permission-popup-permission-item-container",
+ ];
+ this._permissionList
+ .querySelectorAll(permissionItemSelector)
+ .forEach(e => e.remove());
+ // Used by _createPermissionItem to build unique IDs.
+ this._permissionLabelIndex = 0;
+
+ let permissions = SitePermissions.getAllPermissionDetailsForBrowser(
+ gBrowser.selectedBrowser
+ );
+
+ this._sharingState = gBrowser.selectedTab._sharingState;
+
+ if (this._sharingState?.geo) {
+ let geoPermission = permissions.find(perm => perm.id === "geo");
+ if (geoPermission) {
+ geoPermission.sharingState = true;
+ } else {
+ permissions.push({
+ id: "geo",
+ state: SitePermissions.ALLOW,
+ scope: SitePermissions.SCOPE_REQUEST,
+ sharingState: true,
+ });
+ }
+ }
+
+ if (this._sharingState?.xr) {
+ let xrPermission = permissions.find(perm => perm.id === "xr");
+ if (xrPermission) {
+ xrPermission.sharingState = true;
+ } else {
+ permissions.push({
+ id: "xr",
+ state: SitePermissions.ALLOW,
+ scope: SitePermissions.SCOPE_REQUEST,
+ sharingState: true,
+ });
+ }
+ }
+
+ if (this._sharingState?.webRTC) {
+ let webrtcState = this._sharingState.webRTC;
+ // If WebRTC device or screen permissions are in use, we need to find
+ // the associated permission item to set the sharingState field.
+ for (let id of ["camera", "microphone", "screen"]) {
+ if (webrtcState[id]) {
+ let found = false;
+ for (let permission of permissions) {
+ let [permId] = permission.id.split(
+ SitePermissions.PERM_KEY_DELIMITER
+ );
+ if (permId != id) {
+ continue;
+ }
+ found = true;
+ permission.sharingState = webrtcState[id];
+ }
+ if (!found) {
+ // If the permission item we were looking for doesn't exist,
+ // the user has temporarily allowed sharing and we need to add
+ // an item in the permissions array to reflect this.
+ permissions.push({
+ id,
+ state: SitePermissions.ALLOW,
+ scope: SitePermissions.SCOPE_REQUEST,
+ sharingState: webrtcState[id],
+ });
+ }
+ }
+ }
+ }
+
+ let totalBlockedPopups =
+ gBrowser.selectedBrowser.popupBlocker.getBlockedPopupCount();
+ let hasBlockedPopupIndicator = false;
+ for (let permission of permissions) {
+ let [id, key] = permission.id.split(SitePermissions.PERM_KEY_DELIMITER);
+
+ if (id == "storage-access") {
+ // Ignore storage access permissions here, they are made visible inside
+ // the Content Blocking UI.
+ continue;
+ }
+
+ let item;
+ let anchor =
+ this._permissionList.querySelector(`[anchorfor="${id}"]`) ||
+ this._defaultPermissionAnchor;
+
+ if (id == "open-protocol-handler") {
+ let permContainer = this._createProtocolHandlerPermissionItem(
+ permission,
+ key
+ );
+ if (permContainer) {
+ anchor.appendChild(permContainer);
+ }
+ } else if (["camera", "screen", "microphone", "speaker"].includes(id)) {
+ item = this._createWebRTCPermissionItem(permission, id, key);
+ if (!item) {
+ continue;
+ }
+ anchor.appendChild(item);
+ } else {
+ item = this._createPermissionItem({
+ permission,
+ idNoSuffix: id,
+ isContainer: id == "geo" || id == "xr",
+ nowrapLabel: id == "3rdPartyStorage",
+ });
+
+ if (!item) {
+ continue;
+ }
+ anchor.appendChild(item);
+ }
+
+ if (id == "popup" && totalBlockedPopups) {
+ this._createBlockedPopupIndicator(totalBlockedPopups);
+ hasBlockedPopupIndicator = true;
+ } else if (id == "geo" && permission.state === SitePermissions.ALLOW) {
+ this._createGeoLocationLastAccessIndicator();
+ }
+ }
+
+ if (totalBlockedPopups && !hasBlockedPopupIndicator) {
+ let permission = {
+ id: "popup",
+ state: SitePermissions.getDefault("popup"),
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ };
+ let item = this._createPermissionItem({ permission });
+ this._defaultPermissionAnchor.appendChild(item);
+ this._createBlockedPopupIndicator(totalBlockedPopups);
+ }
+ },
+
+ /**
+ * Creates a permission item based on the supplied options and returns it.
+ * It is up to the caller to actually insert the element somewhere.
+ *
+ * @param permission - An object containing information representing the
+ * permission, typically obtained via SitePermissions.sys.mjs
+ * @param isContainer - If true, the permission item will be added to a vbox
+ * and the vbox will be returned.
+ * @param permClearButton - Whether to show an "x" button to clear the permission
+ * @param showStateLabel - Whether to show a label indicating the current status
+ * of the permission e.g. "Temporary Allowed"
+ * @param idNoSuffix - Some permission types have additional information suffixed
+ * to the ID - callers can pass the unsuffixed ID via this
+ * parameter to indicate the permission type manually.
+ * @param nowrapLabel - Whether to prevent the permission item's label from
+ * wrapping its text content. This allows styling text-overflow
+ * and is useful for e.g. 3rdPartyStorage permissions whose
+ * labels are origins - which could be of any length.
+ */
+ _createPermissionItem({
+ permission,
+ isContainer = false,
+ permClearButton = true,
+ showStateLabel = true,
+ idNoSuffix = permission.id,
+ nowrapLabel = false,
+ clearCallback = () => {},
+ }) {
+ let container = document.createXULElement("hbox");
+ container.classList.add(
+ "permission-popup-permission-item",
+ `permission-popup-permission-item-${idNoSuffix}`
+ );
+ container.setAttribute("align", "center");
+ container.setAttribute("role", "group");
+
+ let img = document.createXULElement("image");
+ img.classList.add("permission-popup-permission-icon", idNoSuffix + "-icon");
+ if (
+ permission.state == SitePermissions.BLOCK ||
+ permission.state == SitePermissions.AUTOPLAY_BLOCKED_ALL
+ ) {
+ img.classList.add("blocked-permission-icon");
+ }
+
+ if (
+ permission.sharingState ==
+ Ci.nsIMediaManagerService.STATE_CAPTURE_ENABLED ||
+ (idNoSuffix == "screen" &&
+ permission.sharingState &&
+ !permission.sharingState.includes("Paused"))
+ ) {
+ img.classList.add("in-use");
+ }
+
+ let nameLabel = document.createXULElement("label");
+ nameLabel.setAttribute("flex", "1");
+ nameLabel.setAttribute("class", "permission-popup-permission-label");
+ let label = SitePermissions.getPermissionLabel(permission.id);
+ if (label === null) {
+ return null;
+ }
+ if (nowrapLabel) {
+ nameLabel.setAttribute("value", label);
+ nameLabel.setAttribute("tooltiptext", label);
+ nameLabel.setAttribute("crop", "end");
+ } else {
+ nameLabel.textContent = label;
+ }
+ // idNoSuffix is not unique for double-keyed permissions. Adding an index to
+ // ensure IDs are unique.
+ // permission.id is unique but may not be a valid HTML ID.
+ let nameLabelId = `permission-popup-permission-label-${idNoSuffix}-${this
+ ._permissionLabelIndex++}`;
+ nameLabel.setAttribute("id", nameLabelId);
+
+ let isPolicyPermission = [
+ SitePermissions.SCOPE_POLICY,
+ SitePermissions.SCOPE_GLOBAL,
+ ].includes(permission.scope);
+
+ if (
+ (idNoSuffix == "popup" && !isPolicyPermission) ||
+ idNoSuffix == "autoplay-media"
+ ) {
+ let menulist = document.createXULElement("menulist");
+ let menupopup = document.createXULElement("menupopup");
+ let block = document.createXULElement("vbox");
+ block.setAttribute("id", "permission-popup-container");
+ block.setAttribute("class", "permission-popup-permission-item-container");
+ menulist.setAttribute("sizetopopup", "none");
+ menulist.setAttribute("id", "permission-popup-menulist");
+
+ for (let state of SitePermissions.getAvailableStates(idNoSuffix)) {
+ let menuitem = document.createXULElement("menuitem");
+ // We need to correctly display the default/unknown state, which has its
+ // own integer value (0) but represents one of the other states.
+ if (state == SitePermissions.getDefault(idNoSuffix)) {
+ menuitem.setAttribute("value", "0");
+ } else {
+ menuitem.setAttribute("value", state);
+ }
+
+ menuitem.setAttribute(
+ "label",
+ SitePermissions.getMultichoiceStateLabel(idNoSuffix, state)
+ );
+ menupopup.appendChild(menuitem);
+ }
+
+ menulist.appendChild(menupopup);
+
+ if (permission.state == SitePermissions.getDefault(idNoSuffix)) {
+ menulist.value = "0";
+ } else {
+ menulist.value = permission.state;
+ }
+
+ // Avoiding listening to the "select" event on purpose. See Bug 1404262.
+ menulist.addEventListener("command", () => {
+ SitePermissions.setForPrincipal(
+ gBrowser.contentPrincipal,
+ permission.id,
+ menulist.selectedItem.value
+ );
+ });
+
+ container.appendChild(img);
+ container.appendChild(nameLabel);
+ container.appendChild(menulist);
+ container.setAttribute("aria-labelledby", nameLabelId);
+ block.appendChild(container);
+
+ return block;
+ }
+
+ container.appendChild(img);
+ container.appendChild(nameLabel);
+ let labelledBy = nameLabelId;
+
+ let stateLabel;
+ if (showStateLabel) {
+ stateLabel = this._createStateLabel(permission, idNoSuffix);
+ labelledBy += " " + stateLabel.id;
+ }
+
+ container.setAttribute("aria-labelledby", labelledBy);
+
+ /* We return the permission item here without a remove button if the permission is a
+ SCOPE_POLICY or SCOPE_GLOBAL permission. Policy permissions cannot be
+ removed/changed for the duration of the browser session. */
+ if (isPolicyPermission) {
+ if (stateLabel) {
+ container.appendChild(stateLabel);
+ }
+ return container;
+ }
+
+ if (isContainer) {
+ let block = document.createXULElement("vbox");
+ block.setAttribute("id", "permission-popup-" + idNoSuffix + "-container");
+ block.setAttribute("class", "permission-popup-permission-item-container");
+
+ if (permClearButton) {
+ let button = this._createPermissionClearButton({
+ permission,
+ container: block,
+ idNoSuffix,
+ clearCallback,
+ });
+ if (stateLabel) {
+ button.appendChild(stateLabel);
+ }
+ container.appendChild(button);
+ }
+
+ block.appendChild(container);
+ return block;
+ }
+
+ if (permClearButton) {
+ let button = this._createPermissionClearButton({
+ permission,
+ container,
+ idNoSuffix,
+ clearCallback,
+ });
+ if (stateLabel) {
+ button.appendChild(stateLabel);
+ }
+ container.appendChild(button);
+ }
+
+ return container;
+ },
+
+ _createStateLabel(aPermission, idNoSuffix) {
+ let label = document.createXULElement("label");
+ label.setAttribute("class", "permission-popup-permission-state-label");
+ let labelId = `permission-popup-permission-state-label-${idNoSuffix}-${this
+ ._permissionLabelIndex++}`;
+ label.setAttribute("id", labelId);
+ let { state, scope } = aPermission;
+ // If the user did not permanently allow this device but it is currently
+ // used, set the variables to display a "temporarily allowed" info.
+ if (state != SitePermissions.ALLOW && aPermission.sharingState) {
+ state = SitePermissions.ALLOW;
+ scope = SitePermissions.SCOPE_REQUEST;
+ }
+ label.textContent = SitePermissions.getCurrentStateLabel(
+ state,
+ idNoSuffix,
+ scope
+ );
+ return label;
+ },
+
+ _removePermPersistentAllow(principal, id) {
+ let perm = SitePermissions.getForPrincipal(principal, id);
+ if (
+ perm.state == SitePermissions.ALLOW &&
+ perm.scope == SitePermissions.SCOPE_PERSISTENT
+ ) {
+ SitePermissions.removeFromPrincipal(principal, id);
+ }
+ },
+
+ _createPermissionClearButton({
+ permission,
+ container,
+ idNoSuffix = permission.id,
+ clearCallback = () => {},
+ }) {
+ let button = document.createXULElement("button");
+ button.setAttribute("class", "permission-popup-permission-remove-button");
+ let tooltiptext = gNavigatorBundle.getString("permissions.remove.tooltip");
+ button.setAttribute("tooltiptext", tooltiptext);
+ button.addEventListener("command", () => {
+ let browser = gBrowser.selectedBrowser;
+ container.remove();
+ // For XR permissions we need to keep track of all origins which may have
+ // started XR sharing. This is necessary, because XR does not use
+ // permission delegation and permissions can be granted for sub-frames. We
+ // need to keep track of which origins we need to revoke the permission
+ // for.
+ if (permission.sharingState && idNoSuffix === "xr") {
+ let origins = browser.getDevicePermissionOrigins(idNoSuffix);
+ for (let origin of origins) {
+ let principal =
+ Services.scriptSecurityManager.createContentPrincipalFromOrigin(
+ origin
+ );
+ this._removePermPersistentAllow(principal, permission.id);
+ }
+ origins.clear();
+ }
+ SitePermissions.removeFromPrincipal(
+ gBrowser.contentPrincipal,
+ permission.id,
+ browser
+ );
+
+ this._permissionReloadHint.hidden = false;
+
+ if (idNoSuffix === "geo") {
+ gBrowser.updateBrowserSharing(browser, { geo: false });
+ } else if (idNoSuffix === "xr") {
+ gBrowser.updateBrowserSharing(browser, { xr: false });
+ }
+
+ clearCallback();
+ });
+
+ return button;
+ },
+
+ _getGeoLocationLastAccess() {
+ return new Promise(resolve => {
+ let lastAccess = null;
+ ContentPrefService2.getByDomainAndName(
+ gBrowser.currentURI.spec,
+ "permissions.geoLocation.lastAccess",
+ gBrowser.selectedBrowser.loadContext,
+ {
+ handleResult(pref) {
+ lastAccess = pref.value;
+ },
+ handleCompletion() {
+ resolve(lastAccess);
+ },
+ }
+ );
+ });
+ },
+
+ async _createGeoLocationLastAccessIndicator() {
+ let lastAccessStr = await this._getGeoLocationLastAccess();
+ let geoContainer = document.getElementById(
+ "permission-popup-geo-container"
+ );
+
+ // Check whether geoContainer still exists.
+ // We are async, the identity popup could have been closed already.
+ // Also check if it is already populated with a time label.
+ // This can happen if we update the permission panel multiple times in a
+ // short timeframe.
+ if (
+ lastAccessStr == null ||
+ !geoContainer ||
+ document.getElementById("geo-access-indicator-item")
+ ) {
+ return;
+ }
+ let lastAccess = new Date(lastAccessStr);
+ if (isNaN(lastAccess)) {
+ console.error("Invalid timestamp for last geolocation access");
+ return;
+ }
+
+ let indicator = document.createXULElement("hbox");
+ indicator.setAttribute("class", "permission-popup-permission-item");
+ indicator.setAttribute("align", "center");
+ indicator.setAttribute("id", "geo-access-indicator-item");
+
+ let timeFormat = new Services.intl.RelativeTimeFormat(undefined, {});
+
+ let text = document.createXULElement("label");
+ text.setAttribute("flex", "1");
+ text.setAttribute("class", "permission-popup-permission-label");
+
+ text.textContent = gNavigatorBundle.getFormattedString(
+ "geolocationLastAccessIndicatorText",
+ [timeFormat.formatBestUnit(lastAccess)]
+ );
+
+ indicator.appendChild(text);
+
+ geoContainer.appendChild(indicator);
+ },
+
+ /**
+ * Create a permission item for a WebRTC permission. May return null if there
+ * already is a suitable permission item for this device type.
+ * @param {Object} permission - Permission object.
+ * @param {string} id - Permission ID without suffix.
+ * @param {string} [key] - Secondary permission key.
+ * @returns {xul:hbox|null} - Element for permission or null if permission
+ * should be skipped.
+ */
+ _createWebRTCPermissionItem(permission, id, key) {
+ if (!["camera", "screen", "microphone", "speaker"].includes(id)) {
+ throw new Error("Invalid permission id for WebRTC permission item.");
+ }
+ // Only show WebRTC device-specific ALLOW permissions. Since we only show
+ // one permission item per device type, we don't support showing mixed
+ // states where one devices is allowed and another one blocked.
+ if (key && permission.state != SitePermissions.ALLOW) {
+ return null;
+ }
+ // Check if there is already an item for this permission. Multiple
+ // permissions with the same id can be set, but with different keys.
+ let item = document.querySelector(
+ `.permission-popup-permission-item-${id}`
+ );
+
+ if (key) {
+ // We have a double keyed permission. If there is already an item it will
+ // have ownership of all permissions with this WebRTC permission id.
+ if (item) {
+ return null;
+ }
+ } else if (item) {
+ // If we have a single-key (not device specific) webRTC permission it
+ // overrides any existing (device specific) permission items.
+ item.remove();
+ }
+
+ return this._createPermissionItem({
+ permission,
+ idNoSuffix: id,
+ clearCallback: () => {
+ webrtcUI.clearPermissionsAndStopSharing([id], gBrowser.selectedTab);
+ },
+ });
+ },
+
+ _createProtocolHandlerPermissionItem(permission, key) {
+ let container = document.getElementById(
+ "permission-popup-open-protocol-handler-container"
+ );
+ let initialCall;
+
+ if (!container) {
+ // First open-protocol-handler permission, create container.
+ container = this._createPermissionItem({
+ permission,
+ isContainer: true,
+ permClearButton: false,
+ showStateLabel: false,
+ idNoSuffix: "open-protocol-handler",
+ });
+ initialCall = true;
+ }
+
+ let item = document.createXULElement("hbox");
+ item.setAttribute("class", "permission-popup-permission-item");
+ item.setAttribute("align", "center");
+
+ let text = document.createXULElement("label");
+ text.setAttribute("flex", "1");
+ text.setAttribute("class", "permission-popup-permission-label-subitem");
+
+ text.textContent = gNavigatorBundle.getFormattedString(
+ "openProtocolHandlerPermissionEntryLabel",
+ [key]
+ );
+
+ let stateLabel = this._createStateLabel(
+ permission,
+ "open-protocol-handler"
+ );
+
+ item.appendChild(text);
+
+ let button = this._createPermissionClearButton({
+ permission,
+ container: item,
+ clearCallback: () => {
+ // When we're clearing the last open-protocol-handler permission, clean up
+ // the empty container.
+ // (<= 1 because the heading item is also a child of the container)
+ if (container.childElementCount <= 1) {
+ container.remove();
+ }
+ },
+ });
+ button.appendChild(stateLabel);
+ item.appendChild(button);
+
+ container.appendChild(item);
+
+ // If container already exists in permission list, don't return it again.
+ return initialCall && container;
+ },
+
+ _createBlockedPopupIndicator(aTotalBlockedPopups) {
+ let indicator = document.createXULElement("hbox");
+ indicator.setAttribute("class", "permission-popup-permission-item");
+ indicator.setAttribute("align", "center");
+ indicator.setAttribute("id", "blocked-popup-indicator-item");
+
+ MozXULElement.insertFTLIfNeeded("browser/sitePermissions.ftl");
+ let text = document.createXULElement("label", { is: "text-link" });
+ text.setAttribute("class", "permission-popup-permission-label");
+ text.setAttribute("data-l10n-id", "site-permissions-open-blocked-popups");
+ text.setAttribute(
+ "data-l10n-args",
+ JSON.stringify({ count: aTotalBlockedPopups })
+ );
+
+ text.addEventListener("click", () => {
+ gBrowser.selectedBrowser.popupBlocker.unblockAllPopups();
+ });
+
+ indicator.appendChild(text);
+
+ document
+ .getElementById("permission-popup-container")
+ .appendChild(indicator);
+ },
+};
+
+/**
+ * Returns an object containing two booleans: {camGrace, micGrace},
+ * whether permission grace periods are found for camera/microphone AND
+ * persistent permissions do not exist for said permissions.
+ * @param browser - Browser element to get permissions for.
+ */
+function hasMicCamGracePeriodsSolely(browser) {
+ let perms = SitePermissions.getAllForBrowser(browser);
+ let micGrace = false;
+ let micGrant = false;
+ let camGrace = false;
+ let camGrant = false;
+ for (const perm of perms) {
+ if (perm.state != SitePermissions.ALLOW) {
+ continue;
+ }
+ let [id, key] = perm.id.split(SitePermissions.PERM_KEY_DELIMITER);
+ let temporary = !!key && perm.scope == SitePermissions.SCOPE_TEMPORARY;
+ let persistent = !key && perm.scope == SitePermissions.SCOPE_PERSISTENT;
+
+ if (id == "microphone") {
+ if (temporary) {
+ micGrace = true;
+ }
+ if (persistent) {
+ micGrant = true;
+ }
+ continue;
+ }
+ if (id == "camera") {
+ if (temporary) {
+ camGrace = true;
+ }
+ if (persistent) {
+ camGrant = true;
+ }
+ }
+ }
+ return { micGrace: micGrace && !micGrant, camGrace: camGrace && !camGrant };
+}
diff --git a/browser/base/content/browser-siteProtections.js b/browser/base/content/browser-siteProtections.js
new file mode 100644
index 0000000000..7b3b0a12a7
--- /dev/null
+++ b/browser/base/content/browser-siteProtections.js
@@ -0,0 +1,2644 @@
+/* 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/. */
+
+/* eslint-env mozilla/browser-window */
+
+ChromeUtils.defineESModuleGetters(this, {
+ ContentBlockingAllowList:
+ "resource://gre/modules/ContentBlockingAllowList.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ ToolbarPanelHub: "resource://activity-stream/lib/ToolbarPanelHub.jsm",
+});
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "TrackingDBService",
+ "@mozilla.org/tracking-db-service;1",
+ "nsITrackingDBService"
+);
+
+/**
+ * Represents a protection category shown in the protections UI. For the most
+ * common categories we can directly instantiate this category. Some protections
+ * categories inherit from this class and overwrite some of its members.
+ */
+class ProtectionCategory {
+ /**
+ * Creates a protection category.
+ * @param {string} id - Identifier of the category. Used to query the category
+ * UI elements in the DOM.
+ * @param {Object} options - Category options.
+ * @param {string} options.prefEnabled - ID of pref which controls the
+ * category enabled state.
+ * @param {string} [options.reportBreakageLabel] - Telemetry label to use when
+ * users report TP breakage. Defaults to protection ID.
+ * @param {string} [options.l10nId] - Identifier l10n strings are keyed under
+ * for this category. Defaults to protection ID.
+ * @param {Object} flags - Flags for this category to look for in the content
+ * blocking event and content blocking log.
+ * @param {Number} [flags.load] - Load flag for this protection category. If
+ * omitted, we will never match a isAllowing check for this category.
+ * @param {Number} [flags.block] - Block flag for this protection category. If
+ * omitted, we will never match a isBlocking check for this category.
+ * @param {Number} [flags.shim] - Shim flag for this protection category. This
+ * flag is set if we replaced tracking content with a non-tracking shim
+ * script.
+ * @param {Number} [flags.allow] - Allow flag for this protection category.
+ * This flag is set if we explicitly allow normally blocked tracking content.
+ * The webcompat extension can do this if it needs to unblock content on user
+ * opt-in.
+ */
+ constructor(
+ id,
+ { prefEnabled, reportBreakageLabel, l10nId },
+ {
+ load,
+ block,
+ shim = Ci.nsIWebProgressListener.STATE_REPLACED_TRACKING_CONTENT,
+ allow = Ci.nsIWebProgressListener.STATE_ALLOWED_TRACKING_CONTENT,
+ }
+ ) {
+ this._id = id;
+ this.prefEnabled = prefEnabled;
+ this._reportBreakageLabel = reportBreakageLabel || id;
+
+ this._flags = { load, block, shim, allow };
+
+ if (
+ Services.prefs.getPrefType(this.prefEnabled) == Services.prefs.PREF_BOOL
+ ) {
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "_enabled",
+ this.prefEnabled,
+ false,
+ this.updateCategoryItem.bind(this)
+ );
+ }
+
+ l10nId = l10nId || id;
+ this.strings = {};
+ XPCOMUtils.defineLazyGetter(this.strings, "subViewBlocked", () =>
+ gNavigatorBundle.getString(`contentBlocking.${l10nId}View.blocked.label`)
+ );
+ XPCOMUtils.defineLazyGetter(this.strings, "subViewTitleBlocking", () =>
+ gNavigatorBundle.getString(`protections.blocking.${l10nId}.title`)
+ );
+ XPCOMUtils.defineLazyGetter(this.strings, "subViewTitleNotBlocking", () =>
+ gNavigatorBundle.getString(`protections.notBlocking.${l10nId}.title`)
+ );
+
+ XPCOMUtils.defineLazyGetter(this, "subView", () =>
+ document.getElementById(`protections-popup-${this._id}View`)
+ );
+
+ XPCOMUtils.defineLazyGetter(this, "subViewHeading", () =>
+ document.getElementById(`protections-popup-${this._id}View-heading`)
+ );
+
+ XPCOMUtils.defineLazyGetter(this, "subViewList", () =>
+ document.getElementById(`protections-popup-${this._id}View-list`)
+ );
+
+ XPCOMUtils.defineLazyGetter(this, "subViewShimAllowHint", () =>
+ document.getElementById(
+ `protections-popup-${this._id}View-shim-allow-hint`
+ )
+ );
+ }
+
+ // Child classes may override these to do init / teardown. We expect them to
+ // be called when the protections panel is initialized or destroyed.
+ init() {}
+ uninit() {}
+
+ // Some child classes may overide this getter.
+ get enabled() {
+ return this._enabled;
+ }
+
+ get reportBreakageLabel() {
+ return this._reportBreakageLabel;
+ }
+
+ /**
+ * Get the category item associated with this protection from the main
+ * protections panel.
+ * @returns {xul:toolbarbutton|undefined} - Item or undefined if the panel is
+ * not yet initialized.
+ */
+ get categoryItem() {
+ // We don't use defineLazyGetter for the category item, since it may be null
+ // on first access.
+ return (
+ this._categoryItem ||
+ (this._categoryItem = document.getElementById(
+ `protections-popup-category-${this._id}`
+ ))
+ );
+ }
+
+ /**
+ * Defaults to enabled state. May be overridden by child classes.
+ * @returns {boolean} - Whether the protection is set to block trackers.
+ */
+ get blockingEnabled() {
+ return this.enabled;
+ }
+
+ /**
+ * Update the category item state in the main view of the protections panel.
+ * Determines whether the category is set to block trackers.
+ * @returns {boolean} - true if the state has been updated, false if the
+ * protections popup has not been initialized yet.
+ */
+ updateCategoryItem() {
+ // Can't get `this.categoryItem` without the popup. Using the popup instead
+ // of `this.categoryItem` to guard access, because the category item getter
+ // can trigger bug 1543537. If there's no popup, we'll be called again the
+ // first time the popup shows.
+ if (!gProtectionsHandler._protectionsPopup) {
+ return false;
+ }
+ this.categoryItem.classList.toggle("blocked", this.enabled);
+ this.categoryItem.classList.toggle("subviewbutton-nav", this.enabled);
+ return true;
+ }
+
+ /**
+ * Update the category sub view that is shown when users click on the category
+ * button.
+ */
+ async updateSubView() {
+ let { items, anyShimAllowed } = await this._generateSubViewListItems();
+ this.subViewShimAllowHint.hidden = !anyShimAllowed;
+
+ this.subViewList.textContent = "";
+ this.subViewList.append(items);
+ this.subView.setAttribute(
+ "title",
+ this.blockingEnabled && !gProtectionsHandler.hasException
+ ? this.strings.subViewTitleBlocking
+ : this.strings.subViewTitleNotBlocking
+ );
+ }
+
+ /**
+ * Create a list of items, each representing a tracker.
+ * @returns {Object} result - An object containing the results.
+ * @returns {HTMLDivElement[]} result.items - Generated tracker items. May be
+ * empty.
+ * @returns {boolean} result.anyShimAllowed - Flag indicating if any of the
+ * items have been unblocked by a shim script.
+ */
+ async _generateSubViewListItems() {
+ let contentBlockingLog = gBrowser.selectedBrowser.getContentBlockingLog();
+ contentBlockingLog = JSON.parse(contentBlockingLog);
+ let anyShimAllowed = false;
+
+ let fragment = document.createDocumentFragment();
+ for (let [origin, actions] of Object.entries(contentBlockingLog)) {
+ let { item, shimAllowed } = await this._createListItem(origin, actions);
+ if (!item) {
+ continue;
+ }
+ anyShimAllowed = anyShimAllowed || shimAllowed;
+ fragment.appendChild(item);
+ }
+
+ return {
+ items: fragment,
+ anyShimAllowed,
+ };
+ }
+
+ /**
+ * Create a DOM item representing a tracker.
+ * @param {string} origin - Origin of the tracker.
+ * @param {Array} actions - Array of actions from the content blocking log
+ * associated with the tracking origin.
+ * @returns {Object} result - An object containing the results.
+ * @returns {HTMLDListElement} [options.item] - Generated item or null if we
+ * don't have an item for this origin based on the actions log.
+ * @returns {boolean} options.shimAllowed - Flag indicating whether the
+ * tracking origin was allowed by a shim script.
+ */
+ _createListItem(origin, actions) {
+ let isAllowed = actions.some(
+ ([state]) => this.isAllowing(state) && !this.isShimming(state)
+ );
+ let isDetected =
+ isAllowed || actions.some(([state]) => this.isBlocking(state));
+
+ if (!isDetected) {
+ return {};
+ }
+
+ // Create an item to hold the origin label and shim allow indicator. Using
+ // an html element here, so we can use CSS flex, which handles the label
+ // overflow in combination with the icon correctly.
+ let listItem = document.createElementNS(
+ "http://www.w3.org/1999/xhtml",
+ "div"
+ );
+ listItem.className = "protections-popup-list-item";
+ listItem.classList.toggle("allowed", isAllowed);
+
+ let label = document.createXULElement("label");
+ // Repeat the host in the tooltip in case it's too long
+ // and overflows in our panel.
+ label.tooltipText = origin;
+ label.value = origin;
+ label.className = "protections-popup-list-host-label";
+ label.setAttribute("crop", "end");
+ listItem.append(label);
+
+ // Determine whether we should show a shim-allow indicator for this item.
+ let shimAllowed = actions.some(([flag]) => flag == this._flags.allow);
+ if (shimAllowed) {
+ listItem.append(this._getShimAllowIndicator());
+ }
+
+ return { item: listItem, shimAllowed };
+ }
+
+ /**
+ * Create an indicator icon for marking origins that have been allowed by a
+ * shim script.
+ * @returns {HTMLImageElement} - Created element.
+ */
+ _getShimAllowIndicator() {
+ let allowIndicator = document.createXULElement("image");
+ document.l10n.setAttributes(
+ allowIndicator,
+ "protections-panel-shim-allowed-indicator"
+ );
+ allowIndicator.tooltipText = this.strings.subViewShimAllowedTooltip;
+ allowIndicator.classList.add(
+ "protections-popup-list-host-shim-allow-indicator"
+ );
+ return allowIndicator;
+ }
+
+ /**
+ * @param {Number} state - Content blocking event flags.
+ * @returns {boolean} - Whether the protection has blocked a tracker.
+ */
+ isBlocking(state) {
+ return (state & this._flags.block) != 0;
+ }
+
+ /**
+ * @param {Number} state - Content blocking event flags.
+ * @returns {boolean} - Whether the protection has allowed a tracker.
+ */
+ isAllowing(state) {
+ return (state & this._flags.load) != 0;
+ }
+
+ /**
+ * @param {Number} state - Content blocking event flags.
+ * @returns {boolean} - Whether the protection has detected (blocked or
+ * allowed) a tracker.
+ */
+ isDetected(state) {
+ return this.isBlocking(state) || this.isAllowing(state);
+ }
+
+ /**
+ * @param {Number} state - Content blocking event flags.
+ * @returns {boolean} - Whether the protections has allowed a tracker that
+ * would have normally been blocked.
+ */
+ isShimming(state) {
+ return (state & this._flags.shim) != 0 && this.isAllowing(state);
+ }
+}
+
+let Fingerprinting = new ProtectionCategory(
+ "fingerprinters",
+ {
+ prefEnabled: "privacy.trackingprotection.fingerprinting.enabled",
+ reportBreakageLabel: "fingerprinting",
+ },
+ {
+ load: Ci.nsIWebProgressListener.STATE_LOADED_FINGERPRINTING_CONTENT,
+ block: Ci.nsIWebProgressListener.STATE_BLOCKED_FINGERPRINTING_CONTENT,
+ shim: Ci.nsIWebProgressListener.STATE_REPLACED_FINGERPRINTING_CONTENT,
+ allow: Ci.nsIWebProgressListener.STATE_ALLOWED_FINGERPRINTING_CONTENT,
+ }
+);
+
+let Cryptomining = new ProtectionCategory(
+ "cryptominers",
+ {
+ prefEnabled: "privacy.trackingprotection.cryptomining.enabled",
+ reportBreakageLabel: "cryptomining",
+ },
+ {
+ load: Ci.nsIWebProgressListener.STATE_LOADED_CRYPTOMINING_CONTENT,
+ block: Ci.nsIWebProgressListener.STATE_BLOCKED_CRYPTOMINING_CONTENT,
+ }
+);
+
+let TrackingProtection =
+ new (class TrackingProtection extends ProtectionCategory {
+ constructor() {
+ super(
+ "trackers",
+ {
+ l10nId: "trackingContent",
+ prefEnabled: "privacy.trackingprotection.enabled",
+ reportBreakageLabel: "trackingprotection",
+ },
+ {
+ load: null,
+ block:
+ Ci.nsIWebProgressListener.STATE_BLOCKED_TRACKING_CONTENT |
+ Ci.nsIWebProgressListener.STATE_BLOCKED_EMAILTRACKING_CONTENT,
+ }
+ );
+
+ // Blocked label has custom key, overwrite the getter.
+ XPCOMUtils.defineLazyGetter(this.strings, "subViewBlocked", () =>
+ gNavigatorBundle.getString("contentBlocking.trackersView.blocked.label")
+ );
+
+ this.prefEnabledInPrivateWindows =
+ "privacy.trackingprotection.pbmode.enabled";
+ this.prefTrackingTable = "urlclassifier.trackingTable";
+ this.prefTrackingAnnotationTable =
+ "urlclassifier.trackingAnnotationTable";
+ this.prefAnnotationsLevel2Enabled =
+ "privacy.annotate_channels.strict_list.enabled";
+ this.prefEmailTrackingProtectionEnabled =
+ "privacy.trackingprotection.emailtracking.enabled";
+ this.prefEmailTrackingProtectionEnabledInPrivateWindows =
+ "privacy.trackingprotection.emailtracking.pbmode.enabled";
+
+ this.enabledGlobally = false;
+ this.emailTrackingProtectionEnabledGlobally = false;
+
+ this.enabledInPrivateWindows = false;
+ this.emailTrackingProtectionEnabledInPrivateWindows = false;
+
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "trackingTable",
+ this.prefTrackingTable,
+ ""
+ );
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "trackingAnnotationTable",
+ this.prefTrackingAnnotationTable,
+ ""
+ );
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "annotationsLevel2Enabled",
+ this.prefAnnotationsLevel2Enabled,
+ false
+ );
+ }
+
+ init() {
+ this.updateEnabled();
+
+ Services.prefs.addObserver(this.prefEnabled, this);
+ Services.prefs.addObserver(this.prefEnabledInPrivateWindows, this);
+ Services.prefs.addObserver(this.prefEmailTrackingProtectionEnabled, this);
+ Services.prefs.addObserver(
+ this.prefEmailTrackingProtectionEnabledInPrivateWindows,
+ this
+ );
+ }
+
+ uninit() {
+ Services.prefs.removeObserver(this.prefEnabled, this);
+ Services.prefs.removeObserver(this.prefEnabledInPrivateWindows, this);
+ Services.prefs.removeObserver(
+ this.prefEmailTrackingProtectionEnabled,
+ this
+ );
+ Services.prefs.removeObserver(
+ this.prefEmailTrackingProtectionEnabledInPrivateWindows,
+ this
+ );
+ }
+
+ observe() {
+ this.updateEnabled();
+ this.updateCategoryItem();
+ }
+
+ get trackingProtectionLevel2Enabled() {
+ const CONTENT_TABLE = "content-track-digest256";
+ return this.trackingTable.includes(CONTENT_TABLE);
+ }
+
+ get enabled() {
+ return (
+ this.enabledGlobally ||
+ this.emailTrackingProtectionEnabledGlobally ||
+ (PrivateBrowsingUtils.isWindowPrivate(window) &&
+ (this.enabledInPrivateWindows ||
+ this.emailTrackingProtectionEnabledInPrivateWindows))
+ );
+ }
+
+ updateEnabled() {
+ this.enabledGlobally = Services.prefs.getBoolPref(this.prefEnabled);
+ this.enabledInPrivateWindows = Services.prefs.getBoolPref(
+ this.prefEnabledInPrivateWindows
+ );
+ this.emailTrackingProtectionEnabledGlobally = Services.prefs.getBoolPref(
+ this.prefEmailTrackingProtectionEnabled
+ );
+ this.emailTrackingProtectionEnabledInPrivateWindows =
+ Services.prefs.getBoolPref(
+ this.prefEmailTrackingProtectionEnabledInPrivateWindows
+ );
+ }
+
+ isAllowingLevel1(state) {
+ return (
+ (state &
+ Ci.nsIWebProgressListener.STATE_LOADED_LEVEL_1_TRACKING_CONTENT) !=
+ 0
+ );
+ }
+
+ isAllowingLevel2(state) {
+ return (
+ (state &
+ Ci.nsIWebProgressListener.STATE_LOADED_LEVEL_2_TRACKING_CONTENT) !=
+ 0
+ );
+ }
+
+ isAllowing(state) {
+ return this.isAllowingLevel1(state) || this.isAllowingLevel2(state);
+ }
+
+ async updateSubView() {
+ let previousURI = gBrowser.currentURI.spec;
+ let previousWindow = gBrowser.selectedBrowser.innerWindowID;
+
+ let { items, anyShimAllowed } = await this._generateSubViewListItems();
+
+ // If we don't have trackers we would usually not show the menu item
+ // allowing the user to show the sub-panel. However, in the edge case
+ // that we annotated trackers on the page using the strict list but did
+ // not detect trackers on the page using the basic list, we currently
+ // still show the panel. To reduce the confusion, tell the user that we have
+ // not detected any tracker.
+ if (!items.childNodes.length) {
+ let emptyImage = document.createXULElement("image");
+ emptyImage.classList.add("protections-popup-trackersView-empty-image");
+ emptyImage.classList.add("trackers-icon");
+
+ let emptyLabel = document.createXULElement("label");
+ emptyLabel.classList.add("protections-popup-empty-label");
+ emptyLabel.textContent = gNavigatorBundle.getString(
+ "contentBlocking.trackersView.empty.label"
+ );
+
+ items.appendChild(emptyImage);
+ items.appendChild(emptyLabel);
+
+ this.subViewList.classList.add("empty");
+ } else {
+ this.subViewList.classList.remove("empty");
+ }
+
+ // This might have taken a while. Only update the list if we're still on the same page.
+ if (
+ previousURI == gBrowser.currentURI.spec &&
+ previousWindow == gBrowser.selectedBrowser.innerWindowID
+ ) {
+ this.subViewShimAllowHint.hidden = !anyShimAllowed;
+
+ this.subViewList.textContent = "";
+ this.subViewList.append(items);
+ this.subView.setAttribute(
+ "title",
+ this.enabled && !gProtectionsHandler.hasException
+ ? this.strings.subViewTitleBlocking
+ : this.strings.subViewTitleNotBlocking
+ );
+ }
+ }
+
+ async _createListItem(origin, actions) {
+ // Figure out if this list entry was actually detected by TP or something else.
+ let isAllowed = actions.some(
+ ([state]) => this.isAllowing(state) && !this.isShimming(state)
+ );
+ let isDetected =
+ isAllowed || actions.some(([state]) => this.isBlocking(state));
+
+ if (!isDetected) {
+ return {};
+ }
+
+ // Because we might use different lists for annotation vs. blocking, we
+ // need to make sure that this is a tracker that we would actually have blocked
+ // before showing it to the user.
+ if (
+ this.annotationsLevel2Enabled &&
+ !this.trackingProtectionLevel2Enabled &&
+ actions.some(
+ ([state]) =>
+ (state &
+ Ci.nsIWebProgressListener
+ .STATE_LOADED_LEVEL_2_TRACKING_CONTENT) !=
+ 0
+ )
+ ) {
+ return {};
+ }
+
+ let listItem = document.createElementNS(
+ "http://www.w3.org/1999/xhtml",
+ "div"
+ );
+ listItem.className = "protections-popup-list-item";
+ listItem.classList.toggle("allowed", isAllowed);
+
+ let label = document.createXULElement("label");
+ // Repeat the host in the tooltip in case it's too long
+ // and overflows in our panel.
+ label.tooltipText = origin;
+ label.value = origin;
+ label.className = "protections-popup-list-host-label";
+ label.setAttribute("crop", "end");
+ listItem.append(label);
+
+ let shimAllowed = actions.some(([flag]) => flag == this._flags.allow);
+ if (shimAllowed) {
+ listItem.append(this._getShimAllowIndicator());
+ }
+
+ return { item: listItem, shimAllowed };
+ }
+ })();
+
+let ThirdPartyCookies =
+ new (class ThirdPartyCookies extends ProtectionCategory {
+ constructor() {
+ super(
+ "cookies",
+ {
+ // This would normally expect a boolean pref. However, this category
+ // overwrites the enabled getter for custom handling of cookie behavior
+ // states.
+ prefEnabled: "network.cookie.cookieBehavior",
+ },
+ {
+ // ThirdPartyCookies implements custom flag processing.
+ allow: null,
+ shim: null,
+ load: null,
+ block: null,
+ }
+ );
+
+ XPCOMUtils.defineLazyGetter(this, "categoryLabel", () =>
+ document.getElementById("protections-popup-cookies-category-label")
+ );
+
+ // Not blocking title has custom key, overwrite the getter.
+ XPCOMUtils.defineLazyGetter(this.strings, "subViewTitleNotBlocking", () =>
+ gNavigatorBundle.getString(
+ "protections.notBlocking.crossSiteTrackingCookies.title"
+ )
+ );
+
+ // Cookie permission state label.
+ XPCOMUtils.defineLazyGetter(this.strings, "subViewAllowed", () =>
+ gNavigatorBundle.getString("contentBlocking.cookiesView.allowed.label")
+ );
+
+ this.prefEnabledValues = [
+ // These values match the ones exposed under the Content Blocking section
+ // of the Preferences UI.
+ Ci.nsICookieService.BEHAVIOR_REJECT_FOREIGN, // Block all third-party cookies
+ Ci.nsICookieService.BEHAVIOR_REJECT_TRACKER, // Block third-party cookies from trackers
+ Ci.nsICookieService.BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN, // Block trackers and patition third-party trackers
+ Ci.nsICookieService.BEHAVIOR_REJECT, // Block all cookies
+ ];
+
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "behaviorPref",
+ this.prefEnabled,
+ Ci.nsICookieService.BEHAVIOR_ACCEPT,
+ this.updateCategoryItem.bind(this)
+ );
+ }
+
+ get reportBreakageLabel() {
+ switch (this.behaviorPref) {
+ case Ci.nsICookieService.BEHAVIOR_ACCEPT:
+ return "nocookiesblocked";
+ case Ci.nsICookieService.BEHAVIOR_REJECT_FOREIGN:
+ return "allthirdpartycookiesblocked";
+ case Ci.nsICookieService.BEHAVIOR_REJECT:
+ return "allcookiesblocked";
+ case Ci.nsICookieService.BEHAVIOR_LIMIT_FOREIGN:
+ return "cookiesfromunvisitedsitesblocked";
+ default:
+ console.error(
+ `Error: Unknown cookieBehavior pref observed: ${this.behaviorPref}`
+ );
+ // fall through
+ case Ci.nsICookieService.BEHAVIOR_REJECT_TRACKER:
+ return "cookierestrictions";
+ case Ci.nsICookieService.BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN:
+ return "cookierestrictionsforeignpartitioned";
+ }
+ }
+
+ isBlocking(state) {
+ return (
+ (state & Ci.nsIWebProgressListener.STATE_COOKIES_BLOCKED_TRACKER) !=
+ 0 ||
+ (state &
+ Ci.nsIWebProgressListener.STATE_COOKIES_BLOCKED_SOCIALTRACKER) !=
+ 0 ||
+ (state & Ci.nsIWebProgressListener.STATE_COOKIES_BLOCKED_ALL) != 0 ||
+ (state &
+ Ci.nsIWebProgressListener.STATE_COOKIES_BLOCKED_BY_PERMISSION) !=
+ 0 ||
+ (state & Ci.nsIWebProgressListener.STATE_COOKIES_BLOCKED_FOREIGN) != 0
+ );
+ }
+
+ isDetected(state) {
+ if (this.isBlocking(state)) {
+ return true;
+ }
+
+ if (
+ [
+ Ci.nsICookieService.BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN,
+ Ci.nsICookieService.BEHAVIOR_REJECT_TRACKER,
+ Ci.nsICookieService.BEHAVIOR_ACCEPT,
+ ].includes(this.behaviorPref)
+ ) {
+ return (
+ (state & Ci.nsIWebProgressListener.STATE_COOKIES_LOADED_TRACKER) !=
+ 0 ||
+ (SocialTracking.enabled &&
+ (state &
+ Ci.nsIWebProgressListener.STATE_COOKIES_LOADED_SOCIALTRACKER) !=
+ 0)
+ );
+ }
+
+ // We don't have specific flags for the other cookie behaviors so just
+ // fall back to STATE_COOKIES_LOADED.
+ return (state & Ci.nsIWebProgressListener.STATE_COOKIES_LOADED) != 0;
+ }
+
+ updateCategoryItem() {
+ if (!super.updateCategoryItem()) {
+ return;
+ }
+ let label;
+
+ if (!this.enabled) {
+ label = "contentBlocking.cookies.blockingTrackers3.label";
+ } else {
+ switch (this.behaviorPref) {
+ case Ci.nsICookieService.BEHAVIOR_REJECT_FOREIGN:
+ label = "contentBlocking.cookies.blocking3rdParty2.label";
+ break;
+ case Ci.nsICookieService.BEHAVIOR_REJECT:
+ label = "contentBlocking.cookies.blockingAll2.label";
+ break;
+ case Ci.nsICookieService.BEHAVIOR_LIMIT_FOREIGN:
+ label = "contentBlocking.cookies.blockingUnvisited2.label";
+ break;
+ case Ci.nsICookieService.BEHAVIOR_REJECT_TRACKER:
+ case Ci.nsICookieService
+ .BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN:
+ label = "contentBlocking.cookies.blockingTrackers3.label";
+ break;
+ default:
+ console.error(
+ `Error: Unknown cookieBehavior pref observed: ${this.behaviorPref}`
+ );
+ break;
+ }
+ }
+ this.categoryLabel.textContent = label
+ ? gNavigatorBundle.getString(label)
+ : "";
+ }
+
+ get enabled() {
+ return this.prefEnabledValues.includes(this.behaviorPref);
+ }
+
+ updateSubView() {
+ let contentBlockingLog = gBrowser.selectedBrowser.getContentBlockingLog();
+ contentBlockingLog = JSON.parse(contentBlockingLog);
+
+ let categories = this._processContentBlockingLog(contentBlockingLog);
+
+ this.subViewList.textContent = "";
+
+ let categoryNames = ["trackers"];
+ switch (this.behaviorPref) {
+ case Ci.nsICookieService.BEHAVIOR_REJECT:
+ categoryNames.push("firstParty");
+ // eslint-disable-next-line no-fallthrough
+ case Ci.nsICookieService.BEHAVIOR_REJECT_FOREIGN:
+ categoryNames.push("thirdParty");
+ }
+
+ for (let category of categoryNames) {
+ let itemsToShow = categories[category];
+
+ if (!itemsToShow.length) {
+ continue;
+ }
+
+ let box = document.createXULElement("vbox");
+ box.className = "protections-popup-cookiesView-list-section";
+ let label = document.createXULElement("label");
+ label.className = "protections-popup-cookiesView-list-header";
+ label.textContent = gNavigatorBundle.getString(
+ `contentBlocking.cookiesView.${
+ category == "trackers" ? "trackers2" : category
+ }.label`
+ );
+ box.appendChild(label);
+
+ for (let info of itemsToShow) {
+ box.appendChild(this._createListItem(info));
+ }
+
+ this.subViewList.appendChild(box);
+ }
+
+ this.subViewHeading.hidden = false;
+ if (!this.enabled) {
+ this.subView.setAttribute(
+ "title",
+ this.strings.subViewTitleNotBlocking
+ );
+ return;
+ }
+
+ let title;
+ let siteException = gProtectionsHandler.hasException;
+ let titleStringPrefix = `protections.${
+ siteException ? "notBlocking" : "blocking"
+ }.cookies.`;
+ switch (this.behaviorPref) {
+ case Ci.nsICookieService.BEHAVIOR_REJECT_FOREIGN:
+ title = titleStringPrefix + "3rdParty.title";
+ this.subViewHeading.hidden = true;
+ if (this.subViewHeading.nextSibling.nodeName == "toolbarseparator") {
+ this.subViewHeading.nextSibling.hidden = true;
+ }
+ break;
+ case Ci.nsICookieService.BEHAVIOR_REJECT:
+ title = titleStringPrefix + "all.title";
+ this.subViewHeading.hidden = true;
+ if (this.subViewHeading.nextSibling.nodeName == "toolbarseparator") {
+ this.subViewHeading.nextSibling.hidden = true;
+ }
+ break;
+ case Ci.nsICookieService.BEHAVIOR_LIMIT_FOREIGN:
+ title = "protections.blocking.cookies.unvisited.title";
+ this.subViewHeading.hidden = true;
+ if (this.subViewHeading.nextSibling.nodeName == "toolbarseparator") {
+ this.subViewHeading.nextSibling.hidden = true;
+ }
+ break;
+ case Ci.nsICookieService.BEHAVIOR_REJECT_TRACKER:
+ case Ci.nsICookieService.BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN:
+ title = siteException
+ ? "protections.notBlocking.crossSiteTrackingCookies.title"
+ : "protections.blocking.cookies.trackers.title";
+ break;
+ default:
+ console.error(
+ `Error: Unknown cookieBehavior pref when updating subview: ${this.behaviorPref}`
+ );
+ break;
+ }
+
+ this.subView.setAttribute("title", gNavigatorBundle.getString(title));
+ }
+
+ _getExceptionState(origin) {
+ let thirdPartyStorage = Services.perms.testPermissionFromPrincipal(
+ gBrowser.contentPrincipal,
+ "3rdPartyStorage^" + origin
+ );
+
+ if (thirdPartyStorage != Services.perms.UNKNOWN_ACTION) {
+ return thirdPartyStorage;
+ }
+
+ let principal =
+ Services.scriptSecurityManager.createContentPrincipalFromOrigin(origin);
+ // Cookie exceptions get "inherited" from parent- to sub-domain, so we need to
+ // make sure to include parent domains in the permission check for "cookie".
+ return Services.perms.testPermissionFromPrincipal(principal, "cookie");
+ }
+
+ _clearException(origin) {
+ for (let perm of Services.perms.getAllForPrincipal(
+ gBrowser.contentPrincipal
+ )) {
+ if (perm.type == "3rdPartyStorage^" + origin) {
+ Services.perms.removePermission(perm);
+ }
+ }
+
+ // OAs don't matter here, so we can just use the hostname.
+ let host = Services.io.newURI(origin).host;
+
+ // Cookie exceptions get "inherited" from parent- to sub-domain, so we need to
+ // clear any cookie permissions from parent domains as well.
+ for (let perm of Services.perms.all) {
+ if (
+ perm.type == "cookie" &&
+ Services.eTLD.hasRootDomain(host, perm.principal.host)
+ ) {
+ Services.perms.removePermission(perm);
+ }
+ }
+ }
+
+ // Transforms and filters cookie entries in the content blocking log
+ // so that we can categorize and display them in the UI.
+ _processContentBlockingLog(log) {
+ let newLog = {
+ firstParty: [],
+ trackers: [],
+ thirdParty: [],
+ };
+
+ let firstPartyDomain = null;
+ try {
+ firstPartyDomain = Services.eTLD.getBaseDomain(gBrowser.currentURI);
+ } catch (e) {
+ // There are nasty edge cases here where someone is trying to set a cookie
+ // on a public suffix or an IP address. Just categorize those as third party...
+ if (
+ e.result != Cr.NS_ERROR_HOST_IS_IP_ADDRESS &&
+ e.result != Cr.NS_ERROR_INSUFFICIENT_DOMAIN_LEVELS
+ ) {
+ throw e;
+ }
+ }
+
+ for (let [origin, actions] of Object.entries(log)) {
+ if (!origin.startsWith("http")) {
+ continue;
+ }
+
+ let info = {
+ origin,
+ isAllowed: true,
+ exceptionState: this._getExceptionState(origin),
+ };
+ let hasCookie = false;
+ let isTracker = false;
+
+ // Extract information from the states entries in the content blocking log.
+ // Each state will contain a single state flag from nsIWebProgressListener.
+ // Note that we are using the same helper functions that are applied to the
+ // bit map passed to onSecurityChange (which contains multiple states), thus
+ // not checking exact equality, just presence of bits.
+ for (let [state, blocked] of actions) {
+ if (this.isDetected(state)) {
+ hasCookie = true;
+ }
+ if (TrackingProtection.isAllowing(state)) {
+ isTracker = true;
+ }
+ // blocked tells us whether the resource was actually blocked
+ // (which it may not be in case of an exception).
+ if (this.isBlocking(state)) {
+ info.isAllowed = !blocked;
+ }
+ }
+
+ if (!hasCookie) {
+ continue;
+ }
+
+ let isFirstParty = false;
+ try {
+ let uri = Services.io.newURI(origin);
+ isFirstParty = Services.eTLD.getBaseDomain(uri) == firstPartyDomain;
+ } catch (e) {
+ if (
+ e.result != Cr.NS_ERROR_HOST_IS_IP_ADDRESS &&
+ e.result != Cr.NS_ERROR_INSUFFICIENT_DOMAIN_LEVELS
+ ) {
+ throw e;
+ }
+ }
+
+ if (isFirstParty) {
+ newLog.firstParty.push(info);
+ } else if (isTracker) {
+ newLog.trackers.push(info);
+ } else {
+ newLog.thirdParty.push(info);
+ }
+ }
+
+ return newLog;
+ }
+
+ _createListItem({ origin, isAllowed, exceptionState }) {
+ let listItem = document.createElementNS(
+ "http://www.w3.org/1999/xhtml",
+ "div"
+ );
+ listItem.className = "protections-popup-list-item";
+ // Repeat the origin in the tooltip in case it's too long
+ // and overflows in our panel.
+ listItem.tooltipText = origin;
+
+ let label = document.createXULElement("label");
+ label.value = origin;
+ label.className = "protections-popup-list-host-label";
+ label.setAttribute("crop", "end");
+ listItem.append(label);
+
+ if (
+ (isAllowed && exceptionState == Services.perms.ALLOW_ACTION) ||
+ (!isAllowed && exceptionState == Services.perms.DENY_ACTION)
+ ) {
+ listItem.classList.add("protections-popup-list-item-with-state");
+
+ let stateLabel = document.createXULElement("label");
+ stateLabel.className = "protections-popup-list-state-label";
+ if (isAllowed) {
+ stateLabel.value = this.strings.subViewAllowed;
+ listItem.classList.toggle("allowed", true);
+ } else {
+ stateLabel.value = this.strings.subViewBlocked;
+ }
+
+ let removeException = document.createXULElement("button");
+ removeException.className = "permission-popup-permission-remove-button";
+ removeException.tooltipText = gNavigatorBundle.getFormattedString(
+ "contentBlocking.cookiesView.removeButton.tooltip",
+ [origin]
+ );
+ removeException.appendChild(stateLabel);
+
+ removeException.addEventListener(
+ "click",
+ () => {
+ this._clearException(origin);
+ removeException.remove();
+ listItem.classList.toggle("allowed", !isAllowed);
+ },
+ { once: true }
+ );
+ listItem.append(removeException);
+ }
+
+ return listItem;
+ }
+ })();
+
+let SocialTracking =
+ new (class SocialTrackingProtection extends ProtectionCategory {
+ constructor() {
+ super(
+ "socialblock",
+ {
+ l10nId: "socialMediaTrackers",
+ prefEnabled: "privacy.socialtracking.block_cookies.enabled",
+ reportBreakageLabel: "socialtracking",
+ },
+ {
+ load: Ci.nsIWebProgressListener.STATE_LOADED_SOCIALTRACKING_CONTENT,
+ block: Ci.nsIWebProgressListener.STATE_BLOCKED_SOCIALTRACKING_CONTENT,
+ }
+ );
+
+ this.prefStpTpEnabled =
+ "privacy.trackingprotection.socialtracking.enabled";
+ this.prefSTPCookieEnabled = this.prefEnabled;
+ this.prefCookieBehavior = "network.cookie.cookieBehavior";
+
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "socialTrackingProtectionEnabled",
+ this.prefStpTpEnabled,
+ false,
+ this.updateCategoryItem.bind(this)
+ );
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "rejectTrackingCookies",
+ this.prefCookieBehavior,
+ null,
+ this.updateCategoryItem.bind(this),
+ val =>
+ [
+ Ci.nsICookieService.BEHAVIOR_REJECT_TRACKER,
+ Ci.nsICookieService.BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN,
+ ].includes(val)
+ );
+ }
+
+ get blockingEnabled() {
+ return (
+ (this.socialTrackingProtectionEnabled || this.rejectTrackingCookies) &&
+ this.enabled
+ );
+ }
+
+ isBlockingCookies(state) {
+ return (
+ (state &
+ Ci.nsIWebProgressListener.STATE_COOKIES_BLOCKED_SOCIALTRACKER) !=
+ 0
+ );
+ }
+
+ isBlocking(state) {
+ return super.isBlocking(state) || this.isBlockingCookies(state);
+ }
+
+ isAllowing(state) {
+ if (this.socialTrackingProtectionEnabled) {
+ return super.isAllowing(state);
+ }
+
+ return (
+ (state &
+ Ci.nsIWebProgressListener.STATE_COOKIES_LOADED_SOCIALTRACKER) !=
+ 0
+ );
+ }
+
+ updateCategoryItem() {
+ // Can't get `this.categoryItem` without the popup. Using the popup instead
+ // of `this.categoryItem` to guard access, because the category item getter
+ // can trigger bug 1543537. If there's no popup, we'll be called again the
+ // first time the popup shows.
+ if (!gProtectionsHandler._protectionsPopup) {
+ return;
+ }
+ if (this.enabled) {
+ this.categoryItem.removeAttribute("uidisabled");
+ } else {
+ this.categoryItem.setAttribute("uidisabled", true);
+ }
+ this.categoryItem.classList.toggle("blocked", this.blockingEnabled);
+ }
+ })();
+
+/**
+ * Singleton to manage the cookie banner feature section in the protections
+ * panel and the cookie banner handling subview.
+ */
+let cookieBannerHandling = new (class {
+ // Check if this is a private window. We don't expect PBM state to change
+ // during the lifetime of this window.
+ #isPrivateBrowsing = PrivateBrowsingUtils.isWindowPrivate(window);
+
+ constructor() {
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "_serviceModePref",
+ "cookiebanners.service.mode",
+ Ci.nsICookieBannerService.MODE_DISABLED
+ );
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "_serviceModePrefPrivateBrowsing",
+ "cookiebanners.service.mode.privateBrowsing",
+ Ci.nsICookieBannerService.MODE_DISABLED
+ );
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "_serviceDetectOnly",
+ "cookiebanners.service.detectOnly",
+ false
+ );
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "_uiEnabled",
+ "cookiebanners.ui.desktop.enabled",
+ false
+ );
+ XPCOMUtils.defineLazyGetter(this, "_cookieBannerSection", () =>
+ document.getElementById("protections-popup-cookie-banner-section")
+ );
+ XPCOMUtils.defineLazyGetter(this, "_cookieBannerSectionSeparator", () =>
+ document.getElementById(
+ "protections-popup-cookie-banner-section-separator"
+ )
+ );
+ XPCOMUtils.defineLazyGetter(this, "_cookieBannerSwitch", () =>
+ document.getElementById("protections-popup-cookie-banner-switch")
+ );
+ XPCOMUtils.defineLazyGetter(this, "_cookieBannerSubview", () =>
+ document.getElementById("protections-popup-cookieBannerView")
+ );
+ XPCOMUtils.defineLazyGetter(this, "_cookieBannerEnableSite", () =>
+ document.getElementById("cookieBannerView-enable-site")
+ );
+ XPCOMUtils.defineLazyGetter(this, "_cookieBannerDisableSite", () =>
+ document.getElementById("cookieBannerView-disable-site")
+ );
+ }
+
+ /**
+ * Tests if the current site has a user-created exception from the default
+ * cookie banner handling mode. Currently that means the feature is disabled
+ * for the current site.
+ *
+ * Note: bug 1790688 will move this mode handling logic into the
+ * nsCookieBannerService.
+ *
+ * @returns {boolean} - true if the user has manually created an exception.
+ */
+ get #hasException() {
+ // If the CBH feature is preffed off, we can't have an exception.
+ if (!Services.cookieBanners.isEnabled) {
+ return false;
+ }
+
+ // URLs containing IP addresses are not supported by the CBH service, and
+ // will throw. In this case, users can't create an exception, so initialize
+ // `pref` to the default value returned by `getDomainPref`.
+ let pref = Ci.nsICookieBannerService.MODE_UNSET;
+ try {
+ pref = Services.cookieBanners.getDomainPref(
+ gBrowser.currentURI,
+ this.#isPrivateBrowsing
+ );
+ } catch (ex) {
+ console.error(
+ "Cookie Banner Handling error checking for per-site exceptions: ",
+ ex
+ );
+ }
+ return pref == Ci.nsICookieBannerService.MODE_DISABLED;
+ }
+
+ /**
+ * Tests if the cookie banner handling code supports the current site.
+ *
+ * See nsICookieBannerService.hasRuleForBrowsingContextTree for details.
+ *
+ * @returns {boolean} - true if the base domain is in the list of rules.
+ */
+ get isSiteSupported() {
+ return (
+ Services.cookieBanners.isEnabled &&
+ Services.cookieBanners.hasRuleForBrowsingContextTree(
+ gBrowser.selectedBrowser.browsingContext
+ )
+ );
+ }
+
+ /*
+ * @returns {string} - Base domain (eTLD + 1) used for clearing site data.
+ */
+ get #currentBaseDomain() {
+ return gBrowser.contentPrincipal.baseDomain;
+ }
+
+ /**
+ * Helper method used by both updateSection and updateSubView to map internal
+ * state to UI attribute state. We have to separately set the subview's state
+ * because the subview is not a descendant of the menu item in the DOM, and
+ * we rely on CSS to toggle UI visibility based on attribute state.
+ *
+ * @returns A string value to be set as a UI attribute value.
+ */
+ get #uiState() {
+ if (this.#hasException) {
+ return "site-disabled";
+ } else if (this.isSiteSupported) {
+ return "detected";
+ }
+ return "undetected";
+ }
+
+ updateSection() {
+ let showSection = this.#shouldShowSection();
+ let state = this.#uiState;
+
+ for (let el of [
+ this._cookieBannerSection,
+ this._cookieBannerSectionSeparator,
+ ]) {
+ el.hidden = !showSection;
+ }
+
+ this._cookieBannerSection.dataset.state = state;
+
+ // On unsupported sites, disable button styling and click behavior.
+ // Note: to be replaced with a "please support site" subview in bug 1801971.
+ if (state == "undetected") {
+ this._cookieBannerSection.setAttribute("disabled", true);
+ this._cookieBannerSwitch.classList.remove("subviewbutton-nav");
+ this._cookieBannerSwitch.setAttribute("disabled", true);
+ } else {
+ this._cookieBannerSection.removeAttribute("disabled");
+ this._cookieBannerSwitch.classList.add("subviewbutton-nav");
+ this._cookieBannerSwitch.removeAttribute("disabled");
+ }
+ }
+
+ #shouldShowSection() {
+ // Don't show UI if globally disabled by pref, or if the cookie service
+ // is in detect-only mode.
+ if (!this._uiEnabled || this._serviceDetectOnly) {
+ return false;
+ }
+
+ // Show the section if the feature is not in disabled mode, being sure to
+ // check the different prefs for regular and private windows.
+ if (this.#isPrivateBrowsing) {
+ return (
+ this._serviceModePrefPrivateBrowsing !=
+ Ci.nsICookieBannerService.MODE_DISABLED
+ );
+ }
+ return this._serviceModePref != Ci.nsICookieBannerService.MODE_DISABLED;
+ }
+
+ /*
+ * Updates the cookie banner handling subview just before it's shown.
+ */
+ updateSubView() {
+ this._cookieBannerSubview.dataset.state = this.#uiState;
+
+ let baseDomain = JSON.stringify({ host: this.#currentBaseDomain });
+ this._cookieBannerEnableSite.setAttribute("data-l10n-args", baseDomain);
+ this._cookieBannerDisableSite.setAttribute("data-l10n-args", baseDomain);
+ }
+
+ async #disableCookieBannerHandling() {
+ // We can't clear data during a private browsing session until bug 1818783
+ // is fixed. In the meantime, don't allow the cookie banner controls in a
+ // private window to clear data for regular browsing mode.
+ if (!this.#isPrivateBrowsing) {
+ await SiteDataManager.remove(this.#currentBaseDomain);
+ }
+ Services.cookieBanners.setDomainPref(
+ gBrowser.currentURI,
+ Ci.nsICookieBannerService.MODE_DISABLED,
+ this.#isPrivateBrowsing
+ );
+ }
+
+ #enableCookieBannerHandling() {
+ Services.cookieBanners.removeDomainPref(
+ gBrowser.currentURI,
+ this.#isPrivateBrowsing
+ );
+ }
+
+ async onCookieBannerToggleCommand() {
+ let hasException =
+ this._cookieBannerSection.toggleAttribute("hasException");
+ if (hasException) {
+ await this.#disableCookieBannerHandling();
+ gProtectionsHandler.recordClick("cookieb_toggle_off");
+ } else {
+ this.#enableCookieBannerHandling();
+ gProtectionsHandler.recordClick("cookieb_toggle_on");
+ }
+ gProtectionsHandler._hidePopup();
+ gBrowser.reloadTab(gBrowser.selectedTab);
+ }
+})();
+
+/**
+ * Utility object to handle manipulations of the protections indicators in the UI
+ */
+var gProtectionsHandler = {
+ PREF_REPORT_BREAKAGE_URL: "browser.contentblocking.reportBreakage.url",
+ PREF_CB_CATEGORY: "browser.contentblocking.category",
+
+ _protectionsPopup: null,
+ _initializePopup() {
+ if (!this._protectionsPopup) {
+ let wrapper = document.getElementById("template-protections-popup");
+ this._protectionsPopup = wrapper.content.firstElementChild;
+ wrapper.replaceWith(wrapper.content);
+
+ this.maybeSetMilestoneCounterText();
+
+ for (let blocker of Object.values(this.blockers)) {
+ blocker.updateCategoryItem();
+ }
+
+ let baseURL = Services.urlFormatter.formatURLPref("app.support.baseURL");
+ document.getElementById(
+ "protections-popup-sendReportView-learn-more"
+ ).href = baseURL + "blocking-breakage";
+
+ let shimAllowLearnMoreURL =
+ baseURL + "smartblock-enhanced-tracking-protection";
+
+ document
+ .querySelectorAll(".protections-popup-shim-allow-learn-more")
+ .forEach(label => {
+ label.href = shimAllowLearnMoreURL;
+ });
+ }
+ },
+
+ _hidePopup() {
+ if (this._protectionsPopup) {
+ PanelMultiView.hidePopup(this._protectionsPopup);
+ }
+ },
+
+ // smart getters
+ get iconBox() {
+ delete this.iconBox;
+ return (this.iconBox = document.getElementById(
+ "tracking-protection-icon-box"
+ ));
+ },
+ get _protectionsPopupMultiView() {
+ delete this._protectionsPopupMultiView;
+ return (this._protectionsPopupMultiView = document.getElementById(
+ "protections-popup-multiView"
+ ));
+ },
+ get _protectionsPopupMainView() {
+ delete this._protectionsPopupMainView;
+ return (this._protectionsPopupMainView = document.getElementById(
+ "protections-popup-mainView"
+ ));
+ },
+ get _protectionsPopupMainViewHeaderLabel() {
+ delete this._protectionsPopupMainViewHeaderLabel;
+ return (this._protectionsPopupMainViewHeaderLabel = document.getElementById(
+ "protections-popup-mainView-panel-header-span"
+ ));
+ },
+ get _protectionsPopupTPSwitchBreakageLink() {
+ delete this._protectionsPopupTPSwitchBreakageLink;
+ return (this._protectionsPopupTPSwitchBreakageLink =
+ document.getElementById("protections-popup-tp-switch-breakage-link"));
+ },
+ get _protectionsPopupTPSwitchBreakageFixedLink() {
+ delete this._protectionsPopupTPSwitchBreakageFixedLink;
+ return (this._protectionsPopupTPSwitchBreakageFixedLink =
+ document.getElementById(
+ "protections-popup-tp-switch-breakage-fixed-link"
+ ));
+ },
+ get _protectionsPopupTPSwitch() {
+ delete this._protectionsPopupTPSwitch;
+ return (this._protectionsPopupTPSwitch = document.getElementById(
+ "protections-popup-tp-switch"
+ ));
+ },
+ get _protectionsPopupBlockingHeader() {
+ delete this._protectionsPopupBlockingHeader;
+ return (this._protectionsPopupBlockingHeader = document.getElementById(
+ "protections-popup-blocking-section-header"
+ ));
+ },
+ get _protectionsPopupNotBlockingHeader() {
+ delete this._protectionsPopupNotBlockingHeader;
+ return (this._protectionsPopupNotBlockingHeader = document.getElementById(
+ "protections-popup-not-blocking-section-header"
+ ));
+ },
+ get _protectionsPopupNotFoundHeader() {
+ delete this._protectionsPopupNotFoundHeader;
+ return (this._protectionsPopupNotFoundHeader = document.getElementById(
+ "protections-popup-not-found-section-header"
+ ));
+ },
+ get _protectionsPopupSettingsButton() {
+ delete this._protectionsPopupSettingsButton;
+ return (this._protectionsPopupSettingsButton = document.getElementById(
+ "protections-popup-settings-button"
+ ));
+ },
+ get _protectionsPopupFooter() {
+ delete this._protectionsPopupFooter;
+ return (this._protectionsPopupFooter = document.getElementById(
+ "protections-popup-footer"
+ ));
+ },
+ get _protectionsPopupTrackersCounterBox() {
+ delete this._protectionsPopupTrackersCounterBox;
+ return (this._protectionsPopupTrackersCounterBox = document.getElementById(
+ "protections-popup-trackers-blocked-counter-box"
+ ));
+ },
+ get _protectionsPopupTrackersCounterDescription() {
+ delete this._protectionsPopupTrackersCounterDescription;
+ return (this._protectionsPopupTrackersCounterDescription =
+ document.getElementById(
+ "protections-popup-trackers-blocked-counter-description"
+ ));
+ },
+ get _protectionsPopupFooterProtectionTypeLabel() {
+ delete this._protectionsPopupFooterProtectionTypeLabel;
+ return (this._protectionsPopupFooterProtectionTypeLabel =
+ document.getElementById(
+ "protections-popup-footer-protection-type-label"
+ ));
+ },
+ get _protectionsPopupSiteNotWorkingTPSwitch() {
+ delete this._protectionsPopupSiteNotWorkingTPSwitch;
+ return (this._protectionsPopupSiteNotWorkingTPSwitch =
+ document.getElementById("protections-popup-siteNotWorking-tp-switch"));
+ },
+ get _protectionsPopupSiteNotWorkingReportError() {
+ delete this._protectionsPopupSiteNotWorkingReportError;
+ return (this._protectionsPopupSiteNotWorkingReportError =
+ document.getElementById("protections-popup-sendReportView-report-error"));
+ },
+ get _protectionsPopupSendReportURL() {
+ delete this._protectionsPopupSendReportURL;
+ return (this._protectionsPopupSendReportURL = document.getElementById(
+ "protections-popup-sendReportView-collection-url"
+ ));
+ },
+ get _protectionsPopupSendReportButton() {
+ delete this._protectionsPopupSendReportButton;
+ return (this._protectionsPopupSendReportButton = document.getElementById(
+ "protections-popup-sendReportView-submit"
+ ));
+ },
+ get _trackingProtectionIconTooltipLabel() {
+ delete this._trackingProtectionIconTooltipLabel;
+ return (this._trackingProtectionIconTooltipLabel = document.getElementById(
+ "tracking-protection-icon-tooltip-label"
+ ));
+ },
+ get _trackingProtectionIconContainer() {
+ delete this._trackingProtectionIconContainer;
+ return (this._trackingProtectionIconContainer = document.getElementById(
+ "tracking-protection-icon-container"
+ ));
+ },
+
+ get noTrackersDetectedDescription() {
+ delete this.noTrackersDetectedDescription;
+ return (this.noTrackersDetectedDescription = document.getElementById(
+ "protections-popup-no-trackers-found-description"
+ ));
+ },
+
+ get _protectionsPopupMilestonesText() {
+ delete this._protectionsPopupMilestonesText;
+ return (this._protectionsPopupMilestonesText = document.getElementById(
+ "protections-popup-milestones-text"
+ ));
+ },
+
+ get _notBlockingWhyLink() {
+ delete this._notBlockingWhyLink;
+ return (this._notBlockingWhyLink = document.getElementById(
+ "protections-popup-not-blocking-section-why"
+ ));
+ },
+
+ get _siteNotWorkingIssueListFonts() {
+ delete this._siteNotWorkingIssueListFonts;
+ return (this._siteNotWorkingIssueListFonts = document.getElementById(
+ "protections-panel-site-not-working-view-issue-list-fonts"
+ ));
+ },
+
+ strings: {
+ get activeTooltipText() {
+ delete this.activeTooltipText;
+ return (this.activeTooltipText = gNavigatorBundle.getString(
+ "trackingProtection.icon.activeTooltip2"
+ ));
+ },
+
+ get disabledTooltipText() {
+ delete this.disabledTooltipText;
+ return (this.disabledTooltipText = gNavigatorBundle.getString(
+ "trackingProtection.icon.disabledTooltip2"
+ ));
+ },
+
+ get noTrackerTooltipText() {
+ delete this.noTrackerTooltipText;
+ return (this.noTrackerTooltipText = gNavigatorBundle.getFormattedString(
+ "trackingProtection.icon.noTrackersDetectedTooltip",
+ [gBrandBundle.GetStringFromName("brandShortName")]
+ ));
+ },
+ },
+
+ // A list of blockers that will be displayed in the categories list
+ // when blockable content is detected. A blocker must be an object
+ // with at least the following two properties:
+ // - enabled: Whether the blocker is currently turned on.
+ // - isDetected(state): Given a content blocking state, whether the blocker has
+ // either allowed or blocked elements.
+ // - categoryItem: The DOM item that represents the entry in the category list.
+ //
+ // It may also contain an init() and uninit() function, which will be called
+ // on gProtectionsHandler.init() and gProtectionsHandler.uninit().
+ // The buttons in the protections panel will appear in the same order as this array.
+ blockers: {
+ SocialTracking,
+ ThirdPartyCookies,
+ TrackingProtection,
+ Fingerprinting,
+ Cryptomining,
+ },
+
+ init() {
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "_fontVisibilityTrackingProtection",
+ "layout.css.font-visibility.trackingprotection",
+ 3000
+ );
+
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "_protectionsPopupToastTimeout",
+ "browser.protections_panel.toast.timeout",
+ 3000
+ );
+
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "milestoneListPref",
+ "browser.contentblocking.cfr-milestone.milestones",
+ "[]",
+ () => this.maybeSetMilestoneCounterText(),
+ val => JSON.parse(val)
+ );
+
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "milestonePref",
+ "browser.contentblocking.cfr-milestone.milestone-achieved",
+ 0,
+ () => this.maybeSetMilestoneCounterText()
+ );
+
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "milestoneTimestampPref",
+ "browser.contentblocking.cfr-milestone.milestone-shown-time",
+ "0",
+ null,
+ val => parseInt(val)
+ );
+
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "milestonesEnabledPref",
+ "browser.contentblocking.cfr-milestone.enabled",
+ false,
+ () => this.maybeSetMilestoneCounterText()
+ );
+
+ for (let blocker of Object.values(this.blockers)) {
+ if (blocker.init) {
+ blocker.init();
+ }
+ }
+
+ // Add an observer to observe that the history has been cleared.
+ Services.obs.addObserver(this, "browser:purge-session-history");
+ },
+
+ uninit() {
+ for (let blocker of Object.values(this.blockers)) {
+ if (blocker.uninit) {
+ blocker.uninit();
+ }
+ }
+
+ Services.obs.removeObserver(this, "browser:purge-session-history");
+ },
+
+ getTrackingProtectionLabel() {
+ const value = Services.prefs.getStringPref(this.PREF_CB_CATEGORY);
+
+ switch (value) {
+ case "strict":
+ return "protections-popup-footer-protection-label-strict";
+ case "custom":
+ return "protections-popup-footer-protection-label-custom";
+ case "standard":
+ /* fall through */
+ default:
+ return "protections-popup-footer-protection-label-standard";
+ }
+ },
+
+ openPreferences(origin) {
+ openPreferences("privacy-trackingprotection", { origin });
+ },
+
+ openProtections(relatedToCurrent = false) {
+ switchToTabHavingURI("about:protections", true, {
+ replaceQueryString: true,
+ relatedToCurrent,
+ triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
+ });
+
+ // Don't show the milestones section anymore.
+ Services.prefs.clearUserPref(
+ "browser.contentblocking.cfr-milestone.milestone-shown-time"
+ );
+ },
+
+ async showTrackersSubview(event) {
+ await TrackingProtection.updateSubView();
+ this._protectionsPopupMultiView.showSubView(
+ "protections-popup-trackersView"
+ );
+ },
+
+ async showSocialblockerSubview(event) {
+ await SocialTracking.updateSubView();
+ this._protectionsPopupMultiView.showSubView(
+ "protections-popup-socialblockView"
+ );
+ },
+
+ async showCookiesSubview(event) {
+ await ThirdPartyCookies.updateSubView();
+ this._protectionsPopupMultiView.showSubView(
+ "protections-popup-cookiesView"
+ );
+ },
+
+ async showFingerprintersSubview(event) {
+ await Fingerprinting.updateSubView();
+ this._protectionsPopupMultiView.showSubView(
+ "protections-popup-fingerprintersView"
+ );
+ },
+
+ async showCryptominersSubview(event) {
+ await Cryptomining.updateSubView();
+ this._protectionsPopupMultiView.showSubView(
+ "protections-popup-cryptominersView"
+ );
+ },
+
+ async onCookieBannerClick(event) {
+ if (!cookieBannerHandling.isSiteSupported) {
+ return;
+ }
+ await cookieBannerHandling.updateSubView();
+ this._protectionsPopupMultiView.showSubView(
+ "protections-popup-cookieBannerView"
+ );
+ },
+
+ recordClick(object, value = null, source = "protectionspopup") {
+ Services.telemetry.recordEvent(
+ `security.ui.${source}`,
+ "click",
+ object,
+ value
+ );
+ },
+
+ shieldHistogramAdd(value) {
+ if (PrivateBrowsingUtils.isWindowPrivate(window)) {
+ return;
+ }
+ Services.telemetry
+ .getHistogramById("TRACKING_PROTECTION_SHIELD")
+ .add(value);
+ },
+
+ cryptominersHistogramAdd(value) {
+ Services.telemetry
+ .getHistogramById("CRYPTOMINERS_BLOCKED_COUNT")
+ .add(value);
+ },
+
+ fingerprintersHistogramAdd(value) {
+ Services.telemetry
+ .getHistogramById("FINGERPRINTERS_BLOCKED_COUNT")
+ .add(value);
+ },
+
+ handleProtectionsButtonEvent(event) {
+ event.stopPropagation();
+ if (
+ (event.type == "click" && event.button != 0) ||
+ (event.type == "keypress" &&
+ event.charCode != KeyEvent.DOM_VK_SPACE &&
+ event.keyCode != KeyEvent.DOM_VK_RETURN)
+ ) {
+ return; // Left click, space or enter only
+ }
+
+ this.showProtectionsPopup({ event });
+ },
+
+ onPopupShown(event) {
+ if (event.target == this._protectionsPopup) {
+ window.ensureCustomElements("moz-button-group");
+
+ PopupNotifications.suppressWhileOpen(this._protectionsPopup);
+
+ window.addEventListener("focus", this, true);
+
+ // Insert the info message if needed. This will be shown once and then
+ // remain collapsed.
+ ToolbarPanelHub.insertProtectionPanelMessage(event);
+
+ if (!event.target.hasAttribute("toast")) {
+ Services.telemetry.recordEvent(
+ "security.ui.protectionspopup",
+ "open",
+ "protections_popup"
+ );
+ }
+ }
+ },
+
+ onPopupHidden(event) {
+ if (event.target == this._protectionsPopup) {
+ window.removeEventListener("focus", this, true);
+ }
+ },
+
+ onHeaderClicked(event) {
+ // Display the whole protections panel if the toast has been clicked.
+ if (this._protectionsPopup.hasAttribute("toast")) {
+ // Hide the toast first.
+ PanelMultiView.hidePopup(this._protectionsPopup);
+
+ // Open the full protections panel.
+ this.showProtectionsPopup({ event });
+ }
+ },
+
+ async onTrackingProtectionIconHoveredOrFocused() {
+ // We would try to pre-fetch the data whenever the shield icon is hovered or
+ // focused. We check focus event here due to the keyboard navigation.
+ if (this._updatingFooter) {
+ return;
+ }
+ this._updatingFooter = true;
+
+ // Take the popup out of its template.
+ this._initializePopup();
+
+ // Get the tracker count and set it to the counter in the footer.
+ const trackerCount = await TrackingDBService.sumAllEvents();
+ this.setTrackersBlockedCounter(trackerCount);
+
+ // Set tracking protection label
+ const l10nId = this.getTrackingProtectionLabel();
+ const elem = this._protectionsPopupFooterProtectionTypeLabel;
+ document.l10n.setAttributes(elem, l10nId);
+
+ // Try to get the earliest recorded date in case that there was no record
+ // during the initiation but new records come after that.
+ await this.maybeUpdateEarliestRecordedDateTooltip();
+
+ this._updatingFooter = false;
+ },
+
+ // This triggers from top level location changes.
+ onLocationChange() {
+ if (this._showToastAfterRefresh) {
+ this._showToastAfterRefresh = false;
+
+ // We only display the toast if we're still on the same page.
+ if (
+ this._previousURI == gBrowser.currentURI.spec &&
+ this._previousOuterWindowID == gBrowser.selectedBrowser.outerWindowID
+ ) {
+ this.showProtectionsPopup({
+ toast: true,
+ });
+ }
+ }
+
+ // Reset blocking and exception status so that we can send telemetry
+ this.hadShieldState = false;
+
+ // Don't deal with about:, file: etc.
+ if (!ContentBlockingAllowList.canHandle(gBrowser.selectedBrowser)) {
+ // We hide the icon and thus avoid showing the doorhanger, since
+ // the information contained there would mostly be broken and/or
+ // irrelevant anyway.
+ this._trackingProtectionIconContainer.hidden = true;
+ return;
+ }
+ this._trackingProtectionIconContainer.hidden = false;
+
+ // Check whether the user has added an exception for this site.
+ this.hasException = ContentBlockingAllowList.includes(
+ gBrowser.selectedBrowser
+ );
+
+ if (this._protectionsPopup) {
+ this._protectionsPopup.toggleAttribute("hasException", this.hasException);
+ }
+ this.iconBox.toggleAttribute("hasException", this.hasException);
+
+ // Add to telemetry per page load as a baseline measurement.
+ this.fingerprintersHistogramAdd("pageLoad");
+ this.cryptominersHistogramAdd("pageLoad");
+ this.shieldHistogramAdd(0);
+ },
+
+ notifyContentBlockingEvent(event) {
+ // We don't notify observers until the document stops loading, therefore
+ // a merged event can be sent, which gives an opportunity to decide the
+ // priority by the handler.
+ // Content blocking events coming after stopping will not be merged, and are
+ // sent directly.
+ if (!this._isStoppedState || !this.anyDetected) {
+ return;
+ }
+
+ let uri = gBrowser.currentURI;
+ let uriHost = uri.asciiHost ? uri.host : uri.spec;
+ Services.obs.notifyObservers(
+ {
+ wrappedJSObject: {
+ browser: gBrowser.selectedBrowser,
+ host: uriHost,
+ event,
+ },
+ },
+ "SiteProtection:ContentBlockingEvent"
+ );
+ },
+
+ onStateChange(aWebProgress, stateFlags) {
+ if (!aWebProgress.isTopLevel) {
+ return;
+ }
+
+ this._isStoppedState = !!(
+ stateFlags & Ci.nsIWebProgressListener.STATE_STOP
+ );
+ this.notifyContentBlockingEvent(
+ gBrowser.selectedBrowser.getContentBlockingEvents()
+ );
+ },
+
+ /**
+ * Update the in-panel UI given a blocking event. Called when the popup
+ * is being shown, or when the popup is open while a new event comes in.
+ */
+ updatePanelForBlockingEvent(event) {
+ // Update the categories:
+ for (let blocker of Object.values(this.blockers)) {
+ if (blocker.categoryItem.hasAttribute("uidisabled")) {
+ continue;
+ }
+ blocker.categoryItem.classList.toggle(
+ "notFound",
+ !blocker.isDetected(event)
+ );
+ blocker.categoryItem.classList.toggle(
+ "subviewbutton-nav",
+ blocker.isDetected(event)
+ );
+ }
+
+ // And the popup attributes:
+ this._protectionsPopup.toggleAttribute("detected", this.anyDetected);
+ this._protectionsPopup.toggleAttribute("blocking", this.anyBlocking);
+ this._protectionsPopup.toggleAttribute("hasException", this.hasException);
+
+ this.noTrackersDetectedDescription.hidden = this.anyDetected;
+
+ if (this.anyDetected) {
+ // Reorder categories if any are in use.
+ this.reorderCategoryItems();
+ }
+ },
+
+ reportBlockingEventTelemetry(event, isSimulated, previousState) {
+ if (!isSimulated) {
+ if (this.hasException && !this.hadShieldState) {
+ this.hadShieldState = true;
+ this.shieldHistogramAdd(1);
+ } else if (
+ !this.hasException &&
+ this.anyBlocking &&
+ !this.hadShieldState
+ ) {
+ this.hadShieldState = true;
+ this.shieldHistogramAdd(2);
+ }
+ }
+
+ // We report up to one instance of fingerprinting and cryptomining
+ // blocking and/or allowing per page load.
+ let fingerprintingBlocking =
+ Fingerprinting.isBlocking(event) &&
+ !Fingerprinting.isBlocking(previousState);
+ let fingerprintingAllowing =
+ Fingerprinting.isAllowing(event) &&
+ !Fingerprinting.isAllowing(previousState);
+ let cryptominingBlocking =
+ Cryptomining.isBlocking(event) && !Cryptomining.isBlocking(previousState);
+ let cryptominingAllowing =
+ Cryptomining.isAllowing(event) && !Cryptomining.isAllowing(previousState);
+
+ if (fingerprintingBlocking) {
+ this.fingerprintersHistogramAdd("blocked");
+ } else if (fingerprintingAllowing) {
+ this.fingerprintersHistogramAdd("allowed");
+ }
+
+ if (cryptominingBlocking) {
+ this.cryptominersHistogramAdd("blocked");
+ } else if (cryptominingAllowing) {
+ this.cryptominersHistogramAdd("allowed");
+ }
+ },
+
+ onContentBlockingEvent(event, webProgress, isSimulated, previousState) {
+ // Don't deal with about:, file: etc.
+ if (!ContentBlockingAllowList.canHandle(gBrowser.selectedBrowser)) {
+ this.iconBox.removeAttribute("active");
+ this.iconBox.removeAttribute("hasException");
+ return;
+ }
+
+ // First update all our internal state based on the allowlist and the
+ // different blockers:
+ this.anyDetected = false;
+ this.anyBlocking = false;
+ this._lastEvent = event;
+
+ // Check whether the user has added an exception for this site.
+ this.hasException = ContentBlockingAllowList.includes(
+ gBrowser.selectedBrowser
+ );
+
+ // Update blocker state and find if they detected or blocked anything.
+ for (let blocker of Object.values(this.blockers)) {
+ if (blocker.categoryItem?.hasAttribute("uidisabled")) {
+ continue;
+ }
+ // Store data on whether the blocker is activated for reporting it
+ // using the "report breakage" dialog. Under normal circumstances this
+ // dialog should only be able to open in the currently selected tab
+ // and onSecurityChange runs on tab switch, so we can avoid associating
+ // the data with the document directly.
+ blocker.activated = blocker.isBlocking(event);
+ this.anyDetected = this.anyDetected || blocker.isDetected(event);
+ this.anyBlocking = this.anyBlocking || blocker.activated;
+ }
+
+ this._categoryItemOrderInvalidated = true;
+
+ // Now, update the icon UI:
+
+ // We consider the shield state "active" when some kind of blocking activity
+ // occurs on the page. Note that merely allowing the loading of content that
+ // we could have blocked does not trigger the appearance of the shield.
+ // This state will be overriden later if there's an exception set for this site.
+ this.iconBox.toggleAttribute("active", this.anyBlocking);
+ this.iconBox.toggleAttribute("hasException", this.hasException);
+
+ // Update the icon's tooltip:
+ if (this.hasException) {
+ this.showDisabledTooltipForTPIcon();
+ } else if (this.anyBlocking) {
+ this.showActiveTooltipForTPIcon();
+ } else {
+ this.showNoTrackerTooltipForTPIcon();
+ }
+
+ // Update the panel if it's open.
+ let isPanelOpen = ["showing", "open"].includes(
+ this._protectionsPopup?.state
+ );
+ if (isPanelOpen) {
+ this.updatePanelForBlockingEvent(event);
+ }
+
+ // Notify other consumers, like CFR.
+ // Don't send a content blocking event to CFR for
+ // tab switches since this will already be done via
+ // onStateChange.
+ if (!isSimulated) {
+ this.notifyContentBlockingEvent(event);
+ }
+
+ // Finally, report telemetry.
+ this.reportBlockingEventTelemetry(event, isSimulated, previousState);
+ },
+
+ // We handle focus here when the panel is shown.
+ handleEvent(event) {
+ let elem = document.activeElement;
+ let position = elem.compareDocumentPosition(this._protectionsPopup);
+
+ if (
+ !(
+ position &
+ (Node.DOCUMENT_POSITION_CONTAINS | Node.DOCUMENT_POSITION_CONTAINED_BY)
+ ) &&
+ !this._protectionsPopup.hasAttribute("noautohide")
+ ) {
+ // Hide the panel when focusing an element that is
+ // neither an ancestor nor descendant unless the panel has
+ // @noautohide (e.g. for a tour).
+ PanelMultiView.hidePopup(this._protectionsPopup);
+ }
+ },
+
+ observe(subject, topic, data) {
+ switch (topic) {
+ case "browser:purge-session-history":
+ // We need to update the earliest recorded date if history has been
+ // cleared.
+ this._hasEarliestRecord = false;
+ this.maybeUpdateEarliestRecordedDateTooltip();
+ break;
+ }
+ },
+
+ /**
+ * Update the popup contents. Only called when the popup has been taken
+ * out of the template and is shown or about to be shown.
+ */
+ refreshProtectionsPopup() {
+ let host = gIdentityHandler.getHostForDisplay();
+
+ // Push the appropriate strings out to the UI.
+ this._protectionsPopupMainViewHeaderLabel.textContent =
+ gNavigatorBundle.getFormattedString("protections.header", [host]);
+
+ let currentlyEnabled = !this.hasException;
+
+ for (let tpSwitch of [
+ this._protectionsPopupTPSwitch,
+ this._protectionsPopupSiteNotWorkingTPSwitch,
+ ]) {
+ tpSwitch.toggleAttribute("enabled", currentlyEnabled);
+ }
+
+ this._notBlockingWhyLink.setAttribute(
+ "tooltip",
+ currentlyEnabled
+ ? "protections-popup-not-blocking-why-etp-on-tooltip"
+ : "protections-popup-not-blocking-why-etp-off-tooltip"
+ );
+
+ // Toggle the breakage link according to the current enable state.
+ this.toggleBreakageLink();
+
+ // Give the button an accessible label for screen readers.
+ if (currentlyEnabled) {
+ this._protectionsPopupTPSwitch.setAttribute(
+ "aria-label",
+ gNavigatorBundle.getFormattedString("protections.disableAriaLabel", [
+ host,
+ ])
+ );
+ } else {
+ this._protectionsPopupTPSwitch.setAttribute(
+ "aria-label",
+ gNavigatorBundle.getFormattedString("protections.enableAriaLabel", [
+ host,
+ ])
+ );
+ }
+
+ // Update the tooltip of the blocked tracker counter.
+ this.maybeUpdateEarliestRecordedDateTooltip();
+
+ let today = Date.now();
+ let threeDaysMillis = 72 * 60 * 60 * 1000;
+ let expired = today - this.milestoneTimestampPref > threeDaysMillis;
+
+ if (this._milestoneTextSet && !expired) {
+ this._protectionsPopup.setAttribute("milestone", this.milestonePref);
+ } else {
+ this._protectionsPopup.removeAttribute("milestone");
+ }
+
+ cookieBannerHandling.updateSection();
+
+ this._protectionsPopup.toggleAttribute("detected", this.anyDetected);
+ this._protectionsPopup.toggleAttribute("blocking", this.anyBlocking);
+ this._protectionsPopup.toggleAttribute("hasException", this.hasException);
+ },
+
+ /*
+ * This function sorts the category items into the Blocked/Allowed/None Detected
+ * sections. It's called immediately in onContentBlockingEvent if the popup
+ * is presently open. Otherwise, the next time the popup is shown.
+ */
+ reorderCategoryItems() {
+ if (!this._categoryItemOrderInvalidated) {
+ return;
+ }
+
+ delete this._categoryItemOrderInvalidated;
+
+ // Hide all the headers to start with.
+ this._protectionsPopupBlockingHeader.hidden = true;
+ this._protectionsPopupNotBlockingHeader.hidden = true;
+ this._protectionsPopupNotFoundHeader.hidden = true;
+
+ for (let { categoryItem } of Object.values(this.blockers)) {
+ if (
+ categoryItem.classList.contains("notFound") ||
+ categoryItem.hasAttribute("uidisabled")
+ ) {
+ // Add the item to the bottom of the list. This will be under
+ // the "None Detected" section.
+ categoryItem.parentNode.insertAdjacentElement(
+ "beforeend",
+ categoryItem
+ );
+ categoryItem.setAttribute("disabled", true);
+ // We have an undetected category, show the header.
+ this._protectionsPopupNotFoundHeader.hidden = false;
+ continue;
+ }
+
+ // Clear the disabled attribute in case we are moving the item out of
+ // "None Detected"
+ categoryItem.removeAttribute("disabled");
+
+ if (categoryItem.classList.contains("blocked") && !this.hasException) {
+ // Add the item just above the "Allowed" section - this will be the
+ // bottom of the "Blocked" section.
+ categoryItem.parentNode.insertBefore(
+ categoryItem,
+ this._protectionsPopupNotBlockingHeader
+ );
+ // We have a blocking category, show the header.
+ this._protectionsPopupBlockingHeader.hidden = false;
+ continue;
+ }
+
+ // Add the item just above the "None Detected" section - this will be the
+ // bottom of the "Allowed" section.
+ categoryItem.parentNode.insertBefore(
+ categoryItem,
+ this._protectionsPopupNotFoundHeader
+ );
+ // We have an allowing category, show the header.
+ this._protectionsPopupNotBlockingHeader.hidden = false;
+ }
+ },
+
+ disableForCurrentPage(shouldReload = true) {
+ ContentBlockingAllowList.add(gBrowser.selectedBrowser);
+ if (shouldReload) {
+ this._hidePopup();
+ BrowserReload();
+ }
+ },
+
+ enableForCurrentPage(shouldReload = true) {
+ ContentBlockingAllowList.remove(gBrowser.selectedBrowser);
+ if (shouldReload) {
+ this._hidePopup();
+ BrowserReload();
+ }
+ },
+
+ async onTPSwitchCommand(event) {
+ // When the switch is clicked, we wait 500ms and then disable/enable
+ // protections, causing the page to refresh, and close the popup.
+ // We need to ensure we don't handle more clicks during the 500ms delay,
+ // so we keep track of state and return early if needed.
+ if (this._TPSwitchCommanding) {
+ return;
+ }
+
+ this._TPSwitchCommanding = true;
+
+ // Toggling the 'hasException' on the protections panel in order to do some
+ // styling after toggling the TP switch.
+ let newExceptionState =
+ this._protectionsPopup.toggleAttribute("hasException");
+ for (let tpSwitch of [
+ this._protectionsPopupTPSwitch,
+ this._protectionsPopupSiteNotWorkingTPSwitch,
+ ]) {
+ tpSwitch.toggleAttribute("enabled", !newExceptionState);
+ }
+
+ // Toggle the breakage link if needed.
+ this.toggleBreakageLink();
+
+ // Change the tooltip of the tracking protection icon.
+ if (newExceptionState) {
+ this.showDisabledTooltipForTPIcon();
+ } else {
+ this.showNoTrackerTooltipForTPIcon();
+ }
+
+ // Change the state of the tracking protection icon.
+ this.iconBox.toggleAttribute("hasException", newExceptionState);
+
+ // Indicating that we need to show a toast after refreshing the page.
+ // And caching the current URI and window ID in order to only show the mini
+ // panel if it's still on the same page.
+ this._showToastAfterRefresh = true;
+ this._previousURI = gBrowser.currentURI.spec;
+ this._previousOuterWindowID = gBrowser.selectedBrowser.outerWindowID;
+
+ if (newExceptionState) {
+ this.disableForCurrentPage(false);
+ this.recordClick("etp_toggle_off");
+ } else {
+ this.enableForCurrentPage(false);
+ this.recordClick("etp_toggle_on");
+ }
+
+ // We need to flush the TP state change immediately without waiting the
+ // 500ms delay if the Tab get switched out.
+ let targetTab = gBrowser.selectedTab;
+ let onTabSelectHandler;
+ let tabSelectPromise = new Promise(resolve => {
+ onTabSelectHandler = () => resolve();
+ gBrowser.tabContainer.addEventListener("TabSelect", onTabSelectHandler);
+ });
+ let timeoutPromise = new Promise(resolve => setTimeout(resolve, 500));
+
+ await Promise.race([tabSelectPromise, timeoutPromise]);
+ gBrowser.tabContainer.removeEventListener("TabSelect", onTabSelectHandler);
+ PanelMultiView.hidePopup(this._protectionsPopup);
+ gBrowser.reloadTab(targetTab);
+
+ delete this._TPSwitchCommanding;
+ },
+
+ onCookieBannerToggleCommand() {
+ cookieBannerHandling.onCookieBannerToggleCommand();
+ },
+
+ setTrackersBlockedCounter(trackerCount) {
+ let forms = gNavigatorBundle.getString(
+ "protections.footer.blockedTrackerCounter.description"
+ );
+ this._protectionsPopupTrackersCounterDescription.textContent =
+ PluralForm.get(trackerCount, forms).replace(
+ "#1",
+ trackerCount.toLocaleString(Services.locale.appLocalesAsBCP47)
+ );
+
+ // Show the counter if the number of tracker is not zero.
+ this._protectionsPopupTrackersCounterBox.toggleAttribute(
+ "showing",
+ trackerCount != 0
+ );
+ },
+
+ // Whenever one of the milestone prefs are changed, we attempt to update
+ // the milestone section string. This requires us to fetch the earliest
+ // recorded date from the Tracking DB, hence this process is async.
+ // When completed, we set _milestoneSetText to signal that the section
+ // is populated and ready to be shown - which happens next time we call
+ // refreshProtectionsPopup.
+ _milestoneTextSet: false,
+ async maybeSetMilestoneCounterText() {
+ if (!this._protectionsPopup) {
+ return;
+ }
+ let trackerCount = this.milestonePref;
+ if (
+ !this.milestonesEnabledPref ||
+ !trackerCount ||
+ !this.milestoneListPref.includes(trackerCount)
+ ) {
+ this._milestoneTextSet = false;
+ return;
+ }
+
+ let date = await TrackingDBService.getEarliestRecordedDate();
+ let dateLocaleStr = new Date(date).toLocaleDateString("default", {
+ month: "long",
+ year: "numeric",
+ });
+
+ let desc = PluralForm.get(
+ trackerCount,
+ gNavigatorBundle.getString("protections.milestone.description")
+ );
+
+ this._protectionsPopupMilestonesText.textContent = desc
+ .replace("#1", gBrandBundle.GetStringFromName("brandShortName"))
+ .replace(
+ "#2",
+ trackerCount.toLocaleString(Services.locale.appLocalesAsBCP47)
+ )
+ .replace("#3", dateLocaleStr);
+
+ this._milestoneTextSet = true;
+ },
+
+ showDisabledTooltipForTPIcon() {
+ this._trackingProtectionIconTooltipLabel.textContent =
+ this.strings.disabledTooltipText;
+ this._trackingProtectionIconContainer.setAttribute(
+ "aria-label",
+ this.strings.disabledTooltipText
+ );
+ },
+
+ showActiveTooltipForTPIcon() {
+ this._trackingProtectionIconTooltipLabel.textContent =
+ this.strings.activeTooltipText;
+ this._trackingProtectionIconContainer.setAttribute(
+ "aria-label",
+ this.strings.activeTooltipText
+ );
+ },
+
+ showNoTrackerTooltipForTPIcon() {
+ this._trackingProtectionIconTooltipLabel.textContent =
+ this.strings.noTrackerTooltipText;
+ this._trackingProtectionIconContainer.setAttribute(
+ "aria-label",
+ this.strings.noTrackerTooltipText
+ );
+ },
+
+ /**
+ * Showing the protections popup.
+ *
+ * @param {Object} options
+ * The object could have two properties.
+ * event:
+ * The event triggers the protections popup to be opened.
+ * toast:
+ * A boolean to indicate if we need to open the protections
+ * popup as a toast. A toast only has a header section and
+ * will be hidden after a certain amount of time.
+ */
+ showProtectionsPopup(options = {}) {
+ const { event, toast } = options;
+
+ this._initializePopup();
+
+ // Ensure we've updated category state based on the last blocking event:
+ if (this.hasOwnProperty("_lastEvent")) {
+ this.updatePanelForBlockingEvent(this._lastEvent);
+ delete this._lastEvent;
+ }
+
+ // We need to clear the toast timer if it exists before showing the
+ // protections popup.
+ if (this._toastPanelTimer) {
+ clearTimeout(this._toastPanelTimer);
+ delete this._toastPanelTimer;
+ }
+
+ this._protectionsPopup.toggleAttribute("toast", !!toast);
+ if (!toast) {
+ // Refresh strings if we want to open it as a standard protections popup.
+ this.refreshProtectionsPopup();
+ }
+
+ if (toast) {
+ this._protectionsPopup.addEventListener(
+ "popupshown",
+ () => {
+ this._toastPanelTimer = setTimeout(() => {
+ PanelMultiView.hidePopup(this._protectionsPopup, true);
+ delete this._toastPanelTimer;
+ }, this._protectionsPopupToastTimeout);
+ },
+ { once: true }
+ );
+ }
+
+ // Add the "open" attribute to the tracking protection icon container
+ // for styling.
+ this._trackingProtectionIconContainer.setAttribute("open", "true");
+
+ // Check the panel state of other panels. Hide them if needed.
+ let openPanels = Array.from(document.querySelectorAll("panel[openpanel]"));
+ for (let panel of openPanels) {
+ PanelMultiView.hidePopup(panel);
+ }
+
+ // Now open the popup, anchored off the primary chrome element
+ PanelMultiView.openPopup(
+ this._protectionsPopup,
+ this._trackingProtectionIconContainer,
+ {
+ position: "bottomleft topleft",
+ triggerEvent: event,
+ }
+ ).catch(console.error);
+ },
+
+ showSiteNotWorkingView() {
+ // Only show the Fonts item if we are restricting font visibility
+ if (this._fontVisibilityTrackingProtection >= 3) {
+ this._siteNotWorkingIssueListFonts.setAttribute("hidden", "true");
+ } else {
+ this._siteNotWorkingIssueListFonts.removeAttribute("hidden");
+ }
+
+ this._protectionsPopupMultiView.showSubView(
+ "protections-popup-siteNotWorkingView"
+ );
+ },
+
+ showSendReportView() {
+ // Save this URI to make sure that the user really only submits the location
+ // they see in the report breakage dialog.
+ this.reportURI = gBrowser.currentURI;
+ let urlWithoutQuery = this.reportURI.asciiSpec.replace(
+ "?" + this.reportURI.query,
+ ""
+ );
+ let commentsTextarea = document.getElementById(
+ "protections-popup-sendReportView-collection-comments"
+ );
+ commentsTextarea.value = "";
+ this._protectionsPopupSendReportURL.value = urlWithoutQuery;
+ this._protectionsPopupSiteNotWorkingReportError.hidden = true;
+ this._protectionsPopupMultiView.showSubView(
+ "protections-popup-sendReportView"
+ );
+ },
+
+ toggleBreakageLink() {
+ // The breakage link will only be shown if tracking protection is enabled
+ // for the site and the TP toggle state is on. And we won't show the
+ // link as toggling TP switch to On from Off. In order to do so, we need to
+ // know the previous TP state. We check the ContentBlockingAllowList instead
+ // of 'hasException' attribute of the protection popup for the previous
+ // since the 'hasException' will also be toggled as well as toggling the TP
+ // switch. We won't be able to know the previous TP state through the
+ // 'hasException' attribute. So we fallback to check the
+ // ContentBlockingAllowList here.
+ this._protectionsPopupTPSwitchBreakageLink.hidden =
+ ContentBlockingAllowList.includes(gBrowser.selectedBrowser) ||
+ !this.anyBlocking ||
+ !this._protectionsPopupTPSwitch.hasAttribute("enabled");
+ // The "Site Fixed?" link behaves similarly but for the opposite state.
+ this._protectionsPopupTPSwitchBreakageFixedLink.hidden =
+ !ContentBlockingAllowList.includes(gBrowser.selectedBrowser) ||
+ this._protectionsPopupTPSwitch.hasAttribute("enabled");
+ },
+
+ submitBreakageReport(uri) {
+ let reportEndpoint = Services.prefs.getStringPref(
+ this.PREF_REPORT_BREAKAGE_URL
+ );
+ if (!reportEndpoint) {
+ return;
+ }
+
+ let commentsTextarea = document.getElementById(
+ "protections-popup-sendReportView-collection-comments"
+ );
+
+ let formData = new FormData();
+ formData.set("title", uri.host);
+
+ // Leave the ? at the end of the URL to signify that this URL had its query stripped.
+ let urlWithoutQuery = uri.asciiSpec.replace(uri.query, "");
+ let body = `Full URL: ${urlWithoutQuery}\n`;
+ body += `userAgent: ${navigator.userAgent}\n`;
+
+ body += "\n**Preferences**\n";
+ body += `${TrackingProtection.prefEnabled}: ${Services.prefs.getBoolPref(
+ TrackingProtection.prefEnabled
+ )}\n`;
+ body += `${
+ TrackingProtection.prefEnabledInPrivateWindows
+ }: ${Services.prefs.getBoolPref(
+ TrackingProtection.prefEnabledInPrivateWindows
+ )}\n`;
+ body += `urlclassifier.trackingTable: ${Services.prefs.getStringPref(
+ "urlclassifier.trackingTable"
+ )}\n`;
+ body += `network.http.referer.defaultPolicy: ${Services.prefs.getIntPref(
+ "network.http.referer.defaultPolicy"
+ )}\n`;
+ body += `network.http.referer.defaultPolicy.pbmode: ${Services.prefs.getIntPref(
+ "network.http.referer.defaultPolicy.pbmode"
+ )}\n`;
+ body += `${ThirdPartyCookies.prefEnabled}: ${Services.prefs.getIntPref(
+ ThirdPartyCookies.prefEnabled
+ )}\n`;
+ body += `privacy.annotate_channels.strict_list.enabled: ${Services.prefs.getBoolPref(
+ "privacy.annotate_channels.strict_list.enabled"
+ )}\n`;
+ body += `privacy.restrict3rdpartystorage.expiration: ${Services.prefs.getIntPref(
+ "privacy.restrict3rdpartystorage.expiration"
+ )}\n`;
+ body += `${Fingerprinting.prefEnabled}: ${Services.prefs.getBoolPref(
+ Fingerprinting.prefEnabled
+ )}\n`;
+ body += `${Cryptomining.prefEnabled}: ${Services.prefs.getBoolPref(
+ Cryptomining.prefEnabled
+ )}\n`;
+ body += `\nhasException: ${this.hasException}\n`;
+
+ body += "\n**Comments**\n" + commentsTextarea.value;
+
+ formData.set("body", body);
+
+ let activatedBlockers = [];
+ for (let blocker of Object.values(this.blockers)) {
+ if (blocker.activated) {
+ activatedBlockers.push(blocker.reportBreakageLabel);
+ }
+ }
+
+ formData.set("labels", activatedBlockers.join(","));
+
+ this._protectionsPopupSendReportButton.disabled = true;
+
+ fetch(reportEndpoint, {
+ method: "POST",
+ credentials: "omit",
+ body: formData,
+ })
+ .then(response => {
+ this._protectionsPopupSendReportButton.disabled = false;
+ if (!response.ok) {
+ console.error(
+ `Content Blocking report to ${reportEndpoint} failed with status ${response.status}`
+ );
+ this._protectionsPopupSiteNotWorkingReportError.hidden = false;
+ } else {
+ this._protectionsPopup.hidePopup();
+ ConfirmationHint.show(
+ this.iconBox,
+ "confirmation-hint-breakage-report-sent"
+ );
+ }
+ })
+ .catch(console.error);
+ },
+
+ onSendReportClicked() {
+ this.submitBreakageReport(this.reportURI);
+ },
+
+ async maybeUpdateEarliestRecordedDateTooltip() {
+ // If we've already updated or the popup isn't in the DOM yet, don't bother
+ // doing this:
+ if (this._hasEarliestRecord || !this._protectionsPopup) {
+ return;
+ }
+
+ let date = await TrackingDBService.getEarliestRecordedDate();
+
+ // If there is no record for any blocked tracker, we don't have to do anything
+ // since the tracker counter won't be shown.
+ if (!date) {
+ return;
+ }
+ this._hasEarliestRecord = true;
+
+ const dateLocaleStr = new Date(date).toLocaleDateString("default", {
+ month: "long",
+ day: "numeric",
+ year: "numeric",
+ });
+
+ const tooltipStr = gNavigatorBundle.getFormattedString(
+ "protections.footer.blockedTrackerCounter.tooltip",
+ [dateLocaleStr]
+ );
+
+ this._protectionsPopupTrackersCounterDescription.setAttribute(
+ "tooltiptext",
+ tooltipStr
+ );
+ },
+};
diff --git a/browser/base/content/browser-sync.js b/browser/base/content/browser-sync.js
new file mode 100644
index 0000000000..1280fd8305
--- /dev/null
+++ b/browser/base/content/browser-sync.js
@@ -0,0 +1,1963 @@
+/* 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/. */
+
+// This file is loaded into the browser window scope.
+/* eslint-env mozilla/browser-window */
+
+const { UIState } = ChromeUtils.importESModule(
+ "resource://services-sync/UIState.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ EnsureFxAccountsWebChannel:
+ "resource://gre/modules/FxAccountsWebChannel.sys.mjs",
+
+ ExperimentAPI: "resource://nimbus/ExperimentAPI.sys.mjs",
+ FxAccounts: "resource://gre/modules/FxAccounts.sys.mjs",
+ SyncedTabs: "resource://services-sync/SyncedTabs.sys.mjs",
+ Weave: "resource://services-sync/main.sys.mjs",
+});
+
+const MIN_STATUS_ANIMATION_DURATION = 1600;
+
+this.SyncedTabsPanelList = class SyncedTabsPanelList {
+ static sRemoteTabsDeckIndices = {
+ DECKINDEX_TABS: 0,
+ DECKINDEX_FETCHING: 1,
+ DECKINDEX_TABSDISABLED: 2,
+ DECKINDEX_NOCLIENTS: 3,
+ };
+
+ static sRemoteTabsPerPage = 25;
+ static sRemoteTabsNextPageMinTabs = 5;
+
+ constructor(panelview, deck, tabsList, separator) {
+ this.QueryInterface = ChromeUtils.generateQI([
+ "nsIObserver",
+ "nsISupportsWeakReference",
+ ]);
+
+ Services.obs.addObserver(this, SyncedTabs.TOPIC_TABS_CHANGED, true);
+ this.deck = deck;
+ this.tabsList = tabsList;
+ this.separator = separator;
+ this._showSyncedTabsPromise = Promise.resolve();
+
+ this.createSyncedTabs();
+ }
+
+ observe(subject, topic, data) {
+ if (topic == SyncedTabs.TOPIC_TABS_CHANGED) {
+ this._showSyncedTabs();
+ }
+ }
+
+ createSyncedTabs() {
+ if (SyncedTabs.isConfiguredToSyncTabs) {
+ if (SyncedTabs.hasSyncedThisSession) {
+ this.deck.selectedIndex =
+ SyncedTabsPanelList.sRemoteTabsDeckIndices.DECKINDEX_TABS;
+ } else {
+ // Sync hasn't synced tabs yet, so show the "fetching" panel.
+ this.deck.selectedIndex =
+ SyncedTabsPanelList.sRemoteTabsDeckIndices.DECKINDEX_FETCHING;
+ }
+ // force a background sync.
+ SyncedTabs.syncTabs().catch(ex => {
+ console.error(ex);
+ });
+ this.deck.toggleAttribute("syncingtabs", true);
+ // show the current list - it will be updated by our observer.
+ this._showSyncedTabs();
+ if (this.separator) {
+ this.separator.hidden = false;
+ }
+ } else {
+ // not configured to sync tabs, so no point updating the list.
+ this.deck.selectedIndex =
+ SyncedTabsPanelList.sRemoteTabsDeckIndices.DECKINDEX_TABSDISABLED;
+ this.deck.toggleAttribute("syncingtabs", false);
+ if (this.separator) {
+ this.separator.hidden = true;
+ }
+ }
+ }
+
+ // Update the synced tab list after any existing in-flight updates are complete.
+ _showSyncedTabs(paginationInfo) {
+ this._showSyncedTabsPromise = this._showSyncedTabsPromise.then(
+ () => {
+ return this.__showSyncedTabs(paginationInfo);
+ },
+ e => {
+ console.error(e);
+ }
+ );
+ }
+
+ // Return a new promise to update the tab list.
+ __showSyncedTabs(paginationInfo) {
+ if (!this.tabsList) {
+ // Closed between the previous `this._showSyncedTabsPromise`
+ // resolving and now.
+ return undefined;
+ }
+ return SyncedTabs.getTabClients()
+ .then(clients => {
+ let noTabs = !UIState.get().syncEnabled || !clients.length;
+ this.deck.toggleAttribute("syncingtabs", !noTabs);
+ if (this.separator) {
+ this.separator.hidden = noTabs;
+ }
+
+ // The view may have been hidden while the promise was resolving.
+ if (!this.tabsList) {
+ return;
+ }
+ if (clients.length === 0 && !SyncedTabs.hasSyncedThisSession) {
+ // the "fetching tabs" deck is being shown - let's leave it there.
+ // When that first sync completes we'll be notified and update.
+ return;
+ }
+
+ if (clients.length === 0) {
+ this.deck.selectedIndex =
+ SyncedTabsPanelList.sRemoteTabsDeckIndices.DECKINDEX_NOCLIENTS;
+ return;
+ }
+ this.deck.selectedIndex =
+ SyncedTabsPanelList.sRemoteTabsDeckIndices.DECKINDEX_TABS;
+ this._clearSyncedTabList();
+ SyncedTabs.sortTabClientsByLastUsed(clients);
+ let fragment = document.createDocumentFragment();
+
+ let clientNumber = 0;
+ for (let client of clients) {
+ // add a menu separator for all clients other than the first.
+ if (fragment.lastElementChild) {
+ let separator = document.createXULElement("toolbarseparator");
+ fragment.appendChild(separator);
+ }
+ // We add the client's elements to a container, and indicate which
+ // element labels it.
+ let labelId = `synced-tabs-client-${clientNumber++}`;
+ let container = document.createXULElement("vbox");
+ container.classList.add("PanelUI-remotetabs-clientcontainer");
+ container.setAttribute("role", "group");
+ container.setAttribute("aria-labelledby", labelId);
+ if (paginationInfo && paginationInfo.clientId == client.id) {
+ this._appendSyncClient(
+ client,
+ container,
+ labelId,
+ paginationInfo.maxTabs
+ );
+ } else {
+ this._appendSyncClient(client, container, labelId);
+ }
+ fragment.appendChild(container);
+ }
+ this.tabsList.appendChild(fragment);
+ })
+ .catch(err => {
+ console.error(err);
+ })
+ .then(() => {
+ // an observer for tests.
+ Services.obs.notifyObservers(
+ null,
+ "synced-tabs-menu:test:tabs-updated"
+ );
+ });
+ }
+
+ _clearSyncedTabList() {
+ let list = this.tabsList;
+ while (list.lastChild) {
+ list.lastChild.remove();
+ }
+ }
+
+ _createNoSyncedTabsElement(messageAttr, appendTo = null) {
+ if (!appendTo) {
+ appendTo = this.tabsList;
+ }
+
+ let messageLabel = document.createXULElement("label");
+ document.l10n.setAttributes(
+ messageLabel,
+ this.tabsList.getAttribute(messageAttr)
+ );
+ appendTo.appendChild(messageLabel);
+ return messageLabel;
+ }
+
+ _appendSyncClient(
+ client,
+ container,
+ labelId,
+ maxTabs = SyncedTabsPanelList.sRemoteTabsPerPage
+ ) {
+ // Create the element for the remote client.
+ let clientItem = document.createXULElement("label");
+ clientItem.setAttribute("id", labelId);
+ clientItem.setAttribute("itemtype", "client");
+ clientItem.setAttribute(
+ "tooltiptext",
+ gSync.fluentStrings.formatValueSync("appmenu-fxa-last-sync", {
+ time: gSync.formatLastSyncDate(new Date(client.lastModified)),
+ })
+ );
+ clientItem.textContent = client.name;
+
+ container.appendChild(clientItem);
+
+ if (!client.tabs.length) {
+ let label = this._createNoSyncedTabsElement(
+ "notabsforclientlabel",
+ container
+ );
+ label.setAttribute("class", "PanelUI-remotetabs-notabsforclient-label");
+ } else {
+ // If this page will display all tabs, show no additional buttons.
+ // Otherwise, show a "Show More" button
+ let hasNextPage = client.tabs.length > maxTabs;
+ let nextPageIsLastPage =
+ hasNextPage &&
+ maxTabs + SyncedTabsPanelList.sRemoteTabsPerPage >= client.tabs.length;
+ if (nextPageIsLastPage) {
+ // When the user clicks "Show More", try to have at least sRemoteTabsNextPageMinTabs more tabs
+ // to display in order to avoid user frustration
+ maxTabs = Math.min(
+ client.tabs.length - SyncedTabsPanelList.sRemoteTabsNextPageMinTabs,
+ maxTabs
+ );
+ }
+ if (hasNextPage) {
+ client.tabs = client.tabs.slice(0, maxTabs);
+ }
+ for (let [index, tab] of client.tabs.entries()) {
+ let tabEnt = this._createSyncedTabElement(tab, index);
+ container.appendChild(tabEnt);
+ }
+ if (hasNextPage) {
+ let showAllEnt = this._createShowMoreSyncedTabsElement(client.id);
+ container.appendChild(showAllEnt);
+ }
+ }
+ }
+
+ _createSyncedTabElement(tabInfo, index) {
+ let item = document.createXULElement("toolbarbutton");
+ let tooltipText = (tabInfo.title ? tabInfo.title + "\n" : "") + tabInfo.url;
+ item.setAttribute("itemtype", "tab");
+ item.setAttribute("class", "subviewbutton");
+ item.setAttribute("targetURI", tabInfo.url);
+ item.setAttribute(
+ "label",
+ tabInfo.title != "" ? tabInfo.title : tabInfo.url
+ );
+ if (tabInfo.icon) {
+ item.setAttribute("image", tabInfo.icon);
+ }
+ item.setAttribute("tooltiptext", tooltipText);
+ // We need to use "click" instead of "command" here so openUILink
+ // respects different buttons (eg, to open in a new tab).
+ item.addEventListener("click", e => {
+ // We want to differentiate between when the fxa panel is within the app menu/hamburger bar
+ let object = "fxa_avatar_menu";
+ const appMenuPanel = document.getElementById("appMenu-popup");
+ if (appMenuPanel.contains(e.currentTarget)) {
+ object = "fxa_app_menu";
+ }
+ SyncedTabs.recordSyncedTabsTelemetry(object, "click", {
+ tab_pos: index.toString(),
+ });
+ document.defaultView.openUILink(tabInfo.url, e, {
+ triggeringPrincipal: Services.scriptSecurityManager.createNullPrincipal(
+ {}
+ ),
+ });
+ if (document.defaultView.whereToOpenLink(e) != "current") {
+ e.preventDefault();
+ e.stopPropagation();
+ } else {
+ CustomizableUI.hidePanelForNode(item);
+ }
+ });
+ return item;
+ }
+
+ _createShowMoreSyncedTabsElement(clientId) {
+ let showCount = Infinity;
+
+ let showMoreItem = document.createXULElement("toolbarbutton");
+ showMoreItem.setAttribute("itemtype", "showmorebutton");
+ showMoreItem.setAttribute("closemenu", "none");
+ showMoreItem.classList.add(
+ "subviewbutton",
+ "subviewbutton-nav",
+ "subviewbutton-nav-down"
+ );
+ document.l10n.setAttributes(showMoreItem, "appmenu-remote-tabs-showmore");
+
+ showMoreItem.addEventListener("click", e => {
+ e.preventDefault();
+ e.stopPropagation();
+ this._showSyncedTabs({ clientId, maxTabs: showCount });
+ });
+ return showMoreItem;
+ }
+
+ destroy() {
+ Services.obs.removeObserver(this, SyncedTabs.TOPIC_TABS_CHANGED);
+ this.tabsList = null;
+ this.deck = null;
+ this.separator = null;
+ }
+};
+
+var gSync = {
+ _initialized: false,
+ _isCurrentlySyncing: false,
+ // The last sync start time. Used to calculate the leftover animation time
+ // once syncing completes (bug 1239042).
+ _syncStartTime: 0,
+ _syncAnimationTimer: 0,
+ _obs: ["weave:engine:sync:finish", "quit-application", UIState.ON_UPDATE],
+
+ get log() {
+ if (!this._log) {
+ const { Log } = ChromeUtils.importESModule(
+ "resource://gre/modules/Log.sys.mjs"
+ );
+ let syncLog = Log.repository.getLogger("Sync.Browser");
+ syncLog.manageLevelFromPref("services.sync.log.logger.browser");
+ this._log = syncLog;
+ }
+ return this._log;
+ },
+
+ get fluentStrings() {
+ delete this.fluentStrings;
+ return (this.fluentStrings = new Localization(
+ [
+ "branding/brand.ftl",
+ "browser/accounts.ftl",
+ "browser/appmenu.ftl",
+ "browser/sync.ftl",
+ "toolkit/branding/accounts.ftl",
+ ],
+ true
+ ));
+ },
+
+ // Returns true if FxA is configured, but the send tab targets list isn't
+ // ready yet.
+ get sendTabConfiguredAndLoading() {
+ return (
+ UIState.get().status == UIState.STATUS_SIGNED_IN &&
+ !fxAccounts.device.recentDeviceList
+ );
+ },
+
+ get isSignedIn() {
+ return UIState.get().status == UIState.STATUS_SIGNED_IN;
+ },
+
+ shouldHideSendContextMenuItems(enabled) {
+ const state = UIState.get();
+ // Only show the "Send..." context menu items when sending would be possible
+ if (
+ enabled &&
+ state.status == UIState.STATUS_SIGNED_IN &&
+ state.syncEnabled &&
+ this.getSendTabTargets().length
+ ) {
+ return false;
+ }
+ return true;
+ },
+
+ getSendTabTargets() {
+ const targets = [];
+ if (
+ UIState.get().status != UIState.STATUS_SIGNED_IN ||
+ !fxAccounts.device.recentDeviceList
+ ) {
+ return targets;
+ }
+ for (let d of fxAccounts.device.recentDeviceList) {
+ if (d.isCurrentDevice) {
+ continue;
+ }
+
+ if (fxAccounts.commands.sendTab.isDeviceCompatible(d)) {
+ targets.push(d);
+ }
+ }
+ return targets.sort((a, b) => b.lastAccessTime - a.lastAccessTime);
+ },
+
+ _definePrefGetters() {
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "FXA_ENABLED",
+ "identity.fxaccounts.enabled"
+ );
+ },
+
+ maybeUpdateUIState() {
+ // Update the UI.
+ if (UIState.isReady()) {
+ const state = UIState.get();
+ // If we are not configured, the UI is already in the right state when
+ // we open the window. We can avoid a repaint.
+ if (state.status != UIState.STATUS_NOT_CONFIGURED) {
+ this.updateAllUI(state);
+ }
+ }
+ },
+
+ init() {
+ if (this._initialized) {
+ return;
+ }
+
+ this._definePrefGetters();
+
+ if (!this.FXA_ENABLED) {
+ this.onFxaDisabled();
+ return;
+ }
+
+ MozXULElement.insertFTLIfNeeded("browser/sync.ftl");
+
+ // Label for the sync buttons.
+ const appMenuLabel = PanelMultiView.getViewNode(
+ document,
+ "appMenu-fxa-label2"
+ );
+ if (!appMenuLabel) {
+ // We are in a window without our elements - just abort now, without
+ // setting this._initialized, so we don't attempt to remove observers.
+ return;
+ }
+ // We start with every menuitem hidden (except for the "setup sync" state),
+ // so that we don't need to init the sync UI on windows like pageInfo.xhtml
+ // (see bug 1384856).
+ // maybeUpdateUIState() also optimizes for this - if we should be in the
+ // "setup sync" state, that function assumes we are already in it and
+ // doesn't re-initialize the UI elements.
+ document.getElementById("sync-setup").hidden = false;
+ PanelMultiView.getViewNode(
+ document,
+ "PanelUI-remotetabs-setupsync"
+ ).hidden = false;
+
+ const appMenuHeaderTitle = PanelMultiView.getViewNode(
+ document,
+ "appMenu-header-title"
+ );
+ const appMenuHeaderDescription = PanelMultiView.getViewNode(
+ document,
+ "appMenu-header-description"
+ );
+ const appMenuHeaderText = PanelMultiView.getViewNode(
+ document,
+ "appMenu-fxa-text"
+ );
+ appMenuHeaderTitle.hidden = true;
+ // We must initialize the label attribute here instead of the markup
+ // due to a timing error. The fluent label attribute was being applied
+ // after we had updated appMenuLabel and thus displayed an incorrect
+ // label for signed in users.
+ const [headerDesc, headerText] = this.fluentStrings.formatValuesSync([
+ "appmenu-fxa-signed-in-label",
+ "appmenu-fxa-sync-and-save-data2",
+ ]);
+ appMenuHeaderDescription.value = headerDesc;
+ appMenuHeaderText.textContent = headerText;
+
+ for (let topic of this._obs) {
+ Services.obs.addObserver(this, topic, true);
+ }
+
+ this.maybeUpdateUIState();
+
+ EnsureFxAccountsWebChannel();
+
+ let fxaPanelView = PanelMultiView.getViewNode(document, "PanelUI-fxa");
+ fxaPanelView.addEventListener("ViewShowing", this);
+ fxaPanelView.addEventListener("ViewHiding", this);
+
+ this._initialized = true;
+ },
+
+ uninit() {
+ if (!this._initialized) {
+ return;
+ }
+
+ for (let topic of this._obs) {
+ Services.obs.removeObserver(this, topic);
+ }
+
+ this._initialized = false;
+ },
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "ViewShowing": {
+ this.onFxAPanelViewShowing(event.target);
+ break;
+ }
+ case "ViewHiding": {
+ this.onFxAPanelViewHiding(event.target);
+ }
+ }
+ },
+
+ onFxAPanelViewShowing(panelview) {
+ let syncNowBtn = panelview.querySelector(".syncnow-label");
+ let l10nId = syncNowBtn.getAttribute(
+ this._isCurrentlySyncing
+ ? "syncing-data-l10n-id"
+ : "sync-now-data-l10n-id"
+ );
+ syncNowBtn.setAttribute("data-l10n-id", l10nId);
+
+ // This needs to exist because if the user is signed in
+ // but the user disabled or disconnected sync we should not show the button
+ const syncPrefsButtonEl = PanelMultiView.getViewNode(
+ document,
+ "PanelUI-fxa-menu-sync-prefs-button"
+ );
+ syncPrefsButtonEl.hidden = !UIState.get().syncEnabled;
+
+ panelview.syncedTabsPanelList = new SyncedTabsPanelList(
+ panelview,
+ PanelMultiView.getViewNode(document, "PanelUI-fxa-remotetabs-deck"),
+ PanelMultiView.getViewNode(document, "PanelUI-fxa-remotetabs-tabslist"),
+ PanelMultiView.getViewNode(document, "PanelUI-remote-tabs-separator")
+ );
+ },
+
+ onFxAPanelViewHiding(panelview) {
+ panelview.syncedTabsPanelList.destroy();
+ panelview.syncedTabsPanelList = null;
+ },
+
+ observe(subject, topic, data) {
+ if (!this._initialized) {
+ console.error("browser-sync observer called after unload: ", topic);
+ return;
+ }
+ switch (topic) {
+ case UIState.ON_UPDATE:
+ const state = UIState.get();
+ this.updateAllUI(state);
+ break;
+ case "quit-application":
+ // Stop the animation timer on shutdown, since we can't update the UI
+ // after this.
+ clearTimeout(this._syncAnimationTimer);
+ break;
+ case "weave:engine:sync:finish":
+ if (data != "clients") {
+ return;
+ }
+ this.onClientsSynced();
+ this.updateFxAPanel(UIState.get());
+ break;
+ }
+ },
+
+ updateAllUI(state) {
+ this.updatePanelPopup(state);
+ this.updateState(state);
+ this.updateSyncButtonsTooltip(state);
+ this.updateSyncStatus(state);
+ this.updateFxAPanel(state);
+ // Ensure we have something in the device list in the background.
+ this.ensureFxaDevices();
+ },
+
+ // Ensure we have *something* in `fxAccounts.device.recentDeviceList` as some
+ // of our UI logic depends on it not being null. When FxA is notified of a
+ // device change it will auto refresh `recentDeviceList`, and all UI which
+ // shows the device list will start with `recentDeviceList`, but should also
+ // force a refresh, both of which should mean in the worst-case, the UI is up
+ // to date after a very short delay.
+ async ensureFxaDevices(options) {
+ if (UIState.get().status != UIState.STATUS_SIGNED_IN) {
+ console.info("Skipping device list refresh; not signed in");
+ return;
+ }
+ if (!fxAccounts.device.recentDeviceList) {
+ if (await this.refreshFxaDevices()) {
+ // Assuming we made the call successfully it should be impossible to end
+ // up with a falsey recentDeviceList, so make noise if that's false.
+ if (!fxAccounts.device.recentDeviceList) {
+ console.warn("Refreshing device list didn't find any devices.");
+ }
+ }
+ }
+ },
+
+ // Force a refresh of the fxa device list. Note that while it's theoretically
+ // OK to call `fxAccounts.device.refreshDeviceList` multiple times concurrently
+ // and regularly, this call tells it to avoid those protections, so will always
+ // hit the FxA servers - therefore, you should be very careful how often you
+ // call this.
+ // Returns Promise<bool> to indicate whether a refresh was actually done.
+ async refreshFxaDevices() {
+ if (UIState.get().status != UIState.STATUS_SIGNED_IN) {
+ console.info("Skipping device list refresh; not signed in");
+ return false;
+ }
+ try {
+ // Do the actual refresh telling it to avoid the "flooding" protections.
+ await fxAccounts.device.refreshDeviceList({ ignoreCached: true });
+ return true;
+ } catch (e) {
+ this.log.error("Refreshing device list failed.", e);
+ return false;
+ }
+ },
+
+ updateSendToDeviceTitle() {
+ const tabCount = gBrowser.selectedTab.multiselected
+ ? gBrowser.selectedTabs.length
+ : 1;
+ document.l10n.setArgs(
+ PanelMultiView.getViewNode(document, "PanelUI-fxa-menu-sendtab-button"),
+ { tabCount }
+ );
+ },
+
+ showSendToDeviceView(anchor) {
+ PanelUI.showSubView("PanelUI-sendTabToDevice", anchor);
+ let panelViewNode = document.getElementById("PanelUI-sendTabToDevice");
+ this._populateSendTabToDevicesView(panelViewNode);
+ },
+
+ showSendToDeviceViewFromFxaMenu(anchor) {
+ const { status } = UIState.get();
+ if (status === UIState.STATUS_NOT_CONFIGURED) {
+ PanelUI.showSubView("PanelUI-fxa-menu-sendtab-not-configured", anchor);
+ return;
+ }
+
+ const targets = this.sendTabConfiguredAndLoading
+ ? []
+ : this.getSendTabTargets();
+ if (!targets.length) {
+ PanelUI.showSubView("PanelUI-fxa-menu-sendtab-no-devices", anchor);
+ return;
+ }
+
+ this.showSendToDeviceView(anchor);
+ this.emitFxaToolbarTelemetry("send_tab", anchor);
+ },
+
+ showRemoteTabsFromFxaMenu(panel) {
+ PanelUI.showSubView("PanelUI-remotetabs", panel);
+ this.emitFxaToolbarTelemetry("sync_tabs", panel);
+ },
+
+ showSidebarFromFxaMenu(panel) {
+ SidebarUI.toggle("viewTabsSidebar");
+ this.emitFxaToolbarTelemetry("sync_tabs_sidebar", panel);
+ },
+
+ _populateSendTabToDevicesView(panelViewNode, reloadDevices = true) {
+ let bodyNode = panelViewNode.querySelector(".panel-subview-body");
+ let panelNode = panelViewNode.closest("panel");
+ let browser = gBrowser.selectedBrowser;
+ let uri = browser.currentURI;
+ let title = browser.contentTitle;
+ let multiselected = gBrowser.selectedTab.multiselected;
+
+ // This is on top because it also clears the device list between state
+ // changes.
+ this.populateSendTabToDevicesMenu(
+ bodyNode,
+ uri,
+ title,
+ multiselected,
+ (clientId, name, clientType, lastModified) => {
+ if (!name) {
+ return document.createXULElement("toolbarseparator");
+ }
+ let item = document.createXULElement("toolbarbutton");
+ item.setAttribute("wrap", true);
+ item.setAttribute("align", "start");
+ item.classList.add("sendToDevice-device", "subviewbutton");
+ if (clientId) {
+ item.classList.add("subviewbutton-iconic");
+ if (lastModified) {
+ let lastSyncDate = gSync.formatLastSyncDate(lastModified);
+ if (lastSyncDate) {
+ item.setAttribute(
+ "tooltiptext",
+ this.fluentStrings.formatValueSync("appmenu-fxa-last-sync", {
+ time: lastSyncDate,
+ })
+ );
+ }
+ }
+ }
+
+ item.addEventListener("command", event => {
+ if (panelNode) {
+ PanelMultiView.hidePopup(panelNode);
+ }
+ });
+ return item;
+ },
+ true
+ );
+
+ bodyNode.removeAttribute("state");
+ // If the app just started, we won't have fetched the device list yet. Sync
+ // does this automatically ~10 sec after startup, but there's no trigger for
+ // this if we're signed in to FxA, but not Sync.
+ if (gSync.sendTabConfiguredAndLoading) {
+ bodyNode.setAttribute("state", "notready");
+ }
+ if (reloadDevices) {
+ // We will only pick up new Fennec clients if we sync the clients engine,
+ // but all other send-tab targets can be identified purely from the fxa
+ // device list. Syncing the clients engine doesn't force a refresh of the
+ // fxa list, and it seems overkill to force *both* a clients engine sync
+ // and an fxa device list refresh, especially given (a) the clients engine
+ // will sync by itself every 10 minutes and (b) Fennec is (at time of
+ // writing) about to be replaced by Fenix.
+ // So we suck up the fact that new Fennec clients may not appear for 10
+ // minutes and don't bother syncing the clients engine.
+
+ // Force a refresh of the fxa device list in case the user connected a new
+ // device, and is waiting for it to show up.
+ this.refreshFxaDevices().then(_ => {
+ if (!window.closed) {
+ this._populateSendTabToDevicesView(panelViewNode, false);
+ }
+ });
+ }
+ },
+
+ toggleAccountPanel(
+ anchor = document.getElementById("fxa-toolbar-menu-button"),
+ aEvent
+ ) {
+ // Don't show the panel if the window is in customization mode.
+ if (document.documentElement.hasAttribute("customizing")) {
+ return;
+ }
+
+ if (
+ (aEvent.type == "mousedown" && aEvent.button != 0) ||
+ (aEvent.type == "keypress" &&
+ aEvent.charCode != KeyEvent.DOM_VK_SPACE &&
+ aEvent.keyCode != KeyEvent.DOM_VK_RETURN)
+ ) {
+ return;
+ }
+
+ // We read the state that's been set on the root node, since that makes
+ // it easier to test the various front-end states without having to actually
+ // have UIState know about it.
+ let fxaStatus = document.documentElement.getAttribute("fxastatus");
+
+ if (fxaStatus == "not_configured") {
+ let extraParams = {};
+ let fxaButtonVisibilityExperiment =
+ ExperimentAPI.getExperimentMetaData({
+ featureId: "fxaButtonVisibility",
+ }) ??
+ ExperimentAPI.getRolloutMetaData({
+ featureId: "fxaButtonVisibility",
+ });
+ if (fxaButtonVisibilityExperiment) {
+ extraParams = {
+ entrypoint_experiment: fxaButtonVisibilityExperiment.slug,
+ entrypoint_variation: fxaButtonVisibilityExperiment.branch.slug,
+ };
+ }
+
+ let panel =
+ anchor.id == "appMenu-fxa-label2"
+ ? PanelMultiView.getViewNode(document, "PanelUI-fxa")
+ : undefined;
+ this.openFxAEmailFirstPageFromFxaMenu(panel, extraParams);
+ PanelUI.hide();
+ return;
+ }
+
+ if (!gFxaToolbarAccessed) {
+ Services.prefs.setBoolPref("identity.fxaccounts.toolbar.accessed", true);
+ }
+
+ this.enableSendTabIfValidTab();
+
+ if (!this.getSendTabTargets().length) {
+ PanelMultiView.getViewNode(
+ document,
+ "PanelUI-fxa-menu-sendtab-button"
+ ).hidden = true;
+ }
+
+ if (anchor.getAttribute("open") == "true") {
+ PanelUI.hide();
+ } else {
+ this.emitFxaToolbarTelemetry("toolbar_icon", anchor);
+ PanelUI.showSubView("PanelUI-fxa", anchor, aEvent);
+ }
+ },
+
+ updateFxAPanel(state = {}) {
+ const mainWindowEl = document.documentElement;
+
+ // The Firefox Account toolbar currently handles 3 different states for
+ // users. The default `not_configured` state shows an empty avatar, `unverified`
+ // state shows an avatar with an email icon, `login-failed` state shows an avatar
+ // with a danger icon and the `verified` state will show the users
+ // custom profile image or a filled avatar.
+ let stateValue = "not_configured";
+
+ const menuHeaderTitleEl = PanelMultiView.getViewNode(
+ document,
+ "fxa-menu-header-title"
+ );
+ const menuHeaderDescriptionEl = PanelMultiView.getViewNode(
+ document,
+ "fxa-menu-header-description"
+ );
+
+ const cadButtonEl = PanelMultiView.getViewNode(
+ document,
+ "PanelUI-fxa-menu-connect-device-button"
+ );
+
+ const syncSetupButtonEl = PanelMultiView.getViewNode(
+ document,
+ "PanelUI-fxa-menu-setup-sync-button"
+ );
+
+ const syncNowButtonEl = PanelMultiView.getViewNode(
+ document,
+ "PanelUI-fxa-menu-syncnow-button"
+ );
+
+ const fxaMenuAccountButtonEl = PanelMultiView.getViewNode(
+ document,
+ "fxa-manage-account-button"
+ );
+
+ cadButtonEl.setAttribute("disabled", true);
+ syncNowButtonEl.hidden = true;
+ fxaMenuAccountButtonEl.classList.remove("subviewbutton-nav");
+ fxaMenuAccountButtonEl.removeAttribute("closemenu");
+ syncSetupButtonEl.removeAttribute("hidden");
+
+ let headerTitleL10nId = "appmenuitem-fxa-sign-in";
+ let headerDescription;
+ if (state.status === UIState.STATUS_NOT_CONFIGURED) {
+ mainWindowEl.style.removeProperty("--avatar-image-url");
+ headerDescription = this.fluentStrings.formatValueSync(
+ "appmenu-fxa-signed-in-label"
+ );
+ } else if (state.status === UIState.STATUS_LOGIN_FAILED) {
+ stateValue = "login-failed";
+ headerTitleL10nId = "account-disconnected2";
+ headerDescription = state.email;
+ mainWindowEl.style.removeProperty("--avatar-image-url");
+ } else if (state.status === UIState.STATUS_NOT_VERIFIED) {
+ stateValue = "unverified";
+ headerTitleL10nId = "account-finish-account-setup";
+ headerDescription = state.email;
+ } else if (state.status === UIState.STATUS_SIGNED_IN) {
+ stateValue = "signedin";
+ if (state.avatarURL && !state.avatarIsDefault) {
+ // The user has specified a custom avatar, attempt to load the image on all the menu buttons.
+ const bgImage = `url("${state.avatarURL}")`;
+ let img = new Image();
+ img.onload = () => {
+ // If the image has successfully loaded, update the menu buttons else
+ // we will use the default avatar image.
+ mainWindowEl.style.setProperty("--avatar-image-url", bgImage);
+ };
+ img.onerror = () => {
+ // If the image failed to load, remove the property and default
+ // to standard avatar.
+ mainWindowEl.style.removeProperty("--avatar-image-url");
+ };
+ img.src = state.avatarURL;
+ } else {
+ mainWindowEl.style.removeProperty("--avatar-image-url");
+ }
+
+ cadButtonEl.removeAttribute("disabled");
+
+ if (state.syncEnabled) {
+ syncNowButtonEl.removeAttribute("hidden");
+ syncSetupButtonEl.hidden = true;
+ }
+
+ headerTitleL10nId = "appmenuitem-fxa-manage-account";
+ headerDescription = state.email;
+ } else {
+ headerDescription = this.fluentStrings.formatValueSync(
+ "fxa-menu-turn-on-sync-default"
+ );
+ }
+ mainWindowEl.setAttribute("fxastatus", stateValue);
+
+ menuHeaderTitleEl.value =
+ this.fluentStrings.formatValueSync(headerTitleL10nId);
+ menuHeaderDescriptionEl.value = headerDescription;
+ // We remove the data-l10n-id attribute here to prevent the node's value
+ // attribute from being overwritten by Fluent when the panel is moved
+ // around in the DOM.
+ menuHeaderTitleEl.removeAttribute("data-l10n-id");
+ menuHeaderDescriptionEl.removeAttribute("data-l10n-id");
+ },
+
+ enableSendTabIfValidTab() {
+ // All tabs selected must be sendable for the Send Tab button to be enabled
+ // on the FxA menu.
+ let canSendAllURIs = gBrowser.selectedTabs.every(
+ t => !!BrowserUtils.getShareableURL(t.linkedBrowser.currentURI)
+ );
+
+ PanelMultiView.getViewNode(
+ document,
+ "PanelUI-fxa-menu-sendtab-button"
+ ).hidden = !canSendAllURIs;
+ },
+
+ emitFxaToolbarTelemetry(type, panel) {
+ if (UIState.isReady() && panel) {
+ const state = UIState.get();
+ const hasAvatar = state.avatarURL && !state.avatarIsDefault;
+ let extraOptions = {
+ fxa_status: state.status,
+ fxa_avatar: hasAvatar ? "true" : "false",
+ };
+
+ // When the fxa avatar panel is within the Firefox app menu,
+ // we emit different telemetry.
+ let eventName = "fxa_avatar_menu";
+ if (this.isPanelInsideAppMenu(panel)) {
+ eventName = "fxa_app_menu";
+ }
+
+ Services.telemetry.recordEvent(
+ eventName,
+ "click",
+ type,
+ null,
+ extraOptions
+ );
+ }
+ },
+
+ isPanelInsideAppMenu(panel = undefined) {
+ const appMenuPanel = document.getElementById("appMenu-popup");
+ if (panel && appMenuPanel.contains(panel)) {
+ return true;
+ }
+ return false;
+ },
+
+ updatePanelPopup({ email, status }) {
+ const appMenuStatus = PanelMultiView.getViewNode(
+ document,
+ "appMenu-fxa-status2"
+ );
+ const appMenuLabel = PanelMultiView.getViewNode(
+ document,
+ "appMenu-fxa-label2"
+ );
+ const appMenuHeaderText = PanelMultiView.getViewNode(
+ document,
+ "appMenu-fxa-text"
+ );
+ const appMenuHeaderTitle = PanelMultiView.getViewNode(
+ document,
+ "appMenu-header-title"
+ );
+ const appMenuHeaderDescription = PanelMultiView.getViewNode(
+ document,
+ "appMenu-header-description"
+ );
+ const fxaPanelView = PanelMultiView.getViewNode(document, "PanelUI-fxa");
+
+ let defaultLabel = this.fluentStrings.formatValueSync(
+ "appmenu-fxa-signed-in-label"
+ );
+ // Reset the status bar to its original state.
+ appMenuLabel.setAttribute("label", defaultLabel);
+ appMenuLabel.removeAttribute("aria-labelledby");
+ appMenuStatus.removeAttribute("fxastatus");
+
+ if (status == UIState.STATUS_NOT_CONFIGURED) {
+ appMenuHeaderText.hidden = false;
+ appMenuStatus.classList.add("toolbaritem-combined-buttons");
+ appMenuLabel.classList.remove("subviewbutton-nav");
+ appMenuHeaderTitle.hidden = true;
+ appMenuHeaderDescription.value = defaultLabel;
+ return;
+ }
+ appMenuLabel.classList.remove("subviewbutton-nav");
+
+ appMenuHeaderText.hidden = true;
+ appMenuStatus.classList.remove("toolbaritem-combined-buttons");
+
+ // At this point we consider sync to be configured (but still can be in an error state).
+ if (status == UIState.STATUS_LOGIN_FAILED) {
+ const [tooltipDescription, errorLabel] =
+ this.fluentStrings.formatValuesSync([
+ { id: "account-reconnect", args: { email } },
+ { id: "account-disconnected2" },
+ ]);
+ appMenuStatus.setAttribute("fxastatus", "login-failed");
+ appMenuStatus.setAttribute("tooltiptext", tooltipDescription);
+ appMenuLabel.classList.add("subviewbutton-nav");
+ appMenuHeaderTitle.hidden = false;
+ appMenuHeaderTitle.value = errorLabel;
+ appMenuHeaderDescription.value = email;
+
+ appMenuLabel.removeAttribute("label");
+ appMenuLabel.setAttribute(
+ "aria-labelledby",
+ `${appMenuHeaderTitle.id},${appMenuHeaderDescription.id}`
+ );
+ return;
+ } else if (status == UIState.STATUS_NOT_VERIFIED) {
+ const [tooltipDescription, unverifiedLabel] =
+ this.fluentStrings.formatValuesSync([
+ { id: "account-verify", args: { email } },
+ { id: "account-finish-account-setup" },
+ ]);
+ appMenuStatus.setAttribute("fxastatus", "unverified");
+ appMenuStatus.setAttribute("tooltiptext", tooltipDescription);
+ appMenuLabel.classList.add("subviewbutton-nav");
+ appMenuHeaderTitle.hidden = false;
+ appMenuHeaderTitle.value = unverifiedLabel;
+ appMenuHeaderDescription.value = email;
+
+ appMenuLabel.removeAttribute("label");
+ appMenuLabel.setAttribute(
+ "aria-labelledby",
+ `${appMenuHeaderTitle.id},${appMenuHeaderDescription.id}`
+ );
+ return;
+ }
+
+ // At this point we consider sync to be logged-in.
+ appMenuHeaderTitle.hidden = true;
+ appMenuHeaderDescription.value = email;
+ appMenuStatus.setAttribute("fxastatus", "signedin");
+ appMenuLabel.setAttribute("label", email);
+ appMenuLabel.classList.add("subviewbutton-nav");
+ fxaPanelView.setAttribute(
+ "title",
+ this.fluentStrings.formatValueSync("appmenu-fxa-header2")
+ );
+ appMenuStatus.removeAttribute("tooltiptext");
+ },
+
+ updateState(state) {
+ for (let [shown, menuId, boxId] of [
+ [
+ state.status == UIState.STATUS_NOT_CONFIGURED,
+ "sync-setup",
+ "PanelUI-remotetabs-setupsync",
+ ],
+ [
+ state.status == UIState.STATUS_SIGNED_IN && !state.syncEnabled,
+ "sync-enable",
+ "PanelUI-remotetabs-syncdisabled",
+ ],
+ [
+ state.status == UIState.STATUS_LOGIN_FAILED,
+ "sync-reauthitem",
+ "PanelUI-remotetabs-reauthsync",
+ ],
+ [
+ state.status == UIState.STATUS_NOT_VERIFIED,
+ "sync-unverifieditem",
+ "PanelUI-remotetabs-unverified",
+ ],
+ [
+ state.status == UIState.STATUS_SIGNED_IN && state.syncEnabled,
+ "sync-syncnowitem",
+ "PanelUI-remotetabs-main",
+ ],
+ ]) {
+ document.getElementById(menuId).hidden = PanelMultiView.getViewNode(
+ document,
+ boxId
+ ).hidden = !shown;
+ }
+ },
+
+ updateSyncStatus(state) {
+ let syncNow =
+ document.querySelector(".syncNowBtn") ||
+ document
+ .getElementById("appMenu-viewCache")
+ .content.querySelector(".syncNowBtn");
+ const syncingUI = syncNow.getAttribute("syncstatus") == "active";
+ if (state.syncing != syncingUI) {
+ // Do we need to update the UI?
+ state.syncing ? this.onActivityStart() : this.onActivityStop();
+ }
+ },
+
+ async openSignInAgainPage(entryPoint) {
+ if (!(await FxAccounts.canConnectAccount())) {
+ return;
+ }
+ const url = await FxAccounts.config.promiseForceSigninURI(entryPoint);
+ switchToTabHavingURI(url, true, {
+ replaceQueryString: true,
+ triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
+ });
+ },
+
+ async openDevicesManagementPage(entryPoint) {
+ let url = await FxAccounts.config.promiseManageDevicesURI(entryPoint);
+ switchToTabHavingURI(url, true, {
+ replaceQueryString: true,
+ triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
+ });
+ },
+
+ async openConnectAnotherDevice(entryPoint) {
+ const url = await FxAccounts.config.promiseConnectDeviceURI(entryPoint);
+ openTrustedLinkIn(url, "tab");
+ },
+
+ async openConnectAnotherDeviceFromFxaMenu(panel = undefined) {
+ this.emitFxaToolbarTelemetry("cad", panel);
+ let entryPoint = "fxa_discoverability_native";
+ if (this.isPanelInsideAppMenu(panel)) {
+ entryPoint = "fxa_app_menu";
+ }
+ this.openConnectAnotherDevice(entryPoint);
+ },
+
+ openSendToDevicePromo() {
+ const url = Services.urlFormatter.formatURLPref(
+ "identity.sendtabpromo.url"
+ );
+ switchToTabHavingURI(url, true, { replaceQueryString: true });
+ },
+
+ async clickFxAMenuHeaderButton(panel = undefined) {
+ // Depending on the current logged in state of a user,
+ // clicking the FxA header will either open
+ // a sign-in page, account management page, or sync
+ // preferences page.
+ const { status } = UIState.get();
+ switch (status) {
+ case UIState.STATUS_NOT_CONFIGURED:
+ this.openFxAEmailFirstPageFromFxaMenu(panel);
+ break;
+ case UIState.STATUS_LOGIN_FAILED:
+ case UIState.STATUS_NOT_VERIFIED:
+ this.openPrefsFromFxaMenu("sync_settings", panel);
+ break;
+ case UIState.STATUS_SIGNED_IN:
+ this.openFxAManagePageFromFxaMenu(panel);
+ }
+ },
+
+ async openFxAEmailFirstPage(entryPoint, extraParams = {}) {
+ if (!(await FxAccounts.canConnectAccount())) {
+ return;
+ }
+ const url = await FxAccounts.config.promiseConnectAccountURI(
+ entryPoint,
+ extraParams
+ );
+ switchToTabHavingURI(url, true, { replaceQueryString: true });
+ },
+
+ async openFxAEmailFirstPageFromFxaMenu(panel = undefined, extraParams = {}) {
+ this.emitFxaToolbarTelemetry("login", panel);
+ let entryPoint = "fxa_discoverability_native";
+ if (panel) {
+ entryPoint = "fxa_app_menu";
+ }
+ this.openFxAEmailFirstPage(entryPoint, extraParams);
+ },
+
+ async openFxAManagePage(entryPoint) {
+ const url = await FxAccounts.config.promiseManageURI(entryPoint);
+ switchToTabHavingURI(url, true, { replaceQueryString: true });
+ },
+
+ async openFxAManagePageFromFxaMenu(panel = undefined) {
+ this.emitFxaToolbarTelemetry("account_settings", panel);
+ let entryPoint = "fxa_discoverability_native";
+ if (this.isPanelInsideAppMenu(panel)) {
+ entryPoint = "fxa_app_menu";
+ }
+ this.openFxAManagePage(entryPoint);
+ },
+
+ // Returns true if we managed to send the tab to any targets, false otherwise.
+ async sendTabToDevice(url, targets, title) {
+ const fxaCommandsDevices = [];
+ for (const target of targets) {
+ if (fxAccounts.commands.sendTab.isDeviceCompatible(target)) {
+ fxaCommandsDevices.push(target);
+ } else {
+ this.log.error(`Target ${target.id} unsuitable for send tab.`);
+ }
+ }
+ // If a primary-password is enabled then it must be unlocked so FxA can get
+ // the encryption keys from the login manager. (If we end up using the "sync"
+ // fallback that would end up prompting by itself, but the FxA command route
+ // will not) - so force that here.
+ let cryptoSDR = Cc["@mozilla.org/login-manager/crypto/SDR;1"].getService(
+ Ci.nsILoginManagerCrypto
+ );
+ if (!cryptoSDR.isLoggedIn) {
+ if (cryptoSDR.uiBusy) {
+ this.log.info("Master password UI is busy - not sending the tabs");
+ return false;
+ }
+ try {
+ cryptoSDR.encrypt("bacon"); // forces the mp prompt.
+ } catch (e) {
+ this.log.info(
+ "Master password remains unlocked - not sending the tabs"
+ );
+ return false;
+ }
+ }
+ let numFailed = 0;
+ if (fxaCommandsDevices.length) {
+ this.log.info(
+ `Sending a tab to ${fxaCommandsDevices
+ .map(d => d.id)
+ .join(", ")} using FxA commands.`
+ );
+ const report = await fxAccounts.commands.sendTab.send(
+ fxaCommandsDevices,
+ { url, title }
+ );
+ for (let { device, error } of report.failed) {
+ this.log.error(
+ `Failed to send a tab with FxA commands for ${device.id}.`,
+ error
+ );
+ numFailed++;
+ }
+ }
+ return numFailed < targets.length; // Good enough.
+ },
+
+ populateSendTabToDevicesMenu(
+ devicesPopup,
+ uri,
+ title,
+ multiselected,
+ createDeviceNodeFn,
+ isFxaMenu = false
+ ) {
+ uri = BrowserUtils.getShareableURL(uri);
+ if (!uri) {
+ // log an error as everyone should have already checked this.
+ this.log.error("Ignoring request to share a non-sharable URL");
+ return;
+ }
+ if (!createDeviceNodeFn) {
+ createDeviceNodeFn = (targetId, name, targetType, lastModified) => {
+ let eltName = name ? "menuitem" : "menuseparator";
+ return document.createXULElement(eltName);
+ };
+ }
+
+ // remove existing menu items
+ for (let i = devicesPopup.children.length - 1; i >= 0; --i) {
+ let child = devicesPopup.children[i];
+ if (child.classList.contains("sync-menuitem")) {
+ child.remove();
+ }
+ }
+
+ if (gSync.sendTabConfiguredAndLoading) {
+ // We can only be in this case in the page action menu.
+ return;
+ }
+
+ const fragment = document.createDocumentFragment();
+
+ const state = UIState.get();
+ if (state.status == UIState.STATUS_SIGNED_IN) {
+ const targets = this.getSendTabTargets();
+ if (targets.length) {
+ this._appendSendTabDeviceList(
+ targets,
+ fragment,
+ createDeviceNodeFn,
+ uri.spec,
+ title,
+ multiselected,
+ isFxaMenu
+ );
+ } else {
+ this._appendSendTabSingleDevice(fragment, createDeviceNodeFn);
+ }
+ } else if (
+ state.status == UIState.STATUS_NOT_VERIFIED ||
+ state.status == UIState.STATUS_LOGIN_FAILED
+ ) {
+ this._appendSendTabVerify(fragment, createDeviceNodeFn);
+ } else {
+ // The only status not handled yet is STATUS_NOT_CONFIGURED, and
+ // when we're in that state, none of the menus that call
+ // populateSendTabToDevicesMenu are available, so entering this
+ // state is unexpected.
+ throw new Error(
+ "Called populateSendTabToDevicesMenu when in STATUS_NOT_CONFIGURED " +
+ "state."
+ );
+ }
+
+ devicesPopup.appendChild(fragment);
+ },
+
+ _appendSendTabDeviceList(
+ targets,
+ fragment,
+ createDeviceNodeFn,
+ url,
+ title,
+ multiselected,
+ isFxaMenu = false
+ ) {
+ let tabsToSend = multiselected
+ ? gBrowser.selectedTabs.map(t => {
+ return {
+ url: t.linkedBrowser.currentURI.spec,
+ title: t.linkedBrowser.contentTitle,
+ };
+ })
+ : [{ url, title }];
+
+ const send = to => {
+ Promise.all(
+ tabsToSend.map(t =>
+ // sendTabToDevice does not reject.
+ this.sendTabToDevice(t.url, to, t.title)
+ )
+ ).then(results => {
+ // Show the Sent! confirmation if any of the sends succeeded.
+ if (results.includes(true)) {
+ // FxA button could be hidden with CSS since the user is logged out,
+ // although it seems likely this would only happen in testing...
+ let fxastatus = document.documentElement.getAttribute("fxastatus");
+ let anchorNode =
+ (fxastatus &&
+ fxastatus != "not_configured" &&
+ document.getElementById("fxa-toolbar-menu-button")?.parentNode
+ ?.id != "widget-overflow-list" &&
+ document.getElementById("fxa-toolbar-menu-button")) ||
+ document.getElementById("PanelUI-menu-button");
+ ConfirmationHint.show(anchorNode, "confirmation-hint-send-to-device");
+ }
+ fxAccounts.flushLogFile();
+ });
+ };
+ const onSendAllCommand = event => {
+ send(targets);
+ };
+ const onTargetDeviceCommand = event => {
+ const targetId = event.target.getAttribute("clientId");
+ const target = targets.find(t => t.id == targetId);
+ send([target]);
+ };
+
+ function addTargetDevice(targetId, name, targetType, lastModified) {
+ const targetDevice = createDeviceNodeFn(
+ targetId,
+ name,
+ targetType,
+ lastModified
+ );
+ targetDevice.addEventListener(
+ "command",
+ targetId ? onTargetDeviceCommand : onSendAllCommand,
+ true
+ );
+ targetDevice.classList.add("sync-menuitem", "sendtab-target");
+ targetDevice.setAttribute("clientId", targetId);
+ targetDevice.setAttribute("clientType", targetType);
+ targetDevice.setAttribute("label", name);
+ fragment.appendChild(targetDevice);
+ }
+
+ for (let target of targets) {
+ let type, lastModified;
+ if (target.clientRecord) {
+ type = Weave.Service.clientsEngine.getClientType(
+ target.clientRecord.id
+ );
+ lastModified = new Date(target.clientRecord.serverLastModified * 1000);
+ } else {
+ // For phones, FxA uses "mobile" and Sync clients uses "phone".
+ type = target.type == "mobile" ? "phone" : target.type;
+ lastModified = target.lastAccessTime
+ ? new Date(target.lastAccessTime)
+ : null;
+ }
+ addTargetDevice(target.id, target.name, type, lastModified);
+ }
+
+ if (targets.length > 1) {
+ // "Send to All Devices" menu item
+ const separator = createDeviceNodeFn();
+ separator.classList.add("sync-menuitem");
+ fragment.appendChild(separator);
+ const [allDevicesLabel, manageDevicesLabel] =
+ this.fluentStrings.formatValuesSync(
+ isFxaMenu
+ ? ["account-send-to-all-devices", "account-manage-devices"]
+ : [
+ "account-send-to-all-devices-titlecase",
+ "account-manage-devices-titlecase",
+ ]
+ );
+ addTargetDevice("", allDevicesLabel, "");
+
+ // "Manage devices" menu item
+ // We piggyback on the createDeviceNodeFn implementation,
+ // it's a big disgusting.
+ const targetDevice = createDeviceNodeFn(
+ null,
+ manageDevicesLabel,
+ null,
+ null
+ );
+ targetDevice.addEventListener(
+ "command",
+ () => gSync.openDevicesManagementPage("sendtab"),
+ true
+ );
+ targetDevice.classList.add("sync-menuitem", "sendtab-target");
+ targetDevice.setAttribute("label", manageDevicesLabel);
+ fragment.appendChild(targetDevice);
+ }
+ },
+
+ _appendSendTabSingleDevice(fragment, createDeviceNodeFn) {
+ const [noDevices, learnMore, connectDevice] =
+ this.fluentStrings.formatValuesSync([
+ "account-send-tab-to-device-singledevice-status",
+ "account-send-tab-to-device-singledevice-learnmore",
+ "account-send-tab-to-device-connectdevice",
+ ]);
+ const actions = [
+ {
+ label: connectDevice,
+ command: () => this.openConnectAnotherDevice("sendtab"),
+ },
+ { label: learnMore, command: () => this.openSendToDevicePromo() },
+ ];
+ this._appendSendTabInfoItems(
+ fragment,
+ createDeviceNodeFn,
+ noDevices,
+ actions
+ );
+ },
+
+ _appendSendTabVerify(fragment, createDeviceNodeFn) {
+ const [notVerified, verifyAccount] = this.fluentStrings.formatValuesSync([
+ "account-send-tab-to-device-verify-status",
+ "account-send-tab-to-device-verify",
+ ]);
+ const actions = [
+ { label: verifyAccount, command: () => this.openPrefs("sendtab") },
+ ];
+ this._appendSendTabInfoItems(
+ fragment,
+ createDeviceNodeFn,
+ notVerified,
+ actions
+ );
+ },
+
+ _appendSendTabInfoItems(fragment, createDeviceNodeFn, statusLabel, actions) {
+ const status = createDeviceNodeFn(null, statusLabel, null);
+ status.setAttribute("label", statusLabel);
+ status.setAttribute("disabled", true);
+ status.classList.add("sync-menuitem");
+ fragment.appendChild(status);
+
+ const separator = createDeviceNodeFn(null, null, null);
+ separator.classList.add("sync-menuitem");
+ fragment.appendChild(separator);
+
+ for (let { label, command } of actions) {
+ const actionItem = createDeviceNodeFn(null, label, null);
+ actionItem.addEventListener("command", command, true);
+ actionItem.classList.add("sync-menuitem");
+ actionItem.setAttribute("label", label);
+ fragment.appendChild(actionItem);
+ }
+ },
+
+ // "Send Tab to Device" menu item
+ updateTabContextMenu(aPopupMenu, aTargetTab) {
+ // We may get here before initialisation. This situation
+ // can lead to a empty label for 'Send To Device' Menu.
+ this.init();
+
+ if (!this.FXA_ENABLED) {
+ // These items are hidden in onFxaDisabled(). No need to do anything.
+ return;
+ }
+ let hasASendableURI = false;
+ for (let tab of aTargetTab.multiselected
+ ? gBrowser.selectedTabs
+ : [aTargetTab]) {
+ if (BrowserUtils.getShareableURL(tab.linkedBrowser.currentURI)) {
+ hasASendableURI = true;
+ break;
+ }
+ }
+ const enabled = !this.sendTabConfiguredAndLoading && hasASendableURI;
+ const hideItems = this.shouldHideSendContextMenuItems(enabled);
+
+ let sendTabsToDevice = document.getElementById("context_sendTabToDevice");
+ sendTabsToDevice.disabled = !enabled;
+
+ if (hideItems || !hasASendableURI) {
+ sendTabsToDevice.hidden = true;
+ } else {
+ let tabCount = aTargetTab.multiselected
+ ? gBrowser.multiSelectedTabsCount
+ : 1;
+ sendTabsToDevice.setAttribute(
+ "data-l10n-args",
+ JSON.stringify({ tabCount })
+ );
+ sendTabsToDevice.hidden = false;
+ }
+ },
+
+ // "Send Page to Device" and "Send Link to Device" menu items
+ updateContentContextMenu(contextMenu) {
+ if (!this.FXA_ENABLED) {
+ // These items are hidden by default. No need to do anything.
+ return false;
+ }
+ // showSendLink and showSendPage are mutually exclusive
+ const showSendLink =
+ contextMenu.onSaveableLink || contextMenu.onPlainTextLink;
+ const showSendPage =
+ !showSendLink &&
+ !(
+ contextMenu.isContentSelected ||
+ contextMenu.onImage ||
+ contextMenu.onCanvas ||
+ contextMenu.onVideo ||
+ contextMenu.onAudio ||
+ contextMenu.onLink ||
+ contextMenu.onTextInput
+ );
+
+ const targetURI = showSendLink
+ ? contextMenu.getLinkURI()
+ : contextMenu.browser.currentURI;
+ const enabled =
+ !this.sendTabConfiguredAndLoading &&
+ BrowserUtils.getShareableURL(targetURI);
+ const hideItems = this.shouldHideSendContextMenuItems(enabled);
+
+ contextMenu.showItem(
+ "context-sendpagetodevice",
+ !hideItems && showSendPage
+ );
+ contextMenu.showItem(
+ "context-sendlinktodevice",
+ !hideItems && showSendLink
+ );
+
+ if (!showSendLink && !showSendPage) {
+ return false;
+ }
+
+ contextMenu.setItemAttr(
+ showSendPage ? "context-sendpagetodevice" : "context-sendlinktodevice",
+ "disabled",
+ !enabled || null
+ );
+ // return true if context menu items are visible
+ return !hideItems && (showSendPage || showSendLink);
+ },
+
+ // Functions called by observers
+ onActivityStart() {
+ this._isCurrentlySyncing = true;
+ clearTimeout(this._syncAnimationTimer);
+ this._syncStartTime = Date.now();
+
+ document.querySelectorAll(".syncnow-label").forEach(el => {
+ let l10nId = el.getAttribute("syncing-data-l10n-id");
+ el.setAttribute("data-l10n-id", l10nId);
+ });
+
+ document.querySelectorAll(".syncNowBtn").forEach(el => {
+ el.setAttribute("syncstatus", "active");
+ });
+
+ document
+ .getElementById("appMenu-viewCache")
+ .content.querySelectorAll(".syncNowBtn")
+ .forEach(el => {
+ el.setAttribute("syncstatus", "active");
+ });
+ },
+
+ _onActivityStop() {
+ this._isCurrentlySyncing = false;
+ if (!gBrowser) {
+ return;
+ }
+
+ document.querySelectorAll(".syncnow-label").forEach(el => {
+ let l10nId = el.getAttribute("sync-now-data-l10n-id");
+ el.setAttribute("data-l10n-id", l10nId);
+ });
+
+ document.querySelectorAll(".syncNowBtn").forEach(el => {
+ el.removeAttribute("syncstatus");
+ });
+
+ document
+ .getElementById("appMenu-viewCache")
+ .content.querySelectorAll(".syncNowBtn")
+ .forEach(el => {
+ el.removeAttribute("syncstatus");
+ });
+
+ Services.obs.notifyObservers(null, "test:browser-sync:activity-stop");
+ },
+
+ onActivityStop() {
+ let now = Date.now();
+ let syncDuration = now - this._syncStartTime;
+
+ if (syncDuration < MIN_STATUS_ANIMATION_DURATION) {
+ let animationTime = MIN_STATUS_ANIMATION_DURATION - syncDuration;
+ clearTimeout(this._syncAnimationTimer);
+ this._syncAnimationTimer = setTimeout(
+ () => this._onActivityStop(),
+ animationTime
+ );
+ } else {
+ this._onActivityStop();
+ }
+ },
+
+ // Disconnect from sync, and optionally disconnect from the FxA account.
+ // Returns true if the disconnection happened (ie, if the user didn't decline
+ // when asked to confirm)
+ async disconnect({ confirm = true, disconnectAccount = true } = {}) {
+ if (disconnectAccount) {
+ let deleteLocalData = false;
+ if (confirm) {
+ let options = await this._confirmFxaAndSyncDisconnect();
+ if (!options.userConfirmedDisconnect) {
+ return false;
+ }
+ deleteLocalData = options.deleteLocalData;
+ }
+ return this._disconnectFxaAndSync(deleteLocalData);
+ }
+
+ if (confirm && !(await this._confirmSyncDisconnect())) {
+ return false;
+ }
+ return this._disconnectSync();
+ },
+
+ // Prompt the user to confirm disconnect from FxA and sync with the option
+ // to delete syncable data from the device.
+ async _confirmFxaAndSyncDisconnect() {
+ let options = {
+ userConfirmedDisconnect: false,
+ deleteLocalData: false,
+ };
+
+ let [title, body, button, checkbox] = await document.l10n.formatValues([
+ { id: "fxa-signout-dialog2-title" },
+ { id: "fxa-signout-dialog-body" },
+ { id: "fxa-signout-dialog2-button" },
+ { id: "fxa-signout-dialog2-checkbox" },
+ ]);
+
+ const flags =
+ Services.prompt.BUTTON_TITLE_IS_STRING * Services.prompt.BUTTON_POS_0 +
+ Services.prompt.BUTTON_TITLE_CANCEL * Services.prompt.BUTTON_POS_1;
+
+ if (!UIState.get().syncEnabled) {
+ checkbox = null;
+ }
+
+ const result = await Services.prompt.asyncConfirmEx(
+ window.browsingContext,
+ Services.prompt.MODAL_TYPE_INTERNAL_WINDOW,
+ title,
+ body,
+ flags,
+ button,
+ null,
+ null,
+ checkbox,
+ false
+ );
+ const propBag = result.QueryInterface(Ci.nsIPropertyBag2);
+ options.userConfirmedDisconnect = propBag.get("buttonNumClicked") == 0;
+ options.deleteLocalData = propBag.get("checked");
+
+ return options;
+ },
+
+ async _disconnectFxaAndSync(deleteLocalData) {
+ const { SyncDisconnect } = ChromeUtils.importESModule(
+ "resource://services-sync/SyncDisconnect.sys.mjs"
+ );
+ // Record telemetry.
+ await fxAccounts.telemetry.recordDisconnection(null, "ui");
+
+ await SyncDisconnect.disconnect(deleteLocalData).catch(e => {
+ console.error("Failed to disconnect.", e);
+ });
+
+ return true;
+ },
+
+ // Prompt the user to confirm disconnect from sync. In this case the data
+ // on the device is not deleted.
+ async _confirmSyncDisconnect() {
+ const [title, body, button] = await document.l10n.formatValues([
+ { id: `sync-disconnect-dialog-title2` },
+ { id: `sync-disconnect-dialog-body` },
+ { id: "sync-disconnect-dialog-button" },
+ ]);
+
+ const flags =
+ Services.prompt.BUTTON_TITLE_IS_STRING * Services.prompt.BUTTON_POS_0 +
+ Services.prompt.BUTTON_TITLE_CANCEL * Services.prompt.BUTTON_POS_1;
+
+ // buttonPressed will be 0 for disconnect, 1 for cancel.
+ const buttonPressed = Services.prompt.confirmEx(
+ window,
+ title,
+ body,
+ flags,
+ button,
+ null,
+ null,
+ null,
+ {}
+ );
+ return buttonPressed == 0;
+ },
+
+ async _disconnectSync() {
+ await fxAccounts.telemetry.recordDisconnection("sync", "ui");
+
+ await Weave.Service.promiseInitialized;
+ await Weave.Service.startOver();
+
+ return true;
+ },
+
+ // doSync forces a sync - it *does not* return a promise as it is called
+ // via the various UI components.
+ doSync() {
+ if (!UIState.isReady()) {
+ return;
+ }
+ // Note we don't bother checking if sync is actually enabled - none of the
+ // UI which calls this function should be visible in that case.
+ const state = UIState.get();
+ if (state.status == UIState.STATUS_SIGNED_IN) {
+ this.updateSyncStatus({ syncing: true });
+ Services.tm.dispatchToMainThread(() => {
+ // We are pretty confident that push helps us pick up all FxA commands,
+ // but some users might have issues with push, so let's unblock them
+ // by fetching the missed FxA commands on manual sync.
+ fxAccounts.commands.pollDeviceCommands().catch(e => {
+ this.log.error("Fetching missed remote commands failed.", e);
+ });
+ Weave.Service.sync();
+ });
+ }
+ },
+
+ doSyncFromFxaMenu(panel) {
+ this.doSync();
+ this.emitFxaToolbarTelemetry("sync_now", panel);
+ },
+
+ openPrefs(entryPoint = "syncbutton", origin = undefined) {
+ window.openPreferences("paneSync", {
+ origin,
+ urlParams: { entrypoint: entryPoint },
+ });
+ },
+
+ openPrefsFromFxaMenu(type, panel) {
+ this.emitFxaToolbarTelemetry(type, panel);
+ let entryPoint = "fxa_discoverability_native";
+ if (this.isPanelInsideAppMenu(panel)) {
+ entryPoint = "fxa_app_menu";
+ }
+ this.openPrefs(entryPoint);
+ },
+
+ openSyncedTabsPanel() {
+ let placement = CustomizableUI.getPlacementOfWidget("sync-button");
+ let area = placement?.area;
+ let anchor = document.getElementById("sync-button");
+ if (area == CustomizableUI.AREA_FIXED_OVERFLOW_PANEL) {
+ // The button is in the overflow panel, so we need to show the panel,
+ // then show our subview.
+ let navbar = document.getElementById(CustomizableUI.AREA_NAVBAR);
+ navbar.overflowable.show().then(() => {
+ PanelUI.showSubView("PanelUI-remotetabs", anchor);
+ }, console.error);
+ } else {
+ if (
+ !anchor?.checkVisibility({ checkVisibilityCSS: true, flush: false })
+ ) {
+ anchor = document.getElementById("PanelUI-menu-button");
+ }
+ // It is placed somewhere else - just try and show it.
+ PanelUI.showSubView("PanelUI-remotetabs", anchor);
+ }
+ },
+
+ refreshSyncButtonsTooltip() {
+ const state = UIState.get();
+ this.updateSyncButtonsTooltip(state);
+ },
+
+ /* Update the tooltip for the sync icon in the main menu and in Synced Tabs.
+ If Sync is configured, the tooltip is when the last sync occurred,
+ otherwise the tooltip reflects the fact that Sync needs to be
+ (re-)configured.
+ */
+ updateSyncButtonsTooltip(state) {
+ // Sync buttons are 1/2 Sync related and 1/2 FxA related
+ let l10nId, l10nArgs;
+ switch (state.status) {
+ case UIState.STATUS_NOT_VERIFIED:
+ // "needs verification"
+ l10nId = "account-verify";
+ l10nArgs = { email: state.email };
+ break;
+ case UIState.STATUS_LOGIN_FAILED:
+ // "need to reconnect/re-enter your password"
+ l10nId = "account-reconnect";
+ l10nArgs = { email: state.email };
+ break;
+ case UIState.STATUS_NOT_CONFIGURED:
+ // Button is not shown in this state
+ break;
+ default: {
+ // Sync appears configured - format the "last synced at" time.
+ let lastSyncDate = this.formatLastSyncDate(state.lastSync);
+ if (lastSyncDate) {
+ l10nId = "appmenu-fxa-last-sync";
+ l10nArgs = { time: lastSyncDate };
+ }
+ }
+ }
+ const tooltiptext = l10nId
+ ? this.fluentStrings.formatValueSync(l10nId, l10nArgs)
+ : null;
+
+ let syncNowBtns = [
+ "PanelUI-remotetabs-syncnow",
+ "PanelUI-fxa-menu-syncnow-button",
+ ];
+ syncNowBtns.forEach(id => {
+ let el = PanelMultiView.getViewNode(document, id);
+ if (tooltiptext) {
+ el.setAttribute("tooltiptext", tooltiptext);
+ } else {
+ el.removeAttribute("tooltiptext");
+ }
+ });
+ },
+
+ get relativeTimeFormat() {
+ delete this.relativeTimeFormat;
+ return (this.relativeTimeFormat = new Services.intl.RelativeTimeFormat(
+ undefined,
+ { style: "long" }
+ ));
+ },
+
+ formatLastSyncDate(date) {
+ if (!date) {
+ // Date can be null before the first sync!
+ return null;
+ }
+ try {
+ let adjustedDate = new Date(Date.now() - 1000);
+ let relativeDateStr = this.relativeTimeFormat.formatBestUnit(
+ date < adjustedDate ? date : adjustedDate
+ );
+ return relativeDateStr;
+ } catch (ex) {
+ // shouldn't happen, but one client having an invalid date shouldn't
+ // break the entire feature.
+ this.log.warn("failed to format lastSync time", date, ex);
+ return null;
+ }
+ },
+
+ onClientsSynced() {
+ // Note that this element is only shown if Sync is enabled.
+ let element = PanelMultiView.getViewNode(
+ document,
+ "PanelUI-remotetabs-main"
+ );
+ if (element) {
+ if (Weave.Service.clientsEngine.stats.numClients > 1) {
+ element.setAttribute("devices-status", "multi");
+ } else {
+ element.setAttribute("devices-status", "single");
+ }
+ }
+ },
+
+ onFxaDisabled() {
+ document.documentElement.setAttribute("fxadisabled", true);
+
+ const toHide = [...document.querySelectorAll(".sync-ui-item")];
+ for (const item of toHide) {
+ item.hidden = true;
+ }
+ },
+
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIObserver",
+ "nsISupportsWeakReference",
+ ]),
+};
diff --git a/browser/base/content/browser-tabsintitlebar.js b/browser/base/content/browser-tabsintitlebar.js
new file mode 100644
index 0000000000..caf9986b2f
--- /dev/null
+++ b/browser/base/content/browser-tabsintitlebar.js
@@ -0,0 +1,92 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+ * 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/. */
+
+var TabsInTitlebar = {
+ init() {
+ this._readPref();
+ Services.prefs.addObserver(this._prefName, this);
+
+ this._initialized = true;
+ this._update();
+ },
+
+ allowedBy(condition, allow) {
+ if (allow) {
+ if (condition in this._disallowed) {
+ delete this._disallowed[condition];
+ this._update();
+ }
+ } else if (!(condition in this._disallowed)) {
+ this._disallowed[condition] = null;
+ this._update();
+ }
+ },
+
+ get systemSupported() {
+ let isSupported = false;
+ switch (AppConstants.MOZ_WIDGET_TOOLKIT) {
+ case "windows":
+ case "cocoa":
+ isSupported = true;
+ break;
+ case "gtk":
+ isSupported = window.matchMedia("(-moz-gtk-csd-available)").matches;
+ break;
+ }
+ delete this.systemSupported;
+ return (this.systemSupported = isSupported);
+ },
+
+ get enabled() {
+ return document.documentElement.getAttribute("tabsintitlebar") == "true";
+ },
+
+ observe(subject, topic, data) {
+ if (topic == "nsPref:changed") {
+ this._readPref();
+ }
+ },
+
+ _initialized: false,
+ _disallowed: {},
+ _prefName: "browser.tabs.inTitlebar",
+
+ _readPref() {
+ let hiddenTitlebar = Services.appinfo.drawInTitlebar;
+ this.allowedBy("pref", hiddenTitlebar);
+ },
+
+ _update() {
+ if (!this._initialized) {
+ return;
+ }
+
+ let allowed =
+ this.systemSupported &&
+ !window.fullScreen &&
+ !Object.keys(this._disallowed).length;
+ if (allowed) {
+ document.documentElement.setAttribute("tabsintitlebar", "true");
+ if (AppConstants.platform == "macosx") {
+ document.documentElement.setAttribute("chromemargin", "0,-1,-1,-1");
+ document.documentElement.removeAttribute("drawtitle");
+ } else {
+ document.documentElement.setAttribute("chromemargin", "0,2,2,2");
+ }
+ } else {
+ document.documentElement.removeAttribute("tabsintitlebar");
+ document.documentElement.removeAttribute("chromemargin");
+ if (AppConstants.platform == "macosx") {
+ document.documentElement.setAttribute("drawtitle", "true");
+ }
+ }
+
+ ToolbarIconColor.inferFromText("tabsintitlebar", allowed);
+ },
+
+ uninit() {
+ Services.prefs.removeObserver(this._prefName, this);
+ },
+};
diff --git a/browser/base/content/browser-thumbnails.js b/browser/base/content/browser-thumbnails.js
new file mode 100644
index 0000000000..e17f5aa05b
--- /dev/null
+++ b/browser/base/content/browser-thumbnails.js
@@ -0,0 +1,224 @@
+/* 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/. */
+
+// This file is loaded into the browser window scope.
+/* eslint-env mozilla/browser-window */
+
+/**
+ * Keeps thumbnails of open web pages up-to-date.
+ */
+var gBrowserThumbnails = {
+ /**
+ * Pref that controls whether we can store SSL content on disk
+ */
+ PREF_DISK_CACHE_SSL: "browser.cache.disk_cache_ssl",
+
+ _captureDelayMS: 1000,
+
+ /**
+ * Used to keep track of disk_cache_ssl preference
+ */
+ _sslDiskCacheEnabled: null,
+
+ /**
+ * Map of capture() timeouts assigned to their browsers.
+ */
+ _timeouts: null,
+
+ /**
+ * Top site URLs refresh timer.
+ */
+ _topSiteURLsRefreshTimer: null,
+
+ /**
+ * List of tab events we want to listen for.
+ */
+ _tabEvents: ["TabClose", "TabSelect"],
+
+ init: function Thumbnails_init() {
+ gBrowser.addTabsProgressListener(this);
+ Services.prefs.addObserver(this.PREF_DISK_CACHE_SSL, this);
+
+ this._sslDiskCacheEnabled = Services.prefs.getBoolPref(
+ this.PREF_DISK_CACHE_SSL
+ );
+
+ this._tabEvents.forEach(function (aEvent) {
+ gBrowser.tabContainer.addEventListener(aEvent, this);
+ }, this);
+
+ this._timeouts = new WeakMap();
+ },
+
+ uninit: function Thumbnails_uninit() {
+ gBrowser.removeTabsProgressListener(this);
+ Services.prefs.removeObserver(this.PREF_DISK_CACHE_SSL, this);
+
+ if (this._topSiteURLsRefreshTimer) {
+ this._topSiteURLsRefreshTimer.cancel();
+ this._topSiteURLsRefreshTimer = null;
+ }
+
+ this._tabEvents.forEach(function (aEvent) {
+ gBrowser.tabContainer.removeEventListener(aEvent, this);
+ }, this);
+ },
+
+ handleEvent: function Thumbnails_handleEvent(aEvent) {
+ switch (aEvent.type) {
+ case "scroll":
+ let browser = aEvent.currentTarget;
+ if (this._timeouts.has(browser)) {
+ this._delayedCapture(browser);
+ }
+ break;
+ case "TabSelect":
+ this._delayedCapture(aEvent.target.linkedBrowser);
+ break;
+ case "TabClose": {
+ this._cancelDelayedCapture(aEvent.target.linkedBrowser);
+ break;
+ }
+ }
+ },
+
+ observe: function Thumbnails_observe(subject, topic, data) {
+ switch (data) {
+ case this.PREF_DISK_CACHE_SSL:
+ this._sslDiskCacheEnabled = Services.prefs.getBoolPref(
+ this.PREF_DISK_CACHE_SSL
+ );
+ break;
+ }
+ },
+
+ clearTopSiteURLCache: function Thumbnails_clearTopSiteURLCache() {
+ if (this._topSiteURLsRefreshTimer) {
+ this._topSiteURLsRefreshTimer.cancel();
+ this._topSiteURLsRefreshTimer = null;
+ }
+ // Delete the defined property
+ delete this._topSiteURLs;
+ XPCOMUtils.defineLazyGetter(this, "_topSiteURLs", getTopSiteURLs);
+ },
+
+ notify: function Thumbnails_notify(timer) {
+ gBrowserThumbnails._topSiteURLsRefreshTimer = null;
+ gBrowserThumbnails.clearTopSiteURLCache();
+ },
+
+ /**
+ * State change progress listener for all tabs.
+ */
+ onStateChange: function Thumbnails_onStateChange(
+ aBrowser,
+ aWebProgress,
+ aRequest,
+ aStateFlags,
+ aStatus
+ ) {
+ if (
+ aStateFlags & Ci.nsIWebProgressListener.STATE_STOP &&
+ aStateFlags & Ci.nsIWebProgressListener.STATE_IS_NETWORK
+ ) {
+ this._delayedCapture(aBrowser);
+ }
+ },
+
+ async _capture(aBrowser) {
+ // Only capture about:newtab top sites.
+ const topSites = await this._topSiteURLs;
+ if (!aBrowser.currentURI || !topSites.includes(aBrowser.currentURI.spec)) {
+ return;
+ }
+ if (await this._shouldCapture(aBrowser)) {
+ await PageThumbs.captureAndStoreIfStale(aBrowser);
+ }
+ },
+
+ _delayedCapture: function Thumbnails_delayedCapture(aBrowser) {
+ if (this._timeouts.has(aBrowser)) {
+ this._cancelDelayedCallbacks(aBrowser);
+ } else {
+ aBrowser.addEventListener("scroll", this, true);
+ }
+
+ let idleCallback = () => {
+ this._cancelDelayedCapture(aBrowser);
+ this._capture(aBrowser);
+ };
+
+ // setTimeout to set a guarantee lower bound for the requestIdleCallback
+ // (and therefore the delayed capture)
+ let timeoutId = setTimeout(() => {
+ let idleCallbackId = requestIdleCallback(idleCallback, {
+ timeout: this._captureDelayMS * 30,
+ });
+ this._timeouts.set(aBrowser, { isTimeout: false, id: idleCallbackId });
+ }, this._captureDelayMS);
+
+ this._timeouts.set(aBrowser, { isTimeout: true, id: timeoutId });
+ },
+
+ _shouldCapture: async function Thumbnails_shouldCapture(aBrowser) {
+ // Capture only if it's the currently selected tab and not an about: page.
+ if (
+ aBrowser != gBrowser.selectedBrowser ||
+ gBrowser.currentURI.schemeIs("about")
+ ) {
+ return false;
+ }
+ return PageThumbs.shouldStoreThumbnail(aBrowser);
+ },
+
+ _cancelDelayedCapture: function Thumbnails_cancelDelayedCapture(aBrowser) {
+ if (this._timeouts.has(aBrowser)) {
+ aBrowser.removeEventListener("scroll", this);
+ this._cancelDelayedCallbacks(aBrowser);
+ this._timeouts.delete(aBrowser);
+ }
+ },
+
+ _cancelDelayedCallbacks: function Thumbnails_cancelDelayedCallbacks(
+ aBrowser
+ ) {
+ let timeoutData = this._timeouts.get(aBrowser);
+
+ if (timeoutData.isTimeout) {
+ clearTimeout(timeoutData.id);
+ } else {
+ // idle callback dispatched
+ window.cancelIdleCallback(timeoutData.id);
+ }
+ },
+};
+
+async function getTopSiteURLs() {
+ // The _topSiteURLs getter can be expensive to run, but its return value can
+ // change frequently on new profiles, so as a compromise we cache its return
+ // value as a lazy getter for 1 minute every time it's called.
+ gBrowserThumbnails._topSiteURLsRefreshTimer = Cc[
+ "@mozilla.org/timer;1"
+ ].createInstance(Ci.nsITimer);
+ gBrowserThumbnails._topSiteURLsRefreshTimer.initWithCallback(
+ gBrowserThumbnails,
+ 60 * 1000,
+ Ci.nsITimer.TYPE_ONE_SHOT
+ );
+ let sites = [];
+ // Get both the top sites returned by the query, and also any pinned sites
+ // that the user might have added manually that also need a screenshot.
+ // Also include top sites that don't have rich icons
+ let topSites = await NewTabUtils.activityStreamLinks.getTopSites();
+ sites.push(...topSites.filter(link => !(link.faviconSize >= 96)));
+ sites.push(...NewTabUtils.pinnedLinks.links);
+ return sites.reduce((urls, link) => {
+ if (link) {
+ urls.push(link.url);
+ }
+ return urls;
+ }, []);
+}
+
+XPCOMUtils.defineLazyGetter(gBrowserThumbnails, "_topSiteURLs", getTopSiteURLs);
diff --git a/browser/base/content/browser-toolbarKeyNav.js b/browser/base/content/browser-toolbarKeyNav.js
new file mode 100644
index 0000000000..1cf66aed92
--- /dev/null
+++ b/browser/base/content/browser-toolbarKeyNav.js
@@ -0,0 +1,432 @@
+/* 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/. */
+
+// This file is loaded into the browser window scope.
+/* eslint-env mozilla/browser-window */
+
+/**
+ * Handle keyboard navigation for toolbars.
+ * Having separate tab stops for every toolbar control results in an
+ * unmanageable number of tab stops. Therefore, we group buttons under a single
+ * tab stop and allow movement between them using left/right arrows.
+ * However, text inputs use the arrow keys for their own purposes, so they need
+ * their own tab stop. There are also groups of buttons before and after the
+ * URL bar input which should get their own tab stop. The subsequent buttons on
+ * the toolbar are then another tab stop after that.
+ * Tab stops for groups of buttons are set using the <toolbartabstop/> element.
+ * This element is invisible, but gets included in the tab order. When one of
+ * these gets focus, it redirects focus to the appropriate button. This avoids
+ * the need to continually manage the tabindex of toolbar buttons in response to
+ * toolbarchanges.
+ * In addition to linear navigation with tab and arrows, users can also type
+ * the first (or first few) characters of a button's name to jump directly to
+ * that button.
+ */
+
+ToolbarKeyboardNavigator = {
+ // Toolbars we want to be keyboard navigable.
+ kToolbars: [
+ CustomizableUI.AREA_TABSTRIP,
+ CustomizableUI.AREA_NAVBAR,
+ CustomizableUI.AREA_BOOKMARKS,
+ ],
+ // Delay (in ms) after which to clear any search text typed by the user if
+ // the user hasn't typed anything further.
+ kSearchClearTimeout: 1000,
+
+ _isButton(aElem) {
+ return (
+ aElem.tagName == "toolbarbutton" || aElem.getAttribute("role") == "button"
+ );
+ },
+
+ // Get a TreeWalker which includes only controls which should be keyboard
+ // navigable.
+ _getWalker(aRoot) {
+ if (aRoot._toolbarKeyNavWalker) {
+ return aRoot._toolbarKeyNavWalker;
+ }
+
+ let filter = aNode => {
+ if (aNode.tagName == "toolbartabstop") {
+ return NodeFilter.FILTER_ACCEPT;
+ }
+
+ // Special case for the "View site information" button, which isn't
+ // actionable in some cases but is still visible.
+ if (
+ aNode.id == "identity-box" &&
+ document.getElementById("urlbar").getAttribute("pageproxystate") ==
+ "invalid"
+ ) {
+ return NodeFilter.FILTER_REJECT;
+ }
+
+ // Skip disabled elements.
+ if (aNode.disabled) {
+ return NodeFilter.FILTER_REJECT;
+ }
+
+ // Skip invisible elements.
+ const visible = aNode.checkVisibility({
+ checkVisibilityCSS: true,
+ flush: false,
+ });
+ if (!visible) {
+ return NodeFilter.FILTER_REJECT;
+ }
+
+ // This width check excludes the overflow button when there's no overflow.
+ const bounds = window.windowUtils.getBoundsWithoutFlushing(aNode);
+ if (bounds.width == 0) {
+ return NodeFilter.FILTER_SKIP;
+ }
+
+ if (this._isButton(aNode)) {
+ return NodeFilter.FILTER_ACCEPT;
+ }
+ return NodeFilter.FILTER_SKIP;
+ };
+ aRoot._toolbarKeyNavWalker = document.createTreeWalker(
+ aRoot,
+ NodeFilter.SHOW_ELEMENT,
+ filter
+ );
+ return aRoot._toolbarKeyNavWalker;
+ },
+
+ _initTabStops(aRoot) {
+ for (let stop of aRoot.getElementsByTagName("toolbartabstop")) {
+ // These are invisible, but because they need to be in the tab order,
+ // they can't get display: none or similar. They must therefore be
+ // explicitly hidden for accessibility.
+ stop.setAttribute("aria-hidden", "true");
+ stop.addEventListener("focus", this);
+ }
+ },
+
+ init() {
+ for (let id of this.kToolbars) {
+ let toolbar = document.getElementById(id);
+ // When enabled, no toolbar buttons should themselves be tabbable.
+ // We manage toolbar focus completely. This attribute ensures that CSS
+ // doesn't set -moz-user-focus: normal.
+ toolbar.setAttribute("keyNav", "true");
+ this._initTabStops(toolbar);
+ toolbar.addEventListener("keydown", this);
+ toolbar.addEventListener("keypress", this);
+ }
+ CustomizableUI.addListener(this);
+ },
+
+ uninit() {
+ for (let id of this.kToolbars) {
+ let toolbar = document.getElementById(id);
+ for (let stop of toolbar.getElementsByTagName("toolbartabstop")) {
+ stop.removeEventListener("focus", this);
+ }
+ toolbar.removeEventListener("keydown", this);
+ toolbar.removeEventListener("keypress", this);
+ toolbar.removeAttribute("keyNav");
+ }
+ CustomizableUI.removeListener(this);
+ },
+
+ // CustomizableUI event handler
+ onWidgetAdded(aWidgetId, aArea, aPosition) {
+ if (!this.kToolbars.includes(aArea)) {
+ return;
+ }
+ let widget = document.getElementById(aWidgetId);
+ if (!widget) {
+ return;
+ }
+ this._initTabStops(widget);
+ },
+
+ _focusButton(aButton) {
+ // Toolbar buttons aren't focusable because if they were, clicking them
+ // would focus them, which is undesirable. Therefore, we must make a
+ // button focusable only when we want to focus it.
+ aButton.setAttribute("tabindex", "-1");
+ aButton.focus();
+ // We could remove tabindex now, but even though the button keeps DOM
+ // focus, a11y gets confused because the button reports as not being
+ // focusable. This results in weirdness if the user switches windows and
+ // then switches back. It also means that focus can't be restored to the
+ // button when a panel is closed. Instead, remove tabindex when the button
+ // loses focus.
+ aButton.addEventListener("blur", this);
+ },
+
+ _onButtonBlur(aEvent) {
+ if (document.activeElement == aEvent.target) {
+ // This event was fired because the user switched windows. This button
+ // will get focus again when the user returns.
+ return;
+ }
+ if (aEvent.target.getAttribute("open") == "true") {
+ // The button activated a panel. The button should remain
+ // focusable so that focus can be restored when the panel closes.
+ return;
+ }
+ aEvent.target.removeEventListener("blur", this);
+ aEvent.target.removeAttribute("tabindex");
+ },
+
+ _onTabStopFocus(aEvent) {
+ let toolbar = aEvent.target.closest("toolbar");
+ let walker = this._getWalker(toolbar);
+
+ let oldFocus = aEvent.relatedTarget;
+ if (oldFocus) {
+ // Save this because we might rewind focus and the subsequent focus event
+ // won't get a relatedTarget.
+ this._isFocusMovingBackward =
+ oldFocus.compareDocumentPosition(aEvent.target) &
+ Node.DOCUMENT_POSITION_PRECEDING;
+ if (this._isFocusMovingBackward && oldFocus && this._isButton(oldFocus)) {
+ // Shift+tabbing from a button will land on its toolbartabstop. Skip it.
+ document.commandDispatcher.rewindFocus();
+ return;
+ }
+ }
+
+ walker.currentNode = aEvent.target;
+ let button = walker.nextNode();
+ if (!button || !this._isButton(button)) {
+ // If we think we're moving backward, and focus came from outside the
+ // toolbox, we might actually have wrapped around. In this case, the
+ // event target was the first tabstop. If we can't find a button, e.g.
+ // because we're in a popup where most buttons are hidden, we
+ // should ensure focus keeps moving forward:
+ if (
+ this._isFocusMovingBackward &&
+ (!oldFocus || !gNavToolbox.contains(oldFocus))
+ ) {
+ let allStops = Array.from(
+ gNavToolbox.querySelectorAll("toolbartabstop")
+ );
+ // Find the previous toolbartabstop:
+ let earlierVisibleStopIndex = allStops.indexOf(aEvent.target) - 1;
+ // Then work out if any of the earlier ones are in a visible
+ // toolbar:
+ while (earlierVisibleStopIndex >= 0) {
+ let stopToolbar =
+ allStops[earlierVisibleStopIndex].closest("toolbar");
+ if (!stopToolbar.collapsed) {
+ break;
+ }
+ earlierVisibleStopIndex--;
+ }
+ // If we couldn't find any earlier visible stops, we're not moving
+ // backwards, we're moving forwards and wrapped around:
+ if (earlierVisibleStopIndex == -1) {
+ this._isFocusMovingBackward = false;
+ }
+ }
+ // No navigable buttons for this tab stop. Skip it.
+ if (this._isFocusMovingBackward) {
+ document.commandDispatcher.rewindFocus();
+ } else {
+ document.commandDispatcher.advanceFocus();
+ }
+ return;
+ }
+
+ this._focusButton(button);
+ },
+
+ navigateButtons(aToolbar, aPrevious) {
+ let oldFocus = document.activeElement;
+ let walker = this._getWalker(aToolbar);
+ // Start from the current control and walk to the next/previous control.
+ walker.currentNode = oldFocus;
+ let newFocus;
+ if (aPrevious) {
+ newFocus = walker.previousNode();
+ } else {
+ newFocus = walker.nextNode();
+ }
+ if (!newFocus || newFocus.tagName == "toolbartabstop") {
+ // There are no more controls or we hit a tab stop placeholder.
+ return;
+ }
+ this._focusButton(newFocus);
+ },
+
+ _onKeyDown(aEvent) {
+ let focus = document.activeElement;
+ if (
+ aEvent.key != " " &&
+ aEvent.key.length == 1 &&
+ this._isButton(focus) &&
+ // Don't handle characters if the user is focused in a panel anchored
+ // to the toolbar.
+ !focus.closest("panel")
+ ) {
+ this._onSearchChar(aEvent.currentTarget, aEvent.key);
+ return;
+ }
+ // Anything that doesn't trigger search should clear the search.
+ this._clearSearch();
+
+ if (
+ aEvent.altKey ||
+ aEvent.controlKey ||
+ aEvent.metaKey ||
+ aEvent.shiftKey ||
+ !this._isButton(focus)
+ ) {
+ return;
+ }
+
+ switch (aEvent.key) {
+ case "ArrowLeft":
+ // Previous if UI is LTR, next if UI is RTL.
+ this.navigateButtons(aEvent.currentTarget, !window.RTL_UI);
+ break;
+ case "ArrowRight":
+ // Previous if UI is RTL, next if UI is LTR.
+ this.navigateButtons(aEvent.currentTarget, window.RTL_UI);
+ break;
+ default:
+ return;
+ }
+ aEvent.preventDefault();
+ },
+
+ _clearSearch() {
+ this._searchText = "";
+ if (this._clearSearchTimeout) {
+ clearTimeout(this._clearSearchTimeout);
+ this._clearSearchTimeout = null;
+ }
+ },
+
+ _onSearchChar(aToolbar, aChar) {
+ if (this._clearSearchTimeout) {
+ // The user just typed a character, so reset the timer.
+ clearTimeout(this._clearSearchTimeout);
+ }
+ // Convert to lower case so we can do case insensitive searches.
+ let char = aChar.toLowerCase();
+ // If the user has only typed a single character and they type the same
+ // character again, they want to move to the next item starting with that
+ // same character. Effectively, it's as if there was no existing search.
+ // In that case, we just leave this._searchText alone.
+ if (!this._searchText) {
+ this._searchText = char;
+ } else if (this._searchText != char) {
+ this._searchText += char;
+ }
+ // Clear the search if the user doesn't type anything more within the timeout.
+ this._clearSearchTimeout = setTimeout(
+ this._clearSearch.bind(this),
+ this.kSearchClearTimeout
+ );
+
+ let oldFocus = document.activeElement;
+ let walker = this._getWalker(aToolbar);
+ // Search forward after the current control.
+ walker.currentNode = oldFocus;
+ for (
+ let newFocus = walker.nextNode();
+ newFocus;
+ newFocus = walker.nextNode()
+ ) {
+ if (this._doesSearchMatch(newFocus)) {
+ this._focusButton(newFocus);
+ return;
+ }
+ }
+ // No match, so search from the start until the current control.
+ walker.currentNode = walker.root;
+ for (
+ let newFocus = walker.firstChild();
+ newFocus && newFocus != oldFocus;
+ newFocus = walker.nextNode()
+ ) {
+ if (this._doesSearchMatch(newFocus)) {
+ this._focusButton(newFocus);
+ return;
+ }
+ }
+ },
+
+ _doesSearchMatch(aElem) {
+ if (!this._isButton(aElem)) {
+ return false;
+ }
+ for (let attrib of ["aria-label", "label", "tooltiptext"]) {
+ let label = aElem.getAttribute(attrib);
+ if (!label) {
+ continue;
+ }
+ // Convert to lower case so we do a case insensitive comparison.
+ // (this._searchText is already lower case.)
+ label = label.toLowerCase();
+ if (label.startsWith(this._searchText)) {
+ return true;
+ }
+ }
+ return false;
+ },
+
+ _onKeyPress(aEvent) {
+ let focus = document.activeElement;
+ if (
+ (aEvent.key != "Enter" && aEvent.key != " ") ||
+ !this._isButton(focus)
+ ) {
+ return;
+ }
+
+ if (focus.getAttribute("type") == "menu") {
+ focus.open = true;
+ return;
+ }
+
+ // Several buttons specifically don't use command events; e.g. because
+ // they want to activate for middle click. Therefore, simulate a click
+ // event if we know they handle click explicitly and don't handle
+ // commands.
+ const usesClickInsteadOfCommand = (() => {
+ if (focus.tagName != "toolbarbutton") {
+ return true;
+ }
+ return !focus.hasAttribute("oncommand") && focus.hasAttribute("onclick");
+ })();
+
+ if (!usesClickInsteadOfCommand) {
+ return;
+ }
+ focus.dispatchEvent(
+ new MouseEvent("click", {
+ bubbles: true,
+ ctrlKey: aEvent.ctrlKey,
+ altKey: aEvent.altKey,
+ shiftKey: aEvent.shiftKey,
+ metaKey: aEvent.metaKey,
+ })
+ );
+ },
+
+ handleEvent(aEvent) {
+ switch (aEvent.type) {
+ case "focus":
+ this._onTabStopFocus(aEvent);
+ break;
+ case "keydown":
+ this._onKeyDown(aEvent);
+ break;
+ case "keypress":
+ this._onKeyPress(aEvent);
+ break;
+ case "blur":
+ this._onButtonBlur(aEvent);
+ break;
+ }
+ },
+};
diff --git a/browser/base/content/browser-unified-extensions.js b/browser/base/content/browser-unified-extensions.js
new file mode 100644
index 0000000000..505fefd77b
--- /dev/null
+++ b/browser/base/content/browser-unified-extensions.js
@@ -0,0 +1,204 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+ * 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/. */
+
+// This file is loaded into the browser window scope.
+/* eslint-env mozilla/browser-window */
+
+ChromeUtils.defineESModuleGetters(this, {
+ OriginControls: "resource://gre/modules/ExtensionPermissions.sys.mjs",
+});
+
+/**
+ * The `unified-extensions-item` custom element is used to manage an extension
+ * in the list of extensions, which is displayed when users click the unified
+ * extensions (toolbar) button.
+ *
+ * This custom element must be initialized with `setExtension()`:
+ *
+ * ```
+ * let item = document.createElement("unified-extensions-item");
+ * item.setExtension(extension);
+ * document.body.appendChild(item);
+ * ```
+ */
+customElements.define(
+ "unified-extensions-item",
+ class extends HTMLElement {
+ /**
+ * Set the extension for this item. The item will be populated based on the
+ * extension when it is rendered into the DOM.
+ *
+ * @param {Extension} extension The extension to use.
+ */
+ setExtension(extension) {
+ this.extension = extension;
+ }
+
+ connectedCallback() {
+ if (this._menuButton) {
+ return;
+ }
+
+ const template = document.getElementById(
+ "unified-extensions-item-template"
+ );
+ this.appendChild(template.content.cloneNode(true));
+
+ this._actionButton = this.querySelector(
+ ".unified-extensions-item-action-button"
+ );
+ this._menuButton = this.querySelector(
+ ".unified-extensions-item-menu-button"
+ );
+ this._messageDeck = this.querySelector(
+ ".unified-extensions-item-message-deck"
+ );
+
+ // Focus/blur events are fired on specific elements only.
+ this._actionButton.addEventListener("blur", this);
+ this._actionButton.addEventListener("focus", this);
+ this._menuButton.addEventListener("blur", this);
+ this._menuButton.addEventListener("focus", this);
+
+ this.addEventListener("command", this);
+ this.addEventListener("mouseout", this);
+ this.addEventListener("mouseover", this);
+
+ this.render();
+ }
+
+ handleEvent(event) {
+ const { target } = event;
+
+ switch (event.type) {
+ case "command":
+ if (target === this._menuButton) {
+ const popup = target.ownerDocument.getElementById(
+ "unified-extensions-context-menu"
+ );
+ // Anchor to the visible part of the button.
+ const anchor = target.firstElementChild;
+ popup.openPopup(
+ anchor,
+ "after_end",
+ 0,
+ 0,
+ true /* isContextMenu */,
+ false /* attributesOverride */,
+ event
+ );
+ } else if (target === this._actionButton) {
+ const win = event.target.ownerGlobal;
+ const tab = win.gBrowser.selectedTab;
+
+ this.extension.tabManager.addActiveTabPermission(tab);
+ this.extension.tabManager.activateScripts(tab);
+ }
+ break;
+
+ case "blur":
+ case "mouseout":
+ this._messageDeck.selectedIndex =
+ gUnifiedExtensions.MESSAGE_DECK_INDEX_DEFAULT;
+ break;
+
+ case "focus":
+ case "mouseover":
+ if (target === this._menuButton) {
+ this._messageDeck.selectedIndex =
+ gUnifiedExtensions.MESSAGE_DECK_INDEX_MENU_HOVER;
+ } else if (target === this._actionButton) {
+ this._messageDeck.selectedIndex =
+ gUnifiedExtensions.MESSAGE_DECK_INDEX_HOVER;
+ }
+ break;
+ }
+ }
+
+ #setStateMessage() {
+ const messages = OriginControls.getStateMessageIDs({
+ policy: this.extension.policy,
+ tab: this.ownerGlobal.gBrowser.selectedTab,
+ });
+
+ if (!messages) {
+ return;
+ }
+
+ const messageDefaultElement = this.querySelector(
+ ".unified-extensions-item-message-default"
+ );
+ this.ownerDocument.l10n.setAttributes(
+ messageDefaultElement,
+ messages.default
+ );
+
+ const messageHoverElement = this.querySelector(
+ ".unified-extensions-item-message-hover"
+ );
+ this.ownerDocument.l10n.setAttributes(
+ messageHoverElement,
+ messages.onHover || messages.default
+ );
+ }
+
+ #hasAction() {
+ const state = OriginControls.getState(
+ this.extension.policy,
+ this.ownerGlobal.gBrowser.selectedTab
+ );
+
+ return state && state.whenClicked && !state.hasAccess;
+ }
+
+ render() {
+ if (!this.extension) {
+ throw new Error(
+ "unified-extensions-item requires an extension, forgot to call setExtension()?"
+ );
+ }
+
+ this.setAttribute("extension-id", this.extension.id);
+ this.classList.add(
+ "toolbaritem-combined-buttons",
+ "unified-extensions-item"
+ );
+
+ // The data-extensionid attribute is used by context menu handlers
+ // to identify the extension being manipulated by the context menu.
+ this._actionButton.dataset.extensionid = this.extension.id;
+
+ this.toggleAttribute(
+ "attention",
+ OriginControls.getAttention(this.extension.policy, this.ownerGlobal)
+ );
+
+ this.querySelector(".unified-extensions-item-name").textContent =
+ this.extension.name;
+
+ AddonManager.getAddonByID(this.extension.id).then(addon => {
+ const iconURL = AddonManager.getPreferredIconURL(addon, 32, window);
+ if (iconURL) {
+ this.querySelector(".unified-extensions-item-icon").setAttribute(
+ "src",
+ iconURL
+ );
+ }
+ });
+
+ this._actionButton.disabled = !this.#hasAction();
+
+ // The data-extensionid attribute is used by context menu handlers
+ // to identify the extension being manipulated by the context menu.
+ this._menuButton.dataset.extensionid = this.extension.id;
+ this._menuButton.setAttribute(
+ "data-l10n-args",
+ JSON.stringify({ extensionName: this.extension.name })
+ );
+
+ this.#setStateMessage();
+ }
+ }
+);
diff --git a/browser/base/content/browser-webrtc.js b/browser/base/content/browser-webrtc.js
new file mode 100644
index 0000000000..889769f292
--- /dev/null
+++ b/browser/base/content/browser-webrtc.js
@@ -0,0 +1,140 @@
+/* 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/. */
+
+/* eslint-env mozilla/browser-window */
+
+/**
+ * Utility object to handle WebRTC shared tab warnings.
+ */
+var gSharedTabWarning = {
+ /**
+ * Called externally by gBrowser to determine if we're
+ * in a state such that we'd want to cancel the tab switch
+ * and show the tab switch warning panel instead.
+ *
+ * @param tab (<tab>)
+ * The tab being switched to.
+ * @returns boolean
+ * True if the panel will be shown, and the tab switch should
+ * be cancelled.
+ */
+ willShowSharedTabWarning(tab) {
+ if (!this._sharedTabWarningEnabled) {
+ return false;
+ }
+
+ let shareState = webrtcUI.getWindowShareState(window);
+ if (shareState == webrtcUI.SHARING_NONE) {
+ return false;
+ }
+
+ if (!webrtcUI.shouldShowSharedTabWarning(tab)) {
+ return false;
+ }
+
+ this._createSharedTabWarningIfNeeded();
+ let panel = document.getElementById("sharing-tabs-warning-panel");
+ let hbox = panel.firstChild;
+
+ if (shareState == webrtcUI.SHARING_SCREEN) {
+ hbox.setAttribute("type", "screen");
+ panel.setAttribute(
+ "aria-labelledby",
+ "sharing-screen-warning-panel-header-span"
+ );
+ } else {
+ hbox.setAttribute("type", "window");
+ panel.setAttribute(
+ "aria-labelledby",
+ "sharing-window-warning-panel-header-span"
+ );
+ }
+
+ let allowForSessionCheckbox = document.getElementById(
+ "sharing-warning-disable-for-session"
+ );
+ allowForSessionCheckbox.checked = false;
+
+ panel.openPopup(tab, "bottomleft topleft", 0, 0);
+
+ return true;
+ },
+
+ /**
+ * Called by the tab switch warning panel after it has
+ * shown.
+ */
+ sharedTabWarningShown() {
+ let allowButton = document.getElementById("sharing-warning-proceed-to-tab");
+ allowButton.focus();
+ },
+
+ /**
+ * Called by the button in the tab switch warning panel
+ * to allow the switch to occur.
+ */
+ allowSharedTabSwitch() {
+ let panel = document.getElementById("sharing-tabs-warning-panel");
+ let allowForSession = document.getElementById(
+ "sharing-warning-disable-for-session"
+ ).checked;
+
+ let tab = panel.anchorNode;
+ webrtcUI.allowSharedTabSwitch(tab, allowForSession);
+ this._hideSharedTabWarning();
+ },
+
+ /**
+ * Called externally by gBrowser when a tab has been added.
+ * When this occurs, if we're sharing this window, we notify
+ * the webrtcUI module to exempt the new tab from the tab switch
+ * warning, since the user opened it while they were already
+ * sharing.
+ *
+ * @param tab (<tab>)
+ * The tab being opened.
+ */
+ tabAdded(tab) {
+ if (this._sharedTabWarningEnabled) {
+ let shareState = webrtcUI.getWindowShareState(window);
+ if (shareState != webrtcUI.SHARING_NONE) {
+ webrtcUI.tabAddedWhileSharing(tab);
+ }
+ }
+ },
+
+ get _sharedTabWarningEnabled() {
+ delete this._sharedTabWarningEnabled;
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "_sharedTabWarningEnabled",
+ "privacy.webrtc.sharedTabWarning"
+ );
+ return this._sharedTabWarningEnabled;
+ },
+
+ /**
+ * Internal method for hiding the tab switch warning panel.
+ */
+ _hideSharedTabWarning() {
+ let panel = document.getElementById("sharing-tabs-warning-panel");
+ if (panel) {
+ panel.hidePopup();
+ }
+ },
+
+ /**
+ * Inserts the tab switch warning panel into the DOM
+ * if it hasn't been done already yet.
+ */
+ _createSharedTabWarningIfNeeded() {
+ // Lazy load the panel the first time we need to display it.
+ if (!document.getElementById("sharing-tabs-warning-panel")) {
+ let template = document.getElementById(
+ "sharing-tabs-warning-panel-template"
+ );
+ template.replaceWith(template.content);
+ }
+ },
+};
diff --git a/browser/base/content/browser.css b/browser/base/content/browser.css
new file mode 100644
index 0000000000..4279c9ccfc
--- /dev/null
+++ b/browser/base/content/browser.css
@@ -0,0 +1,1682 @@
+/* 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/. */
+
+@namespace html url("http://www.w3.org/1999/xhtml");
+
+:root,
+body {
+ margin: 0;
+ padding: 0;
+ height: 100%;
+ width: 100%;
+ overflow: clip;
+}
+
+:root {
+ text-rendering: optimizeLegibility;
+ min-height: 95px;
+ min-width: 95px;
+
+ /* z-indices that fight on the browser stack */
+ --browser-stack-z-index-devtools-splitter: 1;
+ --browser-stack-z-index-dialog-stack: 2;
+ --browser-stack-z-index-rdm-toolbar: 3;
+}
+
+:root:-moz-locale-dir(rtl) {
+ direction: rtl;
+}
+
+:root:not([chromehidden~="toolbar"]) {
+ min-width: 450px;
+ min-height: 120px;
+}
+
+#appcontent,
+#browser,
+#tabbrowser-tabbox,
+#tabbrowser-tabpanels,
+.browserSidebarContainer {
+ /* Allow devtools with large specified width/height to shrink */
+ min-width: 0;
+ min-height: 0;
+}
+
+/* We set large flex on both containers to allow the devtools toolbox to
+ * set a flex value itself. We don't want the toolbox to actually take up free
+ * space, but we do want it to collapse when the window shrinks, and with
+ * flex: 0 it can't.
+ *
+ * When the toolbox is on the bottom it's a sibling of browserStack, and when
+ * it's on the side it's a sibling of browserContainer.
+ */
+.browserContainer {
+ flex: 10000 10000;
+ /* To contain the status panel */
+ position: relative;
+
+ /* .browserContainer only contains the devtools when docked horizontally */
+ min-height: 0;
+}
+
+.browserStack {
+ flex: 10000 10000;
+ /* Prevent shrinking the page content to 0 height and width */
+ min-height: 25px;
+ min-width: 25px;
+}
+
+body {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+}
+
+#navigator-toolbox:-moz-lwtheme {
+ background-image: var(--lwt-additional-images);
+ background-repeat: var(--lwt-background-tiling);
+ background-position: var(--lwt-background-alignment);
+}
+
+/* TODO bug 1695280: Remove these media selectors and merge the rule below
+ with the ruleset above. We must set background properties on :root and not
+ #navigator-toolbox on Windows 7/8 due to a WebRender bug that hides the
+ minimize/maximize/close buttons. */
+@media not (-moz-platform: windows-win7) {
+ @media not (-moz-platform: windows-win8) {
+ #navigator-toolbox:-moz-lwtheme {
+ background-color: var(--lwt-accent-color);
+ }
+
+ /* When a theme defines both theme_frame and additional_backgrounds, show
+ the latter atop the former. */
+ :root[lwtheme-image] #navigator-toolbox {
+ background-image: var(--lwt-header-image), var(--lwt-additional-images);
+ background-repeat: no-repeat, var(--lwt-background-tiling);
+ background-position: right top, var(--lwt-background-alignment);
+ }
+
+ #navigator-toolbox:-moz-window-inactive:-moz-lwtheme {
+ background-color: var(--lwt-accent-color-inactive, var(--lwt-accent-color));
+ }
+ }
+}
+/* TODO bug 1695280: Remove this block. */
+@media (-moz-platform: windows-win7),
+ (-moz-platform: windows-win8) {
+ :root:-moz-lwtheme {
+ background-color: var(--lwt-accent-color);
+ background-image: var(--lwt-additional-images);
+ background-repeat: var(--lwt-background-tiling);
+ background-position: var(--lwt-background-alignment);
+ }
+
+ :root[lwtheme-image] {
+ background-image: var(--lwt-header-image, linear-gradient(transparent, transparent)), var(--lwt-additional-images) !important;
+ background-repeat: no-repeat, var(--lwt-background-tiling);
+ background-position: right top, var(--lwt-background-alignment) !important;
+ }
+
+ :root:-moz-lwtheme:-moz-window-inactive {
+ background-color: var(--lwt-accent-color-inactive, var(--lwt-accent-color));
+ }
+}
+
+#titlebar {
+ -moz-window-dragging: drag;
+}
+
+#toolbar-menubar[autohide="true"] {
+ overflow: hidden;
+}
+
+#toolbar-menubar[autohide="true"][inactive="true"]:not([customizing="true"]) {
+ min-height: 0 !important;
+ height: 0 !important;
+ padding: 0 !important;
+ appearance: none !important;
+}
+
+#toolbar-menubar:not([autohide]) {
+ visibility: collapse;
+}
+
+panelmultiview {
+ align-items: flex-start;
+ min-width: 0;
+ min-height: 0;
+}
+
+panelmultiview[transitioning] {
+ pointer-events: none;
+}
+
+panelview {
+ flex-direction: column;
+}
+
+panelview:not([visible]) {
+ visibility: collapse;
+}
+
+.panel-viewcontainer {
+ overflow: hidden;
+ flex-shrink: 0;
+ min-width: 0;
+ min-height: 0;
+}
+
+.panel-viewcontainer[panelopen] {
+ transition-property: height;
+ transition-timing-function: var(--animation-easing-function);
+ transition-duration: var(--panelui-subview-transition-duration);
+ will-change: height;
+}
+
+.panel-viewcontainer.offscreen {
+ display: block;
+}
+
+.panel-viewstack {
+ overflow: visible;
+ transition: height var(--panelui-subview-transition-duration);
+}
+
+@supports not -moz-bool-pref("browser.tabs.tabmanager.enabled") {
+ #tabbrowser-tabs:not([overflow="true"], [hashiddentabs]) ~ #alltabs-button {
+ display: none;
+ }
+ #tabbrowser-tabs:not([overflow="true"])[using-closing-tabs-spacer] ~ #alltabs-button {
+ /* temporary space to keep a tab's close button under the cursor */
+ display: flex;
+ visibility: hidden;
+ }
+}
+
+#tabbrowser-tabs[hasadjacentnewtabbutton]:not([overflow="true"]) ~ #new-tab-button,
+#tabbrowser-tabs[overflow="true"] > #tabbrowser-arrowscrollbox > #tabbrowser-arrowscrollbox-periphery > #tabs-newtab-button,
+#tabbrowser-tabs:not([hasadjacentnewtabbutton]) > #tabbrowser-arrowscrollbox > #tabbrowser-arrowscrollbox-periphery > #tabs-newtab-button,
+#TabsToolbar[customizing="true"] #tabs-newtab-button {
+ display: none;
+}
+
+.tabbrowser-tab:not([pinned]) {
+ flex: 100 100;
+ max-width: 225px;
+ min-width: var(--tab-min-width);
+ transition: min-width 100ms ease-out,
+ max-width 100ms ease-out;
+}
+
+:root[uidensity=touch] .tabbrowser-tab:not([pinned]) {
+ /* Touch mode needs additional space for the close button. */
+ min-width: calc(var(--tab-min-width) + 10px);
+}
+
+.tabbrowser-tab:not([pinned], [fadein]) {
+ max-width: 0.1px;
+ min-width: 0.1px;
+ visibility: hidden;
+}
+
+.tab-icon-pending:not([fadein]),
+.tab-icon-image:not([fadein]),
+.tab-close-button:not([fadein]),
+.tabbrowser-tab:not([fadein])::after,
+.tab-background:not([fadein]) {
+ visibility: hidden;
+}
+
+.tab-label:not([fadein]),
+.tab-throbber:not([fadein]) {
+ display: none;
+}
+
+#tabbrowser-tabs[positionpinnedtabs] > #tabbrowser-arrowscrollbox > .tabbrowser-tab[pinned] {
+ position: absolute !important;
+ display: block;
+}
+
+#tabbrowser-tabs[movingtab] > #tabbrowser-arrowscrollbox > .tabbrowser-tab[selected],
+#tabbrowser-tabs[movingtab] > #tabbrowser-arrowscrollbox > .tabbrowser-tab[multiselected] {
+ position: relative;
+ z-index: 2;
+ pointer-events: none; /* avoid blocking dragover events on scroll buttons */
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ #tabbrowser-tabs[movingtab] > #tabbrowser-arrowscrollbox > .tabbrowser-tab[fadein]:not([selected]):not([multiselected]),
+ .tabbrowser-tab[tab-grouping],
+ .tabbrowser-tab[tabdrop-samewindow] {
+ transition: transform 200ms var(--animation-easing-function);
+ }
+}
+
+.tabbrowser-tab[tab-grouping][multiselected]:not([selected]) {
+ z-index: 2;
+}
+
+/* Make it easier to drag tabs by expanding the drag area downwards. */
+#tabbrowser-tabs[movingtab] {
+ padding-bottom: 15px;
+ margin-bottom: -15px;
+}
+
+#navigator-toolbox[movingtab] > #nav-bar {
+ pointer-events: none;
+}
+
+#nav-bar-customization-target {
+ /* Don't grow if potentially-user-sized elements (like the searchbar or the
+ * bookmarks toolbar item list) are too wide. This forces them to flex to the
+ * available space as much as possible, see bug 1795260. */
+ min-width: 0;
+}
+
+/* Allow dropping a tab on buttons with associated drop actions. */
+#navigator-toolbox[movingtab] > #nav-bar > #nav-bar-customization-target > #personal-bookmarks,
+#navigator-toolbox[movingtab] > #nav-bar > #nav-bar-customization-target > #home-button,
+#navigator-toolbox[movingtab] > #nav-bar > #nav-bar-customization-target > #downloads-button,
+#navigator-toolbox[movingtab] > #nav-bar > #nav-bar-customization-target > #bookmarks-menu-button {
+ pointer-events: auto;
+}
+
+/* The address bar needs to be able to render outside of the toolbar, but as
+ * long as it's within the toolbar's bounds we can clip the toolbar so that the
+ * rendering pipeline doesn't reserve an enormous texture for it. */
+#nav-bar:not([urlbar-exceeds-toolbar-bounds]),
+/* When customizing, overflowable toolbars move automatically moved items back
+ * from the overflow menu, but we still don't want to render them outside of
+ * the customization target. */
+toolbar[overflowable][customizing] > .customization-target {
+ overflow: clip;
+}
+
+toolbar:not([overflowing]) > .overflow-button,
+toolbar[customizing] > .overflow-button {
+ display: none;
+}
+
+toolbar[customizing] #ion-button,
+toolbar[customizing] #whats-new-menu-button {
+ display: none;
+}
+
+:root:not([chromehidden~="toolbar"]) #nav-bar[nonemptyoverflow] > .overflow-button,
+#nav-bar[customizing] > .overflow-button {
+ display: flex;
+}
+
+/* The ids are ugly, but this should be reasonably performant, and
+ * using a tagname as the last item would be less so.
+ */
+#widget-overflow-list:empty + #widget-overflow-fixed-separator,
+#widget-overflow:not([hasfixeditems]) #widget-overflow-fixed-separator {
+ display: none;
+}
+
+/* Hide the TabsToolbar titlebar controls if the menubar is permanently shown.
+ * (That is, if the menu bar doesn't autohide, and we're not in a fullscreen or
+ * popup window.) */
+:root:not([chromehidden~="menubar"], [inFullscreen]) #toolbar-menubar[autohide="false"] + #TabsToolbar > :is(.titlebar-buttonbox-container, .titlebar-spacer) {
+ display: none;
+}
+
+:root:not([chromemargin], [inFullscreen]) .titlebar-buttonbox-container,
+:root[inFullscreen] .titlebar-spacer,
+:root:not([tabsintitlebar]) .titlebar-spacer {
+ display: none;
+}
+
+@media (-moz-platform: windows) {
+ :root:not([sizemode=normal]) .titlebar-spacer[type="pre-tabs"] {
+ display: none;
+ }
+}
+
+@media (-moz-platform: linux) {
+ @media (-moz-gtk-csd-reversed-placement: 0) {
+ :root:not([sizemode=normal]) .titlebar-spacer[type="pre-tabs"],
+ :root[gtktiledwindow=true] .titlebar-spacer[type="pre-tabs"] {
+ display: none;
+ }
+ }
+ @media (-moz-gtk-csd-reversed-placement) {
+ :root:not([sizemode=normal]) .titlebar-spacer[type="post-tabs"],
+ :root[gtktiledwindow=true] .titlebar-spacer[type="post-tabs"] {
+ display: none;
+ }
+ }
+}
+
+:root:not([sizemode=maximized], [sizemode=fullscreen]) .titlebar-restore,
+:root:is([sizemode=maximized], [sizemode=fullscreen]) .titlebar-max {
+ display: none;
+}
+
+#toolbar-menubar[autohide="true"]:not([inactive]) + #TabsToolbar > .titlebar-buttonbox-container {
+ visibility: hidden;
+}
+
+:root[tabsintitlebar] .titlebar-buttonbox {
+ position: relative;
+}
+
+:root:not([tabsintitlebar], [sizemode=fullscreen]) .titlebar-buttonbox {
+ display: none;
+}
+
+.titlebar-buttonbox {
+ appearance: auto;
+ -moz-default-appearance: -moz-window-button-box;
+ position: relative;
+}
+
+#personal-toolbar-empty-description {
+ -moz-window-dragging: no-drag;
+}
+
+#personal-bookmarks {
+ -moz-window-dragging: inherit;
+}
+
+toolbarpaletteitem {
+ -moz-window-dragging: no-drag;
+ justify-content: flex-start;
+}
+
+.titlebar-buttonbox-container {
+ order: 1000;
+}
+
+@media (-moz-platform: macos) {
+ @media not (-moz-mac-rtl) {
+ .titlebar-buttonbox-container:-moz-locale-dir(ltr) {
+ order: -1;
+ }
+ }
+
+ @media (-moz-mac-rtl) {
+ .titlebar-buttonbox-container:-moz-locale-dir(rtl) {
+ order: -1;
+ }
+ }
+}
+
+:root[inDOMFullscreen] #navigator-toolbox,
+:root[inDOMFullscreen] #fullscr-toggler,
+:root[inDOMFullscreen] #sidebar-box,
+:root[inDOMFullscreen] #sidebar-splitter,
+:root[inFullscreen]:not([macOSNativeFullscreen]) toolbar:not([fullscreentoolbar=true]),
+:root[inFullscreen] .global-notificationbox {
+ visibility: collapse;
+}
+
+#navigator-toolbox[fullscreenShouldAnimate] {
+ transition: 0.8s margin-top ease-out;
+}
+
+/* Rules to help integrate WebExtension buttons */
+
+.webextension-browser-action > .toolbarbutton-badge-stack > .toolbarbutton-icon {
+ height: 16px;
+ width: 16px;
+}
+
+@media not all and (min-resolution: 1.1dppx) {
+ .webextension-browser-action {
+ list-style-image: var(--webextension-toolbar-image, inherit);
+ }
+
+ toolbar[brighttext] .webextension-browser-action {
+ list-style-image: var(--webextension-toolbar-image-light, inherit);
+ }
+
+ toolbar:not([brighttext]) .webextension-browser-action:-moz-lwtheme {
+ list-style-image: var(--webextension-toolbar-image-dark, inherit);
+ }
+
+ toolbaritem:is([overflowedItem="true"], [cui-areatype="panel"]) > .webextension-browser-action {
+ list-style-image: var(--webextension-menupanel-image, inherit);
+ }
+
+ :root[lwt-popup-brighttext] toolbaritem:is([overflowedItem="true"], [cui-areatype="panel"]) > .webextension-browser-action {
+ list-style-image: var(--webextension-menupanel-image-light, inherit);
+ }
+
+ :root:not([lwt-popup-brighttext]) toolbaritem:is([overflowedItem="true"], [cui-areatype="panel"]) > .webextension-browser-action:-moz-lwtheme {
+ list-style-image: var(--webextension-menupanel-image-dark, inherit);
+ }
+
+ .webextension-menuitem {
+ list-style-image: var(--webextension-menuitem-image, inherit) !important;
+ }
+}
+
+@media (min-resolution: 1.1dppx) {
+ .webextension-browser-action {
+ list-style-image: var(--webextension-toolbar-image-2x, inherit);
+ }
+
+ toolbar[brighttext] .webextension-browser-action {
+ list-style-image: var(--webextension-toolbar-image-2x-light, inherit);
+ }
+
+ toolbar:not([brighttext]) .webextension-browser-action:-moz-lwtheme {
+ list-style-image: var(--webextension-toolbar-image-2x-dark, inherit);
+ }
+
+ toolbaritem:is([overflowedItem="true"], [cui-areatype="panel"]) > .webextension-browser-action {
+ list-style-image: var(--webextension-menupanel-image-2x, inherit);
+ }
+
+ :root[lwt-popup-brighttext] toolbaritem:is([overflowedItem="true"], [cui-areatype="panel"]) > .webextension-browser-action {
+ list-style-image: var(--webextension-menupanel-image-2x-light, inherit);
+ }
+
+ :root:not([lwt-popup-brighttext]) toolbaritem:is([overflowedItem="true"], [cui-areatype="panel"]) > .webextension-browser-action:-moz-lwtheme {
+ list-style-image: var(--webextension-menupanel-image-2x-dark, inherit);
+ }
+
+ .webextension-menuitem {
+ list-style-image: var(--webextension-menuitem-image-2x, inherit) !important;
+ }
+}
+
+toolbarbutton.webextension-menuitem > .toolbarbutton-icon {
+ width: 16px;
+ height: 16px;
+}
+
+toolbarpaletteitem[removable="false"] {
+ opacity: 0.5;
+}
+
+@media not (-moz-platform: macos) {
+ toolbarpaletteitem[place="palette"],
+ toolbarpaletteitem[place="panel"],
+ toolbarpaletteitem[place="toolbar"] {
+ -moz-user-focus: normal;
+ }
+}
+
+#bookmarks-toolbar-placeholder,
+#bookmarks-toolbar-button,
+toolbarpaletteitem > #personal-bookmarks > #PlacesToolbar,
+#personal-bookmarks:is([overflowedItem=true], [cui-areatype="panel"]) > #PlacesToolbar {
+ display: none;
+}
+
+toolbarpaletteitem[place="toolbar"] > #personal-bookmarks > #bookmarks-toolbar-placeholder,
+toolbarpaletteitem[place="palette"] > #personal-bookmarks > #bookmarks-toolbar-button,
+#personal-bookmarks:is([overflowedItem=true], [cui-areatype="panel"]) > #bookmarks-toolbar-button {
+ display: flex;
+}
+
+#personal-bookmarks {
+ position: relative;
+}
+
+#PlacesToolbarDropIndicatorHolder {
+ display: block;
+ position: absolute;
+}
+
+#allTabsMenu-dropIndicatorHolder {
+ display: block;
+ position: relative;
+}
+
+#allTabsMenu-dropIndicator {
+ background: url(chrome://browser/skin/tabbrowser/tab-drag-indicator.svg) no-repeat center;
+ display: block;
+ position: absolute;
+ transform: rotate(-90deg);
+ width: 12px;
+ height: 29px;
+ inset-inline-start: 8px;
+ top: 0;
+ pointer-events: none;
+}
+
+#allTabsMenu-dropIndicator:-moz-locale-dir(rtl) {
+ transform: rotate(90deg);
+}
+
+#nav-bar-customization-target > #personal-bookmarks,
+toolbar:not(#TabsToolbar) > #wrapper-personal-bookmarks,
+toolbar:not(#TabsToolbar) > #personal-bookmarks {
+ flex: 1 auto;
+}
+
+#reload-button:not([displaystop]) + #stop-button,
+#reload-button[displaystop] {
+ display: none;
+}
+
+/* The reload-button is only disabled temporarily when it becomes visible
+ to prevent users from accidentally clicking it. We don't however need
+ to show this disabled state, as the flicker that it generates is short
+ enough to be visible but not long enough to explain anything to users. */
+#reload-button[disabled]:not(:-moz-window-inactive) > .toolbarbutton-icon {
+ opacity: 1 !important;
+}
+
+/* Ensure stop-button and reload-button are displayed correctly when in the overflow menu */
+.widget-overflow-list > #stop-reload-button > .toolbarbutton-1 {
+ flex: 1;
+}
+
+@media (-moz-platform: macos) {
+ :root[inFullscreen="true"] {
+ padding-top: 0; /* override drawintitlebar="true" */
+ }
+}
+
+/* Hide menu elements intended for keyboard access support */
+#main-menubar[openedwithkey=false] .show-only-for-keyboard {
+ display: none;
+}
+
+/* ::::: location bar & search bar ::::: */
+
+#urlbar,
+#searchbar {
+ /* Setting a min-width to let the location & search bars maintain a constant
+ * width in case they haven't been resized manually. (bug 965772) */
+ min-width: 1px;
+}
+
+/* Align URLs to the right in RTL mode. */
+#urlbar-input:-moz-locale-dir(rtl) {
+ text-align: right !important;
+}
+
+/* Make sure that the location bar's alignment changes according
+ to the input box direction if the user switches the text direction using
+ cmd_switchTextDirection (which applies a dir attribute to the <input>). */
+#urlbar-input[dir=ltr]:-moz-locale-dir(rtl) {
+ text-align: left !important;
+}
+
+#urlbar-input[dir=rtl]:-moz-locale-dir(ltr) {
+ text-align: right !important;
+}
+
+/*
+ * Display visual cue that browser is under remote control.
+ * This is to help users visually distinguish a user agent session that
+ * is under remote control from those used for normal browsing sessions.
+ *
+ * Attribute is controlled by browser.js:/gRemoteControl.
+ */
+:root[remotecontrol] #remote-control-box {
+ visibility: visible;
+ padding-inline: var(--urlbar-icon-padding);
+}
+
+:root[remotecontrol] #remote-control-icon {
+ list-style-image: url(chrome://browser/content/static-robot.png);
+ width: 16px;
+ height: 16px;
+}
+
+:root[remotecontrol] #urlbar-background {
+ background-image: repeating-linear-gradient(
+ -45deg,
+ rgba(255, 60, 60, 0.25) 0 25px,
+ rgba(175, 0, 0, 0.25) 25px 50px
+ );
+
+ background-attachment: fixed;
+ /* Override the usual breakout animation so the gradient doesn't shift around
+ when the panel opens. */
+ animation: none !important;
+}
+
+/* Show the url scheme in a static box when overflowing to the left */
+.urlbar-input-box {
+ position: relative;
+ direction: ltr;
+}
+
+#urlbar-scheme {
+ position: absolute;
+ height: 100%;
+ visibility: hidden;
+ direction: ltr;
+ pointer-events: none;
+}
+
+#urlbar-input {
+ mask-repeat: no-repeat;
+ unicode-bidi: plaintext;
+ text-align: match-parent;
+}
+
+#urlbar:not([focused])[domaindir="ltr"]> #urlbar-input-container > .urlbar-input-box > #urlbar-input {
+ direction: ltr;
+ unicode-bidi: embed;
+}
+
+/* The following rules apply overflow masks to the unfocused urlbar
+ This mask may be overriden when a Contextual Feature Recommendation is shown,
+ see browser/themes/shared/urlbar-searchbar.inc.css for details */
+
+#urlbar:not([focused])[textoverflow="both"] > #urlbar-input-container > .urlbar-input-box > #urlbar-input {
+ mask-image: linear-gradient(to right, transparent, black 3ch, black calc(100% - 3ch), transparent);
+}
+#urlbar:not([focused])[textoverflow="right"] > #urlbar-input-container > .urlbar-input-box > #urlbar-input {
+ mask-image: linear-gradient(to left, transparent, black 3ch);
+}
+#urlbar:not([focused])[textoverflow="left"] > #urlbar-input-container > .urlbar-input-box > #urlbar-input {
+ mask-image: linear-gradient(to right, transparent, black 3ch);
+}
+
+/* The protocol is visible if there is an RTL domain and we overflow to the left.
+ Uses the required-valid trick to check if it contains a value */
+#urlbar:not([focused])[textoverflow="left"][domaindir="rtl"] > #urlbar-input-container > .urlbar-input-box > #urlbar-scheme:valid {
+ visibility: visible;
+}
+#urlbar:not([focused])[textoverflow="left"][domaindir="rtl"] > #urlbar-input-container > .urlbar-input-box > #urlbar-input {
+ mask-image: linear-gradient(to right, transparent var(--urlbar-scheme-size), black calc(var(--urlbar-scheme-size) + 3ch));
+}
+
+/* Apply crisp rendering for favicons at exactly 2dppx resolution */
+@media (resolution: 2dppx) {
+ .searchbar-engine-image {
+ image-rendering: -moz-crisp-edges;
+ }
+}
+
+#urlbar[actiontype="switchtab"][actionoverride] > #urlbar-input-container > #urlbar-label-box,
+#urlbar:not([actiontype="switchtab"], [actiontype="extension"], [searchmode]) > #urlbar-input-container > #urlbar-label-box,
+#urlbar:not([actiontype="switchtab"]) > #urlbar-input-container > #urlbar-label-box > #urlbar-label-switchtab,
+#urlbar:not([actiontype="extension"]) > #urlbar-input-container > #urlbar-label-box > #urlbar-label-extension,
+#urlbar[searchmode][breakout-extend] > #urlbar-input-container > #urlbar-label-box,
+#urlbar:not([searchmode]) > #urlbar-input-container > #urlbar-label-box > #urlbar-label-search-mode,
+#urlbar[breakout-extend] > #urlbar-input-container > #urlbar-label-box > #urlbar-label-search-mode {
+ display: none;
+}
+
+#PopupAutoComplete > richlistbox > richlistitem[originaltype="loginsFooter"] {
+ justify-content: center;
+ color: FieldText;
+ min-height: 2.6666em;
+ border-top: 1px solid rgba(38,38,38,.15);
+ background-color: hsla(0,0%,80%,.35); /* match arrowpanel-dimmed */;
+}
+
+#PopupAutoComplete > richlistbox > richlistitem[originaltype="loginsFooter"]:hover,
+#PopupAutoComplete > richlistbox > richlistitem[originaltype="loginsFooter"][selected] {
+ background-color: hsla(0,0%,80%,.5); /* match arrowpanel-dimmed-further */
+}
+
+/* Define the minimum width based on the style of result rows.
+ The order of the min-width rules below must be in increasing order. */
+#PopupAutoComplete:is([resultstyles~="loginsFooter"], [resultstyles~="insecureWarning"])::part(content) {
+ min-width: 17em;
+}
+
+#PopupAutoComplete:is([resultstyles~="importableLogins"], [resultstyles~="generatedPassword"])::part(content) {
+ min-width: 22em;
+}
+
+#PopupAutoComplete > richlistbox > richlistitem[originaltype="insecureWarning"] {
+ height: auto;
+}
+
+#PopupAutoComplete > richlistbox > richlistitem[originaltype="loginWithOrigin"] > .ac-site-icon,
+#PopupAutoComplete > richlistbox > richlistitem[originaltype="insecureWarning"] > .ac-site-icon {
+ margin-inline-start: 0;
+ display: initial;
+}
+
+#PopupAutoComplete > richlistbox > richlistitem[originaltype="insecureWarning"] > .ac-title > .ac-text-overflow-container > .ac-title-text {
+ text-overflow: initial;
+ white-space: initial;
+}
+
+#PopupAutoComplete > richlistbox > richlistitem[originaltype="insecureWarning"] > .ac-title > label {
+ margin-inline-start: 0;
+}
+
+#urlbar-input-container[pageproxystate=invalid] > #page-action-buttons > .urlbar-page-action,
+#identity-box.chromeUI ~ #page-action-buttons > .urlbar-page-action:not(#star-button-box),
+#urlbar[usertyping] > #urlbar-input-container > #page-action-buttons > #urlbar-zoom-button,
+#urlbar:not([usertyping]) > #urlbar-input-container > #urlbar-go-button,
+#urlbar:not([focused]) > #urlbar-input-container > #urlbar-go-button {
+ display: none;
+}
+
+#nav-bar:not([keyNav=true]) #identity-box,
+#nav-bar:not([keyNav=true]) #tracking-protection-icon-container {
+ -moz-user-focus: normal;
+}
+
+/* We leave 310px plus whatever space the download and unified extensions
+ * buttons will need when they *both* appear. Normally, for each button, this
+ * should be 16px for the icon, plus 2 * 2px padding plus the
+ * toolbarbutton-inner-padding. We're adding 4px to ensure things like rounding
+ * on hidpi don't accidentally result in the buttons going into overflow.
+ */
+#urlbar-container {
+ width: calc(310px + 2 * (24px + 2 * var(--toolbarbutton-inner-padding)));
+}
+
+/* When the download button OR the unified extensions button is shown, we leave
+ * 310px plus the space needed for a single button as described above. */
+#nav-bar:is([downloadsbuttonshown], [unifiedextensionsbuttonshown]) #urlbar-container {
+ width: calc(310px + 24px + 2 * var(--toolbarbutton-inner-padding));
+}
+
+/* When both the download and unified extensions buttons are visible, we use
+ * the base min-width value. */
+#nav-bar[downloadsbuttonshown][unifiedextensionsbuttonshown] #urlbar-container {
+ width: 310px;
+}
+
+/* Customize mode is difficult to use at moderate window width if the Urlbar
+ remains 310px wide. */
+:root[customizing] #urlbar-container {
+ width: 280px;
+}
+
+#identity-icon-box {
+ max-width: calc(30px + 13em);
+}
+
+@media (max-width: 770px) {
+ #urlbar-container {
+ width: calc(240px + 2 * (24px + 2 * var(--toolbarbutton-inner-padding)));
+ }
+ #nav-bar:is([downloadsbuttonshown], [unifiedextensionsbuttonshown]) #urlbar-container {
+ width: calc(240px + 24px + 2 * var(--toolbarbutton-inner-padding));
+ }
+ #nav-bar[downloadsbuttonshown][unifiedextensionsbuttonshown] #urlbar-container {
+ width: 240px;
+ }
+ :root[customizing] #urlbar-container {
+ width: 245px;
+ }
+ #identity-icon-box {
+ max-width: 80px;
+ }
+ /* Contenxtual identity labels are user-customizable and can be very long,
+ so we only show the colored icon when the window gets small. */
+ #userContext-label {
+ display: none;
+ }
+}
+
+/* The page actions menu is hidden by default, it is only shown in small
+ windows as the overflow target of multiple page action buttons */
+#pageActionButton {
+ visibility: collapse;
+}
+
+/* 680px is just below half of popular 1366px wide screens, so when putting two
+ browser windows next to each other on such a screen, they'll be above this
+ threshold. */
+@media (max-width: 680px) {
+ /* Page action buttons are duplicated in the page action menu so we can
+ safely hide them in small windows. */
+ #pageActionSeparator,
+ #pageActionButton[multiple-children] ~ .urlbar-page-action {
+ display: none;
+ }
+ #pageActionButton[multiple-children] {
+ visibility: visible;
+ }
+}
+@media (max-width: 550px) {
+ #urlbar-container {
+ width: calc(176px + 2 * (24px + 2 * var(--toolbarbutton-inner-padding)));
+ }
+ #nav-bar[downloadsbuttonshown] #urlbar-container,
+ #nav-bar[unifiedextensionsbuttonshown] #urlbar-container {
+ width: calc(176px + 24px + 2 * var(--toolbarbutton-inner-padding));
+ }
+ #nav-bar[downloadsbuttonshown][unifiedextensionsbuttonshown] #urlbar-container {
+ width: 176px;
+ }
+ #identity-icon-box {
+ max-width: 70px;
+ }
+ #urlbar-zoom-button {
+ display: none;
+ }
+}
+
+/* Flexible spacer sizing (gets overridden in the navbar) */
+toolbarpaletteitem[place=toolbar][id^=wrapper-customizableui-special-spring],
+toolbarspring {
+ flex: 1;
+ min-width: 28px;
+ max-width: 112px;
+}
+
+#nav-bar toolbarpaletteitem[id^=wrapper-customizableui-special-spring],
+#nav-bar toolbarspring {
+ flex: 80 80;
+ /* We shrink the flexible spacers, but not to nothing so they can be
+ * manipulated in customize mode; the next rule shrinks them further
+ * outside customize mode. */
+ min-width: 10px;
+}
+
+#nav-bar:not([customizing]) toolbarspring {
+ min-width: 1px;
+}
+
+#widget-overflow-list > toolbarspring {
+ display: none;
+}
+
+/* ::::: Unified Back-/Forward Button ::::: */
+.unified-nav-current {
+ font-weight: bold;
+}
+
+.bookmark-item > label {
+ /* ensure we use the direction of the bookmarks label instead of the
+ * browser locale */
+ unicode-bidi: plaintext;
+ /* Preserve whitespace in bookmark names */
+ white-space: pre;
+}
+
+/* Apply crisp rendering for favicons at exactly 2dppx resolution */
+@media (resolution: 2dppx) {
+ .menuitem-with-favicon > .menu-iconic-left > .menu-iconic-icon {
+ image-rendering: -moz-crisp-edges;
+ }
+
+ .bookmark-item > .toolbarbutton-icon,
+ .bookmark-item > .menu-iconic-left > .menu-iconic-icon {
+ image-rendering: -moz-crisp-edges;
+ }
+}
+
+menupopup[emptyplacesresult="true"] > .hide-if-empty-places-result {
+ display: none;
+}
+
+/* Hide extension toolbars that neglected to set the proper class */
+:root[chromehidden~="location"][chromehidden~="toolbar"] toolbar:not(.chromeclass-menubar) {
+ display: none;
+}
+
+#navigator-toolbox ,
+#mainPopupSet {
+ min-width: 1px;
+}
+
+/* History Swipe Animation */
+
+#historySwipeAnimationContainer {
+ overflow: hidden;
+ pointer-events: none;
+}
+
+/* Full Screen UI */
+
+#fullscr-toggler {
+ top: 0;
+ left: 0;
+ right: 0;
+ height: 1px;
+ position: fixed;
+ z-index: 2147483647;
+}
+
+#fullscreen-and-pointerlock-wrapper {
+ position: fixed;
+ z-index: 2147483647 !important;
+ width: 100%;
+ height: 100%;
+ top: 0;
+ pointer-events: none;
+}
+
+.pointerlockfswarning {
+ position: fixed;
+ visibility: visible;
+ transition: transform 300ms ease-in;
+ /* To center the warning box horizontally,
+ we use left: 50% with translateX(-50%). */
+ top: 0; left: 50%;
+ transform: translate(-50%, -100%);
+ box-sizing: border-box;
+ width: max-content;
+ max-width: 95%;
+ pointer-events: none;
+}
+.pointerlockfswarning:not([hidden]) {
+ display: flex;
+ will-change: transform;
+}
+.pointerlockfswarning[onscreen] {
+ transform: translate(-50%, 50px);
+}
+.pointerlockfswarning[ontop] {
+ /* Use -10px to hide the border and border-radius on the top */
+ transform: translate(-50%, -10px);
+}
+:root[macOSNativeFullscreen] .pointerlockfswarning[ontop] {
+ transform: translate(-50%, 80px);
+}
+
+.pointerlockfswarning-domain-text,
+.pointerlockfswarning-generic-text {
+ word-wrap: break-word;
+ /* We must specify a min-width, otherwise word-wrap:break-word doesn't work. Bug 630864. */
+ min-width: 1px
+}
+.pointerlockfswarning-domain-text:not([hidden]) + .pointerlockfswarning-generic-text {
+ display: none;
+}
+
+#fullscreen-exit-button {
+ pointer-events: auto;
+}
+
+/* notification anchors should only be visible when their associated
+ notifications are */
+#nav-bar:not([keyNav=true]) .notification-anchor-icon {
+ -moz-user-focus: normal;
+}
+
+#blocked-permissions-container > .blocked-permission-icon:not([showing]),
+.notification-anchor-icon:not([showing]) {
+ display: none;
+}
+
+#invalid-form-popup > description {
+ max-width: 280px;
+}
+
+.popup-anchor {
+ /* should occupy space but not be visible */
+ opacity: 0;
+ pointer-events: none;
+ position: absolute;
+}
+
+browser[tabmodalPromptShowing], browser[tabDialogShowing] {
+ -moz-user-focus: none !important;
+}
+
+/* Status panel */
+
+#statuspanel:not([hidden]) {
+ max-width: calc(100% - 5px);
+ pointer-events: none;
+
+ /* Take a bit more space vertically for the mouse tracker to hit us more
+ * easily */
+ padding-top: 2em;
+
+ position: absolute;
+ bottom: 0;
+ left: 0;
+}
+
+#statuspanel:not([mirror]):-moz-locale-dir(rtl),
+#statuspanel[mirror]:-moz-locale-dir(ltr) {
+ left: auto;
+ right: 0;
+}
+
+#statuspanel[sizelimit] {
+ max-width: 50%;
+}
+
+#statuspanel[type=status] {
+ min-width: min(23em, 33%);
+}
+
+#statuspanel[type=overLink] {
+ transition: opacity 120ms ease-out, visibility 120ms;
+}
+
+#statuspanel:is([type=overLink], [inactive][previoustype=overLink]) {
+ direction: ltr;
+}
+
+#statuspanel[inactive] {
+ transition: none;
+ opacity: 0;
+ visibility: hidden;
+}
+
+#statuspanel[inactive][previoustype=overLink] {
+ transition: opacity 200ms ease-out, visibility 200ms;
+}
+
+/*** Visibility of downloads indicator controls ***/
+
+/* Hide the default icon, show the anchor instead. */
+#downloads-button > .toolbarbutton-badge-stack > image.toolbarbutton-icon {
+ display: none;
+}
+
+toolbarpaletteitem[place="palette"] > #downloads-button > .toolbarbutton-badge-stack > image.toolbarbutton-icon {
+ display: flex;
+}
+
+toolbarpaletteitem[place="palette"] > #downloads-button > .toolbarbutton-badge-stack > #downloads-indicator-anchor {
+ display: none;
+}
+
+@media (-moz-panel-animations) and (prefers-reduced-motion: no-preference) {
+@media (-moz-platform: macos) {
+ /* On Mac, use the properties "-moz-window-transform" and "-moz-window-opacity"
+ instead of "transform" and "opacity" for these animations.
+ The -moz-window* properties apply to the whole window including the window's
+ shadow, and they don't affect the window's "shape", so the system doesn't
+ have to recompute the shadow shape during the animation. This makes them a
+ lot faster. In fact, Gecko no longer triggers shadow shape recomputations
+ for repaints.
+ These properties are not implemented on other platforms. */
+ #BMB_bookmarksPopup:not([animate="false"]) {
+ transition-property: -moz-window-transform, -moz-window-opacity;
+ transition-duration: 0.18s, 0.18s;
+ transition-timing-function:
+ var(--animation-easing-function), ease-out;
+ }
+
+ /* Only do the fade-in animation on pre-Big Sur to avoid missing shadows on
+ * Big Sur, see bug 1672091. */
+ @media (-moz-mac-big-sur-theme: 0) {
+ #BMB_bookmarksPopup:not([animate="false"]) {
+ -moz-window-opacity: 0;
+ -moz-window-transform: translateY(-70px);
+ }
+
+ #BMB_bookmarksPopup[side="bottom"]:not([animate="false"]) {
+ -moz-window-transform: translateY(70px);
+ }
+ }
+
+ /* [animate] is here only so that this rule has greater specificity than the
+ * rule right above */
+ #BMB_bookmarksPopup[animate][animate="open"] {
+ -moz-window-opacity: 1.0;
+ transition-duration: 0.18s, 0.18s;
+ -moz-window-transform: none;
+ transition-timing-function:
+ var(--animation-easing-function), ease-in-out;
+ }
+
+ #BMB_bookmarksPopup[animate][animate="cancel"] {
+ -moz-window-opacity: 0;
+ -moz-window-transform: none;
+ }
+}
+@media not (-moz-platform: macos) {
+ #BMB_bookmarksPopup:not([animate="false"]) {
+ opacity: 0;
+ transform: translateY(-70px);
+ transition-property: transform, opacity;
+ transition-duration: 0.18s, 0.18s;
+ transition-timing-function:
+ var(--animation-easing-function), ease-out;
+ will-change: transform, opacity;
+ }
+
+ #BMB_bookmarksPopup[side="bottom"]:not([animate="false"]) {
+ transform: translateY(70px);
+ }
+
+ /* [animate] is here only so that this rule has greater specificity than the
+ * rule right above */
+ #BMB_bookmarksPopup[animate][animate="open"] {
+ opacity: 1.0;
+ transition-duration: 0.18s, 0.18s;
+ transform: none;
+ transition-timing-function:
+ var(--animation-easing-function), ease-in-out;
+ }
+
+ #BMB_bookmarksPopup[animate][animate="cancel"] {
+ transform: none;
+ }
+}
+}
+
+/* Apply crisp rendering for favicons at exactly 2dppx resolution */
+@media (resolution: 2dppx) {
+ .PanelUI-remotetabs-clientcontainer > toolbarbutton > .toolbarbutton-icon,
+ #PanelUI-recentlyClosedWindows > toolbarbutton > .toolbarbutton-icon,
+ #PanelUI-recentlyClosedTabs > toolbarbutton > .toolbarbutton-icon,
+ #PanelUI-historyItems > toolbarbutton > .toolbarbutton-icon {
+ image-rendering: -moz-crisp-edges;
+ }
+}
+
+#customization-container {
+ flex-direction: row;
+ flex-direction: column;
+ min-height: 0;
+}
+
+#customization-container:not([hidden]) {
+ /* In a separate rule to avoid 'display:flex' causing the node to be
+ * displayed while the container is still hidden. */
+ display: flex;
+}
+
+#customization-content-container {
+ display: flex;
+ flex: 1; /* Grow so there isn't empty space below the footer */
+ min-height: 0; /* Allow this to shrink so the footer doesn't get pushed out. */
+}
+
+#customization-panelHolder {
+ padding-top: 10px;
+ padding-bottom: 10px;
+}
+
+#customization-panelHolder > #widget-overflow-fixed-list {
+ flex: 1; /* Grow within the available space, and allow ourselves to shrink */
+ display: flex;
+ flex-direction: column;
+ overflow-y: auto;
+ overflow-x: hidden;
+}
+
+#customization-panelWrapper,
+#customization-panelWrapper > .panel-arrowcontent,
+#customization-panelHolder {
+ flex-direction: column;
+ display: flex;
+ flex-shrink: 1;
+ min-height: calc(174px + 9em);
+}
+
+#customization-panelWrapper {
+ flex: 1;
+ align-items: end; /* align to the end on the cross-axis (affects arrow) */
+}
+
+#customization-panel-container {
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ flex: none;
+}
+
+toolbarpaletteitem[dragover] {
+ border-inline-color: transparent;
+}
+
+#customization-palette-container {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+}
+
+#customization-palette:not([hidden]) {
+ display: block;
+ flex: 1 1 auto;
+ overflow: auto;
+ min-height: 3em;
+}
+
+#customization-footer-spacer,
+#customization-spacer {
+ flex: 1 1 auto;
+}
+
+#customization-footer {
+ display: flex;
+ align-items: center;
+ flex-shrink: 0;
+ flex-wrap: wrap;
+}
+
+#customization-toolbar-visibility-button > .box-inherit > .button-menu-dropmarker {
+ display: flex;
+}
+
+#customization-lwtheme-button > .box-inherit > .button-menu-dropmarker,
+#customization-uidensity-button > .box-inherit > .button-menu-dropmarker {
+ display: flex;
+}
+
+toolbarpaletteitem[place="palette"] {
+ flex-direction: column;
+ width: 7em;
+ max-width: 7em;
+ /* icon (16) + margin (9 + 12) + 3 lines of text: */
+ height: calc(39px + 3em);
+ margin-bottom: 5px;
+ margin-inline-end: 24px;
+ overflow: visible;
+ display: inline-flex;
+ vertical-align: top;
+}
+
+toolbarpaletteitem[place="palette"][hidden] {
+ display: none;
+}
+
+toolbarpaletteitem > toolbarbutton,
+toolbarpaletteitem > toolbaritem {
+ /* Prevent children from getting events */
+ pointer-events: none;
+ justify-content: center;
+}
+
+toolbarpaletteitem:not([place="palette"]) > #stop-reload-button {
+ justify-content: inherit;
+}
+
+:root[customizing=true] .addon-banner-item,
+:root[customizing=true] .panel-banner-item {
+ display: none;
+}
+
+/* Firefox View */
+:root[firefoxviewhidden] #wrapper-firefox-view-button,
+:root[firefoxviewhidden] #firefox-view-button {
+ display: none;
+}
+
+/* UI Tour */
+
+@keyframes uitour-wobble {
+ from {
+ transform: rotate(0deg) translateX(3px) rotate(0deg);
+ }
+ 50% {
+ transform: rotate(360deg) translateX(3px) rotate(-360deg);
+ }
+ to {
+ transform: rotate(720deg) translateX(0px) rotate(-720deg);
+ }
+}
+
+@keyframes uitour-zoom {
+ from {
+ transform: scale(0.8);
+ }
+ 50% {
+ transform: scale(1.0);
+ }
+ to {
+ transform: scale(0.8);
+ }
+}
+
+@keyframes uitour-color {
+ from {
+ border-color: #5B9CD9;
+ }
+ 50% {
+ border-color: #FF0000;
+ }
+ to {
+ border-color: #5B9CD9;
+ }
+}
+
+#UITourHighlightContainer,
+#UITourHighlight {
+ pointer-events: none;
+}
+
+#UITourHighlight[active] {
+ animation-delay: 2s;
+ animation-fill-mode: forwards;
+ animation-iteration-count: infinite;
+ animation-timing-function: linear;
+}
+
+#UITourHighlight[active="wobble"] {
+ animation-name: uitour-wobble;
+ animation-delay: 0s;
+ animation-duration: 1.5s;
+ animation-iteration-count: 1;
+}
+#UITourHighlight[active="zoom"] {
+ animation-name: uitour-zoom;
+ animation-duration: 1s;
+}
+#UITourHighlight[active="color"] {
+ animation-name: uitour-color;
+ animation-duration: 2s;
+}
+
+/* Combined context-menu items */
+#context-navigation > .menuitem-iconic > .menu-iconic-text,
+#context-navigation > .menuitem-iconic > .menu-accel-container {
+ display: none;
+}
+
+.popup-notification-invalid-input {
+ box-shadow: 0 0 1.5px 1px red;
+}
+
+.popup-notification-invalid-input[focused] {
+ box-shadow: 0 0 2px 2px rgba(255,0,0,0.4);
+}
+
+.popup-notification-description[popupid=webauthn-prompt-register-direct] {
+ white-space: pre-line;
+}
+
+.dragfeedback-tab {
+ appearance: none;
+ opacity: 0.65;
+ -moz-window-shadow: none;
+}
+
+/* Page action buttons */
+.pageAction-panel-button > .toolbarbutton-icon {
+ list-style-image: var(--pageAction-image-16px, inherit);
+}
+.urlbar-page-action {
+ list-style-image: var(--pageAction-image-16px, inherit);
+}
+@media (min-resolution: 1.1dppx) {
+ .pageAction-panel-button > .toolbarbutton-icon {
+ list-style-image: var(--pageAction-image-32px, inherit);
+ }
+ .urlbar-page-action {
+ list-style-image: var(--pageAction-image-32px, inherit);
+ }
+}
+
+/* Print pending */
+.printSettingsBrowser {
+ width: 250px !important;
+}
+
+.previewStack {
+ background-color: #f9f9fa;
+ color: #0c0c0d;
+}
+
+.previewRendering {
+ background-repeat: no-repeat;
+ background-size: 60px 60px;
+ background-position: center center;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ visibility: hidden;
+}
+
+.printPreviewBrowser {
+ visibility: collapse;
+ opacity: 1;
+}
+
+.previewStack[rendering=true] > .previewRendering,
+.previewStack[previewtype="source"] > .printPreviewBrowser[previewtype="source"],
+.previewStack[previewtype="selection"] > .printPreviewBrowser[previewtype="selection"],
+.previewStack[previewtype="simplified"] > .printPreviewBrowser[previewtype="simplified"] {
+ visibility: inherit;
+}
+
+.previewStack[rendering=true] > .printPreviewBrowser {
+ opacity: 0;
+}
+
+.print-pending-label {
+ margin-top: 110px;
+ font-size: large;
+}
+
+printpreview-pagination {
+ opacity: 0;
+}
+printpreview-pagination:focus-within,
+.previewStack:hover printpreview-pagination {
+ opacity: 1;
+}
+.previewStack[rendering=true] printpreview-pagination {
+ opacity: 0;
+}
+
+@media (prefers-color-scheme: dark) {
+ .previewStack {
+ background-color: #2A2A2E;
+ color: rgb(249, 249, 250);
+ }
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ .previewRendering {
+ background-image: url("chrome://browser/skin/tabbrowser/pendingpaint.png");
+ }
+
+ .printPreviewBrowser {
+ transition: opacity 60ms;
+ }
+
+ .previewStack[rendering=true] > .printPreviewBrowser {
+ transition: opacity 1ms 250ms;
+ }
+
+ printpreview-pagination {
+ transition: opacity 100ms 500ms;
+ }
+
+ printpreview-pagination:focus-within,
+ .previewStack:hover printpreview-pagination {
+ transition: opacity 100ms;
+ }
+}
+
+#sidebar-box {
+ min-width: 14em;
+ max-width: 36em;
+ width: 18em;
+}
+
+/* WebExtension Sidebars */
+#sidebar-box[sidebarcommand$="-sidebar-action"] > #sidebar-header > #sidebar-switcher-target > #sidebar-icon {
+ list-style-image: var(--webextension-menuitem-image, inherit);
+ -moz-context-properties: fill;
+ fill: currentColor;
+ width: 16px;
+ height: 16px;
+}
+
+@media (min-resolution: 1.1dppx) {
+ #sidebar-box[sidebarcommand$="-sidebar-action"] > #sidebar-header > #sidebar-switcher-target > #sidebar-icon {
+ list-style-image: var(--webextension-menuitem-image-2x, inherit);
+ }
+}
+
+toolbar[keyNav=true]:not([collapsed=true], [customizing=true]) toolbartabstop {
+ -moz-user-focus: normal;
+}
+
+/**
+ * Dialogs
+ */
+
+.dialogStack {
+ z-index: var(--browser-stack-z-index-dialog-stack);
+ position: absolute;
+ inset: 0;
+}
+
+.dialogStack.temporarilyHidden {
+ /* For some printing use cases we need to visually hide the dialog before
+ * actually closing it / make it disappear from the frame tree. */
+ visibility: hidden;
+}
+
+.dialogOverlay {
+ align-items: center;
+ visibility: hidden;
+}
+
+.dialogOverlay[topmost="true"] {
+ z-index: 1;
+}
+
+.dialogBox {
+ background-clip: content-box;
+ display: flex;
+ margin: 0 3vw;
+ padding: 0;
+ overflow-x: auto;
+}
+
+.dialogBox:not(.spotlightBox) {
+ box-shadow: 0 2px 14px 0 rgba(0, 0, 0, 0.2);
+ border-radius: 8px;
+}
+
+/*
+ * In High Contrast Mode, this prevents a dialog from visually bleeding into
+ * the window behind it, which looks jarring.
+ */
+@media (prefers-contrast) {
+.dialogBox {
+ outline: 1px solid WindowText;
+}
+}
+
+.dialogBox[resizable="true"] {
+ resize: both;
+ overflow: hidden;
+ min-height: 20em;
+}
+
+.dialogBox[sizeto="available"] {
+ --box-top-px: 0px; /* Overridden using inline style */
+ --box-inline-margin: 4px;
+ --box-block-margin: 4px;
+ --box-ideal-width: 1000;
+ --box-ideal-height: 650;
+ --box-max-width-margin: calc(100vw - 2 * var(--box-inline-margin));
+ --box-max-height-margin: calc(100vh - var(--box-top-px) - var(--box-block-margin));
+ --box-max-width-ratio: 70vw;
+ --box-max-height-ratio: calc(var(--box-ideal-height) / var(--box-ideal-width) * var(--box-max-width-ratio));
+ --box-max-height-requested: 100vh;
+ --box-max-width-requested: 100vw;
+ --box-max-height-remaining: calc(100vh - var(--box-top-px));
+ width: 100vw;
+ height: 100vh;
+ margin: 0;
+}
+
+.dialogBox:not(.spotlightBox)[sizeto="available"] {
+ max-width: min(max(var(--box-ideal-width) * 1px, var(--box-max-width-ratio)), var(--box-max-width-margin), var(--box-max-width-requested));
+ max-height: min(max(var(--box-ideal-height) * 1px, var(--box-max-height-ratio)), var(--box-max-height-margin), var(--box-max-height-requested), var(--box-max-height-remaining));
+}
+
+@media (min-width: 550px) {
+ .dialogBox[sizeto="available"] {
+ --box-inline-margin: min(calc(4px + (100vw - 550px) / 4), 16px);
+ }
+}
+
+@media (min-width: 800px) {
+ .dialogBox[sizeto="available"] {
+ --box-inline-margin: min(calc(16px + (100vw - 800px) / 4), 32px);
+ }
+}
+
+@media (min-height: 350px) {
+ .dialogBox[sizeto="available"] {
+ --box-block-margin: min(calc(4px + (100vh - 350px) / 4), 16px);
+ }
+}
+
+@media (min-height: 550px) {
+ .dialogBox[sizeto="available"] {
+ --box-block-margin: min(calc(16px + (100vh - 550px) / 4), 32px);
+ }
+}
+
+.dialogStack .dialogBox.spotlightBox[sizeto="available"] {
+ /* Tab modal: subtract the navigator toolbox height from the dialog height. */
+ height: calc(100vh - var(--box-top-px));
+}
+
+.content-prompt-dialog > .dialogOverlay {
+ display: grid;
+ align-items: unset;
+ place-content: center;
+ /* 90% for 5% top/bottom margins, the document height so that
+ * smaller dialogs don't become too big. */
+ grid-auto-rows: min(90%, var(--doc-height-px));
+}
+
+:not(.content-prompt-dialog) > .dialogOverlay > .dialogBox:not(.spotlightBox) {
+ /* Make dialogs overlap with upper chrome UI. Not necessary for Spotlight
+ dialogs that are intended to be centered above the window or content area. */
+ margin-top: -5px;
+}
+
+/* For window-modal dialogs, we allow overlapping the urlbar if the window is
+ * small enough. */
+#window-modal-dialog > .dialogOverlay > .dialogBox:not(.spotlightBox) {
+ /* Do not go below 3px (as otherwise the top of the dialog would be
+ * adjacent to or clipped by the top of the window), or above the window
+ * size. */
+ margin-top: clamp(
+ 3px,
+ var(--chrome-offset, 20px) - 5px,
+ calc(100vh - var(--subdialog-inner-height) - 5px)
+ );
+}
+
+#window-modal-dialog {
+ overflow: visible;
+ padding: 0;
+ /* Override default <html:dialog> styles */
+ border-width: 0;
+ background-color: transparent;
+ /* This makes the dialog top-aligned by default (the dialog box will move via
+ * margin-top above) */
+ bottom: auto;
+}
+
+#window-modal-dialog.spotlight {
+ /* Spotlight window modal dialogs should be equal in size to the window. */
+ bottom: revert;
+ max-height: 100%;
+ max-width: 100%;
+}
+
+.dialogFrame {
+ margin: 0;
+ flex: 1;
+ /* Default dialog dimensions */
+ width: 34em;
+}
+
+.dialogOverlay[topmost="true"],
+#window-modal-dialog::backdrop {
+ background-color: rgba(28, 27, 34, 0.45);
+}
+
+.dialogOverlay[hideContent="true"][topmost="true"] {
+ background-color: var(--tabpanel-background-color);
+}
+
+/* For the window-modal dialog, the background is supplied by the HTML dialog
+ * backdrop, so the dialogOverlay background above "double backgrounds" - so
+ * we remove it here: */
+#window-modal-dialog > .dialogOverlay[topmost="true"] {
+ background-color: transparent;
+}
+
+/* Hide tab-modal dialogs when a window-modal one is up. */
+:root[window-modal-open] .browserStack > .dialogStack {
+ visibility: hidden;
+}
+
+/**
+ * End Dialogs
+ */
diff --git a/browser/base/content/browser.js b/browser/base/content/browser.js
new file mode 100644
index 0000000000..8aa9d0a82c
--- /dev/null
+++ b/browser/base/content/browser.js
@@ -0,0 +1,9955 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+ * 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/. */
+
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+var { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+ChromeUtils.importESModule("resource://gre/modules/NotificationDB.sys.mjs");
+
+// lazy module getters
+
+ChromeUtils.defineESModuleGetters(this, {
+ AMTelemetry: "resource://gre/modules/AddonManager.sys.mjs",
+ AboutReaderParent: "resource:///actors/AboutReaderParent.sys.mjs",
+ AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
+ BrowserSearchTelemetry: "resource:///modules/BrowserSearchTelemetry.sys.mjs",
+ BrowserTelemetryUtils: "resource://gre/modules/BrowserTelemetryUtils.sys.mjs",
+ Color: "resource://gre/modules/Color.sys.mjs",
+ ContextualIdentityService:
+ "resource://gre/modules/ContextualIdentityService.sys.mjs",
+ CustomizableUI: "resource:///modules/CustomizableUI.sys.mjs",
+ Deprecated: "resource://gre/modules/Deprecated.sys.mjs",
+ DevToolsSocketStatus:
+ "resource://devtools/shared/security/DevToolsSocketStatus.sys.mjs",
+ DownloadUtils: "resource://gre/modules/DownloadUtils.sys.mjs",
+ DownloadsCommon: "resource:///modules/DownloadsCommon.sys.mjs",
+ E10SUtils: "resource://gre/modules/E10SUtils.sys.mjs",
+ FirefoxViewNotificationManager:
+ "resource:///modules/firefox-view-notification-manager.sys.mjs",
+ LightweightThemeConsumer:
+ "resource://gre/modules/LightweightThemeConsumer.sys.mjs",
+ Log: "resource://gre/modules/Log.sys.mjs",
+ LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs",
+ LoginManagerParent: "resource://gre/modules/LoginManagerParent.sys.mjs",
+ MigrationUtils: "resource:///modules/MigrationUtils.sys.mjs",
+ NewTabUtils: "resource://gre/modules/NewTabUtils.sys.mjs",
+ NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs",
+ PageThumbs: "resource://gre/modules/PageThumbs.sys.mjs",
+ PanelMultiView: "resource:///modules/PanelMultiView.sys.mjs",
+ PanelView: "resource:///modules/PanelMultiView.sys.mjs",
+ PictureInPicture: "resource://gre/modules/PictureInPicture.sys.mjs",
+ PlacesTransactions: "resource://gre/modules/PlacesTransactions.sys.mjs",
+ PlacesUIUtils: "resource:///modules/PlacesUIUtils.sys.mjs",
+ PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
+ PluralForm: "resource://gre/modules/PluralForm.sys.mjs",
+ Pocket: "chrome://pocket/content/Pocket.sys.mjs",
+ PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
+ PromiseUtils: "resource://gre/modules/PromiseUtils.sys.mjs",
+ PromptUtils: "resource://gre/modules/PromptUtils.sys.mjs",
+ ReaderMode: "resource://gre/modules/ReaderMode.sys.mjs",
+ SafeBrowsing: "resource://gre/modules/SafeBrowsing.sys.mjs",
+ Sanitizer: "resource:///modules/Sanitizer.sys.mjs",
+ SaveToPocket: "chrome://pocket/content/SaveToPocket.sys.mjs",
+ ScreenshotsUtils: "resource:///modules/ScreenshotsUtils.sys.mjs",
+ SearchUIUtils: "resource:///modules/SearchUIUtils.sys.mjs",
+ SessionStartup: "resource:///modules/sessionstore/SessionStartup.sys.mjs",
+ SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs",
+ ShortcutUtils: "resource://gre/modules/ShortcutUtils.sys.mjs",
+ SitePermissions: "resource:///modules/SitePermissions.sys.mjs",
+ SubDialog: "resource://gre/modules/SubDialog.sys.mjs",
+ SubDialogManager: "resource://gre/modules/SubDialog.sys.mjs",
+ TabModalPrompt: "chrome://global/content/tabprompts.sys.mjs",
+ TabsSetupFlowManager:
+ "resource:///modules/firefox-view-tabs-setup-manager.sys.mjs",
+ TelemetryEnvironment: "resource://gre/modules/TelemetryEnvironment.sys.mjs",
+ TranslationsParent: "resource://gre/actors/TranslationsParent.sys.mjs",
+ UITour: "resource:///modules/UITour.sys.mjs",
+ UpdateUtils: "resource://gre/modules/UpdateUtils.sys.mjs",
+ UrlbarInput: "resource:///modules/UrlbarInput.sys.mjs",
+ UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs",
+ UrlbarProviderSearchTips:
+ "resource:///modules/UrlbarProviderSearchTips.sys.mjs",
+ UrlbarTokenizer: "resource:///modules/UrlbarTokenizer.sys.mjs",
+ UrlbarUtils: "resource:///modules/UrlbarUtils.sys.mjs",
+ UrlbarValueFormatter: "resource:///modules/UrlbarValueFormatter.sys.mjs",
+ Weave: "resource://services-sync/main.sys.mjs",
+ WebNavigationFrames: "resource://gre/modules/WebNavigationFrames.sys.mjs",
+ WebsiteFilter: "resource:///modules/policies/WebsiteFilter.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ AboutNewTab: "resource:///modules/AboutNewTab.jsm",
+ NewTabPagePreloading: "resource:///modules/NewTabPagePreloading.jsm",
+ BrowserUsageTelemetry: "resource:///modules/BrowserUsageTelemetry.jsm",
+ BrowserUIUtils: "resource:///modules/BrowserUIUtils.jsm",
+ BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.jsm",
+ CFRPageActions: "resource://activity-stream/lib/CFRPageActions.jsm",
+ ExtensionsUI: "resource:///modules/ExtensionsUI.jsm",
+ HomePage: "resource:///modules/HomePage.jsm",
+ NetUtil: "resource://gre/modules/NetUtil.jsm",
+ OpenInTabsUtils: "resource:///modules/OpenInTabsUtils.jsm",
+ PageActions: "resource:///modules/PageActions.jsm",
+ ProcessHangMonitor: "resource:///modules/ProcessHangMonitor.jsm",
+ SiteDataManager: "resource:///modules/SiteDataManager.jsm",
+ TabCrashHandler: "resource:///modules/ContentCrashHandlers.jsm",
+ Translation: "resource:///modules/translation/TranslationParent.jsm",
+ webrtcUI: "resource:///modules/webrtcUI.jsm",
+ ZoomUI: "resource:///modules/ZoomUI.jsm",
+});
+
+XPCOMUtils.defineLazyGetter(this, "fxAccounts", () => {
+ return ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccounts.sys.mjs"
+ ).getFxAccountsSingleton();
+});
+
+XPCOMUtils.defineLazyScriptGetter(
+ this,
+ "PlacesTreeView",
+ "chrome://browser/content/places/treeView.js"
+);
+XPCOMUtils.defineLazyScriptGetter(
+ this,
+ ["PlacesInsertionPoint", "PlacesController", "PlacesControllerDragHelper"],
+ "chrome://browser/content/places/controller.js"
+);
+XPCOMUtils.defineLazyScriptGetter(
+ this,
+ "PrintUtils",
+ "chrome://global/content/printUtils.js"
+);
+XPCOMUtils.defineLazyScriptGetter(
+ this,
+ "ZoomManager",
+ "chrome://global/content/viewZoomOverlay.js"
+);
+XPCOMUtils.defineLazyScriptGetter(
+ this,
+ "FullZoom",
+ "chrome://browser/content/browser-fullZoom.js"
+);
+XPCOMUtils.defineLazyScriptGetter(
+ this,
+ "PanelUI",
+ "chrome://browser/content/customizableui/panelUI.js"
+);
+XPCOMUtils.defineLazyScriptGetter(
+ this,
+ "gViewSourceUtils",
+ "chrome://global/content/viewSourceUtils.js"
+);
+XPCOMUtils.defineLazyScriptGetter(
+ this,
+ "gTabsPanel",
+ "chrome://browser/content/browser-allTabsMenu.js"
+);
+XPCOMUtils.defineLazyScriptGetter(
+ this,
+ [
+ "BrowserAddonUI",
+ "gExtensionsNotifications",
+ "gUnifiedExtensions",
+ "gXPInstallObserver",
+ ],
+ "chrome://browser/content/browser-addons.js"
+);
+XPCOMUtils.defineLazyScriptGetter(
+ this,
+ "ctrlTab",
+ "chrome://browser/content/browser-ctrlTab.js"
+);
+XPCOMUtils.defineLazyScriptGetter(
+ this,
+ ["CustomizationHandler", "AutoHideMenubar"],
+ "chrome://browser/content/browser-customization.js"
+);
+XPCOMUtils.defineLazyScriptGetter(
+ this,
+ ["PointerLock", "FullScreen"],
+ "chrome://browser/content/browser-fullScreenAndPointerLock.js"
+);
+XPCOMUtils.defineLazyScriptGetter(
+ this,
+ "gIdentityHandler",
+ "chrome://browser/content/browser-siteIdentity.js"
+);
+XPCOMUtils.defineLazyScriptGetter(
+ this,
+ "gPermissionPanel",
+ "chrome://browser/content/browser-sitePermissionPanel.js"
+);
+XPCOMUtils.defineLazyScriptGetter(
+ this,
+ "TranslationsPanel",
+ "chrome://browser/content/translations/translationsPanel.js"
+);
+XPCOMUtils.defineLazyScriptGetter(
+ this,
+ "gProtectionsHandler",
+ "chrome://browser/content/browser-siteProtections.js"
+);
+XPCOMUtils.defineLazyScriptGetter(
+ this,
+ ["gGestureSupport", "gHistorySwipeAnimation"],
+ "chrome://browser/content/browser-gestureSupport.js"
+);
+XPCOMUtils.defineLazyScriptGetter(
+ this,
+ "gSafeBrowsing",
+ "chrome://browser/content/browser-safebrowsing.js"
+);
+XPCOMUtils.defineLazyScriptGetter(
+ this,
+ "gSync",
+ "chrome://browser/content/browser-sync.js"
+);
+XPCOMUtils.defineLazyScriptGetter(
+ this,
+ "gBrowserThumbnails",
+ "chrome://browser/content/browser-thumbnails.js"
+);
+XPCOMUtils.defineLazyScriptGetter(
+ this,
+ ["openContextMenu", "nsContextMenu"],
+ "chrome://browser/content/nsContextMenu.js"
+);
+XPCOMUtils.defineLazyScriptGetter(
+ this,
+ [
+ "DownloadsPanel",
+ "DownloadsOverlayLoader",
+ "DownloadsView",
+ "DownloadsViewUI",
+ "DownloadsViewController",
+ "DownloadsSummary",
+ "DownloadsFooter",
+ "DownloadsBlockedSubview",
+ ],
+ "chrome://browser/content/downloads/downloads.js"
+);
+XPCOMUtils.defineLazyScriptGetter(
+ this,
+ ["DownloadsButton", "DownloadsIndicatorView"],
+ "chrome://browser/content/downloads/indicator.js"
+);
+XPCOMUtils.defineLazyScriptGetter(
+ this,
+ "gEditItemOverlay",
+ "chrome://browser/content/places/editBookmark.js"
+);
+XPCOMUtils.defineLazyScriptGetter(
+ this,
+ "gGfxUtils",
+ "chrome://browser/content/browser-graphics-utils.js"
+);
+XPCOMUtils.defineLazyScriptGetter(
+ this,
+ "pktUI",
+ "chrome://pocket/content/pktUI.js"
+);
+XPCOMUtils.defineLazyScriptGetter(
+ this,
+ "ToolbarKeyboardNavigator",
+ "chrome://browser/content/browser-toolbarKeyNav.js"
+);
+XPCOMUtils.defineLazyScriptGetter(
+ this,
+ "A11yUtils",
+ "chrome://browser/content/browser-a11yUtils.js"
+);
+XPCOMUtils.defineLazyScriptGetter(
+ this,
+ "gSharedTabWarning",
+ "chrome://browser/content/browser-webrtc.js"
+);
+XPCOMUtils.defineLazyScriptGetter(
+ this,
+ "gPageStyleMenu",
+ "chrome://browser/content/browser-pagestyle.js"
+);
+
+// lazy service getters
+
+XPCOMUtils.defineLazyServiceGetters(this, {
+ ContentPrefService2: [
+ "@mozilla.org/content-pref/service;1",
+ "nsIContentPrefService2",
+ ],
+ classifierService: [
+ "@mozilla.org/url-classifier/dbservice;1",
+ "nsIURIClassifier",
+ ],
+ Favicons: ["@mozilla.org/browser/favicon-service;1", "nsIFaviconService"],
+ WindowsUIUtils: ["@mozilla.org/windows-ui-utils;1", "nsIWindowsUIUtils"],
+ BrowserHandler: ["@mozilla.org/browser/clh;1", "nsIBrowserHandler"],
+});
+
+if (AppConstants.ENABLE_WEBDRIVER) {
+ XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "Marionette",
+ "@mozilla.org/remote/marionette;1",
+ "nsIMarionette"
+ );
+
+ XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "RemoteAgent",
+ "@mozilla.org/remote/agent;1",
+ "nsIRemoteAgent"
+ );
+} else {
+ this.Marionette = { running: false };
+ this.RemoteAgent = { running: false };
+}
+
+XPCOMUtils.defineLazyGetter(this, "RTL_UI", () => {
+ return Services.locale.isAppLocaleRTL;
+});
+
+XPCOMUtils.defineLazyGetter(this, "gBrandBundle", () => {
+ return Services.strings.createBundle(
+ "chrome://branding/locale/brand.properties"
+ );
+});
+
+XPCOMUtils.defineLazyGetter(this, "gBrowserBundle", () => {
+ return Services.strings.createBundle(
+ "chrome://browser/locale/browser.properties"
+ );
+});
+
+XPCOMUtils.defineLazyGetter(this, "gCustomizeMode", () => {
+ let { CustomizeMode } = ChromeUtils.importESModule(
+ "resource:///modules/CustomizeMode.sys.mjs"
+ );
+ return new CustomizeMode(window);
+});
+
+XPCOMUtils.defineLazyGetter(this, "gNavToolbox", () => {
+ return document.getElementById("navigator-toolbox");
+});
+
+XPCOMUtils.defineLazyGetter(this, "gURLBar", () => {
+ let urlbar = new UrlbarInput({
+ textbox: document.getElementById("urlbar"),
+ eventTelemetryCategory: "urlbar",
+ });
+
+ let beforeFocusOrSelect = event => {
+ // In customize mode, the url bar is disabled. If a new tab is opened or the
+ // user switches to a different tab, this function gets called before we've
+ // finished leaving customize mode, and the url bar will still be disabled.
+ // We can't focus it when it's disabled, so we need to re-run ourselves when
+ // we've finished leaving customize mode.
+ if (
+ CustomizationHandler.isCustomizing() ||
+ CustomizationHandler.isExitingCustomizeMode
+ ) {
+ gNavToolbox.addEventListener(
+ "aftercustomization",
+ () => {
+ if (event.type == "beforeselect") {
+ gURLBar.select();
+ } else {
+ gURLBar.focus();
+ }
+ },
+ {
+ once: true,
+ }
+ );
+ event.preventDefault();
+ return;
+ }
+
+ if (window.fullScreen) {
+ FullScreen.showNavToolbox();
+ }
+ };
+ urlbar.addEventListener("beforefocus", beforeFocusOrSelect);
+ urlbar.addEventListener("beforeselect", beforeFocusOrSelect);
+
+ return urlbar;
+});
+
+XPCOMUtils.defineLazyGetter(this, "ReferrerInfo", () =>
+ Components.Constructor(
+ "@mozilla.org/referrer-info;1",
+ "nsIReferrerInfo",
+ "init"
+ )
+);
+
+// High priority notification bars shown at the top of the window.
+XPCOMUtils.defineLazyGetter(this, "gNotificationBox", () => {
+ return new MozElements.NotificationBox(element => {
+ element.classList.add("global-notificationbox");
+ element.setAttribute("notificationside", "top");
+ element.setAttribute("prepend-notifications", true);
+ const tabNotifications = document.getElementById("tab-notification-deck");
+ gNavToolbox.insertBefore(element, tabNotifications);
+ });
+});
+
+XPCOMUtils.defineLazyGetter(this, "InlineSpellCheckerUI", () => {
+ let { InlineSpellChecker } = ChromeUtils.importESModule(
+ "resource://gre/modules/InlineSpellChecker.sys.mjs"
+ );
+ return new InlineSpellChecker();
+});
+
+XPCOMUtils.defineLazyGetter(this, "PopupNotifications", () => {
+ // eslint-disable-next-line no-shadow
+ let { PopupNotifications } = ChromeUtils.importESModule(
+ "resource://gre/modules/PopupNotifications.sys.mjs"
+ );
+ try {
+ // Hide all PopupNotifications while the the address bar has focus,
+ // including the virtual focus in the results popup, and the URL is being
+ // edited or the page proxy state is invalid while async tab switching.
+ let shouldSuppress = () => {
+ // "Blank" pages, like about:welcome, have a pageproxystate of "invalid", but
+ // popups like CFRs should not automatically be suppressed when the address
+ // bar has focus on these pages as it disrupts user navigation using FN+F6.
+ // See `UrlbarInput.setURI()` where pageproxystate is set to "invalid" for
+ // all pages that the "isBlankPageURL" method returns true for.
+ const urlBarEdited = isBlankPageURL(gBrowser.currentURI.spec)
+ ? gURLBar.hasAttribute("usertyping")
+ : gURLBar.getAttribute("pageproxystate") != "valid";
+ return (
+ (urlBarEdited && gURLBar.focused) ||
+ (gURLBar.getAttribute("pageproxystate") != "valid" &&
+ gBrowser.selectedBrowser._awaitingSetURI) ||
+ shouldSuppressPopupNotifications()
+ );
+ };
+
+ // Before a Popup is shown, check that its anchor is visible.
+ // If the anchor is not visible, use one of the fallbacks.
+ // If no fallbacks are visible, return null.
+ const getVisibleAnchorElement = anchorElement => {
+ // If the anchor element is present in the Urlbar,
+ // ensure that both the anchor and page URL are visible.
+ gURLBar.maybeHandleRevertFromPopup(anchorElement);
+ if (anchorElement?.checkVisibility()) {
+ return anchorElement;
+ }
+ let fallback = [
+ document.getElementById("identity-icon"),
+ document.getElementById("urlbar-search-button"),
+ ];
+ return fallback.find(element => element?.checkVisibility()) ?? null;
+ };
+
+ return new PopupNotifications(
+ gBrowser,
+ document.getElementById("notification-popup"),
+ document.getElementById("notification-popup-box"),
+ { shouldSuppress, getVisibleAnchorElement }
+ );
+ } catch (ex) {
+ console.error(ex);
+ return null;
+ }
+});
+
+XPCOMUtils.defineLazyGetter(this, "MacUserActivityUpdater", () => {
+ if (AppConstants.platform != "macosx") {
+ return null;
+ }
+
+ return Cc["@mozilla.org/widget/macuseractivityupdater;1"].getService(
+ Ci.nsIMacUserActivityUpdater
+ );
+});
+
+XPCOMUtils.defineLazyGetter(this, "Win7Features", () => {
+ if (AppConstants.platform != "win") {
+ return null;
+ }
+
+ const WINTASKBAR_CONTRACTID = "@mozilla.org/windows-taskbar;1";
+ if (
+ WINTASKBAR_CONTRACTID in Cc &&
+ Cc[WINTASKBAR_CONTRACTID].getService(Ci.nsIWinTaskbar).available
+ ) {
+ let { AeroPeek } = ChromeUtils.import(
+ "resource:///modules/WindowsPreviewPerTab.jsm"
+ );
+ return {
+ onOpenWindow() {
+ AeroPeek.onOpenWindow(window);
+ this.handledOpening = true;
+ },
+ onCloseWindow() {
+ if (this.handledOpening) {
+ AeroPeek.onCloseWindow(window);
+ }
+ },
+ handledOpening: false,
+ };
+ }
+ return null;
+});
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "gToolbarKeyNavEnabled",
+ "browser.toolbars.keyboard_navigation",
+ false,
+ (aPref, aOldVal, aNewVal) => {
+ if (window.closed) {
+ return;
+ }
+ if (aNewVal) {
+ ToolbarKeyboardNavigator.init();
+ } else {
+ ToolbarKeyboardNavigator.uninit();
+ }
+ }
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "gBookmarksToolbarVisibility",
+ "browser.toolbars.bookmarks.visibility",
+ "newtab"
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "gFxaToolbarEnabled",
+ "identity.fxaccounts.toolbar.enabled",
+ false,
+ (aPref, aOldVal, aNewVal) => {
+ updateFxaToolbarMenu(aNewVal);
+ }
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "gFxaToolbarAccessed",
+ "identity.fxaccounts.toolbar.accessed",
+ false,
+ (aPref, aOldVal, aNewVal) => {
+ updateFxaToolbarMenu(gFxaToolbarEnabled);
+ }
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "gAddonAbuseReportEnabled",
+ "extensions.abuseReport.enabled",
+ false
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "gAlwaysOpenPanel",
+ "browser.download.alwaysOpenPanel",
+ true
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "gMiddleClickNewTabUsesPasteboard",
+ "browser.tabs.searchclipboardfor.middleclick",
+ true
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "gScreenshotsDisabled",
+ "extensions.screenshots.disabled",
+ false,
+ () => {
+ Services.obs.notifyObservers(
+ window,
+ "toggle-screenshot-disable",
+ gScreenshots.shouldScreenshotsButtonBeDisabled()
+ );
+ }
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "gPrintEnabled",
+ "print.enabled",
+ false,
+ (aPref, aOldVal, aNewVal) => {
+ updatePrintCommands(aNewVal);
+ }
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "gScreenshotsComponentEnabled",
+ "screenshots.browser.component.enabled",
+ false,
+ () => {
+ Services.obs.notifyObservers(
+ window,
+ "toggle-screenshot-disable",
+ gScreenshots.shouldScreenshotsButtonBeDisabled()
+ );
+ }
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "gTranslationsEnabled",
+ "browser.translations.enable",
+ false
+);
+
+customElements.setElementCreationCallback("screenshots-buttons", () => {
+ Services.scriptloader.loadSubScript(
+ "chrome://browser/content/screenshots/screenshots-buttons.js",
+ window
+ );
+});
+
+var gBrowser;
+var gContextMenu = null; // nsContextMenu instance
+var gMultiProcessBrowser = window.docShell.QueryInterface(
+ Ci.nsILoadContext
+).useRemoteTabs;
+var gFissionBrowser = window.docShell.QueryInterface(
+ Ci.nsILoadContext
+).useRemoteSubframes;
+
+var gBrowserAllowScriptsToCloseInitialTabs = false;
+
+if (AppConstants.platform != "macosx") {
+ var gEditUIVisible = true;
+}
+
+Object.defineProperty(this, "gReduceMotion", {
+ enumerable: true,
+ get() {
+ return typeof gReduceMotionOverride == "boolean"
+ ? gReduceMotionOverride
+ : gReduceMotionSetting;
+ },
+});
+// Reduce motion during startup. The setting will be reset later.
+let gReduceMotionSetting = true;
+// This is for tests to set.
+var gReduceMotionOverride;
+
+// Smart getter for the findbar. If you don't wish to force the creation of
+// the findbar, check gFindBarInitialized first.
+
+Object.defineProperty(this, "gFindBar", {
+ enumerable: true,
+ get() {
+ return gBrowser.getCachedFindBar();
+ },
+});
+
+Object.defineProperty(this, "gFindBarInitialized", {
+ enumerable: true,
+ get() {
+ return gBrowser.isFindBarInitialized();
+ },
+});
+
+Object.defineProperty(this, "gFindBarPromise", {
+ enumerable: true,
+ get() {
+ return gBrowser.getFindBar();
+ },
+});
+
+function shouldSuppressPopupNotifications() {
+ // We have to hide notifications explicitly when the window is
+ // minimized because of the effects of the "noautohide" attribute on Linux.
+ // This can be removed once bug 545265 and bug 1320361 are fixed.
+ // Hide popup notifications when system tab prompts are shown so they
+ // don't cover up the prompt.
+ return (
+ window.windowState == window.STATE_MINIMIZED ||
+ gBrowser?.selectedBrowser.hasAttribute("tabmodalChromePromptShowing") ||
+ gBrowser?.selectedBrowser.hasAttribute("tabDialogShowing") ||
+ gDialogBox?.isOpen
+ );
+}
+
+async function gLazyFindCommand(cmd, ...args) {
+ let fb = await gFindBarPromise;
+ // We could be closed by now, or the tab with XBL binding could have gone away:
+ if (fb && fb[cmd]) {
+ fb[cmd].apply(fb, args);
+ }
+}
+
+var gPageIcons = {
+ "about:home": "chrome://branding/content/icon32.png",
+ "about:newtab": "chrome://branding/content/icon32.png",
+ "about:welcome": "chrome://branding/content/icon32.png",
+ "about:privatebrowsing": "chrome://browser/skin/privatebrowsing/favicon.svg",
+};
+
+var gInitialPages = [
+ "about:blank",
+ "about:home",
+ "about:firefoxview",
+ "about:newtab",
+ "about:privatebrowsing",
+ "about:sessionrestore",
+ "about:welcome",
+ "about:welcomeback",
+ "chrome://browser/content/blanktab.html",
+];
+
+function isInitialPage(url) {
+ if (!(url instanceof Ci.nsIURI)) {
+ try {
+ url = Services.io.newURI(url);
+ } catch (ex) {
+ return false;
+ }
+ }
+
+ let nonQuery = url.prePath + url.filePath;
+ return gInitialPages.includes(nonQuery) || nonQuery == BROWSER_NEW_TAB_URL;
+}
+
+function browserWindows() {
+ return Services.wm.getEnumerator("navigator:browser");
+}
+
+// This is a stringbundle-like interface to gBrowserBundle, formerly a getter for
+// the "bundle_browser" element.
+var gNavigatorBundle = {
+ getString(key) {
+ return gBrowserBundle.GetStringFromName(key);
+ },
+ getFormattedString(key, array) {
+ return gBrowserBundle.formatStringFromName(key, array);
+ },
+};
+
+var gScreenshots = {
+ shouldScreenshotsButtonBeDisabled() {
+ // About pages other than about:reader are not currently supported by
+ // the screenshots extension (see Bug 1620992).
+ let uri = gBrowser.selectedBrowser.currentURI;
+ let shouldBeDisabled =
+ gScreenshotsDisabled ||
+ (!gScreenshotsComponentEnabled &&
+ uri.scheme === "about" &&
+ !uri.spec.startsWith("about:reader"));
+
+ return shouldBeDisabled;
+ },
+};
+
+function updateFxaToolbarMenu(enable, isInitialUpdate = false) {
+ // We only show the Firefox Account toolbar menu if the feature is enabled and
+ // if sync is enabled.
+ const syncEnabled = Services.prefs.getBoolPref(
+ "identity.fxaccounts.enabled",
+ false
+ );
+
+ const mainWindowEl = document.documentElement;
+ const fxaPanelEl = PanelMultiView.getViewNode(document, "PanelUI-fxa");
+
+ // To minimize the toolbar button flickering or appearing/disappearing during startup,
+ // we use this pref to anticipate the likely FxA status.
+ const statusGuess = !!Services.prefs.getStringPref(
+ "identity.fxaccounts.account.device.name",
+ ""
+ );
+ mainWindowEl.setAttribute(
+ "fxastatus",
+ statusGuess ? "signed_in" : "not_configured"
+ );
+
+ fxaPanelEl.addEventListener("ViewShowing", gSync.updateSendToDeviceTitle);
+
+ Services.telemetry.setEventRecordingEnabled("fxa_app_menu", true);
+
+ if (enable && syncEnabled) {
+ mainWindowEl.setAttribute("fxatoolbarmenu", "visible");
+
+ // We have to manually update the sync state UI when toggling the FxA toolbar
+ // because it could show an invalid icon if the user is logged in and no sync
+ // event was performed yet.
+ if (!isInitialUpdate) {
+ gSync.maybeUpdateUIState();
+ }
+
+ Services.telemetry.setEventRecordingEnabled("fxa_avatar_menu", true);
+ } else {
+ mainWindowEl.removeAttribute("fxatoolbarmenu");
+ }
+}
+
+function UpdateBackForwardCommands(aWebNavigation) {
+ var backCommand = document.getElementById("Browser:Back");
+ var forwardCommand = document.getElementById("Browser:Forward");
+
+ // Avoid setting attributes on commands if the value hasn't changed!
+ // Remember, guys, setting attributes on elements is expensive! They
+ // get inherited into anonymous content, broadcast to other widgets, etc.!
+ // Don't do it if the value hasn't changed! - dwh
+
+ var backDisabled = backCommand.hasAttribute("disabled");
+ var forwardDisabled = forwardCommand.hasAttribute("disabled");
+ if (backDisabled == aWebNavigation.canGoBack) {
+ if (backDisabled) {
+ backCommand.removeAttribute("disabled");
+ } else {
+ backCommand.setAttribute("disabled", true);
+ }
+ }
+
+ if (forwardDisabled == aWebNavigation.canGoForward) {
+ if (forwardDisabled) {
+ forwardCommand.removeAttribute("disabled");
+ } else {
+ forwardCommand.setAttribute("disabled", true);
+ }
+ }
+}
+
+function updatePrintCommands(enabled) {
+ var printCommand = document.getElementById("cmd_print");
+ var printPreviewCommand = document.getElementById("cmd_printPreviewToggle");
+
+ if (enabled) {
+ printCommand.removeAttribute("disabled");
+ printPreviewCommand.removeAttribute("disabled");
+ } else {
+ printCommand.setAttribute("disabled", "true");
+ printPreviewCommand.setAttribute("disabled", "true");
+ }
+}
+
+/**
+ * Click-and-Hold implementation for the Back and Forward buttons
+ * XXXmano: should this live in toolbarbutton.js?
+ */
+function SetClickAndHoldHandlers() {
+ // Bug 414797: Clone the back/forward buttons' context menu into both buttons.
+ let popup = document.getElementById("backForwardMenu").cloneNode(true);
+ popup.removeAttribute("id");
+ // Prevent the back/forward buttons' context attributes from being inherited.
+ popup.setAttribute("context", "");
+
+ let backButton = document.getElementById("back-button");
+ backButton.setAttribute("type", "menu");
+ backButton.prepend(popup);
+ gClickAndHoldListenersOnElement.add(backButton);
+
+ let forwardButton = document.getElementById("forward-button");
+ popup = popup.cloneNode(true);
+ forwardButton.setAttribute("type", "menu");
+ forwardButton.prepend(popup);
+ gClickAndHoldListenersOnElement.add(forwardButton);
+}
+
+const gClickAndHoldListenersOnElement = {
+ _timers: new Map(),
+
+ _mousedownHandler(aEvent) {
+ if (
+ aEvent.button != 0 ||
+ aEvent.currentTarget.open ||
+ aEvent.currentTarget.disabled
+ ) {
+ return;
+ }
+
+ // Prevent the menupopup from opening immediately
+ aEvent.currentTarget.menupopup.hidden = true;
+
+ aEvent.currentTarget.addEventListener("mouseout", this);
+ aEvent.currentTarget.addEventListener("mouseup", this);
+ this._timers.set(
+ aEvent.currentTarget,
+ setTimeout(b => this._openMenu(b), 500, aEvent.currentTarget)
+ );
+ },
+
+ _clickHandler(aEvent) {
+ if (
+ aEvent.button == 0 &&
+ aEvent.target == aEvent.currentTarget &&
+ !aEvent.currentTarget.open &&
+ !aEvent.currentTarget.disabled
+ ) {
+ let cmdEvent = document.createEvent("xulcommandevent");
+ cmdEvent.initCommandEvent(
+ "command",
+ true,
+ true,
+ window,
+ 0,
+ aEvent.ctrlKey,
+ aEvent.altKey,
+ aEvent.shiftKey,
+ aEvent.metaKey,
+ 0,
+ null,
+ aEvent.mozInputSource
+ );
+ aEvent.currentTarget.dispatchEvent(cmdEvent);
+
+ // This is here to cancel the XUL default event
+ // dom.click() triggers a command even if there is a click handler
+ // however this can now be prevented with preventDefault().
+ aEvent.preventDefault();
+ }
+ },
+
+ _openMenu(aButton) {
+ this._cancelHold(aButton);
+ aButton.firstElementChild.hidden = false;
+ aButton.open = true;
+ },
+
+ _mouseoutHandler(aEvent) {
+ let buttonRect = aEvent.currentTarget.getBoundingClientRect();
+ if (
+ aEvent.clientX >= buttonRect.left &&
+ aEvent.clientX <= buttonRect.right &&
+ aEvent.clientY >= buttonRect.bottom
+ ) {
+ this._openMenu(aEvent.currentTarget);
+ } else {
+ this._cancelHold(aEvent.currentTarget);
+ }
+ },
+
+ _mouseupHandler(aEvent) {
+ this._cancelHold(aEvent.currentTarget);
+ },
+
+ _cancelHold(aButton) {
+ clearTimeout(this._timers.get(aButton));
+ aButton.removeEventListener("mouseout", this);
+ aButton.removeEventListener("mouseup", this);
+ },
+
+ _keypressHandler(aEvent) {
+ if (aEvent.key == " " || aEvent.key == "Enter") {
+ // Normally, command events get fired for keyboard activation. However,
+ // we've set type="menu", so that doesn't happen. Handle this the same
+ // way we handle clicks.
+ aEvent.target.click();
+ }
+ },
+
+ handleEvent(e) {
+ switch (e.type) {
+ case "mouseout":
+ this._mouseoutHandler(e);
+ break;
+ case "mousedown":
+ this._mousedownHandler(e);
+ break;
+ case "click":
+ this._clickHandler(e);
+ break;
+ case "mouseup":
+ this._mouseupHandler(e);
+ break;
+ case "keypress":
+ this._keypressHandler(e);
+ break;
+ }
+ },
+
+ remove(aButton) {
+ aButton.removeEventListener("mousedown", this, true);
+ aButton.removeEventListener("click", this, true);
+ aButton.removeEventListener("keypress", this, true);
+ },
+
+ add(aElm) {
+ this._timers.delete(aElm);
+
+ aElm.addEventListener("mousedown", this, true);
+ aElm.addEventListener("click", this, true);
+ aElm.addEventListener("keypress", this, true);
+ },
+};
+
+const gSessionHistoryObserver = {
+ observe(subject, topic, data) {
+ if (topic != "browser:purge-session-history") {
+ return;
+ }
+
+ var backCommand = document.getElementById("Browser:Back");
+ backCommand.setAttribute("disabled", "true");
+ var fwdCommand = document.getElementById("Browser:Forward");
+ fwdCommand.setAttribute("disabled", "true");
+
+ // Clear undo history of the URL bar
+ gURLBar.editor.clearUndoRedo();
+ },
+};
+
+const gStoragePressureObserver = {
+ _lastNotificationTime: -1,
+
+ async observe(subject, topic, data) {
+ if (topic != "QuotaManager::StoragePressure") {
+ return;
+ }
+
+ const NOTIFICATION_VALUE = "storage-pressure-notification";
+ if (gNotificationBox.getNotificationWithValue(NOTIFICATION_VALUE)) {
+ // Do not display the 2nd notification when there is already one
+ return;
+ }
+
+ // Don't display notification twice within the given interval.
+ // This is because
+ // - not to annoy user
+ // - give user some time to clean space.
+ // Even user sees notification and starts acting, it still takes some time.
+ const MIN_NOTIFICATION_INTERVAL_MS = Services.prefs.getIntPref(
+ "browser.storageManager.pressureNotification.minIntervalMS"
+ );
+ let duration = Date.now() - this._lastNotificationTime;
+ if (duration <= MIN_NOTIFICATION_INTERVAL_MS) {
+ return;
+ }
+ this._lastNotificationTime = Date.now();
+
+ MozXULElement.insertFTLIfNeeded("branding/brand.ftl");
+ MozXULElement.insertFTLIfNeeded("browser/preferences/preferences.ftl");
+
+ const BYTES_IN_GIGABYTE = 1073741824;
+ const USAGE_THRESHOLD_BYTES =
+ BYTES_IN_GIGABYTE *
+ Services.prefs.getIntPref(
+ "browser.storageManager.pressureNotification.usageThresholdGB"
+ );
+ let messageFragment = document.createDocumentFragment();
+ let message = document.createElement("span");
+
+ let buttons = [{ supportPage: "storage-permissions" }];
+ let usage = subject.QueryInterface(Ci.nsISupportsPRUint64).data;
+ if (usage < USAGE_THRESHOLD_BYTES) {
+ // The firefox-used space < 5GB, then warn user to free some disk space.
+ // This is because this usage is small and not the main cause for space issue.
+ // In order to avoid the bad and wrong impression among users that
+ // firefox eats disk space a lot, indicate users to clean up other disk space.
+ document.l10n.setAttributes(message, "space-alert-under-5gb-message2");
+ } else {
+ // The firefox-used space >= 5GB, then guide users to about:preferences
+ // to clear some data stored on firefox by websites.
+ document.l10n.setAttributes(message, "space-alert-over-5gb-message2");
+ buttons.push({
+ "l10n-id": "space-alert-over-5gb-settings-button",
+ callback(notificationBar, button) {
+ // The advanced subpanes are only supported in the old organization, which will
+ // be removed by bug 1349689.
+ openPreferences("privacy-sitedata");
+ },
+ });
+ }
+ messageFragment.appendChild(message);
+
+ gNotificationBox.appendNotification(
+ NOTIFICATION_VALUE,
+ {
+ label: messageFragment,
+ priority: gNotificationBox.PRIORITY_WARNING_HIGH,
+ },
+ buttons
+ );
+
+ // This seems to be necessary to get the buttons to display correctly
+ // See: https://bugzilla.mozilla.org/show_bug.cgi?id=1504216
+ document.l10n.translateFragment(gNotificationBox.currentNotification);
+ },
+};
+
+var gPopupBlockerObserver = {
+ handleEvent(aEvent) {
+ if (aEvent.originalTarget != gBrowser.selectedBrowser) {
+ return;
+ }
+
+ gPermissionPanel.refreshPermissionIcons();
+
+ let popupCount =
+ gBrowser.selectedBrowser.popupBlocker.getBlockedPopupCount();
+
+ if (!popupCount) {
+ // Hide the notification box (if it's visible).
+ let notificationBox = gBrowser.getNotificationBox();
+ let notification =
+ notificationBox.getNotificationWithValue("popup-blocked");
+ if (notification) {
+ notificationBox.removeNotification(notification, false);
+ }
+ return;
+ }
+
+ // Only show the notification again if we've not already shown it. Since
+ // notifications are per-browser, we don't need to worry about re-adding
+ // it.
+ if (gBrowser.selectedBrowser.popupBlocker.shouldShowNotification) {
+ if (Services.prefs.getBoolPref("privacy.popups.showBrowserMessage")) {
+ const label = {
+ "l10n-id":
+ popupCount < this.maxReportedPopups
+ ? "popup-warning-message"
+ : "popup-warning-exceeded-message",
+ "l10n-args": { popupCount },
+ };
+
+ let notificationBox = gBrowser.getNotificationBox();
+ let notification =
+ notificationBox.getNotificationWithValue("popup-blocked");
+ if (notification) {
+ notification.label = label;
+ } else {
+ const image = "chrome://browser/skin/notification-icons/popup.svg";
+ const priority = notificationBox.PRIORITY_INFO_MEDIUM;
+ notificationBox.appendNotification(
+ "popup-blocked",
+ { label, image, priority },
+ [
+ {
+ "l10n-id": "popup-warning-button",
+ popup: "blockedPopupOptions",
+ callback: null,
+ },
+ ]
+ );
+ }
+ }
+
+ // Record the fact that we've reported this blocked popup, so we don't
+ // show it again.
+ gBrowser.selectedBrowser.popupBlocker.didShowNotification();
+ }
+ },
+
+ toggleAllowPopupsForSite(aEvent) {
+ var pm = Services.perms;
+ var shouldBlock = aEvent.target.getAttribute("block") == "true";
+ var perm = shouldBlock ? pm.DENY_ACTION : pm.ALLOW_ACTION;
+ pm.addFromPrincipal(gBrowser.contentPrincipal, "popup", perm);
+
+ if (!shouldBlock) {
+ gBrowser.selectedBrowser.popupBlocker.unblockAllPopups();
+ }
+
+ gBrowser.getNotificationBox().removeCurrentNotification();
+ },
+
+ fillPopupList(aEvent) {
+ // XXXben - rather than using |currentURI| here, which breaks down on multi-framed sites
+ // we should really walk the blockedPopups and create a list of "allow for <host>"
+ // menuitems for the common subset of hosts present in the report, this will
+ // make us frame-safe.
+ //
+ // XXXjst - Note that when this is fixed to work with multi-framed sites,
+ // also back out the fix for bug 343772 where
+ // nsGlobalWindow::CheckOpenAllow() was changed to also
+ // check if the top window's location is allow-listed.
+ let browser = gBrowser.selectedBrowser;
+ var uriOrPrincipal = browser.contentPrincipal.isContentPrincipal
+ ? browser.contentPrincipal
+ : browser.currentURI;
+ var blockedPopupAllowSite = document.getElementById(
+ "blockedPopupAllowSite"
+ );
+ try {
+ blockedPopupAllowSite.removeAttribute("hidden");
+ let uriHost = uriOrPrincipal.asciiHost
+ ? uriOrPrincipal.host
+ : uriOrPrincipal.spec;
+ var pm = Services.perms;
+ if (
+ pm.testPermissionFromPrincipal(browser.contentPrincipal, "popup") ==
+ pm.ALLOW_ACTION
+ ) {
+ // Offer an item to block popups for this site, if an allow-list entry exists
+ // already for it.
+ document.l10n.setAttributes(
+ blockedPopupAllowSite,
+ "popups-infobar-block",
+ { uriHost }
+ );
+ blockedPopupAllowSite.setAttribute("block", "true");
+ } else {
+ // Offer an item to allow popups for this site
+ document.l10n.setAttributes(
+ blockedPopupAllowSite,
+ "popups-infobar-allow",
+ { uriHost }
+ );
+ blockedPopupAllowSite.removeAttribute("block");
+ }
+ } catch (e) {
+ blockedPopupAllowSite.hidden = true;
+ }
+
+ let blockedPopupDontShowMessage = document.getElementById(
+ "blockedPopupDontShowMessage"
+ );
+ let showMessage = Services.prefs.getBoolPref(
+ "privacy.popups.showBrowserMessage"
+ );
+ blockedPopupDontShowMessage.setAttribute("checked", !showMessage);
+
+ let blockedPopupsSeparator = document.getElementById(
+ "blockedPopupsSeparator"
+ );
+ blockedPopupsSeparator.hidden = true;
+
+ browser.popupBlocker.getBlockedPopups().then(blockedPopups => {
+ let foundUsablePopupURI = false;
+ if (blockedPopups) {
+ for (let i = 0; i < blockedPopups.length; i++) {
+ let blockedPopup = blockedPopups[i];
+
+ // popupWindowURI will be null if the file picker popup is blocked.
+ // xxxdz this should make the option say "Show file picker" and do it (Bug 590306)
+ if (!blockedPopup.popupWindowURISpec) {
+ continue;
+ }
+
+ var popupURIspec = blockedPopup.popupWindowURISpec;
+
+ // Sometimes the popup URI that we get back from the blockedPopup
+ // isn't useful (for instance, netscape.com's popup URI ends up
+ // being "http://www.netscape.com", which isn't really the URI of
+ // the popup they're trying to show). This isn't going to be
+ // useful to the user, so we won't create a menu item for it.
+ if (
+ popupURIspec == "" ||
+ popupURIspec == "about:blank" ||
+ popupURIspec == "<self>" ||
+ popupURIspec == uriOrPrincipal.spec
+ ) {
+ continue;
+ }
+
+ // Because of the short-circuit above, we may end up in a situation
+ // in which we don't have any usable popup addresses to show in
+ // the menu, and therefore we shouldn't show the separator. However,
+ // since we got past the short-circuit, we must've found at least
+ // one usable popup URI and thus we'll turn on the separator later.
+ foundUsablePopupURI = true;
+
+ var menuitem = document.createXULElement("menuitem");
+ document.l10n.setAttributes(menuitem, "popup-show-popup-menuitem", {
+ popupURI: popupURIspec,
+ });
+ menuitem.setAttribute(
+ "oncommand",
+ "gPopupBlockerObserver.showBlockedPopup(event);"
+ );
+ menuitem.setAttribute("popupReportIndex", i);
+ menuitem.setAttribute(
+ "popupInnerWindowId",
+ blockedPopup.innerWindowId
+ );
+ menuitem.browsingContext = blockedPopup.browsingContext;
+ menuitem.popupReportBrowser = browser;
+ aEvent.target.appendChild(menuitem);
+ }
+ }
+
+ // Show the separator if we added any
+ // showable popup addresses to the menu.
+ if (foundUsablePopupURI) {
+ blockedPopupsSeparator.removeAttribute("hidden");
+ }
+ }, null);
+ },
+
+ onPopupHiding(aEvent) {
+ let item = aEvent.target.lastElementChild;
+ while (item && item.id != "blockedPopupsSeparator") {
+ let next = item.previousElementSibling;
+ item.remove();
+ item = next;
+ }
+ },
+
+ showBlockedPopup(aEvent) {
+ let target = aEvent.target;
+ let browsingContext = target.browsingContext;
+ let innerWindowId = target.getAttribute("popupInnerWindowId");
+ let popupReportIndex = target.getAttribute("popupReportIndex");
+ let browser = target.popupReportBrowser;
+ browser.popupBlocker.unblockPopup(
+ browsingContext,
+ innerWindowId,
+ popupReportIndex
+ );
+ },
+
+ editPopupSettings() {
+ openPreferences("privacy-permissions-block-popups");
+ },
+
+ dontShowMessage() {
+ var showMessage = Services.prefs.getBoolPref(
+ "privacy.popups.showBrowserMessage"
+ );
+ Services.prefs.setBoolPref(
+ "privacy.popups.showBrowserMessage",
+ !showMessage
+ );
+ gBrowser.getNotificationBox().removeCurrentNotification();
+ },
+};
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ gPopupBlockerObserver,
+ "maxReportedPopups",
+ "privacy.popups.maxReported"
+);
+
+var gKeywordURIFixup = {
+ check(browser, { fixedURI, keywordProviderName, preferredURI }) {
+ // We get called irrespective of whether we did a keyword search, or
+ // whether the original input would be vaguely interpretable as a URL,
+ // so figure that out first.
+ if (
+ !keywordProviderName ||
+ !fixedURI ||
+ !fixedURI.host ||
+ UrlbarPrefs.get("browser.fixup.dns_first_for_single_words") ||
+ UrlbarPrefs.get("dnsResolveSingleWordsAfterSearch") == 0
+ ) {
+ return;
+ }
+
+ let contentPrincipal = browser.contentPrincipal;
+
+ // At this point we're still only just about to load this URI.
+ // When the async DNS lookup comes back, we may be in any of these states:
+ // 1) still on the previous URI, waiting for the preferredURI (keyword
+ // search) to respond;
+ // 2) at the keyword search URI (preferredURI)
+ // 3) at some other page because the user stopped navigation.
+ // We keep track of the currentURI to detect case (1) in the DNS lookup
+ // callback.
+ let previousURI = browser.currentURI;
+
+ // now swap for a weak ref so we don't hang on to browser needlessly
+ // even if the DNS query takes forever
+ let weakBrowser = Cu.getWeakReference(browser);
+ browser = null;
+
+ // Additionally, we need the host of the parsed url
+ let hostName = fixedURI.displayHost;
+ // and the ascii-only host for the pref:
+ let asciiHost = fixedURI.asciiHost;
+
+ let onLookupCompleteListener = {
+ onLookupComplete(request, record, status) {
+ let browserRef = weakBrowser.get();
+ if (!Components.isSuccessCode(status) || !browserRef) {
+ return;
+ }
+
+ let currentURI = browserRef.currentURI;
+ // If we're in case (3) (see above), don't show an info bar.
+ if (
+ !currentURI.equals(previousURI) &&
+ !currentURI.equals(preferredURI)
+ ) {
+ return;
+ }
+
+ // show infobar offering to visit the host
+ let notificationBox = gBrowser.getNotificationBox(browserRef);
+ if (notificationBox.getNotificationWithValue("keyword-uri-fixup")) {
+ return;
+ }
+
+ let displayHostName = "http://" + hostName + "/";
+ let message = gNavigatorBundle.getFormattedString(
+ "keywordURIFixup.message",
+ [displayHostName]
+ );
+ let yesMessage = gNavigatorBundle.getFormattedString(
+ "keywordURIFixup.goTo",
+ [displayHostName]
+ );
+
+ let buttons = [
+ {
+ label: yesMessage,
+ accessKey: gNavigatorBundle.getString(
+ "keywordURIFixup.goTo.accesskey"
+ ),
+ callback() {
+ // Do not set this preference while in private browsing.
+ if (!PrivateBrowsingUtils.isWindowPrivate(window)) {
+ let prefHost = asciiHost;
+ // Normalize out a single trailing dot - NB: not using endsWith/lastIndexOf
+ // because we need to be sure this last dot is the *only* dot, too.
+ // More generally, this is used for the pref and should stay in sync with
+ // the code in URIFixup::KeywordURIFixup .
+ if (prefHost.indexOf(".") == prefHost.length - 1) {
+ prefHost = prefHost.slice(0, -1);
+ }
+ let pref = "browser.fixup.domainwhitelist." + prefHost;
+ Services.prefs.setBoolPref(pref, true);
+ }
+ openTrustedLinkIn(fixedURI.spec, "current");
+ },
+ },
+ ];
+ let notification = notificationBox.appendNotification(
+ "keyword-uri-fixup",
+ {
+ label: message,
+ priority: notificationBox.PRIORITY_INFO_HIGH,
+ },
+ buttons
+ );
+ notification.persistence = 1;
+ },
+ };
+
+ Services.uriFixup.checkHost(
+ fixedURI,
+ onLookupCompleteListener,
+ contentPrincipal.originAttributes
+ );
+ },
+
+ observe(fixupInfo, topic, data) {
+ fixupInfo.QueryInterface(Ci.nsIURIFixupInfo);
+
+ let browser = fixupInfo.consumer?.top?.embedderElement;
+ if (!browser || browser.ownerGlobal != window) {
+ return;
+ }
+
+ this.check(browser, fixupInfo);
+ },
+};
+
+/* Creates a null principal using the userContextId
+ from the current selected tab or a passed in tab argument */
+function _createNullPrincipalFromTabUserContextId(tab = gBrowser.selectedTab) {
+ let userContextId;
+ if (tab.hasAttribute("usercontextid")) {
+ userContextId = tab.getAttribute("usercontextid");
+ }
+ return Services.scriptSecurityManager.createNullPrincipal({
+ userContextId,
+ });
+}
+
+let _resolveDelayedStartup;
+var delayedStartupPromise = new Promise(resolve => {
+ _resolveDelayedStartup = resolve;
+});
+
+var gBrowserInit = {
+ delayedStartupFinished: false,
+ idleTasksFinishedPromise: null,
+ idleTaskPromiseResolve: null,
+ domContentLoaded: false,
+
+ _tabToAdopt: undefined,
+
+ _setupFirstContentWindowPaintPromise() {
+ let lastTransactionId = window.windowUtils.lastTransactionId;
+ let layerTreeListener = () => {
+ if (this.getTabToAdopt()) {
+ // Need to wait until we finish adopting the tab, or we might end
+ // up focusing the initial browser and then losing focus when it
+ // gets swapped out for the tab to adopt.
+ return;
+ }
+ removeEventListener("MozLayerTreeReady", layerTreeListener);
+ let listener = e => {
+ if (e.transactionId > lastTransactionId) {
+ window.removeEventListener("MozAfterPaint", listener);
+ this._firstContentWindowPaintDeferred.resolve();
+ }
+ };
+ addEventListener("MozAfterPaint", listener);
+ };
+ addEventListener("MozLayerTreeReady", layerTreeListener);
+ },
+
+ getTabToAdopt() {
+ if (this._tabToAdopt !== undefined) {
+ return this._tabToAdopt;
+ }
+
+ if (window.arguments && window.XULElement.isInstance(window.arguments[0])) {
+ this._tabToAdopt = window.arguments[0];
+
+ // Clear the reference of the tab being adopted from the arguments.
+ window.arguments[0] = null;
+ } else {
+ // There was no tab to adopt in the arguments, set _tabToAdopt to null
+ // to avoid checking it again.
+ this._tabToAdopt = null;
+ }
+
+ return this._tabToAdopt;
+ },
+
+ _clearTabToAdopt() {
+ this._tabToAdopt = null;
+ },
+
+ // Used to check if the new window is still adopting an existing tab as its first tab
+ // (e.g. from the WebExtensions internals).
+ isAdoptingTab() {
+ return !!this.getTabToAdopt();
+ },
+
+ onBeforeInitialXULLayout() {
+ this._setupFirstContentWindowPaintPromise();
+
+ BookmarkingUI.updateEmptyToolbarMessage();
+ setToolbarVisibility(
+ BookmarkingUI.toolbar,
+ gBookmarksToolbarVisibility,
+ false,
+ false
+ );
+
+ // Set a sane starting width/height for all resolutions on new profiles.
+ if (Services.prefs.getBoolPref("privacy.resistFingerprinting")) {
+ // When the fingerprinting resistance is enabled, making sure that we don't
+ // have a maximum window to interfere with generating rounded window dimensions.
+ document.documentElement.setAttribute("sizemode", "normal");
+ } else if (!document.documentElement.hasAttribute("width")) {
+ const TARGET_WIDTH = 1280;
+ const TARGET_HEIGHT = 1040;
+ let width = Math.min(screen.availWidth * 0.9, TARGET_WIDTH);
+ let height = Math.min(screen.availHeight * 0.9, TARGET_HEIGHT);
+
+ document.documentElement.setAttribute("width", width);
+ document.documentElement.setAttribute("height", height);
+
+ if (width < TARGET_WIDTH && height < TARGET_HEIGHT) {
+ document.documentElement.setAttribute("sizemode", "maximized");
+ }
+ }
+ if (AppConstants.MENUBAR_CAN_AUTOHIDE) {
+ const toolbarMenubar = document.getElementById("toolbar-menubar");
+ // set a default value
+ if (!toolbarMenubar.hasAttribute("autohide")) {
+ toolbarMenubar.setAttribute("autohide", true);
+ }
+ toolbarMenubar.setAttribute(
+ "data-l10n-id",
+ "toolbar-context-menu-menu-bar-cmd"
+ );
+ toolbarMenubar.setAttribute("data-l10n-attrs", "toolbarname");
+ }
+
+ // Run menubar initialization first, to avoid TabsInTitlebar code picking
+ // up mutations from it and causing a reflow.
+ AutoHideMenubar.init();
+ // Update the chromemargin attribute so the window can be sized correctly.
+ window.TabBarVisibility.update();
+ TabsInTitlebar.init();
+
+ new LightweightThemeConsumer(document);
+
+ if (AppConstants.platform == "win") {
+ if (
+ window.matchMedia("(-moz-platform: windows-win8)").matches &&
+ window.matchMedia("(-moz-windows-default-theme)").matches
+ ) {
+ let windowFrameColor = new Color(
+ ...ChromeUtils.importESModule(
+ "resource:///modules/Windows8WindowFrameColor.sys.mjs"
+ ).Windows8WindowFrameColor.get()
+ );
+ // Default to black for foreground text.
+ if (!windowFrameColor.isContrastRatioAcceptable(new Color(0, 0, 0))) {
+ document.documentElement.setAttribute("darkwindowframe", "true");
+ }
+ } else if (AppConstants.isPlatformAndVersionAtLeast("win", "10")) {
+ TelemetryEnvironment.onInitialized().then(() => {
+ // 17763 is the build number of Windows 10 version 1809
+ if (
+ TelemetryEnvironment.currentEnvironment.system.os
+ .windowsBuildNumber < 17763
+ ) {
+ document.documentElement.setAttribute(
+ "always-use-accent-color-for-window-border",
+ ""
+ );
+ }
+ });
+ }
+ }
+
+ if (
+ Services.prefs.getBoolPref(
+ "toolkit.legacyUserProfileCustomizations.windowIcon",
+ false
+ )
+ ) {
+ document.documentElement.setAttribute("icon", "main-window");
+ }
+
+ // Call this after we set attributes that might change toolbars' computed
+ // text color.
+ ToolbarIconColor.init();
+ },
+
+ onDOMContentLoaded() {
+ // This needs setting up before we create the first remote browser.
+ window.docShell.treeOwner
+ .QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIAppWindow).XULBrowserWindow = window.XULBrowserWindow;
+ window.browserDOMWindow = new nsBrowserAccess();
+
+ gBrowser = window._gBrowser;
+ delete window._gBrowser;
+ gBrowser.init();
+
+ BrowserWindowTracker.track(window);
+
+ FirefoxViewHandler.init();
+
+ gNavToolbox.palette = document.getElementById(
+ "BrowserToolbarPalette"
+ ).content;
+ for (let area of CustomizableUI.areas) {
+ let type = CustomizableUI.getAreaType(area);
+ if (type == CustomizableUI.TYPE_TOOLBAR) {
+ let node = document.getElementById(area);
+ CustomizableUI.registerToolbarNode(node);
+ }
+ }
+ BrowserSearch.initPlaceHolder();
+
+ // Hack to ensure that the various initial pages favicon is loaded
+ // instantaneously, to avoid flickering and improve perceived performance.
+ this._callWithURIToLoad(uriToLoad => {
+ let url;
+ try {
+ url = Services.io.newURI(uriToLoad);
+ } catch (e) {
+ return;
+ }
+ let nonQuery = url.prePath + url.filePath;
+ if (nonQuery in gPageIcons) {
+ gBrowser.setIcon(gBrowser.selectedTab, gPageIcons[nonQuery]);
+ }
+ });
+
+ updateFxaToolbarMenu(gFxaToolbarEnabled, true);
+
+ updatePrintCommands(gPrintEnabled);
+
+ gUnifiedExtensions.init();
+
+ // Setting the focus will cause a style flush, it's preferable to call anything
+ // that will modify the DOM from within this function before this call.
+ this._setInitialFocus();
+
+ this.domContentLoaded = true;
+ },
+
+ onLoad() {
+ gBrowser.addEventListener("DOMUpdateBlockedPopups", gPopupBlockerObserver);
+ gBrowser.addEventListener(
+ "TranslationsParent:LanguageState",
+ TranslationsPanel
+ );
+
+ window.addEventListener("AppCommand", HandleAppCommandEvent, true);
+
+ // These routines add message listeners. They must run before
+ // loading the frame script to ensure that we don't miss any
+ // message sent between when the frame script is loaded and when
+ // the listener is registered.
+ CaptivePortalWatcher.init();
+ ZoomUI.init(window);
+
+ if (!gMultiProcessBrowser) {
+ // There is a Content:Click message manually sent from content.
+ Services.els.addSystemEventListener(
+ gBrowser.tabpanels,
+ "click",
+ contentAreaClick,
+ true
+ );
+ }
+
+ // hook up UI through progress listener
+ gBrowser.addProgressListener(window.XULBrowserWindow);
+ gBrowser.addTabsProgressListener(window.TabsProgressListener);
+
+ SidebarUI.init();
+
+ // We do this in onload because we want to ensure the button's state
+ // doesn't flicker as the window is being shown.
+ DownloadsButton.init();
+
+ // Certain kinds of automigration rely on this notification to complete
+ // their tasks BEFORE the browser window is shown. SessionStore uses it to
+ // restore tabs into windows AFTER important parts like gMultiProcessBrowser
+ // have been initialized.
+ Services.obs.notifyObservers(window, "browser-window-before-show");
+
+ if (!window.toolbar.visible) {
+ // adjust browser UI for popups
+ gURLBar.readOnly = true;
+ }
+
+ // Misc. inits.
+ gUIDensity.init();
+ TabletModeUpdater.init();
+ CombinedStopReload.ensureInitialized();
+ gPrivateBrowsingUI.init();
+ BrowserSearch.init();
+ BrowserPageActions.init();
+ if (gToolbarKeyNavEnabled) {
+ ToolbarKeyboardNavigator.init();
+ }
+
+ // Update UI if browser is under remote control.
+ gRemoteControl.updateVisualCue();
+
+ // If we are given a tab to swap in, take care of it before first paint to
+ // avoid an about:blank flash.
+ let tabToAdopt = this.getTabToAdopt();
+ if (tabToAdopt) {
+ let evt = new CustomEvent("before-initial-tab-adopted", {
+ bubbles: true,
+ });
+ gBrowser.tabpanels.dispatchEvent(evt);
+
+ // Stop the about:blank load
+ gBrowser.stop();
+ // make sure it has a docshell
+ gBrowser.docShell;
+
+ // Remove the speculative focus from the urlbar to let the url be formatted.
+ gURLBar.removeAttribute("focused");
+
+ let swapBrowsers = () => {
+ try {
+ gBrowser.swapBrowsersAndCloseOther(gBrowser.selectedTab, tabToAdopt);
+ } catch (e) {
+ console.error(e);
+ }
+
+ // Clear the reference to the tab once its adoption has been completed.
+ this._clearTabToAdopt();
+ };
+ if (tabToAdopt.linkedBrowser.isRemoteBrowser) {
+ // For remote browsers, wait for the paint event, otherwise the tabs
+ // are not yet ready and focus gets confused because the browser swaps
+ // out while tabs are switching.
+ addEventListener("MozAfterPaint", swapBrowsers, { once: true });
+ } else {
+ swapBrowsers();
+ }
+ }
+
+ // Wait until chrome is painted before executing code not critical to making the window visible
+ this._boundDelayedStartup = this._delayedStartup.bind(this);
+ window.addEventListener("MozAfterPaint", this._boundDelayedStartup);
+
+ if (!PrivateBrowsingUtils.enabled) {
+ document.getElementById("Tools:PrivateBrowsing").hidden = true;
+ // Setting disabled doesn't disable the shortcut, so we just remove
+ // the keybinding.
+ document.getElementById("key_privatebrowsing").remove();
+ }
+
+ if (BrowserUIUtils.quitShortcutDisabled) {
+ document.getElementById("key_quitApplication").remove();
+ document.getElementById("menu_FileQuitItem").removeAttribute("key");
+
+ PanelMultiView.getViewNode(
+ document,
+ "appMenu-quit-button2"
+ )?.removeAttribute("key");
+ }
+
+ this._loadHandled = true;
+ },
+
+ _cancelDelayedStartup() {
+ window.removeEventListener("MozAfterPaint", this._boundDelayedStartup);
+ this._boundDelayedStartup = null;
+ },
+
+ _delayedStartup() {
+ let { TelemetryTimestamps } = ChromeUtils.importESModule(
+ "resource://gre/modules/TelemetryTimestamps.sys.mjs"
+ );
+ TelemetryTimestamps.add("delayedStartupStarted");
+
+ this._cancelDelayedStartup();
+
+ // Bug 1531854 - The hidden window is force-created here
+ // until all of its dependencies are handled.
+ Services.appShell.hiddenDOMWindow;
+
+ gBrowser.addEventListener(
+ "PermissionStateChange",
+ function () {
+ gIdentityHandler.refreshIdentityBlock();
+ gPermissionPanel.updateSharingIndicator();
+ },
+ true
+ );
+
+ this._handleURIToLoad();
+
+ Services.obs.addObserver(gIdentityHandler, "perm-changed");
+ Services.obs.addObserver(gRemoteControl, "devtools-socket");
+ Services.obs.addObserver(gRemoteControl, "marionette-listening");
+ Services.obs.addObserver(gRemoteControl, "remote-listening");
+ Services.obs.addObserver(
+ gSessionHistoryObserver,
+ "browser:purge-session-history"
+ );
+ Services.obs.addObserver(
+ gStoragePressureObserver,
+ "QuotaManager::StoragePressure"
+ );
+ Services.obs.addObserver(gXPInstallObserver, "addon-install-disabled");
+ Services.obs.addObserver(gXPInstallObserver, "addon-install-started");
+ Services.obs.addObserver(gXPInstallObserver, "addon-install-blocked");
+ Services.obs.addObserver(
+ gXPInstallObserver,
+ "addon-install-fullscreen-blocked"
+ );
+ Services.obs.addObserver(
+ gXPInstallObserver,
+ "addon-install-origin-blocked"
+ );
+ Services.obs.addObserver(
+ gXPInstallObserver,
+ "addon-install-policy-blocked"
+ );
+ Services.obs.addObserver(
+ gXPInstallObserver,
+ "addon-install-webapi-blocked"
+ );
+ Services.obs.addObserver(gXPInstallObserver, "addon-install-failed");
+ Services.obs.addObserver(gXPInstallObserver, "addon-install-confirmation");
+ Services.obs.addObserver(gKeywordURIFixup, "keyword-uri-fixup");
+
+ BrowserOffline.init();
+ CanvasPermissionPromptHelper.init();
+ WebAuthnPromptHelper.init();
+
+ // Initialize the full zoom setting.
+ // We do this before the session restore service gets initialized so we can
+ // apply full zoom settings to tabs restored by the session restore service.
+ FullZoom.init();
+ PanelUI.init(shouldSuppressPopupNotifications);
+
+ UpdateUrlbarSearchSplitterState();
+
+ BookmarkingUI.init();
+ BrowserSearch.delayedStartupInit();
+ SearchUIUtils.init();
+ gProtectionsHandler.init();
+ HomePage.delayedStartup().catch(console.error);
+
+ let safeMode = document.getElementById("helpSafeMode");
+ if (Services.appinfo.inSafeMode) {
+ document.l10n.setAttributes(safeMode, "menu-help-exit-troubleshoot-mode");
+ safeMode.setAttribute(
+ "appmenu-data-l10n-id",
+ "appmenu-help-exit-troubleshoot-mode"
+ );
+ }
+
+ // BiDi UI
+ gBidiUI = isBidiEnabled();
+ if (gBidiUI) {
+ document.getElementById("documentDirection-separator").hidden = false;
+ document.getElementById("documentDirection-swap").hidden = false;
+ document.getElementById("textfieldDirection-separator").hidden = false;
+ document.getElementById("textfieldDirection-swap").hidden = false;
+ }
+
+ // Setup click-and-hold gestures access to the session history
+ // menus if global click-and-hold isn't turned on
+ if (!Services.prefs.getBoolPref("ui.click_hold_context_menus", false)) {
+ SetClickAndHoldHandlers();
+ }
+
+ function initBackForwardButtonTooltip(tooltipId, l10nId, shortcutId) {
+ let shortcut = document.getElementById(shortcutId);
+ shortcut = ShortcutUtils.prettifyShortcut(shortcut);
+
+ let tooltip = document.getElementById(tooltipId);
+ document.l10n.setAttributes(tooltip, l10nId, { shortcut });
+ }
+
+ initBackForwardButtonTooltip(
+ "back-button-tooltip-description",
+ "navbar-tooltip-back-2",
+ "goBackKb"
+ );
+
+ initBackForwardButtonTooltip(
+ "forward-button-tooltip-description",
+ "navbar-tooltip-forward-2",
+ "goForwardKb"
+ );
+
+ PlacesToolbarHelper.init();
+
+ ctrlTab.readPref();
+ Services.prefs.addObserver(ctrlTab.prefName, ctrlTab);
+
+ // The object handling the downloads indicator is initialized here in the
+ // delayed startup function, but the actual indicator element is not loaded
+ // unless there are downloads to be displayed.
+ DownloadsButton.initializeIndicator();
+
+ if (AppConstants.platform != "macosx") {
+ updateEditUIVisibility();
+ let placesContext = document.getElementById("placesContext");
+ placesContext.addEventListener("popupshowing", updateEditUIVisibility);
+ placesContext.addEventListener("popuphiding", updateEditUIVisibility);
+ }
+
+ FullScreen.init();
+
+ if (AppConstants.isPlatformAndVersionAtLeast("win", "10")) {
+ MenuTouchModeObserver.init();
+ }
+
+ if (AppConstants.MOZ_DATA_REPORTING) {
+ gDataNotificationInfoBar.init();
+ }
+
+ if (!AppConstants.MOZILLA_OFFICIAL) {
+ DevelopmentHelpers.init();
+ }
+
+ gExtensionsNotifications.init();
+
+ let wasMinimized = window.windowState == window.STATE_MINIMIZED;
+ window.addEventListener("sizemodechange", () => {
+ let isMinimized = window.windowState == window.STATE_MINIMIZED;
+ if (wasMinimized != isMinimized) {
+ wasMinimized = isMinimized;
+ UpdatePopupNotificationsVisibility();
+ }
+ });
+
+ window.addEventListener("mousemove", MousePosTracker);
+ window.addEventListener("dragover", MousePosTracker);
+
+ gNavToolbox.addEventListener("customizationstarting", CustomizationHandler);
+ gNavToolbox.addEventListener("aftercustomization", CustomizationHandler);
+
+ SessionStore.promiseInitialized.then(() => {
+ // Bail out if the window has been closed in the meantime.
+ if (window.closed) {
+ return;
+ }
+
+ // Enable the Restore Last Session command if needed
+ RestoreLastSessionObserver.init();
+
+ SidebarUI.startDelayedLoad();
+
+ PanicButtonNotifier.init();
+ });
+
+ if (BrowserHandler.kiosk) {
+ // We don't modify popup windows for kiosk mode
+ if (!gURLBar.readOnly) {
+ window.fullScreen = true;
+ }
+ }
+
+ if (Services.policies.status === Services.policies.ACTIVE) {
+ if (!Services.policies.isAllowed("hideShowMenuBar")) {
+ document
+ .getElementById("toolbar-menubar")
+ .removeAttribute("toolbarname");
+ }
+ let policies = Services.policies.getActivePolicies();
+ if ("ManagedBookmarks" in policies) {
+ let managedBookmarks = policies.ManagedBookmarks;
+ let children = managedBookmarks.filter(
+ child => !("toplevel_name" in child)
+ );
+ if (children.length) {
+ let managedBookmarksButton =
+ document.createXULElement("toolbarbutton");
+ managedBookmarksButton.setAttribute("id", "managed-bookmarks");
+ managedBookmarksButton.setAttribute("class", "bookmark-item");
+ let toplevel = managedBookmarks.find(
+ element => "toplevel_name" in element
+ );
+ if (toplevel) {
+ managedBookmarksButton.setAttribute(
+ "label",
+ toplevel.toplevel_name
+ );
+ } else {
+ managedBookmarksButton.setAttribute(
+ "data-l10n-id",
+ "managed-bookmarks"
+ );
+ }
+ managedBookmarksButton.setAttribute("context", "placesContext");
+ managedBookmarksButton.setAttribute("container", "true");
+ managedBookmarksButton.setAttribute("removable", "false");
+ managedBookmarksButton.setAttribute("type", "menu");
+
+ let managedBookmarksPopup = document.createXULElement("menupopup");
+ managedBookmarksPopup.setAttribute("id", "managed-bookmarks-popup");
+ managedBookmarksPopup.setAttribute(
+ "oncommand",
+ "PlacesToolbarHelper.openManagedBookmark(event);"
+ );
+ managedBookmarksPopup.setAttribute(
+ "ondragover",
+ "event.dataTransfer.effectAllowed='none';"
+ );
+ managedBookmarksPopup.setAttribute(
+ "ondragstart",
+ "PlacesToolbarHelper.onDragStartManaged(event);"
+ );
+ managedBookmarksPopup.setAttribute(
+ "onpopupshowing",
+ "PlacesToolbarHelper.populateManagedBookmarks(this);"
+ );
+ managedBookmarksPopup.setAttribute("placespopup", "true");
+ managedBookmarksPopup.setAttribute("is", "places-popup");
+ managedBookmarksPopup.setAttribute("type", "arrow");
+ managedBookmarksButton.appendChild(managedBookmarksPopup);
+
+ gNavToolbox.palette.appendChild(managedBookmarksButton);
+
+ CustomizableUI.ensureWidgetPlacedInWindow(
+ "managed-bookmarks",
+ window
+ );
+
+ // Add button if it doesn't exist
+ if (!CustomizableUI.getPlacementOfWidget("managed-bookmarks")) {
+ CustomizableUI.addWidgetToArea(
+ "managed-bookmarks",
+ CustomizableUI.AREA_BOOKMARKS,
+ 0
+ );
+ }
+ }
+ }
+ }
+
+ CaptivePortalWatcher.delayedStartup();
+
+ SessionStore.promiseAllWindowsRestored.then(() => {
+ this._schedulePerWindowIdleTasks();
+ document.documentElement.setAttribute("sessionrestored", "true");
+ });
+
+ this.delayedStartupFinished = true;
+ _resolveDelayedStartup();
+ Services.obs.notifyObservers(window, "browser-delayed-startup-finished");
+ TelemetryTimestamps.add("delayedStartupFinished");
+ // We've announced that delayed startup has finished. Do not add code past this point.
+ },
+
+ /**
+ * Resolved on the first MozLayerTreeReady and next MozAfterPaint in the
+ * parent process.
+ */
+ get firstContentWindowPaintPromise() {
+ return this._firstContentWindowPaintDeferred.promise;
+ },
+
+ _setInitialFocus() {
+ let initiallyFocusedElement = document.commandDispatcher.focusedElement;
+
+ // To prevent startup flicker, the urlbar has the 'focused' attribute set
+ // by default. If we are not sure the urlbar will be focused in this
+ // window, we need to remove the attribute before first paint.
+ // TODO (bug 1629956): The urlbar having the 'focused' attribute by default
+ // isn't a useful optimization anymore since UrlbarInput needs layout
+ // information to focus the urlbar properly.
+ let shouldRemoveFocusedAttribute = true;
+
+ this._callWithURIToLoad(uriToLoad => {
+ if (
+ isBlankPageURL(uriToLoad) ||
+ uriToLoad == "about:privatebrowsing" ||
+ this.getTabToAdopt()?.isEmpty
+ ) {
+ gURLBar.select();
+ shouldRemoveFocusedAttribute = false;
+ return;
+ }
+
+ // If the initial browser is remote, in order to optimize for first paint,
+ // we'll defer switching focus to that browser until it has painted.
+ // Otherwise use a regular promise to guarantee that mutationobserver
+ // microtasks that could affect focusability have run.
+ let promise = gBrowser.selectedBrowser.isRemoteBrowser
+ ? this.firstContentWindowPaintPromise
+ : Promise.resolve();
+
+ promise.then(() => {
+ // If focus didn't move while we were waiting, we're okay to move to
+ // the browser.
+ if (
+ document.commandDispatcher.focusedElement == initiallyFocusedElement
+ ) {
+ gBrowser.selectedBrowser.focus();
+ }
+ });
+ });
+
+ // Delay removing the attribute using requestAnimationFrame to avoid
+ // invalidating styles multiple times in a row if uriToLoadPromise
+ // resolves before first paint.
+ if (shouldRemoveFocusedAttribute) {
+ window.requestAnimationFrame(() => {
+ if (shouldRemoveFocusedAttribute) {
+ gURLBar.removeAttribute("focused");
+ }
+ });
+ }
+ },
+
+ _handleURIToLoad() {
+ this._callWithURIToLoad(uriToLoad => {
+ if (!uriToLoad) {
+ // We don't check whether window.arguments[5] (userContextId) is set
+ // because tabbrowser.js takes care of that for the initial tab.
+ return;
+ }
+
+ // We don't check if uriToLoad is a XULElement because this case has
+ // already been handled before first paint, and the argument cleared.
+ if (Array.isArray(uriToLoad)) {
+ // This function throws for certain malformed URIs, so use exception handling
+ // so that we don't disrupt startup
+ try {
+ gBrowser.loadTabs(uriToLoad, {
+ inBackground: false,
+ replace: true,
+ // See below for the semantics of window.arguments. Only the minimum is supported.
+ userContextId: window.arguments[5],
+ triggeringPrincipal:
+ window.arguments[8] ||
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ allowInheritPrincipal: window.arguments[9],
+ csp: window.arguments[10],
+ fromExternal: true,
+ });
+ } catch (e) {}
+ } else if (window.arguments.length >= 3) {
+ // window.arguments[1]: extraOptions (nsIPropertyBag)
+ // [2]: referrerInfo (nsIReferrerInfo)
+ // [3]: postData (nsIInputStream)
+ // [4]: allowThirdPartyFixup (bool)
+ // [5]: userContextId (int)
+ // [6]: originPrincipal (nsIPrincipal)
+ // [7]: originStoragePrincipal (nsIPrincipal)
+ // [8]: triggeringPrincipal (nsIPrincipal)
+ // [9]: allowInheritPrincipal (bool)
+ // [10]: csp (nsIContentSecurityPolicy)
+ // [11]: nsOpenWindowInfo
+ let userContextId =
+ window.arguments[5] != undefined
+ ? window.arguments[5]
+ : Ci.nsIScriptSecurityManager.DEFAULT_USER_CONTEXT_ID;
+
+ let hasValidUserGestureActivation = undefined;
+ let fromExternal = undefined;
+ let globalHistoryOptions = undefined;
+ let triggeringRemoteType = undefined;
+ let forceAllowDataURI = false;
+ if (window.arguments[1]) {
+ if (!(window.arguments[1] instanceof Ci.nsIPropertyBag2)) {
+ throw new Error(
+ "window.arguments[1] must be null or Ci.nsIPropertyBag2!"
+ );
+ }
+
+ let extraOptions = window.arguments[1];
+ if (extraOptions.hasKey("hasValidUserGestureActivation")) {
+ hasValidUserGestureActivation = extraOptions.getPropertyAsBool(
+ "hasValidUserGestureActivation"
+ );
+ }
+ if (extraOptions.hasKey("fromExternal")) {
+ fromExternal = extraOptions.getPropertyAsBool("fromExternal");
+ }
+ if (extraOptions.hasKey("triggeringSponsoredURL")) {
+ globalHistoryOptions = {
+ triggeringSponsoredURL: extraOptions.getPropertyAsACString(
+ "triggeringSponsoredURL"
+ ),
+ };
+ if (extraOptions.hasKey("triggeringSponsoredURLVisitTimeMS")) {
+ globalHistoryOptions.triggeringSponsoredURLVisitTimeMS =
+ extraOptions.getPropertyAsUint64(
+ "triggeringSponsoredURLVisitTimeMS"
+ );
+ }
+ }
+ if (extraOptions.hasKey("triggeringRemoteType")) {
+ triggeringRemoteType = extraOptions.getPropertyAsACString(
+ "triggeringRemoteType"
+ );
+ }
+ if (extraOptions.hasKey("forceAllowDataURI")) {
+ forceAllowDataURI =
+ extraOptions.getPropertyAsBool("forceAllowDataURI");
+ }
+ }
+
+ try {
+ openLinkIn(uriToLoad, "current", {
+ referrerInfo: window.arguments[2] || null,
+ postData: window.arguments[3] || null,
+ allowThirdPartyFixup: window.arguments[4] || false,
+ userContextId,
+ // pass the origin principal (if any) and force its use to create
+ // an initial about:blank viewer if present:
+ originPrincipal: window.arguments[6],
+ originStoragePrincipal: window.arguments[7],
+ triggeringPrincipal: window.arguments[8],
+ // TODO fix allowInheritPrincipal to default to false.
+ // Default to true unless explicitly set to false because of bug 1475201.
+ allowInheritPrincipal: window.arguments[9] !== false,
+ csp: window.arguments[10],
+ forceAboutBlankViewerInCurrent: !!window.arguments[6],
+ forceAllowDataURI,
+ hasValidUserGestureActivation,
+ fromExternal,
+ globalHistoryOptions,
+ triggeringRemoteType,
+ });
+ } catch (e) {
+ console.error(e);
+ }
+
+ window.focus();
+ } else {
+ // Note: loadOneOrMoreURIs *must not* be called if window.arguments.length >= 3.
+ // Such callers expect that window.arguments[0] is handled as a single URI.
+ loadOneOrMoreURIs(
+ uriToLoad,
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ null
+ );
+ }
+ });
+ },
+
+ /**
+ * Use this function as an entry point to schedule tasks that
+ * need to run once per window after startup, and can be scheduled
+ * by using an idle callback.
+ *
+ * The functions scheduled here will fire from idle callbacks
+ * once every window has finished being restored by session
+ * restore, and after the equivalent only-once tasks
+ * have run (from _scheduleStartupIdleTasks in BrowserGlue.sys.mjs).
+ */
+ _schedulePerWindowIdleTasks() {
+ // Bail out if the window has been closed in the meantime.
+ if (window.closed) {
+ return;
+ }
+
+ function scheduleIdleTask(func, options) {
+ requestIdleCallback(function idleTaskRunner() {
+ if (!window.closed) {
+ func();
+ }
+ }, options);
+ }
+
+ scheduleIdleTask(() => {
+ // Initialize the Sync UI
+ gSync.init();
+ });
+
+ scheduleIdleTask(() => {
+ // Read prefers-reduced-motion setting
+ let reduceMotionQuery = window.matchMedia(
+ "(prefers-reduced-motion: reduce)"
+ );
+ function readSetting() {
+ gReduceMotionSetting = reduceMotionQuery.matches;
+ }
+ reduceMotionQuery.addListener(readSetting);
+ readSetting();
+ });
+
+ scheduleIdleTask(() => {
+ // setup simple gestures support
+ gGestureSupport.init(true);
+
+ // setup history swipe animation
+ gHistorySwipeAnimation.init();
+ });
+
+ scheduleIdleTask(() => {
+ gBrowserThumbnails.init();
+ });
+
+ scheduleIdleTask(
+ () => {
+ // Initialize the download manager some time after the app starts so that
+ // auto-resume downloads begin (such as after crashing or quitting with
+ // active downloads) and speeds up the first-load of the download manager UI.
+ // If the user manually opens the download manager before the timeout, the
+ // downloads will start right away, and initializing again won't hurt.
+ try {
+ DownloadsCommon.initializeAllDataLinks();
+ ChromeUtils.importESModule(
+ "resource:///modules/DownloadsTaskbar.sys.mjs"
+ ).DownloadsTaskbar.registerIndicator(window);
+ if (AppConstants.platform == "macosx") {
+ ChromeUtils.importESModule(
+ "resource:///modules/DownloadsMacFinderProgress.sys.mjs"
+ ).DownloadsMacFinderProgress.register();
+ }
+ Services.telemetry.setEventRecordingEnabled("downloads", true);
+ } catch (ex) {
+ console.error(ex);
+ }
+ },
+ { timeout: 10000 }
+ );
+
+ if (Win7Features) {
+ scheduleIdleTask(() => Win7Features.onOpenWindow());
+ }
+
+ scheduleIdleTask(async () => {
+ NewTabPagePreloading.maybeCreatePreloadedBrowser(window);
+ });
+
+ scheduleIdleTask(() => {
+ gGfxUtils.init();
+ });
+
+ // This should always go last, since the idle tasks (except for the ones with
+ // timeouts) should execute in order. Note that this observer notification is
+ // not guaranteed to fire, since the window could close before we get here.
+ scheduleIdleTask(() => {
+ this.idleTaskPromiseResolve();
+ Services.obs.notifyObservers(
+ window,
+ "browser-idle-startup-tasks-finished"
+ );
+ });
+ },
+
+ // Returns the URI(s) to load at startup if it is immediately known, or a
+ // promise resolving to the URI to load.
+ get uriToLoadPromise() {
+ delete this.uriToLoadPromise;
+ return (this.uriToLoadPromise = (function () {
+ // window.arguments[0]: URI to load (string), or an nsIArray of
+ // nsISupportsStrings to load, or a xul:tab of
+ // a tabbrowser, which will be replaced by this
+ // window (for this case, all other arguments are
+ // ignored).
+ let uri = window.arguments?.[0];
+ if (!uri || window.XULElement.isInstance(uri)) {
+ return null;
+ }
+
+ let defaultArgs = BrowserHandler.defaultArgs;
+
+ // If the given URI is different from the homepage, we want to load it.
+ if (uri != defaultArgs) {
+ AboutNewTab.noteNonDefaultStartup();
+
+ if (uri instanceof Ci.nsIArray) {
+ // Transform the nsIArray of nsISupportsString's into a JS Array of
+ // JS strings.
+ return Array.from(
+ uri.enumerate(Ci.nsISupportsString),
+ supportStr => supportStr.data
+ );
+ } else if (uri instanceof Ci.nsISupportsString) {
+ return uri.data;
+ }
+ return uri;
+ }
+
+ // The URI appears to be the the homepage. We want to load it only if
+ // session restore isn't about to override the homepage.
+ let willOverride = SessionStartup.willOverrideHomepage;
+ if (typeof willOverride == "boolean") {
+ return willOverride ? null : uri;
+ }
+ return willOverride.then(willOverrideHomepage =>
+ willOverrideHomepage ? null : uri
+ );
+ })());
+ },
+
+ // Calls the given callback with the URI to load at startup.
+ // Synchronously if possible, or after uriToLoadPromise resolves otherwise.
+ _callWithURIToLoad(callback) {
+ let uriToLoad = this.uriToLoadPromise;
+ if (uriToLoad && uriToLoad.then) {
+ uriToLoad.then(callback);
+ } else {
+ callback(uriToLoad);
+ }
+ },
+
+ onUnload() {
+ gUIDensity.uninit();
+
+ TabsInTitlebar.uninit();
+
+ ToolbarIconColor.uninit();
+
+ // In certain scenarios it's possible for unload to be fired before onload,
+ // (e.g. if the window is being closed after browser.js loads but before the
+ // load completes). In that case, there's nothing to do here.
+ if (!this._loadHandled) {
+ return;
+ }
+
+ // First clean up services initialized in gBrowserInit.onLoad (or those whose
+ // uninit methods don't depend on the services having been initialized).
+
+ CombinedStopReload.uninit();
+
+ gGestureSupport.init(false);
+
+ gHistorySwipeAnimation.uninit();
+
+ FullScreen.uninit();
+
+ gSync.uninit();
+
+ gExtensionsNotifications.uninit();
+ gUnifiedExtensions.uninit();
+
+ try {
+ gBrowser.removeProgressListener(window.XULBrowserWindow);
+ gBrowser.removeTabsProgressListener(window.TabsProgressListener);
+ } catch (ex) {}
+
+ PlacesToolbarHelper.uninit();
+
+ BookmarkingUI.uninit();
+
+ TabletModeUpdater.uninit();
+
+ gTabletModePageCounter.finish();
+
+ CaptivePortalWatcher.uninit();
+
+ SidebarUI.uninit();
+
+ DownloadsButton.uninit();
+
+ if (gToolbarKeyNavEnabled) {
+ ToolbarKeyboardNavigator.uninit();
+ }
+
+ BrowserSearch.uninit();
+
+ NewTabPagePreloading.removePreloadedBrowser(window);
+
+ FirefoxViewHandler.uninit();
+
+ // Now either cancel delayedStartup, or clean up the services initialized from
+ // it.
+ if (this._boundDelayedStartup) {
+ this._cancelDelayedStartup();
+ } else {
+ if (Win7Features) {
+ Win7Features.onCloseWindow();
+ }
+ Services.prefs.removeObserver(ctrlTab.prefName, ctrlTab);
+ ctrlTab.uninit();
+ gBrowserThumbnails.uninit();
+ gProtectionsHandler.uninit();
+ FullZoom.destroy();
+
+ Services.obs.removeObserver(gIdentityHandler, "perm-changed");
+ Services.obs.removeObserver(gRemoteControl, "devtools-socket");
+ Services.obs.removeObserver(gRemoteControl, "marionette-listening");
+ Services.obs.removeObserver(gRemoteControl, "remote-listening");
+ Services.obs.removeObserver(
+ gSessionHistoryObserver,
+ "browser:purge-session-history"
+ );
+ Services.obs.removeObserver(
+ gStoragePressureObserver,
+ "QuotaManager::StoragePressure"
+ );
+ Services.obs.removeObserver(gXPInstallObserver, "addon-install-disabled");
+ Services.obs.removeObserver(gXPInstallObserver, "addon-install-started");
+ Services.obs.removeObserver(gXPInstallObserver, "addon-install-blocked");
+ Services.obs.removeObserver(
+ gXPInstallObserver,
+ "addon-install-fullscreen-blocked"
+ );
+ Services.obs.removeObserver(
+ gXPInstallObserver,
+ "addon-install-origin-blocked"
+ );
+ Services.obs.removeObserver(
+ gXPInstallObserver,
+ "addon-install-policy-blocked"
+ );
+ Services.obs.removeObserver(
+ gXPInstallObserver,
+ "addon-install-webapi-blocked"
+ );
+ Services.obs.removeObserver(gXPInstallObserver, "addon-install-failed");
+ Services.obs.removeObserver(
+ gXPInstallObserver,
+ "addon-install-confirmation"
+ );
+ Services.obs.removeObserver(gKeywordURIFixup, "keyword-uri-fixup");
+
+ if (AppConstants.isPlatformAndVersionAtLeast("win", "10")) {
+ MenuTouchModeObserver.uninit();
+ }
+ BrowserOffline.uninit();
+ CanvasPermissionPromptHelper.uninit();
+ WebAuthnPromptHelper.uninit();
+ PanelUI.uninit();
+ }
+
+ // Final window teardown, do this last.
+ gBrowser.destroy();
+ window.XULBrowserWindow = null;
+ window.docShell.treeOwner
+ .QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIAppWindow).XULBrowserWindow = null;
+ window.browserDOMWindow = null;
+ },
+};
+
+XPCOMUtils.defineLazyGetter(
+ gBrowserInit,
+ "_firstContentWindowPaintDeferred",
+ () => PromiseUtils.defer()
+);
+
+gBrowserInit.idleTasksFinishedPromise = new Promise(resolve => {
+ gBrowserInit.idleTaskPromiseResolve = resolve;
+});
+
+function HandleAppCommandEvent(evt) {
+ switch (evt.command) {
+ case "Back":
+ BrowserBack();
+ break;
+ case "Forward":
+ BrowserForward();
+ break;
+ case "Reload":
+ BrowserReloadSkipCache();
+ break;
+ case "Stop":
+ if (XULBrowserWindow.stopCommand.getAttribute("disabled") != "true") {
+ BrowserStop();
+ }
+ break;
+ case "Search":
+ BrowserSearch.webSearch();
+ break;
+ case "Bookmarks":
+ SidebarUI.toggle("viewBookmarksSidebar");
+ break;
+ case "Home":
+ BrowserHome();
+ break;
+ case "New":
+ BrowserOpenTab();
+ break;
+ case "Close":
+ BrowserCloseTabOrWindow();
+ break;
+ case "Find":
+ gLazyFindCommand("onFindCommand");
+ break;
+ case "Help":
+ openHelpLink("firefox-help");
+ break;
+ case "Open":
+ BrowserOpenFileWindow();
+ break;
+ case "Print":
+ PrintUtils.startPrintWindow(gBrowser.selectedBrowser.browsingContext);
+ break;
+ case "Save":
+ saveBrowser(gBrowser.selectedBrowser);
+ break;
+ case "SendMail":
+ MailIntegration.sendLinkForBrowser(gBrowser.selectedBrowser);
+ break;
+ default:
+ return;
+ }
+ evt.stopPropagation();
+ evt.preventDefault();
+}
+
+function gotoHistoryIndex(aEvent) {
+ aEvent = getRootEvent(aEvent);
+
+ let index = aEvent.target.getAttribute("index");
+ if (!index) {
+ return false;
+ }
+
+ let where = whereToOpenLink(aEvent);
+
+ if (where == "current") {
+ // Normal click. Go there in the current tab and update session history.
+
+ try {
+ gBrowser.gotoIndex(index);
+ } catch (ex) {
+ return false;
+ }
+ return true;
+ }
+ // Modified click. Go there in a new tab/window.
+
+ let historyindex = aEvent.target.getAttribute("historyindex");
+ duplicateTabIn(gBrowser.selectedTab, where, Number(historyindex));
+ return true;
+}
+
+function BrowserForward(aEvent) {
+ let where = whereToOpenLink(aEvent, false, true);
+
+ if (where == "current") {
+ try {
+ gBrowser.goForward();
+ } catch (ex) {}
+ } else {
+ duplicateTabIn(gBrowser.selectedTab, where, 1);
+ }
+}
+
+function BrowserBack(aEvent) {
+ let where = whereToOpenLink(aEvent, false, true);
+
+ if (where == "current") {
+ try {
+ gBrowser.goBack();
+ } catch (ex) {}
+ } else {
+ duplicateTabIn(gBrowser.selectedTab, where, -1);
+ }
+}
+
+function BrowserHandleBackspace() {
+ switch (Services.prefs.getIntPref("browser.backspace_action")) {
+ case 0:
+ BrowserBack();
+ break;
+ case 1:
+ goDoCommand("cmd_scrollPageUp");
+ break;
+ }
+}
+
+function BrowserHandleShiftBackspace() {
+ switch (Services.prefs.getIntPref("browser.backspace_action")) {
+ case 0:
+ BrowserForward();
+ break;
+ case 1:
+ goDoCommand("cmd_scrollPageDown");
+ break;
+ }
+}
+
+function BrowserStop() {
+ gBrowser.webNavigation.stop(Ci.nsIWebNavigation.STOP_ALL);
+}
+
+function BrowserReloadOrDuplicate(aEvent) {
+ aEvent = getRootEvent(aEvent);
+ let accelKeyPressed =
+ AppConstants.platform == "macosx" ? aEvent.metaKey : aEvent.ctrlKey;
+ var backgroundTabModifier = aEvent.button == 1 || accelKeyPressed;
+
+ if (aEvent.shiftKey && !backgroundTabModifier) {
+ BrowserReloadSkipCache();
+ return;
+ }
+
+ let where = whereToOpenLink(aEvent, false, true);
+ if (where == "current") {
+ BrowserReload();
+ } else {
+ duplicateTabIn(gBrowser.selectedTab, where);
+ }
+}
+
+function BrowserReload() {
+ if (gBrowser.currentURI.schemeIs("view-source")) {
+ // Bug 1167797: For view source, we always skip the cache
+ return BrowserReloadSkipCache();
+ }
+ const reloadFlags = Ci.nsIWebNavigation.LOAD_FLAGS_NONE;
+ BrowserReloadWithFlags(reloadFlags);
+}
+
+const kSkipCacheFlags =
+ Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_PROXY |
+ Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CACHE;
+function BrowserReloadSkipCache() {
+ // Bypass proxy and cache.
+ BrowserReloadWithFlags(kSkipCacheFlags);
+}
+
+function BrowserHome(aEvent) {
+ if (aEvent && "button" in aEvent && aEvent.button == 2) {
+ // right-click: do nothing
+ return;
+ }
+
+ var homePage = HomePage.get(window);
+ var where = whereToOpenLink(aEvent, false, true);
+ var urls;
+ var notifyObservers;
+
+ // Don't load the home page in pinned or hidden tabs (e.g. Firefox View).
+ if (
+ where == "current" &&
+ (gBrowser?.selectedTab.pinned || gBrowser?.selectedTab.hidden)
+ ) {
+ where = "tab";
+ }
+
+ // openTrustedLinkIn in utilityOverlay.js doesn't handle loading multiple pages
+ switch (where) {
+ case "current":
+ // If we're going to load an initial page in the current tab as the
+ // home page, we set initialPageLoadedFromURLBar so that the URL
+ // bar is cleared properly (even during a remoteness flip).
+ if (isInitialPage(homePage)) {
+ gBrowser.selectedBrowser.initialPageLoadedFromUserAction = homePage;
+ }
+ loadOneOrMoreURIs(
+ homePage,
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ null
+ );
+ if (isBlankPageURL(homePage)) {
+ gURLBar.select();
+ } else {
+ gBrowser.selectedBrowser.focus();
+ }
+ notifyObservers = true;
+ aEvent?.preventDefault();
+ break;
+ case "tabshifted":
+ case "tab":
+ urls = homePage.split("|");
+ var loadInBackground = Services.prefs.getBoolPref(
+ "browser.tabs.loadBookmarksInBackground",
+ false
+ );
+ // The homepage observer event should only be triggered when the homepage opens
+ // in the foreground. This is mostly to support the homepage changed by extension
+ // doorhanger which doesn't currently support background pages. This may change in
+ // bug 1438396.
+ notifyObservers = !loadInBackground;
+ gBrowser.loadTabs(urls, {
+ inBackground: loadInBackground,
+ triggeringPrincipal:
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ csp: null,
+ });
+ if (!loadInBackground) {
+ if (isBlankPageURL(homePage)) {
+ gURLBar.select();
+ } else {
+ gBrowser.selectedBrowser.focus();
+ }
+ }
+ aEvent?.preventDefault();
+ break;
+ case "window":
+ // OpenBrowserWindow will trigger the observer event, so no need to do so here.
+ notifyObservers = false;
+ OpenBrowserWindow();
+ aEvent?.preventDefault();
+ break;
+ }
+ if (notifyObservers) {
+ // A notification for when a user has triggered their homepage. This is used
+ // to display a doorhanger explaining that an extension has modified the
+ // homepage, if necessary. Observers are only notified if the homepage
+ // becomes the active page.
+ Services.obs.notifyObservers(null, "browser-open-homepage-start");
+ }
+}
+
+function loadOneOrMoreURIs(aURIString, aTriggeringPrincipal, aCsp) {
+ // we're not a browser window, pass the URI string to a new browser window
+ if (window.location.href != AppConstants.BROWSER_CHROME_URL) {
+ window.openDialog(
+ AppConstants.BROWSER_CHROME_URL,
+ "_blank",
+ "all,dialog=no",
+ aURIString
+ );
+ return;
+ }
+
+ // This function throws for certain malformed URIs, so use exception handling
+ // so that we don't disrupt startup
+ try {
+ gBrowser.loadTabs(aURIString.split("|"), {
+ inBackground: false,
+ replace: true,
+ triggeringPrincipal: aTriggeringPrincipal,
+ csp: aCsp,
+ });
+ } catch (e) {}
+}
+
+function openLocation(event) {
+ if (window.location.href == AppConstants.BROWSER_CHROME_URL) {
+ gURLBar.select();
+ gURLBar.view.autoOpen({ event });
+ return;
+ }
+
+ // If there's an open browser window, redirect the command there.
+ let win = URILoadingHelper.getTargetWindow(window);
+ if (win) {
+ win.focus();
+ win.openLocation();
+ return;
+ }
+
+ // There are no open browser windows; open a new one.
+ window.openDialog(
+ AppConstants.BROWSER_CHROME_URL,
+ "_blank",
+ "chrome,all,dialog=no",
+ BROWSER_NEW_TAB_URL
+ );
+}
+
+function BrowserOpenTab({ event, url } = {}) {
+ let werePassedURL = !!url;
+ url ??= BROWSER_NEW_TAB_URL;
+ let searchClipboard = gMiddleClickNewTabUsesPasteboard && event?.button == 1;
+
+ let relatedToCurrent = false;
+ let where = "tab";
+
+ if (event) {
+ where = whereToOpenLink(event, false, true);
+
+ switch (where) {
+ case "tab":
+ case "tabshifted":
+ // When accel-click or middle-click are used, open the new tab as
+ // related to the current tab.
+ relatedToCurrent = true;
+ break;
+ case "current":
+ where = "tab";
+ break;
+ }
+ }
+
+ // A notification intended to be useful for modular peformance tracking
+ // starting as close as is reasonably possible to the time when the user
+ // expressed the intent to open a new tab. Since there are a lot of
+ // entry points, this won't catch every single tab created, but most
+ // initiated by the user should go through here.
+ //
+ // Note 1: This notification gets notified with a promise that resolves
+ // with the linked browser when the tab gets created
+ // Note 2: This is also used to notify a user that an extension has changed
+ // the New Tab page.
+ Services.obs.notifyObservers(
+ {
+ wrappedJSObject: new Promise(resolve => {
+ let options = {
+ relatedToCurrent,
+ resolveOnNewTabCreated: resolve,
+ };
+ if (!werePassedURL && searchClipboard) {
+ let clipboard = readFromClipboard();
+ clipboard = UrlbarUtils.stripUnsafeProtocolOnPaste(clipboard).trim();
+ if (clipboard) {
+ url = clipboard;
+ options.allowThirdPartyFixup = true;
+ }
+ }
+ openTrustedLinkIn(url, where, options);
+ }),
+ },
+ "browser-open-newtab-start"
+ );
+}
+
+var gLastOpenDirectory = {
+ _lastDir: null,
+ get path() {
+ if (!this._lastDir || !this._lastDir.exists()) {
+ try {
+ this._lastDir = Services.prefs.getComplexValue(
+ "browser.open.lastDir",
+ Ci.nsIFile
+ );
+ if (!this._lastDir.exists()) {
+ this._lastDir = null;
+ }
+ } catch (e) {}
+ }
+ return this._lastDir;
+ },
+ set path(val) {
+ try {
+ if (!val || !val.isDirectory()) {
+ return;
+ }
+ } catch (e) {
+ return;
+ }
+ this._lastDir = val.clone();
+
+ // Don't save the last open directory pref inside the Private Browsing mode
+ if (!PrivateBrowsingUtils.isWindowPrivate(window)) {
+ Services.prefs.setComplexValue(
+ "browser.open.lastDir",
+ Ci.nsIFile,
+ this._lastDir
+ );
+ }
+ },
+ reset() {
+ this._lastDir = null;
+ },
+};
+
+function BrowserOpenFileWindow() {
+ // Get filepicker component.
+ try {
+ const nsIFilePicker = Ci.nsIFilePicker;
+ let fp = Cc["@mozilla.org/filepicker;1"].createInstance(nsIFilePicker);
+ let fpCallback = function fpCallback_done(aResult) {
+ if (aResult == nsIFilePicker.returnOK) {
+ try {
+ if (fp.file) {
+ gLastOpenDirectory.path = fp.file.parent.QueryInterface(Ci.nsIFile);
+ }
+ } catch (ex) {}
+ openTrustedLinkIn(fp.fileURL.spec, "current");
+ }
+ };
+
+ fp.init(
+ window,
+ gNavigatorBundle.getString("openFile"),
+ nsIFilePicker.modeOpen
+ );
+ fp.appendFilters(
+ nsIFilePicker.filterAll |
+ nsIFilePicker.filterText |
+ nsIFilePicker.filterImages |
+ nsIFilePicker.filterXML |
+ nsIFilePicker.filterHTML
+ );
+ fp.displayDirectory = gLastOpenDirectory.path;
+ fp.open(fpCallback);
+ } catch (ex) {}
+}
+
+function BrowserCloseTabOrWindow(event) {
+ // If we're not a browser window, just close the window.
+ if (window.location.href != AppConstants.BROWSER_CHROME_URL) {
+ closeWindow(true);
+ return;
+ }
+
+ // In a multi-select context, close all selected tabs
+ if (gBrowser.multiSelectedTabsCount) {
+ gBrowser.removeMultiSelectedTabs();
+ return;
+ }
+
+ // Keyboard shortcuts that would close a tab that is pinned select the first
+ // unpinned tab instead.
+ if (
+ event &&
+ (event.ctrlKey || event.metaKey || event.altKey) &&
+ gBrowser.selectedTab.pinned
+ ) {
+ if (gBrowser.visibleTabs.length > gBrowser._numPinnedTabs) {
+ gBrowser.tabContainer.selectedIndex = gBrowser._numPinnedTabs;
+ }
+ return;
+ }
+
+ // If the current tab is the last one, this will close the window.
+ gBrowser.removeCurrentTab({ animate: true });
+}
+
+function BrowserTryToCloseWindow(event) {
+ if (WindowIsClosing(event)) {
+ window.close();
+ } // WindowIsClosing does all the necessary checks
+}
+
+function getLoadContext() {
+ return window.docShell.QueryInterface(Ci.nsILoadContext);
+}
+
+function readFromClipboard() {
+ var url;
+
+ try {
+ // Create transferable that will transfer the text.
+ var trans = Cc["@mozilla.org/widget/transferable;1"].createInstance(
+ Ci.nsITransferable
+ );
+ trans.init(getLoadContext());
+
+ trans.addDataFlavor("text/plain");
+
+ // If available, use selection clipboard, otherwise global one
+ let clipboard = Services.clipboard;
+ if (clipboard.isClipboardTypeSupported(clipboard.kSelectionClipboard)) {
+ clipboard.getData(trans, clipboard.kSelectionClipboard);
+ } else {
+ clipboard.getData(trans, clipboard.kGlobalClipboard);
+ }
+
+ var data = {};
+ trans.getTransferData("text/plain", data);
+
+ if (data) {
+ data = data.value.QueryInterface(Ci.nsISupportsString);
+ url = data.data;
+ }
+ } catch (ex) {}
+
+ return url;
+}
+
+/**
+ * Open the View Source dialog.
+ *
+ * @param args
+ * An object with the following properties:
+ *
+ * URL (required):
+ * A string URL for the page we'd like to view the source of.
+ * browser (optional):
+ * The browser containing the document that we would like to view the
+ * source of. This is required if outerWindowID is passed.
+ * outerWindowID (optional):
+ * The outerWindowID of the content window containing the document that
+ * we want to view the source of. You only need to provide this if you
+ * want to attempt to retrieve the document source from the network
+ * cache.
+ * lineNumber (optional):
+ * The line number to focus on once the source is loaded.
+ */
+async function BrowserViewSourceOfDocument(args) {
+ // Check if external view source is enabled. If so, try it. If it fails,
+ // fallback to internal view source.
+ if (Services.prefs.getBoolPref("view_source.editor.external")) {
+ try {
+ await top.gViewSourceUtils.openInExternalEditor(args);
+ return;
+ } catch (data) {}
+ }
+
+ let tabBrowser = gBrowser;
+ let preferredRemoteType;
+ let initialBrowsingContextGroupId;
+ if (args.browser) {
+ preferredRemoteType = args.browser.remoteType;
+ initialBrowsingContextGroupId = args.browser.browsingContext.group.id;
+ } else {
+ if (!tabBrowser) {
+ throw new Error(
+ "BrowserViewSourceOfDocument should be passed the " +
+ "subject browser if called from a window without " +
+ "gBrowser defined."
+ );
+ }
+ // Some internal URLs (such as specific chrome: and about: URLs that are
+ // not yet remote ready) cannot be loaded in a remote browser. View
+ // source in tab expects the new view source browser's remoteness to match
+ // that of the original URL, so disable remoteness if necessary for this
+ // URL.
+ var oa = E10SUtils.predictOriginAttributes({ window });
+ preferredRemoteType = E10SUtils.getRemoteTypeForURI(
+ args.URL,
+ gMultiProcessBrowser,
+ gFissionBrowser,
+ E10SUtils.DEFAULT_REMOTE_TYPE,
+ null,
+ oa
+ );
+ }
+
+ // In the case of popups, we need to find a non-popup browser window.
+ if (!tabBrowser || !window.toolbar.visible) {
+ // This returns only non-popup browser windows by default.
+ let browserWindow = BrowserWindowTracker.getTopWindow();
+ tabBrowser = browserWindow.gBrowser;
+ }
+
+ const inNewWindow = !Services.prefs.getBoolPref("view_source.tab");
+
+ // `viewSourceInBrowser` will load the source content from the page
+ // descriptor for the tab (when possible) or fallback to the network if
+ // that fails. Either way, the view source module will manage the tab's
+ // location, so use "about:blank" here to avoid unnecessary redundant
+ // requests.
+ let tab = tabBrowser.addTab("about:blank", {
+ relatedToCurrent: true,
+ inBackground: inNewWindow,
+ skipAnimation: inNewWindow,
+ preferredRemoteType,
+ initialBrowsingContextGroupId,
+ triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
+ skipLoad: true,
+ });
+ args.viewSourceBrowser = tabBrowser.getBrowserForTab(tab);
+ top.gViewSourceUtils.viewSourceInBrowser(args);
+
+ if (inNewWindow) {
+ tabBrowser.hideTab(tab);
+ tabBrowser.replaceTabWithWindow(tab);
+ }
+}
+
+/**
+ * Opens the View Source dialog for the source loaded in the root
+ * top-level document of the browser. This is really just a
+ * convenience wrapper around BrowserViewSourceOfDocument.
+ *
+ * @param browser
+ * The browser that we want to load the source of.
+ */
+function BrowserViewSource(browser) {
+ BrowserViewSourceOfDocument({
+ browser,
+ outerWindowID: browser.outerWindowID,
+ URL: browser.currentURI.spec,
+ });
+}
+
+// documentURL - URL of the document to view, or null for this window's document
+// initialTab - name of the initial tab to display, or null for the first tab
+// imageElement - image to load in the Media Tab of the Page Info window; can be null/omitted
+// browsingContext - the browsingContext of the frame that we want to view information about; can be null/omitted
+// browser - the browser containing the document we're interested in inspecting; can be null/omitted
+function BrowserPageInfo(
+ documentURL,
+ initialTab,
+ imageElement,
+ browsingContext,
+ browser
+) {
+ if (HTMLDocument.isInstance(documentURL)) {
+ Deprecated.warning(
+ "Please pass the location URL instead of the document " +
+ "to BrowserPageInfo() as the first argument.",
+ "https://bugzilla.mozilla.org/show_bug.cgi?id=1238180"
+ );
+ documentURL = documentURL.location;
+ }
+
+ let args = { initialTab, imageElement, browsingContext, browser };
+
+ documentURL = documentURL || window.gBrowser.selectedBrowser.currentURI.spec;
+
+ let isPrivate = PrivateBrowsingUtils.isWindowPrivate(window);
+
+ // Check for windows matching the url
+ for (let currentWindow of Services.wm.getEnumerator("Browser:page-info")) {
+ if (currentWindow.closed) {
+ continue;
+ }
+ if (
+ currentWindow.document.documentElement.getAttribute("relatedUrl") ==
+ documentURL &&
+ PrivateBrowsingUtils.isWindowPrivate(currentWindow) == isPrivate
+ ) {
+ currentWindow.focus();
+ currentWindow.resetPageInfo(args);
+ return currentWindow;
+ }
+ }
+
+ // We didn't find a matching window, so open a new one.
+ let options = "chrome,toolbar,dialog=no,resizable";
+
+ // Ensure the window groups correctly in the Windows taskbar
+ if (isPrivate) {
+ options += ",private";
+ }
+ return openDialog(
+ "chrome://browser/content/pageinfo/pageInfo.xhtml",
+ "",
+ options,
+ args
+ );
+}
+
+function UpdateUrlbarSearchSplitterState() {
+ var splitter = document.getElementById("urlbar-search-splitter");
+ var urlbar = document.getElementById("urlbar-container");
+ var searchbar = document.getElementById("search-container");
+
+ if (document.documentElement.getAttribute("customizing") == "true") {
+ if (splitter) {
+ splitter.remove();
+ }
+ return;
+ }
+
+ // If the splitter is already in the right place, we don't need to do anything:
+ if (
+ splitter &&
+ ((splitter.nextElementSibling == searchbar &&
+ splitter.previousElementSibling == urlbar) ||
+ (splitter.nextElementSibling == urlbar &&
+ splitter.previousElementSibling == searchbar))
+ ) {
+ return;
+ }
+
+ let ibefore = null;
+ let resizebefore = "none";
+ let resizeafter = "none";
+ if (urlbar && searchbar) {
+ if (urlbar.nextElementSibling == searchbar) {
+ resizeafter = "sibling";
+ ibefore = searchbar;
+ } else if (searchbar.nextElementSibling == urlbar) {
+ resizebefore = "sibling";
+ ibefore = urlbar;
+ }
+ }
+
+ if (ibefore) {
+ if (!splitter) {
+ splitter = document.createXULElement("splitter");
+ splitter.id = "urlbar-search-splitter";
+ splitter.setAttribute("resizebefore", resizebefore);
+ splitter.setAttribute("resizeafter", resizeafter);
+ splitter.setAttribute("skipintoolbarset", "true");
+ splitter.setAttribute("overflows", "false");
+ splitter.className = "chromeclass-toolbar-additional";
+ }
+ urlbar.parentNode.insertBefore(splitter, ibefore);
+ } else if (splitter) {
+ splitter.remove();
+ }
+}
+
+function UpdatePopupNotificationsVisibility() {
+ // Only need to update PopupNotifications if it has already been initialized
+ // for this window (i.e. its getter no longer exists).
+ if (!Object.getOwnPropertyDescriptor(window, "PopupNotifications").get) {
+ // Notify PopupNotifications that the visible anchors may have changed. This
+ // also checks the suppression state according to the "shouldSuppress"
+ // function defined earlier in this file.
+ PopupNotifications.anchorVisibilityChange();
+ }
+
+ // This is similar to the above, but for notifications attached to the
+ // hamburger menu icon (such as update notifications and add-on install
+ // notifications.)
+ PanelUI?.updateNotifications();
+}
+
+function PageProxyClickHandler(aEvent) {
+ if (aEvent.button == 1 && Services.prefs.getBoolPref("middlemouse.paste")) {
+ middleMousePaste(aEvent);
+ }
+}
+
+/**
+ * Handle command events bubbling up from error page content
+ * or from about:newtab or from remote error pages that invoke
+ * us via async messaging.
+ */
+var BrowserOnClick = {
+ ignoreWarningLink(reason, blockedInfo, browsingContext) {
+ let triggeringPrincipal =
+ blockedInfo.triggeringPrincipal ||
+ _createNullPrincipalFromTabUserContextId();
+
+ // Allow users to override and continue through to the site,
+ // but add a notify bar as a reminder, so that they don't lose
+ // track after, e.g., tab switching.
+ // Note that we have to use the passed URI info and can't just
+ // rely on the document URI, because the latter contains
+ // additional query parameters that should be stripped.
+ browsingContext.fixupAndLoadURIString(blockedInfo.uri, {
+ triggeringPrincipal,
+ flags: Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_CLASSIFIER,
+ });
+
+ // We can't use browser.contentPrincipal which is principal of about:blocked
+ // Create one from uri with current principal origin attributes
+ let principal = Services.scriptSecurityManager.createContentPrincipal(
+ Services.io.newURI(blockedInfo.uri),
+ browsingContext.currentWindowGlobal.documentPrincipal.originAttributes
+ );
+ Services.perms.addFromPrincipal(
+ principal,
+ "safe-browsing",
+ Ci.nsIPermissionManager.ALLOW_ACTION,
+ Ci.nsIPermissionManager.EXPIRE_SESSION
+ );
+
+ let buttons = [
+ {
+ label: gNavigatorBundle.getString(
+ "safebrowsing.getMeOutOfHereButton.label"
+ ),
+ accessKey: gNavigatorBundle.getString(
+ "safebrowsing.getMeOutOfHereButton.accessKey"
+ ),
+ callback() {
+ getMeOutOfHere(browsingContext);
+ },
+ },
+ ];
+
+ let title;
+ if (reason === "malware") {
+ let reportUrl = gSafeBrowsing.getReportURL("MalwareMistake", blockedInfo);
+ title = gNavigatorBundle.getString("safebrowsing.reportedAttackSite");
+ // There's no button if we can not get report url, for example if the provider
+ // of blockedInfo is not Google
+ if (reportUrl) {
+ buttons[1] = {
+ label: gNavigatorBundle.getString(
+ "safebrowsing.notAnAttackButton.label"
+ ),
+ accessKey: gNavigatorBundle.getString(
+ "safebrowsing.notAnAttackButton.accessKey"
+ ),
+ callback() {
+ openTrustedLinkIn(reportUrl, "tab");
+ },
+ };
+ }
+ } else if (reason === "phishing") {
+ let reportUrl = gSafeBrowsing.getReportURL("PhishMistake", blockedInfo);
+ title = gNavigatorBundle.getString("safebrowsing.deceptiveSite");
+ // There's no button if we can not get report url, for example if the provider
+ // of blockedInfo is not Google
+ if (reportUrl) {
+ buttons[1] = {
+ label: gNavigatorBundle.getString(
+ "safebrowsing.notADeceptiveSiteButton.label"
+ ),
+ accessKey: gNavigatorBundle.getString(
+ "safebrowsing.notADeceptiveSiteButton.accessKey"
+ ),
+ callback() {
+ openTrustedLinkIn(reportUrl, "tab");
+ },
+ };
+ }
+ } else if (reason === "unwanted") {
+ title = gNavigatorBundle.getString("safebrowsing.reportedUnwantedSite");
+ // There is no button for reporting errors since Google doesn't currently
+ // provide a URL endpoint for these reports.
+ } else if (reason === "harmful") {
+ title = gNavigatorBundle.getString("safebrowsing.reportedHarmfulSite");
+ // There is no button for reporting errors since Google doesn't currently
+ // provide a URL endpoint for these reports.
+ }
+
+ SafeBrowsingNotificationBox.show(title, buttons);
+ },
+};
+
+/**
+ * Re-direct the browser to a known-safe page. This function is
+ * used when, for example, the user browses to a known malware page
+ * and is presented with about:blocked. The "Get me out of here!"
+ * button should take the user to the default start page so that even
+ * when their own homepage is infected, we can get them somewhere safe.
+ */
+function getMeOutOfHere(browsingContext) {
+ browsingContext.top.fixupAndLoadURIString(getDefaultHomePage(), {
+ triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), // Also needs to load homepage
+ });
+}
+
+/**
+ * Return the default start page for the cases when the user's own homepage is
+ * infected, so we can get them somewhere safe.
+ */
+function getDefaultHomePage() {
+ let url = BROWSER_NEW_TAB_URL;
+ if (PrivateBrowsingUtils.isWindowPrivate(window)) {
+ return url;
+ }
+ url = HomePage.getDefault();
+ // If url is a pipe-delimited set of pages, just take the first one.
+ if (url.includes("|")) {
+ url = url.split("|")[0];
+ }
+ return url;
+}
+
+function BrowserFullScreen() {
+ window.fullScreen = !window.fullScreen || BrowserHandler.kiosk;
+}
+
+function BrowserReloadWithFlags(reloadFlags) {
+ let unchangedRemoteness = [];
+
+ for (let tab of gBrowser.selectedTabs) {
+ let browser = tab.linkedBrowser;
+ let url = browser.currentURI;
+ let urlSpec = url.spec;
+ // We need to cache the content principal here because the browser will be
+ // reconstructed when the remoteness changes and the content prinicpal will
+ // be cleared after reconstruction.
+ let principal = tab.linkedBrowser.contentPrincipal;
+ if (gBrowser.updateBrowserRemotenessByURL(browser, urlSpec)) {
+ // If the remoteness has changed, the new browser doesn't have any
+ // information of what was loaded before, so we need to load the previous
+ // URL again.
+ if (tab.linkedPanel) {
+ loadBrowserURI(browser, url, principal);
+ } else {
+ // Shift to fully loaded browser and make
+ // sure load handler is instantiated.
+ tab.addEventListener(
+ "SSTabRestoring",
+ () => loadBrowserURI(browser, url, principal),
+ { once: true }
+ );
+ gBrowser._insertBrowser(tab);
+ }
+ } else {
+ unchangedRemoteness.push(tab);
+ }
+ }
+
+ if (!unchangedRemoteness.length) {
+ return;
+ }
+
+ // Reset temporary permissions on the remaining tabs to reload.
+ // This is done here because we only want to reset
+ // permissions on user reload.
+ for (let tab of unchangedRemoteness) {
+ SitePermissions.clearTemporaryBlockPermissions(tab.linkedBrowser);
+ // Also reset DOS mitigations for the basic auth prompt on reload.
+ delete tab.linkedBrowser.authPromptAbuseCounter;
+ }
+ gIdentityHandler.hidePopup();
+ gPermissionPanel.hidePopup();
+
+ let handlingUserInput = document.hasValidTransientUserGestureActivation;
+
+ for (let tab of unchangedRemoteness) {
+ if (tab.linkedPanel) {
+ sendReloadMessage(tab);
+ } else {
+ // Shift to fully loaded browser and make
+ // sure load handler is instantiated.
+ tab.addEventListener("SSTabRestoring", () => sendReloadMessage(tab), {
+ once: true,
+ });
+ gBrowser._insertBrowser(tab);
+ }
+ }
+
+ function loadBrowserURI(browser, url, principal) {
+ browser.loadURI(url, {
+ flags: reloadFlags,
+ triggeringPrincipal: principal,
+ });
+ }
+
+ function sendReloadMessage(tab) {
+ tab.linkedBrowser.sendMessageToActor(
+ "Browser:Reload",
+ { flags: reloadFlags, handlingUserInput },
+ "BrowserTab"
+ );
+ }
+}
+
+// TODO: can we pull getPEMString in from pippki.js instead of
+// duplicating them here?
+function getPEMString(cert) {
+ var derb64 = cert.getBase64DERString();
+ // Wrap the Base64 string into lines of 64 characters,
+ // with CRLF line breaks (as specified in RFC 1421).
+ var wrapped = derb64.replace(/(\S{64}(?!$))/g, "$1\r\n");
+ return (
+ "-----BEGIN CERTIFICATE-----\r\n" +
+ wrapped +
+ "\r\n-----END CERTIFICATE-----\r\n"
+ );
+}
+
+var browserDragAndDrop = {
+ canDropLink: aEvent => Services.droppedLinkHandler.canDropLink(aEvent, true),
+
+ dragOver(aEvent) {
+ if (this.canDropLink(aEvent)) {
+ aEvent.preventDefault();
+ }
+ },
+
+ getTriggeringPrincipal(aEvent) {
+ return Services.droppedLinkHandler.getTriggeringPrincipal(aEvent);
+ },
+
+ getCsp(aEvent) {
+ return Services.droppedLinkHandler.getCsp(aEvent);
+ },
+
+ validateURIsForDrop(aEvent, aURIs) {
+ return Services.droppedLinkHandler.validateURIsForDrop(aEvent, aURIs);
+ },
+
+ dropLinks(aEvent, aDisallowInherit) {
+ return Services.droppedLinkHandler.dropLinks(aEvent, aDisallowInherit);
+ },
+};
+
+var homeButtonObserver = {
+ onDrop(aEvent) {
+ // disallow setting home pages that inherit the principal
+ let links = browserDragAndDrop.dropLinks(aEvent, true);
+ if (links.length) {
+ let urls = [];
+ for (let link of links) {
+ if (link.url.includes("|")) {
+ urls.push(...link.url.split("|"));
+ } else {
+ urls.push(link.url);
+ }
+ }
+
+ try {
+ browserDragAndDrop.validateURIsForDrop(aEvent, urls);
+ } catch (e) {
+ return;
+ }
+
+ setTimeout(openHomeDialog, 0, urls.join("|"));
+ }
+ },
+
+ onDragOver(aEvent) {
+ if (HomePage.locked) {
+ return;
+ }
+ browserDragAndDrop.dragOver(aEvent);
+ aEvent.dropEffect = "link";
+ },
+};
+
+function openHomeDialog(aURL) {
+ var promptTitle = gNavigatorBundle.getString("droponhometitle");
+ var promptMsg;
+ if (aURL.includes("|")) {
+ promptMsg = gNavigatorBundle.getString("droponhomemsgMultiple");
+ } else {
+ promptMsg = gNavigatorBundle.getString("droponhomemsg");
+ }
+
+ var pressedVal = Services.prompt.confirmEx(
+ window,
+ promptTitle,
+ promptMsg,
+ Services.prompt.STD_YES_NO_BUTTONS,
+ null,
+ null,
+ null,
+ null,
+ { value: 0 }
+ );
+
+ if (pressedVal == 0) {
+ HomePage.set(aURL).catch(console.error);
+ }
+}
+
+var newTabButtonObserver = {
+ onDragOver(aEvent) {
+ browserDragAndDrop.dragOver(aEvent);
+ },
+ async onDrop(aEvent) {
+ let links = browserDragAndDrop.dropLinks(aEvent);
+ if (
+ links.length >=
+ Services.prefs.getIntPref("browser.tabs.maxOpenBeforeWarn")
+ ) {
+ // Sync dialog cannot be used inside drop event handler.
+ let answer = await OpenInTabsUtils.promiseConfirmOpenInTabs(
+ links.length,
+ window
+ );
+ if (!answer) {
+ return;
+ }
+ }
+
+ let where = aEvent.shiftKey ? "tabshifted" : "tab";
+ let triggeringPrincipal = browserDragAndDrop.getTriggeringPrincipal(aEvent);
+ let csp = browserDragAndDrop.getCsp(aEvent);
+ for (let link of links) {
+ if (link.url) {
+ let data = await UrlbarUtils.getShortcutOrURIAndPostData(link.url);
+ // Allow third-party services to fixup this URL.
+ openLinkIn(data.url, where, {
+ postData: data.postData,
+ allowThirdPartyFixup: true,
+ triggeringPrincipal,
+ csp,
+ });
+ }
+ }
+ },
+};
+
+var newWindowButtonObserver = {
+ onDragOver(aEvent) {
+ browserDragAndDrop.dragOver(aEvent);
+ },
+ async onDrop(aEvent) {
+ let links = browserDragAndDrop.dropLinks(aEvent);
+ if (
+ links.length >=
+ Services.prefs.getIntPref("browser.tabs.maxOpenBeforeWarn")
+ ) {
+ // Sync dialog cannot be used inside drop event handler.
+ let answer = await OpenInTabsUtils.promiseConfirmOpenInTabs(
+ links.length,
+ window
+ );
+ if (!answer) {
+ return;
+ }
+ }
+
+ let triggeringPrincipal = browserDragAndDrop.getTriggeringPrincipal(aEvent);
+ let csp = browserDragAndDrop.getCsp(aEvent);
+ for (let link of links) {
+ if (link.url) {
+ let data = await UrlbarUtils.getShortcutOrURIAndPostData(link.url);
+ // Allow third-party services to fixup this URL.
+ openLinkIn(data.url, "window", {
+ // TODO fix allowInheritPrincipal
+ // (this is required by javascript: drop to the new window) Bug 1475201
+ allowInheritPrincipal: true,
+ postData: data.postData,
+ allowThirdPartyFixup: true,
+ triggeringPrincipal,
+ csp,
+ });
+ }
+ }
+ },
+};
+
+const BrowserSearch = {
+ _searchInitComplete: false,
+
+ init() {
+ Services.obs.addObserver(this, "browser-search-engine-modified");
+ },
+
+ delayedStartupInit() {
+ // Asynchronously initialize the search service if necessary, to get the
+ // current engine for working out the placeholder.
+ this._updateURLBarPlaceholderFromDefaultEngine(
+ PrivateBrowsingUtils.isWindowPrivate(window),
+ // Delay the update for this until so that we don't change it while
+ // the user is looking at it / isn't expecting it.
+ true
+ ).then(() => {
+ this._searchInitComplete = true;
+ });
+ },
+
+ uninit() {
+ Services.obs.removeObserver(this, "browser-search-engine-modified");
+ },
+
+ observe(engine, topic, data) {
+ // There are two kinds of search engine objects, nsISearchEngine objects and
+ // plain { uri, title, icon } objects. `engine` in this method is the
+ // former. The browser.engines and browser.hiddenEngines arrays are the
+ // latter, and they're the engines offered by the the page in the browser.
+ //
+ // The two types of engines are currently related by their titles/names,
+ // although that may change; see bug 335102.
+ let engineName = engine.wrappedJSObject.name;
+ switch (data) {
+ case "engine-removed":
+ // An engine was removed from the search service. If a page is offering
+ // the engine, then the engine needs to be added back to the corresponding
+ // browser's offered engines.
+ this._addMaybeOfferedEngine(engineName);
+ break;
+ case "engine-added":
+ // An engine was added to the search service. If a page is offering the
+ // engine, then the engine needs to be removed from the corresponding
+ // browser's offered engines.
+ this._removeMaybeOfferedEngine(engineName);
+ break;
+ case "engine-default":
+ if (
+ this._searchInitComplete &&
+ !PrivateBrowsingUtils.isWindowPrivate(window)
+ ) {
+ this._updateURLBarPlaceholder(engineName, false);
+ }
+ break;
+ case "engine-default-private":
+ if (
+ this._searchInitComplete &&
+ PrivateBrowsingUtils.isWindowPrivate(window)
+ ) {
+ this._updateURLBarPlaceholder(engineName, true);
+ }
+ break;
+ }
+ },
+
+ _addMaybeOfferedEngine(engineName) {
+ let selectedBrowserOffersEngine = false;
+ for (let browser of gBrowser.browsers) {
+ for (let i = 0; i < (browser.hiddenEngines || []).length; i++) {
+ if (browser.hiddenEngines[i].title == engineName) {
+ if (!browser.engines) {
+ browser.engines = [];
+ }
+ browser.engines.push(browser.hiddenEngines[i]);
+ browser.hiddenEngines.splice(i, 1);
+ if (browser == gBrowser.selectedBrowser) {
+ selectedBrowserOffersEngine = true;
+ }
+ break;
+ }
+ }
+ }
+ if (selectedBrowserOffersEngine) {
+ this.updateOpenSearchBadge();
+ }
+ },
+
+ _removeMaybeOfferedEngine(engineName) {
+ let selectedBrowserOffersEngine = false;
+ for (let browser of gBrowser.browsers) {
+ for (let i = 0; i < (browser.engines || []).length; i++) {
+ if (browser.engines[i].title == engineName) {
+ if (!browser.hiddenEngines) {
+ browser.hiddenEngines = [];
+ }
+ browser.hiddenEngines.push(browser.engines[i]);
+ browser.engines.splice(i, 1);
+ if (browser == gBrowser.selectedBrowser) {
+ selectedBrowserOffersEngine = true;
+ }
+ break;
+ }
+ }
+ }
+ if (selectedBrowserOffersEngine) {
+ this.updateOpenSearchBadge();
+ }
+ },
+
+ /**
+ * Initializes the urlbar placeholder to the pre-saved engine name. We do this
+ * via a preference, to avoid needing to synchronously init the search service.
+ *
+ * This should be called around the time of DOMContentLoaded, so that it is
+ * initialized quickly before the user sees anything.
+ *
+ * Note: If the preference doesn't exist, we don't do anything as the default
+ * placeholder is a string which doesn't have the engine name; however, this
+ * can be overridden using the `force` parameter.
+ *
+ * @param {Boolean} force If true and the preference doesn't exist, the
+ * placeholder will be set to the default version
+ * without an engine name ("Search or enter address").
+ */
+ initPlaceHolder(force = false) {
+ const prefName =
+ "browser.urlbar.placeholderName" +
+ (PrivateBrowsingUtils.isWindowPrivate(window) ? ".private" : "");
+ let engineName = Services.prefs.getStringPref(prefName, "");
+ if (engineName || force) {
+ // We can do this directly, since we know we're at DOMContentLoaded.
+ this._setURLBarPlaceholder(engineName);
+ }
+ },
+
+ /**
+ * This is a wrapper around '_updateURLBarPlaceholder' that uses the
+ * appropriate default engine to get the engine name.
+ *
+ * @param {Boolean} isPrivate Set to true if this is a private window.
+ * @param {Boolean} [delayUpdate] Set to true, to delay update until the
+ * placeholder is not displayed.
+ */
+ async _updateURLBarPlaceholderFromDefaultEngine(
+ isPrivate,
+ delayUpdate = false
+ ) {
+ const getDefault = isPrivate
+ ? Services.search.getDefaultPrivate
+ : Services.search.getDefault;
+ let defaultEngine = await getDefault();
+ if (!this._searchInitComplete) {
+ // If we haven't finished initialising, ensure the placeholder
+ // preference is set for the next startup.
+ SearchUIUtils.updatePlaceholderNamePreference(defaultEngine, isPrivate);
+ }
+ this._updateURLBarPlaceholder(defaultEngine.name, isPrivate, delayUpdate);
+ },
+
+ /**
+ * Updates the URLBar placeholder for the specified engine, delaying the
+ * update if required. This also saves the current engine name in preferences
+ * for the next restart.
+ *
+ * Note: The engine name will only be displayed for built-in engines, as we
+ * know they should have short names.
+ *
+ * @param {String} engineName The search engine name to use for the update.
+ * @param {Boolean} isPrivate Set to true if this is a private window.
+ * @param {Boolean} [delayUpdate] Set to true, to delay update until the
+ * placeholder is not displayed.
+ */
+ _updateURLBarPlaceholder(engineName, isPrivate, delayUpdate = false) {
+ if (!engineName) {
+ throw new Error("Expected an engineName to be specified");
+ }
+
+ const engine = Services.search.getEngineByName(engineName);
+ if (!engine.isAppProvided) {
+ // Set the engine name to an empty string for non-default engines, which'll
+ // make sure we display the default placeholder string.
+ engineName = "";
+ }
+
+ // Only delay if requested, and we're not displaying text in the URL bar
+ // currently.
+ if (delayUpdate && !gURLBar.value) {
+ // Delays changing the URL Bar placeholder until the user is not going to be
+ // seeing it, e.g. when there is a value entered in the bar, or if there is
+ // a tab switch to a tab which has a url loaded. We delay the update until
+ // the user is out of search mode since an alternative placeholder is used
+ // in search mode.
+ let placeholderUpdateListener = () => {
+ if (gURLBar.value && !gURLBar.searchMode) {
+ // By the time the user has switched, they may have changed the engine
+ // again, so we need to call this function again but with the
+ // new engine name.
+ // No need to await for this to finish, we're in a listener here anyway.
+ this._updateURLBarPlaceholderFromDefaultEngine(isPrivate, false);
+ gURLBar.removeEventListener("input", placeholderUpdateListener);
+ gBrowser.tabContainer.removeEventListener(
+ "TabSelect",
+ placeholderUpdateListener
+ );
+ }
+ };
+
+ gURLBar.addEventListener("input", placeholderUpdateListener);
+ gBrowser.tabContainer.addEventListener(
+ "TabSelect",
+ placeholderUpdateListener
+ );
+ } else if (!gURLBar.searchMode) {
+ this._setURLBarPlaceholder(engineName);
+ }
+ },
+
+ /**
+ * Sets the URLBar placeholder to either something based on the engine name,
+ * or the default placeholder.
+ *
+ * @param {String} name The name of the engine to use, an empty string if to
+ * use the default placeholder.
+ */
+ _setURLBarPlaceholder(name) {
+ document.l10n.setAttributes(
+ gURLBar.inputField,
+ name ? "urlbar-placeholder-with-name" : "urlbar-placeholder",
+ name ? { name } : undefined
+ );
+ },
+
+ addEngine(browser, engine) {
+ if (!this._searchInitComplete) {
+ // We haven't finished initialising search yet. This means we can't
+ // call getEngineByName here. Since this is only on start-up and unlikely
+ // to happen in the normal case, we'll just return early rather than
+ // trying to handle it asynchronously.
+ return;
+ }
+ // Check to see whether we've already added an engine with this title
+ if (browser.engines) {
+ if (browser.engines.some(e => e.title == engine.title)) {
+ return;
+ }
+ }
+
+ var hidden = false;
+ // If this engine (identified by title) is already in the list, add it
+ // to the list of hidden engines rather than to the main list.
+ if (Services.search.getEngineByName(engine.title)) {
+ hidden = true;
+ }
+
+ var engines = (hidden ? browser.hiddenEngines : browser.engines) || [];
+
+ engines.push({
+ uri: engine.href,
+ title: engine.title,
+ get icon() {
+ return browser.mIconURL;
+ },
+ });
+
+ if (hidden) {
+ browser.hiddenEngines = engines;
+ } else {
+ browser.engines = engines;
+ if (browser == gBrowser.selectedBrowser) {
+ this.updateOpenSearchBadge();
+ }
+ }
+ },
+
+ /**
+ * Update the browser UI to show whether or not additional engines are
+ * available when a page is loaded or the user switches tabs to a page that
+ * has search engines.
+ */
+ updateOpenSearchBadge() {
+ gURLBar.addSearchEngineHelper.setEnginesFromBrowser(
+ gBrowser.selectedBrowser
+ );
+
+ var searchBar = this.searchBar;
+ if (!searchBar) {
+ return;
+ }
+
+ var engines = gBrowser.selectedBrowser.engines;
+ if (engines && engines.length) {
+ searchBar.setAttribute("addengines", "true");
+ } else {
+ searchBar.removeAttribute("addengines");
+ }
+ },
+
+ /**
+ * Focuses the search bar if present on the toolbar, or the address bar,
+ * putting it in search mode. Will do so in an existing non-popup browser
+ * window or open a new one if necessary.
+ */
+ webSearch: function BrowserSearch_webSearch() {
+ if (
+ window.location.href != AppConstants.BROWSER_CHROME_URL ||
+ gURLBar.readOnly
+ ) {
+ let win = URILoadingHelper.getTopWin(window, { skipPopups: true });
+ if (win) {
+ // If there's an open browser window, it should handle this command
+ win.focus();
+ win.BrowserSearch.webSearch();
+ } else {
+ // If there are no open browser windows, open a new one
+ var observer = function (subject, topic, data) {
+ if (subject == win) {
+ BrowserSearch.webSearch();
+ Services.obs.removeObserver(
+ observer,
+ "browser-delayed-startup-finished"
+ );
+ }
+ };
+ win = window.openDialog(
+ AppConstants.BROWSER_CHROME_URL,
+ "_blank",
+ "chrome,all,dialog=no",
+ "about:blank"
+ );
+ Services.obs.addObserver(observer, "browser-delayed-startup-finished");
+ }
+ return;
+ }
+
+ let focusUrlBarIfSearchFieldIsNotActive = function (aSearchBar) {
+ if (!aSearchBar || document.activeElement != aSearchBar.textbox) {
+ // Limit the results to search suggestions, like the search bar.
+ gURLBar.searchModeShortcut();
+ }
+ };
+
+ let searchBar = this.searchBar;
+ let placement = CustomizableUI.getPlacementOfWidget("search-container");
+ let focusSearchBar = () => {
+ searchBar = this.searchBar;
+ searchBar.select();
+ focusUrlBarIfSearchFieldIsNotActive(searchBar);
+ };
+ if (
+ placement &&
+ searchBar &&
+ ((searchBar.parentNode.getAttribute("overflowedItem") == "true" &&
+ placement.area == CustomizableUI.AREA_NAVBAR) ||
+ placement.area == CustomizableUI.AREA_FIXED_OVERFLOW_PANEL)
+ ) {
+ let navBar = document.getElementById(CustomizableUI.AREA_NAVBAR);
+ navBar.overflowable.show().then(focusSearchBar);
+ return;
+ }
+ if (searchBar) {
+ if (window.fullScreen) {
+ FullScreen.showNavToolbox();
+ }
+ searchBar.select();
+ }
+ focusUrlBarIfSearchFieldIsNotActive(searchBar);
+ },
+
+ /**
+ * Loads a search results page, given a set of search terms. Uses the current
+ * engine if the search bar is visible, or the default engine otherwise.
+ *
+ * @param searchText
+ * The search terms to use for the search.
+ * @param where
+ * String indicating where the search should load. Most commonly used
+ * are 'tab' or 'window', defaults to 'current'.
+ * @param usePrivate
+ * Whether to use the Private Browsing mode default search engine.
+ * Defaults to `false`.
+ * @param purpose [optional]
+ * A string meant to indicate the context of the search request. This
+ * allows the search service to provide a different nsISearchSubmission
+ * depending on e.g. where the search is triggered in the UI.
+ * @param triggeringPrincipal
+ * The principal to use for a new window or tab.
+ * @param csp
+ * The content security policy to use for a new window or tab.
+ * @param inBackground [optional]
+ * Set to true for the tab to be loaded in the background, default false.
+ * @param engine [optional]
+ * The search engine to use for the search.
+ * @param tab [optional]
+ * The tab to show the search result.
+ *
+ * @return engine The search engine used to perform a search, or null if no
+ * search was performed.
+ */
+ async _loadSearch(
+ searchText,
+ where,
+ usePrivate,
+ purpose,
+ triggeringPrincipal,
+ csp,
+ inBackground = false,
+ engine = null,
+ tab = null
+ ) {
+ if (!triggeringPrincipal) {
+ throw new Error(
+ "Required argument triggeringPrincipal missing within _loadSearch"
+ );
+ }
+
+ if (!engine) {
+ engine = usePrivate
+ ? await Services.search.getDefaultPrivate()
+ : await Services.search.getDefault();
+ }
+
+ let submission = engine.getSubmission(searchText, null, purpose); // HTML response
+
+ // getSubmission can return null if the engine doesn't have a URL
+ // with a text/html response type. This is unlikely (since
+ // SearchService._addEngineToStore() should fail for such an engine),
+ // but let's be on the safe side.
+ if (!submission) {
+ return null;
+ }
+
+ openLinkIn(submission.uri.spec, where || "current", {
+ private: usePrivate && !PrivateBrowsingUtils.isWindowPrivate(window),
+ postData: submission.postData,
+ inBackground,
+ relatedToCurrent: true,
+ triggeringPrincipal,
+ csp,
+ targetBrowser: tab?.linkedBrowser,
+ globalHistoryOptions: {
+ triggeringSearchEngine: engine.name,
+ },
+ });
+
+ return { engine, url: submission.uri };
+ },
+
+ /**
+ * Perform a search initiated from the context menu.
+ *
+ * This should only be called from the context menu. See
+ * BrowserSearch.loadSearch for the preferred API.
+ */
+ async loadSearchFromContext(
+ terms,
+ usePrivate,
+ triggeringPrincipal,
+ csp,
+ event
+ ) {
+ event = getRootEvent(event);
+ let where = whereToOpenLink(event);
+ if (where == "current") {
+ // override: historically search opens in new tab
+ where = "tab";
+ }
+ if (usePrivate && !PrivateBrowsingUtils.isWindowPrivate(window)) {
+ where = "window";
+ }
+ let inBackground = Services.prefs.getBoolPref(
+ "browser.search.context.loadInBackground"
+ );
+ if (event.button == 1 || event.ctrlKey) {
+ inBackground = !inBackground;
+ }
+
+ let { engine, url } = await BrowserSearch._loadSearch(
+ terms,
+ where,
+ usePrivate,
+ "contextmenu",
+ Services.scriptSecurityManager.createNullPrincipal(
+ triggeringPrincipal.originAttributes
+ ),
+ csp,
+ inBackground
+ );
+
+ if (engine) {
+ BrowserSearchTelemetry.recordSearch(
+ gBrowser.selectedBrowser,
+ engine,
+ "contextmenu",
+ { url }
+ );
+ }
+ },
+
+ /**
+ * Perform a search initiated from the command line.
+ */
+ async loadSearchFromCommandLine(terms, usePrivate, triggeringPrincipal, csp) {
+ let { engine, url } = await BrowserSearch._loadSearch(
+ terms,
+ "current",
+ usePrivate,
+ "system",
+ triggeringPrincipal,
+ csp
+ );
+ if (engine) {
+ BrowserSearchTelemetry.recordSearch(
+ gBrowser.selectedBrowser,
+ engine,
+ "system",
+ { url }
+ );
+ }
+ },
+
+ /**
+ * Perform a search initiated from an extension.
+ */
+ async loadSearchFromExtension({
+ query,
+ engine,
+ where,
+ tab,
+ triggeringPrincipal,
+ }) {
+ const result = await BrowserSearch._loadSearch(
+ query,
+ where,
+ PrivateBrowsingUtils.isWindowPrivate(window),
+ "webextension",
+ triggeringPrincipal,
+ null,
+ false,
+ engine,
+ tab
+ );
+
+ BrowserSearchTelemetry.recordSearch(
+ gBrowser.selectedBrowser,
+ result.engine,
+ "webextension",
+ { url: result.url }
+ );
+ },
+
+ /**
+ * Returns the search bar element if it is present in the toolbar, null otherwise.
+ */
+ get searchBar() {
+ return document.getElementById("searchbar");
+ },
+
+ /**
+ * Infobar to notify the user's search engine has been removed
+ * and replaced with an application default search engine.
+ *
+ * @param {string} oldEngine
+ * name of the engine to be moved and replaced.
+ * @param {string} newEngine
+ * name of the application default engine to replaced the removed engine.
+ */
+ removalOfSearchEngineNotificationBox(oldEngine, newEngine) {
+ let messageFragment = document.createDocumentFragment();
+ let message = document.createElement("span");
+ let link = document.createXULElement("label", {
+ is: "text-link",
+ });
+
+ link.href = Services.urlFormatter.formatURLPref(
+ "browser.search.searchEngineRemoval"
+ );
+ link.setAttribute("data-l10n-name", "remove-search-engine-article");
+ document.l10n.setAttributes(message, "removed-search-engine-message", {
+ oldEngine,
+ newEngine,
+ });
+
+ message.appendChild(link);
+ messageFragment.appendChild(message);
+
+ let button = [
+ {
+ "l10n-id": "remove-search-engine-button",
+ primary: true,
+ callback() {
+ const notificationBox = gNotificationBox.getNotificationWithValue(
+ "search-engine-removal"
+ );
+ gNotificationBox.removeNotification(notificationBox);
+ },
+ },
+ ];
+
+ gNotificationBox.appendNotification(
+ "search-engine-removal",
+ {
+ label: messageFragment,
+ priority: gNotificationBox.PRIORITY_SYSTEM,
+ },
+ button
+ );
+
+ // Update engine name in the placeholder to the new default engine name.
+ this._updateURLBarPlaceholderFromDefaultEngine(
+ PrivateBrowsingUtils.isWindowPrivate(window),
+ false
+ ).catch(console.error);
+ },
+};
+
+XPCOMUtils.defineConstant(this, "BrowserSearch", BrowserSearch);
+
+function CreateContainerTabMenu(event) {
+ // Do not open context menus within menus.
+ // Note that triggerNode is null if we're opened by long press.
+ if (event.target.triggerNode?.closest("menupopup")) {
+ return false;
+ }
+ createUserContextMenu(event, {
+ useAccessKeys: false,
+ showDefaultTab: true,
+ });
+}
+
+function FillHistoryMenu(aParent) {
+ // Lazily add the hover listeners on first showing and never remove them
+ if (!aParent.hasStatusListener) {
+ // Show history item's uri in the status bar when hovering, and clear on exit
+ aParent.addEventListener("DOMMenuItemActive", function (aEvent) {
+ // Only the current page should have the checked attribute, so skip it
+ if (!aEvent.target.hasAttribute("checked")) {
+ XULBrowserWindow.setOverLink(aEvent.target.getAttribute("uri"));
+ }
+ });
+ aParent.addEventListener("DOMMenuItemInactive", function () {
+ XULBrowserWindow.setOverLink("");
+ });
+
+ aParent.hasStatusListener = true;
+ }
+
+ // Remove old entries if any
+ let children = aParent.children;
+ for (var i = children.length - 1; i >= 0; --i) {
+ if (children[i].hasAttribute("index")) {
+ aParent.removeChild(children[i]);
+ }
+ }
+
+ const MAX_HISTORY_MENU_ITEMS = 15;
+
+ const tooltipBack = gNavigatorBundle.getString("tabHistory.goBack");
+ const tooltipCurrent = gNavigatorBundle.getString("tabHistory.reloadCurrent");
+ const tooltipForward = gNavigatorBundle.getString("tabHistory.goForward");
+
+ function updateSessionHistory(sessionHistory, initial, ssInParent) {
+ let count = ssInParent
+ ? sessionHistory.count
+ : sessionHistory.entries.length;
+
+ if (!initial) {
+ if (count <= 1) {
+ // if there is only one entry now, close the popup.
+ aParent.hidePopup();
+ return;
+ } else if (aParent.id != "backForwardMenu" && !aParent.parentNode.open) {
+ // if the popup wasn't open before, but now needs to be, reopen the menu.
+ // It should trigger FillHistoryMenu again. This might happen with the
+ // delay from click-and-hold menus but skip this for the context menu
+ // (backForwardMenu) rather than figuring out how the menu should be
+ // positioned and opened as it is an extreme edgecase.
+ aParent.parentNode.open = true;
+ return;
+ }
+ }
+
+ let index = sessionHistory.index;
+ let half_length = Math.floor(MAX_HISTORY_MENU_ITEMS / 2);
+ let start = Math.max(index - half_length, 0);
+ let end = Math.min(
+ start == 0 ? MAX_HISTORY_MENU_ITEMS : index + half_length + 1,
+ count
+ );
+ if (end == count) {
+ start = Math.max(count - MAX_HISTORY_MENU_ITEMS, 0);
+ }
+
+ let existingIndex = 0;
+
+ for (let j = end - 1; j >= start; j--) {
+ let entry = ssInParent
+ ? sessionHistory.getEntryAtIndex(j)
+ : sessionHistory.entries[j];
+ // Explicitly check for "false" to stay backwards-compatible with session histories
+ // from before the hasUserInteraction was implemented.
+ if (
+ BrowserUtils.navigationRequireUserInteraction &&
+ entry.hasUserInteraction === false &&
+ // Always allow going to the first and last navigation points.
+ j != end - 1 &&
+ j != start
+ ) {
+ continue;
+ }
+ let uri = ssInParent ? entry.URI.spec : entry.url;
+
+ let item =
+ existingIndex < children.length
+ ? children[existingIndex]
+ : document.createXULElement("menuitem");
+
+ item.setAttribute("uri", uri);
+ item.setAttribute("label", entry.title || uri);
+ item.setAttribute("index", j);
+
+ // Cache this so that gotoHistoryIndex doesn't need the original index
+ item.setAttribute("historyindex", j - index);
+
+ if (j != index) {
+ // Use list-style-image rather than the image attribute in order to
+ // allow CSS to override this.
+ item.style.listStyleImage = `url(page-icon:${uri})`;
+ }
+
+ if (j < index) {
+ item.className =
+ "unified-nav-back menuitem-iconic menuitem-with-favicon";
+ item.setAttribute("tooltiptext", tooltipBack);
+ } else if (j == index) {
+ item.setAttribute("type", "radio");
+ item.setAttribute("checked", "true");
+ item.className = "unified-nav-current";
+ item.setAttribute("tooltiptext", tooltipCurrent);
+ } else {
+ item.className =
+ "unified-nav-forward menuitem-iconic menuitem-with-favicon";
+ item.setAttribute("tooltiptext", tooltipForward);
+ }
+
+ if (!item.parentNode) {
+ aParent.appendChild(item);
+ }
+
+ existingIndex++;
+ }
+
+ if (!initial) {
+ let existingLength = children.length;
+ while (existingIndex < existingLength) {
+ aParent.removeChild(aParent.lastElementChild);
+ existingIndex++;
+ }
+ }
+ }
+
+ // If session history in parent is available, use it. Otherwise, get the session history
+ // from session store.
+ let sessionHistory = gBrowser.selectedBrowser.browsingContext.sessionHistory;
+ if (sessionHistory?.count) {
+ // Don't show the context menu if there is only one item.
+ if (sessionHistory.count <= 1) {
+ return false;
+ }
+
+ updateSessionHistory(sessionHistory, true, true);
+ } else {
+ sessionHistory = SessionStore.getSessionHistory(
+ gBrowser.selectedTab,
+ updateSessionHistory
+ );
+ updateSessionHistory(sessionHistory, true, false);
+ }
+
+ return true;
+}
+
+function BrowserDownloadsUI() {
+ if (PrivateBrowsingUtils.isWindowPrivate(window)) {
+ openTrustedLinkIn("about:downloads", "tab");
+ } else {
+ PlacesCommandHook.showPlacesOrganizer("Downloads");
+ }
+}
+
+function toOpenWindowByType(inType, uri, features) {
+ var topWindow = Services.wm.getMostRecentWindow(inType);
+
+ if (topWindow) {
+ topWindow.focus();
+ } else if (features) {
+ window.open(uri, "_blank", features);
+ } else {
+ window.open(
+ uri,
+ "_blank",
+ "chrome,extrachrome,menubar,resizable,scrollbars,status,toolbar"
+ );
+ }
+}
+
+/**
+ * Open a new browser window.
+ *
+ * @param {Object} options
+ * {
+ * private: A boolean indicating if the window should be
+ * private
+ * remote: A boolean indicating if the window should run
+ * remote browser tabs or not. If omitted, the window
+ * will choose the profile default state.
+ * fission: A boolean indicating if the window should run
+ * with fission enabled or not. If omitted, the window
+ * will choose the profile default state.
+ * }
+ * @return a reference to the new window.
+ */
+function OpenBrowserWindow(options) {
+ var telemetryObj = {};
+ TelemetryStopwatch.start("FX_NEW_WINDOW_MS", telemetryObj);
+
+ var defaultArgs = BrowserHandler.defaultArgs;
+ var wintype = document.documentElement.getAttribute("windowtype");
+
+ var extraFeatures = "";
+ if (options && options.private && PrivateBrowsingUtils.enabled) {
+ extraFeatures = ",private";
+ if (!PrivateBrowsingUtils.permanentPrivateBrowsing) {
+ // Force the new window to load about:privatebrowsing instead of the default home page
+ defaultArgs = "about:privatebrowsing";
+ }
+ } else {
+ extraFeatures = ",non-private";
+ }
+
+ if (options && options.remote) {
+ extraFeatures += ",remote";
+ } else if (options && options.remote === false) {
+ extraFeatures += ",non-remote";
+ }
+
+ if (options && options.fission) {
+ extraFeatures += ",fission";
+ } else if (options && options.fission === false) {
+ extraFeatures += ",non-fission";
+ }
+
+ // If the window is maximized, we want to skip the animation, since we're
+ // going to be taking up most of the screen anyways, and we want to optimize
+ // for showing the user a useful window as soon as possible.
+ if (window.windowState == window.STATE_MAXIMIZED) {
+ extraFeatures += ",suppressanimation";
+ }
+
+ // if and only if the current window is a browser window and it has a document with a character
+ // set, then extract the current charset menu setting from the current document and use it to
+ // initialize the new browser window...
+ var win;
+ if (
+ window &&
+ wintype == "navigator:browser" &&
+ window.content &&
+ window.content.document
+ ) {
+ var DocCharset = window.content.document.characterSet;
+ let charsetArg = "charset=" + DocCharset;
+
+ // we should "inherit" the charset menu setting in a new window
+ win = window.openDialog(
+ AppConstants.BROWSER_CHROME_URL,
+ "_blank",
+ "chrome,all,dialog=no" + extraFeatures,
+ defaultArgs,
+ charsetArg
+ );
+ } else {
+ // forget about the charset information.
+ win = window.openDialog(
+ AppConstants.BROWSER_CHROME_URL,
+ "_blank",
+ "chrome,all,dialog=no" + extraFeatures,
+ defaultArgs
+ );
+ }
+
+ win.addEventListener(
+ "MozAfterPaint",
+ () => {
+ TelemetryStopwatch.finish("FX_NEW_WINDOW_MS", telemetryObj);
+ if (
+ Services.prefs.getIntPref("browser.startup.page") == 1 &&
+ defaultArgs == HomePage.get()
+ ) {
+ // A notification for when a user has triggered their homepage. This is used
+ // to display a doorhanger explaining that an extension has modified the
+ // homepage, if necessary.
+ Services.obs.notifyObservers(win, "browser-open-homepage-start");
+ }
+ },
+ { once: true }
+ );
+
+ return win;
+}
+
+/**
+ * Update the global flag that tracks whether or not any edit UI (the Edit menu,
+ * edit-related items in the context menu, and edit-related toolbar buttons
+ * is visible, then update the edit commands' enabled state accordingly. We use
+ * this flag to skip updating the edit commands on focus or selection changes
+ * when no UI is visible to improve performance (including pageload performance,
+ * since focus changes when you load a new page).
+ *
+ * If UI is visible, we use goUpdateGlobalEditMenuItems to set the commands'
+ * enabled state so the UI will reflect it appropriately.
+ *
+ * If the UI isn't visible, we enable all edit commands so keyboard shortcuts
+ * still work and just lazily disable them as needed when the user presses a
+ * shortcut.
+ *
+ * This doesn't work on Mac, since Mac menus flash when users press their
+ * keyboard shortcuts, so edit UI is essentially always visible on the Mac,
+ * and we need to always update the edit commands. Thus on Mac this function
+ * is a no op.
+ */
+function updateEditUIVisibility() {
+ if (AppConstants.platform == "macosx") {
+ return;
+ }
+
+ let editMenuPopupState = document.getElementById("menu_EditPopup").state;
+ let contextMenuPopupState = document.getElementById(
+ "contentAreaContextMenu"
+ ).state;
+ let placesContextMenuPopupState =
+ document.getElementById("placesContext").state;
+
+ let oldVisible = gEditUIVisible;
+
+ // The UI is visible if the Edit menu is opening or open, if the context menu
+ // is open, or if the toolbar has been customized to include the Cut, Copy,
+ // or Paste toolbar buttons.
+ gEditUIVisible =
+ editMenuPopupState == "showing" ||
+ editMenuPopupState == "open" ||
+ contextMenuPopupState == "showing" ||
+ contextMenuPopupState == "open" ||
+ placesContextMenuPopupState == "showing" ||
+ placesContextMenuPopupState == "open";
+ const kOpenPopupStates = ["showing", "open"];
+ if (!gEditUIVisible) {
+ // Now check the edit-controls toolbar buttons.
+ let placement = CustomizableUI.getPlacementOfWidget("edit-controls");
+ let areaType = placement ? CustomizableUI.getAreaType(placement.area) : "";
+ if (areaType == CustomizableUI.TYPE_PANEL) {
+ let customizablePanel = PanelUI.overflowPanel;
+ gEditUIVisible = kOpenPopupStates.includes(customizablePanel.state);
+ } else if (
+ areaType == CustomizableUI.TYPE_TOOLBAR &&
+ window.toolbar.visible
+ ) {
+ // The edit controls are on a toolbar, so they are visible,
+ // unless they're in a panel that isn't visible...
+ if (placement.area == "nav-bar") {
+ let editControls = document.getElementById("edit-controls");
+ gEditUIVisible =
+ !editControls.hasAttribute("overflowedItem") ||
+ kOpenPopupStates.includes(
+ document.getElementById("widget-overflow").state
+ );
+ } else {
+ gEditUIVisible = true;
+ }
+ }
+ }
+
+ // Now check the main menu panel
+ if (!gEditUIVisible) {
+ gEditUIVisible = kOpenPopupStates.includes(PanelUI.panel.state);
+ }
+
+ // No need to update commands if the edit UI visibility has not changed.
+ if (gEditUIVisible == oldVisible) {
+ return;
+ }
+
+ // If UI is visible, update the edit commands' enabled state to reflect
+ // whether or not they are actually enabled for the current focus/selection.
+ if (gEditUIVisible) {
+ goUpdateGlobalEditMenuItems();
+ } else {
+ // Otherwise, enable all commands, so that keyboard shortcuts still work,
+ // then lazily determine their actual enabled state when the user presses
+ // a keyboard shortcut.
+ goSetCommandEnabled("cmd_undo", true);
+ goSetCommandEnabled("cmd_redo", true);
+ goSetCommandEnabled("cmd_cut", true);
+ goSetCommandEnabled("cmd_copy", true);
+ goSetCommandEnabled("cmd_paste", true);
+ goSetCommandEnabled("cmd_selectAll", true);
+ goSetCommandEnabled("cmd_delete", true);
+ goSetCommandEnabled("cmd_switchTextDirection", true);
+ }
+}
+
+let gFileMenu = {
+ /**
+ * Updates User Context Menu Item UI visibility depending on
+ * privacy.userContext.enabled pref state.
+ */
+ updateUserContextUIVisibility() {
+ let menu = document.getElementById("menu_newUserContext");
+ menu.hidden = !Services.prefs.getBoolPref(
+ "privacy.userContext.enabled",
+ false
+ );
+ // Visibility of File menu item shouldn't change frequently.
+ if (PrivateBrowsingUtils.isWindowPrivate(window)) {
+ menu.setAttribute("disabled", "true");
+ }
+ },
+
+ /**
+ * Updates the enabled state of the "Import From Another Browser" command
+ * depending on the DisableProfileImport policy.
+ */
+ updateImportCommandEnabledState() {
+ if (!Services.policies.isAllowed("profileImport")) {
+ document
+ .getElementById("cmd_file_importFromAnotherBrowser")
+ .setAttribute("disabled", "true");
+ }
+ },
+
+ /**
+ * Updates the "Close tab" command to reflect the number of selected tabs,
+ * when applicable.
+ */
+ updateTabCloseCountState() {
+ document.l10n.setAttributes(
+ document.getElementById("menu_close"),
+ "menu-file-close-tab",
+ { tabCount: gBrowser.selectedTabs.length }
+ );
+ },
+
+ onPopupShowing(event) {
+ // We don't care about submenus:
+ if (event.target.id != "menu_FilePopup") {
+ return;
+ }
+ this.updateUserContextUIVisibility();
+ this.updateImportCommandEnabledState();
+ this.updateTabCloseCountState();
+ if (AppConstants.platform == "macosx") {
+ gShareUtils.updateShareURLMenuItem(
+ gBrowser.selectedBrowser,
+ document.getElementById("menu_savePage")
+ );
+ }
+ PrintUtils.updatePrintSetupMenuHiddenState();
+ },
+};
+
+let gShareUtils = {
+ /**
+ * Updates a sharing item in a given menu, creating it if necessary.
+ */
+ updateShareURLMenuItem(browser, insertAfterEl) {
+ if (!Services.prefs.getBoolPref("browser.menu.share_url.allow", true)) {
+ return;
+ }
+
+ // We only support "share URL" on macOS and on Windows 10:
+ if (
+ AppConstants.platform != "macosx" &&
+ // Windows 10's internal NT version number was initially 6.4
+ !AppConstants.isPlatformAndVersionAtLeast("win", "6.4")
+ ) {
+ return;
+ }
+
+ let shareURL = insertAfterEl.nextElementSibling;
+ if (!shareURL?.matches(".share-tab-url-item")) {
+ shareURL = this._createShareURLMenuItem(insertAfterEl);
+ }
+
+ shareURL.browserToShare = Cu.getWeakReference(browser);
+ if (AppConstants.platform == "win") {
+ // We disable the item on Windows, as there's no submenu.
+ // On macOS, we handle this inside the menupopup.
+ shareURL.hidden = !BrowserUtils.getShareableURL(browser.currentURI);
+ }
+ },
+
+ /**
+ * Creates and returns the "Share" menu item.
+ */
+ _createShareURLMenuItem(insertAfterEl) {
+ let menu = insertAfterEl.parentNode;
+ let shareURL = null;
+ if (AppConstants.platform == "win") {
+ shareURL = this._buildShareURLItem(menu.id);
+ } else if (AppConstants.platform == "macosx") {
+ shareURL = this._buildShareURLMenu(menu.id);
+ }
+ shareURL.className = "share-tab-url-item";
+
+ let l10nID =
+ menu.id == "tabContextMenu"
+ ? "tab-context-share-url"
+ : "menu-file-share-url";
+ document.l10n.setAttributes(shareURL, l10nID);
+
+ menu.insertBefore(shareURL, insertAfterEl.nextSibling);
+ return shareURL;
+ },
+
+ /**
+ * Returns a menu item specifically for accessing Windows sharing services.
+ */
+ _buildShareURLItem() {
+ let shareURLMenuItem = document.createXULElement("menuitem");
+ shareURLMenuItem.addEventListener("command", this);
+ return shareURLMenuItem;
+ },
+
+ /**
+ * Returns a menu specifically for accessing macOSx sharing services .
+ */
+ _buildShareURLMenu() {
+ let menu = document.createXULElement("menu");
+ let menuPopup = document.createXULElement("menupopup");
+ menuPopup.addEventListener("popupshowing", this);
+ menu.appendChild(menuPopup);
+ return menu;
+ },
+
+ /**
+ * Get the sharing data for a given DOM node.
+ */
+ getDataToShare(node) {
+ let browser = node.browserToShare?.get();
+ let urlToShare = null;
+ let titleToShare = null;
+
+ if (browser) {
+ let maybeToShare = BrowserUtils.getShareableURL(browser.currentURI);
+ if (maybeToShare) {
+ urlToShare = maybeToShare;
+ titleToShare = browser.contentTitle;
+ }
+ }
+ return { urlToShare, titleToShare };
+ },
+
+ /**
+ * Populates the "Share" menupopup on macOSx.
+ */
+ initializeShareURLPopup(menuPopup) {
+ if (AppConstants.platform != "macosx") {
+ return;
+ }
+
+ // Empty menupopup
+ while (menuPopup.firstChild) {
+ menuPopup.firstChild.remove();
+ }
+
+ let { urlToShare } = this.getDataToShare(menuPopup.parentNode);
+
+ // If we can't share the current URL, we display the items disabled,
+ // but enable the "more..." item at the bottom, to allow the user to
+ // change sharing preferences in the system dialog.
+ let shouldEnable = !!urlToShare;
+ if (!urlToShare) {
+ // Fake it so we can ask the sharing service for services:
+ urlToShare = makeURI("https://mozilla.org/");
+ }
+
+ let sharingService = gBrowser.MacSharingService;
+ let currentURI = gURLBar.makeURIReadable(urlToShare).displaySpec;
+ let services = sharingService.getSharingProviders(currentURI);
+
+ services.forEach(share => {
+ let item = document.createXULElement("menuitem");
+ item.classList.add("menuitem-iconic");
+ item.setAttribute("label", share.menuItemTitle);
+ item.setAttribute("share-name", share.name);
+ item.setAttribute("image", share.image);
+ if (!shouldEnable) {
+ item.setAttribute("disabled", "true");
+ }
+ menuPopup.appendChild(item);
+ });
+ menuPopup.appendChild(document.createXULElement("menuseparator"));
+ let moreItem = document.createXULElement("menuitem");
+ document.l10n.setAttributes(moreItem, "menu-share-more");
+ moreItem.classList.add("menuitem-iconic", "share-more-button");
+ menuPopup.appendChild(moreItem);
+
+ menuPopup.addEventListener("command", this);
+ menuPopup.parentNode
+ .closest("menupopup")
+ .addEventListener("popuphiding", this);
+ menuPopup.setAttribute("data-initialized", true);
+ },
+
+ onShareURLCommand(event) {
+ // Only call sharing services for the "Share" menu item. These services
+ // are accessed from a submenu popup for MacOS or the "Share" menu item
+ // for Windows. Use .closest() as a hack to find either the item itself
+ // or a parent with the right class.
+ let target = event.target.closest(".share-tab-url-item");
+ if (!target) {
+ return;
+ }
+
+ // urlToShare/titleToShare may be null, in which case only the "more"
+ // item is enabled, so handle that case first:
+ if (event.target.classList.contains("share-more-button")) {
+ gBrowser.MacSharingService.openSharingPreferences();
+ return;
+ }
+
+ let { urlToShare, titleToShare } = this.getDataToShare(target);
+ let currentURI = gURLBar.makeURIReadable(urlToShare).displaySpec;
+
+ if (AppConstants.platform == "win") {
+ WindowsUIUtils.shareUrl(currentURI, titleToShare);
+ return;
+ }
+
+ // On macOSX platforms
+ let shareName = event.target.getAttribute("share-name");
+
+ if (shareName) {
+ gBrowser.MacSharingService.shareUrl(shareName, currentURI, titleToShare);
+ }
+ },
+
+ onPopupHiding(event) {
+ // We don't want to rebuild the contents of the "Share" menupopup if only its submenu is
+ // hidden. So bail if this isn't the top menupopup in the DOM tree:
+ if (event.target.parentNode.closest("menupopup")) {
+ return;
+ }
+ // Otherwise, clear its "data-initialized" attribute.
+ let menupopup = event.target.querySelector(
+ ".share-tab-url-item"
+ )?.menupopup;
+ menupopup?.removeAttribute("data-initialized");
+
+ event.target.removeEventListener("popuphiding", this);
+ },
+
+ onPopupShowing(event) {
+ if (!event.target.hasAttribute("data-initialized")) {
+ this.initializeShareURLPopup(event.target);
+ }
+ },
+
+ handleEvent(aEvent) {
+ switch (aEvent.type) {
+ case "command":
+ this.onShareURLCommand(aEvent);
+ break;
+ case "popuphiding":
+ this.onPopupHiding(aEvent);
+ break;
+ case "popupshowing":
+ this.onPopupShowing(aEvent);
+ break;
+ }
+ },
+};
+
+/**
+ * Opens a new tab with the userContextId specified as an attribute of
+ * sourceEvent. This attribute is propagated to the top level originAttributes
+ * living on the tab's docShell.
+ *
+ * @param event
+ * A click event on a userContext File Menu option
+ */
+function openNewUserContextTab(event) {
+ openTrustedLinkIn(BROWSER_NEW_TAB_URL, "tab", {
+ userContextId: parseInt(event.target.getAttribute("data-usercontextid")),
+ });
+}
+
+var XULBrowserWindow = {
+ // Stored Status, Link and Loading values
+ status: "",
+ defaultStatus: "",
+ overLink: "",
+ startTime: 0,
+ isBusy: false,
+ busyUI: false,
+
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIWebProgressListener",
+ "nsIWebProgressListener2",
+ "nsISupportsWeakReference",
+ "nsIXULBrowserWindow",
+ ]),
+
+ get stopCommand() {
+ delete this.stopCommand;
+ return (this.stopCommand = document.getElementById("Browser:Stop"));
+ },
+ get reloadCommand() {
+ delete this.reloadCommand;
+ return (this.reloadCommand = document.getElementById("Browser:Reload"));
+ },
+ get _elementsForTextBasedTypes() {
+ delete this._elementsForTextBasedTypes;
+ return (this._elementsForTextBasedTypes = [
+ document.getElementById("pageStyleMenu"),
+ document.getElementById("context-viewpartialsource-selection"),
+ document.getElementById("context-print-selection"),
+ ]);
+ },
+ get _elementsForFind() {
+ delete this._elementsForFind;
+ return (this._elementsForFind = [
+ document.getElementById("cmd_find"),
+ document.getElementById("cmd_findAgain"),
+ document.getElementById("cmd_findPrevious"),
+ ]);
+ },
+ get _elementsForViewSource() {
+ delete this._elementsForViewSource;
+ return (this._elementsForViewSource = [
+ document.getElementById("context-viewsource"),
+ document.getElementById("View:PageSource"),
+ ]);
+ },
+ get _menuItemForRepairTextEncoding() {
+ delete this._menuItemForRepairTextEncoding;
+ return (this._menuItemForRepairTextEncoding = document.getElementById(
+ "repair-text-encoding"
+ ));
+ },
+ get _menuItemForTranslations() {
+ delete this._menuItemForTranslations;
+ return (this._menuItemForTranslations =
+ document.getElementById("cmd_translate"));
+ },
+
+ setDefaultStatus(status) {
+ this.defaultStatus = status;
+ StatusPanel.update();
+ },
+
+ setOverLink(url) {
+ if (url) {
+ url = Services.textToSubURI.unEscapeURIForUI(url);
+
+ // Encode bidirectional formatting characters.
+ // (RFC 3987 sections 3.2 and 4.1 paragraph 6)
+ url = url.replace(
+ /[\u200e\u200f\u202a\u202b\u202c\u202d\u202e]/g,
+ encodeURIComponent
+ );
+
+ if (UrlbarPrefs.get("trimURLs")) {
+ url = BrowserUIUtils.trimURL(url);
+ }
+ }
+
+ this.overLink = url;
+ LinkTargetDisplay.update();
+ },
+
+ showTooltip(xDevPix, yDevPix, tooltip, direction, browser) {
+ if (
+ Cc["@mozilla.org/widget/dragservice;1"]
+ .getService(Ci.nsIDragService)
+ .getCurrentSession()
+ ) {
+ return;
+ }
+
+ let elt = document.getElementById("remoteBrowserTooltip");
+ elt.label = tooltip;
+ elt.style.direction = direction;
+ elt.openPopupAtScreen(
+ xDevPix / window.devicePixelRatio,
+ yDevPix / window.devicePixelRatio,
+ false,
+ null
+ );
+ },
+
+ hideTooltip() {
+ let elt = document.getElementById("remoteBrowserTooltip");
+ elt.hidePopup();
+ },
+
+ getTabCount() {
+ return gBrowser.tabs.length;
+ },
+
+ onProgressChange(
+ aWebProgress,
+ aRequest,
+ aCurSelfProgress,
+ aMaxSelfProgress,
+ aCurTotalProgress,
+ aMaxTotalProgress
+ ) {
+ // Do nothing.
+ },
+
+ onProgressChange64(
+ aWebProgress,
+ aRequest,
+ aCurSelfProgress,
+ aMaxSelfProgress,
+ aCurTotalProgress,
+ aMaxTotalProgress
+ ) {
+ return this.onProgressChange(
+ aWebProgress,
+ aRequest,
+ aCurSelfProgress,
+ aMaxSelfProgress,
+ aCurTotalProgress,
+ aMaxTotalProgress
+ );
+ },
+
+ // This function fires only for the currently selected tab.
+ onStateChange(aWebProgress, aRequest, aStateFlags, aStatus) {
+ const nsIWebProgressListener = Ci.nsIWebProgressListener;
+
+ let browser = gBrowser.selectedBrowser;
+ gProtectionsHandler.onStateChange(aWebProgress, aStateFlags);
+
+ if (
+ aStateFlags & nsIWebProgressListener.STATE_START &&
+ aStateFlags & nsIWebProgressListener.STATE_IS_NETWORK
+ ) {
+ if (aRequest && aWebProgress.isTopLevel) {
+ // clear out search-engine data
+ browser.engines = null;
+ }
+
+ this.isBusy = true;
+
+ if (!(aStateFlags & nsIWebProgressListener.STATE_RESTORING)) {
+ this.busyUI = true;
+
+ // XXX: This needs to be based on window activity...
+ this.stopCommand.removeAttribute("disabled");
+ CombinedStopReload.switchToStop(aRequest, aWebProgress);
+ }
+ } else if (aStateFlags & nsIWebProgressListener.STATE_STOP) {
+ // This (thanks to the filter) is a network stop or the last
+ // request stop outside of loading the document, stop throbbers
+ // and progress bars and such
+ if (aRequest) {
+ let msg = "";
+ let location;
+ let canViewSource = true;
+ // Get the URI either from a channel or a pseudo-object
+ if (aRequest instanceof Ci.nsIChannel || "URI" in aRequest) {
+ location = aRequest.URI;
+
+ // For keyword URIs clear the user typed value since they will be changed into real URIs
+ if (location.scheme == "keyword" && aWebProgress.isTopLevel) {
+ gBrowser.userTypedValue = null;
+ }
+
+ canViewSource = location.scheme != "view-source";
+
+ if (location.spec != "about:blank") {
+ switch (aStatus) {
+ case Cr.NS_ERROR_NET_TIMEOUT:
+ msg = gNavigatorBundle.getString("nv_timeout");
+ break;
+ }
+ }
+ }
+
+ this.status = "";
+ this.setDefaultStatus(msg);
+
+ // Disable View Source menu entries for images, enable otherwise
+ let isText =
+ browser.documentContentType &&
+ BrowserUtils.mimeTypeIsTextBased(browser.documentContentType);
+ for (let element of this._elementsForViewSource) {
+ if (canViewSource && isText) {
+ element.removeAttribute("disabled");
+ } else {
+ element.setAttribute("disabled", "true");
+ }
+ }
+
+ this._updateElementsForContentType();
+
+ // Update Override Text Encoding state.
+ // Can't cache the button, because the presence of the element in the DOM
+ // may change over time.
+ let button = document.getElementById("characterencoding-button");
+ if (browser.mayEnableCharacterEncodingMenu) {
+ this._menuItemForRepairTextEncoding.removeAttribute("disabled");
+ button?.removeAttribute("disabled");
+ } else {
+ this._menuItemForRepairTextEncoding.setAttribute("disabled", "true");
+ button?.setAttribute("disabled", "true");
+ }
+ }
+
+ this.isBusy = false;
+
+ if (this.busyUI) {
+ this.busyUI = false;
+
+ this.stopCommand.setAttribute("disabled", "true");
+ CombinedStopReload.switchToReload(aRequest, aWebProgress);
+ }
+ }
+ },
+
+ /**
+ * An nsIWebProgressListener method called by tabbrowser. The `aIsSimulated`
+ * parameter is extra and not declared in nsIWebProgressListener, however; see
+ * below.
+ *
+ * @param {nsIWebProgress} aWebProgress
+ * The nsIWebProgress instance that fired the notification.
+ * @param {nsIRequest} aRequest
+ * The associated nsIRequest. This may be null in some cases.
+ * @param {nsIURI} aLocationURI
+ * The URI of the location that is being loaded.
+ * @param {integer} aFlags
+ * Flags that indicate the reason the location changed. See the
+ * nsIWebProgressListener.LOCATION_CHANGE_* values.
+ * @param {boolean} aIsSimulated
+ * True when this is called by tabbrowser due to switching tabs and
+ * undefined otherwise. This parameter is not declared in
+ * nsIWebProgressListener.onLocationChange; see bug 1478348.
+ */
+ onLocationChange(aWebProgress, aRequest, aLocationURI, aFlags, aIsSimulated) {
+ var location = aLocationURI ? aLocationURI.spec : "";
+
+ UpdateBackForwardCommands(gBrowser.webNavigation);
+
+ Services.obs.notifyObservers(
+ aWebProgress,
+ "touchbar-location-change",
+ location
+ );
+
+ // For most changes we only need to update the browser UI if the primary
+ // content area was navigated or the selected tab was changed. We don't need
+ // to do anything else if there was a subframe navigation.
+
+ if (!aWebProgress.isTopLevel) {
+ return;
+ }
+
+ this.hideOverLinkImmediately = true;
+ this.setOverLink("");
+ this.hideOverLinkImmediately = false;
+
+ let isSameDocument =
+ aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT;
+ if (
+ (location == "about:blank" &&
+ BrowserUIUtils.checkEmptyPageOrigin(gBrowser.selectedBrowser)) ||
+ location == ""
+ ) {
+ // Second condition is for new tabs, otherwise
+ // reload function is enabled until tab is refreshed.
+ this.reloadCommand.setAttribute("disabled", "true");
+ } else {
+ this.reloadCommand.removeAttribute("disabled");
+ }
+
+ let isSessionRestore = !!(
+ aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SESSION_STORE
+ );
+
+ // We want to update the popup visibility if we received this notification
+ // via simulated locationchange events such as switching between tabs, however
+ // if this is a document navigation then PopupNotifications will be updated
+ // via TabsProgressListener.onLocationChange and we do not want it called twice
+ gURLBar.setURI(
+ aLocationURI,
+ aIsSimulated,
+ isSessionRestore,
+ false,
+ isSameDocument
+ );
+
+ BookmarkingUI.onLocationChange();
+ // If we've actually changed document, update the toolbar visibility.
+ if (!isSameDocument) {
+ let bookmarksToolbar = gNavToolbox.querySelector("#PersonalToolbar");
+ setToolbarVisibility(
+ bookmarksToolbar,
+ gBookmarksToolbarVisibility,
+ false,
+ false
+ );
+ }
+
+ let closeOpenPanels = selector => {
+ for (let panel of document.querySelectorAll(selector)) {
+ if (panel.state != "closed") {
+ panel.hidePopup();
+ }
+ }
+ };
+
+ // If the location is changed due to switching tabs,
+ // ensure we close any open tabspecific panels.
+ if (aIsSimulated) {
+ closeOpenPanels("panel[tabspecific='true']");
+ }
+
+ // Ensure we close any remaining open locationspecific panels
+ if (!isSameDocument) {
+ closeOpenPanels("panel[locationspecific='true']");
+ }
+
+ let screenshotsButtonsDisabled =
+ gScreenshots.shouldScreenshotsButtonBeDisabled();
+ Services.obs.notifyObservers(
+ window,
+ "toggle-screenshot-disable",
+ screenshotsButtonsDisabled
+ );
+
+ gPermissionPanel.onLocationChange();
+
+ gProtectionsHandler.onLocationChange();
+
+ BrowserPageActions.onLocationChange();
+
+ SafeBrowsingNotificationBox.onLocationChange(aLocationURI);
+
+ SaveToPocket.onLocationChange(window);
+
+ let originalURI;
+ if (aRequest instanceof Ci.nsIChannel) {
+ originalURI = aRequest.originalURI;
+ }
+
+ UrlbarProviderSearchTips.onLocationChange(
+ window,
+ aLocationURI,
+ originalURI,
+ aWebProgress,
+ aFlags
+ );
+
+ gTabletModePageCounter.inc();
+
+ this._updateElementsForContentType();
+
+ this._updateMacUserActivity(window, aLocationURI, aWebProgress);
+
+ // Unconditionally disable the Text Encoding button during load to
+ // keep the UI calm when navigating from one modern page to another and
+ // the toolbar button is visible.
+ // Can't cache the button, because the presence of the element in the DOM
+ // may change over time.
+ let button = document.getElementById("characterencoding-button");
+ this._menuItemForRepairTextEncoding.setAttribute("disabled", "true");
+ button?.setAttribute("disabled", "true");
+
+ // Try not to instantiate gCustomizeMode as much as possible,
+ // so don't use CustomizeMode.sys.mjs to check for URI or customizing.
+ if (
+ location == "about:blank" &&
+ gBrowser.selectedTab.hasAttribute("customizemode")
+ ) {
+ gCustomizeMode.enter();
+ } else if (
+ CustomizationHandler.isEnteringCustomizeMode ||
+ CustomizationHandler.isCustomizing()
+ ) {
+ gCustomizeMode.exit();
+ }
+
+ CFRPageActions.updatePageActions(gBrowser.selectedBrowser);
+
+ AboutReaderParent.updateReaderButton(gBrowser.selectedBrowser);
+ TranslationsParent.onLocationChange(gBrowser.selectedBrowser);
+
+ PictureInPicture.updateUrlbarToggle(gBrowser.selectedBrowser);
+
+ if (!gMultiProcessBrowser) {
+ // Bug 1108553 - Cannot rotate images with e10s
+ gGestureSupport.restoreRotationState();
+ }
+
+ // See bug 358202, when tabs are switched during a drag operation,
+ // timers don't fire on windows (bug 203573)
+ if (aRequest) {
+ setTimeout(function () {
+ XULBrowserWindow.asyncUpdateUI();
+ }, 0);
+ } else {
+ this.asyncUpdateUI();
+ }
+
+ if (AppConstants.MOZ_CRASHREPORTER && aLocationURI) {
+ let uri = aLocationURI;
+ try {
+ // If the current URI contains a username/password, remove it.
+ uri = aLocationURI.mutate().setUserPass("").finalize();
+ } catch (ex) {
+ /* Ignore failures on about: URIs. */
+ }
+
+ try {
+ Services.appinfo.annotateCrashReport("URL", uri.spec);
+ } catch (ex) {
+ // Don't make noise when the crash reporter is built but not enabled.
+ if (ex.result != Cr.NS_ERROR_NOT_INITIALIZED) {
+ throw ex;
+ }
+ }
+ }
+ },
+
+ _updateElementsForContentType() {
+ let browser = gBrowser.selectedBrowser;
+
+ let isText =
+ browser.documentContentType &&
+ BrowserUtils.mimeTypeIsTextBased(browser.documentContentType);
+ for (let element of this._elementsForTextBasedTypes) {
+ if (isText) {
+ element.removeAttribute("disabled");
+ } else {
+ element.setAttribute("disabled", "true");
+ }
+ }
+
+ // Always enable find commands in PDF documents, otherwise do it only for
+ // text documents whose location is not in the blacklist.
+ let enableFind =
+ browser.contentPrincipal?.spec == "resource://pdf.js/web/viewer.html" ||
+ (isText && BrowserUtils.canFindInPage(gBrowser.currentURI.spec));
+ for (let element of this._elementsForFind) {
+ if (enableFind) {
+ element.removeAttribute("disabled");
+ } else {
+ element.setAttribute("disabled", "true");
+ }
+ }
+
+ if (TranslationsParent.isRestrictedPage(gBrowser.currentURI.spec)) {
+ this._menuItemForTranslations.setAttribute("disabled", "true");
+ } else {
+ this._menuItemForTranslations.removeAttribute("disabled");
+ }
+ if (gTranslationsEnabled) {
+ this._menuItemForTranslations.removeAttribute("hidden");
+ } else {
+ this._menuItemForTranslations.setAttribute("hidden", "true");
+ }
+ },
+
+ /**
+ * Updates macOS platform code with the current URI and page title.
+ * From there, we update the current NSUserActivity, enabling Handoff to other
+ * Apple devices.
+ * @param {Window} window
+ * The window in which the navigation occurred.
+ * @param {nsIURI} uri
+ * The URI pointing to the current page.
+ * @param {nsIWebProgress} webProgress
+ * The nsIWebProgress instance that fired a onLocationChange notification.
+ */
+ _updateMacUserActivity(win, uri, webProgress) {
+ if (!webProgress.isTopLevel || AppConstants.platform != "macosx") {
+ return;
+ }
+
+ let url = uri.spec;
+ if (PrivateBrowsingUtils.isWindowPrivate(win)) {
+ // Passing an empty string to MacUserActivityUpdater will invalidate the
+ // current user activity.
+ url = "";
+ }
+ let baseWin = win.docShell.treeOwner.QueryInterface(Ci.nsIBaseWindow);
+ MacUserActivityUpdater.updateLocation(
+ url,
+ win.gBrowser.contentTitle,
+ baseWin
+ );
+ },
+
+ asyncUpdateUI() {
+ BrowserSearch.updateOpenSearchBadge();
+ },
+
+ onStatusChange(aWebProgress, aRequest, aStatus, aMessage) {
+ this.status = aMessage;
+ StatusPanel.update();
+ },
+
+ // Properties used to cache security state used to update the UI
+ _state: null,
+ _lastLocation: null,
+ _event: null,
+ _lastLocationForEvent: null,
+ // _isSecureContext can change without the state/location changing, due to security
+ // error pages that intercept certain loads. For example this happens sometimes
+ // with the the HTTPS-Only Mode error page (more details in bug 1656027)
+ _isSecureContext: null,
+
+ // This is called in multiple ways:
+ // 1. Due to the nsIWebProgressListener.onContentBlockingEvent notification.
+ // 2. Called by tabbrowser.xml when updating the current browser.
+ // 3. Called directly during this object's initializations.
+ // 4. Due to the nsIWebProgressListener.onLocationChange notification.
+ // aRequest will be null always in case 2 and 3, and sometimes in case 1 (for
+ // instance, there won't be a request when STATE_BLOCKED_TRACKING_CONTENT or
+ // other blocking events are observed).
+ onContentBlockingEvent(aWebProgress, aRequest, aEvent, aIsSimulated) {
+ // Don't need to do anything if the data we use to update the UI hasn't
+ // changed
+ let uri = gBrowser.currentURI;
+ let spec = uri.spec;
+ if (this._event == aEvent && this._lastLocationForEvent == spec) {
+ return;
+ }
+ this._lastLocationForEvent = spec;
+
+ if (
+ typeof aIsSimulated != "boolean" &&
+ typeof aIsSimulated != "undefined"
+ ) {
+ throw new Error(
+ "onContentBlockingEvent: aIsSimulated receieved an unexpected type"
+ );
+ }
+
+ gProtectionsHandler.onContentBlockingEvent(
+ aEvent,
+ aWebProgress,
+ aIsSimulated,
+ this._event // previous content blocking event
+ );
+
+ // We need the state of the previous content blocking event, so update
+ // event after onContentBlockingEvent is called.
+ this._event = aEvent;
+ },
+
+ // This is called in multiple ways:
+ // 1. Due to the nsIWebProgressListener.onSecurityChange notification.
+ // 2. Called by tabbrowser.xml when updating the current browser.
+ // 3. Called directly during this object's initializations.
+ // aRequest will be null always in case 2 and 3, and sometimes in case 1.
+ onSecurityChange(aWebProgress, aRequest, aState, aIsSimulated) {
+ // Don't need to do anything if the data we use to update the UI hasn't
+ // changed
+ let uri = gBrowser.currentURI;
+ let spec = uri.spec;
+ let isSecureContext = gBrowser.securityUI.isSecureContext;
+ if (
+ this._state == aState &&
+ this._lastLocation == spec &&
+ this._isSecureContext === isSecureContext
+ ) {
+ // Switching to a tab of the same URL doesn't change most security
+ // information, but tab specific permissions may be different.
+ gIdentityHandler.refreshIdentityBlock();
+ return;
+ }
+ this._state = aState;
+ this._lastLocation = spec;
+ this._isSecureContext = isSecureContext;
+
+ // Make sure the "https" part of the URL is striked out or not,
+ // depending on the current mixed active content blocking state.
+ gURLBar.formatValue();
+
+ try {
+ uri = Services.io.createExposableURI(uri);
+ } catch (e) {}
+ gIdentityHandler.updateIdentity(this._state, uri);
+ },
+
+ // simulate all change notifications after switching tabs
+ onUpdateCurrentBrowser: function XWB_onUpdateCurrentBrowser(
+ aStateFlags,
+ aStatus,
+ aMessage,
+ aTotalProgress
+ ) {
+ if (FullZoom.updateBackgroundTabs) {
+ FullZoom.onLocationChange(gBrowser.currentURI, true);
+ }
+
+ CombinedStopReload.onTabSwitch();
+
+ // Docshell should normally take care of hiding the tooltip, but we need to do it
+ // ourselves for tabswitches.
+ this.hideTooltip();
+
+ // Also hide tooltips for content loaded in the parent process:
+ document.getElementById("aHTMLTooltip").hidePopup();
+
+ var nsIWebProgressListener = Ci.nsIWebProgressListener;
+ var loadingDone = aStateFlags & nsIWebProgressListener.STATE_STOP;
+ // use a pseudo-object instead of a (potentially nonexistent) channel for getting
+ // a correct error message - and make sure that the UI is always either in
+ // loading (STATE_START) or done (STATE_STOP) mode
+ this.onStateChange(
+ gBrowser.webProgress,
+ { URI: gBrowser.currentURI },
+ loadingDone
+ ? nsIWebProgressListener.STATE_STOP
+ : nsIWebProgressListener.STATE_START,
+ aStatus
+ );
+ // status message and progress value are undefined if we're done with loading
+ if (loadingDone) {
+ return;
+ }
+ this.onStatusChange(gBrowser.webProgress, null, 0, aMessage);
+ },
+};
+
+var LinkTargetDisplay = {
+ get DELAY_SHOW() {
+ delete this.DELAY_SHOW;
+ return (this.DELAY_SHOW = Services.prefs.getIntPref(
+ "browser.overlink-delay"
+ ));
+ },
+
+ DELAY_HIDE: 250,
+ _timer: 0,
+
+ get _contextMenu() {
+ delete this._contextMenu;
+ return (this._contextMenu = document.getElementById(
+ "contentAreaContextMenu"
+ ));
+ },
+
+ update() {
+ if (
+ this._contextMenu.state == "open" ||
+ this._contextMenu.state == "showing"
+ ) {
+ this._contextMenu.addEventListener("popuphidden", () => this.update(), {
+ once: true,
+ });
+ return;
+ }
+
+ clearTimeout(this._timer);
+ window.removeEventListener("mousemove", this, true);
+
+ if (!XULBrowserWindow.overLink) {
+ if (XULBrowserWindow.hideOverLinkImmediately) {
+ this._hide();
+ } else {
+ this._timer = setTimeout(this._hide.bind(this), this.DELAY_HIDE);
+ }
+ return;
+ }
+
+ if (StatusPanel.isVisible) {
+ StatusPanel.update();
+ } else {
+ // Let the display appear when the mouse doesn't move within the delay
+ this._showDelayed();
+ window.addEventListener("mousemove", this, true);
+ }
+ },
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "mousemove":
+ // Restart the delay since the mouse was moved
+ clearTimeout(this._timer);
+ this._showDelayed();
+ break;
+ }
+ },
+
+ _showDelayed() {
+ this._timer = setTimeout(
+ function (self) {
+ StatusPanel.update();
+ window.removeEventListener("mousemove", self, true);
+ },
+ this.DELAY_SHOW,
+ this
+ );
+ },
+
+ _hide() {
+ clearTimeout(this._timer);
+
+ StatusPanel.update();
+ },
+};
+
+var CombinedStopReload = {
+ // Try to initialize. Returns whether initialization was successful, which
+ // may mean we had already initialized.
+ ensureInitialized() {
+ if (this._initialized) {
+ return true;
+ }
+ if (this._destroyed) {
+ return false;
+ }
+
+ let reload = document.getElementById("reload-button");
+ let stop = document.getElementById("stop-button");
+ // It's possible the stop/reload buttons have been moved to the palette.
+ // They may be reinserted later, so we will retry initialization if/when
+ // we get notified of document loads.
+ if (!stop || !reload) {
+ return false;
+ }
+
+ this._initialized = true;
+ if (XULBrowserWindow.stopCommand.getAttribute("disabled") != "true") {
+ reload.setAttribute("displaystop", "true");
+ }
+ stop.addEventListener("click", this);
+
+ // Removing attributes based on the observed command doesn't happen if the button
+ // is in the palette when the command's attribute is removed (cf. bug 309953)
+ for (let button of [stop, reload]) {
+ if (button.hasAttribute("disabled")) {
+ let command = document.getElementById(button.getAttribute("command"));
+ if (!command.hasAttribute("disabled")) {
+ button.removeAttribute("disabled");
+ }
+ }
+ }
+
+ this.reload = reload;
+ this.stop = stop;
+ this.stopReloadContainer = this.reload.parentNode;
+ this.timeWhenSwitchedToStop = 0;
+
+ this.stopReloadContainer.addEventListener("animationend", this);
+ this.stopReloadContainer.addEventListener("animationcancel", this);
+
+ return true;
+ },
+
+ uninit() {
+ this._destroyed = true;
+
+ if (!this._initialized) {
+ return;
+ }
+
+ this._cancelTransition();
+ this.stop.removeEventListener("click", this);
+ this.stopReloadContainer.removeEventListener("animationend", this);
+ this.stopReloadContainer.removeEventListener("animationcancel", this);
+ this.stopReloadContainer = null;
+ this.reload = null;
+ this.stop = null;
+ },
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "click":
+ if (event.button == 0 && !this.stop.disabled) {
+ this._stopClicked = true;
+ }
+ break;
+ case "animationcancel":
+ case "animationend": {
+ if (
+ event.target.classList.contains("toolbarbutton-animatable-image") &&
+ (event.animationName == "reload-to-stop" ||
+ event.animationName == "stop-to-reload")
+ ) {
+ this.stopReloadContainer.removeAttribute("animate");
+ }
+ }
+ }
+ },
+
+ onTabSwitch() {
+ // Reset the time in the event of a tabswitch since the stored time
+ // would have been associated with the previous tab, so the animation will
+ // still run if the page has been loading until long after the tab switch.
+ this.timeWhenSwitchedToStop = window.performance.now();
+ },
+
+ switchToStop(aRequest, aWebProgress) {
+ if (
+ !this.ensureInitialized() ||
+ !this._shouldSwitch(aRequest, aWebProgress)
+ ) {
+ return;
+ }
+
+ // Store the time that we switched to the stop button only if a request
+ // is active. Requests are null if the switch is related to a tabswitch.
+ // This is used to determine if we should show the stop->reload animation.
+ if (aRequest instanceof Ci.nsIRequest) {
+ this.timeWhenSwitchedToStop = window.performance.now();
+ }
+
+ let shouldAnimate =
+ aRequest instanceof Ci.nsIRequest &&
+ aWebProgress.isTopLevel &&
+ aWebProgress.isLoadingDocument &&
+ !gBrowser.tabAnimationsInProgress &&
+ !gReduceMotion &&
+ this.stopReloadContainer.closest("#nav-bar-customization-target");
+
+ this._cancelTransition();
+ if (shouldAnimate) {
+ this.stopReloadContainer.setAttribute("animate", "true");
+ } else {
+ this.stopReloadContainer.removeAttribute("animate");
+ }
+ this.reload.setAttribute("displaystop", "true");
+ },
+
+ switchToReload(aRequest, aWebProgress) {
+ if (!this.ensureInitialized() || !this.reload.hasAttribute("displaystop")) {
+ return;
+ }
+
+ let shouldAnimate =
+ aRequest instanceof Ci.nsIRequest &&
+ aWebProgress.isTopLevel &&
+ !aWebProgress.isLoadingDocument &&
+ !gBrowser.tabAnimationsInProgress &&
+ !gReduceMotion &&
+ this._loadTimeExceedsMinimumForAnimation() &&
+ this.stopReloadContainer.closest("#nav-bar-customization-target");
+
+ if (shouldAnimate) {
+ this.stopReloadContainer.setAttribute("animate", "true");
+ } else {
+ this.stopReloadContainer.removeAttribute("animate");
+ }
+
+ this.reload.removeAttribute("displaystop");
+
+ if (!shouldAnimate || this._stopClicked) {
+ this._stopClicked = false;
+ this._cancelTransition();
+ this.reload.disabled =
+ XULBrowserWindow.reloadCommand.getAttribute("disabled") == "true";
+ return;
+ }
+
+ if (this._timer) {
+ return;
+ }
+
+ // Temporarily disable the reload button to prevent the user from
+ // accidentally reloading the page when intending to click the stop button
+ this.reload.disabled = true;
+ this._timer = setTimeout(
+ function (self) {
+ self._timer = 0;
+ self.reload.disabled =
+ XULBrowserWindow.reloadCommand.getAttribute("disabled") == "true";
+ },
+ 650,
+ this
+ );
+ },
+
+ _loadTimeExceedsMinimumForAnimation() {
+ // If the time between switching to the stop button then switching to
+ // the reload button exceeds 150ms, then we will show the animation.
+ // If we don't know when we switched to stop (switchToStop is called
+ // after init but before switchToReload), then we will prevent the
+ // animation from occuring.
+ return (
+ this.timeWhenSwitchedToStop &&
+ window.performance.now() - this.timeWhenSwitchedToStop > 150
+ );
+ },
+
+ _shouldSwitch(aRequest, aWebProgress) {
+ if (
+ aRequest &&
+ aRequest.originalURI &&
+ (aRequest.originalURI.schemeIs("chrome") ||
+ (aRequest.originalURI.schemeIs("about") &&
+ aWebProgress.isTopLevel &&
+ !aRequest.originalURI.spec.startsWith("about:reader")))
+ ) {
+ return false;
+ }
+
+ return true;
+ },
+
+ _cancelTransition() {
+ if (this._timer) {
+ clearTimeout(this._timer);
+ this._timer = 0;
+ }
+ },
+};
+
+var TabsProgressListener = {
+ onStateChange(aBrowser, aWebProgress, aRequest, aStateFlags, aStatus) {
+ // Collect telemetry data about tab load times.
+ if (
+ aWebProgress.isTopLevel &&
+ (!aRequest.originalURI || aRequest.originalURI.scheme != "about")
+ ) {
+ let histogram = "FX_PAGE_LOAD_MS_2";
+ let recordLoadTelemetry = true;
+
+ if (aWebProgress.loadType & Ci.nsIDocShell.LOAD_CMD_RELOAD) {
+ // loadType is constructed by shifting loadFlags, this is why we need to
+ // do the same shifting here.
+ // https://searchfox.org/mozilla-central/rev/11cfa0462a6b5d8c5e2111b8cfddcf78098f0141/docshell/base/nsDocShellLoadTypes.h#22
+ if (aWebProgress.loadType & (kSkipCacheFlags << 16)) {
+ histogram = "FX_PAGE_RELOAD_SKIP_CACHE_MS";
+ } else if (aWebProgress.loadType == Ci.nsIDocShell.LOAD_CMD_RELOAD) {
+ histogram = "FX_PAGE_RELOAD_NORMAL_MS";
+ } else {
+ recordLoadTelemetry = false;
+ }
+ }
+
+ let stopwatchRunning = TelemetryStopwatch.running(histogram, aBrowser);
+ if (aStateFlags & Ci.nsIWebProgressListener.STATE_IS_WINDOW) {
+ if (aStateFlags & Ci.nsIWebProgressListener.STATE_START) {
+ if (stopwatchRunning) {
+ // Oops, we're seeing another start without having noticed the previous stop.
+ if (recordLoadTelemetry) {
+ TelemetryStopwatch.cancel(histogram, aBrowser);
+ }
+ }
+ if (recordLoadTelemetry) {
+ TelemetryStopwatch.start(histogram, aBrowser);
+ }
+ Services.telemetry.getHistogramById("FX_TOTAL_TOP_VISITS").add(true);
+ } else if (
+ aStateFlags & Ci.nsIWebProgressListener.STATE_STOP &&
+ stopwatchRunning /* we won't see STATE_START events for pre-rendered tabs */
+ ) {
+ if (recordLoadTelemetry) {
+ TelemetryStopwatch.finish(histogram, aBrowser);
+ BrowserTelemetryUtils.recordSiteOriginTelemetry(browserWindows());
+ }
+ }
+ } else if (
+ aStateFlags & Ci.nsIWebProgressListener.STATE_STOP &&
+ aStatus == Cr.NS_BINDING_ABORTED &&
+ stopwatchRunning /* we won't see STATE_START events for pre-rendered tabs */
+ ) {
+ if (recordLoadTelemetry) {
+ TelemetryStopwatch.cancel(histogram, aBrowser);
+ }
+ }
+ }
+ },
+
+ onLocationChange(aBrowser, aWebProgress, aRequest, aLocationURI, aFlags) {
+ // Filter out location changes caused by anchor navigation
+ // or history.push/pop/replaceState.
+ if (aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT) {
+ // Reader mode cares about history.pushState and friends.
+ // FIXME: The content process should manage this directly (bug 1445351).
+ aBrowser.sendMessageToActor(
+ "Reader:PushState",
+ {
+ isArticle: aBrowser.isArticle,
+ },
+ "AboutReader"
+ );
+ return;
+ }
+
+ // Filter out location changes in sub documents.
+ if (!aWebProgress.isTopLevel) {
+ return;
+ }
+
+ // Only need to call locationChange if the PopupNotifications object
+ // for this window has already been initialized (i.e. its getter no
+ // longer exists)
+ if (!Object.getOwnPropertyDescriptor(window, "PopupNotifications").get) {
+ PopupNotifications.locationChange(aBrowser);
+ }
+
+ let tab = gBrowser.getTabForBrowser(aBrowser);
+ if (tab && tab._sharingState) {
+ gBrowser.resetBrowserSharing(aBrowser);
+ }
+
+ gBrowser.readNotificationBox(aBrowser)?.removeTransientNotifications();
+
+ FullZoom.onLocationChange(aLocationURI, false, aBrowser);
+ CaptivePortalWatcher.onLocationChange(aBrowser);
+ },
+
+ onLinkIconAvailable(browser, dataURI, iconURI) {
+ if (!iconURI) {
+ return;
+ }
+ if (browser == gBrowser.selectedBrowser) {
+ // If the "Add Search Engine" page action is in the urlbar, its image
+ // needs to be set to the new icon, so call updateOpenSearchBadge.
+ BrowserSearch.updateOpenSearchBadge();
+ }
+ },
+};
+
+function nsBrowserAccess() {}
+
+nsBrowserAccess.prototype = {
+ QueryInterface: ChromeUtils.generateQI(["nsIBrowserDOMWindow"]),
+
+ _openURIInNewTab(
+ aURI,
+ aReferrerInfo,
+ aIsPrivate,
+ aIsExternal,
+ aForceNotRemote = false,
+ aUserContextId = Ci.nsIScriptSecurityManager.DEFAULT_USER_CONTEXT_ID,
+ aOpenWindowInfo = null,
+ aOpenerBrowser = null,
+ aTriggeringPrincipal = null,
+ aName = "",
+ aCsp = null,
+ aSkipLoad = false
+ ) {
+ let win, needToFocusWin;
+
+ // try the current window. if we're in a popup, fall back on the most recent browser window
+ if (window.toolbar.visible) {
+ win = window;
+ } else {
+ win = BrowserWindowTracker.getTopWindow({ private: aIsPrivate });
+ needToFocusWin = true;
+ }
+
+ if (!win) {
+ // we couldn't find a suitable window, a new one needs to be opened.
+ return null;
+ }
+
+ if (aIsExternal && (!aURI || aURI.spec == "about:blank")) {
+ win.BrowserOpenTab(); // this also focuses the location bar
+ win.focus();
+ return win.gBrowser.selectedBrowser;
+ }
+
+ let loadInBackground = Services.prefs.getBoolPref(
+ "browser.tabs.loadDivertedInBackground"
+ );
+
+ let tab = win.gBrowser.addTab(aURI ? aURI.spec : "about:blank", {
+ triggeringPrincipal: aTriggeringPrincipal,
+ referrerInfo: aReferrerInfo,
+ userContextId: aUserContextId,
+ fromExternal: aIsExternal,
+ inBackground: loadInBackground,
+ forceNotRemote: aForceNotRemote,
+ openWindowInfo: aOpenWindowInfo,
+ openerBrowser: aOpenerBrowser,
+ name: aName,
+ csp: aCsp,
+ skipLoad: aSkipLoad,
+ });
+ let browser = win.gBrowser.getBrowserForTab(tab);
+
+ if (needToFocusWin || (!loadInBackground && aIsExternal)) {
+ win.focus();
+ }
+
+ return browser;
+ },
+
+ createContentWindow(
+ aURI,
+ aOpenWindowInfo,
+ aWhere,
+ aFlags,
+ aTriggeringPrincipal,
+ aCsp
+ ) {
+ return this.getContentWindowOrOpenURI(
+ null,
+ aOpenWindowInfo,
+ aWhere,
+ aFlags,
+ aTriggeringPrincipal,
+ aCsp,
+ true
+ );
+ },
+
+ openURI(aURI, aOpenWindowInfo, aWhere, aFlags, aTriggeringPrincipal, aCsp) {
+ if (!aURI) {
+ console.error("openURI should only be called with a valid URI");
+ throw Components.Exception("", Cr.NS_ERROR_FAILURE);
+ }
+ return this.getContentWindowOrOpenURI(
+ aURI,
+ aOpenWindowInfo,
+ aWhere,
+ aFlags,
+ aTriggeringPrincipal,
+ aCsp,
+ false
+ );
+ },
+
+ getContentWindowOrOpenURI(
+ aURI,
+ aOpenWindowInfo,
+ aWhere,
+ aFlags,
+ aTriggeringPrincipal,
+ aCsp,
+ aSkipLoad
+ ) {
+ var browsingContext = null;
+ var isExternal = !!(aFlags & Ci.nsIBrowserDOMWindow.OPEN_EXTERNAL);
+
+ if (aOpenWindowInfo && isExternal) {
+ console.error(
+ "nsBrowserAccess.openURI did not expect aOpenWindowInfo to be " +
+ "passed if the context is OPEN_EXTERNAL."
+ );
+ throw Components.Exception("", Cr.NS_ERROR_FAILURE);
+ }
+
+ if (isExternal && aURI && aURI.schemeIs("chrome")) {
+ dump("use --chrome command-line option to load external chrome urls\n");
+ return null;
+ }
+
+ if (aWhere == Ci.nsIBrowserDOMWindow.OPEN_DEFAULTWINDOW) {
+ if (
+ isExternal &&
+ Services.prefs.prefHasUserValue(
+ "browser.link.open_newwindow.override.external"
+ )
+ ) {
+ aWhere = Services.prefs.getIntPref(
+ "browser.link.open_newwindow.override.external"
+ );
+ } else {
+ aWhere = Services.prefs.getIntPref("browser.link.open_newwindow");
+ }
+ }
+
+ let referrerInfo;
+ if (aFlags & Ci.nsIBrowserDOMWindow.OPEN_NO_REFERRER) {
+ referrerInfo = new ReferrerInfo(Ci.nsIReferrerInfo.EMPTY, false, null);
+ } else if (
+ aOpenWindowInfo &&
+ aOpenWindowInfo.parent &&
+ aOpenWindowInfo.parent.window
+ ) {
+ referrerInfo = new ReferrerInfo(
+ aOpenWindowInfo.parent.window.document.referrerInfo.referrerPolicy,
+ true,
+ makeURI(aOpenWindowInfo.parent.window.location.href)
+ );
+ } else {
+ referrerInfo = new ReferrerInfo(Ci.nsIReferrerInfo.EMPTY, true, null);
+ }
+
+ let isPrivate = aOpenWindowInfo
+ ? aOpenWindowInfo.originAttributes.privateBrowsingId != 0
+ : PrivateBrowsingUtils.isWindowPrivate(window);
+
+ switch (aWhere) {
+ case Ci.nsIBrowserDOMWindow.OPEN_NEWWINDOW:
+ // FIXME: Bug 408379. So how come this doesn't send the
+ // referrer like the other loads do?
+ var url = aURI && aURI.spec;
+ let features = "all,dialog=no";
+ if (isPrivate) {
+ features += ",private";
+ }
+ // Pass all params to openDialog to ensure that "url" isn't passed through
+ // loadOneOrMoreURIs, which splits based on "|"
+ try {
+ let extraOptions = Cc[
+ "@mozilla.org/hash-property-bag;1"
+ ].createInstance(Ci.nsIWritablePropertyBag2);
+ extraOptions.setPropertyAsBool("fromExternal", isExternal);
+
+ openDialog(
+ AppConstants.BROWSER_CHROME_URL,
+ "_blank",
+ features,
+ // window.arguments
+ url,
+ extraOptions,
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ aTriggeringPrincipal,
+ null,
+ aCsp,
+ aOpenWindowInfo
+ );
+ // At this point, the new browser window is just starting to load, and
+ // hasn't created the content <browser> that we should return.
+ // If the caller of this function is originating in C++, they can pass a
+ // callback in nsOpenWindowInfo and it will be invoked when the browsing
+ // context for a newly opened window is ready.
+ browsingContext = null;
+ } catch (ex) {
+ console.error(ex);
+ }
+ break;
+ case Ci.nsIBrowserDOMWindow.OPEN_NEWTAB: {
+ // If we have an opener, that means that the caller is expecting access
+ // to the nsIDOMWindow of the opened tab right away. For e10s windows,
+ // this means forcing the newly opened browser to be non-remote so that
+ // we can hand back the nsIDOMWindow. DocumentLoadListener will do the
+ // job of shuttling off the newly opened browser to run in the right
+ // process once it starts loading a URI.
+ let forceNotRemote = aOpenWindowInfo && !aOpenWindowInfo.isRemote;
+ let userContextId = aOpenWindowInfo
+ ? aOpenWindowInfo.originAttributes.userContextId
+ : Ci.nsIScriptSecurityManager.DEFAULT_USER_CONTEXT_ID;
+ let browser = this._openURIInNewTab(
+ aURI,
+ referrerInfo,
+ isPrivate,
+ isExternal,
+ forceNotRemote,
+ userContextId,
+ aOpenWindowInfo,
+ aOpenWindowInfo?.parent?.top.embedderElement,
+ aTriggeringPrincipal,
+ "",
+ aCsp,
+ aSkipLoad
+ );
+ if (browser) {
+ browsingContext = browser.browsingContext;
+ }
+ break;
+ }
+ case Ci.nsIBrowserDOMWindow.OPEN_PRINT_BROWSER: {
+ let browser =
+ PrintUtils.handleStaticCloneCreatedForPrint(aOpenWindowInfo);
+ if (browser) {
+ browsingContext = browser.browsingContext;
+ }
+ break;
+ }
+ default:
+ // OPEN_CURRENTWINDOW or an illegal value
+ browsingContext = window.gBrowser.selectedBrowser.browsingContext;
+ if (aURI) {
+ let loadFlags = Ci.nsIWebNavigation.LOAD_FLAGS_NONE;
+ if (isExternal) {
+ loadFlags |= Ci.nsIWebNavigation.LOAD_FLAGS_FROM_EXTERNAL;
+ } else if (!aTriggeringPrincipal.isSystemPrincipal) {
+ // XXX this code must be reviewed and changed when bug 1616353
+ // lands.
+ loadFlags |= Ci.nsIWebNavigation.LOAD_FLAGS_FIRST_LOAD;
+ }
+ // This should ideally be able to call loadURI with the actual URI.
+ // However, that would bypass some styles of fixup (notably Windows
+ // paths passed as "URI"s), so this needs some further thought. It
+ // should be addressed in bug 1815509.
+ gBrowser.fixupAndLoadURIString(aURI.spec, {
+ triggeringPrincipal: aTriggeringPrincipal,
+ csp: aCsp,
+ loadFlags,
+ referrerInfo,
+ });
+ }
+ if (
+ !Services.prefs.getBoolPref("browser.tabs.loadDivertedInBackground")
+ ) {
+ window.focus();
+ }
+ }
+ return browsingContext;
+ },
+
+ createContentWindowInFrame: function browser_createContentWindowInFrame(
+ aURI,
+ aParams,
+ aWhere,
+ aFlags,
+ aName
+ ) {
+ // Passing a null-URI to only create the content window,
+ // and pass true for aSkipLoad to prevent loading of
+ // about:blank
+ return this.getContentWindowOrOpenURIInFrame(
+ null,
+ aParams,
+ aWhere,
+ aFlags,
+ aName,
+ true
+ );
+ },
+
+ openURIInFrame: function browser_openURIInFrame(
+ aURI,
+ aParams,
+ aWhere,
+ aFlags,
+ aName
+ ) {
+ return this.getContentWindowOrOpenURIInFrame(
+ aURI,
+ aParams,
+ aWhere,
+ aFlags,
+ aName,
+ false
+ );
+ },
+
+ getContentWindowOrOpenURIInFrame:
+ function browser_getContentWindowOrOpenURIInFrame(
+ aURI,
+ aParams,
+ aWhere,
+ aFlags,
+ aName,
+ aSkipLoad
+ ) {
+ if (aWhere == Ci.nsIBrowserDOMWindow.OPEN_PRINT_BROWSER) {
+ return PrintUtils.handleStaticCloneCreatedForPrint(
+ aParams.openWindowInfo
+ );
+ }
+
+ if (aWhere != Ci.nsIBrowserDOMWindow.OPEN_NEWTAB) {
+ dump("Error: openURIInFrame can only open in new tabs or print");
+ return null;
+ }
+
+ var isExternal = !!(aFlags & Ci.nsIBrowserDOMWindow.OPEN_EXTERNAL);
+
+ var userContextId =
+ aParams.openerOriginAttributes &&
+ "userContextId" in aParams.openerOriginAttributes
+ ? aParams.openerOriginAttributes.userContextId
+ : Ci.nsIScriptSecurityManager.DEFAULT_USER_CONTEXT_ID;
+
+ return this._openURIInNewTab(
+ aURI,
+ aParams.referrerInfo,
+ aParams.isPrivate,
+ isExternal,
+ false,
+ userContextId,
+ aParams.openWindowInfo,
+ aParams.openerBrowser,
+ aParams.triggeringPrincipal,
+ aName,
+ aParams.csp,
+ aSkipLoad
+ );
+ },
+
+ canClose() {
+ return CanCloseWindow();
+ },
+
+ get tabCount() {
+ return gBrowser.tabs.length;
+ },
+};
+
+function showFullScreenViewContextMenuItems(popup) {
+ for (let node of popup.querySelectorAll('[contexttype="fullscreen"]')) {
+ node.hidden = !window.fullScreen;
+ }
+ let autoHide = popup.querySelector(".fullscreen-context-autohide");
+ if (autoHide) {
+ FullScreen.updateAutohideMenuitem(autoHide);
+ }
+}
+
+function onViewToolbarsPopupShowing(aEvent, aInsertPoint) {
+ var popup = aEvent.target;
+ if (popup != aEvent.currentTarget) {
+ return;
+ }
+
+ // Empty the menu
+ for (var i = popup.children.length - 1; i >= 0; --i) {
+ var deadItem = popup.children[i];
+ if (deadItem.hasAttribute("toolbarId")) {
+ popup.removeChild(deadItem);
+ }
+ }
+
+ MozXULElement.insertFTLIfNeeded("browser/toolbarContextMenu.ftl");
+ let firstMenuItem = aInsertPoint || popup.firstElementChild;
+ let toolbarNodes = gNavToolbox.querySelectorAll("toolbar");
+ for (let toolbar of toolbarNodes) {
+ if (!toolbar.hasAttribute("toolbarname")) {
+ continue;
+ }
+
+ if (toolbar.id == "PersonalToolbar") {
+ let menu = BookmarkingUI.buildBookmarksToolbarSubmenu(toolbar);
+ popup.insertBefore(menu, firstMenuItem);
+ } else {
+ let menuItem = document.createXULElement("menuitem");
+ menuItem.setAttribute("id", "toggle_" + toolbar.id);
+ menuItem.setAttribute("toolbarId", toolbar.id);
+ menuItem.setAttribute("type", "checkbox");
+ menuItem.setAttribute("label", toolbar.getAttribute("toolbarname"));
+ let hidingAttribute =
+ toolbar.getAttribute("type") == "menubar" ? "autohide" : "collapsed";
+ menuItem.setAttribute(
+ "checked",
+ toolbar.getAttribute(hidingAttribute) != "true"
+ );
+ menuItem.setAttribute("accesskey", toolbar.getAttribute("accesskey"));
+ if (popup.id != "toolbar-context-menu") {
+ menuItem.setAttribute("key", toolbar.getAttribute("key"));
+ }
+
+ popup.insertBefore(menuItem, firstMenuItem);
+ menuItem.addEventListener("command", onViewToolbarCommand);
+ }
+ }
+
+ let moveToPanel = popup.querySelector(".customize-context-moveToPanel");
+ let removeFromToolbar = popup.querySelector(
+ ".customize-context-removeFromToolbar"
+ );
+ // Show/hide fullscreen context menu items and set the
+ // autohide item's checked state to mirror the autohide pref.
+ showFullScreenViewContextMenuItems(popup);
+ // View -> Toolbars menu doesn't have the moveToPanel or removeFromToolbar items.
+ if (!moveToPanel || !removeFromToolbar) {
+ return;
+ }
+
+ // triggerNode can be a nested child element of a toolbaritem.
+ let toolbarItem = popup.triggerNode;
+
+ if (toolbarItem && toolbarItem.localName == "toolbarpaletteitem") {
+ toolbarItem = toolbarItem.firstElementChild;
+ } else if (toolbarItem && toolbarItem.localName != "toolbar") {
+ while (toolbarItem && toolbarItem.parentElement) {
+ let parent = toolbarItem.parentElement;
+ if (
+ (parent.classList &&
+ parent.classList.contains("customization-target")) ||
+ parent.getAttribute("overflowfortoolbar") || // Needs to work in the overflow list as well.
+ parent.localName == "toolbarpaletteitem" ||
+ parent.localName == "toolbar"
+ ) {
+ break;
+ }
+ toolbarItem = parent;
+ }
+ } else {
+ toolbarItem = null;
+ }
+
+ let showTabStripItems = toolbarItem && toolbarItem.id == "tabbrowser-tabs";
+ for (let node of popup.querySelectorAll(
+ 'menuitem[contexttype="toolbaritem"]'
+ )) {
+ node.hidden = showTabStripItems;
+ }
+
+ for (let node of popup.querySelectorAll('menuitem[contexttype="tabbar"]')) {
+ node.hidden = !showTabStripItems;
+ }
+
+ document
+ .getElementById("toolbar-context-menu")
+ .querySelectorAll("[data-lazy-l10n-id]")
+ .forEach(el => {
+ el.setAttribute("data-l10n-id", el.getAttribute("data-lazy-l10n-id"));
+ el.removeAttribute("data-lazy-l10n-id");
+ });
+
+ // The "normal" toolbar items menu separator is hidden because it's unused
+ // when hiding the "moveToPanel" and "removeFromToolbar" items on flexible
+ // space items. But we need to ensure its hidden state is reset in the case
+ // the context menu is subsequently opened on a non-flexible space item.
+ let menuSeparator = document.getElementById("toolbarItemsMenuSeparator");
+ menuSeparator.hidden = false;
+
+ document.getElementById("toolbarNavigatorItemsMenuSeparator").hidden =
+ !showTabStripItems;
+
+ if (
+ !CustomizationHandler.isCustomizing() &&
+ CustomizableUI.isSpecialWidget(toolbarItem?.id || "")
+ ) {
+ moveToPanel.hidden = true;
+ removeFromToolbar.hidden = true;
+ menuSeparator.hidden = !showTabStripItems;
+ }
+
+ if (showTabStripItems) {
+ let multipleTabsSelected = !!gBrowser.multiSelectedTabsCount;
+ document.getElementById("toolbar-context-bookmarkSelectedTabs").hidden =
+ !multipleTabsSelected;
+ document.getElementById("toolbar-context-bookmarkSelectedTab").hidden =
+ multipleTabsSelected;
+ document.getElementById("toolbar-context-reloadSelectedTabs").hidden =
+ !multipleTabsSelected;
+ document.getElementById("toolbar-context-reloadSelectedTab").hidden =
+ multipleTabsSelected;
+ document.getElementById("toolbar-context-selectAllTabs").disabled =
+ gBrowser.allTabsSelected();
+ document.getElementById("toolbar-context-undoCloseTab").disabled =
+ SessionStore.getClosedTabCountForWindow(window) == 0;
+ return;
+ }
+
+ let movable =
+ toolbarItem &&
+ toolbarItem.id &&
+ CustomizableUI.isWidgetRemovable(toolbarItem);
+ if (movable) {
+ if (CustomizableUI.isSpecialWidget(toolbarItem.id)) {
+ moveToPanel.setAttribute("disabled", true);
+ } else {
+ moveToPanel.removeAttribute("disabled");
+ }
+ removeFromToolbar.removeAttribute("disabled");
+ } else {
+ moveToPanel.setAttribute("disabled", true);
+ removeFromToolbar.setAttribute("disabled", true);
+ }
+}
+
+function onViewToolbarCommand(aEvent) {
+ let node = aEvent.originalTarget;
+ let menuId;
+ let toolbarId;
+ let isVisible;
+ if (node.dataset.bookmarksToolbarVisibility) {
+ isVisible = node.dataset.visibilityEnum;
+ toolbarId = "PersonalToolbar";
+ menuId = node.parentNode.parentNode.parentNode.id;
+ Services.prefs.setCharPref(
+ "browser.toolbars.bookmarks.visibility",
+ isVisible
+ );
+ } else {
+ menuId = node.parentNode.id;
+ toolbarId = node.getAttribute("toolbarId");
+ isVisible = node.getAttribute("checked") == "true";
+ }
+ CustomizableUI.setToolbarVisibility(toolbarId, isVisible);
+ BrowserUsageTelemetry.recordToolbarVisibility(toolbarId, isVisible, menuId);
+}
+
+function setToolbarVisibility(
+ toolbar,
+ isVisible,
+ persist = true,
+ animated = true
+) {
+ let hidingAttribute;
+ if (toolbar.getAttribute("type") == "menubar") {
+ hidingAttribute = "autohide";
+ if (AppConstants.platform == "linux") {
+ Services.prefs.setBoolPref("ui.key.menuAccessKeyFocuses", !isVisible);
+ }
+ } else {
+ hidingAttribute = "collapsed";
+ }
+
+ // For the bookmarks toolbar, we need to persist state before toggling
+ // the visibility in this window, because the state can be different
+ // (newtab vs never or always) even when that won't change visibility
+ // in this window.
+ if (persist && toolbar.id == "PersonalToolbar") {
+ let prefValue;
+ if (typeof isVisible == "string") {
+ prefValue = isVisible;
+ } else {
+ prefValue = isVisible ? "always" : "never";
+ }
+ Services.prefs.setCharPref(
+ "browser.toolbars.bookmarks.visibility",
+ prefValue
+ );
+ }
+
+ if (typeof isVisible == "string") {
+ switch (isVisible) {
+ case "always":
+ isVisible = true;
+ break;
+ case "never":
+ isVisible = false;
+ break;
+ case "newtab":
+ let currentURI = gBrowser?.currentURI;
+ if (!gBrowserInit.domContentLoaded) {
+ let uriToLoad = gBrowserInit.uriToLoadPromise;
+ if (uriToLoad) {
+ if (Array.isArray(uriToLoad)) {
+ // We only care about the first tab being loaded
+ uriToLoad = uriToLoad[0];
+ }
+ try {
+ currentURI = Services.io.newURI(uriToLoad);
+ } catch (ex) {}
+ }
+ }
+ isVisible =
+ !!currentURI &&
+ (BookmarkingUI.isOnNewTabPage({ currentURI }) ||
+ currentURI?.spec == "chrome://browser/content/blanktab.html");
+ break;
+ }
+ }
+
+ if (toolbar.getAttribute(hidingAttribute) == (!isVisible).toString()) {
+ // If this call will not result in a visibility change, return early
+ // since dispatching toolbarvisibilitychange will cause views to get rebuilt.
+ return;
+ }
+
+ toolbar.classList.toggle("instant", !animated);
+ toolbar.setAttribute(hidingAttribute, !isVisible);
+ // For the bookmarks toolbar, we will have saved state above. For other
+ // toolbars, we need to do it after setting the attribute, or we might
+ // save the wrong state.
+ if (persist && toolbar.id != "PersonalToolbar") {
+ Services.xulStore.persist(toolbar, hidingAttribute);
+ }
+
+ let eventParams = {
+ detail: {
+ visible: isVisible,
+ },
+ bubbles: true,
+ };
+ let event = new CustomEvent("toolbarvisibilitychange", eventParams);
+ toolbar.dispatchEvent(event);
+}
+
+function updateToggleControlLabel(control) {
+ if (!control.hasAttribute("label-checked")) {
+ return;
+ }
+
+ if (!control.hasAttribute("label-unchecked")) {
+ control.setAttribute("label-unchecked", control.getAttribute("label"));
+ }
+ let prefix = control.getAttribute("checked") == "true" ? "" : "un";
+ control.setAttribute("label", control.getAttribute(`label-${prefix}checked`));
+}
+
+var TabletModeUpdater = {
+ init() {
+ if (AppConstants.isPlatformAndVersionAtLeast("win", "10")) {
+ this.update(WindowsUIUtils.inTabletMode);
+ Services.obs.addObserver(this, "tablet-mode-change");
+ }
+ },
+
+ uninit() {
+ if (AppConstants.isPlatformAndVersionAtLeast("win", "10")) {
+ Services.obs.removeObserver(this, "tablet-mode-change");
+ }
+ },
+
+ observe(subject, topic, data) {
+ this.update(data == "tablet-mode");
+ },
+
+ update(isInTabletMode) {
+ let wasInTabletMode = document.documentElement.hasAttribute("tabletmode");
+ if (isInTabletMode) {
+ document.documentElement.setAttribute("tabletmode", "true");
+ } else {
+ document.documentElement.removeAttribute("tabletmode");
+ }
+ if (wasInTabletMode != isInTabletMode) {
+ gUIDensity.update();
+ }
+ },
+};
+
+var gTabletModePageCounter = {
+ enabled: false,
+ inc() {
+ this.enabled = AppConstants.isPlatformAndVersionAtLeast("win", "10.0");
+ if (!this.enabled) {
+ this.inc = () => {};
+ return;
+ }
+ this.inc = this._realInc;
+ this.inc();
+ },
+
+ _desktopCount: 0,
+ _tabletCount: 0,
+ _realInc() {
+ let inTabletMode = document.documentElement.hasAttribute("tabletmode");
+ this[inTabletMode ? "_tabletCount" : "_desktopCount"]++;
+ },
+
+ finish() {
+ if (this.enabled) {
+ let histogram = Services.telemetry.getKeyedHistogramById(
+ "FX_TABLETMODE_PAGE_LOAD"
+ );
+ histogram.add("tablet", this._tabletCount);
+ histogram.add("desktop", this._desktopCount);
+ }
+ },
+};
+
+function displaySecurityInfo() {
+ BrowserPageInfo(null, "securityTab");
+}
+
+// Updates the UI density (for touch and compact mode) based on the uidensity pref.
+var gUIDensity = {
+ MODE_NORMAL: 0,
+ MODE_COMPACT: 1,
+ MODE_TOUCH: 2,
+ uiDensityPref: "browser.uidensity",
+ autoTouchModePref: "browser.touchmode.auto",
+
+ init() {
+ this.update();
+ Services.prefs.addObserver(this.uiDensityPref, this);
+ Services.prefs.addObserver(this.autoTouchModePref, this);
+ },
+
+ uninit() {
+ Services.prefs.removeObserver(this.uiDensityPref, this);
+ Services.prefs.removeObserver(this.autoTouchModePref, this);
+ },
+
+ observe(aSubject, aTopic, aPrefName) {
+ if (
+ aTopic != "nsPref:changed" ||
+ (aPrefName != this.uiDensityPref && aPrefName != this.autoTouchModePref)
+ ) {
+ return;
+ }
+
+ this.update();
+ },
+
+ getCurrentDensity() {
+ // Automatically override the uidensity to touch in Windows tablet mode.
+ if (
+ AppConstants.isPlatformAndVersionAtLeast("win", "10") &&
+ WindowsUIUtils.inTabletMode &&
+ Services.prefs.getBoolPref(this.autoTouchModePref)
+ ) {
+ return { mode: this.MODE_TOUCH, overridden: true };
+ }
+ return {
+ mode: Services.prefs.getIntPref(this.uiDensityPref),
+ overridden: false,
+ };
+ },
+
+ update(mode) {
+ if (mode == null) {
+ mode = this.getCurrentDensity().mode;
+ }
+
+ let docs = [document.documentElement];
+ let shouldUpdateSidebar = SidebarUI.initialized && SidebarUI.isOpen;
+ if (shouldUpdateSidebar) {
+ docs.push(SidebarUI.browser.contentDocument.documentElement);
+ }
+ for (let doc of docs) {
+ switch (mode) {
+ case this.MODE_COMPACT:
+ doc.setAttribute("uidensity", "compact");
+ break;
+ case this.MODE_TOUCH:
+ doc.setAttribute("uidensity", "touch");
+ break;
+ default:
+ doc.removeAttribute("uidensity");
+ break;
+ }
+ }
+ if (shouldUpdateSidebar) {
+ let tree = SidebarUI.browser.contentDocument.querySelector(
+ ".sidebar-placesTree"
+ );
+ if (tree) {
+ // Tree items don't update their styles without changing some property on the
+ // parent tree element, like background-color or border. See bug 1407399.
+ tree.style.border = "1px";
+ tree.style.border = "";
+ }
+ }
+
+ gBrowser.tabContainer.uiDensityChanged();
+ gURLBar.updateLayoutBreakout();
+ },
+};
+
+const nodeToTooltipMap = {
+ "bookmarks-menu-button": "bookmarksMenuButton.tooltip",
+ "context-reload": "reloadButton.tooltip",
+ "context-stop": "stopButton.tooltip",
+ "downloads-button": "downloads.tooltip",
+ "fullscreen-button": "fullscreenButton.tooltip",
+ "appMenu-fullscreen-button2": "fullscreenButton.tooltip",
+ "new-window-button": "newWindowButton.tooltip",
+ "new-tab-button": "newTabButton.tooltip",
+ "tabs-newtab-button": "newTabButton.tooltip",
+ "reload-button": "reloadButton.tooltip",
+ "stop-button": "stopButton.tooltip",
+ "urlbar-zoom-button": "urlbar-zoom-button.tooltip",
+ "appMenu-zoomEnlarge-button2": "zoomEnlarge-button.tooltip",
+ "appMenu-zoomReset-button2": "zoomReset-button.tooltip",
+ "appMenu-zoomReduce-button2": "zoomReduce-button.tooltip",
+ "reader-mode-button": "reader-mode-button.tooltip",
+ "reader-mode-button-icon": "reader-mode-button.tooltip",
+};
+const nodeToShortcutMap = {
+ "bookmarks-menu-button": "manBookmarkKb",
+ "context-reload": "key_reload",
+ "context-stop": "key_stop",
+ "downloads-button": "key_openDownloads",
+ "fullscreen-button": "key_enterFullScreen",
+ "appMenu-fullscreen-button2": "key_enterFullScreen",
+ "new-window-button": "key_newNavigator",
+ "new-tab-button": "key_newNavigatorTab",
+ "tabs-newtab-button": "key_newNavigatorTab",
+ "reload-button": "key_reload",
+ "stop-button": "key_stop",
+ "urlbar-zoom-button": "key_fullZoomReset",
+ "appMenu-zoomEnlarge-button2": "key_fullZoomEnlarge",
+ "appMenu-zoomReset-button2": "key_fullZoomReset",
+ "appMenu-zoomReduce-button2": "key_fullZoomReduce",
+ "reader-mode-button": "key_toggleReaderMode",
+ "reader-mode-button-icon": "key_toggleReaderMode",
+};
+
+const gDynamicTooltipCache = new Map();
+function GetDynamicShortcutTooltipText(nodeId) {
+ if (!gDynamicTooltipCache.has(nodeId) && nodeId in nodeToTooltipMap) {
+ let strId = nodeToTooltipMap[nodeId];
+ let args = [];
+ if (nodeId in nodeToShortcutMap) {
+ let shortcutId = nodeToShortcutMap[nodeId];
+ let shortcut = document.getElementById(shortcutId);
+ if (shortcut) {
+ args.push(ShortcutUtils.prettifyShortcut(shortcut));
+ }
+ }
+ gDynamicTooltipCache.set(
+ nodeId,
+ gNavigatorBundle.getFormattedString(strId, args)
+ );
+ }
+ return gDynamicTooltipCache.get(nodeId);
+}
+
+function UpdateDynamicShortcutTooltipText(aTooltip) {
+ let nodeId =
+ aTooltip.triggerNode.id || aTooltip.triggerNode.getAttribute("anonid");
+ aTooltip.setAttribute("label", GetDynamicShortcutTooltipText(nodeId));
+}
+
+/*
+ * - [ Dependencies ] ---------------------------------------------------------
+ * utilityOverlay.js:
+ * - gatherTextUnder
+ */
+
+/**
+ * Extracts linkNode and href for the current click target.
+ *
+ * @param event
+ * The click event.
+ * @return [href, linkNode].
+ *
+ * @note linkNode will be null if the click wasn't on an anchor
+ * element (or XLink).
+ */
+function hrefAndLinkNodeForClickEvent(event) {
+ function isHTMLLink(aNode) {
+ // Be consistent with what nsContextMenu.js does.
+ return (
+ (HTMLAnchorElement.isInstance(aNode) && aNode.href) ||
+ (HTMLAreaElement.isInstance(aNode) && aNode.href) ||
+ HTMLLinkElement.isInstance(aNode)
+ );
+ }
+
+ let node = event.composedTarget;
+ while (node && !isHTMLLink(node)) {
+ node = node.flattenedTreeParentNode;
+ }
+
+ if (node) {
+ return [node.href, node];
+ }
+
+ // If there is no linkNode, try simple XLink.
+ let href, baseURI;
+ node = event.composedTarget;
+ while (node && !href) {
+ if (
+ node.nodeType == Node.ELEMENT_NODE &&
+ (node.localName == "a" ||
+ node.namespaceURI == "http://www.w3.org/1998/Math/MathML")
+ ) {
+ href =
+ node.getAttribute("href") ||
+ node.getAttributeNS("http://www.w3.org/1999/xlink", "href");
+
+ if (href) {
+ baseURI = node.baseURI;
+ break;
+ }
+ }
+ node = node.flattenedTreeParentNode;
+ }
+
+ // In case of XLink, we don't return the node we got href from since
+ // callers expect <a>-like elements.
+ return [href ? makeURLAbsolute(baseURI, href) : null, null];
+}
+
+/**
+ * Called whenever the user clicks in the content area.
+ *
+ * @param event
+ * The click event.
+ * @param isPanelClick
+ * Whether the event comes from an extension panel.
+ * @note default event is prevented if the click is handled.
+ */
+function contentAreaClick(event, isPanelClick) {
+ if (!event.isTrusted || event.defaultPrevented || event.button != 0) {
+ return;
+ }
+
+ let [href, linkNode] = hrefAndLinkNodeForClickEvent(event);
+ if (!href) {
+ // Not a link, handle middle mouse navigation.
+ if (
+ event.button == 1 &&
+ Services.prefs.getBoolPref("middlemouse.contentLoadURL") &&
+ !Services.prefs.getBoolPref("general.autoScroll")
+ ) {
+ middleMousePaste(event);
+ event.preventDefault();
+ }
+ return;
+ }
+
+ // This code only applies if we have a linkNode (i.e. clicks on real anchor
+ // elements, as opposed to XLink).
+ if (
+ linkNode &&
+ event.button == 0 &&
+ !event.ctrlKey &&
+ !event.shiftKey &&
+ !event.altKey &&
+ !event.metaKey
+ ) {
+ // An extension panel's links should target the main content area. Do this
+ // if no modifier keys are down and if there's no target or the target
+ // equals _main (the IE convention) or _content (the Mozilla convention).
+ let target = linkNode.target;
+ let mainTarget = !target || target == "_content" || target == "_main";
+ if (isPanelClick && mainTarget) {
+ // javascript and data links should be executed in the current browser.
+ if (
+ linkNode.getAttribute("onclick") ||
+ href.startsWith("javascript:") ||
+ href.startsWith("data:")
+ ) {
+ return;
+ }
+
+ try {
+ urlSecurityCheck(href, linkNode.ownerDocument.nodePrincipal);
+ } catch (ex) {
+ // Prevent loading unsecure destinations.
+ event.preventDefault();
+ return;
+ }
+
+ openLinkIn(href, "current", {
+ allowThirdPartyFixup: false,
+ });
+ event.preventDefault();
+ return;
+ }
+ }
+
+ handleLinkClick(event, href, linkNode);
+
+ // Mark the page as a user followed link. This is done so that history can
+ // distinguish automatic embed visits from user activated ones. For example
+ // pages loaded in frames are embed visits and lost with the session, while
+ // visits across frames should be preserved.
+ try {
+ if (!PrivateBrowsingUtils.isWindowPrivate(window)) {
+ PlacesUIUtils.markPageAsFollowedLink(href);
+ }
+ } catch (ex) {
+ /* Skip invalid URIs. */
+ }
+}
+
+/**
+ * Handles clicks on links.
+ *
+ * @return true if the click event was handled, false otherwise.
+ */
+function handleLinkClick(event, href, linkNode) {
+ if (event.button == 2) {
+ // right click
+ return false;
+ }
+
+ var where = whereToOpenLink(event);
+ if (where == "current") {
+ return false;
+ }
+
+ var doc = event.target.ownerDocument;
+ let referrerInfo = Cc["@mozilla.org/referrer-info;1"].createInstance(
+ Ci.nsIReferrerInfo
+ );
+ if (linkNode) {
+ referrerInfo.initWithElement(linkNode);
+ } else {
+ referrerInfo.initWithDocument(doc);
+ }
+
+ if (where == "save") {
+ saveURL(
+ href,
+ null,
+ linkNode ? gatherTextUnder(linkNode) : "",
+ null,
+ true,
+ true,
+ referrerInfo,
+ doc.cookieJarSettings,
+ doc
+ );
+ event.preventDefault();
+ return true;
+ }
+
+ let frameID = WebNavigationFrames.getFrameId(doc.defaultView);
+
+ urlSecurityCheck(href, doc.nodePrincipal);
+ let params = {
+ charset: doc.characterSet,
+ referrerInfo,
+ originPrincipal: doc.nodePrincipal,
+ originStoragePrincipal: doc.effectiveStoragePrincipal,
+ triggeringPrincipal: doc.nodePrincipal,
+ csp: doc.csp,
+ frameID,
+ };
+
+ // The new tab/window must use the same userContextId
+ if (doc.nodePrincipal.originAttributes.userContextId) {
+ params.userContextId = doc.nodePrincipal.originAttributes.userContextId;
+ }
+
+ openLinkIn(href, where, params);
+ event.preventDefault();
+ return true;
+}
+
+/**
+ * Handles paste on middle mouse clicks.
+ *
+ * @param event {Event | Object} Event or JSON object.
+ */
+function middleMousePaste(event) {
+ let clipboard = readFromClipboard();
+ if (!clipboard) {
+ return;
+ }
+
+ // Strip embedded newlines and surrounding whitespace, to match the URL
+ // bar's behavior (stripsurroundingwhitespace)
+ clipboard = clipboard.replace(/\s*\n\s*/g, "");
+
+ clipboard = UrlbarUtils.stripUnsafeProtocolOnPaste(clipboard);
+
+ // if it's not the current tab, we don't need to do anything because the
+ // browser doesn't exist.
+ let where = whereToOpenLink(event, true, false);
+ let lastLocationChange;
+ if (where == "current") {
+ lastLocationChange = gBrowser.selectedBrowser.lastLocationChange;
+ }
+
+ UrlbarUtils.getShortcutOrURIAndPostData(clipboard).then(data => {
+ try {
+ makeURI(data.url);
+ } catch (ex) {
+ // Not a valid URI.
+ return;
+ }
+
+ try {
+ UrlbarUtils.addToUrlbarHistory(data.url, window);
+ } catch (ex) {
+ // Things may go wrong when adding url to session history,
+ // but don't let that interfere with the loading of the url.
+ console.error(ex);
+ }
+
+ if (
+ where != "current" ||
+ lastLocationChange == gBrowser.selectedBrowser.lastLocationChange
+ ) {
+ openUILink(data.url, event, {
+ ignoreButton: true,
+ allowInheritPrincipal: data.mayInheritPrincipal,
+ triggeringPrincipal: gBrowser.selectedBrowser.contentPrincipal,
+ csp: gBrowser.selectedBrowser.csp,
+ });
+ }
+ });
+
+ if (Event.isInstance(event)) {
+ event.stopPropagation();
+ }
+}
+
+// handleDroppedLink has the following 2 overloads:
+// handleDroppedLink(event, url, name, triggeringPrincipal)
+// handleDroppedLink(event, links, triggeringPrincipal)
+function handleDroppedLink(
+ event,
+ urlOrLinks,
+ nameOrTriggeringPrincipal,
+ triggeringPrincipal
+) {
+ let links;
+ if (Array.isArray(urlOrLinks)) {
+ links = urlOrLinks;
+ triggeringPrincipal = nameOrTriggeringPrincipal;
+ } else {
+ links = [{ url: urlOrLinks, nameOrTriggeringPrincipal, type: "" }];
+ }
+
+ let lastLocationChange = gBrowser.selectedBrowser.lastLocationChange;
+
+ let userContextId = gBrowser.selectedBrowser.getAttribute("usercontextid");
+
+ // event is null if links are dropped in content process.
+ // inBackground should be false, as it's loading into current browser.
+ let inBackground = false;
+ if (event) {
+ inBackground = Services.prefs.getBoolPref("browser.tabs.loadInBackground");
+ if (event.shiftKey) {
+ inBackground = !inBackground;
+ }
+ }
+
+ (async function () {
+ if (
+ links.length >=
+ Services.prefs.getIntPref("browser.tabs.maxOpenBeforeWarn")
+ ) {
+ // Sync dialog cannot be used inside drop event handler.
+ let answer = await OpenInTabsUtils.promiseConfirmOpenInTabs(
+ links.length,
+ window
+ );
+ if (!answer) {
+ return;
+ }
+ }
+
+ let urls = [];
+ let postDatas = [];
+ for (let link of links) {
+ let data = await UrlbarUtils.getShortcutOrURIAndPostData(link.url);
+ urls.push(data.url);
+ postDatas.push(data.postData);
+ }
+ if (lastLocationChange == gBrowser.selectedBrowser.lastLocationChange) {
+ gBrowser.loadTabs(urls, {
+ inBackground,
+ replace: true,
+ allowThirdPartyFixup: false,
+ postDatas,
+ userContextId,
+ triggeringPrincipal,
+ });
+ }
+ })();
+
+ // If links are dropped in content process, event.preventDefault() should be
+ // called in content process.
+ if (event) {
+ // Keep the event from being handled by the dragDrop listeners
+ // built-in to gecko if they happen to be above us.
+ event.preventDefault();
+ }
+}
+
+function BrowserForceEncodingDetection() {
+ gBrowser.selectedBrowser.forceEncodingDetection();
+ BrowserReloadWithFlags(Ci.nsIWebNavigation.LOAD_FLAGS_CHARSET_CHANGE);
+}
+
+var ToolbarContextMenu = {
+ updateDownloadsAutoHide(popup) {
+ let checkbox = document.getElementById(
+ "toolbar-context-autohide-downloads-button"
+ );
+ let isDownloads =
+ popup.triggerNode &&
+ ["downloads-button", "wrapper-downloads-button"].includes(
+ popup.triggerNode.id
+ );
+ checkbox.hidden = !isDownloads;
+ if (DownloadsButton.autoHideDownloadsButton) {
+ checkbox.setAttribute("checked", "true");
+ } else {
+ checkbox.removeAttribute("checked");
+ }
+ },
+
+ onDownloadsAutoHideChange(event) {
+ let autoHide = event.target.getAttribute("checked") == "true";
+ Services.prefs.setBoolPref("browser.download.autohideButton", autoHide);
+ },
+
+ updateDownloadsAlwaysOpenPanel(popup) {
+ let separator = document.getElementById(
+ "toolbarDownloadsAnchorMenuSeparator"
+ );
+ let checkbox = document.getElementById(
+ "toolbar-context-always-open-downloads-panel"
+ );
+ let isDownloads =
+ popup.triggerNode &&
+ ["downloads-button", "wrapper-downloads-button"].includes(
+ popup.triggerNode.id
+ );
+ separator.hidden = checkbox.hidden = !isDownloads;
+ gAlwaysOpenPanel
+ ? checkbox.setAttribute("checked", "true")
+ : checkbox.removeAttribute("checked");
+ },
+
+ onDownloadsAlwaysOpenPanelChange(event) {
+ let alwaysOpen = event.target.getAttribute("checked") == "true";
+ Services.prefs.setBoolPref("browser.download.alwaysOpenPanel", alwaysOpen);
+ },
+
+ _getUnwrappedTriggerNode(popup) {
+ // Toolbar buttons are wrapped in customize mode. Unwrap if necessary.
+ let { triggerNode } = popup;
+ if (triggerNode && gCustomizeMode.isWrappedToolbarItem(triggerNode)) {
+ return triggerNode.firstElementChild;
+ }
+ return triggerNode;
+ },
+
+ _getExtensionId(popup) {
+ let node = this._getUnwrappedTriggerNode(popup);
+ return node && node.getAttribute("data-extensionid");
+ },
+
+ _getWidgetId(popup) {
+ let node = this._getUnwrappedTriggerNode(popup);
+ return node?.closest(".unified-extensions-item")?.id;
+ },
+
+ async updateExtension(popup, event) {
+ let removeExtension = popup.querySelector(
+ ".customize-context-removeExtension"
+ );
+ let manageExtension = popup.querySelector(
+ ".customize-context-manageExtension"
+ );
+ let reportExtension = popup.querySelector(
+ ".customize-context-reportExtension"
+ );
+ let pinToToolbar = popup.querySelector(".customize-context-pinToToolbar");
+ let separator = reportExtension.nextElementSibling;
+ let id = this._getExtensionId(popup);
+ let addon = id && (await AddonManager.getAddonByID(id));
+
+ for (let element of [removeExtension, manageExtension, separator]) {
+ element.hidden = !addon;
+ }
+
+ if (pinToToolbar) {
+ pinToToolbar.hidden = !addon;
+ }
+
+ reportExtension.hidden = !addon || !gAddonAbuseReportEnabled;
+
+ if (addon) {
+ popup.querySelector(".customize-context-moveToPanel").hidden = true;
+ popup.querySelector(".customize-context-removeFromToolbar").hidden = true;
+
+ if (pinToToolbar) {
+ let widgetId = this._getWidgetId(popup);
+ if (widgetId) {
+ let area = CustomizableUI.getPlacementOfWidget(widgetId).area;
+ let inToolbar = area != CustomizableUI.AREA_ADDONS;
+ pinToToolbar.setAttribute("checked", inToolbar);
+ }
+ }
+
+ removeExtension.disabled = !(
+ addon.permissions & AddonManager.PERM_CAN_UNINSTALL
+ );
+
+ if (event?.target?.id === "toolbar-context-menu") {
+ ExtensionsUI.originControlsMenu(popup, id);
+ }
+ }
+ },
+
+ async removeExtensionForContextAction(popup) {
+ let id = this._getExtensionId(popup);
+ await BrowserAddonUI.removeAddon(id, "browserAction");
+ },
+
+ async reportExtensionForContextAction(popup, reportEntryPoint) {
+ let id = this._getExtensionId(popup);
+ await BrowserAddonUI.reportAddon(id, reportEntryPoint);
+ },
+
+ async openAboutAddonsForContextAction(popup) {
+ let id = this._getExtensionId(popup);
+ await BrowserAddonUI.manageAddon(id, "browserAction");
+ },
+};
+
+// Note that this is also called from non-browser windows on OSX, which do
+// share menu items but not much else. See nonbrowser-mac.js.
+var BrowserOffline = {
+ _inited: false,
+
+ // BrowserOffline Public Methods
+ init() {
+ if (!this._uiElement) {
+ this._uiElement = document.getElementById("cmd_toggleOfflineStatus");
+ }
+
+ Services.obs.addObserver(this, "network:offline-status-changed");
+
+ this._updateOfflineUI(Services.io.offline);
+
+ this._inited = true;
+ },
+
+ uninit() {
+ if (this._inited) {
+ Services.obs.removeObserver(this, "network:offline-status-changed");
+ }
+ },
+
+ toggleOfflineStatus() {
+ var ioService = Services.io;
+
+ if (!ioService.offline && !this._canGoOffline()) {
+ this._updateOfflineUI(false);
+ return;
+ }
+
+ ioService.offline = !ioService.offline;
+ },
+
+ // nsIObserver
+ observe(aSubject, aTopic, aState) {
+ if (aTopic != "network:offline-status-changed") {
+ return;
+ }
+
+ // This notification is also received because of a loss in connectivity,
+ // which we ignore by updating the UI to the current value of io.offline
+ this._updateOfflineUI(Services.io.offline);
+ },
+
+ // BrowserOffline Implementation Methods
+ _canGoOffline() {
+ try {
+ var cancelGoOffline = Cc["@mozilla.org/supports-PRBool;1"].createInstance(
+ Ci.nsISupportsPRBool
+ );
+ Services.obs.notifyObservers(cancelGoOffline, "offline-requested");
+
+ // Something aborted the quit process.
+ if (cancelGoOffline.data) {
+ return false;
+ }
+ } catch (ex) {}
+
+ return true;
+ },
+
+ _uiElement: null,
+ _updateOfflineUI(aOffline) {
+ var offlineLocked = Services.prefs.prefIsLocked("network.online");
+ if (offlineLocked) {
+ this._uiElement.setAttribute("disabled", "true");
+ }
+
+ this._uiElement.setAttribute("checked", aOffline);
+ },
+};
+
+var CanvasPermissionPromptHelper = {
+ _permissionsPrompt: "canvas-permissions-prompt",
+ _permissionsPromptHideDoorHanger: "canvas-permissions-prompt-hide-doorhanger",
+ _notificationIcon: "canvas-notification-icon",
+
+ init() {
+ Services.obs.addObserver(this, this._permissionsPrompt);
+ Services.obs.addObserver(this, this._permissionsPromptHideDoorHanger);
+ },
+
+ uninit() {
+ Services.obs.removeObserver(this, this._permissionsPrompt);
+ Services.obs.removeObserver(this, this._permissionsPromptHideDoorHanger);
+ },
+
+ // aSubject is an nsIBrowser (e10s) or an nsIDOMWindow (non-e10s).
+ // aData is an Origin string.
+ observe(aSubject, aTopic, aData) {
+ if (
+ aTopic != this._permissionsPrompt &&
+ aTopic != this._permissionsPromptHideDoorHanger
+ ) {
+ return;
+ }
+
+ let browser;
+ if (aSubject instanceof Ci.nsIDOMWindow) {
+ browser = aSubject.docShell.chromeEventHandler;
+ } else {
+ browser = aSubject;
+ }
+
+ if (gBrowser.selectedBrowser !== browser) {
+ // Must belong to some other window.
+ return;
+ }
+
+ let message = gNavigatorBundle.getFormattedString(
+ "canvas.siteprompt2",
+ ["<>"],
+ 1
+ );
+
+ let principal =
+ Services.scriptSecurityManager.createContentPrincipalFromOrigin(aData);
+
+ function setCanvasPermission(aPerm, aPersistent) {
+ Services.perms.addFromPrincipal(
+ principal,
+ "canvas",
+ aPerm,
+ aPersistent
+ ? Ci.nsIPermissionManager.EXPIRE_NEVER
+ : Ci.nsIPermissionManager.EXPIRE_SESSION
+ );
+ }
+
+ let mainAction = {
+ label: gNavigatorBundle.getString("canvas.allow2"),
+ accessKey: gNavigatorBundle.getString("canvas.allow2.accesskey"),
+ callback(state) {
+ setCanvasPermission(
+ Ci.nsIPermissionManager.ALLOW_ACTION,
+ state && state.checkboxChecked
+ );
+ },
+ };
+
+ let secondaryActions = [
+ {
+ label: gNavigatorBundle.getString("canvas.block"),
+ accessKey: gNavigatorBundle.getString("canvas.block.accesskey"),
+ callback(state) {
+ setCanvasPermission(
+ Ci.nsIPermissionManager.DENY_ACTION,
+ state && state.checkboxChecked
+ );
+ },
+ },
+ ];
+
+ let checkbox = {
+ // In PB mode, we don't want the "always remember" checkbox
+ show: !PrivateBrowsingUtils.isWindowPrivate(window),
+ };
+ if (checkbox.show) {
+ checkbox.checked = true;
+ checkbox.label = gBrowserBundle.GetStringFromName("canvas.remember2");
+ }
+
+ let options = {
+ checkbox,
+ name: principal.host,
+ learnMoreURL:
+ Services.urlFormatter.formatURLPref("app.support.baseURL") +
+ "fingerprint-permission",
+ dismissed: aTopic == this._permissionsPromptHideDoorHanger,
+ eventCallback(e) {
+ if (e == "showing") {
+ this.browser.ownerDocument.getElementById(
+ "canvas-permissions-prompt-warning"
+ ).textContent = gBrowserBundle.GetStringFromName(
+ "canvas.siteprompt2.warning"
+ );
+ }
+ },
+ };
+ PopupNotifications.show(
+ browser,
+ this._permissionsPrompt,
+ message,
+ this._notificationIcon,
+ mainAction,
+ secondaryActions,
+ options
+ );
+ },
+};
+
+var WebAuthnPromptHelper = {
+ _icon: "webauthn-notification-icon",
+ _topic: "webauthn-prompt",
+
+ // The current notification, if any. The U2F manager is a singleton, we will
+ // never allow more than one active request. And thus we'll never have more
+ // than one notification either.
+ _current: null,
+
+ // The current transaction ID. Will be checked when we're notified of the
+ // cancellation of an ongoing WebAuthhn request.
+ _tid: 0,
+
+ // Translation object
+ _l10n: null,
+
+ init() {
+ this._l10n = new Localization(["browser/webauthnDialog.ftl"], true);
+ Services.obs.addObserver(this, this._topic);
+ },
+
+ uninit() {
+ Services.obs.removeObserver(this, this._topic);
+ },
+
+ observe(aSubject, aTopic, aData) {
+ let data = JSON.parse(aData);
+
+ // If we receive a cancel, it might be a WebAuthn prompt starting in another
+ // window, and the other window's browsing context will send out the
+ // cancellations, so any cancel action we get should prompt us to cancel.
+ if (data.action == "cancel") {
+ this.cancel(data);
+ return;
+ }
+
+ if (
+ data.browsingContextId !== gBrowser.selectedBrowser.browsingContext.id
+ ) {
+ // Must belong to some other window.
+ return;
+ }
+
+ let mgr = aSubject.QueryInterface(
+ data.is_ctap2 ? Ci.nsIWebAuthnController : Ci.nsIU2FTokenManager
+ );
+
+ if (data.action == "presence") {
+ this.presence_required(mgr, data);
+ } else if (data.action == "register-direct") {
+ this.registerDirect(mgr, data);
+ } else if (data.action == "pin-required") {
+ this.pin_required(mgr, data);
+ } else if (data.action == "select-sign-result") {
+ this.select_sign_result(mgr, data);
+ } else if (data.action == "already-registered") {
+ this.show_info(
+ mgr,
+ data.origin,
+ data.tid,
+ "alreadyRegistered",
+ "webauthn.alreadyRegisteredPrompt"
+ );
+ } else if (data.action == "select-device") {
+ this.show_info(
+ mgr,
+ data.origin,
+ data.tid,
+ "selectDevice",
+ "webauthn.selectDevicePrompt"
+ );
+ } else if (data.action == "pin-auth-blocked") {
+ this.show_info(
+ mgr,
+ data.origin,
+ data.tid,
+ "pinAuthBlocked",
+ "webauthn.pinAuthBlockedPrompt"
+ );
+ } else if (data.action == "device-blocked") {
+ this.show_info(
+ mgr,
+ data.origin,
+ data.tid,
+ "deviceBlocked",
+ "webauthn.deviceBlockedPrompt"
+ );
+ } else if (data.action == "pin-not-set") {
+ this.show_info(
+ mgr,
+ data.origin,
+ data.tid,
+ "pinNotSet",
+ "webauthn.pinNotSetPrompt"
+ );
+ }
+ },
+
+ prompt_for_password(origin, wasInvalid, retriesLeft, aPassword) {
+ let dialogText;
+ if (!wasInvalid) {
+ dialogText = this._l10n.formatValueSync("webauthn-pin-required-prompt");
+ } else if (retriesLeft < 0 || retriesLeft > 3) {
+ // The token will need to be power cycled after three incorrect attempts,
+ // so we show a short error message that does not include retriesLeft. It
+ // would be confusing to display retriesLeft at this point, as the user
+ // will feel that they only get three attempts.
+ dialogText = this._l10n.formatValueSync(
+ "webauthn-pin-invalid-short-prompt"
+ );
+ } else {
+ // The user is close to having their PIN permanently blocked. Show a more
+ // severe warning that includes the retriesLeft counter.
+ dialogText = this._l10n.formatValueSync(
+ "webauthn-pin-invalid-long-prompt",
+ { retriesLeft }
+ );
+ }
+
+ let res = Services.prompt.promptPasswordBC(
+ gBrowser.selectedBrowser.browsingContext,
+ Services.prompt.MODAL_TYPE_TAB,
+ origin,
+ dialogText,
+ aPassword
+ );
+ return res;
+ },
+
+ select_sign_result(mgr, { origin, tid, usernames }) {
+ let secondaryActions = [];
+ for (let i = 0; i < usernames.length; i++) {
+ secondaryActions.push({
+ label: unescape(decodeURIComponent(usernames[i])),
+ accessKey: i.toString(),
+ callback(aState) {
+ mgr.signatureSelectionCallback(tid, i);
+ },
+ });
+ }
+ let mainAction = this.buildCancelAction(mgr, tid);
+ let options = { escAction: "buttoncommand" };
+ this.show(
+ tid,
+ "select-sign-result",
+ "webauthn.selectSignResultPrompt",
+ origin,
+ mainAction,
+ secondaryActions,
+ options
+ );
+ },
+
+ pin_required(mgr, { origin, tid, wasInvalid, retriesLeft }) {
+ let aPassword = Object.create(null); // create a "null" object
+ let res = this.prompt_for_password(
+ origin,
+ wasInvalid,
+ retriesLeft,
+ aPassword
+ );
+ if (res) {
+ mgr.pinCallback(tid, aPassword.value);
+ } else {
+ mgr.cancel(tid);
+ }
+ },
+
+ presence_required(mgr, { origin, tid }) {
+ let mainAction = this.buildCancelAction(mgr, tid);
+ let options = { escAction: "buttoncommand" };
+ let secondaryActions = [];
+ let message = "webauthn.userPresencePrompt";
+ this.show(
+ tid,
+ "presence",
+ message,
+ origin,
+ mainAction,
+ secondaryActions,
+ options
+ );
+ },
+
+ registerDirect(mgr, { origin, tid }) {
+ let mainAction = this.buildProceedAction(mgr, tid);
+ let secondaryActions = [this.buildCancelAction(mgr, tid)];
+
+ let learnMoreURL =
+ Services.urlFormatter.formatURLPref("app.support.baseURL") +
+ "webauthn-direct-attestation";
+
+ let options = {
+ learnMoreURL,
+ checkbox: {
+ label: gNavigatorBundle.getString("webauthn.anonymize"),
+ },
+ hintText: "webauthn.registerDirectPromptHint",
+ };
+ this.show(
+ tid,
+ "register-direct",
+ "webauthn.registerDirectPrompt3",
+ origin,
+ mainAction,
+ secondaryActions,
+ options
+ );
+ },
+
+ show_info(mgr, origin, tid, id, stringId) {
+ let mainAction = this.buildCancelAction(mgr, tid);
+ this.show(tid, id, stringId, origin, mainAction);
+ },
+
+ show(
+ tid,
+ id,
+ stringId,
+ origin,
+ mainAction,
+ secondaryActions = [],
+ options = {}
+ ) {
+ this.reset();
+
+ try {
+ origin = Services.io.newURI(origin).asciiHost;
+ } catch (e) {
+ /* Might fail for arbitrary U2F RP IDs. */
+ }
+
+ let brandShortName = document
+ .getElementById("bundle_brand")
+ .getString("brandShortName");
+ let message = gNavigatorBundle.getFormattedString(stringId, [
+ "<>",
+ brandShortName,
+ ]);
+ if (options.hintText) {
+ options.hintText = gNavigatorBundle.getFormattedString(options.hintText, [
+ brandShortName,
+ ]);
+ }
+
+ options.name = origin;
+ options.hideClose = true;
+ options.persistent = true;
+ options.eventCallback = event => {
+ if (event == "removed") {
+ this._current = null;
+ this._tid = 0;
+ }
+ };
+
+ this._tid = tid;
+ this._current = PopupNotifications.show(
+ gBrowser.selectedBrowser,
+ `webauthn-prompt-${id}`,
+ message,
+ this._icon,
+ mainAction,
+ secondaryActions,
+ options
+ );
+ },
+
+ cancel({ tid }) {
+ if (this._tid == tid) {
+ this.reset();
+ }
+ },
+
+ reset() {
+ if (this._current) {
+ this._current.remove();
+ }
+ },
+
+ buildProceedAction(mgr, tid) {
+ return {
+ label: gNavigatorBundle.getString("webauthn.proceed"),
+ accessKey: gNavigatorBundle.getString("webauthn.proceed.accesskey"),
+ callback(state) {
+ mgr.resumeRegister(tid, state.checkboxChecked);
+ },
+ };
+ },
+
+ buildCancelAction(mgr, tid) {
+ return {
+ label: gNavigatorBundle.getString("webauthn.cancel"),
+ accessKey: gNavigatorBundle.getString("webauthn.cancel.accesskey"),
+ callback() {
+ mgr.cancel(tid);
+ },
+ };
+ },
+};
+
+function CanCloseWindow() {
+ // Avoid redundant calls to canClose from showing multiple
+ // PermitUnload dialogs.
+ if (Services.startup.shuttingDown || window.skipNextCanClose) {
+ return true;
+ }
+
+ for (let browser of gBrowser.browsers) {
+ // Don't instantiate lazy browsers.
+ if (!browser.isConnected) {
+ continue;
+ }
+
+ let { permitUnload } = browser.permitUnload();
+ if (!permitUnload) {
+ return false;
+ }
+ }
+ return true;
+}
+
+function WindowIsClosing(event) {
+ let source;
+ if (event) {
+ let target = event.sourceEvent?.target;
+ if (target?.id?.startsWith("menu_")) {
+ source = "menuitem";
+ } else if (target?.nodeName == "toolbarbutton") {
+ source = "close-button";
+ } else {
+ let key = AppConstants.platform == "macosx" ? "metaKey" : "ctrlKey";
+ source = event[key] ? "shortcut" : "OS";
+ }
+ }
+ if (!closeWindow(false, warnAboutClosingWindow, source)) {
+ return false;
+ }
+
+ // In theory we should exit here and the Window's internal Close
+ // method should trigger canClose on nsBrowserAccess. However, by
+ // that point it's too late to be able to show a prompt for
+ // PermitUnload. So we do it here, when we still can.
+ if (CanCloseWindow()) {
+ // This flag ensures that the later canClose call does nothing.
+ // It's only needed to make tests pass, since they detect the
+ // prompt even when it's not actually shown.
+ window.skipNextCanClose = true;
+ return true;
+ }
+
+ return false;
+}
+
+/**
+ * Checks if this is the last full *browser* window around. If it is, this will
+ * be communicated like quitting. Otherwise, we warn about closing multiple tabs.
+ *
+ * @param source where the request to close came from (used for telemetry)
+ * @returns true if closing can proceed, false if it got cancelled.
+ */
+function warnAboutClosingWindow(source) {
+ // Popups aren't considered full browser windows; we also ignore private windows.
+ let isPBWindow =
+ PrivateBrowsingUtils.isWindowPrivate(window) &&
+ !PrivateBrowsingUtils.permanentPrivateBrowsing;
+
+ if (!isPBWindow && !toolbar.visible) {
+ return gBrowser.warnAboutClosingTabs(
+ gBrowser.visibleTabs.length,
+ gBrowser.closingTabsEnum.ALL,
+ source
+ );
+ }
+
+ // Figure out if there's at least one other browser window around.
+ let otherPBWindowExists = false;
+ let otherWindowExists = false;
+ for (let win of browserWindows()) {
+ if (!win.closed && win != window) {
+ otherWindowExists = true;
+ if (isPBWindow && PrivateBrowsingUtils.isWindowPrivate(win)) {
+ otherPBWindowExists = true;
+ }
+ // If the current window is not in private browsing mode we don't need to
+ // look for other pb windows, we can leave the loop when finding the
+ // first non-popup window. If however the current window is in private
+ // browsing mode then we need at least one other pb and one non-popup
+ // window to break out early.
+ if (!isPBWindow || otherPBWindowExists) {
+ break;
+ }
+ }
+ }
+
+ if (isPBWindow && !otherPBWindowExists) {
+ let exitingCanceled = Cc["@mozilla.org/supports-PRBool;1"].createInstance(
+ Ci.nsISupportsPRBool
+ );
+ exitingCanceled.data = false;
+ Services.obs.notifyObservers(exitingCanceled, "last-pb-context-exiting");
+ if (exitingCanceled.data) {
+ return false;
+ }
+ }
+
+ if (otherWindowExists) {
+ return (
+ isPBWindow ||
+ gBrowser.warnAboutClosingTabs(
+ gBrowser.visibleTabs.length,
+ gBrowser.closingTabsEnum.ALL,
+ source
+ )
+ );
+ }
+
+ let os = Services.obs;
+
+ let closingCanceled = Cc["@mozilla.org/supports-PRBool;1"].createInstance(
+ Ci.nsISupportsPRBool
+ );
+ os.notifyObservers(closingCanceled, "browser-lastwindow-close-requested");
+ if (closingCanceled.data) {
+ return false;
+ }
+
+ os.notifyObservers(null, "browser-lastwindow-close-granted");
+
+ // OS X doesn't quit the application when the last window is closed, but keeps
+ // the session alive. Hence don't prompt users to save tabs, but warn about
+ // closing multiple tabs.
+ return (
+ AppConstants.platform != "macosx" ||
+ isPBWindow ||
+ gBrowser.warnAboutClosingTabs(
+ gBrowser.visibleTabs.length,
+ gBrowser.closingTabsEnum.ALL,
+ source
+ )
+ );
+}
+
+var MailIntegration = {
+ sendLinkForBrowser(aBrowser) {
+ this.sendMessage(
+ gURLBar.makeURIReadable(aBrowser.currentURI).displaySpec,
+ aBrowser.contentTitle
+ );
+ },
+
+ sendMessage(aBody, aSubject) {
+ // generate a mailto url based on the url and the url's title
+ var mailtoUrl = "mailto:";
+ if (aBody) {
+ mailtoUrl += "?body=" + encodeURIComponent(aBody);
+ mailtoUrl += "&subject=" + encodeURIComponent(aSubject);
+ }
+
+ var uri = makeURI(mailtoUrl);
+
+ // now pass this uri to the operating system
+ this._launchExternalUrl(uri);
+ },
+
+ // a generic method which can be used to pass arbitrary urls to the operating
+ // system.
+ // aURL --> a nsIURI which represents the url to launch
+ _launchExternalUrl(aURL) {
+ var extProtocolSvc = Cc[
+ "@mozilla.org/uriloader/external-protocol-service;1"
+ ].getService(Ci.nsIExternalProtocolService);
+ if (extProtocolSvc) {
+ extProtocolSvc.loadURI(
+ aURL,
+ Services.scriptSecurityManager.getSystemPrincipal()
+ );
+ }
+ },
+};
+
+/**
+ * Open about:addons page by given view id.
+ * @param {String} aView
+ * View id of page that will open.
+ * e.g. "addons://discover/"
+ * @param {Object} options
+ * {
+ * selectTabByViewId: If true, if there is the tab opening page having
+ * same view id, select the tab. Else if the current
+ * page is blank, load on it. Otherwise, open a new
+ * tab, then load on it.
+ * If false, if there is the tab opening
+ * about:addoons page, select the tab and load page
+ * for view id on it. Otherwise, leave the loading
+ * behavior to switchToTabHavingURI().
+ * If no options, handles as false.
+ * }
+ * @returns {Promise} When the Promise resolves, returns window object loaded the
+ * view id.
+ */
+function BrowserOpenAddonsMgr(aView, { selectTabByViewId = false } = {}) {
+ return new Promise(resolve => {
+ let emWindow;
+ let browserWindow;
+
+ var receivePong = function (aSubject, aTopic, aData) {
+ let browserWin = aSubject.browsingContext.topChromeWindow;
+ if (!emWindow || browserWin == window /* favor the current window */) {
+ if (
+ selectTabByViewId &&
+ aSubject.gViewController.currentViewId !== aView
+ ) {
+ return;
+ }
+
+ emWindow = aSubject;
+ browserWindow = browserWin;
+ }
+ };
+ Services.obs.addObserver(receivePong, "EM-pong");
+ Services.obs.notifyObservers(null, "EM-ping");
+ Services.obs.removeObserver(receivePong, "EM-pong");
+
+ if (emWindow) {
+ if (aView && !selectTabByViewId) {
+ emWindow.loadView(aView);
+ }
+ let tab = browserWindow.gBrowser.getTabForBrowser(
+ emWindow.docShell.chromeEventHandler
+ );
+ browserWindow.gBrowser.selectedTab = tab;
+ emWindow.focus();
+ resolve(emWindow);
+ return;
+ }
+
+ if (selectTabByViewId) {
+ const target = isBlankPageURL(gBrowser.currentURI.spec)
+ ? "current"
+ : "tab";
+ openTrustedLinkIn("about:addons", target);
+ } else {
+ // This must be a new load, else the ping/pong would have
+ // found the window above.
+ switchToTabHavingURI("about:addons", true);
+ }
+
+ Services.obs.addObserver(function observer(aSubject, aTopic, aData) {
+ Services.obs.removeObserver(observer, aTopic);
+ if (aView) {
+ aSubject.loadView(aView);
+ }
+ aSubject.focus();
+ resolve(aSubject);
+ }, "EM-loaded");
+ });
+}
+
+function AddKeywordForSearchField() {
+ if (!gContextMenu) {
+ throw new Error("Context menu doesn't seem to be open.");
+ }
+
+ gContextMenu.addKeywordForSearchField();
+}
+
+/**
+ * Re-open a closed tab.
+ * @param aIndex
+ * The index of the tab (via SessionStore.getClosedTabDataForWindow)
+ * @returns a reference to the reopened tab.
+ */
+function undoCloseTab(aIndex) {
+ // wallpaper patch to prevent an unnecessary blank tab (bug 343895)
+ let blankTabToRemove = null;
+ if (gBrowser.tabs.length == 1 && gBrowser.selectedTab.isEmpty) {
+ blankTabToRemove = gBrowser.selectedTab;
+ }
+
+ let closedTabCount = SessionStore.getLastClosedTabCount(window);
+
+ // There's nothing to do here if there are no tabs to re-open for this
+ // window...
+ if (!closedTabCount) {
+ // ... unless there's a previous session that we can restore, in which
+ // case, we use this as a signal to restore that session and merge it into
+ // the current session.
+ if (SessionStore.canRestoreLastSession) {
+ SessionStore.restoreLastSession();
+ }
+ return null;
+ }
+
+ let tab = null;
+ // aIndex is undefined if the function is called without a specific tab to restore.
+ let tabsToRemove =
+ aIndex !== undefined ? [aIndex] : new Array(closedTabCount).fill(0);
+ let tabsRemoved = false;
+ for (let index of tabsToRemove) {
+ if (SessionStore.getClosedTabCountForWindow(window) > index) {
+ tab = SessionStore.undoCloseTab(window, index);
+ tabsRemoved = true;
+ }
+ }
+
+ if (tabsRemoved && blankTabToRemove) {
+ gBrowser.removeTab(blankTabToRemove);
+ }
+
+ return tab;
+}
+
+/**
+ * Re-open a closed window.
+ * @param aIndex
+ * The index of the window (via SessionStore.getClosedWindowData)
+ * @returns a reference to the reopened window.
+ */
+function undoCloseWindow(aIndex) {
+ let window = null;
+ if (SessionStore.getClosedWindowCount() > (aIndex || 0)) {
+ window = SessionStore.undoCloseWindow(aIndex || 0);
+ }
+
+ return window;
+}
+
+function ReportFalseDeceptiveSite() {
+ let contextsToVisit = [gBrowser.selectedBrowser.browsingContext];
+ while (contextsToVisit.length) {
+ let currentContext = contextsToVisit.pop();
+ let global = currentContext.currentWindowGlobal;
+
+ if (!global) {
+ continue;
+ }
+ let docURI = global.documentURI;
+ // Ensure the page is an about:blocked pagae before handling.
+ if (docURI && docURI.spec.startsWith("about:blocked?e=deceptiveBlocked")) {
+ let actor = global.getActor("BlockedSite");
+ actor.sendQuery("DeceptiveBlockedDetails").then(data => {
+ let reportUrl = gSafeBrowsing.getReportURL(
+ "PhishMistake",
+ data.blockedInfo
+ );
+ if (reportUrl) {
+ openTrustedLinkIn(reportUrl, "tab");
+ } else {
+ let bundle = Services.strings.createBundle(
+ "chrome://browser/locale/safebrowsing/safebrowsing.properties"
+ );
+ Services.prompt.alert(
+ window,
+ bundle.GetStringFromName("errorReportFalseDeceptiveTitle"),
+ bundle.formatStringFromName("errorReportFalseDeceptiveMessage", [
+ data.blockedInfo.provider,
+ ])
+ );
+ }
+ });
+ }
+
+ contextsToVisit.push(...currentContext.children);
+ }
+}
+
+/**
+ * This is a temporary hack to connect a Help menu item for reporting
+ * site issues to the WebCompat team's Site Compatability Reporter
+ * WebExtension, which ships by default and is enabled on pre-release
+ * channels.
+ *
+ * Once we determine if Help is the right place for it, we'll do something
+ * slightly better than this.
+ *
+ * See bug 1690573.
+ */
+function ReportSiteIssue() {
+ let subject = { wrappedJSObject: gBrowser.selectedTab };
+ Services.obs.notifyObservers(subject, "report-site-issue");
+}
+
+/**
+ * When the browser is being controlled from out-of-process,
+ * e.g. when Marionette or the remote debugging protocol is used,
+ * we add a visual hint to the browser UI to indicate to the user
+ * that the browser session is under remote control.
+ *
+ * This is called when the content browser initialises (from gBrowserInit.onLoad())
+ * and when the "remote-listening" system notification fires.
+ */
+const gRemoteControl = {
+ observe(subject, topic, data) {
+ gRemoteControl.updateVisualCue();
+ },
+
+ updateVisualCue() {
+ // Disable updating the remote control cue for performance tests,
+ // because these could fail due to an early initialization of Marionette.
+ const disableRemoteControlCue = Services.prefs.getBoolPref(
+ "browser.chrome.disableRemoteControlCueForTests",
+ false
+ );
+ if (disableRemoteControlCue && Cu.isInAutomation) {
+ return;
+ }
+
+ const mainWindow = document.documentElement;
+ const remoteControlComponent = this.getRemoteControlComponent();
+ if (remoteControlComponent) {
+ mainWindow.setAttribute("remotecontrol", "true");
+ const remoteControlIcon = document.getElementById("remote-control-icon");
+ document.l10n.setAttributes(
+ remoteControlIcon,
+ "urlbar-remote-control-notification-anchor2",
+ { component: remoteControlComponent }
+ );
+ } else {
+ mainWindow.removeAttribute("remotecontrol");
+ }
+ },
+
+ getRemoteControlComponent() {
+ // For DevTools sockets, only show the remote control cue if the socket is
+ // not coming from a regular Browser Toolbox debugging session.
+ if (
+ DevToolsSocketStatus.hasSocketOpened({
+ excludeBrowserToolboxSockets: true,
+ })
+ ) {
+ return "DevTools";
+ }
+
+ if (Marionette.running) {
+ return "Marionette";
+ }
+
+ if (RemoteAgent.running) {
+ return "RemoteAgent";
+ }
+
+ return null;
+ },
+};
+
+// Note that this is also called from non-browser windows on OSX, which do
+// share menu items but not much else. See nonbrowser-mac.js.
+var gPrivateBrowsingUI = {
+ init: function PBUI_init() {
+ // Do nothing for normal windows
+ if (!PrivateBrowsingUtils.isWindowPrivate(window)) {
+ return;
+ }
+
+ // Disable the Clear Recent History... menu item when in PB mode
+ // temporary fix until bug 463607 is fixed
+ document.getElementById("Tools:Sanitize").setAttribute("disabled", "true");
+
+ if (window.location.href != AppConstants.BROWSER_CHROME_URL) {
+ return;
+ }
+
+ // Adjust the window's title
+ let docElement = document.documentElement;
+ docElement.setAttribute(
+ "privatebrowsingmode",
+ PrivateBrowsingUtils.permanentPrivateBrowsing ? "permanent" : "temporary"
+ );
+ // If enabled, show the new private browsing indicator with label.
+ // This will hide the old indicator.
+ docElement.toggleAttribute(
+ "privatebrowsingnewindicator",
+ NimbusFeatures.majorRelease2022.getVariable("feltPrivacyPBMNewIndicator")
+ );
+
+ gBrowser.updateTitlebar();
+
+ if (PrivateBrowsingUtils.permanentPrivateBrowsing) {
+ // Adjust the New Window menu entries
+ let newWindow = document.getElementById("menu_newNavigator");
+ let newPrivateWindow = document.getElementById("menu_newPrivateWindow");
+ if (newWindow && newPrivateWindow) {
+ newPrivateWindow.hidden = true;
+ newWindow.label = newPrivateWindow.label;
+ newWindow.accessKey = newPrivateWindow.accessKey;
+ newWindow.command = newPrivateWindow.command;
+ }
+ }
+ },
+};
+
+/**
+ * Switch to a tab that has a given URI, and focuses its browser window.
+ * If a matching tab is in this window, it will be switched to. Otherwise, other
+ * windows will be searched.
+ *
+ * @param aURI
+ * URI to search for
+ * @param aOpenNew
+ * True to open a new tab and switch to it, if no existing tab is found.
+ * If no suitable window is found, a new one will be opened.
+ * @param aOpenParams
+ * If switching to this URI results in us opening a tab, aOpenParams
+ * will be the parameter object that gets passed to openTrustedLinkIn. Please
+ * see the documentation for openTrustedLinkIn to see what parameters can be
+ * passed via this object.
+ * This object also allows:
+ * - 'ignoreFragment' property to be set to true to exclude fragment-portion
+ * matching when comparing URIs.
+ * If set to "whenComparing", the fragment will be unmodified.
+ * If set to "whenComparingAndReplace", the fragment will be replaced.
+ * - 'ignoreQueryString' boolean property to be set to true to exclude query string
+ * matching when comparing URIs.
+ * - 'replaceQueryString' boolean property to be set to true to exclude query string
+ * matching when comparing URIs and overwrite the initial query string with
+ * the one from the new URI.
+ * - 'adoptIntoActiveWindow' boolean property to be set to true to adopt the tab
+ * into the current window.
+ * @return True if an existing tab was found, false otherwise
+ */
+function switchToTabHavingURI(aURI, aOpenNew, aOpenParams = {}) {
+ // Certain URLs can be switched to irrespective of the source or destination
+ // window being in private browsing mode:
+ const kPrivateBrowsingWhitelist = new Set(["about:addons"]);
+
+ let ignoreFragment = aOpenParams.ignoreFragment;
+ let ignoreQueryString = aOpenParams.ignoreQueryString;
+ let replaceQueryString = aOpenParams.replaceQueryString;
+ let adoptIntoActiveWindow = aOpenParams.adoptIntoActiveWindow;
+
+ // These properties are only used by switchToTabHavingURI and should
+ // not be used as a parameter for the new load.
+ delete aOpenParams.ignoreFragment;
+ delete aOpenParams.ignoreQueryString;
+ delete aOpenParams.replaceQueryString;
+ delete aOpenParams.adoptIntoActiveWindow;
+
+ let isBrowserWindow = !!window.gBrowser;
+
+ // This will switch to the tab in aWindow having aURI, if present.
+ function switchIfURIInWindow(aWindow) {
+ // We can switch tab only if if both the source and destination windows have
+ // the same private-browsing status.
+ if (
+ !kPrivateBrowsingWhitelist.has(aURI.spec) &&
+ PrivateBrowsingUtils.isWindowPrivate(window) !==
+ PrivateBrowsingUtils.isWindowPrivate(aWindow)
+ ) {
+ return false;
+ }
+
+ // Remove the query string, fragment, both, or neither from a given url.
+ function cleanURL(url, removeQuery, removeFragment) {
+ let ret = url;
+ if (removeFragment) {
+ ret = ret.split("#")[0];
+ if (removeQuery) {
+ // This removes a query, if present before the fragment.
+ ret = ret.split("?")[0];
+ }
+ } else if (removeQuery) {
+ // This is needed in case there is a fragment after the query.
+ let fragment = ret.split("#")[1];
+ ret = ret
+ .split("?")[0]
+ .concat(fragment != undefined ? "#".concat(fragment) : "");
+ }
+ return ret;
+ }
+
+ // Need to handle nsSimpleURIs here too (e.g. about:...), which don't
+ // work correctly with URL objects - so treat them as strings
+ let ignoreFragmentWhenComparing =
+ typeof ignoreFragment == "string" &&
+ ignoreFragment.startsWith("whenComparing");
+ let requestedCompare = cleanURL(
+ aURI.displaySpec,
+ ignoreQueryString || replaceQueryString,
+ ignoreFragmentWhenComparing
+ );
+ let browsers = aWindow.gBrowser.browsers;
+ for (let i = 0; i < browsers.length; i++) {
+ let browser = browsers[i];
+ let browserCompare = cleanURL(
+ browser.currentURI.displaySpec,
+ ignoreQueryString || replaceQueryString,
+ ignoreFragmentWhenComparing
+ );
+ if (requestedCompare == browserCompare) {
+ // If adoptIntoActiveWindow is set, and this is a cross-window switch,
+ // adopt the tab into the current window, after the active tab.
+ let doAdopt =
+ adoptIntoActiveWindow && isBrowserWindow && aWindow != window;
+
+ if (doAdopt) {
+ const newTab = window.gBrowser.adoptTab(
+ aWindow.gBrowser.getTabForBrowser(browser),
+ window.gBrowser.tabContainer.selectedIndex + 1,
+ /* aSelectTab = */ true
+ );
+ if (!newTab) {
+ doAdopt = false;
+ }
+ }
+ if (!doAdopt) {
+ aWindow.focus();
+ }
+
+ if (ignoreFragment == "whenComparingAndReplace" || replaceQueryString) {
+ browser.loadURI(aURI, {
+ triggeringPrincipal:
+ aOpenParams.triggeringPrincipal ||
+ _createNullPrincipalFromTabUserContextId(),
+ });
+ }
+
+ if (!doAdopt) {
+ aWindow.gBrowser.tabContainer.selectedIndex = i;
+ }
+
+ return true;
+ }
+ }
+ return false;
+ }
+
+ // This can be passed either nsIURI or a string.
+ if (!(aURI instanceof Ci.nsIURI)) {
+ aURI = Services.io.newURI(aURI);
+ }
+
+ // Prioritise this window.
+ if (isBrowserWindow && switchIfURIInWindow(window)) {
+ return true;
+ }
+
+ for (let browserWin of browserWindows()) {
+ // Skip closed (but not yet destroyed) windows,
+ // and the current window (which was checked earlier).
+ if (browserWin.closed || browserWin == window) {
+ continue;
+ }
+ if (switchIfURIInWindow(browserWin)) {
+ return true;
+ }
+ }
+
+ // No opened tab has that url.
+ if (aOpenNew) {
+ if (isBrowserWindow && gBrowser.selectedTab.isEmpty) {
+ openTrustedLinkIn(aURI.spec, "current", aOpenParams);
+ } else {
+ openTrustedLinkIn(aURI.spec, "tab", aOpenParams);
+ }
+ }
+
+ return false;
+}
+
+var RestoreLastSessionObserver = {
+ init() {
+ if (
+ SessionStore.canRestoreLastSession &&
+ !PrivateBrowsingUtils.isWindowPrivate(window)
+ ) {
+ Services.obs.addObserver(this, "sessionstore-last-session-cleared", true);
+ goSetCommandEnabled("Browser:RestoreLastSession", true);
+ } else if (SessionStore.willAutoRestore) {
+ document.getElementById("Browser:RestoreLastSession").hidden = true;
+ }
+ },
+
+ observe() {
+ // The last session can only be restored once so there's
+ // no way we need to re-enable our menu item.
+ Services.obs.removeObserver(this, "sessionstore-last-session-cleared");
+ goSetCommandEnabled("Browser:RestoreLastSession", false);
+ },
+
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIObserver",
+ "nsISupportsWeakReference",
+ ]),
+};
+
+/* Observes menus and adjusts their size for better
+ * usability when opened via a touch screen. */
+var MenuTouchModeObserver = {
+ init() {
+ window.addEventListener("popupshowing", this, true);
+ },
+
+ handleEvent(event) {
+ let target = event.originalTarget;
+ if (event.mozInputSource == MouseEvent.MOZ_SOURCE_TOUCH) {
+ target.setAttribute("touchmode", "true");
+ } else {
+ target.removeAttribute("touchmode");
+ }
+ },
+
+ uninit() {
+ window.removeEventListener("popupshowing", this, true);
+ },
+};
+
+// Prompt user to restart the browser in safe mode
+function safeModeRestart() {
+ if (Services.appinfo.inSafeMode) {
+ let cancelQuit = Cc["@mozilla.org/supports-PRBool;1"].createInstance(
+ Ci.nsISupportsPRBool
+ );
+ Services.obs.notifyObservers(
+ cancelQuit,
+ "quit-application-requested",
+ "restart"
+ );
+
+ if (cancelQuit.data) {
+ return;
+ }
+
+ Services.startup.quit(
+ Ci.nsIAppStartup.eRestart | Ci.nsIAppStartup.eAttemptQuit
+ );
+ return;
+ }
+
+ Services.obs.notifyObservers(window, "restart-in-safe-mode");
+}
+
+/* duplicateTabIn duplicates tab in a place specified by the parameter |where|.
+ *
+ * |where| can be:
+ * "tab" new tab
+ * "tabshifted" same as "tab" but in background if default is to select new
+ * tabs, and vice versa
+ * "window" new window
+ *
+ * delta is the offset to the history entry that you want to load.
+ */
+function duplicateTabIn(aTab, where, delta) {
+ switch (where) {
+ case "window":
+ let otherWin = OpenBrowserWindow({
+ private: PrivateBrowsingUtils.isBrowserPrivate(aTab.linkedBrowser),
+ });
+ let delayedStartupFinished = (subject, topic) => {
+ if (
+ topic == "browser-delayed-startup-finished" &&
+ subject == otherWin
+ ) {
+ Services.obs.removeObserver(delayedStartupFinished, topic);
+ let otherGBrowser = otherWin.gBrowser;
+ let otherTab = otherGBrowser.selectedTab;
+ SessionStore.duplicateTab(otherWin, aTab, delta);
+ otherGBrowser.removeTab(otherTab, { animate: false });
+ }
+ };
+
+ Services.obs.addObserver(
+ delayedStartupFinished,
+ "browser-delayed-startup-finished"
+ );
+ break;
+ case "tabshifted":
+ SessionStore.duplicateTab(window, aTab, delta);
+ // A background tab has been opened, nothing else to do here.
+ break;
+ case "tab":
+ SessionStore.duplicateTab(window, aTab, delta, true, {
+ inBackground: false,
+ });
+ break;
+ }
+}
+
+var MousePosTracker = {
+ _listeners: new Set(),
+ _x: 0,
+ _y: 0,
+
+ /**
+ * Registers a listener.
+ *
+ * @param listener (object)
+ * A listener is expected to expose the following properties:
+ *
+ * getMouseTargetRect (function)
+ * Returns the rect that the MousePosTracker needs to alert
+ * the listener about if the mouse happens to be within it.
+ *
+ * onMouseEnter (function, optional)
+ * The function to be called if the mouse enters the rect
+ * returned by getMouseTargetRect. MousePosTracker always
+ * runs this inside of a requestAnimationFrame, since it
+ * assumes that the notification is used to update the DOM.
+ *
+ * onMouseLeave (function, optional)
+ * The function to be called if the mouse exits the rect
+ * returned by getMouseTargetRect. MousePosTracker always
+ * runs this inside of a requestAnimationFrame, since it
+ * assumes that the notification is used to update the DOM.
+ */
+ addListener(listener) {
+ if (this._listeners.has(listener)) {
+ return;
+ }
+
+ listener._hover = false;
+ this._listeners.add(listener);
+
+ this._callListener(listener);
+ },
+
+ removeListener(listener) {
+ this._listeners.delete(listener);
+ },
+
+ handleEvent(event) {
+ this._x = event.screenX - window.mozInnerScreenX;
+ this._y = event.screenY - window.mozInnerScreenY;
+
+ this._listeners.forEach(listener => {
+ try {
+ this._callListener(listener);
+ } catch (e) {
+ console.error(e);
+ }
+ });
+ },
+
+ _callListener(listener) {
+ let rect = listener.getMouseTargetRect();
+ let hover =
+ this._x >= rect.left &&
+ this._x <= rect.right &&
+ this._y >= rect.top &&
+ this._y <= rect.bottom;
+
+ if (hover == listener._hover) {
+ return;
+ }
+
+ listener._hover = hover;
+
+ if (hover) {
+ if (listener.onMouseEnter) {
+ listener.onMouseEnter();
+ }
+ } else if (listener.onMouseLeave) {
+ listener.onMouseLeave();
+ }
+ },
+};
+
+var ToolbarIconColor = {
+ _windowState: {
+ active: false,
+ fullscreen: false,
+ tabsintitlebar: false,
+ },
+ init() {
+ this._initialized = true;
+
+ window.addEventListener("nativethemechange", this);
+ window.addEventListener("activate", this);
+ window.addEventListener("deactivate", this);
+ window.addEventListener("toolbarvisibilitychange", this);
+ window.addEventListener("windowlwthemeupdate", this);
+
+ // If the window isn't active now, we assume that it has never been active
+ // before and will soon become active such that inferFromText will be
+ // called from the initial activate event.
+ if (Services.focus.activeWindow == window) {
+ this.inferFromText("activate");
+ }
+ },
+
+ uninit() {
+ this._initialized = false;
+
+ window.removeEventListener("nativethemechange", this);
+ window.removeEventListener("activate", this);
+ window.removeEventListener("deactivate", this);
+ window.removeEventListener("toolbarvisibilitychange", this);
+ window.removeEventListener("windowlwthemeupdate", this);
+ },
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "activate":
+ case "deactivate":
+ case "nativethemechange":
+ case "windowlwthemeupdate":
+ this.inferFromText(event.type);
+ break;
+ case "toolbarvisibilitychange":
+ this.inferFromText(event.type, event.visible);
+ break;
+ }
+ },
+
+ // a cache of luminance values for each toolbar
+ // to avoid unnecessary calls to getComputedStyle
+ _toolbarLuminanceCache: new Map(),
+
+ inferFromText(reason, reasonValue) {
+ if (!this._initialized) {
+ return;
+ }
+ function parseRGB(aColorString) {
+ let rgb = aColorString.match(/^rgba?\((\d+), (\d+), (\d+)/);
+ rgb.shift();
+ return rgb.map(x => parseInt(x));
+ }
+
+ switch (reason) {
+ case "activate": // falls through
+ case "deactivate":
+ this._windowState.active = reason === "activate";
+ break;
+ case "fullscreen":
+ this._windowState.fullscreen = reasonValue;
+ break;
+ case "nativethemechange":
+ case "windowlwthemeupdate":
+ // theme change, we'll need to recalculate all color values
+ this._toolbarLuminanceCache.clear();
+ break;
+ case "toolbarvisibilitychange":
+ // toolbar changes dont require reset of the cached color values
+ break;
+ case "tabsintitlebar":
+ this._windowState.tabsintitlebar = reasonValue;
+ break;
+ }
+
+ let toolbarSelector = ".browser-toolbar:not([collapsed=true])";
+ if (AppConstants.platform == "macosx") {
+ toolbarSelector += ":not([type=menubar])";
+ }
+
+ // The getComputedStyle calls and setting the brighttext are separated in
+ // two loops to avoid flushing layout and making it dirty repeatedly.
+ let cachedLuminances = this._toolbarLuminanceCache;
+ let luminances = new Map();
+ for (let toolbar of document.querySelectorAll(toolbarSelector)) {
+ // toolbars *should* all have ids, but guard anyway to avoid blowing up
+ let cacheKey =
+ toolbar.id && toolbar.id + JSON.stringify(this._windowState);
+ // lookup cached luminance value for this toolbar in this window state
+ let luminance = cacheKey && cachedLuminances.get(cacheKey);
+ if (isNaN(luminance)) {
+ let [r, g, b] = parseRGB(getComputedStyle(toolbar).color);
+ luminance = 0.2125 * r + 0.7154 * g + 0.0721 * b;
+ if (cacheKey) {
+ cachedLuminances.set(cacheKey, luminance);
+ }
+ }
+ luminances.set(toolbar, luminance);
+ }
+
+ const luminanceThreshold = 127; // In between 0 and 255
+ for (let [toolbar, luminance] of luminances) {
+ if (luminance <= luminanceThreshold) {
+ toolbar.removeAttribute("brighttext");
+ } else {
+ toolbar.setAttribute("brighttext", "true");
+ }
+ }
+ },
+};
+
+var PanicButtonNotifier = {
+ init() {
+ this._initialized = true;
+ if (window.PanicButtonNotifierShouldNotify) {
+ delete window.PanicButtonNotifierShouldNotify;
+ this.notify();
+ }
+ },
+ createPanelIfNeeded() {
+ // Lazy load the panic-button-success-notification panel the first time we need to display it.
+ if (!document.getElementById("panic-button-success-notification")) {
+ let template = document.getElementById("panicButtonNotificationTemplate");
+ template.replaceWith(template.content);
+ }
+ },
+ notify() {
+ if (!this._initialized) {
+ window.PanicButtonNotifierShouldNotify = true;
+ return;
+ }
+ // Display notification panel here...
+ try {
+ this.createPanelIfNeeded();
+ let popup = document.getElementById("panic-button-success-notification");
+ popup.hidden = false;
+ // To close the popup in 3 seconds after the popup is shown but left uninteracted.
+ let onTimeout = () => {
+ PanicButtonNotifier.close();
+ removeListeners();
+ };
+ popup.addEventListener("popupshown", function () {
+ PanicButtonNotifier.timer = setTimeout(onTimeout, 3000);
+ });
+ // To prevent the popup from closing when user tries to interact with the
+ // popup using mouse or keyboard.
+ let onUserInteractsWithPopup = () => {
+ clearTimeout(PanicButtonNotifier.timer);
+ removeListeners();
+ };
+ popup.addEventListener("mouseover", onUserInteractsWithPopup);
+ window.addEventListener("keydown", onUserInteractsWithPopup);
+ let removeListeners = () => {
+ popup.removeEventListener("mouseover", onUserInteractsWithPopup);
+ window.removeEventListener("keydown", onUserInteractsWithPopup);
+ popup.removeEventListener("popuphidden", removeListeners);
+ };
+ popup.addEventListener("popuphidden", removeListeners);
+
+ let widget = CustomizableUI.getWidget("panic-button").forWindow(window);
+ let anchor = widget.anchor.icon;
+ popup.openPopup(anchor, popup.getAttribute("position"));
+ } catch (ex) {
+ console.error(ex);
+ }
+ },
+ close() {
+ let popup = document.getElementById("panic-button-success-notification");
+ popup.hidePopup();
+ },
+};
+
+const SafeBrowsingNotificationBox = {
+ _currentURIBaseDomain: null,
+ show(title, buttons) {
+ let uri = gBrowser.currentURI;
+
+ // start tracking host so that we know when we leave the domain
+ try {
+ this._currentURIBaseDomain = Services.eTLD.getBaseDomain(uri);
+ } catch (e) {
+ // If we can't get the base domain, fallback to use host instead. However,
+ // host is sometimes empty when the scheme is file. In this case, just use
+ // spec.
+ this._currentURIBaseDomain = uri.asciiHost || uri.asciiSpec;
+ }
+
+ let notificationBox = gBrowser.getNotificationBox();
+ let value = "blocked-badware-page";
+
+ let previousNotification = notificationBox.getNotificationWithValue(value);
+ if (previousNotification) {
+ notificationBox.removeNotification(previousNotification);
+ }
+
+ let notification = notificationBox.appendNotification(
+ value,
+ {
+ label: title,
+ image: "chrome://global/skin/icons/blocked.svg",
+ priority: notificationBox.PRIORITY_CRITICAL_HIGH,
+ },
+ buttons
+ );
+ // Persist the notification until the user removes so it
+ // doesn't get removed on redirects.
+ notification.persistence = -1;
+ },
+ onLocationChange(aLocationURI) {
+ // take this to represent that you haven't visited a bad place
+ if (!this._currentURIBaseDomain) {
+ return;
+ }
+
+ let newURIBaseDomain = Services.eTLD.getBaseDomain(aLocationURI);
+
+ if (newURIBaseDomain !== this._currentURIBaseDomain) {
+ let notificationBox = gBrowser.getNotificationBox();
+ let notification = notificationBox.getNotificationWithValue(
+ "blocked-badware-page"
+ );
+ if (notification) {
+ notificationBox.removeNotification(notification, false);
+ }
+
+ this._currentURIBaseDomain = null;
+ }
+ },
+};
+
+/**
+ * The TabDialogBox supports opening window dialogs as SubDialogs on the tab and content
+ * level. Both tab and content dialogs have their own separate managers.
+ * Dialogs will be queued FIFO and cover the web content.
+ * Dialogs are closed when the user reloads or leaves the page.
+ * While a dialog is open PopupNotifications, such as permission prompts, are
+ * suppressed.
+ */
+class TabDialogBox {
+ static _containerFor(browser) {
+ return browser.closest(".browserStack, .webextension-popup-stack");
+ }
+
+ constructor(browser) {
+ this._weakBrowserRef = Cu.getWeakReference(browser);
+
+ // Create parent element for tab dialogs
+ let template = document.getElementById("dialogStackTemplate");
+ let dialogStack = template.content.cloneNode(true).firstElementChild;
+ dialogStack.classList.add("tab-prompt-dialog");
+
+ TabDialogBox._containerFor(browser).appendChild(dialogStack);
+
+ // Initially the stack only contains the template
+ let dialogTemplate = dialogStack.firstElementChild;
+
+ // Create dialog manager for prompts at the tab level.
+ this._tabDialogManager = new SubDialogManager({
+ dialogStack,
+ dialogTemplate,
+ orderType: SubDialogManager.ORDER_QUEUE,
+ allowDuplicateDialogs: true,
+ dialogOptions: {
+ consumeOutsideClicks: false,
+ },
+ });
+ }
+
+ /**
+ * Open a dialog on tab or content level.
+ * @param {String} aURL - URL of the dialog to load in the tab box.
+ * @param {Object} [aOptions]
+ * @param {String} [aOptions.features] - Comma separated list of window
+ * features.
+ * @param {Boolean} [aOptions.allowDuplicateDialogs] - Whether to allow
+ * showing multiple dialogs with aURL at the same time. If false calls for
+ * duplicate dialogs will be dropped.
+ * @param {String} [aOptions.sizeTo] - Pass "available" to stretch dialog to
+ * roughly content size. Any max-width or max-height style values on the document root
+ * will also be applied to the dialog box.
+ * @param {Boolean} [aOptions.keepOpenSameOriginNav] - By default dialogs are
+ * aborted on any navigation.
+ * Set to true to keep the dialog open for same origin navigation.
+ * @param {Number} [aOptions.modalType] - The modal type to create the dialog for.
+ * By default, we show the dialog for tab prompts.
+ * @param {Boolean} [aOptions.hideContent] - When true, we are about to show a prompt that is requesting the
+ * users credentials for a toplevel load of a resource from a base domain different from the base domain of the currently loaded page.
+ * To avoid auth prompt spoofing (see bug 791594) we hide the current sites content
+ * (among other protection mechanisms, that are not handled here, see the bug for reference).
+ * @returns {Object} [result] Returns an object { closedPromise, dialog }.
+ * @returns {Promise} [result.closedPromise] Resolves once the dialog has been closed.
+ * @returns {SubDialog} [result.dialog] A reference to the opened SubDialog.
+ */
+ open(
+ aURL,
+ {
+ features = null,
+ allowDuplicateDialogs = true,
+ sizeTo,
+ keepOpenSameOriginNav,
+ modalType = null,
+ allowFocusCheckbox = false,
+ hideContent = false,
+ } = {},
+ ...aParams
+ ) {
+ let resolveClosed;
+ let closedPromise = new Promise(resolve => (resolveClosed = resolve));
+ // Get the dialog manager to open the prompt with.
+ let dialogManager =
+ modalType === Ci.nsIPrompt.MODAL_TYPE_CONTENT
+ ? this.getContentDialogManager()
+ : this._tabDialogManager;
+
+ let hasDialogs = () =>
+ this._tabDialogManager.hasDialogs ||
+ this._contentDialogManager?.hasDialogs;
+
+ if (!hasDialogs()) {
+ this._onFirstDialogOpen();
+ }
+
+ let closingCallback = event => {
+ if (!hasDialogs()) {
+ this._onLastDialogClose();
+ }
+
+ if (allowFocusCheckbox && !event.detail?.abort) {
+ this.maybeSetAllowTabSwitchPermission(event.target);
+ }
+ };
+
+ if (modalType == Ci.nsIPrompt.MODAL_TYPE_CONTENT) {
+ sizeTo = "limitheight";
+ }
+
+ // Open dialog and resolve once it has been closed
+ let dialog = dialogManager.open(
+ aURL,
+ {
+ features,
+ allowDuplicateDialogs,
+ sizeTo,
+ closingCallback,
+ closedCallback: resolveClosed,
+ hideContent,
+ },
+ ...aParams
+ );
+
+ // Marking the dialog externally, instead of passing it as an option.
+ // The SubDialog(Manager) does not care about navigation.
+ // dialog can be null here if allowDuplicateDialogs = false.
+ if (dialog) {
+ dialog._keepOpenSameOriginNav = keepOpenSameOriginNav;
+ }
+ return { closedPromise, dialog };
+ }
+
+ _onFirstDialogOpen() {
+ // Hide PopupNotifications to prevent them from covering up dialogs.
+ this.browser.setAttribute("tabDialogShowing", true);
+ UpdatePopupNotificationsVisibility();
+
+ // Register listeners
+ this._lastPrincipal = this.browser.contentPrincipal;
+ this.browser.addProgressListener(this, Ci.nsIWebProgress.NOTIFY_LOCATION);
+
+ this.tab?.addEventListener("TabClose", this);
+ }
+
+ _onLastDialogClose() {
+ // Show PopupNotifications again.
+ this.browser.removeAttribute("tabDialogShowing");
+ UpdatePopupNotificationsVisibility();
+
+ // Clean up listeners
+ this.browser.removeProgressListener(this);
+ this._lastPrincipal = null;
+
+ this.tab?.removeEventListener("TabClose", this);
+ }
+
+ _buildContentPromptDialog() {
+ let template = document.getElementById("dialogStackTemplate");
+ let contentDialogStack = template.content.cloneNode(true).firstElementChild;
+ contentDialogStack.classList.add("content-prompt-dialog");
+
+ // Create a dialog manager for content prompts.
+ let browserContainer = TabDialogBox._containerFor(this.browser);
+ let tabPromptDialog = browserContainer.querySelector(".tab-prompt-dialog");
+ browserContainer.insertBefore(contentDialogStack, tabPromptDialog);
+
+ let contentDialogTemplate = contentDialogStack.firstElementChild;
+ this._contentDialogManager = new SubDialogManager({
+ dialogStack: contentDialogStack,
+ dialogTemplate: contentDialogTemplate,
+ orderType: SubDialogManager.ORDER_QUEUE,
+ allowDuplicateDialogs: true,
+ dialogOptions: {
+ consumeOutsideClicks: false,
+ },
+ });
+ }
+
+ handleEvent(event) {
+ if (event.type !== "TabClose") {
+ return;
+ }
+ this.abortAllDialogs();
+ }
+
+ abortAllDialogs() {
+ this._tabDialogManager.abortDialogs();
+ this._contentDialogManager?.abortDialogs();
+ }
+
+ focus() {
+ // Prioritize focusing the dialog manager for tab prompts
+ if (this._tabDialogManager._dialogs.length) {
+ this._tabDialogManager.focusTopDialog();
+ return;
+ }
+ this._contentDialogManager?.focusTopDialog();
+ }
+
+ /**
+ * If the user navigates away or refreshes the page, close all dialogs for
+ * the current browser.
+ */
+ onLocationChange(aWebProgress, aRequest, aLocation, aFlags) {
+ if (
+ !aWebProgress.isTopLevel ||
+ aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT
+ ) {
+ return;
+ }
+
+ // Dialogs can be exempt from closing on same origin location change.
+ let filterFn;
+
+ // Test for same origin location change
+ if (
+ this._lastPrincipal?.isSameOrigin(
+ aLocation,
+ this.browser.browsingContext.usePrivateBrowsing
+ )
+ ) {
+ filterFn = dialog => !dialog._keepOpenSameOriginNav;
+ }
+
+ this._lastPrincipal = this.browser.contentPrincipal;
+
+ this._tabDialogManager.abortDialogs(filterFn);
+ this._contentDialogManager?.abortDialogs(filterFn);
+ }
+
+ get tab() {
+ return gBrowser.getTabForBrowser(this.browser);
+ }
+
+ get browser() {
+ let browser = this._weakBrowserRef.get();
+ if (!browser) {
+ throw new Error("Stale dialog box! The associated browser is gone.");
+ }
+ return browser;
+ }
+
+ getTabDialogManager() {
+ return this._tabDialogManager;
+ }
+
+ getContentDialogManager() {
+ if (!this._contentDialogManager) {
+ this._buildContentPromptDialog();
+ }
+ return this._contentDialogManager;
+ }
+
+ onNextPromptShowAllowFocusCheckboxFor(principal) {
+ this._allowTabFocusByPromptPrincipal = principal;
+ }
+
+ /**
+ * Sets the "focus-tab-by-prompt" permission for the dialog.
+ */
+ maybeSetAllowTabSwitchPermission(dialog) {
+ let checkbox = dialog.querySelector("checkbox");
+
+ if (checkbox.checked) {
+ Services.perms.addFromPrincipal(
+ this._allowTabFocusByPromptPrincipal,
+ "focus-tab-by-prompt",
+ Services.perms.ALLOW_ACTION
+ );
+ }
+
+ // Don't show the "allow tab switch checkbox" for subsequent prompts.
+ this._allowTabFocusByPromptPrincipal = null;
+ }
+}
+
+TabDialogBox.prototype.QueryInterface = ChromeUtils.generateQI([
+ "nsIWebProgressListener",
+ "nsISupportsWeakReference",
+]);
+
+function TabModalPromptBox(browser) {
+ this._weakBrowserRef = Cu.getWeakReference(browser);
+ /*
+ * These WeakMaps holds the TabModalPrompt instances, key to the <tabmodalprompt> prompt
+ * in the DOM. We don't want to hold the instances directly to avoid leaking.
+ *
+ * WeakMap also prevents us from reading back its insertion order.
+ * Order of the elements in the DOM should be the only order to consider.
+ */
+ this._contentPrompts = new WeakMap();
+ this._tabPrompts = new WeakMap();
+}
+
+TabModalPromptBox.prototype = {
+ _promptCloseCallback(
+ onCloseCallback,
+ principalToAllowFocusFor,
+ allowFocusCheckbox,
+ ...args
+ ) {
+ if (
+ principalToAllowFocusFor &&
+ allowFocusCheckbox &&
+ allowFocusCheckbox.checked
+ ) {
+ Services.perms.addFromPrincipal(
+ principalToAllowFocusFor,
+ "focus-tab-by-prompt",
+ Services.perms.ALLOW_ACTION
+ );
+ }
+ onCloseCallback.apply(this, args);
+ },
+
+ getPrompt(promptEl) {
+ if (promptEl.classList.contains("tab-prompt")) {
+ return this._tabPrompts.get(promptEl);
+ }
+ return this._contentPrompts.get(promptEl);
+ },
+
+ appendPrompt(args, onCloseCallback) {
+ let browser = this.browser;
+ let newPrompt = new TabModalPrompt(browser.ownerGlobal);
+
+ if (args.modalType === Ci.nsIPrompt.MODAL_TYPE_TAB) {
+ newPrompt.element.classList.add("tab-prompt");
+ this._tabPrompts.set(newPrompt.element, newPrompt);
+ } else {
+ newPrompt.element.classList.add("content-prompt");
+ this._contentPrompts.set(newPrompt.element, newPrompt);
+ }
+
+ browser.parentNode.insertBefore(
+ newPrompt.element,
+ browser.nextElementSibling
+ );
+ browser.setAttribute("tabmodalPromptShowing", true);
+
+ // Indicate if a tab modal chrome prompt is being shown so that
+ // PopupNotifications are suppressed.
+ if (
+ args.modalType === Ci.nsIPrompt.MODAL_TYPE_TAB &&
+ !browser.hasAttribute("tabmodalChromePromptShowing")
+ ) {
+ browser.setAttribute("tabmodalChromePromptShowing", true);
+ // Notify popup notifications of the UI change so they hide their
+ // notification panels.
+ UpdatePopupNotificationsVisibility();
+ }
+
+ let prompts = this.listPrompts(args.modalType);
+ if (prompts.length > 1) {
+ // Let's hide ourself behind the current prompt.
+ newPrompt.element.hidden = true;
+ }
+
+ let principalToAllowFocusFor = this._allowTabFocusByPromptPrincipal;
+ delete this._allowTabFocusByPromptPrincipal;
+
+ let allowFocusCheckbox; // Define outside the if block so we can bind it into the callback.
+ let hostForAllowFocusCheckbox = "";
+ try {
+ hostForAllowFocusCheckbox = principalToAllowFocusFor.URI.host;
+ } catch (ex) {
+ /* Ignore exceptions for host-less URIs */
+ }
+ if (hostForAllowFocusCheckbox) {
+ let allowFocusRow = document.createElement("div");
+
+ let spacer = document.createElement("div");
+ allowFocusRow.appendChild(spacer);
+
+ allowFocusCheckbox = document.createXULElement("checkbox");
+ document.l10n.setAttributes(
+ allowFocusCheckbox,
+ "tabbrowser-allow-dialogs-to-get-focus",
+ { domain: hostForAllowFocusCheckbox }
+ );
+ allowFocusRow.appendChild(allowFocusCheckbox);
+
+ newPrompt.ui.rows.append(allowFocusRow);
+ }
+
+ let tab = gBrowser.getTabForBrowser(browser);
+ let closeCB = this._promptCloseCallback.bind(
+ null,
+ onCloseCallback,
+ principalToAllowFocusFor,
+ allowFocusCheckbox
+ );
+ newPrompt.init(args, tab, closeCB);
+ return newPrompt;
+ },
+
+ removePrompt(aPrompt) {
+ let { modalType } = aPrompt.args;
+ if (modalType === Ci.nsIPrompt.MODAL_TYPE_TAB) {
+ this._tabPrompts.delete(aPrompt.element);
+ } else {
+ this._contentPrompts.delete(aPrompt.element);
+ }
+
+ let browser = this.browser;
+ aPrompt.element.remove();
+
+ let prompts = this.listPrompts(modalType);
+ if (prompts.length) {
+ let prompt = prompts[prompts.length - 1];
+ prompt.element.hidden = false;
+ // Because we were hidden before, this won't have been possible, so do it now:
+ prompt.Dialog.setDefaultFocus();
+ } else if (modalType === Ci.nsIPrompt.MODAL_TYPE_TAB) {
+ // If we remove the last tab chrome prompt, also remove the browser
+ // attribute.
+ browser.removeAttribute("tabmodalChromePromptShowing");
+ // Notify popup notifications of the UI change so they show notification
+ // panels again.
+ UpdatePopupNotificationsVisibility();
+ }
+ // Check if all prompts are closed
+ if (!this._hasPrompts()) {
+ browser.removeAttribute("tabmodalPromptShowing");
+ browser.focus();
+ }
+ },
+
+ /**
+ * Checks if the prompt box has prompt elements.
+ * @returns {Boolean} - true if there are prompt elements.
+ */
+ _hasPrompts() {
+ return !!this._getPromptElements().length;
+ },
+
+ /**
+ * Get list of current prompt elements.
+ * @param {Number} [aModalType] - Optionally filter by
+ * Ci.nsIPrompt.MODAL_TYPE_.
+ * @returns {NodeList} - A list of tabmodalprompt elements.
+ */
+ _getPromptElements(aModalType = null) {
+ let selector = "tabmodalprompt";
+
+ if (aModalType != null) {
+ if (aModalType === Ci.nsIPrompt.MODAL_TYPE_TAB) {
+ selector += ".tab-prompt";
+ } else {
+ selector += ".content-prompt";
+ }
+ }
+ return this.browser.parentNode.querySelectorAll(selector);
+ },
+
+ /**
+ * Get a list of all TabModalPrompt objects associated with the prompt box.
+ * @param {Number} [aModalType] - Optionally filter by
+ * Ci.nsIPrompt.MODAL_TYPE_.
+ * @returns {TabModalPrompt[]} - An array of TabModalPrompt objects.
+ */
+ listPrompts(aModalType = null) {
+ // Get the nodelist, then return the TabModalPrompt instances as an array
+ let promptMap;
+
+ if (aModalType) {
+ if (aModalType === Ci.nsIPrompt.MODAL_TYPE_TAB) {
+ promptMap = this._tabPrompts;
+ } else {
+ promptMap = this._contentPrompts;
+ }
+ }
+
+ let elements = this._getPromptElements(aModalType);
+
+ if (promptMap) {
+ return [...elements].map(el => promptMap.get(el));
+ }
+ return [...elements].map(
+ el => this._contentPrompts.get(el) || this._tabPrompts.get(el)
+ );
+ },
+
+ onNextPromptShowAllowFocusCheckboxFor(principal) {
+ this._allowTabFocusByPromptPrincipal = principal;
+ },
+
+ get browser() {
+ let browser = this._weakBrowserRef.get();
+ if (!browser) {
+ throw new Error("Stale promptbox! The associated browser is gone.");
+ }
+ return browser;
+ },
+};
+
+// Handle window-modal prompts that we want to display with the same style as
+// tab-modal prompts.
+var gDialogBox = {
+ _dialog: null,
+ _nextOpenJumpsQueue: false,
+ _queued: [],
+
+ // Used to wait for a `close` event from the HTML
+ // dialog. The event is fired asynchronously, which means
+ // that if we open another dialog immediately after the
+ // previous one, we might be confused into thinking a
+ // `close` event for the old dialog is for the new one.
+ // As they have the same event target, we have no way of
+ // distinguishing them. So we wait for the `close` event
+ // to have happened before allowing another dialog to open.
+ _didCloseHTMLDialog: null,
+ // Whether we managed to open the dialog we tried to open.
+ // Used to avoid waiting for the above callback in case
+ // of an error opening the dialog.
+ _didOpenHTMLDialog: false,
+
+ get dialog() {
+ return this._dialog;
+ },
+
+ get isOpen() {
+ return !!this._dialog;
+ },
+
+ replaceDialogIfOpen() {
+ this._dialog?.close();
+ this._nextOpenJumpsQueue = true;
+ },
+
+ async open(uri, args) {
+ // If we need to queue, some callers indicate they should go first.
+ const queueMethod = this._nextOpenJumpsQueue ? "unshift" : "push";
+ this._nextOpenJumpsQueue = false;
+
+ // If we already have a dialog opened and are trying to open another,
+ // queue the next one to be opened later.
+ if (this.isOpen) {
+ return new Promise((resolve, reject) => {
+ this._queued[queueMethod]({ resolve, reject, uri, args });
+ });
+ }
+
+ // We're not open. If we're in a modal state though, we can't
+ // show the dialog effectively. To avoid hanging by deadlock,
+ // just return immediately for sync prompts:
+ if (window.windowUtils.isInModalState() && !args.getProperty("async")) {
+ throw Components.Exception(
+ "Prompt could not be shown.",
+ Cr.NS_ERROR_NOT_AVAILABLE
+ );
+ }
+
+ // Indicate if we should wait for the dialog to close.
+ this._didOpenHTMLDialog = false;
+ let haveClosedPromise = new Promise(resolve => {
+ this._didCloseHTMLDialog = resolve;
+ });
+
+ // Bring the window to the front in case we're minimized or occluded:
+ window.focus();
+
+ try {
+ await this._open(uri, args);
+ } catch (ex) {
+ console.error(ex);
+ } finally {
+ let dialog = document.getElementById("window-modal-dialog");
+ if (dialog.open) {
+ dialog.close();
+ }
+ // If the dialog was opened successfully, then we can wait for it
+ // to close before trying to open any others.
+ if (this._didOpenHTMLDialog) {
+ await haveClosedPromise;
+ }
+ dialog.style.visibility = "hidden";
+ dialog.style.height = "0";
+ dialog.style.width = "0";
+ document.documentElement.removeAttribute("window-modal-open");
+ dialog.removeEventListener("dialogopen", this);
+ dialog.removeEventListener("close", this);
+ this._updateMenuAndCommandState(true /* to enable */);
+ this._dialog = null;
+ UpdatePopupNotificationsVisibility();
+ }
+ if (this._queued.length) {
+ setTimeout(() => this._openNextDialog(), 0);
+ }
+ return args;
+ },
+
+ _openNextDialog() {
+ if (!this.isOpen) {
+ let { resolve, reject, uri, args } = this._queued.shift();
+ this.open(uri, args).then(resolve, reject);
+ }
+ },
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "dialogopen":
+ this._dialog.focus(true);
+ break;
+ case "close":
+ this._didCloseHTMLDialog();
+ this._dialog.close();
+ break;
+ }
+ },
+
+ _open(uri, args) {
+ // Get this offset before we touch style below, as touching style seems
+ // to reset the cached layout bounds.
+ let offset = window.windowUtils.getBoundsWithoutFlushing(
+ gBrowser.selectedBrowser
+ ).top;
+ let parentElement = document.getElementById("window-modal-dialog");
+ parentElement.style.setProperty("--chrome-offset", offset + "px");
+ parentElement.style.removeProperty("visibility");
+ parentElement.style.removeProperty("width");
+ parentElement.style.removeProperty("height");
+ document.documentElement.setAttribute("window-modal-open", true);
+ // Call this first so the contents show up and get layout, which is
+ // required for SubDialog to work.
+ parentElement.showModal();
+ this._didOpenHTMLDialog = true;
+
+ // Disable menus and shortcuts.
+ this._updateMenuAndCommandState(false /* to disable */);
+
+ // Now actually set up the dialog contents:
+ let template = document.getElementById("window-modal-dialog-template")
+ .content.firstElementChild;
+ parentElement.addEventListener("dialogopen", this);
+ parentElement.addEventListener("close", this);
+ this._dialog = new SubDialog({
+ template,
+ parentElement,
+ id: "window-modal-dialog-subdialog",
+ options: {
+ consumeOutsideClicks: false,
+ },
+ });
+ let closedPromise = new Promise(resolve => {
+ this._closedCallback = function () {
+ PromptUtils.fireDialogEvent(window, "DOMModalDialogClosed");
+ resolve();
+ };
+ });
+ this._dialog.open(
+ uri,
+ {
+ features: "resizable=no",
+ modalType: Ci.nsIPrompt.MODAL_TYPE_INTERNAL_WINDOW,
+ closedCallback: () => {
+ this._closedCallback();
+ },
+ },
+ args
+ );
+ UpdatePopupNotificationsVisibility();
+ return closedPromise;
+ },
+
+ _nonUpdatableElements: new Set([
+ // Make an exception for debugging tools, for developer ease of use.
+ "key_browserConsole",
+ "key_browserToolbox",
+
+ // Don't touch the editing keys/commands which we might want inside the dialog.
+ "key_undo",
+ "key_redo",
+
+ "key_cut",
+ "key_copy",
+ "key_paste",
+ "key_delete",
+ "key_selectAll",
+ ]),
+
+ _updateMenuAndCommandState(shouldBeEnabled) {
+ let editorCommands = document.getElementById("editMenuCommands");
+ // For the following items, set or clear disabled state:
+ // - toplevel menubar items (will affect inner items on macOS)
+ // - command elements
+ // - key elements not connected to command elements.
+ for (let element of document.querySelectorAll(
+ "menubar > menu, command, key:not([command])"
+ )) {
+ if (
+ editorCommands?.contains(element) ||
+ (element.id && this._nonUpdatableElements.has(element.id))
+ ) {
+ continue;
+ }
+ if (element.nodeName == "key" && element.command) {
+ continue;
+ }
+ if (!shouldBeEnabled) {
+ if (element.getAttribute("disabled") != "true") {
+ element.setAttribute("disabled", true);
+ } else {
+ element.setAttribute("wasdisabled", true);
+ }
+ } else if (element.getAttribute("wasdisabled") != "true") {
+ element.removeAttribute("disabled");
+ } else {
+ element.removeAttribute("wasdisabled");
+ }
+ }
+ },
+};
+
+// browser.js loads in the library window, too, but we can only show prompts
+// in the main browser window:
+if (window.location.href != AppConstants.BROWSER_CHROME_URL) {
+ gDialogBox = null;
+}
+
+var ConfirmationHint = {
+ _timerID: null,
+
+ /**
+ * Shows a transient, non-interactive confirmation hint anchored to an
+ * element, usually used in response to a user action to reaffirm that it was
+ * successful and potentially provide extra context. Examples for such hints:
+ * - "Saved to bookmarks" after bookmarking a page
+ * - "Sent!" after sending a tab to another device
+ * - "Queued (offline)" when attempting to send a tab to another device
+ * while offline
+ *
+ * @param anchor (DOM node, required)
+ * The anchor for the panel.
+ * @param messageId (string, required)
+ * For getting the message string from confirmationHints.ftl
+ * @param options (object, optional)
+ * An object with the following optional properties:
+ * - event (DOM event): The event that triggered the feedback
+ * - descriptionId (string): message ID of the description text
+ *
+ */
+ show(anchor, messageId, options = {}) {
+ this._reset();
+
+ MozXULElement.insertFTLIfNeeded("toolkit/branding/brandings.ftl");
+ MozXULElement.insertFTLIfNeeded("browser/confirmationHints.ftl");
+ document.l10n.setAttributes(this._message, messageId);
+
+ if (options.descriptionId) {
+ document.l10n.setAttributes(this._description, options.descriptionId);
+ this._description.hidden = false;
+ this._panel.classList.add("with-description");
+ } else {
+ this._description.hidden = true;
+ this._panel.classList.remove("with-description");
+ }
+
+ this._panel.setAttribute("data-message-id", messageId);
+
+ // The timeout value used here allows the panel to stay open for
+ // 3s after the text transition (duration=120ms) has finished.
+ // If there is a description, we show for 6s after the text transition.
+ const DURATION = options.showDescription ? 6000 : 3000;
+ this._panel.addEventListener(
+ "popupshown",
+ () => {
+ this._animationBox.setAttribute("animate", "true");
+ this._timerID = setTimeout(() => {
+ this._panel.hidePopup(true);
+ }, DURATION + 120);
+ },
+ { once: true }
+ );
+
+ this._panel.addEventListener(
+ "popuphidden",
+ () => {
+ // reset the timerId in case our timeout wasn't the cause of the popup being hidden
+ this._reset();
+ },
+ { once: true }
+ );
+
+ this._panel.openPopup(anchor, {
+ position: "bottomcenter topleft",
+ triggerEvent: options.event,
+ });
+ },
+
+ _reset() {
+ if (this._timerID) {
+ clearTimeout(this._timerID);
+ this._timerID = null;
+ }
+ if (this.__panel) {
+ this._animationBox.removeAttribute("animate");
+ this._panel.removeAttribute("data-message-id");
+ }
+ },
+
+ get _panel() {
+ this._ensurePanel();
+ return this.__panel;
+ },
+
+ get _animationBox() {
+ this._ensurePanel();
+ delete this._animationBox;
+ return (this._animationBox = document.getElementById(
+ "confirmation-hint-checkmark-animation-container"
+ ));
+ },
+
+ get _message() {
+ this._ensurePanel();
+ delete this._message;
+ return (this._message = document.getElementById(
+ "confirmation-hint-message"
+ ));
+ },
+
+ get _description() {
+ this._ensurePanel();
+ delete this._description;
+ return (this._description = document.getElementById(
+ "confirmation-hint-description"
+ ));
+ },
+
+ _ensurePanel() {
+ if (!this.__panel) {
+ let wrapper = document.getElementById("confirmation-hint-wrapper");
+ wrapper.replaceWith(wrapper.content);
+ this.__panel = document.getElementById("confirmation-hint");
+ }
+ },
+};
+
+var FirefoxViewHandler = {
+ tab: null,
+ BUTTON_ID: "firefox-view-button",
+ _enabled: false,
+ get button() {
+ return document.getElementById(this.BUTTON_ID);
+ },
+ init() {
+ CustomizableUI.addListener(this);
+
+ this._updateEnabledState = this._updateEnabledState.bind(this);
+ this._updateEnabledState();
+ NimbusFeatures.majorRelease2022.onUpdate(this._updateEnabledState);
+
+ if (this._enabled) {
+ this._toggleNotificationDot(
+ FirefoxViewNotificationManager.shouldNotificationDotBeShowing()
+ );
+ }
+ ChromeUtils.defineESModuleGetters(this, {
+ SyncedTabs: "resource://services-sync/SyncedTabs.sys.mjs",
+ });
+ Services.obs.addObserver(this, "firefoxview-notification-dot-update");
+ },
+ uninit() {
+ CustomizableUI.removeListener(this);
+ Services.obs.removeObserver(this, "firefoxview-notification-dot-update");
+ NimbusFeatures.majorRelease2022.offUpdate(this._updateEnabledState);
+ },
+ _updateEnabledState() {
+ this._enabled = NimbusFeatures.majorRelease2022.getVariable("firefoxView");
+ // We use a root attribute because there's no guarantee the button is in the
+ // DOM, and visibility changes need to take effect even if it isn't in the DOM
+ // right now.
+ document.documentElement.toggleAttribute(
+ "firefoxviewhidden",
+ !this._enabled
+ );
+ document.getElementById("menu_openFirefoxView").hidden = !this._enabled;
+ },
+ onWidgetRemoved(aWidgetId) {
+ if (aWidgetId == this.BUTTON_ID && this.tab) {
+ gBrowser.removeTab(this.tab);
+ }
+ },
+ onWidgetAdded(aWidgetId) {
+ if (aWidgetId === this.BUTTON_ID) {
+ this.button.removeAttribute("open");
+ }
+ },
+ openTab(event) {
+ if (event?.type == "mousedown" && event?.button != 0) {
+ return;
+ }
+ if (!CustomizableUI.getPlacementOfWidget(this.BUTTON_ID)) {
+ CustomizableUI.addWidgetToArea(
+ this.BUTTON_ID,
+ CustomizableUI.AREA_TABSTRIP,
+ CustomizableUI.getPlacementOfWidget("tabbrowser-tabs").position
+ );
+ }
+ if (!this.tab) {
+ this.tab = gBrowser.addTrustedTab("about:firefoxview");
+ this.tab.addEventListener("TabClose", this, { once: true });
+ gBrowser.tabContainer.addEventListener("TabSelect", this);
+ window.addEventListener("activate", this);
+ gBrowser.hideTab(this.tab);
+ this.button.setAttribute("aria-controls", this.tab.linkedPanel);
+ }
+ // we put this here to avoid a race condition that would occur
+ // if this was called in response to "TabSelect"
+ this._closeDeviceConnectedTab();
+ gBrowser.selectedTab = this.tab;
+ },
+ handleEvent(e) {
+ switch (e.type) {
+ case "TabSelect":
+ const selected = e.target == this.tab;
+ this.button?.toggleAttribute("open", selected);
+ this.button?.setAttribute("aria-pressed", selected);
+ this._recordViewIfTabSelected();
+ this._onTabForegrounded();
+ if (e.target == this.tab) {
+ // If Fx View is opened, add temporary style to make first available tab focusable
+ gBrowser.visibleTabs[0].style["-moz-user-focus"] = "normal";
+ } else {
+ // When Fx View is closed, remove temporary -moz-user-focus style from first available tab
+ gBrowser.visibleTabs[0].style.removeProperty("-moz-user-focus");
+ }
+ break;
+ case "TabClose":
+ this.tab = null;
+ gBrowser.tabContainer.removeEventListener("TabSelect", this);
+ this.button?.removeAttribute("aria-controls");
+ break;
+ case "activate":
+ this._onTabForegrounded();
+ break;
+ }
+ },
+ observe(sub, topic, data) {
+ switch (topic) {
+ case "firefoxview-notification-dot-update":
+ let shouldShow = data === "true";
+ this._toggleNotificationDot(shouldShow);
+ break;
+ }
+ },
+ _closeDeviceConnectedTab() {
+ if (!TabsSetupFlowManager.didFxaTabOpen) {
+ return;
+ }
+ // close the tab left behind after a user pairs a device and
+ // is redirected back to the Firefox View tab
+ const fxaRoot = Services.prefs.getCharPref(
+ "identity.fxaccounts.remote.root"
+ );
+ const fxDeviceConnectedTab = gBrowser.tabs.find(tab =>
+ tab.linkedBrowser.currentURI.displaySpec.startsWith(
+ `${fxaRoot}pair/auth/complete`
+ )
+ );
+
+ if (!fxDeviceConnectedTab) {
+ return;
+ }
+
+ if (gBrowser.tabs.length <= 2) {
+ // if its the only tab besides the Firefox View tab,
+ // open a new tab first so the browser doesn't close
+ gBrowser.addTrustedTab("about:newtab");
+ }
+ gBrowser.removeTab(fxDeviceConnectedTab);
+ TabsSetupFlowManager.didFxaTabOpen = false;
+ },
+ _onTabForegrounded() {
+ if (this.tab?.selected) {
+ this.SyncedTabs.syncTabs();
+ Services.obs.notifyObservers(
+ null,
+ "firefoxview-notification-dot-update",
+ "false"
+ );
+ }
+ },
+ _recordViewIfTabSelected() {
+ if (this.tab?.selected) {
+ const PREF_NAME = "browser.firefox-view.view-count";
+ const MAX_VIEW_COUNT = 10;
+ let viewCount = Services.prefs.getIntPref(PREF_NAME, 0);
+ if (viewCount < MAX_VIEW_COUNT) {
+ Services.prefs.setIntPref(PREF_NAME, viewCount + 1);
+ }
+ }
+ },
+ _toggleNotificationDot(shouldShow) {
+ this.button?.toggleAttribute("attention", shouldShow);
+ },
+};
diff --git a/browser/base/content/browser.xhtml b/browser/base/content/browser.xhtml
new file mode 100644
index 0000000000..4ec9628c17
--- /dev/null
+++ b/browser/base/content/browser.xhtml
@@ -0,0 +1,175 @@
+#filter substitution
+<?xml version="1.0"?>
+# -*- Mode: HTML -*-
+#
+# 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/.
+
+<!-- The "global.css" stylesheet is imported first to allow other stylesheets to
+ override rules using selectors with the same specificity. This applies to
+ both "content" and "skin" packages, which bug 1385444 will unify later. -->
+<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?>
+
+<!-- While these stylesheets are defined in Toolkit, they are only used in the
+ main browser window, so we can load them here. Bug 1474241 is on file to
+ consider moving these widgets to the "browser" folder. -->
+<?xml-stylesheet href="chrome://global/content/tabprompts.css" type="text/css"?>
+<?xml-stylesheet href="chrome://global/skin/tabprompts.css" type="text/css"?>
+
+<?xml-stylesheet href="chrome://browser/content/browser.css" type="text/css"?>
+<?xml-stylesheet href="chrome://browser/content/tabbrowser.css" type="text/css"?>
+<?xml-stylesheet href="chrome://browser/content/downloads/downloads.css" type="text/css"?>
+<?xml-stylesheet href="chrome://browser/content/places/places.css" type="text/css"?>
+<?xml-stylesheet href="chrome://browser/content/usercontext/usercontext.css" type="text/css"?>
+<?xml-stylesheet href="chrome://browser/skin/" type="text/css"?>
+#ifdef XP_WIN
+<?xml-stylesheet href="chrome://browser/skin/browser-aero.css" type="text/css"?>
+#endif
+
+<?xml-stylesheet href="chrome://browser/skin/controlcenter/panel.css" type="text/css"?>
+<?xml-stylesheet href="chrome://browser/skin/customizableui/panelUI.css" type="text/css"?>
+<?xml-stylesheet href="chrome://browser/skin/downloads/downloads.css" type="text/css"?>
+<?xml-stylesheet href="chrome://browser/skin/searchbar.css" type="text/css"?>
+<?xml-stylesheet href="chrome://browser/skin/translations/panel.css" type="text/css"?>
+<?xml-stylesheet href="chrome://browser/skin/places/tree-icons.css" type="text/css"?>
+<?xml-stylesheet href="chrome://browser/skin/places/editBookmark.css" type="text/css"?>
+
+<html id="main-window"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns="http://www.w3.org/1999/xhtml"
+#ifdef XP_MACOSX
+ data-l10n-id="browser-main-window-mac-window-titles"
+#else
+ data-l10n-id="browser-main-window-window-titles"
+#endif
+ data-l10n-args="{&quot;content-title&quot;:&quot;CONTENTTITLE&quot;}"
+ data-l10n-attrs="data-content-title-default, data-content-title-private, data-title-default, data-title-private"
+#ifdef XP_WIN
+ chromemargin="0,2,2,2"
+#else
+ chromemargin="0,-1,-1,-1"
+#endif
+ tabsintitlebar="true"
+ windowtype="navigator:browser"
+ macanimationtype="document"
+ macnativefullscreen="true"
+ screenX="4" screenY="4"
+ sizemode="normal"
+ retargetdocumentfocus="urlbar-input"
+ scrolling="false"
+ persist="screenX screenY width height sizemode"
+ data-l10n-sync="true">
+<head>
+ <link rel="localization" href="branding/brand.ftl"/>
+ <link rel="localization" href="browser/allTabsMenu.ftl"/>
+ <link rel="localization" href="browser/appmenu.ftl"/>
+ <link rel="localization" href="browser/browser.ftl"/>
+ <link rel="localization" href="browser/browserContext.ftl"/>
+ <link rel="localization" href="browser/browserSets.ftl"/>
+ <link rel="localization" href="browser/firefoxView.ftl"/>
+ <link rel="localization" href="browser/menubar.ftl"/>
+ <link rel="localization" href="browser/originControls.ftl"/>
+ <link rel="localization" href="browser/panelUI.ftl"/>
+ <link rel="localization" href="browser/places.ftl"/>
+ <link rel="localization" href="browser/protectionsPanel.ftl"/>
+ <link rel="localization" href="browser/screenshots.ftl"/>
+ <link rel="localization" href="browser/search.ftl"/>
+ <link rel="localization" href="browser/sidebarMenu.ftl"/>
+ <link rel="localization" href="browser/tabbrowser.ftl"/>
+ <link rel="localization" href="browser/toolbarContextMenu.ftl"/>
+ <link rel="localization" href="browser/translations.ftl" />
+ <link rel="localization" href="browser/unifiedExtensions.ftl"/>
+ <link rel="localization" href="browser/webrtcIndicator.ftl"/>
+ <link rel="localization" href="toolkit/branding/accounts.ftl"/>
+ <link rel="localization" href="toolkit/branding/brandings.ftl"/>
+ <link rel="localization" href="toolkit/global/textActions.ftl"/>
+ <link rel="localization" href="toolkit/printing/printUI.ftl"/>
+ <!-- Untranslated FTL files -->
+ <link rel="localization" href="preview/firefoxSuggest.ftl" />
+ <link rel="localization" href="preview/identityCredentialNotification.ftl" />
+ <link rel="localization" href="preview/interventions.ftl" />
+ <link rel="localization" href="preview/stripOnShare.ftl" />
+
+ <title data-l10n-id="browser-main-window-title"></title>
+
+# All JS files which are needed by browser.xhtml and other top level windows to
+# support MacOS specific features *must* go into the global-scripts.inc file so
+# that they can be shared with macWindow.inc.xhtml.
+#include global-scripts.inc
+
+<script>
+ /* eslint-env mozilla/browser-window */
+ Services.scriptloader.loadSubScript("chrome://global/content/contentAreaUtils.js", this);
+ Services.scriptloader.loadSubScript("chrome://browser/content/browser-captivePortal.js", this);
+ if (AppConstants.MOZ_DATA_REPORTING) {
+ Services.scriptloader.loadSubScript("chrome://browser/content/browser-data-submission-info-bar.js", this);
+ }
+ if (!AppConstants.MOZILLA_OFFICIAL) {
+ Services.scriptloader.loadSubScript("chrome://browser/content/browser-development-helpers.js", this);
+ }
+ Services.scriptloader.loadSubScript("chrome://browser/content/browser-pageActions.js", this);
+ Services.scriptloader.loadSubScript("chrome://browser/content/browser-sidebar.js", this);
+ Services.scriptloader.loadSubScript("chrome://browser/content/browser-tabsintitlebar.js", this);
+ Services.scriptloader.loadSubScript("chrome://browser/content/browser-unified-extensions.js", this);
+ Services.scriptloader.loadSubScript("chrome://browser/content/tabbrowser.js", this);
+ Services.scriptloader.loadSubScript("chrome://browser/content/tabbrowser-tab.js", this);
+ Services.scriptloader.loadSubScript("chrome://browser/content/tabbrowser-tabs.js", this);
+ Services.scriptloader.loadSubScript("chrome://browser/content/places/places-menupopup.js", this);
+ Services.scriptloader.loadSubScript("chrome://browser/content/search/autocomplete-popup.js", this);
+ Services.scriptloader.loadSubScript("chrome://browser/content/search/searchbar.js", this);
+
+ window.onload = gBrowserInit.onLoad.bind(gBrowserInit);
+ window.onunload = gBrowserInit.onUnload.bind(gBrowserInit);
+ window.onclose = WindowIsClosing;
+
+ window.addEventListener("MozBeforeInitialXULLayout",
+ gBrowserInit.onBeforeInitialXULLayout.bind(gBrowserInit), { once: true });
+
+ // The listener of DOMContentLoaded must be set on window, rather than
+ // document, because the window can go away before the event is fired.
+ // In that case, we don't want to initialize anything, otherwise we
+ // may be leaking things because they will never be destroyed after.
+ window.addEventListener("DOMContentLoaded",
+ gBrowserInit.onDOMContentLoaded.bind(gBrowserInit), { once: true });
+</script>
+</head>
+<html:body xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+# All sets except for popupsets (commands, keys, and stringbundles)
+# *must* go into the browser-sets.inc file so that they can be shared with other
+# top level windows in macWindow.inc.xhtml.
+#include browser-sets.inc
+
+#include main-popupset.inc.xhtml
+
+#include appmenu-viewcache.inc.xhtml
+#include unified-extensions-viewcache.inc.xhtml
+
+ <html:dialog id="window-modal-dialog" style="visibility: hidden; height: 0; width: 0"/>
+ <html:template id="window-modal-dialog-template">
+ <vbox class="dialogTemplate dialogOverlay" topmost="true">
+ <hbox class="dialogBox">
+ <browser class="dialogFrame" autoscroll="false" disablehistory="true"/>
+ </hbox>
+ </vbox>
+ </html:template>
+
+#include navigator-toolbox.inc.xhtml
+
+#include browser-box.inc.xhtml
+
+ <html:template id="customizationPanel">
+ <box id="customization-container" flex="1" hidden="true"><![CDATA[
+#include ../../components/customizableui/content/customizeMode.inc.xhtml
+ ]]></box>
+ </html:template>
+
+#include fullscreen-and-pointerlock.inc.xhtml
+
+ <html:div id="a11y-announcement" role="alert"/>
+
+ <!-- Put it at the very end to make sure it's not covered by anything. -->
+ <html:div id="fullscr-toggler" hidden="hidden"/>
+</html:body>
+</html>
diff --git a/browser/base/content/contentTheme.js b/browser/base/content/contentTheme.js
new file mode 100644
index 0000000000..a2298986ba
--- /dev/null
+++ b/browser/base/content/contentTheme.js
@@ -0,0 +1,218 @@
+/* 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 prefersDarkQuery = window.matchMedia("(prefers-color-scheme: dark)");
+
+ function _isTextColorDark(r, g, b) {
+ return 0.2125 * r + 0.7154 * g + 0.0721 * b <= 110;
+ }
+
+ const inContentVariableMap = [
+ [
+ "--newtab-background-color",
+ {
+ lwtProperty: "ntp_background",
+ processColor(rgbaChannels) {
+ if (!rgbaChannels) {
+ return null;
+ }
+ const { r, g, b } = rgbaChannels;
+ // Drop alpha channel
+ return `rgb(${r}, ${g}, ${b})`;
+ },
+ },
+ ],
+ [
+ "--newtab-background-color-secondary",
+ {
+ lwtProperty: "ntp_card_background",
+ },
+ ],
+ [
+ "--newtab-text-primary-color",
+ {
+ lwtProperty: "ntp_text",
+ processColor(rgbaChannels, element) {
+ // We only have access to the browser when we're in a chrome
+ // docshell, so for now only set the color scheme in that case, and
+ // use the `lwt-newtab-brighttext` attribute as a fallback mechanism.
+ let browserStyle =
+ element.ownerGlobal?.docShell?.chromeEventHandler.style;
+
+ if (!rgbaChannels) {
+ element.removeAttribute("lwt-newtab");
+ element.toggleAttribute(
+ "lwt-newtab-brighttext",
+ prefersDarkQuery.matches
+ );
+ if (browserStyle) {
+ browserStyle.colorScheme = "";
+ }
+ return null;
+ }
+
+ element.setAttribute("lwt-newtab", "true");
+ const { r, g, b, a } = rgbaChannels;
+ let darkMode = !_isTextColorDark(r, g, b);
+ element.toggleAttribute("lwt-newtab-brighttext", darkMode);
+ if (browserStyle) {
+ browserStyle.colorScheme = darkMode ? "dark" : "light";
+ }
+
+ return `rgba(${r}, ${g}, ${b}, ${a})`;
+ },
+ },
+ ],
+ [
+ "--in-content-zap-gradient",
+ {
+ lwtProperty: "zap_gradient",
+ processColor(value) {
+ return value;
+ },
+ },
+ ],
+ [
+ "--lwt-sidebar-background-color",
+ {
+ lwtProperty: "sidebar",
+ processColor(rgbaChannels) {
+ if (!rgbaChannels) {
+ return null;
+ }
+ const { r, g, b } = rgbaChannels;
+ // Drop alpha channel
+ return `rgb(${r}, ${g}, ${b})`;
+ },
+ },
+ ],
+ [
+ "--lwt-sidebar-text-color",
+ {
+ lwtProperty: "sidebar_text",
+ processColor(rgbaChannels, element) {
+ if (!rgbaChannels) {
+ element.removeAttribute("lwt-sidebar");
+ element.removeAttribute("lwt-sidebar-brighttext");
+ return null;
+ }
+
+ element.setAttribute("lwt-sidebar", "true");
+ const { r, g, b, a } = rgbaChannels;
+ if (!_isTextColorDark(r, g, b)) {
+ element.setAttribute("lwt-sidebar-brighttext", "true");
+ } else {
+ element.removeAttribute("lwt-sidebar-brighttext");
+ }
+
+ return `rgba(${r}, ${g}, ${b}, ${a})`;
+ },
+ },
+ ],
+ [
+ "--lwt-sidebar-highlight-background-color",
+ {
+ lwtProperty: "sidebar_highlight",
+ processColor(rgbaChannels, element) {
+ if (!rgbaChannels) {
+ element.removeAttribute("lwt-sidebar-highlight");
+ return null;
+ }
+ element.setAttribute("lwt-sidebar-highlight", "true");
+
+ const { r, g, b, a } = rgbaChannels;
+ return `rgba(${r}, ${g}, ${b}, ${a})`;
+ },
+ },
+ ],
+ [
+ "--lwt-sidebar-highlight-text-color",
+ {
+ lwtProperty: "sidebar_highlight_text",
+ },
+ ],
+ ];
+
+ /**
+ * ContentThemeController handles theme updates sent by the frame script.
+ * To be able to use ContentThemeController, you must add your page to the whitelist
+ * in LightweightThemeChildListener.jsm
+ */
+ const ContentThemeController = {
+ /**
+ * Listen for theming updates from the LightweightThemeChild actor, and
+ * begin listening to changes in preferred color scheme.
+ */
+ init() {
+ addEventListener("LightweightTheme:Set", this);
+
+ // We don't sync default theme attributes in `init()`, as we may not have
+ // a root element to attach the attribute to yet. They will be set when
+ // the first LightweightTheme:Set event is delivered during pageshow.
+ prefersDarkQuery.addEventListener("change", this);
+ },
+
+ /**
+ * Handle theme updates from the LightweightThemeChild actor or due to
+ * changes to the prefers-color-scheme media query.
+ * @param {Object} event object containing the theme or query update.
+ */
+ handleEvent(event) {
+ const root = document.documentElement;
+
+ if (event.type == "LightweightTheme:Set") {
+ let { data } = event.detail;
+ if (!data) {
+ data = {};
+ }
+ this._setProperties(root, data);
+ } else if (event.type == "change") {
+ // If a lightweight theme doesn't apply, update lwt-newtab-brighttext to
+ // reflect prefers-color-scheme.
+ if (!root.hasAttribute("lwt-newtab")) {
+ root.toggleAttribute("lwt-newtab-brighttext", event.matches);
+ }
+ }
+ },
+
+ /**
+ * Set a CSS variable to a given value
+ * @param {Element} elem The element where the CSS variable should be added.
+ * @param {string} variableName The CSS variable to set.
+ * @param {string} value The new value of the CSS variable.
+ */
+ _setProperty(elem, variableName, value) {
+ if (value) {
+ elem.style.setProperty(variableName, value);
+ } else {
+ elem.style.removeProperty(variableName);
+ }
+ },
+
+ /**
+ * Apply theme data to an element
+ * @param {Element} root The element where the properties should be applied.
+ * @param {Object} themeData The theme data.
+ */
+ _setProperties(elem, themeData) {
+ for (let [cssVarName, definition] of inContentVariableMap) {
+ const { lwtProperty, processColor } = definition;
+ let value = themeData[lwtProperty];
+
+ if (processColor) {
+ value = processColor(value, elem);
+ } else if (value) {
+ const { r, g, b, a } = value;
+ value = `rgba(${r}, ${g}, ${b}, ${a})`;
+ }
+
+ this._setProperty(elem, cssVarName, value);
+ }
+ },
+ };
+ ContentThemeController.init();
+}
diff --git a/browser/base/content/default-bookmarks.html b/browser/base/content/default-bookmarks.html
new file mode 100644
index 0000000000..c42e81ac23
--- /dev/null
+++ b/browser/base/content/default-bookmarks.html
@@ -0,0 +1,69 @@
+#filter substitution
+
+#define mozilla_icon data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16'%3E %3Cpath d='M0 0h16v16H0z'/%3E %3Cpath d='M13.994 10.356H15V12h-3.171V7.741c0-1.308-.435-1.81-1.29-1.81-1.04 0-1.46.737-1.46 1.8v2.63h1.006V12H6.918V7.741c0-1.308-.435-1.81-1.291-1.81-1.039 0-1.459.737-1.459 1.8v2.63h1.441V12H1v-1.644h1.006V6.079H1V4.435h3.168v1.139a2.507 2.507 0 0 1 2.3-1.29A2.452 2.452 0 0 1 8.931 5.91 2.535 2.535 0 0 1 11.4 4.284 2.448 2.448 0 0 1 14 6.9v3.458z' fill='%23fff'/%3E %3C/svg%3E
+
+#define firefox_icon data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAHY0lEQVR4Aa3VA5RrSdfG8f+uOidJp/umczm2ffFhbNvG9722bdv22LZt+3I81+04B1XvfpPmWHut3yk06smus1Z4L8uXDv6MHzpowA8eWFS8FaY9eU+cCvxaFfF8W/FWGDy8a6n7DM7/H96DR3ldu0MVb8a0J+9CI1qXJP11a+79GOdP1f11FW/EtCfvQpx8mziFxMHEEEV1KYkrKl6Pea1Nbnrs/7hz7q2KUQsqRcUE/eV1acb/pyFQ7b9N3fguzNTxVsXrMa/avFgPb6SnukY8W6EgXvszrszjivH08F0VLZFK0rbUgRt9H2aS+lORznUxnTMV45kJG6fNPZSGnEodTJwUFGbphqdSll/H/SxWjEc92kYxSoO0uzEcwo90g/9rlKpHpCmX491MxQgzuvjtE0UieyqxhYZA3UGp8CjUtSMR2YrkFdf+/szi9X88+zM3/uncSx/81/f+7/HzPsu8q09i8MUNcCUHUTImceAAL+RC+UW1nMzHuUvxSVGBCloTgMT+GuOLipaGyg/OpLuE/jVI58wHb/zsdxD5tBVbDMQwOPe/8UDqHYuuPJjCZnP5nw/+mfyUPhADRtkAaIfosum23klBxH8b+KzCfuczG8IPXi4C5yHQwvDoPYhCBSkz1n9y1+WLd8xFzVUxxmIRjBIBVHXFZF58aEtW3exxsp0V8Aac8gpBnGQBRNymkP4VXKPdgdj8H2JB/DgMVwreATFhdoCdj/wY8x7+GM8/djyJ81hlnCPTUWfHb/0QlyRUelalEPcCHswIQARJPd64ohh/KHBagPcQB7sggHgIVHcM0wUyWWUAoNaEuobI9bP1dj9lw1nnMvehj/LS0wdinYO4wM1f/h6z3v9n1t3pTnAWBj04ZQA7LFROwMsu7QCpgcjuCh4Asg5Wa0ImgNDqqHTOtDyIgPPKkZ/cZOstzmT+Nw4jcA5JBO9SHjzzWKZt8CRd03ohD/RZALCigIwHawBmKgKSVoAiAi2VDCzsgo0bYB04lSojEAaQDyETsmTZf3PPdZ+irvMgTTF4SAVX7+SRC/dj5/f/C6D9d5UQLBAIFBJILIhtB1g2a8uZq+1+LwiAV8CSTujPwqoRbJjCJMdAeRVue+j/WLh4T2I3jcCEhN4ShmDFYR2IAXC8OHdDaMYAYBxU82AFAgPShHoejAEgUEViy2h5UbS9LLBajf5oMr866wc0wlWQvEEyNQKbIcSSwZBNIfAO41NQ9ZXd0IgBAQdUDAQWpjQhcfi6gCgguDtTm3vIUBdhdwUA/Pggqmy49/n/pr/q8ZMq4DziEwI0QOtpiT1kXUqQRqC8ohaDy0BqoGzxOUE6q9DwMBiOvtzm5OLi3migAFEwpjnOCzmKhZXUkyr1uEwtLqky1aStNk4jqhFFDVZb6ykYMjBodQxw5RAKZUgSqAq+YmmWzFxF0P8L61Z8pHhf5/S+bfHQJm1OLcuzw4YPcWH3/qysTcebFHyESTOkhLjUokt8M8VFCVYDbLXhvdCfARfiG3lkykDr2qhbXJTRUZBAngMwootGI3tbrbcIsR3ugp3Yhbun89l9/ko+qCDVGpQruHKJqDakBmnq2KyXaDZKrDX1KWau+ij0ZqAvgwR1JFuFmihwPTkdDQN9co3C6IMnwujs0sppELcOV+NHVc2wzv2eb+74J6ZP6kGazeEgZZJqiaRWJo6qbDb5MU7c4ixYmYUhC7YJaQxVgYrgSxa3sgNftdww31+usFuvuykfWDzU/4HytL0llTVz+SbiAScTryKxFFc6dlnnQVZP+wEo2grT7ACb5V7g2BnXsVfxHsLEgfGQTYb/1kJqWpKV3VDLM1iXi/a8PDrtqmecl451DwLg8oG1DtnMmcsKq/bQ1V3BmBTsfzgIfHucwINxICivADt8eADkBLJGtcc0ydHsmU7QEXBFfzwTeFwRnLFtDoBD7nv5+vv61v2XXzHlfR7oKtQxLkFcCqkDK8qMHdIex4gSMxaoKZBtS8lQ18NtJsPSmv/Nyfc3nma4RjsA8Jnq1HU+WC9cY01z865pJQrdDcQkrW6IpGOfun3oxLnw6m/SEBIyVFbOIMhmiXJy35oL+vYDBhkuGxY3YaTuy9TLA+Jv2inu2j2ph9NrTUMmCyIGjwEnyiCtUaUWnGlLR1hIlM6rKwpUX5qBiTuI02Du94aqx8zJhEsVI4IPduUZV+7vDC0CDv9GdeolUjObL18ckutqMKkQkc2kiFHOITLCwyiUp1bNUhuYRFrrxPoMzdDM/XbUf/gZvvYsozX+Cl5d5vh690afrk3+0hR4XyoxqYmQICaTSwjClI6cA3EIvhWi0QiIm6rRgaQh1ikfsMK43/xv8YWfASuUe6sBAIzqPmNwjb1nJdnP5PDbOpPgJMXjWhDAC4JgvEWUaQkoib/o/NzQb37S1fP0+Dt/6wHGKqe6v1yZvuG+zc69p3m7d4dnW8TjAaEdwmFKEcztkfSG67KVG346aeV8YEglincRYLQClVcdKsery6lI1VVNJbyF+jdp8gPG4E08mAAAAABJRU5ErkJggg==
+
+#define bugzilla_icon data:image/x-icon;base64,AAABAAEAEBAAAAEAIABoBAAAFgAAACgAAAAQAAAAIAAAAAEAIAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAnYCL7J0pY9wwETY9kYEM9AAAAAEBAYAhVVVUDAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA1bjTOS1Vb/wAA2/8JEqT/Q0g8kQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJnMqoiJtJ/0haST/NEkp/yQnW/8AAPD8AAHY/RsgP/JZUI+GKitih4ObUEAAAAAAAAAAAAAAAAAAAAAAKnouwip5Lv9ooW//a6Jz/y9GIv8JBXb/AADO/gEBvf8AAK7/FRJp/z9BfP8iLGj1AAAAAAAAAAAAAAAAL4YzwiB0I/+UwJ3/bK+C/02eZ/9CTjz/DBa1/wABxf8BAOj/AACp/w8Oc/sJCv//EhCN9AAAAAAAAAAANJA6iC2MMv93tYL/TZ5U/3mzjP9NoWr/NEss/wYIU/8CBOn/ARCX/wwNqP0TD6X/Cgyex5qacDAAAAAAAAAAADKUN/ZirWj/d76R/0SgXf9Ln1P/eLSM/1mda/8rOkb/CQiD/wMQvf8UEnT/MTAt4P//MwUhZyN8AAAAAAAAAAAznDf5UqlZ/228jP9NqnD/Qp9c/0yiVP+Dv5b/VaVw/0VxXf9PZXD/S3pQ/y54Nf8jcCf/I2wn/wAAAAA0ozjIM6E4/zOeOP+Uz6z/XLR+/06scv9BoV3/TqZX/4XBmP9XqHP/hr6Z/yp+Lf8leSr1JXUqbQAAAAA3rTz7Nqo7/zWmOqM3oz7rUK1W/43Mpf9etYD/T61z/0KjXf9Rqln/msup/46/lf8pgy7/JFg6sAAAAAAAAAAAOK8+8jqvOiMAAAAAAAAAADSlOv85pT//kM6o/2K5hP9Ysnv/YLJ2/ziXPv8piS3/V6Ri/yZQQ9wAAAAAAAAAAAAAAAAAAAAAAAAAADetP0E2qzz/OKg98UWsS/+e1K3/pNe4/4XDjv8ojy3/T7df/5fIqv8sjTH/K4kw/yqFLv8AAAAAAAAAAAAAAAA4sT3xN7A8+QAAAAA4qz3yNag6/zSlOf80oTn/csJ+/6/jwv9fjHj/MmRMdQAAAAAAAAAAAAAAAAAAAAAAAAAAOrdA/zm0QHQAAAAAAAAAADasO/k2qTvuRX5lpjqGT/gznDr/O3FXigAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADq9QiMAAAAAAAAAAAAAAAA4sj7/Nq09s0uOaSI1qTplM6U68wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOrc//zu0P0EAAAAAOK4+UjWsPPgAAAAAAAAAAAAAAAAAAAAA/48AAP8HAAD4AQAA8AAAAOAAAADAAQAAwAMAAMAAAACAAwAAAAMAAHADAADwAAAA5AcAAO4HAAD+bwAA/u8AAA==
+
+#define mdn_icon data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAIAAAD8GO2jAAABgElEQVR4AWPouPiRpmjUAjgatSBj+aG0OVuyVhymsgWl2y475zXySikwwIC4lpFDVk3avG0BrXP9mmeV7bhCvgXuxW3M7JwMeAGfpByZFpjHFzAQB2KmrSfZAtuMKgaiAZ+MUuLc7SRY4FbYwkA60HAJiJm8uubQAwIWeJV1MlAAOAREHDJrcFqQMG+ngIK6oKyykLwqAyMjeXZwiUjgtKD+yGMIwyGzmoGBTAs4hcXrgObgi4OiVkpCiUtYov7YU5wW1B194t0ww69ldvjktYouwWRYIGdkQ1Q+iJ28hp2Xn7B5zKzIPAlNw6S52wlbkL3mJJecOq+CBqeIJC6TjUJTMlcdz9lwLmriSuPgJBVbj/D+5SSXRequQVhNN4nIoEJhV77jKgs7B6bprJw85XtuU8GCpNlbSS3gSA4ii4QiTAvElDWpWeH41kxg5eZDSypUrtFKd1zTcA1EpHRDS5rUyQEts+VN7JjYOGR0TWhY6RdsOhc/Y9NoswUJjVoAAE+h555Rgcg2AAAAAElFTkSuQmCC
+
+#define addon_icon data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAMAAABEpIrGAAABOFBMVEUAAAAAzdwAydcAyNgA//8AzOYAydcAyNgAyNgA2NgAytoAzNkAytgAydgA29sA0egAyNgAyNgA//8AyNgAydgA//8Ay9cAydcAytkAz90Ay9gA1eoAydgAytsAyNgAytgAydkAydgAydgAyNgAydgAyNcAy9oAydgA//8AytsAy9kAytkAyNgAydgAydkAy9kAzP8AydgAydgAydgAz98AzN0AyNcAyNsAydcAydgAydgAydcAytgAytcAydcAydcAytcAydcAyNcAydcAy9gAztgAydcAydgAzOAAydcAyNgAyNgAytwAyNcAyNgAy9wAyNcAyNcAyNgAydgAyNcAydcAydgA0uEAydkAyNgAyeQA4+MAyNgAydkAzN0AyNsA1f8AyNgAydgAydcA1eMAydgAydcAyNdj6PGJAAAAZ3RSTlMAJFpBAQqm8qwNPiiPygcLv8QEw5cDQMA1JU4MwiuDbl/+6+m4+0XLAj9KePz9cjYFvervEA/IDsfFr+xcgeaOYIDo+ica1OMZ9bXlHe73LM3M35itxs8Rf5ATCbFeHjgGttC5ErzO3WvwaAAAAWxJREFUOMt9kudCwkAQhFcITQWCoaggiBqxoaixYUVsYMMGCmLXff838LIXCJAL84fvmCHsTg4AYMDhlIDkcnu8Po4w6BjiIA0joj+gY1BmGKLEiMIwHNFR9xGjuh8jpMQoYZjRGBGOA8QTHDEUB4hynABwcEqCT8GW2DNSnCYBnJzSoSk0NT2jcphlM/rRXpk5NkRgfmFxSWBml6MrOWNlWBUEEmDKtyYIrGttP66gSP4Nww8mUKzNLd5/zHaJbb1Tl9xnzR2WcGM/5QE8BLvJvf0Dc4HDo8IxUVECL5Vywv7s9Mzwz/WLcFHSsWy8w0sa9+qa/JtbOlUY3mnGLbjnCz9Q4JEfnhCrNb5nXi3w754p8MIPKVWpt6psGJ+vFJBbBUO3GlozzYdsSmCV1NlntiTnegNvPQ29Wx7h7fKrEUvg47PD/6oLpgh8m7//AZEav4b/J/bbb75SAzvR3crY+6y8fLGsQV9ZSvwHF4LQIhDjT+8AAAAASUVORK5CYII=
+<!-- 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/. -->
+<!DOCTYPE NETSCAPE-Bookmark-file-1>
+<html>
+<head>
+ <meta charset="UTF-8">
+ <!-- These localization links are not automatically applied to any XHR
+ response body and must be applied manually as well. They are included
+ so that viewing the file directly shows the results. -->
+ <link rel="localization" href="branding/brand.ftl"/>
+ <link rel="localization" href="browser/profile/default-bookmarks.ftl"/>
+ <title data-l10n-id="default-bookmarks-title">default-bookmarks-title</title>
+</head>
+<body>
+<h1 data-l10n-id="default-bookmarks-heading">default-bookmarks-heading</h1>
+
+<dl><p>
+ <dt><h3 personal_toolbar_folder="true" data-l10n-id="default-bookmarks-toolbarfolder">default-bookmarks-toolbarfolder</h3></dt>
+ <dd data-l10n-id="default-bookmarks-toolbarfolder-description">default-bookmarks-toolbarfolder-description</dd>
+#ifndef NIGHTLY_BUILD
+#ifdef EARLY_BETA_OR_EARLIER
+ <dl><p>
+ <dt><a href="https://www.mozilla.org/firefox/central/" icon="@firefox_icon@" data-l10n-id="default-bookmarks-getting-started">default-bookmarks-getting-started</a></dt>
+ </dl><p>
+#else
+ <dl><p>
+ <dt><a href="https://www.mozilla.org/firefox/?utm_medium=firefox-desktop&utm_source=bookmarks-toolbar&utm_campaign=new-users&utm_content=-global" icon="@firefox_icon@" data-l10n-id="default-bookmarks-getting-started">default-bookmarks-getting-started</a></dt>
+ </dl><p>
+#endif
+ <dt><h3 data-l10n-id="default-bookmarks-firefox-heading">default-bookmarks-firefox-heading</h3></dt>
+ <dl><p>
+ <dt><a href="https://support.mozilla.org/products/firefox" icon="@firefox_icon@" data-l10n-id="default-bookmarks-firefox-get-help">default-bookmarks-firefox-get-help</a></dt>
+ <dt><a href="https://support.mozilla.org/kb/customize-firefox-controls-buttons-and-toolbars?utm_source=firefox-browser&utm_medium=default-bookmarks&utm_campaign=customize" icon="@firefox_icon@" data-l10n-id="default-bookmarks-firefox-customize">default-bookmarks-firefox-customize</a></dt>
+#ifdef EARLY_BETA_OR_EARLIER
+ <dt><a href="https://www.mozilla.org/contribute/?utm_medium=firefox-desktop&utm_source=bookmarks-toolbar&utm_campaign=new-users-beta&utm_content=-global" icon="@mozilla_icon@" data-l10n-id="default-bookmarks-firefox-community">default-bookmarks-firefox-community</a></dt>
+#else
+ <dt><a href="https://www.mozilla.org/contribute/" icon="@mozilla_icon@" data-l10n-id="default-bookmarks-firefox-community">default-bookmarks-firefox-community</a></dt>
+#endif
+ <dt><a href="https://www.mozilla.org/about/" icon="@mozilla_icon@" data-l10n-id="default-bookmarks-firefox-about">default-bookmarks-firefox-about</a></dt>
+ </dl><p>
+#else
+ <dl><p>
+ <dt><a href="https://www.mozilla.org/contribute/?utm_medium=firefox-desktop&utm_source=bookmarks-toolbar&utm_campaign=new-users-nightly&utm_content=-global" icon="@mozilla_icon@" data-l10n-id="default-bookmarks-firefox-community">default-bookmarks-firefox-community</a></dt>
+ </dl><p>
+ <dt><h3 data-l10n-id="default-bookmarks-nightly-heading">default-bookmarks-nightly-heading</h3></dt>
+ <dl><p>
+ <dt><a href="https://blog.nightly.mozilla.org/" icon="@mozilla_icon@" data-l10n-id="default-bookmarks-nightly-blog">default-bookmarks-nightly-blog</a></dt>
+ <dt><a href="https://bugzilla.mozilla.org/" icon="@bugzilla_icon@" shortcuturl="bz" data-l10n-id="default-bookmarks-bugzilla">default-bookmarks-bugzilla</a></dt>
+ <dt><a href="https://developer.mozilla.org/" icon="@mdn_icon@" shortcuturl="mdn" data-l10n-id="default-bookmarks-mdn">default-bookmarks-mdn</a></dt>
+ <dt><a href="https://addons.mozilla.org/firefox/addon/nightly-tester-tools/" icon="@addon_icon@" data-l10n-id="default-bookmarks-nightly-tester-tools">default-bookmarks-nightly-tester-tools</a></dt>
+ <dt><a href="about:crashes" icon="@mozilla_icon@" data-l10n-id="default-bookmarks-crashes">default-bookmarks-crashes</a></dt>
+ <dt><a href="https://planet.mozilla.org/" icon="@mozilla_icon@" data-l10n-id="default-bookmarks-planet">default-bookmarks-planet</a></dt>
+ </dl><p>
+#endif
+</dl>
+</body>
+</html>
diff --git a/browser/base/content/docs/tabbrowser/async-tab-switcher.rst b/browser/base/content/docs/tabbrowser/async-tab-switcher.rst
new file mode 100644
index 0000000000..ab758cbd3d
--- /dev/null
+++ b/browser/base/content/docs/tabbrowser/async-tab-switcher.rst
@@ -0,0 +1,239 @@
+.. _tabbrowser_async_tab_switcher:
+
+==================
+Async tab switcher
+==================
+
+At a very high level, the async tab switcher is responsible for telling tabs with out-of-process (or “remote”) ``<xul:browser>``’s to render and upload their contents to the compositor, and then update the UI to show that content as a tab switch. Similarly, the async tab switcher is responsible for telling tabs that have been switched away from to stop rendering their content, and for the compositor to release those contents.
+
+Briefly introducing Layers and the Compositor
+=============================================
+
+For out-of-process tabs, the presentation portion of Gecko computes the final contents of a tab inside the tabs content process, and then uploads that information to the compositor. This uploaded information is usually referred to as *layers*.
+
+The compositor is what eventually presents these layers to the user as pixels. The compositor can retain several sets of layers without necessarily showing them to the user, but this consumes memory. Layers that are no longer needed are released.
+
+From here forward, "contents of a tab" will be referred to as that tab's *layers*.
+
+.. _async-tab-switcher.useful-properties:
+
+renderLayers, hasLayers, docShellIsActive
+=========================================
+
+``<xul:browser>``'s have a number of useful properties exposed on them that the async tab switcher uses:
+
+``renderLayers``
+ For remote ``<xul:browser>``'s, setting this to ``true`` from ``false`` means to ask the content process to render the layers for that ``<xul:browser>`` and upload them to the compositor. Setting this to ``false`` from ``true`` means to ask the content process to stop rendering the layers and for the compositor to release the layers. Setting this property to ``true`` when it is already ``true`` or ``false`` when it is already ``false`` is a no-op. When this property returns ``true``, this means that layers have been requested for this tab, but there is no guarantee that the layers have been received by the compositor yet. Similarly, when this property returns ``false``, this means that this browser has been asked to stop rendering layers, but there is no guarantee that the layers have been released by the compositor yet.
+
+ For non-remote ``<xul:browser>``'s, ``renderLayers`` is an alias for ``docShellIsActive``.
+
+``hasLayers``
+ For remote ``<xul:browser>``'s, this read-only property returns ``true`` if the compositor has layers for this tab, and ``false`` otherwise.
+
+ For non-remote ``<xul:browser>``'s, ``hasLayers`` returns the value for ``docShellIsActive``.
+
+``docShellIsActive``
+ For remote ``<xul:browser>``'s, setting ``docShellIsActive`` to ``true`` also sets ``renderLayers`` to true, and then sends a message to the content process to set its top-level docShell active state to ``true``. Similarly, setting ``docShellIsActive`` to ``false`` also sets ``renderLayers`` to false, and then sends a message to the content process to set its top-level docShell active state to ``false``.
+
+ For non-remote ``<xul:browser>``'s, ``docShellIsActive`` forwards to the ``isActive`` property on the ``<xul:browser>``'s top-level docShell.
+
+ Setting a docShell to be active causes the tab's visibilitychange event to fire to indicate that the tab has become visible. Media that was waiting to be played until the tab is selected will also begin to play.
+
+ An active docShell is also required in order to generate a print preview of the loaded document.
+
+
+Requirements
+============
+
+There are a number of requirements that the tab switcher must satisfy. In no particular order, they are:
+
+1. The switcher must be prepared to switch between any mixture of remote and non-remote tabs. Non-remote tabs include tabs pointed at about:addons, about:config, and others
+
+2. We want to avoid switching the toolbar state (for example, the URL bar input, security indicators, toolbar button states) until we are ready to show the layers of the tab that we're switching to
+
+3. Only one tab should appear to be selected in the tab strip at any given time
+
+4. We want to avoid switching keyboard focus to a selected tab until the layers for the tab are ready - but only if the user doesn’t change focus between the start and end of the async tab switch
+
+5. If the layers for a tab are not available after a certain amount of time, we should “complete” the tab switch by displaying the “tab switch spinner” - an animated spinner against a white background. This way, we at least show the user some activity, despite the fact that we don’t have the layers of the tab to show them
+
+6. The printing UI uses tabs to show print preview, which requires that the print-previewed tab is in the background and yet also have its docShell be "active" - a state that's usually reserved for the selected tab. See :ref:`async-tab-switcher.useful-properties`
+
+7. ``<xul:tab>``'s and ``<xul:browser>``'s might be created or destroyed at any time during an async tab switch
+
+8. It should be possible to render layers for a tab, despite it not having been set as active (this is used for :ref:`async-tab-switcher.warming`)
+
+Lifecycle
+=========
+
+Per window, an async tab switcher instance is only supposed to exist if one or more tabs still need to have their layers loaded or unloaded. This means that an async tab switcher instance might exist even though a tab switch appears to the user to have completed. This also means that an async tab switcher might continue to exist and handle a new tab switch if the user initiates that tab switch before some background tabs have had their layers unloaded.
+
+There’s only one async tab switcher at a time per window, and it’s owned by the ``<xul:tabbrowser>``.
+
+A ``<xul:tabbrowser>`` starts without an async tab switcher, and only once a tab switch (or warming) is initiated by the user is the switcher instantiated.
+
+Once the switcher determines that the tab that the user has requested is being shown, and all background tabs have been properly unloaded or destroyed, the async tab switcher cleans up and destroys itself.
+
+.. _async-tab-switcher.states:
+
+Tab states
+==========
+
+While the async tab switcher exists, it maps each ``<xul:tab>`` in the window to one of the following internal states:
+
+``STATE_UNLOADED``
+ Layers for this ``<xul:tab>`` are not being uploaded to the compositor, and we haven't requested that the tab start doing so. This tab is fully in the background.
+
+ When a tab is in ``STATE_UNLOADED``, this means that the associated ``<xul:browser>`` either does not exist, or will have its ``renderLayers`` and ``hasLayers`` properties both return ``false``.
+
+ If a tab is in this state, it must have either initialized there, or transitioned from ``STATE_UNLOADING``.
+
+ When logging states, this state is indicated by the ``unloaded`` string.
+
+``STATE_LOADING``
+ Layers for this ``<xul:tab>`` have not yet been reported as "received" by the compositor, but we've asked the tab to start rendering. This usually means that we want to switch to the tab, or at least to warm it up.
+
+ When a tab is in ``STATE_LOADING``, this means that the associated ``<xul:browser>`` will have its ``renderLayers`` property return ``true`` and its ``hasLayers`` property return ``false``.
+
+ If a tab is in this state, it must have either initialized there, or transitioned from ``STATE_UNLOADED``.
+
+ When logging states, this state is indicated by the ``loading`` string.
+
+``STATE_LOADED``
+ Layers for this ``<xul:tab>`` are available on the compositor and can be displayed. This means that the tab is either being shown to the user, or could be very quickly shown to the user.
+
+ If a tab is in this state, it must have either initialized there, or transitioned from ``STATE_LOADING``.
+
+ When a tab is in ``STATE_LOADED``, this means that the associated ``<xul:browser>`` will have its ``renderLayers`` and ``hasLayers`` properties both return ``true``.
+
+ When logging states, this state is indicated by the ``loaded`` string.
+
+``STATE_UNLOADING``
+ Layers for this ``<xul:tab>`` were at one time available on the compositor, but we've asked the tab to unload them to preserve memory. This usually means that we've switched away from this tab, or have stopped warming it up.
+
+ When a tab is in ``STATE_UNLOADING``, this means that the associated ``<xul:browser>`` will have its ``renderLayers`` property return ``false`` and its ``hasLayers`` property return ``true``.
+
+ If a tab is in this state, it must have either initialized there, or transitioned from ``STATE_LOADED``.
+
+ When logging states, this state is indicated by the ``unloading`` string.
+
+Having a tab render its layers is done by settings its state to ``STATE_LOADING``. Once the layers have been received, the switcher will automatically set the state to ``STATE_LOADED``. Similarly, telling a tab to stop rendering is done by settings its state to ``STATE_UNLOADING``. The switcher will automatically set the state to ``STATE_UNLOADED`` once the layers have fully unloaded.
+
+Stepping through a simple tab switch
+====================================
+
+In our simple scenario, suppose the user has a single browser window with two tabs: a tab at index **0** and a tab at index **1**. Both tabs are completed loaded, and **0** is currently selected and displaying its content.
+
+The user chooses to switch to tab **1**. An async tab switcher is instantiated, and it immediately attaches a number of event handlers to the window. Among them are handlers for the ``MozLayerTreeReady`` and ``MozLayerTreeCleared`` events.
+
+The switcher then creates an internal mapping from ``<xul:tab>>``'s to states. That mapping is:
+
+.. code-block:: none
+
+ // This is using the logging syntax laid out in the `Tab states` section.
+ 0:(loaded) 1:(unloaded)
+
+Be sure to refer to :ref:`async-tab-switcher.states` for an explanation of the terminology and :ref:`async-tab-switcher.logging` syntax for states.
+
+This last example translates to:
+
+ The tab at index **0**, is in ``STATE_LOADED`` and the tab at index **1** is in ``STATE_UNLOADED``.
+
+Now that initialization done, the switcher is asked to request **1**. It does this by putting **1** into ``STATE_LOADING`` and requesting that **1**'s layers be rendered. The new state mapping is:
+
+.. code-block:: none
+
+ 0:(loaded) 1:(loading)
+
+At this point, the user is still looking at tab **0**, and none of the UI is showing any visible indication of tab change.
+
+Now the switcher is waiting, so it goes back to the event loop. During this time, if any code were to ask the tabbrowser which tab is selected, it'd return **1**, since it's *logically* selected despite not being *visually* selected.
+
+Eventually, the layers for **1** are uploaded to the compositor, and the ``<xul:browser>`` for **1** fires its ``MozLayerTreeReady`` event. This is when the switcher changes its internal state again:
+
+.. code-block:: none
+
+ 0:(loaded) 1:(loaded)
+
+So now layers for both **0** and **1** are uploading and available on the compositor. At this point, the switcher updates the visual state of the browser, and flips the ``<xul:deck>`` to display **1**, and the user experiences the tab switch.
+
+The switcher isn't done, however. After a predefined amount of time (dictated by ``UNLOAD_DELAY``), tabs that aren't currently selected but in ``STATE_LOADED`` are put into ``STATE_UNLOADING``. Now the internal state looks like this:
+
+.. code-block:: none
+
+ 0:(unloading) 1:(loaded)
+
+Having requested that **0** go into ``STATE_UNLOADING``, the switcher returns back to the event loop. The user, meanwhile, continues to use ``1``.
+
+Eventually, the layers for **0** are cleared from the compositor, and the ``<xul:browser>`` for **0** fires its ``MozLayerTreeCleared`` event. This is when the switcher changes its internal state once more:
+
+.. code-block:: none
+
+ 0:(unloaded) 1:(loaded)
+
+The tab at **0** is now in ``STATE_UNLOADED``. Since the last requested tab **1** is in ``STATE_LOADED`` and all other background tabs are in ``STATE_UNLOADED``, the switcher decides its work is done. It deregisters its event handlers, and then destroys itself.
+
+.. _async-tab-switcher.unloading-background:
+
+Unloading background tabs
+=========================
+
+While an async tab switcher exists, it will periodically scan the window for tabs that are in ``STATE_LOADED`` but are also in the background. These tabs will then be put into ``STATE_UNLOADING``. Only once all background tabs have settled into the ``STATE_UNLOADED`` state are the background tabs considered completely cleared.
+
+The background scanning interval is ``UNLOAD_DELAY``, in milliseconds.
+
+Perceived performance optimizations
+===================================
+
+We use a few tricks and optimizations to help improve the perceived performance of tab switches.
+
+1. Sometimes users switch between the same tabs quickly. We want to optimize for this case by not releasing the layers for tabs until some time has gone by. That way, quick switching just resolves in a re-composite in the compositor, as opposed to a full re-paint and re-upload of the layers from a remote tab’s content process.
+
+2. When a tab hasn’t ever been seen before, and is still in the process of loading (right now, dubiously checked by looking for the “busy” attribute on the ``<xul:tab>``) we show a blank content area until its layers are finally ready. The idea here is to shift perceived lag from the async tab switcher to the network by showing the blank space instead of the tab switch spinner.
+
+3. “Warming” is a nascent optimization that will allow us to pre-emptively render and cache the layers for tabs that we think the user is likely to switch to soon. After a timeout (``browser.tabs.remote.warmup.unloadDelayMs``), “warmed” tabs that aren’t switched to have their layers unloaded and cleared from the cache.
+
+4. On platforms that support ``occlusionstatechange`` events (as of this writing, only macOS) and ``sizemodechange`` events (Windows, macOS and Linux), we stop rendering the layers for the currently selected tab when the window is minimized or fully occluded by another window.
+
+5. Based on the browser.tabs.remote.tabCacheSize pref, we keep recently used tabs'
+layers around to speed up tab switches by avoiding the round trip to the content
+process. This uses a simple array (``_tabLayerCache``) inside tabbrowser.js, which
+we examine when determining if we want to unload a tab's layers or not. This is still
+experimental as of Nightly 62.
+
+.. _async-tab-switcher.warming:
+
+Warming
+=======
+
+Tab warming allows the browser to proactively render and upload layers to the compositor for tabs that the user is likely to switch to. The simplest example is when a user's mouse cursor is hovering over a tab. When this occurs, the async tab switcher is told to put that tab into a warming list, and to set its state to ``STATE_LOADING``, even though the user hasn't yet clicked on it.
+
+Warming a tab queues up a timer to unload background tabs (if no such timer already exists), which will clear out the warmed tab if the user doesn't eventually click on it. The unload will occur even if the user continues to hover the tab.
+
+If the user does happen to click on the warmed tab, the tab can be in either one of two states:
+
+``STATE_LOADING``
+ In this case, the user requested the tab switch before the layers were rendered and received by the compositor. We'll at least have shaved off the time between warming and selection to display the tab's contents to the user.
+
+``STATE_LOADED``
+ In this case, the user requested the tab switch after the layers had been rendered and received by the compositor. We can switch to the tab immediately.
+
+Warming is controlled by the following preferences:
+
+``browser.tabs.remote.warmup.enabled``
+ Whether or not the warming optimization is enabled.
+
+``browser.tabs.remote.warmup.maxTabs``
+ The maximum number of tabs that can be warming simultaneously. If the number of warmed tabs exceeds this amount, all background tabs are unloaded (see :ref:`async-tab-switcher.unloading-background`).
+
+``browser.tabs.remote.warmup.unloadDelayMs``
+ The amount of time to wait between the first tab being warmed, and unloading all background tabs (see :ref:`async-tab-switcher.unloading-background`).
+
+.. _async-tab-switcher.logging:
+
+Logging
+=======
+
+The async tab switcher has some logging capabilities that make it easier to debug and reason about its behaviour. Setting the hidden ``browser.tabs.remote.logSwitchTiming`` pref to true will put logging into the Browser Console.
+
+Alternatively, setting the ``useDumpForLogging`` property to true within the source code of the tab switcher will dump those logs to stdout.
diff --git a/browser/base/content/docs/tabbrowser/index.rst b/browser/base/content/docs/tabbrowser/index.rst
new file mode 100644
index 0000000000..c142355b24
--- /dev/null
+++ b/browser/base/content/docs/tabbrowser/index.rst
@@ -0,0 +1,35 @@
+.. _tabbrowser:
+
+===================
+tabbrowser
+===================
+
+In the previous versions of Firefox, ``<xul:tabbrowser>`` was responsible for displaying and managing the contents of a window's tabs. As the browser evolved, the responsibilities of ``<xul:tabbrowser>`` grew. Each Firefox window had one ``<xul:tabbrowser>`` that could be accessed using the ``gBrowser`` variable.
+
+At this point, ``<xul:tabbrowser>`` DOM element doesn't exist anymore, but we mention it here because it's often used synonymously with ``gBrowser``, and other documentation might still make direct or indirect reference to ``<xul:tabbrowser>``.
+
+gBrowser
+---------------
+
+``gBrowser`` is a JavaScript object defined in :searchfox:`tabbrowser.js <browser/base/content/tabbrowser.js>`, that manages tabs, and the underlying infrastructure for switching tabs, adding tabs, removing tabs, knowing about tab switches, etc. ``gBrowser`` is available in the browser window scope and you get only one ``gBrowser`` per browser window.
+
+What does the name gBrowser stand for?
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+What does the *g* in ``gBrowser`` stand for? It's an old Mozilla convention and *g* stands for *global*. It's a way of indicating inside of the variable name that something is globally scoped. In this case it is global to the browser window and every single tab of the window will be managed through that window's ``gBrowser``.
+
+What does the *browser* in ``gBrowser`` stand for? *browser* is an element that knows how to render web content. At some point in its lineage, Firefox didn't have tabs. There was one browser per window. That individual browser was called ``gBrowser``. The new ``gBrowser`` variable has the same interface as the old one, but would forward calls to the current ``selectedBrowser``, which is an actual ``<xul:browser>`` element.
+
+Relationship between tabbrowser, browser and gBrowser
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+``<xul:browser>`` is a XUL element that can load web pages, make HTTP requests and respond accordingly. It is conceptually similar to an ``iframe`` except that except that contains additional methods and has elevated privileges. In Firefox, each tab is associated with one ``<xul:browser>``.
+
+Historically each Firefox window had one ``<xul:tabbrowser>``, that could be accessed using the ``gBrowser`` variable. It could contain multiple tabs each of which was associated with one ``<xul:browser>``.
+
+Although the ``<xul:tabbrowser>`` DOM element was removed, you can still interact with all the browser's tabs using the ``gBrowser`` global. The ``gBrowser`` global is still defined in a file called :searchfox:`tabbrowser.js <browser/base/content/tabbrowser.js>` for the same historical reasons.
+
+.. toctree::
+ :maxdepth: 1
+
+ async-tab-switcher
diff --git a/browser/base/content/fullscreen-and-pointerlock.inc.xhtml b/browser/base/content/fullscreen-and-pointerlock.inc.xhtml
new file mode 100644
index 0000000000..418f2ddcb1
--- /dev/null
+++ b/browser/base/content/fullscreen-and-pointerlock.inc.xhtml
@@ -0,0 +1,30 @@
+# 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/.
+
+<html:div id="fullscreen-and-pointerlock-wrapper">
+ <html:div id="fullscreen-warning" class="pointerlockfswarning" hidden="">
+ <html:div class="pointerlockfswarning-domain-text">
+ <html:span class="pointerlockfswarning-domain" data-l10n-name="domain"/>
+ </html:div>
+ <html:div class="pointerlockfswarning-generic-text"
+ data-l10n-id="fullscreen-warning-no-domain"></html:div>
+ <html:button id="fullscreen-exit-button"
+ onclick="FullScreen.exitDomFullScreen();"
+#ifdef XP_MACOSX
+ data-l10n-id="fullscreen-exit-mac-button"
+#else
+ data-l10n-id="fullscreen-exit-button"
+#endif
+ >
+ </html:button>
+ </html:div>
+
+ <html:div id="pointerlock-warning" class="pointerlockfswarning" hidden="">
+ <html:div class="pointerlockfswarning-domain-text">
+ <html:span class="pointerlockfswarning-domain" data-l10n-name="domain"/>
+ </html:div>
+ <html:div class="pointerlockfswarning-generic-text"
+ data-l10n-id="pointerlock-warning-no-domain"></html:div>
+ </html:div>
+</html:div>
diff --git a/browser/base/content/global-scripts.inc b/browser/base/content/global-scripts.inc
new file mode 100644
index 0000000000..f5f43b8f95
--- /dev/null
+++ b/browser/base/content/global-scripts.inc
@@ -0,0 +1,25 @@
+# -*- Mode: Java; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*-
+# 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/.
+
+# JS files which are needed by browser.xhtml but no other top level windows to
+# support MacOS specific features should be loaded directly from browser.xhtml
+# rather than this file.
+
+# If you update this list, you may need to add a mapping within the following
+# file so that ESLint works correctly:
+# tools/lint/eslint/eslint-plugin-mozilla/lib/environments/browser-window.js
+
+<script type="text/javascript">
+Services.scriptloader.loadSubScript("chrome://browser/content/browser.js", this);
+Services.scriptloader.loadSubScript("chrome://browser/content/places/browserPlacesViews.js", this);
+Services.scriptloader.loadSubScript("chrome://browser/content/browser-places.js", this);
+Services.scriptloader.loadSubScript("chrome://global/content/globalOverlay.js", this);
+Services.scriptloader.loadSubScript("chrome://global/content/editMenuOverlay.js", this);
+Services.scriptloader.loadSubScript("chrome://browser/content/utilityOverlay.js", this);
+if (AppConstants.platform == "macosx") {
+ Services.scriptloader.loadSubScript("chrome://global/content/macWindowMenu.js", this);
+}
+
+</script>
diff --git a/browser/base/content/hiddenWindowMac.xhtml b/browser/base/content/hiddenWindowMac.xhtml
new file mode 100644
index 0000000000..c27d394d37
--- /dev/null
+++ b/browser/base/content/hiddenWindowMac.xhtml
@@ -0,0 +1,33 @@
+<?xml version="1.0"?>
+# -*- Mode: HTML -*-
+#
+# 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/.
+#define HIDDEN_WINDOW
+
+<?xml-stylesheet href="chrome://browser/skin/webRTC-menubar-indicator.css" type="text/css"?>
+
+<window id="main-window"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ data-l10n-sync="true">
+
+#include macWindow.inc.xhtml
+
+<!-- Dock menu -->
+<popupset>
+ <!-- Hide this menupopup from the accessibility tree so that we don't fire
+ any unintended accessibility notifications for it. Accessibility for the
+ Dock menu is handled natively by macOS. -->
+ <menupopup id="menu_mac_dockmenu" aria-hidden="true">
+ <!-- The command cannot be cmd_newNavigator because we need to activate
+ the application. -->
+ <menuitem data-l10n-id="menu-file-new-window" oncommand="OpenBrowserWindowFromDockMenu();"
+ id="macDockMenuNewWindow" />
+ <menuitem data-l10n-id="menu-file-new-private-window" oncommand="OpenBrowserWindowFromDockMenu({private: true});"
+ id="macDockMenuNewPrivateWindow" />
+ </menupopup>
+</popupset>
+
+</window>
diff --git a/browser/base/content/logos/etp-mobile.svg b/browser/base/content/logos/etp-mobile.svg
new file mode 100644
index 0000000000..dfb6eed9c5
--- /dev/null
+++ b/browser/base/content/logos/etp-mobile.svg
@@ -0,0 +1,13 @@
+<!-- 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/. -->
+<svg xmlns="http://www.w3.org/2000/svg" width="50" height="57">
+ <defs>
+ <linearGradient id="a" x1="13.375%" x2="86.625%" y1="0%" y2="100%">
+ <stop offset="0%" stop-color="#9059FF"/>
+ <stop offset="100%" stop-color="#0250BB"/>
+ </linearGradient>
+ </defs>
+ <path fill="context-fill" fill-opacity=".8" fill-rule="evenodd" d="M43.436 34.835c.173 1.234.01 1.996-.491 2.286L23.723 48.218c-.749.433-1.745.113-2.225-.718L3.975 17.148c-.479-.828-.258-1.853.49-2.285L23.688 3.765c.751-.434 1.747-.11 2.225.718l17.524 30.352zM33.89 49.232l4.619-2.667-1.616-2.8-4.619 2.668 1.616 2.799zM2.738 11.289C.191 12.76-.43 16.453 1.351 19.538l19.382 33.571c1.78 3.085 5.29 4.393 7.838 2.923l18.487-10.674c2.547-1.47 3.169-5.164 1.388-8.249L29.063 3.538C27.283.453 23.773-.855 21.226.615L2.738 11.29z"/>
+ <path fill="url(#a)" d="M15.763 23.5c1.117 1.935 1.84 3.32 2.268 3.93a10.404 10.404 0 0 0 4.784 4.195 5.807 5.807 0 0 0 4.095.184 5.804 5.804 0 0 0 1.888-3.638 10.411 10.411 0 0 0-1.24-6.241c-.314-.676-1.152-1.995-2.27-3.93l-5.233 1.935-4.292 3.566zm12.535 10.713l-.141.063a8.585 8.585 0 0 1-6.446-.132 12.733 12.733 0 0 1-5.778-5.057c-.667-.946-1.933-3.14-2.638-4.36a2.537 2.537 0 0 1 .566-3.22l4.798-3.99 5.852-2.16a2.537 2.537 0 0 1 3.074 1.12c.704 1.22 1.97 3.413 2.452 4.467a12.732 12.732 0 0 1 1.49 7.533 8.585 8.585 0 0 1-3.108 5.648l-.12.088zm-7.197-12.466l-2.683 2.229c.659 1.115 1.174 1.949 1.45 2.338a7.927 7.927 0 0 0 3.77 3.427 3.692 3.692 0 0 0 2.196.229l.01-.006-4.743-8.217z"/>
+</svg>
diff --git a/browser/base/content/logos/fxa-logo.svg b/browser/base/content/logos/fxa-logo.svg
new file mode 100644
index 0000000000..5f78f7a711
--- /dev/null
+++ b/browser/base/content/logos/fxa-logo.svg
@@ -0,0 +1,6 @@
+<!-- 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/. -->
+<svg viewBox="0 0 80 80" xmlns="http://www.w3.org/2000/svg">
+<radialGradient id="a" cx="71.531" cy="16.385" gradientUnits="userSpaceOnUse" r="90.78"><stop offset="0" stop-color="#fff36e"/><stop offset=".5" stop-color="#fc4055"/><stop offset="1" stop-color="#e31587"/></radialGradient><radialGradient id="b" cx="6.629" cy="20.14" gradientUnits="userSpaceOnUse" r="53.726"><stop offset=".001" stop-color="#c60084"/><stop offset="1" stop-color="#fc4055" stop-opacity="0"/></radialGradient><radialGradient id="c" cx="79.291" cy="11.219" gradientUnits="userSpaceOnUse" r="106.599"><stop offset="0" stop-color="#ffde67" stop-opacity=".6"/><stop offset=".093" stop-color="#ffd966" stop-opacity=".581"/><stop offset=".203" stop-color="#ffca65" stop-opacity=".525"/><stop offset=".321" stop-color="#feb262" stop-opacity=".432"/><stop offset=".446" stop-color="#fe8f5e" stop-opacity=".302"/><stop offset=".573" stop-color="#fd6459" stop-opacity=".137"/><stop offset=".664" stop-color="#fc4055" stop-opacity="0"/></radialGradient><radialGradient id="d" cx="42.285" cy="44.404" gradientUnits="userSpaceOnUse" r="137.521"><stop offset=".153" stop-color="#810220"/><stop offset=".167" stop-color="#920b27" stop-opacity=".861"/><stop offset=".216" stop-color="#cb2740" stop-opacity=".398"/><stop offset=".253" stop-color="#ef394f" stop-opacity=".11"/><stop offset=".272" stop-color="#fc4055" stop-opacity="0"/></radialGradient><radialGradient id="e" cx="31.878" cy="42.675" gradientUnits="userSpaceOnUse" r="137.521"><stop offset=".113" stop-color="#810220"/><stop offset=".133" stop-color="#920b27" stop-opacity=".861"/><stop offset=".204" stop-color="#cb2740" stop-opacity=".398"/><stop offset=".257" stop-color="#ef394f" stop-opacity=".11"/><stop offset=".284" stop-color="#fc4055" stop-opacity="0"/></radialGradient><linearGradient id="f" gradientUnits="userSpaceOnUse" x1="45.831" x2="69.389" y1="7.787" y2="48.591"><stop offset="0" stop-color="#ffbd4f"/><stop offset=".508" stop-color="#ff9640" stop-opacity="0"/></linearGradient><radialGradient id="g" cx="-1255.933" cy="-77.395" gradientTransform="matrix(.959 0 0 .961 1273.896 86.468)" gradientUnits="userSpaceOnUse" r="88.863"><stop offset="0" stop-color="#ff9640"/><stop offset=".8" stop-color="#fc4055"/></radialGradient><radialGradient id="h" cx="-1255.933" cy="-77.395" gradientTransform="matrix(.959 0 0 .961 1273.896 86.468)" gradientUnits="userSpaceOnUse" r="88.863"><stop offset=".084" stop-color="#ffde67"/><stop offset=".147" stop-color="#ffdc66" stop-opacity=".968"/><stop offset=".246" stop-color="#ffd562" stop-opacity=".879"/><stop offset=".369" stop-color="#ffcb5d" stop-opacity=".734"/><stop offset=".511" stop-color="#ffbc55" stop-opacity=".533"/><stop offset=".667" stop-color="#ffaa4b" stop-opacity=".28"/><stop offset=".822" stop-color="#ff9640" stop-opacity="0"/></radialGradient><radialGradient id="i" cx="49.941" cy="38.654" gradientTransform="matrix(.247 .971 -1.011 .259 76.681 -19.851)" gradientUnits="userSpaceOnUse" r="41.79"><stop offset=".363" stop-color="#fc4055"/><stop offset=".443" stop-color="#fd604d" stop-opacity=".633"/><stop offset=".545" stop-color="#fe8644" stop-opacity=".181"/><stop offset=".59" stop-color="#ff9640" stop-opacity="0"/></radialGradient><radialGradient id="j" cx="42.737" cy="42.098" gradientUnits="userSpaceOnUse" r="41.79"><stop offset=".216" stop-color="#fc4055" stop-opacity=".8"/><stop offset=".267" stop-color="#fd5251" stop-opacity=".633"/><stop offset=".41" stop-color="#fe8345" stop-opacity=".181"/><stop offset=".474" stop-color="#ff9640" stop-opacity="0"/></radialGradient><radialGradient id="k" cx="-1238.198" cy="-87.433" gradientTransform="matrix(.959 0 0 .961 1273.896 86.468)" gradientUnits="userSpaceOnUse" r="150.195"><stop offset=".054" stop-color="#fff36e"/><stop offset=".457" stop-color="#ff9640"/><stop offset=".639" stop-color="#ff9640"/></radialGradient><linearGradient id="l" gradientUnits="userSpaceOnUse" x1="59.052" x2="18.155" y1="7.083" y2="77.92"><stop offset="0" stop-color="#fff36e" stop-opacity=".8"/><stop offset=".094" stop-color="#fff36e" stop-opacity=".699"/><stop offset=".752" stop-color="#fff36e" stop-opacity="0"/></linearGradient><linearGradient id="m" gradientUnits="userSpaceOnUse" x1="40.585" x2="62.3" y1="-.67" y2="62.203"><stop offset="0" stop-color="#b833e1"/><stop offset=".371" stop-color="#9059ff"/><stop offset=".614" stop-color="#5b6df8"/><stop offset="1" stop-color="#0090ed"/></linearGradient><linearGradient id="n" gradientUnits="userSpaceOnUse" x1="27.71" x2="68.071" y1=".324" y2="40.685"><stop offset=".805" stop-color="#722291" stop-opacity="0"/><stop offset="1" stop-color="#592acb" stop-opacity=".5"/></linearGradient><path d="m71.944 15.7a39.47 39.47 0 0 0 -30.356-15.691c-9.288-.186-15.704 2.605-19.334 4.849 4.857-2.817 11.886-4.415 18.04-4.336 15.83.2 32.832 10.981 35.357 30.413 2.9 22.306-12.637 40.923-34.493 40.98-24.045.061-38.67-21.229-34.847-40.352a19.735 19.735 0 0 1 .413-2.787 37.815 37.815 0 0 1 4.193-14.018c-2.769 1.433-6.295 5.965-8.035 10.163a41.355 41.355 0 0 0 -2.598 20.179c.06.518.114 1.035.182 1.549a40.062 40.062 0 1 0 71.478-30.949z" fill="url(#a)"/><path d="m71.944 15.7a39.47 39.47 0 0 0 -30.356-15.691c-9.288-.186-15.704 2.605-19.334 4.849 4.857-2.817 11.886-4.415 18.04-4.336 15.83.2 32.832 10.981 35.357 30.413 2.9 22.306-12.637 40.923-34.493 40.98-24.045.061-38.67-21.229-34.847-40.352a19.735 19.735 0 0 1 .413-2.787 37.815 37.815 0 0 1 4.193-14.018c-2.769 1.433-6.295 5.965-8.035 10.163a41.355 41.355 0 0 0 -2.598 20.179c.06.518.114 1.035.182 1.549a40.062 40.062 0 1 0 71.478-30.949z" fill="url(#b)" opacity=".67"/><path d="m71.944 15.7a39.47 39.47 0 0 0 -30.356-15.691c-9.288-.186-15.704 2.605-19.334 4.849 4.857-2.817 11.886-4.415 18.04-4.336 15.83.2 32.832 10.981 35.357 30.413 2.9 22.306-12.637 40.923-34.493 40.98-24.045.061-38.67-21.229-34.847-40.352a19.735 19.735 0 0 1 .413-2.787 37.815 37.815 0 0 1 4.193-14.018c-2.769 1.433-6.295 5.965-8.035 10.163a41.355 41.355 0 0 0 -2.598 20.179c.06.518.114 1.035.182 1.549a40.062 40.062 0 1 0 71.478-30.949z" fill="url(#c)"/><path d="m71.944 15.7a39.47 39.47 0 0 0 -30.356-15.691c-9.288-.186-15.704 2.605-19.334 4.849 4.857-2.817 11.886-4.415 18.04-4.336 15.83.2 32.832 10.981 35.357 30.413 2.9 22.306-12.637 40.923-34.493 40.98-24.045.061-38.67-21.229-34.847-40.352a19.735 19.735 0 0 1 .413-2.787 37.815 37.815 0 0 1 4.193-14.018c-2.769 1.433-6.295 5.965-8.035 10.163a41.355 41.355 0 0 0 -2.598 20.179c.06.518.114 1.035.182 1.549a40.062 40.062 0 1 0 71.478-30.949z" fill="url(#d)"/><path d="m71.944 15.7a39.47 39.47 0 0 0 -30.356-15.691c-9.288-.186-15.704 2.605-19.334 4.849 4.857-2.817 11.886-4.415 18.04-4.336 15.83.2 32.832 10.981 35.357 30.413 2.9 22.306-12.637 40.923-34.493 40.98-24.045.061-38.67-21.229-34.847-40.352a19.735 19.735 0 0 1 .413-2.787 37.815 37.815 0 0 1 4.193-14.018c-2.769 1.433-6.295 5.965-8.035 10.163a41.355 41.355 0 0 0 -2.598 20.179c.06.518.114 1.035.182 1.549a40.062 40.062 0 1 0 71.478-30.949z" fill="url(#e)"/><path d="m75.651 30.935a41.01 41.01 0 0 1 .3 7.247q1.99-.3 3.987-.53a40.01 40.01 0 0 0 -7.994-21.952 39.47 39.47 0 0 0 -30.356-15.691c-9.288-.186-15.704 2.605-19.334 4.849 4.857-2.817 11.886-4.415 18.04-4.336 15.83.202 32.832 10.978 35.357 30.413z" fill="url(#f)"/><path d="m76.625 29.826c-2.251-20.308-20.362-29.436-36.331-29.304-6.155.05-13.183 1.519-18.04 4.336a19.7 19.7 0 0 0 -3.56 2.7c.129-.107.514-.424 1.152-.862l.063-.043.056-.038a26.655 26.655 0 0 1 7.692-3.572 43.5 43.5 0 0 1 13.183-1.543 33.254 33.254 0 0 1 31.25 31.993c.367 13.207-10.442 23.737-22.902 24.347-9.062.444-17.6-3.941-21.77-12.713a21.68 21.68 0 0 1 -1.964-6.333c-1.976-13.35 6.989-24.735 15.21-27.554-4.435-3.874-15.548-3.611-23.819 2.474-5.956 4.382-9.82 11.049-11.1 19a32.945 32.945 0 0 0 2.34 18 35.3 35.3 0 0 0 30.089 21.443q1.489.114 2.984.113c26.462 0 37.942-20.087 35.467-42.444z" fill="url(#g)"/><path d="m76.625 29.826c-2.251-20.308-20.362-29.436-36.331-29.304-6.155.05-13.183 1.519-18.04 4.336a19.7 19.7 0 0 0 -3.56 2.7c.129-.107.514-.424 1.152-.862l.063-.043.056-.038a26.655 26.655 0 0 1 7.692-3.572 43.5 43.5 0 0 1 13.183-1.543 33.254 33.254 0 0 1 31.25 31.993c.367 13.207-10.442 23.737-22.902 24.347-9.062.444-17.6-3.941-21.77-12.713a21.68 21.68 0 0 1 -1.964-6.333c-1.976-13.35 6.989-24.735 15.21-27.554-4.435-3.874-15.548-3.611-23.819 2.474-5.956 4.382-9.82 11.049-11.1 19a32.945 32.945 0 0 0 2.34 18 35.3 35.3 0 0 0 30.089 21.443q1.489.114 2.984.113c26.462 0 37.942-20.087 35.467-42.444z" fill="url(#h)"/><path d="m76.625 29.826c-2.251-20.308-20.362-29.436-36.331-29.304-6.155.05-13.183 1.519-18.04 4.336a19.7 19.7 0 0 0 -3.56 2.7c.129-.107.514-.424 1.152-.862l.063-.043.056-.038a26.655 26.655 0 0 1 7.692-3.572 43.5 43.5 0 0 1 13.183-1.543 33.254 33.254 0 0 1 31.25 31.993c.367 13.207-10.442 23.737-22.902 24.347-9.062.444-17.6-3.941-21.77-12.713a21.68 21.68 0 0 1 -1.964-6.333c-1.976-13.35 6.989-24.735 15.21-27.554-4.435-3.874-15.548-3.611-23.819 2.474-5.956 4.382-9.82 11.049-11.1 19a32.945 32.945 0 0 0 2.34 18 35.3 35.3 0 0 0 30.089 21.443q1.489.114 2.984.113c26.462 0 37.942-20.087 35.467-42.444z" fill="url(#i)" opacity=".53"/><path d="m76.625 29.826c-2.251-20.308-20.362-29.436-36.331-29.304-6.155.05-13.183 1.519-18.04 4.336a19.7 19.7 0 0 0 -3.56 2.7c.129-.107.514-.424 1.152-.862l.063-.043.056-.038a26.655 26.655 0 0 1 7.692-3.572 43.5 43.5 0 0 1 13.183-1.543 33.254 33.254 0 0 1 31.25 31.993c.367 13.207-10.442 23.737-22.902 24.347-9.062.444-17.6-3.941-21.77-12.713a21.68 21.68 0 0 1 -1.964-6.333c-1.976-13.35 6.989-24.735 15.21-27.554-4.435-3.874-15.548-3.611-23.819 2.474-5.956 4.382-9.82 11.049-11.1 19a32.945 32.945 0 0 0 2.34 18 35.3 35.3 0 0 0 30.089 21.443q1.489.114 2.984.113c26.462 0 37.942-20.087 35.467-42.444z" fill="url(#j)" opacity=".53"/><path d="m49.188 57.84c17.1-1.04 24.42-15.2 24.879-25.245.716-15.695-8.595-32.615-33.227-31.095a43.5 43.5 0 0 0 -13.183 1.546 28.855 28.855 0 0 0 -7.692 3.572l-.056.038-.063.043q-.574.4-1.123.842a33.482 33.482 0 0 1 20.977-3.936c14.142 1.856 27.072 12.857 27.072 27.373 0 11.169-8.631 19.7-18.738 19.087-15.015-.9-18.8-16.3-10.989-22.954-2.106-.453-6.064.435-8.82 4.555-2.473 3.7-2.333 9.41-.807 13.461a22.118 22.118 0 0 0 21.77 12.713z" fill="url(#k)"/><path d="m71.944 15.7a39.958 39.958 0 0 0 -3.482-3.982 31.342 31.342 0 0 0 -3.177-2.926 24.393 24.393 0 0 1 1.849 1.79 22.466 22.466 0 0 1 4.882 8.144c2.089 6.329 1.953 14.25-2.036 20.471a23.539 23.539 0 0 1 -20.855 10.895c-.361 0-.725 0-1.091-.027-15.015-.9-18.8-16.3-10.988-22.954-2.107-.453-6.065.435-8.821 4.555-2.473 3.7-2.333 9.41-.807 13.461a21.679 21.679 0 0 1 -1.963-6.333c-1.977-13.35 6.988-24.735 15.209-27.554-4.435-3.874-15.548-3.611-23.819 2.474a27.845 27.845 0 0 0 -10.087 14.6 38.5 38.5 0 0 1 4.159-13.553c-2.769 1.433-6.295 5.965-8.035 10.163a41.355 41.355 0 0 0 -2.598 20.176c.06.518.114 1.035.182 1.549a40.062 40.062 0 1 0 71.478-30.949z" fill="url(#l)"/><path d="m72.016 18.726a22.458 22.458 0 0 0 -4.882-8.144 30.224 30.224 0 0 0 -9.094-6.493 40.518 40.518 0 0 0 -8.94-3.169 39.834 39.834 0 0 0 -16.565-.1c-5.683 1.2-10.68 3.659-13.841 6.733a32.1 32.1 0 0 1 8.031-3.2 33.565 33.565 0 0 1 31.173 8.1 27.01 27.01 0 0 1 4.329 5.3c4.895 7.959 4.432 17.965.615 23.866-2.835 4.384-8.907 8.5-14.572 8.452a23.629 23.629 0 0 0 21.71-10.871c3.989-6.224 4.125-14.145 2.036-20.474z" fill="url(#m)"/><path d="m72.016 18.726a22.458 22.458 0 0 0 -4.882-8.144 30.224 30.224 0 0 0 -9.094-6.493 40.518 40.518 0 0 0 -8.94-3.169 39.834 39.834 0 0 0 -16.565-.1c-5.683 1.2-10.68 3.659-13.841 6.733a32.1 32.1 0 0 1 8.031-3.2 33.565 33.565 0 0 1 31.173 8.1 27.01 27.01 0 0 1 4.329 5.3c4.895 7.959 4.432 17.965.615 23.866-2.835 4.384-8.907 8.5-14.572 8.452a23.629 23.629 0 0 0 21.71-10.871c3.989-6.224 4.125-14.145 2.036-20.474z" fill="url(#n)"/>
+</svg> \ No newline at end of file
diff --git a/browser/base/content/logos/lockwise.svg b/browser/base/content/logos/lockwise.svg
new file mode 100644
index 0000000000..d3e354cdc1
--- /dev/null
+++ b/browser/base/content/logos/lockwise.svg
@@ -0,0 +1,4 @@
+<!-- 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/. -->
+<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><linearGradient id="a" gradientTransform="matrix(1 0 0 -1 0 66)" gradientUnits="userSpaceOnUse" x1="44.25" x2="18.88" y1="55.37" y2="11.44"><stop offset="0" stop-color="#ff980e"/><stop offset=".11" stop-color="#ff851b"/><stop offset=".57" stop-color="#ff3750"/><stop offset=".8" stop-color="#f92261"/><stop offset="1" stop-color="#f5156c"/></linearGradient><linearGradient id="b" gradientTransform="matrix(1 0 0 -1 0 66)" gradientUnits="userSpaceOnUse" x1="44.12" x2="23.37" y1="62.59" y2="13.68"><stop offset="0" stop-color="#fff261" stop-opacity=".8"/><stop offset=".06" stop-color="#fff261" stop-opacity=".68"/><stop offset=".19" stop-color="#fff261" stop-opacity=".48"/><stop offset=".31" stop-color="#fff261" stop-opacity=".31"/><stop offset=".42" stop-color="#fff261" stop-opacity=".17"/><stop offset=".53" stop-color="#fff261" stop-opacity=".08"/><stop offset=".63" stop-color="#fff261" stop-opacity=".02"/><stop offset=".72" stop-color="#fff261" stop-opacity="0"/></linearGradient><linearGradient id="c" gradientTransform="matrix(1 0 0 -1 0 66)" gradientUnits="userSpaceOnUse" x1="54.08" x2="54.08" y1="8.93" y2="42.2"><stop offset="0" stop-color="#0090ed"/><stop offset=".5" stop-color="#9059ff"/><stop offset=".81" stop-color="#b833e1"/></linearGradient><linearGradient id="d" gradientTransform="matrix(1 0 0 -1 0 66)" gradientUnits="userSpaceOnUse" x1="16.46" x2="37.88" y1="7.08" y2="43.53"><stop offset=".02" stop-color="#0090ed"/><stop offset=".49" stop-color="#9059ff"/><stop offset="1" stop-color="#b833e1"/></linearGradient><linearGradient id="e" gradientTransform="matrix(1 0 0 -1 0 66)" gradientUnits="userSpaceOnUse" x1="19.25" x2="6.77" y1="21.12" y2="33.61"><stop offset=".14" stop-color="#592acb" stop-opacity="0"/><stop offset=".33" stop-color="#542bc8" stop-opacity=".03"/><stop offset=".53" stop-color="#462fbf" stop-opacity=".11"/><stop offset=".74" stop-color="#2f35b1" stop-opacity=".25"/><stop offset=".95" stop-color="#0f3d9c" stop-opacity=".44"/><stop offset="1" stop-color="#054096" stop-opacity=".5"/></linearGradient><linearGradient id="f" gradientTransform="matrix(1 0 0 -1 0 66)" gradientUnits="userSpaceOnUse" x1="57" x2="50.71" y1="34.92" y2="24.03"><stop offset="0" stop-color="#722291" stop-opacity=".5"/><stop offset=".5" stop-color="#b833e1" stop-opacity="0"/></linearGradient><linearGradient id="g" gradientTransform="matrix(1 0 0 -1 0 66)" gradientUnits="userSpaceOnUse" x1="43.72" x2="36.42" y1="19.33" y2="11.1"><stop offset="0" stop-color="#054096" stop-opacity=".5"/><stop offset=".03" stop-color="#0f3d9c" stop-opacity=".44"/><stop offset=".17" stop-color="#2f35b1" stop-opacity=".25"/><stop offset=".3" stop-color="#462fbf" stop-opacity=".11"/><stop offset=".43" stop-color="#542bc8" stop-opacity=".03"/><stop offset=".56" stop-color="#592acb" stop-opacity="0"/></linearGradient><path d="M57.45 25.11A218.35 218.35 0 0 0 38.82 6.48a10.81 10.81 0 0 0-13.77 0A219.81 219.81 0 0 0 6.42 25.11a10.83 10.83 0 0 0 0 13.78 218.35 218.35 0 0 0 18.63 18.63 10.84 10.84 0 0 0 13.8 0c3.43-3.1 6.56-6.09 9.57-9.15a3.1 3.1 0 0 0-.24-4.27l-9.25-8.63a10.62 10.62 0 0 0 3.56-8.4 10.78 10.78 0 0 0-10.08-10.26 10.7 10.7 0 0 0-8.37 18c.21.22.42.42.64.62l-3.35 3a2.7 2.7 0 0 0 3.61 4l3.7-3.35.1-.1a5.07 5.07 0 0 0 1.48-3.79 5.2 5.2 0 0 0-1.78-3.71 5.3 5.3 0 1 1 7.47-.63 4.24 4.24 0 0 1-.64.63 5.2 5.2 0 0 0-1.83 3.73A5 5 0 0 0 34.92 39l.06.07 7.77 7.27c-2.38 2.36-4.86 4.7-7.5 7.09a5.51 5.51 0 0 1-6.61 0 214 214 0 0 1-18.2-18.19 5.55 5.55 0 0 1 0-6.62 214 214 0 0 1 18.2-18.19 5.51 5.51 0 0 1 6.61 0 213.86 213.86 0 0 1 18.19 18.23 5.54 5.54 0 0 1 0 6.61c-.93 1-1.86 2.1-2.8 3.06a2.7 2.7 0 1 0 4 3.65c.92-1 1.87-2 2.8-3.12a10.84 10.84 0 0 0 .01-13.75z" fill="url(#a)"/><path d="M57.56 25.1A218.7 218.7 0 0 0 38.91 6.46a10.82 10.82 0 0 0-13.79 0A217.26 217.26 0 0 0 6.48 25.11a10.82 10.82 0 0 0 0 13.79 217.26 217.26 0 0 0 18.65 18.64 10.85 10.85 0 0 0 13.81 0c3.43-3.09 6.56-6.09 9.58-9.15a3.11 3.11 0 0 0-.24-4.28L39 35.45a10.62 10.62 0 0 0 3.56-8.4A10.79 10.79 0 0 0 32.5 16.79a10.71 10.71 0 0 0-8.38 18.05c.2.21.41.42.63.61l-3.35 3a2.71 2.71 0 0 0 3.62 4l3.7-3.36.1-.09a5.08 5.08 0 0 0 1.48-3.8 5.2 5.2 0 0 0-1.78-3.71 5.3 5.3 0 1 1 7.48-.58 5.43 5.43 0 0 1-.64.63 5.2 5.2 0 0 0-1.83 3.73A5.08 5.08 0 0 0 35 39.05l.07.06 7.77 7.29c-2.38 2.36-4.86 4.7-7.5 7.09a5.54 5.54 0 0 1-6.63 0A217.3 217.3 0 0 1 10.5 35.28a5.55 5.55 0 0 1 0-6.62 214.33 214.33 0 0 1 18.21-18.21 5.52 5.52 0 0 1 6.62 0 214.33 214.33 0 0 1 18.21 18.21 5.52 5.52 0 0 1 0 6.62c-.93 1-1.86 2.1-2.8 3.06a2.7 2.7 0 0 0 4 3.65c.93-1 1.88-2.05 2.8-3.13a10.84 10.84 0 0 0 .02-13.76z" fill="url(#b)"/><path d="M53.41 28.69a5.51 5.51 0 0 1 0 6.61c-.93 1.05-1.86 2.1-2.8 3.06a2.7 2.7 0 0 0 4 3.65c.92-1 1.87-2 2.8-3.13 3.34-3.73-4-10.19-4-10.19z" fill="url(#c)"/><path d="M42.75 46.38c-2.38 2.36-4.86 4.7-7.5 7.09a5.51 5.51 0 0 1-6.61 0 214 214 0 0 1-18.2-18.19 5.55 5.55 0 0 1 0-6.62l-1.22 1.4a9 9 0 0 0 .15 12.08 216.71 216.71 0 0 0 15.68 15.38 10.84 10.84 0 0 0 13.8 0c1.95-1.77 4.12-3.8 6.07-5.68a2.35 2.35 0 0 0 .08-3.35l-.06-.07z" fill="url(#d)"/><path d="M9.39 42.17c2 2.21 4.08 4.33 6.17 6.4l.83-1.35c.7-1.12 1.4-2.21 2.15-3.3-2.69-2.73-5.36-5.56-8.08-8.6a5.55 5.55 0 0 1 0-6.62l-1.22 1.4a9 9 0 0 0 .13 12z" fill="url(#e)" opacity=".9"/><path d="M53.41 28.69a5.51 5.51 0 0 1 0 6.61c-.93 1.05-1.86 2.1-2.8 3.06a2.7 2.7 0 0 0 4 3.65c.92-1 1.87-2 2.8-3.13 3.34-3.73-4-10.19-4-10.19z" fill="url(#f)"/><path d="M44.89 48.42l-2.14-2c-2.38 2.36-4.86 4.7-7.5 7.09a5.4 5.4 0 0 1-4.25 1v5.43c.31 0 .62.05.93.05a10.45 10.45 0 0 0 6.91-2.49c2-1.77 4.12-3.8 6.07-5.68A2.35 2.35 0 0 0 45 48.5a.6.6 0 0 1-.11-.08z" fill="url(#g)" opacity=".9"/></svg>
diff --git a/browser/base/content/logos/monitor.svg b/browser/base/content/logos/monitor.svg
new file mode 100644
index 0000000000..2952a65555
--- /dev/null
+++ b/browser/base/content/logos/monitor.svg
@@ -0,0 +1,4 @@
+<!-- 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/. -->
+<svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><linearGradient id="a" gradientTransform="matrix(1 0 0 -1 0 66)" gradientUnits="userSpaceOnUse" x1="44.39" x2="17.83" y1="55.11" y2="9.1"><stop offset="0" stop-color="#ff980e"/><stop offset=".21" stop-color="#ff7139"/><stop offset=".36" stop-color="#ff5854"/><stop offset=".46" stop-color="#ff4f5e"/><stop offset=".69" stop-color="#ff3750"/><stop offset=".86" stop-color="#f92261"/><stop offset="1" stop-color="#f5156c"/></linearGradient><linearGradient id="b" gradientTransform="matrix(1 0 0 -1 0 66)" gradientUnits="userSpaceOnUse" x1="44.39" x2="17.83" y1="55.11" y2="9.1"><stop offset="0" stop-color="#fff44f" stop-opacity=".8"/><stop offset=".75" stop-color="#fff44f" stop-opacity="0"/></linearGradient><linearGradient id="c" gradientTransform="matrix(1 0 0 -1 0 66)" gradientUnits="userSpaceOnUse" x1="44.49" x2="44.49" y1="3.82" y2="58.55"><stop offset="0" stop-color="#3a8ee6"/><stop offset=".24" stop-color="#5c79f0"/><stop offset=".63" stop-color="#9059ff"/><stop offset="1" stop-color="#c139e6"/></linearGradient><linearGradient id="d" gradientTransform="matrix(1 0 0 -1 0 66)" gradientUnits="userSpaceOnUse" x1="35.2" x2="59.52" y1="60.58" y2="36.25"><stop offset="0" stop-color="#6e008b" stop-opacity=".5"/><stop offset=".5" stop-color="#c846cb" stop-opacity="0"/></linearGradient><linearGradient id="e" gradientTransform="matrix(1 0 0 -1 0 66)" gradientUnits="userSpaceOnUse" x1="59.67" x2="45.66" y1="30.62" y2="16.61"><stop offset=".14" stop-color="#6a2bea" stop-opacity="0"/><stop offset=".3" stop-color="#662ce6" stop-opacity=".09"/><stop offset=".47" stop-color="#592fdb" stop-opacity=".19"/><stop offset=".64" stop-color="#4534c9" stop-opacity=".29"/><stop offset=".81" stop-color="#283baf" stop-opacity=".39"/><stop offset=".99" stop-color="#03448d" stop-opacity=".49"/><stop offset="1" stop-color="#00458b" stop-opacity=".5"/></linearGradient><linearGradient id="f" gradientTransform="matrix(1 0 0 -1 0 66)" gradientUnits="userSpaceOnUse" x1="38.67" x2="41.95" y1="21.69" y2="17.77"><stop offset="0" stop-color="#960e18" stop-opacity=".6"/><stop offset=".17" stop-color="#a91522" stop-opacity=".47"/><stop offset=".51" stop-color="#d9283c" stop-opacity=".19"/><stop offset=".75" stop-color="#ff3750" stop-opacity="0"/></linearGradient><path d="m54.55 15.53-5.71-3.26-13-7.46-.41-.23a6.88 6.88 0 0 0 -6.88 0l-.42.23-18.28 10.48-.41.24a6.83 6.83 0 0 0 -3.44 5.92v21.89a6.86 6.86 0 0 0 3.44 5.92l18.7 10.74a2.75 2.75 0 0 0 1.44.38 2.91 2.91 0 0 0 2.49-1.42 2.83 2.83 0 0 0 -1.07-3.96l-18.29-10.44a2 2 0 0 1 -1-1.69v-20.95a2 2 0 0 1 1-1.69l3.29-1.85 15-8.63a2 2 0 0 1 2 0l18.27 10.48a2 2 0 0 1 1 1.69v20.95a1.94 1.94 0 0 1 -1 1.69l-6.18 3.54-3.09-4.71a15 15 0 1 0 -5 2.92l4.75 7.15a1.91 1.91 0 0 0 .24.32 3.18 3.18 0 0 0 .33.3.27.27 0 0 0 .08.07l.41.25h.1a1.3 1.3 0 0 0 .39.14.14.14 0 0 0 .09 0 2.34 2.34 0 0 0 .45.07.3.3 0 0 1 .13 0h.39a.23.23 0 0 0 .11 0 2.57 2.57 0 0 0 .48-.11.26.26 0 0 0 .12 0l.37-.16h.08l8.94-5.13a6.83 6.83 0 0 0 3.54-5.9v-21.86a6.75 6.75 0 0 0 -3.45-5.92zm-31.74 16.85a9.18 9.18 0 1 1 9.19 9.11 9.15 9.15 0 0 1 -9.19-9.11z" fill="url(#a)"/><path d="m54.55 15.53-5.71-3.26-13-7.46-.41-.23a6.88 6.88 0 0 0 -6.88 0l-.42.23-18.28 10.48-.41.24a6.83 6.83 0 0 0 -3.44 5.92v21.89a6.86 6.86 0 0 0 3.44 5.92l18.7 10.74a2.75 2.75 0 0 0 1.44.38 2.91 2.91 0 0 0 2.49-1.42 2.83 2.83 0 0 0 -1.07-3.96l-18.29-10.44a2 2 0 0 1 -1-1.69v-20.95a2 2 0 0 1 1-1.69l3.29-1.85 15-8.63a2 2 0 0 1 2 0l18.27 10.48a2 2 0 0 1 1 1.69v20.95a1.94 1.94 0 0 1 -1 1.69l-6.18 3.54-3.09-4.71a15 15 0 1 0 -5 2.92l4.75 7.15a1.91 1.91 0 0 0 .24.32 3.18 3.18 0 0 0 .33.3.27.27 0 0 0 .08.07l.41.25h.1a1.3 1.3 0 0 0 .39.14.14.14 0 0 0 .09 0 2.34 2.34 0 0 0 .45.07.3.3 0 0 1 .13 0h.39a.23.23 0 0 0 .11 0 2.57 2.57 0 0 0 .48-.11.26.26 0 0 0 .12 0l.37-.16h.08l8.94-5.13a6.83 6.83 0 0 0 3.54-5.9v-21.86a6.75 6.75 0 0 0 -3.45-5.92zm-31.74 16.85a9.18 9.18 0 1 1 9.19 9.11 9.15 9.15 0 0 1 -9.19-9.11z" fill="url(#b)"/><path d="m54.55 15.53-5.71-3.26-7.94-4.55a6.11 6.11 0 0 0 -5.9-.08l-4 2.11a2 2 0 0 1 2 0l18.3 10.48a1.94 1.94 0 0 1 1 1.69v20.95a1.93 1.93 0 0 1 -1 1.69l-6.23 3.54 1 1.55a4.05 4.05 0 0 0 5.41 1.35l3.06-1.75a6.82 6.82 0 0 0 3.46-5.91v-21.9a6.75 6.75 0 0 0 -3.45-5.91z" fill="url(#c)"/><path d="m52.26 21.92v10.16h5.74v-10.64a6.83 6.83 0 0 0 -3.44-5.92l-5.7-3.26-8-4.55a6.14 6.14 0 0 0 -5.86-.09l-4 2.12a2 2 0 0 1 2 0l18.27 10.48a2 2 0 0 1 .99 1.7z" fill="url(#d)"/><path d="m52.26 33.72v9.14a2 2 0 0 1 -1 1.69l-6.19 3.54 1 1.55a4.06 4.06 0 0 0 5.42 1.36l3.06-1.75a6.84 6.84 0 0 0 3.45-5.92v-9.63h-5.74z" fill="url(#e)" opacity=".9"/><path d="m41.17 32.38a9.18 9.18 0 1 0 -9.17 9.11 9.14 9.14 0 0 0 9.17-9.11z" fill="none"/><path d="m44.64 47.43-2.68-4a15 15 0 0 1 -4.96 2.88l2.86 4.31c1.63-1.02 3.22-2.08 4.78-3.19z" fill="url(#f)" opacity=".9"/></svg>
diff --git a/browser/base/content/logos/proxy-dark.svg b/browser/base/content/logos/proxy-dark.svg
new file mode 100644
index 0000000000..f2f4d327b4
--- /dev/null
+++ b/browser/base/content/logos/proxy-dark.svg
@@ -0,0 +1,4 @@
+<!-- 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/. -->
+<svg width="253" height="262" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient x1="79.078%" y1="61.129%" x2="52.439%" y2="48.719%" id="a"><stop stop-color="#054096" stop-opacity=".5" offset="0%"/><stop stop-color="#173BA1" stop-opacity=".442" offset="9.995%"/><stop stop-color="#3434B3" stop-opacity=".329" offset="29.49%"/><stop stop-color="#482EC1" stop-opacity=".217" offset="48.88%"/><stop stop-color="#552BC8" stop-opacity=".107" offset="67.97%"/><stop stop-color="#592ACB" stop-opacity="0" offset="86.4%"/></linearGradient><linearGradient x1="-.008%" y1="50.069%" x2="100.124%" y2="50.069%" id="b"><stop stop-color="#9059FF" offset="0%"/><stop stop-color="#F770FF" offset="100%"/></linearGradient><linearGradient x1="10.951%" y1="41.295%" x2="102.369%" y2="60.901%" id="c"><stop stop-color="#54FFBD" offset=".103%"/><stop stop-color="#0DF" offset="100%"/></linearGradient></defs><g fill="none"><path d="M226.4 168.3H119.7c-3.1 0-5.7 2.5-5.7 5.7v17c0 25.1 20.4 45.5 45.5 45.5h59c18.9 0 34.1-15.3 34.1-34.1v-14.9c.1-8.6-8.4-19.2-26.2-19.2z" fill="#008787"/><path d="M112.4.3H5.7C2.6.3 0 2.8 0 6v17c0 25.1 20.4 45.5 45.5 45.5h59c18.9 0 34.1-15.3 34.1-34.1V19.5c.1-8.6-8.4-19.2-26.2-19.2z" opacity=".9" fill="url(#a)" transform="translate(114 168)"/><path d="M219 55.1L125.9 2.9c-6.7-3.7-14.8-3.7-21.4 0L11.4 55.1C4.5 59 .2 66.3.2 74.2v104.4c0 7.9 4.3 15.2 11.2 19.1l93.1 52.2c3.3 1.9 7 2.8 10.7 2.8 3.7 0 7.4-.9 10.7-2.8l93.1-52.2c6.9-3.9 11.2-11.2 11.2-19.1V74.2c0-7.9-4.3-15.2-11.2-19.1z" fill="#20123A"/><path d="M187.2 80.3l-67.8-38.7c-2.6-1.5-5.9-1.5-8.5 0L43 80.3c-2.7 1.5-4.3 4.4-4.3 7.4V165c0 3.1 1.6 5.9 4.3 7.4l67.8 38.7c1.3.7 2.8 1.1 4.2 1.1 1.4 0 2.9-.4 4.2-1.1l67.8-38.7c2.7-1.5 4.3-4.4 4.3-7.4V87.7c.2-3-1.4-5.8-4.1-7.4zm-12.8 69.8l-50.7-28.7V63.8l50.7 28.9v57.4zm-59.3 43.8l-59.3-33.8V92.7l50.7-28.9v62.3c0 3.6 2.2 6.6 5.3 7.9.5.6 1.2 1.1 1.9 1.5l52.2 29.5-50.8 28.9z" fill="url(#b)"/><path d="M140 6.1H42c-23.2 0-42 18.8-42 42v20.6c0 3.1 2.5 5.7 5.7 5.7h98.1c23.2 0 42-18.8 42-42V.5c-.1 3.1-2.6 5.6-5.8 5.6z" fill="url(#c)" transform="translate(107 187)"/><path d="M150.8 226.9c2.1-1.2 3.2-3.1 3.2-5.7 0-5.1-3.5-7.9-9.8-7.9h-11.7v28.2h11.8c6.3 0 10.1-2.8 10.1-8.4.1-2.9-1.2-5-3.6-6.2zm-12.8-8.8h6.4c2.9 0 4.2 1.2 4.2 3.2 0 1.9-1.2 3.4-4.1 3.4H138v-6.6zm6.4 18.5H138v-7h6.2c3.4 0 4.8 1.3 4.8 3.5 0 2.1-1.6 3.5-4.6 3.5zm15.3 4.9h19v-5.2h-13.5v-6.4h13.5v-5.1h-13.5v-6.4h13.5v-5.1h-19zm44.7-28.2h-21v5h7.8v23.2h5.4v-23.2h7.8zm14.6 0h-5.4L203 241.5h5.6l1.9-5.1h11.8l1.9 5.1h5.6L219 213.3zm-6.8 18.1l4.1-11.2 4.1 11.2h-8.2z" fill="#20123A"/></g></svg>
diff --git a/browser/base/content/logos/proxy-light.svg b/browser/base/content/logos/proxy-light.svg
new file mode 100644
index 0000000000..01fc54a758
--- /dev/null
+++ b/browser/base/content/logos/proxy-light.svg
@@ -0,0 +1,4 @@
+<!-- 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/. -->
+<svg width="253" height="262" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient x1="79.077%" y1="61.129%" x2="52.439%" y2="48.719%" id="a"><stop stop-color="#054096" stop-opacity=".5" offset="0%"/><stop stop-color="#173BA1" stop-opacity=".442" offset="9.995%"/><stop stop-color="#3434B3" stop-opacity=".329" offset="29.49%"/><stop stop-color="#482EC1" stop-opacity=".217" offset="48.88%"/><stop stop-color="#552BC8" stop-opacity=".107" offset="67.97%"/><stop stop-color="#592ACB" stop-opacity="0" offset="86.4%"/></linearGradient><linearGradient x1="-.075%" y1="50.017%" x2="99.986%" y2="50.017%" id="b"><stop stop-color="#9059FF" offset="0%"/><stop stop-color="#F770FF" offset="100%"/></linearGradient><linearGradient x1="10.951%" y1="41.295%" x2="102.369%" y2="60.901%" id="c"><stop stop-color="#54FFBD" offset=".103%"/><stop stop-color="#0DF" offset="100%"/></linearGradient></defs><g fill="none"><path d="M226.4 168.3H119.7c-3.1 0-5.7 2.5-5.7 5.7v17c0 25.1 20.4 45.5 45.5 45.5h59c18.9 0 34.1-15.3 34.1-34.1v-14.9c.1-8.6-8.4-19.2-26.2-19.2z" fill="#008787"/><path d="M112.4.3H5.7C2.6.3 0 2.8 0 6v17c0 25.1 20.4 45.5 45.5 45.5h59c18.9 0 34.1-15.3 34.1-34.1V19.5c.1-8.6-8.4-19.2-26.2-19.2z" opacity=".9" fill="url(#a)" transform="translate(114 168)"/><path d="M219 55.1L125.9 2.9c-6.7-3.7-14.8-3.7-21.4 0L11.4 55.1C4.5 59 .2 66.3.2 74.2v104.4c0 7.9 4.3 15.2 11.2 19.1l93.1 52.2c3.3 1.9 7 2.8 10.7 2.8 3.7 0 7.4-.9 10.7-2.8l93.1-52.2c6.9-3.9 11.2-11.2 11.2-19.1V74.2c0-7.9-4.3-15.2-11.2-19.1z" fill="url(#b)"/><path d="M187.2 80.3l-67.8-38.7c-2.6-1.5-5.9-1.5-8.5 0L43 80.3c-2.7 1.5-4.3 4.4-4.3 7.4V165c0 3.1 1.6 5.9 4.3 7.4l67.8 38.7c1.3.7 2.8 1.1 4.2 1.1 1.4 0 2.9-.4 4.2-1.1l67.8-38.7c2.7-1.5 4.3-4.4 4.3-7.4V87.7c.2-3-1.4-5.8-4.1-7.4zm-12.8 69.8l-50.7-28.7V63.8l50.7 28.9v57.4zm-59.3 43.8l-59.3-33.8V92.7l50.7-28.9v62.3c0 3.6 2.2 6.6 5.3 7.9.5.6 1.2 1.1 1.9 1.5l52.2 29.5-50.8 28.9z" fill="#20133A"/><path d="M140 6.1H42c-23.2 0-42 18.8-42 42v20.6c0 3.1 2.5 5.7 5.7 5.7h98.1c23.2 0 42-18.8 42-42V.5c-.1 3.1-2.6 5.6-5.8 5.6z" fill="url(#c)" transform="translate(107 187)"/><path d="M150.8 226.9c2.1-1.2 3.2-3.1 3.2-5.7 0-5.1-3.5-7.9-9.8-7.9h-11.7v28.2h11.8c6.3 0 10.1-2.8 10.1-8.4.1-2.9-1.2-5-3.6-6.2zm-12.8-8.8h6.4c2.9 0 4.2 1.2 4.2 3.2 0 1.9-1.2 3.4-4.1 3.4H138v-6.6zm6.4 18.5H138v-7h6.2c3.4 0 4.8 1.3 4.8 3.5 0 2.1-1.6 3.5-4.6 3.5zm15.3 4.9h19v-5.2h-13.5v-6.4h13.5v-5.1h-13.5v-6.4h13.5v-5.1h-19zm44.7-28.2h-21v5h7.8v23.2h5.4v-23.2h7.8zm14.6 0h-5.4L203 241.5h5.6l1.9-5.1h11.8l1.9 5.1h5.6L219 213.3zm-6.8 18.1l4.1-11.2 4.1 11.2h-8.2z" fill="#20123A"/></g></svg>
diff --git a/browser/base/content/logos/relay.svg b/browser/base/content/logos/relay.svg
new file mode 100644
index 0000000000..0920b33e5e
--- /dev/null
+++ b/browser/base/content/logos/relay.svg
@@ -0,0 +1,33 @@
+<!-- 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/. -->
+<svg xmlns="http://www.w3.org/2000/svg"
+ viewBox="0 0 77.2 79.73">
+ <defs>
+ <linearGradient id="a" x1="460.66" y1="-120.15" x2="449.39" y2="-109.49"
+ gradientTransform="matrix(1 0 0 -1 -389 -47.01)" gradientUnits="userSpaceOnUse">
+ <stop offset="0" stop-color="#054096" stop-opacity=".5" />
+ <stop offset=".1" stop-color="#173ba1" stop-opacity=".44" />
+ <stop offset=".29" stop-color="#3434b3" stop-opacity=".33" />
+ <stop offset=".49" stop-color="#482ec1" stop-opacity=".22" />
+ <stop offset=".68" stop-color="#552bc8" stop-opacity=".11" />
+ <stop offset=".86" stop-color="#592acb" stop-opacity="0" />
+ </linearGradient>
+ <linearGradient id="b" x1="3.39" y1="42.56" x2="73.6" y2="42.56" gradientTransform="matrix(1 0 0 -1 0 82.99)"
+ gradientUnits="userSpaceOnUse">
+ <stop offset="0" stop-color="#9059ff" />
+ <stop offset="1" stop-color="#f770ff" />
+ </linearGradient>
+ <linearGradient id="c" x1="40.92" y1="20.28" x2="81.59" y2="3.07" gradientTransform="matrix(1 0 0 -1 0 82.99)"
+ gradientUnits="userSpaceOnUse">
+ <stop offset="0" stop-color="#54ffbd" />
+ <stop offset="1" stop-color="#0df" />
+ </linearGradient>
+ </defs>
+ <path
+ d="M70.19 18.69l-28.4-16a6.81 6.81 0 00-6.5 0l-28.5 16a6.68 6.68 0 00-3.4 5.8v31.9a6.56 6.56 0 003.4 5.8l28.4 15.9a6.29 6.29 0 003.3.9 6.56 6.56 0 003.3-.9l28.4-15.9a6.68 6.68 0 003.4-5.8v-31.9a6.38 6.38 0 00-3.4-5.8z"
+ transform="translate(-3.39 -1.87)" fill="url(#b)" />
+ <path
+ d="M15.3 46.92l-1.7 1a2.62 2.62 0 00-1 3.6 2.65 2.65 0 002.3 1.4 3.08 3.08 0 001.3-.3l1.7-1a2.62 2.62 0 001-3.6 2.57 2.57 0 00-3.6-1.1zm10.4-5.9l-3.5 2a2.62 2.62 0 00-1 3.6 2.65 2.65 0 002.3 1.4 3.08 3.08 0 001.3-.3l3.5-2a2.62 2.62 0 001-3.6 2.57 2.57 0 00-3.6-1.1zm14.1-4.89a2.36 2.36 0 00-1.4-1.2V14.82a2.69 2.69 0 00-2.7-2.7 2.61 2.61 0 00-2.6 2.7v20.2a2.36 2.36 0 00-1.4 1.2 2.66 2.66 0 00-.1 2.3 2.66 2.66 0 00.1 2.3 2.36 2.36 0 001.4 1.2v20.2a2.7 2.7 0 005.4 0v-20.3a2.36 2.36 0 001.4-1.2 2.66 2.66 0 00.1-2.3 3.08 3.08 0 00-.2-2.29zm6.81-6.91l-3.5 2a2.62 2.62 0 00-1 3.6 2.65 2.65 0 002.3 1.4 3.08 3.08 0 001.3-.3l3.5-2a2.62 2.62 0 001-3.6 2.57 2.57 0 00-3.6-1.1zm8.29 1a3.08 3.08 0 001.3-.3l1.7-1a2.642 2.642 0 10-2.6-4.6l-1.7 1a2.62 2.62 0 00-1 3.6 2.56 2.56 0 002.3 1.3zm3 17.7l-1.7-1a2.642 2.642 0 00-2.6 4.6l1.7 1a3.08 3.08 0 001.3.3 2.65 2.65 0 002.3-1.4 2.47 2.47 0 00-1-3.5zm-8.7-4.9l-3.5-2a2.642 2.642 0 10-2.6 4.6l3.5 2a3.08 3.08 0 001.3.3 2.65 2.65 0 002.3-1.4 2.45 2.45 0 00-1-3.5zm-27-9.2l3.5 2a3.08 3.08 0 001.3.3 2.65 2.65 0 002.3-1.4 2.62 2.62 0 00-1-3.6l-3.5-2a2.62 2.62 0 00-3.6 1 2.8 2.8 0 001 3.7zm-4.3-8.5l-1.7-1a2.642 2.642 0 10-2.6 4.6l1.7 1a3.08 3.08 0 001.3.3 2.65 2.65 0 002.3-1.4 2.53 2.53 0 00-1-3.5z"
+ fill="#20133a" />
+</svg>
diff --git a/browser/base/content/logos/send.svg b/browser/base/content/logos/send.svg
new file mode 100644
index 0000000000..f754d727e8
--- /dev/null
+++ b/browser/base/content/logos/send.svg
@@ -0,0 +1,4 @@
+<!-- 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/. -->
+<svg xmlns="http://www.w3.org/2000/svg" width="80" height="80"><defs><linearGradient id="a" x1="57.082" y1="5.474" x2="18.997" y2="71.439" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ff9640"/><stop offset=".6" stop-color="#fc4055"/><stop offset="1" stop-color="#e31587"/></linearGradient><linearGradient id="b" x1="57.082" y1="5.474" x2="18.997" y2="71.439" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fff36e" stop-opacity=".8"/><stop offset=".094" stop-color="#fff36e" stop-opacity=".699"/><stop offset=".752" stop-color="#fff36e" stop-opacity="0"/></linearGradient><linearGradient id="c" x1="48.99" y1="47.048" x2="66.606" y2="16.537" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#0090ed"/><stop offset=".386" stop-color="#5b6df8"/><stop offset=".629" stop-color="#9059ff"/><stop offset="1" stop-color="#b833e1"/></linearGradient><linearGradient id="d" x1="48.305" y1="37.697" x2="75.234" y2="44.176" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#054096" stop-opacity=".5"/><stop offset=".054" stop-color="#0f3d9c" stop-opacity=".441"/><stop offset=".261" stop-color="#2f35b1" stop-opacity=".249"/><stop offset=".466" stop-color="#462fbf" stop-opacity=".111"/><stop offset=".669" stop-color="#542bc8" stop-opacity=".028"/><stop offset=".864" stop-color="#592acb" stop-opacity="0"/></linearGradient><linearGradient id="e" x1="66.607" y1="16.536" x2="58.343" y2="30.85" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#722291" stop-opacity=".5"/><stop offset=".5" stop-color="#722291" stop-opacity="0"/></linearGradient></defs><path fill="none" d="M0 0h80v80H0z"/><path d="M40 0A40.136 40.136 0 0 0 0 39.562 4.4 4.4 0 0 0 4.4 44H36v21.284l-10.174-10.16a4 4 0 1 0-5.652 5.661l17 16.977a4 4 0 0 0 5.652 0l17-16.977a4 4 0 1 0-5.652-5.661L44 65.284V44h31.6a4.4 4.4 0 0 0 4.4-4.447A40.133 40.133 0 0 0 40 0zM8.248 36a32 32 0 0 1 63.505 0z" fill="url(#a)"/><path d="M40 0A40.136 40.136 0 0 0 0 39.562 4.4 4.4 0 0 0 4.4 44H36v21.284l-10.174-10.16a4 4 0 1 0-5.652 5.661l17 16.977a4 4 0 0 0 5.652 0l17-16.977a4 4 0 1 0-5.652-5.661L44 65.284V44h31.6a4.4 4.4 0 0 0 4.4-4.447A40.133 40.133 0 0 0 40 0zM8.248 36a32 32 0 0 1 63.505 0z" fill="url(#b)"/><path d="M44 8.259A32.157 32.157 0 0 1 71.753 36H52a8 8 0 0 0-8 8h31.6a4.428 4.428 0 0 0 3.124-1.3A4.48 4.48 0 0 0 80 39.553c0-22.196-24.462-30.11-36-31.294z" fill="url(#c)"/><path d="M52 36a8 8 0 0 0-8 8h31.6a4.416 4.416 0 0 0 2.973-1.179L71.753 36z" opacity=".9" fill="url(#d)"/><path d="M80 39.553c0-22.2-24.443-30.124-36-31.294A32.157 32.157 0 0 1 71.753 36l6.821 6.821c.048-.044.105-.078.151-.124A4.48 4.48 0 0 0 80 39.553z" fill="url(#e)"/></svg> \ No newline at end of file
diff --git a/browser/base/content/logos/tracking-protection-dark-theme.svg b/browser/base/content/logos/tracking-protection-dark-theme.svg
new file mode 100644
index 0000000000..6d12a2427d
--- /dev/null
+++ b/browser/base/content/logos/tracking-protection-dark-theme.svg
@@ -0,0 +1,4 @@
+<!-- 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/. -->
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><defs><linearGradient id="a" x1="34.8" y1="17.75" x2="20.9" y2="41.82" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#ab71ff"/><stop offset="1" stop-color="#00b3f4"/></linearGradient><linearGradient id="b" x1="44.47" y1="3.13" x2="15.83" y2="52.75" href="#a"/><radialGradient id="c" cx="32.08" cy="29.82" fx="55.31" fy="9.098" r="31.42" gradientUnits="userSpaceOnUse"><stop offset=".18" stop-color="#ab71ff"/><stop offset=".32" stop-color="#a772ff"/><stop offset=".44" stop-color="#9c77fe"/><stop offset=".56" stop-color="#897efd"/><stop offset=".68" stop-color="#6f88fb"/><stop offset=".8" stop-color="#4d95f9"/><stop offset=".92" stop-color="#24a5f6"/><stop offset="1" stop-color="#00b3f4"/></radialGradient></defs><path d="M20.28 27.8c.78 8.5 2.12 11.5 5 15.4A13 13 0 0 0 32 47.79V16.13l-12 2c0 4.6.13 8.06.28 9.67z" fill="url(#a)"/><path d="M56 13.57a6.52 6.52 0 0 0-5.43-6.45L32 4 13.43 7.12A6.52 6.52 0 0 0 8 13.57c0 4.25 0 11.89.33 15.33.91 9.88 2.76 15.29 7.32 21.45a24.94 24.94 0 0 0 16 9.63L32 60h.33a24.94 24.94 0 0 0 16-9.63c4.56-6.16 6.41-11.57 7.32-21.45.35-3.47.35-11.11.35-15.35zm-6.31 14.78c-.81 8.85-2.25 13.15-6.16 18.42A19.25 19.25 0 0 1 32 54a19.17 19.17 0 0 1-11.53-7.19c-3.91-5.27-5.35-9.58-6.16-18.42C14.09 26 14 20.64 14 13.58a.55.55 0 0 1 .43-.55L32 10l17.57 3a.55.55 0 0 1 .43.55c0 7.08-.09 12.45-.31 14.8z" fill="url(#b)"/><path d="M56 13.57a6.52 6.52 0 0 0-5.43-6.45L32 4 13.43 7.12A6.52 6.52 0 0 0 8 13.57c0 4.25 0 11.89.33 15.33.91 9.88 2.76 15.29 7.32 21.45a24.94 24.94 0 0 0 16 9.63L32 60h.33a24.94 24.94 0 0 0 16-9.63c4.56-6.16 6.41-11.57 7.32-21.45.35-3.47.35-11.11.35-15.35zm-6.31 14.78c-.81 8.85-2.25 13.15-6.16 18.42A19.25 19.25 0 0 1 32 54a19.17 19.17 0 0 1-11.53-7.19c-3.91-5.27-5.35-9.58-6.16-18.42C14.09 26 14 20.64 14 13.58a.55.55 0 0 1 .43-.55L32 10l17.57 3a.55.55 0 0 1 .43.55c0 7.08-.09 12.45-.31 14.8zm-29.41-.55c.78 8.5 2.12 11.5 5 15.4A13 13 0 0 0 32 47.79V16.13l-12 2c0 4.6.13 8.06.28 9.67z" fill="url(#c)"/></svg>
diff --git a/browser/base/content/logos/tracking-protection.svg b/browser/base/content/logos/tracking-protection.svg
new file mode 100644
index 0000000000..0d554caaac
--- /dev/null
+++ b/browser/base/content/logos/tracking-protection.svg
@@ -0,0 +1,4 @@
+<!-- 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/. -->
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><defs><radialGradient id="a" cx="32.06" cy="29.49" fx="53.493" fy="9.619" r="31.22" gradientUnits="userSpaceOnUse"><stop offset=".26" stop-color="#7542e5"/><stop offset=".43" stop-color="#7243e5"/><stop offset=".56" stop-color="#6845e4"/><stop offset=".68" stop-color="#574ae3"/><stop offset=".8" stop-color="#3f50e2"/><stop offset=".91" stop-color="#2158e1"/><stop offset="1" stop-color="#0060df"/></radialGradient></defs><path d="M56 13.57a6.52 6.52 0 0 0-5.43-6.45L32 4 13.43 7.12A6.52 6.52 0 0 0 8 13.57c0 4.25 0 11.89.33 15.33.91 9.88 2.76 15.29 7.32 21.45a24.94 24.94 0 0 0 16 9.63L32 60h.33a24.94 24.94 0 0 0 16-9.63c4.56-6.16 6.41-11.57 7.32-21.45.35-3.47.35-11.11.35-15.35zm-6.31 14.78c-.81 8.85-2.25 13.15-6.16 18.42A19.25 19.25 0 0 1 32 54a19.17 19.17 0 0 1-11.53-7.19c-3.91-5.27-5.35-9.58-6.16-18.42C14.09 26 14 20.64 14 13.58a.55.55 0 0 1 .43-.55L32 10l17.57 3a.55.55 0 0 1 .43.55c0 7.08-.09 12.45-.31 14.8zm-29.41-.55c.78 8.5 2.12 11.5 5 15.4A13 13 0 0 0 32 47.79V16.13l-12 2c0 4.6.13 8.06.28 9.67z" fill="url(#a)"/></svg>
diff --git a/browser/base/content/logos/vpn-dark.svg b/browser/base/content/logos/vpn-dark.svg
new file mode 100644
index 0000000000..8c54617052
--- /dev/null
+++ b/browser/base/content/logos/vpn-dark.svg
@@ -0,0 +1,6 @@
+<!-- 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/. -->
+<svg width="192" height="192" fill="none" xmlns="http://www.w3.org/2000/svg">
+ <path fill-rule="evenodd" clip-rule="evenodd" d="M96 21.6c-7.953 0-14.4 6.447-14.4 14.4S88.047 50.4 96 50.4s14.4-6.447 14.4-14.4-6.447-14.4-14.4-14.4zM62.4 36C62.4 17.443 77.443 2.4 96 2.4c18.557 0 33.6 15.043 33.6 33.6 0 18.557-15.043 33.6-33.6 33.6a33.45 33.45 0 01-15.985-4.039L65.561 80.015A33.397 33.397 0 0168.21 86.4h55.582c4.131-13.88 16.988-24 32.209-24 18.557 0 33.6 15.043 33.6 33.6 0 18.557-15.043 33.6-33.6 33.6a33.452 33.452 0 01-15.985-4.039l-14.454 14.454A33.452 33.452 0 01129.6 156c0 18.557-15.043 33.6-33.6 33.6-18.557 0-33.6-15.043-33.6-33.6 0-18.557 15.043-33.6 33.6-33.6a33.452 33.452 0 0115.985 4.039l14.454-14.454a33.37 33.37 0 01-2.648-6.385H68.209c-4.131 13.879-16.988 24-32.209 24-18.557 0-33.6-15.043-33.6-33.6 0-18.557 15.043-33.6 33.6-33.6a33.45 33.45 0 0115.985 4.039l14.454-14.454A33.45 33.45 0 0162.4 36zm19.2 120c0-7.953 6.447-14.4 14.4-14.4s14.4 6.447 14.4 14.4-6.447 14.4-14.4 14.4-14.4-6.447-14.4-14.4zM36 81.6c-7.953 0-14.4 6.447-14.4 14.4s6.447 14.4 14.4 14.4 14.4-6.447 14.4-14.4S43.953 81.6 36 81.6zM141.6 96c0-7.953 6.447-14.4 14.4-14.4s14.4 6.447 14.4 14.4-6.447 14.4-14.4 14.4-14.4-6.447-14.4-14.4z" fill="#fff"/>
+</svg>
diff --git a/browser/base/content/logos/vpn-light.svg b/browser/base/content/logos/vpn-light.svg
new file mode 100644
index 0000000000..710e63a177
--- /dev/null
+++ b/browser/base/content/logos/vpn-light.svg
@@ -0,0 +1,6 @@
+<!-- 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/. -->
+<svg width="188" height="188" xmlns="http://www.w3.org/2000/svg">
+ <path d="M94 19.6c-7.953 0-14.4 6.447-14.4 14.4S86.047 48.4 94 48.4s14.4-6.447 14.4-14.4-6.447-14.4-14.4-14.4zM60.4 34C60.4 15.443 75.443.4 94 .4c18.557 0 33.6 15.043 33.6 33.6 0 18.557-15.043 33.6-33.6 33.6a33.45 33.45 0 01-15.985-4.039L63.561 78.015A33.397 33.397 0 0166.21 84.4h55.582c4.131-13.88 16.988-24 32.209-24 18.557 0 33.6 15.043 33.6 33.6 0 18.557-15.043 33.6-33.6 33.6a33.452 33.452 0 01-15.985-4.039l-14.454 14.454A33.452 33.452 0 01127.6 154c0 18.557-15.043 33.6-33.6 33.6-18.557 0-33.6-15.043-33.6-33.6 0-18.557 15.043-33.6 33.6-33.6a33.452 33.452 0 0115.985 4.039l14.454-14.454a33.37 33.37 0 01-2.648-6.385H66.209c-4.131 13.879-16.988 24-32.209 24C15.443 127.6.4 112.557.4 94 .4 75.443 15.443 60.4 34 60.4a33.45 33.45 0 0115.985 4.039l14.454-14.454A33.45 33.45 0 0160.4 34zm19.2 120c0-7.953 6.447-14.4 14.4-14.4s14.4 6.447 14.4 14.4-6.447 14.4-14.4 14.4-14.4-6.447-14.4-14.4zM34 79.6c-7.953 0-14.4 6.447-14.4 14.4s6.447 14.4 14.4 14.4 14.4-6.447 14.4-14.4S41.953 79.6 34 79.6zM139.6 94c0-7.953 6.447-14.4 14.4-14.4s14.4 6.447 14.4 14.4-6.447 14.4-14.4 14.4-14.4-6.447-14.4-14.4z" fill="#000" fill-rule="evenodd"/>
+</svg>
diff --git a/browser/base/content/logos/vpn-promo-logo.svg b/browser/base/content/logos/vpn-promo-logo.svg
new file mode 100644
index 0000000000..0dd88b9ae3
--- /dev/null
+++ b/browser/base/content/logos/vpn-promo-logo.svg
@@ -0,0 +1,4 @@
+<!-- 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/. -->
+<svg width="127" height="121" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M97.936 27.343h-45.38c-2.406 0-4.357 1.86-4.357 4.152v64.92c0 2.292 1.951 4.151 4.358 4.151h45.379c2.406 0 4.357-1.859 4.357-4.152V31.495c0-2.293-1.951-4.152-4.357-4.152z" fill="#C4C6FC"/><path d="M97.933 100.566v-.065H52.557a4.405 4.405 0 0 1-3.032-1.198 3.994 3.994 0 0 1-1.257-2.889V31.495a3.99 3.99 0 0 1 1.257-2.888 4.402 4.402 0 0 1 3.032-1.198h45.376a4.4 4.4 0 0 1 3.033 1.197 3.99 3.99 0 0 1 1.258 2.89v64.918a3.99 3.99 0 0 1-1.258 2.89 4.4 4.4 0 0 1-3.033 1.197v.131a4.54 4.54 0 0 0 3.13-1.236 4.119 4.119 0 0 0 1.296-2.982V31.495c0-.553-.114-1.102-.337-1.613a4.199 4.199 0 0 0-.959-1.368 4.46 4.46 0 0 0-1.437-.913 4.613 4.613 0 0 0-1.693-.32H52.557c-.582 0-1.157.108-1.694.32a4.443 4.443 0 0 0-1.436.913 4.208 4.208 0 0 0-.96 1.368 4.042 4.042 0 0 0-.337 1.613v64.92c0 1.118.467 2.19 1.297 2.981a4.542 4.542 0 0 0 3.13 1.236h45.376v-.066z" fill="#C4C7FC"/><path d="M91.93 90.498l6.283-.252a5.237 5.237 0 0 0 3.371-1.442 6.97 6.97 0 0 0 1.977-3.471 6.32 6.32 0 0 1 1.786-3.154 4.55 4.55 0 0 1 2.926-1.26l1.662-.06-.027-.649-1.664.063a5.243 5.243 0 0 0-3.371 1.442 6.967 6.967 0 0 0-1.974 3.471 6.32 6.32 0 0 1-1.788 3.152 4.538 4.538 0 0 1-2.91 1.26l-6.285.237.027.648-.013.015zm1.373-43.162c3.881-2.984 8.995-5.142 13.604-6.985a45.786 45.786 0 0 1 4.477-1.512c1.852-.517 3.871-.93 5.535-.93a6.667 6.667 0 0 1 1.733.198c.413.094.797.28 1.119.543a2.522 2.522 0 0 1 .934 1.979 4.683 4.683 0 0 1-.439 1.837c-.5 1.152-1.402 2.417-2.508 3.68a53.812 53.812 0 0 1-5.646 5.38 150.31 150.31 0 0 1-2.564 2.107 45.061 45.061 0 0 0-1.72 1.43c-2.942 2.639-5.868 5.394-8.416 8.366-2.548 2.972-4.715 6.161-6.139 9.673l.636.232c1.386-3.426 3.513-6.555 6.03-9.494 2.516-2.94 5.421-5.674 8.355-8.306.264-.252.682-.585 1.19-1.008 1.778-1.447 4.734-3.794 7.253-6.328a26.11 26.11 0 0 0 3.251-3.831c.403-.6.741-1.235 1.011-1.898.239-.58.368-1.196.381-1.818a3.098 3.098 0 0 0-.263-1.292 3.235 3.235 0 0 0-.779-1.085 3.526 3.526 0 0 0-1.5-.787 7.427 7.427 0 0 0-1.919-.221c-1.778 0-3.847.43-5.725.955a47.279 47.279 0 0 0-4.551 1.54c-4.622 1.848-9.79 4.018-13.758 7.079l.425.504-.007-.008z" fill="#A6A8E2"/><path d="M15.713 30.845c.421 2.1 1.059 4.129 2.366 5.735a7.799 7.799 0 0 0 2.497 2.017c1.164.574 2.432.933 3.736 1.056 1.119.115 2.244.168 3.369.159 1.587 0 3.196-.074 4.746-.074 1.908 0 3.741.109 5.419.575a9.297 9.297 0 0 1 4.519 2.723l.51-.426a10.016 10.016 0 0 0-4.839-2.92c-1.778-.491-3.675-.597-5.609-.6-1.571 0-3.175.074-4.746.074-1.1.008-2.199-.043-3.292-.154a9.972 9.972 0 0 1-3.487-.976 7.29 7.29 0 0 1-3.08-3.025 14.702 14.702 0 0 1-1.436-4.285l-.67.12h-.002z" fill="#C4C6FC"/><path d="M108.744 31.737h-.339a.511.511 0 0 1-.161.369.564.564 0 0 1-.772 0 .51.51 0 0 1-.16-.369.496.496 0 0 1 .334-.484.551.551 0 0 1 .211-.035c.145 0 .284.055.387.152.102.097.16.23.161.367h.68a1.13 1.13 0 0 0-.207-.65 1.213 1.213 0 0 0-.551-.43 1.283 1.283 0 0 0-.709-.067 1.25 1.25 0 0 0-.629.32 1.163 1.163 0 0 0-.336.6 1.12 1.12 0 0 0 .07.675c.093.214.251.396.452.525a1.273 1.273 0 0 0 1.551-.146c.23-.219.359-.517.359-.827h-.341zM30.279 4.013h-.339a.502.502 0 0 1-.16.37.55.55 0 0 1-.388.154.573.573 0 0 1-.411-.138.522.522 0 0 1-.174-.382.5.5 0 0 1 .174-.382.553.553 0 0 1 .411-.139c.145 0 .284.055.387.152a.51.51 0 0 1 .161.368h.68a1.13 1.13 0 0 0-.207-.65 1.217 1.217 0 0 0-.55-.431 1.284 1.284 0 0 0-.71-.067 1.248 1.248 0 0 0-.629.32 1.154 1.154 0 0 0-.336.6 1.12 1.12 0 0 0 .07.675c.093.214.25.397.452.525.202.129.44.197.682.197a1.27 1.27 0 0 0 .472-.086 1.174 1.174 0 0 0 .665-.634c.062-.143.093-.295.091-.45l-.341-.002zm-2.717 83.044h-.342a.51.51 0 0 1-.161.367.562.562 0 0 1-.386.152.574.574 0 0 1-.412-.139.521.521 0 0 1-.174-.381.5.5 0 0 1 .174-.382.553.553 0 0 1 .412-.139c.145 0 .284.055.387.153a.51.51 0 0 1 .16.369h.68a1.13 1.13 0 0 0-.205-.65 1.217 1.217 0 0 0-.55-.432 1.284 1.284 0 0 0-.71-.068 1.248 1.248 0 0 0-.63.32 1.15 1.15 0 0 0-.336.598c-.048.227-.024.463.07.676.092.214.25.397.451.526.202.129.44.197.683.197a1.26 1.26 0 0 0 .87-.339 1.16 1.16 0 0 0 .265-.38 1.1 1.1 0 0 0 .092-.448h-.338zM6.747 58.645v1.658a.32.32 0 0 0 .11.212.35.35 0 0 0 .46 0 .32.32 0 0 0 .11-.212v-1.658a.311.311 0 0 0-.088-.248.342.342 0 0 0-.252-.106.357.357 0 0 0-.252.106.322.322 0 0 0-.088.248z" fill="#000"/><path d="M6.215 59.797h1.741a.358.358 0 0 0 .26-.084.326.326 0 0 0 .111-.24.312.312 0 0 0-.11-.24.343.343 0 0 0-.26-.084H6.214a.358.358 0 0 0-.26.084.326.326 0 0 0-.111.24.312.312 0 0 0 .11.24.345.345 0 0 0 .261.084zm116.79 3.383v1.658a.317.317 0 0 0 .109.212.351.351 0 0 0 .461 0 .322.322 0 0 0 .11-.212V63.18a.316.316 0 0 0-.088-.249.353.353 0 0 0-.504 0 .319.319 0 0 0-.088.249z" fill="#000"/><path d="M122.473 64.332h1.741a.361.361 0 0 0 .261-.084.335.335 0 0 0 .081-.11.299.299 0 0 0 .029-.13.299.299 0 0 0-.029-.131.335.335 0 0 0-.342-.193h-1.741a.35.35 0 0 0-.222.104.32.32 0 0 0-.089.22c0 .081.032.16.089.22a.35.35 0 0 0 .222.104z" fill="#000"/><path d="M90.088 21.924H44.71c-2.407 0-4.358 1.858-4.358 4.152v64.918c0 2.293 1.95 4.152 4.358 4.152h45.378c2.407 0 4.358-1.859 4.358-4.151v-64.92c0-2.293-1.951-4.151-4.358-4.151z" fill="#fff"/><path d="M90.085 95.146v-.322h-45.37a4.123 4.123 0 0 1-2.843-1.117 3.74 3.74 0 0 1-1.182-2.705V26.078c0-.503.104-1.002.306-1.466.203-.465.5-.888.873-1.243a4.039 4.039 0 0 1 1.306-.83 4.193 4.193 0 0 1 1.54-.29H90.09a4.12 4.12 0 0 1 2.841 1.121 3.74 3.74 0 0 1 1.177 2.708v64.924a3.74 3.74 0 0 1-1.177 2.708 4.122 4.122 0 0 1-2.841 1.121v.648a4.82 4.82 0 0 0 3.319-1.313 4.372 4.372 0 0 0 1.374-3.164V26.078a4.372 4.372 0 0 0-1.376-3.166 4.82 4.82 0 0 0-3.323-1.311h-45.37a4.82 4.82 0 0 0-3.323 1.311 4.372 4.372 0 0 0-1.377 3.166v64.924a4.373 4.373 0 0 0 1.38 3.16 4.82 4.82 0 0 0 3.32 1.31H90.09l-.006-.326z" fill="#000"/><path d="M79.862 43.23a4.71 4.71 0 0 0-1.8-2.065 5.057 5.057 0 0 0-2.698-.774 5.034 5.034 0 0 0-2.293.538 4.769 4.769 0 0 0-1.753 1.507 4.4 4.4 0 0 0-.642 1.348h-6.853a3.305 3.305 0 0 0-.196-.554 2.927 2.927 0 0 0-.265-.504l2.45-2.335c.74.404 1.577.615 2.43.613a4.967 4.967 0 0 0 1.894-.368 4.794 4.794 0 0 0 2.419-2.11 4.45 4.45 0 0 0 .469-3.091 4.611 4.611 0 0 0-1.694-2.675 5.025 5.025 0 0 0-3.089-1.048 5.206 5.206 0 0 0-1.902.363 4.83 4.83 0 0 0-2.167 1.715 4.49 4.49 0 0 0-.81 2.57 4.4 4.4 0 0 0 .609 2.244l-2.482 2.367a5.071 5.071 0 0 0-2.357-.575 5.083 5.083 0 0 0-1.867.352 4.894 4.894 0 0 0-1.584 1.006 4.634 4.634 0 0 0-1.057 1.507 4.453 4.453 0 0 0 .015 3.591c.37.83.984 1.538 1.767 2.042a5 5 0 0 0 2.726.794c.797 0 1.583-.186 2.287-.543a4.805 4.805 0 0 0 1.753-1.501 4.18 4.18 0 0 0 .65-1.341h6.848a4.043 4.043 0 0 0 .429.988l-2.485 2.364a5.073 5.073 0 0 0-2.354-.572 5.02 5.02 0 0 0-2.294.538 4.77 4.77 0 0 0-1.755 1.507 4.52 4.52 0 0 0-.83 2.596 4.43 4.43 0 0 0 .383 1.813 4.775 4.775 0 0 0 1.768 2.044c.734.47 1.59.74 2.473.784a5.084 5.084 0 0 0 2.544-.54 4.793 4.793 0 0 0 1.89-1.709 4.487 4.487 0 0 0 .7-2.392 4.493 4.493 0 0 0-.651-2.31l2.444-2.326c.74.404 1.577.615 2.43.613a5.025 5.025 0 0 0 3.445-1.367 4.56 4.56 0 0 0 1.436-3.282 4.481 4.481 0 0 0-.381-1.822zM66.17 35.524c.17-.381.452-.708.812-.94.37-.243.81-.371 1.26-.369.3 0 .596.056.873.167.375.149.7.392.941.703a2.05 2.05 0 0 1 .22 2.188c-.174.35-.445.648-.783.862a2.28 2.28 0 0 1-1.252.368c-.369 0-.732-.087-1.057-.252a2.226 2.226 0 0 1-.81-.696 2.078 2.078 0 0 1-.204-2.032zm-4.967 10.355a2.163 2.163 0 0 1-.74.894 2.348 2.348 0 0 1-2.294.21 2.22 2.22 0 0 1-.905-.743 2.065 2.065 0 0 1-.201-2.03c.17-.382.452-.71.812-.942a2.34 2.34 0 0 1 2.117-.2c.407.164.755.439 1 .79.245.35.375.763.376 1.184a2 2 0 0 1-.165.837zm7.126 8.68a2.203 2.203 0 0 1-1.224 1.149 2.355 2.355 0 0 1-1.719-.008 2.285 2.285 0 0 1-.994-.781 2.037 2.037 0 0 1-.379-1.2c0-.281.058-.56.171-.819.113-.26.279-.496.487-.694.209-.2.456-.357.729-.464a2.342 2.342 0 0 1 1.733.006c.408.162.756.436 1 .787a2.056 2.056 0 0 1 .196 2.016v.007zm9.11-8.68a2.16 2.16 0 0 1-.74.894 2.344 2.344 0 0 1-2.293.21 2.22 2.22 0 0 1-.905-.743 2.056 2.056 0 0 1-.204-2.03c.17-.382.453-.71.813-.942a2.34 2.34 0 0 1 2.117-.2c.407.164.755.439 1 .79.245.35.375.763.375 1.184.007.287-.05.572-.164.837z" fill="#000"/><path d="M72.541 82.552l-12.655-.075a6.026 6.026 0 0 1-4.074-1.707 5.472 5.472 0 0 1-1.657-3.936 5.478 5.478 0 0 1 1.709-3.915 6.034 6.034 0 0 1 4.096-1.658l12.655.076c.783-.01 1.56.128 2.287.408a5.92 5.92 0 0 1 1.942 1.22 5.601 5.601 0 0 1 1.295 1.84 5.382 5.382 0 0 1-.029 4.345 5.609 5.609 0 0 1-1.319 1.826 5.928 5.928 0 0 1-1.958 1.196c-.73.27-1.51.4-2.292.38z" fill="#3BFFB7"/><path d="M72.54 82.552v-.322l-12.654-.079a5.696 5.696 0 0 1-3.9-1.565 5.17 5.17 0 0 1-1.611-3.728v-.033a5.174 5.174 0 0 1 1.643-3.716 5.7 5.7 0 0 1 3.913-1.535h.037l12.652.078a5.696 5.696 0 0 1 3.9 1.566 5.169 5.169 0 0 1 1.611 3.728v.035a5.173 5.173 0 0 1-1.643 3.716 5.7 5.7 0 0 1-3.913 1.535h-.034v.645h.04a6.39 6.39 0 0 0 4.388-1.725c1.166-1.105 1.827-2.604 1.837-4.17v-.039c0-1.566-.65-3.069-1.81-4.18a6.38 6.38 0 0 0-4.376-1.749l-12.655-.078h-.04a6.39 6.39 0 0 0-4.38 1.728c-1.164 1.104-1.824 2.601-1.835 4.166v.038c0 1.566.65 3.07 1.81 4.18a6.383 6.383 0 0 0 4.376 1.751l12.655.076-.01-.323z" fill="#000"/><path d="M80.756 76.926a5.422 5.422 0 0 1-1.012 3.108 5.838 5.838 0 0 1-2.654 2.05 6.158 6.158 0 0 1-3.402.298 5.976 5.976 0 0 1-3.003-1.552 5.526 5.526 0 0 1-1.591-2.88 5.362 5.362 0 0 1 .356-3.238 5.68 5.68 0 0 1 2.184-2.503 6.097 6.097 0 0 1 3.275-.925 6.134 6.134 0 0 1 2.249.44 5.905 5.905 0 0 1 1.901 1.227 5.591 5.591 0 0 1 1.264 1.827 5.39 5.39 0 0 1 .433 2.148z" fill="#fff"/><path d="M80.756 76.926h-.341a5.172 5.172 0 0 1-1.636 3.71 5.699 5.699 0 0 1-3.902 1.543h-.037a5.697 5.697 0 0 1-3.905-1.563 5.17 5.17 0 0 1-1.614-3.73v-.033a5.158 5.158 0 0 1 1.64-3.719 5.684 5.684 0 0 1 3.916-1.53h.037a5.695 5.695 0 0 1 3.888 1.565 5.17 5.17 0 0 1 1.613 3.716v.033h.68v-.033c0-1.566-.65-3.069-1.808-4.18a6.378 6.378 0 0 0-4.376-1.749h-.034c-1.643 0-3.22.62-4.386 1.724a5.794 5.794 0 0 0-1.837 4.168v.037c0 1.567.651 3.07 1.81 4.18a6.385 6.385 0 0 0 4.376 1.752h.037c1.644 0 3.22-.62 4.387-1.725 1.165-1.104 1.826-2.603 1.836-4.169l-.344.003z" fill="#000"/><path d="M32.962 53.674H18.674v13.612h14.288V53.674z" fill="#fff"/><path d="M32.962 67.289v-.326H19.013V54h13.61v13.29h.339v-.326.326h.341V53.35H18.336v14.26h14.967v-.322h-.341z" fill="#000"/><path d="M23.434 60.936l1.905 1.818a.353.353 0 0 0 .265.093.353.353 0 0 0 .248-.129l2.858-3.63a.313.313 0 0 0-.076-.445.354.354 0 0 0-.47.055l-2.621 3.332-1.627-1.55a.342.342 0 0 0-.24-.096.355.355 0 0 0-.24.095.323.323 0 0 0-.1.228.31.31 0 0 0 .098.23z" fill="#000"/><path d="M34.25 9.925H3.544c-.34 0-.614.261-.614.584v19.25c0 .322.275.584.614.584H34.25a.6.6 0 0 0 .613-.585V10.51c0-.323-.274-.584-.613-.584z" fill="#fff"/><path d="M34.253 30.34v-.322H3.543a.272.272 0 0 1-.187-.074.246.246 0 0 1-.078-.178v-19.26a.242.242 0 0 1 .08-.183.266.266 0 0 1 .193-.076h30.702a.278.278 0 0 1 .193.076.252.252 0 0 1 .08.184v19.251a.246.246 0 0 1-.078.179.271.271 0 0 1-.187.073v.648a.982.982 0 0 0 .672-.267.89.89 0 0 0 .28-.64V10.507a.891.891 0 0 0-.28-.64.982.982 0 0 0-.672-.268H3.543a.98.98 0 0 0-.675.268.899.899 0 0 0-.206.293.863.863 0 0 0-.072.347v19.251c0 .12.024.237.072.347a.91.91 0 0 0 .206.294.98.98 0 0 0 .675.267h30.71v-.325z" fill="#000"/><path d="M3.14 13.65h31.936v-.648H3.14m13.868 4.251h13.025a.348.348 0 0 0 .222-.105.317.317 0 0 0 .09-.22.317.317 0 0 0-.09-.219.348.348 0 0 0-.222-.104H17.007a.358.358 0 0 0-.26.084.324.324 0 0 0-.11.24.312.312 0 0 0 .11.24.345.345 0 0 0 .26.084zm0 3.604h13.025a.348.348 0 0 0 .222-.104.317.317 0 0 0 .09-.22.317.317 0 0 0-.09-.22.348.348 0 0 0-.222-.104H17.007a.358.358 0 0 0-.26.085.324.324 0 0 0-.11.24.312.312 0 0 0 .11.24.343.343 0 0 0 .26.083zm0 3.595h13.025a.348.348 0 0 0 .222-.104.317.317 0 0 0 .09-.22.317.317 0 0 0-.09-.22.348.348 0 0 0-.222-.104H17.007a.358.358 0 0 0-.26.084.324.324 0 0 0-.11.24.312.312 0 0 0 .11.24.343.343 0 0 0 .26.084z" fill="#000"/><path d="M13.853 20.734a3.482 3.482 0 0 1-.632 2.004 3.747 3.747 0 0 1-1.694 1.331 3.954 3.954 0 0 1-2.186.21 3.843 3.843 0 0 1-1.938-.984 3.553 3.553 0 0 1-1.038-1.845 3.444 3.444 0 0 1 .213-2.082 3.645 3.645 0 0 1 1.393-1.618 3.915 3.915 0 0 1 2.101-.608c1.001 0 1.962.378 2.67 1.051a3.519 3.519 0 0 1 1.111 2.54z" fill="#A67BFC"/><path d="M13.853 20.734h-.338c0 .758-.277 1.492-.782 2.078a3.474 3.474 0 0 1-1.988 1.135 3.59 3.59 0 0 1-2.292-.325 3.34 3.34 0 0 1-1.556-1.636 3.133 3.133 0 0 1-.113-2.205 3.295 3.295 0 0 1 1.38-1.773 3.572 3.572 0 0 1 2.248-.537 3.513 3.513 0 0 1 2.095.944c.32.304.573.666.746 1.064.173.398.262.824.262 1.255h.68c0-.777-.242-1.536-.695-2.182a4.085 4.085 0 0 0-1.85-1.447 4.311 4.311 0 0 0-2.382-.223 4.19 4.19 0 0 0-2.11 1.075 3.862 3.862 0 0 0-1.128 2.01 3.755 3.755 0 0 0 .234 2.27c.312.718.84 1.33 1.518 1.762a4.269 4.269 0 0 0 2.29.662c.542 0 1.078-.1 1.579-.298.5-.197.954-.486 1.337-.851s.687-.798.894-1.275c.207-.476.313-.987.313-1.503h-.342z" fill="#000"/><path d="M9.409 22.31h.34v-2.468l1.268.907-1.812 1.3.204.253.206.252 2.172-1.558a.327.327 0 0 0 .126-.252.313.313 0 0 0-.126-.252l-2.172-1.56a.35.35 0 0 0-.355-.026.333.333 0 0 0-.137.12.31.31 0 0 0-.05.17v3.113a.315.315 0 0 0 .19.287.353.353 0 0 0 .355-.035l-.21-.252zm11.895 89.507h32.039v-.648H21.304m-15.11 3.854h32.035v-.648H6.195" fill="#000"/><path d="M53.92 109.041H25.166a5.576 5.576 0 0 1 2.024-3.331 6.082 6.082 0 0 1 3.797-1.312h.228a8.338 8.338 0 0 1 3.535-3.241 8.881 8.881 0 0 1 4.825-.843 8.74 8.74 0 0 1 4.512 1.836 8.093 8.093 0 0 1 2.698 3.904 4.337 4.337 0 0 1 2.183-1.019 4.458 4.458 0 0 1 2.412.285 4.214 4.214 0 0 1 1.856 1.496 3.92 3.92 0 0 1 .693 2.22l-.01.005z" fill="#A77FFA"/><path d="M45.879 111.491v-.322H14.552v.322l.333.058a5.745 5.745 0 0 1 2.085-3.436 6.27 6.27 0 0 1 3.916-1.353h.233a.349.349 0 0 0 .18-.04.328.328 0 0 0 .13-.124 8.755 8.755 0 0 1 3.71-3.398 9.312 9.312 0 0 1 5.063-.884 9.164 9.164 0 0 1 4.734 1.925 8.49 8.49 0 0 1 2.833 4.094.34.34 0 0 0 .236.218.349.349 0 0 0 .32-.072 4.378 4.378 0 0 1 2.201-1.027 4.492 4.492 0 0 1 2.432.288 4.248 4.248 0 0 1 1.871 1.508c.457.666.7 1.443.7 2.238h.68c0-.92-.282-1.821-.81-2.592a4.92 4.92 0 0 0-2.167-1.747 5.205 5.205 0 0 0-2.818-.334 5.076 5.076 0 0 0-2.55 1.19l.23.237.326-.094a9.135 9.135 0 0 0-3.047-4.406 9.854 9.854 0 0 0-5.094-2.072 10.03 10.03 0 0 0-5.448.95 9.427 9.427 0 0 0-3.992 3.658l.296.159.032-.308h-.265c-1.59 0-3.131.534-4.35 1.507a6.4 6.4 0 0 0-2.32 3.817.311.311 0 0 0 .076.249.358.358 0 0 0 .244.117h31.327a.358.358 0 0 0 .24-.096.323.323 0 0 0 .101-.23h-.341z" fill="#000"/><path d="M124.211 85.345a1.358 1.358 0 0 0-.037-.209 1.745 1.745 0 0 0-.728-1.025 1.904 1.904 0 0 0-1.478-.27l-8.036 1.814a1.865 1.865 0 0 1-1.121-.097 1.775 1.775 0 0 1-.72-.545l-.344-.444 11.906-3.4a.325.325 0 0 0 .169-.112.303.303 0 0 0 .067-.186l.076-5.904a.291.291 0 0 0-.037-.156.302.302 0 0 0-.082-.094.33.33 0 0 0-.243-.065h-.04l-3.334.756-.092-.38a3.9 3.9 0 0 0-1.623-2.292 4.248 4.248 0 0 0-3.299-.61 4.117 4.117 0 0 0-2.397 1.55 3.776 3.776 0 0 0-.725 2.672c.021.16.051.32.09.477l.096.378-2.776.628-.881-2.458a.309.309 0 0 0-.138-.166.348.348 0 0 0-.214-.048h-.037l-1.463.33a.332.332 0 0 0-.192.125.298.298 0 0 0-.059.215.091.091 0 0 0 0 .04.298.298 0 0 0 .132.184.33.33 0 0 0 .225.056h.042l1.164-.252.119.338c.156.448 2.646 7.562 2.982 8.508.019.047.029.08.035.095a2.385 2.385 0 0 0 1.259 1.311l.368.162-.17.352c-.14.286-.196.602-.161.915.06.453.297.867.664 1.16.186.15.402.262.635.33a1.97 1.97 0 0 0 .953.024c.443-.1.832-.355 1.087-.714.208-.286.32-.623.324-.97a1.675 1.675 0 0 0-.303-.977l-.317-.46 5.069-1.148-.066.552a1.581 1.581 0 0 0 0 .401c.057.456.3.873.677 1.16a1.884 1.884 0 0 0 1.347.375c.074 0 .148-.02.225-.035.434-.102.814-.35 1.071-.699.256-.348.372-.773.326-1.197l.005.005zm-10.879-11.293a3.39 3.39 0 0 1 2.016-1.306c.134-.032.27-.056.407-.07a3.568 3.568 0 0 1 2.344.581 3.275 3.275 0 0 1 1.36 1.91c.033.128.057.258.072.39a3.144 3.144 0 0 1-.606 2.236 3.43 3.43 0 0 1-2.008 1.293 3.567 3.567 0 0 1-2.751-.514 3.277 3.277 0 0 1-1.435-2.3 3.132 3.132 0 0 1 .601-2.22zm-1.71 9.892l-1.931-5.51 2.847-.644.159.232a4.04 4.04 0 0 0 1.682 1.41 4.281 4.281 0 0 0 2.699.274 4.094 4.094 0 0 0 2.143-1.237c.548-.603.889-1.35.979-2.143l.032-.275 3.064-.678-.066 4.979v.287l-11.608 3.305zm3.686 4.048a1.172 1.172 0 0 1-.823.472c-.284.03-.57-.04-.803-.196a1.12 1.12 0 0 1-.491-.79c-.031-.27.042-.543.207-.765a1.176 1.176 0 0 1 .828-.465c.284-.03.569.04.802.196a1.112 1.112 0 0 1 .489.787c.032.27-.043.54-.209.761zm8.041-1.815a1.172 1.172 0 0 1-.826.472 1.234 1.234 0 0 1-.793-.195 1.121 1.121 0 0 1-.495-.791c-.03-.27.044-.542.208-.765.165-.222.409-.38.686-.445a.895.895 0 0 1 .14-.02c.285-.032.571.037.805.194a1.106 1.106 0 0 1 .489.789c.03.27-.046.541-.214.761z" fill="#C4C6FC"/><path d="M118.943 77.788a3.427 3.427 0 0 1-2.019 1.306 3.571 3.571 0 0 1-2.751-.513 3.276 3.276 0 0 1-1.434-2.3 3.16 3.16 0 0 1 .611-2.234 3.45 3.45 0 0 1 2.006-1.296 3.09 3.09 0 0 1 .407-.07 3.571 3.571 0 0 1 2.344.581 3.276 3.276 0 0 1 1.36 1.91c.033.128.057.258.072.39a3.164 3.164 0 0 1-.596 2.226zm4.406 8.389a1.172 1.172 0 0 1-.826.472 1.239 1.239 0 0 1-.794-.195 1.124 1.124 0 0 1-.494-.791c-.03-.27.044-.542.208-.765a1.19 1.19 0 0 1 .686-.445.894.894 0 0 1 .14-.02c.284-.032.571.037.805.193a1.106 1.106 0 0 1 .489.79c.03.27-.046.541-.214.761zm-8.041 1.815a1.172 1.172 0 0 1-.823.472c-.284.03-.57-.04-.804-.196a1.117 1.117 0 0 1-.49-.79c-.031-.27.042-.543.207-.765a1.173 1.173 0 0 1 .828-.465c.283-.03.569.04.802.196a1.112 1.112 0 0 1 .489.787c.032.27-.043.54-.209.761z" fill="#fff"/><path d="M123.296 75.373l-.066 4.979v.287l-11.608 3.305-1.931-5.51 2.847-.644.159.232a4.04 4.04 0 0 0 1.682 1.41 4.281 4.281 0 0 0 2.699.274 4.094 4.094 0 0 0 2.143-1.237c.547-.603.889-1.35.979-2.143l.032-.275 3.064-.678z" fill="#fff"/><path d="M117.388 74.94a.295.295 0 0 1-.048.201l-1.114 1.722a.322.322 0 0 1-.246.148.328.328 0 0 1-.209-.045l-.905-.532a.334.334 0 0 1-.106-.102.3.3 0 0 1-.002-.334.311.311 0 0 1 .087-.09.35.35 0 0 1 .247-.056.34.34 0 0 1 .121.043l.622.365.941-1.454a.343.343 0 0 1 .53-.045.31.31 0 0 1 .082.176v.002z" fill="#000"/></svg> \ No newline at end of file
diff --git a/browser/base/content/macWindow.inc.xhtml b/browser/base/content/macWindow.inc.xhtml
new file mode 100644
index 0000000000..f747218050
--- /dev/null
+++ b/browser/base/content/macWindow.inc.xhtml
@@ -0,0 +1,37 @@
+# -*- Mode: HTML -*-
+#
+# 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/.
+
+# This include file should only contain things that are needed to support MacOS
+# specific features that are needed for all top level windows. If the feature is
+# also needed in browser.xhtml, it should go in one of the various include files
+# below that are shared with browser.xhtml.
+
+<linkset>
+ <html:link rel="localization" href="branding/brand.ftl"/>
+ <html:link rel="localization" href="browser/browserSets.ftl"/>
+ <html:link rel="localization" href="browser/firefoxView.ftl"/>
+ <html:link rel="localization" href="browser/menubar.ftl"/>
+ <html:link rel="localization" href="browser/screenshots.ftl"/>
+ <html:link rel="localization" href="toolkit/branding/accounts.ftl"/>
+ <html:link rel="localization" href="toolkit/branding/brandings.ftl"/>
+ <html:link rel="localization" href="toolkit/global/textActions.ftl"/>
+</linkset>
+
+# All JS files which are needed by browser.xhtml and other top level windows to
+# support MacOS specific features *must* go into the global-scripts.inc file so
+# that they can be shared with browser.xhtml.
+#include global-scripts.inc
+
+<script src="chrome://browser/content/nonbrowser-mac.js"></script>
+
+# All sets except for popupsets (commands, keys, and stringbundles)
+# *must* go into the browser-sets.inc file so that they can be shared with
+# browser.xhtml
+#include browser-sets.inc
+
+# The entire main menubar is placed into browser-menubar.inc, so that it can be
+# shared with browser.xhtml.
+#include browser-menubar.inc
diff --git a/browser/base/content/main-popupset.inc.xhtml b/browser/base/content/main-popupset.inc.xhtml
new file mode 100644
index 0000000000..7ab7007047
--- /dev/null
+++ b/browser/base/content/main-popupset.inc.xhtml
@@ -0,0 +1,655 @@
+# 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/.
+
+<popupset id="mainPopupSet">
+ <menupopup id="tabContextMenu"
+ onpopupshowing="if (event.target == this) TabContextMenu.updateContextMenu(this);"
+ onpopuphidden="if (event.target == this) TabContextMenu.contextTab = null;">
+ <menuitem id="context_openANewTab" data-lazy-l10n-id="tab-context-new-tab"
+ oncommand="gBrowser.addAdjacentNewTab(TabContextMenu.contextTab)"/>
+ <menuseparator/>
+ <menuitem id="context_reloadTab" data-lazy-l10n-id="reload-tab"
+ oncommand="gBrowser.reloadTab(TabContextMenu.contextTab);"/>
+ <menuitem id="context_reloadSelectedTabs" data-lazy-l10n-id="reload-tabs" hidden="true"
+ oncommand="gBrowser.reloadMultiSelectedTabs();"/>
+ <menuitem id="context_playTab" data-lazy-l10n-id="tab-context-play-tab" hidden="true"
+ oncommand="TabContextMenu.contextTab.resumeDelayedMedia();"/>
+ <menuitem id="context_playSelectedTabs" data-lazy-l10n-id="tab-context-play-tabs" hidden="true"
+ oncommand="gBrowser.resumeDelayedMediaOnMultiSelectedTabs(TabContextMenu.contextTab);"/>
+ <menuitem id="context_toggleMuteTab" oncommand="TabContextMenu.contextTab.toggleMuteAudio();"/>
+ <menuitem id="context_toggleMuteSelectedTabs" hidden="true"
+ oncommand="gBrowser.toggleMuteAudioOnMultiSelectedTabs(TabContextMenu.contextTab);"/>
+ <menuitem id="context_pinTab" data-lazy-l10n-id="pin-tab"
+ oncommand="gBrowser.pinTab(TabContextMenu.contextTab);"/>
+ <menuitem id="context_unpinTab" data-lazy-l10n-id="unpin-tab" hidden="true"
+ oncommand="gBrowser.unpinTab(TabContextMenu.contextTab);"/>
+ <menuitem id="context_pinSelectedTabs" data-lazy-l10n-id="pin-selected-tabs" hidden="true"
+ oncommand="gBrowser.pinMultiSelectedTabs();"/>
+ <menuitem id="context_unpinSelectedTabs" data-lazy-l10n-id="unpin-selected-tabs" hidden="true"
+ oncommand="gBrowser.unpinMultiSelectedTabs();"/>
+ <menuitem id="context_duplicateTab" data-lazy-l10n-id="duplicate-tab"
+ oncommand="duplicateTabIn(TabContextMenu.contextTab, 'tab');"/>
+ <menuitem id="context_duplicateTabs" data-lazy-l10n-id="duplicate-tabs"
+ oncommand="TabContextMenu.duplicateSelectedTabs();"/>
+ <menuseparator/>
+ <menuitem id="context_bookmarkSelectedTabs"
+ hidden="true"
+ data-lazy-l10n-id="bookmark-selected-tabs"
+ oncommand="PlacesUIUtils.showBookmarkPagesDialog(PlacesCommandHook.uniqueSelectedPages);"/>
+ <menuitem id="context_bookmarkTab"
+ data-lazy-l10n-id="tab-context-bookmark-tab"
+ oncommand="PlacesUIUtils.showBookmarkPagesDialog(PlacesCommandHook.getUniquePages([TabContextMenu.contextTab]));"/>
+ <menu id="context_moveTabOptions"
+ data-lazy-l10n-id="tab-context-move-tabs"
+ data-l10n-args='{"tabCount": 1}'>
+ <menupopup id="moveTabOptionsMenu">
+ <menuitem id="context_moveToStart"
+ data-lazy-l10n-id="move-to-start"
+ tbattr="tabbrowser-multiple-visible"
+ oncommand="gBrowser.moveTabsToStart(TabContextMenu.contextTab);"/>
+ <menuitem id="context_moveToEnd"
+ data-lazy-l10n-id="move-to-end"
+ tbattr="tabbrowser-multiple-visible"
+ oncommand="gBrowser.moveTabsToEnd(TabContextMenu.contextTab);"/>
+ <menuitem id="context_openTabInWindow" data-lazy-l10n-id="move-to-new-window"
+ tbattr="tabbrowser-multiple-visible"
+ oncommand="gBrowser.replaceTabsWithWindow(TabContextMenu.contextTab);"/>
+ </menupopup>
+ </menu>
+ <menu id="context_sendTabToDevice"
+ class="sync-ui-item"
+ data-lazy-l10n-id="tab-context-send-tabs-to-device"
+ data-l10n-args='{"tabCount": 1}'>
+ <menupopup id="context_sendTabToDevicePopupMenu"
+ onpopupshowing="gSync.populateSendTabToDevicesMenu(event.target, TabContextMenu.contextTab.linkedBrowser.currentURI, TabContextMenu.contextTab.linkedBrowser.contentTitle, TabContextMenu.contextTab.multiselected);"/>
+ </menu>
+ <menu id="context_reopenInContainer"
+ data-lazy-l10n-id="tab-context-open-in-new-container-tab"
+ hidden="true">
+ <menupopup oncommand="TabContextMenu.reopenInContainer(event);"
+ onpopupshowing="TabContextMenu.createReopenInContainerMenu(event);"/>
+ </menu>
+ <menuitem id="context_selectAllTabs" data-lazy-l10n-id="select-all-tabs"
+ oncommand="gBrowser.selectAllTabs();"/>
+ <menuseparator/>
+ <menuitem id="context_closeTab"
+ data-lazy-l10n-id="tab-context-close-n-tabs"
+ data-l10n-args='{"tabCount": 1}'
+ oncommand="TabContextMenu.closeContextTabs();"/>
+ <menu id="context_closeTabOptions"
+ data-lazy-l10n-id="tab-context-close-multiple-tabs">
+ <menupopup id="closeTabOptions">
+ <menuitem id="context_closeTabsToTheStart" data-lazy-l10n-id="close-tabs-to-the-start"
+ oncommand="gBrowser.removeTabsToTheStartFrom(TabContextMenu.contextTab, {animate: true});"/>
+ <menuitem id="context_closeTabsToTheEnd" data-lazy-l10n-id="close-tabs-to-the-end"
+ oncommand="gBrowser.removeTabsToTheEndFrom(TabContextMenu.contextTab, {animate: true});"/>
+ <menuitem id="context_closeOtherTabs" data-lazy-l10n-id="close-other-tabs"
+ oncommand="gBrowser.removeAllTabsBut(TabContextMenu.contextTab);"/>
+ </menupopup>
+ </menu>
+ <menuitem id="context_undoCloseTab"
+ data-lazy-l10n-id="tab-context-reopen-closed-tabs"
+ data-l10n-args='{"tabCount": 1}'
+ observes="History:UndoCloseTab"/>
+ <menuseparator contexttype="fullscreen"/>
+ <menuitem class="fullscreen-context-autohide"
+ contexttype="fullscreen"
+ type="checkbox"
+ data-lazy-l10n-id="full-screen-autohide"
+ oncommand="FullScreen.setAutohide();"/>
+ <menuitem contexttype="fullscreen"
+ data-lazy-l10n-id="full-screen-exit"
+ oncommand="BrowserFullScreen();"/>
+ </menupopup>
+
+ <!-- bug 415444/582485: event.stopPropagation is here for the cloned version
+ of this menupopup, to prevent already-handled clicks on menu items from
+ propagating to the back or forward button.
+ -->
+ <menupopup id="backForwardMenu"
+ onpopupshowing="return FillHistoryMenu(event.target);"
+ oncommand="gotoHistoryIndex(event); event.stopPropagation();"/>
+ <tooltip id="aHTMLTooltip" page="true"/>
+ <tooltip id="remoteBrowserTooltip"/>
+
+ <menupopup id="new-tab-button-popup"
+ onpopupshowing="return CreateContainerTabMenu(event);"/>
+ <!-- for search and content formfill/pw manager -->
+
+ <panel is="autocomplete-richlistbox-popup"
+ type="autocomplete-richlistbox"
+ id="PopupAutoComplete"
+ role="group"
+ noautofocus="true"
+ hidden="true"
+ overflowpadding="4"
+ norolluponanchor="true"
+ nomaxresults="true" />
+
+ <!-- for search with one-off buttons -->
+ <panel is="search-autocomplete-richlistbox-popup"
+ type="autocomplete-richlistbox"
+ id="PopupSearchAutoComplete"
+ orient="vertical"
+ role="group"
+ noautofocus="true"
+ hidden="true" />
+
+ <html:template id="printPreviewStackTemplate">
+ <stack class="previewStack" rendering="true" flex="1" previewtype="primary">
+ <vbox class="previewRendering" flex="1">
+ <h1 class="print-pending-label" data-l10n-id="printui-loading"></h1>
+ </vbox>
+ </stack>
+ </html:template>
+
+ <html:template id="screenshotsPagePanelTemplate">
+ <panel id="screenshotsPagePanel"
+ type="arrow"
+ orient="vertical"
+ norolluponanchor="true"
+ consumeoutsideclicks="never"
+ level="parent"
+ noautohide="true"
+ tabspecific="true">
+ <screenshots-buttons></screenshots-buttons>
+ </panel>
+ </html:template>
+
+ <html:template id="invalidFormTemplate">
+ <!-- for invalid form error message -->
+ <panel id="invalid-form-popup" type="arrow" orient="vertical" noautofocus="true" level="parent" locationspecific="true">
+ <description/>
+ </panel>
+ </html:template>
+
+ <html:template id="editBookmarkPanelTemplate">
+ <panel id="editBookmarkPanel"
+ class="panel-no-padding"
+ type="arrow"
+ orient="vertical"
+ ignorekeys="true"
+ hidden="true"
+ tabspecific="true"
+ aria-labelledby="editBookmarkPanelTitle">
+ <box class="panel-header">
+ <html:h1>
+ <html:span id="editBookmarkPanelTitle"/>
+ </html:h1>
+ </box>
+ <toolbarseparator id="editBookmarkHeaderSeparator"></toolbarseparator>
+ <vbox class="panel-subview-body">
+ <html:div id="editBookmarkPanelInfoArea">
+ <html:div id="editBookmarkPanelFaviconContainer">
+ <html:img id="editBookmarkPanelFavicon"/>
+ </html:div>
+ <html:div id="editBookmarkPanelImage"></html:div>
+ </html:div>
+ <toolbarseparator id="editBookmarkSeparator"></toolbarseparator>
+#include ../../components/places/content/editBookmarkPanel.inc.xhtml
+ <vbox id="editBookmarkPanelBottomContent"
+ flex="1">
+ <checkbox id="editBookmarkPanel_showForNewBookmarks"
+ data-l10n-id="bookmark-panel-show-editor-checkbox"
+ oncommand="StarUI.onShowForNewBookmarksCheckboxCommand();"/>
+ </vbox>
+ <hbox id="editBookmarkPanelBottomButtons"
+ class="panel-footer"
+ data-l10n-id="bookmark-panel"
+ data-l10n-attrs="style">
+#ifndef XP_UNIX
+ <button id="editBookmarkPanelDoneButton"
+ class="editBookmarkPanelBottomButton"
+ data-l10n-id="bookmark-panel-save-button"
+ default="true"
+ oncommand="StarUI.panel.hidePopup();"/>
+ <button id="editBookmarkPanelRemoveButton"
+ class="editBookmarkPanelBottomButton"
+ oncommand="StarUI.removeBookmarkButtonCommand();"/>
+#else
+ <button id="editBookmarkPanelRemoveButton"
+ class="editBookmarkPanelBottomButton"
+ oncommand="StarUI.removeBookmarkButtonCommand();"/>
+ <button id="editBookmarkPanelDoneButton"
+ class="editBookmarkPanelBottomButton"
+ data-l10n-id="bookmark-panel-save-button"
+ default="true"
+ oncommand="StarUI.panel.hidePopup();"/>
+#endif
+ </hbox>
+ </vbox>
+ </panel>
+ </html:template>
+
+ <html:template id="UITourTooltipTemplate">
+ <!-- UI tour experience -->
+ <panel id="UITourTooltip"
+ type="arrow"
+ noautofocus="true"
+ noautohide="true"
+ align="start"
+ orient="vertical"
+ role="alert">
+ <vbox>
+ <hbox id="UITourTooltipBody">
+ <image id="UITourTooltipIcon"/>
+ <vbox flex="1">
+ <hbox id="UITourTooltipTitleContainer">
+ <label id="UITourTooltipTitle" flex="1"/>
+ <toolbarbutton id="UITourTooltipClose" class="close-icon"
+ data-l10n-id="ui-tour-info-panel-close"/>
+ </hbox>
+ <description id="UITourTooltipDescription" flex="1"/>
+ </vbox>
+ </hbox>
+ <hbox id="UITourTooltipButtons" flex="1" align="center"/>
+ </vbox>
+ </panel>
+ </html:template>
+ <html:template id="UITourHighlightTemplate">
+ <!-- type="default" forces frames to be created so that the panel's size can be determined -->
+ <panel id="UITourHighlightContainer"
+ type="default"
+ noautofocus="true"
+ noautohide="true"
+ flip="none"
+ consumeoutsideclicks="false">
+ <box id="UITourHighlight"></box>
+ </panel>
+ </html:template>
+
+ <html:template id="dialogStackTemplate">
+ <stack class="dialogStack tab-dialog-box" hidden="true">
+ <vbox class="dialogTemplate dialogOverlay" topmost="true" hidden="true">
+ <hbox class="dialogBox">
+ <browser class="dialogFrame"
+ autoscroll="false"
+ disablehistory="true"/>
+ </hbox>
+ </vbox>
+ </stack>
+ </html:template>
+
+ <panel id="sidebarMenu-popup"
+ class="cui-widget-panel"
+ role="group"
+ type="arrow"
+ hidden="true"
+ flip="slide"
+ orient="vertical"
+ position="bottomleft topleft">
+ <toolbarbutton id="sidebar-switcher-bookmarks"
+ data-l10n-id="sidebar-menu-bookmarks"
+ class="subviewbutton"
+ key="viewBookmarksSidebarKb"
+ oncommand="SidebarUI.show('viewBookmarksSidebar');"/>
+ <toolbarbutton id="sidebar-switcher-history"
+ data-l10n-id="sidebar-menu-history"
+ class="subviewbutton"
+ key="key_gotoHistory"
+ oncommand="SidebarUI.show('viewHistorySidebar');"/>
+ <toolbarbutton id="sidebar-switcher-tabs"
+ data-l10n-id="sidebar-menu-synced-tabs"
+ class="subviewbutton sync-ui-item"
+ oncommand="SidebarUI.show('viewTabsSidebar');"/>
+ <toolbarseparator/>
+ <!-- Extension toolbarbuttons go here. -->
+ <toolbarseparator id="sidebar-extensions-separator"/>
+ <toolbarbutton id="sidebar-reverse-position"
+ class="subviewbutton"
+ oncommand="SidebarUI.reversePosition()"/>
+ <toolbarseparator/>
+ <toolbarbutton data-l10n-id="sidebar-menu-close"
+ class="subviewbutton"
+ oncommand="SidebarUI.hide()"/>
+ </panel>
+
+ <menupopup id="toolbar-context-menu"
+ onpopupshowing="onViewToolbarsPopupShowing(event, document.getElementById('viewToolbarsMenuSeparator')); ToolbarContextMenu.updateDownloadsAutoHide(this); ToolbarContextMenu.updateDownloadsAlwaysOpenPanel(this); ToolbarContextMenu.updateExtension(this, event)">
+ <menuitem oncommand="ToolbarContextMenu.openAboutAddonsForContextAction(this.parentElement)"
+ data-lazy-l10n-id="toolbar-context-menu-manage-extension"
+ contexttype="toolbaritem"
+ class="customize-context-manageExtension"/>
+ <menuitem oncommand="ToolbarContextMenu.removeExtensionForContextAction(this.parentElement)"
+ data-lazy-l10n-id="toolbar-context-menu-remove-extension"
+ contexttype="toolbaritem"
+ class="customize-context-removeExtension"/>
+ <menuitem oncommand="ToolbarContextMenu.reportExtensionForContextAction(this.parentElement, 'toolbar_context_menu')"
+ data-lazy-l10n-id="toolbar-context-menu-report-extension"
+ contexttype="toolbaritem"
+ class="customize-context-reportExtension"/>
+ <menuseparator/>
+ <menuitem oncommand="gCustomizeMode.addToPanel(this.parentNode.triggerNode, 'toolbar-context-menu')"
+ data-lazy-l10n-id="toolbar-context-menu-pin-to-overflow-menu"
+ contexttype="toolbaritem"
+ class="customize-context-moveToPanel"/>
+ <menuitem id="toolbar-context-autohide-downloads-button"
+ oncommand="ToolbarContextMenu.onDownloadsAutoHideChange(event);"
+ type="checkbox"
+ data-lazy-l10n-id="toolbar-context-menu-auto-hide-downloads-button-2"
+ contexttype="toolbaritem"/>
+ <menuitem oncommand="gCustomizeMode.removeFromArea(this.parentNode.triggerNode, 'toolbar-context-menu')"
+ data-lazy-l10n-id="toolbar-context-menu-remove-from-toolbar"
+ contexttype="toolbaritem"
+ class="customize-context-removeFromToolbar"/>
+ <menuitem oncommand="gUnifiedExtensions.onPinToToolbarChange(this.parentElement, event);"
+ data-lazy-l10n-id="toolbar-context-menu-pin-to-toolbar"
+ type="checkbox"
+ contexttype="toolbaritem"
+ class="customize-context-pinToToolbar"/>
+ <menuseparator id="toolbarDownloadsAnchorMenuSeparator"/>
+ <menuitem id="toolbar-context-always-open-downloads-panel"
+ oncommand="ToolbarContextMenu.onDownloadsAlwaysOpenPanelChange(event);"
+ type="checkbox"
+ data-lazy-l10n-id="toolbar-context-menu-always-open-downloads-panel"
+ contexttype="toolbaritem"/>
+ <menuitem id="toolbar-context-openANewTab"
+ contexttype="tabbar"
+ command="cmd_newNavigatorTab"
+ data-lazy-l10n-id="toolbar-context-menu-new-tab"/>
+ <menuseparator id="toolbarNavigatorItemsMenuSeparator"/>
+ <menuitem id="toolbar-context-reloadSelectedTab"
+ contexttype="tabbar"
+ oncommand="gBrowser.reloadMultiSelectedTabs();"
+ data-lazy-l10n-id="toolbar-context-menu-reload-selected-tab"/>
+ <menuitem id="toolbar-context-reloadSelectedTabs"
+ contexttype="tabbar"
+ oncommand="gBrowser.reloadMultiSelectedTabs();"
+ data-lazy-l10n-id="toolbar-context-menu-reload-selected-tabs"/>
+ <menuitem id="toolbar-context-bookmarkSelectedTab"
+ contexttype="tabbar"
+ oncommand="PlacesUIUtils.showBookmarkPagesDialog(PlacesCommandHook.uniqueSelectedPages);"
+ data-lazy-l10n-id="toolbar-context-menu-bookmark-selected-tab"/>
+ <menuitem id="toolbar-context-bookmarkSelectedTabs"
+ contexttype="tabbar"
+ oncommand="PlacesUIUtils.showBookmarkPagesDialog(PlacesCommandHook.uniqueSelectedPages);"
+ data-lazy-l10n-id="toolbar-context-menu-bookmark-selected-tabs"/>
+ <menuitem id="toolbar-context-selectAllTabs"
+ contexttype="tabbar"
+ oncommand="gBrowser.selectAllTabs();"
+ data-lazy-l10n-id="toolbar-context-menu-select-all-tabs"/>
+ <menuitem id="toolbar-context-undoCloseTab"
+ contexttype="tabbar"
+ data-lazy-l10n-id="toolbar-context-menu-reopen-closed-tabs"
+ observes="History:UndoCloseTab"/>
+ <menuseparator id="toolbarItemsMenuSeparator"/>
+ <menuseparator id="viewToolbarsMenuSeparator"/>
+ <!-- XXXgijs: we're using oncommand handler here to avoid the event being
+ redirected to the command element, thus preventing
+ listeners on the menupopup or further up the tree from
+ seeing the command event pass by. The observes attribute is
+ here so that the menuitem is still disabled and re-enabled
+ correctly. -->
+ <menuitem oncommand="gCustomizeMode.enter()"
+ observes="cmd_CustomizeToolbars"
+ class="viewCustomizeToolbar"
+ data-lazy-l10n-id="toolbar-context-menu-view-customize-toolbar-2"/>
+ <menuseparator contexttype="fullscreen"/>
+ <menuitem class="fullscreen-context-autohide"
+ contexttype="fullscreen"
+ type="checkbox"
+ data-lazy-l10n-id="full-screen-autohide"
+ oncommand="FullScreen.setAutohide();"/>
+ <menuitem contexttype="fullscreen"
+ data-lazy-l10n-id="full-screen-exit"
+ oncommand="BrowserFullScreen();"/>
+ </menupopup>
+
+ <menupopup id="blockedPopupOptions"
+ onpopupshowing="gPopupBlockerObserver.fillPopupList(event);"
+ onpopuphiding="gPopupBlockerObserver.onPopupHiding(event);">
+ <menuitem id="blockedPopupAllowSite"
+ oncommand="gPopupBlockerObserver.toggleAllowPopupsForSite(event);"/>
+ <menuitem
+ data-l10n-id="edit-popup-settings"
+ oncommand="gPopupBlockerObserver.editPopupSettings();"/>
+ <menuitem id="blockedPopupDontShowMessage"
+ data-l10n-id="popups-infobar-dont-show-message"
+ type="checkbox"
+ oncommand="gPopupBlockerObserver.dontShowMessage();"/>
+ <menuseparator id="blockedPopupsSeparator"/>
+ </menupopup>
+
+ <menupopup id="contentAreaContextMenu"
+ onpopupshowing="if (event.target != this)
+ return true;
+ gContextMenu = new nsContextMenu(this, event.shiftKey);
+ if (gContextMenu.shouldDisplay)
+ updateEditUIVisibility();
+ return gContextMenu.shouldDisplay;"
+ onpopuphiding="if (event.target != this)
+ return;
+ gContextMenu.hiding(this);
+ gContextMenu = null;
+ updateEditUIVisibility();">
+#include browser-context.inc
+ </menupopup>
+
+ <menupopup id="pictureInPictureToggleContextMenu">
+ <menuitem
+ id="context_HidePictureInPictureToggle"
+ data-l10n-id="picture-in-picture-hide-toggle"
+ oncommand="PictureInPicture.hideToggle();" />
+ <menuitem id="context_MovePictureInPictureToggle"
+ data-l10n-id="picture-in-picture-move-toggle-left"
+ oncommand="PictureInPicture.moveToggle();" />
+ </menupopup>
+
+#include ../../components/places/content/placesContextMenu.inc.xhtml
+
+ <panel id="ctrlTab-panel" hidden="true" norestorefocus="true" level="top" orient="vertical">
+ <hbox id="ctrlTab-previews"/>
+ <hbox id="ctrlTab-showAll-container" pack="center"/>
+ </panel>
+
+ <html:template id="pageActionPanelTemplate">
+ <panel id="pageActionPanel"
+ class="cui-widget-panel panel-no-padding"
+ role="group"
+ type="arrow"
+ hidden="true"
+ flip="slide"
+ position="bottomright topright"
+ tabspecific="true"
+ noautofocus="true">
+ <panelmultiview id="pageActionPanelMultiView"
+ mainViewId="pageActionPanelMainView"
+ viewCacheId="appMenu-viewCache">
+ <panelview id="pageActionPanelMainView"
+ context="pageActionContextMenu"
+ class="PanelUI-subView">
+ <vbox class="panel-subview-body"/>
+ </panelview>
+ </panelmultiview>
+ </panel>
+ </html:template>
+
+ <html:template id="confirmation-hint-wrapper">
+ <panel id="confirmation-hint"
+ role="alert"
+ type="arrow"
+ flip="slide"
+ position="bottomright topright"
+ tabspecific="true"
+ noautofocus="true">
+ <hbox id="confirmation-hint-checkmark-animation-container">
+ <image id="confirmation-hint-checkmark-image"/>
+ </hbox>
+ <vbox id="confirmation-hint-message-container">
+ <label id="confirmation-hint-message"/>
+ <label id="confirmation-hint-description"/>
+ </vbox>
+ </panel>
+ </html:template>
+
+ <menupopup id="pageActionContextMenu"
+ onpopupshowing="BrowserPageActions.onContextMenuShowing(event, this);">
+ <menuitem class="pageActionContextMenuItem extensionPinned extensionUnpinned manageExtensionItem"
+ oncommand="BrowserPageActions.openAboutAddonsForContextAction();"
+ data-l10n-id="page-action-manage-extension2"/>
+ <menuitem class="pageActionContextMenuItem extensionPinned extensionUnpinned removeExtensionItem"
+ oncommand="BrowserPageActions.removeExtensionForContextAction();"
+ data-l10n-id="page-action-remove-extension2"/>
+ </menupopup>
+
+#include ../../components/places/content/bookmarksHistoryTooltip.inc.xhtml
+
+ <tooltip id="tabbrowser-tab-tooltip"
+ class="places-tooltip"
+ onpopupshowing="gBrowser.createTooltip(event);"
+ onpopuphiding="this.removeAttribute('position')">
+ <box class="places-tooltip-box">
+ <description class="tooltip-label places-tooltip-title"/>
+ <hbox>
+ <image id="places-tooltip-insecure-icon"></image>
+ <description crop="center" class="tooltip-label places-tooltip-uri uri-element"/>
+ </hbox>
+ </box>
+ </tooltip>
+
+ <tooltip id="back-button-tooltip">
+ <description id="back-button-tooltip-description" class="tooltip-label"/>
+ <description class="tooltip-label" data-l10n-id="navbar-tooltip-instruction"/>
+ </tooltip>
+
+ <tooltip id="forward-button-tooltip">
+ <description id="forward-button-tooltip-description" class="tooltip-label"/>
+ <description class="tooltip-label" data-l10n-id="navbar-tooltip-instruction"/>
+ </tooltip>
+
+#include popup-notifications.inc
+
+#include ../../components/customizableui/content/panelUI.inc.xhtml
+#include ../../components/controlcenter/content/identityPanel.inc.xhtml
+#include ../../components/controlcenter/content/permissionPanel.inc.xhtml
+#include ../../components/controlcenter/content/protectionsPanel.inc.xhtml
+#include ../../components/downloads/content/downloadsPanel.inc.xhtml
+#include ../../components/translations/content/translationsPanel.inc.xhtml
+#include ../../../devtools/startup/enableDevToolsPopup.inc.xhtml
+#include browser-allTabsMenu.inc.xhtml
+
+ <tooltip id="dynamic-shortcut-tooltip"
+ onpopupshowing="UpdateDynamicShortcutTooltipText(this);"/>
+
+ <menupopup id="SyncedTabsSidebarContext">
+ <menuitem data-lazy-l10n-id="synced-tabs-context-open"
+ id="syncedTabsOpenSelected" where="current"/>
+ <menuitem data-lazy-l10n-id="synced-tabs-context-open-in-tab"
+ id="syncedTabsOpenSelectedInTab" where="tab"/>
+ <menu data-lazy-l10n-id="synced-tabs-context-open-in-container-tab"
+ id="syncedTabsOpenSelectedInContainerTab"
+ hidden="true">
+ <menupopup id="SyncedTabsOpenSelectedInContainerTabMenu"
+ onpopupshowing="createUserContextMenu(event, { isContextMenu: true });"/>
+ </menu>
+ <menuitem data-lazy-l10n-id="synced-tabs-context-open-in-window"
+ id="syncedTabsOpenSelectedInWindow" where="window"/>
+ <menuitem data-lazy-l10n-id="synced-tabs-context-open-in-private-window"
+ id="syncedTabsOpenSelectedInPrivateWindow" where="window" private="true"/>
+ <menuseparator/>
+ <menuitem data-lazy-l10n-id="synced-tabs-context-bookmark"
+ id="syncedTabsBookmarkSelected"/>
+ <menuitem data-lazy-l10n-id="synced-tabs-context-copy"
+ id="syncedTabsCopySelected"/>
+ <menuseparator/>
+ <menuitem data-lazy-l10n-id="synced-tabs-context-open-all-in-tabs"
+ id="syncedTabsOpenAllInTabs"/>
+ <menuitem data-lazy-l10n-id="synced-tabs-context-manage-devices"
+ id="syncedTabsManageDevices"
+ oncommand="gSync.openDevicesManagementPage('syncedtabs-sidebar');"/>
+ <menuitem data-lazy-l10n-id="synced-tabs-context-sync-now"
+ id="syncedTabsRefresh"/>
+ </menupopup>
+ <menupopup id="SyncedTabsSidebarTabsFilterContext"
+ class="textbox-contextmenu">
+ <menuitem data-l10n-id="text-action-undo"
+ cmd="cmd_undo"/>
+ <menuseparator/>
+ <menuitem data-l10n-id="text-action-cut"
+ cmd="cmd_cut"/>
+ <menuitem data-l10n-id="text-action-copy"
+ cmd="cmd_copy"/>
+ <menuitem data-l10n-id="text-action-paste"
+ cmd="cmd_paste"/>
+ <menuitem data-l10n-id="text-action-delete"
+ cmd="cmd_delete"/>
+ <menuseparator/>
+ <menuitem data-l10n-id="text-action-select-all"
+ cmd="cmd_selectAll"/>
+ <menuseparator/>
+ <menuitem data-lazy-l10n-id="synced-tabs-context-sync-now"
+ id="syncedTabsRefreshFilter"/>
+ </menupopup>
+
+ <hbox id="statuspanel" inactive="true" hidden="true">
+ <label id="statuspanel-label"
+ role="status"
+ aria-live="off"
+ flex="1"
+ crop="end"/>
+ </hbox>
+
+#include swipe-navigation.inc.xhtml
+
+ <html:template id="sharing-tabs-warning-panel-template">
+ <panel id="sharing-tabs-warning-panel"
+ role="alert"
+ flip="slide"
+ type="arrow"
+ orient="vertical"
+ ignorekeys="true"
+ consumeoutsideclicks="never"
+ norolluponanchor="true"
+ onpopupshown="gSharedTabWarning.sharedTabWarningShown();">
+ <hbox type="window" align="start">
+ <image class="screen-icon popup-notification-icon"></image>
+ <vbox flex="1" pack="start">
+ <label>
+ <html:span id="sharing-warning-window-panel-header"
+ role="heading"
+ aria-level="1"
+ data-l10n-id="sharing-warning-window"/>
+ <html:span id="sharing-warning-screen-panel-header"
+ role="heading"
+ aria-level="1"
+ data-l10n-id="sharing-warning-screen"/>
+ </label>
+ <hbox align="center">
+ <button id="sharing-warning-proceed-to-tab" oncommand="gSharedTabWarning.allowSharedTabSwitch();" flex="1" data-l10n-id="sharing-warning-proceed-to-tab"/>
+ </hbox>
+ <hbox pack="start">
+ <checkbox id="sharing-warning-disable-for-session" data-l10n-id="sharing-warning-disable-for-session"/>
+ </hbox>
+ </vbox>
+ </hbox>
+ </panel>
+ </html:template>
+
+ #include ../../../toolkit/components/pictureinpicture/content/pictureInPicturePanel.xhtml
+
+ <menupopup id="unified-extensions-context-menu"
+ onpopupshowing="gUnifiedExtensions.updateContextMenu(this, event)"
+ oncommand="gUnifiedExtensions.onContextMenuCommand(this, event)">
+ <menuitem oncommand="gUnifiedExtensions.onPinToToolbarChange(this.parentElement, event);"
+ data-lazy-l10n-id="unified-extensions-context-menu-pin-to-toolbar"
+ type="checkbox"
+ class="unified-extensions-context-menu-pin-to-toolbar"/>
+ <menuitem oncommand="gUnifiedExtensions.moveWidget(this.parentElement, 'up');"
+ data-lazy-l10n-id="unified-extensions-context-menu-move-widget-up"
+ class="unified-extensions-context-menu-move-widget-up"/>
+ <menuitem oncommand="gUnifiedExtensions.moveWidget(this.parentElement, 'down');"
+ data-lazy-l10n-id="unified-extensions-context-menu-move-widget-down"
+ class="unified-extensions-context-menu-move-widget-down"/>
+ <menuseparator class="unified-extensions-context-menu-management-separator"/>
+ <menuitem data-lazy-l10n-id="unified-extensions-context-menu-manage-extension"
+ class="unified-extensions-context-menu-manage-extension"
+ oncommand="gUnifiedExtensions.manageExtension(this.parentElement)" />
+ <menuitem data-lazy-l10n-id="unified-extensions-context-menu-remove-extension"
+ class="unified-extensions-context-menu-remove-extension"
+ oncommand="gUnifiedExtensions.removeExtension(this.parentElement)" />
+ <menuitem data-lazy-l10n-id="unified-extensions-context-menu-report-extension"
+ class="unified-extensions-context-menu-report-extension"
+ oncommand="gUnifiedExtensions.reportExtension(this.parentElement)" />
+ </menupopup>
+</popupset>
diff --git a/browser/base/content/metrics.yaml b/browser/base/content/metrics.yaml
new file mode 100644
index 0000000000..462474e3f9
--- /dev/null
+++ b/browser/base/content/metrics.yaml
@@ -0,0 +1,11 @@
+# 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/.
+
+# Adding a new metric? We have docs for that!
+# https://firefox-source-docs.mozilla.org/toolkit/components/glean/user/new_definitions_file.html
+
+---
+$schema: moz://mozilla.org/schemas/glean/metrics/2-0-0
+$tags:
+ - 'Firefox :: General'
diff --git a/browser/base/content/moz.build b/browser/base/content/moz.build
new file mode 100644
index 0000000000..23ee760566
--- /dev/null
+++ b/browser/base/content/moz.build
@@ -0,0 +1,182 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+with Files("**"):
+ BUG_COMPONENT = ("Firefox", "General")
+
+with Files("docs/**"):
+ BUG_COMPONENT = ("Core", "Security")
+
+with Files("pageinfo/**"):
+ BUG_COMPONENT = ("Firefox", "Page Info Window")
+
+with Files("test/about/**"):
+ BUG_COMPONENT = ("Firefox", "General")
+
+with Files("test/alerts/**"):
+ BUG_COMPONENT = ("Toolkit", "Notifications and Alerts")
+
+with Files("test/captivePortal/**"):
+ BUG_COMPONENT = ("Firefox", "General")
+
+with Files("test/chrome/**"):
+ BUG_COMPONENT = ("Firefox", "General")
+
+with Files("test/contextMenu/**"):
+ BUG_COMPONENT = ("Firefox", "Menus")
+
+with Files("test/forms/**"):
+ BUG_COMPONENT = ("Core", "Layout: Form Controls")
+
+with Files("test/historySwipeAnimation/**"):
+ BUG_COMPONENT = ("Core", "Widget: Cocoa")
+
+with Files("test/keyboard/**"):
+ BUG_COMPONENT = ("Firefox", "Keyboard Navigation")
+
+with Files("test/outOfProcess/**"):
+ BUG_COMPONENT = ("Firefox", "General")
+
+with Files("test/pageActions*/**"):
+ BUG_COMPONENT = ("Firefox", "Toolbars and Customization")
+
+with Files("test/pageinfo/**"):
+ BUG_COMPONENT = ("Firefox", "Page Info Window")
+
+with Files("test/performance/**"):
+ BUG_COMPONENT = ("Toolkit", "Performance Monitoring")
+
+with Files("test/performance/browser_appmenu.js"):
+ BUG_COMPONENT = ("Firefox", "Menus")
+
+with Files("test/permissions/**"):
+ BUG_COMPONENT = ("Firefox", "Site Permissions")
+
+with Files("test/plugins/**"):
+ BUG_COMPONENT = ("Core", "Audio/Video: GMP")
+
+with Files("test/popupNotifications/**"):
+ BUG_COMPONENT = ("Toolkit", "Notifications and Alerts")
+
+with Files("test/popups/**"):
+ BUG_COMPONENT = ("Firefox", "Site Permissions")
+
+with Files("test/referrer/**"):
+ BUG_COMPONENT = ("Core", "DOM: Navigation")
+
+with Files("test/sanitize/**"):
+ BUG_COMPONENT = ("Toolkit", "Data Sanitization")
+
+with Files("test/siteIdentity/**"):
+ BUG_COMPONENT = ("Firefox", "Site Identity")
+
+with Files("test/sidebar/**"):
+ BUG_COMPONENT = ("Firefox", "General")
+
+with Files("test/startup/**"):
+ BUG_COMPONENT = ("Firefox", "General")
+
+with Files("test/static/**"):
+ BUG_COMPONENT = ("Firefox", "General")
+
+with Files("test/statuspanel/**"):
+ BUG_COMPONENT = ("Firefox", "Tabbed Browser")
+
+with Files("test/sync/**"):
+ BUG_COMPONENT = ("Firefox", "Sync")
+
+with Files("test/tabdialogs/**"):
+ BUG_COMPONENT = ("Firefox", "Tabbed Browser")
+
+with Files("test/notificationbox/**"):
+ BUG_COMPONENT = ("Firefox", "Tabbed Browser")
+
+with Files("test/tabPrompts/**"):
+ BUG_COMPONENT = ("Firefox", "Tabbed Browser")
+
+with Files("test/tabcrashed/**"):
+ BUG_COMPONENT = ("Firefox", "Tabbed Browser")
+
+with Files("test/tabs/**"):
+ BUG_COMPONENT = ("Firefox", "Tabbed Browser")
+
+with Files("test/touch/**"):
+ BUG_COMPONENT = ("Firefox", "General")
+
+with Files("test/utilityOverlay/**"):
+ BUG_COMPONENT = ("Firefox", "Tabbed Browser")
+
+with Files("test/protectionsUI/**"):
+ BUG_COMPONENT = ("Firefox", "Protections UI")
+
+with Files("test/webextensions/**"):
+ BUG_COMPONENT = ("WebExtensions", "Untriaged")
+
+with Files("test/webrtc/**"):
+ BUG_COMPONENT = ("Core", "WebRTC")
+
+with Files("test/zoom/**"):
+ BUG_COMPONENT = ("Firefox", "General")
+
+with Files("test/caps/**"):
+ BUG_COMPONENT = ("Firefox", "Security")
+
+with Files("blockedSite.xhtml"):
+ BUG_COMPONENT = ("Toolkit", "Safe Browsing")
+
+with Files("browser-addons.js"):
+ BUG_COMPONENT = ("Toolkit", "Add-ons Manager")
+
+with Files("browser-unified-extensions.js"):
+ BUG_COMPONENT = ("Toolkit", "Add-ons Manager")
+
+with Files("*menu*"):
+ BUG_COMPONENT = ("Firefox", "Menus")
+
+with Files("browser-customization.js"):
+ BUG_COMPONENT = ("Firefox", "Toolbars and Customization")
+
+with Files("browser-fullZoom.js"):
+ BUG_COMPONENT = ("Firefox", "Tabbed Browser")
+
+with Files("browser-gestureSupport.js"):
+ BUG_COMPONENT = ("Core", "Widget")
+
+with Files("browser-pageActions.js"):
+ BUG_COMPONENT = ("Firefox", "Toolbars and Customization")
+
+with Files("browser-places.js"):
+ BUG_COMPONENT = ("Firefox", "Bookmarks & History")
+
+with Files("browser-safebrowsing.js"):
+ BUG_COMPONENT = ("Toolkit", "Safe Browsing")
+
+with Files("browser-sync.js"):
+ BUG_COMPONENT = ("Firefox", "Sync")
+
+with Files("contentSearch*"):
+ BUG_COMPONENT = ("Firefox", "Search")
+
+with Files("hiddenWindowMac.xhtml"):
+ BUG_COMPONENT = ("Firefox", "Site Permissions")
+
+with Files("macWindow.inc.xhtml"):
+ BUG_COMPONENT = ("Firefox", "Shell Integration")
+
+with Files("sanitize*"):
+ BUG_COMPONENT = ("Toolkit", "Data Sanitization")
+
+with Files("tabbrowser*"):
+ BUG_COMPONENT = ("Firefox", "Tabbed Browser")
+
+with Files("browser-allTabsMenu.js"):
+ BUG_COMPONENT = ("Firefox", "Tabbed Browser")
+
+with Files("webext-panels*"):
+ BUG_COMPONENT = ("WebExtensions", "Frontend")
+
+with Files("webrtcIndicator*"):
+ BUG_COMPONENT = ("Firefox", "Site Permissions")
diff --git a/browser/base/content/navigator-toolbox.inc.xhtml b/browser/base/content/navigator-toolbox.inc.xhtml
new file mode 100644
index 0000000000..0941688d4b
--- /dev/null
+++ b/browser/base/content/navigator-toolbox.inc.xhtml
@@ -0,0 +1,728 @@
+# 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/.
+
+<!--
+ GTK needs to draw behind the lightweight theme toolbox backgrounds, thus the extra box.
+ Also this box allows a negative margin-top to slide the toolbox off screen in
+ fullscreen layout.
+-->
+<box id="navigator-toolbox-background">
+<toolbox id="navigator-toolbox" flex="1">
+
+ <vbox id="titlebar">
+ <!-- Menu -->
+ <toolbar type="menubar" id="toolbar-menubar"
+ class="browser-toolbar chromeclass-menubar titlebar-color"
+ customizable="true"
+ mode="icons"
+ context="toolbar-context-menu">
+ <toolbaritem id="menubar-items" align="center">
+# The entire main menubar is placed into browser-menubar.inc, so that it can be
+# shared with other top level windows in macWindow.inc.xhtml.
+#include browser-menubar.inc
+ </toolbaritem>
+ <spacer flex="1" skipintoolbarset="true" style="order: 1000;"/>
+#include titlebar-items.inc.xhtml
+ </toolbar>
+
+ <toolbar id="TabsToolbar"
+ class="browser-toolbar titlebar-color"
+ fullscreentoolbar="true"
+ customizable="true"
+ customizationtarget="TabsToolbar-customization-target"
+ mode="icons"
+ data-l10n-id="tabs-toolbar"
+ context="toolbar-context-menu"
+ flex="1">
+
+ <hbox class="titlebar-spacer" type="pre-tabs"/>
+
+ <hbox flex="1" align="end" class="toolbar-items">
+ <toolbartabstop/>
+ <hbox id="TabsToolbar-customization-target" flex="1">
+ <toolbarbutton id="firefox-view-button"
+ class="toolbarbutton-1 chromeclass-toolbar-additional"
+ data-l10n-id="toolbar-button-firefox-view"
+ role="button"
+ aria-pressed="false"
+ oncommand="FirefoxViewHandler.openTab(event);"
+ onmousedown="FirefoxViewHandler.openTab(event);"
+ cui-areatype="toolbar"
+ removable="true"/>
+
+ <tabs id="tabbrowser-tabs"
+ is="tabbrowser-tabs"
+ aria-multiselectable="true"
+ setfocus="false"
+ tooltip="tabbrowser-tab-tooltip"
+ stopwatchid="FX_TAB_CLICK_MS">
+ <hbox class="tab-drop-indicator" hidden="true"/>
+# If the name (tabbrowser-arrowscrollbox) or structure of this changes
+# significantly, there is an optimization in
+# DisplayPortUtils::MaybeCreateDisplayPortInFirstScrollFrameEncountered based
+# the current structure that we may want to revisit.
+ <arrowscrollbox id="tabbrowser-arrowscrollbox" orient="horizontal" flex="1" style="min-width: 1px;" clicktoscroll="true" scrolledtostart="true" scrolledtoend="true">
+ <tab is="tabbrowser-tab" class="tabbrowser-tab" selected="true" visuallyselected="true" fadein="true"/>
+ <hbox id="tabbrowser-arrowscrollbox-periphery">
+ <toolbartabstop/>
+ <toolbarbutton id="tabs-newtab-button"
+ class="toolbarbutton-1"
+ command="cmd_newNavigatorTab"
+ onclick="gBrowser.handleNewTabMiddleClick(this, event);"
+ tooltip="dynamic-shortcut-tooltip"/>
+ <spacer class="closing-tabs-spacer" style="width: 0;"/>
+ </hbox>
+ </arrowscrollbox>
+ <html:span id="tabbrowser-tab-a11y-desc" hidden="true"/>
+ </tabs>
+
+ <toolbarbutton id="new-tab-button"
+ class="toolbarbutton-1 chromeclass-toolbar-additional"
+ data-l10n-id="tabs-toolbar-new-tab"
+ command="cmd_newNavigatorTab"
+ onclick="gBrowser.handleNewTabMiddleClick(this, event);"
+ tooltip="dynamic-shortcut-tooltip"
+ ondrop="newTabButtonObserver.onDrop(event)"
+ ondragover="newTabButtonObserver.onDragOver(event)"
+ ondragenter="newTabButtonObserver.onDragOver(event)"
+ cui-areatype="toolbar"
+ removable="true"/>
+
+ <toolbarbutton id="alltabs-button"
+ class="toolbarbutton-1 chromeclass-toolbar-additional tabs-alltabs-button"
+ delegatesanchor="true"
+ badged="true"
+ onkeypress="gTabsPanel.showAllTabsPanel(event, 'alltabs-button');"
+ onmousedown="gTabsPanel.showAllTabsPanel(event, 'alltabs-button');"
+ data-l10n-id="tabs-toolbar-list-all-tabs"
+ removable="false"/>
+ </hbox>
+ </hbox>
+
+ <hbox class="titlebar-spacer" type="post-tabs"/>
+
+ <hbox class="private-browsing-indicator"/>
+ <hbox id="private-browsing-indicator-with-label">
+ <image class="private-browsing-indicator-icon"/>
+ <label data-l10n-id="private-browsing-indicator-label"></label>
+ </hbox>
+
+#include titlebar-items.inc.xhtml
+
+ </toolbar>
+
+ </vbox>
+
+ <toolbar id="nav-bar"
+ class="browser-toolbar"
+ data-l10n-id="navbar-accessible"
+ fullscreentoolbar="true" mode="icons" customizable="true"
+ customizationtarget="nav-bar-customization-target"
+ overflowable="true"
+ default-overflowbutton="nav-bar-overflow-button"
+ default-overflowtarget="widget-overflow-list"
+ default-overflowpanel="widget-overflow"
+ addon-webext-overflowbutton="unified-extensions-button"
+ addon-webext-overflowtarget="overflowed-extensions-list"
+ context="toolbar-context-menu">
+
+ <toolbartabstop/>
+
+ <hbox id="nav-bar-customization-target" flex="1">
+ <toolbarbutton id="back-button" class="toolbarbutton-1 chromeclass-toolbar-additional"
+ data-l10n-id="toolbar-button-back-2"
+ removable="false" overflows="false"
+ keepbroadcastattributeswhencustomizing="true"
+ command="Browser:BackOrBackDuplicate"
+ onclick="checkForMiddleClick(this, event);"
+ tooltip="back-button-tooltip"
+ context="backForwardMenu"/>
+ <toolbarbutton id="forward-button" class="toolbarbutton-1 chromeclass-toolbar-additional"
+ data-l10n-id="toolbar-button-forward-2"
+ removable="false" overflows="false"
+ keepbroadcastattributeswhencustomizing="true"
+ command="Browser:ForwardOrForwardDuplicate"
+ onclick="checkForMiddleClick(this, event);"
+ tooltip="forward-button-tooltip"
+ context="backForwardMenu"/>
+ <toolbaritem id="stop-reload-button" class="chromeclass-toolbar-additional"
+ data-l10n-id="toolbar-button-stop-reload"
+ removable="true" overflows="false">
+ <toolbarbutton id="reload-button" class="toolbarbutton-1"
+ data-l10n-id="toolbar-button-reload"
+ command="Browser:ReloadOrDuplicate"
+ onclick="checkForMiddleClick(this, event);"
+ tooltip="dynamic-shortcut-tooltip">
+ <box class="toolbarbutton-animatable-box">
+ <image class="toolbarbutton-animatable-image"/>
+ </box>
+ </toolbarbutton>
+ <toolbarbutton id="stop-button" class="toolbarbutton-1"
+ data-l10n-id="toolbar-button-stop"
+ command="Browser:Stop"
+ tooltip="dynamic-shortcut-tooltip">
+ <box class="toolbarbutton-animatable-box">
+ <image class="toolbarbutton-animatable-image"/>
+ </box>
+ </toolbarbutton>
+ </toolbaritem>
+ <toolbarspring cui-areatype="toolbar" class="chromeclass-toolbar-additional"/>
+ <toolbaritem id="urlbar-container"
+ removable="false"
+ class="chromeclass-location" overflows="false">
+ <toolbartabstop/>
+ <hbox id="urlbar" flex="1"
+ context=""
+ focused="true"
+ pageproxystate="invalid">
+ <hbox id="urlbar-background"/>
+ <hbox id="urlbar-input-container"
+ flex="1"
+ pageproxystate="invalid">
+ <box id="remote-control-box"
+ align="center"
+ collapsed="true">
+ <image id="remote-control-icon"/>
+ </box>
+ <box id="urlbar-search-button"
+ class="chromeclass-toolbar-additional"/>
+ <!-- Use onclick instead of normal popup= syntax since the popup
+ code fires onmousedown, and hence eats our favicon drag events. -->
+ <box id="tracking-protection-icon-container" align="center"
+ role="button"
+ onclick="gProtectionsHandler.handleProtectionsButtonEvent(event);"
+ onkeypress="gProtectionsHandler.handleProtectionsButtonEvent(event);"
+ onmouseover="gProtectionsHandler.onTrackingProtectionIconHoveredOrFocused();"
+ onfocus="gProtectionsHandler.onTrackingProtectionIconHoveredOrFocused();"
+ tooltip="tracking-protection-icon-tooltip">
+ <box id="tracking-protection-icon-box">
+ <image id="tracking-protection-icon"/>
+ </box>
+ <tooltip id="tracking-protection-icon-tooltip">
+ <description id="tracking-protection-icon-tooltip-label" class="tooltip-label"/>
+ </tooltip>
+ </box>
+ <box id="identity-box"
+ pageproxystate="invalid"
+ ondragstart="gIdentityHandler.onDragStart(event);">
+ <box id="identity-icon-box"
+ role="button"
+ align="center"
+ data-l10n-id="urlbar-identity-button"
+ class="identity-box-button"
+ onclick="gIdentityHandler.handleIdentityButtonEvent(event); PageProxyClickHandler(event);"
+ onkeypress="gIdentityHandler.handleIdentityButtonEvent(event);">
+ <image id="identity-icon"/>
+ <label id="identity-icon-label" class="plain" crop="center" flex="1"/>
+ </box>
+ <box id="identity-permission-box"
+ data-l10n-id="urlbar-permissions-granted"
+ role="button"
+ align="center"
+ class="identity-box-button"
+ onclick="gPermissionPanel.handleIdentityButtonEvent(event); PageProxyClickHandler(event);"
+ onkeypress="gPermissionPanel.handleIdentityButtonEvent(event);">
+ <image id="permissions-granted-icon"/>
+ <box style="pointer-events: none;">
+ <image class="sharing-icon" id="webrtc-sharing-icon"/>
+ <image class="sharing-icon geo-icon" id="geo-sharing-icon"/>
+ <image class="sharing-icon xr-icon" id="xr-sharing-icon"/>
+ </box>
+ <box id="blocked-permissions-container" align="center">
+ <image data-permission-id="geo" class="blocked-permission-icon geo-icon" role="button"
+ data-l10n-id="urlbar-geolocation-blocked"/>
+ <image data-permission-id="xr" class="blocked-permission-icon xr-icon" role="button"
+ data-l10n-id="urlbar-xr-blocked"/>
+ <image data-permission-id="desktop-notification" class="blocked-permission-icon desktop-notification-icon" role="button"
+ data-l10n-id="urlbar-web-notifications-blocked"/>
+ <image data-permission-id="camera" class="blocked-permission-icon camera-icon" role="button"
+ data-l10n-id="urlbar-camera-blocked"/>
+ <image data-permission-id="microphone" class="blocked-permission-icon microphone-icon" role="button"
+ data-l10n-id="urlbar-microphone-blocked"/>
+ <image data-permission-id="screen" class="blocked-permission-icon screen-icon" role="button"
+ data-l10n-id="urlbar-screen-blocked"/>
+ <image data-permission-id="persistent-storage" class="blocked-permission-icon persistent-storage-icon" role="button"
+ data-l10n-id="urlbar-persistent-storage-blocked"/>
+ <image data-permission-id="popup" class="blocked-permission-icon popup-icon" role="button"
+ data-l10n-id="urlbar-popup-blocked"/>
+ <image data-permission-id="autoplay-media" class="blocked-permission-icon autoplay-media-icon" role="button"
+ data-l10n-id="urlbar-autoplay-media-blocked"/>
+ <image data-permission-id="canvas" class="blocked-permission-icon canvas-icon" role="button"
+ data-l10n-id="urlbar-canvas-blocked"/>
+ <image data-permission-id="midi" class="blocked-permission-icon midi-icon" role="button"
+ data-l10n-id="urlbar-midi-blocked"/>
+ <image data-permission-id="install" class="blocked-permission-icon install-icon" role="button"
+ data-l10n-id="urlbar-install-blocked"/>
+ <!-- A speaker icon for blocked speaker selection is not
+ shown because, without text, this may be interpreted as
+ active or blocked audio. -->
+ </box>
+ </box>
+ <box id="notification-popup-box"
+ class="anchor-root"
+ hidden="true"
+ align="center">
+ <image id="default-notification-icon" class="notification-anchor-icon" role="button"
+ data-l10n-id="urlbar-default-notification-anchor"/>
+ <image id="geo-notification-icon" class="notification-anchor-icon geo-icon" role="button"
+ data-l10n-id="urlbar-geolocation-notification-anchor"/>
+ <image id="xr-notification-icon" class="notification-anchor-icon xr-icon" role="button"
+ data-l10n-id="urlbar-xr-notification-anchor"/>
+ <image id="autoplay-media-notification-icon" class="notification-anchor-icon autoplay-media-icon" role="button"
+ data-l10n-id="urlbar-autoplay-notification-anchor"/>
+ <image id="addons-notification-icon" class="notification-anchor-icon install-icon" role="button"
+ data-l10n-id="urlbar-addons-notification-anchor"/>
+ <image id="canvas-notification-icon" class="notification-anchor-icon" role="button"
+ data-l10n-id="urlbar-canvas-notification-anchor"/>
+ <image id="indexedDB-notification-icon" class="notification-anchor-icon indexedDB-icon" role="button"
+ data-l10n-id="urlbar-indexed-db-notification-anchor"/>
+ <image id="password-notification-icon" class="notification-anchor-icon" role="button"
+ data-l10n-id="urlbar-password-notification-anchor"/>
+ <stack id="plugins-notification-icon" class="notification-anchor-icon" role="button" align="center" data-l10n-id="urlbar-plugins-notification-anchor">
+ <image class="plugin-icon" />
+ <image id="plugin-icon-badge" />
+ </stack>
+ <image id="web-notifications-notification-icon" class="notification-anchor-icon desktop-notification-icon" role="button"
+ data-l10n-id="urlbar-web-notification-anchor"/>
+ <image id="webRTC-shareDevices-notification-icon" class="notification-anchor-icon camera-icon" role="button"
+ data-l10n-id="urlbar-web-rtc-share-devices-notification-anchor"/>
+ <image id="webRTC-shareMicrophone-notification-icon" class="notification-anchor-icon microphone-icon" role="button"
+ data-l10n-id="urlbar-web-rtc-share-microphone-notification-anchor"/>
+ <image id="webRTC-shareScreen-notification-icon" class="notification-anchor-icon screen-icon" role="button"
+ data-l10n-id="urlbar-web-rtc-share-screen-notification-anchor"/>
+ <image id="webRTC-shareSpeaker-notification-icon" class="notification-anchor-icon speaker-icon" role="button"
+ data-l10n-id="urlbar-web-rtc-share-speaker-notification-anchor"/>
+ <image id="servicesInstall-notification-icon" class="notification-anchor-icon service-icon" role="button"
+ data-l10n-id="urlbar-services-notification-anchor"/>
+ <image id="eme-notification-icon" class="notification-anchor-icon drm-icon" role="button"
+ data-l10n-id="urlbar-eme-notification-anchor"/>
+ <image id="persistent-storage-notification-icon" class="notification-anchor-icon persistent-storage-icon" role="button"
+ data-l10n-id="urlbar-persistent-storage-notification-anchor"/>
+ <image id="midi-notification-icon" class="notification-anchor-icon midi-icon" role="button"
+ data-l10n-id="urlbar-midi-notification-anchor"/>
+ <image id="webauthn-notification-icon" class="notification-anchor-icon" role="button"
+ data-l10n-id="urlbar-web-authn-anchor"/>
+ <image id="identity-credential-notification-icon" class="notification-anchor-icon" role="button"
+ data-l10n-id="identity-credential-urlbar-anchor"/>
+ <image id="storage-access-notification-icon" class="notification-anchor-icon storage-access-icon" role="button"
+ data-l10n-id="urlbar-storage-access-anchor"/>
+ </box>
+ </box>
+ <box id="urlbar-label-box" align="center">
+ <label id="urlbar-label-switchtab" class="urlbar-label" data-l10n-id="urlbar-switch-to-tab"/>
+ <label id="urlbar-label-extension" class="urlbar-label" data-l10n-id="urlbar-extension"/>
+ <label id="urlbar-label-search-mode" class="urlbar-label"/>
+ </box>
+ <html:div id="urlbar-search-mode-indicator">
+ <html:span id="urlbar-search-mode-indicator-title"/>
+ <html:div id="urlbar-search-mode-indicator-close"
+ class="close-button"
+ role="button"/>
+ </html:div>
+ <moz-input-box tooltip="aHTMLTooltip"
+ class="urlbar-input-box"
+ flex="1"
+ role="combobox"
+ aria-owns="urlbar-results">
+ <html:input id="urlbar-scheme"
+ required="required"/>
+ <html:input id="urlbar-input"
+ anonid="input"
+ aria-controls="urlbar-results"
+ aria-autocomplete="both"
+ inputmode="mozAwesomebar"
+ data-l10n-id="urlbar-placeholder"
+ data-l10n-attrs="placeholder"/>
+ </moz-input-box>
+ <image id="urlbar-go-button"
+ class="urlbar-icon"
+ onclick="gURLBar.handleCommand(event);"
+ data-l10n-id="urlbar-go-button"/>
+ <hbox id="page-action-buttons" context="pageActionContextMenu" align="center">
+ <toolbartabstop/>
+ <hbox id="contextual-feature-recommendation" role="button" hidden="true">
+ <hbox id="cfr-label-container">
+ <label id="cfr-label"/>
+ </hbox>
+ <hbox id="cfr-button"
+ role="presentation"
+ class="urlbar-page-action">
+ <image class="urlbar-icon"/>
+ </hbox>
+ </hbox>
+ <hbox id="userContext-icons" hidden="true">
+ <label id="userContext-label"/>
+ <image id="userContext-indicator"/>
+ </hbox>
+ <hbox id="reader-mode-button"
+ class="urlbar-page-action"
+ role="button"
+ data-l10n-id="reader-view-enter-button"
+ hidden="true"
+ tooltip="dynamic-shortcut-tooltip"
+ onclick="AboutReaderParent.buttonClick(event);">
+ <image id="reader-mode-button-icon"
+ class="urlbar-icon"/>
+ </hbox>
+ <hbox id="picture-in-picture-button"
+ class="urlbar-page-action"
+ role="button"
+ hidden="true"
+ onclick="PictureInPicture.toggleUrlbar(event)">
+ <image id="picture-in-picture-button-icon"
+ class="urlbar-icon"/>
+ </hbox>
+ <hbox id="translations-button"
+ class="urlbar-page-action"
+ role="button"
+ data-l10n-id="urlbar-translations-button"
+ hidden="true"
+ onclick="TranslationsPanel.open(event);">
+ <image class="urlbar-icon" id="translations-button-icon" />
+ <image class="urlbar-icon" id="translations-button-circle-arrows" />
+ <html:span id="translations-button-locale" />
+ </hbox>
+ <toolbarbutton id="urlbar-zoom-button"
+ onclick="FullZoom.reset(); FullZoom.resetScalingZoom();"
+ tooltip="dynamic-shortcut-tooltip"
+ hidden="true"/>
+ <hbox id="pageActionButton"
+ class="urlbar-page-action"
+ role="button"
+ data-l10n-id="urlbar-page-action-button"
+ onmousedown="BrowserPageActions.mainButtonClicked(event);"
+ onkeypress="BrowserPageActions.mainButtonClicked(event);">
+ <image class="urlbar-icon"/>
+ </hbox>
+ <hbox id="star-button-box"
+ hidden="true"
+ role="button"
+ class="urlbar-page-action"
+ onclick="BrowserPageActions.doCommandForAction(PageActions.actionForID('bookmark'), event, this);">
+ <image id="star-button"
+ class="urlbar-icon"/>
+ </hbox>
+ </hbox>
+ </hbox>
+ </hbox>
+ <toolbartabstop/>
+ </toolbaritem>
+
+ <toolbarspring cui-areatype="toolbar" class="chromeclass-toolbar-additional"/>
+
+ <toolbarbutton id="downloads-button"
+ class="toolbarbutton-1 chromeclass-toolbar-additional"
+ delegatesanchor="true"
+ badged="true"
+ key="key_openDownloads"
+ onmousedown="DownloadsIndicatorView.onCommand(event);"
+ onkeypress="DownloadsIndicatorView.onCommand(event);"
+ ondrop="DownloadsIndicatorView.onDrop(event);"
+ ondragover="DownloadsIndicatorView.onDragOver(event);"
+ ondragenter="DownloadsIndicatorView.onDragOver(event);"
+ data-l10n-id="navbar-downloads"
+ removable="true"
+ overflows="false"
+ cui-areatype="toolbar"
+ hidden="true"
+ tooltip="dynamic-shortcut-tooltip">
+ <box id="downloads-indicator-anchor"
+ consumeanchor="downloads-button">
+ <image id="downloads-indicator-icon"/>
+ </box>
+ <box class="toolbarbutton-animatable-box" id="downloads-indicator-progress-outer">
+ <box id="downloads-indicator-progress-inner"/>
+ </box>
+ <box class="toolbarbutton-animatable-box" id="downloads-indicator-start-box">
+ <image class="toolbarbutton-animatable-image" id="downloads-indicator-start-image"/>
+ </box>
+ <box class="toolbarbutton-animatable-box" id="downloads-indicator-finish-box">
+ <image class="toolbarbutton-animatable-image" id="downloads-indicator-finish-image"/>
+ </box>
+ </toolbarbutton>
+
+ <toolbarbutton id="fxa-toolbar-menu-button" class="toolbarbutton-1 chromeclass-toolbar-additional subviewbutton-nav"
+ badged="true"
+ delegatesanchor="true"
+ onmousedown="gSync.toggleAccountPanel(this, event)"
+ onkeypress="gSync.toggleAccountPanel(this, event)"
+ consumeanchor="fxa-toolbar-menu-button"
+ closemenu="none"
+ data-l10n-id="toolbar-button-fxaccount"
+ cui-areatype="toolbar"
+ removable="true">
+ <vbox>
+ <image id="fxa-avatar-image"/>
+ </vbox>
+ </toolbarbutton>
+
+ <toolbarbutton id="unified-extensions-button"
+ class="toolbarbutton-1 chromeclass-toolbar-additional"
+ delegatesanchor="true"
+ data-l10n-id="unified-extensions-button"
+ hidden="true"
+ onkeypress="gUnifiedExtensions.togglePanel(event)"
+ onmousedown="gUnifiedExtensions.togglePanel(event)"
+ overflows="false"
+ removable="false"/>
+ </hbox>
+
+ <toolbarbutton id="nav-bar-overflow-button"
+ class="toolbarbutton-1 chromeclass-toolbar-additional overflow-button"
+ delegatesanchor="true"
+ skipintoolbarset="true"
+ data-l10n-id="navbar-overflow">
+ <box class="toolbarbutton-animatable-box">
+ <image class="toolbarbutton-animatable-image"/>
+ </box>
+ </toolbarbutton>
+
+ <toolbaritem id="PanelUI-button"
+ removable="false">
+ <toolbarbutton id="ion-button"
+ class="toolbarbutton-1"
+ delegatesanchor="true"
+ hidden="true"
+ badged="true"
+ tooltiptext="Ion"
+ onmousedown="switchToTabHavingURI('about:ion', true);"
+ onkeypress="switchToTabHavingURI('about:ion', true);"/>
+ <toolbarbutton id="whats-new-menu-button"
+ class="toolbarbutton-1"
+ delegatesanchor="true"
+ hidden="true"
+ badged="true"
+ onmousedown="PanelUI.showSubView('PanelUI-whatsNew', this, event);"
+ onkeypress="PanelUI.showSubView('PanelUI-whatsNew', this, event);"/>
+ <toolbarbutton id="PanelUI-menu-button"
+ class="toolbarbutton-1"
+ delegatesanchor="true"
+ badged="true"
+ consumeanchor="PanelUI-button"
+ data-l10n-id="appmenu-menu-button-closed2"/>
+ </toolbaritem>
+ </toolbar>
+
+ <toolbar id="PersonalToolbar"
+ mode="icons"
+ class="browser-toolbar chromeclass-directories"
+ context="toolbar-context-menu"
+ data-l10n-id="bookmarks-toolbar"
+ data-l10n-attrs="toolbarname"
+ customizable="true">
+ <toolbartabstop skipintoolbarset="true"/>
+
+ <hbox id="personal-toolbar-empty" skipintoolbarset="true" removable="false" hidden="true">
+ <description id="personal-toolbar-empty-description"
+ data-l10n-id="bookmarks-toolbar-empty-message"
+ onclick="BookmarkingUI.openLibraryIfLinkClicked(event);"
+ onkeydown="BookmarkingUI.openLibraryIfLinkClicked(event);">
+ <html:a data-l10n-name="manage-bookmarks" class="text-link" tabindex="0"/>
+ </description>
+ </hbox>
+
+ <toolbaritem id="personal-bookmarks"
+ data-l10n-id="bookmarks-toolbar-placeholder"
+ cui-areatype="toolbar"
+ removable="true">
+ <toolbarbutton id="bookmarks-toolbar-placeholder"
+ class="bookmark-item"
+ data-l10n-id="bookmarks-toolbar-placeholder-button"/>
+ <toolbarbutton id="bookmarks-toolbar-button"
+ class="toolbarbutton-1"
+ delegatesanchor="true"
+ flex="1"
+ data-l10n-id="bookmarks-toolbar-placeholder-button"
+ oncommand="PlacesToolbarHelper.onPlaceholderCommand();"/>
+ <hbox flex="1"
+ id="PlacesToolbar"
+ context="placesContext"
+ onmouseup="BookmarksEventHandler.onMouseUp(event);"
+ onclick="BookmarksEventHandler.onClick(event, this._placesView);"
+ oncommand="BookmarksEventHandler.onCommand(event);"
+ tooltip="bhTooltip"
+ popupsinherittooltip="true">
+ <hbox id="PlacesToolbarDropIndicatorHolder" align="center" collapsed="true">
+ <image id="PlacesToolbarDropIndicator"
+ collapsed="true"/>
+ </hbox>
+ <scrollbox orient="horizontal"
+ id="PlacesToolbarItems"
+ flex="1"/>
+ <toolbarbutton type="menu"
+ id="PlacesChevron"
+ class="toolbarbutton-1"
+ delegatesanchor="true"
+ collapsed="true"
+ data-l10n-id="bookmarks-toolbar-chevron">
+ <menupopup id="PlacesChevronPopup"
+ is="places-popup"
+ placespopup="true"
+ type="arrow"
+ tooltip="bhTooltip" popupsinherittooltip="true"
+ context="placesContext"
+ onpopupshowing="document.getElementById('PlacesToolbar')
+ ._placesView._onChevronPopupShowing(event);"/>
+ </toolbarbutton>
+ </hbox>
+ </toolbaritem>
+ </toolbar>
+
+ <html:template id="tab-notification-deck-template">
+ <html:named-deck id="tab-notification-deck"></html:named-deck>
+ </html:template>
+
+
+ <html:template id="BrowserToolbarPalette">
+ <toolbarbutton id="import-button"
+ class="toolbarbutton-1 chromeclass-toolbar-additional"
+ oncommand="MigrationUtils.showMigrationWizard(window, { entrypoint: MigrationUtils.MIGRATION_ENTRYPOINTS.BOOKMARKS_TOOLBAR });"
+ data-l10n-id="browser-import-button2"/>
+
+ <toolbarbutton id="new-window-button" class="toolbarbutton-1 chromeclass-toolbar-additional"
+ data-l10n-id="appmenuitem-new-window"
+ command="cmd_newNavigator"
+ tooltip="dynamic-shortcut-tooltip"
+ ondrop="newWindowButtonObserver.onDrop(event)"
+ ondragover="newWindowButtonObserver.onDragOver(event)"
+ ondragenter="newWindowButtonObserver.onDragOver(event)"/>
+
+ <toolbarbutton id="fullscreen-button" class="toolbarbutton-1 chromeclass-toolbar-additional"
+ observes="View:FullScreen"
+ type="checkbox"
+ data-l10n-id="appmenuitem-fullscreen"
+ tooltip="dynamic-shortcut-tooltip"/>
+
+ <toolbarbutton id="bookmarks-menu-button"
+ class="toolbarbutton-1 chromeclass-toolbar-additional subviewbutton-nav"
+ delegatesanchor="true"
+ type="menu"
+ data-l10n-id="bookmarks-menu-button"
+ tooltip="dynamic-shortcut-tooltip"
+ ondragenter="PlacesMenuDNDHandler.onDragEnter(event);"
+ ondragover="PlacesMenuDNDHandler.onDragOver(event);"
+ ondragleave="PlacesMenuDNDHandler.onDragLeave(event);"
+ ondrop="PlacesMenuDNDHandler.onDrop(event);"
+ oncommand="BookmarkingUI.onCommand(event);">
+ <menupopup id="BMB_bookmarksPopup"
+ type="arrow"
+ is="places-popup-arrow"
+ class="cui-widget-panel cui-widget-panelview PanelUI-subView"
+ placespopup="true"
+ appendclasstochildren="subviewbutton"
+ context="placesContext"
+ openInTabs="children"
+ side="top"
+ onmouseup="BookmarksEventHandler.onMouseUp(event);"
+ oncommand="BookmarksEventHandler.onCommand(event);"
+ onclick="BookmarksEventHandler.onClick(event, this.parentNode._placesView);"
+ onpopupshowing="BookmarkingUI.onPopupShowing(event);
+ if (!this.parentNode._placesView)
+ new PlacesMenu(event, `place:parent=${PlacesUtils.bookmarks.menuGuid}`);"
+ tooltip="bhTooltip" popupsinherittooltip="true">
+ <menuitem id="BMB_viewBookmarksSidebar"
+ class="subviewbutton"
+ data-l10n-id="bookmarks-tools-sidebar-visibility"
+ data-l10n-args='{ "isVisible": false }'
+ oncommand="SidebarUI.toggle('viewBookmarksSidebar');"
+ key="viewBookmarksSidebarKb"/>
+ <menuitem id="BMB_searchBookmarks"
+ class="subviewbutton"
+ data-l10n-id="bookmarks-search"
+ oncommand="PlacesCommandHook.searchBookmarks();"/>
+ <!-- NB: temporary solution for bug 985024, this should go away soon. -->
+ <menuitem id="BMB_bookmarksShowAllTop"
+ class="subviewbutton"
+ data-l10n-id="bookmarks-manage-bookmarks"
+ command="Browser:ShowAllBookmarks"
+ key="manBookmarkKb"/>
+ <menuseparator/>
+ <menu id="BMB_bookmarksToolbar"
+ class="bookmark-item subviewbutton menu-iconic"
+ data-l10n-id="bookmarks-toolbar-menu"
+ container="true">
+ <menupopup id="BMB_bookmarksToolbarPopup"
+ is="places-popup"
+ placespopup="true"
+ appendclasstochildren="subviewbutton"
+ context="placesContext"
+ onpopupshowing="if (!this.parentNode._placesView)
+ new PlacesMenu(event, `place:parent=${PlacesUtils.bookmarks.toolbarGuid}`);">
+ <menuitem id="BMB_viewBookmarksToolbar"
+ class="subviewbutton"
+ data-l10n-id="bookmarks-tools-toolbar-visibility-menuitem"
+ data-l10n-args='{ "isVisible": false }'
+ oncommand="BookmarkingUI.toggleBookmarksToolbar('bookmarks-widget');"/>
+ <menuseparator/>
+ <!-- Bookmarks toolbar items -->
+ </menupopup>
+ </menu>
+ <menu id="BMB_unsortedBookmarks"
+ class="bookmark-item subviewbutton menu-iconic"
+ data-l10n-id="bookmarks-other-bookmarks-menu"
+ container="true">
+ <menupopup id="BMB_unsortedBookmarksPopup"
+ is="places-popup"
+ placespopup="true"
+ appendclasstochildren="subviewbutton"
+ context="placesContext"
+ onpopupshowing="if (!this.parentNode._placesView)
+ new PlacesMenu(event, `place:parent=${PlacesUtils.bookmarks.unfiledGuid}`);"/>
+ </menu>
+ <menu id="BMB_mobileBookmarks"
+ class="menu-iconic bookmark-item subviewbutton"
+ data-l10n-id="bookmarks-mobile-bookmarks-menu"
+ hidden="true"
+ container="true">
+ <menupopup id="BMB_mobileBookmarksPopup"
+ is="places-popup"
+ placespopup="true"
+ appendclasstochildren="subviewbutton"
+ context="placesContext"
+ onpopupshowing="if (!this.parentNode._placesView)
+ new PlacesMenu(event, `place:parent=${PlacesUtils.bookmarks.mobileGuid}`);"/>
+ </menu>
+
+ <menuseparator/>
+ <!-- Bookmarks menu items will go here -->
+ <menuitem id="BMB_bookmarksShowAll"
+ class="subviewbutton"
+ data-l10n-id="bookmarks-manage-bookmarks"
+ afterplacescontent="true"
+ command="Browser:ShowAllBookmarks"
+ key="manBookmarkKb"/>
+ </menupopup>
+ </toolbarbutton>
+
+ <toolbaritem id="search-container"
+ class="chromeclass-toolbar-additional"
+ data-l10n-id="navbar-search"
+ align="center"
+ persist="width">
+ <toolbartabstop/>
+ <searchbar id="searchbar" flex="1"/>
+ <toolbartabstop/>
+ </toolbaritem>
+
+ <toolbarbutton id="home-button" class="toolbarbutton-1 chromeclass-toolbar-additional"
+ removable="true"
+ data-l10n-id="navbar-home"
+ ondragover="homeButtonObserver.onDragOver(event)"
+ ondragenter="homeButtonObserver.onDragOver(event)"
+ ondrop="homeButtonObserver.onDrop(event)"
+ key="goHome"
+ onclick="BrowserHome(event);"
+ cui-areatype="toolbar"/>
+
+ <toolbarbutton id="library-button" class="toolbarbutton-1 chromeclass-toolbar-additional subviewbutton-nav"
+ removable="true"
+ delegatesanchor="true"
+ onmousedown="PanelUI.showSubView('appMenu-libraryView', this, event);"
+ onkeypress="PanelUI.showSubView('appMenu-libraryView', this, event);"
+ closemenu="none"
+ cui-areatype="toolbar"
+ data-l10n-id="navbar-library"/>
+ </html:template>
+</toolbox>
+</box>
diff --git a/browser/base/content/nonbrowser-mac.js b/browser/base/content/nonbrowser-mac.js
new file mode 100644
index 0000000000..d2dda363b1
--- /dev/null
+++ b/browser/base/content/nonbrowser-mac.js
@@ -0,0 +1,164 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+ * 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/. */
+
+/* eslint-env mozilla/browser-window */
+
+ChromeUtils.defineESModuleGetters(this, {
+ NimbusFeatures: "resource://nimbus/ExperimentAPI.sys.mjs",
+});
+
+let delayedStartupTimeoutId = null;
+
+function OpenBrowserWindowFromDockMenu(options) {
+ let win = OpenBrowserWindow(options);
+ win.addEventListener(
+ "load",
+ function () {
+ let dockSupport = Cc["@mozilla.org/widget/macdocksupport;1"].getService(
+ Ci.nsIMacDockSupport
+ );
+ dockSupport.activateApplication(true);
+ },
+ { once: true }
+ );
+
+ return win;
+}
+
+function nonBrowserWindowStartup() {
+ // Disable inappropriate commands / submenus
+ var disabledItems = [
+ "Browser:SavePage",
+ "Browser:SendLink",
+ "cmd_pageSetup",
+ "cmd_print",
+ "cmd_find",
+ "cmd_findAgain",
+ "viewToolbarsMenu",
+ "viewSidebarMenuMenu",
+ "Browser:Reload",
+ "viewFullZoomMenu",
+ "pageStyleMenu",
+ "repair-text-encoding",
+ "View:PageSource",
+ "View:FullScreen",
+ "enterFullScreenItem",
+ "viewHistorySidebar",
+ "Browser:AddBookmarkAs",
+ "Browser:BookmarkAllTabs",
+ "View:PageInfo",
+ "History:UndoCloseTab",
+ "menu_openFirefoxView",
+ ];
+ var element;
+
+ for (let disabledItem of disabledItems) {
+ element = document.getElementById(disabledItem);
+ if (element) {
+ element.setAttribute("disabled", "true");
+ }
+ }
+
+ // Show menus that are only visible in non-browser windows
+ let shownItems = ["menu_openLocation"];
+ for (let shownItem of shownItems) {
+ element = document.getElementById(shownItem);
+ if (element) {
+ element.removeAttribute("hidden");
+ }
+ }
+
+ if (
+ window.location.href == "chrome://browser/content/hiddenWindowMac.xhtml"
+ ) {
+ // If no windows are active (i.e. we're the hidden window), disable the
+ // close, minimize and zoom menu commands as well.
+ var hiddenWindowDisabledItems = [
+ "cmd_close",
+ "minimizeWindow",
+ "zoomWindow",
+ ];
+ for (let hiddenWindowDisabledItem of hiddenWindowDisabledItems) {
+ element = document.getElementById(hiddenWindowDisabledItem);
+ if (element) {
+ element.setAttribute("disabled", "true");
+ }
+ }
+
+ // Also hide the window-list separator.
+ element = document.getElementById("sep-window-list");
+ element.hidden = true;
+
+ // Setup the dock menu.
+ let dockMenuElement = document.getElementById("menu_mac_dockmenu");
+ if (dockMenuElement != null) {
+ let nativeMenu = Cc[
+ "@mozilla.org/widget/standalonenativemenu;1"
+ ].createInstance(Ci.nsIStandaloneNativeMenu);
+
+ try {
+ nativeMenu.init(dockMenuElement);
+
+ let dockSupport = Cc["@mozilla.org/widget/macdocksupport;1"].getService(
+ Ci.nsIMacDockSupport
+ );
+ dockSupport.dockMenu = nativeMenu;
+ } catch (e) {}
+ }
+
+ // Hide menuitems that don't apply to private contexts.
+ if (PrivateBrowsingUtils.permanentPrivateBrowsing) {
+ document.getElementById("macDockMenuNewWindow").hidden = true;
+ }
+ if (!PrivateBrowsingUtils.enabled) {
+ document.getElementById("macDockMenuNewPrivateWindow").hidden = true;
+ }
+ if (BrowserUIUtils.quitShortcutDisabled) {
+ document.getElementById("key_quitApplication").remove();
+ document.getElementById("menu_FileQuitItem").removeAttribute("key");
+ }
+ }
+
+ delayedStartupTimeoutId = setTimeout(nonBrowserWindowDelayedStartup, 0);
+}
+
+function nonBrowserWindowDelayedStartup() {
+ delayedStartupTimeoutId = null;
+
+ // initialise the offline listener
+ BrowserOffline.init();
+
+ // initialize the private browsing UI
+ gPrivateBrowsingUI.init();
+
+ if (!NimbusFeatures.majorRelease2022.getVariable("firefoxView")) {
+ document.getElementById("menu_openFirefoxView").hidden = true;
+ }
+}
+
+function nonBrowserWindowShutdown() {
+ // If this is the hidden window being closed, release our reference to
+ // the dock menu element to prevent leaks on shutdown
+ if (
+ window.location.href == "chrome://browser/content/hiddenWindowMac.xhtml"
+ ) {
+ let dockSupport = Cc["@mozilla.org/widget/macdocksupport;1"].getService(
+ Ci.nsIMacDockSupport
+ );
+ dockSupport.dockMenu = null;
+ }
+
+ // If nonBrowserWindowDelayedStartup hasn't run yet, we have no work to do -
+ // just cancel the pending timeout and return;
+ if (delayedStartupTimeoutId) {
+ clearTimeout(delayedStartupTimeoutId);
+ return;
+ }
+
+ BrowserOffline.uninit();
+}
+
+addEventListener("load", nonBrowserWindowStartup, false);
+addEventListener("unload", nonBrowserWindowShutdown, false);
diff --git a/browser/base/content/nsContextMenu.js b/browser/base/content/nsContextMenu.js
new file mode 100644
index 0000000000..2038ae0854
--- /dev/null
+++ b/browser/base/content/nsContextMenu.js
@@ -0,0 +1,2586 @@
+/* -*- tab-width: 2; indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ts=2 sw=2 sts=2 et tw=80: */
+/* 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/. */
+
+const PASSWORD_FIELDNAME_HINTS = ["current-password", "new-password"];
+const USERNAME_FIELDNAME_HINT = "username";
+
+function openContextMenu(aMessage, aBrowser, aActor) {
+ if (BrowserHandler.kiosk) {
+ // Don't display context menus in kiosk mode
+ return;
+ }
+ let data = aMessage.data;
+ let browser = aBrowser;
+ let actor = aActor;
+ let wgp = actor.manager;
+
+ if (!wgp.isCurrentGlobal) {
+ // Don't display context menus for unloaded documents
+ return;
+ }
+
+ // NOTE: We don't use `wgp.documentURI` here as we want to use the failed
+ // channel URI in the case we have loaded an error page.
+ let documentURIObject = wgp.browsingContext.currentURI;
+
+ let frameReferrerInfo = data.frameReferrerInfo;
+ if (frameReferrerInfo) {
+ frameReferrerInfo = E10SUtils.deserializeReferrerInfo(frameReferrerInfo);
+ }
+
+ let linkReferrerInfo = data.linkReferrerInfo;
+ if (linkReferrerInfo) {
+ linkReferrerInfo = E10SUtils.deserializeReferrerInfo(linkReferrerInfo);
+ }
+
+ let frameID = nsContextMenu.WebNavigationFrames.getFrameId(
+ wgp.browsingContext
+ );
+
+ nsContextMenu.contentData = {
+ context: data.context,
+ browser,
+ actor,
+ editFlags: data.editFlags,
+ spellInfo: data.spellInfo,
+ principal: wgp.documentPrincipal,
+ storagePrincipal: wgp.documentStoragePrincipal,
+ documentURIObject,
+ docLocation: documentURIObject.spec,
+ charSet: data.charSet,
+ referrerInfo: E10SUtils.deserializeReferrerInfo(data.referrerInfo),
+ frameReferrerInfo,
+ linkReferrerInfo,
+ contentType: data.contentType,
+ contentDisposition: data.contentDisposition,
+ frameID,
+ frameOuterWindowID: frameID,
+ frameBrowsingContext: wgp.browsingContext,
+ selectionInfo: data.selectionInfo,
+ disableSetDesktopBackground: data.disableSetDesktopBackground,
+ showRelay: data.showRelay,
+ loginFillInfo: data.loginFillInfo,
+ userContextId: wgp.browsingContext.originAttributes.userContextId,
+ webExtContextData: data.webExtContextData,
+ cookieJarSettings: wgp.cookieJarSettings,
+ };
+
+ let popup = browser.ownerDocument.getElementById("contentAreaContextMenu");
+ let context = nsContextMenu.contentData.context;
+
+ // Fill in some values in the context from the WindowGlobalParent actor.
+ context.principal = wgp.documentPrincipal;
+ context.storagePrincipal = wgp.documentStoragePrincipal;
+ context.frameID = frameID;
+ context.frameOuterWindowID = wgp.outerWindowId;
+ context.frameBrowsingContextID = wgp.browsingContext.id;
+
+ // We don't have access to the original event here, as that happened in
+ // another process. Therefore we synthesize a new MouseEvent to propagate the
+ // inputSource to the subsequently triggered popupshowing event.
+ let newEvent = document.createEvent("MouseEvent");
+ let screenX = context.screenXDevPx / window.devicePixelRatio;
+ let screenY = context.screenYDevPx / window.devicePixelRatio;
+ newEvent.initNSMouseEvent(
+ "contextmenu",
+ true,
+ true,
+ null,
+ 0,
+ screenX,
+ screenY,
+ 0,
+ 0,
+ false,
+ false,
+ false,
+ false,
+ 2,
+ null,
+ 0,
+ context.mozInputSource
+ );
+ popup.openPopupAtScreen(newEvent.screenX, newEvent.screenY, true, newEvent);
+}
+
+class nsContextMenu {
+ constructor(aXulMenu, aIsShift) {
+ // Get contextual info.
+ this.setContext();
+
+ if (!this.shouldDisplay) {
+ return;
+ }
+
+ this.isContentSelected = !this.selectionInfo.docSelectionIsCollapsed;
+ if (!aIsShift) {
+ let tab =
+ gBrowser && gBrowser.getTabForBrowser
+ ? gBrowser.getTabForBrowser(this.browser)
+ : undefined;
+
+ let subject = {
+ menu: aXulMenu,
+ tab,
+ timeStamp: this.timeStamp,
+ isContentSelected: this.isContentSelected,
+ inFrame: this.inFrame,
+ isTextSelected: this.isTextSelected,
+ onTextInput: this.onTextInput,
+ onLink: this.onLink,
+ onImage: this.onImage,
+ onVideo: this.onVideo,
+ onAudio: this.onAudio,
+ onCanvas: this.onCanvas,
+ onEditable: this.onEditable,
+ onSpellcheckable: this.onSpellcheckable,
+ onPassword: this.onPassword,
+ passwordRevealed: this.passwordRevealed,
+ srcUrl: this.originalMediaURL,
+ frameUrl: this.contentData ? this.contentData.docLocation : undefined,
+ pageUrl: this.browser ? this.browser.currentURI.spec : undefined,
+ linkText: this.linkTextStr,
+ linkUrl: this.linkURL,
+ linkURI: this.linkURI,
+ selectionText: this.isTextSelected
+ ? this.selectionInfo.fullText
+ : undefined,
+ frameId: this.frameID,
+ webExtBrowserType: this.webExtBrowserType,
+ webExtContextData: this.contentData
+ ? this.contentData.webExtContextData
+ : undefined,
+ };
+ subject.wrappedJSObject = subject;
+ Services.obs.notifyObservers(subject, "on-build-contextmenu");
+ }
+
+ this.viewFrameSourceElement = document.getElementById(
+ "context-viewframesource"
+ );
+ this.ellipsis = "\u2026";
+ try {
+ this.ellipsis = Services.prefs.getComplexValue(
+ "intl.ellipsis",
+ Ci.nsIPrefLocalizedString
+ ).data;
+ } catch (e) {}
+
+ // Reset after "on-build-contextmenu" notification in case selection was
+ // changed during the notification.
+ this.isContentSelected = !this.selectionInfo.docSelectionIsCollapsed;
+ this.onPlainTextLink = false;
+
+ // Initialize (disable/remove) menu items.
+ this.initItems(aXulMenu);
+ }
+
+ setContext() {
+ let context = Object.create(null);
+
+ if (nsContextMenu.contentData) {
+ this.contentData = nsContextMenu.contentData;
+ context = this.contentData.context;
+ nsContextMenu.contentData = null;
+ }
+
+ this.shouldDisplay = context.shouldDisplay;
+ this.timeStamp = context.timeStamp;
+
+ // Assign what's _possibly_ needed from `context` sent by ContextMenuChild.sys.mjs
+ // Keep this consistent with the similar code in ContextMenu's _setContext
+ this.imageDescURL = context.imageDescURL;
+ this.imageInfo = context.imageInfo;
+ this.mediaURL = context.mediaURL || context.bgImageURL;
+ this.originalMediaURL = context.originalMediaURL || this.mediaURL;
+ this.webExtBrowserType = context.webExtBrowserType;
+
+ this.canSpellCheck = context.canSpellCheck;
+ this.hasBGImage = context.hasBGImage;
+ this.hasMultipleBGImages = context.hasMultipleBGImages;
+ this.isDesignMode = context.isDesignMode;
+ this.inFrame = context.inFrame;
+ this.inPDFViewer = context.inPDFViewer;
+ this.inPDFEditor = context.inPDFEditor;
+ this.inSrcdocFrame = context.inSrcdocFrame;
+ this.inSyntheticDoc = context.inSyntheticDoc;
+ this.inTabBrowser = context.inTabBrowser;
+ this.inWebExtBrowser = context.inWebExtBrowser;
+
+ this.link = context.link;
+ this.linkDownload = context.linkDownload;
+ this.linkProtocol = context.linkProtocol;
+ this.linkTextStr = context.linkTextStr;
+ this.linkURL = context.linkURL;
+ this.linkURI = this.getLinkURI(); // can't send; regenerate
+
+ this.onAudio = context.onAudio;
+ this.onCanvas = context.onCanvas;
+ this.onCompletedImage = context.onCompletedImage;
+ this.onDRMMedia = context.onDRMMedia;
+ this.onPiPVideo = context.onPiPVideo;
+ this.onEditable = context.onEditable;
+ this.onImage = context.onImage;
+ this.onKeywordField = context.onKeywordField;
+ this.onLink = context.onLink;
+ this.onLoadedImage = context.onLoadedImage;
+ this.onMailtoLink = context.onMailtoLink;
+ this.onTelLink = context.onTelLink;
+ this.onMozExtLink = context.onMozExtLink;
+ this.onNumeric = context.onNumeric;
+ this.onPassword = context.onPassword;
+ this.passwordRevealed = context.passwordRevealed;
+ this.onSaveableLink = context.onSaveableLink;
+ this.onSpellcheckable = context.onSpellcheckable;
+ this.onTextInput = context.onTextInput;
+ this.onVideo = context.onVideo;
+
+ this.pdfEditorStates = context.pdfEditorStates;
+
+ this.target = context.target;
+ this.targetIdentifier = context.targetIdentifier;
+
+ this.principal = context.principal;
+ this.storagePrincipal = context.storagePrincipal;
+ this.frameID = context.frameID;
+ this.frameOuterWindowID = context.frameOuterWindowID;
+ this.frameBrowsingContext = BrowsingContext.get(
+ context.frameBrowsingContextID
+ );
+
+ this.inSyntheticDoc = context.inSyntheticDoc;
+ this.inAboutDevtoolsToolbox = context.inAboutDevtoolsToolbox;
+
+ this.isSponsoredLink = context.isSponsoredLink;
+
+ // Everything after this isn't sent directly from ContextMenu
+ if (this.target) {
+ this.ownerDoc = this.target.ownerDocument;
+ }
+
+ this.csp = E10SUtils.deserializeCSP(context.csp);
+
+ if (this.contentData) {
+ this.browser = this.contentData.browser;
+ this.selectionInfo = this.contentData.selectionInfo;
+ this.actor = this.contentData.actor;
+ } else {
+ const { SelectionUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/SelectionUtils.sys.mjs"
+ );
+
+ this.browser = this.ownerDoc.defaultView.docShell.chromeEventHandler;
+ this.selectionInfo = SelectionUtils.getSelectionDetails(window);
+ this.actor =
+ this.browser.browsingContext.currentWindowGlobal.getActor(
+ "ContextMenu"
+ );
+ }
+
+ this.remoteType = this.actor?.domProcess?.remoteType;
+
+ const { gBrowser } = this.browser.ownerGlobal;
+
+ this.textSelected = this.selectionInfo.text;
+ this.isTextSelected = !!this.textSelected.length;
+ this.webExtBrowserType = this.browser.getAttribute(
+ "webextension-view-type"
+ );
+ this.inWebExtBrowser = !!this.webExtBrowserType;
+ this.inTabBrowser =
+ gBrowser && gBrowser.getTabForBrowser
+ ? !!gBrowser.getTabForBrowser(this.browser)
+ : false;
+
+ if (context.shouldInitInlineSpellCheckerUINoChildren) {
+ InlineSpellCheckerUI.initFromRemote(
+ this.contentData.spellInfo,
+ this.actor.manager
+ );
+ }
+
+ if (this.contentData.spellInfo) {
+ this.spellSuggestions = this.contentData.spellInfo.spellSuggestions;
+ }
+
+ if (context.shouldInitInlineSpellCheckerUIWithChildren) {
+ InlineSpellCheckerUI.initFromRemote(
+ this.contentData.spellInfo,
+ this.actor.manager
+ );
+ let canSpell = InlineSpellCheckerUI.canSpellCheck && this.canSpellCheck;
+ this.showItem("spell-check-enabled", canSpell);
+ }
+ } // setContext
+
+ hiding(aXulMenu) {
+ if (this.actor) {
+ this.actor.hiding();
+ }
+
+ aXulMenu.showHideSeparators = null;
+
+ this.contentData = null;
+ InlineSpellCheckerUI.clearSuggestionsFromMenu();
+ InlineSpellCheckerUI.clearDictionaryListFromMenu();
+ InlineSpellCheckerUI.uninit();
+ if (
+ Cu.isModuleLoaded("resource://gre/modules/LoginManagerContextMenu.jsm")
+ ) {
+ nsContextMenu.LoginManagerContextMenu.clearLoginsFromMenu(document);
+ }
+
+ // This handler self-deletes, only run it if it is still there:
+ if (this._onPopupHiding) {
+ this._onPopupHiding();
+ }
+ }
+
+ initItems(aXulMenu) {
+ this.initOpenItems();
+ this.initNavigationItems();
+ this.initViewItems();
+ this.initImageItems();
+ this.initMiscItems();
+ this.initPocketItems();
+ this.initSpellingItems();
+ this.initSaveItems();
+ this.initSyncItems();
+ this.initClipboardItems();
+ this.initMediaPlayerItems();
+ this.initLeaveDOMFullScreenItems();
+ this.initPasswordManagerItems();
+ this.initViewSourceItems();
+ this.initScreenshotItem();
+ this.initPasswordControlItems();
+ this.initPDFItems();
+
+ this.showHideSeparators(aXulMenu);
+ if (!aXulMenu.showHideSeparators) {
+ // Set the showHideSeparators function on the menu itself so that
+ // the extension code (ext-menus.js) can call it after modifying
+ // the menus.
+ aXulMenu.showHideSeparators = () => {
+ this.showHideSeparators(aXulMenu);
+ };
+ }
+ }
+
+ initPDFItems() {
+ for (const id of [
+ "context-pdfjs-undo",
+ "context-pdfjs-redo",
+ "context-sep-pdfjs-redo",
+ "context-pdfjs-cut",
+ "context-pdfjs-copy",
+ "context-pdfjs-paste",
+ "context-pdfjs-delete",
+ "context-pdfjs-selectall",
+ "context-sep-pdfjs-selectall",
+ ]) {
+ this.showItem(id, this.inPDFEditor);
+ }
+
+ if (!this.inPDFEditor) {
+ return;
+ }
+
+ const {
+ isEmpty,
+ hasSomethingToUndo,
+ hasSomethingToRedo,
+ hasSelectedEditor,
+ } = this.pdfEditorStates;
+
+ const hasEmptyClipboard = !Services.clipboard.hasDataMatchingFlavors(
+ ["application/pdfjs"],
+ Ci.nsIClipboard.kGlobalClipboard
+ );
+
+ this.setItemAttr("context-pdfjs-undo", "disabled", !hasSomethingToUndo);
+ this.setItemAttr("context-pdfjs-redo", "disabled", !hasSomethingToRedo);
+ this.setItemAttr(
+ "context-sep-pdfjs-redo",
+ "disabled",
+ !hasSomethingToUndo && !hasSomethingToRedo
+ );
+ this.setItemAttr(
+ "context-pdfjs-cut",
+ "disabled",
+ isEmpty || !hasSelectedEditor
+ );
+ this.setItemAttr(
+ "context-pdfjs-copy",
+ "disabled",
+ isEmpty || !hasSelectedEditor
+ );
+ this.setItemAttr("context-pdfjs-paste", "disabled", hasEmptyClipboard);
+ this.setItemAttr(
+ "context-pdfjs-delete",
+ "disabled",
+ isEmpty || !hasSelectedEditor
+ );
+ this.setItemAttr("context-pdfjs-selectall", "disabled", isEmpty);
+ this.setItemAttr("context-sep-pdfjs-selectall", "disabled", isEmpty);
+ }
+
+ initOpenItems() {
+ var isMailtoInternal = false;
+ if (this.onMailtoLink) {
+ var mailtoHandler = Cc[
+ "@mozilla.org/uriloader/external-protocol-service;1"
+ ]
+ .getService(Ci.nsIExternalProtocolService)
+ .getProtocolHandlerInfo("mailto");
+ isMailtoInternal =
+ !mailtoHandler.alwaysAskBeforeHandling &&
+ mailtoHandler.preferredAction == Ci.nsIHandlerInfo.useHelperApp &&
+ mailtoHandler.preferredApplicationHandler instanceof
+ Ci.nsIWebHandlerApp;
+ }
+
+ if (
+ this.isTextSelected &&
+ !this.onLink &&
+ this.selectionInfo &&
+ this.selectionInfo.linkURL
+ ) {
+ this.linkURL = this.selectionInfo.linkURL;
+ this.linkURI = this.getLinkURI();
+
+ this.linkTextStr = this.selectionInfo.linkText;
+ this.onPlainTextLink = true;
+ }
+
+ var inContainer = false;
+ if (this.contentData.userContextId) {
+ inContainer = true;
+ var item = document.getElementById("context-openlinkincontainertab");
+
+ item.setAttribute("data-usercontextid", this.contentData.userContextId);
+
+ var label = ContextualIdentityService.getUserContextLabel(
+ this.contentData.userContextId
+ );
+
+ document.l10n.setAttributes(
+ item,
+ "main-context-menu-open-link-in-container-tab",
+ {
+ containerName: label,
+ }
+ );
+ }
+
+ var shouldShow =
+ this.onSaveableLink || isMailtoInternal || this.onPlainTextLink;
+ var isWindowPrivate = PrivateBrowsingUtils.isWindowPrivate(window);
+ let showContainers =
+ Services.prefs.getBoolPref("privacy.userContext.enabled") &&
+ ContextualIdentityService.getPublicIdentities().length;
+ this.showItem("context-openlink", shouldShow && !isWindowPrivate);
+ this.showItem(
+ "context-openlinkprivate",
+ shouldShow && PrivateBrowsingUtils.enabled
+ );
+ this.showItem("context-openlinkintab", shouldShow && !inContainer);
+ this.showItem("context-openlinkincontainertab", shouldShow && inContainer);
+ this.showItem(
+ "context-openlinkinusercontext-menu",
+ shouldShow && !isWindowPrivate && showContainers
+ );
+ this.showItem("context-openlinkincurrent", this.onPlainTextLink);
+ }
+
+ initNavigationItems() {
+ var shouldShow =
+ !(
+ this.isContentSelected ||
+ this.onLink ||
+ this.onImage ||
+ this.onCanvas ||
+ this.onVideo ||
+ this.onAudio ||
+ this.onTextInput
+ ) && this.inTabBrowser;
+ if (AppConstants.platform == "macosx") {
+ for (let id of [
+ "context-back",
+ "context-forward",
+ "context-reload",
+ "context-stop",
+ "context-sep-navigation",
+ ]) {
+ this.showItem(id, shouldShow);
+ }
+ } else {
+ this.showItem("context-navigation", shouldShow);
+ }
+
+ let stopped =
+ XULBrowserWindow.stopCommand.getAttribute("disabled") == "true";
+
+ let stopReloadItem = "";
+ if (shouldShow) {
+ stopReloadItem = stopped ? "reload" : "stop";
+ }
+
+ this.showItem("context-reload", stopReloadItem == "reload");
+ this.showItem("context-stop", stopReloadItem == "stop");
+
+ function initBackForwardMenuItemTooltip(menuItemId, l10nId, shortcutId) {
+ // On macOS regular menuitems are used and the shortcut isn't added
+ if (AppConstants.platform == "macosx") {
+ return;
+ }
+
+ let shortcut = document.getElementById(shortcutId);
+ if (shortcut) {
+ shortcut = ShortcutUtils.prettifyShortcut(shortcut);
+ } else {
+ // Sidebar doesn't have navigation buttons or shortcuts, but we still
+ // want to format the menu item tooltip to remove "$shortcut" string.
+ shortcut = "";
+ }
+ let menuItem = document.getElementById(menuItemId);
+ document.l10n.setAttributes(menuItem, l10nId, { shortcut });
+ }
+
+ initBackForwardMenuItemTooltip(
+ "context-back",
+ "main-context-menu-back-2",
+ "goBackKb"
+ );
+
+ initBackForwardMenuItemTooltip(
+ "context-forward",
+ "main-context-menu-forward-2",
+ "goForwardKb"
+ );
+ }
+
+ initLeaveDOMFullScreenItems() {
+ // only show the option if the user is in DOM fullscreen
+ var shouldShow = this.target.ownerDocument.fullscreen;
+ this.showItem("context-leave-dom-fullscreen", shouldShow);
+ }
+
+ initSaveItems() {
+ var shouldShow = !(
+ this.onTextInput ||
+ this.onLink ||
+ this.isContentSelected ||
+ this.onImage ||
+ this.onCanvas ||
+ this.onVideo ||
+ this.onAudio
+ );
+ this.showItem("context-savepage", shouldShow);
+
+ // Save link depends on whether we're in a link, or selected text matches valid URL pattern.
+ this.showItem(
+ "context-savelink",
+ this.onSaveableLink || this.onPlainTextLink
+ );
+ if (
+ (this.onSaveableLink || this.onPlainTextLink) &&
+ Services.policies.status === Services.policies.ACTIVE
+ ) {
+ this.setItemAttr(
+ "context-savelink",
+ "disabled",
+ !WebsiteFilter.isAllowed(this.linkURL)
+ );
+ }
+
+ // Save video and audio don't rely on whether it has loaded or not.
+ this.showItem("context-savevideo", this.onVideo);
+ this.showItem("context-saveaudio", this.onAudio);
+ this.showItem("context-video-saveimage", this.onVideo);
+ this.setItemAttr("context-savevideo", "disabled", !this.mediaURL);
+ this.setItemAttr("context-saveaudio", "disabled", !this.mediaURL);
+ this.showItem("context-sendvideo", this.onVideo);
+ this.showItem("context-sendaudio", this.onAudio);
+ let mediaIsBlob = this.mediaURL.startsWith("blob:");
+ this.setItemAttr(
+ "context-sendvideo",
+ "disabled",
+ !this.mediaURL || mediaIsBlob
+ );
+ this.setItemAttr(
+ "context-sendaudio",
+ "disabled",
+ !this.mediaURL || mediaIsBlob
+ );
+ }
+
+ initImageItems() {
+ // Reload image depends on an image that's not fully loaded
+ this.showItem(
+ "context-reloadimage",
+ this.onImage && !this.onCompletedImage
+ );
+
+ // View image depends on having an image that's not standalone
+ // (or is in a frame), or a canvas. If this isn't an image, check
+ // if there is a background image.
+ let showViewImage =
+ ((this.onImage && (!this.inSyntheticDoc || this.inFrame)) ||
+ this.onCanvas) &&
+ !this.inPDFViewer;
+ let showBGImage =
+ this.hasBGImage &&
+ !this.hasMultipleBGImages &&
+ !this.inSyntheticDoc &&
+ !this.inPDFViewer &&
+ !this.isContentSelected &&
+ !this.onImage &&
+ !this.onCanvas &&
+ !this.onVideo &&
+ !this.onAudio &&
+ !this.onLink &&
+ !this.onTextInput;
+ this.showItem("context-viewimage", showViewImage || showBGImage);
+
+ // Save image depends on having loaded its content.
+ this.showItem(
+ "context-saveimage",
+ (this.onLoadedImage || this.onCanvas) && !this.inPDFEditor
+ );
+
+ // Copy image contents depends on whether we're on an image.
+ // Note: the element doesn't exist on all platforms, but showItem() takes
+ // care of that by itself.
+ this.showItem("context-copyimage-contents", this.onImage);
+
+ // Copy image location depends on whether we're on an image.
+ this.showItem("context-copyimage", this.onImage || showBGImage);
+
+ // Performing text recognition only works on images, and if the feature is enabled.
+ this.showItem(
+ "context-imagetext",
+ this.onImage &&
+ Services.appinfo.isTextRecognitionSupported &&
+ TEXT_RECOGNITION_ENABLED
+ );
+
+ // Send media URL (but not for canvas, since it's a big data: URL)
+ this.showItem("context-sendimage", this.onImage || showBGImage);
+
+ // View Image Info defaults to false, user can enable
+ var showViewImageInfo =
+ this.onImage &&
+ Services.prefs.getBoolPref("browser.menu.showViewImageInfo", false);
+
+ this.showItem("context-viewimageinfo", showViewImageInfo);
+ // The image info popup is broken for WebExtension popups, since the browser
+ // is destroyed when the popup is closed.
+ this.setItemAttr(
+ "context-viewimageinfo",
+ "disabled",
+ this.webExtBrowserType === "popup"
+ );
+ // Open the link to more details about the image. Does not apply to
+ // background images.
+ this.showItem(
+ "context-viewimagedesc",
+ this.onImage && this.imageDescURL !== ""
+ );
+
+ // Set as Desktop background depends on whether an image was clicked on,
+ // and only works if we have a shell service.
+ var haveSetDesktopBackground = false;
+
+ if (
+ AppConstants.HAVE_SHELL_SERVICE &&
+ Services.policies.isAllowed("setDesktopBackground")
+ ) {
+ // Only enable Set as Desktop Background if we can get the shell service.
+ var shell = getShellService();
+ if (shell) {
+ haveSetDesktopBackground = shell.canSetDesktopBackground;
+ }
+ }
+
+ this.showItem(
+ "context-setDesktopBackground",
+ haveSetDesktopBackground && this.onLoadedImage
+ );
+
+ if (haveSetDesktopBackground && this.onLoadedImage) {
+ document.getElementById("context-setDesktopBackground").disabled =
+ this.contentData.disableSetDesktopBackground;
+ }
+ }
+
+ initViewItems() {
+ // View source is always OK, unless in directory listing.
+ this.showItem(
+ "context-viewpartialsource-selection",
+ !this.inAboutDevtoolsToolbox &&
+ this.isContentSelected &&
+ this.selectionInfo.isDocumentLevelSelection
+ );
+
+ this.showItem(
+ "context-print-selection",
+ !this.inAboutDevtoolsToolbox &&
+ this.isContentSelected &&
+ this.selectionInfo.isDocumentLevelSelection
+ );
+
+ var shouldShow = !(
+ this.isContentSelected ||
+ this.onImage ||
+ this.onCanvas ||
+ this.onVideo ||
+ this.onAudio ||
+ this.onLink ||
+ this.onTextInput
+ );
+
+ var showInspect =
+ this.inTabBrowser &&
+ !this.inAboutDevtoolsToolbox &&
+ Services.prefs.getBoolPref("devtools.inspector.enabled", true) &&
+ !Services.prefs.getBoolPref("devtools.policy.disabled", false);
+
+ var showInspectA11Y =
+ showInspect &&
+ Services.prefs.getBoolPref("devtools.accessibility.enabled", false) &&
+ Services.prefs.getBoolPref("devtools.enabled", true) &&
+ (Services.prefs.getBoolPref("devtools.everOpened", false) ||
+ // Note: this is a legacy usecase, we will remove it in bug 1695257,
+ // once existing users have had time to set devtools.everOpened
+ // through normal use, and we've passed an ESR cycle (91).
+ nsContextMenu.DevToolsShim.isDevToolsUser());
+
+ this.showItem("context-viewsource", shouldShow);
+ this.showItem("context-inspect", showInspect);
+
+ this.showItem("context-inspect-a11y", showInspectA11Y);
+
+ // View video depends on not having a standalone video.
+ this.showItem(
+ "context-viewvideo",
+ this.onVideo && (!this.inSyntheticDoc || this.inFrame)
+ );
+ this.setItemAttr("context-viewvideo", "disabled", !this.mediaURL);
+ }
+
+ initMiscItems() {
+ // Use "Bookmark Link…" if on a link.
+ let bookmarkPage = document.getElementById("context-bookmarkpage");
+ this.showItem(
+ bookmarkPage,
+ !(
+ this.isContentSelected ||
+ this.onTextInput ||
+ this.onLink ||
+ this.onImage ||
+ this.onVideo ||
+ this.onAudio ||
+ this.onCanvas ||
+ this.inWebExtBrowser
+ )
+ );
+
+ this.showItem(
+ "context-bookmarklink",
+ (this.onLink &&
+ !this.onMailtoLink &&
+ !this.onTelLink &&
+ !this.onMozExtLink) ||
+ this.onPlainTextLink
+ );
+ this.showItem("context-keywordfield", this.shouldShowAddKeyword());
+ this.showItem("frame", this.inFrame);
+
+ if (this.inFrame) {
+ // To make it easier to debug the browser running with out-of-process iframes, we
+ // display the process PID of the iframe in the context menu for the subframe.
+ let frameOsPid =
+ this.actor.manager.browsingContext.currentWindowGlobal.osPid;
+ this.setItemAttr("context-frameOsPid", "label", "PID: " + frameOsPid);
+
+ // We need to check if "Take Screenshot" should be displayed in the "This Frame"
+ // context menu
+ let shouldShowTakeScreenshotFrame = this.shouldShowTakeScreenshot();
+ this.showItem(
+ "context-take-frame-screenshot",
+ shouldShowTakeScreenshotFrame
+ );
+ this.showItem(
+ "context-sep-frame-screenshot",
+ shouldShowTakeScreenshotFrame
+ );
+ }
+
+ this.showAndFormatSearchContextItem();
+
+ // srcdoc cannot be opened separately due to concerns about web
+ // content with about:srcdoc in location bar masquerading as trusted
+ // chrome/addon content.
+ // No need to also test for this.inFrame as this is checked in the parent
+ // submenu.
+ this.showItem("context-showonlythisframe", !this.inSrcdocFrame);
+ this.showItem("context-openframeintab", !this.inSrcdocFrame);
+ this.showItem("context-openframe", !this.inSrcdocFrame);
+ this.showItem("context-bookmarkframe", !this.inSrcdocFrame);
+
+ // Hide menu entries for images, show otherwise
+ if (this.inFrame) {
+ this.viewFrameSourceElement.hidden = !BrowserUtils.mimeTypeIsTextBased(
+ this.target.ownerDocument.contentType
+ );
+ }
+
+ // BiDi UI
+ this.showItem(
+ "context-bidi-text-direction-toggle",
+ this.onTextInput && !this.onNumeric && top.gBidiUI
+ );
+ this.showItem(
+ "context-bidi-page-direction-toggle",
+ !this.onTextInput && top.gBidiUI
+ );
+ }
+
+ initPocketItems() {
+ const pocketEnabled = Services.prefs.getBoolPref(
+ "extensions.pocket.enabled"
+ );
+ let showSaveCurrentPageToPocket = false;
+ let showSaveLinkToPocket = false;
+
+ // We can skip all this is Pocket is not enabled.
+ if (pocketEnabled) {
+ let targetURL, targetURI;
+ // If the context menu is opened over a link, we target the link,
+ // if not, we target the page.
+ if (this.onLink) {
+ targetURL = this.linkURL;
+ // linkURI may be null if the URL is invalid.
+ targetURI = this.linkURI;
+ } else {
+ targetURL = this.browser?.currentURI?.spec;
+ targetURI = Services.io.newURI(targetURL);
+ }
+
+ const canPocket =
+ targetURI?.schemeIs("http") ||
+ targetURI?.schemeIs("https") ||
+ (targetURI?.schemeIs("about") && ReaderMode?.getOriginalUrl(targetURL));
+
+ // If the target is valid, decide which menu item to enable.
+ if (canPocket) {
+ showSaveLinkToPocket = this.onLink;
+ showSaveCurrentPageToPocket = !(
+ this.onTextInput ||
+ this.onLink ||
+ this.isContentSelected ||
+ this.onImage ||
+ this.onCanvas ||
+ this.onVideo ||
+ this.onAudio
+ );
+ }
+ }
+
+ this.showItem("context-pocket", showSaveCurrentPageToPocket);
+ this.showItem("context-savelinktopocket", showSaveLinkToPocket);
+ }
+
+ initSpellingItems() {
+ var canSpell =
+ InlineSpellCheckerUI.canSpellCheck &&
+ !InlineSpellCheckerUI.initialSpellCheckPending &&
+ this.canSpellCheck;
+ let showDictionaries = canSpell && InlineSpellCheckerUI.enabled;
+ var onMisspelling = InlineSpellCheckerUI.overMisspelling;
+ var showUndo = canSpell && InlineSpellCheckerUI.canUndo();
+ this.showItem("spell-check-enabled", canSpell);
+ document
+ .getElementById("spell-check-enabled")
+ .setAttribute("checked", canSpell && InlineSpellCheckerUI.enabled);
+
+ this.showItem("spell-add-to-dictionary", onMisspelling);
+ this.showItem("spell-undo-add-to-dictionary", showUndo);
+
+ // suggestion list
+ if (onMisspelling) {
+ var suggestionsSeparator = document.getElementById(
+ "spell-add-to-dictionary"
+ );
+ var numsug = InlineSpellCheckerUI.addSuggestionsToMenu(
+ suggestionsSeparator.parentNode,
+ suggestionsSeparator,
+ this.spellSuggestions
+ );
+ this.showItem("spell-no-suggestions", numsug == 0);
+ } else {
+ this.showItem("spell-no-suggestions", false);
+ }
+
+ // dictionary list
+ this.showItem("spell-dictionaries", showDictionaries);
+ if (canSpell) {
+ var dictMenu = document.getElementById("spell-dictionaries-menu");
+ var dictSep = document.getElementById("spell-language-separator");
+ InlineSpellCheckerUI.addDictionaryListToMenu(dictMenu, dictSep);
+ this.showItem("spell-add-dictionaries-main", false);
+ } else if (this.onSpellcheckable) {
+ // when there is no spellchecker but we might be able to spellcheck
+ // add the add to dictionaries item. This will ensure that people
+ // with no dictionaries will be able to download them
+ this.showItem("spell-add-dictionaries-main", showDictionaries);
+ } else {
+ this.showItem("spell-add-dictionaries-main", false);
+ }
+ }
+
+ initClipboardItems() {
+ // Copy depends on whether there is selected text.
+ // Enabling this context menu item is now done through the global
+ // command updating system
+ // this.setItemAttr( "context-copy", "disabled", !this.isTextSelected() );
+ goUpdateGlobalEditMenuItems();
+
+ this.showItem("context-undo", this.onTextInput);
+ this.showItem("context-redo", this.onTextInput);
+ this.showItem("context-cut", this.onTextInput);
+ this.showItem("context-copy", this.isContentSelected || this.onTextInput);
+ this.showItem("context-paste", this.onTextInput);
+ this.showItem("context-paste-no-formatting", this.isDesignMode);
+ this.showItem("context-delete", this.onTextInput);
+ this.showItem(
+ "context-selectall",
+ !(
+ this.onLink ||
+ this.onImage ||
+ this.onVideo ||
+ this.onAudio ||
+ this.inSyntheticDoc ||
+ this.inPDFEditor
+ ) || this.isDesignMode
+ );
+
+ // XXX dr
+ // ------
+ // nsDocumentViewer.cpp has code to determine whether we're
+ // on a link or an image. we really ought to be using that...
+
+ // Copy email link depends on whether we're on an email link.
+ this.showItem("context-copyemail", this.onMailtoLink);
+
+ // Copy phone link depends on whether we're on a phone link.
+ this.showItem("context-copyphone", this.onTelLink);
+
+ // Copy link location depends on whether we're on a non-mailto link.
+ this.showItem(
+ "context-copylink",
+ this.onLink && !this.onMailtoLink && !this.onTelLink
+ );
+
+ // Showing "Copy Clean link" depends on whether the strip-on-share feature is enabled
+ // and whether we can strip anything.
+ this.showItem(
+ "context-stripOnShareLink",
+ STRIP_ON_SHARE_ENABLED &&
+ this.onLink &&
+ !this.onMailtoLink &&
+ !this.onTelLink &&
+ !this.onMozExtLink &&
+ this.getStrippedLink()
+ );
+
+ let copyLinkSeparator = document.getElementById("context-sep-copylink");
+ // Show "Copy Link", "Copy" and "Copy Clean Link" with no divider, and "copy link" and "Send link to Device" with no divider between.
+ // Other cases will show a divider.
+ copyLinkSeparator.toggleAttribute(
+ "ensureHidden",
+ this.onLink &&
+ !this.onMailtoLink &&
+ !this.onTelLink &&
+ !this.onImage &&
+ this.syncItemsShown
+ );
+
+ this.showItem("context-copyvideourl", this.onVideo);
+ this.showItem("context-copyaudiourl", this.onAudio);
+ this.setItemAttr("context-copyvideourl", "disabled", !this.mediaURL);
+ this.setItemAttr("context-copyaudiourl", "disabled", !this.mediaURL);
+ }
+
+ initMediaPlayerItems() {
+ var onMedia = this.onVideo || this.onAudio;
+ // Several mutually exclusive items... play/pause, mute/unmute, show/hide
+ this.showItem(
+ "context-media-play",
+ onMedia && (this.target.paused || this.target.ended)
+ );
+ this.showItem(
+ "context-media-pause",
+ onMedia && !this.target.paused && !this.target.ended
+ );
+ this.showItem("context-media-mute", onMedia && !this.target.muted);
+ this.showItem("context-media-unmute", onMedia && this.target.muted);
+ this.showItem(
+ "context-media-playbackrate",
+ onMedia && this.target.duration != Number.POSITIVE_INFINITY
+ );
+ this.showItem("context-media-loop", onMedia);
+ this.showItem(
+ "context-media-showcontrols",
+ onMedia && !this.target.controls
+ );
+ this.showItem(
+ "context-media-hidecontrols",
+ this.target.controls &&
+ (this.onVideo || (this.onAudio && !this.inSyntheticDoc))
+ );
+ this.showItem(
+ "context-video-fullscreen",
+ this.onVideo && !this.target.ownerDocument.fullscreen
+ );
+ {
+ let shouldDisplay =
+ Services.prefs.getBoolPref(
+ "media.videocontrols.picture-in-picture.enabled"
+ ) &&
+ this.onVideo &&
+ !this.target.ownerDocument.fullscreen &&
+ this.target.readyState > 0;
+ this.showItem("context-video-pictureinpicture", shouldDisplay);
+ }
+ this.showItem("context-media-eme-learnmore", this.onDRMMedia);
+
+ // Disable them when there isn't a valid media source loaded.
+ if (onMedia) {
+ this.setItemAttr(
+ "context-media-playbackrate-050x",
+ "checked",
+ this.target.playbackRate == 0.5
+ );
+ this.setItemAttr(
+ "context-media-playbackrate-100x",
+ "checked",
+ this.target.playbackRate == 1.0
+ );
+ this.setItemAttr(
+ "context-media-playbackrate-125x",
+ "checked",
+ this.target.playbackRate == 1.25
+ );
+ this.setItemAttr(
+ "context-media-playbackrate-150x",
+ "checked",
+ this.target.playbackRate == 1.5
+ );
+ this.setItemAttr(
+ "context-media-playbackrate-200x",
+ "checked",
+ this.target.playbackRate == 2.0
+ );
+ this.setItemAttr("context-media-loop", "checked", this.target.loop);
+ var hasError =
+ this.target.error != null ||
+ this.target.networkState == this.target.NETWORK_NO_SOURCE;
+ this.setItemAttr("context-media-play", "disabled", hasError);
+ this.setItemAttr("context-media-pause", "disabled", hasError);
+ this.setItemAttr("context-media-mute", "disabled", hasError);
+ this.setItemAttr("context-media-unmute", "disabled", hasError);
+ this.setItemAttr("context-media-playbackrate", "disabled", hasError);
+ this.setItemAttr("context-media-playbackrate-050x", "disabled", hasError);
+ this.setItemAttr("context-media-playbackrate-100x", "disabled", hasError);
+ this.setItemAttr("context-media-playbackrate-125x", "disabled", hasError);
+ this.setItemAttr("context-media-playbackrate-150x", "disabled", hasError);
+ this.setItemAttr("context-media-playbackrate-200x", "disabled", hasError);
+ this.setItemAttr("context-media-showcontrols", "disabled", hasError);
+ this.setItemAttr("context-media-hidecontrols", "disabled", hasError);
+ if (this.onVideo) {
+ let canSaveSnapshot =
+ !this.onDRMMedia &&
+ this.target.readyState >= this.target.HAVE_CURRENT_DATA;
+ this.setItemAttr(
+ "context-video-saveimage",
+ "disabled",
+ !canSaveSnapshot
+ );
+ this.setItemAttr("context-video-fullscreen", "disabled", hasError);
+ this.setItemAttr(
+ "context-video-pictureinpicture",
+ "checked",
+ this.onPiPVideo
+ );
+ this.setItemAttr(
+ "context-video-pictureinpicture",
+ "disabled",
+ !this.onPiPVideo && hasError
+ );
+ }
+ }
+ }
+
+ initPasswordManagerItems() {
+ let showUseSavedLogin = false;
+ let showGenerate = false;
+ let showManage = false;
+ let enableGeneration = Services.logins.isLoggedIn;
+ try {
+ // If we could not find a password field we don't want to
+ // show the form fill, manage logins and the password generation items.
+ if (!this.isLoginForm()) {
+ return;
+ }
+ showManage = true;
+
+ // Disable the fill option if the user hasn't unlocked with their primary password
+ // or if the password field or target field are disabled.
+ // XXX: Bug 1529025 to maybe respect signon.rememberSignons.
+ let loginFillInfo = this.contentData?.loginFillInfo;
+ let disableFill =
+ !Services.logins.isLoggedIn ||
+ loginFillInfo?.passwordField.disabled ||
+ loginFillInfo?.activeField.disabled;
+ this.setItemAttr("fill-login", "disabled", disableFill);
+
+ let onPasswordLikeField = PASSWORD_FIELDNAME_HINTS.includes(
+ loginFillInfo.activeField.fieldNameHint
+ );
+
+ // Set the correct label for the fill menu
+ let fillMenu = document.getElementById("fill-login");
+ if (onPasswordLikeField) {
+ fillMenu.setAttribute(
+ "data-l10n-id",
+ "main-context-menu-use-saved-password"
+ );
+ } else {
+ // On a username field
+ fillMenu.setAttribute(
+ "data-l10n-id",
+ "main-context-menu-use-saved-login"
+ );
+ }
+
+ let documentURI = this.contentData?.documentURIObject;
+ let formOrigin = LoginHelper.getLoginOrigin(documentURI?.spec);
+ let isGeneratedPasswordEnabled =
+ LoginHelper.generationAvailable && LoginHelper.generationEnabled;
+ showGenerate =
+ onPasswordLikeField &&
+ isGeneratedPasswordEnabled &&
+ Services.logins.getLoginSavingEnabled(formOrigin);
+
+ if (disableFill) {
+ showUseSavedLogin = true;
+
+ // No need to update the submenu if the fill item is disabled.
+ return;
+ }
+
+ // Update sub-menu items.
+ let fragment = nsContextMenu.LoginManagerContextMenu.addLoginsToMenu(
+ this.targetIdentifier,
+ this.browser,
+ formOrigin
+ );
+
+ if (!fragment) {
+ return;
+ }
+
+ showUseSavedLogin = true;
+ let popup = document.getElementById("fill-login-popup");
+ popup.appendChild(fragment);
+ } finally {
+ const documentURI = this.contentData?.documentURIObject;
+ const origin = LoginHelper.getLoginOrigin(documentURI?.spec);
+ const showRelay = origin && this.contentData?.context.showRelay;
+
+ this.showItem("fill-login", showUseSavedLogin);
+ this.showItem("fill-login-generated-password", showGenerate);
+ this.showItem("use-relay-mask", showRelay);
+ this.showItem("manage-saved-logins", showManage);
+ this.setItemAttr(
+ "fill-login-generated-password",
+ "disabled",
+ !enableGeneration
+ );
+ this.setItemAttr(
+ "passwordmgr-items-separator",
+ "ensureHidden",
+ showUseSavedLogin || showGenerate || showManage || showRelay
+ ? null
+ : true
+ );
+ }
+ }
+
+ initSyncItems() {
+ this.syncItemsShown = gSync.updateContentContextMenu(this);
+ }
+
+ initViewSourceItems() {
+ const getString = name => {
+ const { bundle } = gViewSourceUtils.getPageActor(this.browser);
+ return bundle.GetStringFromName(name);
+ };
+ const showViewSourceItem = (id, check, accesskey) => {
+ const fullId = `context-viewsource-${id}`;
+ this.showItem(fullId, onViewSource);
+ if (!onViewSource) {
+ return;
+ }
+ check().then(checked => this.setItemAttr(fullId, "checked", checked));
+ this.setItemAttr(fullId, "label", getString(`context_${id}_label`));
+ if (accesskey) {
+ this.setItemAttr(
+ fullId,
+ "accesskey",
+ getString(`context_${id}_accesskey`)
+ );
+ }
+ };
+
+ const onViewSource = this.browser.currentURI.schemeIs("view-source");
+
+ showViewSourceItem("goToLine", async () => false, true);
+ showViewSourceItem("wrapLongLines", () =>
+ gViewSourceUtils.getPageActor(this.browser).queryIsWrapping()
+ );
+ showViewSourceItem("highlightSyntax", () =>
+ gViewSourceUtils.getPageActor(this.browser).queryIsSyntaxHighlighting()
+ );
+ }
+
+ // Iterate over the visible items on the menu and its submenus and
+ // hide any duplicated separators next to each other.
+ // The attribute "ensureHidden" will override this process and keep a particular separator hidden in special cases.
+ showHideSeparators(aPopup) {
+ let lastVisibleSeparator = null;
+ let count = 0;
+ for (let menuItem of aPopup.children) {
+ // Skip any items that were added by the page menu.
+ if (menuItem.hasAttribute("generateditemid")) {
+ count++;
+ continue;
+ }
+
+ if (menuItem.localName == "menuseparator") {
+ // Individual separators can have the `ensureHidden` attribute added to avoid them
+ // becoming visible. We also set `count` to 0 below because otherwise the
+ // next separator would be made visible, with the same visual effect.
+ if (!count || menuItem.hasAttribute("ensureHidden")) {
+ menuItem.hidden = true;
+ } else {
+ menuItem.hidden = false;
+ lastVisibleSeparator = menuItem;
+ }
+
+ count = 0;
+ } else if (!menuItem.hidden) {
+ if (menuItem.localName == "menu") {
+ this.showHideSeparators(menuItem.menupopup);
+ } else if (menuItem.localName == "menugroup") {
+ this.showHideSeparators(menuItem);
+ }
+ count++;
+ }
+ }
+
+ // If count is 0 yet lastVisibleSeparator is set, then there must be a separator
+ // visible at the end of the menu, so hide it. Note that there could be more than
+ // one but this isn't handled here.
+ if (!count && lastVisibleSeparator) {
+ lastVisibleSeparator.hidden = true;
+ }
+ }
+
+ shouldShowTakeScreenshot() {
+ let shouldShow =
+ !gScreenshots.shouldScreenshotsButtonBeDisabled() &&
+ this.inTabBrowser &&
+ !this.onTextInput &&
+ !this.onLink &&
+ !this.onPlainTextLink &&
+ !this.onImage &&
+ !this.onVideo &&
+ !this.onAudio &&
+ !this.onEditable &&
+ !this.onPassword;
+
+ return shouldShow;
+ }
+
+ initScreenshotItem() {
+ let shouldShow = this.shouldShowTakeScreenshot() && !this.inFrame;
+
+ this.showItem("context-sep-screenshots", shouldShow);
+ this.showItem("context-take-screenshot", shouldShow);
+ }
+
+ initPasswordControlItems() {
+ let shouldShow = this.onPassword && REVEAL_PASSWORD_ENABLED;
+ if (shouldShow) {
+ let revealPassword = document.getElementById("context-reveal-password");
+ if (this.passwordRevealed) {
+ revealPassword.setAttribute("checked", "true");
+ } else {
+ revealPassword.removeAttribute("checked");
+ }
+ }
+ this.showItem("context-reveal-password", shouldShow);
+ }
+
+ toggleRevealPassword() {
+ this.actor.toggleRevealPassword(this.targetIdentifier);
+ }
+
+ openPasswordManager() {
+ LoginHelper.openPasswordManager(window, {
+ entryPoint: "contextmenu",
+ });
+ }
+
+ useRelayMask() {
+ const documentURI = this.contentData?.documentURIObject;
+ const origin = LoginHelper.getLoginOrigin(documentURI?.spec);
+ this.actor.useRelayMask(this.targetIdentifier, origin);
+ }
+
+ useGeneratedPassword() {
+ nsContextMenu.LoginManagerContextMenu.useGeneratedPassword(
+ this.targetIdentifier,
+ this.contentData.documentURIObject,
+ this.browser
+ );
+ }
+
+ isLoginForm() {
+ let loginFillInfo = this.contentData?.loginFillInfo;
+ let documentURI = this.contentData?.documentURIObject;
+
+ // If we could not find a password field or this is not a username-only
+ // form, then don't treat this as a login form.
+ return (
+ (loginFillInfo?.passwordField?.found ||
+ loginFillInfo?.activeField.fieldNameHint == USERNAME_FIELDNAME_HINT) &&
+ !documentURI?.schemeIs("about") &&
+ this.browser.contentPrincipal.spec != "resource://pdf.js/web/viewer.html"
+ );
+ }
+
+ inspectNode() {
+ return nsContextMenu.DevToolsShim.inspectNode(
+ gBrowser.selectedTab,
+ this.targetIdentifier
+ );
+ }
+
+ inspectA11Y() {
+ return nsContextMenu.DevToolsShim.inspectA11Y(
+ gBrowser.selectedTab,
+ this.targetIdentifier
+ );
+ }
+
+ _openLinkInParameters(extra) {
+ let params = {
+ charset: this.contentData.charSet,
+ originPrincipal: this.principal,
+ originStoragePrincipal: this.storagePrincipal,
+ triggeringPrincipal: this.principal,
+ triggeringRemoteType: this.remoteType,
+ csp: this.csp,
+ frameID: this.contentData.frameID,
+ hasValidUserGestureActivation: true,
+ };
+ for (let p in extra) {
+ params[p] = extra[p];
+ }
+
+ let referrerInfo = this.onLink
+ ? this.contentData.linkReferrerInfo
+ : this.contentData.referrerInfo;
+ // If we want to change userContextId, we must be sure that we don't
+ // propagate the referrer.
+ if (
+ ("userContextId" in params &&
+ params.userContextId != this.contentData.userContextId) ||
+ this.onPlainTextLink
+ ) {
+ referrerInfo = new ReferrerInfo(
+ referrerInfo.referrerPolicy,
+ false,
+ referrerInfo.originalReferrer
+ );
+ }
+
+ params.referrerInfo = referrerInfo;
+ return params;
+ }
+
+ _getGlobalHistoryOptions() {
+ if (this.isSponsoredLink) {
+ return {
+ globalHistoryOptions: { triggeringSponsoredURL: this.linkURL },
+ };
+ } else if (this.browser.hasAttribute("triggeringSponsoredURL")) {
+ return {
+ globalHistoryOptions: {
+ triggeringSponsoredURL: this.browser.getAttribute(
+ "triggeringSponsoredURL"
+ ),
+ triggeringSponsoredURLVisitTimeMS: this.browser.getAttribute(
+ "triggeringSponsoredURLVisitTimeMS"
+ ),
+ },
+ };
+ }
+ return {};
+ }
+
+ // Open linked-to URL in a new window.
+ openLink() {
+ const params = this._getGlobalHistoryOptions();
+
+ openLinkIn(this.linkURL, "window", this._openLinkInParameters(params));
+ }
+
+ // Open linked-to URL in a new private window.
+ openLinkInPrivateWindow() {
+ openLinkIn(
+ this.linkURL,
+ "window",
+ this._openLinkInParameters({ private: true })
+ );
+ }
+
+ // Open linked-to URL in a new tab.
+ openLinkInTab(event) {
+ let params = {
+ userContextId: parseInt(event.target.getAttribute("data-usercontextid")),
+ ...this._getGlobalHistoryOptions(),
+ };
+
+ openLinkIn(this.linkURL, "tab", this._openLinkInParameters(params));
+ }
+
+ // open URL in current tab
+ openLinkInCurrent() {
+ openLinkIn(this.linkURL, "current", this._openLinkInParameters());
+ }
+
+ // Open frame in a new tab.
+ openFrameInTab() {
+ openLinkIn(this.contentData.docLocation, "tab", {
+ charset: this.contentData.charSet,
+ triggeringPrincipal: this.browser.contentPrincipal,
+ csp: this.browser.csp,
+ referrerInfo: this.contentData.frameReferrerInfo,
+ });
+ }
+
+ // Reload clicked-in frame.
+ reloadFrame(aEvent) {
+ let forceReload = aEvent.shiftKey;
+ this.actor.reloadFrame(this.targetIdentifier, forceReload);
+ }
+
+ // Open clicked-in frame in its own window.
+ openFrame() {
+ openLinkIn(this.contentData.docLocation, "window", {
+ charset: this.contentData.charSet,
+ triggeringPrincipal: this.browser.contentPrincipal,
+ csp: this.browser.csp,
+ referrerInfo: this.contentData.frameReferrerInfo,
+ });
+ }
+
+ // Open clicked-in frame in the same window.
+ showOnlyThisFrame() {
+ urlSecurityCheck(
+ this.contentData.docLocation,
+ this.browser.contentPrincipal,
+ Ci.nsIScriptSecurityManager.DISALLOW_SCRIPT
+ );
+ openWebLinkIn(this.contentData.docLocation, "current", {
+ referrerInfo: this.contentData.frameReferrerInfo,
+ triggeringPrincipal: this.browser.contentPrincipal,
+ });
+ }
+
+ takeScreenshot() {
+ if (SCREENSHOT_BROWSER_COMPONENT) {
+ Services.obs.notifyObservers(
+ window,
+ "menuitem-screenshot",
+ "context_menu"
+ );
+ } else {
+ Services.obs.notifyObservers(
+ null,
+ "menuitem-screenshot-extension",
+ "contextMenu"
+ );
+ }
+ }
+
+ pdfJSCmd(name) {
+ if (["cut", "copy", "paste"].includes(name)) {
+ const cmd = `cmd_${name}`;
+ document.commandDispatcher.getControllerForCommand(cmd).doCommand(cmd);
+ if (Cu.isInAutomation) {
+ this.browser.sendMessageToActor("PDFJS:Editing", { name }, "Pdfjs");
+ }
+ return;
+ }
+ this.browser.sendMessageToActor("PDFJS:Editing", { name }, "Pdfjs");
+ }
+
+ // View Partial Source
+ viewPartialSource() {
+ let { browser } = this;
+ let openSelectionFn = function () {
+ let tabBrowser = gBrowser;
+ const inNewWindow = !Services.prefs.getBoolPref("view_source.tab");
+ // In the case of popups, we need to find a non-popup browser window.
+ // We might also not have a tabBrowser reference (if this isn't in a
+ // a tabbrowser scope) or might have a fake/stub tabbrowser reference
+ // (in the sidebar). Deal with those cases:
+ if (!tabBrowser || !tabBrowser.addTab || !window.toolbar.visible) {
+ // This returns only non-popup browser windows by default.
+ let browserWindow = BrowserWindowTracker.getTopWindow();
+ tabBrowser = browserWindow.gBrowser;
+ }
+ let relatedToCurrent = gBrowser && gBrowser.selectedBrowser == browser;
+ let tab = tabBrowser.addTab("about:blank", {
+ relatedToCurrent,
+ inBackground: inNewWindow,
+ skipAnimation: inNewWindow,
+ triggeringPrincipal:
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ });
+ const viewSourceBrowser = tabBrowser.getBrowserForTab(tab);
+ if (inNewWindow) {
+ tabBrowser.hideTab(tab);
+ tabBrowser.replaceTabsWithWindow(tab);
+ }
+ return viewSourceBrowser;
+ };
+
+ top.gViewSourceUtils.viewPartialSourceInBrowser(
+ this.actor.browsingContext,
+ openSelectionFn
+ );
+ }
+
+ // Open new "view source" window with the frame's URL.
+ viewFrameSource() {
+ BrowserViewSourceOfDocument({
+ browser: this.browser,
+ URL: this.contentData.docLocation,
+ outerWindowID: this.frameOuterWindowID,
+ });
+ }
+
+ viewInfo() {
+ BrowserPageInfo(
+ this.contentData.docLocation,
+ null,
+ null,
+ null,
+ this.browser
+ );
+ }
+
+ viewImageInfo() {
+ BrowserPageInfo(
+ this.contentData.docLocation,
+ "mediaTab",
+ this.imageInfo,
+ null,
+ this.browser
+ );
+ }
+
+ viewImageDesc(e) {
+ urlSecurityCheck(
+ this.imageDescURL,
+ this.principal,
+ Ci.nsIScriptSecurityManager.DISALLOW_SCRIPT
+ );
+ openUILink(this.imageDescURL, e, {
+ referrerInfo: this.contentData.referrerInfo,
+ triggeringPrincipal: this.principal,
+ triggeringRemoteType: this.remoteType,
+ csp: this.csp,
+ });
+ }
+
+ viewFrameInfo() {
+ BrowserPageInfo(
+ this.contentData.docLocation,
+ null,
+ null,
+ this.actor.browsingContext,
+ this.browser
+ );
+ }
+
+ reloadImage() {
+ urlSecurityCheck(
+ this.mediaURL,
+ this.principal,
+ Ci.nsIScriptSecurityManager.DISALLOW_SCRIPT
+ );
+ this.actor.reloadImage(this.targetIdentifier);
+ }
+
+ _canvasToBlobURL(targetIdentifier) {
+ return this.actor.canvasToBlobURL(targetIdentifier);
+ }
+
+ // Change current window to the URL of the image, video, or audio.
+ viewMedia(e) {
+ let where = whereToOpenLink(e, false, false);
+ if (where == "current") {
+ where = "tab";
+ }
+ let referrerInfo = this.contentData.referrerInfo;
+ let systemPrincipal = Services.scriptSecurityManager.getSystemPrincipal();
+ if (this.onCanvas) {
+ this._canvasToBlobURL(this.targetIdentifier).then(function (blobURL) {
+ openLinkIn(blobURL, where, {
+ referrerInfo,
+ triggeringPrincipal: systemPrincipal,
+ });
+ }, console.error);
+ } else {
+ urlSecurityCheck(
+ this.mediaURL,
+ this.principal,
+ Ci.nsIScriptSecurityManager.DISALLOW_SCRIPT
+ );
+
+ // Default to opening in a new tab.
+ openLinkIn(this.mediaURL, where, {
+ referrerInfo,
+ forceAllowDataURI: true,
+ triggeringPrincipal: this.principal,
+ triggeringRemoteType: this.remoteType,
+ csp: this.csp,
+ });
+ }
+ }
+
+ saveVideoFrameAsImage() {
+ let isPrivate = PrivateBrowsingUtils.isBrowserPrivate(this.browser);
+
+ let name = "";
+ if (this.mediaURL) {
+ try {
+ let uri = makeURI(this.mediaURL);
+ let url = uri.QueryInterface(Ci.nsIURL);
+ if (url.fileBaseName) {
+ name = decodeURI(url.fileBaseName) + ".jpg";
+ }
+ } catch (e) {}
+ }
+ if (!name) {
+ name = "snapshot.jpg";
+ }
+
+ // Cache this because we fetch the data async
+ let referrerInfo = this.contentData.referrerInfo;
+ let cookieJarSettings = this.contentData.cookieJarSettings;
+
+ this.actor.saveVideoFrameAsImage(this.targetIdentifier).then(dataURL => {
+ // FIXME can we switch this to a blob URL?
+ internalSave(
+ dataURL,
+ null, // originalURL
+ null, // document
+ name,
+ null, // content disposition
+ "image/jpeg", // content type - keep in sync with ContextMenuChild!
+ true, // bypass cache
+ "SaveImageTitle",
+ null, // chosen data
+ referrerInfo,
+ cookieJarSettings,
+ null, // initiating doc
+ false, // don't skip prompt for where to save
+ null, // cache key
+ isPrivate,
+ this.principal
+ );
+ });
+ }
+
+ leaveDOMFullScreen() {
+ document.exitFullscreen();
+ }
+
+ // Change current window to the URL of the background image.
+ viewBGImage(e) {
+ urlSecurityCheck(
+ this.bgImageURL,
+ this.principal,
+ Ci.nsIScriptSecurityManager.DISALLOW_SCRIPT
+ );
+
+ openUILink(this.bgImageURL, e, {
+ referrerInfo: this.contentData.referrerInfo,
+ forceAllowDataURI: true,
+ triggeringPrincipal: this.principal,
+ triggeringRemoteType: this.remoteType,
+ csp: this.csp,
+ });
+ }
+
+ setDesktopBackground() {
+ if (!Services.policies.isAllowed("setDesktopBackground")) {
+ return;
+ }
+
+ this.actor
+ .setAsDesktopBackground(this.targetIdentifier)
+ .then(({ failed, dataURL, imageName }) => {
+ if (failed) {
+ return;
+ }
+
+ let image = document.createElementNS(
+ "http://www.w3.org/1999/xhtml",
+ "img"
+ );
+ image.src = dataURL;
+
+ // Confirm since it's annoying if you hit this accidentally.
+ const kDesktopBackgroundURL =
+ "chrome://browser/content/setDesktopBackground.xhtml";
+
+ if (AppConstants.platform == "macosx") {
+ // On Mac, the Set Desktop Background window is not modal.
+ // Don't open more than one Set Desktop Background window.
+ let dbWin = Services.wm.getMostRecentWindow(
+ "Shell:SetDesktopBackground"
+ );
+ if (dbWin) {
+ dbWin.gSetBackground.init(image, imageName);
+ dbWin.focus();
+ } else {
+ openDialog(
+ kDesktopBackgroundURL,
+ "",
+ "centerscreen,chrome,dialog=no,dependent,resizable=no",
+ image,
+ imageName
+ );
+ }
+ } else {
+ // On non-Mac platforms, the Set Wallpaper dialog is modal.
+ openDialog(
+ kDesktopBackgroundURL,
+ "",
+ "centerscreen,chrome,dialog,modal,dependent",
+ image,
+ imageName
+ );
+ }
+ });
+ }
+
+ // Save URL of clicked-on frame.
+ saveFrame() {
+ saveBrowser(this.browser, false, this.frameBrowsingContext);
+ }
+
+ // Helper function to wait for appropriate MIME-type headers and
+ // then prompt the user with a file picker
+ saveHelper(
+ linkURL,
+ linkText,
+ dialogTitle,
+ bypassCache,
+ doc,
+ referrerInfo,
+ cookieJarSettings,
+ windowID,
+ linkDownload,
+ isContentWindowPrivate
+ ) {
+ // canonical def in nsURILoader.h
+ const NS_ERROR_SAVE_LINK_AS_TIMEOUT = 0x805d0020;
+
+ // an object to proxy the data through to
+ // nsIExternalHelperAppService.doContent, which will wait for the
+ // appropriate MIME-type headers and then prompt the user with a
+ // file picker
+ function saveAsListener(principal) {
+ this._triggeringPrincipal = principal;
+ }
+ saveAsListener.prototype = {
+ extListener: null,
+
+ onStartRequest: function saveLinkAs_onStartRequest(aRequest) {
+ // if the timer fired, the error status will have been caused by that,
+ // and we'll be restarting in onStopRequest, so no reason to notify
+ // the user
+ if (aRequest.status == NS_ERROR_SAVE_LINK_AS_TIMEOUT) {
+ return;
+ }
+
+ timer.cancel();
+
+ // some other error occured; notify the user...
+ if (!Components.isSuccessCode(aRequest.status)) {
+ try {
+ const l10n = new Localization(["browser/downloads.ftl"], true);
+
+ let msg = null;
+ try {
+ const channel = aRequest.QueryInterface(Ci.nsIChannel);
+ const reason = channel.loadInfo.requestBlockingReason;
+ if (
+ reason == Ci.nsILoadInfo.BLOCKING_REASON_EXTENSION_WEBREQUEST
+ ) {
+ try {
+ const properties = channel.QueryInterface(Ci.nsIPropertyBag);
+ const id = properties.getProperty("cancelledByExtension");
+ msg = l10n.formatValueSync("downloads-error-blocked-by", {
+ extension: WebExtensionPolicy.getByID(id).name,
+ });
+ } catch (err) {
+ // "cancelledByExtension" doesn't have to be available.
+ msg = l10n.formatValueSync("downloads-error-extension");
+ }
+ }
+ } catch (ex) {}
+ msg ??= l10n.formatValueSync("downloads-error-generic");
+
+ const window = Services.wm.getOuterWindowWithId(windowID);
+ const title = l10n.formatValueSync("downloads-error-alert-title");
+ Services.prompt.alert(window, title, msg);
+ } catch (ex) {}
+ return;
+ }
+
+ let extHelperAppSvc = Cc[
+ "@mozilla.org/uriloader/external-helper-app-service;1"
+ ].getService(Ci.nsIExternalHelperAppService);
+ let channel = aRequest.QueryInterface(Ci.nsIChannel);
+ this.extListener = extHelperAppSvc.doContent(
+ channel.contentType,
+ aRequest,
+ null,
+ true,
+ window
+ );
+ this.extListener.onStartRequest(aRequest);
+ },
+
+ onStopRequest: function saveLinkAs_onStopRequest(aRequest, aStatusCode) {
+ if (aStatusCode == NS_ERROR_SAVE_LINK_AS_TIMEOUT) {
+ // do it the old fashioned way, which will pick the best filename
+ // it can without waiting.
+ saveURL(
+ linkURL,
+ null,
+ linkText,
+ dialogTitle,
+ bypassCache,
+ false,
+ referrerInfo,
+ cookieJarSettings,
+ doc,
+ isContentWindowPrivate,
+ this._triggeringPrincipal
+ );
+ }
+ if (this.extListener) {
+ this.extListener.onStopRequest(aRequest, aStatusCode);
+ }
+ },
+
+ onDataAvailable: function saveLinkAs_onDataAvailable(
+ aRequest,
+ aInputStream,
+ aOffset,
+ aCount
+ ) {
+ this.extListener.onDataAvailable(
+ aRequest,
+ aInputStream,
+ aOffset,
+ aCount
+ );
+ },
+ };
+
+ function callbacks() {}
+ callbacks.prototype = {
+ getInterface: function sLA_callbacks_getInterface(aIID) {
+ if (aIID.equals(Ci.nsIAuthPrompt) || aIID.equals(Ci.nsIAuthPrompt2)) {
+ // If the channel demands authentication prompt, we must cancel it
+ // because the save-as-timer would expire and cancel the channel
+ // before we get credentials from user. Both authentication dialog
+ // and save as dialog would appear on the screen as we fall back to
+ // the old fashioned way after the timeout.
+ timer.cancel();
+ channel.cancel(NS_ERROR_SAVE_LINK_AS_TIMEOUT);
+ }
+ throw Components.Exception("", Cr.NS_ERROR_NO_INTERFACE);
+ },
+ };
+
+ // if it we don't have the headers after a short time, the user
+ // won't have received any feedback from their click. that's bad. so
+ // we give up waiting for the filename.
+ function timerCallback() {}
+ timerCallback.prototype = {
+ notify: function sLA_timer_notify(aTimer) {
+ channel.cancel(NS_ERROR_SAVE_LINK_AS_TIMEOUT);
+ },
+ };
+
+ // setting up a new channel for 'right click - save link as ...'
+ var channel = NetUtil.newChannel({
+ uri: makeURI(linkURL),
+ loadingPrincipal: this.principal,
+ contentPolicyType: Ci.nsIContentPolicy.TYPE_SAVEAS_DOWNLOAD,
+ securityFlags: Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_INHERITS_SEC_CONTEXT,
+ });
+
+ if (linkDownload) {
+ channel.contentDispositionFilename = linkDownload;
+ }
+ if (channel instanceof Ci.nsIPrivateBrowsingChannel) {
+ let docIsPrivate = PrivateBrowsingUtils.isBrowserPrivate(this.browser);
+ channel.setPrivate(docIsPrivate);
+ }
+ channel.notificationCallbacks = new callbacks();
+
+ let flags = Ci.nsIChannel.LOAD_CALL_CONTENT_SNIFFERS;
+
+ if (bypassCache) {
+ flags |= Ci.nsIRequest.LOAD_BYPASS_CACHE;
+ }
+
+ if (channel instanceof Ci.nsICachingChannel) {
+ flags |= Ci.nsICachingChannel.LOAD_BYPASS_LOCAL_CACHE_IF_BUSY;
+ }
+
+ channel.loadFlags |= flags;
+
+ if (channel instanceof Ci.nsIHttpChannel) {
+ channel.referrerInfo = referrerInfo;
+ if (channel instanceof Ci.nsIHttpChannelInternal) {
+ channel.forceAllowThirdPartyCookie = true;
+ }
+
+ channel.loadInfo.cookieJarSettings = cookieJarSettings;
+ }
+
+ // fallback to the old way if we don't see the headers quickly
+ var timeToWait = Services.prefs.getIntPref(
+ "browser.download.saveLinkAsFilenameTimeout"
+ );
+ var timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+ timer.initWithCallback(
+ new timerCallback(),
+ timeToWait,
+ timer.TYPE_ONE_SHOT
+ );
+
+ // kick off the channel with our proxy object as the listener
+ channel.asyncOpen(new saveAsListener(this.principal));
+ }
+
+ // Save URL of clicked-on link.
+ saveLink() {
+ let referrerInfo = this.onLink
+ ? this.contentData.linkReferrerInfo
+ : this.contentData.referrerInfo;
+
+ let isPrivate = PrivateBrowsingUtils.isBrowserPrivate(this.browser);
+ this.saveHelper(
+ this.linkURL,
+ this.linkTextStr,
+ null,
+ true,
+ this.ownerDoc,
+ referrerInfo,
+ this.contentData.cookieJarSettings,
+ this.frameOuterWindowID,
+ this.linkDownload,
+ isPrivate
+ );
+ }
+
+ // Backwards-compatibility wrapper
+ saveImage() {
+ if (this.onCanvas || this.onImage) {
+ this.saveMedia();
+ }
+ }
+
+ // Save URL of the clicked upon image, video, or audio.
+ saveMedia() {
+ let doc = this.ownerDoc;
+ let isPrivate = PrivateBrowsingUtils.isBrowserPrivate(this.browser);
+ let referrerInfo = this.contentData.referrerInfo;
+ let cookieJarSettings = this.contentData.cookieJarSettings;
+ if (this.onCanvas) {
+ // Bypass cache, since it's a data: URL.
+ this._canvasToBlobURL(this.targetIdentifier).then(function (blobURL) {
+ internalSave(
+ blobURL,
+ null, // originalURL
+ null, // document
+ "canvas.png",
+ null, // content disposition
+ "image/png", // _canvasToBlobURL uses image/png by default.
+ true, // bypass cache
+ "SaveImageTitle",
+ null, // chosen data
+ referrerInfo,
+ cookieJarSettings,
+ null, // initiating doc
+ false, // don't skip prompt for where to save
+ null, // cache key
+ isPrivate,
+ document.nodePrincipal /* system, because blob: */
+ );
+ }, console.error);
+ } else if (this.onImage) {
+ urlSecurityCheck(this.mediaURL, this.principal);
+ internalSave(
+ this.mediaURL,
+ null, // originalURL
+ null, // document
+ null, // file name; we'll take it from the URL
+ this.contentData.contentDisposition,
+ this.contentData.contentType,
+ false, // do not bypass the cache
+ "SaveImageTitle",
+ null, // chosen data
+ referrerInfo,
+ cookieJarSettings,
+ null, // initiating doc
+ false, // don't skip prompt for where to save
+ null, // cache key
+ isPrivate,
+ this.principal
+ );
+ } else if (this.onVideo || this.onAudio) {
+ let defaultFileName = "";
+ if (this.mediaURL.startsWith("data")) {
+ // Use default file name "Untitled" for data URIs
+ defaultFileName = ContentAreaUtils.stringBundle.GetStringFromName(
+ "UntitledSaveFileName"
+ );
+ }
+
+ var dialogTitle = this.onVideo ? "SaveVideoTitle" : "SaveAudioTitle";
+ this.saveHelper(
+ this.mediaURL,
+ null,
+ dialogTitle,
+ false,
+ doc,
+ referrerInfo,
+ cookieJarSettings,
+ this.frameOuterWindowID,
+ defaultFileName,
+ isPrivate
+ );
+ }
+ }
+
+ // Backwards-compatibility wrapper
+ sendImage() {
+ if (this.onCanvas || this.onImage) {
+ this.sendMedia();
+ }
+ }
+
+ sendMedia() {
+ MailIntegration.sendMessage(this.mediaURL, "");
+ }
+
+ // Generate email address and put it on clipboard.
+ copyEmail() {
+ // Copy the comma-separated list of email addresses only.
+ // There are other ways of embedding email addresses in a mailto:
+ // link, but such complex parsing is beyond us.
+ var url = this.linkURL;
+ var qmark = url.indexOf("?");
+ var addresses;
+
+ // 7 == length of "mailto:"
+ addresses = qmark > 7 ? url.substring(7, qmark) : url.substr(7);
+
+ // Let's try to unescape it using a character set
+ // in case the address is not ASCII.
+ try {
+ addresses = Services.textToSubURI.unEscapeURIForUI(addresses);
+ } catch (ex) {
+ // Do nothing.
+ }
+
+ var clipboard = Cc["@mozilla.org/widget/clipboardhelper;1"].getService(
+ Ci.nsIClipboardHelper
+ );
+ clipboard.copyString(addresses);
+ }
+
+ // Extract phone and put it on clipboard
+ copyPhone() {
+ // Copies the phone number only. We won't be doing any complex parsing
+ var url = this.linkURL;
+ var phone = url.substr(4);
+
+ // Let's try to unescape it using a character set
+ // in case the phone number is not ASCII.
+ try {
+ phone = Services.textToSubURI.unEscapeURIForUI(phone);
+ } catch (ex) {
+ // Do nothing.
+ }
+
+ var clipboard = Cc["@mozilla.org/widget/clipboardhelper;1"].getService(
+ Ci.nsIClipboardHelper
+ );
+ clipboard.copyString(phone);
+ }
+
+ copyLink() {
+ // If we're in a view source tab, remove the view-source: prefix
+ let linkURL = this.linkURL.replace(/^view-source:/, "");
+ var clipboard = Cc["@mozilla.org/widget/clipboardhelper;1"].getService(
+ Ci.nsIClipboardHelper
+ );
+ clipboard.copyString(linkURL);
+ }
+
+ /**
+ * Copies a stripped version of this.linkURI to the clipboard.
+ * 'Stripped' means that query parameters for tracking/ link decoration
+ * that are known to us will be removed from the URI.
+ */
+ copyStrippedLink() {
+ let strippedLinkURI = this.getStrippedLink();
+ let strippedLinkURL =
+ Services.io.createExposableURI(strippedLinkURI)?.displaySpec;
+ if (strippedLinkURL) {
+ let clipboard = Cc["@mozilla.org/widget/clipboardhelper;1"].getService(
+ Ci.nsIClipboardHelper
+ );
+ clipboard.copyString(strippedLinkURL);
+ }
+ }
+
+ addKeywordForSearchField() {
+ this.actor.getSearchFieldBookmarkData(this.targetIdentifier).then(data => {
+ let title = gNavigatorBundle.getFormattedString(
+ "addKeywordTitleAutoFill",
+ [data.title]
+ );
+ PlacesUIUtils.showBookmarkDialog(
+ {
+ action: "add",
+ type: "bookmark",
+ uri: makeURI(data.spec),
+ title,
+ keyword: "",
+ postData: data.postData,
+ charSet: data.charset,
+ hiddenRows: ["location", "tags"],
+ },
+ window
+ );
+ });
+ }
+
+ /**
+ * Utilities
+ */
+
+ /**
+ * Show/hide one item (specified via name or the item element itself).
+ * If the element is not found, then this function finishes silently.
+ *
+ * @param {Element|String} aItemOrId The item element or the name of the element
+ * to show.
+ * @param {Boolean} aShow Set to true to show the item, false to hide it.
+ */
+ showItem(aItemOrId, aShow) {
+ var item =
+ aItemOrId.constructor == String
+ ? document.getElementById(aItemOrId)
+ : aItemOrId;
+ if (item) {
+ item.hidden = !aShow;
+ }
+ }
+
+ // Set given attribute of specified context-menu item. If the
+ // value is null, then it removes the attribute (which works
+ // nicely for the disabled attribute).
+ setItemAttr(aID, aAttr, aVal) {
+ var elem = document.getElementById(aID);
+ if (elem) {
+ if (aVal == null) {
+ // null indicates attr should be removed.
+ elem.removeAttribute(aAttr);
+ } else {
+ // Set attr=val.
+ elem.setAttribute(aAttr, aVal);
+ }
+ }
+ }
+
+ // Temporary workaround for DOM api not yet implemented by XUL nodes.
+ cloneNode(aItem) {
+ // Create another element like the one we're cloning.
+ var node = document.createElement(aItem.tagName);
+
+ // Copy attributes from argument item to the new one.
+ var attrs = aItem.attributes;
+ for (var i = 0; i < attrs.length; i++) {
+ var attr = attrs.item(i);
+ node.setAttribute(attr.nodeName, attr.nodeValue);
+ }
+
+ // Voila!
+ return node;
+ }
+
+ getLinkURI() {
+ try {
+ return makeURI(this.linkURL);
+ } catch (ex) {
+ // e.g. empty URL string
+ }
+
+ return null;
+ }
+
+ /**
+ * Strips any known query params from the link URI.
+ * @returns {nsIURI|null} - the stripped version of the URI,
+ * or null if we could not strip any query parameter.
+ *
+ */
+ getStrippedLink() {
+ if (!this.linkURI) {
+ return null;
+ }
+ let strippedLinkURI = null;
+ try {
+ strippedLinkURI = QueryStringStripper.stripForCopyOrShare(this.linkURI);
+ } catch (e) {
+ console.warn(`isLinkURIStrippable: ${e.message}`);
+ return null;
+ }
+ return strippedLinkURI;
+ }
+
+ // Kept for addon compat
+ linkText() {
+ return this.linkTextStr;
+ }
+
+ // Determines whether or not the separator with the specified ID should be
+ // shown or not by determining if there are any non-hidden items between it
+ // and the previous separator.
+ shouldShowSeparator(aSeparatorID) {
+ var separator = document.getElementById(aSeparatorID);
+ if (separator) {
+ var sibling = separator.previousSibling;
+ while (sibling && sibling.localName != "menuseparator") {
+ if (!sibling.hidden) {
+ return true;
+ }
+ sibling = sibling.previousSibling;
+ }
+ }
+ return false;
+ }
+
+ shouldShowAddKeyword() {
+ return this.onTextInput && this.onKeywordField && !this.isLoginForm();
+ }
+
+ addDictionaries() {
+ var uri = Services.urlFormatter.formatURLPref(
+ "browser.dictionaries.download.url"
+ );
+
+ var locale = "-";
+ try {
+ locale = Services.prefs.getComplexValue(
+ "intl.accept_languages",
+ Ci.nsIPrefLocalizedString
+ ).data;
+ } catch (e) {}
+
+ var version = "-";
+ try {
+ version = Services.appinfo.version;
+ } catch (e) {}
+
+ uri = uri.replace(/%LOCALE%/, escape(locale)).replace(/%VERSION%/, version);
+
+ var newWindowPref = Services.prefs.getIntPref(
+ "browser.link.open_newwindow"
+ );
+ var where = newWindowPref == 3 ? "tab" : "window";
+
+ openTrustedLinkIn(uri, where);
+ }
+
+ bookmarkThisPage() {
+ window.top.PlacesCommandHook.bookmarkPage().catch(console.error);
+ }
+
+ bookmarkLink() {
+ window.top.PlacesCommandHook.bookmarkLink(
+ this.linkURL,
+ this.linkTextStr
+ ).catch(console.error);
+ }
+
+ addBookmarkForFrame() {
+ let uri = this.contentData.documentURIObject;
+
+ this.actor.getFrameTitle(this.targetIdentifier).then(title => {
+ window.top.PlacesCommandHook.bookmarkLink(uri.spec, title).catch(
+ console.error
+ );
+ });
+ }
+
+ savePageAs() {
+ saveBrowser(this.browser);
+ }
+
+ printFrame() {
+ PrintUtils.startPrintWindow(this.actor.browsingContext, {
+ printFrameOnly: true,
+ });
+ }
+
+ printSelection() {
+ PrintUtils.startPrintWindow(this.actor.browsingContext, {
+ printSelectionOnly: true,
+ });
+ }
+
+ switchPageDirection() {
+ gBrowser.selectedBrowser.sendMessageToActor(
+ "SwitchDocumentDirection",
+ {},
+ "SwitchDocumentDirection",
+ "roots"
+ );
+ }
+
+ mediaCommand(command, data) {
+ this.actor.mediaCommand(this.targetIdentifier, command, data);
+ }
+
+ copyMediaLocation() {
+ var clipboard = Cc["@mozilla.org/widget/clipboardhelper;1"].getService(
+ Ci.nsIClipboardHelper
+ );
+ clipboard.copyString(this.originalMediaURL);
+ }
+
+ getImageText() {
+ let dialogBox = gBrowser.getTabDialogBox(this.browser);
+ const imageTextResult = this.actor.getImageText(this.targetIdentifier);
+ TelemetryStopwatch.start(
+ "TEXT_RECOGNITION_API_PERFORMANCE",
+ imageTextResult
+ );
+ const { dialog } = dialogBox.open(
+ "chrome://browser/content/textrecognition/textrecognition.html",
+ {
+ features: "resizable=no",
+ modalType: Services.prompt.MODAL_TYPE_CONTENT,
+ },
+ imageTextResult,
+ () => dialog.resizeVertically(),
+ openLinkIn
+ );
+ }
+
+ drmLearnMore(aEvent) {
+ let drmInfoURL =
+ Services.urlFormatter.formatURLPref("app.support.baseURL") +
+ "drm-content";
+ let dest = whereToOpenLink(aEvent);
+ // Don't ever want this to open in the same tab as it'll unload the
+ // DRM'd video, which is going to be a bad idea in most cases.
+ if (dest == "current") {
+ dest = "tab";
+ }
+ openTrustedLinkIn(drmInfoURL, dest);
+ }
+
+ // Formats the 'Search <engine> for "<selection or link text>"' context menu.
+ showAndFormatSearchContextItem() {
+ let menuItem = document.getElementById("context-searchselect");
+ let menuItemPrivate = document.getElementById(
+ "context-searchselect-private"
+ );
+ if (!Services.search.isInitialized) {
+ menuItem.hidden = true;
+ menuItemPrivate.hidden = true;
+ return;
+ }
+ const docIsPrivate = PrivateBrowsingUtils.isBrowserPrivate(this.browser);
+ const privatePref = "browser.search.separatePrivateDefault.ui.enabled";
+ let showSearchSelect =
+ !this.inAboutDevtoolsToolbox &&
+ (this.isTextSelected || this.onLink) &&
+ !this.onImage;
+ // Don't show the private search item when we're already in a private
+ // browsing window.
+ let showPrivateSearchSelect =
+ showSearchSelect &&
+ !docIsPrivate &&
+ Services.prefs.getBoolPref(privatePref);
+
+ menuItem.hidden = !showSearchSelect;
+ menuItemPrivate.hidden = !showPrivateSearchSelect;
+ let frameSeparator = document.getElementById("frame-sep");
+
+ // Add a divider between "Search X for Y" and "This Frame", and between "Search X for Y" and "Check Spelling",
+ // but no divider in other cases.
+ frameSeparator.toggleAttribute(
+ "ensureHidden",
+ !showSearchSelect && this.inFrame
+ );
+ // If we're not showing the menu items, we can skip formatting the labels.
+ if (!showSearchSelect) {
+ return;
+ }
+
+ let selectedText = this.isTextSelected
+ ? this.textSelected
+ : this.linkTextStr;
+
+ // Store searchTerms in context menu item so we know what to search onclick
+ menuItem.searchTerms = menuItemPrivate.searchTerms = selectedText;
+ menuItem.principal = menuItemPrivate.principal = this.principal;
+ menuItem.csp = menuItemPrivate.csp = this.csp;
+
+ // Copied to alert.js' prefillAlertInfo().
+ // If the JS character after our truncation point is a trail surrogate,
+ // include it in the truncated string to avoid splitting a surrogate pair.
+ if (selectedText.length > 15) {
+ let truncLength = 15;
+ let truncChar = selectedText[15].charCodeAt(0);
+ if (truncChar >= 0xdc00 && truncChar <= 0xdfff) {
+ truncLength++;
+ }
+ selectedText = selectedText.substr(0, truncLength) + this.ellipsis;
+ }
+
+ // format "Search <engine> for <selection>" string to show in menu
+ let engineName = Services.search.defaultEngine.name;
+ let privateEngineName = Services.search.defaultPrivateEngine.name;
+ menuItem.usePrivate = docIsPrivate;
+ let menuLabel = gNavigatorBundle.getFormattedString("contextMenuSearch", [
+ docIsPrivate ? privateEngineName : engineName,
+ selectedText,
+ ]);
+ menuItem.label = menuLabel;
+ menuItem.accessKey = gNavigatorBundle.getString(
+ "contextMenuSearch.accesskey"
+ );
+
+ if (showPrivateSearchSelect) {
+ let otherEngine = engineName != privateEngineName;
+ let accessKey = "contextMenuPrivateSearch.accesskey";
+ if (otherEngine) {
+ menuItemPrivate.label = gNavigatorBundle.getFormattedString(
+ "contextMenuPrivateSearchOtherEngine",
+ [privateEngineName]
+ );
+ accessKey = "contextMenuPrivateSearchOtherEngine.accesskey";
+ } else {
+ menuItemPrivate.label = gNavigatorBundle.getString(
+ "contextMenuPrivateSearch"
+ );
+ }
+ menuItemPrivate.accessKey = gNavigatorBundle.getString(accessKey);
+ }
+ }
+
+ createContainerMenu(aEvent) {
+ let createMenuOptions = {
+ isContextMenu: true,
+ excludeUserContextId: this.contentData.userContextId,
+ };
+ return createUserContextMenu(aEvent, createMenuOptions);
+ }
+}
+
+ChromeUtils.defineESModuleGetters(nsContextMenu, {
+ DevToolsShim: "chrome://devtools-startup/content/DevToolsShim.sys.mjs",
+ LoginManagerContextMenu:
+ "resource://gre/modules/LoginManagerContextMenu.sys.mjs",
+ WebNavigationFrames: "resource://gre/modules/WebNavigationFrames.sys.mjs",
+});
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "screenshotsDisabled",
+ "extensions.screenshots.disabled",
+ false
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "SCREENSHOT_BROWSER_COMPONENT",
+ "screenshots.browser.component.enabled",
+ false
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "REVEAL_PASSWORD_ENABLED",
+ "layout.forms.reveal-password-context-menu.enabled",
+ false
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "TEXT_RECOGNITION_ENABLED",
+ "dom.text-recognition.enabled",
+ false
+);
+
+XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "STRIP_ON_SHARE_ENABLED",
+ "privacy.query_stripping.strip_on_share.enabled",
+ false
+);
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "QueryStringStripper",
+ "@mozilla.org/url-query-string-stripper;1",
+ "nsIURLQueryStringStripper"
+);
diff --git a/browser/base/content/overrides/app-license.html b/browser/base/content/overrides/app-license.html
new file mode 100644
index 0000000000..eafe95498a
--- /dev/null
+++ b/browser/base/content/overrides/app-license.html
@@ -0,0 +1,8 @@
+<!-- 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/. -->
+<p>
+ <b>Binaries</b> of this product have been made available to you by the
+ <a href="http://www.mozilla.org/">Mozilla Project</a> under the Mozilla Public
+ License 2.0 (MPL). <a href="about:rights">Know your rights</a>.
+</p>
diff --git a/browser/base/content/pageinfo/pageInfo.css b/browser/base/content/pageinfo/pageInfo.css
new file mode 100644
index 0000000000..d3c023a4c0
--- /dev/null
+++ b/browser/base/content/pageinfo/pageInfo.css
@@ -0,0 +1,89 @@
+/* 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/. */
+
+:root {
+ min-width: 24em;
+ min-height: 24em;
+}
+
+#mainDeck {
+ padding: 10px;
+ min-height: 0;
+}
+
+#mediaPreviewBox,
+#imagecontainerbox,
+#thepreviewimage,
+#mainDeck > vbox {
+ min-width: 0;
+ min-height: 0;
+}
+
+#viewGroup > radio > .radio-label-box {
+ flex-direction: column;
+ align-items: center;
+}
+
+/* Hide the radio button for the section headers */
+#viewGroup > radio > .radio-check {
+ display: none;
+}
+
+#thepreviewimage {
+ margin: 1em auto;
+ flex: none;
+ display: block;
+}
+
+table {
+ border-spacing: 0;
+}
+
+.tableSeparator {
+ height: 6px;
+}
+
+th, td {
+ padding: 0;
+}
+
+th {
+ font: inherit;
+ text-align: start;
+ padding-inline-end: .5em;
+}
+
+/*
+ Make the first column shrink to its min-content, except for #securityTable
+ which has full sentences in its first column.
+*/
+table:not(#securityTable) th {
+ width: 0;
+}
+
+th > label,
+td > input,
+.table-split-column {
+ width: 100%;
+ margin-block: 1px 4px;
+}
+
+.table-split-column {
+ display: flex;
+ align-items: center;
+}
+
+.table-split-column > label,
+.table-split-column > input {
+ flex: 1 auto;
+}
+
+.table-split-column > button {
+ flex-shrink: 0;
+}
+
+#hostText {
+ flex: 1;
+ margin-top: 1px; /* same margin as adjacent label */
+}
diff --git a/browser/base/content/pageinfo/pageInfo.js b/browser/base/content/pageinfo/pageInfo.js
new file mode 100644
index 0000000000..e3339afdda
--- /dev/null
+++ b/browser/base/content/pageinfo/pageInfo.js
@@ -0,0 +1,1172 @@
+/* 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/. */
+
+/* import-globals-from /toolkit/content/globalOverlay.js */
+/* import-globals-from /toolkit/content/contentAreaUtils.js */
+/* import-globals-from /toolkit/content/treeUtils.js */
+/* import-globals-from ../utilityOverlay.js */
+/* import-globals-from permissions.js */
+/* import-globals-from security.js */
+
+ChromeUtils.defineESModuleGetters(this, {
+ E10SUtils: "resource://gre/modules/E10SUtils.sys.mjs",
+});
+
+// Inherit color scheme overrides from parent window. This is to inherit the
+// color scheme of dark themed PBM windows.
+{
+ let openerColorSchemeOverride =
+ window.opener?.browsingContext?.top.prefersColorSchemeOverride;
+ if (
+ openerColorSchemeOverride &&
+ window.browsingContext == window.browsingContext.top
+ ) {
+ window.browsingContext.prefersColorSchemeOverride =
+ openerColorSchemeOverride;
+ }
+}
+
+// define a js object to implement nsITreeView
+function pageInfoTreeView(treeid, copycol) {
+ // copycol is the index number for the column that we want to add to
+ // the copy-n-paste buffer when the user hits accel-c
+ this.treeid = treeid;
+ this.copycol = copycol;
+ this.rows = 0;
+ this.tree = null;
+ this.data = [];
+ this.selection = null;
+ this.sortcol = -1;
+ this.sortdir = false;
+}
+
+pageInfoTreeView.prototype = {
+ set rowCount(c) {
+ throw new Error("rowCount is a readonly property");
+ },
+ get rowCount() {
+ return this.rows;
+ },
+
+ setTree(tree) {
+ this.tree = tree;
+ },
+
+ getCellText(row, column) {
+ // row can be null, but js arrays are 0-indexed.
+ // colidx cannot be null, but can be larger than the number
+ // of columns in the array. In this case it's the fault of
+ // whoever typoed while calling this function.
+ return this.data[row][column.index] || "";
+ },
+
+ setCellValue(row, column, value) {},
+
+ setCellText(row, column, value) {
+ this.data[row][column.index] = value;
+ },
+
+ addRow(row) {
+ this.rows = this.data.push(row);
+ this.rowCountChanged(this.rows - 1, 1);
+ if (this.selection.count == 0 && this.rowCount && !gImageElement) {
+ this.selection.select(0);
+ }
+ },
+
+ addRows(rows) {
+ for (let row of rows) {
+ this.addRow(row);
+ }
+ },
+
+ rowCountChanged(index, count) {
+ this.tree.rowCountChanged(index, count);
+ },
+
+ invalidate() {
+ this.tree.invalidate();
+ },
+
+ clear() {
+ if (this.tree) {
+ this.tree.rowCountChanged(0, -this.rows);
+ }
+ this.rows = 0;
+ this.data = [];
+ },
+
+ onPageMediaSort(columnname) {
+ var tree = document.getElementById(this.treeid);
+ var treecol = tree.columns.getNamedColumn(columnname);
+
+ this.sortdir = gTreeUtils.sort(
+ tree,
+ this,
+ this.data,
+ treecol.index,
+ function textComparator(a, b) {
+ return (a || "").toLowerCase().localeCompare((b || "").toLowerCase());
+ },
+ this.sortcol,
+ this.sortdir
+ );
+
+ for (let col of tree.columns) {
+ col.element.removeAttribute("sortActive");
+ col.element.removeAttribute("sortDirection");
+ }
+ treecol.element.setAttribute("sortActive", "true");
+ treecol.element.setAttribute(
+ "sortDirection",
+ this.sortdir ? "ascending" : "descending"
+ );
+
+ this.sortcol = treecol.index;
+ },
+
+ getRowProperties(row) {
+ return "";
+ },
+ getCellProperties(row, column) {
+ return "";
+ },
+ getColumnProperties(column) {
+ return "";
+ },
+ isContainer(index) {
+ return false;
+ },
+ isContainerOpen(index) {
+ return false;
+ },
+ isSeparator(index) {
+ return false;
+ },
+ isSorted() {
+ return this.sortcol > -1;
+ },
+ canDrop(index, orientation) {
+ return false;
+ },
+ drop(row, orientation) {
+ return false;
+ },
+ getParentIndex(index) {
+ return 0;
+ },
+ hasNextSibling(index, after) {
+ return false;
+ },
+ getLevel(index) {
+ return 0;
+ },
+ getImageSrc(row, column) {},
+ getCellValue(row, column) {
+ let col = column != null ? column : this.copycol;
+ return row < 0 || col < 0 ? "" : this.data[row][col] || "";
+ },
+ toggleOpenState(index) {},
+ cycleHeader(col) {},
+ selectionChanged() {},
+ cycleCell(row, column) {},
+ isEditable(row, column) {
+ return false;
+ },
+};
+
+// mmm, yummy. global variables.
+var gDocInfo = null;
+var gImageElement = null;
+
+// column number to help using the data array
+const COL_IMAGE_ADDRESS = 0;
+const COL_IMAGE_TYPE = 1;
+const COL_IMAGE_SIZE = 2;
+const COL_IMAGE_ALT = 3;
+const COL_IMAGE_COUNT = 4;
+const COL_IMAGE_NODE = 5;
+const COL_IMAGE_BG = 6;
+
+// column number to copy from, second argument to pageInfoTreeView's constructor
+const COPYCOL_NONE = -1;
+const COPYCOL_META_CONTENT = 1;
+const COPYCOL_IMAGE = COL_IMAGE_ADDRESS;
+
+// one nsITreeView for each tree in the window
+var gMetaView = new pageInfoTreeView("metatree", COPYCOL_META_CONTENT);
+var gImageView = new pageInfoTreeView("imagetree", COPYCOL_IMAGE);
+
+gImageView.getCellProperties = function (row, col) {
+ var data = gImageView.data[row];
+ var item = gImageView.data[row][COL_IMAGE_NODE];
+ var props = "";
+ if (
+ !checkProtocol(data) ||
+ HTMLEmbedElement.isInstance(item) ||
+ (HTMLObjectElement.isInstance(item) && !item.type.startsWith("image/"))
+ ) {
+ props += "broken";
+ }
+
+ if (col.element.id == "image-address") {
+ props += " ltr";
+ }
+
+ return props;
+};
+
+gImageView.onPageMediaSort = function (columnname) {
+ var tree = document.getElementById(this.treeid);
+ var treecol = tree.columns.getNamedColumn(columnname);
+
+ var comparator;
+ var index = treecol.index;
+ if (index == COL_IMAGE_SIZE || index == COL_IMAGE_COUNT) {
+ comparator = function numComparator(a, b) {
+ return a - b;
+ };
+ } else {
+ comparator = function textComparator(a, b) {
+ return (a || "").toLowerCase().localeCompare((b || "").toLowerCase());
+ };
+ }
+
+ this.sortdir = gTreeUtils.sort(
+ tree,
+ this,
+ this.data,
+ index,
+ comparator,
+ this.sortcol,
+ this.sortdir
+ );
+
+ for (let col of tree.columns) {
+ col.element.removeAttribute("sortActive");
+ col.element.removeAttribute("sortDirection");
+ }
+ treecol.element.setAttribute("sortActive", "true");
+ treecol.element.setAttribute(
+ "sortDirection",
+ this.sortdir ? "ascending" : "descending"
+ );
+
+ this.sortcol = index;
+};
+
+var gImageHash = {};
+
+// localized strings (will be filled in when the document is loaded)
+const MEDIA_STRINGS = {};
+let SIZE_UNKNOWN = "";
+let ALT_NOT_SET = "";
+
+// a number of services I'll need later
+// the cache services
+const nsICacheStorageService = Ci.nsICacheStorageService;
+const nsICacheStorage = Ci.nsICacheStorage;
+const cacheService = Cc[
+ "@mozilla.org/netwerk/cache-storage-service;1"
+].getService(nsICacheStorageService);
+
+var loadContextInfo = Services.loadContextInfo.fromLoadContext(
+ window.docShell.QueryInterface(Ci.nsILoadContext),
+ false
+);
+var diskStorage = cacheService.diskCacheStorage(loadContextInfo);
+
+const nsICookiePermission = Ci.nsICookiePermission;
+
+const nsICertificateDialogs = Ci.nsICertificateDialogs;
+const CERTIFICATEDIALOGS_CONTRACTID = "@mozilla.org/nsCertificateDialogs;1";
+
+// clipboard helper
+function getClipboardHelper() {
+ try {
+ return Cc["@mozilla.org/widget/clipboardhelper;1"].getService(
+ Ci.nsIClipboardHelper
+ );
+ } catch (e) {
+ // do nothing, later code will handle the error
+ return null;
+ }
+}
+const gClipboardHelper = getClipboardHelper();
+
+/* Called when PageInfo window is loaded. Arguments are:
+ * window.arguments[0] - (optional) an object consisting of
+ * - doc: (optional) document to use for source. if not provided,
+ * the calling window's document will be used
+ * - initialTab: (optional) id of the inital tab to display
+ */
+async function onLoadPageInfo() {
+ [
+ SIZE_UNKNOWN,
+ ALT_NOT_SET,
+ MEDIA_STRINGS.img,
+ MEDIA_STRINGS["bg-img"],
+ MEDIA_STRINGS["border-img"],
+ MEDIA_STRINGS["list-img"],
+ MEDIA_STRINGS.cursor,
+ MEDIA_STRINGS.object,
+ MEDIA_STRINGS.embed,
+ MEDIA_STRINGS.link,
+ MEDIA_STRINGS.input,
+ MEDIA_STRINGS.video,
+ MEDIA_STRINGS.audio,
+ ] = await document.l10n.formatValues([
+ "image-size-unknown",
+ "not-set-alternative-text",
+ "media-img",
+ "media-bg-img",
+ "media-border-img",
+ "media-list-img",
+ "media-cursor",
+ "media-object",
+ "media-embed",
+ "media-link",
+ "media-input",
+ "media-video",
+ "media-audio",
+ ]);
+
+ const args =
+ "arguments" in window &&
+ window.arguments.length >= 1 &&
+ window.arguments[0];
+
+ // Init media view
+ let imageTree = document.getElementById("imagetree");
+ imageTree.view = gImageView;
+
+ imageTree.controllers.appendController(treeController);
+
+ document
+ .getElementById("metatree")
+ .controllers.appendController(treeController);
+
+ // Select the requested tab, if the name is specified
+ await loadTab(args);
+
+ // Emit init event for tests
+ window.dispatchEvent(new Event("page-info-init"));
+}
+
+async function loadPageInfo(browsingContext, imageElement, browser) {
+ browser = browser || window.opener.gBrowser.selectedBrowser;
+ browsingContext = browsingContext || browser.browsingContext;
+
+ let actor = browsingContext.currentWindowGlobal.getActor("PageInfo");
+
+ let result = await actor.sendQuery("PageInfo:getData");
+ await onNonMediaPageInfoLoad(browser, result, imageElement);
+
+ // Here, we are walking the frame tree via BrowsingContexts to collect all of the
+ // media information for each frame
+ let contextsToVisit = [browsingContext];
+ while (contextsToVisit.length) {
+ let currContext = contextsToVisit.pop();
+ let global = currContext.currentWindowGlobal;
+
+ if (!global) {
+ continue;
+ }
+
+ let subframeActor = global.getActor("PageInfo");
+ let mediaResult = await subframeActor.sendQuery("PageInfo:getMediaData");
+ for (let item of mediaResult.mediaItems) {
+ addImage(item);
+ }
+ selectImage();
+ contextsToVisit.push(...currContext.children);
+ }
+}
+
+/**
+ * onNonMediaPageInfoLoad is responsible for populating the page info
+ * UI other than the media tab. This includes general, permissions, and security.
+ */
+async function onNonMediaPageInfoLoad(browser, pageInfoData, imageInfo) {
+ const { docInfo, windowInfo } = pageInfoData;
+ let uri = Services.io.newURI(docInfo.documentURIObject.spec);
+ let principal = docInfo.principal;
+ gDocInfo = docInfo;
+
+ gImageElement = imageInfo;
+ var titleFormat = windowInfo.isTopWindow
+ ? "page-info-page"
+ : "page-info-frame";
+ document.l10n.setAttributes(document.documentElement, titleFormat, {
+ website: docInfo.location,
+ });
+
+ document
+ .getElementById("main-window")
+ .setAttribute("relatedUrl", docInfo.location);
+
+ await makeGeneralTab(pageInfoData.metaViewRows, docInfo);
+ if (
+ uri.spec.startsWith("about:neterror") ||
+ uri.spec.startsWith("about:certerror") ||
+ uri.spec.startsWith("about:httpsonlyerror")
+ ) {
+ uri = browser.currentURI;
+ principal = Services.scriptSecurityManager.createContentPrincipal(
+ uri,
+ browser.contentPrincipal.originAttributes
+ );
+ }
+ onLoadPermission(uri, principal);
+ securityOnLoad(uri, windowInfo);
+}
+
+function resetPageInfo(args) {
+ /* Reset Meta tags part */
+ gMetaView.clear();
+
+ /* Reset Media tab */
+ var mediaTab = document.getElementById("mediaTab");
+ if (!mediaTab.hidden) {
+ mediaTab.hidden = true;
+ }
+ gImageView.clear();
+ gImageHash = {};
+
+ /* Rebuild the data */
+ loadTab(args);
+}
+
+function doHelpButton() {
+ const helpTopics = {
+ generalPanel: "pageinfo_general",
+ mediaPanel: "pageinfo_media",
+ permPanel: "pageinfo_permissions",
+ securityPanel: "pageinfo_security",
+ };
+
+ var deck = document.getElementById("mainDeck");
+ var helpdoc = helpTopics[deck.selectedPanel.id] || "pageinfo_general";
+ openHelpLink(helpdoc);
+}
+
+function showTab(id) {
+ var deck = document.getElementById("mainDeck");
+ var pagel = document.getElementById(id + "Panel");
+ deck.selectedPanel = pagel;
+}
+
+async function loadTab(args) {
+ // If the "View Image Info" context menu item was used, the related image
+ // element is provided as an argument. This can't be a background image.
+ let imageElement = args?.imageElement;
+ let browsingContext = args?.browsingContext;
+ let browser = args?.browser;
+
+ /* Load the page info */
+ await loadPageInfo(browsingContext, imageElement, browser);
+
+ var initialTab = args?.initialTab || "generalTab";
+ var radioGroup = document.getElementById("viewGroup");
+ initialTab =
+ document.getElementById(initialTab) ||
+ document.getElementById("generalTab");
+ radioGroup.selectedItem = initialTab;
+ radioGroup.selectedItem.doCommand();
+ radioGroup.focus({ focusVisible: false });
+}
+
+function openCacheEntry(key, cb) {
+ var checkCacheListener = {
+ onCacheEntryCheck(entry) {
+ return Ci.nsICacheEntryOpenCallback.ENTRY_WANTED;
+ },
+ onCacheEntryAvailable(entry, isNew, status) {
+ cb(entry);
+ },
+ };
+ diskStorage.asyncOpenURI(
+ Services.io.newURI(key),
+ "",
+ nsICacheStorage.OPEN_READONLY,
+ checkCacheListener
+ );
+}
+
+async function makeGeneralTab(metaViewRows, docInfo) {
+ // Sets Title in the General Tab, set to "Untitled Page" if no title found
+ if (docInfo.title) {
+ document.getElementById("titletext").value = docInfo.title;
+ } else {
+ document.l10n.setAttributes(
+ document.getElementById("titletext"),
+ "no-page-title"
+ );
+ }
+
+ var url = docInfo.location;
+ setItemValue("urltext", url);
+
+ var referrer = "referrer" in docInfo && docInfo.referrer;
+ setItemValue("refertext", referrer);
+
+ var mode =
+ "compatMode" in docInfo && docInfo.compatMode == "BackCompat"
+ ? "general-quirks-mode"
+ : "general-strict-mode";
+ document.l10n.setAttributes(document.getElementById("modetext"), mode);
+
+ // find out the mime type
+ setItemValue("typetext", docInfo.contentType);
+
+ // get the document characterset
+ var encoding = docInfo.characterSet;
+ document.getElementById("encodingtext").value = encoding;
+
+ let length = metaViewRows.length;
+
+ var metaGroup = document.getElementById("metaTags");
+ if (!length) {
+ metaGroup.style.visibility = "hidden";
+ } else {
+ document.l10n.setAttributes(
+ document.getElementById("metaTagsCaption"),
+ "general-meta-tags",
+ { tags: length }
+ );
+
+ document.getElementById("metatree").view = gMetaView;
+
+ // Add the metaViewRows onto the general tab's meta info tree.
+ gMetaView.addRows(metaViewRows);
+
+ metaGroup.style.removeProperty("visibility");
+ }
+
+ var modifiedText = formatDate(
+ docInfo.lastModified,
+ await document.l10n.formatValue("not-set-date")
+ );
+ document.getElementById("modifiedtext").value = modifiedText;
+
+ // get cache info
+ var cacheKey = url.replace(/#.*$/, "");
+ openCacheEntry(cacheKey, function (cacheEntry) {
+ if (cacheEntry) {
+ var pageSize = cacheEntry.dataSize;
+ var kbSize = formatNumber(Math.round((pageSize / 1024) * 100) / 100);
+ document.l10n.setAttributes(
+ document.getElementById("sizetext"),
+ "properties-general-size",
+ { kb: kbSize, bytes: formatNumber(pageSize) }
+ );
+ } else {
+ setItemValue("sizetext", null);
+ }
+ });
+}
+
+async function addImage({ url, type, alt, altNotProvided, element, isBg }) {
+ if (!url) {
+ return;
+ }
+
+ if (altNotProvided) {
+ alt = ALT_NOT_SET;
+ }
+
+ if (!gImageHash.hasOwnProperty(url)) {
+ gImageHash[url] = {};
+ }
+ if (!gImageHash[url].hasOwnProperty(type)) {
+ gImageHash[url][type] = {};
+ }
+ if (!gImageHash[url][type].hasOwnProperty(alt)) {
+ gImageHash[url][type][alt] = gImageView.data.length;
+ var row = [url, MEDIA_STRINGS[type], SIZE_UNKNOWN, alt, 1, element, isBg];
+ gImageView.addRow(row);
+
+ // Fill in cache data asynchronously
+ openCacheEntry(url, function (cacheEntry) {
+ // The data at row[2] corresponds to the data size.
+ if (cacheEntry) {
+ let value = cacheEntry.dataSize;
+ // If value is not -1 then replace with actual value, else keep as "unknown"
+ if (value != -1) {
+ let kbSize = Number(Math.round((value / 1024) * 100) / 100);
+ document.l10n
+ .formatValue("media-file-size", { size: kbSize })
+ .then(function (response) {
+ row[2] = response;
+ // Invalidate the row to trigger a repaint.
+ gImageView.tree.invalidateRow(gImageView.data.indexOf(row));
+ });
+ }
+ }
+ });
+
+ if (gImageView.data.length == 1) {
+ document.getElementById("mediaTab").hidden = false;
+ }
+ } else {
+ var i = gImageHash[url][type][alt];
+ gImageView.data[i][COL_IMAGE_COUNT]++;
+ // The same image can occur several times on the page at different sizes.
+ // If the "View Image Info" context menu item was used, ensure we select
+ // the correct element.
+ if (
+ !gImageView.data[i][COL_IMAGE_BG] &&
+ gImageElement &&
+ url == gImageElement.currentSrc &&
+ gImageElement.width == element.width &&
+ gImageElement.height == element.height &&
+ gImageElement.imageText == element.imageText
+ ) {
+ gImageView.data[i][COL_IMAGE_NODE] = element;
+ }
+ }
+}
+
+// Link Stuff
+function onBeginLinkDrag(event, urlField, descField) {
+ if (event.originalTarget.localName != "treechildren") {
+ return;
+ }
+
+ var tree = event.target;
+ if (tree.localName != "tree") {
+ tree = tree.parentNode;
+ }
+
+ var row = tree.getRowAt(event.clientX, event.clientY);
+ if (row == -1) {
+ return;
+ }
+
+ // Adding URL flavor
+ var col = tree.columns[urlField];
+ var url = tree.view.getCellText(row, col);
+ col = tree.columns[descField];
+ var desc = tree.view.getCellText(row, col);
+
+ var dt = event.dataTransfer;
+ dt.setData("text/x-moz-url", url + "\n" + desc);
+ dt.setData("text/url-list", url);
+ dt.setData("text/plain", url);
+}
+
+// Image Stuff
+function getSelectedRows(tree) {
+ var start = {};
+ var end = {};
+ var numRanges = tree.view.selection.getRangeCount();
+
+ var rowArray = [];
+ for (var t = 0; t < numRanges; t++) {
+ tree.view.selection.getRangeAt(t, start, end);
+ for (var v = start.value; v <= end.value; v++) {
+ rowArray.push(v);
+ }
+ }
+
+ return rowArray;
+}
+
+function getSelectedRow(tree) {
+ var rows = getSelectedRows(tree);
+ return rows.length == 1 ? rows[0] : -1;
+}
+
+async function selectSaveFolder(aCallback) {
+ const { nsIFile, nsIFilePicker } = Ci;
+ let titleText = await document.l10n.formatValue("media-select-folder");
+ let fp = Cc["@mozilla.org/filepicker;1"].createInstance(nsIFilePicker);
+ let fpCallback = function fpCallback_done(aResult) {
+ if (aResult == nsIFilePicker.returnOK) {
+ aCallback(fp.file.QueryInterface(nsIFile));
+ } else {
+ aCallback(null);
+ }
+ };
+
+ fp.init(window, titleText, nsIFilePicker.modeGetFolder);
+ fp.appendFilters(nsIFilePicker.filterAll);
+ try {
+ let initialDir = Services.prefs.getComplexValue(
+ "browser.download.dir",
+ nsIFile
+ );
+ if (initialDir) {
+ fp.displayDirectory = initialDir;
+ }
+ } catch (ex) {}
+ fp.open(fpCallback);
+}
+
+function saveMedia() {
+ var tree = document.getElementById("imagetree");
+ var rowArray = getSelectedRows(tree);
+ let ReferrerInfo = Components.Constructor(
+ "@mozilla.org/referrer-info;1",
+ "nsIReferrerInfo",
+ "init"
+ );
+
+ if (rowArray.length == 1) {
+ let row = rowArray[0];
+ let item = gImageView.data[row][COL_IMAGE_NODE];
+ let url = gImageView.data[row][COL_IMAGE_ADDRESS];
+
+ if (url) {
+ var titleKey = "SaveImageTitle";
+
+ if (HTMLVideoElement.isInstance(item)) {
+ titleKey = "SaveVideoTitle";
+ } else if (HTMLAudioElement.isInstance(item)) {
+ titleKey = "SaveAudioTitle";
+ }
+
+ // Bug 1565216 to evaluate passing referrer as item.baseURL
+ let referrerInfo = new ReferrerInfo(
+ Ci.nsIReferrerInfo.EMPTY,
+ true,
+ Services.io.newURI(item.baseURI)
+ );
+ let cookieJarSettings = E10SUtils.deserializeCookieJarSettings(
+ gDocInfo.cookieJarSettings
+ );
+ saveURL(
+ url,
+ null,
+ null,
+ titleKey,
+ false,
+ false,
+ referrerInfo,
+ cookieJarSettings,
+ null,
+ gDocInfo.isContentWindowPrivate,
+ gDocInfo.principal
+ );
+ }
+ } else {
+ selectSaveFolder(function (aDirectory) {
+ if (aDirectory) {
+ var saveAnImage = function (aURIString, aChosenData, aBaseURI) {
+ uniqueFile(aChosenData.file);
+
+ let referrerInfo = new ReferrerInfo(
+ Ci.nsIReferrerInfo.EMPTY,
+ true,
+ aBaseURI
+ );
+ let cookieJarSettings = E10SUtils.deserializeCookieJarSettings(
+ gDocInfo.cookieJarSettings
+ );
+ internalSave(
+ aURIString,
+ null,
+ null,
+ null,
+ null,
+ null,
+ false,
+ "SaveImageTitle",
+ aChosenData,
+ referrerInfo,
+ cookieJarSettings,
+ null,
+ false,
+ null,
+ gDocInfo.isContentWindowPrivate,
+ gDocInfo.principal
+ );
+ };
+
+ for (var i = 0; i < rowArray.length; i++) {
+ let v = rowArray[i];
+ let dir = aDirectory.clone();
+ let item = gImageView.data[v][COL_IMAGE_NODE];
+ let uriString = gImageView.data[v][COL_IMAGE_ADDRESS];
+ let uri = Services.io.newURI(uriString);
+
+ try {
+ uri.QueryInterface(Ci.nsIURL);
+ dir.append(decodeURIComponent(uri.fileName));
+ } catch (ex) {
+ // data:/blob: uris
+ // Supply a dummy filename, otherwise Download Manager
+ // will try to delete the base directory on failure.
+ dir.append(gImageView.data[v][COL_IMAGE_TYPE]);
+ }
+
+ if (i == 0) {
+ saveAnImage(
+ uriString,
+ new AutoChosen(dir, uri),
+ Services.io.newURI(item.baseURI)
+ );
+ } else {
+ // This delay is a hack which prevents the download manager
+ // from opening many times. See bug 377339.
+ setTimeout(
+ saveAnImage,
+ 200,
+ uriString,
+ new AutoChosen(dir, uri),
+ Services.io.newURI(item.baseURI)
+ );
+ }
+ }
+ }
+ });
+ }
+}
+
+function onImageSelect() {
+ var previewBox = document.getElementById("mediaPreviewBox");
+ var mediaSaveBox = document.getElementById("mediaSaveBox");
+ var splitter = document.getElementById("mediaSplitter");
+ var tree = document.getElementById("imagetree");
+ var count = tree.view.selection.count;
+ if (count == 0) {
+ previewBox.collapsed = true;
+ mediaSaveBox.collapsed = true;
+ splitter.collapsed = true;
+ tree.setAttribute("flex", "1");
+ } else if (count > 1) {
+ splitter.collapsed = true;
+ previewBox.collapsed = true;
+ mediaSaveBox.collapsed = false;
+ tree.setAttribute("flex", "1");
+ } else {
+ mediaSaveBox.collapsed = true;
+ splitter.collapsed = false;
+ previewBox.collapsed = false;
+ tree.setAttribute("flex", "0");
+ makePreview(getSelectedRows(tree)[0]);
+ }
+}
+
+// Makes the media preview (image, video, etc) for the selected row on the media tab.
+function makePreview(row) {
+ var item = gImageView.data[row][COL_IMAGE_NODE];
+ var url = gImageView.data[row][COL_IMAGE_ADDRESS];
+ var isBG = gImageView.data[row][COL_IMAGE_BG];
+ var isAudio = false;
+
+ setItemValue("imageurltext", url);
+ setItemValue("imagetext", item.imageText);
+ setItemValue("imagelongdesctext", item.longDesc);
+
+ // get cache info
+ var cacheKey = url.replace(/#.*$/, "");
+ openCacheEntry(cacheKey, function (cacheEntry) {
+ // find out the file size
+ if (cacheEntry) {
+ let imageSize = cacheEntry.dataSize;
+ var kbSize = Math.round((imageSize / 1024) * 100) / 100;
+ document.l10n.setAttributes(
+ document.getElementById("imagesizetext"),
+ "properties-general-size",
+ { kb: formatNumber(kbSize), bytes: formatNumber(imageSize) }
+ );
+ } else {
+ document.l10n.setAttributes(
+ document.getElementById("imagesizetext"),
+ "media-unknown-not-cached"
+ );
+ }
+
+ var mimeType = item.mimeType || this.getContentTypeFromHeaders(cacheEntry);
+ var numFrames = item.numFrames;
+
+ let element = document.getElementById("imagetypetext");
+ var imageType;
+ if (mimeType) {
+ // We found the type, try to display it nicely
+ let imageMimeType = /^image\/(.*)/i.exec(mimeType);
+ if (imageMimeType) {
+ imageType = imageMimeType[1].toUpperCase();
+ if (numFrames > 1) {
+ document.l10n.setAttributes(element, "media-animated-image-type", {
+ type: imageType,
+ frames: numFrames,
+ });
+ } else {
+ document.l10n.setAttributes(element, "media-image-type", {
+ type: imageType,
+ });
+ }
+ } else {
+ // the MIME type doesn't begin with image/, display the raw type
+ element.setAttribute("value", mimeType);
+ element.removeAttribute("data-l10n-id");
+ }
+ } else {
+ // We couldn't find the type, fall back to the value in the treeview
+ element.setAttribute("value", gImageView.data[row][COL_IMAGE_TYPE]);
+ element.removeAttribute("data-l10n-id");
+ }
+
+ var imageContainer = document.getElementById("theimagecontainer");
+ var oldImage = document.getElementById("thepreviewimage");
+
+ var isProtocolAllowed = checkProtocol(gImageView.data[row]);
+
+ var newImage = new Image();
+ newImage.id = "thepreviewimage";
+ var physWidth = 0,
+ physHeight = 0;
+ var width = 0,
+ height = 0;
+
+ let triggeringPrinStr = E10SUtils.serializePrincipal(gDocInfo.principal);
+ if (
+ (item.HTMLLinkElement ||
+ item.HTMLInputElement ||
+ item.HTMLImageElement ||
+ item.SVGImageElement ||
+ (item.HTMLObjectElement && mimeType && mimeType.startsWith("image/")) ||
+ isBG) &&
+ isProtocolAllowed
+ ) {
+ function loadOrErrorListener() {
+ newImage.removeEventListener("load", loadOrErrorListener);
+ newImage.removeEventListener("error", loadOrErrorListener);
+ physWidth = newImage.width || 0;
+ physHeight = newImage.height || 0;
+
+ // "width" and "height" attributes must be set to newImage,
+ // even if there is no "width" or "height attribute in item;
+ // otherwise, the preview image cannot be displayed correctly.
+ // Since the image might have been loaded out-of-process, we expect
+ // the item to tell us its width / height dimensions. Failing that
+ // the item should tell us the natural dimensions of the image. Finally
+ // failing that, we'll assume that the image was never loaded in the
+ // other process (this can be true for favicons, for example), and so
+ // we'll assume that we can use the natural dimensions of the newImage
+ // we just created. If the natural dimensions of newImage are not known
+ // then the image is probably broken.
+ if (!isBG) {
+ newImage.width =
+ ("width" in item && item.width) || newImage.naturalWidth;
+ newImage.height =
+ ("height" in item && item.height) || newImage.naturalHeight;
+ } else {
+ // the Width and Height of an HTML tag should not be used for its background image
+ // (for example, "table" can have "width" or "height" attributes)
+ newImage.width = item.naturalWidth || newImage.naturalWidth;
+ newImage.height = item.naturalHeight || newImage.naturalHeight;
+ }
+
+ if (item.SVGImageElement) {
+ newImage.width = item.SVGImageElementWidth;
+ newImage.height = item.SVGImageElementHeight;
+ }
+
+ width = newImage.width;
+ height = newImage.height;
+
+ document.getElementById("theimagecontainer").collapsed = false;
+ document.getElementById("brokenimagecontainer").collapsed = true;
+
+ if (url) {
+ if (width != physWidth || height != physHeight) {
+ document.l10n.setAttributes(
+ document.getElementById("imagedimensiontext"),
+ "media-dimensions-scaled",
+ {
+ dimx: formatNumber(physWidth),
+ dimy: formatNumber(physHeight),
+ scaledx: formatNumber(width),
+ scaledy: formatNumber(height),
+ }
+ );
+ } else {
+ document.l10n.setAttributes(
+ document.getElementById("imagedimensiontext"),
+ "media-dimensions",
+ { dimx: formatNumber(width), dimy: formatNumber(height) }
+ );
+ }
+ }
+ }
+
+ // We need to wait for the image to finish loading before using width & height
+ newImage.addEventListener("load", loadOrErrorListener);
+ newImage.addEventListener("error", loadOrErrorListener);
+
+ newImage.setAttribute("triggeringprincipal", triggeringPrinStr);
+ newImage.setAttribute("src", url);
+ } else {
+ // Handle the case where newImage is not used for width & height
+ if (item.HTMLVideoElement && isProtocolAllowed) {
+ newImage = document.createElement("video");
+ newImage.id = "thepreviewimage";
+ newImage.setAttribute("triggeringprincipal", triggeringPrinStr);
+ newImage.src = url;
+ newImage.controls = true;
+ width = physWidth = item.videoWidth;
+ height = physHeight = item.videoHeight;
+
+ document.getElementById("theimagecontainer").collapsed = false;
+ document.getElementById("brokenimagecontainer").collapsed = true;
+ } else if (item.HTMLAudioElement && isProtocolAllowed) {
+ newImage = new Audio();
+ newImage.id = "thepreviewimage";
+ newImage.setAttribute("triggeringprincipal", triggeringPrinStr);
+ newImage.src = url;
+ newImage.controls = true;
+ isAudio = true;
+
+ document.getElementById("theimagecontainer").collapsed = false;
+ document.getElementById("brokenimagecontainer").collapsed = true;
+ } else {
+ // fallback image for protocols not allowed (e.g., javascript:)
+ // or elements not [yet] handled (e.g., object, embed).
+ document.getElementById("brokenimagecontainer").collapsed = false;
+ document.getElementById("theimagecontainer").collapsed = true;
+ }
+
+ if (url && !isAudio) {
+ document.l10n.setAttributes(
+ document.getElementById("imagedimensiontext"),
+ "media-dimensions",
+ { dimx: formatNumber(width), dimy: formatNumber(height) }
+ );
+ }
+ }
+
+ imageContainer.removeChild(oldImage);
+ imageContainer.appendChild(newImage);
+ });
+}
+
+function getContentTypeFromHeaders(cacheEntryDescriptor) {
+ if (!cacheEntryDescriptor) {
+ return null;
+ }
+
+ let headers = cacheEntryDescriptor.getMetaDataElement("response-head");
+ let type = /^Content-Type:\s*(.*?)\s*(?:\;|$)/im.exec(headers);
+ return type && type[1];
+}
+
+function setItemValue(id, value) {
+ var item = document.getElementById(id);
+ item.closest("tr").hidden = !value;
+ if (value) {
+ item.value = value;
+ }
+}
+
+function formatNumber(number) {
+ return (+number).toLocaleString(); // coerce number to a numeric value before calling toLocaleString()
+}
+
+function formatDate(datestr, unknown) {
+ var date = new Date(datestr);
+ if (!date.valueOf()) {
+ return unknown;
+ }
+
+ const dateTimeFormatter = new Services.intl.DateTimeFormat(undefined, {
+ dateStyle: "long",
+ timeStyle: "long",
+ });
+ return dateTimeFormatter.format(date);
+}
+
+let treeController = {
+ supportsCommand(command) {
+ return command == "cmd_copy" || command == "cmd_selectAll";
+ },
+
+ isCommandEnabled(command) {
+ return true; // not worth checking for this
+ },
+
+ doCommand(command) {
+ switch (command) {
+ case "cmd_copy":
+ doCopy();
+ break;
+ case "cmd_selectAll":
+ document.activeElement.view.selection.selectAll();
+ break;
+ }
+ },
+};
+
+function doCopy() {
+ if (!gClipboardHelper) {
+ return;
+ }
+
+ var elem = document.commandDispatcher.focusedElement;
+
+ if (elem && elem.localName == "tree") {
+ var view = elem.view;
+ var selection = view.selection;
+ var text = [],
+ tmp = "";
+ var min = {},
+ max = {};
+
+ var count = selection.getRangeCount();
+
+ for (var i = 0; i < count; i++) {
+ selection.getRangeAt(i, min, max);
+
+ for (var row = min.value; row <= max.value; row++) {
+ tmp = view.getCellValue(row, null);
+ if (tmp) {
+ text.push(tmp);
+ }
+ }
+ }
+ gClipboardHelper.copyString(text.join("\n"));
+ }
+}
+
+function doSelectAllMedia() {
+ var tree = document.getElementById("imagetree");
+
+ if (tree) {
+ tree.view.selection.selectAll();
+ }
+}
+
+function selectImage() {
+ if (!gImageElement) {
+ return;
+ }
+
+ var tree = document.getElementById("imagetree");
+ for (var i = 0; i < tree.view.rowCount; i++) {
+ // If the image row element is the image selected from the "View Image Info" context menu item.
+ let image = gImageView.data[i][COL_IMAGE_NODE];
+ if (
+ !gImageView.data[i][COL_IMAGE_BG] &&
+ gImageElement.currentSrc == gImageView.data[i][COL_IMAGE_ADDRESS] &&
+ gImageElement.width == image.width &&
+ gImageElement.height == image.height &&
+ gImageElement.imageText == image.imageText
+ ) {
+ tree.view.selection.select(i);
+ tree.ensureRowIsVisible(i);
+ tree.focus();
+ return;
+ }
+ }
+}
+
+function checkProtocol(img) {
+ var url = img[COL_IMAGE_ADDRESS];
+ return (
+ /^data:image\//i.test(url) ||
+ /^(https?|file|about|chrome|resource):/.test(url)
+ );
+}
diff --git a/browser/base/content/pageinfo/pageInfo.xhtml b/browser/base/content/pageinfo/pageInfo.xhtml
new file mode 100644
index 0000000000..5e4c893b22
--- /dev/null
+++ b/browser/base/content/pageinfo/pageInfo.xhtml
@@ -0,0 +1,411 @@
+<?xml version="1.0"?>
+# 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/.
+
+<?xml-stylesheet href="chrome://browser/content/pageinfo/pageInfo.css" type="text/css"?>
+<?xml-stylesheet href="chrome://browser/skin/pageInfo.css" type="text/css"?>
+
+<window id="main-window"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ data-l10n-id="page-info-window"
+ data-l10n-attrs="style"
+ windowtype="Browser:page-info"
+ onload="onLoadPageInfo()"
+ align="stretch"
+ screenX="10" screenY="10"
+ persist="screenX screenY width height sizemode">
+
+ <linkset>
+ <html:link rel="localization" href="browser/pageInfo.ftl"/>
+ </linkset>
+ #ifdef XP_MACOSX
+ #include ../macWindow.inc.xhtml
+ #else
+ <script src="chrome://global/content/globalOverlay.js"/>
+ <script src="chrome://global/content/editMenuOverlay.js"/>
+ <script src="chrome://browser/content/utilityOverlay.js"/>
+ #endif
+ <script src="chrome://global/content/contentAreaUtils.js"/>
+ <script src="chrome://global/content/treeUtils.js"/>
+ <script src="chrome://browser/content/pageinfo/pageInfo.js"/>
+ <script src="chrome://browser/content/pageinfo/permissions.js"/>
+ <script src="chrome://browser/content/pageinfo/security.js"/>
+
+ <stringbundleset id="pageinfobundleset">
+ <stringbundle id="pkiBundle" src="chrome://pippki/locale/pippki.properties"/>
+ <stringbundle id="browserBundle" src="chrome://browser/locale/browser.properties"/>
+ </stringbundleset>
+
+ <commandset id="pageInfoCommandSet">
+ <command id="cmd_close" oncommand="window.close();"/>
+ <command id="cmd_help" oncommand="doHelpButton();"/>
+ </commandset>
+
+ <keyset id="pageInfoKeySet">
+ <key data-l10n-id="close-dialog" data-l10n-attrs="key" modifiers="accel" command="cmd_close"/>
+ <key keycode="VK_ESCAPE" command="cmd_close"/>
+#ifdef XP_MACOSX
+ <key key="." modifiers="meta" command="cmd_close"/>
+#else
+ <key keycode="VK_F1" command="cmd_help"/>
+#endif
+ <key data-l10n-id="copy" data-l10n-attrs="key" modifiers="accel" command="cmd_copy"/>
+ <key data-l10n-id="select-all" data-l10n-attrs="key" modifiers="accel" command="cmd_selectAll"/>
+ <key data-l10n-id="select-all" data-l10n-attrs="key" modifiers="alt" command="cmd_selectAll"/>
+ </keyset>
+
+ <menupopup id="picontext">
+ <menuitem id="menu_selectall" data-l10n-id="menu-select-all" command="cmd_selectAll"/>
+ <menuitem id="menu_copy" data-l10n-id="menu-copy" command="cmd_copy"/>
+ </menupopup>
+
+ <vbox id="topBar">
+ <radiogroup id="viewGroup" class="chromeclass-toolbar" orient="horizontal">
+ <radio id="generalTab" data-l10n-id="general-tab"
+ oncommand="showTab('general');"/>
+ <radio id="mediaTab" data-l10n-id="media-tab"
+ oncommand="showTab('media');" hidden="true"/>
+ <radio id="permTab" data-l10n-id="perm-tab"
+ oncommand="showTab('perm');"/>
+ <radio id="securityTab" data-l10n-id="security-tab"
+ oncommand="showTab('security');"/>
+ </radiogroup>
+ </vbox>
+
+ <deck id="mainDeck" flex="1">
+ <!-- General page information -->
+ <vbox id="generalPanel">
+ <table id="generalTable" xmlns="http://www.w3.org/1999/xhtml">
+ <tr id="generalTitle">
+ <th>
+ <xul:label control="titletext" data-l10n-id="general-title"/>
+ </th>
+ <td>
+ <input readonly="readonly" id="titletext" data-l10n-attrs="value"/>
+ </td>
+ </tr>
+ <tr id="generalURLRow">
+ <th>
+ <xul:label control="urltext" data-l10n-id="general-url"/>
+ </th>
+ <td>
+ <input readonly="readonly" id="urltext"/>
+ </td>
+ </tr>
+ <tr class="tableSeparator"/>
+ <tr id="generalTypeRow">
+ <th>
+ <xul:label control="typetext" data-l10n-id="general-type"/>
+ </th>
+ <td>
+ <input readonly="readonly" id="typetext"/>
+ </td>
+ </tr>
+ <tr id="generalModeRow">
+ <th>
+ <xul:label control="modetext" data-l10n-id="general-mode"/>
+ </th>
+ <td>
+ <input readonly="readonly" id="modetext" data-l10n-attrs="value"/>
+ </td>
+ </tr>
+ <tr id="generalEncodingRow">
+ <th>
+ <xul:label control="encodingtext" data-l10n-id="general-encoding"/>
+ </th>
+ <td>
+ <input readonly="readonly" id="encodingtext"/>
+ </td>
+ </tr>
+ <tr id="generalSizeRow">
+ <th>
+ <xul:label control="sizetext" data-l10n-id="general-size"/>
+ </th>
+ <td>
+ <input readonly="readonly" id="sizetext" data-l10n-attrs="value"/>
+ </td>
+ </tr>
+ <tr id="generalReferrerRow">
+ <th>
+ <xul:label control="refertext" data-l10n-id="general-referrer"/>
+ </th>
+ <td>
+ <input readonly="readonly" id="refertext"/>
+ </td>
+ </tr>
+ <tr class="tableSeparator"/>
+ <tr id="generalModifiedRow">
+ <th>
+ <xul:label control="modifiedtext" data-l10n-id="general-modified"/>
+ </th>
+ <td>
+ <input readonly="readonly" id="modifiedtext"/>
+ </td>
+ </tr>
+ </table>
+ <separator class="thin"/>
+ <vbox id="metaTags" flex="1">
+ <label control="metatree" id="metaTagsCaption" class="header"/>
+ <tree id="metatree" flex="1" hidecolumnpicker="true" contextmenu="picontext">
+ <treecols>
+ <treecol id="meta-name" data-l10n-id="general-meta-name"
+ persist="width" style="flex: 1 auto;"
+ onclick="gMetaView.onPageMediaSort('meta-name');"/>
+ <splitter class="tree-splitter"/>
+ <treecol id="meta-content" data-l10n-id="general-meta-content"
+ persist="width" style="flex: 4 4 auto"
+ onclick="gMetaView.onPageMediaSort('meta-content');"/>
+ </treecols>
+ <treechildren id="metatreechildren" flex="1"/>
+ </tree>
+ </vbox>
+ <hbox pack="end">
+ <button command="cmd_help" data-l10n-id="help-button" class="help-button"/>
+ </hbox>
+ </vbox>
+
+ <!-- Media information -->
+ <vbox id="mediaPanel">
+ <tree id="imagetree" onselect="onImageSelect();" contextmenu="picontext"
+ ondragstart="onBeginLinkDrag(event, 'image-address', 'image-alt')">
+ <treecols>
+ <treecol primary="true" persist="width" style="flex: 10 10 auto"
+ width="10" id="image-address" data-l10n-id="media-address"
+ onclick="gImageView.onPageMediaSort('image-address');"/>
+ <splitter class="tree-splitter"/>
+ <treecol persist="hidden width" style="flex: 2 2 auto"
+ width="2" id="image-type" data-l10n-id="media-type"
+ onclick="gImageView.onPageMediaSort('image-type');"/>
+ <splitter class="tree-splitter"/>
+ <treecol hidden="true" persist="hidden width" style="flex: 2 2 auto"
+ width="2" id="image-size" data-l10n-id="media-size" value="size"
+ onclick="gImageView.onPageMediaSort('image-size');"/>
+ <splitter class="tree-splitter"/>
+ <treecol hidden="true" persist="hidden width" style="flex: 4 4 auto"
+ width="4" id="image-alt" data-l10n-id="media-alt-header"
+ onclick="gImageView.onPageMediaSort('image-alt');"/>
+ <splitter class="tree-splitter"/>
+ <treecol hidden="true" persist="hidden width" style="flex: 1 1 auto"
+ width="1" id="image-count" data-l10n-id="media-count"
+ onclick="gImageView.onPageMediaSort('image-count');"/>
+ </treecols>
+ <treechildren id="imagetreechildren" flex="1"/>
+ </tree>
+ <splitter orient="vertical" id="mediaSplitter" resizebefore="sibling" resizeafter="none" />
+ <vbox flex="1" id="mediaPreviewBox" collapsed="true">
+ <table id="mediaTable" xmlns="http://www.w3.org/1999/xhtml">
+ <tr id="mediaLocationRow">
+ <th>
+ <xul:label control="imageurltext" data-l10n-id="media-location"/>
+ </th>
+ <td>
+ <input readonly="readonly" id="imageurltext"/>
+ </td>
+ </tr>
+ <tr id="mediaTypeRow">
+ <th>
+ <xul:label control="imagetypetext" data-l10n-id="general-type"/>
+ </th>
+ <td>
+ <input id="imagetypetext" data-l10n-attrs="value"/>
+ </td>
+ </tr>
+ <tr id="mediaSizeRow">
+ <th>
+ <xul:label control="imagesizetext" data-l10n-id="general-size"/>
+ </th>
+ <td>
+ <input readonly="readonly" id="imagesizetext" data-l10n-attrs="value"/>
+ </td>
+ </tr>
+ <tr id="mediaDimensionRow">
+ <th>
+ <xul:label control="imagedimensiontext" data-l10n-id="media-dimension"/>
+ </th>
+ <td>
+ <input readonly="readonly" id="imagedimensiontext" data-l10n-attrs="value"/>
+ </td>
+ </tr>
+ <tr id="mediaTextRow">
+ <th>
+ <xul:label control="imagetext" data-l10n-id="media-text"/>
+ </th>
+ <td>
+ <input readonly="readonly" id="imagetext"/>
+ </td>
+ </tr>
+ <tr id="mediaLongdescRow">
+ <th>
+ <xul:label control="imagelongdesctext" data-l10n-id="media-long-desc"/>
+ </th>
+ <td>
+ <input readonly="readonly" id="imagelongdesctext"/>
+ </td>
+ </tr>
+ </table>
+ <hbox id="imageSaveBox" align="end">
+ <spacer id="imageSaveBoxSpacer" flex="1"/>
+ <button data-l10n-id="media-select-all"
+ id="selectallbutton"
+ oncommand="doSelectAllMedia();"/>
+ <button data-l10n-id="media-save-as"
+ id="imagesaveasbutton"
+ oncommand="saveMedia();"/>
+ </hbox>
+ <vbox id="imagecontainerbox" flex="1">
+ <hbox id="theimagecontainer">
+ <image id="thepreviewimage"/>
+ </hbox>
+ <hbox id="brokenimagecontainer" pack="center" collapsed="true">
+ <image id="brokenimage" src="resource://gre-resources/broken-image.png"/>
+ </hbox>
+ </vbox>
+ </vbox>
+ <hbox id="mediaSaveBox" collapsed="true">
+ <spacer id="mediaSaveBoxSpacer" flex="1"/>
+ <button data-l10n-id="media-save-image-as"
+ id="mediasaveasbutton"
+ oncommand="saveMedia();"/>
+ </hbox>
+ <hbox pack="end">
+ <button command="cmd_help" data-l10n-id="help-button" class="help-button"/>
+ </hbox>
+ </vbox>
+
+ <!-- Permissions -->
+ <vbox id="permPanel">
+ <hbox id="permHostBox">
+ <label data-l10n-id="permissions-for" control="hostText" />
+ <html:input id="hostText" class="header" readonly="readonly"/>
+ </hbox>
+
+ <vbox id="permList" flex="1"/>
+ <hbox pack="end">
+ <button command="cmd_help" data-l10n-id="help-button" class="help-button"/>
+ </hbox>
+ </vbox>
+
+ <!-- Security & Privacy -->
+ <vbox id="securityPanel">
+ <!-- Identity Section -->
+ <groupbox>
+ <label class="header" data-l10n-id="security-view-identity"/>
+ <table xmlns="http://www.w3.org/1999/xhtml">
+ <!-- Domain -->
+ <tr>
+ <th>
+ <xul:label data-l10n-id="security-view-identity-domain"
+ control="security-identity-domain-value"/>
+ </th>
+ <td>
+ <input id="security-identity-domain-value" readonly="readonly"/>
+ </td>
+ </tr>
+ <!-- Owner -->
+ <tr>
+ <th>
+ <xul:label id="security-identity-owner-label"
+ class="fieldLabel"
+ data-l10n-id="security-view-identity-owner"
+ control="security-identity-owner-value"/>
+ </th>
+ <td>
+ <input id="security-identity-owner-value" readonly="readonly" data-l10n-attrs="value"/>
+ </td>
+ </tr>
+ <!-- Verifier -->
+ <tr>
+ <th>
+ <xul:label data-l10n-id="security-view-identity-verifier"
+ control="security-identity-verifier-value"/>
+ </th>
+ <td>
+ <div class="table-split-column">
+ <input id="security-identity-verifier-value" readonly="readonly"
+ data-l10n-attrs="value"/>
+ <xul:button id="security-view-cert" data-l10n-id="security-view"
+ collapsed="true"
+ oncommand="security.viewCert();"/>
+ </div>
+ </td>
+ </tr>
+ <!-- Certificate Validity -->
+ <tr id="security-identity-validity-row">
+ <th>
+ <xul:label data-l10n-id="security-view-identity-validity"
+ control="security-identity-validity-value"/>
+ </th>
+ <td>
+ <input id="security-identity-validity-value" readonly="readonly"/>
+ </td>
+ </tr>
+ </table>
+ </groupbox>
+
+ <!-- Privacy & History section -->
+ <groupbox>
+ <label class="header" data-l10n-id="security-view-privacy"/>
+ <table id="securityTable" xmlns="http://www.w3.org/1999/xhtml">
+ <!-- History -->
+ <tr>
+ <th>
+ <xul:label control="security-privacy-history-value" data-l10n-id="security-view-privacy-history-value"/>
+ </th>
+ <td>
+ <xul:label id="security-privacy-history-value"
+ data-l10n-id="security-view-unknown"/>
+ </td>
+ </tr>
+ <!-- Site Data & Cookies -->
+ <tr id="security-privacy-sitedata-row">
+ <th>
+ <xul:label control="security-privacy-sitedata-value" data-l10n-id="security-view-privacy-sitedata-value"/>
+ </th>
+ <td>
+ <div class="table-split-column">
+ <xul:label id="security-privacy-sitedata-value" data-l10n-id="security-view-unknown"/>
+ <xul:button id="security-clear-sitedata"
+ disabled="true"
+ data-l10n-id="security-view-privacy-clearsitedata"
+ oncommand="security.clearSiteData();"/>
+ </div>
+ </td>
+ </tr>
+ <!-- Passwords -->
+ <tr>
+ <th>
+ <xul:label control="security-privacy-passwords-value" data-l10n-id="security-view-privacy-passwords-value"/>
+ </th>
+ <td>
+ <div class="table-split-column">
+ <xul:label id="security-privacy-passwords-value"
+ data-l10n-id="security-view-unknown"/>
+ <xul:button id="security-view-password"
+ data-l10n-id="security-view-privacy-viewpasswords"
+ oncommand="security.viewPasswords();"/>
+ </div>
+ </td>
+ </tr>
+ </table>
+ </groupbox>
+
+ <!-- Technical Details section -->
+ <groupbox>
+ <label class="header" data-l10n-id="security-view-technical"/>
+ <label id="security-technical-shortform"/>
+ <description id="security-technical-longform1"/>
+ <description id="security-technical-longform2"/>
+ <description id="security-technical-certificate-transparency"/>
+ </groupbox>
+
+ <hbox pack="end">
+ <button command="cmd_help" data-l10n-id="help-button" class="help-button"/>
+ </hbox>
+ </vbox>
+ <!-- Others added by overlay -->
+ </deck>
+
+</window>
diff --git a/browser/base/content/pageinfo/permissions.js b/browser/base/content/pageinfo/permissions.js
new file mode 100644
index 0000000000..7834e27c98
--- /dev/null
+++ b/browser/base/content/pageinfo/permissions.js
@@ -0,0 +1,240 @@
+/* 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/. */
+
+/* import-globals-from pageInfo.js */
+
+const { SitePermissions } = ChromeUtils.importESModule(
+ "resource:///modules/SitePermissions.sys.mjs"
+);
+
+var gPermPrincipal;
+
+// List of ids of permissions to hide.
+const EXCLUDE_PERMS = ["open-protocol-handler"];
+
+// Array of permissionIDs sorted alphabetically by label.
+let gPermissions = SitePermissions.listPermissions()
+ .filter(permissionID => {
+ if (!SitePermissions.getPermissionLabel(permissionID)) {
+ return false;
+ }
+ return !EXCLUDE_PERMS.includes(permissionID);
+ })
+ .sort((a, b) => {
+ let firstLabel = SitePermissions.getPermissionLabel(a);
+ let secondLabel = SitePermissions.getPermissionLabel(b);
+ return firstLabel.localeCompare(secondLabel);
+ });
+
+var permissionObserver = {
+ observe(aSubject, aTopic, aData) {
+ if (aTopic == "perm-changed") {
+ var permission = aSubject.QueryInterface(Ci.nsIPermission);
+ if (
+ permission.matches(gPermPrincipal, true) &&
+ gPermissions.includes(permission.type)
+ ) {
+ initRow(permission.type);
+ }
+ }
+ },
+};
+
+function getExcludedPermissions() {
+ return EXCLUDE_PERMS;
+}
+
+function onLoadPermission(uri, principal) {
+ var permTab = document.getElementById("permTab");
+ if (SitePermissions.isSupportedPrincipal(principal)) {
+ gPermPrincipal = principal;
+ var hostText = document.getElementById("hostText");
+ hostText.value = uri.displayPrePath;
+
+ for (var i of gPermissions) {
+ initRow(i);
+ }
+ Services.obs.addObserver(permissionObserver, "perm-changed");
+ window.addEventListener("unload", onUnloadPermission);
+ permTab.hidden = false;
+ } else {
+ permTab.hidden = true;
+ }
+}
+
+function onUnloadPermission() {
+ Services.obs.removeObserver(permissionObserver, "perm-changed");
+}
+
+function initRow(aPartId) {
+ createRow(aPartId);
+
+ var checkbox = document.getElementById(aPartId + "Def");
+ var command = document.getElementById("cmd_" + aPartId + "Toggle");
+ var { state, scope } = SitePermissions.getForPrincipal(
+ gPermPrincipal,
+ aPartId
+ );
+ let defaultState = SitePermissions.getDefault(aPartId);
+
+ // Since cookies preferences have many different possible configuration states
+ // we don't consider any permission except "no permission" to be default.
+ if (aPartId == "cookie") {
+ state = Services.perms.testPermissionFromPrincipal(
+ gPermPrincipal,
+ "cookie"
+ );
+
+ if (state == SitePermissions.UNKNOWN) {
+ checkbox.checked = true;
+ command.setAttribute("disabled", "true");
+ // Don't select any item in the radio group, as we can't
+ // confidently say that all cookies on the site will be allowed.
+ let radioGroup = document.getElementById("cookieRadioGroup");
+ radioGroup.selectedItem = null;
+ } else {
+ checkbox.checked = false;
+ command.removeAttribute("disabled");
+ }
+
+ setRadioState(aPartId, state);
+
+ checkbox.disabled = Services.prefs.prefIsLocked(
+ "network.cookie.cookieBehavior"
+ );
+
+ return;
+ }
+
+ if (state != defaultState) {
+ checkbox.checked = false;
+ command.removeAttribute("disabled");
+ } else {
+ checkbox.checked = true;
+ command.setAttribute("disabled", "true");
+ }
+
+ if (
+ [SitePermissions.SCOPE_POLICY, SitePermissions.SCOPE_GLOBAL].includes(scope)
+ ) {
+ checkbox.setAttribute("disabled", "true");
+ command.setAttribute("disabled", "true");
+ }
+
+ setRadioState(aPartId, state);
+
+ switch (aPartId) {
+ case "install":
+ checkbox.disabled = !Services.policies.isAllowed("xpinstall");
+ break;
+ case "popup":
+ checkbox.disabled = Services.prefs.prefIsLocked(
+ "dom.disable_open_during_load"
+ );
+ break;
+ case "autoplay-media":
+ checkbox.disabled = Services.prefs.prefIsLocked("media.autoplay.default");
+ break;
+ case "geo":
+ case "desktop-notification":
+ case "camera":
+ case "microphone":
+ case "xr":
+ checkbox.disabled = Services.prefs.prefIsLocked(
+ "permissions.default." + aPartId
+ );
+ break;
+ }
+}
+
+function createRow(aPartId) {
+ let rowId = "perm-" + aPartId + "-row";
+ if (document.getElementById(rowId)) {
+ return;
+ }
+
+ let commandId = "cmd_" + aPartId + "Toggle";
+ let labelId = "perm-" + aPartId + "-label";
+ let radiogroupId = aPartId + "RadioGroup";
+
+ let command = document.createXULElement("command");
+ command.setAttribute("id", commandId);
+ command.setAttribute("oncommand", "onRadioClick('" + aPartId + "');");
+ document.getElementById("pageInfoCommandSet").appendChild(command);
+
+ let row = document.createXULElement("vbox");
+ row.setAttribute("id", rowId);
+ row.setAttribute("class", "permission");
+
+ let label = document.createXULElement("label");
+ label.setAttribute("id", labelId);
+ label.setAttribute("control", radiogroupId);
+ label.setAttribute("value", SitePermissions.getPermissionLabel(aPartId));
+ label.setAttribute("class", "permissionLabel");
+ row.appendChild(label);
+
+ let controls = document.createXULElement("hbox");
+ controls.setAttribute("role", "group");
+ controls.setAttribute("aria-labelledby", labelId);
+
+ let checkbox = document.createXULElement("checkbox");
+ checkbox.setAttribute("id", aPartId + "Def");
+ checkbox.setAttribute("oncommand", "onCheckboxClick('" + aPartId + "');");
+ checkbox.setAttribute("native", true);
+ document.l10n.setAttributes(checkbox, "permissions-use-default");
+ controls.appendChild(checkbox);
+
+ let spacer = document.createXULElement("spacer");
+ spacer.setAttribute("flex", "1");
+ controls.appendChild(spacer);
+
+ let radiogroup = document.createXULElement("radiogroup");
+ radiogroup.setAttribute("id", radiogroupId);
+ radiogroup.setAttribute("orient", "horizontal");
+ for (let state of SitePermissions.getAvailableStates(aPartId)) {
+ let radio = document.createXULElement("radio");
+ radio.setAttribute("id", aPartId + "#" + state);
+ radio.setAttribute(
+ "label",
+ SitePermissions.getMultichoiceStateLabel(aPartId, state)
+ );
+ radio.setAttribute("command", commandId);
+ radiogroup.appendChild(radio);
+ }
+ controls.appendChild(radiogroup);
+
+ row.appendChild(controls);
+
+ document.getElementById("permList").appendChild(row);
+}
+
+function onCheckboxClick(aPartId) {
+ var command = document.getElementById("cmd_" + aPartId + "Toggle");
+ var checkbox = document.getElementById(aPartId + "Def");
+ if (checkbox.checked) {
+ SitePermissions.removeFromPrincipal(gPermPrincipal, aPartId);
+ command.setAttribute("disabled", "true");
+ } else {
+ onRadioClick(aPartId);
+ command.removeAttribute("disabled");
+ }
+}
+
+function onRadioClick(aPartId) {
+ var radioGroup = document.getElementById(aPartId + "RadioGroup");
+ let permission;
+ if (radioGroup.selectedItem) {
+ permission = parseInt(radioGroup.selectedItem.id.split("#")[1]);
+ } else {
+ permission = SitePermissions.getDefault(aPartId);
+ }
+ SitePermissions.setForPrincipal(gPermPrincipal, aPartId, permission);
+}
+
+function setRadioState(aPartId, aValue) {
+ var radio = document.getElementById(aPartId + "#" + aValue);
+ if (radio) {
+ radio.radioGroup.selectedItem = radio;
+ }
+}
diff --git a/browser/base/content/pageinfo/security.js b/browser/base/content/pageinfo/security.js
new file mode 100644
index 0000000000..e4d52f889f
--- /dev/null
+++ b/browser/base/content/pageinfo/security.js
@@ -0,0 +1,426 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* 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/. */
+
+const { SiteDataManager } = ChromeUtils.import(
+ "resource:///modules/SiteDataManager.jsm"
+);
+const { DownloadUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/DownloadUtils.sys.mjs"
+);
+
+/* import-globals-from pageInfo.js */
+
+ChromeUtils.defineESModuleGetters(this, {
+ LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs",
+ PluralForm: "resource://gre/modules/PluralForm.sys.mjs",
+});
+
+var security = {
+ async init(uri, windowInfo) {
+ this.uri = uri;
+ this.windowInfo = windowInfo;
+ this.securityInfo = await this._getSecurityInfo();
+ },
+
+ viewCert() {
+ let certChain = this.securityInfo.certChain;
+ let certs = certChain.map(elem =>
+ encodeURIComponent(elem.getBase64DERString())
+ );
+ let certsStringURL = certs.map(elem => `cert=${elem}`);
+ certsStringURL = certsStringURL.join("&");
+ let url = `about:certificate?${certsStringURL}`;
+ let win = BrowserWindowTracker.getTopWindow();
+ win.switchToTabHavingURI(url, true, {});
+ },
+
+ async _getSecurityInfo() {
+ // We don't have separate info for a frame, return null until further notice
+ // (see bug 138479)
+ if (!this.windowInfo.isTopWindow) {
+ return null;
+ }
+
+ var ui = security._getSecurityUI();
+ if (!ui) {
+ return null;
+ }
+
+ var isBroken = ui.state & Ci.nsIWebProgressListener.STATE_IS_BROKEN;
+ var isMixed =
+ ui.state &
+ (Ci.nsIWebProgressListener.STATE_LOADED_MIXED_ACTIVE_CONTENT |
+ Ci.nsIWebProgressListener.STATE_LOADED_MIXED_DISPLAY_CONTENT);
+ var isEV = ui.state & Ci.nsIWebProgressListener.STATE_IDENTITY_EV_TOPLEVEL;
+
+ let retval = {
+ cAName: "",
+ encryptionAlgorithm: "",
+ encryptionStrength: 0,
+ version: "",
+ isBroken,
+ isMixed,
+ isEV,
+ cert: null,
+ certificateTransparency: null,
+ };
+
+ // Only show certificate info for secure contexts. This prevents us from
+ // showing certificate data for http origins when using a proxy.
+ // https://searchfox.org/mozilla-central/rev/9c72508fcf2bba709a5b5b9eae9da35e0c707baa/security/manager/ssl/nsSecureBrowserUI.cpp#62-64
+ if (!ui.isSecureContext) {
+ return retval;
+ }
+
+ let secInfo = ui.secInfo;
+ if (!secInfo) {
+ return retval;
+ }
+
+ let cert = secInfo.serverCert;
+ let issuerName = null;
+ if (cert) {
+ issuerName = cert.issuerOrganization || cert.issuerName;
+ }
+
+ let certChainArray = [];
+ if (secInfo.succeededCertChain.length) {
+ certChainArray = secInfo.succeededCertChain;
+ } else {
+ certChainArray = secInfo.failedCertChain;
+ }
+
+ retval = {
+ cAName: issuerName,
+ encryptionAlgorithm: undefined,
+ encryptionStrength: undefined,
+ version: undefined,
+ isBroken,
+ isMixed,
+ isEV,
+ cert,
+ certChain: certChainArray,
+ certificateTransparency: undefined,
+ };
+
+ var version;
+ try {
+ retval.encryptionAlgorithm = secInfo.cipherName;
+ retval.encryptionStrength = secInfo.secretKeyLength;
+ version = secInfo.protocolVersion;
+ } catch (e) {}
+
+ switch (version) {
+ case Ci.nsITransportSecurityInfo.SSL_VERSION_3:
+ retval.version = "SSL 3";
+ break;
+ case Ci.nsITransportSecurityInfo.TLS_VERSION_1:
+ retval.version = "TLS 1.0";
+ break;
+ case Ci.nsITransportSecurityInfo.TLS_VERSION_1_1:
+ retval.version = "TLS 1.1";
+ break;
+ case Ci.nsITransportSecurityInfo.TLS_VERSION_1_2:
+ retval.version = "TLS 1.2";
+ break;
+ case Ci.nsITransportSecurityInfo.TLS_VERSION_1_3:
+ retval.version = "TLS 1.3";
+ break;
+ }
+
+ // Select the status text to display for Certificate Transparency.
+ // Since we do not yet enforce the CT Policy on secure connections,
+ // we must not complain on policy discompliance (it might be viewed
+ // as a security issue by the user).
+ switch (secInfo.certificateTransparencyStatus) {
+ case Ci.nsITransportSecurityInfo.CERTIFICATE_TRANSPARENCY_NOT_APPLICABLE:
+ case Ci.nsITransportSecurityInfo
+ .CERTIFICATE_TRANSPARENCY_POLICY_NOT_ENOUGH_SCTS:
+ case Ci.nsITransportSecurityInfo
+ .CERTIFICATE_TRANSPARENCY_POLICY_NOT_DIVERSE_SCTS:
+ retval.certificateTransparency = null;
+ break;
+ case Ci.nsITransportSecurityInfo
+ .CERTIFICATE_TRANSPARENCY_POLICY_COMPLIANT:
+ retval.certificateTransparency = "Compliant";
+ break;
+ }
+
+ return retval;
+ },
+
+ // Find the secureBrowserUI object (if present)
+ _getSecurityUI() {
+ if (window.opener.gBrowser) {
+ return window.opener.gBrowser.securityUI;
+ }
+ return null;
+ },
+
+ async _updateSiteDataInfo() {
+ // Save site data info for deleting.
+ this.siteData = await SiteDataManager.getSite(this.uri.host);
+
+ let clearSiteDataButton = document.getElementById(
+ "security-clear-sitedata"
+ );
+ let siteDataLabel = document.getElementById(
+ "security-privacy-sitedata-value"
+ );
+
+ if (!this.siteData) {
+ document.l10n.setAttributes(siteDataLabel, "security-site-data-no");
+ clearSiteDataButton.setAttribute("disabled", "true");
+ return;
+ }
+
+ let { usage } = this.siteData;
+ if (usage > 0) {
+ let size = DownloadUtils.convertByteUnits(usage);
+ if (this.siteData.cookies.length) {
+ document.l10n.setAttributes(
+ siteDataLabel,
+ "security-site-data-cookies",
+ { value: size[0], unit: size[1] }
+ );
+ } else {
+ document.l10n.setAttributes(siteDataLabel, "security-site-data-only", {
+ value: size[0],
+ unit: size[1],
+ });
+ }
+ } else {
+ // We're storing cookies, else getSite would have returned null.
+ document.l10n.setAttributes(
+ siteDataLabel,
+ "security-site-data-cookies-only"
+ );
+ }
+
+ clearSiteDataButton.removeAttribute("disabled");
+ },
+
+ /**
+ * Clear Site Data and Cookies
+ */
+ clearSiteData() {
+ if (this.siteData) {
+ let { baseDomain } = this.siteData;
+ if (SiteDataManager.promptSiteDataRemoval(window, [baseDomain])) {
+ SiteDataManager.remove(baseDomain).then(() =>
+ this._updateSiteDataInfo()
+ );
+ }
+ }
+ },
+
+ /**
+ * Open the login manager window
+ */
+ viewPasswords() {
+ LoginHelper.openPasswordManager(window, {
+ filterString: this.windowInfo.hostName,
+ entryPoint: "pageinfo",
+ });
+ },
+};
+
+async function securityOnLoad(uri, windowInfo) {
+ await security.init(uri, windowInfo);
+
+ let info = security.securityInfo;
+ if (
+ !info ||
+ (uri.scheme === "about" && !uri.spec.startsWith("about:certerror"))
+ ) {
+ document.getElementById("securityTab").hidden = true;
+ return;
+ }
+ document.getElementById("securityTab").hidden = false;
+
+ /* Set Identity section text */
+ setText("security-identity-domain-value", windowInfo.hostName);
+
+ var validity;
+ if (info.cert && !info.isBroken) {
+ validity = info.cert.validity.notAfterLocalDay;
+
+ // Try to pull out meaningful values. Technically these fields are optional
+ // so we'll employ fallbacks where appropriate. The EV spec states that Org
+ // fields must be specified for subject and issuer so that case is simpler.
+ if (info.isEV) {
+ setText("security-identity-owner-value", info.cert.organization);
+ setText("security-identity-verifier-value", info.cAName);
+ } else {
+ // Technically, a non-EV cert might specify an owner in the O field or not,
+ // depending on the CA's issuing policies. However we don't have any programmatic
+ // way to tell those apart, and no policy way to establish which organization
+ // vetting standards are good enough (that's what EV is for) so we default to
+ // treating these certs as domain-validated only.
+ document.l10n.setAttributes(
+ document.getElementById("security-identity-owner-value"),
+ "page-info-security-no-owner"
+ );
+ setText(
+ "security-identity-verifier-value",
+ info.cAName || info.cert.issuerCommonName || info.cert.issuerName
+ );
+ }
+ } else {
+ // We don't have valid identity credentials.
+ document.l10n.setAttributes(
+ document.getElementById("security-identity-owner-value"),
+ "page-info-security-no-owner"
+ );
+ document.l10n.setAttributes(
+ document.getElementById("security-identity-verifier-value"),
+ "page-info-not-specified"
+ );
+ }
+
+ if (validity) {
+ setText("security-identity-validity-value", validity);
+ } else {
+ document.getElementById("security-identity-validity-row").hidden = true;
+ }
+
+ /* Manage the View Cert button*/
+ var viewCert = document.getElementById("security-view-cert");
+ if (info.cert) {
+ viewCert.collapsed = false;
+ } else {
+ viewCert.collapsed = true;
+ }
+
+ /* Set Privacy & History section text */
+
+ // Only show quota usage data for websites, not internal sites.
+ if (uri.scheme == "http" || uri.scheme == "https") {
+ SiteDataManager.updateSites().then(() => security._updateSiteDataInfo());
+ } else {
+ document.getElementById("security-privacy-sitedata-row").hidden = true;
+ }
+
+ if (realmHasPasswords(uri)) {
+ document.l10n.setAttributes(
+ document.getElementById("security-privacy-passwords-value"),
+ "saved-passwords-yes"
+ );
+ } else {
+ document.l10n.setAttributes(
+ document.getElementById("security-privacy-passwords-value"),
+ "saved-passwords-no"
+ );
+ }
+
+ document.l10n.setAttributes(
+ document.getElementById("security-privacy-history-value"),
+ "security-visits-number",
+ { visits: previousVisitCount(windowInfo.hostName) }
+ );
+
+ /* Set the Technical Detail section messages */
+ const pkiBundle = document.getElementById("pkiBundle");
+ var hdr;
+ var msg1;
+ var msg2;
+
+ if (info.isBroken) {
+ if (info.isMixed) {
+ hdr = pkiBundle.getString("pageInfo_MixedContent");
+ msg1 = pkiBundle.getString("pageInfo_MixedContent2");
+ } else {
+ hdr = pkiBundle.getFormattedString("pageInfo_BrokenEncryption", [
+ info.encryptionAlgorithm,
+ info.encryptionStrength + "",
+ info.version,
+ ]);
+ msg1 = pkiBundle.getString("pageInfo_WeakCipher");
+ }
+ msg2 = pkiBundle.getString("pageInfo_Privacy_None2");
+ } else if (info.encryptionStrength > 0) {
+ hdr = pkiBundle.getFormattedString(
+ "pageInfo_EncryptionWithBitsAndProtocol",
+ [info.encryptionAlgorithm, info.encryptionStrength + "", info.version]
+ );
+ msg1 = pkiBundle.getString("pageInfo_Privacy_Encrypted1");
+ msg2 = pkiBundle.getString("pageInfo_Privacy_Encrypted2");
+ } else {
+ hdr = pkiBundle.getString("pageInfo_NoEncryption");
+ if (windowInfo.hostName != null) {
+ msg1 = pkiBundle.getFormattedString("pageInfo_Privacy_None1", [
+ windowInfo.hostName,
+ ]);
+ } else {
+ msg1 = pkiBundle.getString("pageInfo_Privacy_None4");
+ }
+ msg2 = pkiBundle.getString("pageInfo_Privacy_None2");
+ }
+ setText("security-technical-shortform", hdr);
+ setText("security-technical-longform1", msg1);
+ setText("security-technical-longform2", msg2);
+
+ const ctStatus = document.getElementById(
+ "security-technical-certificate-transparency"
+ );
+ if (info.certificateTransparency) {
+ ctStatus.hidden = false;
+ ctStatus.value = pkiBundle.getString(
+ "pageInfo_CertificateTransparency_" + info.certificateTransparency
+ );
+ } else {
+ ctStatus.hidden = true;
+ }
+}
+
+function setText(id, value) {
+ var element = document.getElementById(id);
+ if (!element) {
+ return;
+ }
+ if (element.localName == "input" || element.localName == "label") {
+ element.value = value;
+ } else {
+ element.textContent = value;
+ }
+}
+
+/**
+ * Return true iff realm (proto://host:port) (extracted from uri) has
+ * saved passwords
+ */
+function realmHasPasswords(uri) {
+ return Services.logins.countLogins(uri.prePath, "", "") > 0;
+}
+
+/**
+ * Return the number of previous visits recorded for host before today.
+ *
+ * @param host - the domain name to look for in history
+ */
+function previousVisitCount(host, endTimeReference) {
+ if (!host) {
+ return 0;
+ }
+
+ var historyService = Cc[
+ "@mozilla.org/browser/nav-history-service;1"
+ ].getService(Ci.nsINavHistoryService);
+
+ var options = historyService.getNewQueryOptions();
+ options.resultType = options.RESULTS_AS_VISIT;
+
+ // Search for visits to this host before today
+ var query = historyService.getNewQuery();
+ query.endTimeReference = query.TIME_RELATIVE_TODAY;
+ query.endTime = 0;
+ query.domain = host;
+
+ var result = historyService.executeQuery(query, options);
+ result.root.containerOpen = true;
+ var cc = result.root.childCount;
+ result.root.containerOpen = false;
+ return cc;
+}
diff --git a/browser/base/content/popup-notifications.inc b/browser/base/content/popup-notifications.inc
new file mode 100644
index 0000000000..a93cfb0553
--- /dev/null
+++ b/browser/base/content/popup-notifications.inc
@@ -0,0 +1,245 @@
+# to be included inside a popupset element
+
+ <panel id="notification-popup"
+ type="arrow"
+ position="after_start"
+ hidden="true"
+ orient="vertical"
+ noautofocus="true"
+ role="alert"/>
+
+ <popupnotification id="webRTC-shareDevices-notification" hidden="true"
+ descriptionid="webRTC-shareDevices-notification-description">
+ <popupnotificationcontent id="webRTC-selectCamera" orient="vertical">
+ <label id="webRTC-selectCamera-label"
+ data-l10n-id="popup-select-camera-device"
+ control="webRTC-selectCamera-menulist"/>
+ <html:div class="webRTC-selectDevice-selector-container">
+ <xul:image id="webRTC-selectCamera-icon" class="webRTC-selectDevice-icon" data-l10n-id="popup-select-camera-icon"/>
+ <menulist id="webRTC-selectCamera-menulist" aria-labelledby="webRTC-selectCamera-icon" size="large">
+ <menupopup id="webRTC-selectCamera-menupopup"/>
+ </menulist>
+ <label id="webRTC-selectCamera-single-device-label" class="webRTC-selectDevice-label"></label>
+ </html:div>
+ </popupnotificationcontent>
+
+ <popupnotificationcontent id="webRTC-selectWindowOrScreen" orient="vertical">
+ <label id="webRTC-selectWindow-label"
+ data-l10n-id="popup-select-window-or-screen"
+ control="webRTC-selectWindow-menulist"/>
+ <menulist id="webRTC-selectWindow-menulist"
+ oncommand="webrtcUI.updateWarningLabel(this);"
+ size="large">
+ <menupopup id="webRTC-selectWindow-menupopup"/>
+ </menulist>
+ <description id="webRTC-all-windows-shared" hidden="true" data-l10n-id="popup-all-windows-shared"></description>
+ </popupnotificationcontent>
+
+ <popupnotificationcontent id="webRTC-preview" hidden="true">
+ <html:video id="webRTC-previewVideo" tabindex="-1"/>
+ <vbox id="webRTC-previewWarningBox">
+ <description id="webRTC-previewWarning"/>
+ <hbox>
+ <label id="webRTC-previewWarning-learnMore" is="text-link" class="popup-notification-learnmore-link"/>
+ </hbox>
+ </vbox>
+ </popupnotificationcontent>
+
+ <popupnotificationcontent id="webRTC-selectMicrophone" orient="vertical">
+ <label id="webRTC-selectMicrophone-label"
+ data-l10n-id="popup-select-microphone-device"
+ control="webRTC-selectMicrophone-menulist"/>
+ <html:div class="webRTC-selectDevice-selector-container">
+ <xul:image id="webRTC-selectMicrophone-icon" data-l10n-id="popup-select-microphone-icon" class="webRTC-selectDevice-icon"/>
+ <menulist id="webRTC-selectMicrophone-menulist" aria-labelledby="webRTC-selectMicrophone-icon" size="large">
+ <menupopup id="webRTC-selectMicrophone-menupopup"/>
+ </menulist>
+ <label id="webRTC-selectMicrophone-single-device-label" class="webRTC-selectDevice-label"></label>
+ </html:div>
+ </popupnotificationcontent>
+
+ <popupnotificationcontent id="webRTC-selectSpeaker" orient="vertical">
+ <html:div class="webRTC-selectDevice-selector-container">
+ <xul:image id="webRTC-selectSpeaker-icon" data-l10n-id="popup-select-speaker-icon" class="webRTC-selectDevice-icon"/>
+ <menulist id="webRTC-selectSpeaker-menulist" aria-labelledby="webRTC-selectSpeaker-icon" size="large">
+ <menupopup id="webRTC-selectSpeaker-menupopup"/>
+ </menulist>
+ <label id="webRTC-selectSpeaker-single-device-label" class="webRTC-selectDevice-label"></label>
+ </html:div>
+ </popupnotificationcontent>
+ </popupnotification>
+
+ <popupnotification id="servicesInstall-notification" hidden="true">
+ <popupnotificationcontent orient="vertical" align="start">
+ <!-- XXX bug 974146, tests are looking for this, can't remove yet. -->
+ </popupnotificationcontent>
+ </popupnotification>
+
+ <popupnotification id="password-notification" hidden="true">
+ <popupnotificationcontent orient="vertical">
+ <label data-l10n-id="panel-save-update-username" control="password-notification-username" class="password-notification-label"></label>
+ <stack>
+ <html:input id="password-notification-username"
+ type="text"
+ class="ac-has-end-icon"
+ autocompletesearch="login-doorhanger-username"
+ autocompletepopup="PopupAutoComplete"
+ is="autocomplete-input"
+ maxrows="10"
+ maxdropmarkerrows="10"/>
+ <dropmarker id="password-notification-username-dropmarker"
+ class="ac-dropmarker"/>
+ </stack>
+ <label data-l10n-id="panel-save-update-password" control="password-notification-password" class="password-notification-label"></label>
+ <stack>
+ <html:input id="password-notification-password" type="password"/>
+ <dropmarker id="password-notification-password-dropmarker"
+ class="ac-dropmarker"
+ hidden="true"/>
+ </stack>
+ <checkbox id="password-notification-visibilityToggle" hidden="true"/>
+ </popupnotificationcontent>
+ </popupnotification>
+
+ <popupnotification id="addon-progress-notification" is="addon-progress-notification" hidden="true">
+ <popupnotificationcontent orient="vertical">
+ <html:progress id="addon-progress-notification-progressmeter" max="100"/>
+ <label id="addon-progress-notification-progresstext" crop="end"/>
+ </popupnotificationcontent>
+ </popupnotification>
+
+ <popupnotification id="addon-install-confirmation-notification" hidden="true">
+ <popupnotificationcontent id="addon-install-confirmation-content" orient="vertical"/>
+ </popupnotification>
+
+ <popupnotification id="addon-webext-permissions-notification" hidden="true">
+ <popupnotificationcontent class="addon-webext-perm-notification-content" orient="vertical">
+ <description id="addon-webext-perm-text" class="addon-webext-perm-text"/>
+ <label id="addon-webext-perm-intro" class="addon-webext-perm-text"/>
+ <label id="addon-webext-perm-single-entry" class="addon-webext-perm-single-entry"/>
+ <html:ul id="addon-webext-perm-list" class="addon-webext-perm-list"/>
+ <hbox>
+ <label id="addon-webext-perm-info" is="text-link" class="popup-notification-learnmore-link"/>
+ </hbox>
+ </popupnotificationcontent>
+ </popupnotification>
+
+ <popupnotification id="addon-install-blocked-notification" hidden="true">
+ <popupnotificationcontent id="addon-install-blocked-content" orient="vertical">
+ <description id="addon-install-blocked-message" class="popup-notification-description"></description>
+ <hbox>
+ <html:a
+ is="moz-support-link"
+ id="addon-install-blocked-info"
+ class="popup-notification-learnmore-link"
+ data-l10n-id="popup-notification-xpinstall-prompt-learn-more"
+ />
+ </hbox>
+ </popupnotificationcontent>
+ </popupnotification>
+
+ <popupnotification id="canvas-permissions-prompt-notification" hidden="true">
+ <popupnotificationcontent orient="vertical">
+ <description class="popup-notification-description"/>
+ <label id="canvas-permissions-prompt-warning"/>
+ </popupnotificationcontent>
+ </popupnotification>
+
+ <popupnotification id="contextual-feature-recommendation-notification"
+ buttonhighlight="true"
+ hidden="true">
+ <popupnotificationheader id="cfr-notification-header">
+ <stack id="cfr-notification-header-stack">
+ <description id="cfr-notification-header-label"></description>
+ <label id="cfr-notification-header-link" is="text-link">
+ <xul:image id="cfr-notification-header-image"/>
+ </label>
+ </stack>
+ </popupnotificationheader>
+ <popupnotificationcontent>
+ <hbox>
+ <description id="cfr-notification-author"></description>
+ <hbox id="cfr-notification-footer-addon-info">
+ <hbox id="cfr-notification-footer-filled-stars"/>
+ <hbox id="cfr-notification-footer-empty-stars"/>
+ <label id="cfr-notification-footer-users"/>
+ </hbox>
+ </hbox>
+ </popupnotificationcontent>
+ <popupnotificationfooter id="cfr-notification-footer" orient="vertical">
+ <vbox id="cfr-notification-footer-text-and-addon-info">
+ <description id="cfr-notification-footer-text"/>
+ <label id="cfr-notification-footer-learn-more-link" is="text-link"/>
+ </vbox>
+ </popupnotificationfooter>
+ </popupnotification>
+
+ <popupnotification id="identity-credential-notification" hidden="true">
+ <popupnotificationheader id="identity-credential-header" orient="horizontal" hidden="true">
+ <html:div class="identity-credential-header-container">
+ <html:img class="identity-credential-header-icon"></html:img>
+ <span id="identity-credential-header-text"></span>
+ </html:div>
+ </popupnotificationheader>
+ <popupnotificationcontent id="identity-credential-provider" orient="vertical">
+ <html:div id="identity-credential-provider-selector-container">
+ </html:div>
+ <html:template id="template-credential-provider-list-item">
+ <html:label class="identity-credential-list-item" align="center">
+ <html:input type="radio" name="credential-provider" class="identity-credential-list-item-radio"></html:input>
+ <html:img class="identity-credential-list-item-icon" src="chrome://global/skin/icons/defaultFavicon.svg"></html:img>
+ <span class="identity-credential-list-item-label"></span>
+ </html:label>
+ </html:template>
+ </popupnotificationcontent>
+ <popupnotificationcontent id="identity-credential-policy" orient="vertical">
+ <description id="identity-credential-policy-explanation" data-l10n-id="identity-credential-policy-description" data-l10n-args='{"host":"", "provider":""}'>
+ <label class="text-link" is="text-link" data-l10n-name="privacy-url" id="identity-credential-privacy-policy"></label>
+ <label class="text-link" is="text-link" data-l10n-name="tos-url" id="identity-credential-terms-of-service"></label>
+ </description>
+ </popupnotificationcontent>
+ <popupnotificationcontent id="identity-credential-account" orient="vertical" hidden="true">
+ <html:div id="identity-credential-account-selector-container">
+ </html:div>
+ <html:template id="template-credential-account-list-item">
+ <html:label class="identity-credential-list-item" align="center">
+ <html:input type="radio" name="credential-account" class="identity-credential-list-item-radio"></html:input>
+ <html:img class="identity-credential-list-item-icon" src="chrome://browser/skin/fxa/avatar.svg"></html:img>
+ <div class="identity-credential-list-item-label-stack">
+ <div class="identity-credential-list-item-label-name"></div>
+ <div class="identity-credential-list-item-label-email"></div>
+ </div>
+ </html:label>
+ </html:template>
+ </popupnotificationcontent>
+ </popupnotification>
+
+ <popupnotification id="relay-integration-offer-notification" hidden="true">
+ <popupnotificationcontent orient="vertical">
+ <html:div>
+ <html:p data-l10n-id="firefox-relay-offer-why-to-use-relay"></html:p>
+ <html:p id="firefox-relay-offer-what-relay-provides" data-l10n-id="firefox-relay-offer-what-relay-provides" data-l10n-args='{"useremail": ""}'></html:p>
+ <html:p id="firefox-relay-offer-legal-notice" data-l10n-id="firefox-relay-offer-legal-notice">
+ <label id="firefox-relay-offer-tos-url" is="text-link" data-l10n-name="tos-url"/>
+ <label id="firefox-relay-offer-privacy-url" is="text-link" data-l10n-name="privacy-url"/>
+ </html:p>
+ </html:div>
+ </popupnotificationcontent>
+ </popupnotification>
+
+ <popupnotification id="relay-integration-reuse-masks-notification" hidden="true">
+ <popupnotificationcontent orient="vertical">
+ <html:div>
+ <html:p class="error-message"></html:p>
+ <html:div class="reusable-relay-masks" />
+ </html:div>
+ </popupnotificationcontent>
+ </popupnotification>
+
+ <template id="firefox-relay-header">
+ <html:div class="relay-integration-header">
+ <html:div />
+ <html:span>Firefox</html:span>
+ <html:span> Relay</html:span>
+ </html:div>
+ </template>
diff --git a/browser/base/content/robot.ico b/browser/base/content/robot.ico
new file mode 100644
index 0000000000..8913387fc9
--- /dev/null
+++ b/browser/base/content/robot.ico
Binary files differ
diff --git a/browser/base/content/safeMode.css b/browser/base/content/safeMode.css
new file mode 100644
index 0000000000..7a2cfff8b1
--- /dev/null
+++ b/browser/base/content/safeMode.css
@@ -0,0 +1,7 @@
+/* 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/. */
+
+#resetProfileFooter {
+ font-weight: bold;
+}
diff --git a/browser/base/content/safeMode.js b/browser/base/content/safeMode.js
new file mode 100644
index 0000000000..090f057c69
--- /dev/null
+++ b/browser/base/content/safeMode.js
@@ -0,0 +1,85 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* 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/. */
+
+const appStartup = Services.startup;
+
+const { ResetProfile } = ChromeUtils.importESModule(
+ "resource://gre/modules/ResetProfile.sys.mjs"
+);
+
+var defaultToReset = false;
+
+function restartApp() {
+ appStartup.quit(appStartup.eForceQuit | appStartup.eRestart);
+}
+
+function resetProfile() {
+ // Set the reset profile environment variable.
+ Services.env.set("MOZ_RESET_PROFILE_RESTART", "1");
+}
+
+function showResetDialog() {
+ // Prompt the user to confirm the reset.
+ let retVals = {
+ reset: false,
+ };
+ window.openDialog(
+ "chrome://global/content/resetProfile.xhtml",
+ null,
+ "chrome,modal,centerscreen,titlebar,dialog=yes",
+ retVals
+ );
+ if (!retVals.reset) {
+ return;
+ }
+ resetProfile();
+ restartApp();
+}
+
+function onDefaultButton(event) {
+ if (defaultToReset) {
+ // Prevent starting into safe mode while restarting.
+ event.preventDefault();
+ // Restart to reset the profile.
+ resetProfile();
+ restartApp();
+ }
+ // Dialog will be closed by default Event handler.
+ // Continue in safe mode. No restart needed.
+}
+
+function onCancel() {
+ appStartup.quit(appStartup.eForceQuit);
+}
+
+function onExtra1() {
+ if (defaultToReset) {
+ // Continue in safe mode
+ window.close();
+ }
+ // The reset dialog will handle starting the reset process if the user confirms.
+ showResetDialog();
+}
+
+function onLoad() {
+ const dialog = document.getElementById("safeModeDialog");
+ if (appStartup.automaticSafeModeNecessary) {
+ document.getElementById("autoSafeMode").hidden = false;
+ document.getElementById("safeMode").hidden = true;
+ if (ResetProfile.resetSupported()) {
+ document.getElementById("resetProfile").hidden = false;
+ } else {
+ // Hide the reset button is it's not supported.
+ dialog.getButton("extra1").hidden = true;
+ }
+ } else if (!ResetProfile.resetSupported()) {
+ // Hide the reset button and text if it's not supported.
+ dialog.getButton("extra1").hidden = true;
+ document.getElementById("resetProfileInstead").hidden = true;
+ }
+ document.addEventListener("dialogaccept", onDefaultButton);
+ document.addEventListener("dialogcancel", onCancel);
+ document.addEventListener("dialogextra1", onExtra1);
+}
diff --git a/browser/base/content/safeMode.xhtml b/browser/base/content/safeMode.xhtml
new file mode 100644
index 0000000000..8d48812f35
--- /dev/null
+++ b/browser/base/content/safeMode.xhtml
@@ -0,0 +1,49 @@
+<?xml version="1.0"?>
+
+<!-- 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/. -->
+
+<?xml-stylesheet href="chrome://global/skin/global.css"?>
+<?xml-stylesheet href="chrome://browser/content/safeMode.css"?>
+
+<window
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ data-l10n-id="troubleshoot-mode-window"
+ data-l10n-attrs="title,style"
+ onload="onLoad()"
+>
+ <dialog
+ id="safeModeDialog"
+ buttons="accept,extra1"
+ buttonidaccept="start-troubleshoot-mode"
+ buttonidextra1="refresh-profile"
+ >
+ <linkset>
+ <html:link rel="localization" href="branding/brand.ftl" />
+ <html:link rel="localization" href="browser/safeMode.ftl" />
+ </linkset>
+
+ <script src="chrome://browser/content/safeMode.js" />
+
+ <vbox id="autoSafeMode" hidden="true">
+ <description data-l10n-id="auto-safe-mode-description" />
+ </vbox>
+
+ <vbox id="safeMode">
+ <label data-l10n-id="troubleshoot-mode-description" />
+ <separator class="thin" />
+ <label
+ id="resetProfileInstead"
+ data-l10n-id="skip-troubleshoot-refresh-profile"
+ />
+ </vbox>
+
+ <vbox id="resetProfile" hidden="true">
+ <label data-l10n-id="refresh-profile-instead" />
+ </vbox>
+
+ <separator class="thin" />
+ </dialog>
+</window>
diff --git a/browser/base/content/sanitize.xhtml b/browser/base/content/sanitize.xhtml
new file mode 100644
index 0000000000..32ff15b76e
--- /dev/null
+++ b/browser/base/content/sanitize.xhtml
@@ -0,0 +1,135 @@
+<?xml version="1.0"?>
+
+<!-- -*- Mode: Java; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*- -->
+<!-- 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/. -->
+
+<?xml-stylesheet href="chrome://global/skin/global.css"?>
+<?xml-stylesheet href="chrome://browser/skin/preferences/preferences.css"?>
+<?xml-stylesheet href="chrome://browser/skin/sanitizeDialog.css"?>
+
+<?xml-stylesheet href="chrome://browser/content/sanitizeDialog.css"?>
+
+<!DOCTYPE window>
+
+<window
+ id="SanitizeDialog"
+ type="child"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ persist="lastSelected screenX screenY"
+ data-l10n-id="sanitize-dialog-title"
+ data-l10n-attrs="style"
+>
+ <dialog buttons="accept,cancel">
+ <hbox>
+ <html:h2 id="titleText" />
+ </hbox>
+
+ <linkset>
+ <html:link rel="localization" href="browser/sanitize.ftl" />
+ </linkset>
+
+ <script src="chrome://global/content/preferencesBindings.js" />
+ <script src="chrome://browser/content/sanitizeDialog.js" />
+
+ <hbox id="SanitizeDurationBox" align="center">
+ <label
+ data-l10n-id="clear-time-duration-prefix"
+ control="sanitizeDurationChoice"
+ id="sanitizeDurationLabel"
+ />
+ <menulist
+ id="sanitizeDurationChoice"
+ preference="privacy.sanitize.timeSpan"
+ onselect="gSanitizePromptDialog.selectByTimespan();"
+ flex="1"
+ >
+ <menupopup id="sanitizeDurationPopup">
+ <menuitem
+ data-l10n-id="clear-time-duration-value-last-hour"
+ value="1"
+ />
+ <menuitem
+ data-l10n-id="clear-time-duration-value-last-2-hours"
+ value="2"
+ />
+ <menuitem
+ data-l10n-id="clear-time-duration-value-last-4-hours"
+ value="3"
+ />
+ <menuitem data-l10n-id="clear-time-duration-value-today" value="4" />
+ <menuseparator />
+ <menuitem
+ data-l10n-id="clear-time-duration-value-everything"
+ value="0"
+ />
+ </menupopup>
+ </menulist>
+ <label
+ id="sanitizeDurationSuffixLabel"
+ data-l10n-id="clear-time-duration-suffix"
+ />
+ </hbox>
+
+ <vbox id="sanitizeEverythingWarningBox">
+ <spacer flex="1" />
+ <hbox align="center">
+ <image id="sanitizeEverythingWarningIcon" />
+ <vbox id="sanitizeEverythingWarningDescBox" flex="1">
+ <description id="sanitizeEverythingWarning" />
+ <description
+ id="sanitizeEverythingUndoWarning"
+ data-l10n-id="sanitize-everything-undo-warning"
+ ></description>
+ </vbox>
+ </hbox>
+ <spacer flex="1" />
+ </vbox>
+
+ <groupbox>
+ <label><html:h2 data-l10n-id="history-section-label" /></label>
+ <hbox>
+ <vbox data-l10n-id="sanitize-prefs-style" data-l10n-attrs="style">
+ <checkbox
+ data-l10n-id="item-history-and-downloads"
+ preference="privacy.cpd.history"
+ />
+ <checkbox
+ data-l10n-id="item-active-logins"
+ preference="privacy.cpd.sessions"
+ />
+ <checkbox
+ data-l10n-id="item-form-search-history"
+ preference="privacy.cpd.formdata"
+ />
+ </vbox>
+ <vbox flex="1">
+ <checkbox
+ data-l10n-id="item-cookies"
+ preference="privacy.cpd.cookies"
+ />
+ <checkbox data-l10n-id="item-cache" preference="privacy.cpd.cache" />
+ </vbox>
+ </hbox>
+ </groupbox>
+ <groupbox>
+ <label><html:h2 data-l10n-id="data-section-label" /></label>
+ <hbox>
+ <vbox data-l10n-id="sanitize-prefs-style" data-l10n-attrs="style">
+ <checkbox
+ data-l10n-id="item-site-settings"
+ preference="privacy.cpd.siteSettings"
+ />
+ </vbox>
+ <vbox flex="1">
+ <checkbox
+ data-l10n-id="item-offline-apps"
+ preference="privacy.cpd.offlineApps"
+ />
+ </vbox>
+ </hbox>
+ </groupbox>
+ </dialog>
+</window>
diff --git a/browser/base/content/sanitizeDialog.css b/browser/base/content/sanitizeDialog.css
new file mode 100644
index 0000000000..905a5b4f6a
--- /dev/null
+++ b/browser/base/content/sanitizeDialog.css
@@ -0,0 +1,73 @@
+/* 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/. */
+
+/* Sanitize everything warnings */
+
+#sanitizeEverythingWarning,
+#sanitizeEverythingUndoWarning {
+ white-space: pre-wrap;
+}
+
+/* Hide the duration dropdown suffix label if it's empty. Otherwise it
+ takes up a little space, causing the end of the dropdown to not be aligned
+ with the warning box. */
+#sanitizeDurationSuffixLabel[value=""] {
+ display: none;
+}
+
+#sanitizeDurationLabel[value=""] + #sanitizeDurationChoice {
+ margin-inline-start: 0;
+}
+
+/* Sanitize everything warning box */
+#sanitizeEverythingWarningBox {
+ /* Fallback colors are used when the dialog is open outside of in-content prefs */
+ background-color: var(--in-content-box-background, Window);
+ border: 1px solid var(--in-content-box-border-color, ThreeDDarkShadow);
+ border-radius: 5px;
+ padding: 16px;
+ margin-block: 6px;
+}
+
+#sanitizeEverythingWarningIcon {
+ padding: 0;
+ margin: 0;
+}
+
+#sanitizeEverythingWarningDescBox {
+ padding: 0 16px;
+ margin: 0;
+}
+
+#titleText {
+ margin-block-start: 0;
+ margin-inline: 4px;
+}
+
+dialog:not([inbrowserwindow]) #titleText {
+ display: none;
+}
+
+dialog[inbrowserwindow] {
+ --grid-padding: 16px;
+ padding: var(--grid-padding) calc(var(--grid-padding) - 4px);
+}
+
+dialog[inbrowserwindow] groupbox,
+dialog[inbrowserwindow] #sanitizeEverythingWarningBox,
+dialog[inbrowserwindow] #SanitizeDurationBox {
+ margin-inline: 4px;
+}
+
+dialog[inbrowserwindow] #sanitizeDurationLabel {
+ margin-inline-start: 0;
+}
+
+dialog[inbrowserwindow] #sanitizeDurationSuffixLabel {
+ margin-inline-end: 0;
+}
+
+dialog[inbrowserwindow] groupbox:last-child {
+ margin-block-end: 16px;
+}
diff --git a/browser/base/content/sanitizeDialog.js b/browser/base/content/sanitizeDialog.js
new file mode 100644
index 0000000000..d1ec7ae3fa
--- /dev/null
+++ b/browser/base/content/sanitizeDialog.js
@@ -0,0 +1,278 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* 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/. */
+
+/* import-globals-from /toolkit/content/preferencesBindings.js */
+
+var { Sanitizer } = ChromeUtils.importESModule(
+ "resource:///modules/Sanitizer.sys.mjs"
+);
+
+Preferences.addAll([
+ { id: "privacy.cpd.history", type: "bool" },
+ { id: "privacy.cpd.formdata", type: "bool" },
+ { id: "privacy.cpd.downloads", type: "bool", disabled: true },
+ { id: "privacy.cpd.cookies", type: "bool" },
+ { id: "privacy.cpd.cache", type: "bool" },
+ { id: "privacy.cpd.sessions", type: "bool" },
+ { id: "privacy.cpd.offlineApps", type: "bool" },
+ { id: "privacy.cpd.siteSettings", type: "bool" },
+ { id: "privacy.sanitize.timeSpan", type: "int" },
+]);
+
+var gSanitizePromptDialog = {
+ get selectedTimespan() {
+ var durList = document.getElementById("sanitizeDurationChoice");
+ return parseInt(durList.value);
+ },
+
+ get warningBox() {
+ return document.getElementById("sanitizeEverythingWarningBox");
+ },
+
+ async init() {
+ // This is used by selectByTimespan() to determine if the window has loaded.
+ this._inited = true;
+ this._dialog = document.querySelector("dialog");
+ let arg = window.arguments?.[0] || {};
+ if (arg.inBrowserWindow) {
+ this._dialog.setAttribute("inbrowserwindow", "true");
+ this._observeTitleForChanges();
+ } else if (arg.wrappedJSObject?.needNativeUI) {
+ document
+ .getElementById("sanitizeDurationChoice")
+ .setAttribute("native", "true");
+ for (let cb of document.querySelectorAll("checkbox")) {
+ cb.setAttribute("native", "true");
+ }
+ }
+
+ let OKButton = this._dialog.getButton("accept");
+ document.l10n.setAttributes(OKButton, "sanitize-button-ok");
+
+ document.addEventListener("dialogaccept", function (e) {
+ gSanitizePromptDialog.sanitize(e);
+ });
+
+ this.registerSyncFromPrefListeners();
+
+ if (this.selectedTimespan === Sanitizer.TIMESPAN_EVERYTHING) {
+ this.prepareWarning();
+ this.warningBox.hidden = false;
+ document.l10n.setAttributes(
+ document.documentElement,
+ "sanitize-dialog-title-everything"
+ );
+ let warningDesc = document.getElementById("sanitizeEverythingWarning");
+ // Ensure we've translated and sized the warning.
+ await document.l10n.translateFragment(warningDesc);
+ let rootWin = window.browsingContext.topChromeWindow;
+ await rootWin.promiseDocumentFlushed(() => {});
+ } else {
+ this.warningBox.hidden = true;
+ }
+ },
+
+ selectByTimespan() {
+ // This method is the onselect handler for the duration dropdown. As a
+ // result it's called a couple of times before onload calls init().
+ if (!this._inited) {
+ return;
+ }
+
+ var warningBox = this.warningBox;
+
+ // If clearing everything
+ if (this.selectedTimespan === Sanitizer.TIMESPAN_EVERYTHING) {
+ this.prepareWarning();
+ if (warningBox.hidden) {
+ warningBox.hidden = false;
+ let diff =
+ warningBox.nextElementSibling.getBoundingClientRect().top -
+ warningBox.previousElementSibling.getBoundingClientRect().bottom;
+ window.resizeBy(0, diff);
+ }
+ document.l10n.setAttributes(
+ document.documentElement,
+ "sanitize-dialog-title-everything"
+ );
+ return;
+ }
+
+ // If clearing a specific time range
+ if (!warningBox.hidden) {
+ let diff =
+ warningBox.nextElementSibling.getBoundingClientRect().top -
+ warningBox.previousElementSibling.getBoundingClientRect().bottom;
+ window.resizeBy(0, -diff);
+ warningBox.hidden = true;
+ }
+ document.l10n.setAttributes(
+ document.documentElement,
+ "sanitize-dialog-title"
+ );
+ },
+
+ sanitize(event) {
+ // Update pref values before handing off to the sanitizer (bug 453440)
+ this.updatePrefs();
+
+ // As the sanitize is async, we disable the buttons, update the label on
+ // the 'accept' button to indicate things are happening and return false -
+ // once the async operation completes (either with or without errors)
+ // we close the window.
+ let acceptButton = this._dialog.getButton("accept");
+ acceptButton.disabled = true;
+ document.l10n.setAttributes(acceptButton, "sanitize-button-clearing");
+ this._dialog.getButton("cancel").disabled = true;
+
+ try {
+ let range = Sanitizer.getClearRange(this.selectedTimespan);
+ let options = {
+ ignoreTimespan: !range,
+ range,
+ };
+ Sanitizer.sanitize(null, options)
+ .catch(console.error)
+ .then(() => window.close())
+ .catch(console.error);
+ event.preventDefault();
+ } catch (er) {
+ console.error("Exception during sanitize: ", er);
+ }
+ },
+
+ /**
+ * If the panel that displays a warning when the duration is "Everything" is
+ * not set up, sets it up. Otherwise does nothing.
+ */
+ prepareWarning() {
+ // If the date and time-aware locale warning string is ever used again,
+ // initialize it here. Currently we use the no-visits warning string,
+ // which does not include date and time. See bug 480169 comment 48.
+
+ var warningDesc = document.getElementById("sanitizeEverythingWarning");
+ if (this.hasNonSelectedItems()) {
+ document.l10n.setAttributes(warningDesc, "sanitize-selected-warning");
+ } else {
+ document.l10n.setAttributes(warningDesc, "sanitize-everything-warning");
+ }
+ },
+
+ /**
+ * Return the boolean prefs that correspond to the checkboxes on the dialog.
+ */
+ _getItemPrefs() {
+ return Preferences.getAll().filter(
+ p =>
+ p.id !== "privacy.sanitize.timeSpan" && p.id !== "privacy.cpd.downloads"
+ );
+ },
+
+ /**
+ * Called when the value of a preference element is synced from the actual
+ * pref. Enables or disables the OK button appropriately.
+ */
+ onReadGeneric() {
+ // Find any other pref that's checked and enabled (except for
+ // privacy.sanitize.timeSpan, which doesn't affect the button's status
+ // and privacy.cpd.downloads which is not controlled directly by a
+ // checkbox).
+ var found = this._getItemPrefs().some(
+ pref => !!pref.value && !pref.disabled
+ );
+
+ try {
+ this._dialog.getButton("accept").disabled = !found;
+ } catch (e) {}
+
+ // Update the warning prompt if needed
+ this.prepareWarning();
+
+ return undefined;
+ },
+
+ /**
+ * Sanitizer.prototype.sanitize() requires the prefs to be up-to-date.
+ * Because the type of this prefwindow is "child" -- and that's needed because
+ * without it the dialog has no OK and Cancel buttons -- the prefs are not
+ * updated on dialogaccept. We must therefore manually set the prefs
+ * from their corresponding preference elements.
+ */
+ updatePrefs() {
+ Services.prefs.setIntPref(Sanitizer.PREF_TIMESPAN, this.selectedTimespan);
+
+ // Keep the pref for the download history in sync with the history pref.
+ let historyValue = Preferences.get("privacy.cpd.history").value;
+ Preferences.get("privacy.cpd.downloads").value = historyValue;
+ Services.prefs.setBoolPref("privacy.cpd.downloads", historyValue);
+
+ // Now manually set the prefs from their corresponding preference
+ // elements.
+ var prefs = this._getItemPrefs();
+ for (let i = 0; i < prefs.length; ++i) {
+ var p = prefs[i];
+ Services.prefs.setBoolPref(p.id, p.value);
+ }
+ },
+
+ /**
+ * Check if all of the history items have been selected like the default status.
+ */
+ hasNonSelectedItems() {
+ let checkboxes = document.querySelectorAll("checkbox[preference]");
+ for (let i = 0; i < checkboxes.length; ++i) {
+ let pref = Preferences.get(checkboxes[i].getAttribute("preference"));
+ if (!pref.value) {
+ return true;
+ }
+ }
+ return false;
+ },
+
+ /**
+ * Register syncFromPref listener functions.
+ */
+ registerSyncFromPrefListeners() {
+ let checkboxes = document.querySelectorAll("checkbox[preference]");
+ for (let checkbox of checkboxes) {
+ Preferences.addSyncFromPrefListener(checkbox, () => this.onReadGeneric());
+ }
+ },
+
+ _titleChanged() {
+ let title = document.documentElement.getAttribute("title");
+ if (title) {
+ document.getElementById("titleText").textContent = title;
+ }
+ },
+
+ _observeTitleForChanges() {
+ this._titleChanged();
+ this._mutObs = new MutationObserver(() => {
+ this._titleChanged();
+ });
+ this._mutObs.observe(document.documentElement, {
+ attributes: true,
+ attributeFilter: ["title"],
+ });
+ },
+};
+
+// We need to give the dialog an opportunity to set up the DOM
+// before its measured for the SubDialog it will be embedded in.
+// This is because the sanitizeEverythingWarningBox may or may
+// not be visible, depending on whether "Everything" is the default
+// choice in the menulist.
+document.mozSubdialogReady = new Promise(resolve => {
+ window.addEventListener(
+ "load",
+ function () {
+ gSanitizePromptDialog.init().then(resolve);
+ },
+ {
+ once: true,
+ }
+ );
+});
diff --git a/browser/base/content/spotlight.html b/browser/base/content/spotlight.html
new file mode 100644
index 0000000000..bdb8926b3e
--- /dev/null
+++ b/browser/base/content/spotlight.html
@@ -0,0 +1,30 @@
+<!DOCTYPE html>
+
+<!-- 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/. -->
+
+<html>
+ <head>
+ <meta
+ http-equiv="Content-Security-Policy"
+ content="default-src resource: chrome:; img-src https://www.mozilla.org https://firefox-settings-attachments.cdn.mozilla.net blob: chrome:; object-src 'none'"
+ />
+ <meta name="referrer" content="no-referrer" />
+ <link
+ rel="stylesheet"
+ type="text/css"
+ href="chrome://global/skin/in-content/common.css"
+ />
+ <link rel="localization" href="branding/brand.ftl" />
+ <link rel="localization" href="toolkit/branding/brandings.ftl" />
+ <link rel="localization" href="browser/newtab/asrouter.ftl" />
+ <link rel="localization" href="browser/newtab/onboarding.ftl" />
+ <link rel="localization" href="browser/spotlight.ftl" />
+ </head>
+ <body role="dialog" aria-labelledby="title" aria-describedby="content">
+ <script src="resource://activity-stream/vendor/react.js"></script>
+ <script src="resource://activity-stream/vendor/react-dom.js"></script>
+ <script src="chrome://browser/content/spotlight.js"></script>
+ </body>
+</html>
diff --git a/browser/base/content/spotlight.js b/browser/base/content/spotlight.js
new file mode 100644
index 0000000000..b07d54a714
--- /dev/null
+++ b/browser/base/content/spotlight.js
@@ -0,0 +1,86 @@
+/* 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/. */
+
+const browser = window.docShell.chromeEventHandler;
+const { document: gDoc, XPCOMUtils } = browser.ownerGlobal;
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ AboutWelcomeParent: "resource:///actors/AboutWelcomeParent.jsm",
+});
+
+const CONFIG = window.arguments[0];
+
+function addStylesheet(href) {
+ const link = document.head.appendChild(document.createElement("link"));
+ link.rel = "stylesheet";
+ link.href = href;
+}
+
+/**
+ * Render content based on about:welcome multistage template.
+ */
+function renderMultistage(ready) {
+ const AWParent = new AboutWelcomeParent();
+ const receive = name => data =>
+ AWParent.onContentMessage(`AWPage:${name}`, data, browser);
+
+ // Expose top level functions expected by the bundle.
+ window.AWGetFeatureConfig = () => CONFIG;
+ window.AWGetSelectedTheme = receive("GET_SELECTED_THEME");
+ window.AWSelectTheme = data => receive("SELECT_THEME")(data?.toUpperCase());
+ // Do not send telemetry if message (e.g. spotlight in PBM) config sets metrics as 'block'.
+ if (CONFIG?.metrics !== "block") {
+ window.AWSendEventTelemetry = receive("TELEMETRY_EVENT");
+ }
+ window.AWSendToDeviceEmailsSupported = receive(
+ "SEND_TO_DEVICE_EMAILS_SUPPORTED"
+ );
+ window.AWAddScreenImpression = receive("ADD_SCREEN_IMPRESSION");
+ window.AWSendToParent = (name, data) => receive(name)(data);
+ window.AWFinish = () => {
+ window.close();
+ };
+ window.AWWaitForMigrationClose = receive("WAIT_FOR_MIGRATION_CLOSE");
+ window.AWEvaluateScreenTargeting = receive("EVALUATE_SCREEN_TARGETING");
+
+ // Update styling to be compatible with about:welcome.
+ addStylesheet(
+ "chrome://activity-stream/content/aboutwelcome/aboutwelcome.css"
+ );
+
+ document.body.classList.add("onboardingContainer");
+ document.body.id = "multi-stage-message-root";
+ // This value is reported as the "page" in telemetry
+ document.body.dataset.page = "spotlight";
+
+ // Prevent applying the default modal shadow and margins because the content
+ // handles styling, including its own modal shadowing.
+ const box = browser.closest(".dialogBox");
+ const dialog = box.closest("dialog");
+ box.classList.add("spotlightBox");
+ dialog?.classList.add("spotlight");
+ // Prevent SubDialog methods from manually setting dialog size.
+ box.setAttribute("sizeto", "available");
+ addEventListener("pagehide", () => {
+ box.classList.remove("spotlightBox");
+ dialog?.classList.remove("spotlight");
+ box.removeAttribute("sizeto");
+ });
+
+ // Load the bundle to render the content as configured.
+ document.head.appendChild(document.createElement("script")).src =
+ "resource://activity-stream/aboutwelcome/aboutwelcome.bundle.js";
+ ready();
+}
+
+// Indicate when we're ready to show and size (async localized) content.
+document.mozSubdialogReady = new Promise(resolve =>
+ document.addEventListener(
+ "DOMContentLoaded",
+ () => renderMultistage(resolve),
+ {
+ once: true,
+ }
+ )
+);
diff --git a/browser/base/content/static-robot.png b/browser/base/content/static-robot.png
new file mode 100644
index 0000000000..52338ff81e
--- /dev/null
+++ b/browser/base/content/static-robot.png
Binary files differ
diff --git a/browser/base/content/swipe-navigation.inc.xhtml b/browser/base/content/swipe-navigation.inc.xhtml
new file mode 100644
index 0000000000..c72af7fdee
--- /dev/null
+++ b/browser/base/content/swipe-navigation.inc.xhtml
@@ -0,0 +1,33 @@
+# 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/.
+#
+# This file has an SVG icon for the swipe navigation. It's merged into
+# browser.xhtml at build time so that loading the SVG during browser starting
+# up can be avoided.
+#
+# Note that the real navigation icon is cloned and injected into the browserStack
+# <stack> element in each tab when needed, so the SVG here is being hidden.
+ <hbox hidden="true">
+#ifdef XP_MACOSX
+ <svg id="swipe-nav-icon" class="swipe-nav-icon"
+ width="25" height="49" viewBox="0 0 25 49" fill="none"
+ xmlns="http://www.w3.org/2000/svg">
+ <path class="swipe-nav-icon-circle" d="M.5,.5V48.5c13.23,0,24-10.77,24-24S13.73,.5,.5,.5Z"/>
+ <path class="swipe-nav-icon-circle-outline" d="M.5,0H0V49H.5c13.51,0,24.5-10.99,24.5-24.5S14.01,0,.5,0ZM.5,48.5V.5C13.73,.5,24.5,11.27,24.5,24.5S13.73,48.5,.5,48.5Z"/>
+ <path class="swipe-nav-icon-arrow" d="M13.15,24.43H4.76l3.19-3.19c.08-.09,.13-.2,.13-.33,0-.12-.05-.24-.14-.32-.09-.09-.2-.13-.32-.14-.12,0-.24,.04-.33,.13l-4.05,4.05v.51l4.05,4.05s.09,.08,.15,.1c.06,.02,.12,.04,.18,.04s.12-.01,.18-.04c.06-.02,.11-.06,.15-.1,.09-.09,.14-.2,.14-.33s-.05-.24-.14-.33l-3.19-3.19H13.15c.12,0,.24-.05,.33-.14,.09-.09,.14-.2,.14-.33s-.05-.24-.14-.33c-.09-.09-.2-.14-.33-.14Z"/>
+ </svg>
+#else
+#
+# To make the edges of the circle clear during the animations, there are two
+# <circle> elements and the lower one is used for the glowing effect.
+#
+ <svg id="swipe-nav-icon" class="swipe-nav-icon"
+ width="66" height="66" viewBox="0 0 66 66" fill="none"
+ xmlns="http://www.w3.org/2000/svg">
+ <circle cx="33" cy="33" r="30" />
+ <circle cx="33" cy="33" r="12" />
+ <path d="M37.6481 32.9253H29.2578L32.4489 29.7341C32.5321 29.6465 32.5778 29.5298 32.5763 29.4089C32.5747 29.288 32.526 29.1725 32.4405 29.087C32.355 29.0015 32.2395 28.9528 32.1186 28.9512C31.9977 28.9496 31.881 28.9954 31.7933 29.0786L27.7408 33.1334V33.6438L31.7941 37.6978C31.837 37.741 31.888 37.7753 31.9442 37.7987C32.0004 37.8221 32.0606 37.8341 32.1215 37.8341C32.1823 37.8341 32.2426 37.8221 32.2988 37.7987C32.355 37.7753 32.406 37.741 32.4489 37.6978C32.5354 37.6107 32.584 37.4929 32.584 37.3701C32.584 37.2473 32.5354 37.1294 32.4489 37.0423L29.2578 33.8512H37.6481C37.7709 33.8512 37.8887 33.8024 37.9755 33.7156C38.0623 33.6288 38.1111 33.511 38.1111 33.3882C38.1111 33.2654 38.0623 33.1477 37.9755 33.0609C37.8887 32.974 37.7709 32.9253 37.6481 32.9253Z" />
+ </svg>
+#endif
+ </hbox>
diff --git a/browser/base/content/tabbrowser-tab.js b/browser/base/content/tabbrowser-tab.js
new file mode 100644
index 0000000000..5b2e15d825
--- /dev/null
+++ b/browser/base/content/tabbrowser-tab.js
@@ -0,0 +1,670 @@
+/* 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 is loaded into chrome windows with the subscript loader. Wrap in
+// a block to prevent accidentally leaking globals onto `window`.
+{
+ class MozTabbrowserTab extends MozElements.MozTab {
+ static markup = `
+ <stack class="tab-stack" flex="1">
+ <vbox class="tab-background">
+ <hbox class="tab-context-line"/>
+ <hbox class="tab-loading-burst" flex="1"/>
+ </vbox>
+ <hbox class="tab-content" align="center">
+ <stack class="tab-icon-stack">
+ <hbox class="tab-throbber"/>
+ <hbox class="tab-icon-pending"/>
+ <html:img class="tab-icon-image" role="presentation" decoding="sync" />
+ <image class="tab-sharing-icon-overlay" role="presentation"/>
+ <image class="tab-icon-overlay" role="presentation"/>
+ </stack>
+ <vbox class="tab-label-container"
+ onoverflow="this.setAttribute('textoverflow', 'true');"
+ onunderflow="this.removeAttribute('textoverflow');"
+ align="start"
+ pack="center"
+ flex="1">
+ <label class="tab-text tab-label" role="presentation"/>
+ <hbox class="tab-secondary-label">
+ <label class="tab-icon-sound-label tab-icon-sound-playing-label" data-l10n-id="browser-tab-audio-playing2" role="presentation"/>
+ <label class="tab-icon-sound-label tab-icon-sound-muted-label" data-l10n-id="browser-tab-audio-muted2" role="presentation"/>
+ <label class="tab-icon-sound-label tab-icon-sound-blocked-label" data-l10n-id="browser-tab-audio-blocked" role="presentation"/>
+ <label class="tab-icon-sound-label tab-icon-sound-pip-label" data-l10n-id="browser-tab-audio-pip" role="presentation"/>
+ <label class="tab-icon-sound-label tab-icon-sound-tooltip-label" role="presentation"/>
+ </hbox>
+ </vbox>
+ <image class="tab-close-button close-icon" role="presentation"/>
+ </hbox>
+ </stack>
+ `;
+
+ constructor() {
+ super();
+
+ this.addEventListener("mouseover", this);
+ this.addEventListener("mouseout", this);
+ this.addEventListener("dragstart", this, true);
+ this.addEventListener("dragstart", this);
+ this.addEventListener("mousedown", this);
+ this.addEventListener("mouseup", this);
+ this.addEventListener("click", this);
+ this.addEventListener("dblclick", this, true);
+ this.addEventListener("animationend", this);
+ this.addEventListener("focus", this);
+ this.addEventListener("AriaFocus", this);
+
+ this._hover = false;
+ this._selectedOnFirstMouseDown = false;
+
+ /**
+ * Describes how the tab ended up in this mute state. May be any of:
+ *
+ * - undefined: The tabs mute state has never changed.
+ * - null: The mute state was last changed through the UI.
+ * - Any string: The ID was changed through an extension API. The string
+ * must be the ID of the extension which changed it.
+ */
+ this.muteReason = undefined;
+
+ this.mOverCloseButton = false;
+
+ this.mCorrespondingMenuitem = null;
+
+ this.closing = false;
+ }
+
+ static get inheritedAttributes() {
+ return {
+ ".tab-background": "selected=visuallyselected,fadein,multiselected",
+ ".tab-line": "selected=visuallyselected,multiselected",
+ ".tab-loading-burst": "pinned,bursting,notselectedsinceload",
+ ".tab-content":
+ "pinned,selected=visuallyselected,titlechanged,attention",
+ ".tab-icon-stack":
+ "sharing,pictureinpicture,crashed,busy,soundplaying,soundplaying-scheduledremoval,pinned,muted,blocked,selected=visuallyselected,activemedia-blocked,indicator-replaces-favicon",
+ ".tab-throbber":
+ "fadein,pinned,busy,progress,selected=visuallyselected",
+ ".tab-icon-pending":
+ "fadein,pinned,busy,progress,selected=visuallyselected,pendingicon",
+ ".tab-icon-image":
+ "src=image,triggeringprincipal=iconloadingprincipal,requestcontextid,fadein,pinned,selected=visuallyselected,busy,crashed,sharing,pictureinpicture",
+ ".tab-sharing-icon-overlay": "sharing,selected=visuallyselected,pinned",
+ ".tab-icon-overlay":
+ "sharing,pictureinpicture,crashed,busy,soundplaying,soundplaying-scheduledremoval,pinned,muted,blocked,selected=visuallyselected,activemedia-blocked,indicator-replaces-favicon",
+ ".tab-label-container":
+ "pinned,selected=visuallyselected,labeldirection",
+ ".tab-label":
+ "text=label,accesskey,fadein,pinned,selected=visuallyselected,attention",
+ ".tab-label-container .tab-secondary-label":
+ "soundplaying,soundplaying-scheduledremoval,pinned,muted,blocked,selected=visuallyselected,activemedia-blocked,pictureinpicture",
+ ".tab-close-button": "fadein,pinned,selected=visuallyselected",
+ };
+ }
+
+ connectedCallback() {
+ this.initialize();
+ }
+
+ initialize() {
+ if (this._initialized) {
+ return;
+ }
+
+ this.textContent = "";
+ this.appendChild(this.constructor.fragment);
+ this.initializeAttributeInheritance();
+ this.setAttribute("context", "tabContextMenu");
+ this._initialized = true;
+
+ if (!("_lastAccessed" in this)) {
+ this.updateLastAccessed();
+ }
+ }
+
+ get container() {
+ return gBrowser.tabContainer;
+ }
+
+ set attention(val) {
+ if (val == this.hasAttribute("attention")) {
+ return;
+ }
+
+ this.toggleAttribute("attention", val);
+ gBrowser._tabAttrModified(this, ["attention"]);
+ }
+
+ set _visuallySelected(val) {
+ if (val == (this.getAttribute("visuallyselected") == "true")) {
+ return;
+ }
+
+ if (val) {
+ this.setAttribute("visuallyselected", "true");
+ } else {
+ this.removeAttribute("visuallyselected");
+ }
+ gBrowser._tabAttrModified(this, ["visuallyselected"]);
+ }
+
+ set _selected(val) {
+ // in e10s we want to only pseudo-select a tab before its rendering is done, so that
+ // the rest of the system knows that the tab is selected, but we don't want to update its
+ // visual status to selected until after we receive confirmation that its content has painted.
+ if (val) {
+ this.setAttribute("selected", "true");
+ } else {
+ this.removeAttribute("selected");
+ }
+
+ // If we're non-e10s we should update the visual selection as well at the same time,
+ // *or* if we're e10s and the visually selected tab isn't changing, in which case the
+ // tab switcher code won't run and update anything else (like the before- and after-
+ // selected attributes).
+ if (
+ !gMultiProcessBrowser ||
+ (val && this.hasAttribute("visuallyselected"))
+ ) {
+ this._visuallySelected = val;
+ }
+ }
+
+ get pinned() {
+ return this.getAttribute("pinned") == "true";
+ }
+
+ get hidden() {
+ // This getter makes `hidden` read-only
+ return super.hidden;
+ }
+
+ get muted() {
+ return this.getAttribute("muted") == "true";
+ }
+
+ get multiselected() {
+ return this.getAttribute("multiselected") == "true";
+ }
+
+ get userContextId() {
+ return this.hasAttribute("usercontextid")
+ ? parseInt(this.getAttribute("usercontextid"))
+ : 0;
+ }
+
+ get soundPlaying() {
+ return this.getAttribute("soundplaying") == "true";
+ }
+
+ get pictureinpicture() {
+ return this.getAttribute("pictureinpicture") == "true";
+ }
+
+ get activeMediaBlocked() {
+ return this.getAttribute("activemedia-blocked") == "true";
+ }
+
+ get isEmpty() {
+ // Determines if a tab is "empty", usually used in the context of determining
+ // if it's ok to close the tab.
+ if (this.hasAttribute("busy")) {
+ return false;
+ }
+
+ if (this.hasAttribute("customizemode")) {
+ return false;
+ }
+
+ let browser = this.linkedBrowser;
+ if (!isBlankPageURL(browser.currentURI.spec)) {
+ return false;
+ }
+
+ if (!BrowserUIUtils.checkEmptyPageOrigin(browser)) {
+ return false;
+ }
+
+ if (browser.canGoForward || browser.canGoBack) {
+ return false;
+ }
+
+ return true;
+ }
+
+ get lastAccessed() {
+ return this._lastAccessed == Infinity ? Date.now() : this._lastAccessed;
+ }
+
+ get _overPlayingIcon() {
+ return this.overlayIcon?.matches(":hover");
+ }
+
+ get overlayIcon() {
+ return this.querySelector(".tab-icon-overlay");
+ }
+
+ get throbber() {
+ return this.querySelector(".tab-throbber");
+ }
+
+ get iconImage() {
+ return this.querySelector(".tab-icon-image");
+ }
+
+ get sharingIcon() {
+ return this.querySelector(".tab-sharing-icon-overlay");
+ }
+
+ get textLabel() {
+ return this.querySelector(".tab-label");
+ }
+
+ get closeButton() {
+ return this.querySelector(".tab-close-button");
+ }
+
+ updateLastAccessed(aDate) {
+ this._lastAccessed = this.selected ? Infinity : aDate || Date.now();
+ }
+
+ updateLastUnloadedByTabUnloader() {
+ this._lastUnloaded = Date.now();
+ Services.telemetry.scalarAdd("browser.engagement.tab_unload_count", 1);
+ }
+
+ recordTimeFromUnloadToReload() {
+ if (!this._lastUnloaded) {
+ return;
+ }
+
+ const diff_in_msec = Date.now() - this._lastUnloaded;
+ Services.telemetry
+ .getHistogramById("TAB_UNLOAD_TO_RELOAD")
+ .add(diff_in_msec / 1000);
+ Services.telemetry.scalarAdd("browser.engagement.tab_reload_count", 1);
+ delete this._lastUnloaded;
+ }
+
+ on_mouseover(event) {
+ if (event.target.classList.contains("tab-close-button")) {
+ this.mOverCloseButton = true;
+ }
+ if (this._overPlayingIcon) {
+ const selectedTabs = gBrowser.selectedTabs;
+ const contextTabInSelection = selectedTabs.includes(this);
+ const affectedTabsLength = contextTabInSelection
+ ? selectedTabs.length
+ : 1;
+ let stringID;
+ if (this.hasAttribute("activemedia-blocked")) {
+ stringID = "browser-tab-unblock";
+ } else {
+ stringID = this.linkedBrowser.audioMuted
+ ? "browser-tab-unmute"
+ : "browser-tab-mute";
+ }
+ this.setSecondaryTabTooltipLabel(stringID, {
+ count: affectedTabsLength,
+ });
+ }
+ this._mouseenter();
+ }
+
+ on_mouseout(event) {
+ if (event.target.classList.contains("tab-close-button")) {
+ this.mOverCloseButton = false;
+ }
+ if (event.target == this.overlayIcon) {
+ this.setSecondaryTabTooltipLabel(null);
+ }
+ this._mouseleave();
+ }
+
+ on_dragstart(event) {
+ // We use "failed" drag end events that weren't cancelled by the user
+ // to detach tabs. Ensure that we do not show the drag image returning
+ // to its point of origin when this happens, as it makes the drag
+ // finishing feel very slow.
+ event.dataTransfer.mozShowFailAnimation = false;
+ if (event.eventPhase == Event.CAPTURING_PHASE) {
+ this.style.MozUserFocus = "";
+ } else if (
+ this.mOverCloseButton ||
+ gSharedTabWarning.willShowSharedTabWarning(this)
+ ) {
+ event.stopPropagation();
+ }
+ }
+
+ on_mousedown(event) {
+ let eventMaySelectTab = true;
+ let tabContainer = this.container;
+
+ if (
+ tabContainer._closeTabByDblclick &&
+ event.button == 0 &&
+ event.detail == 1
+ ) {
+ this._selectedOnFirstMouseDown = this.selected;
+ }
+
+ if (this.selected) {
+ this.style.MozUserFocus = "ignore";
+ } else if (
+ event.target.classList.contains("tab-close-button") ||
+ event.target.classList.contains("tab-icon-overlay")
+ ) {
+ eventMaySelectTab = false;
+ }
+
+ if (event.button == 1) {
+ gBrowser.warmupTab(gBrowser._findTabToBlurTo(this));
+ }
+
+ if (event.button == 0) {
+ let shiftKey = event.shiftKey;
+ let accelKey = event.getModifierState("Accel");
+ if (shiftKey) {
+ eventMaySelectTab = false;
+ const lastSelectedTab = gBrowser.lastMultiSelectedTab;
+ if (!accelKey) {
+ gBrowser.selectedTab = lastSelectedTab;
+
+ // Make sure selection is cleared when tab-switch doesn't happen.
+ gBrowser.clearMultiSelectedTabs();
+ }
+ gBrowser.addRangeToMultiSelectedTabs(lastSelectedTab, this);
+ } else if (accelKey) {
+ // Ctrl (Cmd for mac) key is pressed
+ eventMaySelectTab = false;
+ if (this.multiselected) {
+ gBrowser.removeFromMultiSelectedTabs(this);
+ } else if (this != gBrowser.selectedTab) {
+ gBrowser.addToMultiSelectedTabs(this);
+ gBrowser.lastMultiSelectedTab = this;
+ }
+ } else if (!this.selected && this.multiselected) {
+ gBrowser.lockClearMultiSelectionOnce();
+ }
+ }
+
+ if (gSharedTabWarning.willShowSharedTabWarning(this)) {
+ eventMaySelectTab = false;
+ }
+
+ if (eventMaySelectTab) {
+ super.on_mousedown(event);
+ }
+ }
+
+ on_mouseup(event) {
+ // Make sure that clear-selection is released.
+ // Otherwise selection using Shift key may be broken.
+ gBrowser.unlockClearMultiSelection();
+
+ this.style.MozUserFocus = "";
+ }
+
+ on_click(event) {
+ if (event.button != 0) {
+ return;
+ }
+
+ if (event.getModifierState("Accel") || event.shiftKey) {
+ return;
+ }
+
+ if (
+ gBrowser.multiSelectedTabsCount > 0 &&
+ !event.target.classList.contains("tab-close-button") &&
+ !event.target.classList.contains("tab-icon-overlay")
+ ) {
+ // Tabs were previously multi-selected and user clicks on a tab
+ // without holding Ctrl/Cmd Key
+ gBrowser.clearMultiSelectedTabs();
+ }
+
+ if (event.target.classList.contains("tab-icon-overlay")) {
+ if (this.activeMediaBlocked) {
+ if (this.multiselected) {
+ gBrowser.resumeDelayedMediaOnMultiSelectedTabs(this);
+ } else {
+ this.resumeDelayedMedia();
+ }
+ } else if (this.soundPlaying || this.muted) {
+ if (this.multiselected) {
+ gBrowser.toggleMuteAudioOnMultiSelectedTabs(this);
+ } else {
+ this.toggleMuteAudio();
+ }
+ }
+ return;
+ }
+
+ if (event.target.classList.contains("tab-close-button")) {
+ if (this.multiselected) {
+ gBrowser.removeMultiSelectedTabs();
+ } else {
+ gBrowser.removeTab(this, {
+ animate: true,
+ triggeringEvent: event,
+ });
+ }
+ // This enables double-click protection for the tab container
+ // (see tabbrowser-tabs 'click' handler).
+ gBrowser.tabContainer._blockDblClick = true;
+ }
+ }
+
+ on_dblclick(event) {
+ if (event.button != 0) {
+ return;
+ }
+
+ // for the one-close-button case
+ if (event.target.classList.contains("tab-close-button")) {
+ event.stopPropagation();
+ }
+
+ let tabContainer = this.container;
+ if (
+ tabContainer._closeTabByDblclick &&
+ this._selectedOnFirstMouseDown &&
+ this.selected &&
+ !event.target.classList.contains("tab-icon-overlay")
+ ) {
+ gBrowser.removeTab(this, {
+ animate: true,
+ triggeringEvent: event,
+ });
+ }
+ }
+
+ on_animationend(event) {
+ if (event.target.classList.contains("tab-loading-burst")) {
+ this.removeAttribute("bursting");
+ }
+ }
+
+ _mouseenter() {
+ if (this.hidden || this.closing) {
+ return;
+ }
+ this._hover = true;
+
+ if (this.selected) {
+ this.container._handleTabSelect();
+ } else if (this.linkedPanel) {
+ this.linkedBrowser.unselectedTabHover(true);
+ this.startUnselectedTabHoverTimer();
+ }
+
+ // Prepare connection to host beforehand.
+ SessionStore.speculativeConnectOnTabHover(this);
+
+ let tabToWarm = this;
+ if (this.mOverCloseButton) {
+ tabToWarm = gBrowser._findTabToBlurTo(this);
+ }
+ gBrowser.warmupTab(tabToWarm);
+ }
+
+ _mouseleave() {
+ if (!this._hover) {
+ return;
+ }
+ this._hover = false;
+ if (this.linkedPanel && !this.selected) {
+ this.linkedBrowser.unselectedTabHover(false);
+ this.cancelUnselectedTabHoverTimer();
+ }
+ }
+
+ setSecondaryTabTooltipLabel(l10nID, l10nArgs) {
+ this.querySelector(".tab-secondary-label").toggleAttribute(
+ "showtooltip",
+ l10nID
+ );
+
+ const tooltipEl = this.querySelector(".tab-icon-sound-tooltip-label");
+
+ if (l10nArgs) {
+ tooltipEl.setAttribute("data-l10n-args", JSON.stringify(l10nArgs));
+ } else {
+ tooltipEl.removeAttribute("data-l10n-args");
+ }
+ if (l10nID) {
+ tooltipEl.setAttribute("data-l10n-id", l10nID);
+ } else {
+ tooltipEl.removeAttribute("data-l10n-id");
+ }
+ }
+
+ startUnselectedTabHoverTimer() {
+ // Only record data when we need to.
+ if (!this.linkedBrowser.shouldHandleUnselectedTabHover) {
+ return;
+ }
+
+ if (
+ !TelemetryStopwatch.running("HOVER_UNTIL_UNSELECTED_TAB_OPENED", this)
+ ) {
+ TelemetryStopwatch.start("HOVER_UNTIL_UNSELECTED_TAB_OPENED", this);
+ }
+
+ if (this._hoverTabTimer) {
+ clearTimeout(this._hoverTabTimer);
+ this._hoverTabTimer = null;
+ }
+ }
+
+ cancelUnselectedTabHoverTimer() {
+ // Since we're listening "mouseout" event, instead of "mouseleave".
+ // Every time the cursor is moving from the tab to its child node (icon),
+ // it would dispatch "mouseout"(for tab) first and then dispatch
+ // "mouseover" (for icon, eg: close button, speaker icon) soon.
+ // It causes we would cancel present TelemetryStopwatch immediately
+ // when cursor is moving on the icon, and then start a new one.
+ // In order to avoid this situation, we could delay cancellation and
+ // remove it if we get "mouseover" within very short period.
+ this._hoverTabTimer = setTimeout(() => {
+ if (
+ TelemetryStopwatch.running("HOVER_UNTIL_UNSELECTED_TAB_OPENED", this)
+ ) {
+ TelemetryStopwatch.cancel("HOVER_UNTIL_UNSELECTED_TAB_OPENED", this);
+ }
+ }, 100);
+ }
+
+ finishUnselectedTabHoverTimer() {
+ // Stop timer when the tab is opened.
+ if (
+ TelemetryStopwatch.running("HOVER_UNTIL_UNSELECTED_TAB_OPENED", this)
+ ) {
+ TelemetryStopwatch.finish("HOVER_UNTIL_UNSELECTED_TAB_OPENED", this);
+ }
+ }
+
+ resumeDelayedMedia() {
+ if (this.activeMediaBlocked) {
+ Services.telemetry
+ .getHistogramById("TAB_AUDIO_INDICATOR_USED")
+ .add(3 /* unblockByClickingIcon */);
+ this.removeAttribute("activemedia-blocked");
+ this.linkedBrowser.resumeMedia();
+ gBrowser._tabAttrModified(this, ["activemedia-blocked"]);
+ }
+ }
+
+ toggleMuteAudio(aMuteReason) {
+ let browser = this.linkedBrowser;
+ let hist = Services.telemetry.getHistogramById(
+ "TAB_AUDIO_INDICATOR_USED"
+ );
+
+ if (browser.audioMuted) {
+ if (this.linkedPanel) {
+ // "Lazy Browser" should not invoke its unmute method
+ browser.unmute();
+ }
+ this.removeAttribute("muted");
+ hist.add(1 /* unmute */);
+ } else {
+ if (this.linkedPanel) {
+ // "Lazy Browser" should not invoke its mute method
+ browser.mute();
+ }
+ this.setAttribute("muted", "true");
+ hist.add(0 /* mute */);
+ }
+ this.muteReason = aMuteReason || null;
+
+ gBrowser._tabAttrModified(this, ["muted"]);
+ }
+
+ setUserContextId(aUserContextId) {
+ if (aUserContextId) {
+ if (this.linkedBrowser) {
+ this.linkedBrowser.setAttribute("usercontextid", aUserContextId);
+ }
+ this.setAttribute("usercontextid", aUserContextId);
+ } else {
+ if (this.linkedBrowser) {
+ this.linkedBrowser.removeAttribute("usercontextid");
+ }
+ this.removeAttribute("usercontextid");
+ }
+
+ ContextualIdentityService.setTabStyle(this);
+ }
+
+ updateA11yDescription() {
+ let prevDescTab = gBrowser.tabContainer.querySelector(
+ "tab[aria-describedby]"
+ );
+ if (prevDescTab) {
+ // We can only have a description for the focused tab.
+ prevDescTab.removeAttribute("aria-describedby");
+ }
+ let desc = document.getElementById("tabbrowser-tab-a11y-desc");
+ desc.textContent = gBrowser.getTabTooltip(this, false);
+ this.setAttribute("aria-describedby", "tabbrowser-tab-a11y-desc");
+ }
+
+ on_focus(event) {
+ this.updateA11yDescription();
+ }
+
+ on_AriaFocus(event) {
+ this.updateA11yDescription();
+ }
+ }
+
+ customElements.define("tabbrowser-tab", MozTabbrowserTab, {
+ extends: "tab",
+ });
+}
diff --git a/browser/base/content/tabbrowser-tabs.js b/browser/base/content/tabbrowser-tabs.js
new file mode 100644
index 0000000000..06c2479886
--- /dev/null
+++ b/browser/base/content/tabbrowser-tabs.js
@@ -0,0 +1,2172 @@
+/* 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/. */
+
+/* eslint-env mozilla/browser-window */
+
+"use strict";
+
+// This is loaded into all browser windows. Wrap in a block to prevent
+// leaking to window scope.
+{
+ class MozTabbrowserTabs extends MozElements.TabsBase {
+ constructor() {
+ super();
+
+ this.addEventListener("TabSelect", this);
+ this.addEventListener("TabClose", this);
+ this.addEventListener("TabAttrModified", this);
+ this.addEventListener("TabHide", this);
+ this.addEventListener("TabShow", this);
+ this.addEventListener("TabPinned", this);
+ this.addEventListener("TabUnpinned", this);
+ this.addEventListener("transitionend", this);
+ this.addEventListener("dblclick", this);
+ this.addEventListener("click", this);
+ this.addEventListener("click", this, true);
+ this.addEventListener("keydown", this, { mozSystemGroup: true });
+ this.addEventListener("dragstart", this);
+ this.addEventListener("dragover", this);
+ this.addEventListener("drop", this);
+ this.addEventListener("dragend", this);
+ this.addEventListener("dragleave", this);
+ }
+
+ init() {
+ this.arrowScrollbox = this.querySelector("arrowscrollbox");
+ this.arrowScrollbox.addEventListener("wheel", this, true);
+
+ this.baseConnect();
+
+ this._blockDblClick = false;
+ this._tabDropIndicator = this.querySelector(".tab-drop-indicator");
+ this._dragOverDelay = 350;
+ this._dragTime = 0;
+ this._closeButtonsUpdatePending = false;
+ this._closingTabsSpacer = this.querySelector(".closing-tabs-spacer");
+ this._tabDefaultMaxWidth = NaN;
+ this._lastTabClosedByMouse = false;
+ this._hasTabTempMaxWidth = false;
+ this._scrollButtonWidth = 0;
+ this._lastNumPinned = 0;
+ this._pinnedTabsLayoutCache = null;
+ this._animateElement = this.arrowScrollbox;
+ this._tabClipWidth = Services.prefs.getIntPref(
+ "browser.tabs.tabClipWidth"
+ );
+ this._hiddenSoundPlayingTabs = new Set();
+ this._allTabs = null;
+ this._visibleTabs = null;
+
+ var tab = this.allTabs[0];
+ tab.label = this.emptyTabTitle;
+
+ // Hide the secondary text for locales where it is unsupported due to size constraints.
+ const language = Services.locale.appLocaleAsBCP47;
+ const unsupportedLocales = Services.prefs.getCharPref(
+ "browser.tabs.secondaryTextUnsupportedLocales"
+ );
+ this.toggleAttribute(
+ "secondarytext-unsupported",
+ unsupportedLocales.split(",").includes(language.split("-")[0])
+ );
+
+ this.newTabButton.setAttribute(
+ "aria-label",
+ GetDynamicShortcutTooltipText("tabs-newtab-button")
+ );
+
+ let handleResize = () => {
+ this._updateCloseButtons();
+ this._handleTabSelect(true);
+ };
+ window.addEventListener("resize", handleResize);
+ this._fullscreenMutationObserver = new MutationObserver(handleResize);
+ this._fullscreenMutationObserver.observe(document.documentElement, {
+ attributeFilter: ["inFullscreen", "inDOMFullscreen"],
+ });
+
+ this.boundObserve = (...args) => this.observe(...args);
+ Services.prefs.addObserver("privacy.userContext", this.boundObserve);
+ this.observe(null, "nsPref:changed", "privacy.userContext.enabled");
+
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "_tabMinWidthPref",
+ "browser.tabs.tabMinWidth",
+ null,
+ (pref, prevValue, newValue) => (this._tabMinWidth = newValue),
+ newValue => {
+ const LIMIT = 50;
+ return Math.max(newValue, LIMIT);
+ }
+ );
+
+ this._tabMinWidth = this._tabMinWidthPref;
+
+ this._setPositionalAttributes();
+
+ CustomizableUI.addListener(this);
+ this._updateNewTabVisibility();
+ this._initializeArrowScrollbox();
+
+ XPCOMUtils.defineLazyPreferenceGetter(
+ this,
+ "_closeTabByDblclick",
+ "browser.tabs.closeTabByDblclick",
+ false
+ );
+
+ if (gMultiProcessBrowser) {
+ this.tabbox.tabpanels.setAttribute("async", "true");
+ }
+ }
+
+ on_TabSelect(event) {
+ this._handleTabSelect();
+ }
+
+ on_TabClose(event) {
+ this._hiddenSoundPlayingStatusChanged(event.target, { closed: true });
+ }
+
+ on_TabAttrModified(event) {
+ if (
+ ["soundplaying", "muted", "activemedia-blocked", "sharing"].some(attr =>
+ event.detail.changed.includes(attr)
+ )
+ ) {
+ this.updateTabIndicatorAttr(event.target);
+ }
+
+ if (
+ event.detail.changed.includes("soundplaying") &&
+ event.target.hidden
+ ) {
+ this._hiddenSoundPlayingStatusChanged(event.target);
+ }
+ }
+
+ on_TabHide(event) {
+ if (event.target.soundPlaying) {
+ this._hiddenSoundPlayingStatusChanged(event.target);
+ }
+ }
+
+ on_TabShow(event) {
+ if (event.target.soundPlaying) {
+ this._hiddenSoundPlayingStatusChanged(event.target);
+ }
+ }
+
+ on_TabPinned(event) {
+ this.updateTabIndicatorAttr(event.target);
+ }
+
+ on_TabUnpinned(event) {
+ this.updateTabIndicatorAttr(event.target);
+ }
+
+ on_transitionend(event) {
+ if (event.propertyName != "max-width") {
+ return;
+ }
+
+ let tab = event.target ? event.target.closest("tab") : null;
+
+ if (tab.getAttribute("fadein") == "true") {
+ if (tab._fullyOpen) {
+ this._updateCloseButtons();
+ } else {
+ this._handleNewTab(tab);
+ }
+ } else if (tab.closing) {
+ gBrowser._endRemoveTab(tab);
+ }
+
+ let evt = new CustomEvent("TabAnimationEnd", { bubbles: true });
+ tab.dispatchEvent(evt);
+ }
+
+ on_dblclick(event) {
+ // When the tabbar has an unified appearance with the titlebar
+ // and menubar, a double-click in it should have the same behavior
+ // as double-clicking the titlebar
+ if (TabsInTitlebar.enabled) {
+ return;
+ }
+
+ if (event.button != 0 || event.originalTarget.localName != "scrollbox") {
+ return;
+ }
+
+ if (!this._blockDblClick) {
+ BrowserOpenTab();
+ }
+
+ event.preventDefault();
+ }
+
+ on_click(event) {
+ if (event.eventPhase == Event.CAPTURING_PHASE && event.button == 0) {
+ /* Catches extra clicks meant for the in-tab close button.
+ * Placed here to avoid leaking (a temporary handler added from the
+ * in-tab close button binding would close over the tab and leak it
+ * until the handler itself was removed). (bug 897751)
+ *
+ * The only sequence in which a second click event (i.e. dblclik)
+ * can be dispatched on an in-tab close button is when it is shown
+ * after the first click (i.e. the first click event was dispatched
+ * on the tab). This happens when we show the close button only on
+ * the active tab. (bug 352021)
+ * The only sequence in which a third click event can be dispatched
+ * on an in-tab close button is when the tab was opened with a
+ * double click on the tabbar. (bug 378344)
+ * In both cases, it is most likely that the close button area has
+ * been accidentally clicked, therefore we do not close the tab.
+ *
+ * We don't want to ignore processing of more than one click event,
+ * though, since the user might actually be repeatedly clicking to
+ * close many tabs at once.
+ */
+ let target = event.originalTarget;
+ if (target.classList.contains("tab-close-button")) {
+ // We preemptively set this to allow the closing-multiple-tabs-
+ // in-a-row case.
+ if (this._blockDblClick) {
+ target._ignoredCloseButtonClicks = true;
+ } else if (event.detail > 1 && !target._ignoredCloseButtonClicks) {
+ target._ignoredCloseButtonClicks = true;
+ event.stopPropagation();
+ return;
+ } else {
+ // Reset the "ignored click" flag
+ target._ignoredCloseButtonClicks = false;
+ }
+ }
+
+ /* Protects from close-tab-button errant doubleclick:
+ * Since we're removing the event target, if the user
+ * double-clicks the button, the dblclick event will be dispatched
+ * with the tabbar as its event target (and explicit/originalTarget),
+ * which treats that as a mouse gesture for opening a new tab.
+ * In this context, we're manually blocking the dblclick event.
+ */
+ if (this._blockDblClick) {
+ if (!("_clickedTabBarOnce" in this)) {
+ this._clickedTabBarOnce = true;
+ return;
+ }
+ delete this._clickedTabBarOnce;
+ this._blockDblClick = false;
+ }
+ } else if (
+ event.eventPhase == Event.BUBBLING_PHASE &&
+ event.button == 1
+ ) {
+ let tab = event.target ? event.target.closest("tab") : null;
+ if (tab) {
+ if (tab.multiselected) {
+ gBrowser.removeMultiSelectedTabs();
+ } else {
+ gBrowser.removeTab(tab, {
+ animate: true,
+ triggeringEvent: event,
+ });
+ }
+ } else if (event.originalTarget.closest("scrollbox")) {
+ // The user middleclicked on the tabstrip. Check whether the click
+ // was dispatched on the open space of it.
+ let visibleTabs = this._getVisibleTabs();
+ let lastTab = visibleTabs[visibleTabs.length - 1];
+ let winUtils = window.windowUtils;
+ let endOfTab =
+ winUtils.getBoundsWithoutFlushing(lastTab)[
+ RTL_UI ? "left" : "right"
+ ];
+ if (
+ (!RTL_UI && event.clientX > endOfTab) ||
+ (RTL_UI && event.clientX < endOfTab)
+ ) {
+ BrowserOpenTab();
+ }
+ } else {
+ return;
+ }
+
+ event.preventDefault();
+ event.stopPropagation();
+ }
+ }
+
+ on_keydown(event) {
+ let { altKey, shiftKey } = event;
+ let [accel, nonAccel] =
+ AppConstants.platform == "macosx"
+ ? [event.metaKey, event.ctrlKey]
+ : [event.ctrlKey, event.metaKey];
+
+ let keyComboForMove = accel && shiftKey && !altKey && !nonAccel;
+ let keyComboForFocus = accel && !shiftKey && !altKey && !nonAccel;
+
+ if (!keyComboForMove && !keyComboForFocus) {
+ return;
+ }
+
+ // Don't check if the event was already consumed because tab navigation
+ // should work always for better user experience.
+ let { visibleTabs, selectedTab } = gBrowser;
+ let { arrowKeysShouldWrap } = this;
+ let focusedTabIndex = this.ariaFocusedIndex;
+ if (focusedTabIndex == -1) {
+ focusedTabIndex = visibleTabs.indexOf(selectedTab);
+ }
+ let lastFocusedTabIndex = focusedTabIndex;
+ switch (event.keyCode) {
+ case KeyEvent.DOM_VK_UP:
+ if (keyComboForMove) {
+ gBrowser.moveTabBackward();
+ } else {
+ focusedTabIndex--;
+ }
+ break;
+ case KeyEvent.DOM_VK_DOWN:
+ if (keyComboForMove) {
+ gBrowser.moveTabForward();
+ } else {
+ focusedTabIndex++;
+ }
+ break;
+ case KeyEvent.DOM_VK_RIGHT:
+ case KeyEvent.DOM_VK_LEFT:
+ if (keyComboForMove) {
+ gBrowser.moveTabOver(event);
+ } else if (
+ (!RTL_UI && event.keyCode == KeyEvent.DOM_VK_RIGHT) ||
+ (RTL_UI && event.keyCode == KeyEvent.DOM_VK_LEFT)
+ ) {
+ focusedTabIndex++;
+ } else {
+ focusedTabIndex--;
+ }
+ break;
+ case KeyEvent.DOM_VK_HOME:
+ if (keyComboForMove) {
+ gBrowser.moveTabToStart();
+ } else {
+ focusedTabIndex = 0;
+ }
+ break;
+ case KeyEvent.DOM_VK_END:
+ if (keyComboForMove) {
+ gBrowser.moveTabToEnd();
+ } else {
+ focusedTabIndex = visibleTabs.length - 1;
+ }
+ break;
+ case KeyEvent.DOM_VK_SPACE:
+ if (visibleTabs[lastFocusedTabIndex].multiselected) {
+ gBrowser.removeFromMultiSelectedTabs(
+ visibleTabs[lastFocusedTabIndex]
+ );
+ } else {
+ gBrowser.addToMultiSelectedTabs(visibleTabs[lastFocusedTabIndex]);
+ }
+ break;
+ default:
+ // Consume the keydown event for the above keyboard
+ // shortcuts only.
+ return;
+ }
+
+ if (arrowKeysShouldWrap) {
+ if (focusedTabIndex >= visibleTabs.length) {
+ focusedTabIndex = 0;
+ } else if (focusedTabIndex < 0) {
+ focusedTabIndex = visibleTabs.length - 1;
+ }
+ } else {
+ focusedTabIndex = Math.min(
+ visibleTabs.length - 1,
+ Math.max(0, focusedTabIndex)
+ );
+ }
+
+ if (keyComboForFocus && focusedTabIndex != lastFocusedTabIndex) {
+ this.ariaFocusedItem = visibleTabs[focusedTabIndex];
+ }
+
+ event.preventDefault();
+ }
+
+ on_dragstart(event) {
+ var tab = this._getDragTargetTab(event);
+ if (!tab || this._isCustomizing) {
+ return;
+ }
+
+ this.startTabDrag(event, tab);
+ }
+
+ startTabDrag(event, tab, { fromTabList = false } = {}) {
+ let selectedTabs = gBrowser.selectedTabs;
+ let otherSelectedTabs = selectedTabs.filter(
+ selectedTab => selectedTab != tab
+ );
+ let dataTransferOrderedTabs = [tab].concat(otherSelectedTabs);
+
+ let dt = event.dataTransfer;
+ for (let i = 0; i < dataTransferOrderedTabs.length; i++) {
+ let dtTab = dataTransferOrderedTabs[i];
+
+ dt.mozSetDataAt(TAB_DROP_TYPE, dtTab, i);
+ let dtBrowser = dtTab.linkedBrowser;
+
+ // We must not set text/x-moz-url or text/plain data here,
+ // otherwise trying to detach the tab by dropping it on the desktop
+ // may result in an "internet shortcut"
+ dt.mozSetDataAt(
+ "text/x-moz-text-internal",
+ dtBrowser.currentURI.spec,
+ i
+ );
+ }
+
+ // Set the cursor to an arrow during tab drags.
+ dt.mozCursor = "default";
+
+ // Set the tab as the source of the drag, which ensures we have a stable
+ // node to deliver the `dragend` event. See bug 1345473.
+ dt.addElement(tab);
+
+ if (tab.multiselected) {
+ this._groupSelectedTabs(tab);
+ }
+
+ // Create a canvas to which we capture the current tab.
+ // Until canvas is HiDPI-aware (bug 780362), we need to scale the desired
+ // canvas size (in CSS pixels) to the window's backing resolution in order
+ // to get a full-resolution drag image for use on HiDPI displays.
+ let scale = window.devicePixelRatio;
+ let canvas = this._dndCanvas;
+ if (!canvas) {
+ this._dndCanvas = canvas = document.createElementNS(
+ "http://www.w3.org/1999/xhtml",
+ "canvas"
+ );
+ canvas.style.width = "100%";
+ canvas.style.height = "100%";
+ canvas.mozOpaque = true;
+ }
+
+ canvas.width = 160 * scale;
+ canvas.height = 90 * scale;
+ let toDrag = canvas;
+ let dragImageOffset = -16;
+ let browser = tab.linkedBrowser;
+ if (gMultiProcessBrowser) {
+ var context = canvas.getContext("2d");
+ context.fillStyle = "white";
+ context.fillRect(0, 0, canvas.width, canvas.height);
+
+ let captureListener;
+ let platform = AppConstants.platform;
+ // On Windows and Mac we can update the drag image during a drag
+ // using updateDragImage. On Linux, we can use a panel.
+ if (platform == "win" || platform == "macosx") {
+ captureListener = function () {
+ dt.updateDragImage(canvas, dragImageOffset, dragImageOffset);
+ };
+ } else {
+ // Create a panel to use it in setDragImage
+ // which will tell xul to render a panel that follows
+ // the pointer while a dnd session is on.
+ if (!this._dndPanel) {
+ this._dndCanvas = canvas;
+ this._dndPanel = document.createXULElement("panel");
+ this._dndPanel.className = "dragfeedback-tab";
+ this._dndPanel.setAttribute("type", "drag");
+ let wrapper = document.createElementNS(
+ "http://www.w3.org/1999/xhtml",
+ "div"
+ );
+ wrapper.style.width = "160px";
+ wrapper.style.height = "90px";
+ wrapper.appendChild(canvas);
+ this._dndPanel.appendChild(wrapper);
+ document.documentElement.appendChild(this._dndPanel);
+ }
+ toDrag = this._dndPanel;
+ }
+ // PageThumb is async with e10s but that's fine
+ // since we can update the image during the dnd.
+ PageThumbs.captureToCanvas(browser, canvas)
+ .then(captureListener)
+ .catch(e => console.error(e));
+ } else {
+ // For the non e10s case we can just use PageThumbs
+ // sync, so let's use the canvas for setDragImage.
+ PageThumbs.captureToCanvas(browser, canvas).catch(e =>
+ console.error(e)
+ );
+ dragImageOffset = dragImageOffset * scale;
+ }
+ dt.setDragImage(toDrag, dragImageOffset, dragImageOffset);
+
+ // _dragData.offsetX/Y give the coordinates that the mouse should be
+ // positioned relative to the corner of the new window created upon
+ // dragend such that the mouse appears to have the same position
+ // relative to the corner of the dragged tab.
+ function clientX(ele) {
+ return ele.getBoundingClientRect().left;
+ }
+ let tabOffsetX = clientX(tab) - clientX(this);
+ tab._dragData = {
+ offsetX: event.screenX - window.screenX - tabOffsetX,
+ offsetY: event.screenY - window.screenY,
+ scrollX: this.arrowScrollbox.scrollbox.scrollLeft,
+ screenX: event.screenX,
+ movingTabs: (tab.multiselected ? gBrowser.selectedTabs : [tab]).filter(
+ t => t.pinned == tab.pinned
+ ),
+ fromTabList,
+ };
+
+ event.stopPropagation();
+
+ if (fromTabList) {
+ Services.telemetry.scalarAdd(
+ "browser.ui.interaction.all_tabs_panel_dragstart_tab_event_count",
+ 1
+ );
+ }
+ }
+
+ on_dragover(event) {
+ var effects = this.getDropEffectForTabDrag(event);
+
+ var ind = this._tabDropIndicator;
+ if (effects == "" || effects == "none") {
+ ind.hidden = true;
+ return;
+ }
+ event.preventDefault();
+ event.stopPropagation();
+
+ var arrowScrollbox = this.arrowScrollbox;
+
+ // autoscroll the tab strip if we drag over the scroll
+ // buttons, even if we aren't dragging a tab, but then
+ // return to avoid drawing the drop indicator
+ var pixelsToScroll = 0;
+ if (this.getAttribute("overflow") == "true") {
+ switch (event.originalTarget) {
+ case arrowScrollbox._scrollButtonUp:
+ pixelsToScroll = arrowScrollbox.scrollIncrement * -1;
+ break;
+ case arrowScrollbox._scrollButtonDown:
+ pixelsToScroll = arrowScrollbox.scrollIncrement;
+ break;
+ }
+ if (pixelsToScroll) {
+ arrowScrollbox.scrollByPixels(
+ (RTL_UI ? -1 : 1) * pixelsToScroll,
+ true
+ );
+ }
+ }
+
+ let draggedTab = event.dataTransfer.mozGetDataAt(TAB_DROP_TYPE, 0);
+ if (
+ (effects == "move" || effects == "copy") &&
+ this == draggedTab.container &&
+ !draggedTab._dragData.fromTabList
+ ) {
+ ind.hidden = true;
+
+ if (!this._isGroupTabsAnimationOver()) {
+ // Wait for grouping tabs animation to finish
+ return;
+ }
+ this._finishGroupSelectedTabs(draggedTab);
+
+ if (effects == "move") {
+ this._animateTabMove(event);
+ return;
+ }
+ }
+
+ this._finishAnimateTabMove();
+
+ if (effects == "link") {
+ let tab = this._getDragTargetTab(event, { ignoreTabSides: true });
+ if (tab) {
+ if (!this._dragTime) {
+ this._dragTime = Date.now();
+ }
+ if (Date.now() >= this._dragTime + this._dragOverDelay) {
+ this.selectedItem = tab;
+ }
+ ind.hidden = true;
+ return;
+ }
+ }
+
+ var rect = arrowScrollbox.getBoundingClientRect();
+ var newMargin;
+ if (pixelsToScroll) {
+ // if we are scrolling, put the drop indicator at the edge
+ // so that it doesn't jump while scrolling
+ let scrollRect = arrowScrollbox.scrollClientRect;
+ let minMargin = scrollRect.left - rect.left;
+ let maxMargin = Math.min(
+ minMargin + scrollRect.width,
+ scrollRect.right
+ );
+ if (RTL_UI) {
+ [minMargin, maxMargin] = [
+ this.clientWidth - maxMargin,
+ this.clientWidth - minMargin,
+ ];
+ }
+ newMargin = pixelsToScroll > 0 ? maxMargin : minMargin;
+ } else {
+ let newIndex = this._getDropIndex(event);
+ let children = this.allTabs;
+ if (newIndex == children.length) {
+ let tabRect = this._getVisibleTabs().at(-1).getBoundingClientRect();
+ if (RTL_UI) {
+ newMargin = rect.right - tabRect.left;
+ } else {
+ newMargin = tabRect.right - rect.left;
+ }
+ } else {
+ let tabRect = children[newIndex].getBoundingClientRect();
+ if (RTL_UI) {
+ newMargin = rect.right - tabRect.right;
+ } else {
+ newMargin = tabRect.left - rect.left;
+ }
+ }
+ }
+
+ ind.hidden = false;
+ newMargin += ind.clientWidth / 2;
+ if (RTL_UI) {
+ newMargin *= -1;
+ }
+ ind.style.transform = "translate(" + Math.round(newMargin) + "px)";
+ }
+
+ on_drop(event) {
+ var dt = event.dataTransfer;
+ var dropEffect = dt.dropEffect;
+ var draggedTab;
+ let movingTabs;
+ if (dt.mozTypesAt(0)[0] == TAB_DROP_TYPE) {
+ // tab copy or move
+ draggedTab = dt.mozGetDataAt(TAB_DROP_TYPE, 0);
+ // not our drop then
+ if (!draggedTab) {
+ return;
+ }
+ movingTabs = draggedTab._dragData.movingTabs;
+ draggedTab.container._finishGroupSelectedTabs(draggedTab);
+ }
+
+ this._tabDropIndicator.hidden = true;
+ event.stopPropagation();
+ if (draggedTab && dropEffect == "copy") {
+ // copy the dropped tab (wherever it's from)
+ let newIndex = this._getDropIndex(event);
+ let draggedTabCopy;
+ for (let tab of movingTabs) {
+ let newTab = gBrowser.duplicateTab(tab);
+ gBrowser.moveTabTo(newTab, newIndex++);
+ if (tab == draggedTab) {
+ draggedTabCopy = newTab;
+ }
+ }
+ if (draggedTab.container != this || event.shiftKey) {
+ this.selectedItem = draggedTabCopy;
+ }
+ } else if (draggedTab && draggedTab.container == this) {
+ let oldTranslateX = Math.round(draggedTab._dragData.translateX);
+ let tabWidth = Math.round(draggedTab._dragData.tabWidth);
+ let translateOffset = oldTranslateX % tabWidth;
+ let newTranslateX = oldTranslateX - translateOffset;
+ if (oldTranslateX > 0 && translateOffset > tabWidth / 2) {
+ newTranslateX += tabWidth;
+ } else if (oldTranslateX < 0 && -translateOffset > tabWidth / 2) {
+ newTranslateX -= tabWidth;
+ }
+
+ let dropIndex;
+ if (draggedTab._dragData.fromTabList) {
+ dropIndex = this._getDropIndex(event);
+ } else {
+ dropIndex =
+ "animDropIndex" in draggedTab._dragData &&
+ draggedTab._dragData.animDropIndex;
+ }
+ let incrementDropIndex = true;
+ if (dropIndex && dropIndex > movingTabs[0]._tPos) {
+ dropIndex--;
+ incrementDropIndex = false;
+ }
+
+ if (oldTranslateX && oldTranslateX != newTranslateX && !gReduceMotion) {
+ for (let tab of movingTabs) {
+ tab.setAttribute("tabdrop-samewindow", "true");
+ tab.style.transform = "translateX(" + newTranslateX + "px)";
+ let postTransitionCleanup = () => {
+ tab.removeAttribute("tabdrop-samewindow");
+
+ this._finishAnimateTabMove();
+ if (dropIndex !== false) {
+ gBrowser.moveTabTo(tab, dropIndex);
+ if (incrementDropIndex) {
+ dropIndex++;
+ }
+ }
+
+ gBrowser.syncThrobberAnimations(tab);
+ };
+ if (gReduceMotion) {
+ postTransitionCleanup();
+ } else {
+ let onTransitionEnd = transitionendEvent => {
+ if (
+ transitionendEvent.propertyName != "transform" ||
+ transitionendEvent.originalTarget != tab
+ ) {
+ return;
+ }
+ tab.removeEventListener("transitionend", onTransitionEnd);
+
+ postTransitionCleanup();
+ };
+ tab.addEventListener("transitionend", onTransitionEnd);
+ }
+ }
+ } else {
+ this._finishAnimateTabMove();
+ if (dropIndex !== false) {
+ for (let tab of movingTabs) {
+ gBrowser.moveTabTo(tab, dropIndex);
+ if (incrementDropIndex) {
+ dropIndex++;
+ }
+ }
+ }
+ }
+ } else if (draggedTab) {
+ // Move the tabs. To avoid multiple tab-switches in the original window,
+ // the selected tab should be adopted last.
+ const dropIndex = this._getDropIndex(event);
+ let newIndex = dropIndex;
+ let selectedTab;
+ let indexForSelectedTab;
+ for (let i = 0; i < movingTabs.length; ++i) {
+ const tab = movingTabs[i];
+ if (tab.selected) {
+ selectedTab = tab;
+ indexForSelectedTab = newIndex;
+ } else {
+ const newTab = gBrowser.adoptTab(tab, newIndex, tab == draggedTab);
+ if (newTab) {
+ ++newIndex;
+ }
+ }
+ }
+ if (selectedTab) {
+ const newTab = gBrowser.adoptTab(
+ selectedTab,
+ indexForSelectedTab,
+ selectedTab == draggedTab
+ );
+ if (newTab) {
+ ++newIndex;
+ }
+ }
+
+ // Restore tab selection
+ gBrowser.addRangeToMultiSelectedTabs(
+ gBrowser.tabs[dropIndex],
+ gBrowser.tabs[newIndex - 1]
+ );
+ } else {
+ // Pass true to disallow dropping javascript: or data: urls
+ let links;
+ try {
+ links = browserDragAndDrop.dropLinks(event, true);
+ } catch (ex) {}
+
+ if (!links || links.length === 0) {
+ return;
+ }
+
+ let inBackground = Services.prefs.getBoolPref(
+ "browser.tabs.loadInBackground"
+ );
+ if (event.shiftKey) {
+ inBackground = !inBackground;
+ }
+
+ let targetTab = this._getDragTargetTab(event, { ignoreTabSides: true });
+ let userContextId = this.selectedItem.getAttribute("usercontextid");
+ let replace = !!targetTab;
+ let newIndex = this._getDropIndex(event);
+ let urls = links.map(link => link.url);
+ let csp = browserDragAndDrop.getCsp(event);
+ let triggeringPrincipal =
+ browserDragAndDrop.getTriggeringPrincipal(event);
+
+ (async () => {
+ if (
+ urls.length >=
+ Services.prefs.getIntPref("browser.tabs.maxOpenBeforeWarn")
+ ) {
+ // Sync dialog cannot be used inside drop event handler.
+ let answer = await OpenInTabsUtils.promiseConfirmOpenInTabs(
+ urls.length,
+ window
+ );
+ if (!answer) {
+ return;
+ }
+ }
+
+ gBrowser.loadTabs(urls, {
+ inBackground,
+ replace,
+ allowThirdPartyFixup: true,
+ targetTab,
+ newIndex,
+ userContextId,
+ triggeringPrincipal,
+ csp,
+ });
+ })();
+ }
+
+ if (draggedTab) {
+ delete draggedTab._dragData;
+ }
+ }
+
+ on_dragend(event) {
+ var dt = event.dataTransfer;
+ var draggedTab = dt.mozGetDataAt(TAB_DROP_TYPE, 0);
+
+ // Prevent this code from running if a tabdrop animation is
+ // running since calling _finishAnimateTabMove would clear
+ // any CSS transition that is running.
+ if (draggedTab.hasAttribute("tabdrop-samewindow")) {
+ return;
+ }
+
+ this._finishGroupSelectedTabs(draggedTab);
+ this._finishAnimateTabMove();
+
+ if (
+ dt.mozUserCancelled ||
+ dt.dropEffect != "none" ||
+ this._isCustomizing
+ ) {
+ delete draggedTab._dragData;
+ return;
+ }
+
+ // Check if tab detaching is enabled
+ if (!Services.prefs.getBoolPref("browser.tabs.allowTabDetach")) {
+ return;
+ }
+
+ // Disable detach within the browser toolbox
+ var eX = event.screenX;
+ var eY = event.screenY;
+ var wX = window.screenX;
+ // check if the drop point is horizontally within the window
+ if (eX > wX && eX < wX + window.outerWidth) {
+ // also avoid detaching if the the tab was dropped too close to
+ // the tabbar (half a tab)
+ let rect = window.windowUtils.getBoundsWithoutFlushing(
+ this.arrowScrollbox
+ );
+ let detachTabThresholdY = window.screenY + rect.top + 1.5 * rect.height;
+ if (eY < detachTabThresholdY && eY > window.screenY) {
+ return;
+ }
+ }
+
+ // screen.availLeft et. al. only check the screen that this window is on,
+ // but we want to look at the screen the tab is being dropped onto.
+ var screen = event.screen;
+ var availX = {},
+ availY = {},
+ availWidth = {},
+ availHeight = {};
+ // Get available rect in desktop pixels.
+ screen.GetAvailRectDisplayPix(availX, availY, availWidth, availHeight);
+ availX = availX.value;
+ availY = availY.value;
+ availWidth = availWidth.value;
+ availHeight = availHeight.value;
+
+ // Compute the final window size in desktop pixels ensuring that the new
+ // window entirely fits within `screen`.
+ let ourCssToDesktopScale =
+ window.devicePixelRatio / window.desktopToDeviceScale;
+ let screenCssToDesktopScale =
+ screen.defaultCSSScaleFactor / screen.contentsScaleFactor;
+
+ // NOTE(emilio): Multiplying the sizes here for screenCssToDesktopScale
+ // means that we'll try to create a window that has the same amount of CSS
+ // pixels than our current window, not the same amount of device pixels.
+ // There are pros and cons of both conversions, though this matches the
+ // pre-existing intended behavior.
+ var winWidth = Math.min(
+ window.outerWidth * screenCssToDesktopScale,
+ availWidth
+ );
+ var winHeight = Math.min(
+ window.outerHeight * screenCssToDesktopScale,
+ availHeight
+ );
+
+ // This is slightly tricky: _dragData.offsetX/Y is an offset in CSS
+ // pixels. Since we're doing the sizing above based on those, we also need
+ // to apply the offset with pixels relative to the screen's scale rather
+ // than our scale.
+ var left = Math.min(
+ Math.max(
+ eX * ourCssToDesktopScale -
+ draggedTab._dragData.offsetX * screenCssToDesktopScale,
+ availX
+ ),
+ availX + availWidth - winWidth
+ );
+ var top = Math.min(
+ Math.max(
+ eY * ourCssToDesktopScale -
+ draggedTab._dragData.offsetY * screenCssToDesktopScale,
+ availY
+ ),
+ availY + availHeight - winHeight
+ );
+
+ // Convert back left and top to our CSS pixel space.
+ left /= ourCssToDesktopScale;
+ top /= ourCssToDesktopScale;
+
+ delete draggedTab._dragData;
+
+ if (gBrowser.tabs.length == 1) {
+ // resize _before_ move to ensure the window fits the new screen. if
+ // the window is too large for its screen, the window manager may do
+ // automatic repositioning.
+ //
+ // Since we're resizing before moving to our new screen, we need to use
+ // sizes relative to the current screen. If we moved, then resized, then
+ // we could avoid this special-case and share this with the else branch
+ // below...
+ winWidth /= ourCssToDesktopScale;
+ winHeight /= ourCssToDesktopScale;
+
+ window.resizeTo(winWidth, winHeight);
+ window.moveTo(left, top);
+ window.focus();
+ } else {
+ // We're opening a new window in a new screen, so make sure to use sizes
+ // relative to the new screen.
+ winWidth /= screenCssToDesktopScale;
+ winHeight /= screenCssToDesktopScale;
+
+ let props = { screenX: left, screenY: top, suppressanimation: 1 };
+ if (AppConstants.platform != "win") {
+ props.outerWidth = winWidth;
+ props.outerHeight = winHeight;
+ }
+ gBrowser.replaceTabsWithWindow(draggedTab, props);
+ }
+ event.stopPropagation();
+ }
+
+ on_dragleave(event) {
+ this._dragTime = 0;
+
+ // This does not work at all (see bug 458613)
+ var target = event.relatedTarget;
+ while (target && target != this) {
+ target = target.parentNode;
+ }
+ if (target) {
+ return;
+ }
+
+ this._tabDropIndicator.hidden = true;
+ event.stopPropagation();
+ }
+
+ on_wheel(event) {
+ if (
+ Services.prefs.getBoolPref("toolkit.tabbox.switchByScrolling", false)
+ ) {
+ event.stopImmediatePropagation();
+ }
+ }
+
+ get emptyTabTitle() {
+ // Normal tab title is used also in the permanent private browsing mode.
+ const l10nId =
+ PrivateBrowsingUtils.isWindowPrivate(window) &&
+ !Services.prefs.getBoolPref("browser.privatebrowsing.autostart")
+ ? "tabbrowser-empty-private-tab-title"
+ : "tabbrowser-empty-tab-title";
+ return gBrowser.tabLocalization.formatValueSync(l10nId);
+ }
+
+ get tabbox() {
+ return document.getElementById("tabbrowser-tabbox");
+ }
+
+ get newTabButton() {
+ return this.querySelector("#tabs-newtab-button");
+ }
+
+ // Accessor for tabs. arrowScrollbox has a container for non-tab elements
+ // at the end, everything else is <tab>s.
+ get allTabs() {
+ if (this._allTabs) {
+ return this._allTabs;
+ }
+ let children = Array.from(this.arrowScrollbox.children);
+ children.pop();
+ this._allTabs = children;
+ return children;
+ }
+
+ _getVisibleTabs() {
+ if (!this._visibleTabs) {
+ this._visibleTabs = Array.prototype.filter.call(
+ this.allTabs,
+ tab => !tab.hidden && !tab.closing
+ );
+ }
+ return this._visibleTabs;
+ }
+
+ _invalidateCachedTabs() {
+ this._allTabs = null;
+ this._visibleTabs = null;
+ }
+
+ _invalidateCachedVisibleTabs() {
+ this._visibleTabs = null;
+ }
+
+ appendChild(tab) {
+ return this.insertBefore(tab, null);
+ }
+
+ insertBefore(tab, node) {
+ if (!this.arrowScrollbox) {
+ throw new Error("Shouldn't call this without arrowscrollbox");
+ }
+
+ let { arrowScrollbox } = this;
+ if (node == null) {
+ // We have a container for non-tab elements at the end of the scrollbox.
+ node = arrowScrollbox.lastChild;
+ }
+ return arrowScrollbox.insertBefore(tab, node);
+ }
+
+ set _tabMinWidth(val) {
+ this.style.setProperty("--tab-min-width", val + "px");
+ }
+
+ get _isCustomizing() {
+ return document.documentElement.getAttribute("customizing") == "true";
+ }
+
+ // This overrides the TabsBase _selectNewTab method so that we can
+ // potentially interrupt keyboard tab switching when sharing the
+ // window or screen.
+ _selectNewTab(aNewTab, aFallbackDir, aWrap) {
+ if (!gSharedTabWarning.willShowSharedTabWarning(aNewTab)) {
+ super._selectNewTab(aNewTab, aFallbackDir, aWrap);
+ }
+ }
+
+ _initializeArrowScrollbox() {
+ let arrowScrollbox = this.arrowScrollbox;
+ arrowScrollbox.shadowRoot.addEventListener(
+ "underflow",
+ event => {
+ // Ignore underflow events:
+ // - from nested scrollable elements
+ // - for vertical orientation
+ // - corresponding to an overflow event that we ignored
+ if (
+ event.originalTarget != arrowScrollbox.scrollbox ||
+ event.detail == 0 ||
+ !this.hasAttribute("overflow")
+ ) {
+ return;
+ }
+
+ this.removeAttribute("overflow");
+
+ if (this._lastTabClosedByMouse) {
+ this._expandSpacerBy(this._scrollButtonWidth);
+ }
+
+ for (let tab of gBrowser._removingTabs) {
+ gBrowser.removeTab(tab);
+ }
+
+ this._positionPinnedTabs();
+ this._updateCloseButtons();
+ },
+ true
+ );
+
+ arrowScrollbox.shadowRoot.addEventListener("overflow", event => {
+ // Ignore overflow events:
+ // - from nested scrollable elements
+ // - for vertical orientation
+ if (
+ event.originalTarget != arrowScrollbox.scrollbox ||
+ event.detail == 0
+ ) {
+ return;
+ }
+
+ this.setAttribute("overflow", "true");
+ this._positionPinnedTabs();
+ this._updateCloseButtons();
+ this._handleTabSelect(true);
+ });
+
+ // Override arrowscrollbox.js method, since our scrollbox's children are
+ // inherited from the scrollbox binding parent (this).
+ arrowScrollbox._getScrollableElements = () => {
+ return this.allTabs.filter(arrowScrollbox._canScrollToElement);
+ };
+ arrowScrollbox._canScrollToElement = tab => {
+ return !tab._pinnedUnscrollable && !tab.hidden;
+ };
+ }
+
+ observe(aSubject, aTopic, aData) {
+ switch (aTopic) {
+ case "nsPref:changed":
+ // This is has to deal with changes in
+ // privacy.userContext.enabled and
+ // privacy.userContext.newTabContainerOnLeftClick.enabled.
+ let containersEnabled =
+ Services.prefs.getBoolPref("privacy.userContext.enabled") &&
+ !PrivateBrowsingUtils.isWindowPrivate(window);
+
+ // This pref won't change so often, so just recreate the menu.
+ const newTabLeftClickOpensContainersMenu = Services.prefs.getBoolPref(
+ "privacy.userContext.newTabContainerOnLeftClick.enabled"
+ );
+
+ // There are separate "new tab" buttons for when the tab strip
+ // is overflowed and when it is not. Attach the long click
+ // popup to both of them.
+ const newTab = document.getElementById("new-tab-button");
+ const newTab2 = this.newTabButton;
+
+ for (let parent of [newTab, newTab2]) {
+ if (!parent) {
+ continue;
+ }
+
+ parent.removeAttribute("type");
+ if (parent.menupopup) {
+ parent.menupopup.remove();
+ }
+
+ if (containersEnabled) {
+ parent.setAttribute("context", "new-tab-button-popup");
+
+ let popup = document
+ .getElementById("new-tab-button-popup")
+ .cloneNode(true);
+ popup.removeAttribute("id");
+ popup.className = "new-tab-popup";
+ popup.setAttribute("position", "after_end");
+ parent.prepend(popup);
+ parent.setAttribute("type", "menu");
+ // Update tooltip text
+ nodeToTooltipMap[parent.id] = newTabLeftClickOpensContainersMenu
+ ? "newTabAlwaysContainer.tooltip"
+ : "newTabContainer.tooltip";
+ } else {
+ nodeToTooltipMap[parent.id] = "newTabButton.tooltip";
+ parent.removeAttribute("context", "new-tab-button-popup");
+ }
+ // evict from tooltip cache
+ gDynamicTooltipCache.delete(parent.id);
+
+ // If containers and press-hold container menu are both used,
+ // add to gClickAndHoldListenersOnElement; otherwise, remove.
+ if (containersEnabled && !newTabLeftClickOpensContainersMenu) {
+ gClickAndHoldListenersOnElement.add(parent);
+ } else {
+ gClickAndHoldListenersOnElement.remove(parent);
+ }
+ }
+
+ break;
+ }
+ }
+
+ _setPositionalAttributes() {
+ let visibleTabs = this._getVisibleTabs();
+ if (!visibleTabs.length) {
+ return;
+ }
+
+ this._firstUnpinnedTab?.removeAttribute("first-visible-unpinned-tab");
+ this._firstUnpinnedTab = visibleTabs.find(t => !t.pinned);
+ this._firstUnpinnedTab?.setAttribute(
+ "first-visible-unpinned-tab",
+ "true"
+ );
+ }
+
+ _updateCloseButtons() {
+ // If we're overflowing, tabs are at their minimum widths.
+ if (this.getAttribute("overflow") == "true") {
+ this.setAttribute("closebuttons", "activetab");
+ return;
+ }
+
+ if (this._closeButtonsUpdatePending) {
+ return;
+ }
+ this._closeButtonsUpdatePending = true;
+
+ // Wait until after the next paint to get current layout data from
+ // getBoundsWithoutFlushing.
+ window.requestAnimationFrame(() => {
+ window.requestAnimationFrame(() => {
+ this._closeButtonsUpdatePending = false;
+
+ // The scrollbox may have started overflowing since we checked
+ // overflow earlier, so check again.
+ if (this.getAttribute("overflow") == "true") {
+ this.setAttribute("closebuttons", "activetab");
+ return;
+ }
+
+ // Check if tab widths are below the threshold where we want to
+ // remove close buttons from background tabs so that people don't
+ // accidentally close tabs by selecting them.
+ let rect = ele => {
+ return window.windowUtils.getBoundsWithoutFlushing(ele);
+ };
+ let tab = this._getVisibleTabs()[gBrowser._numPinnedTabs];
+ if (tab && rect(tab).width <= this._tabClipWidth) {
+ this.setAttribute("closebuttons", "activetab");
+ } else {
+ this.removeAttribute("closebuttons");
+ }
+ });
+ });
+ }
+
+ _updateHiddenTabsStatus() {
+ if (gBrowser.visibleTabs.length < gBrowser.tabs.length) {
+ this.setAttribute("hashiddentabs", "true");
+ } else {
+ this.removeAttribute("hashiddentabs");
+ }
+ }
+
+ _handleTabSelect(aInstant) {
+ let selectedTab = this.selectedItem;
+ if (this.getAttribute("overflow") == "true") {
+ this.arrowScrollbox.ensureElementIsVisible(selectedTab, aInstant);
+ }
+
+ selectedTab._notselectedsinceload = false;
+ }
+
+ /**
+ * Try to keep the active tab's close button under the mouse cursor
+ */
+ _lockTabSizing(aTab, aTabWidth) {
+ let tabs = this._getVisibleTabs();
+ if (!tabs.length) {
+ return;
+ }
+
+ var isEndTab = aTab._tPos > tabs[tabs.length - 1]._tPos;
+
+ if (!this._tabDefaultMaxWidth) {
+ this._tabDefaultMaxWidth = parseFloat(
+ window.getComputedStyle(aTab).maxWidth
+ );
+ }
+ this._lastTabClosedByMouse = true;
+ this._scrollButtonWidth = window.windowUtils.getBoundsWithoutFlushing(
+ this.arrowScrollbox._scrollButtonDown
+ ).width;
+
+ if (this.getAttribute("overflow") == "true") {
+ // Don't need to do anything if we're in overflow mode and aren't scrolled
+ // all the way to the right, or if we're closing the last tab.
+ if (isEndTab || !this.arrowScrollbox._scrollButtonDown.disabled) {
+ return;
+ }
+ // If the tab has an owner that will become the active tab, the owner will
+ // be to the left of it, so we actually want the left tab to slide over.
+ // This can't be done as easily in non-overflow mode, so we don't bother.
+ if (aTab.owner) {
+ return;
+ }
+ this._expandSpacerBy(aTabWidth);
+ } else {
+ // non-overflow mode
+ // Locking is neither in effect nor needed, so let tabs expand normally.
+ if (isEndTab && !this._hasTabTempMaxWidth) {
+ return;
+ }
+ let numPinned = gBrowser._numPinnedTabs;
+ // Force tabs to stay the same width, unless we're closing the last tab,
+ // which case we need to let them expand just enough so that the overall
+ // tabbar width is the same.
+ if (isEndTab) {
+ let numNormalTabs = tabs.length - numPinned;
+ aTabWidth = (aTabWidth * (numNormalTabs + 1)) / numNormalTabs;
+ if (aTabWidth > this._tabDefaultMaxWidth) {
+ aTabWidth = this._tabDefaultMaxWidth;
+ }
+ }
+ aTabWidth += "px";
+ let tabsToReset = [];
+ for (let i = numPinned; i < tabs.length; i++) {
+ let tab = tabs[i];
+ tab.style.setProperty("max-width", aTabWidth, "important");
+ if (!isEndTab) {
+ // keep tabs the same width
+ tab.style.transition = "none";
+ tabsToReset.push(tab);
+ }
+ }
+
+ if (tabsToReset.length) {
+ window
+ .promiseDocumentFlushed(() => {})
+ .then(() => {
+ window.requestAnimationFrame(() => {
+ for (let tab of tabsToReset) {
+ tab.style.transition = "";
+ }
+ });
+ });
+ }
+
+ this._hasTabTempMaxWidth = true;
+ gBrowser.addEventListener("mousemove", this);
+ window.addEventListener("mouseout", this);
+ }
+ }
+
+ _expandSpacerBy(pixels) {
+ let spacer = this._closingTabsSpacer;
+ spacer.style.width = parseFloat(spacer.style.width) + pixels + "px";
+ this.setAttribute("using-closing-tabs-spacer", "true");
+ gBrowser.addEventListener("mousemove", this);
+ window.addEventListener("mouseout", this);
+ }
+
+ _unlockTabSizing() {
+ gBrowser.removeEventListener("mousemove", this);
+ window.removeEventListener("mouseout", this);
+
+ if (this._hasTabTempMaxWidth) {
+ this._hasTabTempMaxWidth = false;
+ let tabs = this._getVisibleTabs();
+ for (let i = 0; i < tabs.length; i++) {
+ tabs[i].style.maxWidth = "";
+ }
+ }
+
+ if (this.hasAttribute("using-closing-tabs-spacer")) {
+ this.removeAttribute("using-closing-tabs-spacer");
+ this._closingTabsSpacer.style.width = 0;
+ }
+ }
+
+ uiDensityChanged() {
+ this._positionPinnedTabs();
+ this._updateCloseButtons();
+ this._handleTabSelect(true);
+ }
+
+ _positionPinnedTabs() {
+ let tabs = this._getVisibleTabs();
+ let numPinned = gBrowser._numPinnedTabs;
+ let doPosition =
+ this.getAttribute("overflow") == "true" &&
+ tabs.length > numPinned &&
+ numPinned > 0;
+
+ this.toggleAttribute("haspinnedtabs", !!numPinned);
+
+ if (doPosition) {
+ this.setAttribute("positionpinnedtabs", "true");
+
+ let layoutData = this._pinnedTabsLayoutCache;
+ let uiDensity = document.documentElement.getAttribute("uidensity");
+ if (!layoutData || layoutData.uiDensity != uiDensity) {
+ let arrowScrollbox = this.arrowScrollbox;
+ layoutData = this._pinnedTabsLayoutCache = {
+ uiDensity,
+ pinnedTabWidth: tabs[0].getBoundingClientRect().width,
+ scrollStartOffset:
+ arrowScrollbox.scrollbox.getBoundingClientRect().left -
+ arrowScrollbox.getBoundingClientRect().left +
+ parseFloat(
+ getComputedStyle(arrowScrollbox.scrollbox).paddingInlineStart
+ ),
+ };
+ }
+
+ let width = 0;
+ for (let i = numPinned - 1; i >= 0; i--) {
+ let tab = tabs[i];
+ width += layoutData.pinnedTabWidth;
+ tab.style.setProperty(
+ "margin-inline-start",
+ -(width + layoutData.scrollStartOffset) + "px",
+ "important"
+ );
+ tab._pinnedUnscrollable = true;
+ }
+ this.style.setProperty(
+ "--tab-overflow-pinned-tabs-width",
+ width + "px"
+ );
+ } else {
+ this.removeAttribute("positionpinnedtabs");
+
+ for (let i = 0; i < numPinned; i++) {
+ let tab = tabs[i];
+ tab.style.marginInlineStart = "";
+ tab._pinnedUnscrollable = false;
+ }
+
+ this.style.removeProperty("--tab-overflow-pinned-tabs-width");
+ }
+
+ if (this._lastNumPinned != numPinned) {
+ this._lastNumPinned = numPinned;
+ this._handleTabSelect(true);
+ }
+ }
+
+ _animateTabMove(event) {
+ let draggedTab = event.dataTransfer.mozGetDataAt(TAB_DROP_TYPE, 0);
+ let movingTabs = draggedTab._dragData.movingTabs;
+
+ if (this.getAttribute("movingtab") != "true") {
+ this.setAttribute("movingtab", "true");
+ gNavToolbox.setAttribute("movingtab", "true");
+ if (!draggedTab.multiselected) {
+ this.selectedItem = draggedTab;
+ }
+ }
+
+ if (!("animLastScreenX" in draggedTab._dragData)) {
+ draggedTab._dragData.animLastScreenX = draggedTab._dragData.screenX;
+ }
+
+ let screenX = event.screenX;
+ if (screenX == draggedTab._dragData.animLastScreenX) {
+ return;
+ }
+
+ // Direction of the mouse movement.
+ let ltrMove = screenX > draggedTab._dragData.animLastScreenX;
+
+ draggedTab._dragData.animLastScreenX = screenX;
+
+ let pinned = draggedTab.pinned;
+ let numPinned = gBrowser._numPinnedTabs;
+ let tabs = this._getVisibleTabs().slice(
+ pinned ? 0 : numPinned,
+ pinned ? numPinned : undefined
+ );
+
+ if (RTL_UI) {
+ tabs.reverse();
+ // Copy moving tabs array to avoid infinite reversing.
+ movingTabs = [...movingTabs].reverse();
+ }
+ let tabWidth = draggedTab.getBoundingClientRect().width;
+ let shiftWidth = tabWidth * movingTabs.length;
+ draggedTab._dragData.tabWidth = tabWidth;
+
+ // Move the dragged tab based on the mouse position.
+
+ let leftTab = tabs[0];
+ let rightTab = tabs[tabs.length - 1];
+ let rightMovingTabScreenX = movingTabs[movingTabs.length - 1].screenX;
+ let leftMovingTabScreenX = movingTabs[0].screenX;
+ let translateX = screenX - draggedTab._dragData.screenX;
+ if (!pinned) {
+ translateX +=
+ this.arrowScrollbox.scrollbox.scrollLeft -
+ draggedTab._dragData.scrollX;
+ }
+ let leftBound = leftTab.screenX - leftMovingTabScreenX;
+ let rightBound =
+ rightTab.screenX +
+ rightTab.getBoundingClientRect().width -
+ (rightMovingTabScreenX + tabWidth);
+ translateX = Math.min(Math.max(translateX, leftBound), rightBound);
+
+ for (let tab of movingTabs) {
+ tab.style.transform = "translateX(" + translateX + "px)";
+ }
+
+ draggedTab._dragData.translateX = translateX;
+
+ // Determine what tab we're dragging over.
+ // * Single tab dragging: Point of reference is the center of the dragged tab. If that
+ // point touches a background tab, the dragged tab would take that
+ // tab's position when dropped.
+ // * Multiple tabs dragging: All dragged tabs are one "giant" tab with two
+ // points of reference (center of tabs on the extremities). When
+ // mouse is moving from left to right, the right reference gets activated,
+ // otherwise the left reference will be used. Everything else works the same
+ // as single tab dragging.
+ // * We're doing a binary search in order to reduce the amount of
+ // tabs we need to check.
+
+ tabs = tabs.filter(t => !movingTabs.includes(t) || t == draggedTab);
+ let leftTabCenter = leftMovingTabScreenX + translateX + tabWidth / 2;
+ let rightTabCenter = rightMovingTabScreenX + translateX + tabWidth / 2;
+ let tabCenter = ltrMove ? rightTabCenter : leftTabCenter;
+ let newIndex = -1;
+ let oldIndex =
+ "animDropIndex" in draggedTab._dragData
+ ? draggedTab._dragData.animDropIndex
+ : movingTabs[0]._tPos;
+ let low = 0;
+ let high = tabs.length - 1;
+ while (low <= high) {
+ let mid = Math.floor((low + high) / 2);
+ if (tabs[mid] == draggedTab && ++mid > high) {
+ break;
+ }
+ screenX = tabs[mid].screenX + getTabShift(tabs[mid], oldIndex);
+ if (screenX > tabCenter) {
+ high = mid - 1;
+ } else if (
+ screenX + tabs[mid].getBoundingClientRect().width <
+ tabCenter
+ ) {
+ low = mid + 1;
+ } else {
+ newIndex = tabs[mid]._tPos;
+ break;
+ }
+ }
+ if (newIndex >= oldIndex) {
+ newIndex++;
+ }
+ if (newIndex < 0 || newIndex == oldIndex) {
+ return;
+ }
+ draggedTab._dragData.animDropIndex = newIndex;
+
+ // Shift background tabs to leave a gap where the dragged tab
+ // would currently be dropped.
+
+ for (let tab of tabs) {
+ if (tab != draggedTab) {
+ let shift = getTabShift(tab, newIndex);
+ tab.style.transform = shift ? "translateX(" + shift + "px)" : "";
+ }
+ }
+
+ function getTabShift(tab, dropIndex) {
+ if (tab._tPos < draggedTab._tPos && tab._tPos >= dropIndex) {
+ return RTL_UI ? -shiftWidth : shiftWidth;
+ }
+ if (tab._tPos > draggedTab._tPos && tab._tPos < dropIndex) {
+ return RTL_UI ? shiftWidth : -shiftWidth;
+ }
+ return 0;
+ }
+ }
+
+ _finishAnimateTabMove() {
+ if (this.getAttribute("movingtab") != "true") {
+ return;
+ }
+
+ for (let tab of this._getVisibleTabs()) {
+ tab.style.transform = "";
+ }
+
+ this.removeAttribute("movingtab");
+ gNavToolbox.removeAttribute("movingtab");
+
+ this._handleTabSelect();
+ }
+
+ /**
+ * Regroup all selected tabs around the
+ * tab in param
+ */
+ _groupSelectedTabs(tab) {
+ let draggedTabPos = tab._tPos;
+ let selectedTabs = gBrowser.selectedTabs;
+ let animate = !gReduceMotion;
+
+ tab.groupingTabsData = {
+ finished: !animate,
+ };
+
+ // Animate left selected tabs
+
+ let insertAtPos = draggedTabPos - 1;
+ for (let i = selectedTabs.indexOf(tab) - 1; i > -1; i--) {
+ let movingTab = selectedTabs[i];
+ insertAtPos = newIndex(movingTab, insertAtPos);
+
+ if (animate) {
+ movingTab.groupingTabsData = {};
+ addAnimationData(movingTab, insertAtPos, "left");
+ } else {
+ gBrowser.moveTabTo(movingTab, insertAtPos);
+ }
+ insertAtPos--;
+ }
+
+ // Animate right selected tabs
+
+ insertAtPos = draggedTabPos + 1;
+ for (
+ let i = selectedTabs.indexOf(tab) + 1;
+ i < selectedTabs.length;
+ i++
+ ) {
+ let movingTab = selectedTabs[i];
+ insertAtPos = newIndex(movingTab, insertAtPos);
+
+ if (animate) {
+ movingTab.groupingTabsData = {};
+ addAnimationData(movingTab, insertAtPos, "right");
+ } else {
+ gBrowser.moveTabTo(movingTab, insertAtPos);
+ }
+ insertAtPos++;
+ }
+
+ // Slide the relevant tabs to their new position.
+ for (let t of this._getVisibleTabs()) {
+ if (t.groupingTabsData && t.groupingTabsData.translateX) {
+ let translateX = (RTL_UI ? -1 : 1) * t.groupingTabsData.translateX;
+ t.style.transform = "translateX(" + translateX + "px)";
+ }
+ }
+
+ function newIndex(aTab, index) {
+ // Don't allow mixing pinned and unpinned tabs.
+ if (aTab.pinned) {
+ return Math.min(index, gBrowser._numPinnedTabs - 1);
+ }
+ return Math.max(index, gBrowser._numPinnedTabs);
+ }
+
+ function addAnimationData(movingTab, movingTabNewIndex, side) {
+ let movingTabOldIndex = movingTab._tPos;
+
+ if (movingTabOldIndex == movingTabNewIndex) {
+ // movingTab is already at the right position
+ // and thus don't need to be animated.
+ return;
+ }
+
+ let movingTabWidth = movingTab.getBoundingClientRect().width;
+ let shift = (movingTabNewIndex - movingTabOldIndex) * movingTabWidth;
+
+ movingTab.groupingTabsData.animate = true;
+ movingTab.setAttribute("tab-grouping", "true");
+
+ movingTab.groupingTabsData.translateX = shift;
+
+ let postTransitionCleanup = () => {
+ movingTab.groupingTabsData.newIndex = movingTabNewIndex;
+ movingTab.groupingTabsData.animate = false;
+ };
+ if (gReduceMotion) {
+ postTransitionCleanup();
+ } else {
+ let onTransitionEnd = transitionendEvent => {
+ if (
+ transitionendEvent.propertyName != "transform" ||
+ transitionendEvent.originalTarget != movingTab
+ ) {
+ return;
+ }
+ movingTab.removeEventListener("transitionend", onTransitionEnd);
+ postTransitionCleanup();
+ };
+
+ movingTab.addEventListener("transitionend", onTransitionEnd);
+ }
+
+ // Add animation data for tabs between movingTab (selected
+ // tab moving towards the dragged tab) and draggedTab.
+ // Those tabs in the middle should move in
+ // the opposite direction of movingTab.
+
+ let lowerIndex = Math.min(movingTabOldIndex, draggedTabPos);
+ let higherIndex = Math.max(movingTabOldIndex, draggedTabPos);
+
+ for (let i = lowerIndex + 1; i < higherIndex; i++) {
+ let middleTab = gBrowser.visibleTabs[i];
+
+ if (middleTab.pinned != movingTab.pinned) {
+ // Don't mix pinned and unpinned tabs
+ break;
+ }
+
+ if (middleTab.multiselected) {
+ // Skip because this selected tab should
+ // be shifted towards the dragged Tab.
+ continue;
+ }
+
+ if (
+ !middleTab.groupingTabsData ||
+ !middleTab.groupingTabsData.translateX
+ ) {
+ middleTab.groupingTabsData = { translateX: 0 };
+ }
+ if (side == "left") {
+ middleTab.groupingTabsData.translateX -= movingTabWidth;
+ } else {
+ middleTab.groupingTabsData.translateX += movingTabWidth;
+ }
+
+ middleTab.setAttribute("tab-grouping", "true");
+ }
+ }
+ }
+
+ _finishGroupSelectedTabs(tab) {
+ if (!tab.groupingTabsData || tab.groupingTabsData.finished) {
+ return;
+ }
+
+ tab.groupingTabsData.finished = true;
+
+ let selectedTabs = gBrowser.selectedTabs;
+ let tabIndex = selectedTabs.indexOf(tab);
+
+ // Moving left tabs
+ for (let i = tabIndex - 1; i > -1; i--) {
+ let movingTab = selectedTabs[i];
+ if (movingTab.groupingTabsData.newIndex) {
+ gBrowser.moveTabTo(movingTab, movingTab.groupingTabsData.newIndex);
+ }
+ }
+
+ // Moving right tabs
+ for (let i = tabIndex + 1; i < selectedTabs.length; i++) {
+ let movingTab = selectedTabs[i];
+ if (movingTab.groupingTabsData.newIndex) {
+ gBrowser.moveTabTo(movingTab, movingTab.groupingTabsData.newIndex);
+ }
+ }
+
+ for (let t of this._getVisibleTabs()) {
+ t.style.transform = "";
+ t.removeAttribute("tab-grouping");
+ delete t.groupingTabsData;
+ }
+ }
+
+ _isGroupTabsAnimationOver() {
+ for (let tab of gBrowser.selectedTabs) {
+ if (tab.groupingTabsData && tab.groupingTabsData.animate) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ handleEvent(aEvent) {
+ switch (aEvent.type) {
+ case "mouseout":
+ // If the "related target" (the node to which the pointer went) is not
+ // a child of the current document, the mouse just left the window.
+ let relatedTarget = aEvent.relatedTarget;
+ if (relatedTarget && relatedTarget.ownerDocument == document) {
+ break;
+ }
+ // fall through
+ case "mousemove":
+ if (document.getElementById("tabContextMenu").state != "open") {
+ this._unlockTabSizing();
+ }
+ break;
+ default:
+ let methodName = `on_${aEvent.type}`;
+ if (methodName in this) {
+ this[methodName](aEvent);
+ } else {
+ throw new Error(`Unexpected event ${aEvent.type}`);
+ }
+ }
+ }
+
+ _notifyBackgroundTab(aTab) {
+ if (
+ aTab.pinned ||
+ aTab.hidden ||
+ this.getAttribute("overflow") != "true"
+ ) {
+ return;
+ }
+
+ this._lastTabToScrollIntoView = aTab;
+ if (!this._backgroundTabScrollPromise) {
+ this._backgroundTabScrollPromise = window
+ .promiseDocumentFlushed(() => {
+ let lastTabRect =
+ this._lastTabToScrollIntoView.getBoundingClientRect();
+ let selectedTab = this.selectedItem;
+ if (selectedTab.pinned) {
+ selectedTab = null;
+ } else {
+ selectedTab = selectedTab.getBoundingClientRect();
+ selectedTab = {
+ left: selectedTab.left,
+ right: selectedTab.right,
+ };
+ }
+ return [
+ this._lastTabToScrollIntoView,
+ this.arrowScrollbox.scrollClientRect,
+ { left: lastTabRect.left, right: lastTabRect.right },
+ selectedTab,
+ ];
+ })
+ .then(([tabToScrollIntoView, scrollRect, tabRect, selectedRect]) => {
+ // First off, remove the promise so we can re-enter if necessary.
+ delete this._backgroundTabScrollPromise;
+ // Then, if the layout info isn't for the last-scrolled-to-tab, re-run
+ // the code above to get layout info for *that* tab, and don't do
+ // anything here, as we really just want to run this for the last-opened tab.
+ if (this._lastTabToScrollIntoView != tabToScrollIntoView) {
+ this._notifyBackgroundTab(this._lastTabToScrollIntoView);
+ return;
+ }
+ delete this._lastTabToScrollIntoView;
+ // Is the new tab already completely visible?
+ if (
+ scrollRect.left <= tabRect.left &&
+ tabRect.right <= scrollRect.right
+ ) {
+ return;
+ }
+
+ if (this.arrowScrollbox.smoothScroll) {
+ // Can we make both the new tab and the selected tab completely visible?
+ if (
+ !selectedRect ||
+ Math.max(
+ tabRect.right - selectedRect.left,
+ selectedRect.right - tabRect.left
+ ) <= scrollRect.width
+ ) {
+ this.arrowScrollbox.ensureElementIsVisible(tabToScrollIntoView);
+ return;
+ }
+
+ this.arrowScrollbox.scrollByPixels(
+ RTL_UI
+ ? selectedRect.right - scrollRect.right
+ : selectedRect.left - scrollRect.left
+ );
+ }
+
+ if (!this._animateElement.hasAttribute("highlight")) {
+ this._animateElement.setAttribute("highlight", "true");
+ setTimeout(
+ function (ele) {
+ ele.removeAttribute("highlight");
+ },
+ 150,
+ this._animateElement
+ );
+ }
+ });
+ }
+ }
+
+ /**
+ * Returns the tab where an event happened, or null if it didn't occur on a tab.
+ *
+ * @param {Event} event
+ * The event for which we want to know on which tab it happened.
+ * @param {object} options
+ * @param {boolean} options.ignoreTabSides
+ * If set to true: events will only be associated with a tab if they happened
+ * on its central part (from 25% to 75%); if they happened on the left or right
+ * sides of the tab, the method will return null.
+ */
+ _getDragTargetTab(event, { ignoreTabSides = false } = {}) {
+ let { target } = event;
+ if (target.nodeType != Node.ELEMENT_NODE) {
+ target = target.parentElement;
+ }
+ let tab = target?.closest("tab");
+ if (tab && ignoreTabSides) {
+ let { width } = tab.getBoundingClientRect();
+ if (
+ event.screenX < tab.screenX + width * 0.25 ||
+ event.screenX > tab.screenX + width * 0.75
+ ) {
+ return null;
+ }
+ }
+ return tab;
+ }
+
+ _getDropIndex(event) {
+ let tab = this._getDragTargetTab(event);
+ if (!tab) {
+ return this.allTabs.length;
+ }
+ let middle = tab.screenX + tab.getBoundingClientRect().width / 2;
+ let isBeforeMiddle = RTL_UI
+ ? event.screenX > middle
+ : event.screenX < middle;
+ return tab._tPos + (isBeforeMiddle ? 0 : 1);
+ }
+
+ getDropEffectForTabDrag(event) {
+ var dt = event.dataTransfer;
+
+ let isMovingTabs = dt.mozItemCount > 0;
+ for (let i = 0; i < dt.mozItemCount; i++) {
+ // tabs are always added as the first type
+ let types = dt.mozTypesAt(0);
+ if (types[0] != TAB_DROP_TYPE) {
+ isMovingTabs = false;
+ break;
+ }
+ }
+
+ if (isMovingTabs) {
+ let sourceNode = dt.mozGetDataAt(TAB_DROP_TYPE, 0);
+ if (
+ XULElement.isInstance(sourceNode) &&
+ sourceNode.localName == "tab" &&
+ sourceNode.ownerGlobal.isChromeWindow &&
+ sourceNode.ownerDocument.documentElement.getAttribute("windowtype") ==
+ "navigator:browser" &&
+ sourceNode.ownerGlobal.gBrowser.tabContainer == sourceNode.container
+ ) {
+ // Do not allow transfering a private tab to a non-private window
+ // and vice versa.
+ if (
+ PrivateBrowsingUtils.isWindowPrivate(window) !=
+ PrivateBrowsingUtils.isWindowPrivate(sourceNode.ownerGlobal)
+ ) {
+ return "none";
+ }
+
+ if (
+ window.gMultiProcessBrowser !=
+ sourceNode.ownerGlobal.gMultiProcessBrowser
+ ) {
+ return "none";
+ }
+
+ if (
+ window.gFissionBrowser != sourceNode.ownerGlobal.gFissionBrowser
+ ) {
+ return "none";
+ }
+
+ return dt.dropEffect == "copy" ? "copy" : "move";
+ }
+ }
+
+ if (browserDragAndDrop.canDropLink(event)) {
+ return "link";
+ }
+ return "none";
+ }
+
+ _handleNewTab(tab) {
+ if (tab.container != this) {
+ return;
+ }
+ tab._fullyOpen = true;
+ gBrowser.tabAnimationsInProgress--;
+
+ this._updateCloseButtons();
+
+ if (tab.getAttribute("selected") == "true") {
+ this._handleTabSelect();
+ } else if (!tab.hasAttribute("skipbackgroundnotify")) {
+ this._notifyBackgroundTab(tab);
+ }
+
+ // XXXmano: this is a temporary workaround for bug 345399
+ // We need to manually update the scroll buttons disabled state
+ // if a tab was inserted to the overflow area or removed from it
+ // without any scrolling and when the tabbar has already
+ // overflowed.
+ this.arrowScrollbox._updateScrollButtonsDisabledState();
+
+ // If this browser isn't lazy (indicating it's probably created by
+ // session restore), preload the next about:newtab if we don't
+ // already have a preloaded browser.
+ if (tab.linkedPanel) {
+ NewTabPagePreloading.maybeCreatePreloadedBrowser(window);
+ }
+
+ if (UserInteraction.running("browser.tabs.opening", window)) {
+ UserInteraction.finish("browser.tabs.opening", window);
+ }
+ }
+
+ _canAdvanceToTab(aTab) {
+ return !aTab.closing;
+ }
+
+ /**
+ * Returns the panel associated with a tab if it has a connected browser
+ * and/or it is the selected tab.
+ * For background lazy browsers, this will return null.
+ */
+ getRelatedElement(aTab) {
+ if (!aTab) {
+ return null;
+ }
+
+ // Cannot access gBrowser before it's initialized.
+ if (!gBrowser._initialized) {
+ return this.tabbox.tabpanels.firstElementChild;
+ }
+
+ // If the tab's browser is lazy, we need to `_insertBrowser` in order
+ // to have a linkedPanel. This will also serve to bind the browser
+ // and make it ready to use. We only do this if the tab is selected
+ // because otherwise, callers might end up unintentionally binding the
+ // browser for lazy background tabs.
+ if (!aTab.linkedPanel) {
+ if (!aTab.selected) {
+ return null;
+ }
+ gBrowser._insertBrowser(aTab);
+ }
+ return document.getElementById(aTab.linkedPanel);
+ }
+
+ _updateNewTabVisibility() {
+ // Helper functions to help deal with customize mode wrapping some items
+ let wrap = n =>
+ n.parentNode.localName == "toolbarpaletteitem" ? n.parentNode : n;
+ let unwrap = n =>
+ n && n.localName == "toolbarpaletteitem" ? n.firstElementChild : n;
+
+ // Starting from the tabs element, find the next sibling that:
+ // - isn't hidden; and
+ // - isn't the all-tabs button.
+ // If it's the new tab button, consider the new tab button adjacent to the tabs.
+ // If the new tab button is marked as adjacent and the tabstrip doesn't
+ // overflow, we'll display the 'new tab' button inline in the tabstrip.
+ // In all other cases, the separate new tab button is displayed in its
+ // customized location.
+ let sib = this;
+ do {
+ sib = unwrap(wrap(sib).nextElementSibling);
+ } while (sib && (sib.hidden || sib.id == "alltabs-button"));
+
+ const kAttr = "hasadjacentnewtabbutton";
+ if (sib && sib.id == "new-tab-button") {
+ this.setAttribute(kAttr, "true");
+ } else {
+ this.removeAttribute(kAttr);
+ }
+ }
+
+ onWidgetAfterDOMChange(aNode, aNextNode, aContainer) {
+ if (
+ aContainer.ownerDocument == document &&
+ aContainer.id == "TabsToolbar-customization-target"
+ ) {
+ this._updateNewTabVisibility();
+ }
+ }
+
+ onAreaNodeRegistered(aArea, aContainer) {
+ if (aContainer.ownerDocument == document && aArea == "TabsToolbar") {
+ this._updateNewTabVisibility();
+ }
+ }
+
+ onAreaReset(aArea, aContainer) {
+ this.onAreaNodeRegistered(aArea, aContainer);
+ }
+
+ _hiddenSoundPlayingStatusChanged(tab, opts) {
+ let closed = opts && opts.closed;
+ if (!closed && tab.soundPlaying && tab.hidden) {
+ this._hiddenSoundPlayingTabs.add(tab);
+ this.setAttribute("hiddensoundplaying", "true");
+ } else {
+ this._hiddenSoundPlayingTabs.delete(tab);
+ if (this._hiddenSoundPlayingTabs.size == 0) {
+ this.removeAttribute("hiddensoundplaying");
+ }
+ }
+ }
+
+ destroy() {
+ if (this.boundObserve) {
+ Services.prefs.removeObserver("privacy.userContext", this.boundObserve);
+ }
+ CustomizableUI.removeListener(this);
+ }
+
+ updateTabIndicatorAttr(tab) {
+ const theseAttributes = ["soundplaying", "muted", "activemedia-blocked"];
+ const notTheseAttributes = ["pinned", "sharing", "crashed"];
+
+ if (notTheseAttributes.some(attr => tab.getAttribute(attr))) {
+ tab.removeAttribute("indicator-replaces-favicon");
+ return;
+ }
+
+ if (theseAttributes.some(attr => tab.getAttribute(attr))) {
+ tab.setAttribute("indicator-replaces-favicon", true);
+ } else {
+ tab.removeAttribute("indicator-replaces-favicon");
+ }
+ }
+ }
+
+ customElements.define("tabbrowser-tabs", MozTabbrowserTabs, {
+ extends: "tabs",
+ });
+}
diff --git a/browser/base/content/tabbrowser.css b/browser/base/content/tabbrowser.css
new file mode 100644
index 0000000000..4945c13989
--- /dev/null
+++ b/browser/base/content/tabbrowser.css
@@ -0,0 +1,101 @@
+/* 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/. */
+
+.tab-close-button[pinned],
+#tabbrowser-tabs[closebuttons="activetab"] > #tabbrowser-arrowscrollbox > .tabbrowser-tab > .tab-stack > .tab-content > .tab-close-button:not([selected="true"]),
+.tab-icon-pending:not([pendingicon]),
+.tab-icon-pending[busy],
+.tab-icon-pending[pinned],
+.tab-icon-image:not([src], [pinned], [crashed], [pictureinpicture])[selected],
+.tab-icon-image:not([src], [pinned], [crashed], [sharing], [pictureinpicture]),
+.tab-icon-image[busy],
+.tab-throbber:not([busy]),
+.tab-sharing-icon-overlay,
+.tab-icon-overlay {
+ display: none;
+}
+
+:root[uidensity=compact] .tab-secondary-label,
+.tab-secondary-label:not([soundplaying], [muted], [activemedia-blocked], [pictureinpicture]),
+.tab-secondary-label:not([activemedia-blocked]) > .tab-icon-sound-blocked-label,
+.tab-secondary-label[muted][activemedia-blocked] > .tab-icon-sound-blocked-label,
+.tab-secondary-label[activemedia-blocked] > .tab-icon-sound-playing-label,
+.tab-secondary-label[muted] > .tab-icon-sound-playing-label,
+.tab-secondary-label[pictureinpicture] > .tab-icon-sound-playing-label,
+.tab-secondary-label[pictureinpicture] > .tab-icon-sound-muted-label,
+.tab-secondary-label:not([pictureinpicture]) > .tab-icon-sound-pip-label,
+.tab-secondary-label:not([muted]) > .tab-icon-sound-muted-label,
+.tab-secondary-label:not([showtooltip]) > .tab-icon-sound-tooltip-label,
+.tab-secondary-label[showtooltip] > .tab-icon-sound-label:not(.tab-icon-sound-tooltip-label) {
+ display: none;
+}
+
+.tab-sharing-icon-overlay[sharing]:not([selected]),
+.tab-icon-overlay:is([soundplaying], [muted], [activemedia-blocked], [crashed]) {
+ display: revert;
+}
+
+.tabbrowser-tab {
+ --tab-label-mask-size: 2em;
+}
+
+.tab-label {
+ white-space: nowrap;
+ line-height: 1.7; /* override 'normal' in case of fallback math fonts with huge metrics */
+}
+
+.tab-secondary-label {
+ margin: -.3em 0 .3em; /* adjust margins to compensate for line-height of .tab-label */
+}
+
+.tab-label-container {
+ overflow: hidden;
+}
+
+.tab-label-container[pinned] {
+ width: 0;
+}
+
+.tab-label-container[textoverflow][labeldirection=ltr]:not([pinned]),
+.tab-label-container[textoverflow]:not([labeldirection], [pinned]):-moz-locale-dir(ltr) {
+ direction: ltr;
+ mask-image: linear-gradient(to left, transparent, black var(--tab-label-mask-size));
+}
+
+.tab-label-container[textoverflow][labeldirection=rtl]:not([pinned]),
+.tab-label-container[textoverflow]:not([labeldirection], [pinned]):-moz-locale-dir(rtl) {
+ direction: rtl;
+ mask-image: linear-gradient(to right, transparent, black var(--tab-label-mask-size));
+}
+
+tabpanels {
+ background-color: transparent;
+}
+
+/* Apply crisp rendering for favicons at exactly 2dppx resolution */
+@media (resolution: 2dppx) {
+ .tab-icon-image {
+ image-rendering: -moz-crisp-edges;
+ }
+}
+
+.closing-tabs-spacer {
+ pointer-events: none;
+}
+
+#tabbrowser-arrowscrollbox:not(:hover) > #tabbrowser-arrowscrollbox-periphery > .closing-tabs-spacer {
+ transition: width .15s ease-out;
+}
+
+browser[blank],
+browser[pendingpaint] {
+ opacity: 0;
+}
+
+#tabbrowser-tabpanels[pendingpaint] {
+ background-image: url(chrome://browser/skin/tabbrowser/pendingpaint.png);
+ background-repeat: no-repeat;
+ background-position: center center;
+ background-size: 30px;
+}
diff --git a/browser/base/content/tabbrowser.js b/browser/base/content/tabbrowser.js
new file mode 100644
index 0000000000..f56e1f0a01
--- /dev/null
+++ b/browser/base/content/tabbrowser.js
@@ -0,0 +1,7800 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+ * 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/. */
+
+{
+ // start private scope for gBrowser
+ /**
+ * A set of known icons to use for internal pages. These are hardcoded so we can
+ * start loading them faster than ContentLinkHandler would normally find them.
+ */
+ const FAVICON_DEFAULTS = {
+ "about:newtab": "chrome://branding/content/icon32.png",
+ "about:home": "chrome://branding/content/icon32.png",
+ "about:welcome": "chrome://branding/content/icon32.png",
+ "about:privatebrowsing":
+ "chrome://browser/skin/privatebrowsing/favicon.svg",
+ };
+
+ const {
+ LOAD_FLAGS_NONE,
+ LOAD_FLAGS_FROM_EXTERNAL,
+ LOAD_FLAGS_FIRST_LOAD,
+ LOAD_FLAGS_DISALLOW_INHERIT_PRINCIPAL,
+ LOAD_FLAGS_ALLOW_THIRD_PARTY_FIXUP,
+ LOAD_FLAGS_FIXUP_SCHEME_TYPOS,
+ LOAD_FLAGS_FORCE_ALLOW_DATA_URI,
+ LOAD_FLAGS_DISABLE_TRR,
+ } = Ci.nsIWebNavigation;
+
+ /**
+ * Updates the User Context UI indicators if the browser is in a non-default context
+ */
+ function updateUserContextUIIndicator() {
+ function replaceContainerClass(classType, element, value) {
+ let prefix = "identity-" + classType + "-";
+ if (value && element.classList.contains(prefix + value)) {
+ return;
+ }
+ for (let className of element.classList) {
+ if (className.startsWith(prefix)) {
+ element.classList.remove(className);
+ }
+ }
+ if (value) {
+ element.classList.add(prefix + value);
+ }
+ }
+
+ let hbox = document.getElementById("userContext-icons");
+
+ let userContextId = gBrowser.selectedBrowser.getAttribute("usercontextid");
+ if (!userContextId) {
+ replaceContainerClass("color", hbox, "");
+ hbox.hidden = true;
+ return;
+ }
+
+ let identity =
+ ContextualIdentityService.getPublicIdentityFromId(userContextId);
+ if (!identity) {
+ replaceContainerClass("color", hbox, "");
+ hbox.hidden = true;
+ return;
+ }
+
+ replaceContainerClass("color", hbox, identity.color);
+
+ let label = ContextualIdentityService.getUserContextLabel(userContextId);
+ document.getElementById("userContext-label").setAttribute("value", label);
+ // Also set the container label as the tooltip so we can only show the icon
+ // in small windows.
+ hbox.setAttribute("tooltiptext", label);
+
+ let indicator = document.getElementById("userContext-indicator");
+ replaceContainerClass("icon", indicator, identity.icon);
+
+ hbox.hidden = false;
+ }
+
+ window._gBrowser = {
+ init() {
+ ChromeUtils.defineModuleGetter(
+ this,
+ "AsyncTabSwitcher",
+ "resource:///modules/AsyncTabSwitcher.jsm"
+ );
+ ChromeUtils.defineESModuleGetters(this, {
+ UrlbarProviderOpenTabs:
+ "resource:///modules/UrlbarProviderOpenTabs.sys.mjs",
+ PictureInPicture: "resource://gre/modules/PictureInPicture.sys.mjs",
+ });
+ XPCOMUtils.defineLazyServiceGetters(this, {
+ MacSharingService: [
+ "@mozilla.org/widget/macsharingservice;1",
+ "nsIMacSharingService",
+ ],
+ });
+ XPCOMUtils.defineLazyGetter(this, "tabLocalization", () => {
+ return new Localization(
+ ["browser/tabbrowser.ftl", "branding/brand.ftl"],
+ true
+ );
+ });
+
+ if (AppConstants.MOZ_CRASHREPORTER) {
+ ChromeUtils.defineModuleGetter(
+ this,
+ "TabCrashHandler",
+ "resource:///modules/ContentCrashHandlers.jsm"
+ );
+ }
+
+ Services.obs.addObserver(this, "contextual-identity-updated");
+
+ Services.els.addSystemEventListener(document, "keydown", this, false);
+ Services.els.addSystemEventListener(document, "keypress", this, false);
+ document.addEventListener("visibilitychange", this);
+ window.addEventListener("framefocusrequested", this);
+
+ this.tabContainer.init();
+ this._setupInitialBrowserAndTab();
+
+ if (
+ Services.prefs.getIntPref("browser.display.document_color_use") == 2
+ ) {
+ this.tabpanels.style.backgroundColor = Services.prefs.getBoolPref(
+ "browser.display.use_system_colors"
+ )
+ ? "canvas"
+ : Services.prefs.getCharPref("browser.display.background_color");
+ }
+
+ this._setFindbarData();
+
+ // We take over setting the document title, so remove the l10n id to
+ // avoid it being re-translated and overwriting document content if
+ // we ever switch languages at runtime. After a language change, the
+ // window title will update at the next tab or location change.
+ document.querySelector("title").removeAttribute("data-l10n-id");
+
+ this._setupEventListeners();
+ this._initialized = true;
+ },
+
+ ownerGlobal: window,
+
+ ownerDocument: document,
+
+ closingTabsEnum: {
+ ALL: 0,
+ OTHER: 1,
+ TO_START: 2,
+ TO_END: 3,
+ MULTI_SELECTED: 4,
+ },
+
+ _lastRelatedTabMap: new WeakMap(),
+
+ mProgressListeners: [],
+
+ mTabsProgressListeners: [],
+
+ _tabListeners: new Map(),
+
+ _tabFilters: new Map(),
+
+ _isBusy: false,
+
+ _awaitingToggleCaretBrowsingPrompt: false,
+
+ arrowKeysShouldWrap: AppConstants == "macosx",
+
+ _previewMode: false,
+
+ _lastFindValue: "",
+
+ _contentWaitingCount: 0,
+
+ _tabLayerCache: [],
+
+ tabAnimationsInProgress: 0,
+
+ /**
+ * Binding from browser to tab
+ */
+ _tabForBrowser: new WeakMap(),
+
+ /**
+ * `_createLazyBrowser` will define properties on the unbound lazy browser
+ * which correspond to properties defined in MozBrowser which will be bound to
+ * the browser when it is inserted into the document. If any of these
+ * properties are accessed by consumers, `_insertBrowser` is called and
+ * the browser is inserted to ensure that things don't break. This list
+ * provides the names of properties that may be called while the browser
+ * is in its unbound (lazy) state.
+ */
+ _browserBindingProperties: [
+ "canGoBack",
+ "canGoForward",
+ "goBack",
+ "goForward",
+ "permitUnload",
+ "reload",
+ "reloadWithFlags",
+ "stop",
+ "loadURI",
+ "fixupAndLoadURIString",
+ "gotoIndex",
+ "currentURI",
+ "documentURI",
+ "remoteType",
+ "preferences",
+ "imageDocument",
+ "isRemoteBrowser",
+ "messageManager",
+ "getTabBrowser",
+ "finder",
+ "fastFind",
+ "sessionHistory",
+ "contentTitle",
+ "characterSet",
+ "fullZoom",
+ "textZoom",
+ "tabHasCustomZoom",
+ "webProgress",
+ "addProgressListener",
+ "removeProgressListener",
+ "audioPlaybackStarted",
+ "audioPlaybackStopped",
+ "resumeMedia",
+ "mute",
+ "unmute",
+ "blockedPopups",
+ "lastURI",
+ "purgeSessionHistory",
+ "stopScroll",
+ "startScroll",
+ "userTypedValue",
+ "userTypedClear",
+ "didStartLoadSinceLastUserTyping",
+ "audioMuted",
+ ],
+
+ _removingTabs: new Set(),
+
+ _multiSelectedTabsSet: new WeakSet(),
+
+ _lastMultiSelectedTabRef: null,
+
+ _clearMultiSelectionLocked: false,
+
+ _clearMultiSelectionLockedOnce: false,
+
+ _multiSelectChangeStarted: false,
+
+ _multiSelectChangeAdditions: new Set(),
+
+ _multiSelectChangeRemovals: new Set(),
+
+ _multiSelectChangeSelected: false,
+
+ /**
+ * Tab close requests are ignored if the window is closing anyway,
+ * e.g. when holding Ctrl+W.
+ */
+ _windowIsClosing: false,
+
+ preloadedBrowser: null,
+
+ /**
+ * This defines a proxy which allows us to access browsers by
+ * index without actually creating a full array of browsers.
+ */
+ browsers: new Proxy([], {
+ has: (target, name) => {
+ if (typeof name == "string" && Number.isInteger(parseInt(name))) {
+ return name in gBrowser.tabs;
+ }
+ return false;
+ },
+ get: (target, name) => {
+ if (name == "length") {
+ return gBrowser.tabs.length;
+ }
+ if (typeof name == "string" && Number.isInteger(parseInt(name))) {
+ if (!(name in gBrowser.tabs)) {
+ return undefined;
+ }
+ return gBrowser.tabs[name].linkedBrowser;
+ }
+ return target[name];
+ },
+ }),
+
+ /**
+ * List of browsers whose docshells must be active in order for print preview
+ * to work.
+ */
+ _printPreviewBrowsers: new Set(),
+
+ _switcher: null,
+
+ _soundPlayingAttrRemovalTimer: 0,
+
+ _hoverTabTimer: null,
+
+ _featureCallout: null,
+
+ _featureCalloutPanelId: null,
+
+ get tabContainer() {
+ delete this.tabContainer;
+ return (this.tabContainer = document.getElementById("tabbrowser-tabs"));
+ },
+
+ get tabs() {
+ return this.tabContainer.allTabs;
+ },
+
+ get tabbox() {
+ delete this.tabbox;
+ return (this.tabbox = document.getElementById("tabbrowser-tabbox"));
+ },
+
+ get tabpanels() {
+ delete this.tabpanels;
+ return (this.tabpanels = document.getElementById("tabbrowser-tabpanels"));
+ },
+
+ addEventListener(...args) {
+ this.tabpanels.addEventListener(...args);
+ },
+
+ removeEventListener(...args) {
+ this.tabpanels.removeEventListener(...args);
+ },
+
+ dispatchEvent(...args) {
+ return this.tabpanels.dispatchEvent(...args);
+ },
+
+ get visibleTabs() {
+ return this.tabContainer._getVisibleTabs();
+ },
+
+ get _numPinnedTabs() {
+ for (var i = 0; i < this.tabs.length; i++) {
+ if (!this.tabs[i].pinned) {
+ break;
+ }
+ }
+ return i;
+ },
+
+ set selectedTab(val) {
+ if (
+ gSharedTabWarning.willShowSharedTabWarning(val) ||
+ document.documentElement.hasAttribute("window-modal-open") ||
+ (gNavToolbox.collapsed && !this._allowTabChange)
+ ) {
+ return;
+ }
+ // Update the tab
+ this.tabbox.selectedTab = val;
+ },
+
+ get selectedTab() {
+ return this._selectedTab;
+ },
+
+ get selectedBrowser() {
+ return this._selectedBrowser;
+ },
+
+ get featureCallout() {
+ return this._featureCallout;
+ },
+
+ set featureCallout(val) {
+ this._featureCallout = val;
+ },
+
+ get instantiateFeatureCalloutTour() {
+ return this._instantiateFeatureCalloutTour;
+ },
+
+ get featureCalloutPanelId() {
+ return this._featureCalloutPanelId;
+ },
+
+ _instantiateFeatureCalloutTour(browser, panelId) {
+ this._featureCalloutPanelId = panelId;
+ const { FeatureCallout } = ChromeUtils.importESModule(
+ "resource:///modules/FeatureCallout.sys.mjs"
+ );
+ // Note - once we have additional browser chrome messages,
+ // only use PDF.js pref value when navigating to PDF viewer
+ this._featureCallout = new FeatureCallout({
+ win: window,
+ browser,
+ prefName: "browser.pdfjs.feature-tour",
+ page: "chrome",
+ theme: { preset: "pdfjs", simulateContent: true },
+ });
+ },
+ _setupInitialBrowserAndTab() {
+ // See browser.js for the meaning of window.arguments.
+ // Bug 1485961 covers making this more sane.
+ let userContextId = window.arguments && window.arguments[5];
+
+ let openWindowInfo = window.docShell.treeOwner
+ .QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIAppWindow).initialOpenWindowInfo;
+
+ if (!openWindowInfo && window.arguments && window.arguments[11]) {
+ openWindowInfo = window.arguments[11];
+ }
+
+ let tabArgument = gBrowserInit.getTabToAdopt();
+
+ // If we have a tab argument with browser, we use its remoteType. Otherwise,
+ // if e10s is disabled or there's a parent process opener (e.g. parent
+ // process about: page) for the content tab, we use a parent
+ // process remoteType. Otherwise, we check the URI to determine
+ // what to do - if there isn't one, we default to the default remote type.
+ //
+ // When adopting a tab, we'll also use that tab's browsingContextGroupId,
+ // if available, to ensure we don't spawn a new process.
+ let remoteType;
+ let initialBrowsingContextGroupId;
+
+ if (tabArgument && tabArgument.hasAttribute("usercontextid")) {
+ // The window's first argument is a tab if and only if we are swapping tabs.
+ // We must set the browser's usercontextid so that the newly created remote
+ // tab child has the correct usercontextid.
+ userContextId = parseInt(tabArgument.getAttribute("usercontextid"), 10);
+ }
+
+ if (tabArgument && tabArgument.linkedBrowser) {
+ remoteType = tabArgument.linkedBrowser.remoteType;
+ initialBrowsingContextGroupId =
+ tabArgument.linkedBrowser.browsingContext?.group.id;
+ } else if (openWindowInfo) {
+ userContextId = openWindowInfo.originAttributes.userContextId;
+ if (openWindowInfo.isRemote) {
+ remoteType = E10SUtils.DEFAULT_REMOTE_TYPE;
+ } else {
+ remoteType = E10SUtils.NOT_REMOTE;
+ }
+ } else {
+ let uriToLoad = gBrowserInit.uriToLoadPromise;
+ if (uriToLoad && Array.isArray(uriToLoad)) {
+ uriToLoad = uriToLoad[0]; // we only care about the first item
+ }
+
+ if (uriToLoad && typeof uriToLoad == "string") {
+ let oa = E10SUtils.predictOriginAttributes({
+ window,
+ userContextId,
+ });
+ remoteType = E10SUtils.getRemoteTypeForURI(
+ uriToLoad,
+ gMultiProcessBrowser,
+ gFissionBrowser,
+ E10SUtils.DEFAULT_REMOTE_TYPE,
+ null,
+ oa
+ );
+ } else {
+ // If we reach here, we don't have the url to load. This means that
+ // `uriToLoad` is most likely a promise which is waiting on SessionStore
+ // initialization. We can't delay setting up the browser here, as that
+ // would mean that `gBrowser.selectedBrowser` might not always exist,
+ // which is the current assumption.
+
+ // In this case we default to the privileged about process as that's
+ // the best guess we can make, and we'll likely need it eventually.
+ remoteType = E10SUtils.PRIVILEGEDABOUT_REMOTE_TYPE;
+ }
+ }
+
+ let createOptions = {
+ uriIsAboutBlank: false,
+ userContextId,
+ initialBrowsingContextGroupId,
+ remoteType,
+ openWindowInfo,
+ };
+ let browser = this.createBrowser(createOptions);
+ browser.setAttribute("primary", "true");
+ if (gBrowserAllowScriptsToCloseInitialTabs) {
+ browser.setAttribute("allowscriptstoclose", "true");
+ }
+ browser.droppedLinkHandler = handleDroppedLink;
+ browser.loadURI = URILoadingWrapper.loadURI.bind(
+ URILoadingWrapper,
+ browser
+ );
+ browser.fixupAndLoadURIString =
+ URILoadingWrapper.fixupAndLoadURIString.bind(
+ URILoadingWrapper,
+ browser
+ );
+
+ let uniqueId = this._generateUniquePanelID();
+ let panel = this.getPanel(browser);
+ panel.id = uniqueId;
+ this.tabpanels.appendChild(panel);
+
+ let tab = this.tabs[0];
+ tab.linkedPanel = uniqueId;
+ this._selectedTab = tab;
+ this._selectedBrowser = browser;
+ tab.permanentKey = browser.permanentKey;
+ tab._tPos = 0;
+ tab._fullyOpen = true;
+ tab.linkedBrowser = browser;
+
+ if (userContextId) {
+ tab.setAttribute("usercontextid", userContextId);
+ ContextualIdentityService.setTabStyle(tab);
+ }
+
+ this._tabForBrowser.set(browser, tab);
+
+ this._appendStatusPanel();
+
+ // This is the initial browser, so it's usually active; the default is false
+ // so we have to update it:
+ browser.docShellIsActive = this.shouldActivateDocShell(browser);
+
+ // Hook the browser up with a progress listener.
+ let tabListener = new TabProgressListener(tab, browser, true, false);
+ let filter = Cc[
+ "@mozilla.org/appshell/component/browser-status-filter;1"
+ ].createInstance(Ci.nsIWebProgress);
+ filter.addProgressListener(tabListener, Ci.nsIWebProgress.NOTIFY_ALL);
+ this._tabListeners.set(tab, tabListener);
+ this._tabFilters.set(tab, filter);
+ browser.webProgress.addProgressListener(
+ filter,
+ Ci.nsIWebProgress.NOTIFY_ALL
+ );
+ },
+
+ /**
+ * BEGIN FORWARDED BROWSER PROPERTIES. IF YOU ADD A PROPERTY TO THE BROWSER ELEMENT
+ * MAKE SURE TO ADD IT HERE AS WELL.
+ */
+ get canGoBack() {
+ return this.selectedBrowser.canGoBack;
+ },
+
+ get canGoForward() {
+ return this.selectedBrowser.canGoForward;
+ },
+
+ goBack(requireUserInteraction) {
+ return this.selectedBrowser.goBack(requireUserInteraction);
+ },
+
+ goForward(requireUserInteraction) {
+ return this.selectedBrowser.goForward(requireUserInteraction);
+ },
+
+ reload() {
+ return this.selectedBrowser.reload();
+ },
+
+ reloadWithFlags(aFlags) {
+ return this.selectedBrowser.reloadWithFlags(aFlags);
+ },
+
+ stop() {
+ return this.selectedBrowser.stop();
+ },
+
+ /**
+ * throws exception for unknown schemes
+ */
+ loadURI(uri, params) {
+ return this.selectedBrowser.loadURI(uri, params);
+ },
+ /**
+ * throws exception for unknown schemes
+ */
+ fixupAndLoadURIString(uriString, params) {
+ return this.selectedBrowser.fixupAndLoadURIString(uriString, params);
+ },
+
+ gotoIndex(aIndex) {
+ return this.selectedBrowser.gotoIndex(aIndex);
+ },
+
+ get currentURI() {
+ return this.selectedBrowser.currentURI;
+ },
+
+ get finder() {
+ return this.selectedBrowser.finder;
+ },
+
+ get docShell() {
+ return this.selectedBrowser.docShell;
+ },
+
+ get webNavigation() {
+ return this.selectedBrowser.webNavigation;
+ },
+
+ get webProgress() {
+ return this.selectedBrowser.webProgress;
+ },
+
+ get contentWindow() {
+ return this.selectedBrowser.contentWindow;
+ },
+
+ get sessionHistory() {
+ return this.selectedBrowser.sessionHistory;
+ },
+
+ get markupDocumentViewer() {
+ return this.selectedBrowser.markupDocumentViewer;
+ },
+
+ get contentDocument() {
+ return this.selectedBrowser.contentDocument;
+ },
+
+ get contentTitle() {
+ return this.selectedBrowser.contentTitle;
+ },
+
+ get contentPrincipal() {
+ return this.selectedBrowser.contentPrincipal;
+ },
+
+ get securityUI() {
+ return this.selectedBrowser.securityUI;
+ },
+
+ set fullZoom(val) {
+ this.selectedBrowser.fullZoom = val;
+ },
+
+ get fullZoom() {
+ return this.selectedBrowser.fullZoom;
+ },
+
+ set textZoom(val) {
+ this.selectedBrowser.textZoom = val;
+ },
+
+ get textZoom() {
+ return this.selectedBrowser.textZoom;
+ },
+
+ get isSyntheticDocument() {
+ return this.selectedBrowser.isSyntheticDocument;
+ },
+
+ set userTypedValue(val) {
+ this.selectedBrowser.userTypedValue = val;
+ },
+
+ get userTypedValue() {
+ return this.selectedBrowser.userTypedValue;
+ },
+
+ _setFindbarData() {
+ // Ensure we know what the find bar key is in the content process:
+ let { sharedData } = Services.ppmm;
+ if (!sharedData.has("Findbar:Shortcut")) {
+ let keyEl = document.getElementById("key_find");
+ let mods = keyEl
+ .getAttribute("modifiers")
+ .replace(
+ /accel/i,
+ AppConstants.platform == "macosx" ? "meta" : "control"
+ );
+ sharedData.set("Findbar:Shortcut", {
+ key: keyEl.getAttribute("key"),
+ shiftKey: mods.includes("shift"),
+ ctrlKey: mods.includes("control"),
+ altKey: mods.includes("alt"),
+ metaKey: mods.includes("meta"),
+ });
+ }
+ },
+
+ isFindBarInitialized(aTab) {
+ return (aTab || this.selectedTab)._findBar != undefined;
+ },
+
+ /**
+ * Get the already constructed findbar
+ */
+ getCachedFindBar(aTab = this.selectedTab) {
+ return aTab._findBar;
+ },
+
+ /**
+ * Get the findbar, and create it if it doesn't exist.
+ * @return the find bar (or null if the window or tab is closed/closing in the interim).
+ */
+ async getFindBar(aTab = this.selectedTab) {
+ let findBar = this.getCachedFindBar(aTab);
+ if (findBar) {
+ return findBar;
+ }
+
+ // Avoid re-entrancy by caching the promise we're about to return.
+ if (!aTab._pendingFindBar) {
+ aTab._pendingFindBar = this._createFindBar(aTab);
+ }
+ return aTab._pendingFindBar;
+ },
+
+ /**
+ * Create a findbar instance.
+ * @param aTab the tab to create the find bar for.
+ * @return the created findbar, or null if the window or tab is closed/closing.
+ */
+ async _createFindBar(aTab) {
+ let findBar = document.createXULElement("findbar");
+ let browser = this.getBrowserForTab(aTab);
+
+ browser.parentNode.insertAdjacentElement("afterend", findBar);
+
+ await new Promise(r => requestAnimationFrame(r));
+ delete aTab._pendingFindBar;
+ if (window.closed || aTab.closing) {
+ return null;
+ }
+
+ findBar.browser = browser;
+ findBar._findField.value = this._lastFindValue;
+
+ aTab._findBar = findBar;
+
+ let event = document.createEvent("Events");
+ event.initEvent("TabFindInitialized", true, false);
+ aTab.dispatchEvent(event);
+
+ return findBar;
+ },
+
+ _appendStatusPanel() {
+ this.selectedBrowser.insertAdjacentElement("afterend", StatusPanel.panel);
+ },
+
+ _updateTabBarForPinnedTabs() {
+ this.tabContainer._unlockTabSizing();
+ this.tabContainer._positionPinnedTabs();
+ this.tabContainer._setPositionalAttributes();
+ this.tabContainer._updateCloseButtons();
+ },
+
+ _notifyPinnedStatus(aTab) {
+ // browsingContext is expected to not be defined on discarded tabs.
+ if (aTab.linkedBrowser.browsingContext) {
+ aTab.linkedBrowser.browsingContext.isAppTab = aTab.pinned;
+ }
+
+ let event = document.createEvent("Events");
+ event.initEvent(aTab.pinned ? "TabPinned" : "TabUnpinned", true, false);
+ aTab.dispatchEvent(event);
+ },
+
+ pinTab(aTab) {
+ if (aTab.pinned) {
+ return;
+ }
+
+ this.showTab(aTab);
+ this.moveTabTo(aTab, this._numPinnedTabs);
+ aTab.setAttribute("pinned", "true");
+ this._updateTabBarForPinnedTabs();
+ this._notifyPinnedStatus(aTab);
+ },
+
+ unpinTab(aTab) {
+ if (!aTab.pinned) {
+ return;
+ }
+
+ this.moveTabTo(aTab, this._numPinnedTabs - 1);
+ aTab.removeAttribute("pinned");
+ aTab.style.marginInlineStart = "";
+ aTab._pinnedUnscrollable = false;
+ this._updateTabBarForPinnedTabs();
+ this._notifyPinnedStatus(aTab);
+ },
+
+ previewTab(aTab, aCallback) {
+ let currentTab = this.selectedTab;
+ try {
+ // Suppress focus, ownership and selected tab changes
+ this._previewMode = true;
+ this.selectedTab = aTab;
+ aCallback();
+ } finally {
+ this.selectedTab = currentTab;
+ this._previewMode = false;
+ }
+ },
+
+ syncThrobberAnimations(aTab) {
+ aTab.ownerGlobal.promiseDocumentFlushed(() => {
+ if (!aTab.container) {
+ return;
+ }
+
+ const animations = Array.from(
+ aTab.container.getElementsByTagName("tab")
+ )
+ .map(tab => {
+ const throbber = tab.throbber;
+ return throbber ? throbber.getAnimations({ subtree: true }) : [];
+ })
+ .reduce((a, b) => a.concat(b))
+ .filter(
+ anim =>
+ CSSAnimation.isInstance(anim) &&
+ (anim.animationName === "tab-throbber-animation" ||
+ anim.animationName === "tab-throbber-animation-rtl") &&
+ anim.playState === "running"
+ );
+
+ // Synchronize with the oldest running animation, if any.
+ const firstStartTime = Math.min(
+ ...animations.map(anim =>
+ anim.startTime === null ? Infinity : anim.startTime
+ )
+ );
+ if (firstStartTime === Infinity) {
+ return;
+ }
+ requestAnimationFrame(() => {
+ for (let animation of animations) {
+ // If |animation| has been cancelled since this rAF callback
+ // was scheduled we don't want to set its startTime since
+ // that would restart it. We check for a cancelled animation
+ // by looking for a null currentTime rather than checking
+ // the playState, since reading the playState of
+ // a CSSAnimation object will flush style.
+ if (animation.currentTime !== null) {
+ animation.startTime = firstStartTime;
+ }
+ }
+ });
+ });
+ },
+
+ getBrowserAtIndex(aIndex) {
+ return this.browsers[aIndex];
+ },
+
+ getBrowserForOuterWindowID(aID) {
+ for (let b of this.browsers) {
+ if (b.outerWindowID == aID) {
+ return b;
+ }
+ }
+
+ return null;
+ },
+
+ getTabForBrowser(aBrowser) {
+ return this._tabForBrowser.get(aBrowser);
+ },
+
+ getPanel(aBrowser) {
+ return this.getBrowserContainer(aBrowser).parentNode;
+ },
+
+ getBrowserContainer(aBrowser) {
+ return (aBrowser || this.selectedBrowser).parentNode.parentNode;
+ },
+
+ getTabNotificationDeck() {
+ if (!this._tabNotificationDeck) {
+ let template = document.getElementById(
+ "tab-notification-deck-template"
+ );
+ template.replaceWith(template.content);
+ this._tabNotificationDeck = document.getElementById(
+ "tab-notification-deck"
+ );
+ }
+ return this._tabNotificationDeck;
+ },
+
+ _nextNotificationBoxId: 0,
+ getNotificationBox(aBrowser) {
+ let browser = aBrowser || this.selectedBrowser;
+ if (!browser._notificationBox) {
+ browser._notificationBox = new MozElements.NotificationBox(element => {
+ element.setAttribute("notificationside", "top");
+ element.setAttribute(
+ "name",
+ `tab-notification-box-${this._nextNotificationBoxId++}`
+ );
+ this.getTabNotificationDeck().append(element);
+ if (browser == this.selectedBrowser) {
+ this._updateVisibleNotificationBox(browser);
+ }
+ });
+ }
+ return browser._notificationBox;
+ },
+
+ readNotificationBox(aBrowser) {
+ let browser = aBrowser || this.selectedBrowser;
+ return browser._notificationBox || null;
+ },
+
+ _updateVisibleNotificationBox(aBrowser) {
+ if (!this._tabNotificationDeck) {
+ // If the deck hasn't been created we don't need to create it here.
+ return;
+ }
+ let notificationBox = this.readNotificationBox(aBrowser);
+ this.getTabNotificationDeck().selectedViewName = notificationBox
+ ? notificationBox.stack.getAttribute("name")
+ : "";
+ },
+
+ getTabModalPromptBox(aBrowser) {
+ let browser = aBrowser || this.selectedBrowser;
+ if (!browser.tabModalPromptBox) {
+ browser.tabModalPromptBox = new TabModalPromptBox(browser);
+ }
+ return browser.tabModalPromptBox;
+ },
+
+ getTabDialogBox(aBrowser) {
+ if (!aBrowser) {
+ throw new Error("aBrowser is required");
+ }
+ if (!aBrowser.tabDialogBox) {
+ aBrowser.tabDialogBox = new TabDialogBox(aBrowser);
+ }
+ return aBrowser.tabDialogBox;
+ },
+
+ getTabFromAudioEvent(aEvent) {
+ if (!aEvent.isTrusted) {
+ return null;
+ }
+
+ var browser = aEvent.originalTarget;
+ var tab = this.getTabForBrowser(browser);
+ return tab;
+ },
+
+ _callProgressListeners(
+ aBrowser,
+ aMethod,
+ aArguments,
+ aCallGlobalListeners = true,
+ aCallTabsListeners = true
+ ) {
+ var rv = true;
+
+ function callListeners(listeners, args) {
+ for (let p of listeners) {
+ if (aMethod in p) {
+ try {
+ if (!p[aMethod].apply(p, args)) {
+ rv = false;
+ }
+ } catch (e) {
+ // don't inhibit other listeners
+ console.error(e);
+ }
+ }
+ }
+ }
+
+ aBrowser = aBrowser || this.selectedBrowser;
+
+ if (aCallGlobalListeners && aBrowser == this.selectedBrowser) {
+ callListeners(this.mProgressListeners, aArguments);
+ }
+
+ if (aCallTabsListeners) {
+ aArguments.unshift(aBrowser);
+
+ callListeners(this.mTabsProgressListeners, aArguments);
+ }
+
+ return rv;
+ },
+
+ /**
+ * Sets an icon for the tab if the URI is defined in FAVICON_DEFAULTS.
+ */
+ setDefaultIcon(aTab, aURI) {
+ if (aURI && aURI.spec in FAVICON_DEFAULTS) {
+ this.setIcon(aTab, FAVICON_DEFAULTS[aURI.spec]);
+ }
+ },
+
+ setIcon(
+ aTab,
+ aIconURL = "",
+ aOriginalURL = aIconURL,
+ aLoadingPrincipal = null
+ ) {
+ let makeString = url => (url instanceof Ci.nsIURI ? url.spec : url);
+
+ aIconURL = makeString(aIconURL);
+ aOriginalURL = makeString(aOriginalURL);
+
+ let LOCAL_PROTOCOLS = ["chrome:", "about:", "resource:", "data:"];
+
+ if (
+ aIconURL &&
+ !aLoadingPrincipal &&
+ !LOCAL_PROTOCOLS.some(protocol => aIconURL.startsWith(protocol))
+ ) {
+ console.error(
+ `Attempt to set a remote URL ${aIconURL} as a tab icon without a loading principal.`
+ );
+ return;
+ }
+
+ let browser = this.getBrowserForTab(aTab);
+ browser.mIconURL = aIconURL;
+
+ if (aIconURL != aTab.getAttribute("image")) {
+ if (aIconURL) {
+ if (aLoadingPrincipal) {
+ aTab.setAttribute("iconloadingprincipal", aLoadingPrincipal);
+ } else {
+ aTab.removeAttribute("iconloadingprincipal");
+ }
+ aTab.setAttribute("image", aIconURL);
+ } else {
+ aTab.removeAttribute("image");
+ aTab.removeAttribute("iconloadingprincipal");
+ }
+ this._tabAttrModified(aTab, ["image"]);
+ }
+
+ // The aOriginalURL argument is currently only used by tests.
+ this._callProgressListeners(browser, "onLinkIconAvailable", [
+ aIconURL,
+ aOriginalURL,
+ ]);
+ },
+
+ getIcon(aTab) {
+ let browser = aTab ? this.getBrowserForTab(aTab) : this.selectedBrowser;
+ return browser.mIconURL;
+ },
+
+ setPageInfo(aURL, aDescription, aPreviewImage) {
+ if (aURL) {
+ let pageInfo = {
+ url: aURL,
+ description: aDescription,
+ previewImageURL: aPreviewImage,
+ };
+ PlacesUtils.history.update(pageInfo).catch(console.error);
+ }
+ },
+
+ getWindowTitleForBrowser(aBrowser) {
+ let docElement = document.documentElement;
+ let title = "";
+
+ // If location bar is hidden and the URL type supports a host,
+ // add the scheme and host to the title to prevent spoofing.
+ // XXX https://bugzilla.mozilla.org/show_bug.cgi?id=22183#c239
+ try {
+ if (docElement.getAttribute("chromehidden").includes("location")) {
+ const uri = Services.io.createExposableURI(aBrowser.currentURI);
+ let prefix = uri.prePath;
+ if (uri.scheme == "about") {
+ prefix = uri.spec;
+ } else if (uri.scheme == "moz-extension") {
+ const ext = WebExtensionPolicy.getByHostname(uri.host);
+ if (ext && ext.name) {
+ let extensionLabel = document.getElementById(
+ "urlbar-label-extension"
+ );
+ prefix = `${extensionLabel.value} (${ext.name})`;
+ }
+ }
+ title = prefix + " - ";
+ }
+ } catch (e) {
+ // ignored
+ }
+
+ if (docElement.hasAttribute("titlepreface")) {
+ title += docElement.getAttribute("titlepreface");
+ }
+
+ let tab = this.getTabForBrowser(aBrowser);
+ if (tab._labelIsContentTitle) {
+ // Strip out any null bytes in the content title, since the
+ // underlying widget implementations of nsWindow::SetTitle pass
+ // null-terminated strings to system APIs.
+ title += tab.getAttribute("label").replace(/\0/g, "");
+ }
+
+ let dataSuffix =
+ docElement.getAttribute("privatebrowsingmode") == "temporary"
+ ? "Private"
+ : "Default";
+ if (title) {
+ // We're using a function rather than just using `title` as the
+ // new substring to avoid `$$`, `$'` etc. having a special
+ // meaning to `replace`.
+ // See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/replace#specifying_a_string_as_a_parameter
+ // and the documentation for functions for more info about this.
+ return docElement.dataset["contentTitle" + dataSuffix].replace(
+ "CONTENTTITLE",
+ () => title
+ );
+ }
+
+ return docElement.dataset["title" + dataSuffix];
+ },
+
+ updateTitlebar() {
+ document.title = this.getWindowTitleForBrowser(this.selectedBrowser);
+ },
+
+ updateCurrentBrowser(aForceUpdate) {
+ let newBrowser = this.getBrowserAtIndex(this.tabContainer.selectedIndex);
+ if (this.selectedBrowser == newBrowser && !aForceUpdate) {
+ return;
+ }
+
+ let newTab = this.getTabForBrowser(newBrowser);
+
+ if (
+ this._featureCallout &&
+ this._featureCalloutPanelId !== newTab.linkedPanel
+ ) {
+ this._featureCallout.endTour(true);
+ this._featureCallout = null;
+ }
+
+ // For now, only check for Feature Callout messages
+ // when viewing PDFs. Later, we can expand this to check
+ // for callout messages on every change of tab location.
+ if (
+ !this._featureCallout &&
+ newBrowser.contentPrincipal.originNoSuffix === "resource://pdf.js"
+ ) {
+ this._instantiateFeatureCalloutTour(newBrowser, newTab.linkedPanel);
+ window.gBrowser.featureCallout.showFeatureCallout();
+ }
+
+ if (!aForceUpdate) {
+ TelemetryStopwatch.start("FX_TAB_SWITCH_UPDATE_MS");
+
+ if (gMultiProcessBrowser) {
+ this._asyncTabSwitching = true;
+ this._getSwitcher().requestTab(newTab);
+ this._asyncTabSwitching = false;
+ }
+
+ document.commandDispatcher.lock();
+ }
+
+ let oldTab = this.selectedTab;
+
+ // Preview mode should not reset the owner
+ if (!this._previewMode && !oldTab.selected) {
+ oldTab.owner = null;
+ }
+
+ let lastRelatedTab = this._lastRelatedTabMap.get(oldTab);
+ if (lastRelatedTab) {
+ if (!lastRelatedTab.selected) {
+ lastRelatedTab.owner = null;
+ }
+ }
+ this._lastRelatedTabMap = new WeakMap();
+
+ let oldBrowser = this.selectedBrowser;
+
+ if (!gMultiProcessBrowser) {
+ oldBrowser.removeAttribute("primary");
+ oldBrowser.docShellIsActive = false;
+ newBrowser.setAttribute("primary", "true");
+ newBrowser.docShellIsActive = !document.hidden;
+ }
+
+ if (gURLBar) {
+ oldBrowser._urlbarSelectionStart = gURLBar.selectionStart;
+ oldBrowser._urlbarSelectionEnd = gURLBar.selectionEnd;
+ }
+
+ this._selectedBrowser = newBrowser;
+ this._selectedTab = newTab;
+ this.showTab(newTab);
+
+ this._appendStatusPanel();
+
+ this._updateVisibleNotificationBox(newBrowser);
+
+ let oldBrowserPopupsBlocked =
+ oldBrowser.popupBlocker.getBlockedPopupCount();
+ let newBrowserPopupsBlocked =
+ newBrowser.popupBlocker.getBlockedPopupCount();
+ if (oldBrowserPopupsBlocked != newBrowserPopupsBlocked) {
+ newBrowser.popupBlocker.updateBlockedPopupsUI();
+ }
+
+ // Update the URL bar.
+ let webProgress = newBrowser.webProgress;
+ this._callProgressListeners(
+ null,
+ "onLocationChange",
+ [webProgress, null, newBrowser.currentURI, 0, true],
+ true,
+ false
+ );
+
+ let securityUI = newBrowser.securityUI;
+ if (securityUI) {
+ this._callProgressListeners(
+ null,
+ "onSecurityChange",
+ [webProgress, null, securityUI.state],
+ true,
+ false
+ );
+ // Include the true final argument to indicate that this event is
+ // simulated (instead of being observed by the webProgressListener).
+ this._callProgressListeners(
+ null,
+ "onContentBlockingEvent",
+ [webProgress, null, newBrowser.getContentBlockingEvents(), true],
+ true,
+ false
+ );
+ }
+
+ let listener = this._tabListeners.get(newTab);
+ if (listener && listener.mStateFlags) {
+ this._callProgressListeners(
+ null,
+ "onUpdateCurrentBrowser",
+ [
+ listener.mStateFlags,
+ listener.mStatus,
+ listener.mMessage,
+ listener.mTotalProgress,
+ ],
+ true,
+ false
+ );
+ }
+
+ if (!this._previewMode) {
+ newTab.recordTimeFromUnloadToReload();
+ newTab.updateLastAccessed();
+ oldTab.updateLastAccessed();
+
+ let oldFindBar = oldTab._findBar;
+ if (
+ oldFindBar &&
+ oldFindBar.findMode == oldFindBar.FIND_NORMAL &&
+ !oldFindBar.hidden
+ ) {
+ this._lastFindValue = oldFindBar._findField.value;
+ }
+
+ this.updateTitlebar();
+
+ newTab.removeAttribute("titlechanged");
+ newTab.attention = false;
+
+ // The tab has been selected, it's not unselected anymore.
+ // (1) Call the current tab's finishUnselectedTabHoverTimer()
+ // to save a telemetry record.
+ // (2) Call the current browser's unselectedTabHover() with false
+ // to dispatch an event.
+ newTab.finishUnselectedTabHoverTimer();
+ newBrowser.unselectedTabHover(false);
+ }
+
+ // If the new tab is busy, and our current state is not busy, then
+ // we need to fire a start to all progress listeners.
+ if (newTab.hasAttribute("busy") && !this._isBusy) {
+ this._isBusy = true;
+ this._callProgressListeners(
+ null,
+ "onStateChange",
+ [
+ webProgress,
+ null,
+ Ci.nsIWebProgressListener.STATE_START |
+ Ci.nsIWebProgressListener.STATE_IS_NETWORK,
+ 0,
+ ],
+ true,
+ false
+ );
+ }
+
+ // If the new tab is not busy, and our current state is busy, then
+ // we need to fire a stop to all progress listeners.
+ if (!newTab.hasAttribute("busy") && this._isBusy) {
+ this._isBusy = false;
+ this._callProgressListeners(
+ null,
+ "onStateChange",
+ [
+ webProgress,
+ null,
+ Ci.nsIWebProgressListener.STATE_STOP |
+ Ci.nsIWebProgressListener.STATE_IS_NETWORK,
+ 0,
+ ],
+ true,
+ false
+ );
+ }
+
+ // TabSelect events are suppressed during preview mode to avoid confusing extensions and other bits of code
+ // that might rely upon the other changes suppressed.
+ // Focus is suppressed in the event that the main browser window is minimized - focusing a tab would restore the window
+ if (!this._previewMode) {
+ // We've selected the new tab, so go ahead and notify listeners.
+ let event = new CustomEvent("TabSelect", {
+ bubbles: true,
+ cancelable: false,
+ detail: {
+ previousTab: oldTab,
+ },
+ });
+ newTab.dispatchEvent(event);
+
+ this._tabAttrModified(oldTab, ["selected"]);
+ this._tabAttrModified(newTab, ["selected"]);
+
+ this.readNotificationBox(newBrowser)?.shown();
+
+ this._startMultiSelectChange();
+ this._multiSelectChangeSelected = true;
+ this.clearMultiSelectedTabs();
+ if (this._multiSelectChangeAdditions.size) {
+ // Some tab has been multiselected just before switching tabs.
+ // The tab that was selected at that point should also be multiselected.
+ this.addToMultiSelectedTabs(oldTab);
+ }
+
+ if (oldBrowser != newBrowser && oldBrowser.getInPermitUnload) {
+ oldBrowser.getInPermitUnload(inPermitUnload => {
+ if (!inPermitUnload) {
+ return;
+ }
+ // Since the user is switching away from a tab that has
+ // a beforeunload prompt active, we remove the prompt.
+ // This prevents confusing user flows like the following:
+ // 1. User attempts to close Firefox
+ // 2. User switches tabs (ingoring a beforeunload prompt)
+ // 3. User returns to tab, presses "Leave page"
+ let promptBox = this.getTabModalPromptBox(oldBrowser);
+ let prompts = promptBox.listPrompts();
+ // There might not be any prompts here if the tab was closed
+ // while in an onbeforeunload prompt, which will have
+ // destroyed aforementioned prompt already, so check there's
+ // something to remove, first:
+ if (prompts.length) {
+ // NB: This code assumes that the beforeunload prompt
+ // is the top-most prompt on the tab.
+ prompts[prompts.length - 1].abortPrompt();
+ }
+ });
+ }
+
+ if (!gMultiProcessBrowser) {
+ this._adjustFocusBeforeTabSwitch(oldTab, newTab);
+ this._adjustFocusAfterTabSwitch(newTab);
+ gURLBar.afterTabSwitchFocusChange();
+ }
+ }
+
+ updateUserContextUIIndicator();
+ gPermissionPanel.updateSharingIndicator();
+
+ // Enable touch events to start a native dragging
+ // session to allow the user to easily drag the selected tab.
+ // This is currently only supported on Windows.
+ oldTab.removeAttribute("touchdownstartsdrag");
+ newTab.setAttribute("touchdownstartsdrag", "true");
+
+ if (!gMultiProcessBrowser) {
+ this.tabContainer._setPositionalAttributes();
+
+ document.commandDispatcher.unlock();
+
+ let event = new CustomEvent("TabSwitchDone", {
+ bubbles: true,
+ cancelable: true,
+ });
+ this.dispatchEvent(event);
+ }
+
+ if (!aForceUpdate) {
+ TelemetryStopwatch.finish("FX_TAB_SWITCH_UPDATE_MS");
+ }
+ },
+
+ _adjustFocusBeforeTabSwitch(oldTab, newTab) {
+ if (this._previewMode) {
+ return;
+ }
+
+ let oldBrowser = oldTab.linkedBrowser;
+ let newBrowser = newTab.linkedBrowser;
+
+ oldBrowser._urlbarFocused = gURLBar && gURLBar.focused;
+
+ if (this._asyncTabSwitching) {
+ newBrowser._userTypedValueAtBeforeTabSwitch = newBrowser.userTypedValue;
+ }
+
+ if (this.isFindBarInitialized(oldTab)) {
+ let findBar = this.getCachedFindBar(oldTab);
+ oldTab._findBarFocused =
+ !findBar.hidden &&
+ findBar._findField.getAttribute("focused") == "true";
+ }
+
+ let activeEl = document.activeElement;
+ // If focus is on the old tab, move it to the new tab.
+ if (activeEl == oldTab) {
+ newTab.focus();
+ } else if (
+ gMultiProcessBrowser &&
+ activeEl != newBrowser &&
+ activeEl != newTab
+ ) {
+ // In e10s, if focus isn't already in the tabstrip or on the new browser,
+ // and the new browser's previous focus wasn't in the url bar but focus is
+ // there now, we need to adjust focus further.
+ let keepFocusOnUrlBar =
+ newBrowser && newBrowser._urlbarFocused && gURLBar && gURLBar.focused;
+ if (!keepFocusOnUrlBar) {
+ // Clear focus so that _adjustFocusAfterTabSwitch can detect if
+ // some element has been focused and respect that.
+ document.activeElement.blur();
+ }
+ }
+ },
+
+ _adjustFocusAfterTabSwitch(newTab) {
+ // Don't steal focus from the tab bar.
+ if (document.activeElement == newTab) {
+ return;
+ }
+
+ let newBrowser = this.getBrowserForTab(newTab);
+
+ if (newBrowser.hasAttribute("tabDialogShowing")) {
+ newBrowser.tabDialogBox.focus();
+ return;
+ }
+ if (newBrowser.hasAttribute("tabmodalPromptShowing")) {
+ // If there's a tabmodal prompt showing, focus it.
+ let prompts = newBrowser.tabModalPromptBox.listPrompts();
+ let prompt = prompts[prompts.length - 1];
+ // @tabmodalPromptShowing is also set for other tab modal prompts
+ // (e.g. the Payment Request dialog) so there may not be a <tabmodalprompt>.
+ // Bug 1492814 will implement this for the Payment Request dialog.
+ if (prompt) {
+ prompt.Dialog.setDefaultFocus();
+ return;
+ }
+ }
+
+ // Focus the location bar if it was previously focused for that tab.
+ // In full screen mode, only bother making the location bar visible
+ // if the tab is a blank one.
+ if (newBrowser._urlbarFocused && gURLBar) {
+ let selectURL = () => {
+ if (this._asyncTabSwitching) {
+ // Set _awaitingSetURI flag to suppress popup notification
+ // explicitly while tab switching asynchronously.
+ newBrowser._awaitingSetURI = true;
+
+ // The onLocationChange event called in updateCurrentBrowser() will
+ // be captured in browser.js, then it calls gURLBar.setURI(). In case
+ // of that doing processing of here before doing above processing,
+ // the selection status that gURLBar.select() does will be releasing
+ // by gURLBar.setURI(). To resolve it, we call gURLBar.select() after
+ // finishing gURLBar.setURI().
+ const currentActiveElement = document.activeElement;
+ gURLBar.inputField.addEventListener(
+ "SetURI",
+ () => {
+ delete newBrowser._awaitingSetURI;
+
+ // If the user happened to type into the URL bar for this browser
+ // by the time we got here, focusing will cause the text to be
+ // selected which could cause them to overwrite what they've
+ // already typed in.
+ let userTypedValueAtBeforeTabSwitch =
+ newBrowser._userTypedValueAtBeforeTabSwitch;
+ delete newBrowser._userTypedValueAtBeforeTabSwitch;
+ if (
+ newBrowser.userTypedValue &&
+ newBrowser.userTypedValue != userTypedValueAtBeforeTabSwitch
+ ) {
+ return;
+ }
+
+ if (currentActiveElement != document.activeElement) {
+ return;
+ }
+
+ gURLBar.setSelectionRange(
+ newBrowser._urlbarSelectionStart,
+ newBrowser._urlbarSelectionEnd
+ );
+ },
+ { once: true }
+ );
+ } else {
+ gURLBar.setSelectionRange(
+ newBrowser._urlbarSelectionStart,
+ newBrowser._urlbarSelectionEnd
+ );
+ }
+ };
+
+ // This inDOMFullscreen attribute indicates that the page has something
+ // such as a video in fullscreen mode. Opening a new tab will cancel
+ // fullscreen mode, so we need to wait for that to happen and then
+ // select the url field.
+ if (window.document.documentElement.hasAttribute("inDOMFullscreen")) {
+ window.addEventListener("MozDOMFullscreen:Exited", selectURL, {
+ once: true,
+ wantsUntrusted: false,
+ });
+ return;
+ }
+
+ if (!window.fullScreen || newTab.isEmpty) {
+ selectURL();
+ return;
+ }
+ }
+
+ // Focus the find bar if it was previously focused for that tab.
+ if (
+ gFindBarInitialized &&
+ !gFindBar.hidden &&
+ this.selectedTab._findBarFocused
+ ) {
+ gFindBar._findField.focus();
+ return;
+ }
+
+ // Don't focus the content area if something has been focused after the
+ // tab switch was initiated.
+ if (gMultiProcessBrowser && document.activeElement != document.body) {
+ return;
+ }
+
+ // We're now committed to focusing the content area.
+ let fm = Services.focus;
+ let focusFlags = fm.FLAG_NOSCROLL;
+
+ if (!gMultiProcessBrowser) {
+ let newFocusedElement = fm.getFocusedElementForWindow(
+ window.content,
+ true,
+ {}
+ );
+
+ // for anchors, use FLAG_SHOWRING so that it is clear what link was
+ // last clicked when switching back to that tab
+ if (
+ newFocusedElement &&
+ (HTMLAnchorElement.isInstance(newFocusedElement) ||
+ newFocusedElement.getAttributeNS(
+ "http://www.w3.org/1999/xlink",
+ "type"
+ ) == "simple")
+ ) {
+ focusFlags |= fm.FLAG_SHOWRING;
+ }
+ }
+
+ fm.setFocus(newBrowser, focusFlags);
+ },
+
+ _tabAttrModified(aTab, aChanged) {
+ if (aTab.closing) {
+ return;
+ }
+
+ let event = new CustomEvent("TabAttrModified", {
+ bubbles: true,
+ cancelable: false,
+ detail: {
+ changed: aChanged,
+ },
+ });
+ aTab.dispatchEvent(event);
+ },
+
+ resetBrowserSharing(aBrowser) {
+ let tab = this.getTabForBrowser(aBrowser);
+ if (!tab) {
+ return;
+ }
+ // If WebRTC was used, leave object to enable tracking of grace periods.
+ tab._sharingState = tab._sharingState?.webRTC ? { webRTC: {} } : {};
+ tab.removeAttribute("sharing");
+ this._tabAttrModified(tab, ["sharing"]);
+ if (aBrowser == this.selectedBrowser) {
+ gPermissionPanel.updateSharingIndicator();
+ }
+ },
+
+ updateBrowserSharing(aBrowser, aState) {
+ let tab = this.getTabForBrowser(aBrowser);
+ if (!tab) {
+ return;
+ }
+ if (tab._sharingState == null) {
+ tab._sharingState = {};
+ }
+ tab._sharingState = Object.assign(tab._sharingState, aState);
+
+ if ("webRTC" in aState) {
+ if (tab._sharingState.webRTC?.sharing) {
+ if (tab._sharingState.webRTC.paused) {
+ tab.removeAttribute("sharing");
+ } else {
+ tab.setAttribute("sharing", aState.webRTC.sharing);
+ }
+ } else {
+ tab.removeAttribute("sharing");
+ }
+ this._tabAttrModified(tab, ["sharing"]);
+ }
+
+ if (aBrowser == this.selectedBrowser) {
+ gPermissionPanel.updateSharingIndicator();
+ }
+ },
+
+ getTabSharingState(aTab) {
+ // Normalize the state object for consumers (ie.extensions).
+ let state = Object.assign(
+ {},
+ aTab._sharingState && aTab._sharingState.webRTC
+ );
+ return {
+ camera: !!state.camera,
+ microphone: !!state.microphone,
+ screen: state.screen && state.screen.replace("Paused", ""),
+ };
+ },
+
+ setInitialTabTitle(aTab, aTitle, aOptions = {}) {
+ // Convert some non-content title (actually a url) to human readable title
+ if (!aOptions.isContentTitle && isBlankPageURL(aTitle)) {
+ aTitle = this.tabContainer.emptyTabTitle;
+ }
+
+ if (aTitle) {
+ if (!aTab.getAttribute("label")) {
+ aTab._labelIsInitialTitle = true;
+ }
+
+ this._setTabLabel(aTab, aTitle, aOptions);
+ }
+ },
+
+ _dataURLRegEx: /^data:[^,]+;base64,/i,
+
+ setTabTitle(aTab) {
+ var browser = this.getBrowserForTab(aTab);
+ var title = browser.contentTitle;
+
+ if (aTab.hasAttribute("customizemode")) {
+ title = this.tabLocalization.formatValueSync(
+ "tabbrowser-customizemode-tab-title"
+ );
+ }
+
+ // Don't replace an initially set label with the URL while the tab
+ // is loading.
+ if (aTab._labelIsInitialTitle) {
+ if (!title) {
+ return false;
+ }
+ delete aTab._labelIsInitialTitle;
+ }
+
+ let isURL = false;
+ let isContentTitle = !!title;
+ if (!title) {
+ // See if we can use the URI as the title.
+ if (browser.currentURI.displaySpec) {
+ try {
+ title = Services.io.createExposableURI(
+ browser.currentURI
+ ).displaySpec;
+ } catch (ex) {
+ title = browser.currentURI.displaySpec;
+ }
+ }
+
+ if (title && !isBlankPageURL(title)) {
+ isURL = true;
+ if (title.length <= 500 || !this._dataURLRegEx.test(title)) {
+ // Try to unescape not-ASCII URIs using the current character set.
+ try {
+ let characterSet = browser.characterSet;
+ title = Services.textToSubURI.unEscapeNonAsciiURI(
+ characterSet,
+ title
+ );
+ } catch (ex) {
+ /* Do nothing. */
+ }
+ }
+ } else {
+ // No suitable URI? Fall back to our untitled string.
+ title = this.tabContainer.emptyTabTitle;
+ }
+ }
+
+ return this._setTabLabel(aTab, title, { isContentTitle, isURL });
+ },
+
+ // While an auth prompt from a base domain different than the current sites is open, we do not want to show the tab title of the current site,
+ // but of the origin that is requesting authentication.
+ // This is to prevent possible auth spoofing scenarios.
+ // See bug 791594 for reference.
+ setTabLabelForAuthPrompts(aTab, aLabel) {
+ return this._setTabLabel(aTab, aLabel);
+ },
+
+ _setTabLabel(aTab, aLabel, { beforeTabOpen, isContentTitle, isURL } = {}) {
+ if (!aLabel || aLabel.includes("about:reader?")) {
+ return false;
+ }
+
+ // If it's a long data: URI that uses base64 encoding, truncate to a
+ // reasonable length rather than trying to display the entire thing,
+ // which can hang or crash the browser.
+ // We can't shorten arbitrary URIs like this, as bidi etc might mean
+ // we need the trailing characters for display. But a base64-encoded
+ // data-URI is plain ASCII, so this is OK for tab-title display.
+ // (See bug 1408854.)
+ if (isURL && aLabel.length > 500 && this._dataURLRegEx.test(aLabel)) {
+ aLabel = aLabel.substring(0, 500) + "\u2026";
+ }
+
+ aTab._fullLabel = aLabel;
+
+ if (!isContentTitle) {
+ // Remove protocol and "www."
+ if (!("_regex_shortenURLForTabLabel" in this)) {
+ this._regex_shortenURLForTabLabel = /^[^:]+:\/\/(?:www\.)?/;
+ }
+ aLabel = aLabel.replace(this._regex_shortenURLForTabLabel, "");
+ }
+
+ aTab._labelIsContentTitle = isContentTitle;
+
+ if (aTab.getAttribute("label") == aLabel) {
+ return false;
+ }
+
+ let dwu = window.windowUtils;
+ let isRTL =
+ dwu.getDirectionFromText(aLabel) == Ci.nsIDOMWindowUtils.DIRECTION_RTL;
+
+ aTab.setAttribute("label", aLabel);
+ aTab.setAttribute("labeldirection", isRTL ? "rtl" : "ltr");
+ aTab.toggleAttribute("labelendaligned", isRTL != (document.dir == "rtl"));
+
+ // Dispatch TabAttrModified event unless we're setting the label
+ // before the TabOpen event was dispatched.
+ if (!beforeTabOpen) {
+ this._tabAttrModified(aTab, ["label"]);
+ }
+
+ if (aTab.selected) {
+ this.updateTitlebar();
+ }
+
+ return true;
+ },
+
+ loadTabs(
+ aURIs,
+ {
+ allowInheritPrincipal,
+ allowThirdPartyFixup,
+ inBackground,
+ newIndex,
+ postDatas,
+ replace,
+ targetTab,
+ triggeringPrincipal,
+ csp,
+ userContextId,
+ fromExternal,
+ } = {}
+ ) {
+ if (!aURIs.length) {
+ return;
+ }
+
+ // The tab selected after this new tab is closed (i.e. the new tab's
+ // "owner") is the next adjacent tab (i.e. not the previously viewed tab)
+ // when several urls are opened here (i.e. closing the first should select
+ // the next of many URLs opened) or if the pref to have UI links opened in
+ // the background is set (i.e. the link is not being opened modally)
+ //
+ // i.e.
+ // Number of URLs Load UI Links in BG Focus Last Viewed?
+ // == 1 false YES
+ // == 1 true NO
+ // > 1 false/true NO
+ var multiple = aURIs.length > 1;
+ var owner = multiple || inBackground ? null : this.selectedTab;
+ var firstTabAdded = null;
+ var targetTabIndex = -1;
+
+ if (typeof newIndex != "number") {
+ newIndex = -1;
+ }
+
+ // When bulk opening tabs, such as from a bookmark folder, we want to insertAfterCurrent
+ // if necessary, but we also will set the bulkOrderedOpen flag so that the bookmarks
+ // open in the same order they are in the folder.
+ if (
+ multiple &&
+ newIndex < 0 &&
+ Services.prefs.getBoolPref("browser.tabs.insertAfterCurrent")
+ ) {
+ newIndex = this.selectedTab._tPos + 1;
+ }
+
+ if (replace) {
+ let browser;
+ if (targetTab) {
+ browser = this.getBrowserForTab(targetTab);
+ targetTabIndex = targetTab._tPos;
+ } else {
+ browser = this.selectedBrowser;
+ targetTabIndex = this.tabContainer.selectedIndex;
+ }
+ let flags = LOAD_FLAGS_NONE;
+ if (allowThirdPartyFixup) {
+ flags |=
+ LOAD_FLAGS_ALLOW_THIRD_PARTY_FIXUP | LOAD_FLAGS_FIXUP_SCHEME_TYPOS;
+ }
+ if (!allowInheritPrincipal) {
+ flags |= LOAD_FLAGS_DISALLOW_INHERIT_PRINCIPAL;
+ }
+ if (fromExternal) {
+ flags |= LOAD_FLAGS_FROM_EXTERNAL;
+ }
+ try {
+ browser.fixupAndLoadURIString(aURIs[0], {
+ flags,
+ postData: postDatas && postDatas[0],
+ triggeringPrincipal,
+ csp,
+ });
+ } catch (e) {
+ // Ignore failure in case a URI is wrong, so we can continue
+ // opening the next ones.
+ }
+ } else {
+ let params = {
+ allowInheritPrincipal,
+ ownerTab: owner,
+ skipAnimation: multiple,
+ allowThirdPartyFixup,
+ postData: postDatas && postDatas[0],
+ userContextId,
+ triggeringPrincipal,
+ bulkOrderedOpen: multiple,
+ csp,
+ fromExternal,
+ };
+ if (newIndex > -1) {
+ params.index = newIndex;
+ }
+ firstTabAdded = this.addTab(aURIs[0], params);
+ if (newIndex > -1) {
+ targetTabIndex = firstTabAdded._tPos;
+ }
+ }
+
+ let tabNum = targetTabIndex;
+ for (let i = 1; i < aURIs.length; ++i) {
+ let params = {
+ allowInheritPrincipal,
+ skipAnimation: true,
+ allowThirdPartyFixup,
+ postData: postDatas && postDatas[i],
+ userContextId,
+ triggeringPrincipal,
+ bulkOrderedOpen: true,
+ csp,
+ fromExternal,
+ };
+ if (targetTabIndex > -1) {
+ params.index = ++tabNum;
+ }
+ this.addTab(aURIs[i], params);
+ }
+
+ if (firstTabAdded && !inBackground) {
+ this.selectedTab = firstTabAdded;
+ }
+ },
+
+ updateBrowserRemoteness(aBrowser, { newFrameloader, remoteType } = {}) {
+ let isRemote = aBrowser.getAttribute("remote") == "true";
+
+ // We have to be careful with this here, as the "no remote type" is null,
+ // not a string. Make sure to check only for undefined, since null is
+ // allowed.
+ if (remoteType === undefined) {
+ throw new Error("Remote type must be set!");
+ }
+
+ let shouldBeRemote = remoteType !== E10SUtils.NOT_REMOTE;
+
+ if (!gMultiProcessBrowser && shouldBeRemote) {
+ throw new Error(
+ "Cannot switch to remote browser in a window " +
+ "without the remote tabs load context."
+ );
+ }
+
+ // Abort if we're not going to change anything
+ let oldRemoteType = aBrowser.remoteType;
+ if (
+ isRemote == shouldBeRemote &&
+ !newFrameloader &&
+ (!isRemote || oldRemoteType == remoteType)
+ ) {
+ return false;
+ }
+
+ let tab = this.getTabForBrowser(aBrowser);
+ // aBrowser needs to be inserted now if it hasn't been already.
+ this._insertBrowser(tab);
+
+ let evt = document.createEvent("Events");
+ evt.initEvent("BeforeTabRemotenessChange", true, false);
+ tab.dispatchEvent(evt);
+
+ let wasActive = document.activeElement == aBrowser;
+
+ // Unhook our progress listener.
+ let filter = this._tabFilters.get(tab);
+ let listener = this._tabListeners.get(tab);
+ aBrowser.webProgress.removeProgressListener(filter);
+ filter.removeProgressListener(listener);
+
+ // We'll be creating a new listener, so destroy the old one.
+ listener.destroy();
+
+ let oldDroppedLinkHandler = aBrowser.droppedLinkHandler;
+ let oldUserTypedValue = aBrowser.userTypedValue;
+ let hadStartedLoad = aBrowser.didStartLoadSinceLastUserTyping();
+
+ // Change the "remote" attribute.
+
+ // Make sure the browser is destroyed so it unregisters from observer notifications
+ aBrowser.destroy();
+
+ if (shouldBeRemote) {
+ aBrowser.setAttribute("remote", "true");
+ aBrowser.setAttribute("remoteType", remoteType);
+ } else {
+ aBrowser.setAttribute("remote", "false");
+ aBrowser.removeAttribute("remoteType");
+ }
+
+ // This call actually switches out our frameloaders. Do this as late as
+ // possible before rebuilding the browser, as we'll need the new browser
+ // state set up completely first.
+ aBrowser.changeRemoteness({
+ remoteType,
+ });
+
+ // Once we have new frameloaders, this call sets the browser back up.
+ aBrowser.construct();
+
+ aBrowser.userTypedValue = oldUserTypedValue;
+ if (hadStartedLoad) {
+ aBrowser.urlbarChangeTracker.startedLoad();
+ }
+
+ aBrowser.droppedLinkHandler = oldDroppedLinkHandler;
+
+ // This shouldn't really be necessary, however, this has the side effect
+ // of sending MozLayerTreeReady / MozLayerTreeCleared events for remote
+ // frames, which the tab switcher depends on.
+ //
+ // eslint-disable-next-line no-self-assign
+ aBrowser.docShellIsActive = aBrowser.docShellIsActive;
+
+ // Create a new tab progress listener for the new browser we just injected,
+ // since tab progress listeners have logic for handling the initial about:blank
+ // load
+ listener = new TabProgressListener(tab, aBrowser, true, false);
+ this._tabListeners.set(tab, listener);
+ filter.addProgressListener(listener, Ci.nsIWebProgress.NOTIFY_ALL);
+
+ // Restore the progress listener.
+ aBrowser.webProgress.addProgressListener(
+ filter,
+ Ci.nsIWebProgress.NOTIFY_ALL
+ );
+
+ // Restore the securityUI state.
+ let securityUI = aBrowser.securityUI;
+ let state = securityUI
+ ? securityUI.state
+ : Ci.nsIWebProgressListener.STATE_IS_INSECURE;
+ this._callProgressListeners(
+ aBrowser,
+ "onSecurityChange",
+ [aBrowser.webProgress, null, state],
+ true,
+ false
+ );
+ let event = aBrowser.getContentBlockingEvents();
+ // Include the true final argument to indicate that this event is
+ // simulated (instead of being observed by the webProgressListener).
+ this._callProgressListeners(
+ aBrowser,
+ "onContentBlockingEvent",
+ [aBrowser.webProgress, null, event, true],
+ true,
+ false
+ );
+
+ if (shouldBeRemote) {
+ // Switching the browser to be remote will connect to a new child
+ // process so the browser can no longer be considered to be
+ // crashed.
+ tab.removeAttribute("crashed");
+ // we call updatetabIndicatorAttr here, rather than _tabAttrModified, so as
+ // to be consistent with how "crashed" attribute changes are handled elsewhere
+ this.tabContainer.updateTabIndicatorAttr(tab);
+ }
+
+ if (wasActive) {
+ aBrowser.focus();
+ }
+
+ // If the findbar has been initialised, reset its browser reference.
+ if (this.isFindBarInitialized(tab)) {
+ this.getCachedFindBar(tab).browser = aBrowser;
+ }
+
+ evt = document.createEvent("Events");
+ evt.initEvent("TabRemotenessChange", true, false);
+ tab.dispatchEvent(evt);
+
+ return true;
+ },
+
+ updateBrowserRemotenessByURL(aBrowser, aURL, aOptions = {}) {
+ if (!gMultiProcessBrowser) {
+ return this.updateBrowserRemoteness(aBrowser, {
+ remoteType: E10SUtils.NOT_REMOTE,
+ });
+ }
+
+ let oldRemoteType = aBrowser.remoteType;
+
+ let oa = E10SUtils.predictOriginAttributes({ browser: aBrowser });
+
+ aOptions.remoteType = E10SUtils.getRemoteTypeForURI(
+ aURL,
+ gMultiProcessBrowser,
+ gFissionBrowser,
+ oldRemoteType,
+ aBrowser.currentURI,
+ oa
+ );
+
+ // If this URL can't load in the current browser then flip it to the
+ // correct type.
+ if (oldRemoteType != aOptions.remoteType || aOptions.newFrameloader) {
+ return this.updateBrowserRemoteness(aBrowser, aOptions);
+ }
+
+ return false;
+ },
+
+ createBrowser({
+ isPreloadBrowser,
+ name,
+ openWindowInfo,
+ remoteType,
+ initialBrowsingContextGroupId,
+ uriIsAboutBlank,
+ userContextId,
+ skipLoad,
+ initiallyActive,
+ } = {}) {
+ let b = document.createXULElement("browser");
+ // Use the JSM global to create the permanentKey, so that if the
+ // permanentKey is held by something after this window closes, it
+ // doesn't keep the window alive.
+ b.permanentKey = new (Cu.getGlobalForObject(Services).Object)();
+
+ // Ensure that SessionStore has flushed any session history state from the
+ // content process before we this browser's remoteness.
+ if (!Services.appinfo.sessionHistoryInParent) {
+ b.prepareToChangeRemoteness = () =>
+ SessionStore.prepareToChangeRemoteness(b);
+ b.afterChangeRemoteness = switchId => {
+ let tab = this.getTabForBrowser(b);
+ SessionStore.finishTabRemotenessChange(tab, switchId);
+ return true;
+ };
+ }
+
+ const defaultBrowserAttributes = {
+ contextmenu: "contentAreaContextMenu",
+ message: "true",
+ messagemanagergroup: "browsers",
+ tooltip: "aHTMLTooltip",
+ type: "content",
+ };
+ for (let attribute in defaultBrowserAttributes) {
+ b.setAttribute(attribute, defaultBrowserAttributes[attribute]);
+ }
+
+ if (gMultiProcessBrowser || remoteType) {
+ b.setAttribute("maychangeremoteness", "true");
+ }
+
+ if (!initiallyActive) {
+ b.setAttribute("initiallyactive", "false");
+ }
+
+ if (userContextId) {
+ b.setAttribute("usercontextid", userContextId);
+ }
+
+ if (remoteType) {
+ b.setAttribute("remoteType", remoteType);
+ b.setAttribute("remote", "true");
+ }
+
+ if (!isPreloadBrowser) {
+ b.setAttribute("autocompletepopup", "PopupAutoComplete");
+ }
+
+ /*
+ * This attribute is meant to describe if the browser is the
+ * preloaded browser. When the preloaded browser is created, the
+ * 'preloadedState' attribute for that browser is set to "preloaded", and
+ * when a new tab is opened, and it is time to show that preloaded
+ * browser, the 'preloadedState' attribute for that browser is removed.
+ *
+ * See more details on Bug 1420285.
+ */
+ if (isPreloadBrowser) {
+ b.setAttribute("preloadedState", "preloaded");
+ }
+
+ // Ensure that the browser will be created in a specific initial
+ // BrowsingContextGroup. This may change the process selection behaviour
+ // of the newly created browser, and is often used in combination with
+ // "remoteType" to ensure that the initial about:blank load occurs
+ // within the same process as another window.
+ if (initialBrowsingContextGroupId) {
+ b.setAttribute(
+ "initialBrowsingContextGroupId",
+ initialBrowsingContextGroupId
+ );
+ }
+
+ // Propagate information about the opening content window to the browser.
+ if (openWindowInfo) {
+ b.openWindowInfo = openWindowInfo;
+ }
+
+ // This will be used by gecko to control the name of the opened
+ // window.
+ if (name) {
+ // XXX: The `name` property is special in HTML and XUL. Should
+ // we use a different attribute name for this?
+ b.setAttribute("name", name);
+ }
+
+ let notificationbox = document.createXULElement("notificationbox");
+ notificationbox.setAttribute("notificationside", "top");
+
+ let stack = document.createXULElement("stack");
+ stack.className = "browserStack";
+ stack.appendChild(b);
+
+ let browserContainer = document.createXULElement("vbox");
+ browserContainer.className = "browserContainer";
+ browserContainer.appendChild(notificationbox);
+ browserContainer.appendChild(stack);
+
+ let browserSidebarContainer = document.createXULElement("hbox");
+ browserSidebarContainer.className = "browserSidebarContainer";
+ browserSidebarContainer.appendChild(browserContainer);
+
+ // Prevent the superfluous initial load of a blank document
+ // if we're going to load something other than about:blank.
+ if (!uriIsAboutBlank || skipLoad) {
+ b.setAttribute("nodefaultsrc", "true");
+ }
+
+ return b;
+ },
+
+ _createLazyBrowser(aTab) {
+ let browser = aTab.linkedBrowser;
+
+ let names = this._browserBindingProperties;
+
+ for (let i = 0; i < names.length; i++) {
+ let name = names[i];
+ let getter;
+ let setter;
+ switch (name) {
+ case "audioMuted":
+ getter = () => aTab.hasAttribute("muted");
+ break;
+ case "contentTitle":
+ getter = () => SessionStore.getLazyTabValue(aTab, "title");
+ break;
+ case "currentURI":
+ getter = () => {
+ // Avoid recreating the same nsIURI object over and over again...
+ if (browser._cachedCurrentURI) {
+ return browser._cachedCurrentURI;
+ }
+ let url =
+ SessionStore.getLazyTabValue(aTab, "url") || "about:blank";
+ return (browser._cachedCurrentURI = Services.io.newURI(url));
+ };
+ break;
+ case "didStartLoadSinceLastUserTyping":
+ getter = () => () => false;
+ break;
+ case "fullZoom":
+ case "textZoom":
+ getter = () => 1;
+ break;
+ case "tabHasCustomZoom":
+ getter = () => false;
+ break;
+ case "getTabBrowser":
+ getter = () => () => this;
+ break;
+ case "isRemoteBrowser":
+ getter = () => browser.getAttribute("remote") == "true";
+ break;
+ case "permitUnload":
+ getter = () => () => ({ permitUnload: true });
+ break;
+ case "reload":
+ case "reloadWithFlags":
+ getter = () => params => {
+ // Wait for load handler to be instantiated before
+ // initializing the reload.
+ aTab.addEventListener(
+ "SSTabRestoring",
+ () => {
+ browser[name](params);
+ },
+ { once: true }
+ );
+ gBrowser._insertBrowser(aTab);
+ };
+ break;
+ case "remoteType":
+ getter = () => {
+ let url =
+ SessionStore.getLazyTabValue(aTab, "url") || "about:blank";
+ // Avoid recreating the same nsIURI object over and over again...
+ let uri;
+ if (browser._cachedCurrentURI) {
+ uri = browser._cachedCurrentURI;
+ } else {
+ uri = browser._cachedCurrentURI = Services.io.newURI(url);
+ }
+ let oa = E10SUtils.predictOriginAttributes({
+ browser,
+ userContextId: aTab.getAttribute("usercontextid"),
+ });
+ return E10SUtils.getRemoteTypeForURI(
+ url,
+ gMultiProcessBrowser,
+ gFissionBrowser,
+ undefined,
+ uri,
+ oa
+ );
+ };
+ break;
+ case "userTypedValue":
+ case "userTypedClear":
+ getter = () => SessionStore.getLazyTabValue(aTab, name);
+ break;
+ default:
+ getter = () => {
+ if (AppConstants.NIGHTLY_BUILD) {
+ let message = `[bug 1345098] Lazy browser prematurely inserted via '${name}' property access:\n`;
+ Services.console.logStringMessage(message + new Error().stack);
+ }
+ this._insertBrowser(aTab);
+ return browser[name];
+ };
+ setter = value => {
+ if (AppConstants.NIGHTLY_BUILD) {
+ let message = `[bug 1345098] Lazy browser prematurely inserted via '${name}' property access:\n`;
+ Services.console.logStringMessage(message + new Error().stack);
+ }
+ this._insertBrowser(aTab);
+ return (browser[name] = value);
+ };
+ }
+ Object.defineProperty(browser, name, {
+ get: getter,
+ set: setter,
+ configurable: true,
+ enumerable: true,
+ });
+ }
+ },
+
+ _insertBrowser(aTab, aInsertedOnTabCreation) {
+ "use strict";
+
+ // If browser is already inserted or window is closed don't do anything.
+ if (aTab.linkedPanel || window.closed) {
+ return;
+ }
+
+ let browser = aTab.linkedBrowser;
+
+ // If browser is a lazy browser, delete the substitute properties.
+ if (this._browserBindingProperties[0] in browser) {
+ for (let name of this._browserBindingProperties) {
+ delete browser[name];
+ }
+ }
+
+ let { uriIsAboutBlank, usingPreloadedContent } = aTab._browserParams;
+ delete aTab._browserParams;
+ delete browser._cachedCurrentURI;
+
+ let panel = this.getPanel(browser);
+ let uniqueId = this._generateUniquePanelID();
+ panel.id = uniqueId;
+ aTab.linkedPanel = uniqueId;
+
+ // Inject the <browser> into the DOM if necessary.
+ if (!panel.parentNode) {
+ // NB: this appendChild call causes us to run constructors for the
+ // browser element, which fires off a bunch of notifications. Some
+ // of those notifications can cause code to run that inspects our
+ // state, so it is important that the tab element is fully
+ // initialized by this point.
+ this.tabpanels.appendChild(panel);
+ }
+
+ // wire up a progress listener for the new browser object.
+ let tabListener = new TabProgressListener(
+ aTab,
+ browser,
+ uriIsAboutBlank,
+ usingPreloadedContent
+ );
+ const filter = Cc[
+ "@mozilla.org/appshell/component/browser-status-filter;1"
+ ].createInstance(Ci.nsIWebProgress);
+ filter.addProgressListener(tabListener, Ci.nsIWebProgress.NOTIFY_ALL);
+ browser.webProgress.addProgressListener(
+ filter,
+ Ci.nsIWebProgress.NOTIFY_ALL
+ );
+ this._tabListeners.set(aTab, tabListener);
+ this._tabFilters.set(aTab, filter);
+
+ browser.droppedLinkHandler = handleDroppedLink;
+ browser.loadURI = URILoadingWrapper.loadURI.bind(
+ URILoadingWrapper,
+ browser
+ );
+ browser.fixupAndLoadURIString =
+ URILoadingWrapper.fixupAndLoadURIString.bind(
+ URILoadingWrapper,
+ browser
+ );
+
+ // Most of the time, we start our browser's docShells out as inactive,
+ // and then maintain activeness in the tab switcher. Preloaded about:newtab's
+ // are already created with their docShell's as inactive, but then explicitly
+ // render their layers to ensure that we can switch to them quickly. We avoid
+ // setting docShellIsActive to false again in this case, since that'd cause
+ // the layers for the preloaded tab to be dropped, and we'd see a flash
+ // of empty content instead.
+ //
+ // So for all browsers except for the preloaded case, we set the browser
+ // docShell to inactive.
+ if (!usingPreloadedContent) {
+ browser.docShellIsActive = false;
+ }
+
+ // If we transitioned from one browser to two browsers, we need to set
+ // hasSiblings=false on both the existing browser and the new browser.
+ if (this.tabs.length == 2) {
+ this.tabs[0].linkedBrowser.browsingContext.hasSiblings = true;
+ this.tabs[1].linkedBrowser.browsingContext.hasSiblings = true;
+ } else {
+ aTab.linkedBrowser.browsingContext.hasSiblings = this.tabs.length > 1;
+ }
+
+ if (aTab.userContextId) {
+ browser.setAttribute("usercontextid", aTab.userContextId);
+ }
+
+ browser.browsingContext.isAppTab = aTab.pinned;
+
+ // We don't want to update the container icon and identifier if
+ // this is not the selected browser.
+ if (aTab.selected) {
+ updateUserContextUIIndicator();
+ }
+
+ // Only fire this event if the tab is already in the DOM
+ // and will be handled by a listener.
+ if (aTab.isConnected) {
+ var evt = new CustomEvent("TabBrowserInserted", {
+ bubbles: true,
+ detail: { insertedOnTabCreation: aInsertedOnTabCreation },
+ });
+ aTab.dispatchEvent(evt);
+ }
+ },
+
+ _mayDiscardBrowser(aTab, aForceDiscard) {
+ let browser = aTab.linkedBrowser;
+ let action = aForceDiscard ? "unload" : "dontUnload";
+
+ if (
+ !aTab ||
+ aTab.selected ||
+ aTab.closing ||
+ this._windowIsClosing ||
+ !browser.isConnected ||
+ !browser.isRemoteBrowser ||
+ !browser.permitUnload(action).permitUnload
+ ) {
+ return false;
+ }
+
+ return true;
+ },
+
+ discardBrowser(aTab, aForceDiscard) {
+ "use strict";
+ let browser = aTab.linkedBrowser;
+
+ if (!this._mayDiscardBrowser(aTab, aForceDiscard)) {
+ return false;
+ }
+
+ // Reset sharing state.
+ if (aTab._sharingState) {
+ this.resetBrowserSharing(browser);
+ }
+ webrtcUI.forgetStreamsFromBrowserContext(browser.browsingContext);
+
+ // Set browser parameters for when browser is restored. Also remove
+ // listeners and set up lazy restore data in SessionStore. This must
+ // be done before browser is destroyed and removed from the document.
+ aTab._browserParams = {
+ uriIsAboutBlank: browser.currentURI.spec == "about:blank",
+ remoteType: browser.remoteType,
+ usingPreloadedContent: false,
+ };
+
+ SessionStore.resetBrowserToLazyState(aTab);
+
+ // Remove the tab's filter and progress listener.
+ let filter = this._tabFilters.get(aTab);
+ let listener = this._tabListeners.get(aTab);
+ browser.webProgress.removeProgressListener(filter);
+ filter.removeProgressListener(listener);
+ listener.destroy();
+
+ this._tabListeners.delete(aTab);
+ this._tabFilters.delete(aTab);
+
+ // Reset the findbar and remove it if it is attached to the tab.
+ if (aTab._findBar) {
+ aTab._findBar.close(true);
+ aTab._findBar.remove();
+ delete aTab._findBar;
+ }
+
+ // Remove potentially stale attributes.
+ let attributesToRemove = [
+ "activemedia-blocked",
+ "busy",
+ "pendingicon",
+ "progress",
+ "soundplaying",
+ ];
+ let removedAttributes = [];
+ for (let attr of attributesToRemove) {
+ if (aTab.hasAttribute(attr)) {
+ removedAttributes.push(attr);
+ aTab.removeAttribute(attr);
+ }
+ }
+ if (removedAttributes.length) {
+ this._tabAttrModified(aTab, removedAttributes);
+ }
+
+ browser.destroy();
+ this.getPanel(browser).remove();
+ aTab.removeAttribute("linkedpanel");
+
+ this._createLazyBrowser(aTab);
+
+ let evt = new CustomEvent("TabBrowserDiscarded", { bubbles: true });
+ aTab.dispatchEvent(evt);
+ return true;
+ },
+
+ /**
+ * Loads a tab with a default null principal unless specified
+ */
+ addWebTab(aURI, params = {}) {
+ if (!params.triggeringPrincipal) {
+ params.triggeringPrincipal =
+ Services.scriptSecurityManager.createNullPrincipal({
+ userContextId: params.userContextId,
+ });
+ }
+ if (params.triggeringPrincipal.isSystemPrincipal) {
+ throw new Error(
+ "System principal should never be passed into addWebTab()"
+ );
+ }
+ return this.addTab(aURI, params);
+ },
+
+ addAdjacentNewTab(tab) {
+ Services.obs.notifyObservers(
+ {
+ wrappedJSObject: new Promise(resolve => {
+ this.selectedTab = this.addTrustedTab(BROWSER_NEW_TAB_URL, {
+ index: tab._tPos + 1,
+ userContextId: tab.userContextId,
+ });
+ resolve(this.selectedBrowser);
+ }),
+ },
+ "browser-open-newtab-start"
+ );
+ },
+
+ /**
+ * Must only be used sparingly for content that came from Chrome context
+ * If in doubt use addWebTab
+ */
+ addTrustedTab(aURI, params = {}) {
+ params.triggeringPrincipal =
+ Services.scriptSecurityManager.getSystemPrincipal();
+ return this.addTab(aURI, params);
+ },
+
+ /**
+ * @returns {object}
+ * The new tab. The return value will be null if the tab couldn't be
+ * created; this shouldn't normally happen, and an error will be logged
+ * to the console if it does.
+ */
+ addTab(
+ uriString,
+ {
+ allowInheritPrincipal,
+ allowThirdPartyFixup,
+ bulkOrderedOpen,
+ charset,
+ createLazyBrowser,
+ disableTRR,
+ eventDetail,
+ focusUrlBar,
+ forceNotRemote,
+ forceAllowDataURI,
+ fromExternal,
+ inBackground = true,
+ index,
+ lazyTabTitle,
+ name,
+ noInitialLabel,
+ openWindowInfo,
+ openerBrowser,
+ originPrincipal,
+ originStoragePrincipal,
+ ownerTab,
+ pinned,
+ postData,
+ preferredRemoteType,
+ referrerInfo,
+ relatedToCurrent,
+ initialBrowsingContextGroupId,
+ skipAnimation,
+ skipBackgroundNotify,
+ triggeringPrincipal,
+ userContextId,
+ csp,
+ skipLoad = createLazyBrowser,
+ insertTab = true,
+ globalHistoryOptions,
+ triggeringRemoteType,
+ } = {}
+ ) {
+ // all callers of addTab that pass a params object need to pass
+ // a valid triggeringPrincipal.
+ if (!triggeringPrincipal) {
+ throw new Error(
+ "Required argument triggeringPrincipal missing within addTab"
+ );
+ }
+
+ if (!UserInteraction.running("browser.tabs.opening", window)) {
+ UserInteraction.start("browser.tabs.opening", "initting", window);
+ }
+
+ // If we're opening a foreground tab, set the owner by default.
+ ownerTab ??= inBackground ? null : this.selectedTab;
+
+ // Don't use document.l10n.setAttributes because the FTL file is loaded
+ // lazily and we won't be able to resolve the string.
+ document
+ .getElementById("History:UndoCloseTab")
+ .setAttribute("data-l10n-args", JSON.stringify({ tabCount: 1 }));
+
+ // if we're adding tabs, we're past interrupt mode, ditch the owner
+ if (this.selectedTab.owner) {
+ this.selectedTab.owner = null;
+ }
+
+ // Find the tab that opened this one, if any. This is used for
+ // determining positioning, and inherited attributes such as the
+ // user context ID.
+ //
+ // If we have a browser opener (which is usually the browser
+ // element from a remote window.open() call), use that.
+ //
+ // Otherwise, if the tab is related to the current tab (e.g.,
+ // because it was opened by a link click), use the selected tab as
+ // the owner. If referrerInfo is set, and we don't have an
+ // explicit relatedToCurrent arg, we assume that the tab is
+ // related to the current tab, since referrerURI is null or
+ // undefined if the tab is opened from an external application or
+ // bookmark (i.e. somewhere other than an existing tab).
+ if (relatedToCurrent == null) {
+ relatedToCurrent = !!(referrerInfo && referrerInfo.originalReferrer);
+ }
+ let openerTab =
+ (openerBrowser && this.getTabForBrowser(openerBrowser)) ||
+ (relatedToCurrent && this.selectedTab);
+
+ // When overflowing, new tabs are scrolled into view smoothly, which
+ // doesn't go well together with the width transition. So we skip the
+ // transition in that case.
+ let animate =
+ !skipAnimation &&
+ !pinned &&
+ this.tabContainer.getAttribute("overflow") != "true" &&
+ !gReduceMotion;
+
+ let uriInfo = this._determineURIToLoad(uriString, createLazyBrowser);
+ let { uri, uriIsAboutBlank, lazyBrowserURI } = uriInfo;
+ // Have to overwrite this if we're lazy-loading. Should go away
+ // with bug 1818777.
+ ({ uriString } = uriInfo);
+
+ let usingPreloadedContent = false;
+ let b, t;
+
+ try {
+ t = this._createTab({
+ uriString,
+ animate,
+ userContextId,
+ openerTab,
+ createLazyBrowser,
+ skipAnimation,
+ pinned,
+ noInitialLabel,
+ skipBackgroundNotify,
+ });
+ if (insertTab) {
+ // insert the tab into the tab container in the correct position
+ this._insertTabAtIndex(t, {
+ index,
+ ownerTab,
+ openerTab,
+ pinned,
+ bulkOrderedOpen,
+ });
+ }
+
+ ({ browser: b, usingPreloadedContent } = this._createBrowserForTab(t, {
+ uriString,
+ uri,
+ preferredRemoteType,
+ openerBrowser,
+ uriIsAboutBlank,
+ referrerInfo,
+ forceNotRemote,
+ name,
+ initialBrowsingContextGroupId,
+ openWindowInfo,
+ skipLoad,
+ }));
+
+ if (focusUrlBar) {
+ b._urlbarFocused = true;
+ }
+
+ // If the caller opts in, create a lazy browser.
+ if (createLazyBrowser) {
+ this._createLazyBrowser(t);
+
+ if (lazyBrowserURI) {
+ // Lazy browser must be explicitly registered so tab will appear as
+ // a switch-to-tab candidate in autocomplete.
+ this.UrlbarProviderOpenTabs.registerOpenTab(
+ lazyBrowserURI.spec,
+ t.userContextId,
+ PrivateBrowsingUtils.isWindowPrivate(window)
+ );
+ b.registeredOpenURI = lazyBrowserURI;
+ }
+ // If we're not inserting the tab into the DOM, we can't set the tab
+ // state meaningfully. Session restore (the only caller who does this)
+ // will have to do this work itself later, when the tabs have been
+ // inserted.
+ if (insertTab) {
+ SessionStore.setTabState(t, {
+ entries: [
+ {
+ url: lazyBrowserURI?.spec || "about:blank",
+ title: lazyTabTitle,
+ triggeringPrincipal_base64:
+ E10SUtils.serializePrincipal(triggeringPrincipal),
+ },
+ ],
+ // Make sure to store the userContextId associated to the lazy tab
+ // otherwise it would be created as a default tab when recreated on a
+ // session restore (See Bug 1819794).
+ userContextId,
+ });
+ }
+ } else {
+ this._insertBrowser(t, true);
+ // If we were called by frontend and don't have openWindowInfo,
+ // but we were opened from another browser, set the cross group
+ // opener ID:
+ if (openerBrowser && !openWindowInfo) {
+ b.browsingContext.setCrossGroupOpener(
+ openerBrowser.browsingContext
+ );
+ }
+ }
+ } catch (e) {
+ console.error("Failed to create tab");
+ console.error(e);
+ t?.remove();
+ if (t?.linkedBrowser) {
+ this._tabFilters.delete(t);
+ this._tabListeners.delete(t);
+ this.getPanel(t.linkedBrowser).remove();
+ }
+ return null;
+ }
+
+ if (insertTab) {
+ // Fire a TabOpen event
+ this._fireTabOpen(t, eventDetail);
+
+ this._kickOffBrowserLoad(b, {
+ uri,
+ uriString,
+ usingPreloadedContent,
+ triggeringPrincipal,
+ originPrincipal,
+ originStoragePrincipal,
+ uriIsAboutBlank,
+ allowInheritPrincipal,
+ allowThirdPartyFixup,
+ fromExternal,
+ disableTRR,
+ forceAllowDataURI,
+ skipLoad,
+ referrerInfo,
+ charset,
+ postData,
+ csp,
+ globalHistoryOptions,
+ triggeringRemoteType,
+ });
+ }
+
+ // This field is updated regardless if we actually animate
+ // since it's important that we keep this count correct in all cases.
+ this.tabAnimationsInProgress++;
+
+ if (animate) {
+ requestAnimationFrame(function () {
+ // kick the animation off
+ t.setAttribute("fadein", "true");
+ });
+ }
+
+ // Additionally send pinned tab events
+ if (pinned) {
+ this._notifyPinnedStatus(t);
+ }
+
+ gSharedTabWarning.tabAdded(t);
+
+ if (!inBackground) {
+ this.selectedTab = t;
+ }
+ return t;
+ },
+
+ _determineURIToLoad(uriString, createLazyBrowser) {
+ uriString = uriString || "about:blank";
+ let aURIObject = null;
+ try {
+ aURIObject = Services.io.newURI(uriString);
+ } catch (ex) {
+ /* we'll try to fix up this URL later */
+ }
+
+ let lazyBrowserURI;
+ if (createLazyBrowser && uriString != "about:blank") {
+ lazyBrowserURI = aURIObject;
+ uriString = "about:blank";
+ }
+
+ let uriIsAboutBlank = uriString == "about:blank";
+ return { uri: aURIObject, uriIsAboutBlank, lazyBrowserURI, uriString };
+ },
+
+ _createTab({
+ uriString,
+ userContextId,
+ openerTab,
+ createLazyBrowser,
+ skipAnimation,
+ pinned,
+ noInitialLabel,
+ skipBackgroundNotify,
+ animate,
+ }) {
+ var t = document.createXULElement("tab", { is: "tabbrowser-tab" });
+ // Tag the tab as being created so extension code can ignore events
+ // prior to TabOpen.
+ t.initializingTab = true;
+ t.openerTab = openerTab;
+
+ // Related tab inherits current tab's user context unless a different
+ // usercontextid is specified
+ if (userContextId == null && openerTab) {
+ userContextId = openerTab.getAttribute("usercontextid") || 0;
+ }
+
+ if (!noInitialLabel) {
+ if (isBlankPageURL(uriString)) {
+ t.setAttribute("label", this.tabContainer.emptyTabTitle);
+ } else {
+ // Set URL as label so that the tab isn't empty initially.
+ this.setInitialTabTitle(t, uriString, {
+ beforeTabOpen: true,
+ isURL: true,
+ });
+ }
+ }
+
+ if (userContextId) {
+ t.setAttribute("usercontextid", userContextId);
+ ContextualIdentityService.setTabStyle(t);
+ }
+
+ if (skipBackgroundNotify) {
+ t.setAttribute("skipbackgroundnotify", true);
+ }
+
+ if (pinned) {
+ t.setAttribute("pinned", "true");
+ }
+
+ t.classList.add("tabbrowser-tab");
+
+ this.tabContainer._unlockTabSizing();
+
+ if (!animate) {
+ UserInteraction.update("browser.tabs.opening", "not-animated", window);
+ t.setAttribute("fadein", "true");
+
+ // Call _handleNewTab asynchronously as it needs to know if the
+ // new tab is selected.
+ setTimeout(
+ function (tabContainer) {
+ tabContainer._handleNewTab(t);
+ },
+ 0,
+ this.tabContainer
+ );
+ } else {
+ UserInteraction.update("browser.tabs.opening", "animated", window);
+ }
+
+ return t;
+ },
+
+ _createBrowserForTab(
+ tab,
+ {
+ uriString,
+ uri,
+ name,
+ preferredRemoteType,
+ openerBrowser,
+ uriIsAboutBlank,
+ referrerInfo,
+ forceNotRemote,
+ initialBrowsingContextGroupId,
+ openWindowInfo,
+ skipLoad,
+ }
+ ) {
+ // If we don't have a preferred remote type, and we have a remote
+ // opener, use the opener's remote type.
+ if (!preferredRemoteType && openerBrowser) {
+ preferredRemoteType = openerBrowser.remoteType;
+ }
+
+ let { userContextId } = tab;
+
+ var oa = E10SUtils.predictOriginAttributes({ window, userContextId });
+
+ // If URI is about:blank and we don't have a preferred remote type,
+ // then we need to use the referrer, if we have one, to get the
+ // correct remote type for the new tab.
+ if (
+ uriIsAboutBlank &&
+ !preferredRemoteType &&
+ referrerInfo &&
+ referrerInfo.originalReferrer
+ ) {
+ preferredRemoteType = E10SUtils.getRemoteTypeForURI(
+ referrerInfo.originalReferrer.spec,
+ gMultiProcessBrowser,
+ gFissionBrowser,
+ E10SUtils.DEFAULT_REMOTE_TYPE,
+ null,
+ oa
+ );
+ }
+
+ let remoteType = forceNotRemote
+ ? E10SUtils.NOT_REMOTE
+ : E10SUtils.getRemoteTypeForURI(
+ uriString,
+ gMultiProcessBrowser,
+ gFissionBrowser,
+ preferredRemoteType,
+ null,
+ oa
+ );
+
+ let b,
+ usingPreloadedContent = false;
+ // If we open a new tab with the newtab URL in the default
+ // userContext, check if there is a preloaded browser ready.
+ if (uriString == BROWSER_NEW_TAB_URL && !userContextId) {
+ b = NewTabPagePreloading.getPreloadedBrowser(window);
+ if (b) {
+ usingPreloadedContent = true;
+ }
+ }
+
+ if (!b) {
+ // No preloaded browser found, create one.
+ b = this.createBrowser({
+ remoteType,
+ uriIsAboutBlank,
+ userContextId,
+ initialBrowsingContextGroupId,
+ openWindowInfo,
+ name,
+ skipLoad,
+ });
+ }
+
+ tab.linkedBrowser = b;
+
+ this._tabForBrowser.set(b, tab);
+ tab.permanentKey = b.permanentKey;
+ tab._browserParams = {
+ uriIsAboutBlank,
+ remoteType,
+ usingPreloadedContent,
+ };
+
+ // Hack to ensure that the about:newtab, and about:welcome favicon is loaded
+ // instantaneously, to avoid flickering and improve perceived performance.
+ this.setDefaultIcon(tab, uri);
+
+ return { browser: b, usingPreloadedContent };
+ },
+
+ _kickOffBrowserLoad(
+ browser,
+ {
+ uri,
+ uriString,
+ usingPreloadedContent,
+ triggeringPrincipal,
+ originPrincipal,
+ originStoragePrincipal,
+ uriIsAboutBlank,
+ allowInheritPrincipal,
+ allowThirdPartyFixup,
+ fromExternal,
+ disableTRR,
+ forceAllowDataURI,
+ skipLoad,
+ referrerInfo,
+ charset,
+ postData,
+ csp,
+ globalHistoryOptions,
+ triggeringRemoteType,
+ }
+ ) {
+ if (
+ !usingPreloadedContent &&
+ originPrincipal &&
+ originStoragePrincipal &&
+ uriString
+ ) {
+ let { URI_INHERITS_SECURITY_CONTEXT } = Ci.nsIProtocolHandler;
+ // Unless we know for sure we're not inheriting principals,
+ // force the about:blank viewer to have the right principal:
+ if (!uri || doGetProtocolFlags(uri) & URI_INHERITS_SECURITY_CONTEXT) {
+ browser.createAboutBlankContentViewer(
+ originPrincipal,
+ originStoragePrincipal
+ );
+ }
+ }
+
+ // If we didn't swap docShells with a preloaded browser
+ // then let's just continue loading the page normally.
+ if (
+ !usingPreloadedContent &&
+ (!uriIsAboutBlank || !allowInheritPrincipal) &&
+ !skipLoad
+ ) {
+ // pretend the user typed this so it'll be available till
+ // the document successfully loads
+ if (uriString && !gInitialPages.includes(uriString)) {
+ browser.userTypedValue = uriString;
+ }
+
+ let flags = LOAD_FLAGS_NONE;
+ if (allowThirdPartyFixup) {
+ flags |=
+ LOAD_FLAGS_ALLOW_THIRD_PARTY_FIXUP | LOAD_FLAGS_FIXUP_SCHEME_TYPOS;
+ }
+ if (fromExternal) {
+ flags |= LOAD_FLAGS_FROM_EXTERNAL;
+ } else if (!triggeringPrincipal.isSystemPrincipal) {
+ // XXX this code must be reviewed and changed when bug 1616353
+ // lands.
+ flags |= LOAD_FLAGS_FIRST_LOAD;
+ }
+ if (!allowInheritPrincipal) {
+ flags |= LOAD_FLAGS_DISALLOW_INHERIT_PRINCIPAL;
+ }
+ if (disableTRR) {
+ flags |= LOAD_FLAGS_DISABLE_TRR;
+ }
+ if (forceAllowDataURI) {
+ flags |= LOAD_FLAGS_FORCE_ALLOW_DATA_URI;
+ }
+ try {
+ browser.fixupAndLoadURIString(uriString, {
+ flags,
+ triggeringPrincipal,
+ referrerInfo,
+ charset,
+ postData,
+ csp,
+ globalHistoryOptions,
+ triggeringRemoteType,
+ });
+ } catch (ex) {
+ console.error(ex);
+ }
+ }
+ },
+
+ createTabsForSessionRestore(restoreTabsLazily, selectTab, tabDataList) {
+ let tabs = [];
+ let tabsFragment = document.createDocumentFragment();
+ let tabToSelect = null;
+ let hiddenTabs = new Map();
+ let shouldUpdateForPinnedTabs = false;
+
+ // We create each tab and browser, but only insert them
+ // into a document fragment so that we can insert them all
+ // together. This prevents synch reflow for each tab
+ // insertion.
+ for (var i = 0; i < tabDataList.length; i++) {
+ let tabData = tabDataList[i];
+
+ let userContextId = tabData.userContextId;
+ let select = i == selectTab - 1;
+ let tab;
+ let tabWasReused = false;
+
+ // Re-use existing selected tab if possible to avoid the overhead of
+ // selecting a new tab.
+ if (
+ select &&
+ this.selectedTab.userContextId == userContextId &&
+ !SessionStore.isTabRestoring(this.selectedTab)
+ ) {
+ tabWasReused = true;
+ tab = this.selectedTab;
+ if (!tabData.pinned) {
+ this.unpinTab(tab);
+ } else {
+ this.pinTab(tab);
+ }
+ }
+
+ // Add a new tab if needed.
+ if (!tab) {
+ let createLazyBrowser =
+ restoreTabsLazily && !select && !tabData.pinned;
+
+ let url = "about:blank";
+ if (tabData.entries?.length) {
+ let activeIndex = (tabData.index || tabData.entries.length) - 1;
+ // Ensure the index is in bounds.
+ activeIndex = Math.min(activeIndex, tabData.entries.length - 1);
+ activeIndex = Math.max(activeIndex, 0);
+ url = tabData.entries[activeIndex].url;
+ }
+
+ let preferredRemoteType = E10SUtils.getRemoteTypeForURI(
+ url,
+ gMultiProcessBrowser,
+ gFissionBrowser,
+ E10SUtils.DEFAULT_REMOTE_TYPE,
+ null,
+ E10SUtils.predictOriginAttributes({ window, userContextId })
+ );
+
+ // If we're creating a lazy browser, let tabbrowser know the future
+ // URI because progress listeners won't get onLocationChange
+ // notification before the browser is inserted.
+ //
+ // Setting noInitialLabel is a perf optimization. Rendering tab labels
+ // would make resizing the tabs more expensive as we're adding them.
+ // Each tab will get its initial label set in restoreTab.
+ tab = this.addTrustedTab(createLazyBrowser ? url : "about:blank", {
+ createLazyBrowser,
+ skipAnimation: true,
+ noInitialLabel: true,
+ userContextId,
+ skipBackgroundNotify: true,
+ bulkOrderedOpen: true,
+ insertTab: false,
+ skipLoad: true,
+ preferredRemoteType,
+ });
+
+ if (select) {
+ tabToSelect = tab;
+ }
+ }
+
+ tabs.push(tab);
+
+ if (tabData.pinned) {
+ // Calling `pinTab` calls `moveTabTo`, which assumes the tab is
+ // inserted in the DOM. If the tab is not yet in the DOM,
+ // just insert it in the right place from the start.
+ if (!tab.parentNode) {
+ tab._tPos = this._numPinnedTabs;
+ this.tabContainer.insertBefore(tab, this.tabs[this._numPinnedTabs]);
+ tab.setAttribute("pinned", "true");
+ this.tabContainer._invalidateCachedTabs();
+ // Then ensure all the tab open/pinning information is sent.
+ this._fireTabOpen(tab, {});
+ this._notifyPinnedStatus(tab);
+ // Once we're done adding all tabs, _updateTabBarForPinnedTabs
+ // needs calling:
+ shouldUpdateForPinnedTabs = true;
+ }
+ } else {
+ if (tab.hidden) {
+ tab.hidden = true;
+ hiddenTabs.set(tab, tabData.extData && tabData.extData.hiddenBy);
+ }
+
+ tabsFragment.appendChild(tab);
+ if (tabWasReused) {
+ this.tabContainer._invalidateCachedTabs();
+ }
+ }
+
+ tab.initialize();
+ }
+
+ // inject the new DOM nodes
+ this.tabContainer.appendChild(tabsFragment);
+
+ for (let [tab, hiddenBy] of hiddenTabs) {
+ let event = document.createEvent("Events");
+ event.initEvent("TabHide", true, false);
+ tab.dispatchEvent(event);
+ if (hiddenBy) {
+ SessionStore.setCustomTabValue(tab, "hiddenBy", hiddenBy);
+ }
+ }
+
+ this.tabContainer._invalidateCachedTabs();
+ if (shouldUpdateForPinnedTabs) {
+ this._updateTabBarForPinnedTabs();
+ }
+
+ // We need to wait until after all tabs have been appended to the DOM
+ // to remove the old selected tab.
+ if (tabToSelect) {
+ let leftoverTab = this.selectedTab;
+ this.selectedTab = tabToSelect;
+ this.removeTab(leftoverTab);
+ }
+
+ if (tabs.length > 1 || !tabs[0].selected) {
+ this._updateTabsAfterInsert();
+ this.tabContainer._setPositionalAttributes();
+ TabBarVisibility.update();
+
+ for (let tab of tabs) {
+ // If tabToSelect is a tab, we didn't reuse the selected tab.
+ if (tabToSelect || !tab.selected) {
+ // Fire a TabOpen event for all unpinned tabs, except reused selected
+ // tabs.
+ if (!tab.pinned) {
+ this._fireTabOpen(tab, {});
+ }
+
+ // Fire a TabBrowserInserted event on all tabs that have a connected,
+ // real browser, except for reused selected tabs.
+ if (tab.linkedPanel) {
+ var evt = new CustomEvent("TabBrowserInserted", {
+ bubbles: true,
+ detail: { insertedOnTabCreation: true },
+ });
+ tab.dispatchEvent(evt);
+ }
+ }
+ }
+ }
+
+ return tabs;
+ },
+
+ moveTabsToStart(contextTab) {
+ let tabs = contextTab.multiselected ? this.selectedTabs : [contextTab];
+ // Walk the array in reverse order so the tabs are kept in order.
+ for (let i = tabs.length - 1; i >= 0; i--) {
+ let tab = tabs[i];
+ if (tab._tPos > 0) {
+ this.moveTabTo(tab, 0);
+ }
+ }
+ },
+
+ moveTabsToEnd(contextTab) {
+ let tabs = contextTab.multiselected ? this.selectedTabs : [contextTab];
+ for (let tab of tabs) {
+ if (tab._tPos < this.tabs.length - 1) {
+ this.moveTabTo(tab, this.tabs.length - 1);
+ }
+ }
+ },
+
+ warnAboutClosingTabs(tabsToClose, aCloseTabs, aSource) {
+ if (tabsToClose <= 1) {
+ return true;
+ }
+
+ const pref =
+ aCloseTabs == this.closingTabsEnum.ALL
+ ? "browser.tabs.warnOnClose"
+ : "browser.tabs.warnOnCloseOtherTabs";
+ var shouldPrompt = Services.prefs.getBoolPref(pref);
+ if (!shouldPrompt) {
+ return true;
+ }
+
+ const maxTabsUndo = Services.prefs.getIntPref(
+ "browser.sessionstore.max_tabs_undo"
+ );
+ if (
+ aCloseTabs != this.closingTabsEnum.ALL &&
+ tabsToClose <= maxTabsUndo
+ ) {
+ return true;
+ }
+
+ // Our prompt to close this window is most important, so replace others.
+ gDialogBox.replaceDialogIfOpen();
+
+ var ps = Services.prompt;
+
+ // default to true: if it were false, we wouldn't get this far
+ var warnOnClose = { value: true };
+
+ // focus the window before prompting.
+ // this will raise any minimized window, which will
+ // make it obvious which window the prompt is for and will
+ // solve the problem of windows "obscuring" the prompt.
+ // see bug #350299 for more details
+ window.focus();
+ const [title, button, checkbox] = this.tabLocalization.formatValuesSync([
+ {
+ id: "tabbrowser-confirm-close-tabs-title",
+ args: { tabCount: tabsToClose },
+ },
+ { id: "tabbrowser-confirm-close-tabs-button" },
+ { id: "tabbrowser-confirm-close-tabs-checkbox" },
+ ]);
+ let flags =
+ ps.BUTTON_TITLE_IS_STRING * ps.BUTTON_POS_0 +
+ ps.BUTTON_TITLE_CANCEL * ps.BUTTON_POS_1;
+ let checkboxLabel =
+ aCloseTabs == this.closingTabsEnum.ALL ? checkbox : null;
+ var buttonPressed = ps.confirmEx(
+ window,
+ title,
+ null,
+ flags,
+ button,
+ null,
+ null,
+ checkboxLabel,
+ warnOnClose
+ );
+
+ Services.telemetry.setEventRecordingEnabled("close_tab_warning", true);
+ let closeTabEnumKey =
+ Object.entries(this.closingTabsEnum)
+ .find(([k, v]) => v == aCloseTabs)?.[0]
+ ?.toLowerCase() || "some";
+
+ let warnCheckbox = warnOnClose.value ? "checked" : "unchecked";
+ if (!checkboxLabel) {
+ warnCheckbox = "not-present";
+ }
+ let sessionWillBeRestored =
+ Services.prefs.getIntPref("browser.startup.page") == 3 ||
+ Services.prefs.getBoolPref("browser.sessionstore.resume_session_once");
+ let closesWindow = aCloseTabs == this.closingTabsEnum.ALL;
+ Services.telemetry.recordEvent(
+ "close_tab_warning",
+ "shown",
+ closesWindow ? "window" : "tabs",
+ null,
+ {
+ source: aSource || `close-${closeTabEnumKey}-tabs`,
+ button: buttonPressed == 0 ? "close" : "cancel",
+ warn_checkbox: warnCheckbox,
+ closing_tabs: "" + tabsToClose,
+ closing_wins: "" + +closesWindow, // ("1" or "0", depending on the value)
+ // This value doesn't really apply to whether this warning
+ // gets shown, but having pings be consistent (and perhaps
+ // being able to see trends for users with/without sessionrestore)
+ // seems useful:
+ will_restore: sessionWillBeRestored ? "yes" : "no",
+ }
+ );
+
+ var reallyClose = buttonPressed == 0;
+
+ // don't set the pref unless they press OK and it's false
+ if (
+ aCloseTabs == this.closingTabsEnum.ALL &&
+ reallyClose &&
+ !warnOnClose.value
+ ) {
+ Services.prefs.setBoolPref(pref, false);
+ }
+
+ return reallyClose;
+ },
+
+ /**
+ * This determines where the tab should be inserted within the tabContainer
+ */
+ _insertTabAtIndex(
+ tab,
+ { index, ownerTab, openerTab, pinned, bulkOrderedOpen } = {}
+ ) {
+ // If this new tab is owned by another, assert that relationship
+ if (ownerTab) {
+ tab.owner = ownerTab;
+ }
+
+ // Ensure we have an index if one was not provided.
+ if (typeof index != "number") {
+ // Move the new tab after another tab if needed, to the end otherwise.
+ index = Infinity;
+ if (
+ !bulkOrderedOpen &&
+ ((openerTab &&
+ Services.prefs.getBoolPref(
+ "browser.tabs.insertRelatedAfterCurrent"
+ )) ||
+ Services.prefs.getBoolPref("browser.tabs.insertAfterCurrent"))
+ ) {
+ let lastRelatedTab =
+ openerTab && this._lastRelatedTabMap.get(openerTab);
+ let previousTab = lastRelatedTab || openerTab || this.selectedTab;
+ if (!previousTab.hidden) {
+ index = previousTab._tPos + 1;
+ } else if (previousTab == FirefoxViewHandler.tab) {
+ index = 0;
+ }
+
+ if (lastRelatedTab) {
+ lastRelatedTab.owner = null;
+ } else if (openerTab) {
+ tab.owner = openerTab;
+ }
+ // Always set related map if opener exists.
+ if (openerTab) {
+ this._lastRelatedTabMap.set(openerTab, tab);
+ }
+ }
+ }
+ // Ensure index is within bounds.
+ if (pinned) {
+ index = Math.max(index, 0);
+ index = Math.min(index, this._numPinnedTabs);
+ } else {
+ index = Math.max(index, this._numPinnedTabs);
+ index = Math.min(index, this.tabs.length);
+ }
+
+ let tabAfter = this.tabs[index] || null;
+ this.tabContainer._invalidateCachedTabs();
+ // Prevent a flash of unstyled content by setting up the tab content
+ // and inherited attributes before appending it (see Bug 1592054):
+ tab.initialize();
+ this.tabContainer.insertBefore(tab, tabAfter);
+ if (tabAfter) {
+ this._updateTabsAfterInsert();
+ } else {
+ tab._tPos = index;
+ }
+
+ if (pinned) {
+ this._updateTabBarForPinnedTabs();
+ }
+ this.tabContainer._setPositionalAttributes();
+
+ TabBarVisibility.update();
+ },
+
+ /**
+ * Dispatch a new tab event. This should be called when things are in a
+ * consistent state, such that listeners of this event can again open
+ * or close tabs.
+ */
+ _fireTabOpen(tab, eventDetail) {
+ delete tab.initializingTab;
+ let evt = new CustomEvent("TabOpen", {
+ bubbles: true,
+ detail: eventDetail || {},
+ });
+ tab.dispatchEvent(evt);
+ },
+
+ getTabsToTheStartFrom(aTab) {
+ let tabsToStart = [];
+ if (aTab.hidden) {
+ return tabsToStart;
+ }
+ let tabs = this.visibleTabs;
+ for (let i = 0; i < tabs.length; ++i) {
+ if (tabs[i] == aTab) {
+ break;
+ }
+ // Ignore pinned tabs.
+ if (tabs[i].pinned) {
+ continue;
+ }
+ // In a multi-select context, select all unselected tabs
+ // starting from the context tab.
+ if (aTab.multiselected && tabs[i].multiselected) {
+ continue;
+ }
+ tabsToStart.push(tabs[i]);
+ }
+ return tabsToStart;
+ },
+
+ getTabsToTheEndFrom(aTab) {
+ let tabsToEnd = [];
+ if (aTab.hidden) {
+ return tabsToEnd;
+ }
+ let tabs = this.visibleTabs;
+ for (let i = tabs.length - 1; i >= 0; --i) {
+ if (tabs[i] == aTab) {
+ break;
+ }
+ // Ignore pinned tabs.
+ if (tabs[i].pinned) {
+ continue;
+ }
+ // In a multi-select context, select all unselected tabs
+ // starting from the context tab.
+ if (aTab.multiselected && tabs[i].multiselected) {
+ continue;
+ }
+ tabsToEnd.push(tabs[i]);
+ }
+ return tabsToEnd;
+ },
+
+ /**
+ * In a multi-select context, the tabs (except pinned tabs) that are located to the
+ * left of the leftmost selected tab will be removed.
+ */
+ removeTabsToTheStartFrom(aTab) {
+ let tabs = this.getTabsToTheStartFrom(aTab);
+ if (
+ !this.warnAboutClosingTabs(tabs.length, this.closingTabsEnum.TO_START)
+ ) {
+ return;
+ }
+
+ this.removeTabs(tabs);
+ },
+
+ /**
+ * In a multi-select context, the tabs (except pinned tabs) that are located to the
+ * right of the rightmost selected tab will be removed.
+ */
+ removeTabsToTheEndFrom(aTab) {
+ let tabs = this.getTabsToTheEndFrom(aTab);
+ if (
+ !this.warnAboutClosingTabs(tabs.length, this.closingTabsEnum.TO_END)
+ ) {
+ return;
+ }
+
+ this.removeTabs(tabs);
+ },
+
+ /**
+ * In a multi-select context, all unpinned and unselected tabs are removed.
+ * Otherwise all unpinned tabs except aTab are removed.
+ *
+ * @param aTab
+ * The tab we will skip removing
+ * @param aParams
+ * An optional set of parameters that will be passed to the
+ * removeTabs function.
+ */
+ removeAllTabsBut(aTab, aParams) {
+ let tabsToRemove = [];
+ if (aTab && aTab.multiselected) {
+ tabsToRemove = this.visibleTabs.filter(
+ tab => !tab.multiselected && !tab.pinned
+ );
+ } else {
+ tabsToRemove = this.visibleTabs.filter(
+ tab => tab != aTab && !tab.pinned
+ );
+ }
+
+ if (
+ !this.warnAboutClosingTabs(
+ tabsToRemove.length,
+ this.closingTabsEnum.OTHER
+ )
+ ) {
+ return;
+ }
+
+ this.removeTabs(tabsToRemove, aParams);
+ },
+
+ removeMultiSelectedTabs() {
+ let selectedTabs = this.selectedTabs;
+ if (
+ !this.warnAboutClosingTabs(
+ selectedTabs.length,
+ this.closingTabsEnum.MULTI_SELECTED
+ )
+ ) {
+ return;
+ }
+
+ this.removeTabs(selectedTabs);
+ },
+
+ /**
+ * @typedef {object} _startRemoveTabsReturnValue
+ * @property {Promise} beforeUnloadComplete
+ * A promise that is resolved once all the beforeunload handlers have been
+ * called.
+ * @property {object[]} tabsWithBeforeUnloadPrompt
+ * An array of tabs with unload prompts that need to be handled.
+ * @property {object} [lastToClose]
+ * The last tab to be closed, if appropriate.
+ */
+
+ /**
+ * Starts to remove tabs from the UI: checking for beforeunload handlers,
+ * closing tabs where possible and triggering running of the unload handlers.
+ *
+ * @param {object[]} tabs
+ * The set of tabs to remove.
+ * @param {object} options
+ * @param {boolean} options.animate
+ * Whether or not to animate closing.
+ * @param {boolean} options.suppressWarnAboutClosingWindow
+ * This will supress the warning about closing a window with the last tab.
+ * @param {boolean} options.skipPermitUnload
+ * Skips the before unload checks for the tabs. Only set this to true when
+ * using it in tandem with `runBeforeUnloadForTabs`.
+ * @param {boolean} options.skipRemoves
+ * Skips actually removing the tabs. The beforeunload handlers still run.
+ * @returns {_startRemoveTabsReturnValue}
+ */
+ _startRemoveTabs(
+ tabs,
+ { animate, suppressWarnAboutClosingWindow, skipPermitUnload, skipRemoves }
+ ) {
+ // Note: if you change any of the unload algorithm, consider also
+ // changing `runBeforeUnloadForTabs` above.
+ let tabsWithBeforeUnloadPrompt = [];
+ let tabsWithoutBeforeUnload = [];
+ let beforeUnloadPromises = [];
+ let lastToClose;
+
+ for (let tab of tabs) {
+ if (!skipRemoves) {
+ tab._closedInGroup = true;
+ }
+ if (!skipRemoves && tab.selected) {
+ lastToClose = tab;
+ let toBlurTo = this._findTabToBlurTo(lastToClose, tabs);
+ if (toBlurTo) {
+ this._getSwitcher().warmupTab(toBlurTo);
+ }
+ } else if (!skipPermitUnload && this._hasBeforeUnload(tab)) {
+ TelemetryStopwatch.start("FX_TAB_CLOSE_PERMIT_UNLOAD_TIME_MS", tab);
+ // We need to block while calling permitUnload() because it
+ // processes the event queue and may lead to another removeTab()
+ // call before permitUnload() returns.
+ tab._pendingPermitUnload = true;
+ beforeUnloadPromises.push(
+ // To save time, we first run the beforeunload event listeners in all
+ // content processes in parallel. Tabs that would have shown a prompt
+ // will be handled again later.
+ tab.linkedBrowser.asyncPermitUnload("dontUnload").then(
+ ({ permitUnload }) => {
+ tab._pendingPermitUnload = false;
+ TelemetryStopwatch.finish(
+ "FX_TAB_CLOSE_PERMIT_UNLOAD_TIME_MS",
+ tab
+ );
+ if (tab.closing) {
+ // The tab was closed by the user while we were in permitUnload, don't
+ // attempt to close it a second time.
+ } else if (permitUnload) {
+ if (!skipRemoves) {
+ // OK to close without prompting, do it immediately.
+ this.removeTab(tab, {
+ animate,
+ prewarmed: true,
+ skipPermitUnload: true,
+ });
+ }
+ } else {
+ // We will need to prompt, queue it so it happens sequentially.
+ tabsWithBeforeUnloadPrompt.push(tab);
+ }
+ },
+ err => {
+ console.error("error while calling asyncPermitUnload", err);
+ tab._pendingPermitUnload = false;
+ TelemetryStopwatch.finish(
+ "FX_TAB_CLOSE_PERMIT_UNLOAD_TIME_MS",
+ tab
+ );
+ }
+ )
+ );
+ } else {
+ tabsWithoutBeforeUnload.push(tab);
+ }
+ }
+
+ // Now that all the beforeunload IPCs have been sent to content processes,
+ // we can queue unload messages for all the tabs without beforeunload listeners.
+ // Doing this first would cause content process main threads to be busy and delay
+ // beforeunload responses, which would be user-visible.
+ if (!skipRemoves) {
+ for (let tab of tabsWithoutBeforeUnload) {
+ this.removeTab(tab, {
+ animate,
+ prewarmed: true,
+ skipPermitUnload,
+ });
+ }
+ }
+
+ return {
+ beforeUnloadComplete: Promise.all(beforeUnloadPromises),
+ tabsWithBeforeUnloadPrompt,
+ lastToClose,
+ };
+ },
+
+ /**
+ * Runs the before unload handler for the provided tabs, waiting for them
+ * to complete.
+ *
+ * This can be used in tandem with removeTabs to allow any before unload
+ * prompts to happen before any tab closures. This should only be used
+ * in the case where any prompts need to happen before other items before
+ * the actual tabs are closed.
+ *
+ * When using this function alongside removeTabs, specify the `skipUnload`
+ * option to removeTabs.
+ *
+ * @param {object[]} tabs
+ * An array of tabs to remove.
+ * @returns {Promise<boolean>}
+ * Returns true if the unload has been blocked by the user. False if tabs
+ * may be subsequently closed.
+ */
+ async runBeforeUnloadForTabs(tabs) {
+ try {
+ let { beforeUnloadComplete, tabsWithBeforeUnloadPrompt } =
+ this._startRemoveTabs(tabs, {
+ animate: false,
+ suppressWarnAboutClosingWindow: false,
+ skipPermitUnload: false,
+ skipRemoves: true,
+ });
+
+ await beforeUnloadComplete;
+
+ // Now run again sequentially the beforeunload listeners that will result in a prompt.
+ for (let tab of tabsWithBeforeUnloadPrompt) {
+ tab._pendingPermitUnload = true;
+ let { permitUnload } = this.getBrowserForTab(tab).permitUnload();
+ tab._pendingPermitUnload = false;
+ if (!permitUnload) {
+ return true;
+ }
+ }
+ } catch (e) {
+ console.error(e);
+ }
+ return false;
+ },
+
+ /**
+ * Removes multiple tabs from the tab browser.
+ *
+ * @param {object[]} tabs
+ * The set of tabs to remove.
+ * @param {object} [options]
+ * @param {boolean} [options.animate]
+ * Whether or not to animate closing, defaults to true.
+ * @param {boolean} [options.suppressWarnAboutClosingWindow]
+ * This will supress the warning about closing a window with the last tab.
+ * @param {boolean} [options.skipPermitUnload]
+ * Skips the before unload checks for the tabs. Only set this to true when
+ * using it in tandem with `runBeforeUnloadForTabs`.
+ */
+ removeTabs(
+ tabs,
+ {
+ animate = true,
+ suppressWarnAboutClosingWindow = false,
+ skipPermitUnload = false,
+ } = {}
+ ) {
+ // When 'closeWindowWithLastTab' pref is enabled, closing all tabs
+ // can be considered equivalent to closing the window.
+ if (
+ this.tabs.length == tabs.length &&
+ Services.prefs.getBoolPref("browser.tabs.closeWindowWithLastTab")
+ ) {
+ window.closeWindow(
+ true,
+ suppressWarnAboutClosingWindow ? null : window.warnAboutClosingWindow,
+ "close-last-tab"
+ );
+ return;
+ }
+
+ SessionStore.resetLastClosedTabCount(window);
+ this._clearMultiSelectionLocked = true;
+
+ // Guarantee that _clearMultiSelectionLocked lock gets released.
+ try {
+ let { beforeUnloadComplete, tabsWithBeforeUnloadPrompt, lastToClose } =
+ this._startRemoveTabs(tabs, {
+ animate,
+ suppressWarnAboutClosingWindow,
+ skipPermitUnload,
+ skipRemoves: false,
+ });
+
+ // Wait for all the beforeunload events to have been processed by content processes.
+ // The permitUnload() promise will, alas, not call its resolution
+ // callbacks after the browser window the promise lives in has closed,
+ // so we have to check for that case explicitly.
+ let done = false;
+ beforeUnloadComplete.then(() => {
+ done = true;
+ });
+ Services.tm.spinEventLoopUntilOrQuit(
+ "tabbrowser.js:removeTabs",
+ () => done || window.closed
+ );
+ if (!done) {
+ return;
+ }
+
+ let aParams = {
+ animate,
+ prewarmed: true,
+ skipPermitUnload,
+ };
+
+ // Now run again sequentially the beforeunload listeners that will result in a prompt.
+ for (let tab of tabsWithBeforeUnloadPrompt) {
+ this.removeTab(tab, aParams);
+ if (!tab.closing) {
+ // If we abort the closing of the tab.
+ tab._closedInGroup = false;
+ }
+ }
+
+ // Avoid changing the selected browser several times by removing it,
+ // if appropriate, lastly.
+ if (lastToClose) {
+ this.removeTab(lastToClose, aParams);
+ }
+ } catch (e) {
+ console.error(e);
+ }
+
+ this._clearMultiSelectionLocked = false;
+ this._avoidSingleSelectedTab();
+ // Don't use document.l10n.setAttributes because the FTL file is loaded
+ // lazily and we won't be able to resolve the string.
+ document.getElementById("History:UndoCloseTab").setAttribute(
+ "data-l10n-args",
+ JSON.stringify({
+ tabCount: SessionStore.getLastClosedTabCount(window),
+ })
+ );
+ },
+
+ removeCurrentTab(aParams) {
+ this.removeTab(this.selectedTab, aParams);
+ },
+
+ removeTab(
+ aTab,
+ {
+ animate,
+ triggeringEvent,
+ skipPermitUnload,
+ closeWindowWithLastTab,
+ prewarmed,
+ } = {}
+ ) {
+ if (UserInteraction.running("browser.tabs.opening", window)) {
+ UserInteraction.finish("browser.tabs.opening", window);
+ }
+
+ // Telemetry stopwatches may already be running if removeTab gets
+ // called again for an already closing tab.
+ if (
+ !TelemetryStopwatch.running("FX_TAB_CLOSE_TIME_ANIM_MS", aTab) &&
+ !TelemetryStopwatch.running("FX_TAB_CLOSE_TIME_NO_ANIM_MS", aTab)
+ ) {
+ // Speculatevely start both stopwatches now. We'll cancel one of
+ // the two later depending on whether we're animating.
+ TelemetryStopwatch.start("FX_TAB_CLOSE_TIME_ANIM_MS", aTab);
+ TelemetryStopwatch.start("FX_TAB_CLOSE_TIME_NO_ANIM_MS", aTab);
+ }
+
+ // Handle requests for synchronously removing an already
+ // asynchronously closing tab.
+ if (!animate && aTab.closing) {
+ this._endRemoveTab(aTab);
+ return;
+ }
+
+ let isLastTab = !aTab.hidden && this.visibleTabs.length == 1;
+ // We have to sample the tab width now, since _beginRemoveTab might
+ // end up modifying the DOM in such a way that aTab gets a new
+ // frame created for it (for example, by updating the visually selected
+ // state).
+ let tabWidth = window.windowUtils.getBoundsWithoutFlushing(aTab).width;
+
+ if (
+ !this._beginRemoveTab(aTab, {
+ closeWindowFastpath: true,
+ skipPermitUnload,
+ closeWindowWithLastTab,
+ prewarmed,
+ })
+ ) {
+ TelemetryStopwatch.cancel("FX_TAB_CLOSE_TIME_ANIM_MS", aTab);
+ TelemetryStopwatch.cancel("FX_TAB_CLOSE_TIME_NO_ANIM_MS", aTab);
+ return;
+ }
+
+ let lockTabSizing =
+ !aTab.pinned &&
+ !aTab.hidden &&
+ aTab._fullyOpen &&
+ triggeringEvent?.mozInputSource == MouseEvent.MOZ_SOURCE_MOUSE &&
+ triggeringEvent?.target.closest(".tabbrowser-tab");
+ if (lockTabSizing) {
+ this.tabContainer._lockTabSizing(aTab, tabWidth);
+ } else {
+ this.tabContainer._unlockTabSizing();
+ }
+
+ if (
+ !animate /* the caller didn't opt in */ ||
+ gReduceMotion ||
+ isLastTab ||
+ aTab.pinned ||
+ aTab.hidden ||
+ this._removingTabs.size >
+ 3 /* don't want lots of concurrent animations */ ||
+ aTab.getAttribute("fadein") !=
+ "true" /* fade-in transition hasn't been triggered yet */ ||
+ tabWidth == 0 /* fade-in transition hasn't moved yet */
+ ) {
+ // We're not animating, so we can cancel the animation stopwatch.
+ TelemetryStopwatch.cancel("FX_TAB_CLOSE_TIME_ANIM_MS", aTab);
+ this._endRemoveTab(aTab);
+ return;
+ }
+
+ // We're animating, so we can cancel the non-animation stopwatch.
+ TelemetryStopwatch.cancel("FX_TAB_CLOSE_TIME_NO_ANIM_MS", aTab);
+
+ aTab.style.maxWidth = ""; // ensure that fade-out transition happens
+ aTab.removeAttribute("fadein");
+ aTab.removeAttribute("bursting");
+
+ setTimeout(
+ function (tab, tabbrowser) {
+ if (
+ tab.container &&
+ window.getComputedStyle(tab).maxWidth == "0.1px"
+ ) {
+ console.assert(
+ false,
+ "Giving up waiting for the tab closing animation to finish (bug 608589)"
+ );
+ tabbrowser._endRemoveTab(tab);
+ }
+ },
+ 3000,
+ aTab,
+ this
+ );
+ },
+
+ _hasBeforeUnload(aTab) {
+ let browser = aTab.linkedBrowser;
+ if (browser.isRemoteBrowser && browser.frameLoader) {
+ return browser.hasBeforeUnload;
+ }
+ return false;
+ },
+
+ _beginRemoveTab(
+ aTab,
+ {
+ adoptedByTab,
+ closeWindowWithLastTab,
+ closeWindowFastpath,
+ skipPermitUnload,
+ prewarmed,
+ } = {}
+ ) {
+ if (aTab.closing || this._windowIsClosing) {
+ return false;
+ }
+
+ var browser = this.getBrowserForTab(aTab);
+ if (
+ !skipPermitUnload &&
+ !adoptedByTab &&
+ aTab.linkedPanel &&
+ !aTab._pendingPermitUnload &&
+ (!browser.isRemoteBrowser || this._hasBeforeUnload(aTab))
+ ) {
+ if (!prewarmed) {
+ let blurTab = this._findTabToBlurTo(aTab);
+ if (blurTab) {
+ this.warmupTab(blurTab);
+ }
+ }
+
+ TelemetryStopwatch.start("FX_TAB_CLOSE_PERMIT_UNLOAD_TIME_MS", aTab);
+
+ // We need to block while calling permitUnload() because it
+ // processes the event queue and may lead to another removeTab()
+ // call before permitUnload() returns.
+ aTab._pendingPermitUnload = true;
+ let { permitUnload } = browser.permitUnload();
+ aTab._pendingPermitUnload = false;
+
+ TelemetryStopwatch.finish("FX_TAB_CLOSE_PERMIT_UNLOAD_TIME_MS", aTab);
+
+ // If we were closed during onbeforeunload, we return false now
+ // so we don't (try to) close the same tab again. Of course, we
+ // also stop if the unload was cancelled by the user:
+ if (aTab.closing || !permitUnload) {
+ return false;
+ }
+ }
+
+ // this._switcher would normally cover removing a tab from this
+ // cache, but we may not have one at this time.
+ let tabCacheIndex = this._tabLayerCache.indexOf(aTab);
+ if (tabCacheIndex != -1) {
+ this._tabLayerCache.splice(tabCacheIndex, 1);
+ }
+
+ // Delay hiding the the active tab if we're screen sharing.
+ // See Bug 1642747.
+ let screenShareInActiveTab =
+ aTab == this.selectedTab && aTab._sharingState?.webRTC?.screen;
+
+ if (!screenShareInActiveTab) {
+ this._blurTab(aTab);
+ }
+
+ var closeWindow = false;
+ var newTab = false;
+ if (!aTab.hidden && this.visibleTabs.length == 1) {
+ closeWindow =
+ closeWindowWithLastTab != null
+ ? closeWindowWithLastTab
+ : !window.toolbar.visible ||
+ Services.prefs.getBoolPref("browser.tabs.closeWindowWithLastTab");
+
+ if (closeWindow) {
+ // We've already called beforeunload on all the relevant tabs if we get here,
+ // so avoid calling it again:
+ window.skipNextCanClose = true;
+ }
+
+ // Closing the tab and replacing it with a blank one is notably slower
+ // than closing the window right away. If the caller opts in, take
+ // the fast path.
+ if (closeWindow && closeWindowFastpath && !this._removingTabs.size) {
+ // This call actually closes the window, unless the user
+ // cancels the operation. We are finished here in both cases.
+ this._windowIsClosing = window.closeWindow(
+ true,
+ window.warnAboutClosingWindow,
+ "close-last-tab"
+ );
+ return false;
+ }
+
+ newTab = true;
+ }
+ aTab._endRemoveArgs = [closeWindow, newTab];
+
+ // swapBrowsersAndCloseOther will take care of closing the window without animation.
+ if (closeWindow && adoptedByTab) {
+ // Remove the tab's filter and progress listener to avoid leaking.
+ if (aTab.linkedPanel) {
+ const filter = this._tabFilters.get(aTab);
+ browser.webProgress.removeProgressListener(filter);
+ const listener = this._tabListeners.get(aTab);
+ filter.removeProgressListener(listener);
+ listener.destroy();
+ this._tabListeners.delete(aTab);
+ this._tabFilters.delete(aTab);
+ }
+ return true;
+ }
+
+ if (!aTab._fullyOpen) {
+ // If the opening tab animation hasn't finished before we start closing the
+ // tab, decrement the animation count since _handleNewTab will not get called.
+ this.tabAnimationsInProgress--;
+ }
+
+ this.tabAnimationsInProgress++;
+
+ // Mute audio immediately to improve perceived speed of tab closure.
+ if (!adoptedByTab && aTab.hasAttribute("soundplaying")) {
+ // Don't persist the muted state as this wasn't a user action.
+ // This lets undo-close-tab return it to an unmuted state.
+ aTab.linkedBrowser.mute(true);
+ }
+
+ aTab.closing = true;
+ this._removingTabs.add(aTab);
+ this.tabContainer._invalidateCachedTabs();
+
+ // Invalidate hovered tab state tracking for this closing tab.
+ aTab._mouseleave();
+
+ if (newTab) {
+ this.addTrustedTab(BROWSER_NEW_TAB_URL, {
+ skipAnimation: true,
+ });
+ } else {
+ TabBarVisibility.update();
+ }
+
+ // Splice this tab out of any lines of succession before any events are
+ // dispatched.
+ this.replaceInSuccession(aTab, aTab.successor);
+ this.setSuccessor(aTab, null);
+
+ // We're committed to closing the tab now.
+ // Dispatch a notification.
+ // We dispatch it before any teardown so that event listeners can
+ // inspect the tab that's about to close.
+ let evt = new CustomEvent("TabClose", {
+ bubbles: true,
+ detail: { adoptedBy: adoptedByTab },
+ });
+ aTab.dispatchEvent(evt);
+
+ if (this.tabs.length == 2) {
+ // We're closing one of our two open tabs, inform the other tab that its
+ // sibling is going away.
+ for (let tab of this.tabs) {
+ let bc = tab.linkedBrowser.browsingContext;
+ if (bc) {
+ bc.hasSiblings = false;
+ }
+ }
+ }
+
+ let notificationBox = this.readNotificationBox(browser);
+ notificationBox?._stack?.remove();
+
+ if (aTab.linkedPanel) {
+ if (!adoptedByTab && !gMultiProcessBrowser) {
+ // Prevent this tab from showing further dialogs, since we're closing it
+ browser.contentWindow.windowUtils.disableDialogs();
+ }
+
+ // Remove the tab's filter and progress listener.
+ const filter = this._tabFilters.get(aTab);
+
+ browser.webProgress.removeProgressListener(filter);
+
+ const listener = this._tabListeners.get(aTab);
+ filter.removeProgressListener(listener);
+ listener.destroy();
+ }
+
+ if (browser.registeredOpenURI && !adoptedByTab) {
+ let userContextId = browser.getAttribute("usercontextid") || 0;
+ this.UrlbarProviderOpenTabs.unregisterOpenTab(
+ browser.registeredOpenURI.spec,
+ userContextId,
+ PrivateBrowsingUtils.isWindowPrivate(window)
+ );
+ delete browser.registeredOpenURI;
+ }
+
+ // We are no longer the primary content area.
+ browser.removeAttribute("primary");
+
+ // Remove this tab as the owner of any other tabs, since it's going away.
+ for (let tab of this.tabs) {
+ if ("owner" in tab && tab.owner == aTab) {
+ // |tab| is a child of the tab we're removing, make it an orphan
+ tab.owner = null;
+ }
+ }
+
+ return true;
+ },
+
+ _endRemoveTab(aTab) {
+ if (!aTab || !aTab._endRemoveArgs) {
+ return;
+ }
+
+ var [aCloseWindow, aNewTab] = aTab._endRemoveArgs;
+ aTab._endRemoveArgs = null;
+
+ if (this._windowIsClosing) {
+ aCloseWindow = false;
+ aNewTab = false;
+ }
+
+ this.tabAnimationsInProgress--;
+
+ this._lastRelatedTabMap = new WeakMap();
+
+ // update the UI early for responsiveness
+ aTab.collapsed = true;
+ this._blurTab(aTab);
+
+ this._removingTabs.delete(aTab);
+
+ if (aCloseWindow) {
+ this._windowIsClosing = true;
+ for (let tab of this._removingTabs) {
+ this._endRemoveTab(tab);
+ }
+ } else if (!this._windowIsClosing) {
+ if (aNewTab) {
+ gURLBar.select();
+ }
+
+ // workaround for bug 345399
+ this.tabContainer.arrowScrollbox._updateScrollButtonsDisabledState();
+ }
+
+ // We're going to remove the tab and the browser now.
+ this._tabFilters.delete(aTab);
+ this._tabListeners.delete(aTab);
+
+ var browser = this.getBrowserForTab(aTab);
+
+ if (aTab.linkedPanel) {
+ // Because of the fact that we are setting JS properties on
+ // the browser elements, and we have code in place
+ // to preserve the JS objects for any elements that have
+ // JS properties set on them, the browser element won't be
+ // destroyed until the document goes away. So we force a
+ // cleanup ourselves.
+ // This has to happen before we remove the child since functions
+ // like `getBrowserContainer` expect the browser to be parented.
+ browser.destroy();
+ }
+
+ var wasPinned = aTab.pinned;
+
+ // Remove the tab ...
+ aTab.remove();
+ this.tabContainer._invalidateCachedTabs();
+
+ // Update hashiddentabs if this tab was hidden.
+ if (aTab.hidden) {
+ this.tabContainer._updateHiddenTabsStatus();
+ }
+
+ // ... and fix up the _tPos properties immediately.
+ for (let i = aTab._tPos; i < this.tabs.length; i++) {
+ this.tabs[i]._tPos = i;
+ }
+
+ if (!this._windowIsClosing) {
+ if (wasPinned) {
+ this.tabContainer._positionPinnedTabs();
+ }
+
+ // update tab close buttons state
+ this.tabContainer._updateCloseButtons();
+
+ setTimeout(
+ function (tabs) {
+ tabs._lastTabClosedByMouse = false;
+ },
+ 0,
+ this.tabContainer
+ );
+ }
+
+ // update tab positional properties and attributes
+ this.selectedTab._selected = true;
+ this.tabContainer._setPositionalAttributes();
+
+ // Removing the panel requires fixing up selectedPanel immediately
+ // (see below), which would be hindered by the potentially expensive
+ // browser removal. So we remove the browser and the panel in two
+ // steps.
+
+ var panel = this.getPanel(browser);
+
+ // In the multi-process case, it's possible an asynchronous tab switch
+ // is still underway. If so, then it's possible that the last visible
+ // browser is the one we're in the process of removing. There's the
+ // risk of displaying preloaded browsers that are at the end of the
+ // deck if we remove the browser before the switch is complete, so
+ // we alert the switcher in order to show a spinner instead.
+ if (this._switcher) {
+ this._switcher.onTabRemoved(aTab);
+ }
+
+ // This will unload the document. An unload handler could remove
+ // dependant tabs, so it's important that the tabbrowser is now in
+ // a consistent state (tab removed, tab positions updated, etc.).
+ browser.remove();
+
+ // Release the browser in case something is erroneously holding a
+ // reference to the tab after its removal.
+ this._tabForBrowser.delete(aTab.linkedBrowser);
+ aTab.linkedBrowser = null;
+
+ panel.remove();
+
+ // closeWindow might wait an arbitrary length of time if we're supposed
+ // to warn about closing the window, so we'll just stop the tab close
+ // stopwatches here instead.
+ TelemetryStopwatch.finish(
+ "FX_TAB_CLOSE_TIME_ANIM_MS",
+ aTab,
+ true /* aCanceledOkay */
+ );
+ TelemetryStopwatch.finish(
+ "FX_TAB_CLOSE_TIME_NO_ANIM_MS",
+ aTab,
+ true /* aCanceledOkay */
+ );
+
+ if (aCloseWindow) {
+ this._windowIsClosing = closeWindow(
+ true,
+ window.warnAboutClosingWindow,
+ "close-last-tab"
+ );
+ }
+ },
+
+ /**
+ * Handles opening a new tab with mouse middleclick.
+ * @param node
+ * @param event
+ * The click event
+ */
+ handleNewTabMiddleClick(node, event) {
+ // We should be using the disabled property here instead of the attribute,
+ // but some elements that this function is used with don't support it (e.g.
+ // menuitem).
+ if (node.getAttribute("disabled") == "true") {
+ return;
+ } // Do nothing
+
+ if (event.button == 1) {
+ BrowserOpenTab({ event });
+ // Stop the propagation of the click event, to prevent the event from being
+ // handled more than once.
+ // E.g. see https://bugzilla.mozilla.org/show_bug.cgi?id=1657992#c4
+ event.stopPropagation();
+ event.preventDefault();
+ }
+ },
+
+ /**
+ * Finds the tab that we will blur to if we blur aTab.
+ * @param aTab
+ * The tab we would blur
+ * @param aExcludeTabs
+ * Tabs to exclude from our search (i.e., because they are being
+ * closed along with aTab)
+ */
+ _findTabToBlurTo(aTab, aExcludeTabs = []) {
+ if (!aTab.selected) {
+ return null;
+ }
+ if (FirefoxViewHandler.tab) {
+ aExcludeTabs.push(FirefoxViewHandler.tab);
+ }
+
+ let excludeTabs = new Set(aExcludeTabs);
+
+ // If this tab has a successor, it should be selectable, since
+ // hiding or closing a tab removes that tab as a successor.
+ if (aTab.successor && !excludeTabs.has(aTab.successor)) {
+ return aTab.successor;
+ }
+
+ if (
+ aTab.owner &&
+ !aTab.owner.hidden &&
+ !aTab.owner.closing &&
+ !excludeTabs.has(aTab.owner) &&
+ Services.prefs.getBoolPref("browser.tabs.selectOwnerOnClose")
+ ) {
+ return aTab.owner;
+ }
+
+ // Switch to a visible tab unless there aren't any others remaining
+ let remainingTabs = this.visibleTabs;
+ let numTabs = remainingTabs.length;
+ if (numTabs == 0 || (numTabs == 1 && remainingTabs[0] == aTab)) {
+ remainingTabs = Array.prototype.filter.call(
+ this.tabs,
+ tab => !tab.closing && !excludeTabs.has(tab)
+ );
+ }
+
+ // Try to find a remaining tab that comes after the given tab
+ let tab = this.tabContainer.findNextTab(aTab, {
+ direction: 1,
+ filter: _tab => remainingTabs.includes(_tab),
+ });
+
+ if (!tab) {
+ tab = this.tabContainer.findNextTab(aTab, {
+ direction: -1,
+ filter: _tab => remainingTabs.includes(_tab),
+ });
+ }
+
+ return tab;
+ },
+
+ _blurTab(aTab) {
+ this.selectedTab = this._findTabToBlurTo(aTab);
+ },
+
+ /**
+ * @returns {boolean}
+ * False if swapping isn't permitted, true otherwise.
+ */
+ swapBrowsersAndCloseOther(aOurTab, aOtherTab) {
+ // Do not allow transfering a private tab to a non-private window
+ // and vice versa.
+ if (
+ PrivateBrowsingUtils.isWindowPrivate(window) !=
+ PrivateBrowsingUtils.isWindowPrivate(aOtherTab.ownerGlobal)
+ ) {
+ return false;
+ }
+
+ // Do not allow transfering a useRemoteSubframes tab to a
+ // non-useRemoteSubframes window and vice versa.
+ if (gFissionBrowser != aOtherTab.ownerGlobal.gFissionBrowser) {
+ return false;
+ }
+
+ let ourBrowser = this.getBrowserForTab(aOurTab);
+ let otherBrowser = aOtherTab.linkedBrowser;
+
+ // Can't swap between chrome and content processes.
+ if (ourBrowser.isRemoteBrowser != otherBrowser.isRemoteBrowser) {
+ return false;
+ }
+
+ // Keep the userContextId if set on other browser
+ if (otherBrowser.hasAttribute("usercontextid")) {
+ ourBrowser.setAttribute(
+ "usercontextid",
+ otherBrowser.getAttribute("usercontextid")
+ );
+ }
+
+ // That's gBrowser for the other window, not the tab's browser!
+ var remoteBrowser = aOtherTab.ownerGlobal.gBrowser;
+ var isPending = aOtherTab.hasAttribute("pending");
+
+ let otherTabListener = remoteBrowser._tabListeners.get(aOtherTab);
+ let stateFlags = 0;
+ if (otherTabListener) {
+ stateFlags = otherTabListener.mStateFlags;
+ }
+
+ // Expedite the removal of the icon if it was already scheduled.
+ if (aOtherTab._soundPlayingAttrRemovalTimer) {
+ clearTimeout(aOtherTab._soundPlayingAttrRemovalTimer);
+ aOtherTab._soundPlayingAttrRemovalTimer = 0;
+ aOtherTab.removeAttribute("soundplaying");
+ remoteBrowser._tabAttrModified(aOtherTab, ["soundplaying"]);
+ }
+
+ // First, start teardown of the other browser. Make sure to not
+ // fire the beforeunload event in the process. Close the other
+ // window if this was its last tab.
+ if (
+ !remoteBrowser._beginRemoveTab(aOtherTab, {
+ adoptedByTab: aOurTab,
+ closeWindowWithLastTab: true,
+ })
+ ) {
+ return false;
+ }
+
+ // If this is the last tab of the window, hide the window
+ // immediately without animation before the docshell swap, to avoid
+ // about:blank being painted.
+ let [closeWindow] = aOtherTab._endRemoveArgs;
+ if (closeWindow) {
+ let win = aOtherTab.ownerGlobal;
+ win.windowUtils.suppressAnimation(true);
+ // Only suppressing window animations isn't enough to avoid
+ // an empty content area being painted.
+ let baseWin = win.docShell.treeOwner.QueryInterface(Ci.nsIBaseWindow);
+ baseWin.visibility = false;
+ }
+
+ let modifiedAttrs = [];
+ if (aOtherTab.hasAttribute("muted")) {
+ aOurTab.setAttribute("muted", "true");
+ aOurTab.muteReason = aOtherTab.muteReason;
+ // For non-lazy tabs, mute() must be called.
+ if (aOurTab.linkedPanel) {
+ ourBrowser.mute();
+ }
+ modifiedAttrs.push("muted");
+ }
+ if (aOtherTab.hasAttribute("soundplaying")) {
+ aOurTab.setAttribute("soundplaying", "true");
+ modifiedAttrs.push("soundplaying");
+ }
+ if (aOtherTab.hasAttribute("usercontextid")) {
+ aOurTab.setUserContextId(aOtherTab.getAttribute("usercontextid"));
+ modifiedAttrs.push("usercontextid");
+ }
+ if (aOtherTab.hasAttribute("sharing")) {
+ aOurTab.setAttribute("sharing", aOtherTab.getAttribute("sharing"));
+ modifiedAttrs.push("sharing");
+ aOurTab._sharingState = aOtherTab._sharingState;
+ webrtcUI.swapBrowserForNotification(otherBrowser, ourBrowser);
+ }
+ if (aOtherTab.hasAttribute("pictureinpicture")) {
+ aOurTab.setAttribute("pictureinpicture", true);
+ modifiedAttrs.push("pictureinpicture");
+
+ let event = new CustomEvent("TabSwapPictureInPicture", {
+ detail: aOurTab,
+ });
+ aOtherTab.dispatchEvent(event);
+ }
+
+ SitePermissions.copyTemporaryPermissions(otherBrowser, ourBrowser);
+
+ // If the other tab is pending (i.e. has not been restored, yet)
+ // then do not switch docShells but retrieve the other tab's state
+ // and apply it to our tab.
+ if (isPending) {
+ // Tag tab so that the extension framework can ignore tab events that
+ // are triggered amidst the tab/browser restoration process
+ // (TabHide, TabPinned, TabUnpinned, "muted" attribute changes, etc.).
+ aOurTab.initializingTab = true;
+ delete ourBrowser._cachedCurrentURI;
+ SessionStore.setTabState(aOurTab, SessionStore.getTabState(aOtherTab));
+ delete aOurTab.initializingTab;
+
+ // Make sure to unregister any open URIs.
+ this._swapRegisteredOpenURIs(ourBrowser, otherBrowser);
+ } else {
+ // Workarounds for bug 458697
+ // Icon might have been set on DOMLinkAdded, don't override that.
+ if (!ourBrowser.mIconURL && otherBrowser.mIconURL) {
+ this.setIcon(aOurTab, otherBrowser.mIconURL);
+ }
+ var isBusy = aOtherTab.hasAttribute("busy");
+ if (isBusy) {
+ aOurTab.setAttribute("busy", "true");
+ modifiedAttrs.push("busy");
+ if (aOurTab.selected) {
+ this._isBusy = true;
+ }
+ }
+
+ this._swapBrowserDocShells(aOurTab, otherBrowser, stateFlags);
+ }
+
+ // Unregister the previously opened URI
+ if (otherBrowser.registeredOpenURI) {
+ let userContextId = otherBrowser.getAttribute("usercontextid") || 0;
+ this.UrlbarProviderOpenTabs.unregisterOpenTab(
+ otherBrowser.registeredOpenURI.spec,
+ userContextId,
+ PrivateBrowsingUtils.isWindowPrivate(window)
+ );
+ delete otherBrowser.registeredOpenURI;
+ }
+
+ // Handle findbar data (if any)
+ let otherFindBar = aOtherTab._findBar;
+ if (otherFindBar && otherFindBar.findMode == otherFindBar.FIND_NORMAL) {
+ let oldValue = otherFindBar._findField.value;
+ let wasHidden = otherFindBar.hidden;
+ let ourFindBarPromise = this.getFindBar(aOurTab);
+ ourFindBarPromise.then(ourFindBar => {
+ if (!ourFindBar) {
+ return;
+ }
+ ourFindBar._findField.value = oldValue;
+ if (!wasHidden) {
+ ourFindBar.onFindCommand();
+ }
+ });
+ }
+
+ // Finish tearing down the tab that's going away.
+ if (closeWindow) {
+ aOtherTab.ownerGlobal.close();
+ } else {
+ remoteBrowser._endRemoveTab(aOtherTab);
+ }
+
+ this.setTabTitle(aOurTab);
+
+ // If the tab was already selected (this happens in the scenario
+ // of replaceTabWithWindow), notify onLocationChange, etc.
+ if (aOurTab.selected) {
+ this.updateCurrentBrowser(true);
+ }
+
+ if (modifiedAttrs.length) {
+ this._tabAttrModified(aOurTab, modifiedAttrs);
+ }
+
+ return true;
+ },
+
+ swapBrowsers(aOurTab, aOtherTab) {
+ let otherBrowser = aOtherTab.linkedBrowser;
+ let otherTabBrowser = otherBrowser.getTabBrowser();
+
+ // We aren't closing the other tab so, we also need to swap its tablisteners.
+ let filter = otherTabBrowser._tabFilters.get(aOtherTab);
+ let tabListener = otherTabBrowser._tabListeners.get(aOtherTab);
+ otherBrowser.webProgress.removeProgressListener(filter);
+ filter.removeProgressListener(tabListener);
+
+ // Perform the docshell swap through the common mechanism.
+ this._swapBrowserDocShells(aOurTab, otherBrowser);
+
+ // Restore the listeners for the swapped in tab.
+ tabListener = new otherTabBrowser.ownerGlobal.TabProgressListener(
+ aOtherTab,
+ otherBrowser,
+ false,
+ false
+ );
+ otherTabBrowser._tabListeners.set(aOtherTab, tabListener);
+
+ const notifyAll = Ci.nsIWebProgress.NOTIFY_ALL;
+ filter.addProgressListener(tabListener, notifyAll);
+ otherBrowser.webProgress.addProgressListener(filter, notifyAll);
+ },
+
+ _swapBrowserDocShells(aOurTab, aOtherBrowser, aStateFlags) {
+ // aOurTab's browser needs to be inserted now if it hasn't already.
+ this._insertBrowser(aOurTab);
+
+ // Unhook our progress listener
+ const filter = this._tabFilters.get(aOurTab);
+ let tabListener = this._tabListeners.get(aOurTab);
+ let ourBrowser = this.getBrowserForTab(aOurTab);
+ ourBrowser.webProgress.removeProgressListener(filter);
+ filter.removeProgressListener(tabListener);
+
+ // Make sure to unregister any open URIs.
+ this._swapRegisteredOpenURIs(ourBrowser, aOtherBrowser);
+
+ let remoteBrowser = aOtherBrowser.ownerGlobal.gBrowser;
+
+ // If switcher is active, it will intercept swap events and
+ // react as needed.
+ if (!this._switcher) {
+ aOtherBrowser.docShellIsActive =
+ this.shouldActivateDocShell(ourBrowser);
+ }
+
+ // Swap the docshells
+ ourBrowser.swapDocShells(aOtherBrowser);
+
+ // Swap permanentKey properties.
+ let ourPermanentKey = ourBrowser.permanentKey;
+ ourBrowser.permanentKey = aOtherBrowser.permanentKey;
+ aOtherBrowser.permanentKey = ourPermanentKey;
+ aOurTab.permanentKey = ourBrowser.permanentKey;
+ if (remoteBrowser) {
+ let otherTab = remoteBrowser.getTabForBrowser(aOtherBrowser);
+ if (otherTab) {
+ otherTab.permanentKey = aOtherBrowser.permanentKey;
+ }
+ }
+
+ // Restore the progress listener
+ tabListener = new TabProgressListener(
+ aOurTab,
+ ourBrowser,
+ false,
+ false,
+ aStateFlags
+ );
+ this._tabListeners.set(aOurTab, tabListener);
+
+ const notifyAll = Ci.nsIWebProgress.NOTIFY_ALL;
+ filter.addProgressListener(tabListener, notifyAll);
+ ourBrowser.webProgress.addProgressListener(filter, notifyAll);
+ },
+
+ _swapRegisteredOpenURIs(aOurBrowser, aOtherBrowser) {
+ // Swap the registeredOpenURI properties of the two browsers
+ let tmp = aOurBrowser.registeredOpenURI;
+ delete aOurBrowser.registeredOpenURI;
+ if (aOtherBrowser.registeredOpenURI) {
+ aOurBrowser.registeredOpenURI = aOtherBrowser.registeredOpenURI;
+ delete aOtherBrowser.registeredOpenURI;
+ }
+ if (tmp) {
+ aOtherBrowser.registeredOpenURI = tmp;
+ }
+ },
+
+ reloadMultiSelectedTabs() {
+ this.reloadTabs(this.selectedTabs);
+ },
+
+ reloadTabs(tabs) {
+ for (let tab of tabs) {
+ try {
+ this.getBrowserForTab(tab).reload();
+ } catch (e) {
+ // ignore failure to reload so others will be reloaded
+ }
+ }
+ },
+
+ reloadTab(aTab) {
+ let browser = this.getBrowserForTab(aTab);
+ // Reset temporary permissions on the current tab. This is done here
+ // because we only want to reset permissions on user reload.
+ SitePermissions.clearTemporaryBlockPermissions(browser);
+ // Also reset DOS mitigations for the basic auth prompt on reload.
+ delete browser.authPromptAbuseCounter;
+ gIdentityHandler.hidePopup();
+ gPermissionPanel.hidePopup();
+ browser.reload();
+ },
+
+ addProgressListener(aListener) {
+ if (arguments.length != 1) {
+ console.error(
+ "gBrowser.addProgressListener was " +
+ "called with a second argument, " +
+ "which is not supported. See bug " +
+ "608628. Call stack: ",
+ new Error().stack
+ );
+ }
+
+ this.mProgressListeners.push(aListener);
+ },
+
+ removeProgressListener(aListener) {
+ this.mProgressListeners = this.mProgressListeners.filter(
+ l => l != aListener
+ );
+ },
+
+ addTabsProgressListener(aListener) {
+ this.mTabsProgressListeners.push(aListener);
+ },
+
+ removeTabsProgressListener(aListener) {
+ this.mTabsProgressListeners = this.mTabsProgressListeners.filter(
+ l => l != aListener
+ );
+ },
+
+ getBrowserForTab(aTab) {
+ return aTab.linkedBrowser;
+ },
+
+ showOnlyTheseTabs(aTabs) {
+ for (let tab of this.tabs) {
+ if (!aTabs.includes(tab)) {
+ this.hideTab(tab);
+ } else {
+ this.showTab(tab);
+ }
+ }
+
+ this.tabContainer._updateHiddenTabsStatus();
+ this.tabContainer._handleTabSelect(true);
+ },
+
+ showTab(aTab) {
+ if (!aTab.hidden || aTab == FirefoxViewHandler.tab) {
+ return;
+ }
+ aTab.removeAttribute("hidden");
+ this.tabContainer._invalidateCachedVisibleTabs();
+
+ this.tabContainer._updateCloseButtons();
+ this.tabContainer._updateHiddenTabsStatus();
+
+ this.tabContainer._setPositionalAttributes();
+
+ let event = document.createEvent("Events");
+ event.initEvent("TabShow", true, false);
+ aTab.dispatchEvent(event);
+ SessionStore.deleteCustomTabValue(aTab, "hiddenBy");
+ },
+
+ hideTab(aTab, aSource) {
+ if (
+ aTab.hidden ||
+ aTab.pinned ||
+ aTab.selected ||
+ aTab.closing ||
+ // Tabs that are sharing the screen, microphone or camera cannot be hidden.
+ aTab._sharingState?.webRTC?.sharing
+ ) {
+ return;
+ }
+ aTab.setAttribute("hidden", "true");
+ this.tabContainer._invalidateCachedVisibleTabs();
+
+ this.tabContainer._updateCloseButtons();
+ this.tabContainer._updateHiddenTabsStatus();
+
+ this.tabContainer._setPositionalAttributes();
+
+ // Splice this tab out of any lines of succession before any events are
+ // dispatched.
+ this.replaceInSuccession(aTab, aTab.successor);
+ this.setSuccessor(aTab, null);
+
+ let event = document.createEvent("Events");
+ event.initEvent("TabHide", true, false);
+ aTab.dispatchEvent(event);
+ if (aSource) {
+ SessionStore.setCustomTabValue(aTab, "hiddenBy", aSource);
+ }
+ },
+
+ selectTabAtIndex(aIndex, aEvent) {
+ let tabs = this.visibleTabs;
+
+ // count backwards for aIndex < 0
+ if (aIndex < 0) {
+ aIndex += tabs.length;
+ // clamp at index 0 if still negative.
+ if (aIndex < 0) {
+ aIndex = 0;
+ }
+ } else if (aIndex >= tabs.length) {
+ // clamp at right-most tab if out of range.
+ aIndex = tabs.length - 1;
+ }
+
+ this.selectedTab = tabs[aIndex];
+
+ if (aEvent) {
+ aEvent.preventDefault();
+ aEvent.stopPropagation();
+ }
+ },
+
+ /**
+ * Moves a tab to a new browser window, unless it's already the only tab
+ * in the current window, in which case this will do nothing.
+ */
+ replaceTabWithWindow(aTab, aOptions) {
+ if (this.tabs.length == 1) {
+ return null;
+ }
+
+ var options = "chrome,dialog=no,all";
+ for (var name in aOptions) {
+ options += "," + name + "=" + aOptions[name];
+ }
+
+ if (PrivateBrowsingUtils.isWindowPrivate(window)) {
+ options += ",private=1";
+ }
+
+ // Play the tab closing animation to give immediate feedback while
+ // waiting for the new window to appear.
+ // content area when the docshells are swapped.
+ if (!gReduceMotion) {
+ aTab.style.maxWidth = ""; // ensure that fade-out transition happens
+ aTab.removeAttribute("fadein");
+ }
+
+ // tell a new window to take the "dropped" tab
+ return window.openDialog(
+ AppConstants.BROWSER_CHROME_URL,
+ "_blank",
+ options,
+ aTab
+ );
+ },
+
+ /**
+ * Move contextTab (or selected tabs in a mutli-select context)
+ * to a new browser window, unless it is (they are) already the only tab(s)
+ * in the current window, in which case this will do nothing.
+ */
+ replaceTabsWithWindow(contextTab, aOptions = {}) {
+ let tabs;
+ if (contextTab.multiselected) {
+ tabs = this.selectedTabs;
+ } else {
+ tabs = [contextTab];
+ }
+
+ if (this.tabs.length == tabs.length) {
+ return null;
+ }
+
+ if (tabs.length == 1) {
+ return this.replaceTabWithWindow(tabs[0], aOptions);
+ }
+
+ // Play the closing animation for all selected tabs to give
+ // immediate feedback while waiting for the new window to appear.
+ if (!gReduceMotion) {
+ for (let tab of tabs) {
+ tab.style.maxWidth = ""; // ensure that fade-out transition happens
+ tab.removeAttribute("fadein");
+ }
+ }
+
+ // Create a new window and make it adopt the tabs, preserving their relative order.
+ // The initial tab of the new window will be selected, so it should adopt the
+ // selected tab of the original window, if applicable, or else the first moving tab.
+ // This avoids tab-switches in the new window, preserving tab laziness.
+ // However, to avoid multiple tab-switches in the original window, the other tabs
+ // should be adopted before the selected one.
+ let { selectedTab } = gBrowser;
+ if (!tabs.includes(selectedTab)) {
+ selectedTab = tabs[0];
+ }
+ let win = this.replaceTabWithWindow(selectedTab, aOptions);
+ win.addEventListener(
+ "before-initial-tab-adopted",
+ () => {
+ let index = 0;
+ for (let tab of tabs) {
+ if (tab !== selectedTab) {
+ const newTab = win.gBrowser.adoptTab(tab, index);
+ if (!newTab) {
+ // The adoption failed. Restore "fadein" and don't increase the index.
+ tab.setAttribute("fadein", "true");
+ continue;
+ }
+ }
+ ++index;
+ }
+ // Restore tab selection
+ let winVisibleTabs = win.gBrowser.visibleTabs;
+ let winTabLength = winVisibleTabs.length;
+ win.gBrowser.addRangeToMultiSelectedTabs(
+ winVisibleTabs[0],
+ winVisibleTabs[winTabLength - 1]
+ );
+ win.gBrowser.lockClearMultiSelectionOnce();
+ },
+ { once: true }
+ );
+ return win;
+ },
+
+ _updateTabsAfterInsert() {
+ for (let i = 0; i < this.tabs.length; i++) {
+ this.tabs[i]._tPos = i;
+ this.tabs[i]._selected = false;
+ }
+
+ // If we're in the midst of an async tab switch while calling
+ // moveTabTo, we can get into a case where _visuallySelected
+ // is set to true on two different tabs.
+ //
+ // What we want to do in moveTabTo is to remove logical selection
+ // from all tabs, and then re-add logical selection to selectedTab
+ // (and visual selection as well if we're not running with e10s, which
+ // setting _selected will do automatically).
+ //
+ // If we're running with e10s, then the visual selection will not
+ // be changed, which is fine, since if we weren't in the midst of a
+ // tab switch, the previously visually selected tab should still be
+ // correct, and if we are in the midst of a tab switch, then the async
+ // tab switcher will set the visually selected tab once the tab switch
+ // has completed.
+ this.selectedTab._selected = true;
+ },
+
+ moveTabTo(aTab, aIndex, aKeepRelatedTabs) {
+ var oldPosition = aTab._tPos;
+ if (oldPosition == aIndex) {
+ return;
+ }
+
+ // Don't allow mixing pinned and unpinned tabs.
+ if (aTab.pinned) {
+ aIndex = Math.min(aIndex, this._numPinnedTabs - 1);
+ } else {
+ aIndex = Math.max(aIndex, this._numPinnedTabs);
+ }
+ if (oldPosition == aIndex) {
+ return;
+ }
+
+ if (!aKeepRelatedTabs) {
+ this._lastRelatedTabMap = new WeakMap();
+ }
+
+ let wasFocused = document.activeElement == this.selectedTab;
+
+ aIndex = aIndex < aTab._tPos ? aIndex : aIndex + 1;
+
+ let neighbor = this.tabs[aIndex] || null;
+ this.tabContainer._invalidateCachedTabs();
+ this.tabContainer.insertBefore(aTab, neighbor);
+ this._updateTabsAfterInsert();
+
+ if (wasFocused) {
+ this.selectedTab.focus();
+ }
+
+ this.tabContainer._handleTabSelect(true);
+
+ if (aTab.pinned) {
+ this.tabContainer._positionPinnedTabs();
+ }
+
+ this.tabContainer._setPositionalAttributes();
+
+ var evt = document.createEvent("UIEvents");
+ evt.initUIEvent("TabMove", true, false, window, oldPosition);
+ aTab.dispatchEvent(evt);
+ },
+
+ moveTabForward() {
+ let nextTab = this.tabContainer.findNextTab(this.selectedTab, {
+ direction: 1,
+ filter: tab => !tab.hidden,
+ });
+
+ if (nextTab) {
+ this.moveTabTo(this.selectedTab, nextTab._tPos);
+ } else if (this.arrowKeysShouldWrap) {
+ this.moveTabToStart();
+ }
+ },
+
+ /**
+ * Adopts a tab from another browser window, and inserts it at aIndex
+ *
+ * @returns {object}
+ * The new tab in the current window, null if the tab couldn't be adopted.
+ */
+ adoptTab(aTab, aIndex, aSelectTab) {
+ // Swap the dropped tab with a new one we create and then close
+ // it in the other window (making it seem to have moved between
+ // windows). We also ensure that the tab we create to swap into has
+ // the same remote type and process as the one we're swapping in.
+ // This makes sure we don't get a short-lived process for the new tab.
+ let linkedBrowser = aTab.linkedBrowser;
+ let createLazyBrowser = !aTab.linkedPanel;
+ let params = {
+ eventDetail: { adoptedTab: aTab },
+ preferredRemoteType: linkedBrowser.remoteType,
+ initialBrowsingContextGroupId: linkedBrowser.browsingContext?.group.id,
+ skipAnimation: true,
+ index: aIndex,
+ createLazyBrowser,
+ };
+
+ let numPinned = this._numPinnedTabs;
+ if (aIndex < numPinned || (aTab.pinned && aIndex == numPinned)) {
+ params.pinned = true;
+ }
+
+ if (aTab.hasAttribute("usercontextid")) {
+ // new tab must have the same usercontextid as the old one
+ params.userContextId = aTab.getAttribute("usercontextid");
+ }
+ let newTab = this.addWebTab("about:blank", params);
+ let newBrowser = this.getBrowserForTab(newTab);
+
+ aTab.container._finishAnimateTabMove();
+
+ if (!createLazyBrowser) {
+ // Stop the about:blank load.
+ newBrowser.stop();
+ // Make sure it has a docshell.
+ newBrowser.docShell;
+ }
+
+ if (!this.swapBrowsersAndCloseOther(newTab, aTab)) {
+ // Swapping wasn't permitted. Bail out.
+ this.removeTab(newTab);
+ return null;
+ }
+
+ if (aSelectTab) {
+ this.selectedTab = newTab;
+ }
+
+ return newTab;
+ },
+
+ moveTabBackward() {
+ let previousTab = this.tabContainer.findNextTab(this.selectedTab, {
+ direction: -1,
+ filter: tab => !tab.hidden,
+ });
+
+ if (previousTab) {
+ this.moveTabTo(this.selectedTab, previousTab._tPos);
+ } else if (this.arrowKeysShouldWrap) {
+ this.moveTabToEnd();
+ }
+ },
+
+ moveTabToStart() {
+ let tabPos = this.selectedTab._tPos;
+ if (tabPos > 0) {
+ this.moveTabTo(this.selectedTab, 0);
+ }
+ },
+
+ moveTabToEnd() {
+ let tabPos = this.selectedTab._tPos;
+ if (tabPos < this.browsers.length - 1) {
+ this.moveTabTo(this.selectedTab, this.browsers.length - 1);
+ }
+ },
+
+ moveTabOver(aEvent) {
+ if (
+ (!RTL_UI && aEvent.keyCode == KeyEvent.DOM_VK_RIGHT) ||
+ (RTL_UI && aEvent.keyCode == KeyEvent.DOM_VK_LEFT)
+ ) {
+ this.moveTabForward();
+ } else {
+ this.moveTabBackward();
+ }
+ },
+
+ /**
+ * @param aTab
+ * Can be from a different window as well
+ * @param aRestoreTabImmediately
+ * Can defer loading of the tab contents
+ * @param aOptions
+ * The new index of the tab
+ */
+ duplicateTab(aTab, aRestoreTabImmediately, aOptions) {
+ return SessionStore.duplicateTab(
+ window,
+ aTab,
+ 0,
+ aRestoreTabImmediately,
+ aOptions
+ );
+ },
+
+ addToMultiSelectedTabs(aTab) {
+ if (aTab.multiselected) {
+ return;
+ }
+
+ aTab.setAttribute("multiselected", "true");
+ aTab.setAttribute("aria-selected", "true");
+ this._multiSelectedTabsSet.add(aTab);
+ this._startMultiSelectChange();
+ if (this._multiSelectChangeRemovals.has(aTab)) {
+ this._multiSelectChangeRemovals.delete(aTab);
+ } else {
+ this._multiSelectChangeAdditions.add(aTab);
+ }
+ },
+
+ /**
+ * Adds two given tabs and all tabs between them into the (multi) selected tabs collection
+ */
+ addRangeToMultiSelectedTabs(aTab1, aTab2) {
+ if (aTab1 == aTab2) {
+ return;
+ }
+
+ const tabs = this.visibleTabs;
+ const indexOfTab1 = tabs.indexOf(aTab1);
+ const indexOfTab2 = tabs.indexOf(aTab2);
+
+ const [lowerIndex, higherIndex] =
+ indexOfTab1 < indexOfTab2
+ ? [Math.max(0, indexOfTab1), indexOfTab2]
+ : [Math.max(0, indexOfTab2), indexOfTab1];
+
+ for (let i = lowerIndex; i <= higherIndex; i++) {
+ this.addToMultiSelectedTabs(tabs[i]);
+ }
+ },
+
+ removeFromMultiSelectedTabs(aTab) {
+ if (!aTab.multiselected) {
+ return;
+ }
+ aTab.removeAttribute("multiselected");
+ aTab.removeAttribute("aria-selected");
+ this._multiSelectedTabsSet.delete(aTab);
+ this._startMultiSelectChange();
+ if (this._multiSelectChangeAdditions.has(aTab)) {
+ this._multiSelectChangeAdditions.delete(aTab);
+ } else {
+ this._multiSelectChangeRemovals.add(aTab);
+ }
+ },
+
+ clearMultiSelectedTabs() {
+ if (this._clearMultiSelectionLocked) {
+ if (this._clearMultiSelectionLockedOnce) {
+ this._clearMultiSelectionLockedOnce = false;
+ this._clearMultiSelectionLocked = false;
+ }
+ return;
+ }
+
+ if (this.multiSelectedTabsCount < 1) {
+ return;
+ }
+
+ for (let tab of this.selectedTabs) {
+ this.removeFromMultiSelectedTabs(tab);
+ }
+ this._lastMultiSelectedTabRef = null;
+ },
+
+ selectAllTabs() {
+ let visibleTabs = this.visibleTabs;
+ gBrowser.addRangeToMultiSelectedTabs(
+ visibleTabs[0],
+ visibleTabs[visibleTabs.length - 1]
+ );
+ },
+
+ allTabsSelected() {
+ return (
+ this.visibleTabs.length == 1 ||
+ this.visibleTabs.every(t => t.multiselected)
+ );
+ },
+
+ lockClearMultiSelectionOnce() {
+ this._clearMultiSelectionLockedOnce = true;
+ this._clearMultiSelectionLocked = true;
+ },
+
+ unlockClearMultiSelection() {
+ this._clearMultiSelectionLockedOnce = false;
+ this._clearMultiSelectionLocked = false;
+ },
+
+ /**
+ * Remove a tab from the multiselection if it's the only one left there.
+ *
+ * In fact, some scenario may lead to only one single tab multi-selected,
+ * this is something to avoid (Chrome does the same)
+ * Consider 4 tabs A,B,C,D with A having the focus
+ * 1. select C with Ctrl
+ * 2. Right-click on B and "Close Tabs to The Right"
+ *
+ * Expected result
+ * C and D closing
+ * A being the only multi-selected tab, selection should be cleared
+ *
+ *
+ * Single selected tab could even happen with a none-focused tab.
+ * For exemple with the menu "Close other tabs", it could happen
+ * with a multi-selected pinned tab.
+ * For illustration, consider 4 tabs A,B,C,D with B active
+ * 1. pin A and Ctrl-select it
+ * 2. Ctrl-select C
+ * 3. right-click on D and click "Close Other Tabs"
+ *
+ * Expected result
+ * B and C closing
+ * A[pinned] being the only multi-selected tab, selection should be cleared.
+ */
+ _avoidSingleSelectedTab() {
+ if (this.multiSelectedTabsCount == 1) {
+ this.clearMultiSelectedTabs();
+ }
+ },
+
+ _switchToNextMultiSelectedTab() {
+ this._clearMultiSelectionLocked = true;
+
+ // Guarantee that _clearMultiSelectionLocked lock gets released.
+ try {
+ let lastMultiSelectedTab = this.lastMultiSelectedTab;
+ if (!lastMultiSelectedTab.selected) {
+ this.selectedTab = lastMultiSelectedTab;
+ } else {
+ let selectedTabs = ChromeUtils.nondeterministicGetWeakSetKeys(
+ this._multiSelectedTabsSet
+ ).filter(this._mayTabBeMultiselected);
+ this.selectedTab = selectedTabs.at(-1);
+ }
+ } catch (e) {
+ console.error(e);
+ }
+
+ this._clearMultiSelectionLocked = false;
+ },
+
+ set selectedTabs(tabs) {
+ this.clearMultiSelectedTabs();
+ this.selectedTab = tabs[0];
+ if (tabs.length > 1) {
+ for (let tab of tabs) {
+ this.addToMultiSelectedTabs(tab);
+ }
+ }
+ },
+
+ get selectedTabs() {
+ let { selectedTab, _multiSelectedTabsSet } = this;
+ let tabs = ChromeUtils.nondeterministicGetWeakSetKeys(
+ _multiSelectedTabsSet
+ ).filter(this._mayTabBeMultiselected);
+ if (
+ (!_multiSelectedTabsSet.has(selectedTab) &&
+ this._mayTabBeMultiselected(selectedTab)) ||
+ !tabs.length
+ ) {
+ tabs.push(selectedTab);
+ }
+ return tabs.sort((a, b) => a._tPos > b._tPos);
+ },
+
+ get multiSelectedTabsCount() {
+ return ChromeUtils.nondeterministicGetWeakSetKeys(
+ this._multiSelectedTabsSet
+ ).filter(this._mayTabBeMultiselected).length;
+ },
+
+ get lastMultiSelectedTab() {
+ let tab = this._lastMultiSelectedTabRef
+ ? this._lastMultiSelectedTabRef.get()
+ : null;
+ if (tab && tab.isConnected && this._multiSelectedTabsSet.has(tab)) {
+ return tab;
+ }
+ let selectedTab = this.selectedTab;
+ this.lastMultiSelectedTab = selectedTab;
+ return selectedTab;
+ },
+
+ set lastMultiSelectedTab(aTab) {
+ this._lastMultiSelectedTabRef = Cu.getWeakReference(aTab);
+ },
+
+ _mayTabBeMultiselected(aTab) {
+ return aTab.isConnected && !aTab.closing && !aTab.hidden;
+ },
+
+ _startMultiSelectChange() {
+ if (!this._multiSelectChangeStarted) {
+ this._multiSelectChangeStarted = true;
+ Promise.resolve().then(() => this._endMultiSelectChange());
+ }
+ },
+
+ _endMultiSelectChange() {
+ let noticeable = false;
+ let { selectedTab } = this;
+ if (this._multiSelectChangeAdditions.size) {
+ if (!selectedTab.multiselected) {
+ this.addToMultiSelectedTabs(selectedTab);
+ }
+ noticeable = true;
+ }
+ if (this._multiSelectChangeRemovals.size) {
+ if (this._multiSelectChangeRemovals.has(selectedTab)) {
+ this._switchToNextMultiSelectedTab();
+ }
+ this._avoidSingleSelectedTab();
+ noticeable = true;
+ }
+ this._multiSelectChangeStarted = false;
+ if (noticeable || this._multiSelectChangeSelected) {
+ this._multiSelectChangeSelected = false;
+ this._multiSelectChangeAdditions.clear();
+ this._multiSelectChangeRemovals.clear();
+ if (noticeable) {
+ this.tabContainer._setPositionalAttributes();
+ }
+ this.dispatchEvent(
+ new CustomEvent("TabMultiSelect", { bubbles: true })
+ );
+ }
+ },
+
+ toggleMuteAudioOnMultiSelectedTabs(aTab) {
+ let tabMuted = aTab.linkedBrowser.audioMuted;
+ let tabsToToggle = this.selectedTabs.filter(
+ tab => tab.linkedBrowser.audioMuted == tabMuted
+ );
+ for (let tab of tabsToToggle) {
+ tab.toggleMuteAudio();
+ }
+ },
+
+ resumeDelayedMediaOnMultiSelectedTabs() {
+ for (let tab of this.selectedTabs) {
+ tab.resumeDelayedMedia();
+ }
+ },
+
+ pinMultiSelectedTabs() {
+ for (let tab of this.selectedTabs) {
+ this.pinTab(tab);
+ }
+ },
+
+ unpinMultiSelectedTabs() {
+ // The selectedTabs getter returns the tabs
+ // in visual order. We need to unpin in reverse
+ // order to maintain visual order.
+ let selectedTabs = this.selectedTabs;
+ for (let i = selectedTabs.length - 1; i >= 0; i--) {
+ let tab = selectedTabs[i];
+ this.unpinTab(tab);
+ }
+ },
+
+ activateBrowserForPrintPreview(aBrowser) {
+ this._printPreviewBrowsers.add(aBrowser);
+ if (this._switcher) {
+ this._switcher.activateBrowserForPrintPreview(aBrowser);
+ }
+ aBrowser.docShellIsActive = true;
+ },
+
+ deactivatePrintPreviewBrowsers() {
+ let browsers = this._printPreviewBrowsers;
+ this._printPreviewBrowsers = new Set();
+ for (let browser of browsers) {
+ browser.docShellIsActive = this.shouldActivateDocShell(browser);
+ }
+ },
+
+ /**
+ * Returns true if a given browser's docshell should be active.
+ */
+ shouldActivateDocShell(aBrowser) {
+ if (this._switcher) {
+ return this._switcher.shouldActivateDocShell(aBrowser);
+ }
+ return (
+ (aBrowser == this.selectedBrowser && !document.hidden) ||
+ this._printPreviewBrowsers.has(aBrowser) ||
+ this.PictureInPicture.isOriginatingBrowser(aBrowser)
+ );
+ },
+
+ _getSwitcher() {
+ if (!this._switcher) {
+ this._switcher = new this.AsyncTabSwitcher(this);
+ }
+ return this._switcher;
+ },
+
+ warmupTab(aTab) {
+ if (gMultiProcessBrowser) {
+ this._getSwitcher().warmupTab(aTab);
+ }
+ },
+
+ /**
+ * _maybeRequestReplyFromRemoteContent may call
+ * aEvent.requestReplyFromRemoteContent if necessary.
+ *
+ * @param aEvent The handling event.
+ * @return true if the handler should wait a reply event.
+ * false if the handle can handle the immediately.
+ */
+ _maybeRequestReplyFromRemoteContent(aEvent) {
+ if (aEvent.defaultPrevented) {
+ return false;
+ }
+ // If the event target is a remote browser, and the event has not been
+ // handled by the remote content yet, we should wait a reply event
+ // from the content.
+ if (aEvent.isWaitingReplyFromRemoteContent) {
+ return true; // Somebody called requestReplyFromRemoteContent already.
+ }
+ if (
+ !aEvent.isReplyEventFromRemoteContent &&
+ aEvent.target?.isRemoteBrowser === true
+ ) {
+ aEvent.requestReplyFromRemoteContent();
+ return true;
+ }
+ return false;
+ },
+
+ _handleKeyDownEvent(aEvent) {
+ if (!aEvent.isTrusted) {
+ // Don't let untrusted events mess with tabs.
+ return;
+ }
+
+ // Skip this only if something has explicitly cancelled it.
+ if (aEvent.defaultCancelled) {
+ return;
+ }
+
+ // Skip if chrome code has cancelled this:
+ if (aEvent.defaultPreventedByChrome) {
+ return;
+ }
+
+ // Don't check if the event was already consumed because tab
+ // navigation should always work for better user experience.
+
+ switch (ShortcutUtils.getSystemActionForEvent(aEvent)) {
+ case ShortcutUtils.TOGGLE_CARET_BROWSING:
+ this._maybeRequestReplyFromRemoteContent(aEvent);
+ return;
+ case ShortcutUtils.MOVE_TAB_BACKWARD:
+ this.moveTabBackward();
+ aEvent.preventDefault();
+ return;
+ case ShortcutUtils.MOVE_TAB_FORWARD:
+ this.moveTabForward();
+ aEvent.preventDefault();
+ return;
+ case ShortcutUtils.CLOSE_TAB:
+ if (gBrowser.multiSelectedTabsCount) {
+ gBrowser.removeMultiSelectedTabs();
+ } else if (!this.selectedTab.pinned) {
+ this.removeCurrentTab({ animate: true });
+ }
+ aEvent.preventDefault();
+ }
+ },
+
+ toggleCaretBrowsing() {
+ const kPrefShortcutEnabled =
+ "accessibility.browsewithcaret_shortcut.enabled";
+ const kPrefWarnOnEnable = "accessibility.warn_on_browsewithcaret";
+ const kPrefCaretBrowsingOn = "accessibility.browsewithcaret";
+
+ var isEnabled = Services.prefs.getBoolPref(kPrefShortcutEnabled);
+ if (!isEnabled || this._awaitingToggleCaretBrowsingPrompt) {
+ return;
+ }
+
+ // Toggle browse with caret mode
+ var browseWithCaretOn = Services.prefs.getBoolPref(
+ kPrefCaretBrowsingOn,
+ false
+ );
+ var warn = Services.prefs.getBoolPref(kPrefWarnOnEnable, true);
+ if (warn && !browseWithCaretOn) {
+ var checkValue = { value: false };
+ var promptService = Services.prompt;
+
+ try {
+ this._awaitingToggleCaretBrowsingPrompt = true;
+ const [title, message, checkbox] =
+ this.tabLocalization.formatValuesSync([
+ "tabbrowser-confirm-caretbrowsing-title",
+ "tabbrowser-confirm-caretbrowsing-message",
+ "tabbrowser-confirm-caretbrowsing-checkbox",
+ ]);
+ var buttonPressed = promptService.confirmEx(
+ window,
+ title,
+ message,
+ // Make "No" the default:
+ promptService.STD_YES_NO_BUTTONS |
+ promptService.BUTTON_POS_1_DEFAULT,
+ null,
+ null,
+ null,
+ checkbox,
+ checkValue
+ );
+ } catch (ex) {
+ return;
+ } finally {
+ this._awaitingToggleCaretBrowsingPrompt = false;
+ }
+ if (buttonPressed != 0) {
+ if (checkValue.value) {
+ try {
+ Services.prefs.setBoolPref(kPrefShortcutEnabled, false);
+ } catch (ex) {}
+ }
+ return;
+ }
+ if (checkValue.value) {
+ try {
+ Services.prefs.setBoolPref(kPrefWarnOnEnable, false);
+ } catch (ex) {}
+ }
+ }
+
+ // Toggle the pref
+ try {
+ Services.prefs.setBoolPref(kPrefCaretBrowsingOn, !browseWithCaretOn);
+ } catch (ex) {}
+ },
+
+ _handleKeyPressEvent(aEvent) {
+ if (!aEvent.isTrusted) {
+ // Don't let untrusted events mess with tabs.
+ return;
+ }
+
+ // Skip this only if something has explicitly cancelled it.
+ if (aEvent.defaultCancelled) {
+ return;
+ }
+
+ // Skip if chrome code has cancelled this:
+ if (aEvent.defaultPreventedByChrome) {
+ return;
+ }
+
+ switch (ShortcutUtils.getSystemActionForEvent(aEvent, { rtl: RTL_UI })) {
+ case ShortcutUtils.TOGGLE_CARET_BROWSING:
+ if (
+ aEvent.defaultPrevented ||
+ this._maybeRequestReplyFromRemoteContent(aEvent)
+ ) {
+ break;
+ }
+ this.toggleCaretBrowsing();
+ break;
+
+ case ShortcutUtils.NEXT_TAB:
+ if (AppConstants.platform == "macosx") {
+ this.tabContainer.advanceSelectedTab(1, true);
+ aEvent.preventDefault();
+ }
+ break;
+ case ShortcutUtils.PREVIOUS_TAB:
+ if (AppConstants.platform == "macosx") {
+ this.tabContainer.advanceSelectedTab(-1, true);
+ aEvent.preventDefault();
+ }
+ break;
+ }
+ },
+
+ getTabTooltip(tab, includeLabel = true) {
+ let labelArray = [];
+ if (includeLabel) {
+ labelArray.push(tab._fullLabel || tab.getAttribute("label"));
+ }
+ if (
+ Services.prefs.getBoolPref(
+ "browser.tabs.tooltipsShowPidAndActiveness",
+ false
+ )
+ ) {
+ if (tab.linkedBrowser) {
+ // Show the PIDs of the content process and remote subframe processes.
+ let [contentPid, ...framePids] = E10SUtils.getBrowserPids(
+ tab.linkedBrowser,
+ gFissionBrowser
+ );
+ if (contentPid) {
+ if (framePids && framePids.length) {
+ labelArray.push(
+ `(pids ${contentPid}, ${framePids.sort().join(", ")})`
+ );
+ } else {
+ labelArray.push(`(pid ${contentPid})`);
+ }
+ }
+ if (tab.linkedBrowser.docShellIsActive) {
+ labelArray.push("[A]");
+ }
+ }
+ }
+
+ let label = labelArray.join(" ");
+ if (tab.userContextId) {
+ const containerName = ContextualIdentityService.getUserContextLabel(
+ tab.userContextId
+ );
+ label = this.tabLocalization.formatValueSync(
+ "tabbrowser-container-tab-title",
+ { title: label, containerName }
+ );
+ }
+
+ labelArray = [label];
+ if (tab.soundPlaying) {
+ let audioPlayingString = this.tabLocalization.formatValueSync(
+ "tabbrowser-tab-audio-playing-description"
+ );
+ labelArray.push(audioPlayingString);
+ }
+ return labelArray.join("\n");
+ },
+
+ createTooltip(event) {
+ event.stopPropagation();
+ let tab = event.target.triggerNode?.closest("tab");
+ if (!tab) {
+ event.preventDefault();
+ return;
+ }
+
+ const tooltip = event.target;
+ tooltip.removeAttribute("data-l10n-id");
+
+ const tabCount = this.selectedTabs.includes(tab)
+ ? this.selectedTabs.length
+ : 1;
+ if (tab.mOverCloseButton) {
+ tooltip.label = "";
+ document.l10n.setAttributes(tooltip, "tabbrowser-close-tabs-tooltip", {
+ tabCount,
+ });
+ } else if (tab._overPlayingIcon) {
+ let l10nId;
+ const l10nArgs = { tabCount };
+ if (tab.selected) {
+ l10nId = tab.linkedBrowser.audioMuted
+ ? "tabbrowser-unmute-tab-audio-tooltip"
+ : "tabbrowser-mute-tab-audio-tooltip";
+ const keyElem = document.getElementById("key_toggleMute");
+ l10nArgs.shortcut = ShortcutUtils.prettifyShortcut(keyElem);
+ } else if (tab.hasAttribute("activemedia-blocked")) {
+ l10nId = "tabbrowser-unblock-tab-audio-tooltip";
+ } else {
+ l10nId = tab.linkedBrowser.audioMuted
+ ? "tabbrowser-unmute-tab-audio-background-tooltip"
+ : "tabbrowser-mute-tab-audio-background-tooltip";
+ }
+ tooltip.label = "";
+ document.l10n.setAttributes(tooltip, l10nId, l10nArgs);
+ } else {
+ tooltip.label = this.getTabTooltip(tab, true);
+ }
+ },
+
+ handleEvent(aEvent) {
+ switch (aEvent.type) {
+ case "keydown":
+ this._handleKeyDownEvent(aEvent);
+ break;
+ case "keypress":
+ this._handleKeyPressEvent(aEvent);
+ break;
+ case "framefocusrequested": {
+ let tab = this.getTabForBrowser(aEvent.target);
+ if (!tab || tab == this.selectedTab) {
+ // Let the focus manager try to do its thing by not calling
+ // preventDefault(). It will still raise the window if appropriate.
+ break;
+ }
+ this.selectedTab = tab;
+ window.focus();
+ aEvent.preventDefault();
+ break;
+ }
+ case "visibilitychange":
+ const inactive = document.hidden;
+ if (!this._switcher) {
+ this.selectedBrowser.preserveLayers(inactive);
+ this.selectedBrowser.docShellIsActive = !inactive;
+ }
+ break;
+ }
+ },
+
+ observe(aSubject, aTopic, aData) {
+ switch (aTopic) {
+ case "contextual-identity-updated": {
+ let identity = aSubject.wrappedJSObject;
+ for (let tab of this.tabs) {
+ if (tab.getAttribute("usercontextid") == identity.userContextId) {
+ ContextualIdentityService.setTabStyle(tab);
+ }
+ }
+ break;
+ }
+ }
+ },
+
+ refreshBlocked(actor, browser, data) {
+ // The data object is expected to contain the following properties:
+ // - URI (string)
+ // The URI that a page is attempting to refresh or redirect to.
+ // - delay (int)
+ // The delay (in milliseconds) before the page was going to
+ // reload or redirect.
+ // - sameURI (bool)
+ // true if we're refreshing the page. false if we're redirecting.
+
+ let notificationBox = this.getNotificationBox(browser);
+ let notification =
+ notificationBox.getNotificationWithValue("refresh-blocked");
+
+ let l10nId = data.sameURI
+ ? "refresh-blocked-refresh-label"
+ : "refresh-blocked-redirect-label";
+ if (notification) {
+ notification.label = { "l10n-id": l10nId };
+ } else {
+ const buttons = [
+ {
+ "l10n-id": "refresh-blocked-allow",
+ callback() {
+ actor.sendAsyncMessage("RefreshBlocker:Refresh", data);
+ },
+ },
+ ];
+
+ notificationBox.appendNotification(
+ "refresh-blocked",
+ {
+ label: { "l10n-id": l10nId },
+ image: "chrome://browser/skin/notification-icons/popup.svg",
+ priority: notificationBox.PRIORITY_INFO_MEDIUM,
+ },
+ buttons
+ );
+ }
+ },
+
+ _generateUniquePanelID() {
+ if (!this._uniquePanelIDCounter) {
+ this._uniquePanelIDCounter = 0;
+ }
+
+ let outerID = window.docShell.outerWindowID;
+
+ // We want panel IDs to be globally unique, that's why we include the
+ // window ID. We switched to a monotonic counter as Date.now() lead
+ // to random failures because of colliding IDs.
+ return "panel-" + outerID + "-" + ++this._uniquePanelIDCounter;
+ },
+
+ destroy() {
+ this.tabContainer.destroy();
+ Services.obs.removeObserver(this, "contextual-identity-updated");
+
+ for (let tab of this.tabs) {
+ let browser = tab.linkedBrowser;
+ if (browser.registeredOpenURI) {
+ let userContextId = browser.getAttribute("usercontextid") || 0;
+ this.UrlbarProviderOpenTabs.unregisterOpenTab(
+ browser.registeredOpenURI.spec,
+ userContextId,
+ PrivateBrowsingUtils.isWindowPrivate(window)
+ );
+ delete browser.registeredOpenURI;
+ }
+
+ let filter = this._tabFilters.get(tab);
+ if (filter) {
+ browser.webProgress.removeProgressListener(filter);
+
+ let listener = this._tabListeners.get(tab);
+ if (listener) {
+ filter.removeProgressListener(listener);
+ listener.destroy();
+ }
+
+ this._tabFilters.delete(tab);
+ this._tabListeners.delete(tab);
+ }
+ }
+
+ Services.els.removeSystemEventListener(document, "keydown", this, false);
+ if (AppConstants.platform == "macosx") {
+ Services.els.removeSystemEventListener(
+ document,
+ "keypress",
+ this,
+ false
+ );
+ }
+ document.removeEventListener("visibilitychange", this);
+ window.removeEventListener("framefocusrequested", this);
+
+ if (gMultiProcessBrowser) {
+ if (this._switcher) {
+ this._switcher.destroy();
+ }
+ }
+ },
+
+ _setupEventListeners() {
+ this.tabpanels.addEventListener("select", event => {
+ if (event.target == this.tabpanels) {
+ this.updateCurrentBrowser();
+ }
+ });
+
+ this.addEventListener("DOMWindowClose", event => {
+ let browser = event.target;
+ if (!browser.isRemoteBrowser) {
+ if (!event.isTrusted) {
+ // If the browser is not remote, then we expect the event to be trusted.
+ // In the remote case, the DOMWindowClose event is captured in content,
+ // a message is sent to the parent, and another DOMWindowClose event
+ // is re-dispatched on the actual browser node. In that case, the event
+ // won't be marked as trusted, since it's synthesized by JavaScript.
+ return;
+ }
+ // In the parent-process browser case, it's possible that the browser
+ // that fired DOMWindowClose is actually a child of another browser. We
+ // want to find the top-most browser to determine whether or not this is
+ // for a tab or not. The chromeEventHandler will be the top-most browser.
+ browser = event.target.docShell.chromeEventHandler;
+ }
+
+ if (this.tabs.length == 1) {
+ // We already did PermitUnload in the content process
+ // for this tab (the only one in the window). So we don't
+ // need to do it again for any tabs.
+ window.skipNextCanClose = true;
+ // In the parent-process browser case, the nsCloseEvent will actually take
+ // care of tearing down the window, but we need to do this ourselves in the
+ // content-process browser case. Doing so in both cases doesn't appear to
+ // hurt.
+ window.close();
+ return;
+ }
+
+ let tab = this.getTabForBrowser(browser);
+ if (tab) {
+ // Skip running PermitUnload since it already happened in
+ // the content process.
+ this.removeTab(tab, { skipPermitUnload: true });
+ // If we don't preventDefault on the DOMWindowClose event, then
+ // in the parent-process browser case, we're telling the platform
+ // to close the entire window. Calling preventDefault is our way of
+ // saying we took care of this close request by closing the tab.
+ event.preventDefault();
+ }
+ });
+
+ this.addEventListener("pagetitlechanged", event => {
+ let browser = event.target;
+ let tab = this.getTabForBrowser(browser);
+ if (!tab || tab.hasAttribute("pending")) {
+ return;
+ }
+
+ // Ignore empty title changes on internal pages. This prevents the title
+ // from changing while Fluent is populating the (initially-empty) title
+ // element.
+ if (
+ !browser.contentTitle &&
+ browser.contentPrincipal.isSystemPrincipal
+ ) {
+ return;
+ }
+
+ let titleChanged = this.setTabTitle(tab);
+ if (titleChanged && !tab.selected && !tab.hasAttribute("busy")) {
+ tab.setAttribute("titlechanged", "true");
+ }
+ });
+
+ this.addEventListener(
+ "DOMWillOpenModalDialog",
+ event => {
+ if (!event.isTrusted) {
+ return;
+ }
+
+ let targetIsWindow = Window.isInstance(event.target);
+
+ // We're about to open a modal dialog, so figure out for which tab:
+ // If this is a same-process modal dialog, then we're given its DOM
+ // window as the event's target. For remote dialogs, we're given the
+ // browser, but that's in the originalTarget and not the target,
+ // because it's across the tabbrowser's XBL boundary.
+ let tabForEvent = targetIsWindow
+ ? this.getTabForBrowser(event.target.docShell.chromeEventHandler)
+ : this.getTabForBrowser(event.originalTarget);
+
+ // Focus window for beforeunload dialog so it is seen but don't
+ // steal focus from other applications.
+ if (
+ event.detail &&
+ event.detail.tabPrompt &&
+ event.detail.inPermitUnload &&
+ Services.focus.activeWindow
+ ) {
+ window.focus();
+ }
+
+ // Don't need to act if the tab is already selected or if there isn't
+ // a tab for the event (e.g. for the webextensions options_ui remote
+ // browsers embedded in the "about:addons" page):
+ if (!tabForEvent || tabForEvent.selected) {
+ return;
+ }
+
+ // We always switch tabs for beforeunload tab-modal prompts.
+ if (
+ event.detail &&
+ event.detail.tabPrompt &&
+ !event.detail.inPermitUnload
+ ) {
+ let docPrincipal = targetIsWindow
+ ? event.target.document.nodePrincipal
+ : null;
+ // At least one of these should/will be non-null:
+ let promptPrincipal =
+ event.detail.promptPrincipal ||
+ docPrincipal ||
+ tabForEvent.linkedBrowser.contentPrincipal;
+
+ // For null principals, we bail immediately and don't show the checkbox:
+ if (!promptPrincipal || promptPrincipal.isNullPrincipal) {
+ tabForEvent.attention = true;
+ return;
+ }
+
+ // For non-system/expanded principals without permission, we bail and show the checkbox.
+ if (promptPrincipal.URI && !promptPrincipal.isSystemPrincipal) {
+ let permission = Services.perms.testPermissionFromPrincipal(
+ promptPrincipal,
+ "focus-tab-by-prompt"
+ );
+ if (permission != Services.perms.ALLOW_ACTION) {
+ // Tell the prompt box we want to show the user a checkbox:
+ let tabPrompt = Services.prefs.getBoolPref(
+ "prompts.contentPromptSubDialog"
+ )
+ ? this.getTabDialogBox(tabForEvent.linkedBrowser)
+ : this.getTabModalPromptBox(tabForEvent.linkedBrowser);
+
+ tabPrompt.onNextPromptShowAllowFocusCheckboxFor(
+ promptPrincipal
+ );
+ tabForEvent.attention = true;
+ return;
+ }
+ }
+ // ... so system and expanded principals, as well as permitted "normal"
+ // URI-based principals, always get to steal focus for the tab when prompting.
+ }
+
+ // If permissions/origins dictate so, bring tab to the front.
+ this.selectedTab = tabForEvent;
+ },
+ true
+ );
+
+ // When cancelling beforeunload tabmodal dialogs, reset the URL bar to
+ // avoid spoofing risks.
+ this.addEventListener(
+ "DOMModalDialogClosed",
+ event => {
+ if (
+ !event.detail?.wasPermitUnload ||
+ event.detail.areLeaving ||
+ event.target.nodeName != "browser"
+ ) {
+ return;
+ }
+ event.target.userTypedValue = null;
+ if (event.target == this.selectedBrowser) {
+ gURLBar.setURI();
+ }
+ },
+ true
+ );
+
+ let onTabCrashed = event => {
+ if (!event.isTrusted) {
+ return;
+ }
+
+ let browser = event.originalTarget;
+
+ if (!event.isTopFrame) {
+ TabCrashHandler.onSubFrameCrash(browser, event.childID);
+ return;
+ }
+
+ // Preloaded browsers do not actually have any tabs. If one crashes,
+ // it should be released and removed.
+ if (browser === this.preloadedBrowser) {
+ NewTabPagePreloading.removePreloadedBrowser(window);
+ return;
+ }
+
+ let isRestartRequiredCrash =
+ event.type == "oop-browser-buildid-mismatch";
+
+ let icon = browser.mIconURL;
+ let tab = this.getTabForBrowser(browser);
+
+ if (this.selectedBrowser == browser) {
+ TabCrashHandler.onSelectedBrowserCrash(
+ browser,
+ isRestartRequiredCrash
+ );
+ } else {
+ TabCrashHandler.onBackgroundBrowserCrash(
+ browser,
+ isRestartRequiredCrash
+ );
+ }
+
+ tab.removeAttribute("soundplaying");
+ this.setIcon(tab, icon);
+ };
+
+ this.addEventListener("oop-browser-crashed", onTabCrashed);
+ this.addEventListener("oop-browser-buildid-mismatch", onTabCrashed);
+
+ this.addEventListener("DOMAudioPlaybackStarted", event => {
+ var tab = this.getTabFromAudioEvent(event);
+ if (!tab) {
+ return;
+ }
+
+ clearTimeout(tab._soundPlayingAttrRemovalTimer);
+ tab._soundPlayingAttrRemovalTimer = 0;
+
+ let modifiedAttrs = [];
+ if (tab.hasAttribute("soundplaying-scheduledremoval")) {
+ tab.removeAttribute("soundplaying-scheduledremoval");
+ modifiedAttrs.push("soundplaying-scheduledremoval");
+ }
+
+ if (!tab.hasAttribute("soundplaying")) {
+ tab.setAttribute("soundplaying", true);
+ modifiedAttrs.push("soundplaying");
+ }
+
+ if (modifiedAttrs.length) {
+ // Flush style so that the opacity takes effect immediately, in
+ // case the media is stopped before the style flushes naturally.
+ getComputedStyle(tab).opacity;
+ }
+
+ this._tabAttrModified(tab, modifiedAttrs);
+ });
+
+ this.addEventListener("DOMAudioPlaybackStopped", event => {
+ var tab = this.getTabFromAudioEvent(event);
+ if (!tab) {
+ return;
+ }
+
+ if (tab.hasAttribute("soundplaying")) {
+ let removalDelay = Services.prefs.getIntPref(
+ "browser.tabs.delayHidingAudioPlayingIconMS"
+ );
+
+ tab.style.setProperty(
+ "--soundplaying-removal-delay",
+ `${removalDelay - 300}ms`
+ );
+ tab.setAttribute("soundplaying-scheduledremoval", "true");
+ this._tabAttrModified(tab, ["soundplaying-scheduledremoval"]);
+
+ tab._soundPlayingAttrRemovalTimer = setTimeout(() => {
+ tab.removeAttribute("soundplaying-scheduledremoval");
+ tab.removeAttribute("soundplaying");
+ this._tabAttrModified(tab, [
+ "soundplaying",
+ "soundplaying-scheduledremoval",
+ ]);
+ }, removalDelay);
+ }
+ });
+
+ this.addEventListener("DOMAudioPlaybackBlockStarted", event => {
+ var tab = this.getTabFromAudioEvent(event);
+ if (!tab) {
+ return;
+ }
+
+ if (!tab.hasAttribute("activemedia-blocked")) {
+ tab.setAttribute("activemedia-blocked", true);
+ this._tabAttrModified(tab, ["activemedia-blocked"]);
+ }
+ });
+
+ this.addEventListener("DOMAudioPlaybackBlockStopped", event => {
+ var tab = this.getTabFromAudioEvent(event);
+ if (!tab) {
+ return;
+ }
+
+ if (tab.hasAttribute("activemedia-blocked")) {
+ tab.removeAttribute("activemedia-blocked");
+ this._tabAttrModified(tab, ["activemedia-blocked"]);
+ let hist = Services.telemetry.getHistogramById(
+ "TAB_AUDIO_INDICATOR_USED"
+ );
+ hist.add(2 /* unblockByVisitingTab */);
+ }
+ });
+
+ this.addEventListener("GloballyAutoplayBlocked", event => {
+ let browser = event.originalTarget;
+ let tab = this.getTabForBrowser(browser);
+ if (!tab) {
+ return;
+ }
+
+ SitePermissions.setForPrincipal(
+ browser.contentPrincipal,
+ "autoplay-media",
+ SitePermissions.BLOCK,
+ SitePermissions.SCOPE_GLOBAL,
+ browser
+ );
+ });
+
+ let tabContextFTLInserter = () => {
+ this.translateTabContextMenu();
+ this.tabContainer.removeEventListener(
+ "contextmenu",
+ tabContextFTLInserter,
+ true
+ );
+ this.tabContainer.removeEventListener(
+ "mouseover",
+ tabContextFTLInserter
+ );
+ this.tabContainer.removeEventListener(
+ "focus",
+ tabContextFTLInserter,
+ true
+ );
+ };
+ this.tabContainer.addEventListener(
+ "contextmenu",
+ tabContextFTLInserter,
+ true
+ );
+ this.tabContainer.addEventListener("mouseover", tabContextFTLInserter);
+ this.tabContainer.addEventListener("focus", tabContextFTLInserter, true);
+
+ // Fired when Gecko has decided a <browser> element will change
+ // remoteness. This allows persisting some state on this element across
+ // process switches.
+ this.addEventListener("WillChangeBrowserRemoteness", event => {
+ let browser = event.originalTarget;
+ let tab = this.getTabForBrowser(browser);
+ if (!tab) {
+ return;
+ }
+
+ // Dispatch the `BeforeTabRemotenessChange` event, allowing other code
+ // to react to this tab's process switch.
+ let evt = document.createEvent("Events");
+ evt.initEvent("BeforeTabRemotenessChange", true, false);
+ tab.dispatchEvent(evt);
+
+ let wasActive = document.activeElement == browser;
+
+ // Unhook our progress listener.
+ let filter = this._tabFilters.get(tab);
+ let oldListener = this._tabListeners.get(tab);
+ browser.webProgress.removeProgressListener(filter);
+ filter.removeProgressListener(oldListener);
+ let stateFlags = oldListener.mStateFlags;
+ let requestCount = oldListener.mRequestCount;
+
+ // We'll be creating a new listener, so destroy the old one.
+ oldListener.destroy();
+
+ let oldDroppedLinkHandler = browser.droppedLinkHandler;
+ let oldUserTypedValue = browser.userTypedValue;
+ let hadStartedLoad = browser.didStartLoadSinceLastUserTyping();
+
+ let didChange = didChangeEvent => {
+ browser.userTypedValue = oldUserTypedValue;
+ if (hadStartedLoad) {
+ browser.urlbarChangeTracker.startedLoad();
+ }
+
+ browser.droppedLinkHandler = oldDroppedLinkHandler;
+
+ // This shouldn't really be necessary, however, this has the side effect
+ // of sending MozLayerTreeReady / MozLayerTreeCleared events for remote
+ // frames, which the tab switcher depends on.
+ //
+ // eslint-disable-next-line no-self-assign
+ browser.docShellIsActive = browser.docShellIsActive;
+
+ // Create a new tab progress listener for the new browser we just
+ // injected, since tab progress listeners have logic for handling the
+ // initial about:blank load
+ let listener = new TabProgressListener(
+ tab,
+ browser,
+ false,
+ false,
+ stateFlags,
+ requestCount
+ );
+ this._tabListeners.set(tab, listener);
+ filter.addProgressListener(listener, Ci.nsIWebProgress.NOTIFY_ALL);
+
+ // Restore the progress listener.
+ browser.webProgress.addProgressListener(
+ filter,
+ Ci.nsIWebProgress.NOTIFY_ALL
+ );
+
+ let cbEvent = browser.getContentBlockingEvents();
+ // Include the true final argument to indicate that this event is
+ // simulated (instead of being observed by the webProgressListener).
+ this._callProgressListeners(
+ browser,
+ "onContentBlockingEvent",
+ [browser.webProgress, null, cbEvent, true],
+ true,
+ false
+ );
+
+ if (browser.isRemoteBrowser) {
+ // Switching the browser to be remote will connect to a new child
+ // process so the browser can no longer be considered to be
+ // crashed.
+ tab.removeAttribute("crashed");
+ gBrowser.tabContainer.updateTabIndicatorAttr(tab);
+ }
+
+ if (wasActive) {
+ browser.focus();
+ }
+
+ if (this.isFindBarInitialized(tab)) {
+ this.getCachedFindBar(tab).browser = browser;
+ }
+
+ evt = document.createEvent("Events");
+ evt.initEvent("TabRemotenessChange", true, false);
+ tab.dispatchEvent(evt);
+ };
+ browser.addEventListener("DidChangeBrowserRemoteness", didChange, {
+ once: true,
+ });
+ });
+
+ this.addEventListener("pageinfo", event => {
+ let browser = event.originalTarget;
+ let tab = this.getTabForBrowser(browser);
+ if (!tab) {
+ return;
+ }
+
+ const { url, description, previewImageURL } = event.detail;
+ this.setPageInfo(url, description, previewImageURL);
+ });
+ },
+
+ translateTabContextMenu() {
+ if (this._tabContextMenuTranslated) {
+ return;
+ }
+ MozXULElement.insertFTLIfNeeded("browser/tabContextMenu.ftl");
+ // Un-lazify the l10n-ids now that the FTL file has been inserted.
+ document
+ .getElementById("tabContextMenu")
+ .querySelectorAll("[data-lazy-l10n-id]")
+ .forEach(el => {
+ el.setAttribute("data-l10n-id", el.getAttribute("data-lazy-l10n-id"));
+ el.removeAttribute("data-lazy-l10n-id");
+ });
+ this._tabContextMenuTranslated = true;
+ },
+
+ setSuccessor(aTab, successorTab) {
+ if (aTab.ownerGlobal != window) {
+ throw new Error("Cannot set the successor of another window's tab");
+ }
+ if (successorTab == aTab) {
+ successorTab = null;
+ }
+ if (successorTab && successorTab.ownerGlobal != window) {
+ throw new Error("Cannot set the successor to another window's tab");
+ }
+ if (aTab.successor) {
+ aTab.successor.predecessors.delete(aTab);
+ }
+ aTab.successor = successorTab;
+ if (successorTab) {
+ if (!successorTab.predecessors) {
+ successorTab.predecessors = new Set();
+ }
+ successorTab.predecessors.add(aTab);
+ }
+ },
+
+ /**
+ * For all tabs with aTab as a successor, set the successor to aOtherTab
+ * instead.
+ */
+ replaceInSuccession(aTab, aOtherTab) {
+ if (aTab.predecessors) {
+ for (const predecessor of Array.from(aTab.predecessors)) {
+ this.setSuccessor(predecessor, aOtherTab);
+ }
+ }
+ },
+
+ /**
+ * Get the triggering principal for the last navigation in the session history.
+ */
+ _getTriggeringPrincipalFromHistory(aBrowser) {
+ let sessionHistory = aBrowser?.browsingContext?.sessionHistory;
+ if (
+ !sessionHistory ||
+ !sessionHistory.index ||
+ sessionHistory.count == 0
+ ) {
+ return undefined;
+ }
+ let currentEntry = sessionHistory.getEntryAtIndex(sessionHistory.index);
+ let triggeringPrincipal = currentEntry?.triggeringPrincipal;
+ return triggeringPrincipal;
+ },
+
+ clearRelatedTabs() {
+ this._lastRelatedTabMap = new WeakMap();
+ },
+ };
+
+ /**
+ * A web progress listener object definition for a given tab.
+ */
+ class TabProgressListener {
+ constructor(
+ aTab,
+ aBrowser,
+ aStartsBlank,
+ aWasPreloadedBrowser,
+ aOrigStateFlags,
+ aOrigRequestCount
+ ) {
+ let stateFlags = aOrigStateFlags || 0;
+ // Initialize mStateFlags to non-zero e.g. when creating a progress
+ // listener for preloaded browsers as there was no progress listener
+ // around when the content started loading. If the content didn't
+ // quite finish loading yet, mStateFlags will very soon be overridden
+ // with the correct value and end up at STATE_STOP again.
+ if (aWasPreloadedBrowser) {
+ stateFlags =
+ Ci.nsIWebProgressListener.STATE_STOP |
+ Ci.nsIWebProgressListener.STATE_IS_REQUEST;
+ }
+
+ this.mTab = aTab;
+ this.mBrowser = aBrowser;
+ this.mBlank = aStartsBlank;
+
+ // cache flags for correct status UI update after tab switching
+ this.mStateFlags = stateFlags;
+ this.mStatus = 0;
+ this.mMessage = "";
+ this.mTotalProgress = 0;
+
+ // count of open requests (should always be 0 or 1)
+ this.mRequestCount = aOrigRequestCount || 0;
+ }
+
+ destroy() {
+ delete this.mTab;
+ delete this.mBrowser;
+ }
+
+ _callProgressListeners(...args) {
+ args.unshift(this.mBrowser);
+ return gBrowser._callProgressListeners.apply(gBrowser, args);
+ }
+
+ _shouldShowProgress(aRequest) {
+ if (this.mBlank) {
+ return false;
+ }
+
+ // Don't show progress indicators in tabs for about: URIs
+ // pointing to local resources.
+ if (
+ aRequest instanceof Ci.nsIChannel &&
+ aRequest.originalURI.schemeIs("about")
+ ) {
+ return false;
+ }
+
+ return true;
+ }
+
+ _isForInitialAboutBlank(aWebProgress, aStateFlags, aLocation) {
+ if (!this.mBlank || !aWebProgress.isTopLevel) {
+ return false;
+ }
+
+ // If the state has STATE_STOP, and no requests were in flight, then this
+ // must be the initial "stop" for the initial about:blank document.
+ if (
+ aStateFlags & Ci.nsIWebProgressListener.STATE_STOP &&
+ this.mRequestCount == 0 &&
+ !aLocation
+ ) {
+ return true;
+ }
+
+ let location = aLocation ? aLocation.spec : "";
+ return location == "about:blank";
+ }
+
+ onProgressChange(
+ aWebProgress,
+ aRequest,
+ aCurSelfProgress,
+ aMaxSelfProgress,
+ aCurTotalProgress,
+ aMaxTotalProgress
+ ) {
+ this.mTotalProgress = aMaxTotalProgress
+ ? aCurTotalProgress / aMaxTotalProgress
+ : 0;
+
+ if (!this._shouldShowProgress(aRequest)) {
+ return;
+ }
+
+ if (this.mTotalProgress && this.mTab.hasAttribute("busy")) {
+ this.mTab.setAttribute("progress", "true");
+ gBrowser._tabAttrModified(this.mTab, ["progress"]);
+ }
+
+ this._callProgressListeners("onProgressChange", [
+ aWebProgress,
+ aRequest,
+ aCurSelfProgress,
+ aMaxSelfProgress,
+ aCurTotalProgress,
+ aMaxTotalProgress,
+ ]);
+ }
+
+ onProgressChange64(
+ aWebProgress,
+ aRequest,
+ aCurSelfProgress,
+ aMaxSelfProgress,
+ aCurTotalProgress,
+ aMaxTotalProgress
+ ) {
+ return this.onProgressChange(
+ aWebProgress,
+ aRequest,
+ aCurSelfProgress,
+ aMaxSelfProgress,
+ aCurTotalProgress,
+ aMaxTotalProgress
+ );
+ }
+
+ /* eslint-disable complexity */
+ onStateChange(aWebProgress, aRequest, aStateFlags, aStatus) {
+ if (!aRequest) {
+ return;
+ }
+
+ let location, originalLocation;
+ try {
+ aRequest.QueryInterface(Ci.nsIChannel);
+ location = aRequest.URI;
+ originalLocation = aRequest.originalURI;
+ } catch (ex) {}
+
+ let ignoreBlank = this._isForInitialAboutBlank(
+ aWebProgress,
+ aStateFlags,
+ location
+ );
+
+ const { STATE_START, STATE_STOP, STATE_IS_NETWORK } =
+ Ci.nsIWebProgressListener;
+
+ // If we were ignoring some messages about the initial about:blank, and we
+ // got the STATE_STOP for it, we'll want to pay attention to those messages
+ // from here forward. Similarly, if we conclude that this state change
+ // is one that we shouldn't be ignoring, then stop ignoring.
+ if (
+ (ignoreBlank &&
+ aStateFlags & STATE_STOP &&
+ aStateFlags & STATE_IS_NETWORK) ||
+ (!ignoreBlank && this.mBlank)
+ ) {
+ this.mBlank = false;
+ }
+
+ if (aStateFlags & STATE_START && aStateFlags & STATE_IS_NETWORK) {
+ this.mRequestCount++;
+
+ if (aWebProgress.isTopLevel) {
+ // Need to use originalLocation rather than location because things
+ // like about:home and about:privatebrowsing arrive with nsIRequest
+ // pointing to their resolved jar: or file: URIs.
+ if (
+ !(
+ originalLocation &&
+ gInitialPages.includes(originalLocation.spec) &&
+ originalLocation != "about:blank" &&
+ this.mBrowser.initialPageLoadedFromUserAction !=
+ originalLocation.spec &&
+ this.mBrowser.currentURI &&
+ this.mBrowser.currentURI.spec == "about:blank"
+ )
+ ) {
+ // Indicating that we started a load will allow the location
+ // bar to be cleared when the load finishes.
+ // In order to not overwrite user-typed content, we avoid it
+ // (see if condition above) in a very specific case:
+ // If the load is of an 'initial' page (e.g. about:privatebrowsing,
+ // about:newtab, etc.), was not explicitly typed in the location
+ // bar by the user, is not about:blank (because about:blank can be
+ // loaded by websites under their principal), and the current
+ // page in the browser is about:blank (indicating it is a newly
+ // created or re-created browser, e.g. because it just switched
+ // remoteness or is a new tab/window).
+ this.mBrowser.urlbarChangeTracker.startedLoad();
+
+ // To improve the user experience and perceived performance when
+ // opening links in new tabs, we show the url and tab title sooner,
+ // but only if it's safe (from a phishing point of view) to do so,
+ // thus there's no session history and the load starts from a
+ // non-web-controlled blank page.
+ if (
+ this.mBrowser.browsingContext.sessionHistory?.count === 0 &&
+ BrowserUIUtils.checkEmptyPageOrigin(
+ this.mBrowser,
+ originalLocation
+ )
+ ) {
+ gBrowser.setInitialTabTitle(this.mTab, originalLocation.spec, {
+ isURL: true,
+ });
+
+ this.mBrowser.browsingContext.nonWebControlledBlankURI =
+ originalLocation;
+ if (this.mTab.selected && !gBrowser.userTypedValue) {
+ gURLBar.setURI();
+ }
+ }
+ }
+ delete this.mBrowser.initialPageLoadedFromUserAction;
+ // If the browser is loading it must not be crashed anymore
+ this.mTab.removeAttribute("crashed");
+ gBrowser.tabContainer.updateTabIndicatorAttr(this.mTab);
+ }
+
+ if (this._shouldShowProgress(aRequest)) {
+ if (
+ !(aStateFlags & Ci.nsIWebProgressListener.STATE_RESTORING) &&
+ aWebProgress &&
+ aWebProgress.isTopLevel
+ ) {
+ this.mTab.setAttribute("busy", "true");
+ gBrowser._tabAttrModified(this.mTab, ["busy"]);
+ this.mTab._notselectedsinceload = !this.mTab.selected;
+ gBrowser.syncThrobberAnimations(this.mTab);
+ }
+
+ if (this.mTab.selected) {
+ gBrowser._isBusy = true;
+ }
+ }
+ } else if (aStateFlags & STATE_STOP && aStateFlags & STATE_IS_NETWORK) {
+ // since we (try to) only handle STATE_STOP of the last request,
+ // the count of open requests should now be 0
+ this.mRequestCount = 0;
+
+ let modifiedAttrs = [];
+ if (this.mTab.hasAttribute("busy")) {
+ this.mTab.removeAttribute("busy");
+ modifiedAttrs.push("busy");
+
+ // Only animate the "burst" indicating the page has loaded if
+ // the top-level page is the one that finished loading.
+ if (
+ aWebProgress.isTopLevel &&
+ !aWebProgress.isLoadingDocument &&
+ Components.isSuccessCode(aStatus) &&
+ !gBrowser.tabAnimationsInProgress &&
+ !gReduceMotion
+ ) {
+ if (this.mTab._notselectedsinceload) {
+ this.mTab.setAttribute("notselectedsinceload", "true");
+ } else {
+ this.mTab.removeAttribute("notselectedsinceload");
+ }
+
+ this.mTab.setAttribute("bursting", "true");
+ }
+ }
+
+ if (this.mTab.hasAttribute("progress")) {
+ this.mTab.removeAttribute("progress");
+ modifiedAttrs.push("progress");
+ }
+
+ if (modifiedAttrs.length) {
+ gBrowser._tabAttrModified(this.mTab, modifiedAttrs);
+ }
+
+ if (aWebProgress.isTopLevel) {
+ let isSuccessful = Components.isSuccessCode(aStatus);
+ if (!isSuccessful && !this.mTab.isEmpty) {
+ // Restore the current document's location in case the
+ // request was stopped (possibly from a content script)
+ // before the location changed.
+
+ this.mBrowser.userTypedValue = null;
+ // When browser.tabs.documentchannel.parent-controlled pref and SHIP
+ // are enabled and a load gets cancelled due to another one
+ // starting, the error is NS_BINDING_CANCELLED_OLD_LOAD.
+ // When these prefs are not enabled, the error is different and
+ // that's why we still want to look at the isNavigating flag.
+ // We could add a workaround and make sure that in the alternative
+ // codepaths we would also omit the same error, but considering
+ // how we will be enabling fission by default soon, we can keep
+ // using isNavigating for now, and remove it when the
+ // parent-controlled pref and SHIP are enabled by default.
+ // Bug 1725716 has been filed to consider removing isNavigating
+ // field alltogether.
+ let isNavigating = this.mBrowser.isNavigating;
+ if (
+ this.mTab.selected &&
+ aStatus != Cr.NS_BINDING_CANCELLED_OLD_LOAD &&
+ !isNavigating
+ ) {
+ gURLBar.setURI();
+ }
+ } else if (isSuccessful) {
+ this.mBrowser.urlbarChangeTracker.finishedLoad();
+ }
+ }
+
+ // If we don't already have an icon for this tab then clear the tab's
+ // icon. Don't do this on the initial about:blank load to prevent
+ // flickering. Don't clear the icon if we already set it from one of the
+ // known defaults. Note we use the original URL since about:newtab
+ // redirects to a prerendered page.
+ if (
+ !this.mBrowser.mIconURL &&
+ !ignoreBlank &&
+ !(originalLocation.spec in FAVICON_DEFAULTS)
+ ) {
+ this.mTab.removeAttribute("image");
+ }
+
+ // For keyword URIs clear the user typed value since they will be changed into real URIs
+ if (location.scheme == "keyword") {
+ this.mBrowser.userTypedValue = null;
+ }
+
+ if (this.mTab.selected) {
+ gBrowser._isBusy = false;
+ }
+ }
+
+ if (ignoreBlank) {
+ this._callProgressListeners(
+ "onUpdateCurrentBrowser",
+ [aStateFlags, aStatus, "", 0],
+ true,
+ false
+ );
+ } else {
+ this._callProgressListeners(
+ "onStateChange",
+ [aWebProgress, aRequest, aStateFlags, aStatus],
+ true,
+ false
+ );
+ }
+
+ this._callProgressListeners(
+ "onStateChange",
+ [aWebProgress, aRequest, aStateFlags, aStatus],
+ false
+ );
+
+ if (aStateFlags & (STATE_START | STATE_STOP)) {
+ // reset cached temporary values at beginning and end
+ this.mMessage = "";
+ this.mTotalProgress = 0;
+ }
+ this.mStateFlags = aStateFlags;
+ this.mStatus = aStatus;
+ }
+ /* eslint-enable complexity */
+
+ onLocationChange(aWebProgress, aRequest, aLocation, aFlags) {
+ // OnLocationChange is called for both the top-level content
+ // and the subframes.
+ let topLevel = aWebProgress.isTopLevel;
+
+ let isSameDocument = !!(
+ aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT
+ );
+ if (topLevel) {
+ let isReload = !!(
+ aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_RELOAD
+ );
+ let isErrorPage = !!(
+ aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_ERROR_PAGE
+ );
+
+ // We need to clear the typed value
+ // if the document failed to load, to make sure the urlbar reflects the
+ // failed URI (particularly for SSL errors). However, don't clear the value
+ // if the error page's URI is about:blank, because that causes complete
+ // loss of urlbar contents for invalid URI errors (see bug 867957).
+ // Another reason to clear the userTypedValue is if this was an anchor
+ // navigation initiated by the user.
+ // Finally, we do insert the URL if this is a same-document navigation
+ // and the user cleared the URL manually.
+ if (
+ this.mBrowser.didStartLoadSinceLastUserTyping() ||
+ (isErrorPage && aLocation.spec != "about:blank") ||
+ (isSameDocument && this.mBrowser.isNavigating) ||
+ (isSameDocument && !this.mBrowser.userTypedValue)
+ ) {
+ this.mBrowser.userTypedValue = null;
+ }
+
+ // If the tab has been set to "busy" outside the stateChange
+ // handler below (e.g. by sessionStore.navigateAndRestore), and
+ // the load results in an error page, it's possible that there
+ // isn't any (STATE_IS_NETWORK & STATE_STOP) state to cause busy
+ // attribute being removed. In this case we should remove the
+ // attribute here.
+ if (isErrorPage && this.mTab.hasAttribute("busy")) {
+ this.mTab.removeAttribute("busy");
+ gBrowser._tabAttrModified(this.mTab, ["busy"]);
+ }
+
+ if (!isSameDocument) {
+ // If the browser was playing audio, we should remove the playing state.
+ if (this.mTab.hasAttribute("soundplaying")) {
+ clearTimeout(this.mTab._soundPlayingAttrRemovalTimer);
+ this.mTab._soundPlayingAttrRemovalTimer = 0;
+ this.mTab.removeAttribute("soundplaying");
+ gBrowser._tabAttrModified(this.mTab, ["soundplaying"]);
+ }
+
+ // If the browser was previously muted, we should restore the muted state.
+ if (this.mTab.hasAttribute("muted")) {
+ this.mTab.linkedBrowser.mute();
+ }
+
+ if (gBrowser.isFindBarInitialized(this.mTab)) {
+ let findBar = gBrowser.getCachedFindBar(this.mTab);
+
+ // Close the Find toolbar if we're in old-style TAF mode
+ if (findBar.findMode != findBar.FIND_NORMAL) {
+ findBar.close();
+ }
+ }
+
+ // Note that we're not updating for same-document loads, despite
+ // the `title` argument to `history.pushState/replaceState`. For
+ // context, see https://bugzilla.mozilla.org/show_bug.cgi?id=585653
+ // and https://github.com/whatwg/html/issues/2174
+ if (!isReload) {
+ gBrowser.setTabTitle(this.mTab);
+ }
+
+ // Don't clear the favicon if this tab is in the pending
+ // state, as SessionStore will have set the icon for us even
+ // though we're pointed at an about:blank. Also don't clear it
+ // if the tab is in customize mode, to keep the one set by
+ // gCustomizeMode.setTab (bug 1551239). Also don't clear it
+ // if onLocationChange was triggered by a pushState or a
+ // replaceState (bug 550565) or a hash change (bug 408415).
+ if (
+ !this.mTab.hasAttribute("pending") &&
+ !this.mTab.hasAttribute("customizemode") &&
+ aWebProgress.isLoadingDocument
+ ) {
+ // Removing the tab's image here causes flickering, wait until the
+ // load is complete.
+ this.mBrowser.mIconURL = null;
+ }
+
+ if (!isReload && aWebProgress.isLoadingDocument) {
+ let triggerer = gBrowser._getTriggeringPrincipalFromHistory(
+ this.mBrowser
+ );
+ // Typing a url, searching or clicking a bookmark will load a new
+ // document that is no longer tied to a navigation from the previous
+ // content and will have a system principal as the triggerer.
+ if (triggerer && triggerer.isSystemPrincipal) {
+ // Reset the related tab map so that the next tab opened will be related
+ // to this new document and not to tabs opened by the previous one.
+ gBrowser.clearRelatedTabs();
+ }
+ }
+
+ if (
+ aRequest instanceof Ci.nsIChannel &&
+ !isBlankPageURL(aRequest.originalURI.spec)
+ ) {
+ this.mBrowser.originalURI = aRequest.originalURI;
+ }
+ }
+
+ let userContextId = this.mBrowser.getAttribute("usercontextid") || 0;
+ if (this.mBrowser.registeredOpenURI) {
+ let uri = this.mBrowser.registeredOpenURI;
+ gBrowser.UrlbarProviderOpenTabs.unregisterOpenTab(
+ uri.spec,
+ userContextId,
+ PrivateBrowsingUtils.isWindowPrivate(window)
+ );
+ delete this.mBrowser.registeredOpenURI;
+ }
+ if (!isBlankPageURL(aLocation.spec)) {
+ gBrowser.UrlbarProviderOpenTabs.registerOpenTab(
+ aLocation.spec,
+ userContextId,
+ PrivateBrowsingUtils.isWindowPrivate(window)
+ );
+ this.mBrowser.registeredOpenURI = aLocation;
+ }
+
+ if (this.mTab != gBrowser.selectedTab) {
+ let tabCacheIndex = gBrowser._tabLayerCache.indexOf(this.mTab);
+ if (tabCacheIndex != -1) {
+ gBrowser._tabLayerCache.splice(tabCacheIndex, 1);
+ gBrowser._getSwitcher().cleanUpTabAfterEviction(this.mTab);
+ }
+ } else {
+ if (
+ gBrowser.featureCallout &&
+ (gBrowser.featureCalloutPanelId !==
+ gBrowser.selectedTab.linkedPanel ||
+ gBrowser.contentPrincipal.originNoSuffix !== "resource://pdf.js")
+ ) {
+ gBrowser.featureCallout.endTour(true);
+ gBrowser.featureCallout = null;
+ }
+
+ // For now, only check for Feature Callout messages
+ // when viewing PDFs. Later, we can expand this to check
+ // for callout messages on every change of tab location.
+ if (
+ !gBrowser.featureCallout &&
+ gBrowser.contentPrincipal.originNoSuffix === "resource://pdf.js"
+ ) {
+ gBrowser.instantiateFeatureCalloutTour(
+ gBrowser.selectedBrowser,
+ gBrowser.selectedTab.linkedPanel
+ );
+ gBrowser.featureCallout.showFeatureCallout();
+ }
+ }
+ }
+
+ if (!this.mBlank || this.mBrowser.hasContentOpener) {
+ this._callProgressListeners("onLocationChange", [
+ aWebProgress,
+ aRequest,
+ aLocation,
+ aFlags,
+ ]);
+ if (topLevel && !isSameDocument) {
+ // Include the true final argument to indicate that this event is
+ // simulated (instead of being observed by the webProgressListener).
+ this._callProgressListeners("onContentBlockingEvent", [
+ aWebProgress,
+ null,
+ 0,
+ true,
+ ]);
+ }
+ }
+
+ if (topLevel) {
+ this.mBrowser.lastURI = aLocation;
+ this.mBrowser.lastLocationChange = Date.now();
+ }
+ }
+
+ onStatusChange(aWebProgress, aRequest, aStatus, aMessage) {
+ if (this.mBlank) {
+ return;
+ }
+
+ this._callProgressListeners("onStatusChange", [
+ aWebProgress,
+ aRequest,
+ aStatus,
+ aMessage,
+ ]);
+
+ this.mMessage = aMessage;
+ }
+
+ onSecurityChange(aWebProgress, aRequest, aState) {
+ this._callProgressListeners("onSecurityChange", [
+ aWebProgress,
+ aRequest,
+ aState,
+ ]);
+ }
+
+ onContentBlockingEvent(aWebProgress, aRequest, aEvent) {
+ this._callProgressListeners("onContentBlockingEvent", [
+ aWebProgress,
+ aRequest,
+ aEvent,
+ ]);
+ }
+
+ onRefreshAttempted(aWebProgress, aURI, aDelay, aSameURI) {
+ return this._callProgressListeners("onRefreshAttempted", [
+ aWebProgress,
+ aURI,
+ aDelay,
+ aSameURI,
+ ]);
+ }
+ }
+ TabProgressListener.prototype.QueryInterface = ChromeUtils.generateQI([
+ "nsIWebProgressListener",
+ "nsIWebProgressListener2",
+ "nsISupportsWeakReference",
+ ]);
+
+ let URILoadingWrapper = {
+ _normalizeLoadURIOptions(browser, loadURIOptions) {
+ if (!loadURIOptions.triggeringPrincipal) {
+ throw new Error("Must load with a triggering Principal");
+ }
+
+ if (
+ loadURIOptions.userContextId &&
+ loadURIOptions.userContextId != browser.getAttribute("usercontextid")
+ ) {
+ throw new Error("Cannot load with mismatched userContextId");
+ }
+
+ loadURIOptions.loadFlags |= loadURIOptions.flags | LOAD_FLAGS_NONE;
+ delete loadURIOptions.flags;
+ loadURIOptions.hasValidUserGestureActivation ??=
+ document.hasValidTransientUserGestureActivation;
+ },
+
+ _loadFlagsToFixupFlags(browser, loadFlags) {
+ // Attempt to perform URI fixup to see if we can handle this URI in chrome.
+ let fixupFlags = Ci.nsIURIFixup.FIXUP_FLAG_NONE;
+ if (loadFlags & LOAD_FLAGS_ALLOW_THIRD_PARTY_FIXUP) {
+ fixupFlags |= Ci.nsIURIFixup.FIXUP_FLAG_ALLOW_KEYWORD_LOOKUP;
+ }
+ if (loadFlags & LOAD_FLAGS_FIXUP_SCHEME_TYPOS) {
+ fixupFlags |= Ci.nsIURIFixup.FIXUP_FLAG_FIX_SCHEME_TYPOS;
+ }
+ if (PrivateBrowsingUtils.isBrowserPrivate(browser)) {
+ fixupFlags |= Ci.nsIURIFixup.FIXUP_FLAG_PRIVATE_CONTEXT;
+ }
+ return fixupFlags;
+ },
+
+ _fixupURIString(browser, uriString, loadURIOptions) {
+ let fixupFlags = this._loadFlagsToFixupFlags(
+ browser,
+ loadURIOptions.loadFlags
+ );
+
+ // XXXgijs: If we switch to loading the URI we return from this method,
+ // rather than redoing fixup in docshell (see bug 1815509), we need to
+ // ensure that the loadURIOptions have the fixup flag removed here for
+ // loads where `uriString` already parses if just passed immediately
+ // to `newURI`.
+ // Right now this happens in nsDocShellLoadState code.
+ try {
+ let fixupInfo = Services.uriFixup.getFixupURIInfo(
+ uriString,
+ fixupFlags
+ );
+ return fixupInfo.preferredURI;
+ } catch (e) {
+ // getFixupURIInfo may throw. Just return null, our caller will deal.
+ }
+ return null;
+ },
+
+ /**
+ * Handles URIs when we want to deal with them in chrome code rather than pass
+ * them down to a content browser. This can avoid unnecessary process switching
+ * for the browser.
+ * @param aBrowser the browser that is attempting to load the URI
+ * @param aUri the nsIURI that is being loaded
+ * @returns true if the URI is handled, otherwise false
+ */
+ _handleUriInChrome(aBrowser, aUri) {
+ if (aUri.scheme == "file") {
+ try {
+ let mimeType = Cc["@mozilla.org/mime;1"]
+ .getService(Ci.nsIMIMEService)
+ .getTypeFromURI(aUri);
+ if (mimeType == "application/x-xpinstall") {
+ let systemPrincipal =
+ Services.scriptSecurityManager.getSystemPrincipal();
+ AddonManager.getInstallForURL(aUri.spec, {
+ telemetryInfo: { source: "file-url" },
+ }).then(install => {
+ AddonManager.installAddonFromWebpage(
+ mimeType,
+ aBrowser,
+ systemPrincipal,
+ install
+ );
+ });
+ return true;
+ }
+ } catch (e) {
+ return false;
+ }
+ }
+
+ return false;
+ },
+
+ _updateTriggerMetadataForLoad(
+ browser,
+ uriString,
+ { loadFlags, globalHistoryOptions }
+ ) {
+ if (globalHistoryOptions?.triggeringSponsoredURL) {
+ try {
+ // Browser may access URL after fixing it up, then store the URL into DB.
+ // To match with it, fix the link up explicitly.
+ const triggeringSponsoredURL = Services.uriFixup.getFixupURIInfo(
+ globalHistoryOptions.triggeringSponsoredURL,
+ this._loadFlagsToFixupFlags(browser, loadFlags)
+ ).fixedURI.spec;
+ browser.setAttribute(
+ "triggeringSponsoredURL",
+ triggeringSponsoredURL
+ );
+ const time =
+ globalHistoryOptions.triggeringSponsoredURLVisitTimeMS ||
+ Date.now();
+ browser.setAttribute("triggeringSponsoredURLVisitTimeMS", time);
+ } catch (e) {}
+ }
+
+ if (globalHistoryOptions?.triggeringSearchEngine) {
+ browser.setAttribute(
+ "triggeringSearchEngine",
+ globalHistoryOptions.triggeringSearchEngine
+ );
+ browser.setAttribute("triggeringSearchEngineURL", uriString);
+ } else {
+ browser.removeAttribute("triggeringSearchEngine");
+ browser.removeAttribute("triggeringSearchEngineURL");
+ }
+ },
+
+ // Both of these are used to override functions on browser-custom-element.
+ fixupAndLoadURIString(browser, uriString, loadURIOptions = {}) {
+ this._internalMaybeFixupLoadURI(browser, uriString, null, loadURIOptions);
+ },
+ loadURI(browser, uri, loadURIOptions = {}) {
+ this._internalMaybeFixupLoadURI(browser, "", uri, loadURIOptions);
+ },
+
+ // A shared function used by both remote and non-remote browsers to
+ // load a string URI or redirect it to the correct process.
+ _internalMaybeFixupLoadURI(browser, uriString, uri, loadURIOptions) {
+ this._normalizeLoadURIOptions(browser, loadURIOptions);
+ // Some callers pass undefined/null when calling
+ // loadURI/fixupAndLoadURIString. Just load about:blank instead:
+ if (!uriString && !uri) {
+ uri = Services.io.newURI("about:blank");
+ }
+
+ // We need a URI in frontend code for checking various things. Ideally
+ // we would then also pass that URI to webnav/browsingcontext code
+ // for loading, but we historically haven't. Changing this would alter
+ // fixup scenarios in some non-obvious cases.
+ let startedWithURI = !!uri;
+ if (!uri) {
+ // Note: this may return null if we can't make a URI out of the input.
+ uri = this._fixupURIString(browser, uriString, loadURIOptions);
+ }
+
+ if (uri && this._handleUriInChrome(browser, uri)) {
+ // If we've handled the URI in chrome, then just return here.
+ return;
+ }
+
+ this._updateTriggerMetadataForLoad(
+ browser,
+ uriString || uri.spec,
+ loadURIOptions
+ );
+
+ // XXX(nika): Is `browser.isNavigating` necessary anymore?
+ // XXX(gijs): Unsure. But it mirrors docShell.isNavigating, but in the parent process
+ // (and therefore imperfectly so).
+ browser.isNavigating = true;
+
+ try {
+ // Should more generally prefer loadURI here - see bug 1815509.
+ if (startedWithURI) {
+ browser.webNavigation.loadURI(uri, loadURIOptions);
+ } else {
+ browser.webNavigation.fixupAndLoadURIString(
+ uriString,
+ loadURIOptions
+ );
+ }
+ } finally {
+ browser.isNavigating = false;
+ }
+ },
+ };
+} // end private scope for gBrowser
+
+var StatusPanel = {
+ // This is useful for debugging (set to `true` in the interesting state for
+ // the panel to remain in that state).
+ _frozen: false,
+
+ get panel() {
+ delete this.panel;
+ this.panel = document.getElementById("statuspanel");
+ this.panel.addEventListener(
+ "transitionend",
+ this._onTransitionEnd.bind(this)
+ );
+ this.panel.addEventListener(
+ "transitioncancel",
+ this._onTransitionEnd.bind(this)
+ );
+ return this.panel;
+ },
+
+ get isVisible() {
+ return !this.panel.hasAttribute("inactive");
+ },
+
+ update() {
+ if (BrowserHandler.kiosk || this._frozen) {
+ return;
+ }
+ let text;
+ let type;
+ let types = ["overLink"];
+ if (XULBrowserWindow.busyUI) {
+ types.push("status");
+ }
+ types.push("defaultStatus");
+ for (type of types) {
+ if ((text = XULBrowserWindow[type])) {
+ break;
+ }
+ }
+
+ // If it's a long data: URI that uses base64 encoding, truncate to
+ // a reasonable length rather than trying to display the entire thing.
+ // We can't shorten arbitrary URIs like this, as bidi etc might mean
+ // we need the trailing characters for display. But a base64-encoded
+ // data-URI is plain ASCII, so this is OK for status panel display.
+ // (See bug 1484071.)
+ let textCropped = false;
+ if (text.length > 500 && text.match(/^data:[^,]+;base64,/)) {
+ text = text.substring(0, 500) + "\u2026";
+ textCropped = true;
+ }
+
+ if (this._labelElement.value != text || (text && !this.isVisible)) {
+ this.panel.setAttribute("previoustype", this.panel.getAttribute("type"));
+ this.panel.setAttribute("type", type);
+
+ this._label = text;
+ this._labelElement.setAttribute(
+ "crop",
+ type == "overLink" && !textCropped ? "center" : "end"
+ );
+ }
+ },
+
+ get _labelElement() {
+ delete this._labelElement;
+ return (this._labelElement = document.getElementById("statuspanel-label"));
+ },
+
+ set _label(val) {
+ if (!this.isVisible) {
+ this.panel.removeAttribute("mirror");
+ this.panel.removeAttribute("sizelimit");
+ }
+
+ if (
+ this.panel.getAttribute("type") == "status" &&
+ this.panel.getAttribute("previoustype") == "status"
+ ) {
+ // Before updating the label, set the panel's current width as its
+ // min-width to let the panel grow but not shrink and prevent
+ // unnecessary flicker while loading pages. We only care about the
+ // panel's width once it has been painted, so we can do this
+ // without flushing layout.
+ this.panel.style.minWidth =
+ window.windowUtils.getBoundsWithoutFlushing(this.panel).width + "px";
+ } else {
+ this.panel.style.minWidth = "";
+ }
+
+ if (val) {
+ this._labelElement.value = val;
+ if (this.panel.hidden) {
+ this.panel.hidden = false;
+ // This ensures that the "inactive" attribute removal triggers a
+ // transition.
+ getComputedStyle(this.panel).display;
+ }
+ this.panel.removeAttribute("inactive");
+ MousePosTracker.addListener(this);
+ } else {
+ this.panel.setAttribute("inactive", "true");
+ MousePosTracker.removeListener(this);
+ }
+ },
+
+ _onTransitionEnd() {
+ if (!this.isVisible) {
+ this.panel.hidden = true;
+ }
+ },
+
+ getMouseTargetRect() {
+ let container = this.panel.parentNode;
+ let panelRect = window.windowUtils.getBoundsWithoutFlushing(this.panel);
+ let containerRect = window.windowUtils.getBoundsWithoutFlushing(container);
+
+ return {
+ top: panelRect.top,
+ bottom: panelRect.bottom,
+ left: RTL_UI ? containerRect.right - panelRect.width : containerRect.left,
+ right: RTL_UI
+ ? containerRect.right
+ : containerRect.left + panelRect.width,
+ };
+ },
+
+ onMouseEnter() {
+ this._mirror();
+ },
+
+ onMouseLeave() {
+ this._mirror();
+ },
+
+ _mirror() {
+ if (this._frozen) {
+ return;
+ }
+ if (this.panel.hasAttribute("mirror")) {
+ this.panel.removeAttribute("mirror");
+ } else {
+ this.panel.setAttribute("mirror", "true");
+ }
+
+ if (!this.panel.hasAttribute("sizelimit")) {
+ this.panel.setAttribute("sizelimit", "true");
+ }
+ },
+};
+
+var TabBarVisibility = {
+ _initialUpdateDone: false,
+
+ update() {
+ let toolbar = document.getElementById("TabsToolbar");
+ let collapse = false;
+ if (
+ !gBrowser /* gBrowser isn't initialized yet */ ||
+ gBrowser.visibleTabs.length == 1
+ ) {
+ collapse = !window.toolbar.visible;
+ }
+
+ if (collapse == toolbar.collapsed && this._initialUpdateDone) {
+ return;
+ }
+ this._initialUpdateDone = true;
+
+ toolbar.collapsed = collapse;
+ let navbar = document.getElementById("nav-bar");
+ navbar.setAttribute("tabs-hidden", collapse);
+
+ document.getElementById("menu_closeWindow").hidden = collapse;
+ document.l10n.setAttributes(
+ document.getElementById("menu_close"),
+ collapse ? "tabbrowser-menuitem-close" : "tabbrowser-menuitem-close-tab"
+ );
+
+ TabsInTitlebar.allowedBy("tabs-visible", !collapse);
+ },
+};
+
+var TabContextMenu = {
+ contextTab: null,
+ _updateToggleMuteMenuItems(aTab, aConditionFn) {
+ ["muted", "soundplaying"].forEach(attr => {
+ if (!aConditionFn || aConditionFn(attr)) {
+ if (aTab.hasAttribute(attr)) {
+ aTab.toggleMuteMenuItem.setAttribute(attr, "true");
+ aTab.toggleMultiSelectMuteMenuItem.setAttribute(attr, "true");
+ } else {
+ aTab.toggleMuteMenuItem.removeAttribute(attr);
+ aTab.toggleMultiSelectMuteMenuItem.removeAttribute(attr);
+ }
+ }
+ });
+ },
+ updateContextMenu(aPopupMenu) {
+ let tab =
+ aPopupMenu.triggerNode &&
+ (aPopupMenu.triggerNode.tab || aPopupMenu.triggerNode.closest("tab"));
+
+ this.contextTab = tab || gBrowser.selectedTab;
+ this.contextTab.addEventListener("TabAttrModified", this);
+ aPopupMenu.addEventListener("popuphiding", this);
+
+ let disabled = gBrowser.tabs.length == 1;
+ let multiselectionContext = this.contextTab.multiselected;
+ let tabCountInfo = JSON.stringify({
+ tabCount: (multiselectionContext && gBrowser.multiSelectedTabsCount) || 1,
+ });
+
+ var menuItems = aPopupMenu.getElementsByAttribute(
+ "tbattr",
+ "tabbrowser-multiple"
+ );
+ for (let menuItem of menuItems) {
+ menuItem.disabled = disabled;
+ }
+
+ disabled = gBrowser.visibleTabs.length == 1;
+ menuItems = aPopupMenu.getElementsByAttribute(
+ "tbattr",
+ "tabbrowser-multiple-visible"
+ );
+ for (let menuItem of menuItems) {
+ menuItem.disabled = disabled;
+ }
+
+ // Session store
+ document.getElementById("context_undoCloseTab").disabled =
+ SessionStore.getClosedTabCountForWindow(window) == 0;
+
+ // Show/hide fullscreen context menu items and set the
+ // autohide item's checked state to mirror the autohide pref.
+ showFullScreenViewContextMenuItems(aPopupMenu);
+
+ // Only one of Reload_Tab/Reload_Selected_Tabs should be visible.
+ document.getElementById("context_reloadTab").hidden = multiselectionContext;
+ document.getElementById("context_reloadSelectedTabs").hidden =
+ !multiselectionContext;
+
+ // Show Play Tab menu item if the tab has attribute activemedia-blocked
+ document.getElementById("context_playTab").hidden = !(
+ this.contextTab.activeMediaBlocked && !multiselectionContext
+ );
+ document.getElementById("context_playSelectedTabs").hidden = !(
+ this.contextTab.activeMediaBlocked && multiselectionContext
+ );
+
+ // Only one of pin/unpin/multiselect-pin/multiselect-unpin should be visible
+ let contextPinTab = document.getElementById("context_pinTab");
+ contextPinTab.hidden = this.contextTab.pinned || multiselectionContext;
+ let contextUnpinTab = document.getElementById("context_unpinTab");
+ contextUnpinTab.hidden = !this.contextTab.pinned || multiselectionContext;
+ let contextPinSelectedTabs = document.getElementById(
+ "context_pinSelectedTabs"
+ );
+ contextPinSelectedTabs.hidden =
+ this.contextTab.pinned || !multiselectionContext;
+ let contextUnpinSelectedTabs = document.getElementById(
+ "context_unpinSelectedTabs"
+ );
+ contextUnpinSelectedTabs.hidden =
+ !this.contextTab.pinned || !multiselectionContext;
+
+ // Move Tab items
+ let contextMoveTabOptions = document.getElementById(
+ "context_moveTabOptions"
+ );
+ contextMoveTabOptions.setAttribute("data-l10n-args", tabCountInfo);
+ contextMoveTabOptions.disabled =
+ this.contextTab.hidden || gBrowser.allTabsSelected();
+ let selectedTabs = gBrowser.selectedTabs;
+ let contextMoveTabToEnd = document.getElementById("context_moveToEnd");
+ let allSelectedTabsAdjacent = selectedTabs.every(
+ (element, index, array) => {
+ return array.length > index + 1
+ ? element._tPos + 1 == array[index + 1]._tPos
+ : true;
+ }
+ );
+ let contextTabIsSelected = this.contextTab.multiselected;
+ let visibleTabs = gBrowser.visibleTabs;
+ let lastVisibleTab = visibleTabs[visibleTabs.length - 1];
+ let tabsToMove = contextTabIsSelected ? selectedTabs : [this.contextTab];
+ let lastTabToMove = tabsToMove[tabsToMove.length - 1];
+
+ let isLastPinnedTab = false;
+ if (lastTabToMove.pinned) {
+ let sibling = gBrowser.tabContainer.findNextTab(lastTabToMove);
+ isLastPinnedTab = !sibling || !sibling.pinned;
+ }
+ contextMoveTabToEnd.disabled =
+ (lastTabToMove == lastVisibleTab || isLastPinnedTab) &&
+ allSelectedTabsAdjacent;
+ let contextMoveTabToStart = document.getElementById("context_moveToStart");
+ let isFirstTab =
+ tabsToMove[0] == visibleTabs[0] ||
+ tabsToMove[0] == visibleTabs[gBrowser._numPinnedTabs];
+ contextMoveTabToStart.disabled = isFirstTab && allSelectedTabsAdjacent;
+
+ if (this.contextTab.hasAttribute("customizemode")) {
+ document.getElementById("context_openTabInWindow").disabled = true;
+ }
+
+ // Only one of "Duplicate Tab"/"Duplicate Tabs" should be visible.
+ document.getElementById("context_duplicateTab").hidden =
+ multiselectionContext;
+ document.getElementById("context_duplicateTabs").hidden =
+ !multiselectionContext;
+
+ // Disable "Close Tabs to the Left/Right" if there are no tabs
+ // preceding/following it.
+ let closeTabsToTheStartItem = document.getElementById(
+ "context_closeTabsToTheStart"
+ );
+ let noTabsToStart = !gBrowser.getTabsToTheStartFrom(this.contextTab).length;
+ closeTabsToTheStartItem.disabled = noTabsToStart;
+ let closeTabsToTheEndItem = document.getElementById(
+ "context_closeTabsToTheEnd"
+ );
+ let noTabsToEnd = !gBrowser.getTabsToTheEndFrom(this.contextTab).length;
+ closeTabsToTheEndItem.disabled = noTabsToEnd;
+
+ // Disable "Close other Tabs" if there are no unpinned tabs.
+ let unpinnedTabsToClose = multiselectionContext
+ ? gBrowser.visibleTabs.filter(t => !t.multiselected && !t.pinned).length
+ : gBrowser.visibleTabs.filter(t => t != this.contextTab && !t.pinned)
+ .length;
+ let closeOtherTabsItem = document.getElementById("context_closeOtherTabs");
+ closeOtherTabsItem.disabled = unpinnedTabsToClose < 1;
+
+ // Update the close item with how many tabs will close.
+ document
+ .getElementById("context_closeTab")
+ .setAttribute("data-l10n-args", tabCountInfo);
+
+ // Disable "Close Multiple Tabs" if all sub menuitems are disabled
+ document.getElementById("context_closeTabOptions").disabled =
+ closeTabsToTheStartItem.disabled &&
+ closeTabsToTheEndItem.disabled &&
+ closeOtherTabsItem.disabled;
+
+ // Hide "Bookmark Tab…" for multiselection.
+ // Update its state if visible.
+ let bookmarkTab = document.getElementById("context_bookmarkTab");
+ bookmarkTab.hidden = multiselectionContext;
+
+ // Show "Bookmark Selected Tabs" in a multiselect context and hide it otherwise.
+ let bookmarkMultiSelectedTabs = document.getElementById(
+ "context_bookmarkSelectedTabs"
+ );
+ bookmarkMultiSelectedTabs.hidden = !multiselectionContext;
+
+ let toggleMute = document.getElementById("context_toggleMuteTab");
+ let toggleMultiSelectMute = document.getElementById(
+ "context_toggleMuteSelectedTabs"
+ );
+
+ // Only one of mute_unmute_tab/mute_unmute_selected_tabs should be visible
+ toggleMute.hidden = multiselectionContext;
+ toggleMultiSelectMute.hidden = !multiselectionContext;
+
+ const isMuted = this.contextTab.hasAttribute("muted");
+ document.l10n.setAttributes(
+ toggleMute,
+ isMuted ? "tabbrowser-context-unmute-tab" : "tabbrowser-context-mute-tab"
+ );
+ document.l10n.setAttributes(
+ toggleMultiSelectMute,
+ isMuted
+ ? "tabbrowser-context-unmute-selected-tabs"
+ : "tabbrowser-context-mute-selected-tabs"
+ );
+
+ this.contextTab.toggleMuteMenuItem = toggleMute;
+ this.contextTab.toggleMultiSelectMuteMenuItem = toggleMultiSelectMute;
+ this._updateToggleMuteMenuItems(this.contextTab);
+
+ let selectAllTabs = document.getElementById("context_selectAllTabs");
+ selectAllTabs.disabled = gBrowser.allTabsSelected();
+
+ gSync.updateTabContextMenu(aPopupMenu, this.contextTab);
+
+ let reopenInContainer = document.getElementById(
+ "context_reopenInContainer"
+ );
+ reopenInContainer.hidden =
+ !Services.prefs.getBoolPref("privacy.userContext.enabled", false) ||
+ PrivateBrowsingUtils.isWindowPrivate(window);
+ reopenInContainer.disabled = this.contextTab.hidden;
+
+ gShareUtils.updateShareURLMenuItem(
+ this.contextTab.linkedBrowser,
+ document.getElementById("context_sendTabToDevice")
+ );
+ },
+
+ handleEvent(aEvent) {
+ switch (aEvent.type) {
+ case "popuphiding":
+ if (aEvent.target.id == "tabContextMenu") {
+ this.contextTab.removeEventListener("TabAttrModified", this);
+ }
+ break;
+ case "TabAttrModified":
+ let tab = aEvent.target;
+ this._updateToggleMuteMenuItems(tab, attr =>
+ aEvent.detail.changed.includes(attr)
+ );
+ break;
+ }
+ },
+
+ createReopenInContainerMenu(event) {
+ createUserContextMenu(event, {
+ isContextMenu: true,
+ excludeUserContextId: this.contextTab.getAttribute("usercontextid"),
+ });
+ },
+ duplicateSelectedTabs() {
+ let tabsToDuplicate = gBrowser.selectedTabs;
+ let newIndex = tabsToDuplicate[tabsToDuplicate.length - 1]._tPos + 1;
+ for (let tab of tabsToDuplicate) {
+ let newTab = SessionStore.duplicateTab(window, tab);
+ gBrowser.moveTabTo(newTab, newIndex++);
+ }
+ },
+ reopenInContainer(event) {
+ let userContextId = parseInt(
+ event.target.getAttribute("data-usercontextid")
+ );
+ let reopenedTabs = this.contextTab.multiselected
+ ? gBrowser.selectedTabs
+ : [this.contextTab];
+
+ for (let tab of reopenedTabs) {
+ if (tab.getAttribute("usercontextid") == userContextId) {
+ continue;
+ }
+
+ /* Create a triggering principal that is able to load the new tab
+ For content principals that are about: chrome: or resource: we need system to load them.
+ Anything other than system principal needs to have the new userContextId.
+ */
+ let triggeringPrincipal;
+
+ if (tab.linkedPanel) {
+ triggeringPrincipal = tab.linkedBrowser.contentPrincipal;
+ } else {
+ // For lazy tab browsers, get the original principal
+ // from SessionStore
+ let tabState = JSON.parse(SessionStore.getTabState(tab));
+ try {
+ triggeringPrincipal = E10SUtils.deserializePrincipal(
+ tabState.triggeringPrincipal_base64
+ );
+ } catch (ex) {
+ continue;
+ }
+ }
+
+ if (!triggeringPrincipal || triggeringPrincipal.isNullPrincipal) {
+ // Ensure that we have a null principal if we couldn't
+ // deserialize it (for lazy tab browsers) ...
+ // This won't always work however is safe to use.
+ triggeringPrincipal =
+ Services.scriptSecurityManager.createNullPrincipal({ userContextId });
+ } else if (triggeringPrincipal.isContentPrincipal) {
+ triggeringPrincipal = Services.scriptSecurityManager.principalWithOA(
+ triggeringPrincipal,
+ {
+ userContextId,
+ }
+ );
+ }
+
+ let newTab = gBrowser.addTab(tab.linkedBrowser.currentURI.spec, {
+ userContextId,
+ pinned: tab.pinned,
+ index: tab._tPos + 1,
+ triggeringPrincipal,
+ });
+
+ if (gBrowser.selectedTab == tab) {
+ gBrowser.selectedTab = newTab;
+ }
+ if (tab.muted && !newTab.muted) {
+ newTab.toggleMuteAudio(tab.muteReason);
+ }
+ }
+ },
+
+ closeContextTabs(event) {
+ if (this.contextTab.multiselected) {
+ gBrowser.removeMultiSelectedTabs();
+ } else {
+ gBrowser.removeTab(this.contextTab, { animate: true });
+ }
+ },
+};
diff --git a/browser/base/content/test/about/POSTSearchEngine.xml b/browser/base/content/test/about/POSTSearchEngine.xml
new file mode 100644
index 0000000000..f2f884cf51
--- /dev/null
+++ b/browser/base/content/test/about/POSTSearchEngine.xml
@@ -0,0 +1,6 @@
+<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/">
+ <ShortName>POST Search</ShortName>
+ <Url type="text/html" method="POST" template="http://mochi.test:8888/browser/browser/base/content/test/about/print_postdata.sjs">
+ <Param name="searchterms" value="{searchTerms}"/>
+ </Url>
+</OpenSearchDescription>
diff --git a/browser/base/content/test/about/browser.ini b/browser/base/content/test/about/browser.ini
new file mode 100644
index 0000000000..ce82ff8006
--- /dev/null
+++ b/browser/base/content/test/about/browser.ini
@@ -0,0 +1,59 @@
+[DEFAULT]
+support-files =
+ head.js
+ print_postdata.sjs
+ searchSuggestionEngine.sjs
+ searchSuggestionEngine.xml
+ slow_loading_page.sjs
+ POSTSearchEngine.xml
+ dummy_page.html
+
+[browser_aboutCertError.js]
+[browser_aboutCertError_clockSkew.js]
+[browser_aboutCertError_exception.js]
+[browser_aboutCertError_mitm.js]
+[browser_aboutCertError_noSubjectAltName.js]
+[browser_aboutCertError_offlineSupport.js]
+[browser_aboutCertError_telemetry.js]
+[browser_aboutDialog_distribution.js]
+[browser_aboutHome_search_POST.js]
+[browser_aboutHome_search_composing.js]
+[browser_aboutHome_search_searchbar.js]
+[browser_aboutHome_search_suggestion.js]
+skip-if =
+ os == "mac"
+ os == "linux" && (!debug || bits == 64)
+ os == 'win' && os_version == '10.0' && bits == 64 && !debug # Bug 1399648, bug 1402502
+[browser_aboutHome_search_telemetry.js]
+[browser_aboutNetError.js]
+[browser_aboutNetError_csp_iframe.js]
+https_first_disabled = true
+support-files =
+ iframe_page_csp.html
+ csp_iframe.sjs
+[browser_aboutNetError_native_fallback.js]
+skip-if =
+ socketprocess_networking
+[browser_aboutNetError_trr.js]
+skip-if =
+ socketprocess_networking
+[browser_aboutNetError_xfo_iframe.js]
+https_first_disabled = true
+support-files =
+ iframe_page_xfo.html
+ xfo_iframe.sjs
+[browser_aboutNewTab_bookmarksToolbar.js]
+[browser_aboutNewTab_bookmarksToolbarEmpty.js]
+skip-if = tsan # Bug 1676326, highly frequent on TSan
+[browser_aboutNewTab_bookmarksToolbarNewWindow.js]
+[browser_aboutNewTab_bookmarksToolbarPrefs.js]
+[browser_aboutStopReload.js]
+[browser_aboutSupport.js]
+skip-if =
+ os == 'linux' && bits == 64 && asan && !debug # Bug 1713368
+[browser_aboutSupport_newtab_security_state.js]
+[browser_aboutSupport_places.js]
+skip-if = os == 'android'
+[browser_bug435325.js]
+skip-if = verify && !debug && os == 'mac'
+[browser_bug633691.js]
diff --git a/browser/base/content/test/about/browser_aboutCertError.js b/browser/base/content/test/about/browser_aboutCertError.js
new file mode 100644
index 0000000000..7f1f8149fa
--- /dev/null
+++ b/browser/base/content/test/about/browser_aboutCertError.js
@@ -0,0 +1,548 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// This is testing the aboutCertError page (Bug 1207107).
+
+const GOOD_PAGE = "https://example.com/";
+const GOOD_PAGE_2 = "https://example.org/";
+const BAD_CERT = "https://expired.example.com/";
+const UNKNOWN_ISSUER = "https://self-signed.example.com ";
+const BAD_STS_CERT =
+ "https://badchain.include-subdomains.pinning.example.com:443";
+const { TabStateFlusher } = ChromeUtils.importESModule(
+ "resource:///modules/sessionstore/TabStateFlusher.sys.mjs"
+);
+
+add_task(async function checkReturnToAboutHome() {
+ info(
+ "Loading a bad cert page directly and making sure 'return to previous page' goes to about:home"
+ );
+ for (let useFrame of [false, true]) {
+ let tab = await openErrorPage(BAD_CERT, useFrame);
+ let browser = tab.linkedBrowser;
+
+ is(browser.webNavigation.canGoBack, false, "!webNavigation.canGoBack");
+ is(
+ browser.webNavigation.canGoForward,
+ false,
+ "!webNavigation.canGoForward"
+ );
+
+ // Populate the shistory entries manually, since it happens asynchronously
+ // and the following tests will be too soon otherwise.
+ await TabStateFlusher.flush(browser);
+ let { entries } = JSON.parse(SessionStore.getTabState(tab));
+ is(entries.length, 1, "there is one shistory entry");
+
+ info("Clicking the go back button on about:certerror");
+ let bc = browser.browsingContext;
+ if (useFrame) {
+ bc = bc.children[0];
+ }
+ let locationChangePromise = BrowserTestUtils.waitForLocationChange(
+ gBrowser,
+ "about:home"
+ );
+ await SpecialPowers.spawn(bc, [useFrame], async function (subFrame) {
+ let returnButton = content.document.getElementById("returnButton");
+ if (!subFrame) {
+ if (!Services.focus.focusedElement == returnButton) {
+ await ContentTaskUtils.waitForEvent(returnButton, "focus");
+ }
+ Assert.ok(true, "returnButton has focus");
+ }
+ // Note that going back to about:newtab might cause a process flip, if
+ // the browser is configured to run about:newtab in its own special
+ // content process.
+ returnButton.click();
+ });
+
+ await locationChangePromise;
+
+ is(browser.webNavigation.canGoBack, true, "webNavigation.canGoBack");
+ is(
+ browser.webNavigation.canGoForward,
+ false,
+ "!webNavigation.canGoForward"
+ );
+ is(gBrowser.currentURI.spec, "about:home", "Went back");
+
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ }
+});
+
+add_task(async function checkReturnToPreviousPage() {
+ info(
+ "Loading a bad cert page and making sure 'return to previous page' goes back"
+ );
+ for (let useFrame of [false, true]) {
+ let tab;
+ let browser;
+ if (useFrame) {
+ tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, GOOD_PAGE);
+ browser = tab.linkedBrowser;
+
+ BrowserTestUtils.loadURIString(browser, GOOD_PAGE_2);
+ await BrowserTestUtils.browserLoaded(browser, false, GOOD_PAGE_2);
+ await injectErrorPageFrame(tab, BAD_CERT);
+ } else {
+ tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, GOOD_PAGE);
+ browser = gBrowser.selectedBrowser;
+
+ info("Loading and waiting for the cert error");
+ let certErrorLoaded = BrowserTestUtils.waitForErrorPage(browser);
+ BrowserTestUtils.loadURIString(browser, BAD_CERT);
+ await certErrorLoaded;
+ }
+
+ is(browser.webNavigation.canGoBack, true, "webNavigation.canGoBack");
+ is(
+ browser.webNavigation.canGoForward,
+ false,
+ "!webNavigation.canGoForward"
+ );
+
+ // Populate the shistory entries manually, since it happens asynchronously
+ // and the following tests will be too soon otherwise.
+ await TabStateFlusher.flush(browser);
+ let { entries } = JSON.parse(SessionStore.getTabState(tab));
+ is(entries.length, 2, "there are two shistory entries");
+
+ info("Clicking the go back button on about:certerror");
+ let bc = browser.browsingContext;
+ if (useFrame) {
+ bc = bc.children[0];
+ }
+
+ let pageShownPromise = BrowserTestUtils.waitForContentEvent(
+ browser,
+ "pageshow",
+ true
+ );
+ await SpecialPowers.spawn(bc, [useFrame], async function (subFrame) {
+ let returnButton = content.document.getElementById("returnButton");
+ returnButton.click();
+ });
+ await pageShownPromise;
+
+ is(browser.webNavigation.canGoBack, false, "!webNavigation.canGoBack");
+ is(browser.webNavigation.canGoForward, true, "webNavigation.canGoForward");
+ is(gBrowser.currentURI.spec, GOOD_PAGE, "Went back");
+
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ }
+});
+
+// This checks that the appinfo.appBuildID starts with a date string,
+// which is required for the misconfigured system time check.
+add_task(async function checkAppBuildIDIsDate() {
+ let appBuildID = Services.appinfo.appBuildID;
+ let year = parseInt(appBuildID.substr(0, 4), 10);
+ let month = parseInt(appBuildID.substr(4, 2), 10);
+ let day = parseInt(appBuildID.substr(6, 2), 10);
+
+ ok(year >= 2016 && year <= 2100, "appBuildID contains a valid year");
+ ok(month >= 1 && month <= 12, "appBuildID contains a valid month");
+ ok(day >= 1 && day <= 31, "appBuildID contains a valid day");
+});
+
+add_task(async function checkAdvancedDetails() {
+ info(
+ "Loading a bad cert page and verifying the main error and advanced details section"
+ );
+ for (let useFrame of [false, true]) {
+ let tab = await openErrorPage(BAD_CERT, useFrame);
+ let browser = tab.linkedBrowser;
+
+ let bc = browser.browsingContext;
+ if (useFrame) {
+ bc = bc.children[0];
+ }
+
+ let message = await SpecialPowers.spawn(bc, [], async function () {
+ let doc = content.document;
+
+ const shortDesc = doc.getElementById("errorShortDesc");
+ const sdArgs = JSON.parse(shortDesc.dataset.l10nArgs);
+ is(
+ sdArgs.hostname,
+ "expired.example.com",
+ "Should list hostname in error message."
+ );
+
+ Assert.ok(
+ doc.getElementById("certificateErrorDebugInformation").hidden,
+ "Debug info is initially hidden"
+ );
+
+ let exceptionButton = doc.getElementById("exceptionDialogButton");
+ Assert.ok(
+ !exceptionButton.disabled,
+ "Exception button is not disabled by default."
+ );
+
+ let advancedButton = doc.getElementById("advancedButton");
+ advancedButton.click();
+
+ // Wait until fluent sets the errorCode inner text.
+ let errorCode;
+ await ContentTaskUtils.waitForCondition(() => {
+ errorCode = doc.getElementById("errorCode");
+ return errorCode && errorCode.textContent != "";
+ }, "error code has been set inside the advanced button panel");
+
+ return { textContent: errorCode.textContent, tagName: errorCode.tagName };
+ });
+ is(
+ message.textContent,
+ "SEC_ERROR_EXPIRED_CERTIFICATE",
+ "Correct error message found"
+ );
+ is(message.tagName, "a", "Error message is a link");
+
+ message = await SpecialPowers.spawn(bc, [], async function () {
+ let doc = content.document;
+ let errorCode = doc.getElementById("errorCode");
+ errorCode.click();
+ let div = doc.getElementById("certificateErrorDebugInformation");
+ let text = doc.getElementById("certificateErrorText");
+ Assert.ok(
+ content.getComputedStyle(div).display !== "none",
+ "Debug information is visible"
+ );
+ let failedCertChain =
+ content.docShell.failedChannel.securityInfo.failedCertChain.map(cert =>
+ cert.getBase64DERString()
+ );
+ return {
+ divDisplay: content.getComputedStyle(div).display,
+ text: text.textContent,
+ failedCertChain,
+ };
+ });
+ isnot(message.divDisplay, "none", "Debug information is visible");
+ ok(message.text.includes(BAD_CERT), "Correct URL found");
+ ok(
+ message.text.includes("Certificate has expired"),
+ "Correct error message found"
+ );
+ ok(
+ message.text.includes("HTTP Strict Transport Security: false"),
+ "Correct HSTS value found"
+ );
+ ok(
+ message.text.includes("HTTP Public Key Pinning: false"),
+ "Correct HPKP value found"
+ );
+ let certChain = getCertChainAsString(message.failedCertChain);
+ ok(message.text.includes(certChain), "Found certificate chain");
+
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ }
+});
+
+add_task(async function checkAdvancedDetailsForHSTS() {
+ info(
+ "Loading a bad STS cert page and verifying the advanced details section"
+ );
+ for (let useFrame of [false, true]) {
+ let tab = await openErrorPage(BAD_STS_CERT, useFrame);
+ let browser = tab.linkedBrowser;
+
+ let bc = browser.browsingContext;
+ if (useFrame) {
+ bc = bc.children[0];
+ }
+
+ let message = await SpecialPowers.spawn(bc, [], async function () {
+ let doc = content.document;
+ let advancedButton = doc.getElementById("advancedButton");
+ advancedButton.click();
+
+ // Wait until fluent sets the errorCode inner text.
+ let ec;
+ await ContentTaskUtils.waitForCondition(() => {
+ ec = doc.getElementById("errorCode");
+ return ec.textContent != "";
+ }, "error code has been set inside the advanced button panel");
+
+ let cdl = doc.getElementById("cert_domain_link");
+ return {
+ ecTextContent: ec.textContent,
+ ecTagName: ec.tagName,
+ cdlTextContent: cdl.textContent,
+ cdlTagName: cdl.tagName,
+ };
+ });
+
+ const badStsUri = Services.io.newURI(BAD_STS_CERT);
+ is(
+ message.ecTextContent,
+ "SSL_ERROR_BAD_CERT_DOMAIN",
+ "Correct error message found"
+ );
+ is(message.ecTagName, "a", "Error message is a link");
+ const url = badStsUri.prePath.slice(badStsUri.prePath.indexOf(".") + 1);
+ is(message.cdlTextContent, url, "Correct cert_domain_link contents found");
+ is(message.cdlTagName, "a", "cert_domain_link is a link");
+
+ message = await SpecialPowers.spawn(bc, [], async function () {
+ let doc = content.document;
+ let errorCode = doc.getElementById("errorCode");
+ errorCode.click();
+ let div = doc.getElementById("certificateErrorDebugInformation");
+ let text = doc.getElementById("certificateErrorText");
+ let failedCertChain =
+ content.docShell.failedChannel.securityInfo.failedCertChain.map(cert =>
+ cert.getBase64DERString()
+ );
+ return {
+ divDisplay: content.getComputedStyle(div).display,
+ text: text.textContent,
+ failedCertChain,
+ };
+ });
+ isnot(message.divDisplay, "none", "Debug information is visible");
+ ok(message.text.includes(badStsUri.spec), "Correct URL found");
+ ok(
+ message.text.includes(
+ "requested domain name does not match the server\u2019s certificate"
+ ),
+ "Correct error message found"
+ );
+ ok(
+ message.text.includes("HTTP Strict Transport Security: false"),
+ "Correct HSTS value found"
+ );
+ ok(
+ message.text.includes("HTTP Public Key Pinning: true"),
+ "Correct HPKP value found"
+ );
+ let certChain = getCertChainAsString(message.failedCertChain);
+ ok(message.text.includes(certChain), "Found certificate chain");
+
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ }
+});
+
+add_task(async function checkUnknownIssuerLearnMoreLink() {
+ info(
+ "Loading a cert error for self-signed pages and checking the correct link is shown"
+ );
+ for (let useFrame of [false, true]) {
+ let tab = await openErrorPage(UNKNOWN_ISSUER, useFrame);
+ let browser = tab.linkedBrowser;
+
+ let bc = browser.browsingContext;
+ if (useFrame) {
+ bc = bc.children[0];
+ }
+
+ let href = await SpecialPowers.spawn(bc, [], async function () {
+ let learnMoreLink = content.document.getElementById("learnMoreLink");
+ return learnMoreLink.href;
+ });
+ ok(href.endsWith("security-error"), "security-error in the Learn More URL");
+
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ }
+});
+
+add_task(async function checkViewCertificate() {
+ info("Loading a cert error and checking that the certificate can be shown.");
+ for (let useFrame of [true, false]) {
+ if (useFrame) {
+ // Bug #1573502
+ continue;
+ }
+ let tab = await openErrorPage(UNKNOWN_ISSUER, useFrame);
+ let browser = tab.linkedBrowser;
+
+ let bc = browser.browsingContext;
+ if (useFrame) {
+ bc = bc.children[0];
+ }
+
+ let loaded = BrowserTestUtils.waitForNewTab(gBrowser, null, true);
+ await SpecialPowers.spawn(bc, [], async function () {
+ let viewCertificate = content.document.getElementById("viewCertificate");
+ viewCertificate.click();
+ });
+ await loaded;
+
+ let spec = gBrowser.selectedTab.linkedBrowser.documentURI.spec;
+ Assert.ok(
+ spec.startsWith("about:certificate"),
+ "about:certificate is the new opened tab"
+ );
+
+ await SpecialPowers.spawn(
+ gBrowser.selectedTab.linkedBrowser,
+ [],
+ async function () {
+ let doc = content.document;
+ let certificateSection = await ContentTaskUtils.waitForCondition(() => {
+ return doc.querySelector("certificate-section");
+ }, "Certificate section found");
+
+ let infoGroup =
+ certificateSection.shadowRoot.querySelector("info-group");
+ Assert.ok(infoGroup, "infoGroup found");
+
+ let items = infoGroup.shadowRoot.querySelectorAll("info-item");
+ let commonnameID = items[items.length - 1].shadowRoot
+ .querySelector("label")
+ .getAttribute("data-l10n-id");
+ Assert.equal(
+ commonnameID,
+ "certificate-viewer-common-name",
+ "The correct item was selected"
+ );
+
+ let commonnameValue =
+ items[items.length - 1].shadowRoot.querySelector(".info").textContent;
+ Assert.equal(
+ commonnameValue,
+ "self-signed.example.com",
+ "Shows the correct certificate in the page"
+ );
+ }
+ );
+ BrowserTestUtils.removeTab(gBrowser.selectedTab); // closes about:certificate
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ }
+});
+
+add_task(async function checkBadStsCertHeadline() {
+ info(
+ "Loading a bad sts cert error page and checking that the correct headline is shown"
+ );
+ for (let useFrame of [false, true]) {
+ let tab = await openErrorPage(BAD_CERT, useFrame);
+ let browser = tab.linkedBrowser;
+
+ let bc = browser.browsingContext;
+ if (useFrame) {
+ bc = bc.children[0];
+ }
+
+ await SpecialPowers.spawn(bc, [useFrame], async _useFrame => {
+ const titleText = content.document.querySelector(".title-text");
+ is(
+ titleText.dataset.l10nId,
+ _useFrame ? "nssBadCert-sts-title" : "nssBadCert-title",
+ "Error page title is set"
+ );
+ });
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ }
+});
+
+add_task(async function checkSandboxedIframe() {
+ info(
+ "Loading a bad sts cert error in a sandboxed iframe and check that the correct headline is shown"
+ );
+ let useFrame = true;
+ let sandboxed = true;
+ let tab = await openErrorPage(BAD_CERT, useFrame, sandboxed);
+ let browser = tab.linkedBrowser;
+
+ let bc = browser.browsingContext.children[0];
+ await SpecialPowers.spawn(bc, [], async function () {
+ let doc = content.document;
+
+ const titleText = doc.querySelector(".title-text");
+ is(
+ titleText.dataset.l10nId,
+ "nssBadCert-sts-title",
+ "Title shows Did Not Connect: Potential Security Issue"
+ );
+
+ const errorLabel = doc.querySelector(
+ '[data-l10n-id="cert-error-code-prefix-link"]'
+ );
+ const elArgs = JSON.parse(errorLabel.dataset.l10nArgs);
+ is(
+ elArgs.error,
+ "SEC_ERROR_EXPIRED_CERTIFICATE",
+ "Correct error message found"
+ );
+ is(
+ doc.getElementById("errorCode").tagName,
+ "a",
+ "Error message contains a link"
+ );
+ });
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+});
+
+add_task(async function checkViewSource() {
+ info(
+ "Loading a bad sts cert error in a sandboxed iframe and check that the correct headline is shown"
+ );
+ let uri = "view-source:" + BAD_CERT;
+ let tab = await openErrorPage(uri);
+ let browser = tab.linkedBrowser;
+
+ await SpecialPowers.spawn(browser, [], async function () {
+ let doc = content.document;
+
+ const errorLabel = doc.querySelector(
+ '[data-l10n-id="cert-error-code-prefix-link"]'
+ );
+ const elArgs = JSON.parse(errorLabel.dataset.l10nArgs);
+ is(
+ elArgs.error,
+ "SEC_ERROR_EXPIRED_CERTIFICATE",
+ "Correct error message found"
+ );
+ is(
+ doc.getElementById("errorCode").tagName,
+ "a",
+ "Error message contains a link"
+ );
+
+ const titleText = doc.querySelector(".title-text");
+ is(titleText.dataset.l10nId, "nssBadCert-title", "Error page title is set");
+
+ const shortDesc = doc.getElementById("errorShortDesc");
+ const sdArgs = JSON.parse(shortDesc.dataset.l10nArgs);
+ is(
+ sdArgs.hostname,
+ "expired.example.com",
+ "Should list hostname in error message."
+ );
+ });
+
+ let loaded = BrowserTestUtils.browserLoaded(browser, false, uri);
+ info("Clicking the exceptionDialogButton in advanced panel");
+ await SpecialPowers.spawn(browser, [], async function () {
+ let doc = content.document;
+ let exceptionButton = doc.getElementById("exceptionDialogButton");
+ exceptionButton.click();
+ });
+
+ info("Loading the url after adding exception");
+ await loaded;
+
+ await SpecialPowers.spawn(browser, [], async function () {
+ let doc = content.document;
+ ok(
+ !doc.documentURI.startsWith("about:certerror"),
+ "Exception has been added"
+ );
+ });
+
+ let certOverrideService = Cc[
+ "@mozilla.org/security/certoverride;1"
+ ].getService(Ci.nsICertOverrideService);
+ certOverrideService.clearValidityOverride("expired.example.com", -1, {});
+
+ loaded = BrowserTestUtils.waitForErrorPage(browser);
+ BrowserReloadSkipCache();
+ await loaded;
+
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+});
diff --git a/browser/base/content/test/about/browser_aboutCertError_clockSkew.js b/browser/base/content/test/about/browser_aboutCertError_clockSkew.js
new file mode 100644
index 0000000000..e3b77bd636
--- /dev/null
+++ b/browser/base/content/test/about/browser_aboutCertError_clockSkew.js
@@ -0,0 +1,153 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const PREF_SERVICES_SETTINGS_CLOCK_SKEW_SECONDS =
+ "services.settings.clock_skew_seconds";
+const PREF_SERVICES_SETTINGS_LAST_FETCHED =
+ "services.settings.last_update_seconds";
+
+add_task(async function checkWrongSystemTimeWarning() {
+ async function setUpPage() {
+ let browser;
+ let certErrorLoaded;
+ await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ () => {
+ gBrowser.selectedTab = BrowserTestUtils.addTab(
+ gBrowser,
+ "https://expired.example.com/"
+ );
+ browser = gBrowser.selectedBrowser;
+ certErrorLoaded = BrowserTestUtils.waitForErrorPage(browser);
+ },
+ false
+ );
+
+ info("Loading and waiting for the cert error");
+ await certErrorLoaded;
+
+ return SpecialPowers.spawn(browser, [], async function () {
+ let doc = content.document;
+ let div = doc.getElementById("errorShortDesc");
+ let learnMoreLink = doc.getElementById("learnMoreLink");
+
+ await ContentTaskUtils.waitForCondition(
+ () => div.textContent.includes("update your computer clock"),
+ "Correct error message found"
+ );
+
+ return {
+ divDisplay: content.getComputedStyle(div).display,
+ text: div.textContent,
+ learnMoreLink: learnMoreLink.href,
+ };
+ });
+ }
+
+ // Pretend that we recently updated our kinto clock skew pref
+ Services.prefs.setIntPref(
+ PREF_SERVICES_SETTINGS_LAST_FETCHED,
+ Math.floor(Date.now() / 1000)
+ );
+
+ // For this test, we want to trick Firefox into believing that
+ // the local system time (as returned by Date.now()) is wrong.
+ // Because we don't want to actually change the local system time,
+ // we will do the following:
+
+ // Take the validity date of our test page (expired.example.com).
+ let expiredDate = new Date("2010/01/05 12:00");
+ let localDate = Date.now();
+
+ // Compute the difference between the server date and the correct
+ // local system date.
+ let skew = Math.floor((localDate - expiredDate) / 1000);
+
+ // Make it seem like our reference server agrees that the certificate
+ // date is correct by recording the difference as clock skew.
+ Services.prefs.setIntPref(PREF_SERVICES_SETTINGS_CLOCK_SKEW_SECONDS, skew);
+
+ let localDateFmt = new Intl.DateTimeFormat("en-US", {
+ dateStyle: "medium",
+ }).format(localDate);
+
+ info("Loading a bad cert page with a skewed clock");
+ let message = await setUpPage();
+
+ isnot(
+ message.divDisplay,
+ "none",
+ "Wrong time message information is visible"
+ );
+ ok(
+ message.text.includes("update your computer clock"),
+ "Correct error message found"
+ );
+ ok(
+ message.text.includes("expired.example.com"),
+ "URL found in error message"
+ );
+ ok(message.text.includes(localDateFmt), "Correct local date displayed");
+ ok(
+ message.learnMoreLink.includes("time-errors"),
+ "time-errors in the Learn More URL"
+ );
+
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+
+ Services.prefs.clearUserPref(PREF_SERVICES_SETTINGS_LAST_FETCHED);
+ Services.prefs.clearUserPref(PREF_SERVICES_SETTINGS_CLOCK_SKEW_SECONDS);
+});
+
+add_task(async function checkCertError() {
+ async function setUpPage() {
+ let browser;
+ let certErrorLoaded;
+ gBrowser.selectedTab = BrowserTestUtils.addTab(
+ gBrowser,
+ "https://expired.example.com/"
+ );
+ browser = gBrowser.selectedBrowser;
+ certErrorLoaded = BrowserTestUtils.waitForErrorPage(browser);
+
+ info("Loading and waiting for the cert error");
+ await certErrorLoaded;
+
+ return SpecialPowers.spawn(browser, [], async function () {
+ let doc = content.document;
+ let el = doc.getElementById("errorWhatToDoText");
+ await ContentTaskUtils.waitForCondition(() => el.textContent);
+ return el.textContent;
+ });
+ }
+
+ // The particular error message will be displayed only when clock_skew_seconds is
+ // less or equal to a day and the difference between date.now() and last_fetched is less than
+ // or equal to 5 days. Setting the prefs accordingly.
+
+ Services.prefs.setIntPref(
+ PREF_SERVICES_SETTINGS_LAST_FETCHED,
+ Math.floor(Date.now() / 1000)
+ );
+
+ let skew = 60 * 60 * 24;
+ Services.prefs.setIntPref(PREF_SERVICES_SETTINGS_CLOCK_SKEW_SECONDS, skew);
+
+ info("Loading a bad cert page");
+ let message = await setUpPage();
+
+ ok(
+ message.includes(
+ "The issue is most likely with the website, and there is nothing you can do" +
+ " to resolve it. You can notify the website’s administrator about the problem."
+ ),
+ "Correct error message found"
+ );
+
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+
+ Services.prefs.clearUserPref(PREF_SERVICES_SETTINGS_LAST_FETCHED);
+ Services.prefs.clearUserPref(PREF_SERVICES_SETTINGS_CLOCK_SKEW_SECONDS);
+});
diff --git a/browser/base/content/test/about/browser_aboutCertError_exception.js b/browser/base/content/test/about/browser_aboutCertError_exception.js
new file mode 100644
index 0000000000..7ee1bdde45
--- /dev/null
+++ b/browser/base/content/test/about/browser_aboutCertError_exception.js
@@ -0,0 +1,221 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const BAD_CERT = "https://expired.example.com/";
+const BAD_STS_CERT =
+ "https://badchain.include-subdomains.pinning.example.com:443";
+const PREF_PERMANENT_OVERRIDE = "security.certerrors.permanentOverride";
+
+add_task(async function checkExceptionDialogButton() {
+ info(
+ "Loading a bad cert page and making sure the exceptionDialogButton directly adds an exception"
+ );
+ let tab = await openErrorPage(BAD_CERT);
+ let browser = tab.linkedBrowser;
+ let loaded = BrowserTestUtils.browserLoaded(browser, false, BAD_CERT);
+ info("Clicking the exceptionDialogButton in advanced panel");
+ await SpecialPowers.spawn(browser, [], async function () {
+ let doc = content.document;
+ let exceptionButton = doc.getElementById("exceptionDialogButton");
+ exceptionButton.click();
+ });
+
+ info("Loading the url after adding exception");
+ await loaded;
+
+ await SpecialPowers.spawn(browser, [], async function () {
+ let doc = content.document;
+ ok(
+ !doc.documentURI.startsWith("about:certerror"),
+ "Exception has been added"
+ );
+ });
+
+ let certOverrideService = Cc[
+ "@mozilla.org/security/certoverride;1"
+ ].getService(Ci.nsICertOverrideService);
+ certOverrideService.clearValidityOverride("expired.example.com", -1, {});
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+});
+
+add_task(async function checkPermanentExceptionPref() {
+ info(
+ "Loading a bad cert page and making sure the permanent state of exceptions can be controlled via pref"
+ );
+
+ for (let permanentOverride of [false, true]) {
+ Services.prefs.setBoolPref(PREF_PERMANENT_OVERRIDE, permanentOverride);
+
+ let tab = await openErrorPage(BAD_CERT);
+ let browser = tab.linkedBrowser;
+ let loaded = BrowserTestUtils.browserLoaded(browser, false, BAD_CERT);
+ info("Clicking the exceptionDialogButton in advanced panel");
+ let serverCertBytes = await SpecialPowers.spawn(
+ browser,
+ [],
+ async function () {
+ let doc = content.document;
+ let exceptionButton = doc.getElementById("exceptionDialogButton");
+ exceptionButton.click();
+ return content.docShell.failedChannel.securityInfo.serverCert.getRawDER();
+ }
+ );
+
+ info("Loading the url after adding exception");
+ await loaded;
+
+ await SpecialPowers.spawn(browser, [], async function () {
+ let doc = content.document;
+ ok(
+ !doc.documentURI.startsWith("about:certerror"),
+ "Exception has been added"
+ );
+ });
+
+ let certOverrideService = Cc[
+ "@mozilla.org/security/certoverride;1"
+ ].getService(Ci.nsICertOverrideService);
+
+ let isTemporary = {};
+ let certdb = Cc["@mozilla.org/security/x509certdb;1"].getService(
+ Ci.nsIX509CertDB
+ );
+ let cert = certdb.constructX509(serverCertBytes);
+ let hasException = certOverrideService.hasMatchingOverride(
+ "expired.example.com",
+ -1,
+ {},
+ cert,
+ isTemporary
+ );
+ ok(hasException, "Has stored an exception for the page.");
+ is(
+ isTemporary.value,
+ !permanentOverride,
+ `Has stored a ${
+ permanentOverride ? "permanent" : "temporary"
+ } exception for the page.`
+ );
+
+ certOverrideService.clearValidityOverride("expired.example.com", -1, {});
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ }
+
+ Services.prefs.clearUserPref(PREF_PERMANENT_OVERRIDE);
+});
+
+add_task(async function checkBadStsCert() {
+ info("Loading a badStsCert and making sure exception button doesn't show up");
+
+ for (let useFrame of [false, true]) {
+ let tab = await openErrorPage(BAD_STS_CERT, useFrame);
+ let browser = tab.linkedBrowser;
+
+ await SpecialPowers.spawn(
+ browser,
+ [{ frame: useFrame }],
+ async function ({ frame }) {
+ let doc = frame
+ ? content.document.querySelector("iframe").contentDocument
+ : content.document;
+ let exceptionButton = doc.getElementById("exceptionDialogButton");
+ ok(
+ ContentTaskUtils.is_hidden(exceptionButton),
+ "Exception button is hidden."
+ );
+ }
+ );
+
+ let message = await SpecialPowers.spawn(
+ browser,
+ [{ frame: useFrame }],
+ async function ({ frame }) {
+ let doc = frame
+ ? content.document.querySelector("iframe").contentDocument
+ : content.document;
+ let advancedButton = doc.getElementById("advancedButton");
+ advancedButton.click();
+
+ // aboutNetError.mjs is using async localization to format several
+ // messages and in result the translation may be applied later.
+ // We want to return the textContent of the element only after
+ // the translation completes, so let's wait for it here.
+ let elements = [doc.getElementById("badCertTechnicalInfo")];
+ await ContentTaskUtils.waitForCondition(() => {
+ return elements.every(elem => !!elem.textContent.trim().length);
+ });
+
+ return doc.getElementById("badCertTechnicalInfo").textContent;
+ }
+ );
+ ok(
+ message.includes("SSL_ERROR_BAD_CERT_DOMAIN"),
+ "Didn't find SSL_ERROR_BAD_CERT_DOMAIN."
+ );
+ ok(
+ message.includes("The certificate is only valid for"),
+ "Didn't find error message."
+ );
+ ok(
+ message.includes("a certificate that is not valid for"),
+ "Didn't find error message."
+ );
+ ok(
+ message.includes("badchain.include-subdomains.pinning.example.com"),
+ "Didn't find domain in error message."
+ );
+
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ }
+});
+
+add_task(async function checkhideAddExceptionButtonViaPref() {
+ info(
+ "Loading a bad cert page and verifying the pref security.certerror.hideAddException"
+ );
+ Services.prefs.setBoolPref("security.certerror.hideAddException", true);
+
+ for (let useFrame of [false, true]) {
+ let tab = await openErrorPage(BAD_CERT, useFrame);
+ let browser = tab.linkedBrowser;
+
+ await SpecialPowers.spawn(
+ browser,
+ [{ frame: useFrame }],
+ async function ({ frame }) {
+ let doc = frame
+ ? content.document.querySelector("iframe").contentDocument
+ : content.document;
+
+ let exceptionButton = doc.getElementById("exceptionDialogButton");
+ ok(
+ ContentTaskUtils.is_hidden(exceptionButton),
+ "Exception button is hidden."
+ );
+ }
+ );
+
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ }
+
+ Services.prefs.clearUserPref("security.certerror.hideAddException");
+});
+
+add_task(async function checkhideAddExceptionButtonInFrames() {
+ info("Loading a bad cert page in a frame and verifying it's hidden.");
+ let tab = await openErrorPage(BAD_CERT, true);
+ let browser = tab.linkedBrowser;
+
+ await SpecialPowers.spawn(browser, [], async function () {
+ let doc = content.document.querySelector("iframe").contentDocument;
+ let exceptionButton = doc.getElementById("exceptionDialogButton");
+ ok(
+ ContentTaskUtils.is_hidden(exceptionButton),
+ "Exception button is hidden."
+ );
+ });
+
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+});
diff --git a/browser/base/content/test/about/browser_aboutCertError_mitm.js b/browser/base/content/test/about/browser_aboutCertError_mitm.js
new file mode 100644
index 0000000000..5c9b5e8144
--- /dev/null
+++ b/browser/base/content/test/about/browser_aboutCertError_mitm.js
@@ -0,0 +1,158 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const PREF_MITM_PRIMING = "security.certerrors.mitm.priming.enabled";
+const PREF_MITM_PRIMING_ENDPOINT = "security.certerrors.mitm.priming.endpoint";
+const PREF_MITM_CANARY_ISSUER = "security.pki.mitm_canary_issuer";
+const PREF_MITM_AUTO_ENABLE_ENTERPRISE_ROOTS =
+ "security.certerrors.mitm.auto_enable_enterprise_roots";
+const PREF_ENTERPRISE_ROOTS = "security.enterprise_roots.enabled";
+
+const UNKNOWN_ISSUER = "https://untrusted.example.com";
+
+// Check that basic MitM priming works and the MitM error page is displayed successfully.
+add_task(async function checkMitmPriming() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [PREF_MITM_PRIMING, true],
+ [PREF_MITM_PRIMING_ENDPOINT, UNKNOWN_ISSUER],
+ ],
+ });
+
+ let browser;
+ let certErrorLoaded;
+ await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ () => {
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, UNKNOWN_ISSUER);
+ browser = gBrowser.selectedBrowser;
+ // The page will reload by itself after the initial canary request, so we wait
+ // until the AboutNetErrorLoad event has happened twice.
+ certErrorLoaded = new Promise(resolve => {
+ let loaded = 0;
+ let removeEventListener = BrowserTestUtils.addContentEventListener(
+ browser,
+ "AboutNetErrorLoad",
+ () => {
+ if (++loaded == 2) {
+ removeEventListener();
+ resolve();
+ }
+ },
+ { capture: false, wantUntrusted: true }
+ );
+ });
+ },
+ false
+ );
+
+ await certErrorLoaded;
+
+ await SpecialPowers.spawn(browser, [], () => {
+ is(
+ content.document.body.getAttribute("code"),
+ "MOZILLA_PKIX_ERROR_MITM_DETECTED",
+ "MitM error page has loaded."
+ );
+ });
+
+ ok(true, "Successfully loaded the MitM error page.");
+
+ is(
+ Services.prefs.getStringPref(PREF_MITM_CANARY_ISSUER),
+ "CN=Unknown CA",
+ "Stored the correct issuer"
+ );
+
+ await SpecialPowers.spawn(browser, [], async () => {
+ const shortDesc = content.document.querySelector("#errorShortDesc");
+ const whatToDo = content.document.querySelector("#errorWhatToDoText");
+
+ await ContentTaskUtils.waitForCondition(
+ () => shortDesc.textContent != "" && whatToDo.textContent != "",
+ "DOM localization has been updated"
+ );
+
+ ok(
+ shortDesc.textContent.includes("Unknown CA"),
+ "Shows the name of the issuer."
+ );
+
+ ok(
+ whatToDo.textContent.includes("Unknown CA"),
+ "Shows the name of the issuer."
+ );
+ });
+
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+
+ Services.prefs.clearUserPref(PREF_MITM_CANARY_ISSUER);
+});
+
+// Check that we set the enterprise roots pref correctly on MitM
+add_task(async function checkMitmAutoEnableEnterpriseRoots() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [PREF_MITM_PRIMING, true],
+ [PREF_MITM_PRIMING_ENDPOINT, UNKNOWN_ISSUER],
+ [PREF_MITM_AUTO_ENABLE_ENTERPRISE_ROOTS, true],
+ [PREF_ENTERPRISE_ROOTS, false],
+ ],
+ });
+
+ let browser;
+ let certErrorLoaded;
+
+ let prefChanged = TestUtils.waitForPrefChange(
+ PREF_ENTERPRISE_ROOTS,
+ value => value === true
+ );
+ await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ () => {
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, UNKNOWN_ISSUER);
+ browser = gBrowser.selectedBrowser;
+ // The page will reload by itself after the initial canary request, so we wait
+ // until the AboutNetErrorLoad event has happened twice.
+ certErrorLoaded = new Promise(resolve => {
+ let loaded = 0;
+ let removeEventListener = BrowserTestUtils.addContentEventListener(
+ browser,
+ "AboutNetErrorLoad",
+ () => {
+ if (++loaded == 2) {
+ removeEventListener();
+ resolve();
+ }
+ },
+ { capture: false, wantUntrusted: true }
+ );
+ });
+ },
+ false
+ );
+
+ await certErrorLoaded;
+ await prefChanged;
+
+ await SpecialPowers.spawn(browser, [], () => {
+ is(
+ content.document.body.getAttribute("code"),
+ "MOZILLA_PKIX_ERROR_MITM_DETECTED",
+ "MitM error page has loaded."
+ );
+ });
+
+ ok(true, "Successfully loaded the MitM error page.");
+
+ ok(
+ !Services.prefs.prefHasUserValue(PREF_ENTERPRISE_ROOTS),
+ "Flipped the enterprise roots pref back"
+ );
+
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+
+ Services.prefs.clearUserPref(PREF_MITM_CANARY_ISSUER);
+});
diff --git a/browser/base/content/test/about/browser_aboutCertError_noSubjectAltName.js b/browser/base/content/test/about/browser_aboutCertError_noSubjectAltName.js
new file mode 100644
index 0000000000..1a2add1c96
--- /dev/null
+++ b/browser/base/content/test/about/browser_aboutCertError_noSubjectAltName.js
@@ -0,0 +1,67 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const BROWSER_NAME = document
+ .getElementById("bundle_brand")
+ .getString("brandShortName");
+const UNKNOWN_ISSUER = "https://no-subject-alt-name.example.com:443";
+
+const checkAdvancedAndGetTechnicalInfoText = async () => {
+ let doc = content.document;
+
+ let advancedButton = doc.getElementById("advancedButton");
+ ok(advancedButton, "advancedButton found");
+ is(
+ advancedButton.hasAttribute("disabled"),
+ false,
+ "advancedButton should be clickable"
+ );
+ advancedButton.click();
+
+ let badCertAdvancedPanel = doc.getElementById("badCertAdvancedPanel");
+ ok(badCertAdvancedPanel, "badCertAdvancedPanel found");
+
+ let badCertTechnicalInfo = doc.getElementById("badCertTechnicalInfo");
+ ok(badCertTechnicalInfo, "badCertTechnicalInfo found");
+
+ // Wait until fluent sets the errorCode inner text.
+ await ContentTaskUtils.waitForCondition(() => {
+ let errorCode = doc.getElementById("errorCode");
+ return errorCode.textContent == "SSL_ERROR_BAD_CERT_DOMAIN";
+ }, "correct error code has been set inside the advanced button panel");
+
+ let viewCertificate = doc.getElementById("viewCertificate");
+ ok(viewCertificate, "viewCertificate found");
+
+ return badCertTechnicalInfo.innerHTML;
+};
+
+const checkCorrectMessages = message => {
+ let isCorrectMessage = message.includes(
+ "Websites prove their identity via certificates. " +
+ BROWSER_NAME +
+ " does not trust this site because it uses a certificate that is" +
+ " not valid for no-subject-alt-name.example.com"
+ );
+ is(isCorrectMessage, true, "That message should appear");
+ let isWrongMessage = message.includes("The certificate is only valid for ");
+ is(isWrongMessage, false, "That message shouldn't appear");
+};
+
+add_task(async function checkUntrustedCertError() {
+ info(
+ `Loading ${UNKNOWN_ISSUER} which does not have a subject specified in the certificate`
+ );
+ let tab = await openErrorPage(UNKNOWN_ISSUER);
+ let browser = tab.linkedBrowser;
+ info("Clicking the exceptionDialogButton in advanced panel");
+ let badCertTechnicalInfoText = await SpecialPowers.spawn(
+ browser,
+ [],
+ checkAdvancedAndGetTechnicalInfoText
+ );
+ checkCorrectMessages(badCertTechnicalInfoText, browser);
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+});
diff --git a/browser/base/content/test/about/browser_aboutCertError_offlineSupport.js b/browser/base/content/test/about/browser_aboutCertError_offlineSupport.js
new file mode 100644
index 0000000000..5b717a683a
--- /dev/null
+++ b/browser/base/content/test/about/browser_aboutCertError_offlineSupport.js
@@ -0,0 +1,51 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const BAD_CERT_PAGE = "https://expired.example.com";
+const DUMMY_SUPPORT_BASE_PATH = "/1/firefox/fxVersion/OSVersion/language/";
+const DUMMY_SUPPORT_URL = BAD_CERT_PAGE + DUMMY_SUPPORT_BASE_PATH;
+const OFFLINE_SUPPORT_PAGE =
+ "chrome://global/content/neterror/supportpages/time-errors.html";
+
+add_task(async function testOfflineSupportPage() {
+ // Cache the original value of app.support.baseURL pref to reset later
+ let originalBaseURL = Services.prefs.getCharPref("app.support.baseURL");
+
+ Services.prefs.setCharPref("app.support.baseURL", DUMMY_SUPPORT_URL);
+ let errorTab = await openErrorPage(BAD_CERT_PAGE);
+
+ let offlineSupportPromise = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ DUMMY_SUPPORT_URL + "time-errors"
+ );
+ await SpecialPowers.spawn(
+ errorTab.linkedBrowser,
+ [DUMMY_SUPPORT_URL],
+ async expectedURL => {
+ let doc = content.document;
+
+ let learnMoreLink = doc.getElementById("learnMoreLink");
+ let supportPageURL = learnMoreLink.getAttribute("href");
+ ok(
+ supportPageURL == expectedURL + "time-errors",
+ "Correct support page URL has been set"
+ );
+ await EventUtils.synthesizeMouseAtCenter(learnMoreLink, {}, content);
+ }
+ );
+ let offlineSupportTab = await offlineSupportPromise;
+ await BrowserTestUtils.browserLoaded(
+ gBrowser.selectedBrowser,
+ false,
+ OFFLINE_SUPPORT_PAGE
+ );
+
+ // Reset this pref instead of clearing it to maintain globally set
+ // custom value for testing purposes.
+ Services.prefs.setCharPref("app.support.baseURL", originalBaseURL);
+
+ await BrowserTestUtils.removeTab(offlineSupportTab);
+ await BrowserTestUtils.removeTab(errorTab);
+});
diff --git a/browser/base/content/test/about/browser_aboutCertError_telemetry.js b/browser/base/content/test/about/browser_aboutCertError_telemetry.js
new file mode 100644
index 0000000000..61ec8afcbf
--- /dev/null
+++ b/browser/base/content/test/about/browser_aboutCertError_telemetry.js
@@ -0,0 +1,164 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+requestLongerTimeout(2);
+
+const BAD_CERT = "https://expired.example.com/";
+const BAD_STS_CERT =
+ "https://badchain.include-subdomains.pinning.example.com:443";
+
+add_task(async function checkTelemetryClickEvents() {
+ info("Loading a bad cert page and verifying telemetry click events arrive.");
+
+ let oldCanRecord = Services.telemetry.canRecordExtended;
+ Services.telemetry.canRecordExtended = true;
+
+ registerCleanupFunction(() => {
+ Services.telemetry.canRecordExtended = oldCanRecord;
+ });
+
+ // For obvious reasons event telemetry in the content processes updates with
+ // the main processs asynchronously, so we need to wait for the main process
+ // to catch up through the entire test.
+
+ // There's an arbitrary interval of 2 seconds in which the content
+ // processes sync their event data with the parent process, we wait
+ // this out to ensure that we clear everything that is left over from
+ // previous tests and don't receive random events in the middle of our tests.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(c => setTimeout(c, 2000));
+
+ // Clear everything.
+ Services.telemetry.clearEvents();
+ await TestUtils.waitForCondition(() => {
+ let events = Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS,
+ true
+ ).content;
+ return !events || !events.length;
+ });
+
+ // Now enable recording our telemetry. Even if this is disabled, content
+ // processes will send event telemetry to the parent, thus we needed to ensure
+ // we waited and cleared first. Sigh.
+ Services.telemetry.setEventRecordingEnabled("security.ui.certerror", true);
+
+ for (let useFrame of [false, true]) {
+ let recordedObjects = [
+ "advanced_button",
+ "learn_more_link",
+ "error_code_link",
+ "clipboard_button_top",
+ "clipboard_button_bot",
+ "return_button_top",
+ ];
+
+ recordedObjects.push("return_button_adv");
+ if (!useFrame) {
+ recordedObjects.push("exception_button");
+ }
+
+ for (let object of recordedObjects) {
+ let tab = await openErrorPage(BAD_CERT, useFrame);
+ let browser = tab.linkedBrowser;
+
+ let loadEvents = await TestUtils.waitForCondition(() => {
+ let events = Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS,
+ true
+ ).content;
+ if (events && events.length) {
+ events = events.filter(
+ e => e[1] == "security.ui.certerror" && e[2] == "load"
+ );
+ if (
+ events.length == 1 &&
+ events[0][5].is_frame == useFrame.toString()
+ ) {
+ return events;
+ }
+ }
+ return null;
+ }, "recorded telemetry for the load");
+
+ is(
+ loadEvents.length,
+ 1,
+ `recorded telemetry for the load testing ${object}, useFrame: ${useFrame}`
+ );
+
+ let bc = browser.browsingContext;
+ if (useFrame) {
+ bc = bc.children[0];
+ }
+
+ await SpecialPowers.spawn(bc, [object], async function (objectId) {
+ let doc = content.document;
+
+ await ContentTaskUtils.waitForCondition(
+ () => doc.body.classList.contains("certerror"),
+ "Wait for certerror to be loaded"
+ );
+
+ let domElement = doc.querySelector(`[data-telemetry-id='${objectId}']`);
+ domElement.click();
+ });
+
+ let clickEvents = await TestUtils.waitForCondition(() => {
+ let events = Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS,
+ true
+ ).content;
+ if (events && events.length) {
+ events = events.filter(
+ e =>
+ e[1] == "security.ui.certerror" &&
+ e[2] == "click" &&
+ e[3] == object
+ );
+ if (
+ events.length == 1 &&
+ events[0][5].is_frame == useFrame.toString()
+ ) {
+ return events;
+ }
+ }
+ return null;
+ }, "Has captured telemetry events.");
+
+ is(
+ clickEvents.length,
+ 1,
+ `recorded telemetry for the click on ${object}, useFrame: ${useFrame}`
+ );
+
+ // We opened an extra tab for the SUMO page, need to close it.
+ if (object == "learn_more_link") {
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ }
+
+ if (object == "exception_button") {
+ let certOverrideService = Cc[
+ "@mozilla.org/security/certoverride;1"
+ ].getService(Ci.nsICertOverrideService);
+ certOverrideService.clearValidityOverride(
+ "expired.example.com",
+ -1,
+ {}
+ );
+ }
+
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ }
+ }
+
+ let enableCertErrorUITelemetry = Services.prefs.getBoolPref(
+ "security.certerrors.recordEventTelemetry"
+ );
+ Services.telemetry.setEventRecordingEnabled(
+ "security.ui.certerror",
+ enableCertErrorUITelemetry
+ );
+});
diff --git a/browser/base/content/test/about/browser_aboutDialog_distribution.js b/browser/base/content/test/about/browser_aboutDialog_distribution.js
new file mode 100644
index 0000000000..8f52533bbc
--- /dev/null
+++ b/browser/base/content/test/about/browser_aboutDialog_distribution.js
@@ -0,0 +1,66 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Services.scriptloader.loadSubScript(
+ "chrome://mochitests/content/browser/toolkit/mozapps/update/tests/browser/head.js",
+ this
+);
+
+add_task(async function verify_distribution_info_hides() {
+ let defaultBranch = Services.prefs.getDefaultBranch(null);
+
+ defaultBranch.setCharPref("distribution.id", "mozilla-test-distribution-id");
+ defaultBranch.setCharPref("distribution.version", "1.0");
+
+ let aboutDialog = await waitForAboutDialog();
+
+ let distroIdField = aboutDialog.document.getElementById("distributionId");
+ let distroField = aboutDialog.document.getElementById("distribution");
+
+ if (
+ AppConstants.platform === "win" &&
+ Services.sysinfo.getProperty("hasWinPackageId")
+ ) {
+ is(distroIdField.value, "mozilla-test-distribution-id - 1.0");
+ is(distroIdField.style.display, "block");
+ is(distroField.style.display, "block");
+ } else {
+ is(distroIdField.value, "");
+ isnot(distroIdField.style.display, "block");
+ isnot(distroField.style.display, "block");
+ }
+
+ aboutDialog.close();
+});
+
+add_task(async function verify_distribution_info_displays() {
+ let defaultBranch = Services.prefs.getDefaultBranch(null);
+
+ defaultBranch.setCharPref("distribution.id", "test-distribution-id");
+ defaultBranch.setCharPref("distribution.version", "1.0");
+ defaultBranch.setCharPref("distribution.about", "About Text");
+
+ let aboutDialog = await waitForAboutDialog();
+
+ let distroIdField = aboutDialog.document.getElementById("distributionId");
+
+ is(distroIdField.value, "test-distribution-id - 1.0");
+ is(distroIdField.style.display, "block");
+
+ let distroField = aboutDialog.document.getElementById("distribution");
+ is(distroField.value, "About Text");
+ is(distroField.style.display, "block");
+
+ aboutDialog.close();
+});
+
+add_task(async function cleanup() {
+ let defaultBranch = Services.prefs.getDefaultBranch(null);
+
+ // This is the best we can do since we can't remove default prefs
+ defaultBranch.setCharPref("distribution.id", "");
+ defaultBranch.setCharPref("distribution.version", "");
+ defaultBranch.setCharPref("distribution.about", "");
+});
diff --git a/browser/base/content/test/about/browser_aboutHome_search_POST.js b/browser/base/content/test/about/browser_aboutHome_search_POST.js
new file mode 100644
index 0000000000..c892198207
--- /dev/null
+++ b/browser/base/content/test/about/browser_aboutHome_search_POST.js
@@ -0,0 +1,104 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+ignoreAllUncaughtExceptions();
+
+add_task(async function () {
+ info("Check POST search engine support");
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "browser.newtabpage.activity-stream.improvesearch.handoffToAwesomebar",
+ false,
+ ],
+ ],
+ });
+
+ let currEngine = await Services.search.getDefault();
+
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: "about:home" },
+ async browser => {
+ let observerPromise = new Promise(resolve => {
+ let searchObserver = async function search_observer(
+ subject,
+ topic,
+ data
+ ) {
+ let engine = subject.QueryInterface(Ci.nsISearchEngine);
+ info("Observer: " + data + " for " + engine.name);
+
+ if (data != "engine-added") {
+ return;
+ }
+
+ if (engine.name != "POST Search") {
+ return;
+ }
+
+ Services.obs.removeObserver(
+ searchObserver,
+ "browser-search-engine-modified"
+ );
+
+ resolve(engine);
+ };
+
+ Services.obs.addObserver(
+ searchObserver,
+ "browser-search-engine-modified"
+ );
+ });
+
+ let engine;
+ await promiseContentSearchChange(browser, async () => {
+ Services.search.addOpenSearchEngine(
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://test:80/browser/browser/base/content/test/about/POSTSearchEngine.xml",
+ null
+ );
+
+ engine = await observerPromise;
+ Services.search.setDefault(
+ engine,
+ Ci.nsISearchService.CHANGE_REASON_UNKNOWN
+ );
+ return engine.name;
+ });
+
+ // Ready to execute the tests!
+ let needle = "Search for something awesome.";
+
+ let promise = BrowserTestUtils.browserLoaded(browser);
+ await SpecialPowers.spawn(browser, [{ needle }], async function (args) {
+ let doc = content.document;
+ let el = doc.querySelector(["#searchText", "#newtab-search-text"]);
+ el.value = args.needle;
+ doc.getElementById("searchSubmit").click();
+ });
+
+ await promise;
+
+ // When the search results load, check them for correctness.
+ await SpecialPowers.spawn(browser, [{ needle }], async function (args) {
+ let loadedText = content.document.body.textContent;
+ ok(loadedText, "search page loaded");
+ is(
+ loadedText,
+ "searchterms=" + escape(args.needle.replace(/\s/g, "+")),
+ "Search text should arrive correctly"
+ );
+ });
+
+ await Services.search.setDefault(
+ currEngine,
+ Ci.nsISearchService.CHANGE_REASON_UNKNOWN
+ );
+ try {
+ await Services.search.removeEngine(engine);
+ } catch (ex) {}
+ }
+ );
+ await SpecialPowers.popPrefEnv();
+});
diff --git a/browser/base/content/test/about/browser_aboutHome_search_composing.js b/browser/base/content/test/about/browser_aboutHome_search_composing.js
new file mode 100644
index 0000000000..309f1f674a
--- /dev/null
+++ b/browser/base/content/test/about/browser_aboutHome_search_composing.js
@@ -0,0 +1,110 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+ignoreAllUncaughtExceptions();
+
+add_task(async function () {
+ info("Clicking suggestion list while composing");
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "browser.newtabpage.activity-stream.improvesearch.handoffToAwesomebar",
+ false,
+ ],
+ ],
+ });
+
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: "about:home" },
+ async function (browser) {
+ // Add a test engine that provides suggestions and switch to it.
+ let engine;
+ await promiseContentSearchChange(browser, async () => {
+ engine = await SearchTestUtils.promiseNewSearchEngine({
+ url: getRootDirectory(gTestPath) + "searchSuggestionEngine.xml",
+ setAsDefault: true,
+ });
+ return engine.name;
+ });
+
+ // Clear any search history results
+ await FormHistory.update({ op: "remove" });
+
+ await SpecialPowers.spawn(browser, [], async function () {
+ // Start composition and type "x"
+ let input = content.document.querySelector([
+ "#searchText",
+ "#newtab-search-text",
+ ]);
+ input.focus();
+ });
+
+ info("Setting up the mutation observer before synthesizing composition");
+ let mutationPromise = SpecialPowers.spawn(browser, [], async function () {
+ let searchController = content.wrappedJSObject.gContentSearchController;
+
+ // Wait for the search suggestions to become visible.
+ let table = searchController._suggestionsList;
+ let input = content.document.querySelector([
+ "#searchText",
+ "#newtab-search-text",
+ ]);
+
+ await ContentTaskUtils.waitForMutationCondition(
+ input,
+ { attributeFilter: ["aria-expanded"] },
+ () => input.getAttribute("aria-expanded") == "true"
+ );
+ ok(!table.hidden, "Search suggestion table unhidden");
+
+ let row = table.children[1];
+ row.setAttribute("id", "TEMPID");
+
+ // ContentSearchUIController looks at the current selectedIndex when
+ // performing a search. Synthesizing the mouse event on the suggestion
+ // doesn't actually mouseover the suggestion and trigger it to be flagged
+ // as selected, so we manually select it first.
+ searchController.selectedIndex = 1;
+ });
+
+ // FYI: "compositionstart" will be dispatched automatically.
+ await BrowserTestUtils.synthesizeCompositionChange(
+ {
+ composition: {
+ string: "x",
+ clauses: [
+ { length: 1, attr: Ci.nsITextInputProcessor.ATTR_RAW_CLAUSE },
+ ],
+ },
+ caret: { start: 1, length: 0 },
+ },
+ browser
+ );
+
+ info("Waiting for search suggestion table unhidden");
+ await mutationPromise;
+
+ // Click the second suggestion.
+ let expectedURL = (await Services.search.getDefault()).getSubmission(
+ "xbar",
+ null,
+ "homepage"
+ ).uri.spec;
+ let loadPromise = BrowserTestUtils.waitForDocLoadAndStopIt(
+ expectedURL,
+ gBrowser.selectedBrowser
+ );
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#TEMPID",
+ {
+ button: 0,
+ },
+ browser
+ );
+ await loadPromise;
+ }
+ );
+ await SpecialPowers.popPrefEnv();
+});
diff --git a/browser/base/content/test/about/browser_aboutHome_search_searchbar.js b/browser/base/content/test/about/browser_aboutHome_search_searchbar.js
new file mode 100644
index 0000000000..7b08d2ae34
--- /dev/null
+++ b/browser/base/content/test/about/browser_aboutHome_search_searchbar.js
@@ -0,0 +1,44 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+const { CustomizableUITestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/CustomizableUITestUtils.sys.mjs"
+);
+let gCUITestUtils = new CustomizableUITestUtils(window);
+
+ignoreAllUncaughtExceptions();
+
+add_task(async function test_setup() {
+ await gCUITestUtils.addSearchBar();
+ registerCleanupFunction(() => {
+ gCUITestUtils.removeSearchBar();
+ });
+});
+
+add_task(async function () {
+ info("Cmd+k should focus the search box in the toolbar when it's present");
+
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: "about:home" },
+ async function (browser) {
+ await BrowserTestUtils.synthesizeMouseAtCenter("#brandLogo", {}, browser);
+
+ let doc = window.document;
+ let searchInput = BrowserSearch.searchBar.textbox;
+ isnot(
+ searchInput,
+ doc.activeElement,
+ "Search bar should not be the active element."
+ );
+
+ EventUtils.synthesizeKey("k", { accelKey: true });
+ await TestUtils.waitForCondition(() => doc.activeElement === searchInput);
+ is(
+ searchInput,
+ doc.activeElement,
+ "Search bar should be the active element."
+ );
+ }
+ );
+});
diff --git a/browser/base/content/test/about/browser_aboutHome_search_suggestion.js b/browser/base/content/test/about/browser_aboutHome_search_suggestion.js
new file mode 100644
index 0000000000..4e1da9fe3e
--- /dev/null
+++ b/browser/base/content/test/about/browser_aboutHome_search_suggestion.js
@@ -0,0 +1,78 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+ignoreAllUncaughtExceptions();
+
+add_task(async function () {
+ // See browser_contentSearchUI.js for comprehensive content search UI tests.
+ info("Search suggestion smoke test");
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "browser.newtabpage.activity-stream.improvesearch.handoffToAwesomebar",
+ false,
+ ],
+ ],
+ });
+
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: "about:home" },
+ async function (browser) {
+ // Add a test engine that provides suggestions and switch to it.
+ let engine;
+ await promiseContentSearchChange(browser, async () => {
+ engine = await SearchTestUtils.promiseNewSearchEngine({
+ url: getRootDirectory(gTestPath) + "searchSuggestionEngine.xml",
+ setAsDefault: true,
+ });
+ await Services.search.setDefault(
+ engine,
+ Ci.nsISearchService.CHANGE_REASON_UNKNOWN
+ );
+ return engine.name;
+ });
+
+ await SpecialPowers.spawn(browser, [], async function () {
+ // Type an X in the search input.
+ let input = content.document.querySelector([
+ "#searchText",
+ "#newtab-search-text",
+ ]);
+ input.focus();
+ });
+
+ await BrowserTestUtils.synthesizeKey("x", {}, browser);
+
+ await SpecialPowers.spawn(browser, [], async function () {
+ // Wait for the search suggestions to become visible.
+ let table = content.document.getElementById("searchSuggestionTable");
+ let input = content.document.querySelector([
+ "#searchText",
+ "#newtab-search-text",
+ ]);
+
+ await ContentTaskUtils.waitForMutationCondition(
+ input,
+ { attributeFilter: ["aria-expanded"] },
+ () => input.getAttribute("aria-expanded") == "true"
+ );
+ ok(!table.hidden, "Search suggestion table unhidden");
+ });
+
+ // Empty the search input, causing the suggestions to be hidden.
+ await BrowserTestUtils.synthesizeKey("a", { accelKey: true }, browser);
+ await BrowserTestUtils.synthesizeKey("VK_DELETE", {}, browser);
+
+ await SpecialPowers.spawn(browser, [], async function () {
+ let table = content.document.getElementById("searchSuggestionTable");
+ await ContentTaskUtils.waitForCondition(
+ () => table.hidden,
+ "Search suggestion table hidden"
+ );
+ });
+ }
+ );
+ await SpecialPowers.popPrefEnv();
+});
diff --git a/browser/base/content/test/about/browser_aboutHome_search_telemetry.js b/browser/base/content/test/about/browser_aboutHome_search_telemetry.js
new file mode 100644
index 0000000000..e23d07aa38
--- /dev/null
+++ b/browser/base/content/test/about/browser_aboutHome_search_telemetry.js
@@ -0,0 +1,101 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+ignoreAllUncaughtExceptions();
+
+add_task(async function () {
+ info(
+ "Check that performing a search fires a search event and records to Telemetry."
+ );
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "browser.newtabpage.activity-stream.improvesearch.handoffToAwesomebar",
+ false,
+ ],
+ ],
+ });
+
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: "about:home" },
+ async function (browser) {
+ let engine;
+ await promiseContentSearchChange(browser, async () => {
+ engine = await SearchTestUtils.promiseNewSearchEngine({
+ url: getRootDirectory(gTestPath) + "searchSuggestionEngine.xml",
+ setAsDefault: true,
+ });
+ return engine.name;
+ });
+
+ await SpecialPowers.spawn(
+ browser,
+ [{ expectedName: engine.name }],
+ async function (args) {
+ let engineName =
+ content.wrappedJSObject.gContentSearchController.defaultEngine.name;
+ is(
+ engineName,
+ args.expectedName,
+ "Engine name in DOM should match engine we just added"
+ );
+ }
+ );
+
+ let numSearchesBefore = 0;
+ // Get the current number of recorded searches.
+ let histogramKey = `other-${engine.name}.abouthome`;
+ try {
+ let hs = Services.telemetry
+ .getKeyedHistogramById("SEARCH_COUNTS")
+ .snapshot();
+ if (histogramKey in hs) {
+ numSearchesBefore = hs[histogramKey].sum;
+ }
+ } catch (ex) {
+ // No searches performed yet, not a problem, |numSearchesBefore| is 0.
+ }
+
+ let searchStr = "a search";
+
+ let expectedURL = (await Services.search.getDefault()).getSubmission(
+ searchStr,
+ null,
+ "homepage"
+ ).uri.spec;
+ let promise = BrowserTestUtils.waitForDocLoadAndStopIt(
+ expectedURL,
+ browser
+ );
+
+ // Perform a search to increase the SEARCH_COUNT histogram.
+ await SpecialPowers.spawn(
+ browser,
+ [{ searchStr }],
+ async function (args) {
+ let doc = content.document;
+ info("Perform a search.");
+ let el = doc.querySelector(["#searchText", "#newtab-search-text"]);
+ el.value = args.searchStr;
+ doc.getElementById("searchSubmit").click();
+ }
+ );
+
+ await promise;
+
+ // Make sure the SEARCH_COUNTS histogram has the right key and count.
+ let hs = Services.telemetry
+ .getKeyedHistogramById("SEARCH_COUNTS")
+ .snapshot();
+ Assert.ok(histogramKey in hs, "histogram with key should be recorded");
+ Assert.equal(
+ hs[histogramKey].sum,
+ numSearchesBefore + 1,
+ "histogram sum should be incremented"
+ );
+ }
+ );
+ await SpecialPowers.popPrefEnv();
+});
diff --git a/browser/base/content/test/about/browser_aboutNetError.js b/browser/base/content/test/about/browser_aboutNetError.js
new file mode 100644
index 0000000000..0f98413f33
--- /dev/null
+++ b/browser/base/content/test/about/browser_aboutNetError.js
@@ -0,0 +1,245 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const SSL3_PAGE = "https://ssl3.example.com/";
+const TLS10_PAGE = "https://tls1.example.com/";
+const TLS12_PAGE = "https://tls12.example.com/";
+const TRIPLEDES_PAGE = "https://3des.example.com/";
+
+const lazy = {};
+
+XPCOMUtils.defineLazyServiceGetter(
+ lazy,
+ "gDNSOverride",
+ "@mozilla.org/network/native-dns-override;1",
+ "nsINativeDNSResolverOverride"
+);
+
+// This includes all the cipher suite prefs we have.
+function resetPrefs() {
+ Services.prefs.clearUserPref("security.tls.version.min");
+ Services.prefs.clearUserPref("security.tls.version.max");
+ Services.prefs.clearUserPref("security.tls.version.enable-deprecated");
+ Services.prefs.clearUserPref("browser.fixup.alternate.enabled");
+}
+
+add_task(async function resetToDefaultConfig() {
+ info(
+ "Change TLS config to cause page load to fail, check that reset button is shown and that it works"
+ );
+
+ // Set ourselves up for a TLS error.
+ Services.prefs.setIntPref("security.tls.version.min", 1); // TLS 1.0
+ Services.prefs.setIntPref("security.tls.version.max", 1);
+
+ let browser;
+ let pageLoaded;
+ await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ () => {
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, TLS12_PAGE);
+ browser = gBrowser.selectedBrowser;
+ pageLoaded = BrowserTestUtils.waitForErrorPage(browser);
+ },
+ false
+ );
+
+ info("Loading and waiting for the net error");
+ await pageLoaded;
+
+ // Setup an observer for the target page.
+ const finalLoadComplete = BrowserTestUtils.browserLoaded(
+ browser,
+ false,
+ TLS12_PAGE
+ );
+
+ await SpecialPowers.spawn(browser, [], async function () {
+ const doc = content.document;
+ ok(
+ doc.documentURI.startsWith("about:neterror"),
+ "Should be showing error page"
+ );
+
+ const prefResetButton = doc.getElementById("prefResetButton");
+ await ContentTaskUtils.waitForCondition(
+ () => ContentTaskUtils.is_visible(prefResetButton),
+ "prefResetButton is visible"
+ );
+
+ if (!Services.focus.focusedElement == prefResetButton) {
+ await ContentTaskUtils.waitForEvent(prefResetButton, "focus");
+ }
+
+ Assert.ok(true, "prefResetButton has focus");
+
+ prefResetButton.click();
+ });
+
+ info("Waiting for the page to load after the click");
+ await finalLoadComplete;
+
+ resetPrefs();
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+});
+
+add_task(async function checkLearnMoreLink() {
+ info("Load an unsupported TLS page and check for a learn more link");
+
+ // Set ourselves up for TLS error
+ Services.prefs.setIntPref("security.tls.version.min", 3);
+ Services.prefs.setIntPref("security.tls.version.max", 4);
+
+ let browser;
+ let pageLoaded;
+ await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ () => {
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, TLS10_PAGE);
+ browser = gBrowser.selectedBrowser;
+ pageLoaded = BrowserTestUtils.waitForErrorPage(browser);
+ },
+ false
+ );
+
+ info("Loading and waiting for the net error");
+ await pageLoaded;
+
+ const baseURL = Services.urlFormatter.formatURLPref("app.support.baseURL");
+
+ await SpecialPowers.spawn(browser, [baseURL], function (_baseURL) {
+ const doc = content.document;
+ ok(
+ doc.documentURI.startsWith("about:neterror"),
+ "Should be showing error page"
+ );
+
+ const tlsVersionNotice = doc.getElementById("tlsVersionNotice");
+ ok(
+ ContentTaskUtils.is_visible(tlsVersionNotice),
+ "TLS version notice is visible"
+ );
+
+ const learnMoreLink = doc.getElementById("learnMoreLink");
+ ok(
+ ContentTaskUtils.is_visible(learnMoreLink),
+ "Learn More link is visible"
+ );
+ is(learnMoreLink.getAttribute("href"), _baseURL + "connection-not-secure");
+
+ const titleEl = doc.querySelector(".title-text");
+ const actualDataL10nID = titleEl.getAttribute("data-l10n-id");
+ is(
+ actualDataL10nID,
+ "nssFailure2-title",
+ "Correct error page title is set"
+ );
+
+ const errorCodeEl = doc.querySelector("#errorShortDesc2");
+ const actualDataL10Args = errorCodeEl.getAttribute("data-l10n-args");
+ ok(
+ actualDataL10Args.includes("SSL_ERROR_PROTOCOL_VERSION_ALERT"),
+ "Correct error code is set"
+ );
+ });
+
+ resetPrefs();
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+});
+
+// When a user tries going to a host without a suffix
+// and the term doesn't match a host and we are able to suggest a
+// valid correction, the page should show the correction.
+// e.g. http://example/example2 -> https://www.example.com/example2
+add_task(async function checkDomainCorrection() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.fixup.alternate.enabled", false]],
+ });
+ lazy.gDNSOverride.addIPOverride("www.example.com", "::1");
+
+ info("Try loading a URI that should result in an error page");
+ BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example/example2/",
+ false
+ );
+
+ info("Loading and waiting for the net error");
+ let browser = gBrowser.selectedBrowser;
+ let pageLoaded = BrowserTestUtils.waitForErrorPage(browser);
+ await pageLoaded;
+
+ const baseURL = Services.urlFormatter.formatURLPref("app.support.baseURL");
+
+ await SpecialPowers.spawn(browser, [baseURL], async function (_baseURL) {
+ const doc = content.document;
+ ok(
+ doc.documentURI.startsWith("about:neterror"),
+ "Should be showing error page"
+ );
+
+ const errorNotice = doc.getElementById("errorShortDesc");
+ ok(ContentTaskUtils.is_visible(errorNotice), "Error text is visible");
+
+ // Wait for the domain suggestion to be resolved and for the text to update
+ let link;
+ await ContentTaskUtils.waitForCondition(() => {
+ link = errorNotice.querySelector("a");
+ return link && link.textContent != "";
+ }, "Helper link has been set");
+
+ is(
+ link.getAttribute("href"),
+ "https://www.example.com/example2/",
+ "Link was corrected"
+ );
+
+ const actualDataL10nID = link.getAttribute("data-l10n-name");
+ is(actualDataL10nID, "website", "Correct name is set");
+ });
+
+ lazy.gDNSOverride.clearHostOverride("www.example.com");
+ resetPrefs();
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+});
+
+// Test that ciphersuites that use 3DES (namely, TLS_RSA_WITH_3DES_EDE_CBC_SHA)
+// can only be enabled when deprecated TLS is enabled.
+add_task(async function onlyAllow3DESWithDeprecatedTLS() {
+ // By default, connecting to a server that only uses 3DES should fail.
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: "about:blank" },
+ async browser => {
+ BrowserTestUtils.loadURIString(browser, TRIPLEDES_PAGE);
+ await BrowserTestUtils.waitForErrorPage(browser);
+ }
+ );
+
+ // Enabling deprecated TLS should also enable 3DES.
+ Services.prefs.setBoolPref("security.tls.version.enable-deprecated", true);
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: "about:blank" },
+ async browser => {
+ BrowserTestUtils.loadURIString(browser, TRIPLEDES_PAGE);
+ await BrowserTestUtils.browserLoaded(browser, false, TRIPLEDES_PAGE);
+ }
+ );
+
+ // 3DES can be disabled separately.
+ Services.prefs.setBoolPref(
+ "security.ssl3.deprecated.rsa_des_ede3_sha",
+ false
+ );
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: "about:blank" },
+ async browser => {
+ BrowserTestUtils.loadURIString(browser, TRIPLEDES_PAGE);
+ await BrowserTestUtils.waitForErrorPage(browser);
+ }
+ );
+
+ resetPrefs();
+});
diff --git a/browser/base/content/test/about/browser_aboutNetError_csp_iframe.js b/browser/base/content/test/about/browser_aboutNetError_csp_iframe.js
new file mode 100644
index 0000000000..21e2ba7b51
--- /dev/null
+++ b/browser/base/content/test/about/browser_aboutNetError_csp_iframe.js
@@ -0,0 +1,153 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const BLOCKED_PAGE =
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.org:8000/browser/browser/base/content/test/about/csp_iframe.sjs";
+
+add_task(async function test_csp() {
+ let { iframePageTab, blockedPageTab } = await setupPage(
+ "iframe_page_csp.html",
+ BLOCKED_PAGE
+ );
+
+ let cspBrowser = gBrowser.selectedTab.linkedBrowser;
+
+ // The blocked page opened in a new window/tab
+ await SpecialPowers.spawn(
+ cspBrowser,
+ [BLOCKED_PAGE],
+ async function (cspBlockedPage) {
+ let cookieHeader = content.document.getElementById("strictCookie");
+ let location = content.document.location.href;
+
+ Assert.ok(
+ cookieHeader.textContent.includes("No same site strict cookie header"),
+ "Same site strict cookie has not been set"
+ );
+ Assert.equal(
+ location,
+ cspBlockedPage,
+ "Location of new page is correct!"
+ );
+ }
+ );
+
+ Services.cookies.removeAll();
+ BrowserTestUtils.removeTab(iframePageTab);
+ BrowserTestUtils.removeTab(blockedPageTab);
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+});
+
+async function setupPage(htmlPageName, blockedPage) {
+ let iFramePage =
+ getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com"
+ ) + htmlPageName;
+
+ // Opening the blocked page once in a new tab
+ let blockedPageTab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ blockedPage
+ );
+ let blockedPageBrowser = blockedPageTab.linkedBrowser;
+
+ let cookies = Services.cookies.getCookiesFromHost(
+ "example.org",
+ blockedPageBrowser.contentPrincipal.originAttributes
+ );
+ let strictCookie = cookies[0];
+
+ is(
+ strictCookie.value,
+ "green",
+ "Same site strict cookie has the expected value"
+ );
+
+ is(strictCookie.sameSite, 2, "The cookie is a same site strict cookie");
+
+ // Opening the page that contains the iframe
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser);
+ let browser = tab.linkedBrowser;
+ let browserLoaded = BrowserTestUtils.browserLoaded(
+ browser,
+ true,
+ blockedPage,
+ true
+ );
+
+ BrowserTestUtils.loadURIString(browser, iFramePage);
+ await browserLoaded;
+ info("The error page has loaded!");
+
+ await SpecialPowers.spawn(browser, [], async function () {
+ let iframe = content.document.getElementById("theIframe");
+
+ await ContentTaskUtils.waitForCondition(() =>
+ SpecialPowers.spawn(iframe, [], () =>
+ content.document.body.classList.contains("neterror")
+ )
+ );
+ });
+
+ let iframe = browser.browsingContext.children[0];
+
+ let newTabLoaded = BrowserTestUtils.waitForNewTab(gBrowser, null, true);
+
+ // In the iframe, we see the correct error page and click on the button
+ // to open the blocked page in a new window/tab
+ await SpecialPowers.spawn(iframe, [], async function () {
+ let doc = content.document;
+
+ // aboutNetError.mjs is using async localization to format several
+ // messages and in result the translation may be applied later.
+ // We want to return the textContent of the element only after
+ // the translation completes, so let's wait for it here.
+ let elements = [
+ doc.getElementById("errorLongDesc"),
+ doc.getElementById("openInNewWindowButton"),
+ ];
+ await ContentTaskUtils.waitForCondition(() => {
+ return elements.every(elem => !!elem.textContent.trim().length);
+ });
+
+ let textLongDescription = doc.getElementById("errorLongDesc").textContent;
+ let learnMoreLinkLocation = doc.getElementById("learnMoreLink").href;
+
+ Assert.ok(
+ textLongDescription.includes(
+ "To see this page, you need to open it in a new window."
+ ),
+ "Correct error message found"
+ );
+
+ let button = doc.getElementById("openInNewWindowButton");
+ Assert.ok(
+ button.textContent.includes("Open Site in New Window"),
+ "We see the correct button to open the site in a new window"
+ );
+
+ Assert.ok(
+ learnMoreLinkLocation.includes("xframe-neterror-page"),
+ "Correct Learn More URL for CSP error page"
+ );
+
+ // We click on the button
+ await EventUtils.synthesizeMouseAtCenter(button, {}, content);
+ });
+ info("Button was clicked!");
+
+ // We wait for the new tab to load
+ await newTabLoaded;
+ info("The new tab has loaded!");
+
+ let iframePageTab = tab;
+ return {
+ iframePageTab,
+ blockedPageTab,
+ };
+}
diff --git a/browser/base/content/test/about/browser_aboutNetError_native_fallback.js b/browser/base/content/test/about/browser_aboutNetError_native_fallback.js
new file mode 100644
index 0000000000..4a87ad5cce
--- /dev/null
+++ b/browser/base/content/test/about/browser_aboutNetError_native_fallback.js
@@ -0,0 +1,174 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+let oldProxyType = Services.prefs.getIntPref("network.proxy.type");
+
+function reset() {
+ Services.prefs.clearUserPref("network.trr.display_fallback_warning");
+ Services.prefs.clearUserPref("network.trr.mode");
+ Services.prefs.clearUserPref("network.dns.native-is-localhost");
+ Services.prefs.clearUserPref("doh-rollout.disable-heuristics");
+ Services.prefs.setIntPref("network.proxy.type", oldProxyType);
+ Services.prefs.clearUserPref("network.trr.uri");
+
+ Services.dns.setHeuristicDetectionResult(Ci.nsITRRSkipReason.TRR_OK);
+}
+
+// This helper verifies that the given url loads correctly
+async function verifyLoad(url, testName) {
+ let browser;
+ await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ () => {
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, url);
+ browser = gBrowser.selectedBrowser;
+ },
+ true
+ );
+
+ await SpecialPowers.spawn(browser, [{ url, testName }], function (args) {
+ const doc = content.document;
+ ok(
+ doc.documentURI == args.url,
+ "Should have loaded page: " + args.testName
+ );
+ });
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+}
+
+// This helper verifies that loading the given url will lead to an error -- the fallback warning if the parameter is true
+async function verifyError(url, fallbackWarning, testName) {
+ // Clear everything.
+ Services.telemetry.clearEvents();
+ await TestUtils.waitForCondition(() => {
+ let events = Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS,
+ true
+ ).content;
+ return !events || !events.length;
+ });
+ Services.telemetry.setEventRecordingEnabled("security.doh.neterror", true);
+
+ let browser;
+ let pageLoaded;
+ await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ () => {
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, url);
+ browser = gBrowser.selectedBrowser;
+ pageLoaded = BrowserTestUtils.waitForErrorPage(browser);
+ },
+ false
+ );
+
+ info("Loading and waiting for the net error");
+ await pageLoaded;
+
+ await SpecialPowers.spawn(
+ browser,
+ [{ url, fallbackWarning, testName }],
+ function (args) {
+ const doc = content.document;
+
+ ok(doc.documentURI.startsWith("about:neterror"));
+ "Should be showing error page: " + args.testName;
+
+ const titleEl = doc.querySelector(".title-text");
+ const actualDataL10nID = titleEl.getAttribute("data-l10n-id");
+ if (args.fallbackWarning) {
+ is(
+ actualDataL10nID,
+ "dns-not-found-native-fallback-title2",
+ "Correct fallback warning error page title is set: " + args.testName
+ );
+ } else {
+ ok(
+ actualDataL10nID != "dns-not-found-native-fallback-title2",
+ "Should not show fallback warning: " + args.testName
+ );
+ }
+ }
+ );
+
+ if (fallbackWarning) {
+ let loadEvent = await TestUtils.waitForCondition(() => {
+ let events = Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS,
+ true
+ ).content;
+ return events?.find(
+ e => e[1] == "security.doh.neterror" && e[2] == "load"
+ );
+ }, "recorded telemetry for the load");
+ loadEvent.shift();
+ Assert.deepEqual(loadEvent, [
+ "security.doh.neterror",
+ "load",
+ "dohwarning",
+ "NativeFallbackWarning",
+ {
+ mode: "0",
+ provider_key: "0.0.0.0",
+ skip_reason: "TRR_HEURISTIC_TRIPPED_CANARY",
+ },
+ ]);
+ }
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+}
+
+// This test verifies that the native fallback warning appears in the desired scenarios, and only in those scenarios
+add_task(async function nativeFallbackWarnings() {
+ Services.prefs.setBoolPref("network.dns.native-is-localhost", true);
+
+ // Disable heuristics since they will attempt to connect to external servers
+ Services.prefs.setBoolPref("doh-rollout.disable-heuristics", true);
+
+ // Set a local TRR to prevent external connections
+ Services.prefs.setCharPref("network.trr.uri", "https://0.0.0.0/dns-query");
+
+ registerCleanupFunction(reset);
+
+ // Test without DoH
+ Services.prefs.setIntPref(
+ "network.trr.mode",
+ Ci.nsIDNSService.MODE_NATIVEONLY
+ );
+
+ Services.dns.clearCache(true);
+ await verifyLoad("https://www.example.com/", "valid url, no error");
+
+ // Should not trigger the native fallback warning
+ await verifyError("https://does-not-exist.test", false, "non existent url");
+
+ // We need to disable proxy, otherwise TRR isn't used for name resolution.
+ Services.prefs.setIntPref("network.proxy.type", 0);
+
+ // Switch to TRR first
+ Services.prefs.setIntPref(
+ "network.trr.mode",
+ Ci.nsIDNSService.MODE_NATIVEONLY
+ );
+ Services.prefs.setBoolPref("network.trr.display_fallback_warning", true);
+
+ // Simulate a tripped canary network
+ Services.dns.setHeuristicDetectionResult(
+ Ci.nsITRRSkipReason.TRR_HEURISTIC_TRIPPED_CANARY
+ );
+
+ // We should see the fallback warning displayed in both of these scenarios
+ Services.dns.clearCache(true);
+ await verifyError(
+ "https://www.example.com",
+ true,
+ "canary heuristic tripped"
+ );
+ await verifyError(
+ "https://does-not-exist.test",
+ true,
+ "canary heuristic tripped - non existent url"
+ );
+
+ reset();
+});
diff --git a/browser/base/content/test/about/browser_aboutNetError_trr.js b/browser/base/content/test/about/browser_aboutNetError_trr.js
new file mode 100644
index 0000000000..bfee686e7c
--- /dev/null
+++ b/browser/base/content/test/about/browser_aboutNetError_trr.js
@@ -0,0 +1,189 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// See bug 1831731. This test should not actually try to create a connection to
+// the real DoH endpoint. But that may happen when clearing the proxy type, and
+// sometimes even in the next test.
+// To prevent that we override the IP to a local address.
+Cc["@mozilla.org/network/native-dns-override;1"]
+ .getService(Ci.nsINativeDNSResolverOverride)
+ .addIPOverride("mozilla.cloudflare-dns.com", "127.0.0.1");
+
+let oldProxyType = Services.prefs.getIntPref("network.proxy.type");
+function resetPrefs() {
+ Services.prefs.clearUserPref("network.trr.mode");
+ Services.prefs.clearUserPref("network.dns.native-is-localhost");
+ Services.prefs.setIntPref("network.proxy.type", oldProxyType);
+}
+
+async function loadErrorPage() {
+ Services.prefs.setBoolPref("network.dns.native-is-localhost", true);
+ Services.prefs.setIntPref("network.trr.mode", Ci.nsIDNSService.MODE_TRRONLY);
+ // We need to disable proxy, otherwise TRR isn't used for name resolution.
+ Services.prefs.setIntPref("network.proxy.type", 0);
+ registerCleanupFunction(resetPrefs);
+
+ let browser;
+ let pageLoaded;
+ await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ () => {
+ gBrowser.selectedTab = BrowserTestUtils.addTab(
+ gBrowser,
+ "https://does-not-exist.test"
+ );
+ browser = gBrowser.selectedBrowser;
+ pageLoaded = BrowserTestUtils.waitForErrorPage(browser);
+ },
+ false
+ );
+
+ info("Loading and waiting for the net error");
+ await pageLoaded;
+ return browser;
+}
+
+// This test makes sure that the Add exception button only shows up
+// when the skipReason indicates that the domain could not be resolved.
+// If instead there is a problem with the TRR connection, then we don't
+// show the exception button.
+add_task(async function exceptionButtonTRROnly() {
+ let browser = await loadErrorPage();
+
+ await SpecialPowers.spawn(browser, [], function () {
+ const doc = content.document;
+ ok(
+ doc.documentURI.startsWith("about:neterror"),
+ "Should be showing error page"
+ );
+
+ const titleEl = doc.querySelector(".title-text");
+ const actualDataL10nID = titleEl.getAttribute("data-l10n-id");
+ is(
+ actualDataL10nID,
+ "dns-not-found-trr-only-title2",
+ "Correct error page title is set"
+ );
+
+ let trrExceptionButton = doc.getElementById("trrExceptionButton");
+ Assert.equal(
+ trrExceptionButton.hidden,
+ true,
+ "Exception button should be hidden for TRR service failures"
+ );
+ });
+
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ resetPrefs();
+});
+
+add_task(async function TRROnlyExceptionButtonTelemetry() {
+ // Clear everything.
+ Services.telemetry.clearEvents();
+ await TestUtils.waitForCondition(() => {
+ let events = Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS,
+ true
+ ).content;
+ return !events || !events.length;
+ });
+ Services.telemetry.setEventRecordingEnabled("security.doh.neterror", true);
+
+ let browser = await loadErrorPage();
+
+ await SpecialPowers.spawn(browser, [], function () {
+ const doc = content.document;
+ ok(
+ doc.documentURI.startsWith("about:neterror"),
+ "Should be showing error page"
+ );
+ });
+
+ let loadEvent = await TestUtils.waitForCondition(() => {
+ let events = Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS,
+ true
+ ).content;
+ return events?.find(e => e[1] == "security.doh.neterror" && e[2] == "load");
+ }, "recorded telemetry for the load");
+
+ loadEvent.shift();
+ Assert.deepEqual(loadEvent, [
+ "security.doh.neterror",
+ "load",
+ "dohwarning",
+ "TRROnlyFailure",
+ {
+ mode: "3",
+ provider_key: "mozilla.cloudflare-dns.com",
+ skip_reason: "TRR_UNKNOWN_CHANNEL_FAILURE",
+ },
+ ]);
+
+ await SpecialPowers.spawn(browser, [], function () {
+ const doc = content.document;
+ let buttons = ["neterrorTryAgainButton", "trrSettingsButton"];
+ for (let buttonId of buttons) {
+ let button = doc.getElementById(buttonId);
+ button.click();
+ }
+ });
+
+ // Since we click TryAgain, make sure the error page is loaded again.
+ await BrowserTestUtils.waitForErrorPage(browser);
+
+ is(
+ gBrowser.tabs.length,
+ 3,
+ "Should open about:preferences#privacy-doh in another tab"
+ );
+
+ let clickEvents = await TestUtils.waitForCondition(
+ () => {
+ let events = Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS,
+ true
+ ).content;
+ return events?.filter(
+ e => e[1] == "security.doh.neterror" && e[2] == "click"
+ );
+ },
+ "recorded telemetry for clicking buttons",
+ 500,
+ 100
+ );
+
+ let firstEvent = clickEvents[0];
+ firstEvent.shift(); // remove timestamp
+ Assert.deepEqual(firstEvent, [
+ "security.doh.neterror",
+ "click",
+ "try_again_button",
+ "TRROnlyFailure",
+ {
+ mode: "3",
+ provider_key: "mozilla.cloudflare-dns.com",
+ skip_reason: "TRR_UNKNOWN_CHANNEL_FAILURE",
+ },
+ ]);
+
+ let secondEvent = clickEvents[1];
+ secondEvent.shift(); // remove timestamp
+ Assert.deepEqual(secondEvent, [
+ "security.doh.neterror",
+ "click",
+ "settings_button",
+ "TRROnlyFailure",
+ {
+ mode: "3",
+ provider_key: "mozilla.cloudflare-dns.com",
+ skip_reason: "TRR_UNKNOWN_CHANNEL_FAILURE",
+ },
+ ]);
+
+ BrowserTestUtils.removeTab(gBrowser.tabs[2]);
+ BrowserTestUtils.removeTab(gBrowser.tabs[1]);
+ resetPrefs();
+});
diff --git a/browser/base/content/test/about/browser_aboutNetError_xfo_iframe.js b/browser/base/content/test/about/browser_aboutNetError_xfo_iframe.js
new file mode 100644
index 0000000000..ae4d5c22a2
--- /dev/null
+++ b/browser/base/content/test/about/browser_aboutNetError_xfo_iframe.js
@@ -0,0 +1,139 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const BLOCKED_PAGE =
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.org:8000/browser/browser/base/content/test/about/xfo_iframe.sjs";
+
+add_task(async function test_xfo_iframe() {
+ let { iframePageTab, blockedPageTab } = await setupPage(
+ "iframe_page_xfo.html",
+ BLOCKED_PAGE
+ );
+
+ let xfoBrowser = gBrowser.selectedTab.linkedBrowser;
+
+ // The blocked page opened in a new window/tab
+ await SpecialPowers.spawn(
+ xfoBrowser,
+ [BLOCKED_PAGE],
+ async function (xfoBlockedPage) {
+ let cookieHeader = content.document.getElementById("strictCookie");
+ let location = content.document.location.href;
+
+ Assert.ok(
+ cookieHeader.textContent.includes("No same site strict cookie header"),
+ "Same site strict cookie has not been set"
+ );
+ Assert.equal(
+ location,
+ xfoBlockedPage,
+ "Location of new page is correct!"
+ );
+ }
+ );
+
+ Services.cookies.removeAll();
+ BrowserTestUtils.removeTab(iframePageTab);
+ BrowserTestUtils.removeTab(blockedPageTab);
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+});
+
+async function setupPage(htmlPageName, blockedPage) {
+ let iFramePage =
+ getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com"
+ ) + htmlPageName;
+
+ // Opening the blocked page once in a new tab
+ let blockedPageTab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ blockedPage
+ );
+ let blockedPageBrowser = blockedPageTab.linkedBrowser;
+
+ let cookies = Services.cookies.getCookiesFromHost(
+ "example.org",
+ blockedPageBrowser.contentPrincipal.originAttributes
+ );
+ let strictCookie = cookies[0];
+
+ is(
+ strictCookie.value,
+ "creamy",
+ "Same site strict cookie has the expected value"
+ );
+
+ is(strictCookie.sameSite, 2, "The cookie is a same site strict cookie");
+
+ // Opening the page that contains the iframe
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser);
+ let browser = tab.linkedBrowser;
+ let browserLoaded = BrowserTestUtils.browserLoaded(
+ browser,
+ true,
+ blockedPage,
+ true
+ );
+
+ BrowserTestUtils.loadURIString(browser, iFramePage);
+ await browserLoaded;
+ info("The error page has loaded!");
+
+ await SpecialPowers.spawn(browser, [], async function () {
+ let iframe = content.document.getElementById("theIframe");
+
+ await ContentTaskUtils.waitForCondition(() =>
+ SpecialPowers.spawn(iframe, [], () =>
+ content.document.body.classList.contains("neterror")
+ )
+ );
+ });
+
+ let frameContext = browser.browsingContext.children[0];
+ let newTabLoaded = BrowserTestUtils.waitForNewTab(gBrowser, null, true);
+
+ // In the iframe, we see the correct error page and click on the button
+ // to open the blocked page in a new window/tab
+ await SpecialPowers.spawn(frameContext, [], async function () {
+ let doc = content.document;
+ let textLongDescription = doc.getElementById("errorLongDesc").textContent;
+ let learnMoreLinkLocation = doc.getElementById("learnMoreLink").href;
+
+ Assert.ok(
+ textLongDescription.includes(
+ "To see this page, you need to open it in a new window."
+ ),
+ "Correct error message found"
+ );
+
+ let button = doc.getElementById("openInNewWindowButton");
+ Assert.ok(
+ button.textContent.includes("Open Site in New Window"),
+ "We see the correct button to open the site in a new window"
+ );
+
+ Assert.ok(
+ learnMoreLinkLocation.includes("xframe-neterror-page"),
+ "Correct Learn More URL for XFO error page"
+ );
+
+ // We click on the button
+ await EventUtils.synthesizeMouseAtCenter(button, {}, content);
+ });
+ info("Button was clicked!");
+
+ // We wait for the new tab to load
+ await newTabLoaded;
+ info("The new tab has loaded!");
+
+ let iframePageTab = tab;
+ return {
+ iframePageTab,
+ blockedPageTab,
+ };
+}
diff --git a/browser/base/content/test/about/browser_aboutNewTab_bookmarksToolbar.js b/browser/base/content/test/about/browser_aboutNewTab_bookmarksToolbar.js
new file mode 100644
index 0000000000..c566276d9f
--- /dev/null
+++ b/browser/base/content/test/about/browser_aboutNewTab_bookmarksToolbar.js
@@ -0,0 +1,311 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function bookmarks_toolbar_shown_on_newtab() {
+ let newtab = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ opening: "about:newtab",
+ waitForLoad: false,
+ });
+
+ // 1: Test that the toolbar is shown in a newly opened foreground about:newtab
+ await waitForBookmarksToolbarVisibility({
+ visible: true,
+ message: "Toolbar should be visible on newtab",
+ });
+ ok(isBookmarksToolbarVisible(), "Toolbar should be visible on newtab");
+
+ // 2: Test that the toolbar is hidden when the browser is navigated away from newtab
+ BrowserTestUtils.loadURIString(newtab.linkedBrowser, "https://example.com");
+ await BrowserTestUtils.browserLoaded(newtab.linkedBrowser);
+ await waitForBookmarksToolbarVisibility({
+ visible: false,
+ message:
+ "Toolbar should not be visible on newtab after example.com is loaded within",
+ });
+ ok(
+ !isBookmarksToolbarVisible(),
+ "Toolbar should not be visible on newtab after example.com is loaded within"
+ );
+
+ // 3: Re-load about:newtab in the browser for the following tests and confirm toolbar reappears
+ BrowserTestUtils.loadURIString(newtab.linkedBrowser, "about:newtab");
+ await BrowserTestUtils.browserLoaded(newtab.linkedBrowser);
+ await waitForBookmarksToolbarVisibility({
+ visible: true,
+ message: "Toolbar should be visible on newtab",
+ });
+ ok(isBookmarksToolbarVisible(), "Toolbar should be visible on newtab");
+
+ // 4: Toolbar should get hidden when opening a new tab to example.com
+ let example = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ opening: "https://example.com",
+ });
+ await waitForBookmarksToolbarVisibility({
+ visible: false,
+ message: "Toolbar should be hidden on example.com",
+ });
+
+ // 5: Toolbar should become visible when switching tabs to newtab
+ await BrowserTestUtils.switchTab(gBrowser, newtab);
+ await waitForBookmarksToolbarVisibility({
+ visible: true,
+ message: "Toolbar is visible with switch to newtab",
+ });
+ ok(isBookmarksToolbarVisible(), "Toolbar is visible with switch to newtab");
+
+ // 6: Toolbar should become hidden when switching tabs to example.com
+ await BrowserTestUtils.switchTab(gBrowser, example);
+ await waitForBookmarksToolbarVisibility({
+ visible: false,
+ message: "Toolbar is hidden with switch to example",
+ });
+
+ // 7: Similar to #3 above, loading about:newtab in example should show toolbar
+ BrowserTestUtils.loadURIString(example.linkedBrowser, "about:newtab");
+ await BrowserTestUtils.browserLoaded(example.linkedBrowser);
+ await waitForBookmarksToolbarVisibility({
+ visible: true,
+ message: "Toolbar is visible with newtab load",
+ });
+ ok(isBookmarksToolbarVisible(), "Toolbar is visible with newtab load");
+
+ // 8: Switching back and forth between two browsers showing about:newtab will still show the toolbar
+ await BrowserTestUtils.switchTab(gBrowser, newtab);
+ ok(isBookmarksToolbarVisible(), "Toolbar is visible with switch to newtab");
+ await BrowserTestUtils.switchTab(gBrowser, example);
+ ok(
+ isBookmarksToolbarVisible(),
+ "Toolbar is visible with switch to example(newtab)"
+ );
+
+ // 9: With custom newtab URL, toolbar isn't shown on about:newtab but is shown on custom URL
+ let oldNewTab = AboutNewTab.newTabURL;
+ AboutNewTab.newTabURL = "https://example.com/2";
+ await BrowserTestUtils.switchTab(gBrowser, newtab);
+ await waitForBookmarksToolbarVisibility({ visible: false });
+ ok(!isBookmarksToolbarVisible(), "Toolbar should hide with custom newtab");
+ BrowserTestUtils.loadURIString(example.linkedBrowser, AboutNewTab.newTabURL);
+ await BrowserTestUtils.browserLoaded(example.linkedBrowser);
+ await BrowserTestUtils.switchTab(gBrowser, example);
+ await waitForBookmarksToolbarVisibility({ visible: true });
+ ok(
+ isBookmarksToolbarVisible(),
+ "Toolbar is visible with switch to custom newtab"
+ );
+
+ await BrowserTestUtils.removeTab(newtab);
+ await BrowserTestUtils.removeTab(example);
+ AboutNewTab.newTabURL = oldNewTab;
+});
+
+add_task(async function bookmarks_toolbar_open_persisted() {
+ let newtab = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ opening: "about:newtab",
+ waitForLoad: false,
+ });
+ let example = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ opening: "https://example.com",
+ });
+ let isToolbarPersistedOpen = () =>
+ Services.prefs.getCharPref("browser.toolbars.bookmarks.visibility") ==
+ "always";
+
+ ok(!isBookmarksToolbarVisible(), "Toolbar is hidden");
+ await BrowserTestUtils.switchTab(gBrowser, newtab);
+ await waitForBookmarksToolbarVisibility({ visible: true });
+ ok(isBookmarksToolbarVisible(), "Toolbar is visible");
+ await BrowserTestUtils.switchTab(gBrowser, example);
+ await waitForBookmarksToolbarVisibility({ visible: false });
+ ok(!isBookmarksToolbarVisible(), "Toolbar is hidden");
+ ok(!isToolbarPersistedOpen(), "Toolbar is not persisted open");
+
+ let contextMenu = document.querySelector("#toolbar-context-menu");
+ let popupShown = BrowserTestUtils.waitForEvent(contextMenu, "popupshown");
+ let menuButton = document.getElementById("PanelUI-menu-button");
+ EventUtils.synthesizeMouseAtCenter(
+ menuButton,
+ { type: "contextmenu" },
+ window
+ );
+ await popupShown;
+ let bookmarksToolbarMenu = document.querySelector("#toggle_PersonalToolbar");
+ let subMenu = bookmarksToolbarMenu.querySelector("menupopup");
+ popupShown = BrowserTestUtils.waitForEvent(subMenu, "popupshown");
+ bookmarksToolbarMenu.openMenu(true);
+ await popupShown;
+ let alwaysMenuItem = document.querySelector(
+ 'menuitem[data-visibility-enum="always"]'
+ );
+ let neverMenuItem = document.querySelector(
+ 'menuitem[data-visibility-enum="never"]'
+ );
+ let newTabMenuItem = document.querySelector(
+ 'menuitem[data-visibility-enum="newtab"]'
+ );
+ is(alwaysMenuItem.getAttribute("checked"), "false", "Menuitem isn't checked");
+ is(neverMenuItem.getAttribute("checked"), "false", "Menuitem isn't checked");
+ is(newTabMenuItem.getAttribute("checked"), "true", "Menuitem is checked");
+
+ subMenu.activateItem(alwaysMenuItem);
+
+ await waitForBookmarksToolbarVisibility({ visible: true });
+ popupShown = BrowserTestUtils.waitForEvent(contextMenu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ menuButton,
+ { type: "contextmenu" },
+ window
+ );
+ await popupShown;
+ bookmarksToolbarMenu = document.querySelector("#toggle_PersonalToolbar");
+ subMenu = bookmarksToolbarMenu.querySelector("menupopup");
+ popupShown = BrowserTestUtils.waitForEvent(subMenu, "popupshown");
+ bookmarksToolbarMenu.openMenu(true);
+ await popupShown;
+ alwaysMenuItem = document.querySelector(
+ 'menuitem[data-visibility-enum="always"]'
+ );
+ neverMenuItem = document.querySelector(
+ 'menuitem[data-visibility-enum="never"]'
+ );
+ newTabMenuItem = document.querySelector(
+ 'menuitem[data-visibility-enum="newtab"]'
+ );
+ is(alwaysMenuItem.getAttribute("checked"), "true", "Menuitem is checked");
+ is(neverMenuItem.getAttribute("checked"), "false", "Menuitem isn't checked");
+ is(newTabMenuItem.getAttribute("checked"), "false", "Menuitem isn't checked");
+ contextMenu.hidePopup();
+ ok(isBookmarksToolbarVisible(), "Toolbar is visible");
+ ok(isToolbarPersistedOpen(), "Toolbar is persisted open");
+ await BrowserTestUtils.switchTab(gBrowser, newtab);
+ ok(isBookmarksToolbarVisible(), "Toolbar is visible");
+ await BrowserTestUtils.switchTab(gBrowser, example);
+ ok(isBookmarksToolbarVisible(), "Toolbar is visible");
+
+ popupShown = BrowserTestUtils.waitForEvent(contextMenu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ menuButton,
+ { type: "contextmenu" },
+ window
+ );
+ await popupShown;
+ bookmarksToolbarMenu = document.querySelector("#toggle_PersonalToolbar");
+ subMenu = bookmarksToolbarMenu.querySelector("menupopup");
+ popupShown = BrowserTestUtils.waitForEvent(subMenu, "popupshown");
+ bookmarksToolbarMenu.openMenu(true);
+ await popupShown;
+ alwaysMenuItem = document.querySelector(
+ 'menuitem[data-visibility-enum="always"]'
+ );
+ neverMenuItem = document.querySelector(
+ 'menuitem[data-visibility-enum="never"]'
+ );
+ newTabMenuItem = document.querySelector(
+ 'menuitem[data-visibility-enum="newtab"]'
+ );
+ is(alwaysMenuItem.getAttribute("checked"), "true", "Menuitem is checked");
+ is(neverMenuItem.getAttribute("checked"), "false", "Menuitem isn't checked");
+ is(newTabMenuItem.getAttribute("checked"), "false", "Menuitem isn't checked");
+ subMenu.activateItem(newTabMenuItem);
+ await waitForBookmarksToolbarVisibility({
+ visible: false,
+ message: "Toolbar is hidden",
+ });
+ await BrowserTestUtils.switchTab(gBrowser, newtab);
+ await waitForBookmarksToolbarVisibility({
+ visible: true,
+ message: "Toolbar is visible",
+ });
+ await BrowserTestUtils.switchTab(gBrowser, example);
+ await waitForBookmarksToolbarVisibility({
+ visible: false,
+ message: "Toolbar is hidden",
+ });
+
+ await BrowserTestUtils.removeTab(newtab);
+ await BrowserTestUtils.removeTab(example);
+});
+
+add_task(async function test_with_newtabpage_disabled() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.newtabpage.enabled", true]],
+ });
+
+ let tabCount = gBrowser.tabs.length;
+ document.getElementById("cmd_newNavigatorTab").doCommand();
+ // Can't use BrowserTestUtils.waitForNewTab since onLocationChange will not
+ // fire due to preloaded new tabs.
+ await TestUtils.waitForCondition(() => gBrowser.tabs.length == tabCount + 1);
+ let newtab = gBrowser.selectedTab;
+ is(newtab.linkedBrowser.currentURI.spec, "about:newtab", "newtab is loaded");
+ await waitForBookmarksToolbarVisibility({
+ visible: true,
+ message: "Toolbar is visible with NTP enabled",
+ });
+ let firstid = await SpecialPowers.spawn(newtab.linkedBrowser, [], () => {
+ return content.document.body.firstElementChild?.id;
+ });
+ is(firstid, "root", "new tab page contains content");
+ await BrowserTestUtils.removeTab(newtab);
+
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.newtabpage.enabled", false]],
+ });
+
+ document.getElementById("cmd_newNavigatorTab").doCommand();
+ await TestUtils.waitForCondition(() => gBrowser.tabs.length == tabCount + 1);
+ newtab = gBrowser.selectedTab;
+
+ await waitForBookmarksToolbarVisibility({
+ visible: true,
+ message: "Toolbar is visible with NTP disabled",
+ });
+
+ is(
+ newtab.linkedBrowser.currentURI.spec,
+ "about:newtab",
+ "blank new tab is loaded"
+ );
+ firstid = await SpecialPowers.spawn(newtab.linkedBrowser, [], () => {
+ return content.document.body.firstElementChild;
+ });
+ ok(!firstid, "blank new tab page contains no content");
+
+ await BrowserTestUtils.removeTab(newtab);
+
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.newtabpage.enabled", true]],
+ });
+});
+
+add_task(async function test_history_pushstate() {
+ await BrowserTestUtils.withNewTab("https://example.com/", async browser => {
+ await waitForBookmarksToolbarVisibility({ visible: false });
+ ok(!isBookmarksToolbarVisible(), "Toolbar should be hidden");
+
+ // Temporarily show the toolbar:
+ setToolbarVisibility(
+ document.querySelector("#PersonalToolbar"),
+ true,
+ false,
+ false
+ );
+ ok(isBookmarksToolbarVisible(), "Toolbar should now be visible");
+
+ // Now "navigate"
+ await SpecialPowers.spawn(browser, [], () => {
+ content.location.href += "#foo";
+ });
+
+ await TestUtils.waitForCondition(
+ () => gURLBar.value.endsWith("#foo"),
+ "URL bar should update"
+ );
+ ok(isBookmarksToolbarVisible(), "Toolbar should still be visible");
+ });
+});
diff --git a/browser/base/content/test/about/browser_aboutNewTab_bookmarksToolbarEmpty.js b/browser/base/content/test/about/browser_aboutNewTab_bookmarksToolbarEmpty.js
new file mode 100644
index 0000000000..8e9ef8d163
--- /dev/null
+++ b/browser/base/content/test/about/browser_aboutNewTab_bookmarksToolbarEmpty.js
@@ -0,0 +1,158 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const bookmarksInfo = [
+ {
+ title: "firefox",
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ url: "http://example.com",
+ },
+ {
+ title: "rules",
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ url: "http://example.com/2",
+ },
+ {
+ title: "yo",
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ url: "http://example.com/2",
+ },
+];
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ // Ensure we can wait for about:newtab to load.
+ set: [["browser.newtab.preload", false]],
+ });
+ // Move all existing bookmarks in the Bookmarks Toolbar and
+ // Other Bookmarks to the Bookmarks Menu so they don't affect
+ // the visibility of the Bookmarks Toolbar. Restore them at
+ // the end of the test.
+ let Bookmarks = PlacesUtils.bookmarks;
+ let toolbarBookmarks = [];
+ let unfiledBookmarks = [];
+ let guidBookmarkTuples = [
+ [Bookmarks.toolbarGuid, toolbarBookmarks],
+ [Bookmarks.unfiledGuid, unfiledBookmarks],
+ ];
+ for (let [parentGuid, arr] of guidBookmarkTuples) {
+ await Bookmarks.fetch({ parentGuid }, bookmark => arr.push(bookmark));
+ }
+ await Promise.all(
+ [...toolbarBookmarks, ...unfiledBookmarks].map(async bookmark => {
+ bookmark.parentGuid = Bookmarks.menuGuid;
+ return Bookmarks.update(bookmark);
+ })
+ );
+ registerCleanupFunction(async () => {
+ for (let [parentGuid, arr] of guidBookmarkTuples) {
+ await Promise.all(
+ arr.map(async bookmark => {
+ bookmark.parentGuid = parentGuid;
+ return Bookmarks.update(bookmark);
+ })
+ );
+ }
+ });
+});
+
+add_task(async function bookmarks_toolbar_not_shown_when_empty() {
+ let bookmarks = await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.toolbarGuid,
+ children: bookmarksInfo,
+ });
+ let example = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ opening: "https://example.com",
+ });
+ let newtab = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ opening: "about:newtab",
+ });
+ let emptyMessage = document.getElementById("personal-toolbar-empty");
+
+ // 1: Test that the toolbar is shown in a newly opened foreground about:newtab
+ await waitForBookmarksToolbarVisibility({
+ visible: true,
+ message: "Toolbar should be visible on newtab",
+ });
+ ok(emptyMessage.hidden, "Empty message is hidden with toolbar populated");
+
+ // 2: Toolbar should get hidden when switching tab to example.com
+ await BrowserTestUtils.switchTab(gBrowser, example);
+ await waitForBookmarksToolbarVisibility({
+ visible: false,
+ message: "Toolbar should be hidden on example.com",
+ });
+
+ // 3: Remove all children of the Bookmarks Toolbar and confirm that
+ // the toolbar should not become visible when switching to newtab
+ CustomizableUI.addWidgetToArea(
+ "personal-bookmarks",
+ CustomizableUI.AREA_TABSTRIP
+ );
+ CustomizableUI.removeWidgetFromArea("import-button");
+ await BrowserTestUtils.switchTab(gBrowser, newtab);
+ await waitForBookmarksToolbarVisibility({
+ visible: true,
+ message: "Toolbar is visible when there are no items in the toolbar area",
+ });
+ ok(!emptyMessage.hidden, "Empty message is shown with toolbar empty");
+ // Click the link and check we open the library:
+ let winPromise = BrowserTestUtils.domWindowOpenedAndLoaded();
+ EventUtils.synthesizeMouseAtCenter(
+ emptyMessage.querySelector(".text-link"),
+ {}
+ );
+ let libraryWin = await winPromise;
+ is(
+ libraryWin.document.location.href,
+ "chrome://browser/content/places/places.xhtml",
+ "Should have opened library."
+ );
+ await BrowserTestUtils.closeWindow(libraryWin);
+
+ // 4: Put personal-bookmarks back in the toolbar and confirm the toolbar is visible now
+ CustomizableUI.addWidgetToArea(
+ "personal-bookmarks",
+ CustomizableUI.AREA_BOOKMARKS
+ );
+ await BrowserTestUtils.switchTab(gBrowser, example);
+ await BrowserTestUtils.switchTab(gBrowser, newtab);
+ await waitForBookmarksToolbarVisibility({
+ visible: true,
+ message: "Toolbar should be visible with Bookmarks Toolbar Items restored",
+ });
+ ok(emptyMessage.hidden, "Empty message is hidden with toolbar populated");
+
+ // 5: Remove all the bookmarks in the toolbar and confirm that the toolbar
+ // is hidden on the New Tab now
+ await PlacesUtils.bookmarks.remove(bookmarks);
+ await BrowserTestUtils.switchTab(gBrowser, example);
+ await BrowserTestUtils.switchTab(gBrowser, newtab);
+ await waitForBookmarksToolbarVisibility({
+ visible: true,
+ message:
+ "Toolbar is visible when there are no items or nested bookmarks in the toolbar area",
+ });
+ ok(!emptyMessage.hidden, "Empty message is shown with toolbar empty");
+
+ // 6: Add a toolbarbutton and make sure that the toolbar appears when the button is visible
+ CustomizableUI.addWidgetToArea(
+ "characterencoding-button",
+ CustomizableUI.AREA_BOOKMARKS
+ );
+ await BrowserTestUtils.switchTab(gBrowser, example);
+ await BrowserTestUtils.switchTab(gBrowser, newtab);
+ await waitForBookmarksToolbarVisibility({
+ visible: true,
+ message: "Toolbar is visible when there is a visible button in the toolbar",
+ });
+ ok(emptyMessage.hidden, "Empty message is hidden with button in toolbar");
+
+ await BrowserTestUtils.removeTab(newtab);
+ await BrowserTestUtils.removeTab(example);
+ CustomizableUI.reset();
+});
diff --git a/browser/base/content/test/about/browser_aboutNewTab_bookmarksToolbarNewWindow.js b/browser/base/content/test/about/browser_aboutNewTab_bookmarksToolbarNewWindow.js
new file mode 100644
index 0000000000..19c990bbbc
--- /dev/null
+++ b/browser/base/content/test/about/browser_aboutNewTab_bookmarksToolbarNewWindow.js
@@ -0,0 +1,82 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+requestLongerTimeout(2);
+
+const testCases = [
+ {
+ name: "bookmarks_toolbar_shown_on_newtab_newTabEnabled",
+ newTabEnabled: true,
+ },
+ {
+ name: "bookmarks_toolbar_shown_on_newtab",
+ newTabEnabled: false,
+ },
+];
+
+async function test_bookmarks_toolbar_visibility({ newTabEnabled }) {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.newtabpage.enabled", newTabEnabled]],
+ });
+
+ // Ensure the toolbar doesnt become visible at any point before the tab finishes loading
+
+ let url =
+ getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+ ) + "slow_loading_page.sjs";
+
+ let startTime = Date.now();
+ let newWindowOpened = BrowserTestUtils.domWindowOpened();
+ let beforeShown = TestUtils.topicObserved("browser-window-before-show");
+
+ openTrustedLinkIn(url, "window");
+
+ let newWin = await newWindowOpened;
+ let slowSiteLoaded = BrowserTestUtils.firstBrowserLoaded(newWin, false);
+
+ function checkToolbarIsCollapsed(win, message) {
+ let toolbar = win.document.getElementById("PersonalToolbar");
+ ok(toolbar && toolbar.collapsed, message);
+ }
+
+ await beforeShown;
+ checkToolbarIsCollapsed(
+ newWin,
+ "Toolbar is initially hidden on the new window"
+ );
+
+ function onToolbarMutation() {
+ checkToolbarIsCollapsed(newWin, "Toolbar should remain collapsed");
+ }
+ let toolbarMutationObserver = new newWin.MutationObserver(onToolbarMutation);
+ toolbarMutationObserver.observe(
+ newWin.document.getElementById("PersonalToolbar"),
+ {
+ attributeFilter: ["collapsed"],
+ }
+ );
+
+ info("Waiting for the slow site to load");
+ await slowSiteLoaded;
+ info(`Window opened and slow site loaded in: ${Date.now() - startTime}ms`);
+
+ checkToolbarIsCollapsed(newWin, "Finally, the toolbar is still hidden");
+
+ toolbarMutationObserver.disconnect();
+ await BrowserTestUtils.closeWindow(newWin);
+}
+
+// Make separate tasks for each test case, so we get more useful stack traces on failure
+for (let testData of testCases) {
+ let tmp = {
+ async [testData.name]() {
+ info("testing with: " + JSON.stringify(testData));
+ await test_bookmarks_toolbar_visibility(testData);
+ },
+ };
+ add_task(tmp[testData.name]);
+}
diff --git a/browser/base/content/test/about/browser_aboutNewTab_bookmarksToolbarPrefs.js b/browser/base/content/test/about/browser_aboutNewTab_bookmarksToolbarPrefs.js
new file mode 100644
index 0000000000..e9f7768beb
--- /dev/null
+++ b/browser/base/content/test/about/browser_aboutNewTab_bookmarksToolbarPrefs.js
@@ -0,0 +1,74 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+requestLongerTimeout(3);
+
+add_task(async function test_with_different_pref_states() {
+ // [prefName, prefValue, toolbarVisibleExampleCom, toolbarVisibleNewTab]
+ let bookmarksToolbarVisibilityStates = [
+ ["browser.toolbars.bookmarks.visibility", "newtab"],
+ ["browser.toolbars.bookmarks.visibility", "always"],
+ ["browser.toolbars.bookmarks.visibility", "never"],
+ ];
+ for (let visibilityState of bookmarksToolbarVisibilityStates) {
+ await SpecialPowers.pushPrefEnv({
+ set: [visibilityState],
+ });
+
+ for (let privateWin of [true, false]) {
+ info(
+ `Testing with ${visibilityState} in a ${
+ privateWin ? "private" : "non-private"
+ } window`
+ );
+ let win = await BrowserTestUtils.openNewBrowserWindow({
+ private: privateWin,
+ });
+ is(
+ win.gBrowser.currentURI.spec,
+ privateWin ? "about:privatebrowsing" : "about:blank",
+ "Expecting about:privatebrowsing or about:blank as URI of new window"
+ );
+
+ if (!privateWin) {
+ await waitForBookmarksToolbarVisibility({
+ win,
+ visible: visibilityState[1] == "always",
+ message:
+ "Toolbar should be visible only if visibilityState is 'always'. State: " +
+ visibilityState[1],
+ });
+ await BrowserTestUtils.openNewForegroundTab({
+ gBrowser: win.gBrowser,
+ opening: "about:newtab",
+ waitForLoad: false,
+ });
+ }
+
+ await waitForBookmarksToolbarVisibility({
+ win,
+ visible:
+ visibilityState[1] == "newtab" || visibilityState[1] == "always",
+ message:
+ "Toolbar should be visible as long as visibilityState isn't set to 'never'. State: " +
+ visibilityState[1],
+ });
+
+ await BrowserTestUtils.openNewForegroundTab({
+ gBrowser: win.gBrowser,
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ opening: "http://example.com",
+ });
+ await waitForBookmarksToolbarVisibility({
+ win,
+ visible: visibilityState[1] == "always",
+ message:
+ "Toolbar should be visible only if visibilityState is 'always'. State: " +
+ visibilityState[1],
+ });
+ await BrowserTestUtils.closeWindow(win);
+ }
+ }
+});
diff --git a/browser/base/content/test/about/browser_aboutStopReload.js b/browser/base/content/test/about/browser_aboutStopReload.js
new file mode 100644
index 0000000000..66c11a3de3
--- /dev/null
+++ b/browser/base/content/test/about/browser_aboutStopReload.js
@@ -0,0 +1,169 @@
+async function waitForNoAnimation(elt) {
+ return TestUtils.waitForCondition(() => !elt.hasAttribute("animate"));
+}
+
+async function getAnimatePromise(elt) {
+ return BrowserTestUtils.waitForAttribute("animate", elt).then(() =>
+ Assert.ok(true, `${elt.id} should animate`)
+ );
+}
+
+function stopReloadMutationCallback() {
+ Assert.ok(
+ false,
+ "stop-reload's animate attribute should not have been mutated"
+ );
+}
+
+// Force-enable the animation
+gReduceMotionOverride = false;
+
+add_task(async function checkDontShowStopOnNewTab() {
+ let stopReloadContainer = document.getElementById("stop-reload-button");
+ let stopReloadContainerObserver = new MutationObserver(
+ stopReloadMutationCallback
+ );
+
+ await waitForNoAnimation(stopReloadContainer);
+ stopReloadContainerObserver.observe(stopReloadContainer, {
+ attributeFilter: ["animate"],
+ });
+ let tab = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ opening: "about:robots",
+ waitForStateStop: true,
+ });
+ BrowserTestUtils.removeTab(tab);
+
+ Assert.ok(
+ true,
+ "Test finished: stop-reload does not animate when navigating to local URI on new tab"
+ );
+ stopReloadContainerObserver.disconnect();
+});
+
+add_task(async function checkDontShowStopFromLocalURI() {
+ let stopReloadContainer = document.getElementById("stop-reload-button");
+ let stopReloadContainerObserver = new MutationObserver(
+ stopReloadMutationCallback
+ );
+
+ let tab = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ opening: "about:robots",
+ waitForStateStop: true,
+ });
+ await waitForNoAnimation(stopReloadContainer);
+ stopReloadContainerObserver.observe(stopReloadContainer, {
+ attributeFilter: ["animate"],
+ });
+ BrowserTestUtils.loadURIString(tab.linkedBrowser, "about:mozilla");
+ BrowserTestUtils.removeTab(tab);
+
+ Assert.ok(
+ true,
+ "Test finished: stop-reload does not animate when navigating between local URIs"
+ );
+ stopReloadContainerObserver.disconnect();
+});
+
+add_task(async function checkDontShowStopFromNonLocalURI() {
+ let stopReloadContainer = document.getElementById("stop-reload-button");
+ let stopReloadContainerObserver = new MutationObserver(
+ stopReloadMutationCallback
+ );
+
+ let tab = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ opening: "https://example.com",
+ waitForStateStop: true,
+ });
+ await waitForNoAnimation(stopReloadContainer);
+ stopReloadContainerObserver.observe(stopReloadContainer, {
+ attributeFilter: ["animate"],
+ });
+ BrowserTestUtils.loadURIString(tab.linkedBrowser, "about:mozilla");
+ BrowserTestUtils.removeTab(tab);
+
+ Assert.ok(
+ true,
+ "Test finished: stop-reload does not animate when navigating to local URI from non-local URI"
+ );
+ stopReloadContainerObserver.disconnect();
+});
+
+add_task(async function checkDoShowStopOnNewTab() {
+ let stopReloadContainer = document.getElementById("stop-reload-button");
+ let reloadButton = document.getElementById("reload-button");
+ let stopPromise = BrowserTestUtils.waitForAttribute(
+ "displaystop",
+ reloadButton
+ );
+
+ await waitForNoAnimation(stopReloadContainer);
+
+ let tab = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ opening: "https://example.com",
+ waitForStateStop: true,
+ });
+ await stopPromise;
+ await waitForNoAnimation(stopReloadContainer);
+ BrowserTestUtils.removeTab(tab);
+
+ info(
+ "Test finished: stop-reload shows stop when navigating to non-local URI during tab opening"
+ );
+});
+
+add_task(async function checkAnimateStopOnTabAfterTabFinishesOpening() {
+ let stopReloadContainer = document.getElementById("stop-reload-button");
+
+ await waitForNoAnimation(stopReloadContainer);
+ let tab = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ waitForStateStop: true,
+ });
+ await TestUtils.waitForCondition(() => {
+ info(
+ "Waiting for tabAnimationsInProgress to equal 0, currently " +
+ gBrowser.tabAnimationsInProgress
+ );
+ return !gBrowser.tabAnimationsInProgress;
+ });
+ let animatePromise = getAnimatePromise(stopReloadContainer);
+ BrowserTestUtils.loadURIString(tab.linkedBrowser, "https://example.com");
+ await animatePromise;
+ BrowserTestUtils.removeTab(tab);
+
+ info(
+ "Test finished: stop-reload animates when navigating to non-local URI on new tab after tab has opened"
+ );
+});
+
+add_task(async function checkDoShowStopFromLocalURI() {
+ let stopReloadContainer = document.getElementById("stop-reload-button");
+
+ await waitForNoAnimation(stopReloadContainer);
+ let tab = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ opening: "about:robots",
+ waitForStateStop: true,
+ });
+ await TestUtils.waitForCondition(() => {
+ info(
+ "Waiting for tabAnimationsInProgress to equal 0, currently " +
+ gBrowser.tabAnimationsInProgress
+ );
+ return !gBrowser.tabAnimationsInProgress;
+ });
+ let animatePromise = getAnimatePromise(stopReloadContainer);
+ BrowserTestUtils.loadURIString(tab.linkedBrowser, "https://example.com");
+ await animatePromise;
+ await waitForNoAnimation(stopReloadContainer);
+ BrowserTestUtils.removeTab(tab);
+
+ info(
+ "Test finished: stop-reload animates when navigating to non-local URI from local URI"
+ );
+});
diff --git a/browser/base/content/test/about/browser_aboutSupport.js b/browser/base/content/test/about/browser_aboutSupport.js
new file mode 100644
index 0000000000..e846a2b493
--- /dev/null
+++ b/browser/base/content/test/about/browser_aboutSupport.js
@@ -0,0 +1,146 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { ExperimentAPI } = ChromeUtils.importESModule(
+ "resource://nimbus/ExperimentAPI.sys.mjs"
+);
+const { ExperimentFakes } = ChromeUtils.importESModule(
+ "resource://testing-common/NimbusTestUtils.sys.mjs"
+);
+
+add_task(async function () {
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: "about:support" },
+ async function (browser) {
+ let keyLocationServiceGoogleStatus = await SpecialPowers.spawn(
+ browser,
+ [],
+ async function () {
+ let textBox = content.document.getElementById(
+ "key-location-service-google-box"
+ );
+ await ContentTaskUtils.waitForCondition(
+ () => content.document.l10n.getAttributes(textBox).id,
+ "Google location service API key status loaded"
+ );
+ return content.document.l10n.getAttributes(textBox).id;
+ }
+ );
+ ok(
+ keyLocationServiceGoogleStatus,
+ "Google location service API key status shown"
+ );
+
+ let keySafebrowsingGoogleStatus = await SpecialPowers.spawn(
+ browser,
+ [],
+ async function () {
+ let textBox = content.document.getElementById(
+ "key-safebrowsing-google-box"
+ );
+ await ContentTaskUtils.waitForCondition(
+ () => content.document.l10n.getAttributes(textBox).id,
+ "Google Safebrowsing API key status loaded"
+ );
+ return content.document.l10n.getAttributes(textBox).id;
+ }
+ );
+ ok(
+ keySafebrowsingGoogleStatus,
+ "Google Safebrowsing API key status shown"
+ );
+
+ let keyMozillaStatus = await SpecialPowers.spawn(
+ browser,
+ [],
+ async function () {
+ let textBox = content.document.getElementById("key-mozilla-box");
+ await ContentTaskUtils.waitForCondition(
+ () => content.document.l10n.getAttributes(textBox).id,
+ "Mozilla API key status loaded"
+ );
+ return content.document.l10n.getAttributes(textBox).id;
+ }
+ );
+ ok(keyMozillaStatus, "Mozilla API key status shown");
+ }
+ );
+});
+
+add_task(async function test_nimbus_experiments() {
+ await ExperimentAPI.ready();
+ let doExperimentCleanup = await ExperimentFakes.enrollWithFeatureConfig({
+ featureId: "aboutwelcome",
+ value: { enabled: true },
+ });
+
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: "about:support" },
+ async function (browser) {
+ let experimentName = await SpecialPowers.spawn(
+ browser,
+ [],
+ async function () {
+ await ContentTaskUtils.waitForCondition(
+ () =>
+ content.document.querySelector(
+ "#remote-experiments-tbody tr:first-child td"
+ )?.innerText
+ );
+ return content.document.querySelector(
+ "#remote-experiments-tbody tr:first-child td"
+ ).innerText;
+ }
+ );
+ ok(
+ experimentName.match("Nimbus"),
+ "Rendered the expected experiment slug"
+ );
+ }
+ );
+
+ await doExperimentCleanup();
+});
+
+add_task(async function test_remote_configuration() {
+ await ExperimentAPI.ready();
+ let doCleanup = await ExperimentFakes.enrollWithRollout({
+ featureId: NimbusFeatures.aboutwelcome.featureId,
+ value: { enabled: true },
+ });
+
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: "about:support" },
+ async function (browser) {
+ let [userFacingName, branch] = await SpecialPowers.spawn(
+ browser,
+ [],
+ async function () {
+ await ContentTaskUtils.waitForCondition(
+ () =>
+ content.document.querySelector(
+ "#remote-features-tbody tr:first-child td"
+ )?.innerText
+ );
+ let rolloutName = content.document.querySelector(
+ "#remote-features-tbody tr:first-child td"
+ ).innerText;
+ let branchName = content.document.querySelector(
+ "#remote-features-tbody tr:first-child td:nth-child(2)"
+ ).innerText;
+
+ return [rolloutName, branchName];
+ }
+ );
+ ok(
+ userFacingName.match("NimbusTestUtils"),
+ "Rendered the expected rollout"
+ );
+ ok(branch.match("aboutwelcome"), "Rendered the expected rollout branch");
+ }
+ );
+
+ await doCleanup();
+});
diff --git a/browser/base/content/test/about/browser_aboutSupport_newtab_security_state.js b/browser/base/content/test/about/browser_aboutSupport_newtab_security_state.js
new file mode 100644
index 0000000000..caa45a1af5
--- /dev/null
+++ b/browser/base/content/test/about/browser_aboutSupport_newtab_security_state.js
@@ -0,0 +1,19 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+add_task(async function checkIdentityOfAboutSupport() {
+ let tab = gBrowser.addTab("about:support", {
+ referrerURI: null,
+ inBackground: false,
+ allowThirdPartyFixup: false,
+ relatedToCurrent: false,
+ skipAnimation: true,
+ allowMixedContent: false,
+ triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
+ });
+
+ await promiseTabLoaded(tab);
+ let identityBox = document.getElementById("identity-box");
+ is(identityBox.className, "chromeUI", "Should know that we're chrome.");
+ gBrowser.removeTab(tab);
+});
diff --git a/browser/base/content/test/about/browser_aboutSupport_places.js b/browser/base/content/test/about/browser_aboutSupport_places.js
new file mode 100644
index 0000000000..e971de7f0e
--- /dev/null
+++ b/browser/base/content/test/about/browser_aboutSupport_places.js
@@ -0,0 +1,45 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_places_db_stats_table() {
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: "about:support" },
+ async function (browser) {
+ const [initialToggleText, toggleTextAfterShow, toggleTextAfterHide] =
+ await SpecialPowers.spawn(browser, [], async function () {
+ const toggleButton = content.document.getElementById(
+ "place-database-stats-toggle"
+ );
+ const getToggleText = () =>
+ content.document.l10n.getAttributes(toggleButton).id;
+ const toggleTexts = [];
+ const table = content.document.getElementById(
+ "place-database-stats-tbody"
+ );
+ await ContentTaskUtils.waitForCondition(
+ () => table.style.display === "none",
+ "Stats table is hidden initially"
+ );
+ toggleTexts.push(getToggleText());
+ toggleButton.click();
+ await ContentTaskUtils.waitForCondition(
+ () => table.style.display === "",
+ "Stats table is shown after first toggle"
+ );
+ toggleTexts.push(getToggleText());
+ toggleButton.click();
+ await ContentTaskUtils.waitForCondition(
+ () => table.style.display === "none",
+ "Stats table is hidden after second toggle"
+ );
+ toggleTexts.push(getToggleText());
+ return toggleTexts;
+ });
+ Assert.equal(initialToggleText, "place-database-stats-show");
+ Assert.equal(toggleTextAfterShow, "place-database-stats-hide");
+ Assert.equal(toggleTextAfterHide, "place-database-stats-show");
+ }
+ );
+});
diff --git a/browser/base/content/test/about/browser_bug435325.js b/browser/base/content/test/about/browser_bug435325.js
new file mode 100644
index 0000000000..70a3b272a9
--- /dev/null
+++ b/browser/base/content/test/about/browser_bug435325.js
@@ -0,0 +1,58 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/* Ensure that clicking the button in the Offline mode neterror page makes the browser go online. See bug 435325. */
+
+add_task(async function checkSwitchPageToOnlineMode() {
+ // Go offline and disable the proxy and cache, then try to load the test URL.
+ Services.io.offline = true;
+
+ // Tests always connect to localhost, and per bug 87717, localhost is now
+ // reachable in offline mode. To avoid this, disable any proxy.
+ let proxyPrefValue = SpecialPowers.getIntPref("network.proxy.type");
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["network.proxy.type", 0],
+ ["browser.cache.disk.enable", false],
+ ["browser.cache.memory.enable", false],
+ ],
+ });
+
+ await BrowserTestUtils.withNewTab("about:blank", async function (browser) {
+ let netErrorLoaded = BrowserTestUtils.waitForErrorPage(browser);
+
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ BrowserTestUtils.loadURIString(browser, "http://example.com/");
+ await netErrorLoaded;
+
+ // Re-enable the proxy so example.com is resolved to localhost, rather than
+ // the actual example.com.
+ await SpecialPowers.pushPrefEnv({
+ set: [["network.proxy.type", proxyPrefValue]],
+ });
+ let changeObserved = TestUtils.topicObserved(
+ "network:offline-status-changed"
+ );
+
+ // Click on the 'Try again' button.
+ await SpecialPowers.spawn(browser, [], async function () {
+ ok(
+ content.document.documentURI.startsWith("about:neterror?e=netOffline"),
+ "Should be showing error page"
+ );
+ content.document
+ .querySelector("#netErrorButtonContainer > .try-again")
+ .click();
+ });
+
+ await changeObserved;
+ ok(
+ !Services.io.offline,
+ "After clicking the 'Try Again' button, we're back online."
+ );
+ });
+});
+
+registerCleanupFunction(function () {
+ Services.io.offline = false;
+});
diff --git a/browser/base/content/test/about/browser_bug633691.js b/browser/base/content/test/about/browser_bug633691.js
new file mode 100644
index 0000000000..33d58475f6
--- /dev/null
+++ b/browser/base/content/test/about/browser_bug633691.js
@@ -0,0 +1,32 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+add_task(async function test() {
+ const URL = "data:text/html,<iframe width='700' height='700'></iframe>";
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: URL },
+ async function (browser) {
+ let context = await SpecialPowers.spawn(browser, [], function () {
+ let iframe = content.document.querySelector("iframe");
+ iframe.src = "https://expired.example.com/";
+ return BrowsingContext.getFromWindow(iframe.contentWindow);
+ });
+ await TestUtils.waitForCondition(() => {
+ let frame = context.currentWindowGlobal;
+ return frame && frame.documentURI.spec.startsWith("about:certerror");
+ });
+ await SpecialPowers.spawn(context, [], async function () {
+ await ContentTaskUtils.waitForCondition(
+ () => content.document.readyState == "interactive"
+ );
+ let aP = content.document.getElementById("badCertAdvancedPanel");
+ Assert.ok(aP, "Advanced content should exist");
+ Assert.ok(
+ ContentTaskUtils.is_hidden(aP),
+ "Advanced content should not be visible by default"
+ );
+ });
+ }
+ );
+});
diff --git a/browser/base/content/test/about/csp_iframe.sjs b/browser/base/content/test/about/csp_iframe.sjs
new file mode 100644
index 0000000000..f53ed8498f
--- /dev/null
+++ b/browser/base/content/test/about/csp_iframe.sjs
@@ -0,0 +1,33 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+function handleRequest(request, response) {
+ // let's enjoy the amazing CSP setting
+ response.setHeader(
+ "Content-Security-Policy",
+ "frame-ancestors 'self'",
+ false
+ );
+
+ // let's avoid caching issues
+ response.setHeader("Pragma", "no-cache");
+ response.setHeader("Cache-Control", "no-cache", false);
+
+ // everything is fine - no needs to worry :)
+ response.setStatusLine(request.httpVersion, 200);
+ response.setHeader("Content-Type", "text/html", false);
+ let txt = "<html><body><h1>CSP Page opened in new window!</h1></body></html>";
+ response.write(txt);
+
+ let cookie = request.hasHeader("Cookie")
+ ? request.getHeader("Cookie")
+ : "<html><body>" +
+ "<h2 id='strictCookie'>No same site strict cookie header</h2>" +
+ "</body></html>";
+ response.write(cookie);
+
+ if (!request.hasHeader("Cookie")) {
+ let strictCookie = `matchaCookie=green; Domain=.example.org; SameSite=Strict`;
+ response.setHeader("Set-Cookie", strictCookie);
+ }
+}
diff --git a/browser/base/content/test/about/dummy_page.html b/browser/base/content/test/about/dummy_page.html
new file mode 100644
index 0000000000..1a87e28408
--- /dev/null
+++ b/browser/base/content/test/about/dummy_page.html
@@ -0,0 +1,9 @@
+<html>
+<head>
+<title>Dummy test page</title>
+<meta http-equiv="Content-Type" content="text/html;charset=utf-8"></meta>
+</head>
+<body>
+<p>Dummy test page</p>
+</body>
+</html>
diff --git a/browser/base/content/test/about/head.js b/browser/base/content/test/about/head.js
new file mode 100644
index 0000000000..c723fbee33
--- /dev/null
+++ b/browser/base/content/test/about/head.js
@@ -0,0 +1,220 @@
+ChromeUtils.defineESModuleGetters(this, {
+ FormHistory: "resource://gre/modules/FormHistory.sys.mjs",
+ SearchTestUtils: "resource://testing-common/SearchTestUtils.sys.mjs",
+});
+
+SearchTestUtils.init(this);
+
+function getCertChainAsString(certBase64Array) {
+ let certChain = "";
+ for (let cert of certBase64Array) {
+ certChain += getPEMString(cert);
+ }
+ return certChain;
+}
+
+function getPEMString(derb64) {
+ // Wrap the Base64 string into lines of 64 characters,
+ // with CRLF line breaks (as specified in RFC 1421).
+ var wrapped = derb64.replace(/(\S{64}(?!$))/g, "$1\r\n");
+ return (
+ "-----BEGIN CERTIFICATE-----\r\n" +
+ wrapped +
+ "\r\n-----END CERTIFICATE-----\r\n"
+ );
+}
+
+async function injectErrorPageFrame(tab, src, sandboxed) {
+ let loadedPromise = BrowserTestUtils.browserLoaded(
+ tab.linkedBrowser,
+ true,
+ null,
+ true
+ );
+
+ await SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [src, sandboxed],
+ async function (frameSrc, frameSandboxed) {
+ let iframe = content.document.createElement("iframe");
+ iframe.src = frameSrc;
+ if (frameSandboxed) {
+ iframe.setAttribute("sandbox", "allow-scripts");
+ }
+ content.document.body.appendChild(iframe);
+ }
+ );
+
+ await loadedPromise;
+}
+
+async function openErrorPage(src, useFrame, sandboxed) {
+ let dummyPage =
+ getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+ ) + "dummy_page.html";
+
+ let tab;
+ if (useFrame) {
+ info("Loading cert error page in an iframe");
+ tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, dummyPage);
+ await injectErrorPageFrame(tab, src, sandboxed);
+ } else {
+ let certErrorLoaded;
+ tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ () => {
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, src);
+ let browser = gBrowser.selectedBrowser;
+ certErrorLoaded = BrowserTestUtils.waitForErrorPage(browser);
+ },
+ false
+ );
+ info("Loading and waiting for the cert error");
+ await certErrorLoaded;
+ }
+
+ return tab;
+}
+
+function waitForCondition(condition, nextTest, errorMsg, retryTimes) {
+ retryTimes = typeof retryTimes !== "undefined" ? retryTimes : 30;
+ var tries = 0;
+ var interval = setInterval(function () {
+ if (tries >= retryTimes) {
+ ok(false, errorMsg);
+ moveOn();
+ }
+ var conditionPassed;
+ try {
+ conditionPassed = condition();
+ } catch (e) {
+ ok(false, e + "\n" + e.stack);
+ conditionPassed = false;
+ }
+ if (conditionPassed) {
+ moveOn();
+ }
+ tries++;
+ }, 100);
+ var moveOn = function () {
+ clearInterval(interval);
+ nextTest();
+ };
+}
+
+function whenTabLoaded(aTab, aCallback) {
+ promiseTabLoadEvent(aTab).then(aCallback);
+}
+
+function promiseTabLoaded(aTab) {
+ return new Promise(resolve => {
+ whenTabLoaded(aTab, resolve);
+ });
+}
+
+/**
+ * Waits for a load (or custom) event to finish in a given tab. If provided
+ * load an uri into the tab.
+ *
+ * @param tab
+ * The tab to load into.
+ * @param [optional] url
+ * The url to load, or the current url.
+ * @return {Promise} resolved when the event is handled.
+ * @resolves to the received event
+ * @rejects if a valid load event is not received within a meaningful interval
+ */
+function promiseTabLoadEvent(tab, url) {
+ info("Wait tab event: load");
+
+ function handle(loadedUrl) {
+ if (loadedUrl === "about:blank" || (url && loadedUrl !== url)) {
+ info(`Skipping spurious load event for ${loadedUrl}`);
+ return false;
+ }
+
+ info("Tab event received: load");
+ return true;
+ }
+
+ let loaded = BrowserTestUtils.browserLoaded(tab.linkedBrowser, false, handle);
+
+ if (url) {
+ BrowserTestUtils.loadURIString(tab.linkedBrowser, url);
+ }
+
+ return loaded;
+}
+
+/**
+ * Wait for the search engine to change. searchEngineChangeFn is a function
+ * that will be called to change the search engine.
+ */
+async function promiseContentSearchChange(browser, searchEngineChangeFn) {
+ // Add an event listener manually then perform the action, rather than using
+ // BrowserTestUtils.addContentEventListener as that doesn't add the listener
+ // early enough.
+ await SpecialPowers.spawn(browser, [], async () => {
+ // Store the results in a temporary place.
+ content._searchDetails = {
+ defaultEnginesList: [],
+ listener: event => {
+ if (event.detail.type == "CurrentState") {
+ content._searchDetails.defaultEnginesList.push(
+ content.wrappedJSObject.gContentSearchController.defaultEngine.name
+ );
+ }
+ },
+ };
+
+ // Listen using the system group to ensure that it fires after
+ // the default behaviour.
+ content.addEventListener(
+ "ContentSearchService",
+ content._searchDetails.listener,
+ { mozSystemGroup: true }
+ );
+ });
+
+ let expectedEngineName = await searchEngineChangeFn();
+
+ await SpecialPowers.spawn(
+ browser,
+ [expectedEngineName],
+ async expectedEngineNameChild => {
+ await ContentTaskUtils.waitForCondition(
+ () =>
+ content._searchDetails.defaultEnginesList &&
+ content._searchDetails.defaultEnginesList[
+ content._searchDetails.defaultEnginesList.length - 1
+ ] == expectedEngineNameChild
+ );
+ content.removeEventListener(
+ "ContentSearchService",
+ content._searchDetails.listener,
+ { mozSystemGroup: true }
+ );
+ delete content._searchDetails;
+ }
+ );
+}
+
+async function waitForBookmarksToolbarVisibility({
+ win = window,
+ visible,
+ message,
+}) {
+ let result = await TestUtils.waitForCondition(() => {
+ let toolbar = win.document.getElementById("PersonalToolbar");
+ return toolbar && (visible ? !toolbar.collapsed : toolbar.collapsed);
+ }, message || "waiting for toolbar to become " + (visible ? "visible" : "hidden"));
+ ok(result, message);
+ return result;
+}
+
+function isBookmarksToolbarVisible(win = window) {
+ let toolbar = win.document.getElementById("PersonalToolbar");
+ return !toolbar.collapsed;
+}
diff --git a/browser/base/content/test/about/iframe_page_csp.html b/browser/base/content/test/about/iframe_page_csp.html
new file mode 100644
index 0000000000..93a23de15d
--- /dev/null
+++ b/browser/base/content/test/about/iframe_page_csp.html
@@ -0,0 +1,16 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <title>Dummy iFrame page</title>
+</head>
+<body>
+<h1>iFrame CSP test</h1>
+<iframe id="theIframe"
+ sandbox="allow-scripts"
+ width=800
+ height=800
+ src="http://example.org:8000/browser/browser/base/content/test/about/csp_iframe.sjs">
+</iframe>
+</body>
+</html>
diff --git a/browser/base/content/test/about/iframe_page_xfo.html b/browser/base/content/test/about/iframe_page_xfo.html
new file mode 100644
index 0000000000..34e7f5cc52
--- /dev/null
+++ b/browser/base/content/test/about/iframe_page_xfo.html
@@ -0,0 +1,16 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <title>Dummy iFrame page</title>
+</head>
+<body>
+<h1>iFrame XFO test</h1>
+<iframe id="theIframe"
+ sandbox="allow-scripts"
+ width=800
+ height=800
+ src="http://example.org:8000/browser/browser/base/content/test/about/xfo_iframe.sjs">
+</iframe>
+</body>
+</html>
diff --git a/browser/base/content/test/about/print_postdata.sjs b/browser/base/content/test/about/print_postdata.sjs
new file mode 100644
index 0000000000..0e3ef38419
--- /dev/null
+++ b/browser/base/content/test/about/print_postdata.sjs
@@ -0,0 +1,25 @@
+const CC = Components.Constructor;
+const BinaryInputStream = CC(
+ "@mozilla.org/binaryinputstream;1",
+ "nsIBinaryInputStream",
+ "setInputStream"
+);
+
+function handleRequest(request, response) {
+ response.setHeader("Content-Type", "text/plain", false);
+ if (request.method == "GET") {
+ response.write(request.queryString);
+ } else {
+ var body = new BinaryInputStream(request.bodyInputStream);
+
+ var avail;
+ var bytes = [];
+
+ while ((avail = body.available()) > 0) {
+ Array.prototype.push.apply(bytes, body.readByteArray(avail));
+ }
+
+ var data = String.fromCharCode.apply(null, bytes);
+ response.bodyOutputStream.write(data, data.length);
+ }
+}
diff --git a/browser/base/content/test/about/searchSuggestionEngine.sjs b/browser/base/content/test/about/searchSuggestionEngine.sjs
new file mode 100644
index 0000000000..1978b4f665
--- /dev/null
+++ b/browser/base/content/test/about/searchSuggestionEngine.sjs
@@ -0,0 +1,9 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+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/base/content/test/about/searchSuggestionEngine.xml b/browser/base/content/test/about/searchSuggestionEngine.xml
new file mode 100644
index 0000000000..409d0b4084
--- /dev/null
+++ b/browser/base/content/test/about/searchSuggestionEngine.xml
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- Any copyright is dedicated to the Public Domain.
+ - http://creativecommons.org/publicdomain/zero/1.0/ -->
+
+<SearchPlugin xmlns="http://www.mozilla.org/2006/browser/search/">
+<ShortName>browser_searchSuggestionEngine searchSuggestionEngine.xml</ShortName>
+<Url type="application/x-suggestions+json" method="GET" template="http://mochi.test:8888/browser/browser/base/content/test/about/searchSuggestionEngine.sjs?{searchTerms}"/>
+<Url type="text/html" method="GET" template="http://mochi.test:8888/" rel="searchform">
+ <Param name="terms" value="{searchTerms}"/>
+</Url>
+</SearchPlugin>
diff --git a/browser/base/content/test/about/slow_loading_page.sjs b/browser/base/content/test/about/slow_loading_page.sjs
new file mode 100644
index 0000000000..747390cdf7
--- /dev/null
+++ b/browser/base/content/test/about/slow_loading_page.sjs
@@ -0,0 +1,29 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const DELAY_MS = 400;
+
+const HTML = `<!DOCTYPE HTML>
+<html dir="ltr" xml:lang="en-US" lang="en-US">
+ <head>
+ <meta charset="utf8">
+ </head>
+ <body>hi mom!
+ </body>
+</html>`;
+
+function handleRequest(req, resp) {
+ resp.processAsync();
+
+ let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+ timer.init(
+ () => {
+ resp.setHeader("Cache-Control", "no-cache", false);
+ resp.setHeader("Content-Type", "text/html;charset=utf-8", false);
+ resp.write(HTML);
+ resp.finish();
+ },
+ DELAY_MS,
+ Ci.nsITimer.TYPE_ONE_SHOT
+ );
+}
diff --git a/browser/base/content/test/about/xfo_iframe.sjs b/browser/base/content/test/about/xfo_iframe.sjs
new file mode 100644
index 0000000000..e8a6352ce0
--- /dev/null
+++ b/browser/base/content/test/about/xfo_iframe.sjs
@@ -0,0 +1,34 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+function handleRequest(request, response) {
+ // let's enjoy the amazing XFO setting
+ response.setHeader("X-Frame-Options", "SAMEORIGIN");
+
+ // let's avoid caching issues
+ response.setHeader("Pragma", "no-cache");
+ response.setHeader("Cache-Control", "no-cache", false);
+
+ // everything is fine - no needs to worry :)
+ response.setStatusLine(request.httpVersion, 200);
+
+ response.setHeader("Content-Type", "text/html", false);
+ let txt =
+ "<html><head><title>XFO page</title></head>" +
+ "<body><h1>" +
+ "XFO blocked page opened in new window!" +
+ "</h1></body></html>";
+ response.write(txt);
+
+ let cookie = request.hasHeader("Cookie")
+ ? request.getHeader("Cookie")
+ : "<html><body>" +
+ "<h2 id='strictCookie'>No same site strict cookie header</h2></body>" +
+ "</html>";
+ response.write(cookie);
+
+ if (!request.hasHeader("Cookie")) {
+ let strictCookie = `matchaCookie=creamy; Domain=.example.org; SameSite=Strict`;
+ response.setHeader("Set-Cookie", strictCookie);
+ }
+}
diff --git a/browser/base/content/test/alerts/browser.ini b/browser/base/content/test/alerts/browser.ini
new file mode 100644
index 0000000000..c06f0d03d9
--- /dev/null
+++ b/browser/base/content/test/alerts/browser.ini
@@ -0,0 +1,22 @@
+[DEFAULT]
+support-files =
+ head.js
+ file_dom_notifications.html
+
+[browser_notification_close.js]
+https_first_disabled = true
+skip-if = os == 'win' # Bug 1227785
+[browser_notification_do_not_disturb.js]
+https_first_disabled = true
+[browser_notification_open_settings.js]
+https_first_disabled = true
+skip-if = os == 'win' # Bug 1411118
+[browser_notification_remove_permission.js]
+https_first_disabled = true
+skip-if = os == 'win' # Bug 1411118
+[browser_notification_replace.js]
+https_first_disabled = true
+skip-if = os == 'win' # Bug 1422928
+[browser_notification_tab_switching.js]
+https_first_disabled = true
+skip-if = os == 'win' # Bug 1243263
diff --git a/browser/base/content/test/alerts/browser_notification_close.js b/browser/base/content/test/alerts/browser_notification_close.js
new file mode 100644
index 0000000000..2d71db31a0
--- /dev/null
+++ b/browser/base/content/test/alerts/browser_notification_close.js
@@ -0,0 +1,107 @@
+"use strict";
+
+const { PlacesTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PlacesTestUtils.sys.mjs"
+);
+
+const { PermissionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PermissionTestUtils.sys.mjs"
+);
+
+let notificationURL =
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.org/browser/browser/base/content/test/alerts/file_dom_notifications.html";
+let oldShowFavicons;
+
+add_task(async function test_notificationClose() {
+ let notificationURI = makeURI(notificationURL);
+ await addNotificationPermission(notificationURL);
+
+ oldShowFavicons = Services.prefs.getBoolPref("alerts.showFavicons");
+ Services.prefs.setBoolPref("alerts.showFavicons", true);
+
+ await PlacesTestUtils.addVisits(notificationURI);
+ let faviconURI = await new Promise(resolve => {
+ let uri = makeURI(
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQI12P4//8/AAX+Av7czFnnAAAAAElFTkSuQmCC"
+ );
+ PlacesUtils.favicons.setAndFetchFaviconForPage(
+ notificationURI,
+ uri,
+ true,
+ PlacesUtils.favicons.FAVICON_LOAD_NON_PRIVATE,
+ uriResult => resolve(uriResult),
+ Services.scriptSecurityManager.getSystemPrincipal()
+ );
+ });
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: notificationURL,
+ },
+ async function dummyTabTask(aBrowser) {
+ await openNotification(aBrowser, "showNotification2");
+
+ info("Notification alert showing");
+
+ let alertWindow = Services.wm.getMostRecentWindow("alert:alert");
+ if (!alertWindow) {
+ ok(true, "Notifications don't use XUL windows on all platforms.");
+ await closeNotification(aBrowser);
+ return;
+ }
+
+ let alertTitleLabel =
+ alertWindow.document.getElementById("alertTitleLabel");
+ is(
+ alertTitleLabel.value,
+ "Test title",
+ "Title text of notification should be present"
+ );
+ let alertTextLabel =
+ alertWindow.document.getElementById("alertTextLabel");
+ is(
+ alertTextLabel.textContent,
+ "Test body 2",
+ "Body text of notification should be present"
+ );
+ let alertIcon = alertWindow.document.getElementById("alertIcon");
+ is(
+ alertIcon.src,
+ faviconURI.spec,
+ "Icon of notification should be present"
+ );
+
+ let alertCloseButton = alertWindow.document.querySelector(".close-icon");
+ is(alertCloseButton.localName, "toolbarbutton", "close button found");
+ let promiseBeforeUnloadEvent = BrowserTestUtils.waitForEvent(
+ alertWindow,
+ "beforeunload"
+ );
+ let closedTime = alertWindow.Date.now();
+ alertCloseButton.click();
+ info("Clicked on close button");
+ await promiseBeforeUnloadEvent;
+
+ ok(true, "Alert should close when the close button is clicked");
+ let currentTime = alertWindow.Date.now();
+ // The notification will self-close at 12 seconds, so this checks
+ // that the notification closed before the timeout.
+ ok(
+ currentTime - closedTime < 5000,
+ "Close requested at " +
+ closedTime +
+ ", actually closed at " +
+ currentTime
+ );
+ }
+ );
+});
+
+add_task(async function cleanup() {
+ PermissionTestUtils.remove(notificationURL, "desktop-notification");
+ if (typeof oldShowFavicons == "boolean") {
+ Services.prefs.setBoolPref("alerts.showFavicons", oldShowFavicons);
+ }
+});
diff --git a/browser/base/content/test/alerts/browser_notification_do_not_disturb.js b/browser/base/content/test/alerts/browser_notification_do_not_disturb.js
new file mode 100644
index 0000000000..8fb5a8a52b
--- /dev/null
+++ b/browser/base/content/test/alerts/browser_notification_do_not_disturb.js
@@ -0,0 +1,160 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that notifications can be silenced using nsIAlertsDoNotDisturb
+ * on systems where that interface and its methods are implemented for
+ * the nsIAlertService.
+ */
+
+const ALERT_SERVICE = Cc["@mozilla.org/alerts-service;1"]
+ .getService(Ci.nsIAlertsService)
+ .QueryInterface(Ci.nsIAlertsDoNotDisturb);
+
+const PAGE =
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.org/browser/browser/base/content/test/alerts/file_dom_notifications.html";
+
+// The amount of time in seconds that we will wait for a notification
+// to show up before we decide that it's not coming.
+const NOTIFICATION_TIMEOUT_SECS = 2000;
+
+add_setup(async function () {
+ await addNotificationPermission(PAGE);
+});
+
+/**
+ * Test that the manualDoNotDisturb attribute can prevent
+ * notifications from appearing.
+ */
+add_task(async function test_manualDoNotDisturb() {
+ try {
+ // Only run the test if the do-not-disturb
+ // interface has been implemented.
+ ALERT_SERVICE.manualDoNotDisturb;
+ ok(true, "Alert service implements do-not-disturb interface");
+ } catch (e) {
+ ok(
+ true,
+ "Alert service doesn't implement do-not-disturb interface, exiting test"
+ );
+ return;
+ }
+
+ // In the event that something goes wrong during this test, make sure
+ // we put the attribute back to the default setting when this test file
+ // exits.
+ registerCleanupFunction(() => {
+ ALERT_SERVICE.manualDoNotDisturb = false;
+ });
+
+ // Make sure that do-not-disturb is not enabled before we start.
+ ok(
+ !ALERT_SERVICE.manualDoNotDisturb,
+ "Alert service should not be disabled when test starts"
+ );
+
+ await BrowserTestUtils.withNewTab(PAGE, async browser => {
+ await openNotification(browser, "showNotification2");
+
+ info("Notification alert showing");
+
+ let alertWindow = Services.wm.getMostRecentWindow("alert:alert");
+
+ // For now, only the XUL alert backend implements the manualDoNotDisturb
+ // method for nsIAlertsDoNotDisturb, so we expect there to be a XUL alert
+ // window. If the method gets implemented by native backends in the future,
+ // we'll probably want to branch here and set the manualDoNotDisturb
+ // attribute manually.
+ ok(alertWindow, "Expected a XUL alert window.");
+
+ // We're using the XUL notification backend. This means that there's
+ // a menuitem for enabling manualDoNotDisturb. We exercise that
+ // menuitem here.
+ let doNotDisturbMenuItem = alertWindow.document.getElementById(
+ "doNotDisturbMenuItem"
+ );
+ is(doNotDisturbMenuItem.localName, "menuitem", "menuitem found");
+
+ let unloadPromise = BrowserTestUtils.waitForEvent(
+ alertWindow,
+ "beforeunload"
+ );
+
+ doNotDisturbMenuItem.click();
+ info("Clicked on do-not-disturb menuitem");
+ await unloadPromise;
+
+ // At this point, we should be configured to not display notifications
+ // to the user.
+ ok(
+ ALERT_SERVICE.manualDoNotDisturb,
+ "Alert service should be disabled after clicking menuitem"
+ );
+
+ // The notification should not appear, but there is no way from the
+ // client-side to know that it was blocked, except for waiting some time
+ // and realizing that the "onshow" event never fired.
+ await Assert.rejects(
+ openNotification(browser, "showNotification2", NOTIFICATION_TIMEOUT_SECS),
+ /timed out/,
+ "The notification should never display."
+ );
+
+ ALERT_SERVICE.manualDoNotDisturb = false;
+ });
+});
+
+/**
+ * Test that the suppressForScreenSharing attribute can prevent
+ * notifications from appearing.
+ */
+add_task(async function test_suppressForScreenSharing() {
+ try {
+ // Only run the test if the do-not-disturb
+ // interface has been implemented.
+ ALERT_SERVICE.suppressForScreenSharing;
+ ok(true, "Alert service implements do-not-disturb interface");
+ } catch (e) {
+ ok(
+ true,
+ "Alert service doesn't implement do-not-disturb interface, exiting test"
+ );
+ return;
+ }
+
+ // In the event that something goes wrong during this test, make sure
+ // we put the attribute back to the default setting when this test file
+ // exits.
+ registerCleanupFunction(() => {
+ ALERT_SERVICE.suppressForScreenSharing = false;
+ });
+
+ // Make sure that do-not-disturb is not enabled before we start.
+ ok(
+ !ALERT_SERVICE.suppressForScreenSharing,
+ "Alert service should not be suppressing for screen sharing when test " +
+ "starts"
+ );
+
+ await BrowserTestUtils.withNewTab(PAGE, async browser => {
+ await openNotification(browser, "showNotification2");
+
+ info("Notification alert showing");
+ await closeNotification(browser);
+ ALERT_SERVICE.suppressForScreenSharing = true;
+
+ // The notification should not appear, but there is no way from the
+ // client-side to know that it was blocked, except for waiting some time
+ // and realizing that the "onshow" event never fired.
+ await Assert.rejects(
+ openNotification(browser, "showNotification2", NOTIFICATION_TIMEOUT_SECS),
+ /timed out/,
+ "The notification should never display."
+ );
+ });
+
+ ALERT_SERVICE.suppressForScreenSharing = false;
+});
diff --git a/browser/base/content/test/alerts/browser_notification_open_settings.js b/browser/base/content/test/alerts/browser_notification_open_settings.js
new file mode 100644
index 0000000000..ed51cd782b
--- /dev/null
+++ b/browser/base/content/test/alerts/browser_notification_open_settings.js
@@ -0,0 +1,80 @@
+"use strict";
+
+var notificationURL =
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.org/browser/browser/base/content/test/alerts/file_dom_notifications.html";
+var expectedURL = "about:preferences#privacy";
+
+add_task(async function test_settingsOpen_observer() {
+ info(
+ "Opening a dummy tab so openPreferences=>switchToTabHavingURI doesn't use the blank tab."
+ );
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:robots",
+ },
+ async function dummyTabTask(aBrowser) {
+ // Ensure preferences is loaded before removing the tab.
+ let syncPaneLoadedPromise = TestUtils.topicObserved(
+ "sync-pane-loaded",
+ () => true
+ );
+ let tabPromise = BrowserTestUtils.waitForNewTab(gBrowser, expectedURL);
+ info("simulate a notifications-open-settings notification");
+ let uri = NetUtil.newURI("https://example.com");
+ let principal = Services.scriptSecurityManager.createContentPrincipal(
+ uri,
+ {}
+ );
+ Services.obs.notifyObservers(principal, "notifications-open-settings");
+ let tab = await tabPromise;
+ ok(tab, "The notification settings tab opened");
+ await syncPaneLoadedPromise;
+ BrowserTestUtils.removeTab(tab);
+ }
+ );
+});
+
+add_task(async function test_settingsOpen_button() {
+ info("Adding notification permission");
+ await addNotificationPermission(notificationURL);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: notificationURL,
+ },
+ async function tabTask(aBrowser) {
+ info("Waiting for notification");
+ await openNotification(aBrowser, "showNotification2");
+
+ let alertWindow = Services.wm.getMostRecentWindow("alert:alert");
+ if (!alertWindow) {
+ ok(true, "Notifications don't use XUL windows on all platforms.");
+ await closeNotification(aBrowser);
+ return;
+ }
+
+ // Ensure preferences is loaded before removing the tab.
+ let syncPaneLoadedPromise = TestUtils.topicObserved(
+ "sync-pane-loaded",
+ () => true
+ );
+ let closePromise = promiseWindowClosed(alertWindow);
+ let tabPromise = BrowserTestUtils.waitForNewTab(gBrowser, expectedURL);
+ let openSettingsMenuItem = alertWindow.document.getElementById(
+ "openSettingsMenuItem"
+ );
+ openSettingsMenuItem.click();
+
+ info("Waiting for notification settings tab");
+ let tab = await tabPromise;
+ ok(tab, "The notification settings tab opened");
+
+ await syncPaneLoadedPromise;
+ await closePromise;
+ BrowserTestUtils.removeTab(tab);
+ }
+ );
+});
diff --git a/browser/base/content/test/alerts/browser_notification_remove_permission.js b/browser/base/content/test/alerts/browser_notification_remove_permission.js
new file mode 100644
index 0000000000..ba198870a3
--- /dev/null
+++ b/browser/base/content/test/alerts/browser_notification_remove_permission.js
@@ -0,0 +1,86 @@
+"use strict";
+
+const { PermissionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PermissionTestUtils.sys.mjs"
+);
+
+var tab;
+var notificationURL =
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.org/browser/browser/base/content/test/alerts/file_dom_notifications.html";
+var alertWindowClosed = false;
+var permRemoved = false;
+
+function test() {
+ waitForExplicitFinish();
+
+ registerCleanupFunction(function () {
+ gBrowser.removeTab(tab);
+ window.restore();
+ });
+
+ addNotificationPermission(notificationURL).then(function openTab() {
+ tab = BrowserTestUtils.addTab(gBrowser, notificationURL);
+ gBrowser.selectedTab = tab;
+ BrowserTestUtils.browserLoaded(tab.linkedBrowser).then(() => onLoad());
+ });
+}
+
+function onLoad() {
+ openNotification(tab.linkedBrowser, "showNotification2").then(onAlertShowing);
+}
+
+function onAlertShowing() {
+ info("Notification alert showing");
+
+ let alertWindow = Services.wm.getMostRecentWindow("alert:alert");
+ if (!alertWindow) {
+ ok(true, "Notifications don't use XUL windows on all platforms.");
+ closeNotification(tab.linkedBrowser).then(finish);
+ return;
+ }
+ ok(
+ PermissionTestUtils.testExactPermission(
+ notificationURL,
+ "desktop-notification"
+ ),
+ "Permission should exist prior to removal"
+ );
+ let disableForOriginMenuItem = alertWindow.document.getElementById(
+ "disableForOriginMenuItem"
+ );
+ is(disableForOriginMenuItem.localName, "menuitem", "menuitem found");
+ Services.obs.addObserver(permObserver, "perm-changed");
+ alertWindow.addEventListener("beforeunload", onAlertClosing);
+ disableForOriginMenuItem.click();
+ info("Clicked on disable-for-origin menuitem");
+}
+
+function permObserver(subject, topic, data) {
+ if (topic != "perm-changed") {
+ return;
+ }
+
+ let permission = subject.QueryInterface(Ci.nsIPermission);
+ is(
+ permission.type,
+ "desktop-notification",
+ "desktop-notification permission changed"
+ );
+ is(data, "deleted", "desktop-notification permission deleted");
+
+ Services.obs.removeObserver(permObserver, "perm-changed");
+ permRemoved = true;
+ if (alertWindowClosed) {
+ finish();
+ }
+}
+
+function onAlertClosing(event) {
+ event.target.removeEventListener("beforeunload", onAlertClosing);
+
+ alertWindowClosed = true;
+ if (permRemoved) {
+ finish();
+ }
+}
diff --git a/browser/base/content/test/alerts/browser_notification_replace.js b/browser/base/content/test/alerts/browser_notification_replace.js
new file mode 100644
index 0000000000..9c72e90ab1
--- /dev/null
+++ b/browser/base/content/test/alerts/browser_notification_replace.js
@@ -0,0 +1,66 @@
+"use strict";
+
+let notificationURL =
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.org/browser/browser/base/content/test/alerts/file_dom_notifications.html";
+
+add_task(async function test_notificationReplace() {
+ await addNotificationPermission(notificationURL);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: notificationURL,
+ },
+ async function dummyTabTask(aBrowser) {
+ await SpecialPowers.spawn(aBrowser, [], async function () {
+ let win = content.window.wrappedJSObject;
+ let notification = win.showNotification1();
+ let promiseCloseEvent = ContentTaskUtils.waitForEvent(
+ notification,
+ "close"
+ );
+
+ let showEvent = await ContentTaskUtils.waitForEvent(
+ notification,
+ "show"
+ );
+ Assert.equal(
+ showEvent.target.body,
+ "Test body 1",
+ "Showed tagged notification"
+ );
+
+ let newNotification = win.showNotification2();
+ let newShowEvent = await ContentTaskUtils.waitForEvent(
+ newNotification,
+ "show"
+ );
+ Assert.equal(
+ newShowEvent.target.body,
+ "Test body 2",
+ "Showed new notification with same tag"
+ );
+
+ let closeEvent = await promiseCloseEvent;
+ Assert.equal(
+ closeEvent.target.body,
+ "Test body 1",
+ "Closed previous tagged notification"
+ );
+
+ let promiseNewCloseEvent = ContentTaskUtils.waitForEvent(
+ newNotification,
+ "close"
+ );
+ newNotification.close();
+ let newCloseEvent = await promiseNewCloseEvent;
+ Assert.equal(
+ newCloseEvent.target.body,
+ "Test body 2",
+ "Closed new notification"
+ );
+ });
+ }
+ );
+});
diff --git a/browser/base/content/test/alerts/browser_notification_tab_switching.js b/browser/base/content/test/alerts/browser_notification_tab_switching.js
new file mode 100644
index 0000000000..ee675670cb
--- /dev/null
+++ b/browser/base/content/test/alerts/browser_notification_tab_switching.js
@@ -0,0 +1,117 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+"use strict";
+
+const { PermissionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PermissionTestUtils.sys.mjs"
+);
+
+var tab;
+var notification;
+var notificationURL =
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.org/browser/browser/base/content/test/alerts/file_dom_notifications.html";
+var newWindowOpenedFromTab;
+
+add_task(async function test_notificationPreventDefaultAndSwitchTabs() {
+ await addNotificationPermission(notificationURL);
+
+ let originalTab = gBrowser.selectedTab;
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: notificationURL,
+ },
+ async function dummyTabTask(aBrowser) {
+ // Put new tab in background so it is obvious when it is re-focused.
+ await BrowserTestUtils.switchTab(gBrowser, originalTab);
+ isnot(
+ gBrowser.selectedBrowser,
+ aBrowser,
+ "Notification page loaded as a background tab"
+ );
+
+ // First, show a notification that will be have the tab-switching prevented.
+ function promiseNotificationEvent(evt) {
+ return SpecialPowers.spawn(
+ aBrowser,
+ [evt],
+ async function (contentEvt) {
+ return new Promise(resolve => {
+ let contentNotification = content.wrappedJSObject._notification;
+ contentNotification.addEventListener(
+ contentEvt,
+ function (event) {
+ resolve({ defaultPrevented: event.defaultPrevented });
+ },
+ { once: true }
+ );
+ });
+ }
+ );
+ }
+ await openNotification(aBrowser, "showNotification1");
+ info("Notification alert showing");
+ let alertWindow = Services.wm.getMostRecentWindow("alert:alert");
+ if (!alertWindow) {
+ ok(true, "Notifications don't use XUL windows on all platforms.");
+ await closeNotification(aBrowser);
+ return;
+ }
+ info("Clicking on notification");
+ let promiseClickEvent = promiseNotificationEvent("click");
+
+ // NB: This executeSoon is needed to allow the non-e10s runs of this test
+ // a chance to set the event listener on the page. Otherwise, we
+ // synchronously fire the click event before we listen for the event.
+ executeSoon(() => {
+ EventUtils.synthesizeMouseAtCenter(
+ alertWindow.document.getElementById("alertTitleLabel"),
+ {},
+ alertWindow
+ );
+ });
+ let clickEvent = await promiseClickEvent;
+ ok(
+ clickEvent.defaultPrevented,
+ "The event handler for the first notification cancels the event"
+ );
+ isnot(
+ gBrowser.selectedBrowser,
+ aBrowser,
+ "Notification page still a background tab"
+ );
+ let notificationClosed = promiseNotificationEvent("close");
+ await closeNotification(aBrowser);
+ await notificationClosed;
+
+ // Second, show a notification that will cause the tab to get switched.
+ await openNotification(aBrowser, "showNotification2");
+ alertWindow = Services.wm.getMostRecentWindow("alert:alert");
+ let promiseTabSelect = BrowserTestUtils.waitForEvent(
+ gBrowser.tabContainer,
+ "TabSelect"
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ alertWindow.document.getElementById("alertTitleLabel"),
+ {},
+ alertWindow
+ );
+ await promiseTabSelect;
+ is(
+ gBrowser.selectedBrowser.currentURI.spec,
+ notificationURL,
+ "Clicking on the second notification should select its originating tab"
+ );
+ notificationClosed = promiseNotificationEvent("close");
+ await closeNotification(aBrowser);
+ await notificationClosed;
+ }
+ );
+});
+
+add_task(async function cleanup() {
+ PermissionTestUtils.remove(notificationURL, "desktop-notification");
+});
diff --git a/browser/base/content/test/alerts/file_dom_notifications.html b/browser/base/content/test/alerts/file_dom_notifications.html
new file mode 100644
index 0000000000..6deede8fcf
--- /dev/null
+++ b/browser/base/content/test/alerts/file_dom_notifications.html
@@ -0,0 +1,39 @@
+<html>
+<head>
+<meta charset="utf-8">
+<script>
+"use strict";
+
+function showNotification1() {
+ var options = {
+ dir: undefined,
+ lang: undefined,
+ body: "Test body 1",
+ tag: "Test tag",
+ icon: undefined,
+ };
+ var n = new Notification("Test title", options);
+ n.addEventListener("click", function(event) {
+ event.preventDefault();
+ });
+ return n;
+}
+
+function showNotification2() {
+ var options = {
+ dir: undefined,
+ lang: undefined,
+ body: "Test body 2",
+ tag: "Test tag",
+ icon: undefined,
+ };
+ return new Notification("Test title", options);
+}
+</script>
+</head>
+<body>
+<form id="notificationForm" onsubmit="showNotification();">
+ <input type="submit" value="Show notification" id="submit"/>
+</form>
+</body>
+</html>
diff --git a/browser/base/content/test/alerts/head.js b/browser/base/content/test/alerts/head.js
new file mode 100644
index 0000000000..4be18f6c41
--- /dev/null
+++ b/browser/base/content/test/alerts/head.js
@@ -0,0 +1,73 @@
+// Platforms may default to reducing motion. We override this to ensure the
+// alert slide animation is enabled in tests.
+SpecialPowers.pushPrefEnv({
+ set: [["ui.prefersReducedMotion", 0]],
+});
+
+async function addNotificationPermission(originString) {
+ return SpecialPowers.pushPermissions([
+ {
+ type: "desktop-notification",
+ allow: true,
+ context: originString,
+ },
+ ]);
+}
+
+/**
+ * Similar to `BrowserTestUtils.closeWindow`, but
+ * doesn't call `window.close()`.
+ */
+function promiseWindowClosed(window) {
+ return new Promise(function (resolve) {
+ Services.ww.registerNotification(function observer(subject, topic, data) {
+ if (topic == "domwindowclosed" && subject == window) {
+ Services.ww.unregisterNotification(observer);
+ resolve();
+ }
+ });
+ });
+}
+
+/**
+ * These two functions work with file_dom_notifications.html to open the
+ * notification and close it.
+ *
+ * |fn| can be showNotification1 or showNotification2.
+ * if |timeout| is passed, then the promise returned from this function is
+ * rejected after the requested number of miliseconds.
+ */
+function openNotification(aBrowser, fn, timeout) {
+ info(`openNotification: ${fn}`);
+ return SpecialPowers.spawn(
+ aBrowser,
+ [[fn, timeout]],
+ async function ([contentFn, contentTimeout]) {
+ await new Promise((resolve, reject) => {
+ let win = content.wrappedJSObject;
+ let notification = win[contentFn]();
+ win._notification = notification;
+
+ function listener() {
+ notification.removeEventListener("show", listener);
+ resolve();
+ }
+
+ notification.addEventListener("show", listener);
+
+ if (contentTimeout) {
+ content.setTimeout(() => {
+ notification.removeEventListener("show", listener);
+ reject("timed out");
+ }, contentTimeout);
+ }
+ });
+ }
+ );
+}
+
+function closeNotification(aBrowser) {
+ return SpecialPowers.spawn(aBrowser, [], function () {
+ content.wrappedJSObject._notification.close();
+ });
+}
diff --git a/browser/base/content/test/backforward/browser.ini b/browser/base/content/test/backforward/browser.ini
new file mode 100644
index 0000000000..2bfb071ee4
--- /dev/null
+++ b/browser/base/content/test/backforward/browser.ini
@@ -0,0 +1,2 @@
+[browser_history_menu.js]
+https_first_disabled = true
diff --git a/browser/base/content/test/backforward/browser_history_menu.js b/browser/base/content/test/backforward/browser_history_menu.js
new file mode 100644
index 0000000000..b812a6ae24
--- /dev/null
+++ b/browser/base/content/test/backforward/browser_history_menu.js
@@ -0,0 +1,175 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// This test verifies that the back forward button long-press menu and context menu
+// shows the correct history items.
+
+add_task(async function mousedown_back() {
+ await testBackForwardMenu(false);
+});
+
+add_task(async function contextmenu_back() {
+ await testBackForwardMenu(true);
+});
+
+async function openHistoryMenu(useContextMenu) {
+ let backButton = document.getElementById("back-button");
+ let rect = backButton.getBoundingClientRect();
+
+ info("waiting for the history menu to open");
+
+ let popupShownPromise = BrowserTestUtils.waitForEvent(
+ useContextMenu ? document.getElementById("backForwardMenu") : backButton,
+ "popupshown"
+ );
+ if (useContextMenu) {
+ EventUtils.synthesizeMouseAtCenter(backButton, {
+ type: "contextmenu",
+ button: 2,
+ });
+ } else {
+ EventUtils.synthesizeMouseAtCenter(backButton, { type: "mousedown" });
+ }
+
+ EventUtils.synthesizeMouse(backButton, rect.width / 2, rect.height, {
+ type: "mouseup",
+ });
+ let popupEvent = await popupShownPromise;
+
+ ok(true, "history menu opened");
+
+ return popupEvent;
+}
+
+async function testBackForwardMenu(useContextMenu) {
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com"
+ );
+
+ for (let iter = 2; iter <= 4; iter++) {
+ // Iterate three times. For the first two times through the loop, add a new history item.
+ // But for the last iteration, go back in the history instead.
+ await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [iter],
+ async function (iterChild) {
+ if (iterChild == 4) {
+ let popStatePromise = new Promise(function (resolve) {
+ content.onpopstate = resolve;
+ });
+ content.history.back();
+ await popStatePromise;
+ } else {
+ content.history.pushState({}, "" + iterChild, iterChild + ".html");
+ }
+ }
+ );
+
+ // Wait for the session data to be flushed before continuing the test
+ await new Promise(resolve =>
+ SessionStore.getSessionHistory(gBrowser.selectedTab, resolve)
+ );
+
+ let popupEvent = await openHistoryMenu(useContextMenu);
+
+ // Wait for the session data to be flushed before continuing the test
+ await new Promise(resolve =>
+ SessionStore.getSessionHistory(gBrowser.selectedTab, resolve)
+ );
+
+ is(
+ popupEvent.target.children.length,
+ iter > 3 ? 3 : iter,
+ "Correct number of history items"
+ );
+
+ let node = popupEvent.target.lastElementChild;
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ is(node.getAttribute("uri"), "http://example.com/", "'1' item uri");
+ is(node.getAttribute("index"), "0", "'1' item index");
+ is(
+ node.getAttribute("historyindex"),
+ iter == 3 ? "-2" : "-1",
+ "'1' item historyindex"
+ );
+
+ node = node.previousElementSibling;
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ is(node.getAttribute("uri"), "http://example.com/2.html", "'2' item uri");
+ is(node.getAttribute("index"), "1", "'2' item index");
+ is(
+ node.getAttribute("historyindex"),
+ iter == 3 ? "-1" : "0",
+ "'2' item historyindex"
+ );
+
+ if (iter >= 3) {
+ node = node.previousElementSibling;
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ is(node.getAttribute("uri"), "http://example.com/3.html", "'3' item uri");
+ is(node.getAttribute("index"), "2", "'3' item index");
+ is(
+ node.getAttribute("historyindex"),
+ iter == 4 ? "1" : "0",
+ "'3' item historyindex"
+ );
+ }
+
+ // Close the popup, but on the last iteration, click on one of the history items
+ // to ensure it opens in a new tab.
+ let popupHiddenPromise = BrowserTestUtils.waitForEvent(
+ popupEvent.target,
+ "popuphidden"
+ );
+
+ if (iter < 4) {
+ popupEvent.target.hidePopup();
+ } else {
+ let newTabPromise = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ url => url == "http://example.com/"
+ );
+
+ popupEvent.target.activateItem(popupEvent.target.children[2], {
+ button: 1,
+ });
+
+ let newtab = await newTabPromise;
+ gBrowser.removeTab(newtab);
+ }
+
+ await popupHiddenPromise;
+ }
+
+ gBrowser.removeTab(tab);
+}
+
+// Make sure that the history popup appears after navigating around in a preferences page.
+add_task(async function test_preferences_page() {
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:preferences"
+ );
+
+ openPreferences("search");
+ let popupEvent = await openHistoryMenu(true);
+
+ // Wait for the session data to be flushed before continuing the test
+ await new Promise(resolve =>
+ SessionStore.getSessionHistory(gBrowser.selectedTab, resolve)
+ );
+
+ is(popupEvent.target.children.length, 2, "Correct number of history items");
+
+ let popupHiddenPromise = BrowserTestUtils.waitForEvent(
+ popupEvent.target,
+ "popuphidden"
+ );
+ popupEvent.target.hidePopup();
+ await popupHiddenPromise;
+
+ gBrowser.removeTab(tab);
+});
diff --git a/browser/base/content/test/caps/browser.ini b/browser/base/content/test/caps/browser.ini
new file mode 100644
index 0000000000..18464cdf77
--- /dev/null
+++ b/browser/base/content/test/caps/browser.ini
@@ -0,0 +1,6 @@
+[DEFAULT]
+
+[browser_principalSerialization_csp.js]
+[browser_principalSerialization_json.js]
+skip-if = debug # deliberately bypass assertions when deserializing. Bug 965637 removed the CSP from Principals, but the remaining bits in such Principals should deserialize correctly.
+[browser_principalSerialization_version1.js]
diff --git a/browser/base/content/test/caps/browser_principalSerialization_csp.js b/browser/base/content/test/caps/browser_principalSerialization_csp.js
new file mode 100644
index 0000000000..909e728794
--- /dev/null
+++ b/browser/base/content/test/caps/browser_principalSerialization_csp.js
@@ -0,0 +1,106 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/*
+ * Within Bug 965637 we move the CSP away from the Principal. Serialized Principals however
+ * might still have CSPs serialized within them. This tests ensures that we do not
+ * encounter a memory corruption when deserializing. It's fine that the deserialized
+ * CSP is null, but the Principal itself should deserialize correctly.
+ */
+
+add_task(async function test_deserialize_principal_with_csp() {
+ /*
+ This test should be resilient to changes in principal serialization, if these are failing then it's likely the code will break session storage.
+ To recreate this for another version, copy the function into the browser console, browse some pages and printHistory.
+
+ Generated with:
+ function printHistory() {
+ let tests = [];
+ let entries = SessionStore.getSessionHistory(gBrowser.selectedTab).entries.map((entry) => { return entry.triggeringPrincipal_base64 });
+ entries.push(E10SUtils.serializePrincipal(gBrowser.selectedTab.linkedBrowser._contentPrincipal));
+ for (let entry of entries) {
+ console.log(entry);
+ let testData = {};
+ testData.input = entry;
+ let principal = E10SUtils.deserializePrincipal(testData.input);
+ testData.output = {};
+ if (principal.URI === null) {
+ testData.output.URI = false;
+ } else {
+ testData.output.URISpec = principal.URI.spec;
+ }
+ testData.output.originAttributes = principal.originAttributes;
+ testData.output.cspJSON = principal.cspJSON;
+
+ tests.push(testData);
+ }
+ return tests;
+ }
+ printHistory(); // Copy this into: serializedPrincipalsFromFirefox
+ */
+
+ let serializedPrincipalsFromFirefox = [
+ {
+ input:
+ "ZT4OTT7kRfqycpfCC8AeuAAAAAAAAAAAwAAAAAAAAEYB3pRy0IA0EdOTmQAQS6D9QJIHOlRteE8wkTq4cYEyCMYAAAAC/////wAAAbsBAAAAHmh0dHBzOi8vd3d3Lm1vemlsbGEub3JnL2VuLVVTLwAAAAAAAAAFAAAACAAAAA8AAAAA/////wAAAAD/////AAAACAAAAA8AAAAXAAAABwAAABcAAAAHAAAAFwAAAAcAAAAeAAAAAAAAAAD/////AAAAAP////8AAAAA/////wAAAAD/////AQAAAAAAAAAAAAAAAQnZ7Rrl1EAEv+Anzrkj2ayzxMCuvV5MrYfgjSENuz+fAd6UctCANBHTk5kAEEug/UCSBzpUbXhPMJE6uHGBMgjGAAAAAv////8AAAG7AQAAAB5odHRwczovL3d3dy5tb3ppbGxhLm9yZy9lbi1VUy8AAAAAAAAABQAAAAgAAAAPAAAAAP////8AAAAA/////wAAAAgAAAAPAAAAFwAAAAcAAAAXAAAABwAAABcAAAAHAAAAHgAAAAAAAAAA/////wAAAAD/////AAAAAP////8AAAAA/////wEAAAAAAAAAAAABAAAFtgBzAGMAcgBpAHAAdAAtAHMAcgBjACAAJwBzAGUAbABmACcAIABoAHQAdABwAHMAOgAvAC8AKgAuAG0AbwB6AGkAbABsAGEALgBuAGUAdAAgAGgAdAB0AHAAcwA6AC8ALwAqAC4AbQBvAHoAaQBsAGwAYQAuAG8AcgBnACAAaAB0AHQAcABzADoALwAvACoALgBtAG8AegBpAGwAbABhAC4AYwBvAG0AIAAnAHUAbgBzAGEAZgBlAC0AaQBuAGwAaQBuAGUAJwAgACcAdQBuAHMAYQBmAGUALQBlAHYAYQBsACcAIABoAHQAdABwAHMAOgAvAC8AdwB3AHcALgBnAG8AbwBnAGwAZQB0AGEAZwBtAGEAbgBhAGcAZQByAC4AYwBvAG0AIABoAHQAdABwAHMAOgAvAC8AdwB3AHcALgBnAG8AbwBnAGwAZQAtAGEAbgBhAGwAeQB0AGkAYwBzAC4AYwBvAG0AIABoAHQAdABwAHMAOgAvAC8AdABhAGcAbQBhAG4AYQBnAGUAcgAuAGcAbwBvAGcAbABlAC4AYwBvAG0AIABoAHQAdABwAHMAOgAvAC8AdwB3AHcALgB5AG8AdQB0AHUAYgBlAC4AYwBvAG0AIABoAHQAdABwAHMAOgAvAC8AcwAuAHkAdABpAG0AZwAuAGMAbwBtADsAIABpAG0AZwAtAHMAcgBjACAAJwBzAGUAbABmACcAIABoAHQAdABwAHMAOgAvAC8AKgAuAG0AbwB6AGkAbABsAGEALgBuAGUAdAAgAGgAdAB0AHAAcwA6AC8ALwAqAC4AbQBvAHoAaQBsAGwAYQAuAG8AcgBnACAAaAB0AHQAcABzADoALwAvACoALgBtAG8AegBpAGwAbABhAC4AYwBvAG0AIABkAGEAdABhADoAIABoAHQAdABwAHMAOgAvAC8AbQBvAHoAaQBsAGwAYQAuAG8AcgBnACAAaAB0AHQAcABzADoALwAvAHcAdwB3AC4AZwBvAG8AZwBsAGUAdABhAGcAbQBhAG4AYQBnAGUAcgAuAGMAbwBtACAAaAB0AHQAcABzADoALwAvAHcAdwB3AC4AZwBvAG8AZwBsAGUALQBhAG4AYQBsAHkAdABpAGMAcwAuAGMAbwBtACAAaAB0AHQAcABzADoALwAvAGEAZABzAGUAcgB2AGkAYwBlAC4AZwBvAG8AZwBsAGUALgBjAG8AbQAgAGgAdAB0AHAAcwA6AC8ALwBhAGQAcwBlAHIAdgBpAGMAZQAuAGcAbwBvAGcAbABlAC4AZABlACAAaAB0AHQAcABzADoALwAvAGEAZABzAGUAcgB2AGkAYwBlAC4AZwBvAG8AZwBsAGUALgBkAGsAIABoAHQAdABwAHMAOgAvAC8AYwByAGUAYQB0AGkAdgBlAGMAbwBtAG0AbwBuAHMALgBvAHIAZwAgAGgAdAB0AHAAcwA6AC8ALwBhAGQALgBkAG8AdQBiAGwAZQBjAGwAaQBjAGsALgBuAGUAdAA7ACAAZABlAGYAYQB1AGwAdAAtAHMAcgBjACAAJwBzAGUAbABmACcAIABoAHQAdABwAHMAOgAvAC8AKgAuAG0AbwB6AGkAbABsAGEALgBuAGUAdAAgAGgAdAB0AHAAcwA6AC8ALwAqAC4AbQBvAHoAaQBsAGwAYQAuAG8AcgBnACAAaAB0AHQAcABzADoALwAvACoALgBtAG8AegBpAGwAbABhAC4AYwBvAG0AOwAgAGYAcgBhAG0AZQAtAHMAcgBjACAAaAB0AHQAcABzADoALwAvAHcAdwB3AC4AZwBvAG8AZwBsAGUAdABhAGcAbQBhAG4AYQBnAGUAcgAuAGMAbwBtACAAaAB0AHQAcABzADoALwAvAHcAdwB3AC4AZwBvAG8AZwBsAGUALQBhAG4AYQBsAHkAdABpAGMAcwAuAGMAbwBtACAAaAB0AHQAcABzADoALwAvAHcAdwB3AC4AeQBvAHUAdAB1AGIAZQAtAG4AbwBjAG8AbwBrAGkAZQAuAGMAbwBtACAAaAB0AHQAcABzADoALwAvAHQAcgBhAGMAawBlAHIAdABlAHMAdAAuAG8AcgBnACAAaAB0AHQAcABzADoALwAvAHcAdwB3AC4AcwB1AHIAdgBlAHkAZwBpAHoAbQBvAC4AYwBvAG0AIABoAHQAdABwAHMAOgAvAC8AYQBjAGMAbwB1AG4AdABzAC4AZgBpAHIAZQBmAG8AeAAuAGMAbwBtACAAaAB0AHQAcABzADoALwAvAGEAYwBjAG8AdQBuAHQAcwAuAGYAaQByAGUAZgBvAHgALgBjAG8AbQAuAGMAbgAgAGgAdAB0AHAAcwA6AC8ALwB3AHcAdwAuAHkAbwB1AHQAdQBiAGUALgBjAG8AbQA7ACAAcwB0AHkAbABlAC0AcwByAGMAIAAnAHMAZQBsAGYAJwAgAGgAdAB0AHAAcwA6AC8ALwAqAC4AbQBvAHoAaQBsAGwAYQAuAG4AZQB0ACAAaAB0AHQAcABzADoALwAvACoALgBtAG8AegBpAGwAbABhAC4AbwByAGcAIABoAHQAdABwAHMAOgAvAC8AKgAuAG0AbwB6AGkAbABsAGEALgBjAG8AbQAgACcAdQBuAHMAYQBmAGUALQBpAG4AbABpAG4AZQAnADsAIABjAG8AbgBuAGUAYwB0AC0AcwByAGMAIAAnAHMAZQBsAGYAJwAgAGgAdAB0AHAAcwA6AC8ALwAqAC4AbQBvAHoAaQBsAGwAYQAuAG4AZQB0ACAAaAB0AHQAcABzADoALwAvACoALgBtAG8AegBpAGwAbABhAC4AbwByAGcAIABoAHQAdABwAHMAOgAvAC8AKgAuAG0AbwB6AGkAbABsAGEALgBjAG8AbQAgAGgAdAB0AHAAcwA6AC8ALwB3AHcAdwAuAGcAbwBvAGcAbABlAHQAYQBnAG0AYQBuAGEAZwBlAHIALgBjAG8AbQAgAGgAdAB0AHAAcwA6AC8ALwB3AHcAdwAuAGcAbwBvAGcAbABlAC0AYQBuAGEAbAB5AHQAaQBjAHMALgBjAG8AbQAgAGgAdAB0AHAAcwA6AC8ALwBhAGMAYwBvAHUAbgB0AHMALgBmAGkAcgBlAGYAbwB4AC4AYwBvAG0ALwAgAGgAdAB0AHAAcwA6AC8ALwBhAGMAYwBvAHUAbgB0AHMALgBmAGkAcgBlAGYAbwB4AC4AYwBvAG0ALgBjAG4ALwA7ACAAYwBoAGkAbABkAC0AcwByAGMAIABoAHQAdABwAHMAOgAvAC8AdwB3AHcALgBnAG8AbwBnAGwAZQB0AGEAZwBtAGEAbgBhAGcAZQByAC4AYwBvAG0AIABoAHQAdABwAHMAOgAvAC8AdwB3AHcALgBnAG8AbwBnAGwAZQAtAGEAbgBhAGwAeQB0AGkAYwBzAC4AYwBvAG0AIABoAHQAdABwAHMAOgAvAC8AdwB3AHcALgB5AG8AdQB0AHUAYgBlAC0AbgBvAGMAbwBvAGsAaQBlAC4AYwBvAG0AIABoAHQAdABwAHMAOgAvAC8AdAByAGEAYwBrAGUAcgB0AGUAcwB0AC4AbwByAGcAIABoAHQAdABwAHMAOgAvAC8AdwB3AHcALgBzAHUAcgB2AGUAeQBnAGkAegBtAG8ALgBjAG8AbQAgAGgAdAB0AHAAcwA6AC8ALwBhAGMAYwBvAHUAbgB0AHMALgBmAGkAcgBlAGYAbwB4AC4AYwBvAG0AIABoAHQAdABwAHMAOgAvAC8AYQBjAGMAbwB1AG4AdABzAC4AZgBpAHIAZQBmAG8AeAAuAGMAbwBtAC4AYwBuACAAaAB0AHQAcABzADoALwAvAHcAdwB3AC4AeQBvAHUAdAB1AGIAZQAuAGMAbwBtAAA=",
+ output: {
+ // Within Bug 965637 we removed CSP from Principals. Already serialized Principals however should still deserialize correctly (just without the CSP).
+ // "cspJSON": "{\"csp-policies\":[{\"child-src\":[\"https://www.googletagmanager.com\",\"https://www.google-analytics.com\",\"https://www.youtube-nocookie.com\",\"https://trackertest.org\",\"https://www.surveygizmo.com\",\"https://accounts.firefox.com\",\"https://accounts.firefox.com.cn\",\"https://www.youtube.com\"],\"connect-src\":[\"'self'\",\"https://*.mozilla.net\",\"https://*.mozilla.org\",\"https://*.mozilla.com\",\"https://www.googletagmanager.com\",\"https://www.google-analytics.com\",\"https://accounts.firefox.com/\",\"https://accounts.firefox.com.cn/\"],\"default-src\":[\"'self'\",\"https://*.mozilla.net\",\"https://*.mozilla.org\",\"https://*.mozilla.com\"],\"frame-src\":[\"https://www.googletagmanager.com\",\"https://www.google-analytics.com\",\"https://www.youtube-nocookie.com\",\"https://trackertest.org\",\"https://www.surveygizmo.com\",\"https://accounts.firefox.com\",\"https://accounts.firefox.com.cn\",\"https://www.youtube.com\"],\"img-src\":[\"'self'\",\"https://*.mozilla.net\",\"https://*.mozilla.org\",\"https://*.mozilla.com\",\"data:\",\"https://mozilla.org\",\"https://www.googletagmanager.com\",\"https://www.google-analytics.com\",\"https://adservice.google.com\",\"https://adservice.google.de\",\"https://adservice.google.dk\",\"https://creativecommons.org\",\"https://ad.doubleclick.net\"],\"report-only\":false,\"script-src\":[\"'self'\",\"https://*.mozilla.net\",\"https://*.mozilla.org\",\"https://*.mozilla.com\",\"'unsafe-inline'\",\"'unsafe-eval'\",\"https://www.googletagmanager.com\",\"https://www.google-analytics.com\",\"https://tagmanager.google.com\",\"https://www.youtube.com\",\"https://s.ytimg.com\"],\"style-src\":[\"'self'\",\"https://*.mozilla.net\",\"https://*.mozilla.org\",\"https://*.mozilla.com\",\"'unsafe-inline'\"]}]}",
+ URISpec: "https://www.mozilla.org/en-US/",
+ originAttributes: {
+ firstPartyDomain: "",
+ inIsolatedMozBrowser: false,
+ privateBrowsingId: 0,
+ userContextId: 0,
+ geckoViewSessionContextId: "",
+ partitionKey: "",
+ },
+ },
+ },
+ {
+ input:
+ "ZT4OTT7kRfqycpfCC8AeuAAAAAAAAAAAwAAAAAAAAEYB3pRy0IA0EdOTmQAQS6D9QJIHOlRteE8wkTq4cYEyCMYAAAAC/////wAAAbsBAAAAL2h0dHBzOi8vd3d3Lm1vemlsbGEub3JnL2VuLVVTL2ZpcmVmb3gvYWNjb3VudHMvAAAAAAAAAAUAAAAIAAAADwAAAAj/////AAAACP////8AAAAIAAAADwAAABcAAAAYAAAAFwAAABgAAAAXAAAAGAAAAC8AAAAAAAAAL/////8AAAAA/////wAAABf/////AAAAF/////8BAAAAAAAAAAAAAAABCdntGuXUQAS/4CfOuSPZrLPEwK69Xkyth+CNIQ27P58B3pRy0IA0EdOTmQAQS6D9QJIHOlRteE8wkTq4cYEyCMYAAAAC/////wAAAbsBAAAAL2h0dHBzOi8vd3d3Lm1vemlsbGEub3JnL2VuLVVTL2ZpcmVmb3gvYWNjb3VudHMvAAAAAAAAAAUAAAAIAAAADwAAAAj/////AAAACP////8AAAAIAAAADwAAABcAAAAYAAAAFwAAABgAAAAXAAAAGAAAAC8AAAAAAAAAL/////8AAAAA/////wAAABf/////AAAAF/////8BAAAAAAAAAAAAAQAABbYAcwBjAHIAaQBwAHQALQBzAHIAYwAgACcAcwBlAGwAZgAnACAAaAB0AHQAcABzADoALwAvACoALgBtAG8AegBpAGwAbABhAC4AbgBlAHQAIABoAHQAdABwAHMAOgAvAC8AKgAuAG0AbwB6AGkAbABsAGEALgBvAHIAZwAgAGgAdAB0AHAAcwA6AC8ALwAqAC4AbQBvAHoAaQBsAGwAYQAuAGMAbwBtACAAJwB1AG4AcwBhAGYAZQAtAGkAbgBsAGkAbgBlACcAIAAnAHUAbgBzAGEAZgBlAC0AZQB2AGEAbAAnACAAaAB0AHQAcABzADoALwAvAHcAdwB3AC4AZwBvAG8AZwBsAGUAdABhAGcAbQBhAG4AYQBnAGUAcgAuAGMAbwBtACAAaAB0AHQAcABzADoALwAvAHcAdwB3AC4AZwBvAG8AZwBsAGUALQBhAG4AYQBsAHkAdABpAGMAcwAuAGMAbwBtACAAaAB0AHQAcABzADoALwAvAHQAYQBnAG0AYQBuAGEAZwBlAHIALgBnAG8AbwBnAGwAZQAuAGMAbwBtACAAaAB0AHQAcABzADoALwAvAHcAdwB3AC4AeQBvAHUAdAB1AGIAZQAuAGMAbwBtACAAaAB0AHQAcABzADoALwAvAHMALgB5AHQAaQBtAGcALgBjAG8AbQA7ACAAaQBtAGcALQBzAHIAYwAgACcAcwBlAGwAZgAnACAAaAB0AHQAcABzADoALwAvACoALgBtAG8AegBpAGwAbABhAC4AbgBlAHQAIABoAHQAdABwAHMAOgAvAC8AKgAuAG0AbwB6AGkAbABsAGEALgBvAHIAZwAgAGgAdAB0AHAAcwA6AC8ALwAqAC4AbQBvAHoAaQBsAGwAYQAuAGMAbwBtACAAZABhAHQAYQA6ACAAaAB0AHQAcABzADoALwAvAG0AbwB6AGkAbABsAGEALgBvAHIAZwAgAGgAdAB0AHAAcwA6AC8ALwB3AHcAdwAuAGcAbwBvAGcAbABlAHQAYQBnAG0AYQBuAGEAZwBlAHIALgBjAG8AbQAgAGgAdAB0AHAAcwA6AC8ALwB3AHcAdwAuAGcAbwBvAGcAbABlAC0AYQBuAGEAbAB5AHQAaQBjAHMALgBjAG8AbQAgAGgAdAB0AHAAcwA6AC8ALwBhAGQAcwBlAHIAdgBpAGMAZQAuAGcAbwBvAGcAbABlAC4AYwBvAG0AIABoAHQAdABwAHMAOgAvAC8AYQBkAHMAZQByAHYAaQBjAGUALgBnAG8AbwBnAGwAZQAuAGQAZQAgAGgAdAB0AHAAcwA6AC8ALwBhAGQAcwBlAHIAdgBpAGMAZQAuAGcAbwBvAGcAbABlAC4AZABrACAAaAB0AHQAcABzADoALwAvAGMAcgBlAGEAdABpAHYAZQBjAG8AbQBtAG8AbgBzAC4AbwByAGcAIABoAHQAdABwAHMAOgAvAC8AYQBkAC4AZABvAHUAYgBsAGUAYwBsAGkAYwBrAC4AbgBlAHQAOwAgAGQAZQBmAGEAdQBsAHQALQBzAHIAYwAgACcAcwBlAGwAZgAnACAAaAB0AHQAcABzADoALwAvACoALgBtAG8AegBpAGwAbABhAC4AbgBlAHQAIABoAHQAdABwAHMAOgAvAC8AKgAuAG0AbwB6AGkAbABsAGEALgBvAHIAZwAgAGgAdAB0AHAAcwA6AC8ALwAqAC4AbQBvAHoAaQBsAGwAYQAuAGMAbwBtADsAIABmAHIAYQBtAGUALQBzAHIAYwAgAGgAdAB0AHAAcwA6AC8ALwB3AHcAdwAuAGcAbwBvAGcAbABlAHQAYQBnAG0AYQBuAGEAZwBlAHIALgBjAG8AbQAgAGgAdAB0AHAAcwA6AC8ALwB3AHcAdwAuAGcAbwBvAGcAbABlAC0AYQBuAGEAbAB5AHQAaQBjAHMALgBjAG8AbQAgAGgAdAB0AHAAcwA6AC8ALwB3AHcAdwAuAHkAbwB1AHQAdQBiAGUALQBuAG8AYwBvAG8AawBpAGUALgBjAG8AbQAgAGgAdAB0AHAAcwA6AC8ALwB0AHIAYQBjAGsAZQByAHQAZQBzAHQALgBvAHIAZwAgAGgAdAB0AHAAcwA6AC8ALwB3AHcAdwAuAHMAdQByAHYAZQB5AGcAaQB6AG0AbwAuAGMAbwBtACAAaAB0AHQAcABzADoALwAvAGEAYwBjAG8AdQBuAHQAcwAuAGYAaQByAGUAZgBvAHgALgBjAG8AbQAgAGgAdAB0AHAAcwA6AC8ALwBhAGMAYwBvAHUAbgB0AHMALgBmAGkAcgBlAGYAbwB4AC4AYwBvAG0ALgBjAG4AIABoAHQAdABwAHMAOgAvAC8AdwB3AHcALgB5AG8AdQB0AHUAYgBlAC4AYwBvAG0AOwAgAHMAdAB5AGwAZQAtAHMAcgBjACAAJwBzAGUAbABmACcAIABoAHQAdABwAHMAOgAvAC8AKgAuAG0AbwB6AGkAbABsAGEALgBuAGUAdAAgAGgAdAB0AHAAcwA6AC8ALwAqAC4AbQBvAHoAaQBsAGwAYQAuAG8AcgBnACAAaAB0AHQAcABzADoALwAvACoALgBtAG8AegBpAGwAbABhAC4AYwBvAG0AIAAnAHUAbgBzAGEAZgBlAC0AaQBuAGwAaQBuAGUAJwA7ACAAYwBvAG4AbgBlAGMAdAAtAHMAcgBjACAAJwBzAGUAbABmACcAIABoAHQAdABwAHMAOgAvAC8AKgAuAG0AbwB6AGkAbABsAGEALgBuAGUAdAAgAGgAdAB0AHAAcwA6AC8ALwAqAC4AbQBvAHoAaQBsAGwAYQAuAG8AcgBnACAAaAB0AHQAcABzADoALwAvACoALgBtAG8AegBpAGwAbABhAC4AYwBvAG0AIABoAHQAdABwAHMAOgAvAC8AdwB3AHcALgBnAG8AbwBnAGwAZQB0AGEAZwBtAGEAbgBhAGcAZQByAC4AYwBvAG0AIABoAHQAdABwAHMAOgAvAC8AdwB3AHcALgBnAG8AbwBnAGwAZQAtAGEAbgBhAGwAeQB0AGkAYwBzAC4AYwBvAG0AIABoAHQAdABwAHMAOgAvAC8AYQBjAGMAbwB1AG4AdABzAC4AZgBpAHIAZQBmAG8AeAAuAGMAbwBtAC8AIABoAHQAdABwAHMAOgAvAC8AYQBjAGMAbwB1AG4AdABzAC4AZgBpAHIAZQBmAG8AeAAuAGMAbwBtAC4AYwBuAC8AOwAgAGMAaABpAGwAZAAtAHMAcgBjACAAaAB0AHQAcABzADoALwAvAHcAdwB3AC4AZwBvAG8AZwBsAGUAdABhAGcAbQBhAG4AYQBnAGUAcgAuAGMAbwBtACAAaAB0AHQAcABzADoALwAvAHcAdwB3AC4AZwBvAG8AZwBsAGUALQBhAG4AYQBsAHkAdABpAGMAcwAuAGMAbwBtACAAaAB0AHQAcABzADoALwAvAHcAdwB3AC4AeQBvAHUAdAB1AGIAZQAtAG4AbwBjAG8AbwBrAGkAZQAuAGMAbwBtACAAaAB0AHQAcABzADoALwAvAHQAcgBhAGMAawBlAHIAdABlAHMAdAAuAG8AcgBnACAAaAB0AHQAcABzADoALwAvAHcAdwB3AC4AcwB1AHIAdgBlAHkAZwBpAHoAbQBvAC4AYwBvAG0AIABoAHQAdABwAHMAOgAvAC8AYQBjAGMAbwB1AG4AdABzAC4AZgBpAHIAZQBmAG8AeAAuAGMAbwBtACAAaAB0AHQAcABzADoALwAvAGEAYwBjAG8AdQBuAHQAcwAuAGYAaQByAGUAZgBvAHgALgBjAG8AbQAuAGMAbgAgAGgAdAB0AHAAcwA6AC8ALwB3AHcAdwAuAHkAbwB1AHQAdQBiAGUALgBjAG8AbQAA",
+ output: {
+ URISpec: "https://www.mozilla.org/en-US/firefox/accounts/",
+ originAttributes: {
+ firstPartyDomain: "",
+ inIsolatedMozBrowser: false,
+ privateBrowsingId: 0,
+ userContextId: 0,
+ geckoViewSessionContextId: "",
+ partitionKey: "",
+ },
+ // Within Bug 965637 we removed CSP from Principals. Already serialized Principals however should still deserialize correctly (just without the CSP).
+ // "cspJSON": "{\"csp-policies\":[{\"child-src\":[\"https://www.googletagmanager.com\",\"https://www.google-analytics.com\",\"https://www.youtube-nocookie.com\",\"https://trackertest.org\",\"https://www.surveygizmo.com\",\"https://accounts.firefox.com\",\"https://accounts.firefox.com.cn\",\"https://www.youtube.com\"],\"connect-src\":[\"'self'\",\"https://*.mozilla.net\",\"https://*.mozilla.org\",\"https://*.mozilla.com\",\"https://www.googletagmanager.com\",\"https://www.google-analytics.com\",\"https://accounts.firefox.com/\",\"https://accounts.firefox.com.cn/\"],\"default-src\":[\"'self'\",\"https://*.mozilla.net\",\"https://*.mozilla.org\",\"https://*.mozilla.com\"],\"frame-src\":[\"https://www.googletagmanager.com\",\"https://www.google-analytics.com\",\"https://www.youtube-nocookie.com\",\"https://trackertest.org\",\"https://www.surveygizmo.com\",\"https://accounts.firefox.com\",\"https://accounts.firefox.com.cn\",\"https://www.youtube.com\"],\"img-src\":[\"'self'\",\"https://*.mozilla.net\",\"https://*.mozilla.org\",\"https://*.mozilla.com\",\"data:\",\"https://mozilla.org\",\"https://www.googletagmanager.com\",\"https://www.google-analytics.com\",\"https://adservice.google.com\",\"https://adservice.google.de\",\"https://adservice.google.dk\",\"https://creativecommons.org\",\"https://ad.doubleclick.net\"],\"report-only\":false,\"script-src\":[\"'self'\",\"https://*.mozilla.net\",\"https://*.mozilla.org\",\"https://*.mozilla.com\",\"'unsafe-inline'\",\"'unsafe-eval'\",\"https://www.googletagmanager.com\",\"https://www.google-analytics.com\",\"https://tagmanager.google.com\",\"https://www.youtube.com\",\"https://s.ytimg.com\"],\"style-src\":[\"'self'\",\"https://*.mozilla.net\",\"https://*.mozilla.org\",\"https://*.mozilla.com\",\"'unsafe-inline'\"]}]}",
+ },
+ },
+ ];
+
+ for (let test of serializedPrincipalsFromFirefox) {
+ let principal = E10SUtils.deserializePrincipal(test.input);
+
+ for (let key in principal.originAttributes) {
+ is(
+ principal.originAttributes[key],
+ test.output.originAttributes[key],
+ `Ensure value of ${key} is ${test.output.originAttributes[key]}`
+ );
+ }
+
+ if ("URI" in test.output && test.output.URI === false) {
+ is(
+ principal.isContentPrincipal,
+ false,
+ "Should have not have a URI for system"
+ );
+ } else {
+ is(
+ principal.spec,
+ test.output.URISpec,
+ `Should have spec ${test.output.URISpec}`
+ );
+ }
+ }
+});
diff --git a/browser/base/content/test/caps/browser_principalSerialization_json.js b/browser/base/content/test/caps/browser_principalSerialization_json.js
new file mode 100644
index 0000000000..f79f269fd7
--- /dev/null
+++ b/browser/base/content/test/caps/browser_principalSerialization_json.js
@@ -0,0 +1,161 @@
+"use strict";
+
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/*
+ This test file exists to ensure whenever changes to principal serialization happens,
+ we guarantee that the data can be restored and generated into a new principal.
+
+ The tests are written to be brittle so we encode all versions of the changes into the tests.
+*/
+
+add_task(async function test_nullPrincipal() {
+ const nullId = "0";
+ // fields
+ const uri = 0;
+ const suffix = 1;
+
+ const nullReplaceRegex =
+ /moz-nullprincipal:{[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}}/;
+ const NULL_REPLACE = "NULL_PRINCIPAL_URL";
+
+ /*
+ This test should NOT be resilient to changes in versioning,
+ however it exists purely to verify the code doesn't unintentionally change without updating versioning and migration code.
+ */
+ let tests = [
+ {
+ input: { OA: {} },
+ expected: `{"${nullId}":{"${uri}":"${NULL_REPLACE}"}}`,
+ },
+ {
+ input: { OA: {} },
+ expected: `{"${nullId}":{"${uri}":"${NULL_REPLACE}"}}`,
+ },
+ {
+ input: { OA: { userContextId: 0 } },
+ expected: `{"${nullId}":{"${uri}":"${NULL_REPLACE}"}}`,
+ },
+ {
+ input: { OA: { userContextId: 2 } },
+ expected: `{"${nullId}":{"${uri}":"${NULL_REPLACE}","${suffix}":"^userContextId=2"}}`,
+ },
+ {
+ input: { OA: { privateBrowsingId: 1 } },
+ expected: `{"${nullId}":{"${uri}":"${NULL_REPLACE}","${suffix}":"^privateBrowsingId=1"}}`,
+ },
+ {
+ input: { OA: { privateBrowsingId: 0 } },
+ expected: `{"${nullId}":{"${uri}":"${NULL_REPLACE}"}}`,
+ },
+ ];
+
+ for (let test of tests) {
+ let p = Services.scriptSecurityManager.createNullPrincipal(test.input.OA);
+ let sp = E10SUtils.serializePrincipal(p);
+ // Not sure why cppjson is adding a \n here
+ let spr = sp.replace(nullReplaceRegex, NULL_REPLACE);
+ is(
+ test.expected,
+ spr,
+ "Expected serialized object for " + JSON.stringify(test.input)
+ );
+ let dp = E10SUtils.deserializePrincipal(sp);
+
+ // Check all the origin attributes
+ for (let key in test.input.OA) {
+ is(
+ dp.originAttributes[key],
+ test.input.OA[key],
+ "Ensure value of " + key + " is " + test.input.OA[key]
+ );
+ }
+ }
+});
+
+add_task(async function test_contentPrincipal() {
+ const contentId = "1";
+ // fields
+ const content = 0;
+ // const domain = 1;
+ const suffix = 2;
+ // const csp = 3;
+
+ /*
+ This test should NOT be resilient to changes in versioning,
+ however it exists purely to verify the code doesn't unintentionally change without updating versioning and migration code.
+ */
+ let tests = [
+ {
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ input: { uri: "http://example.com/", OA: {} },
+ expected: `{"${contentId}":{"${content}":"http://example.com/"}}`,
+ },
+ {
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ input: { uri: "http://mozilla1.com/", OA: {} },
+ expected: `{"${contentId}":{"${content}":"http://mozilla1.com/"}}`,
+ },
+ {
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ input: { uri: "http://mozilla2.com/", OA: { userContextId: 0 } },
+ expected: `{"${contentId}":{"${content}":"http://mozilla2.com/"}}`,
+ },
+ {
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ input: { uri: "http://mozilla3.com/", OA: { userContextId: 2 } },
+ expected: `{"${contentId}":{"${content}":"http://mozilla3.com/","${suffix}":"^userContextId=2"}}`,
+ },
+ {
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ input: { uri: "http://mozilla4.com/", OA: { privateBrowsingId: 1 } },
+ expected: `{"${contentId}":{"${content}":"http://mozilla4.com/","${suffix}":"^privateBrowsingId=1"}}`,
+ },
+ {
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ input: { uri: "http://mozilla5.com/", OA: { privateBrowsingId: 0 } },
+ expected: `{"${contentId}":{"${content}":"http://mozilla5.com/"}}`,
+ },
+ ];
+
+ for (let test of tests) {
+ let uri = Services.io.newURI(test.input.uri);
+ let p = Services.scriptSecurityManager.createContentPrincipal(
+ uri,
+ test.input.OA
+ );
+ let sp = E10SUtils.serializePrincipal(p);
+ is(test.expected, sp, "Expected serialized object for " + test.input.uri);
+ let dp = E10SUtils.deserializePrincipal(sp);
+ is(dp.URI.spec, test.input.uri, "Ensure spec is the same");
+
+ // Check all the origin attributes
+ for (let key in test.input.OA) {
+ is(
+ dp.originAttributes[key],
+ test.input.OA[key],
+ "Ensure value of " + key + " is " + test.input.OA[key]
+ );
+ }
+ }
+});
+
+add_task(async function test_systemPrincipal() {
+ const systemId = "3";
+ /*
+ This test should NOT be resilient to changes in versioning,
+ however it exists purely to verify the code doesn't unintentionally change without updating versioning and migration code.
+ */
+ const expected = `{"${systemId}":{}}`;
+
+ let p = Services.scriptSecurityManager.getSystemPrincipal();
+ let sp = E10SUtils.serializePrincipal(p);
+ is(expected, sp, "Expected serialized object for system principal");
+ let dp = E10SUtils.deserializePrincipal(sp);
+ is(
+ dp,
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ "Deserialized the system principal"
+ );
+});
diff --git a/browser/base/content/test/caps/browser_principalSerialization_version1.js b/browser/base/content/test/caps/browser_principalSerialization_version1.js
new file mode 100644
index 0000000000..6c4a41e911
--- /dev/null
+++ b/browser/base/content/test/caps/browser_principalSerialization_version1.js
@@ -0,0 +1,159 @@
+"use strict";
+
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/*
+ This test file exists to ensure whenever changes to principal serialization happens,
+ we guarantee that the data can be restored and generated into a new principal.
+
+ The tests are written to be brittle so we encode all versions of the changes into the tests.
+*/
+
+add_task(function test_nullPrincipal() {
+ /*
+ As Null principals are designed to be non deterministic we just need to ensure that
+ a previous serialized version matches what it was generated as.
+
+ This test should be resilient to changes in versioning, however it should also be duplicated for a new serialization change.
+ */
+ // Principal created with: E10SUtils.serializePrincipal(Services.scriptSecurityManager.createNullPrincipal({ }));
+ let p = E10SUtils.deserializePrincipal(
+ "vQZuXxRvRHKDMXv9BbHtkAAAAAAAAAAAwAAAAAAAAEYAAAA4bW96LW51bGxwcmluY2lwYWw6ezU2Y2FjNTQwLTg2NGQtNDdlNy04ZTI1LTE2MTRlYWI1MTU1ZX0AAAAA"
+ );
+ is(
+ "moz-nullprincipal:{56cac540-864d-47e7-8e25-1614eab5155e}",
+ p.URI.spec,
+ "Deserialized principal doesn't have the correct URI"
+ );
+
+ // Principal created with: E10SUtils.serializePrincipal(Services.scriptSecurityManager.createNullPrincipal({ userContextId: 2 }));
+ let p2 = E10SUtils.deserializePrincipal(
+ "vQZuXxRvRHKDMXv9BbHtkAAAAAAAAAAAwAAAAAAAAEYAAAA4bW96LW51bGxwcmluY2lwYWw6ezA1ZjllN2JhLWIwODMtNDJhMi1iNDdkLTZiODRmNmYwYTM3OX0AAAAQXnVzZXJDb250ZXh0SWQ9Mg=="
+ );
+ is(
+ "moz-nullprincipal:{05f9e7ba-b083-42a2-b47d-6b84f6f0a379}",
+ p2.URI.spec,
+ "Deserialized principal doesn't have the correct URI"
+ );
+ is(p2.originAttributes.userContextId, 2, "Expected a userContextId of 2");
+});
+
+add_task(async function test_realHistoryCheck() {
+ /*
+ This test should be resilient to changes in principal serialization, if these are failing then it's likely the code will break session storage.
+ To recreate this for another version, copy the function into the browser console, browse some pages and printHistory.
+
+ Generated with:
+ function printHistory() {
+ let tests = [];
+ let entries = SessionStore.getSessionHistory(gBrowser.selectedTab).entries.map((entry) => { return entry.triggeringPrincipal_base64 });
+ entries.push(E10SUtils.serializePrincipal(gBrowser.selectedTab.linkedBrowser._contentPrincipal));
+ for (let entry of entries) {
+ console.log(entry);
+ let testData = {};
+ testData.input = entry;
+ let principal = E10SUtils.deserializePrincipal(testData.input);
+ testData.output = {};
+ if (principal.URI === null) {
+ testData.output.URI = false;
+ } else {
+ testData.output.URISpec = principal.URI.spec;
+ }
+ testData.output.originAttributes = principal.originAttributes;
+
+ tests.push(testData);
+ }
+ return tests;
+ }
+ printHistory(); // Copy this into: serializedPrincipalsFromFirefox
+ */
+
+ let serializedPrincipalsFromFirefox = [
+ {
+ input: "SmIS26zLEdO3ZQBgsLbOywAAAAAAAAAAwAAAAAAAAEY=",
+ output: {
+ URI: false,
+ originAttributes: {
+ firstPartyDomain: "",
+ inIsolatedMozBrowser: false,
+ privateBrowsingId: 0,
+ userContextId: 0,
+ geckoViewSessionContextId: "",
+ partitionKey: "",
+ },
+ },
+ },
+ {
+ input:
+ "ZT4OTT7kRfqycpfCC8AeuAAAAAAAAAAAwAAAAAAAAEYB3pRy0IA0EdOTmQAQS6D9QJIHOlRteE8wkTq4cYEyCMYAAAAC/////wAAAbsBAAAAe2h0dHBzOi8vZGV2ZWxvcGVyLm1vemlsbGEub3JnL2VuLVVTLz91dG1fc291cmNlPXd3dy5tb3ppbGxhLm9yZyZ1dG1fbWVkaXVtPXJlZmVycmFsJnV0bV9jYW1wYWlnbj1uYXYmdXRtX2NvbnRlbnQ9ZGV2ZWxvcGVycwAAAAAAAAAFAAAACAAAABUAAAAA/////wAAAAD/////AAAACAAAABUAAAAdAAAAXgAAAB0AAAAHAAAAHQAAAAcAAAAkAAAAAAAAAAD/////AAAAAP////8AAAAlAAAAVgAAAAD/////AQAAAAAAAAAAAAAAAA==",
+ output: {
+ URISpec:
+ "https://developer.mozilla.org/en-US/?utm_source=www.mozilla.org&utm_medium=referral&utm_campaign=nav&utm_content=developers",
+ originAttributes: {
+ firstPartyDomain: "",
+ inIsolatedMozBrowser: false,
+ privateBrowsingId: 0,
+ userContextId: 0,
+ geckoViewSessionContextId: "",
+ partitionKey: "",
+ },
+ },
+ },
+ {
+ input: "SmIS26zLEdO3ZQBgsLbOywAAAAAAAAAAwAAAAAAAAEY=",
+ output: {
+ URI: false,
+ originAttributes: {
+ firstPartyDomain: "",
+ inIsolatedMozBrowser: false,
+ privateBrowsingId: 0,
+ userContextId: 0,
+ geckoViewSessionContextId: "",
+ partitionKey: "",
+ },
+ },
+ },
+ {
+ input:
+ "vQZuXxRvRHKDMXv9BbHtkAAAAAAAAAAAwAAAAAAAAEYAAAA4bW96LW51bGxwcmluY2lwYWw6ezA0NWNhMThkLTQzNmMtNDc0NC1iYmI2LWIxYTE1MzY2ZGY3OX0AAAAA",
+ output: {
+ URISpec: "moz-nullprincipal:{045ca18d-436c-4744-bbb6-b1a15366df79}",
+ originAttributes: {
+ firstPartyDomain: "",
+ inIsolatedMozBrowser: false,
+ privateBrowsingId: 0,
+ userContextId: 0,
+ geckoViewSessionContextId: "",
+ partitionKey: "",
+ },
+ },
+ },
+ ];
+
+ for (let test of serializedPrincipalsFromFirefox) {
+ let principal = E10SUtils.deserializePrincipal(test.input);
+
+ for (let key in principal.originAttributes) {
+ is(
+ principal.originAttributes[key],
+ test.output.originAttributes[key],
+ `Ensure value of ${key} is ${test.output.originAttributes[key]}`
+ );
+ }
+
+ if ("URI" in test.output && test.output.URI === false) {
+ is(
+ principal.isContentPrincipal,
+ false,
+ "Should have not have a URI for system"
+ );
+ } else {
+ is(
+ principal.spec,
+ test.output.URISpec,
+ `Should have spec ${test.output.URISpec}`
+ );
+ }
+ }
+});
diff --git a/browser/base/content/test/captivePortal/browser.ini b/browser/base/content/test/captivePortal/browser.ini
new file mode 100644
index 0000000000..37b72ab758
--- /dev/null
+++ b/browser/base/content/test/captivePortal/browser.ini
@@ -0,0 +1,12 @@
+[DEFAULT]
+support-files =
+ head.js
+
+[browser_CaptivePortalWatcher.js]
+skip-if = os == "win" # Bug 1313894
+[browser_CaptivePortalWatcher_1.js]
+skip-if = os == "win" # Bug 1313894
+[browser_captivePortalTabReference.js]
+[browser_captivePortal_certErrorUI.js]
+[browser_captivePortal_https_only.js]
+[browser_closeCapPortalTabCanonicalURL.js]
diff --git a/browser/base/content/test/captivePortal/browser_CaptivePortalWatcher.js b/browser/base/content/test/captivePortal/browser_CaptivePortalWatcher.js
new file mode 100644
index 0000000000..aeafae21d8
--- /dev/null
+++ b/browser/base/content/test/captivePortal/browser_CaptivePortalWatcher.js
@@ -0,0 +1,125 @@
+/* eslint-disable mozilla/no-arbitrary-setTimeout */
+"use strict";
+
+// Bug 1318389 - This test does a lot of window and tab manipulation,
+// causing it to take a long time on debug.
+requestLongerTimeout(2);
+
+add_task(setupPrefsAndRecentWindowBehavior);
+
+// Each of the test cases below is run twice: once for login-success and once
+// for login-abort (aSuccess set to true and false respectively).
+let testCasesForBothSuccessAndAbort = [
+ /**
+ * A portal is detected when there's no browser window, then a browser
+ * window is opened, then the portal is freed.
+ * The portal tab should be added and focused when the window is
+ * opened, and closed automatically when the success event is fired.
+ * The captive portal notification should be shown when the window is
+ * opened, and closed automatically when the success event is fired.
+ */
+ async function test_detectedWithNoBrowserWindow_Open(aSuccess) {
+ await portalDetected();
+ let win = await focusWindowAndWaitForPortalUI();
+ await freePortal(aSuccess);
+ ensureNoPortalTab(win);
+ ensureNoPortalNotification(win);
+ await closeWindowAndWaitForWindowActivate(win);
+ },
+
+ /**
+ * A portal is detected when multiple browser windows are open but none
+ * have focus. A browser window is focused, then the portal is freed.
+ * The portal tab should be added and focused when the window is
+ * focused, and closed automatically when the success event is fired.
+ * The captive portal notification should be shown in all windows upon
+ * detection, and closed automatically when the success event is fired.
+ */
+ async function test_detectedWithNoBrowserWindow_Focused(aSuccess) {
+ let win1 = await openWindowAndWaitForFocus();
+ let win2 = await openWindowAndWaitForFocus();
+ // Defocus both windows.
+ await SimpleTest.promiseFocus(window);
+
+ await portalDetected();
+
+ // Notification should be shown in both windows.
+ ensurePortalNotification(win1);
+ ensureNoPortalTab(win1);
+ ensurePortalNotification(win2);
+ ensureNoPortalTab(win2);
+
+ await focusWindowAndWaitForPortalUI(false, win2);
+
+ await freePortal(aSuccess);
+
+ ensureNoPortalNotification(win1);
+ ensureNoPortalTab(win2);
+ ensureNoPortalNotification(win2);
+
+ await closeWindowAndWaitForWindowActivate(win2);
+ // No need to wait for xul-window-visible: after win2 is closed, focus
+ // is restored to the default window and win1 remains in the background.
+ await BrowserTestUtils.closeWindow(win1);
+ },
+
+ /**
+ * A portal is detected when there's no browser window, then a browser
+ * window is opened, then the portal is freed.
+ * The recheck triggered when the browser window is opened takes a
+ * long time. No portal tab should be added.
+ * The captive portal notification should be shown when the window is
+ * opened, and closed automatically when the success event is fired.
+ */
+ async function test_detectedWithNoBrowserWindow_LongRecheck(aSuccess) {
+ await portalDetected();
+ let win = await focusWindowAndWaitForPortalUI(true);
+ await freePortal(aSuccess);
+ ensureNoPortalTab(win);
+ ensureNoPortalNotification(win);
+ await closeWindowAndWaitForWindowActivate(win);
+ },
+
+ /**
+ * A portal is detected when there's no browser window, and the
+ * portal is freed before a browser window is opened. No portal
+ * UI should be shown when a browser window is opened.
+ */
+ async function test_detectedWithNoBrowserWindow_GoneBeforeOpen(aSuccess) {
+ await portalDetected();
+ await freePortal(aSuccess);
+ let win = await openWindowAndWaitForFocus();
+ // Wait for a while to make sure no UI is shown.
+ await new Promise(resolve => {
+ setTimeout(resolve, 1000);
+ });
+ ensureNoPortalTab(win);
+ ensureNoPortalNotification(win);
+ await closeWindowAndWaitForWindowActivate(win);
+ },
+
+ /**
+ * A portal is detected when a browser window has focus. No portal tab should
+ * be opened. A notification bar should be displayed in all browser windows.
+ */
+ async function test_detectedWithFocus(aSuccess) {
+ let win1 = await openWindowAndWaitForFocus();
+ let win2 = await openWindowAndWaitForFocus();
+ await portalDetected();
+ ensureNoPortalTab(win1);
+ ensureNoPortalTab(win2);
+ ensurePortalNotification(win1);
+ ensurePortalNotification(win2);
+ await freePortal(aSuccess);
+ ensureNoPortalNotification(win1);
+ ensureNoPortalNotification(win2);
+ await BrowserTestUtils.closeWindow(win2);
+ await BrowserTestUtils.closeWindow(win1);
+ await waitForBrowserWindowActive(window);
+ },
+];
+
+for (let testcase of testCasesForBothSuccessAndAbort) {
+ add_task(testcase.bind(null, true));
+ add_task(testcase.bind(null, false));
+}
diff --git a/browser/base/content/test/captivePortal/browser_CaptivePortalWatcher_1.js b/browser/base/content/test/captivePortal/browser_CaptivePortalWatcher_1.js
new file mode 100644
index 0000000000..6c6cc5f438
--- /dev/null
+++ b/browser/base/content/test/captivePortal/browser_CaptivePortalWatcher_1.js
@@ -0,0 +1,108 @@
+"use strict";
+
+add_task(setupPrefsAndRecentWindowBehavior);
+
+let testcases = [
+ /**
+ * A portal is detected when there's no browser window,
+ * then a browser window is opened, and the portal is logged into
+ * and redirects to a different page. The portal tab should be added
+ * and focused when the window is opened, and left open after login
+ * since it redirected.
+ */
+ async function test_detectedWithNoBrowserWindow_Redirect() {
+ await portalDetected();
+ let win = await focusWindowAndWaitForPortalUI();
+ let browser = win.gBrowser.selectedTab.linkedBrowser;
+ let loadPromise = BrowserTestUtils.browserLoaded(
+ browser,
+ false,
+ CANONICAL_URL_REDIRECTED
+ );
+ BrowserTestUtils.loadURIString(browser, CANONICAL_URL_REDIRECTED);
+ await loadPromise;
+ await freePortal(true);
+ ensurePortalTab(win);
+ ensureNoPortalNotification(win);
+ await closeWindowAndWaitForWindowActivate(win);
+ },
+
+ /**
+ * Test the various expected behaviors of the "Show Login Page" button
+ * in the captive portal notification. The button should be visible for
+ * all tabs except the captive portal tab, and when clicked, should
+ * ensure a captive portal tab is open and select it.
+ */
+ async function test_showLoginPageButton() {
+ let win = await openWindowAndWaitForFocus();
+ await portalDetected();
+ let notification = ensurePortalNotification(win);
+ testShowLoginPageButtonVisibility(notification, "visible");
+
+ function testPortalTabSelectedAndButtonNotVisible() {
+ is(
+ win.gBrowser.selectedTab,
+ tab,
+ "The captive portal tab should be selected."
+ );
+ testShowLoginPageButtonVisibility(notification, "hidden");
+ }
+
+ let button = notification.buttonContainer.querySelector(
+ "button.notification-button"
+ );
+ async function clickButtonAndExpectNewPortalTab() {
+ let p = BrowserTestUtils.waitForNewTab(win.gBrowser, CANONICAL_URL);
+ button.click();
+ let tab = await p;
+ is(
+ win.gBrowser.selectedTab,
+ tab,
+ "The captive portal tab should be selected."
+ );
+ return tab;
+ }
+
+ // Simulate clicking the button. The portal tab should be opened and
+ // selected and the button should hide.
+ let tab = await clickButtonAndExpectNewPortalTab();
+ testPortalTabSelectedAndButtonNotVisible();
+
+ // Close the tab. The button should become visible.
+ BrowserTestUtils.removeTab(tab);
+ ensureNoPortalTab(win);
+ testShowLoginPageButtonVisibility(notification, "visible");
+
+ // When the button is clicked, a new portal tab should be opened and
+ // selected.
+ tab = await clickButtonAndExpectNewPortalTab();
+
+ // Open another arbitrary tab. The button should become visible. When it's clicked,
+ // the portal tab should be selected.
+ let anotherTab = await BrowserTestUtils.openNewForegroundTab(win.gBrowser);
+ testShowLoginPageButtonVisibility(notification, "visible");
+ button.click();
+ is(
+ win.gBrowser.selectedTab,
+ tab,
+ "The captive portal tab should be selected."
+ );
+
+ // Close the portal tab and select the arbitrary tab. The button should become
+ // visible and when it's clicked, a new portal tab should be opened.
+ BrowserTestUtils.removeTab(tab);
+ win.gBrowser.selectedTab = anotherTab;
+ testShowLoginPageButtonVisibility(notification, "visible");
+ tab = await clickButtonAndExpectNewPortalTab();
+
+ BrowserTestUtils.removeTab(anotherTab);
+ await freePortal(true);
+ ensureNoPortalTab(win);
+ ensureNoPortalNotification(win);
+ await closeWindowAndWaitForWindowActivate(win);
+ },
+];
+
+for (let testcase of testcases) {
+ add_task(testcase);
+}
diff --git a/browser/base/content/test/captivePortal/browser_captivePortalTabReference.js b/browser/base/content/test/captivePortal/browser_captivePortalTabReference.js
new file mode 100644
index 0000000000..b630f35149
--- /dev/null
+++ b/browser/base/content/test/captivePortal/browser_captivePortalTabReference.js
@@ -0,0 +1,65 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const CPS = Cc["@mozilla.org/network/captive-portal-service;1"].getService(
+ Ci.nsICaptivePortalService
+);
+
+async function checkCaptivePortalTabReference(evt, currState) {
+ await portalDetected();
+ let errorTab = await openCaptivePortalErrorTab();
+ let portalTab = await openCaptivePortalLoginTab(errorTab);
+
+ // Release the reference held to the portal tab by sending success/abort events.
+ Services.obs.notifyObservers(null, evt);
+ await TestUtils.waitForCondition(
+ () => CPS.state == currState,
+ "Captive portal has been released"
+ );
+ gBrowser.removeTab(errorTab);
+
+ await portalDetected();
+ ok(CPS.state == CPS.LOCKED_PORTAL, "Captive portal is locked again");
+ errorTab = await openCaptivePortalErrorTab();
+ let portalTab2 = await openCaptivePortalLoginTab(errorTab);
+ ok(
+ portalTab != portalTab2,
+ "waitForNewTab in openCaptivePortalLoginTab should not have completed at this point if references were held to the old captive portal tab after login/abort."
+ );
+ gBrowser.removeTab(portalTab);
+ gBrowser.removeTab(portalTab2);
+
+ let errorTabReloaded = BrowserTestUtils.waitForErrorPage(
+ errorTab.linkedBrowser
+ );
+ Services.obs.notifyObservers(null, "captive-portal-login-success");
+ await errorTabReloaded;
+
+ gBrowser.removeTab(errorTab);
+}
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["captivedetect.canonicalURL", CANONICAL_URL],
+ ["captivedetect.canonicalContent", CANONICAL_CONTENT],
+ ],
+ });
+});
+
+let capPortalStates = [
+ {
+ evt: "captive-portal-login-success",
+ state: CPS.UNLOCKED_PORTAL,
+ },
+ {
+ evt: "captive-portal-login-abort",
+ state: CPS.UNKNOWN,
+ },
+];
+
+for (let elem of capPortalStates) {
+ add_task(checkCaptivePortalTabReference.bind(null, elem.evt, elem.state));
+}
diff --git a/browser/base/content/test/captivePortal/browser_captivePortal_certErrorUI.js b/browser/base/content/test/captivePortal/browser_captivePortal_certErrorUI.js
new file mode 100644
index 0000000000..d23125a627
--- /dev/null
+++ b/browser/base/content/test/captivePortal/browser_captivePortal_certErrorUI.js
@@ -0,0 +1,221 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["captivedetect.canonicalURL", CANONICAL_URL],
+ ["captivedetect.canonicalContent", CANONICAL_CONTENT],
+ ],
+ });
+});
+
+// This tests the alternate cert error UI when we are behind a captive portal.
+add_task(async function checkCaptivePortalCertErrorUI() {
+ info(
+ "Checking that the alternate cert error UI is shown when we are behind a captive portal"
+ );
+
+ // Open a second window in the background. Later, we'll check that
+ // when we click the button to open the captive portal tab, the tab
+ // only opens in the active window and not in the background one.
+ let secondWindow = await openWindowAndWaitForFocus();
+ await SimpleTest.promiseFocus(window);
+
+ await portalDetected();
+
+ // Check that we didn't open anything in the background window.
+ ensureNoPortalTab(secondWindow);
+
+ let tab = await openCaptivePortalErrorTab();
+ let browser = tab.linkedBrowser;
+ let portalTabPromise = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ CANONICAL_URL
+ );
+
+ await SpecialPowers.spawn(browser, [], async () => {
+ let doc = content.document;
+ let loginButton = doc.getElementById("openPortalLoginPageButton");
+ await ContentTaskUtils.waitForCondition(
+ () => ContentTaskUtils.is_visible(loginButton),
+ "Captive portal error page UI is visible"
+ );
+
+ if (!Services.focus.focusedElement == loginButton) {
+ await ContentTaskUtils.waitForEvent(loginButton, "focus");
+ }
+
+ Assert.ok(true, "openPortalLoginPageButton has focus");
+ info("Clicking the Open Login Page button");
+ await EventUtils.synthesizeMouseAtCenter(loginButton, {}, content);
+ });
+
+ let portalTab = await portalTabPromise;
+ is(
+ gBrowser.selectedTab,
+ portalTab,
+ "Login page should be open in a new foreground tab."
+ );
+
+ // Check that we didn't open anything in the background window.
+ ensureNoPortalTab(secondWindow);
+
+ // Make sure clicking the "Open Login Page" button again focuses the existing portal tab.
+ await BrowserTestUtils.switchTab(gBrowser, tab);
+ // Passing an empty function to BrowserTestUtils.switchTab lets us wait for an arbitrary
+ // tab switch.
+ portalTabPromise = BrowserTestUtils.switchTab(gBrowser, () => {});
+ await SpecialPowers.spawn(browser, [], async () => {
+ info("Clicking the Open Login Page button.");
+ let loginButton = content.document.getElementById(
+ "openPortalLoginPageButton"
+ );
+ await EventUtils.synthesizeMouseAtCenter(loginButton, {}, content);
+ });
+
+ info("Opening captive portal login page");
+ let portalTab2 = await portalTabPromise;
+ is(portalTab2, portalTab, "The existing portal tab should be focused.");
+
+ // Check that we didn't open anything in the background window.
+ ensureNoPortalTab(secondWindow);
+
+ let portalTabClosing = BrowserTestUtils.waitForTabClosing(portalTab);
+ let errorTabReloaded = BrowserTestUtils.waitForErrorPage(browser);
+
+ Services.obs.notifyObservers(null, "captive-portal-login-success");
+ await portalTabClosing;
+
+ info(
+ "Waiting for error tab to be reloaded after the captive portal was freed."
+ );
+ await errorTabReloaded;
+ await SpecialPowers.spawn(browser, [], () => {
+ let doc = content.document;
+ ok(
+ !doc.body.classList.contains("captiveportal"),
+ "Captive portal error page UI is not visible."
+ );
+ });
+
+ await BrowserTestUtils.removeTab(tab);
+ await BrowserTestUtils.closeWindow(secondWindow);
+});
+
+add_task(async function testCaptivePortalAdvancedPanel() {
+ info(
+ "Checking that the advanced section of the about:certerror UI is shown when we are behind a captive portal."
+ );
+ await portalDetected();
+ let tab = await openCaptivePortalErrorTab();
+ let browser = tab.linkedBrowser;
+
+ const waitForLocationChange = (async () => {
+ await BrowserTestUtils.waitForLocationChange(gBrowser, BAD_CERT_PAGE);
+ info("(waitForLocationChange resolved)");
+ })();
+ await SpecialPowers.spawn(browser, [BAD_CERT_PAGE], async expectedURL => {
+ const doc = content.document;
+ let advancedButton = doc.getElementById("advancedButton");
+ await ContentTaskUtils.waitForCondition(
+ () => ContentTaskUtils.is_visible(advancedButton),
+ "Captive portal UI is visible"
+ );
+
+ info("Clicking on the advanced button");
+ const advPanel = doc.getElementById("badCertAdvancedPanel");
+ ok(
+ !ContentTaskUtils.is_visible(advPanel),
+ "Advanced panel is not yet visible"
+ );
+ await EventUtils.synthesizeMouseAtCenter(advancedButton, {}, content);
+ ok(ContentTaskUtils.is_visible(advPanel), "Advanced panel is now visible");
+
+ let advPanelContent = doc.getElementById("badCertTechnicalInfo");
+ ok(
+ ContentTaskUtils.is_visible(advPanelContent) &&
+ advPanelContent.textContent.includes("expired.example.com"),
+ "Advanced panel text content is visible"
+ );
+
+ let advPanelErrorCode = doc.getElementById("errorCode");
+ ok(
+ advPanelErrorCode.textContent,
+ "Cert error code is visible in the advanced panel"
+ );
+
+ // -
+
+ const advPanelExceptionButton = doc.getElementById("exceptionDialogButton");
+
+ function isOnCertErrorPage() {
+ return ContentTaskUtils.is_visible(advPanel);
+ }
+
+ ok(isOnCertErrorPage(), "On cert error page before adding exception");
+ ok(
+ advPanelExceptionButton.disabled,
+ "Exception button should start disabled"
+ );
+ await EventUtils.synthesizeMouseAtCenter(
+ advPanelExceptionButton,
+ {},
+ content
+ ); // Click
+ const clickTime = content.performance.now();
+ ok(
+ isOnCertErrorPage(),
+ "Still on cert error page because clicked too early"
+ );
+
+ // Now waitForCondition now that it's possible.
+ try {
+ await ContentTaskUtils.waitForCondition(
+ () => !advPanelExceptionButton.disabled,
+ "Wait for exception button enabled"
+ );
+ } catch (rejected) {
+ ok(false, rejected);
+ return;
+ }
+ ok(
+ !advPanelExceptionButton.disabled,
+ "Exception button should be enabled after waiting"
+ );
+ const msSinceClick = content.performance.now() - clickTime;
+ const expr = `${msSinceClick} > 1000`;
+ /* eslint-disable no-eval */
+ ok(eval(expr), `Exception button should stay disabled for ${expr} ms`);
+
+ await EventUtils.synthesizeMouseAtCenter(
+ advPanelExceptionButton,
+ {},
+ content
+ ); // Click
+ info("Clicked");
+ });
+ await waitForLocationChange;
+ info("Page reloaded after adding cert exception");
+
+ // Clear the certificate exception.
+ let certOverrideService = Cc[
+ "@mozilla.org/security/certoverride;1"
+ ].getService(Ci.nsICertOverrideService);
+ certOverrideService.clearValidityOverride("expired.example.com", -1, {});
+
+ info("After clearing cert override, asking for reload...");
+ const waitForErrorPage = BrowserTestUtils.waitForErrorPage(browser);
+ await SpecialPowers.spawn(browser, [], async () => {
+ info("reload...");
+ content.location.reload();
+ });
+ info("waitForErrorPage...");
+ await waitForErrorPage;
+
+ info("removeTab...");
+ await BrowserTestUtils.removeTab(tab);
+ info("Done!");
+});
diff --git a/browser/base/content/test/captivePortal/browser_captivePortal_https_only.js b/browser/base/content/test/captivePortal/browser_captivePortal_https_only.js
new file mode 100644
index 0000000000..789d392107
--- /dev/null
+++ b/browser/base/content/test/captivePortal/browser_captivePortal_https_only.js
@@ -0,0 +1,73 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+const { PermissionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PermissionTestUtils.sys.mjs"
+);
+const testPath = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com"
+);
+const CANONICAL_URI = Services.io.newURI(testPath);
+const PERMISSION_NAME = "https-only-load-insecure";
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ // That changes the canoncicalURL from "http://{server}/captive-detect/success.txt"
+ // to http://example.com
+ set: [
+ ["captivedetect.canonicalURL", testPath],
+ ["dom.security.https_only_mode", true],
+ ],
+ });
+});
+
+// This test checks if https-only exempts the canoncial uri.
+add_task(async function checkCaptivePortalExempt() {
+ await portalDetected();
+ info("Checking that the canonical uri is exempt by https-only mode");
+ let tab = await openCaptivePortalErrorTab();
+ let browser = tab.linkedBrowser;
+ let portalTabPromise = BrowserTestUtils.waitForNewTab(gBrowser, testPath);
+
+ await SpecialPowers.spawn(browser, [], async () => {
+ let doc = content.document;
+ let loginButton = doc.getElementById("openPortalLoginPageButton");
+ await ContentTaskUtils.waitForCondition(
+ () => ContentTaskUtils.is_visible(loginButton),
+ "Captive portal error page UI is visible"
+ );
+
+ if (!Services.focus.focusedElement == loginButton) {
+ await ContentTaskUtils.waitForEvent(loginButton, "focus");
+ }
+
+ Assert.ok(true, "openPortalLoginPageButton has focus");
+ info("Clicking the Open Login Page button");
+ await EventUtils.synthesizeMouseAtCenter(loginButton, {}, content);
+ });
+ is(
+ PermissionTestUtils.testPermission(CANONICAL_URI, PERMISSION_NAME),
+ Services.perms.ALLOW_ACTION,
+ "Check permission in perm. manager if canoncial uri is set as exempt."
+ );
+ let portalTab = await portalTabPromise;
+ is(
+ gBrowser.selectedTab,
+ portalTab,
+ "Login page should be open in a new foreground tab."
+ );
+ is(
+ gBrowser.currentURI.spec,
+ testPath,
+ "Opened the right URL without upgrading it."
+ );
+ // Close all tabs
+ await BrowserTestUtils.removeTab(portalTab);
+ let tabReloaded = BrowserTestUtils.waitForErrorPage(tab.linkedBrowser);
+ Services.obs.notifyObservers(null, "captive-portal-login-success");
+ await tabReloaded;
+ await BrowserTestUtils.removeTab(tab);
+});
diff --git a/browser/base/content/test/captivePortal/browser_closeCapPortalTabCanonicalURL.js b/browser/base/content/test/captivePortal/browser_closeCapPortalTabCanonicalURL.js
new file mode 100644
index 0000000000..0457dab1c0
--- /dev/null
+++ b/browser/base/content/test/captivePortal/browser_closeCapPortalTabCanonicalURL.js
@@ -0,0 +1,152 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { HttpServer } = ChromeUtils.import("resource://testing-common/httpd.js");
+const LOGIN_LINK = `<html><body><a href="/unlock">login</a></body></html>`;
+const LOGIN_URL = "http://localhost:8080/login";
+const CANONICAL_SUCCESS_URL = "http://localhost:8080/success";
+const CPS = Cc["@mozilla.org/network/captive-portal-service;1"].getService(
+ Ci.nsICaptivePortalService
+);
+
+let server;
+let loginPageShown = false;
+
+function redirectHandler(request, response) {
+ if (loginPageShown) {
+ return;
+ }
+ response.setStatusLine(request.httpVersion, 302, "captive");
+ response.setHeader("Content-Type", "text/html");
+ response.setHeader("Location", LOGIN_URL);
+}
+
+function loginHandler(request, response) {
+ response.setHeader("Content-Type", "text/html");
+ response.bodyOutputStream.write(LOGIN_LINK, LOGIN_LINK.length);
+ loginPageShown = true;
+}
+
+function unlockHandler(request, response) {
+ response.setStatusLine(request.httpVersion, 302, "login complete");
+ response.setHeader("Content-Type", "text/html");
+ response.setHeader("Location", CANONICAL_SUCCESS_URL);
+}
+
+add_setup(async function () {
+ // Set up a mock server for handling captive portal redirect.
+ server = new HttpServer();
+ server.registerPathHandler("/success", redirectHandler);
+ server.registerPathHandler("/login", loginHandler);
+ server.registerPathHandler("/unlock", unlockHandler);
+ server.start(8080);
+ info("Mock server is now set up for captive portal redirect");
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["captivedetect.canonicalURL", CANONICAL_SUCCESS_URL],
+ ["captivedetect.canonicalContent", CANONICAL_CONTENT],
+ ],
+ });
+});
+
+// This test checks if the captive portal tab is removed after the
+// sucess/abort events are fired, assuming the tab has already redirected
+// to the canonical URL before they are fired.
+add_task(async function checkCaptivePortalTabCloseOnCanonicalURL_one() {
+ await portalDetected();
+ let errorTab = await openCaptivePortalErrorTab();
+ let tab = await openCaptivePortalLoginTab(errorTab, LOGIN_URL);
+ let browser = tab.linkedBrowser;
+
+ let redirectedToCanonicalURL = BrowserTestUtils.browserLoaded(
+ browser,
+ false,
+ CANONICAL_SUCCESS_URL
+ );
+ let errorPageReloaded = BrowserTestUtils.waitForErrorPage(
+ errorTab.linkedBrowser
+ );
+
+ await SpecialPowers.spawn(browser, [], async () => {
+ let doc = content.document;
+ let loginButton = doc.querySelector("a");
+ await ContentTaskUtils.waitForCondition(
+ () => loginButton,
+ "Login button on the captive portal tab is visible"
+ );
+ info("Clicking the login button on the captive portal tab page");
+ await EventUtils.synthesizeMouseAtCenter(loginButton, {}, content);
+ });
+
+ await redirectedToCanonicalURL;
+ info(
+ "Re-direct to canonical URL in the captive portal tab was succcessful after login"
+ );
+
+ let tabClosed = BrowserTestUtils.waitForTabClosing(tab);
+ Services.obs.notifyObservers(null, "captive-portal-login-success");
+ await tabClosed;
+ info(
+ "Captive portal tab was closed on re-direct to canonical URL after login as expected"
+ );
+
+ await errorPageReloaded;
+ info("Captive portal error page was reloaded");
+ gBrowser.removeTab(errorTab);
+});
+
+// This test checks if the captive portal tab is removed on location change
+// i.e. when it is re-directed to the canonical URL long after success/abort
+// event handlers are executed.
+add_task(async function checkCaptivePortalTabCloseOnCanonicalURL_two() {
+ loginPageShown = false;
+ await portalDetected();
+ let errorTab = await openCaptivePortalErrorTab();
+ let tab = await openCaptivePortalLoginTab(errorTab, LOGIN_URL);
+ let browser = tab.linkedBrowser;
+
+ let redirectedToCanonicalURL = BrowserTestUtils.waitForLocationChange(
+ gBrowser,
+ CANONICAL_SUCCESS_URL
+ );
+ let errorPageReloaded = BrowserTestUtils.waitForErrorPage(
+ errorTab.linkedBrowser
+ );
+
+ Services.obs.notifyObservers(null, "captive-portal-login-success");
+ await TestUtils.waitForCondition(
+ () => CPS.state == CPS.UNLOCKED_PORTAL,
+ "Captive portal is released"
+ );
+
+ let tabClosed = BrowserTestUtils.waitForTabClosing(tab);
+ await SpecialPowers.spawn(browser, [], async () => {
+ let doc = content.document;
+ let loginButton = doc.querySelector("a");
+ await ContentTaskUtils.waitForCondition(
+ () => loginButton,
+ "Login button on the captive portal tab is visible"
+ );
+ info("Clicking the login button on the captive portal tab page");
+ await EventUtils.synthesizeMouseAtCenter(loginButton, {}, content);
+ });
+
+ await redirectedToCanonicalURL;
+ info(
+ "Re-direct to canonical URL in the captive portal tab was succcessful after login"
+ );
+ await tabClosed;
+ info(
+ "Captive portal tab was closed on re-direct to canonical URL after login as expected"
+ );
+
+ await errorPageReloaded;
+ info("Captive portal error page was reloaded");
+ gBrowser.removeTab(errorTab);
+
+ // Stop the server.
+ await new Promise(r => server.stop(r));
+});
diff --git a/browser/base/content/test/captivePortal/head.js b/browser/base/content/test/captivePortal/head.js
new file mode 100644
index 0000000000..4e47c3012a
--- /dev/null
+++ b/browser/base/content/test/captivePortal/head.js
@@ -0,0 +1,260 @@
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "cps",
+ "@mozilla.org/network/captive-portal-service;1",
+ "nsICaptivePortalService"
+);
+
+const CANONICAL_CONTENT = "success";
+const CANONICAL_URL = "data:text/plain;charset=utf-8," + CANONICAL_CONTENT;
+const CANONICAL_URL_REDIRECTED = "data:text/plain;charset=utf-8,redirected";
+const PORTAL_NOTIFICATION_VALUE = "captive-portal-detected";
+const BAD_CERT_PAGE = "https://expired.example.com/";
+
+async function setupPrefsAndRecentWindowBehavior() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["captivedetect.canonicalURL", CANONICAL_URL],
+ ["captivedetect.canonicalContent", CANONICAL_CONTENT],
+ ],
+ });
+ // We need to test behavior when a portal is detected when there is no browser
+ // window, but we can't close the default window opened by the test harness.
+ // Instead, we deactivate CaptivePortalWatcher in the default window and
+ // exclude it using an attribute to mask its presence.
+ window.CaptivePortalWatcher.uninit();
+ window.document.documentElement.setAttribute("ignorecaptiveportal", "true");
+
+ registerCleanupFunction(function cleanUp() {
+ window.CaptivePortalWatcher.init();
+ window.document.documentElement.removeAttribute("ignorecaptiveportal");
+ });
+}
+
+async function portalDetected() {
+ Services.obs.notifyObservers(null, "captive-portal-login");
+ await TestUtils.waitForCondition(() => {
+ return cps.state == cps.LOCKED_PORTAL;
+ }, "Waiting for Captive Portal Service to update state after portal detected.");
+}
+
+async function freePortal(aSuccess) {
+ Services.obs.notifyObservers(
+ null,
+ "captive-portal-login-" + (aSuccess ? "success" : "abort")
+ );
+ await TestUtils.waitForCondition(() => {
+ return cps.state != cps.LOCKED_PORTAL;
+ }, "Waiting for Captive Portal Service to update state after portal freed.");
+}
+
+// If a window is provided, it will be focused. Otherwise, a new window
+// will be opened and focused.
+async function focusWindowAndWaitForPortalUI(aLongRecheck, win) {
+ // CaptivePortalWatcher triggers a recheck when a window gains focus. If
+ // the time taken for the check to complete is under PORTAL_RECHECK_DELAY_MS,
+ // a tab with the login page is opened and selected. If it took longer,
+ // no tab is opened. It's not reliable to time things in an async test,
+ // so use a delay threshold of -1 to simulate a long recheck (so that any
+ // amount of time is considered excessive), and a very large threshold to
+ // simulate a short recheck.
+ Services.prefs.setIntPref(
+ "captivedetect.portalRecheckDelayMS",
+ aLongRecheck ? -1 : 1000000
+ );
+
+ if (!win) {
+ win = await BrowserTestUtils.openNewBrowserWindow();
+ }
+ let windowActivePromise = waitForBrowserWindowActive(win);
+ win.focus();
+ await windowActivePromise;
+
+ // After a new window is opened, CaptivePortalWatcher asks for a recheck, and
+ // waits for it to complete. We need to manually tell it a recheck completed.
+ await TestUtils.waitForCondition(() => {
+ return win.CaptivePortalWatcher._waitingForRecheck;
+ }, "Waiting for CaptivePortalWatcher to trigger a recheck.");
+ Services.obs.notifyObservers(null, "captive-portal-check-complete");
+
+ let notification = ensurePortalNotification(win);
+
+ if (aLongRecheck) {
+ ensureNoPortalTab(win);
+ testShowLoginPageButtonVisibility(notification, "visible");
+ return win;
+ }
+
+ let tab = win.gBrowser.tabs[1];
+ if (tab.linkedBrowser.currentURI.spec != CANONICAL_URL) {
+ // The tab should load the canonical URL, wait for it.
+ await BrowserTestUtils.waitForLocationChange(win.gBrowser, CANONICAL_URL);
+ }
+ is(
+ win.gBrowser.selectedTab,
+ tab,
+ "The captive portal tab should be open and selected in the new window."
+ );
+ testShowLoginPageButtonVisibility(notification, "hidden");
+ return win;
+}
+
+function ensurePortalTab(win) {
+ // For the tests that call this function, it's enough to ensure there
+ // are two tabs in the window - the default tab and the portal tab.
+ is(
+ win.gBrowser.tabs.length,
+ 2,
+ "There should be a captive portal tab in the window."
+ );
+}
+
+function ensurePortalNotification(win) {
+ let notification = win.gNotificationBox.getNotificationWithValue(
+ PORTAL_NOTIFICATION_VALUE
+ );
+ isnot(
+ notification,
+ null,
+ "There should be a captive portal notification in the window."
+ );
+ return notification;
+}
+
+// Helper to test whether the "Show Login Page" is visible in the captive portal
+// notification (it should be hidden when the portal tab is selected).
+function testShowLoginPageButtonVisibility(notification, visibility) {
+ let showLoginPageButton = notification.buttonContainer.querySelector(
+ "button.notification-button"
+ );
+ // If the visibility property was never changed from default, it will be
+ // an empty string, so we pretend it's "visible" (effectively the same).
+ is(
+ showLoginPageButton.style.visibility || "visible",
+ visibility,
+ 'The "Show Login Page" button should be ' + visibility + "."
+ );
+}
+
+function ensureNoPortalTab(win) {
+ is(
+ win.gBrowser.tabs.length,
+ 1,
+ "There should be no captive portal tab in the window."
+ );
+}
+
+function ensureNoPortalNotification(win) {
+ is(
+ win.gNotificationBox.getNotificationWithValue(PORTAL_NOTIFICATION_VALUE),
+ null,
+ "There should be no captive portal notification in the window."
+ );
+}
+
+/**
+ * Some tests open a new window and close it later. When the window is closed,
+ * the original window opened by mochitest gains focus, generating an
+ * activate event. If the next test also opens a new window
+ * before this event has a chance to fire, CaptivePortalWatcher picks
+ * up the first one instead of the one from the new window. To avoid this
+ * unfortunate intermittent timing issue, we wait for the event from
+ * the original window every time we close a window that we opened.
+ */
+function waitForBrowserWindowActive(win) {
+ return new Promise(resolve => {
+ if (Services.focus.activeWindow == win) {
+ resolve();
+ } else {
+ win.addEventListener(
+ "activate",
+ () => {
+ resolve();
+ },
+ { once: true }
+ );
+ }
+ });
+}
+
+async function closeWindowAndWaitForWindowActivate(win) {
+ let activationPromises = [];
+ for (let w of BrowserWindowTracker.orderedWindows) {
+ if (
+ w != win &&
+ !win.document.documentElement.getAttribute("ignorecaptiveportal")
+ ) {
+ activationPromises.push(waitForBrowserWindowActive(win));
+ }
+ }
+ await BrowserTestUtils.closeWindow(win);
+ await Promise.race(activationPromises);
+}
+
+/**
+ * BrowserTestUtils.openNewBrowserWindow() does not guarantee the newly
+ * opened window has received focus when the promise resolves, so we
+ * have to manually wait every time.
+ */
+async function openWindowAndWaitForFocus() {
+ let win = await BrowserTestUtils.openNewBrowserWindow();
+ await waitForBrowserWindowActive(win);
+ return win;
+}
+
+async function openCaptivePortalErrorTab() {
+ // Open a page with a cert error.
+ let browser;
+ let certErrorLoaded;
+ let errorTab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ () => {
+ let tab = BrowserTestUtils.addTab(gBrowser, BAD_CERT_PAGE);
+ gBrowser.selectedTab = tab;
+ browser = gBrowser.selectedBrowser;
+ certErrorLoaded = BrowserTestUtils.waitForErrorPage(browser);
+ return tab;
+ },
+ false
+ );
+ await certErrorLoaded;
+ info("A cert error page was opened");
+ await SpecialPowers.spawn(errorTab.linkedBrowser, [], async () => {
+ let doc = content.document;
+ let loginButton = doc.getElementById("openPortalLoginPageButton");
+ await ContentTaskUtils.waitForCondition(
+ () => loginButton && doc.body.className == "captiveportal",
+ "Captive portal error page UI is visible"
+ );
+ });
+ info("Captive portal error page UI is visible");
+
+ return errorTab;
+}
+
+async function openCaptivePortalLoginTab(
+ errorTab,
+ LOGIN_PAGE_URL = CANONICAL_URL
+) {
+ let portalTabPromise = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ LOGIN_PAGE_URL,
+ true
+ );
+
+ await SpecialPowers.spawn(errorTab.linkedBrowser, [], async () => {
+ let doc = content.document;
+ let loginButton = doc.getElementById("openPortalLoginPageButton");
+ info("Click on the login button on the captive portal error page");
+ await EventUtils.synthesizeMouseAtCenter(loginButton, {}, content);
+ });
+
+ let portalTab = await portalTabPromise;
+ is(
+ gBrowser.selectedTab,
+ portalTab,
+ "Captive Portal login page is now open in a new foreground tab."
+ );
+
+ return portalTab;
+}
diff --git a/browser/base/content/test/chrome/chrome.ini b/browser/base/content/test/chrome/chrome.ini
new file mode 100644
index 0000000000..9882f4b647
--- /dev/null
+++ b/browser/base/content/test/chrome/chrome.ini
@@ -0,0 +1,4 @@
+[DEFAULT]
+
+[test_aboutCrashed.xhtml]
+[test_aboutRestartRequired.xhtml]
diff --git a/browser/base/content/test/chrome/test_aboutCrashed.xhtml b/browser/base/content/test/chrome/test_aboutCrashed.xhtml
new file mode 100644
index 0000000000..0e2ef64f9b
--- /dev/null
+++ b/browser/base/content/test/chrome/test_aboutCrashed.xhtml
@@ -0,0 +1,77 @@
+<?xml version="1.0"?>
+<!-- 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/. -->
+
+<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?>
+<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?>
+
+<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js" />
+ <iframe type="content" id="frame1"/>
+ <iframe type="content" id="frame2" onload="doTest()"/>
+ <script type="application/javascript"><![CDATA[
+ SimpleTest.waitForExplicitFinish();
+
+ // Load error pages do not fire "load" events, so let's use a progressListener.
+ function waitForErrorPage(frame) {
+ return new Promise(resolve => {
+ let progressListener = {
+ onLocationChange(aWebProgress, aRequest, aLocation, aFlags) {
+ if (aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_ERROR_PAGE) {
+ frame.docShell.QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIWebProgress)
+ .removeProgressListener(progressListener,
+ Ci.nsIWebProgress.NOTIFY_LOCATION);
+
+ resolve();
+ }
+ },
+
+ QueryInterface: ChromeUtils.generateQI(["nsIWebProgressListener",
+ "nsISupportsWeakReference"])
+ };
+
+ frame.docShell.QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIWebProgress)
+ .addProgressListener(progressListener,
+ Ci.nsIWebProgress.NOTIFY_LOCATION);
+ });
+ }
+
+ function doTest() {
+ (async function testBody() {
+ let frame1 = document.getElementById("frame1");
+ let frame2 = document.getElementById("frame2");
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ let uri1 = Services.io.newURI("http://www.example.com/1");
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ let uri2 = Services.io.newURI("http://www.example.com/2");
+
+ let errorPageReady = waitForErrorPage(frame1);
+ frame1.docShell.chromeEventHandler.setAttribute("crashedPageTitle", "pageTitle");
+ frame1.docShell.displayLoadError(Cr.NS_ERROR_CONTENT_CRASHED, uri1, null);
+
+ await errorPageReady;
+ frame1.docShell.chromeEventHandler.removeAttribute("crashedPageTitle");
+
+ SimpleTest.is(frame1.contentDocument.documentURI,
+ "about:tabcrashed?e=tabcrashed&u=http%3A//www.example.com/1&c=UTF-8&d=pageTitle",
+ "Correct about:tabcrashed displayed for page with title.");
+
+ errorPageReady = waitForErrorPage(frame2);
+ frame2.docShell.displayLoadError(Cr.NS_ERROR_CONTENT_CRASHED, uri2, null);
+
+ await errorPageReady;
+
+ SimpleTest.is(frame2.contentDocument.documentURI,
+ "about:tabcrashed?e=tabcrashed&u=http%3A//www.example.com/2&c=UTF-8&d=%20",
+ "Correct about:tabcrashed displayed for page with no title.");
+
+ SimpleTest.finish();
+ })().catch(ex => SimpleTest.ok(false, ex));
+ }
+ ]]></script>
+
+ <body xmlns="http://www.w3.org/1999/xhtml" style="height: 300px; overflow: auto;" />
+</window>
diff --git a/browser/base/content/test/chrome/test_aboutRestartRequired.xhtml b/browser/base/content/test/chrome/test_aboutRestartRequired.xhtml
new file mode 100644
index 0000000000..9745e8e935
--- /dev/null
+++ b/browser/base/content/test/chrome/test_aboutRestartRequired.xhtml
@@ -0,0 +1,76 @@
+<?xml version="1.0"?>
+<!-- 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/. -->
+
+<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?>
+<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?>
+
+<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js" />
+ <iframe type="content" id="frame1"/>
+ <iframe type="content" id="frame2" onload="doTest()"/>
+ <script type="application/javascript"><![CDATA[
+ SimpleTest.waitForExplicitFinish();
+
+ // Load error pages do not fire "load" events, so let's use a progressListener.
+ function waitForErrorPage(frame) {
+ return new Promise(resolve => {
+ let progressListener = {
+ onLocationChange(aWebProgress, aRequest, aLocation, aFlags) {
+ if (aFlags & Ci.nsIWebProgressListener.LOCATION_CHANGE_ERROR_PAGE) {
+ frame.docShell.QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIWebProgress)
+ .removeProgressListener(progressListener,
+ Ci.nsIWebProgress.NOTIFY_LOCATION);
+
+ resolve();
+ }
+ },
+
+ QueryInterface: ChromeUtils.generateQI(["nsIWebProgressListener",
+ "nsISupportsWeakReference"])
+ };
+
+ frame.docShell.QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIWebProgress)
+ .addProgressListener(progressListener,
+ Ci.nsIWebProgress.NOTIFY_LOCATION);
+ });
+ }
+
+ function doTest() {
+ (async function testBody() {
+ let frame1 = document.getElementById("frame1");
+ let frame2 = document.getElementById("frame2");
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ let uri1 = Services.io.newURI("http://www.example.com/1");
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ let uri2 = Services.io.newURI("http://www.example.com/2");
+
+ let errorPageReady = waitForErrorPage(frame1);
+ frame1.docShell.displayLoadError(Cr.NS_ERROR_BUILDID_MISMATCH, uri1, null);
+
+ await errorPageReady;
+ frame1.docShell.chromeEventHandler.removeAttribute("crashedPageTitle");
+
+ SimpleTest.is(frame1.contentDocument.documentURI,
+ "about:restartrequired?e=restartrequired&u=http%3A//www.example.com/1&c=UTF-8&d=%20",
+ "Correct about:restartrequired displayed for page with title.");
+
+ errorPageReady = waitForErrorPage(frame2);
+ frame2.docShell.displayLoadError(Cr.NS_ERROR_BUILDID_MISMATCH, uri2, null);
+
+ await errorPageReady;
+
+ SimpleTest.is(frame2.contentDocument.documentURI,
+ "about:restartrequired?e=restartrequired&u=http%3A//www.example.com/2&c=UTF-8&d=%20",
+ "Correct about:restartrequired displayed for page with no title.");
+
+ SimpleTest.finish();
+ })().catch(ex => SimpleTest.ok(false, ex));
+ }
+ ]]></script>
+
+ <body xmlns="http://www.w3.org/1999/xhtml" style="height: 300px; overflow: auto;" />
+</window>
diff --git a/browser/base/content/test/contentTheme/browser.ini b/browser/base/content/test/contentTheme/browser.ini
new file mode 100644
index 0000000000..a7c859a1b3
--- /dev/null
+++ b/browser/base/content/test/contentTheme/browser.ini
@@ -0,0 +1,3 @@
+[DEFAULT]
+
+[browser_contentTheme_in_process_tab.js]
diff --git a/browser/base/content/test/contentTheme/browser_contentTheme_in_process_tab.js b/browser/base/content/test/contentTheme/browser_contentTheme_in_process_tab.js
new file mode 100644
index 0000000000..c53e178bc5
--- /dev/null
+++ b/browser/base/content/test/contentTheme/browser_contentTheme_in_process_tab.js
@@ -0,0 +1,80 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that tabs running in the parent process can hear about updates
+ * to lightweight themes via contentTheme.js.
+ *
+ * The test loads the History Sidebar document in a tab to avoid having
+ * to create a special parent-process page for the LightweightTheme
+ * JSWindow actors.
+ */
+add_task(async function test_in_process_tab() {
+ let win = await BrowserTestUtils.openNewBrowserWindow();
+ const IN_PROCESS_URI = "chrome://browser/content/places/historySidebar.xhtml";
+ const SIDEBAR_BGCOLOR = "rgb(255, 0, 0)";
+ // contentTheme.js will always convert the sidebar text color to rgba, so
+ // we need to compare against that.
+ const SIDEBAR_TEXT_COLOR = "rgba(0, 255, 0, 1)";
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser: win.gBrowser,
+ url: IN_PROCESS_URI,
+ },
+ async browser => {
+ await SpecialPowers.spawn(
+ browser,
+ [SIDEBAR_BGCOLOR, SIDEBAR_TEXT_COLOR],
+ async (bgColor, textColor) => {
+ let style = content.document.documentElement.style;
+ Assert.notEqual(
+ style.getPropertyValue("--lwt-sidebar-background-color"),
+ bgColor
+ );
+ Assert.notEqual(
+ style.getPropertyValue("--lwt-sidebar-text-color"),
+ textColor
+ );
+ }
+ );
+
+ // Now cobble together a very simple theme that sets the sidebar background
+ // and text color.
+ let lwtData = {
+ theme: {
+ sidebar: SIDEBAR_BGCOLOR,
+ sidebar_text: SIDEBAR_TEXT_COLOR,
+ },
+ darkTheme: null,
+ window: win.docShell.outerWindowID,
+ };
+
+ Services.obs.notifyObservers(lwtData, "lightweight-theme-styling-update");
+
+ await SpecialPowers.spawn(
+ browser,
+ [SIDEBAR_BGCOLOR, SIDEBAR_TEXT_COLOR],
+ async (bgColor, textColor) => {
+ let style = content.document.documentElement.style;
+ Assert.equal(
+ style.getPropertyValue("--lwt-sidebar-background-color"),
+ bgColor,
+ "The sidebar background text color should have been set by " +
+ "contentTheme.js"
+ );
+ Assert.equal(
+ style.getPropertyValue("--lwt-sidebar-text-color"),
+ textColor,
+ "The sidebar background text color should have been set by " +
+ "contentTheme.js"
+ );
+ }
+ );
+ }
+ );
+
+ await BrowserTestUtils.closeWindow(win);
+});
diff --git a/browser/base/content/test/contextMenu/browser.ini b/browser/base/content/test/contextMenu/browser.ini
new file mode 100644
index 0000000000..df0a013059
--- /dev/null
+++ b/browser/base/content/test/contextMenu/browser.ini
@@ -0,0 +1,91 @@
+[DEFAULT]
+support-files =
+ subtst_contextmenu_webext.html
+ test_contextmenu_links.html
+ subtst_contextmenu.html
+ subtst_contextmenu_input.html
+ subtst_contextmenu_keyword.html
+ subtst_contextmenu_xul.xhtml
+ ctxmenu-image.png
+ ../general/head.js
+ ../general/video.ogg
+ ../general/audio.ogg
+ ../../../../../toolkit/components/pdfjs/test/file_pdfjs_test.pdf
+ contextmenu_common.js
+ file_bug1798178.html
+ bug1798178.sjs
+
+[browser_bug1798178.js]
+[browser_contextmenu.js]
+tags = fullscreen
+skip-if =
+ os == "linux"
+ verify
+[browser_contextmenu_badiframe.js]
+https_first_disabled = true
+skip-if =
+ os == "win" # Bug 1719856
+ os == "linux" && socketprocess_networking
+[browser_contextmenu_contenteditable.js]
+[browser_contextmenu_iframe.js]
+support-files =
+ test_contextmenu_iframe.html
+skip-if =
+ os == "linux" && socketprocess_networking
+[browser_contextmenu_input.js]
+skip-if =
+ os == "linux"
+[browser_contextmenu_inspect.js]
+skip-if =
+ os == "linux" && socketprocess_networking
+[browser_contextmenu_keyword.js]
+skip-if =
+ os == "linux" # disabled on Linux due to bug 513558
+[browser_contextmenu_linkopen.js]
+skip-if =
+ os == "linux" && socketprocess_networking
+[browser_contextmenu_loadblobinnewtab.js]
+support-files = browser_contextmenu_loadblobinnewtab.html
+skip-if =
+ os == "linux" && socketprocess_networking
+[browser_contextmenu_save_blocked.js]
+skip-if =
+ os == "linux" && socketprocess_networking
+[browser_contextmenu_share_macosx.js]
+support-files =
+ browser_contextmenu_shareurl.html
+run-if =
+ os == "mac"
+[browser_contextmenu_share_win.js]
+https_first_disabled = true
+support-files =
+ browser_contextmenu_shareurl.html
+run-if =
+ os == "win"
+[browser_contextmenu_spellcheck.js]
+https_first_disabled = true
+skip-if =
+ os == "linux"
+ debug # bug 1798233 - this trips assertions that seem harmless in opt and unlikely to occur in practical use.
+[browser_contextmenu_touch.js]
+skip-if = true # Bug 1424433, disable due to very high frequency failure rate also on Windows 10
+[browser_copy_image_link.js]
+support-files =
+ doggy.png
+ firebird.png
+ firebird.png^headers^
+skip-if =
+ os == "linux" && socketprocess_networking
+[browser_strip_on_share_link.js]
+[browser_utilityOverlay.js]
+https_first_disabled = true
+skip-if =
+ os == "linux" && socketprocess_networking
+[browser_utilityOverlayPrincipal.js]
+https_first_disabled = true
+[browser_view_image.js]
+support-files =
+ test_view_image_revoked_cached_blob.html
+ test_view_image_inline_svg.html
+skip-if =
+ os == "linux" && socketprocess_networking
diff --git a/browser/base/content/test/contextMenu/browser_bug1798178.js b/browser/base/content/test/contextMenu/browser_bug1798178.js
new file mode 100644
index 0000000000..529665a6f9
--- /dev/null
+++ b/browser/base/content/test/contextMenu/browser_bug1798178.js
@@ -0,0 +1,89 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Services.scriptloader.loadSubScript(
+ "chrome://mochitests/content/browser/toolkit/content/tests/browser/common/mockTransfer.js",
+ this
+);
+
+const TEST_URL =
+ getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+ ) + "file_bug1798178.html";
+
+let MockFilePicker = SpecialPowers.MockFilePicker;
+MockFilePicker.init(window);
+
+function createTemporarySaveDirectory() {
+ let saveDir = Services.dirsvc.get("TmpD", Ci.nsIFile);
+ saveDir.append("testsavedir");
+ if (!saveDir.exists()) {
+ info("create testsavedir!");
+ saveDir.create(Ci.nsIFile.DIRECTORY_TYPE, 0o755);
+ }
+ info("return from createTempSaveDir: " + saveDir.path);
+ return saveDir;
+}
+
+add_task(async function test_save_link_cross_origin() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.opaqueResponseBlocking", true]],
+ });
+ await BrowserTestUtils.withNewTab(TEST_URL, async browser => {
+ let menu = document.getElementById("contentAreaContextMenu");
+ let popupShown = BrowserTestUtils.waitForEvent(menu, "popupshown");
+ BrowserTestUtils.synthesizeMouseAtCenter(
+ "a[href]",
+ { type: "contextmenu", button: 2 },
+ browser
+ );
+ await popupShown;
+
+ let filePickerShow = new Promise(r => {
+ MockFilePicker.showCallback = function (fp) {
+ ok(true, "filepicker should be shown");
+ info("MockFilePicker showCallback");
+
+ let fileName = fp.defaultString;
+ destFile = tempDir.clone();
+ destFile.append(fileName);
+
+ MockFilePicker.setFiles([destFile]);
+ MockFilePicker.filterIndex = 1; // kSaveAsType_URL
+
+ info("MockFilePicker showCallback done");
+ r();
+ };
+ });
+
+ info("Let's create a temporary dir");
+ let tempDir = createTemporarySaveDirectory();
+ let destFile;
+
+ MockFilePicker.displayDirectory = tempDir;
+
+ let transferCompletePromise = new Promise(resolve => {
+ function onTransferComplete(downloadSuccess) {
+ ok(downloadSuccess, "File should have been downloaded successfully");
+ resolve();
+ }
+ mockTransferCallback = onTransferComplete;
+ mockTransferRegisterer.register();
+ });
+
+ let saveLinkCommand = document.getElementById("context-savelink");
+ info("saveLinkCommand: " + saveLinkCommand);
+ saveLinkCommand.doCommand();
+
+ await filePickerShow;
+
+ let popupHiddenPromise = BrowserTestUtils.waitForEvent(menu, "popuphidden");
+ menu.hidePopup();
+ await popupHiddenPromise;
+
+ await transferCompletePromise;
+ });
+});
diff --git a/browser/base/content/test/contextMenu/browser_contextmenu.js b/browser/base/content/test/contextMenu/browser_contextmenu.js
new file mode 100644
index 0000000000..03a848f26d
--- /dev/null
+++ b/browser/base/content/test/contextMenu/browser_contextmenu.js
@@ -0,0 +1,1943 @@
+"use strict";
+
+let contextMenu;
+let LOGIN_FILL_ITEMS = ["---", null, "manage-saved-logins", true];
+let NAVIGATION_ITEMS =
+ AppConstants.platform == "macosx"
+ ? [
+ "context-back",
+ false,
+ "context-forward",
+ false,
+ "context-reload",
+ true,
+ "---",
+ null,
+ "context-bookmarkpage",
+ true,
+ ]
+ : [
+ "context-navigation",
+ null,
+ [
+ "context-back",
+ false,
+ "context-forward",
+ false,
+ "context-reload",
+ true,
+ "context-bookmarkpage",
+ true,
+ ],
+ null,
+ "---",
+ null,
+ ];
+let hasPocket = Services.prefs.getBoolPref("extensions.pocket.enabled");
+let hasContainers =
+ Services.prefs.getBoolPref("privacy.userContext.enabled") &&
+ ContextualIdentityService.getPublicIdentities().length;
+
+const example_base =
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com/browser/browser/base/content/test/contextMenu/";
+const chrome_base =
+ "chrome://mochitests/content/browser/browser/base/content/test/contextMenu/";
+const head_base =
+ "chrome://mochitests/content/browser/browser/base/content/test/contextMenu/";
+
+/* import-globals-from contextmenu_common.js */
+Services.scriptloader.loadSubScript(
+ chrome_base + "contextmenu_common.js",
+ this
+);
+
+function getThisFrameSubMenu(base_menu) {
+ if (AppConstants.NIGHTLY_BUILD) {
+ let osPidItem = ["context-frameOsPid", false];
+ base_menu = base_menu.concat(osPidItem);
+ }
+ return base_menu;
+}
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.search.separatePrivateDefault.ui.enabled", true],
+ ["extensions.screenshots.disabled", false],
+ ["layout.forms.reveal-password-context-menu.enabled", true],
+ ],
+ });
+});
+
+// Below are test cases for XUL element
+add_task(async function test_xul_text_link_label() {
+ let url = chrome_base + "subtst_contextmenu_xul.xhtml";
+
+ await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ url,
+ waitForLoad: true,
+ waitForStateStop: true,
+ });
+
+ await test_contextmenu("#test-xul-text-link-label", [
+ "context-openlinkintab",
+ true,
+ ...(hasContainers ? ["context-openlinkinusercontext-menu", true] : []),
+ // We need a blank entry here because the containers submenu is
+ // dynamically generated with no ids.
+ ...(hasContainers ? ["", null] : []),
+ "context-openlink",
+ true,
+ "context-openlinkprivate",
+ true,
+ "---",
+ null,
+ "context-bookmarklink",
+ true,
+ "context-savelink",
+ true,
+ ...(hasPocket ? ["context-savelinktopocket", true] : []),
+ "context-copylink",
+ true,
+ "---",
+ null,
+ "context-searchselect",
+ true,
+ "context-searchselect-private",
+ true,
+ ]);
+
+ // Clean up so won't affect HTML element test cases.
+ lastElementSelector = null;
+ gBrowser.removeCurrentTab();
+});
+
+// Below are test cases for HTML element.
+
+add_task(async function test_setup_html() {
+ let url = example_base + "subtst_contextmenu.html";
+
+ await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () {
+ let doc = content.document;
+ let audioIframe = doc.querySelector("#test-audio-in-iframe");
+ // media documents always use a <video> tag.
+ let audio = audioIframe.contentDocument.querySelector("video");
+ let videoIframe = doc.querySelector("#test-video-in-iframe");
+ let video = videoIframe.contentDocument.querySelector("video");
+
+ audio.loop = true;
+ audio.src = "audio.ogg";
+ video.loop = true;
+ video.src = "video.ogg";
+
+ let awaitPause = ContentTaskUtils.waitForEvent(audio, "pause");
+ await ContentTaskUtils.waitForCondition(
+ () => !audio.paused,
+ "Making sure audio is playing before calling pause"
+ );
+ audio.pause();
+ await awaitPause;
+
+ awaitPause = ContentTaskUtils.waitForEvent(video, "pause");
+ await ContentTaskUtils.waitForCondition(
+ () => !video.paused,
+ "Making sure video is playing before calling pause"
+ );
+ video.pause();
+ await awaitPause;
+ });
+});
+
+let plainTextItems;
+add_task(async function test_plaintext() {
+ await test_contextmenu("#test-text", [
+ ...NAVIGATION_ITEMS,
+ "context-savepage",
+ true,
+ ...(hasPocket ? ["context-pocket", true] : []),
+ "context-selectall",
+ true,
+ "---",
+ null,
+ "context-take-screenshot",
+ true,
+ "---",
+ null,
+ "context-viewsource",
+ true,
+ ]);
+});
+
+const kLinkItems = [
+ "context-openlinkintab",
+ true,
+ ...(hasContainers ? ["context-openlinkinusercontext-menu", true] : []),
+ // We need a blank entry here because the containers submenu is
+ // dynamically generated with no ids.
+ ...(hasContainers ? ["", null] : []),
+ "context-openlink",
+ true,
+ "context-openlinkprivate",
+ true,
+ "---",
+ null,
+ "context-bookmarklink",
+ true,
+ "context-savelink",
+ true,
+ ...(hasPocket ? ["context-savelinktopocket", true] : []),
+ "context-copylink",
+ true,
+ "---",
+ null,
+ "context-searchselect",
+ true,
+ "context-searchselect-private",
+ true,
+];
+
+add_task(async function test_link() {
+ await test_contextmenu("#test-link", kLinkItems);
+});
+
+add_task(async function test_link_in_shadow_dom() {
+ await test_contextmenu("#shadow-host", kLinkItems, {
+ offsetX: 6,
+ offsetY: 6,
+ });
+});
+
+add_task(async function test_link_over_shadow_dom() {
+ await test_contextmenu("#shadow-host-in-link", kLinkItems, {
+ offsetX: 6,
+ offsetY: 6,
+ });
+});
+
+add_task(async function test_mailto() {
+ await test_contextmenu("#test-mailto", [
+ "context-copyemail",
+ true,
+ "---",
+ null,
+ "context-searchselect",
+ true,
+ "context-searchselect-private",
+ true,
+ ]);
+});
+
+add_task(async function test_tel() {
+ await test_contextmenu("#test-tel", [
+ "context-copyphone",
+ true,
+ "---",
+ null,
+ "context-searchselect",
+ true,
+ "context-searchselect-private",
+ true,
+ ]);
+});
+
+add_task(async function test_image() {
+ for (let selector of ["#test-image", "#test-svg-image"]) {
+ await test_contextmenu(
+ selector,
+ [
+ "context-viewimage",
+ true,
+ "context-saveimage",
+ true,
+ "context-copyimage-contents",
+ true,
+ "context-copyimage",
+ true,
+ "context-sendimage",
+ true,
+ ...getTextRecognitionItems(),
+ ...(Services.prefs.getBoolPref("browser.menu.showViewImageInfo", false)
+ ? ["context-viewimageinfo", true]
+ : []),
+ "---",
+ null,
+ "context-setDesktopBackground",
+ true,
+ ],
+ {
+ onContextMenuShown() {
+ is(
+ typeof gContextMenu.imageInfo.height,
+ "number",
+ "Should have height"
+ );
+ is(
+ typeof gContextMenu.imageInfo.width,
+ "number",
+ "Should have width"
+ );
+ },
+ }
+ );
+ }
+});
+
+add_task(async function test_canvas() {
+ await test_contextmenu("#test-canvas", [
+ "context-viewimage",
+ true,
+ "context-saveimage",
+ true,
+ "---",
+ null,
+ "context-selectall",
+ true,
+ "---",
+ null,
+ "context-take-screenshot",
+ true,
+ ]);
+});
+
+add_task(async function test_video_ok() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["media.videocontrols.picture-in-picture.enabled", true]],
+ });
+
+ await test_contextmenu("#test-video-ok", [
+ "context-media-play",
+ true,
+ "context-media-mute",
+ true,
+ "context-media-playbackrate",
+ null,
+ [
+ "context-media-playbackrate-050x",
+ true,
+ "context-media-playbackrate-100x",
+ true,
+ "context-media-playbackrate-125x",
+ true,
+ "context-media-playbackrate-150x",
+ true,
+ "context-media-playbackrate-200x",
+ true,
+ ],
+ null,
+ "context-media-loop",
+ true,
+ "context-video-fullscreen",
+ true,
+ "context-media-hidecontrols",
+ true,
+ "---",
+ null,
+ "context-viewvideo",
+ true,
+ "context-video-pictureinpicture",
+ true,
+ "---",
+ null,
+ "context-video-saveimage",
+ true,
+ "context-savevideo",
+ true,
+ "context-copyvideourl",
+ true,
+ "context-sendvideo",
+ true,
+ ]);
+
+ await SpecialPowers.popPrefEnv();
+
+ await SpecialPowers.pushPrefEnv({
+ set: [["media.videocontrols.picture-in-picture.enabled", false]],
+ });
+
+ await test_contextmenu("#test-video-ok", [
+ "context-media-play",
+ true,
+ "context-media-mute",
+ true,
+ "context-media-playbackrate",
+ null,
+ [
+ "context-media-playbackrate-050x",
+ true,
+ "context-media-playbackrate-100x",
+ true,
+ "context-media-playbackrate-125x",
+ true,
+ "context-media-playbackrate-150x",
+ true,
+ "context-media-playbackrate-200x",
+ true,
+ ],
+ null,
+ "context-media-loop",
+ true,
+ "context-video-fullscreen",
+ true,
+ "context-media-hidecontrols",
+ true,
+ "---",
+ null,
+ "context-viewvideo",
+ true,
+ "---",
+ null,
+ "context-video-saveimage",
+ true,
+ "context-savevideo",
+ true,
+ "context-copyvideourl",
+ true,
+ "context-sendvideo",
+ true,
+ ]);
+
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function test_audio_in_video() {
+ await test_contextmenu("#test-audio-in-video", [
+ "context-media-play",
+ true,
+ "context-media-mute",
+ true,
+ "context-media-playbackrate",
+ null,
+ [
+ "context-media-playbackrate-050x",
+ true,
+ "context-media-playbackrate-100x",
+ true,
+ "context-media-playbackrate-125x",
+ true,
+ "context-media-playbackrate-150x",
+ true,
+ "context-media-playbackrate-200x",
+ true,
+ ],
+ null,
+ "context-media-loop",
+ true,
+ "context-media-showcontrols",
+ true,
+ "---",
+ null,
+ "context-saveaudio",
+ true,
+ "context-copyaudiourl",
+ true,
+ "context-sendaudio",
+ true,
+ ]);
+});
+
+add_task(async function test_video_bad() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["media.videocontrols.picture-in-picture.enabled", true]],
+ });
+
+ await test_contextmenu("#test-video-bad", [
+ "context-media-play",
+ false,
+ "context-media-mute",
+ false,
+ "context-media-playbackrate",
+ null,
+ [
+ "context-media-playbackrate-050x",
+ false,
+ "context-media-playbackrate-100x",
+ false,
+ "context-media-playbackrate-125x",
+ false,
+ "context-media-playbackrate-150x",
+ false,
+ "context-media-playbackrate-200x",
+ false,
+ ],
+ null,
+ "context-media-loop",
+ true,
+ "context-video-fullscreen",
+ false,
+ "context-media-hidecontrols",
+ false,
+ "---",
+ null,
+ "context-viewvideo",
+ true,
+ "---",
+ null,
+ "context-video-saveimage",
+ false,
+ "context-savevideo",
+ true,
+ "context-copyvideourl",
+ true,
+ "context-sendvideo",
+ true,
+ ]);
+
+ await SpecialPowers.popPrefEnv();
+
+ await SpecialPowers.pushPrefEnv({
+ set: [["media.videocontrols.picture-in-picture.enabled", false]],
+ });
+
+ await test_contextmenu("#test-video-bad", [
+ "context-media-play",
+ false,
+ "context-media-mute",
+ false,
+ "context-media-playbackrate",
+ null,
+ [
+ "context-media-playbackrate-050x",
+ false,
+ "context-media-playbackrate-100x",
+ false,
+ "context-media-playbackrate-125x",
+ false,
+ "context-media-playbackrate-150x",
+ false,
+ "context-media-playbackrate-200x",
+ false,
+ ],
+ null,
+ "context-media-loop",
+ true,
+ "context-video-fullscreen",
+ false,
+ "context-media-hidecontrols",
+ false,
+ "---",
+ null,
+ "context-viewvideo",
+ true,
+ "---",
+ null,
+ "context-video-saveimage",
+ false,
+ "context-savevideo",
+ true,
+ "context-copyvideourl",
+ true,
+ "context-sendvideo",
+ true,
+ ]);
+
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function test_video_bad2() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["media.videocontrols.picture-in-picture.enabled", true]],
+ });
+
+ await test_contextmenu("#test-video-bad2", [
+ "context-media-play",
+ false,
+ "context-media-mute",
+ false,
+ "context-media-playbackrate",
+ null,
+ [
+ "context-media-playbackrate-050x",
+ false,
+ "context-media-playbackrate-100x",
+ false,
+ "context-media-playbackrate-125x",
+ false,
+ "context-media-playbackrate-150x",
+ false,
+ "context-media-playbackrate-200x",
+ false,
+ ],
+ null,
+ "context-media-loop",
+ true,
+ "context-video-fullscreen",
+ false,
+ "context-media-hidecontrols",
+ false,
+ "---",
+ null,
+ "context-viewvideo",
+ false,
+ "---",
+ null,
+ "context-video-saveimage",
+ false,
+ "context-savevideo",
+ false,
+ "context-copyvideourl",
+ false,
+ "context-sendvideo",
+ false,
+ ]);
+
+ await SpecialPowers.popPrefEnv();
+
+ await SpecialPowers.pushPrefEnv({
+ set: [["media.videocontrols.picture-in-picture.enabled", false]],
+ });
+
+ await test_contextmenu("#test-video-bad2", [
+ "context-media-play",
+ false,
+ "context-media-mute",
+ false,
+ "context-media-playbackrate",
+ null,
+ [
+ "context-media-playbackrate-050x",
+ false,
+ "context-media-playbackrate-100x",
+ false,
+ "context-media-playbackrate-125x",
+ false,
+ "context-media-playbackrate-150x",
+ false,
+ "context-media-playbackrate-200x",
+ false,
+ ],
+ null,
+ "context-media-loop",
+ true,
+ "context-video-fullscreen",
+ false,
+ "context-media-hidecontrols",
+ false,
+ "---",
+ null,
+ "context-viewvideo",
+ false,
+ "---",
+ null,
+ "context-video-saveimage",
+ false,
+ "context-savevideo",
+ false,
+ "context-copyvideourl",
+ false,
+ "context-sendvideo",
+ false,
+ ]);
+
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function test_iframe() {
+ await test_contextmenu("#test-iframe", [
+ ...NAVIGATION_ITEMS,
+ "context-savepage",
+ true,
+ ...(hasPocket ? ["context-pocket", true] : []),
+ "context-selectall",
+ true,
+ "---",
+ null,
+ "frame",
+ null,
+ getThisFrameSubMenu([
+ "context-showonlythisframe",
+ true,
+ "context-openframeintab",
+ true,
+ "context-openframe",
+ true,
+ "---",
+ null,
+ "context-reloadframe",
+ true,
+ "---",
+ null,
+ "context-bookmarkframe",
+ true,
+ "context-saveframe",
+ true,
+ "---",
+ null,
+ "context-printframe",
+ true,
+ "---",
+ null,
+ "context-take-frame-screenshot",
+ true,
+ "---",
+ null,
+ "context-viewframesource",
+ true,
+ "context-viewframeinfo",
+ true,
+ ]),
+ null,
+ "---",
+ null,
+ "context-viewsource",
+ true,
+ ]);
+});
+
+add_task(async function test_video_in_iframe() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["media.videocontrols.picture-in-picture.enabled", true]],
+ });
+
+ await test_contextmenu("#test-video-in-iframe", [
+ "context-media-play",
+ true,
+ "context-media-mute",
+ true,
+ "context-media-playbackrate",
+ null,
+ [
+ "context-media-playbackrate-050x",
+ true,
+ "context-media-playbackrate-100x",
+ true,
+ "context-media-playbackrate-125x",
+ true,
+ "context-media-playbackrate-150x",
+ true,
+ "context-media-playbackrate-200x",
+ true,
+ ],
+ null,
+ "context-media-loop",
+ true,
+ "context-video-fullscreen",
+ true,
+ "context-media-hidecontrols",
+ true,
+ "---",
+ null,
+ "context-viewvideo",
+ true,
+ "context-video-pictureinpicture",
+ true,
+ "---",
+ null,
+ "context-video-saveimage",
+ true,
+ "context-savevideo",
+ true,
+ "context-copyvideourl",
+ true,
+ "context-sendvideo",
+ true,
+ "---",
+ null,
+ "frame",
+ null,
+ getThisFrameSubMenu([
+ "context-showonlythisframe",
+ true,
+ "context-openframeintab",
+ true,
+ "context-openframe",
+ true,
+ "---",
+ null,
+ "context-reloadframe",
+ true,
+ "---",
+ null,
+ "context-bookmarkframe",
+ true,
+ "context-saveframe",
+ true,
+ "---",
+ null,
+ "context-printframe",
+ true,
+ "---",
+ null,
+ "context-viewframeinfo",
+ true,
+ ]),
+ null,
+ ]);
+
+ await SpecialPowers.popPrefEnv();
+
+ await SpecialPowers.pushPrefEnv({
+ set: [["media.videocontrols.picture-in-picture.enabled", false]],
+ });
+
+ await test_contextmenu("#test-video-in-iframe", [
+ "context-media-play",
+ true,
+ "context-media-mute",
+ true,
+ "context-media-playbackrate",
+ null,
+ [
+ "context-media-playbackrate-050x",
+ true,
+ "context-media-playbackrate-100x",
+ true,
+ "context-media-playbackrate-125x",
+ true,
+ "context-media-playbackrate-150x",
+ true,
+ "context-media-playbackrate-200x",
+ true,
+ ],
+ null,
+ "context-media-loop",
+ true,
+ "context-video-fullscreen",
+ true,
+ "context-media-hidecontrols",
+ true,
+ "---",
+ null,
+ "context-viewvideo",
+ true,
+ "---",
+ null,
+ "context-video-saveimage",
+ true,
+ "context-savevideo",
+ true,
+ "context-copyvideourl",
+ true,
+ "context-sendvideo",
+ true,
+ "---",
+ null,
+ "frame",
+ null,
+ getThisFrameSubMenu([
+ "context-showonlythisframe",
+ true,
+ "context-openframeintab",
+ true,
+ "context-openframe",
+ true,
+ "---",
+ null,
+ "context-reloadframe",
+ true,
+ "---",
+ null,
+ "context-bookmarkframe",
+ true,
+ "context-saveframe",
+ true,
+ "---",
+ null,
+ "context-printframe",
+ true,
+ "---",
+ null,
+ "context-viewframeinfo",
+ true,
+ ]),
+ null,
+ ]);
+
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function test_audio_in_iframe() {
+ await test_contextmenu("#test-audio-in-iframe", [
+ "context-media-play",
+ true,
+ "context-media-mute",
+ true,
+ "context-media-playbackrate",
+ null,
+ [
+ "context-media-playbackrate-050x",
+ true,
+ "context-media-playbackrate-100x",
+ true,
+ "context-media-playbackrate-125x",
+ true,
+ "context-media-playbackrate-150x",
+ true,
+ "context-media-playbackrate-200x",
+ true,
+ ],
+ null,
+ "context-media-loop",
+ true,
+ "---",
+ null,
+ "context-saveaudio",
+ true,
+ "context-copyaudiourl",
+ true,
+ "context-sendaudio",
+ true,
+ "---",
+ null,
+ "frame",
+ null,
+ getThisFrameSubMenu([
+ "context-showonlythisframe",
+ true,
+ "context-openframeintab",
+ true,
+ "context-openframe",
+ true,
+ "---",
+ null,
+ "context-reloadframe",
+ true,
+ "---",
+ null,
+ "context-bookmarkframe",
+ true,
+ "context-saveframe",
+ true,
+ "---",
+ null,
+ "context-printframe",
+ true,
+ "---",
+ null,
+ "context-viewframeinfo",
+ true,
+ ]),
+ null,
+ ]);
+});
+
+add_task(async function test_image_in_iframe() {
+ await test_contextmenu("#test-image-in-iframe", [
+ "context-viewimage",
+ true,
+ "context-saveimage",
+ true,
+ "context-copyimage-contents",
+ true,
+ "context-copyimage",
+ true,
+ "context-sendimage",
+ true,
+ ...getTextRecognitionItems(),
+ ...(Services.prefs.getBoolPref("browser.menu.showViewImageInfo", false)
+ ? ["context-viewimageinfo", true]
+ : []),
+ "---",
+ null,
+ "context-setDesktopBackground",
+ true,
+ "---",
+ null,
+ "frame",
+ null,
+ getThisFrameSubMenu([
+ "context-showonlythisframe",
+ true,
+ "context-openframeintab",
+ true,
+ "context-openframe",
+ true,
+ "---",
+ null,
+ "context-reloadframe",
+ true,
+ "---",
+ null,
+ "context-bookmarkframe",
+ true,
+ "context-saveframe",
+ true,
+ "---",
+ null,
+ "context-printframe",
+ true,
+ "---",
+ null,
+ "context-viewframeinfo",
+ true,
+ ]),
+ null,
+ ]);
+});
+
+add_task(async function test_pdf_viewer_in_iframe() {
+ await test_contextmenu(
+ "#test-pdf-viewer-in-frame",
+ [
+ ...NAVIGATION_ITEMS,
+ "context-savepage",
+ true,
+ ...(hasPocket ? ["context-pocket", true] : []),
+ "context-selectall",
+ true,
+ "---",
+ null,
+ "frame",
+ null,
+ getThisFrameSubMenu([
+ "context-showonlythisframe",
+ true,
+ "context-openframeintab",
+ true,
+ "context-openframe",
+ true,
+ "---",
+ null,
+ "context-reloadframe",
+ true,
+ "---",
+ null,
+ "context-bookmarkframe",
+ true,
+ "context-saveframe",
+ true,
+ "---",
+ null,
+ "context-printframe",
+ true,
+ "---",
+ null,
+ "context-take-frame-screenshot",
+ true,
+ "---",
+ null,
+ "context-viewframeinfo",
+ true,
+ ]),
+ null,
+ "---",
+ null,
+ "context-viewsource",
+ true,
+ ],
+ {
+ shiftkey: true,
+ }
+ );
+});
+
+add_task(async function test_textarea() {
+ // Disabled since this is seeing spell-check-enabled
+ // instead of spell-add-dictionaries-main
+ todo(false, "spell checker tests are failing, bug 1246296");
+
+ /*
+ yield test_contextmenu("#test-textarea",
+ ["context-undo", false,
+ "context-redo", false,
+ "---", null,
+ "context-cut", true,
+ "context-copy", true,
+ "context-paste", null,
+ "context-delete", false,
+ "---", null,
+ "context-selectall", true,
+ "---", null,
+ "spell-add-dictionaries-main", true,
+ ],
+ {
+ skipFocusChange: true,
+ }
+ );
+ */
+});
+
+add_task(async function test_textarea_spellcheck() {
+ todo(false, "spell checker tests are failing, bug 1246296");
+
+ /*
+ yield test_contextmenu("#test-textarea",
+ ["*chubbiness", true, // spelling suggestion
+ "spell-add-to-dictionary", true,
+ "---", null,
+ "context-undo", false,
+ "context-redo", false,
+ "---", null,
+ "context-cut", true,
+ "context-copy", true,
+ "context-paste", null, // ignore clipboard state
+ "context-delete", false,
+ "---", null,
+ "context-selectall", true,
+ "---", null,
+ "spell-check-enabled", true,
+ "spell-dictionaries", true,
+ ["spell-check-dictionary-en-US", true,
+ "---", null,
+ "spell-add-dictionaries", true], null
+ ],
+ {
+ waitForSpellCheck: true,
+ offsetX: 6,
+ offsetY: 6,
+ postCheckContextMenuFn() {
+ document.getElementById("spell-add-to-dictionary").doCommand();
+ }
+ }
+ );
+ */
+});
+
+add_task(async function test_plaintext2() {
+ await test_contextmenu("#test-text", plainTextItems);
+});
+
+add_task(async function test_undo_add_to_dictionary() {
+ todo(false, "spell checker tests are failing, bug 1246296");
+
+ /*
+ yield test_contextmenu("#test-textarea",
+ ["spell-undo-add-to-dictionary", true,
+ "---", null,
+ "context-undo", false,
+ "context-redo", false,
+ "---", null,
+ "context-cut", true,
+ "context-copy", true,
+ "context-paste", null, // ignore clipboard state
+ "context-delete", false,
+ "context-selectall", true,
+ "---", null,
+ "spell-check-enabled", true,
+ "spell-dictionaries", true,
+ ["spell-check-dictionary-en-US", true,
+ "---", null,
+ "spell-add-dictionaries", true], null
+ ],
+ {
+ waitForSpellCheck: true,
+ postCheckContextMenuFn() {
+ document.getElementById("spell-undo-add-to-dictionary")
+ .doCommand();
+ }
+ }
+ );
+ */
+});
+
+add_task(async function test_contenteditable() {
+ todo(false, "spell checker tests are failing, bug 1246296");
+
+ /*
+ yield test_contextmenu("#test-contenteditable",
+ ["spell-no-suggestions", false,
+ "spell-add-to-dictionary", true,
+ "---", null,
+ "context-undo", false,
+ "context-redo", false,
+ "---", null,
+ "context-cut", true,
+ "context-copy", true,
+ "context-paste", null, // ignore clipboard state
+ "context-delete", false,
+ "context-selectall", true,
+ "---", null,
+ "spell-check-enabled", true,
+ "spell-dictionaries", true,
+ ["spell-check-dictionary-en-US", true,
+ "---", null,
+ "spell-add-dictionaries", true], null
+ ],
+ {waitForSpellCheck: true}
+ );
+ */
+});
+
+add_task(async function test_copylinkcommand() {
+ await test_contextmenu("#test-link", null, {
+ async postCheckContextMenuFn() {
+ document.commandDispatcher
+ .getControllerForCommand("cmd_copyLink")
+ .doCommand("cmd_copyLink");
+
+ // The easiest way to check the clipboard is to paste the contents
+ // into a textbox.
+ await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [],
+ async function () {
+ let doc = content.document;
+ let input = doc.getElementById("test-input");
+ input.focus();
+ input.value = "";
+ }
+ );
+ document.commandDispatcher
+ .getControllerForCommand("cmd_paste")
+ .doCommand("cmd_paste");
+ await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [],
+ async function () {
+ let doc = content.document;
+ let input = doc.getElementById("test-input");
+ Assert.equal(
+ input.value,
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://mozilla.com/",
+ "paste for command cmd_paste"
+ );
+ // Don't keep focus, because that may affect clipboard commands in
+ // subsequently-opened menus.
+ input.blur();
+ }
+ );
+ },
+ });
+});
+
+add_task(async function test_dom_full_screen() {
+ let fullscreenItems = NAVIGATION_ITEMS.concat([
+ "context-leave-dom-fullscreen",
+ true,
+ "---",
+ null,
+ "context-savepage",
+ true,
+ ...(hasPocket ? ["context-pocket", true] : []),
+ "context-selectall",
+ true,
+ "---",
+ null,
+ "context-take-screenshot",
+ true,
+ "---",
+ null,
+ "context-viewsource",
+ true,
+ ]);
+ if (AppConstants.platform == "macosx") {
+ // Put the bookmarks item next to save page:
+ const bmPageIndex = fullscreenItems.indexOf("context-bookmarkpage");
+ let bmPageItems = fullscreenItems.splice(bmPageIndex, 2);
+ fullscreenItems.splice(
+ fullscreenItems.indexOf("context-savepage"),
+ 0,
+ ...bmPageItems
+ );
+ }
+ await test_contextmenu("#test-dom-full-screen", fullscreenItems, {
+ shiftkey: true,
+ async preCheckContextMenuFn() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["full-screen-api.allow-trusted-requests-only", false],
+ ["full-screen-api.transition-duration.enter", "0 0"],
+ ["full-screen-api.transition-duration.leave", "0 0"],
+ ],
+ });
+ await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [],
+ async function () {
+ let doc = content.document;
+ let win = doc.defaultView;
+ let full_screen_element = doc.getElementById("test-dom-full-screen");
+ let awaitFullScreenChange = ContentTaskUtils.waitForEvent(
+ win,
+ "fullscreenchange"
+ );
+ full_screen_element.requestFullscreen();
+ await awaitFullScreenChange;
+ }
+ );
+ },
+ async postCheckContextMenuFn() {
+ await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [],
+ async function () {
+ let win = content.document.defaultView;
+ let awaitFullScreenChange = ContentTaskUtils.waitForEvent(
+ win,
+ "fullscreenchange"
+ );
+ content.document.exitFullscreen();
+ await awaitFullScreenChange;
+ }
+ );
+ },
+ });
+});
+
+add_task(async function test_pagemenu2() {
+ await test_contextmenu(
+ "#test-text",
+ [
+ ...NAVIGATION_ITEMS,
+ "context-savepage",
+ true,
+ ...(hasPocket ? ["context-pocket", true] : []),
+ "context-selectall",
+ true,
+ "---",
+ null,
+ "context-take-screenshot",
+ true,
+ "---",
+ null,
+ "context-viewsource",
+ true,
+ ],
+ { shiftkey: true }
+ );
+});
+
+add_task(async function test_select_text() {
+ await test_contextmenu(
+ "#test-select-text",
+ [
+ "context-copy",
+ true,
+ "context-selectall",
+ true,
+ "context-print-selection",
+ true,
+ "---",
+ null,
+ "context-take-screenshot",
+ true,
+ "---",
+ null,
+ "context-searchselect",
+ true,
+ "context-searchselect-private",
+ true,
+ "---",
+ null,
+ "context-viewpartialsource-selection",
+ true,
+ ],
+ {
+ offsetX: 6,
+ offsetY: 6,
+ async preCheckContextMenuFn() {
+ await selectText("#test-select-text");
+ },
+ }
+ );
+});
+
+add_task(async function test_select_text_search_service_not_initialized() {
+ // Pretend the search service is not initialised.
+ Services.search.wrappedJSObject.forceInitializationStatusForTests(
+ "not initialized"
+ );
+ await test_contextmenu(
+ "#test-select-text",
+ [
+ "context-copy",
+ true,
+ "context-selectall",
+ true,
+ "context-print-selection",
+ true,
+ "---",
+ null,
+ "context-take-screenshot",
+ true,
+ "---",
+ null,
+ "context-viewpartialsource-selection",
+ true,
+ ],
+ {
+ offsetX: 6,
+ offsetY: 6,
+ async preCheckContextMenuFn() {
+ await selectText("#test-select-text");
+ },
+ }
+ );
+
+ // Restore the search service initialization status
+ Services.search.wrappedJSObject.forceInitializationStatusForTests("success");
+});
+
+add_task(async function test_select_text_link() {
+ await test_contextmenu(
+ "#test-select-text-link",
+ [
+ "context-openlinkincurrent",
+ true,
+ "context-openlinkintab",
+ true,
+ ...(hasContainers ? ["context-openlinkinusercontext-menu", true] : []),
+ // We need a blank entry here because the containers submenu is
+ // dynamically generated with no ids.
+ ...(hasContainers ? ["", null] : []),
+ "context-openlink",
+ true,
+ "context-openlinkprivate",
+ true,
+ "---",
+ null,
+ "context-bookmarklink",
+ true,
+ "context-savelink",
+ true,
+ "---",
+ null,
+ "context-copy",
+ true,
+ "context-selectall",
+ true,
+ "context-print-selection",
+ true,
+ "---",
+ null,
+ "context-searchselect",
+ true,
+ "context-searchselect-private",
+ true,
+ "---",
+ null,
+ "context-viewpartialsource-selection",
+ true,
+ ],
+ {
+ offsetX: 6,
+ offsetY: 6,
+ async preCheckContextMenuFn() {
+ await selectText("#test-select-text-link");
+ },
+ async postCheckContextMenuFn() {
+ await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [],
+ async function () {
+ let win = content.document.defaultView;
+ win.getSelection().removeAllRanges();
+ }
+ );
+ },
+ }
+ );
+});
+
+add_task(async function test_imagelink() {
+ await test_contextmenu("#test-image-link", [
+ "context-openlinkintab",
+ true,
+ ...(hasContainers ? ["context-openlinkinusercontext-menu", true] : []),
+ // We need a blank entry here because the containers submenu is
+ // dynamically generated with no ids.
+ ...(hasContainers ? ["", null] : []),
+ "context-openlink",
+ true,
+ "context-openlinkprivate",
+ true,
+ "---",
+ null,
+ "context-bookmarklink",
+ true,
+ "context-savelink",
+ true,
+ ...(hasPocket ? ["context-savelinktopocket", true] : []),
+ "context-copylink",
+ true,
+ "---",
+ null,
+ "context-viewimage",
+ true,
+ "context-saveimage",
+ true,
+ "context-copyimage-contents",
+ true,
+ "context-copyimage",
+ true,
+ "context-sendimage",
+ true,
+ ...getTextRecognitionItems(),
+ ...(Services.prefs.getBoolPref("browser.menu.showViewImageInfo", false)
+ ? ["context-viewimageinfo", true]
+ : []),
+ "---",
+ null,
+ "context-setDesktopBackground",
+ true,
+ ]);
+});
+
+add_task(async function test_select_input_text() {
+ todo(false, "spell checker tests are failing, bug 1246296");
+
+ /*
+ yield test_contextmenu("#test-select-input-text",
+ ["context-undo", false,
+ "context-redo", false,
+ "---", null,
+ "context-cut", true,
+ "context-copy", true,
+ "context-paste", null, // ignore clipboard state
+ "context-delete", true,
+ "context-selectall", true,
+ "---", null,
+ "context-searchselect", true,
+ "context-searchselect-private", true,
+ "---", null,
+ "spell-check-enabled", true
+ ].concat(LOGIN_FILL_ITEMS),
+ {
+ *preCheckContextMenuFn() {
+ yield ContentTask.spawn(gBrowser.selectedBrowser, null, function*() {
+ let doc = content.document;
+ let win = doc.defaultView;
+ win.getSelection().removeAllRanges();
+ let element = doc.querySelector("#test-select-input-text");
+ element.select();
+ });
+ }
+ }
+ );
+ */
+});
+
+add_task(async function test_select_input_text_password() {
+ todo(false, "spell checker tests are failing, bug 1246296");
+
+ /*
+ yield test_contextmenu("#test-select-input-text-type-password",
+ ["context-undo", false,
+ "context-redo", false,
+ "---", null,
+ "context-cut", true,
+ "context-copy", true,
+ "context-paste", null, // ignore clipboard state
+ "context-delete", true,
+ "context-selectall", true,
+ "---", null,
+ "spell-check-enabled", true,
+ // spell checker is shown on input[type="password"] on this testcase
+ "spell-dictionaries", true,
+ ["spell-check-dictionary-en-US", true,
+ "---", null,
+ "spell-add-dictionaries", true], null
+ ].concat(LOGIN_FILL_ITEMS),
+ {
+ *preCheckContextMenuFn() {
+ yield ContentTask.spawn(gBrowser.selectedBrowser, null, function*() {
+ let doc = content.document;
+ let win = doc.defaultView;
+ win.getSelection().removeAllRanges();
+ let element = doc.querySelector("#test-select-input-text-type-password");
+ element.select();
+ });
+ },
+ *postCheckContextMenuFn() {
+ yield ContentTask.spawn(gBrowser.selectedBrowser, null, function*() {
+ let win = content.document.defaultView;
+ win.getSelection().removeAllRanges();
+ });
+ }
+ }
+ );
+ */
+});
+
+add_task(async function test_longdesc() {
+ await test_contextmenu("#test-longdesc", [
+ "context-viewimage",
+ true,
+ "context-saveimage",
+ true,
+ "context-copyimage-contents",
+ true,
+ "context-copyimage",
+ true,
+ "context-sendimage",
+ true,
+ ...getTextRecognitionItems(),
+ ...(Services.prefs.getBoolPref("browser.menu.showViewImageInfo", false)
+ ? ["context-viewimageinfo", true]
+ : []),
+ "context-viewimagedesc",
+ true,
+ "---",
+ null,
+ "context-setDesktopBackground",
+ true,
+ ]);
+});
+
+add_task(async function test_srcdoc() {
+ await test_contextmenu("#test-srcdoc", [
+ ...NAVIGATION_ITEMS,
+ "context-savepage",
+ true,
+ ...(hasPocket ? ["context-pocket", true] : []),
+ "context-selectall",
+ true,
+ "---",
+ null,
+ "frame",
+ null,
+ getThisFrameSubMenu([
+ "context-reloadframe",
+ true,
+ "---",
+ null,
+ "context-saveframe",
+ true,
+ "---",
+ null,
+ "context-printframe",
+ true,
+ "---",
+ null,
+ "context-take-frame-screenshot",
+ true,
+ "---",
+ null,
+ "context-viewframesource",
+ true,
+ "context-viewframeinfo",
+ true,
+ ]),
+ null,
+ "---",
+ null,
+ "context-viewsource",
+ true,
+ ]);
+});
+
+add_task(async function test_input_spell_false() {
+ todo(false, "spell checker tests are failing, bug 1246296");
+
+ /*
+ yield test_contextmenu("#test-contenteditable-spellcheck-false",
+ ["context-undo", false,
+ "context-redo", false,
+ "---", null,
+ "context-cut", true,
+ "context-copy", true,
+ "context-paste", null, // ignore clipboard state
+ "context-delete", false,
+ "context-selectall", true,
+ ]
+ );
+ */
+});
+
+add_task(async function test_svg_link() {
+ await test_contextmenu("#svg-with-link > a", [
+ "context-openlinkintab",
+ true,
+ ...(hasContainers ? ["context-openlinkinusercontext-menu", true] : []),
+ // We need a blank entry here because the containers submenu is
+ // dynamically generated with no ids.
+ ...(hasContainers ? ["", null] : []),
+ "context-openlink",
+ true,
+ "context-openlinkprivate",
+ true,
+ "---",
+ null,
+ "context-bookmarklink",
+ true,
+ "context-savelink",
+ true,
+ ...(hasPocket ? ["context-savelinktopocket", true] : []),
+ "context-copylink",
+ true,
+ "---",
+ null,
+ "context-searchselect",
+ true,
+ "context-searchselect-private",
+ true,
+ ]);
+
+ await test_contextmenu("#svg-with-link2 > a", [
+ "context-openlinkintab",
+ true,
+ ...(hasContainers ? ["context-openlinkinusercontext-menu", true] : []),
+ // We need a blank entry here because the containers submenu is
+ // dynamically generated with no ids.
+ ...(hasContainers ? ["", null] : []),
+ "context-openlink",
+ true,
+ "context-openlinkprivate",
+ true,
+ "---",
+ null,
+ "context-bookmarklink",
+ true,
+ "context-savelink",
+ true,
+ ...(hasPocket ? ["context-savelinktopocket", true] : []),
+ "context-copylink",
+ true,
+ "---",
+ null,
+ "context-searchselect",
+ true,
+ "context-searchselect-private",
+ true,
+ ]);
+
+ await test_contextmenu("#svg-with-link3 > a", [
+ "context-openlinkintab",
+ true,
+ ...(hasContainers ? ["context-openlinkinusercontext-menu", true] : []),
+ // We need a blank entry here because the containers submenu is
+ // dynamically generated with no ids.
+ ...(hasContainers ? ["", null] : []),
+ "context-openlink",
+ true,
+ "context-openlinkprivate",
+ true,
+ "---",
+ null,
+ "context-bookmarklink",
+ true,
+ "context-savelink",
+ true,
+ ...(hasPocket ? ["context-savelinktopocket", true] : []),
+ "context-copylink",
+ true,
+ "---",
+ null,
+ "context-searchselect",
+ true,
+ "context-searchselect-private",
+ true,
+ ]);
+});
+
+add_task(async function test_svg_relative_link() {
+ await test_contextmenu("#svg-with-relative-link > a", [
+ "context-openlinkintab",
+ true,
+ ...(hasContainers ? ["context-openlinkinusercontext-menu", true] : []),
+ // We need a blank entry here because the containers submenu is
+ // dynamically generated with no ids.
+ ...(hasContainers ? ["", null] : []),
+ "context-openlink",
+ true,
+ "context-openlinkprivate",
+ true,
+ "---",
+ null,
+ "context-bookmarklink",
+ true,
+ "context-savelink",
+ true,
+ ...(hasPocket ? ["context-savelinktopocket", true] : []),
+ "context-copylink",
+ true,
+ "---",
+ null,
+ "context-searchselect",
+ true,
+ "context-searchselect-private",
+ true,
+ ]);
+
+ await test_contextmenu("#svg-with-relative-link2 > a", [
+ "context-openlinkintab",
+ true,
+ ...(hasContainers ? ["context-openlinkinusercontext-menu", true] : []),
+ // We need a blank entry here because the containers submenu is
+ // dynamically generated with no ids.
+ ...(hasContainers ? ["", null] : []),
+ "context-openlink",
+ true,
+ "context-openlinkprivate",
+ true,
+ "---",
+ null,
+ "context-bookmarklink",
+ true,
+ "context-savelink",
+ true,
+ ...(hasPocket ? ["context-savelinktopocket", true] : []),
+ "context-copylink",
+ true,
+ "---",
+ null,
+ "context-searchselect",
+ true,
+ "context-searchselect-private",
+ true,
+ ]);
+
+ await test_contextmenu("#svg-with-relative-link3 > a", [
+ "context-openlinkintab",
+ true,
+ ...(hasContainers ? ["context-openlinkinusercontext-menu", true] : []),
+ // We need a blank entry here because the containers submenu is
+ // dynamically generated with no ids.
+ ...(hasContainers ? ["", null] : []),
+ "context-openlink",
+ true,
+ "context-openlinkprivate",
+ true,
+ "---",
+ null,
+ "context-bookmarklink",
+ true,
+ "context-savelink",
+ true,
+ ...(hasPocket ? ["context-savelinktopocket", true] : []),
+ "context-copylink",
+ true,
+ "---",
+ null,
+ "context-searchselect",
+ true,
+ "context-searchselect-private",
+ true,
+ ]);
+});
+
+add_task(async function test_background_image() {
+ let bgImageItems = [
+ "context-viewimage",
+ true,
+ "context-copyimage",
+ true,
+ "context-sendimage",
+ true,
+ "---",
+ null,
+ "context-savepage",
+ true,
+ ...(hasPocket ? ["context-pocket", true] : []),
+ "context-selectall",
+ true,
+ "---",
+ null,
+ "context-take-screenshot",
+ true,
+ "---",
+ null,
+ "context-viewsource",
+ true,
+ ];
+ if (AppConstants.platform == "macosx") {
+ // Back/fwd/(stop|reload) and their separator go before the image items,
+ // followed by the bookmark item which goes with save page - so we need
+ // to split up NAVIGATION_ITEMS and bgImageItems:
+ bgImageItems = [
+ ...NAVIGATION_ITEMS.slice(0, 8),
+ ...bgImageItems.slice(0, 8),
+ ...NAVIGATION_ITEMS.slice(8),
+ ...bgImageItems.slice(8),
+ ];
+ } else {
+ bgImageItems = NAVIGATION_ITEMS.concat(bgImageItems);
+ }
+ await test_contextmenu("#test-background-image", bgImageItems);
+
+ // Don't show image related context menu commands for links with background images.
+ await test_contextmenu("#test-background-image-link", [
+ "context-openlinkintab",
+ true,
+ ...(hasContainers ? ["context-openlinkinusercontext-menu", true] : []),
+ // We need a blank entry here because the containers submenu is
+ // dynamically generated with no ids.
+ ...(hasContainers ? ["", null] : []),
+ "context-openlink",
+ true,
+ "context-openlinkprivate",
+ true,
+ "---",
+ null,
+ "context-bookmarklink",
+ true,
+ "context-savelink",
+ true,
+ "context-copylink",
+ true,
+ "---",
+ null,
+ "context-searchselect",
+ true,
+ "context-searchselect-private",
+ true,
+ ]);
+
+ // Don't show image related context menu commands when there is a selection
+ // with background images.
+ await test_contextmenu(
+ "#test-background-image",
+ [
+ "context-copy",
+ true,
+ "context-selectall",
+ true,
+ "context-print-selection",
+ true,
+ "---",
+ null,
+ "context-take-screenshot",
+ true,
+ "---",
+ null,
+ "context-searchselect",
+ true,
+ "context-searchselect-private",
+ true,
+ "---",
+ null,
+ "context-viewpartialsource-selection",
+ true,
+ ],
+ {
+ async preCheckContextMenuFn() {
+ await selectText("#test-background-image");
+ },
+ }
+ );
+});
+
+add_task(async function test_cleanup_html() {
+ gBrowser.removeCurrentTab();
+});
+
+/**
+ * Selects the text of the element that matches the provided `selector`
+ *
+ * @param {String} selector
+ * A selector passed to querySelector to find
+ * the element that will be referenced.
+ */
+async function selectText(selector) {
+ await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [selector],
+ async function (contentSelector) {
+ info(`Selecting text of ${contentSelector}`);
+ let doc = content.document;
+ let win = doc.defaultView;
+ win.getSelection().removeAllRanges();
+ let div = doc.createRange();
+ let element = doc.querySelector(contentSelector);
+ Assert.ok(element, "Found element to select text from");
+ div.setStartBefore(element);
+ div.setEndAfter(element);
+ win.getSelection().addRange(div);
+ }
+ );
+}
+
+/**
+ * Not all platforms support text recognition.
+ * @returns {string[]}
+ */
+function getTextRecognitionItems() {
+ return Services.prefs.getBoolPref("dom.text-recognition.enabled") &&
+ Services.appinfo.isTextRecognitionSupported
+ ? ["context-imagetext", true]
+ : [];
+}
diff --git a/browser/base/content/test/contextMenu/browser_contextmenu_badiframe.js b/browser/base/content/test/contextMenu/browser_contextmenu_badiframe.js
new file mode 100644
index 0000000000..89e7fe15e0
--- /dev/null
+++ b/browser/base/content/test/contextMenu/browser_contextmenu_badiframe.js
@@ -0,0 +1,182 @@
+/* Tests for proper behaviour of "Show this frame" context menu options with a valid frame and
+ a frame with an invalid url.
+ */
+
+// Two frames, one with text content, the other an error page
+var invalidPage = "http://127.0.0.1:55555/";
+// eslint-disable-next-line @microsoft/sdl/no-insecure-url
+var validPage = "http://example.com/";
+var testPage =
+ 'data:text/html,<frameset cols="400,400"><frame src="' +
+ validPage +
+ '"><frame src="' +
+ invalidPage +
+ '"></frameset>';
+
+async function openTestPage() {
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:blank",
+ true,
+ true
+ );
+ let browser = tab.linkedBrowser;
+
+ // The test page has a top-level document and two subframes. One of
+ // those subframes is an error page, which doesn't fire a load event.
+ // We'll use BrowserTestUtils.browserLoaded and have it wait for all
+ // 3 loads before resolving.
+ let expectedLoads = 3;
+ let pageAndIframesLoaded = BrowserTestUtils.browserLoaded(
+ browser,
+ true /* includeSubFrames */,
+ url => {
+ expectedLoads--;
+ return !expectedLoads;
+ },
+ true /* maybeErrorPage */
+ );
+ BrowserTestUtils.loadURIString(browser, testPage);
+ await pageAndIframesLoaded;
+
+ // Make sure both the top-level document and the iframe documents have
+ // had a chance to present. We need this so that the context menu event
+ // gets dispatched properly.
+ for (let bc of [
+ ...browser.browsingContext.children,
+ browser.browsingContext,
+ ]) {
+ await SpecialPowers.spawn(bc, [], async function () {
+ await new Promise(resolve => {
+ content.requestAnimationFrame(resolve);
+ });
+ });
+ }
+}
+
+async function selectFromFrameMenu(frameNumber, menuId) {
+ const contextMenu = document.getElementById("contentAreaContextMenu");
+
+ let popupShownPromise = BrowserTestUtils.waitForEvent(
+ contextMenu,
+ "popupshown"
+ );
+
+ await BrowserTestUtils.synthesizeMouseAtPoint(
+ 40,
+ 40,
+ {
+ type: "contextmenu",
+ button: 2,
+ },
+ gBrowser.selectedBrowser.browsingContext.children[frameNumber]
+ );
+
+ await popupShownPromise;
+
+ let frameItem = document.getElementById("frame");
+ let framePopup = frameItem.menupopup;
+ let subPopupShownPromise = BrowserTestUtils.waitForEvent(
+ framePopup,
+ "popupshown"
+ );
+
+ frameItem.openMenu(true);
+ await subPopupShownPromise;
+
+ let subPopupHiddenPromise = BrowserTestUtils.waitForEvent(
+ framePopup,
+ "popuphidden"
+ );
+ let contextMenuHiddenPromise = BrowserTestUtils.waitForEvent(
+ contextMenu,
+ "popuphidden"
+ );
+ contextMenu.activateItem(document.getElementById(menuId));
+ await subPopupHiddenPromise;
+ await contextMenuHiddenPromise;
+}
+
+add_task(async function testOpenFrame() {
+ for (let frameNumber = 0; frameNumber < 2; frameNumber++) {
+ await openTestPage();
+
+ let expectedResultURI = [validPage, invalidPage][frameNumber];
+
+ info("show only this frame for " + expectedResultURI);
+
+ let browserLoadedPromise = BrowserTestUtils.browserLoaded(
+ gBrowser.selectedBrowser,
+ false,
+ expectedResultURI,
+ frameNumber == 1
+ );
+
+ await selectFromFrameMenu(frameNumber, "context-showonlythisframe");
+ await browserLoadedPromise;
+
+ is(
+ gBrowser.selectedBrowser.currentURI.spec,
+ expectedResultURI,
+ "Should navigate to page url, not about:neterror"
+ );
+
+ gBrowser.removeCurrentTab();
+ }
+});
+
+add_task(async function testOpenFrameInTab() {
+ for (let frameNumber = 0; frameNumber < 2; frameNumber++) {
+ await openTestPage();
+
+ let expectedResultURI = [validPage, invalidPage][frameNumber];
+
+ info("open frame in tab for " + expectedResultURI);
+
+ let newTabPromise = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ expectedResultURI,
+ false
+ );
+ await selectFromFrameMenu(frameNumber, "context-openframeintab");
+ let newTab = await newTabPromise;
+
+ await BrowserTestUtils.switchTab(gBrowser, newTab);
+
+ // We should now have the error page in a new, active tab.
+ is(
+ gBrowser.selectedBrowser.currentURI.spec,
+ expectedResultURI,
+ "New tab should have page url, not about:neterror"
+ );
+
+ gBrowser.removeCurrentTab();
+ gBrowser.removeCurrentTab();
+ }
+});
+
+add_task(async function testOpenFrameInWindow() {
+ for (let frameNumber = 0; frameNumber < 2; frameNumber++) {
+ await openTestPage();
+
+ let expectedResultURI = [validPage, invalidPage][frameNumber];
+
+ info("open frame in window for " + expectedResultURI);
+
+ let newWindowPromise = BrowserTestUtils.waitForNewWindow({
+ url: frameNumber == 1 ? invalidPage : validPage,
+ maybeErrorPage: frameNumber == 1,
+ });
+ await selectFromFrameMenu(frameNumber, "context-openframe");
+ let newWindow = await newWindowPromise;
+
+ is(
+ newWindow.gBrowser.selectedBrowser.currentURI.spec,
+ expectedResultURI,
+ "New window should have page url, not about:neterror"
+ );
+
+ newWindow.close();
+ gBrowser.removeCurrentTab();
+ }
+});
diff --git a/browser/base/content/test/contextMenu/browser_contextmenu_contenteditable.js b/browser/base/content/test/contextMenu/browser_contextmenu_contenteditable.js
new file mode 100644
index 0000000000..ccb0be8d95
--- /dev/null
+++ b/browser/base/content/test/contextMenu/browser_contextmenu_contenteditable.js
@@ -0,0 +1,118 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+let contextMenu;
+
+const example_base =
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com/browser/browser/base/content/test/contextMenu/";
+const chrome_base =
+ "chrome://mochitests/content/browser/browser/base/content/test/contextMenu/";
+
+/* import-globals-from contextmenu_common.js */
+Services.scriptloader.loadSubScript(
+ chrome_base + "contextmenu_common.js",
+ this
+);
+
+async function openMenuAndPaste(browser, useFormatting) {
+ const kElementToUse = "test-contenteditable-spellcheck-false";
+ let oldText = await SpecialPowers.spawn(browser, [kElementToUse], elemID => {
+ return content.document.getElementById(elemID).textContent;
+ });
+
+ // Open context menu and paste
+ await test_contextmenu(
+ "#" + kElementToUse,
+ [
+ "context-undo",
+ null, // whether we can undo changes mid-test.
+ "context-redo",
+ false,
+ "---",
+ null,
+ "context-cut",
+ false,
+ "context-copy",
+ false,
+ "context-paste",
+ true,
+ "context-paste-no-formatting",
+ true,
+ "context-delete",
+ false,
+ "context-selectall",
+ true,
+ ],
+ {
+ keepMenuOpen: true,
+ }
+ );
+ let popupHidden = BrowserTestUtils.waitForPopupEvent(contextMenu, "hidden");
+ let menuID = "context-paste" + (useFormatting ? "" : "-no-formatting");
+ contextMenu.activateItem(document.getElementById(menuID));
+ await popupHidden;
+ await SpecialPowers.spawn(
+ browser,
+ [kElementToUse, oldText, useFormatting],
+ (elemID, textToReset, expectStrong) => {
+ let node = content.document.getElementById(elemID);
+ Assert.stringContains(
+ node.textContent,
+ "Bold text",
+ "Text should have been pasted"
+ );
+ if (expectStrong) {
+ isnot(
+ node.querySelector("strong"),
+ null,
+ "Should be markup in the text."
+ );
+ } else {
+ is(
+ node.querySelector("strong"),
+ null,
+ "Should be no markup in the text."
+ );
+ }
+ node.textContent = textToReset;
+ }
+ );
+}
+
+add_task(async function test_contenteditable() {
+ // Put some HTML on the clipboard:
+ const xferable = Cc["@mozilla.org/widget/transferable;1"].createInstance(
+ Ci.nsITransferable
+ );
+ xferable.init(null);
+ xferable.addDataFlavor("text/html");
+ xferable.setTransferData(
+ "text/html",
+ PlacesUtils.toISupportsString("<strong>Bold text</strong>")
+ );
+ xferable.addDataFlavor("text/plain");
+ xferable.setTransferData(
+ "text/plain",
+ PlacesUtils.toISupportsString("Bold text")
+ );
+ Services.clipboard.setData(
+ xferable,
+ null,
+ Services.clipboard.kGlobalClipboard
+ );
+
+ let url = example_base + "subtst_contextmenu.html";
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url,
+ },
+ async function (browser) {
+ await openMenuAndPaste(browser, false);
+ await openMenuAndPaste(browser, true);
+ }
+ );
+});
diff --git a/browser/base/content/test/contextMenu/browser_contextmenu_iframe.js b/browser/base/content/test/contextMenu/browser_contextmenu_iframe.js
new file mode 100644
index 0000000000..bd52862eb4
--- /dev/null
+++ b/browser/base/content/test/contextMenu/browser_contextmenu_iframe.js
@@ -0,0 +1,73 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TEST_LINK = "https://example.com/";
+const RESOURCE_LINK =
+ getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+ ) + "test_contextmenu_iframe.html";
+
+/* This test checks that a context menu can open up
+ * a frame into it's own tab. */
+
+add_task(async function test_open_iframe() {
+ let testTab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ RESOURCE_LINK
+ );
+ const selector = "#iframe";
+ const openPromise = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ TEST_LINK,
+ false
+ );
+ const contextMenu = document.getElementById("contentAreaContextMenu");
+ is(contextMenu.state, "closed", "checking if popup is closed");
+ let awaitPopupShown = BrowserTestUtils.waitForEvent(
+ contextMenu,
+ "popupshown"
+ );
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ selector,
+ {
+ type: "contextmenu",
+ button: 2,
+ centered: true,
+ },
+ gBrowser.selectedBrowser
+ );
+ await awaitPopupShown;
+ info("Popup Shown");
+ const awaitPopupHidden = BrowserTestUtils.waitForEvent(
+ contextMenu,
+ "popuphidden"
+ );
+
+ // Open frame submenu
+ const frameItem = contextMenu.querySelector("#frame");
+ const menuPopup = frameItem.menupopup;
+ const menuPopupPromise = BrowserTestUtils.waitForEvent(
+ menuPopup,
+ "popupshown"
+ );
+ frameItem.openMenu(true);
+ await menuPopupPromise;
+
+ let domItem = contextMenu.querySelector("#context-openframeintab");
+ info("Going to click item " + domItem.id);
+ ok(
+ BrowserTestUtils.is_visible(domItem),
+ "DOM context menu item tab should be visible"
+ );
+ ok(!domItem.disabled, "DOM context menu item tab shouldn't be disabled");
+ contextMenu.activateItem(domItem);
+
+ let openedTab = await openPromise;
+ await awaitPopupHidden;
+ await BrowserTestUtils.removeTab(openedTab);
+
+ BrowserTestUtils.removeTab(testTab);
+});
diff --git a/browser/base/content/test/contextMenu/browser_contextmenu_input.js b/browser/base/content/test/contextMenu/browser_contextmenu_input.js
new file mode 100644
index 0000000000..c580a8184a
--- /dev/null
+++ b/browser/base/content/test/contextMenu/browser_contextmenu_input.js
@@ -0,0 +1,387 @@
+"use strict";
+
+let contextMenu;
+let hasPocket = Services.prefs.getBoolPref("extensions.pocket.enabled");
+
+const NAVIGATION_ITEMS =
+ AppConstants.platform == "macosx"
+ ? [
+ "context-back",
+ false,
+ "context-forward",
+ false,
+ "context-reload",
+ true,
+ "---",
+ null,
+ "context-bookmarkpage",
+ true,
+ ]
+ : [
+ "context-navigation",
+ null,
+ [
+ "context-back",
+ false,
+ "context-forward",
+ false,
+ "context-reload",
+ true,
+ "context-bookmarkpage",
+ true,
+ ],
+ null,
+ "---",
+ null,
+ ];
+
+add_task(async function test_setup() {
+ const example_base =
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com/browser/browser/base/content/test/contextMenu/";
+ const url = example_base + "subtst_contextmenu_input.html";
+ await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+
+ const chrome_base =
+ "chrome://mochitests/content/browser/browser/base/content/test/contextMenu/";
+ const contextmenu_common = chrome_base + "contextmenu_common.js";
+ /* import-globals-from contextmenu_common.js */
+ Services.scriptloader.loadSubScript(contextmenu_common, this);
+});
+
+add_task(async function test_text_input() {
+ await test_contextmenu("#input_text", [
+ "context-undo",
+ false,
+ "context-redo",
+ false,
+ "---",
+ null,
+ "context-cut",
+ false,
+ "context-copy",
+ false,
+ "context-paste",
+ null, // ignore clipboard state
+ "context-delete",
+ false,
+ "context-selectall",
+ false,
+ "---",
+ null,
+ "spell-check-enabled",
+ true,
+ ]);
+});
+
+add_task(async function test_text_input_disabled() {
+ await test_contextmenu(
+ "#input_disabled",
+ [
+ "context-undo",
+ false,
+ "context-redo",
+ false,
+ "---",
+ null,
+ "context-cut",
+ false,
+ "context-copy",
+ false,
+ "context-paste",
+ null, // ignore clipboard state
+ "context-delete",
+ false,
+ "context-selectall",
+ false,
+ "---",
+ null,
+ "spell-check-enabled",
+ true,
+ ],
+ { skipFocusChange: true }
+ );
+});
+
+add_task(async function test_password_input() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["signon.generation.enabled", false],
+ ["layout.forms.reveal-password-context-menu.enabled", true],
+ ],
+ });
+ todo(
+ false,
+ "context-selectall is enabled on osx-e10s, and windows when" +
+ " it should be disabled"
+ );
+ await test_contextmenu(
+ "#input_password",
+ [
+ "manage-saved-logins",
+ true,
+ "---",
+ null,
+ "context-undo",
+ false,
+ "context-redo",
+ false,
+ "---",
+ null,
+ "context-cut",
+ false,
+ "context-copy",
+ false,
+ "context-paste",
+ null, // ignore clipboard state
+ "context-delete",
+ false,
+ "context-selectall",
+ null,
+ "context-reveal-password",
+ null,
+ ],
+ {
+ skipFocusChange: true,
+ // Need to dynamically add the "password" type or LoginManager
+ // will think that the form inputs on the page are part of a login form
+ // and will add fill-login context menu items. The element needs to be
+ // re-created as type=text afterwards since it uses hasBeenTypePassword.
+ async preCheckContextMenuFn() {
+ await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [],
+ async function () {
+ let doc = content.document;
+ let input = doc.getElementById("input_password");
+ input.type = "password";
+ input.clientTop; // force layout flush
+ }
+ );
+ },
+ async postCheckContextMenuFn() {
+ await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [],
+ async function () {
+ let doc = content.document;
+ let input = doc.getElementById("input_password");
+ input.outerHTML = `<input id=\"input_password\">`;
+ input.clientTop; // force layout flush
+ }
+ );
+ },
+ }
+ );
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function firefox_relay_input() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["signon.firefoxRelay.feature", "enabled"]],
+ });
+
+ await test_contextmenu("#input_username", [
+ "use-relay-mask",
+ true,
+ "---",
+ null,
+ "context-undo",
+ false,
+ "context-redo",
+ false,
+ "---",
+ null,
+ "context-cut",
+ false,
+ "context-copy",
+ false,
+ "context-paste",
+ null, // ignore clipboard state
+ "context-delete",
+ false,
+ "context-selectall",
+ false,
+ "---",
+ null,
+ "spell-check-enabled",
+ true,
+ ]);
+
+ await test_contextmenu(
+ "#input_email",
+ [
+ "use-relay-mask",
+ true,
+ "---",
+ null,
+ "context-undo",
+ false,
+ "context-redo",
+ false,
+ "---",
+ null,
+ "context-cut",
+ false,
+ "context-copy",
+ false,
+ "context-paste",
+ null, // ignore clipboard state
+ "context-delete",
+ false,
+ "context-selectall",
+ null,
+ ],
+ {
+ skipFocusChange: true,
+ }
+ );
+
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function test_tel_email_url_number_input() {
+ todo(
+ false,
+ "context-selectall is enabled on osx-e10s, and windows when" +
+ " it should be disabled"
+ );
+ for (let selector of [
+ "#input_email",
+ "#input_url",
+ "#input_tel",
+ "#input_number",
+ ]) {
+ await test_contextmenu(
+ selector,
+ [
+ "context-undo",
+ false,
+ "context-redo",
+ false,
+ "---",
+ null,
+ "context-cut",
+ false,
+ "context-copy",
+ false,
+ "context-paste",
+ null, // ignore clipboard state
+ "context-delete",
+ false,
+ "context-selectall",
+ null,
+ ],
+ {
+ skipFocusChange: true,
+ }
+ );
+ }
+});
+
+add_task(
+ async function test_date_time_color_range_month_week_datetimelocal_input() {
+ for (let selector of [
+ "#input_date",
+ "#input_time",
+ "#input_color",
+ "#input_range",
+ "#input_month",
+ "#input_week",
+ "#input_datetime-local",
+ ]) {
+ await test_contextmenu(
+ selector,
+ [
+ ...NAVIGATION_ITEMS,
+ "context-savepage",
+ true,
+ ...(hasPocket ? ["context-pocket", true] : []),
+ "context-selectall",
+ null,
+ "---",
+ null,
+ "context-viewsource",
+ true,
+ ],
+ {
+ skipFocusChange: true,
+ }
+ );
+ }
+ }
+);
+
+add_task(async function test_search_input() {
+ todo(
+ false,
+ "context-selectall is enabled on osx-e10s, and windows when" +
+ " it should be disabled"
+ );
+ await test_contextmenu(
+ "#input_search",
+ [
+ "context-undo",
+ false,
+ "context-redo",
+ false,
+ "---",
+ null,
+ "context-cut",
+ false,
+ "context-copy",
+ false,
+ "context-paste",
+ null, // ignore clipboard state
+ "context-delete",
+ false,
+ "context-selectall",
+ null,
+ "---",
+ null,
+ "spell-check-enabled",
+ true,
+ ],
+ { skipFocusChange: true }
+ );
+});
+
+add_task(async function test_text_input_readonly() {
+ todo(
+ false,
+ "context-selectall is enabled on osx-e10s, and windows when" +
+ " it should be disabled"
+ );
+ todo(
+ false,
+ "spell-check should not be enabled for input[readonly]. see bug 1246296"
+ );
+ await test_contextmenu(
+ "#input_readonly",
+ [
+ "context-undo",
+ false,
+ "context-redo",
+ false,
+ "---",
+ null,
+ "context-cut",
+ false,
+ "context-copy",
+ false,
+ "context-paste",
+ null, // ignore clipboard state
+ "context-delete",
+ false,
+ "context-selectall",
+ null,
+ ],
+ {
+ skipFocusChange: true,
+ }
+ );
+});
+
+add_task(async function test_cleanup() {
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+});
diff --git a/browser/base/content/test/contextMenu/browser_contextmenu_inspect.js b/browser/base/content/test/contextMenu/browser_contextmenu_inspect.js
new file mode 100644
index 0000000000..94241e9e1f
--- /dev/null
+++ b/browser/base/content/test/contextMenu/browser_contextmenu_inspect.js
@@ -0,0 +1,61 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Check that we show the inspect item(s) as appropriate.
+ */
+add_task(async function test_contextmenu_inspect() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["devtools.selfxss.count", 0],
+ ["devtools.everOpened", false],
+ ],
+ });
+ let contextMenu = document.getElementById("contentAreaContextMenu");
+ await BrowserTestUtils.withNewTab("about:blank", async browser => {
+ for (let [pref, value, expectation] of [
+ ["devtools.selfxss.count", 10, true],
+ ["devtools.selfxss.count", 0, false],
+ ["devtools.everOpened", false, false],
+ ["devtools.everOpened", true, true],
+ ]) {
+ await SpecialPowers.pushPrefEnv({
+ set: [["devtools.selfxss.count", value]],
+ });
+ is(contextMenu.state, "closed", "checking if popup is closed");
+ let promisePopupShown = BrowserTestUtils.waitForEvent(
+ contextMenu,
+ "popupshown"
+ );
+ let promisePopupHidden = BrowserTestUtils.waitForEvent(
+ contextMenu,
+ "popuphidden"
+ );
+ await BrowserTestUtils.synthesizeMouse(
+ "body",
+ 2,
+ 2,
+ { type: "contextmenu", button: 2 },
+ browser
+ );
+ await promisePopupShown;
+ let inspectItem = document.getElementById("context-inspect");
+ ok(
+ !inspectItem.hidden,
+ `Inspect should be shown (pref ${pref} is ${value}).`
+ );
+ let inspectA11y = document.getElementById("context-inspect-a11y");
+ is(
+ inspectA11y.hidden,
+ !expectation,
+ `A11y should be ${
+ expectation ? "visible" : "hidden"
+ } (pref ${pref} is ${value}).`
+ );
+ contextMenu.hidePopup();
+ await promisePopupHidden;
+ }
+ });
+});
diff --git a/browser/base/content/test/contextMenu/browser_contextmenu_keyword.js b/browser/base/content/test/contextMenu/browser_contextmenu_keyword.js
new file mode 100644
index 0000000000..2e1253107c
--- /dev/null
+++ b/browser/base/content/test/contextMenu/browser_contextmenu_keyword.js
@@ -0,0 +1,198 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+let contextMenu;
+
+const example_base =
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com/browser/browser/base/content/test/contextMenu/";
+const MAIN_URL = example_base + "subtst_contextmenu_keyword.html";
+
+add_task(async function test_setup() {
+ await BrowserTestUtils.openNewForegroundTab(gBrowser, MAIN_URL);
+
+ const chrome_base =
+ "chrome://mochitests/content/browser/browser/base/content/test/contextMenu/";
+ const contextmenu_common = chrome_base + "contextmenu_common.js";
+ /* import-globals-from contextmenu_common.js */
+ Services.scriptloader.loadSubScript(contextmenu_common, this);
+});
+
+add_task(async function test_text_input_spellcheck_noform() {
+ await test_contextmenu(
+ "#input_text_no_form",
+ [
+ "context-undo",
+ false,
+ "context-redo",
+ false,
+ "---",
+ null,
+ "context-cut",
+ null, // ignore the enabled/disabled states; there are race conditions
+ // in the edit commands but they're not relevant for what we're testing.
+ "context-copy",
+ null,
+ "context-paste",
+ null, // ignore clipboard state
+ "context-delete",
+ null,
+ "context-selectall",
+ null,
+ "---",
+ null,
+ "spell-check-enabled",
+ true,
+ "spell-dictionaries",
+ true,
+ [
+ "spell-check-dictionary-en-US",
+ true,
+ "---",
+ null,
+ "spell-add-dictionaries",
+ true,
+ ],
+ null,
+ ],
+ {
+ waitForSpellCheck: true,
+ async preCheckContextMenuFn() {
+ await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [],
+ async function () {
+ let doc = content.document;
+ let input = doc.getElementById("input_text_no_form");
+ input.setAttribute("spellcheck", "true");
+ input.clientTop; // force layout flush
+ }
+ );
+ },
+ }
+ );
+});
+
+add_task(async function test_text_input_spellcheck_loginform() {
+ await test_contextmenu(
+ "#login_text",
+ [
+ "manage-saved-logins",
+ true,
+ "---",
+ null,
+ "context-undo",
+ false,
+ "context-redo",
+ false,
+ "---",
+ null,
+ "context-cut",
+ null, // ignore the enabled/disabled states; there are race conditions
+ // in the edit commands but they're not relevant for what we're testing.
+ "context-copy",
+ null,
+ "context-paste",
+ null, // ignore clipboard state
+ "context-delete",
+ null,
+ "context-selectall",
+ null,
+ "---",
+ null,
+ "spell-check-enabled",
+ true,
+ "spell-dictionaries",
+ true,
+ [
+ "spell-check-dictionary-en-US",
+ true,
+ "---",
+ null,
+ "spell-add-dictionaries",
+ true,
+ ],
+ null,
+ ],
+ {
+ waitForSpellCheck: true,
+ async preCheckContextMenuFn() {
+ await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [],
+ async function () {
+ let doc = content.document;
+ let input = doc.getElementById("login_text");
+ input.setAttribute("spellcheck", "true");
+ input.clientTop; // force layout flush
+ }
+ );
+ },
+ }
+ );
+});
+
+add_task(async function test_text_input_spellcheck_searchform() {
+ await test_contextmenu(
+ "#search_text",
+ [
+ "context-undo",
+ false,
+ "context-redo",
+ false,
+ "---",
+ null,
+ "context-cut",
+ null, // ignore the enabled/disabled states; there are race conditions
+ // in the edit commands but they're not relevant for what we're testing.
+ "context-copy",
+ null,
+ "context-paste",
+ null, // ignore clipboard state
+ "context-delete",
+ null,
+ "context-selectall",
+ null,
+ "---",
+ null,
+ "context-keywordfield",
+ null,
+ "---",
+ null,
+ "spell-check-enabled",
+ true,
+ "spell-dictionaries",
+ true,
+ [
+ "spell-check-dictionary-en-US",
+ true,
+ "---",
+ null,
+ "spell-add-dictionaries",
+ true,
+ ],
+ null,
+ ],
+ {
+ waitForSpellCheck: true,
+ async preCheckContextMenuFn() {
+ await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [],
+ async function () {
+ let doc = content.document;
+ let input = doc.getElementById("search_text");
+ input.setAttribute("spellcheck", "true");
+ input.clientTop; // force layout flush
+ }
+ );
+ },
+ }
+ );
+});
+
+add_task(async function test_cleanup() {
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+});
diff --git a/browser/base/content/test/contextMenu/browser_contextmenu_linkopen.js b/browser/base/content/test/contextMenu/browser_contextmenu_linkopen.js
new file mode 100644
index 0000000000..ac793b8011
--- /dev/null
+++ b/browser/base/content/test/contextMenu/browser_contextmenu_linkopen.js
@@ -0,0 +1,109 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TEST_LINK = "https://example.com/";
+const RESOURCE_LINK =
+ getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+ ) + "test_contextmenu_links.html";
+
+async function activateContextAndWaitFor(selector, where) {
+ info("Starting test for " + where);
+ let contextMenuItem = "openlink";
+ let openPromise;
+ let closeMethod;
+ switch (where) {
+ case "tab":
+ contextMenuItem += "intab";
+ openPromise = BrowserTestUtils.waitForNewTab(gBrowser, TEST_LINK, false);
+ closeMethod = async tab => BrowserTestUtils.removeTab(tab);
+ break;
+ case "privatewindow":
+ contextMenuItem += "private";
+ openPromise = BrowserTestUtils.waitForNewWindow({ url: TEST_LINK }).then(
+ win => {
+ ok(
+ PrivateBrowsingUtils.isWindowPrivate(win),
+ "Should have opened a private window."
+ );
+ return win;
+ }
+ );
+ closeMethod = async win => BrowserTestUtils.closeWindow(win);
+ break;
+ case "window":
+ // No contextMenuItem suffix for normal new windows;
+ openPromise = BrowserTestUtils.waitForNewWindow({ url: TEST_LINK }).then(
+ win => {
+ ok(
+ !PrivateBrowsingUtils.isWindowPrivate(win),
+ "Should have opened a normal window."
+ );
+ return win;
+ }
+ );
+ closeMethod = async win => BrowserTestUtils.closeWindow(win);
+ break;
+ }
+ let contextMenu = document.getElementById("contentAreaContextMenu");
+ is(contextMenu.state, "closed", "checking if popup is closed");
+ let awaitPopupShown = BrowserTestUtils.waitForEvent(
+ contextMenu,
+ "popupshown"
+ );
+ await BrowserTestUtils.synthesizeMouse(
+ selector,
+ 0,
+ 0,
+ {
+ type: "contextmenu",
+ button: 2,
+ centered: true,
+ },
+ gBrowser.selectedBrowser
+ );
+ await awaitPopupShown;
+ info("Popup Shown");
+ let awaitPopupHidden = BrowserTestUtils.waitForEvent(
+ contextMenu,
+ "popuphidden"
+ );
+ let domItem = contextMenu.querySelector("#context-" + contextMenuItem);
+ info("Going to click item " + domItem.id);
+ ok(
+ BrowserTestUtils.is_visible(domItem),
+ "DOM context menu item " + where + " should be visible"
+ );
+ ok(
+ !domItem.disabled,
+ "DOM context menu item " + where + " shouldn't be disabled"
+ );
+ contextMenu.activateItem(domItem);
+ await awaitPopupHidden;
+
+ info("Waiting for the link to open");
+ let openedThing = await openPromise;
+ info("Waiting for the opened window/tab to close");
+ await closeMethod(openedThing);
+}
+
+add_task(async function test_select_text_link() {
+ let testTab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ RESOURCE_LINK
+ );
+ for (let elementID of [
+ "test-link",
+ "test-image-link",
+ "svg-with-link",
+ "svg-with-relative-link",
+ ]) {
+ for (let where of ["tab", "window", "privatewindow"]) {
+ await activateContextAndWaitFor("#" + elementID, where);
+ }
+ }
+ BrowserTestUtils.removeTab(testTab);
+});
diff --git a/browser/base/content/test/contextMenu/browser_contextmenu_loadblobinnewtab.html b/browser/base/content/test/contextMenu/browser_contextmenu_loadblobinnewtab.html
new file mode 100644
index 0000000000..ca96fcfaa0
--- /dev/null
+++ b/browser/base/content/test/contextMenu/browser_contextmenu_loadblobinnewtab.html
@@ -0,0 +1,56 @@
+<!DOCTYPE html>
+<html>
+
+<head>
+ <meta charset="utf-8" />
+</head>
+
+<body onload="add_content()">
+ <p>This example creates a typed array containing the ASCII codes for the space character through the letter Z, then
+ converts it to an object URL.A link to open that object URL is created. Click the link to see the decoded object
+ URL.</p>
+ <br />
+ <br />
+ <a id='blob-url-link'>Open the array URL</a>
+ <br />
+ <br />
+ <a id='blob-url-referrer-link'>Open the URL that fetches the URL above</a>
+
+ <script>
+ function typedArrayToURL(typedArray, mimeType) {
+ return URL.createObjectURL(new Blob([typedArray.buffer], { type: mimeType }))
+ }
+
+ function add_content() {
+ const bytes = new Uint8Array(59);
+
+ for (let i = 0;i < 59;i++) {
+ bytes[i] = 32 + i;
+ }
+
+ const url = typedArrayToURL(bytes, 'text/plain');
+ document.getElementById('blob-url-link').href = url;
+
+ const ref_url = URL.createObjectURL(new Blob([`
+ <body>
+ <script>
+ fetch("${url}", {headers: {'Content-Type': 'text/plain'}})
+ .then((response) => {
+ response.text().then((textData) => {
+ var pre = document.createElement("pre");
+ pre.textContent = textData.trim();
+ document.body.insertBefore(pre, document.body.firstChild);
+ });
+ });
+ <\/script>
+ <\/body>
+ `], { type: 'text/html' }));
+
+ document.getElementById('blob-url-referrer-link').href = ref_url;
+ };
+
+ </script>
+
+</body>
+
+</html>
diff --git a/browser/base/content/test/contextMenu/browser_contextmenu_loadblobinnewtab.js b/browser/base/content/test/contextMenu/browser_contextmenu_loadblobinnewtab.js
new file mode 100644
index 0000000000..cbf1b27590
--- /dev/null
+++ b/browser/base/content/test/contextMenu/browser_contextmenu_loadblobinnewtab.js
@@ -0,0 +1,186 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const RESOURCE_LINK =
+ getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+ ) + "browser_contextmenu_loadblobinnewtab.html";
+
+const blobDataAsString = `!"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ`;
+
+// Helper method to right click on the provided link (selector as id),
+// open in new tab and return the content of the first <pre> under the
+// <body> of the new tab's document.
+async function rightClickOpenInNewTabAndReturnContent(selector) {
+ const loaded = BrowserTestUtils.browserLoaded(
+ gBrowser.selectedBrowser,
+ false,
+ RESOURCE_LINK
+ );
+ BrowserTestUtils.loadURIString(gBrowser.selectedBrowser, RESOURCE_LINK);
+ await loaded;
+
+ const generatedBlobURL = await ContentTask.spawn(
+ gBrowser.selectedBrowser,
+ { selector },
+ async args => {
+ return content.document.getElementById(args.selector).href;
+ }
+ );
+
+ const contextMenu = document.getElementById("contentAreaContextMenu");
+ is(contextMenu.state, "closed", "checking if context menu is closed");
+
+ let awaitPopupShown = BrowserTestUtils.waitForEvent(
+ contextMenu,
+ "popupshown"
+ );
+
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#" + selector,
+ { type: "contextmenu", button: 2 },
+ gBrowser.selectedBrowser
+ );
+ await awaitPopupShown;
+
+ let awaitPopupHidden = BrowserTestUtils.waitForEvent(
+ contextMenu,
+ "popuphidden"
+ );
+
+ const openPromise = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ generatedBlobURL,
+ false
+ );
+
+ document.getElementById("context-openlinkintab").doCommand();
+
+ contextMenu.hidePopup();
+ await awaitPopupHidden;
+
+ let openTab = await openPromise;
+
+ await BrowserTestUtils.switchTab(gBrowser, gBrowser.tabs[1]);
+
+ let blobDataFromContent = await ContentTask.spawn(
+ gBrowser.selectedBrowser,
+ null,
+ async function () {
+ while (!content.document.querySelector("body pre")) {
+ await new Promise(resolve =>
+ content.setTimeout(() => {
+ resolve();
+ }, 100)
+ );
+ }
+ return content.document.body.firstElementChild.innerText.trim();
+ }
+ );
+
+ let tabClosed = BrowserTestUtils.waitForTabClosing(openTab);
+ await BrowserTestUtils.removeTab(openTab);
+ await tabClosed;
+
+ return blobDataFromContent;
+}
+
+// Helper method to open selected link in new tab (selector as id),
+// and return the content of the first <pre> under the <body> of
+// the new tab's document.
+async function openInNewTabAndReturnContent(selector) {
+ const loaded = BrowserTestUtils.browserLoaded(
+ gBrowser.selectedBrowser,
+ false,
+ RESOURCE_LINK
+ );
+ BrowserTestUtils.loadURIString(gBrowser.selectedBrowser, RESOURCE_LINK);
+ await loaded;
+
+ const generatedBlobURL = await ContentTask.spawn(
+ gBrowser.selectedBrowser,
+ { selector },
+ async args => {
+ return content.document.getElementById(args.selector).href;
+ }
+ );
+
+ let openTab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ generatedBlobURL
+ );
+
+ let blobDataFromContent = await ContentTask.spawn(
+ gBrowser.selectedBrowser,
+ null,
+ async function () {
+ while (!content.document.querySelector("body pre")) {
+ await new Promise(resolve =>
+ content.setTimeout(() => {
+ resolve();
+ }, 100)
+ );
+ }
+ return content.document.body.firstElementChild.innerText.trim();
+ }
+ );
+
+ let tabClosed = BrowserTestUtils.waitForTabClosing(openTab);
+ await BrowserTestUtils.removeTab(openTab);
+ await tabClosed;
+
+ return blobDataFromContent;
+}
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [["privacy.partition.bloburl_per_agent_cluster", false]],
+ });
+});
+
+add_task(async function test_rightclick_open_bloburl_in_new_tab() {
+ let blobDataFromLoadedPage = await rightClickOpenInNewTabAndReturnContent(
+ "blob-url-link"
+ );
+ is(
+ blobDataFromLoadedPage,
+ blobDataAsString,
+ "The blobURL is correctly loaded"
+ );
+});
+
+add_task(async function test_rightclick_open_bloburl_referrer_in_new_tab() {
+ let blobDataFromLoadedPage = await rightClickOpenInNewTabAndReturnContent(
+ "blob-url-referrer-link"
+ );
+ is(
+ blobDataFromLoadedPage,
+ blobDataAsString,
+ "The blobURL is correctly loaded"
+ );
+});
+
+add_task(async function test_open_bloburl_in_new_tab() {
+ let blobDataFromLoadedPage = await openInNewTabAndReturnContent(
+ "blob-url-link"
+ );
+ is(
+ blobDataFromLoadedPage,
+ blobDataAsString,
+ "The blobURL is correctly loaded"
+ );
+});
+
+add_task(async function test_open_bloburl_referrer_in_new_tab() {
+ let blobDataFromLoadedPage = await openInNewTabAndReturnContent(
+ "blob-url-referrer-link"
+ );
+ is(
+ blobDataFromLoadedPage,
+ blobDataAsString,
+ "The blobURL is correctly loaded"
+ );
+});
diff --git a/browser/base/content/test/contextMenu/browser_contextmenu_save_blocked.js b/browser/base/content/test/contextMenu/browser_contextmenu_save_blocked.js
new file mode 100644
index 0000000000..5064d9a316
--- /dev/null
+++ b/browser/base/content/test/contextMenu/browser_contextmenu_save_blocked.js
@@ -0,0 +1,78 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+var MockFilePicker = SpecialPowers.MockFilePicker;
+MockFilePicker.init(window);
+
+function mockPromptService() {
+ let { prompt } = Services;
+ let promptService = {
+ QueryInterface: ChromeUtils.generateQI(["nsIPromptService"]),
+ alert: () => {},
+ };
+ Services.prompt = promptService;
+ registerCleanupFunction(() => {
+ Services.prompt = prompt;
+ });
+ return promptService;
+}
+
+add_task(async function test_save_link_blocked_by_extension() {
+ let ext = ExtensionTestUtils.loadExtension({
+ manifest: {
+ browser_specific_settings: { gecko: { id: "cancel@test" } },
+ name: "Cancel Test",
+ permissions: ["webRequest", "webRequestBlocking", "<all_urls>"],
+ },
+
+ background() {
+ // eslint-disable-next-line no-undef
+ browser.webRequest.onBeforeRequest.addListener(
+ details => {
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ return { cancel: details.url === "http://example.com/" };
+ },
+ { urls: ["*://*/*"] },
+ ["blocking"]
+ );
+ },
+ });
+ await ext.startup();
+
+ await BrowserTestUtils.withNewTab(
+ `data:text/html;charset=utf-8,<a href="http://example.com">Download</a>`,
+ async browser => {
+ let menu = document.getElementById("contentAreaContextMenu");
+ let popupShown = BrowserTestUtils.waitForEvent(menu, "popupshown");
+ BrowserTestUtils.synthesizeMouseAtCenter(
+ "a[href]",
+ { type: "contextmenu", button: 2 },
+ browser
+ );
+ await popupShown;
+
+ await new Promise(resolve => {
+ let promptService = mockPromptService();
+ promptService.alert = (window, title, msg) => {
+ is(
+ msg,
+ "The download cannot be saved because it is blocked by Cancel Test.",
+ "prompt should be shown"
+ );
+ setTimeout(resolve, 0);
+ };
+
+ MockFilePicker.showCallback = function (fp) {
+ ok(false, "filepicker should never been shown");
+ setTimeout(resolve, 0);
+ return Ci.nsIFilePicker.returnCancel;
+ };
+ menu.activateItem(menu.querySelector("#context-savelink"));
+ });
+ }
+ );
+
+ await ext.unload();
+});
diff --git a/browser/base/content/test/contextMenu/browser_contextmenu_share_macosx.js b/browser/base/content/test/contextMenu/browser_contextmenu_share_macosx.js
new file mode 100644
index 0000000000..8175b93052
--- /dev/null
+++ b/browser/base/content/test/contextMenu/browser_contextmenu_share_macosx.js
@@ -0,0 +1,144 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { sinon } = ChromeUtils.importESModule(
+ "resource://testing-common/Sinon.sys.mjs"
+);
+const BASE = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com"
+);
+const TEST_URL = BASE + "browser_contextmenu_shareurl.html";
+
+let mockShareData = [
+ {
+ name: "Test",
+ menuItemTitle: "Sharing Service Test",
+ image:
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAIAAAD8GO2jAAAAKE" +
+ "lEQVR42u3NQQ0AAAgEoNP+nTWFDzcoQE1udQQCgUAgEAgEAsGTYAGjxAE/G/Q2tQAAAABJRU5ErkJggg==",
+ },
+];
+
+// Setup spies for observing function calls from MacSharingService
+let shareUrlSpy = sinon.spy();
+let openSharingPreferencesSpy = sinon.spy();
+let getSharingProvidersSpy = sinon.spy();
+
+let stub = sinon.stub(gBrowser, "MacSharingService").get(() => {
+ return {
+ getSharingProviders(url) {
+ getSharingProvidersSpy(url);
+ return mockShareData;
+ },
+ shareUrl(name, url, title) {
+ shareUrlSpy(name, url, title);
+ },
+ openSharingPreferences() {
+ openSharingPreferencesSpy();
+ },
+ };
+});
+
+registerCleanupFunction(async function () {
+ stub.restore();
+});
+
+/**
+ * Test the "Share" item menus in the tab contextmenu on MacOSX.
+ */
+add_task(async function test_contextmenu_share_macosx() {
+ await BrowserTestUtils.withNewTab(TEST_URL, async () => {
+ let contextMenu = await openTabContextMenu(gBrowser.selectedTab);
+ await BrowserTestUtils.waitForMutationCondition(
+ contextMenu,
+ { childList: true },
+ () => contextMenu.querySelector(".share-tab-url-item")
+ );
+ ok(true, "Got Share item");
+
+ await openMenuPopup(contextMenu);
+ ok(getSharingProvidersSpy.calledOnce, "getSharingProviders called");
+
+ info(
+ "Check we have a service and one extra menu item for the More... button"
+ );
+ let popup = contextMenu.querySelector(".share-tab-url-item").menupopup;
+ let items = popup.querySelectorAll("menuitem");
+ is(items.length, 2, "There should be 2 sharing services.");
+
+ info("Click on the sharing service");
+ let menuPopupClosedPromised = BrowserTestUtils.waitForPopupEvent(
+ contextMenu,
+ "hidden"
+ );
+ let shareButton = items[0];
+ is(
+ shareButton.label,
+ mockShareData[0].menuItemTitle,
+ "Share button's label should match the service's menu item title. "
+ );
+ is(
+ shareButton.getAttribute("share-name"),
+ mockShareData[0].name,
+ "Share button's share-name value should match the service's name. "
+ );
+
+ popup.activateItem(shareButton);
+ await menuPopupClosedPromised;
+
+ ok(shareUrlSpy.calledOnce, "shareUrl called");
+
+ info("Check the correct data was shared.");
+ let [name, url, title] = shareUrlSpy.getCall(0).args;
+ is(name, mockShareData[0].name, "Shared correct service name");
+ is(url, TEST_URL, "Shared correct URL");
+ is(title, "Sharing URL", "Shared the correct title.");
+
+ info("Test the More... button");
+ contextMenu = await openTabContextMenu(gBrowser.selectedTab);
+ await openMenuPopup(contextMenu);
+ // Since the tab context menu was collapsed previously, the popup needs to get the
+ // providers again.
+ ok(getSharingProvidersSpy.calledTwice, "getSharingProviders called again");
+ popup = contextMenu.querySelector(".share-tab-url-item").menupopup;
+ items = popup.querySelectorAll("menuitem");
+ is(items.length, 2, "There should be 2 sharing services.");
+
+ info("Click on the More Button");
+ let moreButton = items[1];
+ menuPopupClosedPromised = BrowserTestUtils.waitForPopupEvent(
+ contextMenu,
+ "hidden"
+ );
+ popup.activateItem(moreButton);
+ await menuPopupClosedPromised;
+ ok(openSharingPreferencesSpy.calledOnce, "openSharingPreferences called");
+ });
+});
+
+/**
+ * Helper for opening the toolbar context menu.
+ */
+async function openTabContextMenu(tab) {
+ info("Opening tab context menu");
+ let contextMenu = document.getElementById("tabContextMenu");
+ let openTabContextMenuPromise = BrowserTestUtils.waitForPopupEvent(
+ contextMenu,
+ "shown"
+ );
+
+ EventUtils.synthesizeMouseAtCenter(tab, { type: "contextmenu" });
+ await openTabContextMenuPromise;
+ return contextMenu;
+}
+
+async function openMenuPopup(contextMenu) {
+ info("Opening Share menu popup.");
+ let shareItem = contextMenu.querySelector(".share-tab-url-item");
+ shareItem.openMenu(true);
+ await BrowserTestUtils.waitForPopupEvent(shareItem.menupopup, "shown");
+}
diff --git a/browser/base/content/test/contextMenu/browser_contextmenu_share_win.js b/browser/base/content/test/contextMenu/browser_contextmenu_share_win.js
new file mode 100644
index 0000000000..716da584c5
--- /dev/null
+++ b/browser/base/content/test/contextMenu/browser_contextmenu_share_win.js
@@ -0,0 +1,77 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { sinon } = ChromeUtils.importESModule(
+ "resource://testing-common/Sinon.sys.mjs"
+);
+const BASE = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com"
+);
+const TEST_URL = BASE + "browser_contextmenu_shareurl.html";
+
+// Setup spies for observing function calls from MacSharingService
+let shareUrlSpy = sinon.spy();
+
+let stub = sinon.stub(gBrowser.ownerGlobal, "WindowsUIUtils").get(() => {
+ return {
+ shareUrl(url, title) {
+ shareUrlSpy(url, title);
+ },
+ };
+});
+
+registerCleanupFunction(async function () {
+ stub.restore();
+});
+
+/**
+ * Test the "Share" item in the tab contextmenu on Windows.
+ */
+add_task(async function test_contextmenu_share_win() {
+ await BrowserTestUtils.withNewTab(TEST_URL, async () => {
+ await openTabContextMenu(gBrowser.selectedTab);
+
+ let contextMenu = document.getElementById("tabContextMenu");
+ let contextMenuClosedPromise = BrowserTestUtils.waitForPopupEvent(
+ contextMenu,
+ "hidden"
+ );
+ let itemCreated = contextMenu.querySelector(".share-tab-url-item");
+ if (!AppConstants.isPlatformAndVersionAtLeast("win", "6.4")) {
+ Assert.ok(!itemCreated, "We only expose share on windows 10 and above");
+ contextMenu.hidePopup();
+ await contextMenuClosedPromise;
+ return;
+ }
+
+ ok(itemCreated, "Got Share item on Windows 10");
+
+ info("Test the correct URL is shared when Share is selected.");
+ EventUtils.synthesizeMouseAtCenter(itemCreated, {});
+ await contextMenuClosedPromise;
+
+ ok(shareUrlSpy.calledOnce, "shareUrl called");
+ let [url, title] = shareUrlSpy.getCall(0).args;
+ is(url, TEST_URL, "Shared correct URL");
+ is(title, "Sharing URL", "Shared correct URL");
+ });
+});
+
+/**
+ * Helper for opening the toolbar context menu.
+ */
+async function openTabContextMenu(tab) {
+ info("Opening tab context menu");
+ let contextMenu = document.getElementById("tabContextMenu");
+ let openTabContextMenuPromise = BrowserTestUtils.waitForPopupEvent(
+ contextMenu,
+ "shown"
+ );
+
+ EventUtils.synthesizeMouseAtCenter(tab, { type: "contextmenu" });
+ await openTabContextMenuPromise;
+}
diff --git a/browser/base/content/test/contextMenu/browser_contextmenu_shareurl.html b/browser/base/content/test/contextMenu/browser_contextmenu_shareurl.html
new file mode 100644
index 0000000000..c7fb193972
--- /dev/null
+++ b/browser/base/content/test/contextMenu/browser_contextmenu_shareurl.html
@@ -0,0 +1,2 @@
+<!DOCTYPE html>
+<title>Sharing URL</title>
diff --git a/browser/base/content/test/contextMenu/browser_contextmenu_spellcheck.js b/browser/base/content/test/contextMenu/browser_contextmenu_spellcheck.js
new file mode 100644
index 0000000000..6f556a58dd
--- /dev/null
+++ b/browser/base/content/test/contextMenu/browser_contextmenu_spellcheck.js
@@ -0,0 +1,334 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+let contextMenu;
+
+const { sinon } = ChromeUtils.importESModule(
+ "resource://testing-common/Sinon.sys.mjs"
+);
+
+const example_base =
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com/browser/browser/base/content/test/contextMenu/";
+const MAIN_URL = example_base + "subtst_contextmenu_input.html";
+
+add_task(async function test_setup() {
+ await BrowserTestUtils.openNewForegroundTab(gBrowser, MAIN_URL);
+
+ const chrome_base =
+ "chrome://mochitests/content/browser/browser/base/content/test/contextMenu/";
+ const contextmenu_common = chrome_base + "contextmenu_common.js";
+ /* import-globals-from contextmenu_common.js */
+ Services.scriptloader.loadSubScript(contextmenu_common, this);
+});
+
+add_task(async function test_text_input_spellcheck() {
+ await test_contextmenu(
+ "#input_spellcheck_no_value",
+ [
+ "context-undo",
+ false,
+ "context-redo",
+ false,
+ "---",
+ null,
+ "context-cut",
+ null, // ignore the enabled/disabled states; there are race conditions
+ // in the edit commands but they're not relevant for what we're testing.
+ "context-copy",
+ null,
+ "context-paste",
+ null, // ignore clipboard state
+ "context-delete",
+ null,
+ "context-selectall",
+ null,
+ "---",
+ null,
+ "spell-check-enabled",
+ true,
+ "spell-dictionaries",
+ true,
+ [
+ "spell-check-dictionary-en-US",
+ true,
+ "---",
+ null,
+ "spell-add-dictionaries",
+ true,
+ ],
+ null,
+ ],
+ {
+ waitForSpellCheck: true,
+ async preCheckContextMenuFn() {
+ await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [],
+ async function () {
+ let doc = content.document;
+ let input = doc.getElementById("input_spellcheck_no_value");
+ input.setAttribute("spellcheck", "true");
+ input.clientTop; // force layout flush
+ }
+ );
+ },
+ }
+ );
+});
+
+add_task(async function test_text_input_spellcheckwrong() {
+ await test_contextmenu(
+ "#input_spellcheck_incorrect",
+ [
+ "*prodigality",
+ true, // spelling suggestion
+ "spell-add-to-dictionary",
+ true,
+ "---",
+ null,
+ "context-undo",
+ null,
+ "context-redo",
+ null,
+ "---",
+ null,
+ "context-cut",
+ null,
+ "context-copy",
+ null,
+ "context-paste",
+ null, // ignore clipboard state
+ "context-delete",
+ null,
+ "context-selectall",
+ null,
+ "---",
+ null,
+ "spell-check-enabled",
+ true,
+ "spell-dictionaries",
+ true,
+ [
+ "spell-check-dictionary-en-US",
+ true,
+ "---",
+ null,
+ "spell-add-dictionaries",
+ true,
+ ],
+ null,
+ ],
+ { waitForSpellCheck: true }
+ );
+});
+
+const kCorrectItems = [
+ "context-undo",
+ false,
+ "context-redo",
+ false,
+ "---",
+ null,
+ "context-cut",
+ null,
+ "context-copy",
+ null,
+ "context-paste",
+ null, // ignore clipboard state
+ "context-delete",
+ null,
+ "context-selectall",
+ null,
+ "---",
+ null,
+ "spell-check-enabled",
+ true,
+ "spell-dictionaries",
+ true,
+ [
+ "spell-check-dictionary-en-US",
+ true,
+ "---",
+ null,
+ "spell-add-dictionaries",
+ true,
+ ],
+ null,
+];
+
+add_task(async function test_text_input_spellcheckcorrect() {
+ await test_contextmenu("#input_spellcheck_correct", kCorrectItems, {
+ waitForSpellCheck: true,
+ });
+});
+
+add_task(async function test_text_input_spellcheck_deadactor() {
+ await test_contextmenu("#input_spellcheck_correct", kCorrectItems, {
+ waitForSpellCheck: true,
+ keepMenuOpen: true,
+ });
+ let wgp = gBrowser.selectedBrowser.browsingContext.currentWindowGlobal;
+
+ // Now the menu is open, and spellcheck is running, switch to another tab and
+ // close the original:
+ let tab = gBrowser.selectedTab;
+ await BrowserTestUtils.openNewForegroundTab(gBrowser, "https://example.org");
+ BrowserTestUtils.removeTab(tab);
+ // Ensure we've invalidated the actor
+ await TestUtils.waitForCondition(
+ () => wgp.isClosed,
+ "Waiting for actor to be dead after tab closes"
+ );
+ contextMenu.hidePopup();
+
+ // Now go back to the input testcase:
+ BrowserTestUtils.loadURIString(gBrowser.selectedBrowser, MAIN_URL);
+ await BrowserTestUtils.browserLoaded(
+ gBrowser.selectedBrowser,
+ false,
+ MAIN_URL
+ );
+
+ // Check the menu still looks the same, keep it open again:
+ await test_contextmenu("#input_spellcheck_correct", kCorrectItems, {
+ waitForSpellCheck: true,
+ keepMenuOpen: true,
+ });
+
+ // Now navigate the tab, after ensuring there's an unload listener, so
+ // we don't end up in bfcache:
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () {
+ content.document.body.setAttribute("onunload", "");
+ });
+ wgp = gBrowser.selectedBrowser.browsingContext.currentWindowGlobal;
+
+ const NEW_URL = MAIN_URL.replace(".com", ".org");
+ BrowserTestUtils.loadURIString(gBrowser.selectedBrowser, NEW_URL);
+ await BrowserTestUtils.browserLoaded(
+ gBrowser.selectedBrowser,
+ false,
+ NEW_URL
+ );
+ // Ensure we've invalidated the actor
+ await TestUtils.waitForCondition(
+ () => wgp.isClosed,
+ "Waiting for actor to be dead after onunload"
+ );
+ contextMenu.hidePopup();
+
+ // Check the menu *still* looks the same (and keep it open again):
+ await test_contextmenu("#input_spellcheck_correct", kCorrectItems, {
+ waitForSpellCheck: true,
+ keepMenuOpen: true,
+ });
+
+ // Check what happens if the actor stays alive by loading the same page
+ // again; now the context menu stuff should be destroyed by the menu
+ // hiding, nothing else.
+ wgp = gBrowser.selectedBrowser.browsingContext.currentWindowGlobal;
+ BrowserTestUtils.loadURIString(gBrowser.selectedBrowser, NEW_URL);
+ await BrowserTestUtils.browserLoaded(
+ gBrowser.selectedBrowser,
+ false,
+ NEW_URL
+ );
+ contextMenu.hidePopup();
+
+ // Check the menu still looks the same:
+ await test_contextmenu("#input_spellcheck_correct", kCorrectItems, {
+ waitForSpellCheck: true,
+ });
+ // And test it a last time without any navigation:
+ await test_contextmenu("#input_spellcheck_correct", kCorrectItems, {
+ waitForSpellCheck: true,
+ });
+});
+
+add_task(async function test_text_input_spellcheck_multilingual() {
+ if (AppConstants.platform == "macosx") {
+ todo(
+ false,
+ "Need macOS support for closemenu attributes in order to " +
+ "stop the spellcheck menu closing, see bug 1796007."
+ );
+ return;
+ }
+ let sandbox = sinon.createSandbox();
+ registerCleanupFunction(() => sandbox.restore());
+
+ // We need to mock InlineSpellCheckerUI.mRemote's properties, but
+ // InlineSpellCheckerUI.mRemote won't exist until we initialize the context
+ // menu, so do that and then manually reinit the spellcheck bits so
+ // we control them:
+ await test_contextmenu("#input_spellcheck_correct", kCorrectItems, {
+ waitForSpellCheck: true,
+ keepMenuOpen: true,
+ });
+ sandbox
+ .stub(InlineSpellCheckerUI.mRemote, "dictionaryList")
+ .get(() => ["en-US", "nl-NL"]);
+ let setterSpy = sandbox.spy();
+ sandbox
+ .stub(InlineSpellCheckerUI.mRemote, "currentDictionaries")
+ .get(() => ["en-US"])
+ .set(setterSpy);
+ // Re-init the spellcheck items:
+ InlineSpellCheckerUI.clearDictionaryListFromMenu();
+ gContextMenu.initSpellingItems();
+
+ let dictionaryMenu = document.getElementById("spell-dictionaries-menu");
+ let menuOpen = BrowserTestUtils.waitForPopupEvent(dictionaryMenu, "shown");
+ dictionaryMenu.parentNode.openMenu(true);
+ await menuOpen;
+ checkMenu(dictionaryMenu, [
+ "spell-check-dictionary-nl-NL",
+ true,
+ "spell-check-dictionary-en-US",
+ true,
+ "---",
+ null,
+ "spell-add-dictionaries",
+ true,
+ ]);
+ is(
+ dictionaryMenu.children.length,
+ 4,
+ "Should have 2 dictionaries, a separator and 'add more dictionaries' item in the menu."
+ );
+
+ let dictionaryEventPromise = BrowserTestUtils.waitForEvent(
+ document,
+ "spellcheck-changed"
+ );
+ dictionaryMenu.activateItem(
+ dictionaryMenu.querySelector("[data-locale-code*=nl]")
+ );
+ let event = await dictionaryEventPromise;
+ Assert.deepEqual(
+ event.detail?.dictionaries,
+ ["en-US", "nl-NL"],
+ "Should have sent right dictionaries with event."
+ );
+ ok(setterSpy.called, "Should have set currentDictionaries");
+ Assert.deepEqual(
+ setterSpy.firstCall?.args,
+ [["en-US", "nl-NL"]],
+ "Should have called setter with single argument array of 2 dictionaries."
+ );
+ // Allow for the menu to potentially close:
+ await new Promise(r => Services.tm.dispatchToMainThread(r));
+ // Check it hasn't:
+ is(
+ dictionaryMenu.closest("menupopup").state,
+ "open",
+ "Main menu should still be open."
+ );
+ contextMenu.hidePopup();
+});
+
+add_task(async function test_cleanup() {
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+});
diff --git a/browser/base/content/test/contextMenu/browser_contextmenu_touch.js b/browser/base/content/test/contextMenu/browser_contextmenu_touch.js
new file mode 100644
index 0000000000..2f4e5a79c6
--- /dev/null
+++ b/browser/base/content/test/contextMenu/browser_contextmenu_touch.js
@@ -0,0 +1,94 @@
+/* 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/. */
+
+/* This test checks that context menus are in touchmode
+ * when opened through a touch event (long tap). */
+
+async function openAndCheckContextMenu(contextMenu, target) {
+ is(contextMenu.state, "closed", "Context menu is initally closed.");
+
+ let popupshown = BrowserTestUtils.waitForEvent(contextMenu, "popupshown");
+ EventUtils.synthesizeNativeTapAtCenter(target, true);
+ await popupshown;
+
+ is(contextMenu.state, "open", "Context menu is open.");
+ is(
+ contextMenu.getAttribute("touchmode"),
+ "true",
+ "Context menu is in touchmode."
+ );
+
+ contextMenu.hidePopup();
+
+ popupshown = BrowserTestUtils.waitForEvent(contextMenu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(target, { type: "contextmenu" });
+ await popupshown;
+
+ is(contextMenu.state, "open", "Context menu is open.");
+ ok(
+ !contextMenu.hasAttribute("touchmode"),
+ "Context menu is not in touchmode."
+ );
+
+ contextMenu.hidePopup();
+}
+
+// Ensure that we can run touch events properly for windows [10]
+add_setup(async function () {
+ let isWindows = AppConstants.isPlatformAndVersionAtLeast("win", "10.0");
+ await SpecialPowers.pushPrefEnv({
+ set: [["apz.test.fails_with_native_injection", isWindows]],
+ });
+});
+
+// Test the content area context menu.
+add_task(async function test_contentarea_contextmenu_touch() {
+ await BrowserTestUtils.withNewTab("about:blank", async function (browser) {
+ let contextMenu = document.getElementById("contentAreaContextMenu");
+ await openAndCheckContextMenu(contextMenu, browser);
+ });
+});
+
+// Test the back and forward buttons.
+add_task(async function test_back_forward_button_contextmenu_touch() {
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ await BrowserTestUtils.withNewTab(
+ "http://example.com",
+ async function (browser) {
+ let contextMenu = document.getElementById("backForwardMenu");
+
+ let backbutton = document.getElementById("back-button");
+ let notDisabled = TestUtils.waitForCondition(
+ () => !backbutton.hasAttribute("disabled")
+ );
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ BrowserTestUtils.loadURIString(browser, "http://example.org");
+ await notDisabled;
+ await openAndCheckContextMenu(contextMenu, backbutton);
+
+ let forwardbutton = document.getElementById("forward-button");
+ notDisabled = TestUtils.waitForCondition(
+ () => !forwardbutton.hasAttribute("disabled")
+ );
+ backbutton.click();
+ await notDisabled;
+ await openAndCheckContextMenu(contextMenu, forwardbutton);
+ }
+ );
+});
+
+// Test the toolbar context menu.
+add_task(async function test_toolbar_contextmenu_touch() {
+ let toolbarContextMenu = document.getElementById("toolbar-context-menu");
+ let target = document.getElementById("PanelUI-menu-button");
+ await openAndCheckContextMenu(toolbarContextMenu, target);
+});
+
+// Test the urlbar input context menu.
+add_task(async function test_urlbar_contextmenu_touch() {
+ let urlbar = document.getElementById("urlbar");
+ let textBox = urlbar.querySelector("moz-input-box");
+ let menu = textBox.menupopup;
+ await openAndCheckContextMenu(menu, textBox);
+});
diff --git a/browser/base/content/test/contextMenu/browser_copy_image_link.js b/browser/base/content/test/contextMenu/browser_copy_image_link.js
new file mode 100644
index 0000000000..4853006a61
--- /dev/null
+++ b/browser/base/content/test/contextMenu/browser_copy_image_link.js
@@ -0,0 +1,40 @@
+/**
+ * Testcase for bug 1719203
+ * <https://bugzilla.mozilla.org/show_bug.cgi?id=1719203>
+ *
+ * Load firebird.png, redirect it to doggy.png, and verify that "Copy Image
+ * Link" copies firebird.png.
+ */
+
+add_task(async function () {
+ // This URL will redirect to doggy.png.
+ const URL_FIREBIRD =
+ "http://mochi.test:8888/browser/browser/base/content/test/contextMenu/firebird.png";
+
+ await BrowserTestUtils.withNewTab(URL_FIREBIRD, async function (browser) {
+ // Click image to show context menu.
+ let popupShownPromise = BrowserTestUtils.waitForEvent(
+ document,
+ "popupshown"
+ );
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "img",
+ { type: "contextmenu", button: 2 },
+ browser
+ );
+ await popupShownPromise;
+
+ await SimpleTest.promiseClipboardChange(URL_FIREBIRD, () => {
+ document.getElementById("context-copyimage").doCommand();
+ });
+
+ // Close context menu.
+ let contextMenu = document.getElementById("contentAreaContextMenu");
+ let popupHiddenPromise = BrowserTestUtils.waitForEvent(
+ contextMenu,
+ "popuphidden"
+ );
+ contextMenu.hidePopup();
+ await popupHiddenPromise;
+ });
+});
diff --git a/browser/base/content/test/contextMenu/browser_strip_on_share_link.js b/browser/base/content/test/contextMenu/browser_strip_on_share_link.js
new file mode 100644
index 0000000000..ba3fd33caa
--- /dev/null
+++ b/browser/base/content/test/contextMenu/browser_strip_on_share_link.js
@@ -0,0 +1,151 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+let listService;
+
+let url =
+ "https://example.com/browser/browser/base/content/test/general/dummy_page.html";
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [["privacy.query_stripping.strip_list", "stripParam"]],
+ });
+
+ // Get the list service so we can wait for it to be fully initialized before running tests.
+ listService = Cc["@mozilla.org/query-stripping-list-service;1"].getService(
+ Ci.nsIURLQueryStrippingListService
+ );
+
+ await listService.testWaitForInit();
+});
+
+/*
+ Tests the strip-on-share feature for in-content links
+ */
+
+// Tests that the link url is properly stripped
+add_task(async function testStrip() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["privacy.query_stripping.strip_on_share.enabled", true]],
+ });
+ let strippedURI = "https://www.example.com/?otherParam=1234";
+ await BrowserTestUtils.withNewTab(url, async function (browser) {
+ // Prepare a link
+ await SpecialPowers.spawn(browser, [], async function () {
+ let link = content.document.createElement("a");
+ link.href = "https://www.example.com/?stripParam=1234&otherParam=1234";
+ link.textContent = "link with query param";
+ link.id = "link";
+ content.document.body.appendChild(link);
+ });
+ let contextMenu = document.getElementById("contentAreaContextMenu");
+ // Open the context menu
+ let awaitPopupShown = BrowserTestUtils.waitForEvent(
+ contextMenu,
+ "popupshown"
+ );
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#link",
+ { type: "contextmenu", button: 2 },
+ browser
+ );
+ await awaitPopupShown;
+ let awaitPopupHidden = BrowserTestUtils.waitForEvent(
+ contextMenu,
+ "popuphidden"
+ );
+ let stripOnShare = contextMenu.querySelector("#context-stripOnShareLink");
+ Assert.ok(
+ BrowserTestUtils.is_visible(stripOnShare),
+ "Menu item is visible"
+ );
+
+ // Make sure the stripped link will be copied to the clipboard
+ await SimpleTest.promiseClipboardChange(strippedURI, () => {
+ contextMenu.activateItem(stripOnShare);
+ });
+ await awaitPopupHidden;
+ });
+});
+
+// Tests that the menu item does not show if the pref is disabled
+add_task(async function testPrefDisabled() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["privacy.query_stripping.strip_on_share.enabled", false]],
+ });
+ await BrowserTestUtils.withNewTab(url, async function (browser) {
+ // Prepare a link
+ await SpecialPowers.spawn(browser, [], async function () {
+ let link = content.document.createElement("a");
+ link.href = "https://www.example.com/?stripParam=1234&otherParam=1234";
+ link.textContent = "link with query param";
+ link.id = "link";
+ content.document.body.appendChild(link);
+ });
+ let contextMenu = document.getElementById("contentAreaContextMenu");
+ // Open the context menu
+ let awaitPopupShown = BrowserTestUtils.waitForEvent(
+ contextMenu,
+ "popupshown"
+ );
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#link",
+ { type: "contextmenu", button: 2 },
+ browser
+ );
+ await awaitPopupShown;
+ let awaitPopupHidden = BrowserTestUtils.waitForEvent(
+ contextMenu,
+ "popuphidden"
+ );
+ let stripOnShare = contextMenu.querySelector("#context-stripOnShareLink");
+ Assert.ok(
+ !BrowserTestUtils.is_visible(stripOnShare),
+ "Menu item is not visible"
+ );
+ contextMenu.hidePopup();
+ await awaitPopupHidden;
+ });
+});
+
+// Tests that the menu item does not show if there is nothing to strip
+add_task(async function testUnknownQueryParam() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["privacy.query_stripping.strip_on_share.enabled", true]],
+ });
+ await BrowserTestUtils.withNewTab(url, async function (browser) {
+ // Prepare a link
+ await SpecialPowers.spawn(browser, [], async function () {
+ let link = content.document.createElement("a");
+ link.href = "https://www.example.com/?otherParam=1234";
+ link.textContent = "link with unknown query param";
+ link.id = "link";
+ content.document.body.appendChild(link);
+ });
+ let contextMenu = document.getElementById("contentAreaContextMenu");
+ // open the context menu
+ let awaitPopupShown = BrowserTestUtils.waitForEvent(
+ contextMenu,
+ "popupshown"
+ );
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#link",
+ { type: "contextmenu", button: 2 },
+ browser
+ );
+ await awaitPopupShown;
+ let awaitPopupHidden = BrowserTestUtils.waitForEvent(
+ contextMenu,
+ "popuphidden"
+ );
+ let stripOnShare = contextMenu.querySelector("#context-stripOnShareLink");
+ Assert.ok(
+ !BrowserTestUtils.is_visible(stripOnShare),
+ "Menu item is not visible"
+ );
+ contextMenu.hidePopup();
+ await awaitPopupHidden;
+ });
+});
diff --git a/browser/base/content/test/contextMenu/browser_utilityOverlay.js b/browser/base/content/test/contextMenu/browser_utilityOverlay.js
new file mode 100644
index 0000000000..2a3b881c92
--- /dev/null
+++ b/browser/base/content/test/contextMenu/browser_utilityOverlay.js
@@ -0,0 +1,78 @@
+/* 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/. */
+
+add_task(async function test_eventMatchesKey() {
+ let eventMatchResult;
+ let key;
+ let checkEvent = function (e) {
+ e.stopPropagation();
+ e.preventDefault();
+ eventMatchResult = eventMatchesKey(e, key);
+ };
+ document.addEventListener("keypress", checkEvent);
+
+ try {
+ key = document.createXULElement("key");
+ let keyset = document.getElementById("mainKeyset");
+ key.setAttribute("key", "t");
+ key.setAttribute("modifiers", "accel");
+ keyset.appendChild(key);
+ EventUtils.synthesizeKey("t", { accelKey: true });
+ is(eventMatchResult, true, "eventMatchesKey: one modifier");
+ keyset.removeChild(key);
+
+ key = document.createXULElement("key");
+ key.setAttribute("key", "g");
+ key.setAttribute("modifiers", "accel,shift");
+ keyset.appendChild(key);
+ EventUtils.synthesizeKey("g", { accelKey: true, shiftKey: true });
+ is(eventMatchResult, true, "eventMatchesKey: combination modifiers");
+ keyset.removeChild(key);
+
+ key = document.createXULElement("key");
+ key.setAttribute("key", "w");
+ key.setAttribute("modifiers", "accel");
+ keyset.appendChild(key);
+ EventUtils.synthesizeKey("f", { accelKey: true });
+ is(eventMatchResult, false, "eventMatchesKey: mismatch keys");
+ keyset.removeChild(key);
+
+ key = document.createXULElement("key");
+ key.setAttribute("keycode", "VK_DELETE");
+ keyset.appendChild(key);
+ EventUtils.synthesizeKey("VK_DELETE", { accelKey: true });
+ is(eventMatchResult, false, "eventMatchesKey: mismatch modifiers");
+ keyset.removeChild(key);
+ } finally {
+ // Make sure to remove the event listener so future tests don't
+ // fail when they simulate key presses.
+ document.removeEventListener("keypress", checkEvent);
+ }
+});
+
+add_task(async function test_getTargetWindow() {
+ is(URILoadingHelper.getTargetWindow(window), window, "got top window");
+});
+
+add_task(async function test_openUILink() {
+ const kURL = "https://example.org/";
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:blank"
+ );
+ let loadPromise = BrowserTestUtils.browserLoaded(
+ tab.linkedBrowser,
+ false,
+ kURL
+ );
+
+ openUILink(kURL, null, {
+ triggeringPrincipal: Services.scriptSecurityManager.createNullPrincipal({}),
+ }); // defaults to "current"
+
+ await loadPromise;
+
+ is(tab.linkedBrowser.currentURI.spec, kURL, "example.org loaded");
+ gBrowser.removeCurrentTab();
+});
diff --git a/browser/base/content/test/contextMenu/browser_utilityOverlayPrincipal.js b/browser/base/content/test/contextMenu/browser_utilityOverlayPrincipal.js
new file mode 100644
index 0000000000..5b8252b973
--- /dev/null
+++ b/browser/base/content/test/contextMenu/browser_utilityOverlayPrincipal.js
@@ -0,0 +1,72 @@
+/* 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/. */
+
+const gTests = [test_openUILink_checkPrincipal];
+
+function test() {
+ waitForExplicitFinish();
+ executeSoon(runNextTest);
+}
+
+function runNextTest() {
+ if (gTests.length) {
+ let testFun = gTests.shift();
+ info("Running " + testFun.name);
+ testFun();
+ } else {
+ finish();
+ }
+}
+
+function test_openUILink_checkPrincipal() {
+ let tab = (gBrowser.selectedTab = BrowserTestUtils.addTab(
+ gBrowser,
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com/"
+ )); // remote tab
+ BrowserTestUtils.browserLoaded(tab.linkedBrowser).then(async function () {
+ is(
+ tab.linkedBrowser.currentURI.spec,
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com/",
+ "example.com loaded"
+ );
+
+ await SpecialPowers.spawn(tab.linkedBrowser, [], function () {
+ let channel = content.docShell.currentDocumentChannel;
+
+ const loadingPrincipal = channel.loadInfo.loadingPrincipal;
+ is(loadingPrincipal, null, "sanity: correct loadingPrincipal");
+ const triggeringPrincipal = channel.loadInfo.triggeringPrincipal;
+ ok(
+ triggeringPrincipal.isSystemPrincipal,
+ "sanity: correct triggeringPrincipal"
+ );
+ const principalToInherit = channel.loadInfo.principalToInherit;
+ ok(
+ principalToInherit.isNullPrincipal,
+ "sanity: correct principalToInherit"
+ );
+ ok(
+ content.document.nodePrincipal.isContentPrincipal,
+ "sanity: correct doc.nodePrincipal"
+ );
+ is(
+ content.document.nodePrincipal.asciiSpec,
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com/",
+ "sanity: correct doc.nodePrincipal URL"
+ );
+ });
+
+ gBrowser.removeCurrentTab();
+ runNextTest();
+ });
+
+ // Ensure we get the correct default of "allowInheritPrincipal: false" from openUILink
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ openUILink("http://example.com", null, {
+ triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal({}),
+ }); // defaults to "current"
+}
diff --git a/browser/base/content/test/contextMenu/browser_view_image.js b/browser/base/content/test/contextMenu/browser_view_image.js
new file mode 100644
index 0000000000..485fdf3fb2
--- /dev/null
+++ b/browser/base/content/test/contextMenu/browser_view_image.js
@@ -0,0 +1,197 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const chrome_base = getRootDirectory(gTestPath);
+
+/* import-globals-from contextmenu_common.js */
+Services.scriptloader.loadSubScript(
+ chrome_base + "contextmenu_common.js",
+ this
+);
+const http_base = chrome_base.replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+);
+
+async function test_view_image_works({ page, selector, urlChecker }) {
+ let mainURL = http_base + page;
+ let accel = AppConstants.platform == "macosx" ? "metaKey" : "ctrlKey";
+ let tests = {
+ tab: {
+ modifiers: { [accel]: true },
+ async loadedPromise() {
+ return BrowserTestUtils.waitForNewTab(gBrowser, urlChecker, true).then(
+ t => t.linkedBrowser
+ );
+ },
+ cleanup(browser) {
+ BrowserTestUtils.removeTab(gBrowser.getTabForBrowser(browser));
+ },
+ },
+ window: {
+ modifiers: { shiftKey: true },
+ async loadedPromise() {
+ // Unfortunately we can't predict the URL so can't just pass that to waitForNewWindow
+ let w = await BrowserTestUtils.waitForNewWindow();
+ let browser = w.gBrowser.selectedBrowser;
+ let getCx = () => browser.browsingContext;
+ await TestUtils.waitForCondition(
+ () =>
+ getCx() && urlChecker(getCx().currentWindowGlobal.documentURI.spec)
+ );
+ await SpecialPowers.spawn(browser, [], () => {
+ return ContentTaskUtils.waitForCondition(
+ () => content.document.readyState == "complete"
+ );
+ });
+ return browser;
+ },
+ async cleanup(browser) {
+ return BrowserTestUtils.closeWindow(browser.ownerGlobal);
+ },
+ },
+ tab_default: {
+ modifiers: {},
+ async loadedPromise() {
+ return BrowserTestUtils.waitForNewTab(gBrowser, urlChecker, true).then(
+ t => {
+ is(t.selected, false, "Tab should not be selected.");
+ return t.linkedBrowser;
+ }
+ );
+ },
+ cleanup(browser) {
+ is(gBrowser.tabs.length, 3, "number of tabs");
+ BrowserTestUtils.removeTab(gBrowser.getTabForBrowser(browser));
+ },
+ },
+ tab_default_flip_bg_pref: {
+ prefs: [["browser.tabs.loadInBackground", false]],
+ modifiers: {},
+ async loadedPromise() {
+ return BrowserTestUtils.waitForNewTab(gBrowser, urlChecker, true).then(
+ t => {
+ is(t.selected, true, "Tab should be selected with pref flipped.");
+ return t.linkedBrowser;
+ }
+ );
+ },
+ cleanup(browser) {
+ is(gBrowser.tabs.length, 3, "number of tabs");
+ BrowserTestUtils.removeTab(gBrowser.getTabForBrowser(browser));
+ },
+ },
+ };
+ await BrowserTestUtils.withNewTab(mainURL, async browser => {
+ await SpecialPowers.spawn(browser, [], () => {
+ return ContentTaskUtils.waitForCondition(
+ () => !content.document.documentElement.classList.contains("wait")
+ );
+ });
+ for (let [testLabel, test] of Object.entries(tests)) {
+ if (test.prefs) {
+ await SpecialPowers.pushPrefEnv({ set: test.prefs });
+ }
+ let contextMenu = document.getElementById("contentAreaContextMenu");
+ is(
+ contextMenu.state,
+ "closed",
+ `${testLabel} - checking if popup is closed`
+ );
+ let promisePopupShown = BrowserTestUtils.waitForEvent(
+ contextMenu,
+ "popupshown"
+ );
+ await BrowserTestUtils.synthesizeMouse(
+ selector,
+ 2,
+ 2,
+ { type: "contextmenu", button: 2 },
+ browser
+ );
+ await promisePopupShown;
+ info(`${testLabel} - Popup Shown`);
+ let promisePopupHidden = BrowserTestUtils.waitForEvent(
+ contextMenu,
+ "popuphidden"
+ );
+ let browserPromise = test.loadedPromise();
+ contextMenu.activateItem(
+ document.getElementById("context-viewimage"),
+ test.modifiers
+ );
+ await promisePopupHidden;
+
+ let newBrowser = await browserPromise;
+ let { documentURI } = newBrowser.browsingContext.currentWindowGlobal;
+ if (documentURI.spec.startsWith("data:image/svg")) {
+ await SpecialPowers.spawn(newBrowser, [testLabel], msgPrefix => {
+ let svgEl = content.document.querySelector("svg");
+ ok(svgEl, `${msgPrefix} - should have loaded SVG.`);
+ is(svgEl.height.baseVal.value, 500, `${msgPrefix} - SVG has height`);
+ is(svgEl.width.baseVal.value, 500, `${msgPrefix} - SVG has height`);
+ });
+ } else {
+ await SpecialPowers.spawn(newBrowser, [testLabel], msgPrefix => {
+ let img = content.document.querySelector("img");
+ ok(
+ img instanceof Ci.nsIImageLoadingContent,
+ `${msgPrefix} - Image should have loaded content.`
+ );
+ const request = img.getRequest(
+ Ci.nsIImageLoadingContent.CURRENT_REQUEST
+ );
+ ok(
+ request.imageStatus & request.STATUS_LOAD_COMPLETE,
+ `${msgPrefix} - Should have loaded image.`
+ );
+ });
+ }
+ await test.cleanup(newBrowser);
+ if (test.prefs) {
+ await SpecialPowers.popPrefEnv();
+ }
+ }
+ });
+}
+
+/**
+ * Verify that the 'view image' context menu in a new tab for a canvas works,
+ * when opened in a new tab, a new window, or in the same tab.
+ */
+add_task(async function test_view_image_canvas_works() {
+ await test_view_image_works({
+ page: "subtst_contextmenu.html",
+ selector: "#test-canvas",
+ urlChecker: url => url.startsWith("blob:"),
+ });
+});
+
+/**
+ * Test for https://bugzilla.mozilla.org/show_bug.cgi?id=1625786
+ */
+add_task(async function test_view_image_revoked_cached_blob() {
+ await test_view_image_works({
+ page: "test_view_image_revoked_cached_blob.html",
+ selector: "#second",
+ urlChecker: url => url.startsWith("blob:"),
+ });
+});
+
+/**
+ * Test for https://bugzilla.mozilla.org/show_bug.cgi?id=1738190
+ * Inline SVG data URIs as a background image should also open.
+ */
+add_task(async function test_view_image_inline_svg_bgimage() {
+ await SpecialPowers.pushPrefEnv({
+ // This is the default but we turn it off for unit tests.
+ set: [["security.data_uri.block_toplevel_data_uri_navigations", true]],
+ });
+ await test_view_image_works({
+ page: "test_view_image_inline_svg.html",
+ selector: "body",
+ urlChecker: url => url.startsWith("data:"),
+ });
+});
diff --git a/browser/base/content/test/contextMenu/bug1798178.sjs b/browser/base/content/test/contextMenu/bug1798178.sjs
new file mode 100644
index 0000000000..790dc2bee5
--- /dev/null
+++ b/browser/base/content/test/contextMenu/bug1798178.sjs
@@ -0,0 +1,9 @@
+/* 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/. */
+
+function handleRequest(request, response) {
+ response.setHeader("Content-Type", "text/plain", false);
+ response.setHeader("X-Content-Type-Options", "nosniff");
+ response.write("Hello");
+}
diff --git a/browser/base/content/test/contextMenu/contextmenu_common.js b/browser/base/content/test/contextMenu/contextmenu_common.js
new file mode 100644
index 0000000000..ac61aa2a3a
--- /dev/null
+++ b/browser/base/content/test/contextMenu/contextmenu_common.js
@@ -0,0 +1,437 @@
+// This file expects contextMenu to be defined in the scope it is loaded into.
+/* global contextMenu:true */
+
+var lastElement;
+const FRAME_OS_PID = "context-frameOsPid";
+
+function openContextMenuFor(element, shiftkey, waitForSpellCheck) {
+ // Context menu should be closed before we open it again.
+ is(
+ SpecialPowers.wrap(contextMenu).state,
+ "closed",
+ "checking if popup is closed"
+ );
+
+ if (lastElement) {
+ lastElement.blur();
+ }
+ element.focus();
+
+ // Some elements need time to focus and spellcheck before any tests are
+ // run on them.
+ function actuallyOpenContextMenuFor() {
+ lastElement = element;
+ var eventDetails = { type: "contextmenu", button: 2, shiftKey: shiftkey };
+ synthesizeMouse(element, 2, 2, eventDetails, element.ownerGlobal);
+ }
+
+ if (waitForSpellCheck) {
+ var { onSpellCheck } = SpecialPowers.ChromeUtils.importESModule(
+ "resource://testing-common/AsyncSpellCheckTestHelper.sys.mjs"
+ );
+ onSpellCheck(element, actuallyOpenContextMenuFor);
+ } else {
+ actuallyOpenContextMenuFor();
+ }
+}
+
+function closeContextMenu() {
+ contextMenu.hidePopup();
+}
+
+function getVisibleMenuItems(aMenu, aData) {
+ var items = [];
+ var accessKeys = {};
+ for (var i = 0; i < aMenu.children.length; i++) {
+ var item = aMenu.children[i];
+ if (item.hidden) {
+ continue;
+ }
+
+ var key = item.accessKey;
+ if (key) {
+ key = key.toLowerCase();
+ }
+
+ if (item.nodeName == "menuitem") {
+ var isGenerated =
+ item.classList.contains("spell-suggestion") ||
+ item.classList.contains("sendtab-target");
+ if (isGenerated) {
+ is(item.id, "", "child menuitem #" + i + " is generated");
+ } else {
+ ok(item.id, "child menuitem #" + i + " has an ID");
+ }
+ var label = item.getAttribute("label");
+ ok(label.length, "menuitem " + item.id + " has a label");
+ if (isGenerated) {
+ is(key, "", "Generated items shouldn't have an access key");
+ items.push("*" + label);
+ } else if (
+ item.id.indexOf("spell-check-dictionary-") != 0 &&
+ item.id != "spell-no-suggestions" &&
+ item.id != "spell-add-dictionaries-main" &&
+ item.id != "context-savelinktopocket" &&
+ item.id != "fill-login-no-logins" &&
+ // Inspect accessibility properties does not have an access key. See
+ // bug 1630717 for more details.
+ item.id != "context-inspect-a11y" &&
+ !item.id.includes("context-media-playbackrate")
+ ) {
+ if (item.id != FRAME_OS_PID) {
+ ok(key, "menuitem " + item.id + " has an access key");
+ }
+ if (accessKeys[key]) {
+ ok(
+ false,
+ "menuitem " + item.id + " has same accesskey as " + accessKeys[key]
+ );
+ } else {
+ accessKeys[key] = item.id;
+ }
+ }
+ if (!isGenerated) {
+ items.push(item.id);
+ }
+ items.push(!item.disabled);
+ } else if (item.nodeName == "menuseparator") {
+ ok(true, "--- seperator id is " + item.id);
+ items.push("---");
+ items.push(null);
+ } else if (item.nodeName == "menu") {
+ ok(item.id, "child menu #" + i + " has an ID");
+ ok(key, "menu has an access key");
+ if (accessKeys[key]) {
+ ok(
+ false,
+ "menu " + item.id + " has same accesskey as " + accessKeys[key]
+ );
+ } else {
+ accessKeys[key] = item.id;
+ }
+ items.push(item.id);
+ items.push(!item.disabled);
+ // Add a dummy item so that the indexes in checkMenu are the same
+ // for expectedItems and actualItems.
+ items.push([]);
+ items.push(null);
+ } else if (item.nodeName == "menugroup") {
+ ok(item.id, "child menugroup #" + i + " has an ID");
+ items.push(item.id);
+ items.push(!item.disabled);
+ var menugroupChildren = [];
+ for (var child of item.children) {
+ if (child.hidden) {
+ continue;
+ }
+
+ menugroupChildren.push([child.id, !child.disabled]);
+ }
+ items.push(menugroupChildren);
+ items.push(null);
+ } else {
+ ok(
+ false,
+ "child #" +
+ i +
+ " of menu ID " +
+ aMenu.id +
+ " has an unknown type (" +
+ item.nodeName +
+ ")"
+ );
+ }
+ }
+ return items;
+}
+
+function checkContextMenu(expectedItems) {
+ is(contextMenu.state, "open", "checking if popup is open");
+ var data = { generatedSubmenuId: 1 };
+ checkMenu(contextMenu, expectedItems, data);
+}
+
+function checkMenuItem(
+ actualItem,
+ actualEnabled,
+ expectedItem,
+ expectedEnabled,
+ index
+) {
+ is(
+ `${actualItem}`,
+ expectedItem,
+ "checking item #" + index / 2 + " (" + expectedItem + ") name"
+ );
+
+ if (
+ (typeof expectedEnabled == "object" && expectedEnabled != null) ||
+ (typeof actualEnabled == "object" && actualEnabled != null)
+ ) {
+ ok(!(actualEnabled == null), "actualEnabled is not null");
+ ok(!(expectedEnabled == null), "expectedEnabled is not null");
+ is(typeof actualEnabled, typeof expectedEnabled, "checking types");
+
+ if (
+ typeof actualEnabled != typeof expectedEnabled ||
+ actualEnabled == null ||
+ expectedEnabled == null
+ ) {
+ return;
+ }
+
+ is(
+ actualEnabled.type,
+ expectedEnabled.type,
+ "checking item #" + index / 2 + " (" + expectedItem + ") type attr value"
+ );
+ var icon = actualEnabled.icon;
+ if (icon) {
+ var tmp = "";
+ var j = icon.length - 1;
+ while (j && icon[j] != "/") {
+ tmp = icon[j--] + tmp;
+ }
+ icon = tmp;
+ }
+ is(
+ icon,
+ expectedEnabled.icon,
+ "checking item #" + index / 2 + " (" + expectedItem + ") icon attr value"
+ );
+ is(
+ actualEnabled.checked,
+ expectedEnabled.checked,
+ "checking item #" + index / 2 + " (" + expectedItem + ") has checked attr"
+ );
+ is(
+ actualEnabled.disabled,
+ expectedEnabled.disabled,
+ "checking item #" +
+ index / 2 +
+ " (" +
+ expectedItem +
+ ") has disabled attr"
+ );
+ } else if (expectedEnabled != null) {
+ is(
+ actualEnabled,
+ expectedEnabled,
+ "checking item #" + index / 2 + " (" + expectedItem + ") enabled state"
+ );
+ }
+}
+
+/*
+ * checkMenu - checks to see if the specified <menupopup> contains the
+ * expected items and state.
+ * expectedItems is a array of (1) item IDs and (2) a boolean specifying if
+ * the item is enabled or not (or null to ignore it). Submenus can be checked
+ * by providing a nested array entry after the expected <menu> ID.
+ * For example: ["blah", true, // item enabled
+ * "submenu", null, // submenu
+ * ["sub1", true, // submenu contents
+ * "sub2", false], null, // submenu contents
+ * "lol", false] // item disabled
+ *
+ */
+function checkMenu(menu, expectedItems, data) {
+ var actualItems = getVisibleMenuItems(menu, data);
+ // ok(false, "Items are: " + actualItems);
+ for (var i = 0; i < expectedItems.length; i += 2) {
+ var actualItem = actualItems[i];
+ var actualEnabled = actualItems[i + 1];
+ var expectedItem = expectedItems[i];
+ var expectedEnabled = expectedItems[i + 1];
+ if (expectedItem instanceof Array) {
+ ok(true, "Checking submenu/menugroup...");
+ var previousId = expectedItems[i - 2]; // The last item was the menu ID.
+ var previousItem = menu.getElementsByAttribute("id", previousId)[0];
+ ok(
+ previousItem,
+ (previousItem ? previousItem.nodeName : "item") +
+ " with previous id (" +
+ previousId +
+ ") found"
+ );
+ if (previousItem && previousItem.nodeName == "menu") {
+ ok(previousItem, "got a submenu element of id='" + previousId + "'");
+ is(
+ previousItem.nodeName,
+ "menu",
+ "submenu element of id='" + previousId + "' has expected nodeName"
+ );
+ checkMenu(previousItem.menupopup, expectedItem, data, i);
+ } else if (previousItem && previousItem.nodeName == "menugroup") {
+ ok(expectedItem.length, "menugroup must not be empty");
+ for (var j = 0; j < expectedItem.length / 2; j++) {
+ checkMenuItem(
+ actualItems[i][j][0],
+ actualItems[i][j][1],
+ expectedItem[j * 2],
+ expectedItem[j * 2 + 1],
+ i + j * 2
+ );
+ }
+ i += j;
+ } else {
+ ok(false, "previous item is not a menu or menugroup");
+ }
+ } else {
+ checkMenuItem(
+ actualItem,
+ actualEnabled,
+ expectedItem,
+ expectedEnabled,
+ i
+ );
+ }
+ }
+ // Could find unexpected extra items at the end...
+ is(
+ actualItems.length,
+ expectedItems.length,
+ "checking expected number of menu entries"
+ );
+}
+
+let lastElementSelector = null;
+/**
+ * Right-clicks on the element that matches `selector` and checks the
+ * context menu that appears against the `menuItems` array.
+ *
+ * @param {String} selector
+ * A selector passed to querySelector to find
+ * the element that will be referenced.
+ * @param {Array} menuItems
+ * An array of menuitem ids and their associated enabled state. A state
+ * of null means that it will be ignored. Ids of '---' are used for
+ * menuseparators.
+ * @param {Object} options, optional
+ * skipFocusChange: don't move focus to the element before test, useful
+ * if you want to delay spell-check initialization
+ * offsetX: horizontal mouse offset from the top-left corner of
+ * the element, optional
+ * offsetY: vertical mouse offset from the top-left corner of the
+ * element, optional
+ * centered: if true, mouse position is centered in element, defaults
+ * to true if offsetX and offsetY are not provided
+ * waitForSpellCheck: wait until spellcheck is initialized before
+ * starting test
+ * preCheckContextMenuFn: callback to run before opening menu
+ * onContextMenuShown: callback to run when the context menu is shown
+ * postCheckContextMenuFn: callback to run after opening menu
+ * keepMenuOpen: if true, we do not call hidePopup, the consumer is
+ * responsible for calling it.
+ * @return {Promise} resolved after the test finishes
+ */
+async function test_contextmenu(selector, menuItems, options = {}) {
+ contextMenu = document.getElementById("contentAreaContextMenu");
+ is(contextMenu.state, "closed", "checking if popup is closed");
+
+ // Default to centered if no positioning is defined.
+ if (!options.offsetX && !options.offsetY) {
+ options.centered = true;
+ }
+
+ if (!options.skipFocusChange) {
+ await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [[lastElementSelector, selector]],
+ async function ([contentLastElementSelector, contentSelector]) {
+ if (contentLastElementSelector) {
+ let contentLastElement = content.document.querySelector(
+ contentLastElementSelector
+ );
+ contentLastElement.blur();
+ }
+ let element = content.document.querySelector(contentSelector);
+ element.focus();
+ }
+ );
+ lastElementSelector = selector;
+ info(`Moved focus to ${selector}`);
+ }
+
+ if (options.preCheckContextMenuFn) {
+ await options.preCheckContextMenuFn();
+ info("Completed preCheckContextMenuFn");
+ }
+
+ if (options.waitForSpellCheck) {
+ info("Waiting for spell check");
+ await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [selector],
+ async function (contentSelector) {
+ let { onSpellCheck } = ChromeUtils.importESModule(
+ "resource://testing-common/AsyncSpellCheckTestHelper.sys.mjs"
+ );
+ let element = content.document.querySelector(contentSelector);
+ await new Promise(resolve => onSpellCheck(element, resolve));
+ info("Spell check running");
+ }
+ );
+ }
+
+ let awaitPopupShown = BrowserTestUtils.waitForEvent(
+ contextMenu,
+ "popupshown"
+ );
+ await BrowserTestUtils.synthesizeMouse(
+ selector,
+ options.offsetX || 0,
+ options.offsetY || 0,
+ {
+ type: "contextmenu",
+ button: 2,
+ shiftkey: options.shiftkey,
+ centered: options.centered,
+ },
+ gBrowser.selectedBrowser
+ );
+ await awaitPopupShown;
+ info("Popup Shown");
+
+ if (options.onContextMenuShown) {
+ await options.onContextMenuShown();
+ info("Completed onContextMenuShown");
+ }
+
+ if (menuItems) {
+ if (Services.prefs.getBoolPref("devtools.inspector.enabled", true)) {
+ const inspectItems =
+ menuItems.includes("context-viewsource") ||
+ menuItems.includes("context-viewpartialsource-selection")
+ ? []
+ : ["---", null];
+ if (
+ Services.prefs.getBoolPref("devtools.accessibility.enabled", true) &&
+ (Services.prefs.getBoolPref("devtools.everOpened", false) ||
+ Services.prefs.getIntPref("devtools.selfxss.count", 0) > 0)
+ ) {
+ inspectItems.push("context-inspect-a11y", true);
+ }
+ inspectItems.push("context-inspect", true);
+
+ menuItems = menuItems.concat(inspectItems);
+ }
+
+ checkContextMenu(menuItems);
+ }
+
+ let awaitPopupHidden = BrowserTestUtils.waitForEvent(
+ contextMenu,
+ "popuphidden"
+ );
+
+ if (options.postCheckContextMenuFn) {
+ await options.postCheckContextMenuFn();
+ info("Completed postCheckContextMenuFn");
+ }
+
+ if (!options.keepMenuOpen) {
+ contextMenu.hidePopup();
+ await awaitPopupHidden;
+ }
+}
diff --git a/browser/base/content/test/contextMenu/ctxmenu-image.png b/browser/base/content/test/contextMenu/ctxmenu-image.png
new file mode 100644
index 0000000000..4c3be50847
--- /dev/null
+++ b/browser/base/content/test/contextMenu/ctxmenu-image.png
Binary files differ
diff --git a/browser/base/content/test/contextMenu/doggy.png b/browser/base/content/test/contextMenu/doggy.png
new file mode 100644
index 0000000000..73632d3229
--- /dev/null
+++ b/browser/base/content/test/contextMenu/doggy.png
Binary files differ
diff --git a/browser/base/content/test/contextMenu/file_bug1798178.html b/browser/base/content/test/contextMenu/file_bug1798178.html
new file mode 100644
index 0000000000..49e0092a4f
--- /dev/null
+++ b/browser/base/content/test/contextMenu/file_bug1798178.html
@@ -0,0 +1,5 @@
+<html>
+ <body>
+ <a href="https://example.org/browser/browser/base/content/test/contextMenu/bug1798178.sjs">Download Link</a>
+ </body>
+</html>
diff --git a/browser/base/content/test/contextMenu/firebird.png b/browser/base/content/test/contextMenu/firebird.png
new file mode 100644
index 0000000000..de5c22f8ce
--- /dev/null
+++ b/browser/base/content/test/contextMenu/firebird.png
Binary files differ
diff --git a/browser/base/content/test/contextMenu/firebird.png^headers^ b/browser/base/content/test/contextMenu/firebird.png^headers^
new file mode 100644
index 0000000000..2918fdbe5f
--- /dev/null
+++ b/browser/base/content/test/contextMenu/firebird.png^headers^
@@ -0,0 +1,2 @@
+HTTP 302 Found
+Location: doggy.png
diff --git a/browser/base/content/test/contextMenu/subtst_contextmenu.html b/browser/base/content/test/contextMenu/subtst_contextmenu.html
new file mode 100644
index 0000000000..2c263fbce4
--- /dev/null
+++ b/browser/base/content/test/contextMenu/subtst_contextmenu.html
@@ -0,0 +1,61 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Subtest for browser context menu</title>
+</head>
+<body>
+Browser context menu subtest.
+
+<div id="test-text">Lorem ipsum dolor sit amet, consectetuer adipiscing elit.</div>
+<a id="test-link" href="http://mozilla.com">Click the monkey!</a>
+<div id="shadow-host"></div>
+<a href="http://mozilla.com" style="display: block">
+ <span id="shadow-host-in-link"></span>
+</a>
+<script>
+document.getElementById("shadow-host").attachShadow({ mode: "closed" }).innerHTML =
+ "<a href='http://mozilla.com'>Click the monkey!</a>";
+document.getElementById("shadow-host-in-link").attachShadow({ mode: "closed" }).innerHTML =
+ "<span>Click the monkey!</span>";
+</script>
+<a id="test-mailto" href="mailto:codemonkey@mozilla.com">Mail the monkey!</a><br>
+<a id="test-tel" href="tel:555-123-4567">Call random number!</a><br>
+<input id="test-input"><br>
+<img id="test-image" src="ctxmenu-image.png">
+<svg>
+ <image id="test-svg-image" href="ctxmenu-image.png"/>
+</svg>
+<canvas id="test-canvas" width="100" height="100" style="background-color: blue"></canvas>
+<video controls id="test-video-ok" src="video.ogg" width="100" height="100" style="background-color: green"></video>
+<video id="test-audio-in-video" src="audio.ogg" width="100" height="100" style="background-color: red"></video>
+<video controls id="test-video-bad" src="bogus.duh" width="100" height="100" style="background-color: orange"></video>
+<video controls id="test-video-bad2" width="100" height="100" style="background-color: yellow">
+ <source src="bogus.duh" type="video/durrrr;">
+</video>
+<iframe id="test-iframe" width="98" height="98" style="border: 1px solid black"></iframe>
+<iframe id="test-video-in-iframe" src="video.ogg" width="98" height="98" style="border: 1px solid black"></iframe>
+<iframe id="test-audio-in-iframe" src="audio.ogg" width="98" height="98" style="border: 1px solid black"></iframe>
+<iframe id="test-image-in-iframe" src="ctxmenu-image.png" width="98" height="98" style="border: 1px solid black"></iframe>
+<iframe id="test-pdf-viewer-in-frame" src="file_pdfjs_test.pdf" width="100" height="100" style="border: 1px solid black"></iframe>
+<textarea id="test-textarea">chssseesbbbie</textarea> <!-- a weird word which generates only one suggestion -->
+<div id="test-contenteditable" contenteditable="true">chssseefsbbbie</div> <!-- a more weird word which generates no suggestions -->
+<div id="test-contenteditable-spellcheck-false" contenteditable="true" spellcheck="false">test</div> <!-- No Check Spelling menu item -->
+<div id="test-dom-full-screen">DOM full screen FTW</div>
+<div id="test-select-text">Lorem ipsum dolor sit amet, consectetuer adipiscing elit.</div>
+<div id="test-select-text-link">http://mozilla.com</div>
+<a id="test-image-link" href="#"><img src="ctxmenu-image.png"></a>
+<input id="test-select-input-text" type="text" value="input">
+<input id="test-select-input-text-type-password" type="password" value="password">
+<img id="test-longdesc" src="ctxmenu-image.png" longdesc="http://www.mozilla.org"></embed>
+<iframe id="test-srcdoc" width="98" height="98" srcdoc="Hello World" style="border: 1px solid black"></iframe>
+<svg id="svg-with-link" width=10 height=10><a xlink:href="http://example.com/"><circle cx="50%" cy="50%" r="50%" fill="blue"/></a></svg>
+<svg id="svg-with-link2" width=10 height=10><a xlink:href="http://example.com/" xlink:type="simple"><circle cx="50%" cy="50%" r="50%" fill="green"/></a></svg>
+<svg id="svg-with-link3" width=10 height=10><a href="http://example.com/"><circle cx="50%" cy="50%" r="50%" fill="red"/></a></svg>
+<svg id="svg-with-relative-link" width=10 height=10><a xlink:href="/"><circle cx="50%" cy="50%" r="50%" fill="blue"/></a></svg>
+<svg id="svg-with-relative-link2" width=10 height=10><a xlink:href="/" xlink:type="simple"><circle cx="50%" cy="50%" r="50%" fill="green"/></a></svg>
+<svg id="svg-with-relative-link3" width=10 height=10><a href="/"><circle cx="50%" cy="50%" r="50%" fill="red"/></a></svg>
+<span id="test-background-image" style="background-image: url('ctxmenu-image.png')">Text with background
+ <a id='test-background-image-link' href="about:blank">image</a>
+ .
+</span></body>
+</html>
diff --git a/browser/base/content/test/contextMenu/subtst_contextmenu_input.html b/browser/base/content/test/contextMenu/subtst_contextmenu_input.html
new file mode 100644
index 0000000000..a34cbbe122
--- /dev/null
+++ b/browser/base/content/test/contextMenu/subtst_contextmenu_input.html
@@ -0,0 +1,30 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Subtest for browser context menu</title>
+</head>
+<body>
+ Browser context menu subtest.
+ <input id="input_text">
+ <input id="input_spellcheck_no_value">
+ <input id="input_spellcheck_incorrect" spellcheck="true" value="prodkjfgigrty">
+ <input id="input_spellcheck_correct" spellcheck="true" value="foo">
+ <input id="input_disabled" disabled="true">
+ <input id="input_password">
+ <input id="input_email" type="email">
+ <input id="input_tel" type="tel">
+ <input id="input_url" type="url">
+ <input id="input_number" type="number">
+ <input id="input_date" type="date">
+ <input id="input_time" type="time">
+ <input id="input_color" type="color">
+ <input id="input_range" type="range">
+ <input id="input_search" type="search">
+ <input id="input_datetime" type="datetime">
+ <input id="input_month" type="month">
+ <input id="input_week" type="week">
+ <input id="input_datetime-local" type="datetime-local">
+ <input id="input_readonly" readonly="true">
+ <input id="input_username" name="username">
+</body>
+</html>
diff --git a/browser/base/content/test/contextMenu/subtst_contextmenu_keyword.html b/browser/base/content/test/contextMenu/subtst_contextmenu_keyword.html
new file mode 100644
index 0000000000..a0f2b0584f
--- /dev/null
+++ b/browser/base/content/test/contextMenu/subtst_contextmenu_keyword.html
@@ -0,0 +1,17 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Subtest for browser context menu</title>
+</head>
+<body>
+ Browser context menu subtest.
+ <input id="input_text_no_form">
+ <form id="form_with_password">
+ <input id="login_text">
+ <input id="input_password" type="password">
+ </form>
+ <form id="form_without_password">
+ <input id="search_text">
+ </form>
+</body>
+</html>
diff --git a/browser/base/content/test/contextMenu/subtst_contextmenu_webext.html b/browser/base/content/test/contextMenu/subtst_contextmenu_webext.html
new file mode 100644
index 0000000000..ac3b5415dd
--- /dev/null
+++ b/browser/base/content/test/contextMenu/subtst_contextmenu_webext.html
@@ -0,0 +1,12 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Subtest for browser context menu</title>
+</head>
+<body>
+ Browser context menu subtest.
+ <a href="moz-extension://foo-bar/tab.html" id="link">Link to an extension resource</a>
+ <video src="moz-extension://foo-bar/video.ogg" id="video"></video>
+</body>
+</html>
diff --git a/browser/base/content/test/contextMenu/subtst_contextmenu_xul.xhtml b/browser/base/content/test/contextMenu/subtst_contextmenu_xul.xhtml
new file mode 100644
index 0000000000..c8ff92a76c
--- /dev/null
+++ b/browser/base/content/test/contextMenu/subtst_contextmenu_xul.xhtml
@@ -0,0 +1,9 @@
+<?xml version="1.0"?>
+<!-- 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/. -->
+
+<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml">
+ <label id="test-xul-text-link-label" is="text-link" value="XUL text-link label" href="https://www.mozilla.com"/>
+</window>
diff --git a/browser/base/content/test/contextMenu/test_contextmenu_iframe.html b/browser/base/content/test/contextMenu/test_contextmenu_iframe.html
new file mode 100644
index 0000000000..cf5b871ecd
--- /dev/null
+++ b/browser/base/content/test/contextMenu/test_contextmenu_iframe.html
@@ -0,0 +1,11 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Subtest for browser context menu iframes</title>
+</head>
+<body>
+Browser context menu iframe subtest.
+
+<iframe src="https://example.com/" id="iframe"></iframe>
+</body>
+</html>
diff --git a/browser/base/content/test/contextMenu/test_contextmenu_links.html b/browser/base/content/test/contextMenu/test_contextmenu_links.html
new file mode 100644
index 0000000000..650c136f99
--- /dev/null
+++ b/browser/base/content/test/contextMenu/test_contextmenu_links.html
@@ -0,0 +1,14 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Subtest for browser context menu links</title>
+</head>
+<body>
+Browser context menu link subtest.
+
+<a id="test-link" href="https://example.com">Click the monkey!</a>
+<a id="test-image-link" href="/"><img src="ctxmenu-image.png"></a>
+<svg id="svg-with-link" width=10 height=10><a xlink:href="https://example.com/"><circle cx="50%" cy="50%" r="50%" fill="blue"/></a></svg>
+<svg id="svg-with-relative-link" width=10 height=10><a xlink:href="/"><circle cx="50%" cy="50%" r="50%" fill="blue"/></a></svg>
+</body>
+</html>
diff --git a/browser/base/content/test/contextMenu/test_view_image_inline_svg.html b/browser/base/content/test/contextMenu/test_view_image_inline_svg.html
new file mode 100644
index 0000000000..42a41e42cb
--- /dev/null
+++ b/browser/base/content/test/contextMenu/test_view_image_inline_svg.html
@@ -0,0 +1,15 @@
+<!DOCTYPE html>
+<html><head>
+<style>
+body {
+ background: fixed #222 url("data:image/svg+xml;base64,PHN2ZyBoZWlnaHQ9IjUwMCIgd2lkdGg9IjUwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayI+PGZpbHRlciBpZD0iYSI+PGZlVHVyYnVsZW5jZSBiYXNlRnJlcXVlbmN5PSIuOSIgbnVtT2N0YXZlcz0iMTAiIHN0aXRjaFRpbGVzPSJzdGl0Y2giIHR5cGU9ImZyYWN0YWxOb2lzZSIvPjwvZmlsdGVyPjxwYXRoIGQ9Im0wIDBoNTAwdjUwMGgtNTAweiIgZmlsbD0iIzExMSIvPjxwYXRoIGQ9Im0wIDBoNTAwdjUwMGgtNTAweiIgZmlsdGVyPSJ1cmwoI2EpIiBvcGFjaXR5PSIuMiIvPjwvc3ZnPgo=");
+ background-size: cover;
+ color: #ccc;
+}
+</style>
+</head>
+<body>
+This page has an inline SVG image as a background.
+
+
+</body></html>
diff --git a/browser/base/content/test/contextMenu/test_view_image_revoked_cached_blob.html b/browser/base/content/test/contextMenu/test_view_image_revoked_cached_blob.html
new file mode 100644
index 0000000000..ba130c793a
--- /dev/null
+++ b/browser/base/content/test/contextMenu/test_view_image_revoked_cached_blob.html
@@ -0,0 +1,40 @@
+<!doctype html>
+<html class="wait">
+<meta charset="utf-8">
+<title>currentSrc is right even if underlying image is a shared blob</title>
+<img id="first">
+<img id="second">
+<script>
+(async function() {
+ let canvas = document.createElement("canvas");
+ canvas.width = 100;
+ canvas.height = 100;
+ let ctx = canvas.getContext("2d");
+ ctx.fillStyle = "green";
+ ctx.rect(0, 0, 100, 100);
+ ctx.fill();
+
+ let blob = await new Promise(resolve => canvas.toBlob(resolve));
+
+ let first = document.querySelector("#first");
+ let second = document.querySelector("#second");
+
+ let firstLoad = new Promise(resolve => {
+ first.addEventListener("load", resolve, { once: true });
+ });
+
+ let secondLoad = new Promise(resolve => {
+ second.addEventListener("load", resolve, { once: true });
+ });
+
+ let uri1 = URL.createObjectURL(blob);
+ let uri2 = URL.createObjectURL(blob);
+ first.src = uri1;
+ second.src = uri2;
+
+ await firstLoad;
+ await secondLoad;
+ URL.revokeObjectURL(uri1);
+ document.documentElement.className = "";
+}());
+</script>
diff --git a/browser/base/content/test/favicons/accept.html b/browser/base/content/test/favicons/accept.html
new file mode 100644
index 0000000000..4bb00243b3
--- /dev/null
+++ b/browser/base/content/test/favicons/accept.html
@@ -0,0 +1,9 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test file for accept header</title>
+ <link rel="icon" href="accept.sjs">
+</head>
+<body>
+</body>
+</html>
diff --git a/browser/base/content/test/favicons/accept.sjs b/browser/base/content/test/favicons/accept.sjs
new file mode 100644
index 0000000000..3e798ba817
--- /dev/null
+++ b/browser/base/content/test/favicons/accept.sjs
@@ -0,0 +1,15 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+function handleRequest(request, response) {
+ // Doesn't seem any way to get the value from prefs from here. :(
+ let expected = "image/avif,image/webp,*/*";
+ if (expected != request.getHeader("Accept")) {
+ response.setStatusLine(request.httpVersion, 404, "Not Found");
+ return;
+ }
+
+ response.setStatusLine(request.httpVersion, 302, "Moved Temporarily");
+ response.setHeader("Location", "moz.png");
+}
diff --git a/browser/base/content/test/favicons/auth_test.html b/browser/base/content/test/favicons/auth_test.html
new file mode 100644
index 0000000000..90b78432f8
--- /dev/null
+++ b/browser/base/content/test/favicons/auth_test.html
@@ -0,0 +1,11 @@
+<!DOCTYPE HTML>
+<html>
+ <head>
+ <meta charset='utf-8'>
+ <title>Favicon Test for http auth</title>
+ <link rel="icon" type="image/png" href="auth_test.png" />
+ </head>
+ <body>
+ Favicon!!
+ </body>
+</html>
diff --git a/browser/base/content/test/favicons/auth_test.png b/browser/base/content/test/favicons/auth_test.png
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/browser/base/content/test/favicons/auth_test.png
diff --git a/browser/base/content/test/favicons/auth_test.png^headers^ b/browser/base/content/test/favicons/auth_test.png^headers^
new file mode 100644
index 0000000000..5024ae1c4b
--- /dev/null
+++ b/browser/base/content/test/favicons/auth_test.png^headers^
@@ -0,0 +1,2 @@
+HTTP 401 Unauthorized
+WWW-Authenticate: Basic realm="Favicon auth"
diff --git a/browser/base/content/test/favicons/blank.html b/browser/base/content/test/favicons/blank.html
new file mode 100644
index 0000000000..297eb8cd78
--- /dev/null
+++ b/browser/base/content/test/favicons/blank.html
@@ -0,0 +1,6 @@
+<!DOCTYPE html>
+
+<html>
+<head>
+</head>
+</html>
diff --git a/browser/base/content/test/favicons/browser.ini b/browser/base/content/test/favicons/browser.ini
new file mode 100644
index 0000000000..a5fc30ecfd
--- /dev/null
+++ b/browser/base/content/test/favicons/browser.ini
@@ -0,0 +1,113 @@
+[DEFAULT]
+support-files =
+ head.js
+ discovery.html
+ moz.png
+ rich_moz_1.png
+ rich_moz_2.png
+ file_bug970276_favicon1.ico
+ file_generic_favicon.ico
+ file_with_favicon.html
+prefs =
+ browser.chrome.guess_favicon=true
+
+[browser_bug408415.js]
+[browser_bug550565.js]
+[browser_favicon_accept.js]
+support-files =
+ accept.html
+ accept.sjs
+[browser_favicon_auth.js]
+support-files =
+ auth_test.html
+ auth_test.png
+ auth_test.png^headers^
+[browser_favicon_cache.js]
+support-files =
+ cookie_favicon.sjs
+ cookie_favicon.html
+[browser_favicon_change.js]
+support-files =
+ file_favicon_change.html
+[browser_favicon_change_not_in_document.js]
+support-files =
+ file_favicon_change_not_in_document.html
+[browser_favicon_credentials.js]
+https_first_disabled = true
+support-files =
+ credentials1.html
+ credentials2.html
+ credentials.png
+ credentials.png^headers^
+[browser_favicon_crossorigin.js]
+https_first_disabled = true
+support-files =
+ crossorigin.html
+ crossorigin.png
+ crossorigin.png^headers^
+[browser_favicon_load.js]
+https_first_disabled = true
+support-files =
+ file_favicon.html
+ file_favicon.png
+ file_favicon.png^headers^
+ file_favicon_thirdParty.html
+[browser_favicon_nostore.js]
+https_first_disabled = true
+support-files =
+ no-store.html
+ no-store.png
+ no-store.png^headers^
+[browser_favicon_referer.js]
+support-files =
+ file_favicon_no_referrer.html
+[browser_favicon_store.js]
+support-files =
+ datauri-favicon.html
+ file_favicon.html
+ file_favicon.png
+ file_favicon.png^headers^
+[browser_icon_discovery.js]
+[browser_invalid_href_fallback.js]
+https_first_disabled = true
+support-files =
+ file_invalid_href.html
+[browser_missing_favicon.js]
+support-files =
+ blank.html
+[browser_mixed_content.js]
+support-files =
+ file_insecure_favicon.html
+ file_favicon.png
+[browser_multiple_icons_in_short_timeframe.js]
+[browser_oversized.js]
+support-files =
+ large_favicon.html
+ large.png
+[browser_preferred_icons.js]
+support-files =
+ icon.svg
+[browser_redirect.js]
+support-files =
+ file_favicon_redirect.html
+ file_favicon_redirect.ico
+ file_favicon_redirect.ico^headers^
+[browser_rich_icons.js]
+support-files =
+ file_rich_icon.html
+ file_mask_icon.html
+[browser_rooticon.js]
+https_first_disabled = true
+support-files =
+ blank.html
+[browser_subframe_favicons_not_used.js]
+support-files =
+ file_bug970276_popup1.html
+ file_bug970276_popup2.html
+ file_bug970276_favicon2.ico
+[browser_title_flicker.js]
+https_first_disabled = true
+support-files =
+ file_with_slow_favicon.html
+ blank.html
+ file_favicon.png
diff --git a/browser/base/content/test/favicons/browser_bug408415.js b/browser/base/content/test/favicons/browser_bug408415.js
new file mode 100644
index 0000000000..1526477db3
--- /dev/null
+++ b/browser/base/content/test/favicons/browser_bug408415.js
@@ -0,0 +1,34 @@
+add_task(async function test() {
+ let testPath = getRootDirectory(gTestPath);
+
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: "about:blank" },
+ async function (tabBrowser) {
+ const URI = testPath + "file_with_favicon.html";
+ const expectedIcon = testPath + "file_generic_favicon.ico";
+ let faviconPromise = waitForLinkAvailable(tabBrowser);
+
+ BrowserTestUtils.loadURIString(tabBrowser, URI);
+
+ let iconURI = await faviconPromise;
+ is(iconURI, expectedIcon, "Correct icon before pushState.");
+
+ faviconPromise = waitForLinkAvailable(tabBrowser);
+
+ await SpecialPowers.spawn(tabBrowser, [], function () {
+ content.location.href += "#foo";
+ });
+
+ TestUtils.executeSoon(() => {
+ faviconPromise.cancel();
+ });
+
+ try {
+ await faviconPromise;
+ ok(false, "Should not have seen a new icon load.");
+ } catch (e) {
+ ok(true, "Should have been able to cancel the promise.");
+ }
+ }
+ );
+});
diff --git a/browser/base/content/test/favicons/browser_bug550565.js b/browser/base/content/test/favicons/browser_bug550565.js
new file mode 100644
index 0000000000..32a7527bbf
--- /dev/null
+++ b/browser/base/content/test/favicons/browser_bug550565.js
@@ -0,0 +1,35 @@
+add_task(async function test() {
+ let testPath = getRootDirectory(gTestPath);
+
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: "about:blank" },
+ async function (tabBrowser) {
+ const URI = testPath + "file_with_favicon.html";
+ const expectedIcon = testPath + "file_generic_favicon.ico";
+ let faviconPromise = waitForLinkAvailable(tabBrowser);
+
+ BrowserTestUtils.loadURIString(tabBrowser, URI);
+
+ let iconURI = await faviconPromise;
+ is(iconURI, expectedIcon, "Correct icon before pushState.");
+
+ faviconPromise = waitForLinkAvailable(tabBrowser);
+
+ await SpecialPowers.spawn(tabBrowser, [], function () {
+ content.history.pushState("page2", "page2", "page2");
+ });
+
+ // We've navigated and shouldn't get a call to onLinkIconAvailable.
+ TestUtils.executeSoon(() => {
+ faviconPromise.cancel();
+ });
+
+ try {
+ await faviconPromise;
+ ok(false, "Should not have seen a new icon load.");
+ } catch (e) {
+ ok(true, "Should have been able to cancel the promise.");
+ }
+ }
+ );
+});
diff --git a/browser/base/content/test/favicons/browser_favicon_accept.js b/browser/base/content/test/favicons/browser_favicon_accept.js
new file mode 100644
index 0000000000..dc59a406b5
--- /dev/null
+++ b/browser/base/content/test/favicons/browser_favicon_accept.js
@@ -0,0 +1,30 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const ROOT = getRootDirectory(gTestPath).replace(
+ "chrome://mochitest/content/",
+ "http://mochi.test:8888/"
+);
+
+add_task(async () => {
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: "about:blank" },
+ async browser => {
+ let faviconPromise = waitForFaviconMessage(true, `${ROOT}accept.sjs`);
+
+ BrowserTestUtils.loadURIString(browser, ROOT + "accept.html");
+ await BrowserTestUtils.browserLoaded(browser);
+
+ try {
+ let result = await faviconPromise;
+ Assert.equal(
+ result.iconURL,
+ `${ROOT}accept.sjs`,
+ "Should have seen the icon"
+ );
+ } catch (e) {
+ Assert.ok(false, "Favicon load failed.");
+ }
+ }
+ );
+});
diff --git a/browser/base/content/test/favicons/browser_favicon_auth.js b/browser/base/content/test/favicons/browser_favicon_auth.js
new file mode 100644
index 0000000000..fb0e75f2ab
--- /dev/null
+++ b/browser/base/content/test/favicons/browser_favicon_auth.js
@@ -0,0 +1,27 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const ROOT = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content/",
+ "http://mochi.test:8888/"
+);
+
+add_task(async () => {
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: "about:blank" },
+ async browser => {
+ let faviconPromise = waitForFaviconMessage(true, `${ROOT}auth_test.png`);
+
+ BrowserTestUtils.loadURIString(browser, `${ROOT}auth_test.html`);
+ await BrowserTestUtils.browserLoaded(browser);
+
+ await Assert.rejects(
+ faviconPromise,
+ result => {
+ return result.iconURL == `${ROOT}auth_test.png`;
+ },
+ "Should have failed to load the icon."
+ );
+ }
+ );
+});
diff --git a/browser/base/content/test/favicons/browser_favicon_cache.js b/browser/base/content/test/favicons/browser_favicon_cache.js
new file mode 100644
index 0000000000..903f038d6c
--- /dev/null
+++ b/browser/base/content/test/favicons/browser_favicon_cache.js
@@ -0,0 +1,50 @@
+add_task(async () => {
+ const testPath =
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com/browser/browser/base/content/test/favicons/cookie_favicon.html";
+ const resetPath =
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com/browser/browser/base/content/test/favicons/cookie_favicon.sjs?reset";
+
+ let tab = BrowserTestUtils.addTab(gBrowser, testPath);
+ gBrowser.selectedTab = tab;
+ let browser = tab.linkedBrowser;
+
+ let faviconPromise = waitForLinkAvailable(browser);
+ await BrowserTestUtils.browserLoaded(browser);
+ await faviconPromise;
+ let cookies = Services.cookies.getCookiesFromHost(
+ "example.com",
+ browser.contentPrincipal.originAttributes
+ );
+ let seenCookie = false;
+ for (let cookie of cookies) {
+ if (cookie.name == "faviconCookie") {
+ seenCookie = true;
+ is(cookie.value, "1", "Should have seen the right initial cookie.");
+ }
+ }
+ ok(seenCookie, "Should have seen the cookie.");
+
+ faviconPromise = waitForLinkAvailable(browser);
+ BrowserTestUtils.loadURIString(browser, testPath);
+ await BrowserTestUtils.browserLoaded(browser);
+ await faviconPromise;
+ cookies = Services.cookies.getCookiesFromHost(
+ "example.com",
+ browser.contentPrincipal.originAttributes
+ );
+ seenCookie = false;
+ for (let cookie of cookies) {
+ if (cookie.name == "faviconCookie") {
+ seenCookie = true;
+ is(cookie.value, "1", "Should have seen the cached cookie.");
+ }
+ }
+ ok(seenCookie, "Should have seen the cookie.");
+
+ // Reset the cookie so if this test is run again it will still pass.
+ await fetch(resetPath);
+
+ BrowserTestUtils.removeTab(tab);
+});
diff --git a/browser/base/content/test/favicons/browser_favicon_change.js b/browser/base/content/test/favicons/browser_favicon_change.js
new file mode 100644
index 0000000000..8faf266665
--- /dev/null
+++ b/browser/base/content/test/favicons/browser_favicon_change.js
@@ -0,0 +1,33 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TEST_ROOT =
+ "http://mochi.test:8888/browser/browser/base/content/test/favicons/";
+const TEST_URL = TEST_ROOT + "file_favicon_change.html";
+
+add_task(async function () {
+ let extraTab = (gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser));
+ let haveChanged = waitForFavicon(
+ extraTab.linkedBrowser,
+ TEST_ROOT + "file_bug970276_favicon1.ico"
+ );
+
+ BrowserTestUtils.loadURIString(extraTab.linkedBrowser, TEST_URL);
+ await BrowserTestUtils.browserLoaded(extraTab.linkedBrowser);
+ await haveChanged;
+
+ haveChanged = waitForFavicon(extraTab.linkedBrowser, TEST_ROOT + "moz.png");
+
+ SpecialPowers.spawn(extraTab.linkedBrowser, [], function () {
+ let ev = new content.CustomEvent("PleaseChangeFavicon", {});
+ content.dispatchEvent(ev);
+ });
+
+ await haveChanged;
+
+ ok(true, "Saw all the icons we expected.");
+
+ gBrowser.removeTab(extraTab);
+});
diff --git a/browser/base/content/test/favicons/browser_favicon_change_not_in_document.js b/browser/base/content/test/favicons/browser_favicon_change_not_in_document.js
new file mode 100644
index 0000000000..34c5ae8baf
--- /dev/null
+++ b/browser/base/content/test/favicons/browser_favicon_change_not_in_document.js
@@ -0,0 +1,55 @@
+"use strict";
+
+const TEST_ROOT =
+ "http://mochi.test:8888/browser/browser/base/content/test/favicons/";
+const TEST_URL = TEST_ROOT + "file_favicon_change_not_in_document.html";
+
+/*
+ * This test tests a link element won't fire DOMLinkChanged/DOMLinkAdded unless
+ * it is added to the DOM. See more details in bug 1083895.
+ *
+ * Note that there is debounce logic in ContentLinkHandler.jsm, adding a new
+ * icon link after the icon parsing timeout will trigger a new icon extraction
+ * cycle. Hence, there should be two favicons loads in this test as it appends
+ * a new link to the DOM in the timeout callback defined in the test HTML page.
+ * However, the not-yet-added link element with href as "http://example.org/other-icon"
+ * should not fire the DOMLinkAdded event, nor should it fire the DOMLinkChanged
+ * event after its href gets updated later.
+ */
+add_task(async function () {
+ let extraTab = (gBrowser.selectedTab = BrowserTestUtils.addTab(
+ gBrowser,
+ TEST_ROOT
+ ));
+ let domLinkAddedFired = 0;
+ let domLinkChangedFired = 0;
+ const linkAddedHandler = event => domLinkAddedFired++;
+ const linkChangedhandler = event => domLinkChangedFired++;
+ BrowserTestUtils.addContentEventListener(
+ gBrowser.selectedBrowser,
+ "DOMLinkAdded",
+ linkAddedHandler
+ );
+ BrowserTestUtils.addContentEventListener(
+ gBrowser.selectedBrowser,
+ "DOMLinkChanged",
+ linkChangedhandler
+ );
+
+ let expectedFavicon = TEST_ROOT + "file_generic_favicon.ico";
+ let faviconPromise = waitForFavicon(extraTab.linkedBrowser, expectedFavicon);
+
+ BrowserTestUtils.loadURIString(extraTab.linkedBrowser, TEST_URL);
+ await BrowserTestUtils.browserLoaded(extraTab.linkedBrowser);
+
+ await faviconPromise;
+
+ is(
+ domLinkAddedFired,
+ 2,
+ "Should fire the correct number of DOMLinkAdded event."
+ );
+ is(domLinkChangedFired, 0, "Should not fire any DOMLinkChanged event.");
+
+ gBrowser.removeTab(extraTab);
+});
diff --git a/browser/base/content/test/favicons/browser_favicon_credentials.js b/browser/base/content/test/favicons/browser_favicon_credentials.js
new file mode 100644
index 0000000000..405c620c8a
--- /dev/null
+++ b/browser/base/content/test/favicons/browser_favicon_credentials.js
@@ -0,0 +1,89 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const ROOT_DIR = getRootDirectory(gTestPath);
+
+const EXAMPLE_NET_ROOT = ROOT_DIR.replace(
+ "chrome://mochitests/content/",
+ "https://example.net/"
+);
+
+const EXAMPLE_COM_ROOT = ROOT_DIR.replace(
+ "chrome://mochitests/content/",
+ "https://example.com/"
+);
+
+const FAVICON_URL = EXAMPLE_COM_ROOT + "credentials.png";
+
+// Bug 1746646: Make mochitests work with TCP enabled (cookieBehavior = 5)
+// All instances of addPermission and removePermission set up 3rd-party storage
+// access in a way that allows the test to proceed with TCP enabled.
+
+function run_test(url, shouldHaveCookies, description) {
+ add_task(async () => {
+ await SpecialPowers.addPermission(
+ "3rdPartyStorage^https://example.com",
+ true,
+ url
+ );
+
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: "about:blank" },
+ async browser => {
+ const faviconPromise = waitForFaviconMessage(true, FAVICON_URL);
+
+ BrowserTestUtils.loadURIString(browser, url);
+ await BrowserTestUtils.browserLoaded(browser);
+
+ await faviconPromise;
+
+ const seenCookie = Services.cookies
+ .getCookiesFromHost(
+ "example.com", // the icon's host, not the page's
+ browser.contentPrincipal.originAttributes
+ )
+ .some(cookie => cookie.name == "faviconCookie2");
+
+ // Clean up.
+ Services.cookies.removeAll();
+ Services.cache2.clear();
+
+ if (shouldHaveCookies) {
+ Assert.ok(
+ seenCookie,
+ `Should have seen the cookie (${description}).`
+ );
+ } else {
+ Assert.ok(
+ !seenCookie,
+ `Should have not seen the cookie (${description}).`
+ );
+ }
+ }
+ );
+ await SpecialPowers.removePermission(
+ "3rdPartyStorage^https://example.com",
+ url
+ );
+ });
+}
+
+// crossorigin="" only has credentials in the same-origin case
+run_test(`${EXAMPLE_NET_ROOT}credentials1.html`, false, "anonymous, remote");
+run_test(
+ `${EXAMPLE_COM_ROOT}credentials1.html`,
+ true,
+ "anonymous, same-origin"
+);
+
+// crossorigin="use-credentials" always has them
+run_test(
+ `${EXAMPLE_NET_ROOT}credentials2.html`,
+ true,
+ "use-credentials, remote"
+);
+run_test(
+ `${EXAMPLE_COM_ROOT}credentials2.html`,
+ true,
+ "use-credentials, same-origin"
+);
diff --git a/browser/base/content/test/favicons/browser_favicon_crossorigin.js b/browser/base/content/test/favicons/browser_favicon_crossorigin.js
new file mode 100644
index 0000000000..c1ae18f765
--- /dev/null
+++ b/browser/base/content/test/favicons/browser_favicon_crossorigin.js
@@ -0,0 +1,61 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const ROOT_DIR = getRootDirectory(gTestPath);
+
+const MOCHI_ROOT = ROOT_DIR.replace(
+ "chrome://mochitests/content/",
+ "http://mochi.test:8888/"
+);
+
+const EXAMPLE_NET_ROOT = ROOT_DIR.replace(
+ "chrome://mochitests/content/",
+ "http://example.net/"
+);
+
+const EXAMPLE_COM_ROOT = ROOT_DIR.replace(
+ "chrome://mochitests/content/",
+ "http://example.com/"
+);
+
+const FAVICON_URL = EXAMPLE_COM_ROOT + "crossorigin.png";
+
+function run_test(root, shouldSucceed, description) {
+ add_task(async () => {
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: "about:blank" },
+ async browser => {
+ const faviconPromise = waitForFaviconMessage(true, FAVICON_URL);
+
+ BrowserTestUtils.loadURIString(browser, `${root}crossorigin.html`);
+ await BrowserTestUtils.browserLoaded(browser);
+
+ if (shouldSucceed) {
+ try {
+ const result = await faviconPromise;
+ Assert.equal(
+ result.iconURL,
+ FAVICON_URL,
+ `Should have seen the icon (${description}).`
+ );
+ } catch (e) {
+ Assert.ok(false, `Favicon load failed (${description}).`);
+ }
+ } else {
+ await Assert.rejects(
+ faviconPromise,
+ result => result.iconURL == FAVICON_URL,
+ `Should have failed to load the icon (${description}).`
+ );
+ }
+ }
+ );
+ });
+}
+
+// crossorigin.png only allows CORS for MOCHI_ROOT.
+run_test(EXAMPLE_NET_ROOT, false, "remote origin not allowed");
+run_test(MOCHI_ROOT, true, "remote origin allowed");
+
+// Same-origin but with the crossorigin attribute.
+run_test(EXAMPLE_COM_ROOT, true, "same-origin");
diff --git a/browser/base/content/test/favicons/browser_favicon_load.js b/browser/base/content/test/favicons/browser_favicon_load.js
new file mode 100644
index 0000000000..f948b681b0
--- /dev/null
+++ b/browser/base/content/test/favicons/browser_favicon_load.js
@@ -0,0 +1,168 @@
+/**
+ * Bug 1247843 - A test case for testing whether the channel used to load favicon
+ * has correct classFlags.
+ * Note that this test is modified based on browser_favicon_userContextId.js.
+ */
+
+const CC = Components.Constructor;
+
+// eslint-disable-next-line @microsoft/sdl/no-insecure-url
+const TEST_SITE = "http://example.net";
+const TEST_THIRD_PARTY_SITE = "http://mochi.test:8888";
+
+const TEST_PAGE =
+ TEST_SITE + "/browser/browser/base/content/test/favicons/file_favicon.html";
+const FAVICON_URI =
+ TEST_SITE + "/browser/browser/base/content/test/favicons/file_favicon.png";
+const TEST_THIRD_PARTY_PAGE =
+ TEST_SITE +
+ "/browser/browser/base/content/test/favicons/file_favicon_thirdParty.html";
+const THIRD_PARTY_FAVICON_URI =
+ TEST_THIRD_PARTY_SITE +
+ "/browser/browser/base/content/test/favicons/file_favicon.png";
+
+ChromeUtils.defineESModuleGetters(this, {
+ PlacesTestUtils: "resource://testing-common/PlacesTestUtils.sys.mjs",
+ PromiseUtils: "resource://gre/modules/PromiseUtils.sys.mjs",
+});
+
+let systemPrincipal = Services.scriptSecurityManager.getSystemPrincipal();
+
+function clearAllImageCaches() {
+ var tools = Cc["@mozilla.org/image/tools;1"].getService(Ci.imgITools);
+ var imageCache = tools.getImgCacheForDocument(window.document);
+ imageCache.clearCache(true); // true=chrome
+ imageCache.clearCache(false); // false=content
+}
+
+function clearAllPlacesFavicons() {
+ return new Promise(resolve => {
+ Services.obs.addObserver(function observer() {
+ Services.obs.removeObserver(observer, "places-favicons-expired");
+ resolve();
+ }, "places-favicons-expired");
+
+ PlacesUtils.favicons.expireAllFavicons();
+ });
+}
+
+function FaviconObserver(aPageURI, aFaviconURL, aTailingEnabled) {
+ this.reset(aPageURI, aFaviconURL, aTailingEnabled);
+}
+
+FaviconObserver.prototype = {
+ observe(aSubject, aTopic, aData) {
+ // Make sure that the topic is 'http-on-modify-request'.
+ if (aTopic === "http-on-modify-request") {
+ let httpChannel = aSubject.QueryInterface(Ci.nsIHttpChannel);
+ let reqLoadInfo = httpChannel.loadInfo;
+ // Make sure this is a favicon request.
+ if (httpChannel.URI.spec !== this._faviconURL) {
+ return;
+ }
+
+ let cos = aSubject.QueryInterface(Ci.nsIClassOfService);
+ if (!cos) {
+ ok(false, "Http channel should implement nsIClassOfService.");
+ return;
+ }
+
+ if (!reqLoadInfo) {
+ ok(false, "Should have load info.");
+ return;
+ }
+
+ let haveTailFlag = !!(cos.classFlags & Ci.nsIClassOfService.Tail);
+ info("classFlags=" + cos.classFlags);
+ is(haveTailFlag, this._tailingEnabled, "Should have correct cos flag.");
+ } else {
+ ok(false, "Received unexpected topic: ", aTopic);
+ }
+
+ this._faviconLoaded.resolve();
+ },
+
+ reset(aPageURI, aFaviconURL, aTailingEnabled) {
+ this._faviconURL = aFaviconURL;
+ this._faviconLoaded = PromiseUtils.defer();
+ this._tailingEnabled = aTailingEnabled;
+ },
+
+ get promise() {
+ return this._faviconLoaded.promise;
+ },
+};
+
+function waitOnFaviconLoaded(aFaviconURL) {
+ return PlacesTestUtils.waitForNotification("favicon-changed", events =>
+ events.some(e => e.faviconUrl == aFaviconURL)
+ );
+}
+
+async function doTest(aTestPage, aFaviconURL, aTailingEnabled) {
+ let pageURI = Services.io.newURI(aTestPage);
+
+ // Create the observer object for observing favion channels.
+ let observer = new FaviconObserver(pageURI, aFaviconURL, aTailingEnabled);
+
+ let promiseWaitOnFaviconLoaded = waitOnFaviconLoaded(aFaviconURL);
+
+ // Add the observer earlier in case we miss it.
+ Services.obs.addObserver(observer, "http-on-modify-request");
+
+ // Open the tab.
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, aTestPage);
+ // Waiting for favicon requests are all made.
+ await observer.promise;
+ // Waiting for favicon loaded.
+ await promiseWaitOnFaviconLoaded;
+
+ Services.obs.removeObserver(observer, "http-on-modify-request");
+
+ // Close the tab.
+ BrowserTestUtils.removeTab(tab);
+}
+
+async function setupTailingPreference(aTailingEnabled) {
+ await SpecialPowers.pushPrefEnv({
+ set: [["network.http.tailing.enabled", aTailingEnabled]],
+ });
+}
+
+async function cleanup() {
+ // Clear all cookies.
+ Services.cookies.removeAll();
+ // Clear cache.
+ Services.cache2.clear();
+ // Clear Places favicon caches.
+ await clearAllPlacesFavicons();
+ // Clear all image caches and network caches.
+ clearAllImageCaches();
+ // Clear Places history.
+ await PlacesUtils.history.clear();
+}
+
+// A clean up function to prevent affecting other tests.
+registerCleanupFunction(async () => {
+ await cleanup();
+});
+
+add_task(async function test_favicon_with_tailing_enabled() {
+ await cleanup();
+
+ let tailingEnabled = true;
+
+ await setupTailingPreference(tailingEnabled);
+
+ await doTest(TEST_PAGE, FAVICON_URI, tailingEnabled);
+});
+
+add_task(async function test_favicon_with_tailing_disabled() {
+ await cleanup();
+
+ let tailingEnabled = false;
+
+ await setupTailingPreference(tailingEnabled);
+
+ await doTest(TEST_THIRD_PARTY_PAGE, THIRD_PARTY_FAVICON_URI, tailingEnabled);
+});
diff --git a/browser/base/content/test/favicons/browser_favicon_nostore.js b/browser/base/content/test/favicons/browser_favicon_nostore.js
new file mode 100644
index 0000000000..3fec666bbe
--- /dev/null
+++ b/browser/base/content/test/favicons/browser_favicon_nostore.js
@@ -0,0 +1,169 @@
+/* Any copyright is dedicated to the Public Domain.
+ * https://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Test that a favicon with Cache-Control: no-store is not stored in Places.
+// Also tests that favicons added after pageshow are not stored.
+
+const TEST_SITE = "http://example.net";
+const ICON_URL =
+ TEST_SITE + "/browser/browser/base/content/test/favicons/no-store.png";
+const PAGE_URL =
+ TEST_SITE + "/browser/browser/base/content/test/favicons/no-store.html";
+
+async function cleanup() {
+ Services.cache2.clear();
+ await PlacesTestUtils.clearFavicons();
+ await PlacesUtils.history.clear();
+}
+
+add_task(async function browser_loader() {
+ await cleanup();
+ let iconPromise = waitForFaviconMessage(true, ICON_URL);
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, PAGE_URL);
+ registerCleanupFunction(async () => {
+ await cleanup();
+ });
+
+ let { iconURL } = await iconPromise;
+ is(iconURL, ICON_URL, "Should have seen the expected icon.");
+
+ // Ensure the favicon has not been stored.
+ /* eslint-disable mozilla/no-arbitrary-setTimeout */
+ await new Promise(resolve => setTimeout(resolve, 1000));
+ await new Promise((resolve, reject) => {
+ PlacesUtils.favicons.getFaviconURLForPage(
+ Services.io.newURI(PAGE_URL),
+ foundIconURI => {
+ if (foundIconURI) {
+ reject(new Error("An icon has been stored " + foundIconURI.spec));
+ }
+ resolve();
+ }
+ );
+ });
+ BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function places_loader() {
+ await cleanup();
+
+ // Ensure the favicon is not stored even if Places is directly invoked.
+ await PlacesTestUtils.addVisits(PAGE_URL);
+ let faviconData = new Map();
+ faviconData.set(PAGE_URL, ICON_URL);
+ // We can't wait for the promise due to bug 740457, so we race with a timer.
+ await Promise.race([
+ PlacesTestUtils.addFavicons(faviconData),
+ /* eslint-disable mozilla/no-arbitrary-setTimeout */
+ new Promise(resolve => setTimeout(resolve, 1000)),
+ ]);
+ await new Promise((resolve, reject) => {
+ PlacesUtils.favicons.getFaviconURLForPage(
+ Services.io.newURI(PAGE_URL),
+ foundIconURI => {
+ if (foundIconURI) {
+ reject(new Error("An icon has been stored " + foundIconURI.spec));
+ }
+ resolve();
+ }
+ );
+ });
+});
+
+async function later_addition(iconUrl) {
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, PAGE_URL);
+ registerCleanupFunction(async () => {
+ await cleanup();
+ BrowserTestUtils.removeTab(tab);
+ });
+
+ let iconPromise = waitForFaviconMessage(true, iconUrl);
+ await ContentTask.spawn(gBrowser.selectedBrowser, iconUrl, href => {
+ let doc = content.document;
+ let head = doc.head;
+ let link = doc.createElement("link");
+ link.rel = "icon";
+ link.href = href;
+ link.type = "image/png";
+ head.appendChild(link);
+ });
+ let { iconURL } = await iconPromise;
+ is(iconURL, iconUrl, "Should have seen the expected icon.");
+
+ // Ensure the favicon has not been stored.
+ /* eslint-disable mozilla/no-arbitrary-setTimeout */
+ await new Promise(resolve => setTimeout(resolve, 1000));
+ await new Promise((resolve, reject) => {
+ PlacesUtils.favicons.getFaviconURLForPage(
+ Services.io.newURI(PAGE_URL),
+ foundIconURI => {
+ if (foundIconURI) {
+ reject(new Error("An icon has been stored " + foundIconURI.spec));
+ }
+ resolve();
+ }
+ );
+ });
+ BrowserTestUtils.removeTab(tab);
+}
+
+add_task(async function test_later_addition() {
+ for (let iconUrl of [
+ TEST_SITE + "/browser/browser/base/content/test/favicons/moz.png",
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAABH0lEQVRYw2P8////f4YBBEwMAwxGHcBCUMX/91DGOSj/BpT/DkpzQChGBSjfBErLQsVZhmoI/L8LpRdD6X1QietQGhYy7FB5aAgwmkLpBKi4BZTPMThDgBGjHIDF+f9mKD0fKvGBRKNdoF7sgPL1saaJwZgGDkJ9vpZMn8PAHqg5G9FyifBgD4H/W9HyOWrU/f+DIzHhkoeZxxgzZEIAVtJ9RxX+Q6DAxCmP3byhXxkxshAs5odqbcioAY3UC1CBLyTGOTqAmsfAOWRCwBvqxV0oIUB2OQAzDy3/D+a6wB7q8mCU2vD/nw94GziYIQOtDRn9oXz+IZMGBKGMbCjNh9Ii+v8HR4uIAUeLiEEbb9twELaIRlqrmHG0bzjiHQAA1LVfww8jwM4AAAAASUVORK5CYII=",
+ ]) {
+ await later_addition(iconUrl);
+ }
+});
+
+add_task(async function root_icon_stored() {
+ XPCShellContentUtils.ensureInitialized(this);
+ let server = XPCShellContentUtils.createHttpServer({
+ hosts: ["www.nostore.com"],
+ });
+ server.registerFile(
+ "/favicon.ico",
+ new FileUtils.File(
+ PathUtils.join(
+ Services.dirsvc.get("CurWorkD", Ci.nsIFile).path,
+ "browser",
+ "browser",
+ "base",
+ "content",
+ "test",
+ "favicons",
+ "no-store.png"
+ )
+ )
+ );
+ server.registerPathHandler("/page", (request, response) => {
+ response.write("<html>A page without icon</html>");
+ });
+
+ let noStorePromise = TestUtils.topicObserved(
+ "http-on-stop-request",
+ (s, t, d) => {
+ let chan = s.QueryInterface(Ci.nsIHttpChannel);
+ return chan?.URI.spec == "http://www.nostore.com/favicon.ico";
+ }
+ ).then(([chan]) => chan.isNoStoreResponse());
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "http://www.nostore.com/page",
+ },
+ async function (browser) {
+ await TestUtils.waitForCondition(async () => {
+ let uri = await new Promise(resolve =>
+ PlacesUtils.favicons.getFaviconURLForPage(
+ Services.io.newURI("http://www.nostore.com/page"),
+ resolve
+ )
+ );
+ return uri?.spec == "http://www.nostore.com/favicon.ico";
+ }, "wait for the favicon to be stored");
+ Assert.ok(await noStorePromise, "Should have received no-store header");
+ }
+ );
+});
diff --git a/browser/base/content/test/favicons/browser_favicon_referer.js b/browser/base/content/test/favicons/browser_favicon_referer.js
new file mode 100644
index 0000000000..ad1cb5d9b1
--- /dev/null
+++ b/browser/base/content/test/favicons/browser_favicon_referer.js
@@ -0,0 +1,62 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const FOLDER = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content/",
+ "http://mochi.test:8888/"
+);
+
+add_task(async function test_check_referrer_for_discovered_favicon() {
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: "about:blank" },
+ async browser => {
+ let referrerPromise = TestUtils.topicObserved(
+ "http-on-modify-request",
+ (s, t, d) => {
+ let chan = s.QueryInterface(Ci.nsIHttpChannel);
+ return chan.URI.spec == "http://mochi.test:8888/favicon.ico";
+ }
+ ).then(([chan]) => chan.getRequestHeader("Referer"));
+
+ BrowserTestUtils.loadURIString(browser, `${FOLDER}discovery.html`);
+
+ let referrer = await referrerPromise;
+ is(
+ referrer,
+ `${FOLDER}discovery.html`,
+ "Should have sent referrer for autodiscovered favicon."
+ );
+ }
+ );
+});
+
+add_task(
+ async function test_check_referrer_for_referrerpolicy_explicit_favicon() {
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: "about:blank" },
+ async browser => {
+ let referrerPromise = TestUtils.topicObserved(
+ "http-on-modify-request",
+ (s, t, d) => {
+ let chan = s.QueryInterface(Ci.nsIHttpChannel);
+ return chan.URI.spec == `${FOLDER}file_favicon.png`;
+ }
+ ).then(([chan]) => chan.getRequestHeader("Referer"));
+
+ BrowserTestUtils.loadURIString(
+ browser,
+ `${FOLDER}file_favicon_no_referrer.html`
+ );
+
+ let referrer = await referrerPromise;
+ is(
+ referrer,
+ "http://mochi.test:8888/",
+ "Should have sent the origin referrer only due to the per-link referrer policy specified."
+ );
+ }
+ );
+ }
+);
diff --git a/browser/base/content/test/favicons/browser_favicon_store.js b/browser/base/content/test/favicons/browser_favicon_store.js
new file mode 100644
index 0000000000..a183effe1a
--- /dev/null
+++ b/browser/base/content/test/favicons/browser_favicon_store.js
@@ -0,0 +1,56 @@
+/* Any copyright is dedicated to the Public Domain.
+ * https://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Test that favicons are stored.
+
+registerCleanupFunction(async () => {
+ Services.cache2.clear();
+ await PlacesTestUtils.clearFavicons();
+ await PlacesUtils.history.clear();
+});
+
+async function test_icon(pageUrl, iconUrl) {
+ let iconPromise = waitForFaviconMessage(true, iconUrl);
+ let storedIconPromise = PlacesTestUtils.waitForNotification(
+ "favicon-changed",
+ events => events.some(e => e.url == pageUrl)
+ );
+ await BrowserTestUtils.withNewTab(pageUrl, async () => {
+ let { iconURL } = await iconPromise;
+ Assert.equal(iconURL, iconUrl, "Should have seen the expected icon.");
+
+ // Ensure the favicon has been stored.
+ await storedIconPromise;
+ await new Promise((resolve, reject) => {
+ PlacesUtils.favicons.getFaviconURLForPage(
+ Services.io.newURI(pageUrl),
+ foundIconURI => {
+ if (foundIconURI) {
+ Assert.equal(
+ foundIconURI.spec,
+ iconUrl,
+ "Should have stored the expected icon."
+ );
+ resolve();
+ }
+ reject();
+ }
+ );
+ });
+ });
+}
+
+add_task(async function test_icon_stored() {
+ for (let [pageUrl, iconUrl] of [
+ [
+ "https://example.net/browser/browser/base/content/test/favicons/datauri-favicon.html",
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAATklEQVRYhe3SIQ4AIBADwf7/04elBAtrVlSduGnSTDJ7cuT1PQJwwO+Hl7sAGAA07gjAAfgIBeAAoHFHAA7ARygABwCNOwJwAD5CATRgAYXh+kypw86nAAAAAElFTkSuQmCC",
+ ],
+ [
+ "https://example.net/browser/browser/base/content/test/favicons/file_favicon.html",
+ "https://example.net/browser/browser/base/content/test/favicons/file_favicon.png",
+ ],
+ ]) {
+ await test_icon(pageUrl, iconUrl);
+ }
+});
diff --git a/browser/base/content/test/favicons/browser_icon_discovery.js b/browser/base/content/test/favicons/browser_icon_discovery.js
new file mode 100644
index 0000000000..6dc57b9880
--- /dev/null
+++ b/browser/base/content/test/favicons/browser_icon_discovery.js
@@ -0,0 +1,136 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/* eslint-disable mozilla/no-arbitrary-setTimeout */
+
+const ROOTURI =
+ "http://mochi.test:8888/browser/browser/base/content/test/favicons/";
+const ICON = "moz.png";
+const DATAURL =
+ "data:image/x-icon;base64,AAABAAEAEBAAAAAAAABoBQAAFgAAACgAAAAQAAAAIAAAAAEACAAAAAAAAAEAAAAAAAAAAAAAAAEAAAABAAAAAAAAAACAAACAAAAAgIAAgAAAAIAAgACAgAAAwMDAAMDcwADwyqYABAQEAAgICAAMDAwAERERABYWFgAcHBwAIiIiACkpKQBVVVUATU1NAEJCQgA5OTkAgHz/AFBQ/wCTANYA/+zMAMbW7wDW5+cAkKmtAAAAMwAAAGYAAACZAAAAzAAAMwAAADMzAAAzZgAAM5kAADPMAAAz/wAAZgAAAGYzAABmZgAAZpkAAGbMAABm/wAAmQAAAJkzAACZZgAAmZkAAJnMAACZ/wAAzAAAAMwzAADMZgAAzJkAAMzMAADM/wAA/2YAAP+ZAAD/zAAzAAAAMwAzADMAZgAzAJkAMwDMADMA/wAzMwAAMzMzADMzZgAzM5kAMzPMADMz/wAzZgAAM2YzADNmZgAzZpkAM2bMADNm/wAzmQAAM5kzADOZZgAzmZkAM5nMADOZ/wAzzAAAM8wzADPMZgAzzJkAM8zMADPM/wAz/zMAM/9mADP/mQAz/8wAM///AGYAAABmADMAZgBmAGYAmQBmAMwAZgD/AGYzAABmMzMAZjNmAGYzmQBmM8wAZjP/AGZmAABmZjMAZmZmAGZmmQBmZswAZpkAAGaZMwBmmWYAZpmZAGaZzABmmf8AZswAAGbMMwBmzJkAZszMAGbM/wBm/wAAZv8zAGb/mQBm/8wAzAD/AP8AzACZmQAAmTOZAJkAmQCZAMwAmQAAAJkzMwCZAGYAmTPMAJkA/wCZZgAAmWYzAJkzZgCZZpkAmWbMAJkz/wCZmTMAmZlmAJmZmQCZmcwAmZn/AJnMAACZzDMAZsxmAJnMmQCZzMwAmcz/AJn/AACZ/zMAmcxmAJn/mQCZ/8wAmf//AMwAAACZADMAzABmAMwAmQDMAMwAmTMAAMwzMwDMM2YAzDOZAMwzzADMM/8AzGYAAMxmMwCZZmYAzGaZAMxmzACZZv8AzJkAAMyZMwDMmWYAzJmZAMyZzADMmf8AzMwAAMzMMwDMzGYAzMyZAMzMzADMzP8AzP8AAMz/MwCZ/2YAzP+ZAMz/zADM//8AzAAzAP8AZgD/AJkAzDMAAP8zMwD/M2YA/zOZAP8zzAD/M/8A/2YAAP9mMwDMZmYA/2aZAP9mzADMZv8A/5kAAP+ZMwD/mWYA/5mZAP+ZzAD/mf8A/8wAAP/MMwD/zGYA/8yZAP/MzAD/zP8A//8zAMz/ZgD//5kA///MAGZm/wBm/2YAZv//AP9mZgD/Zv8A//9mACEApQBfX18Ad3d3AIaGhgCWlpYAy8vLALKysgDX19cA3d3dAOPj4wDq6uoA8fHxAPj4+ADw+/8ApKCgAICAgAAAAP8AAP8AAAD//wD/AAAA/wD/AP//AAD///8ACgoKCgoKCgoKCgoKCgoKCgoKCgoHAQEMbQoKCgoKCgoAAAdDH/kgHRIAAAAAAAAAAADrHfn5ASQQAAAAAAAAAArsBx0B+fkgHesAAAAAAAD/Cgwf+fn5IA4dEus/IvcACgcMAfkg+QEB+SABHushbf8QHR/5HQH5+QEdHetEHx4K7B/5+QH5+fkdDBL5+SBE/wwdJfkf+fn5AR8g+fkfEArsCh/5+QEeJR/5+SAeBwAACgoe+SAlHwFAEhAfAAAAAPcKHh8eASYBHhAMAAAAAAAA9EMdIB8gHh0dBwAAAAAAAAAA7BAdQ+wHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//AADwfwAAwH8AAMB/AAAAPwAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAQAAgAcAAIAPAADADwAA8D8AAP//AAA=";
+
+let iconDiscoveryTests = [
+ {
+ text: "rel icon discovered",
+ icons: [{}],
+ },
+ {
+ text: "rel may contain additional rels separated by spaces",
+ icons: [{ rel: "abcdefg icon qwerty" }],
+ },
+ {
+ text: "rel is case insensitive",
+ icons: [{ rel: "ICON" }],
+ },
+ {
+ text: "rel shortcut-icon not discovered",
+ expectedIcon: ROOTURI + ICON,
+ icons: [
+ // We will prefer the later icon if detected
+ {},
+ { rel: "shortcut-icon", href: "nothere.png" },
+ ],
+ },
+ {
+ text: "relative href works",
+ icons: [{ href: "moz.png" }],
+ },
+ {
+ text: "404'd icon is removed properly",
+ pass: false,
+ icons: [{ href: "notthere.png" }],
+ },
+ {
+ text: "data: URIs work",
+ icons: [{ href: DATAURL, type: "image/x-icon" }],
+ },
+ {
+ text: "type may have optional parameters (RFC2046)",
+ icons: [{ type: "image/png; charset=utf-8" }],
+ },
+ {
+ text: "apple-touch-icon discovered",
+ richIcon: true,
+ icons: [{ rel: "apple-touch-icon" }],
+ },
+ {
+ text: "apple-touch-icon-precomposed discovered",
+ richIcon: true,
+ icons: [{ rel: "apple-touch-icon-precomposed" }],
+ },
+ {
+ text: "fluid-icon discovered",
+ richIcon: true,
+ icons: [{ rel: "fluid-icon" }],
+ },
+ {
+ text: "unknown icon not discovered",
+ expectedIcon: ROOTURI + ICON,
+ richIcon: true,
+ icons: [
+ // We will prefer the larger icon if detected
+ { rel: "apple-touch-icon", sizes: "32x32" },
+ { rel: "unknown-icon", sizes: "128x128", href: "notthere.png" },
+ ],
+ },
+];
+
+add_task(async function () {
+ let url = ROOTURI + "discovery.html";
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+
+ for (let testCase of iconDiscoveryTests) {
+ info(`Running test "${testCase.text}"`);
+
+ if (testCase.pass === undefined) {
+ testCase.pass = true;
+ }
+
+ if (testCase.icons.length > 1 && !testCase.expectedIcon) {
+ ok(false, "Invalid test data, missing expectedIcon");
+ continue;
+ }
+
+ let expectedIcon = testCase.expectedIcon || testCase.icons[0].href || ICON;
+ expectedIcon = new URL(expectedIcon, ROOTURI).href;
+
+ let iconPromise = waitForFaviconMessage(!testCase.richIcon, expectedIcon);
+
+ await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [[testCase.icons, ROOTURI + ICON]],
+ ([icons, defaultIcon]) => {
+ let doc = content.document;
+ let head = doc.head;
+
+ for (let icon of icons) {
+ let link = doc.createElement("link");
+ link.rel = icon.rel || "icon";
+ link.href = icon.href || defaultIcon;
+ link.type = icon.type || "image/png";
+ if (icon.sizes) {
+ link.sizes = icon.sizes;
+ }
+ head.appendChild(link);
+ }
+ }
+ );
+
+ try {
+ let { iconURL } = await iconPromise;
+ ok(testCase.pass, testCase.text);
+ is(iconURL, expectedIcon, "Should have seen the expected icon.");
+ } catch (e) {
+ ok(!testCase.pass, testCase.text);
+ }
+
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => {
+ let links = content.document.querySelectorAll("link");
+ for (let link of links) {
+ link.remove();
+ }
+ });
+ }
+
+ BrowserTestUtils.removeTab(tab);
+});
diff --git a/browser/base/content/test/favicons/browser_invalid_href_fallback.js b/browser/base/content/test/favicons/browser_invalid_href_fallback.js
new file mode 100644
index 0000000000..d2a36b970d
--- /dev/null
+++ b/browser/base/content/test/favicons/browser_invalid_href_fallback.js
@@ -0,0 +1,29 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+add_task(async () => {
+ const testPath =
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com/browser/browser/base/content/test/favicons/";
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ const expectedIcon = "http://example.com/favicon.ico";
+
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: "about:blank" },
+ async browser => {
+ let faviconPromise = waitForLinkAvailable(browser);
+ BrowserTestUtils.loadURIString(
+ browser,
+ testPath + "file_invalid_href.html"
+ );
+ await BrowserTestUtils.browserLoaded(browser);
+
+ let iconURI = await faviconPromise;
+ Assert.equal(
+ iconURI,
+ expectedIcon,
+ "Should have fallen back to the default site favicon for an invalid href attribute"
+ );
+ }
+ );
+});
diff --git a/browser/base/content/test/favicons/browser_missing_favicon.js b/browser/base/content/test/favicons/browser_missing_favicon.js
new file mode 100644
index 0000000000..40dce7f7a9
--- /dev/null
+++ b/browser/base/content/test/favicons/browser_missing_favicon.js
@@ -0,0 +1,36 @@
+add_task(async () => {
+ let testPath = getRootDirectory(gTestPath);
+
+ // The default favicon would interfere with this test.
+ Services.prefs.setBoolPref("browser.chrome.guess_favicon", false);
+ registerCleanupFunction(() => {
+ Services.prefs.setBoolPref("browser.chrome.guess_favicon", true);
+ });
+
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: "about:blank" },
+ async browser => {
+ const expectedIcon = testPath + "file_generic_favicon.ico";
+ let faviconPromise = waitForLinkAvailable(browser);
+
+ BrowserTestUtils.loadURIString(
+ browser,
+ testPath + "file_with_favicon.html"
+ );
+ await BrowserTestUtils.browserLoaded(browser);
+
+ let iconURI = await faviconPromise;
+ is(iconURI, expectedIcon, "Got correct icon.");
+
+ BrowserTestUtils.loadURIString(browser, testPath + "blank.html");
+ await BrowserTestUtils.browserLoaded(browser);
+
+ is(browser.mIconURL, null, "Should have blanked the icon.");
+ is(
+ gBrowser.getTabForBrowser(browser).getAttribute("image"),
+ "",
+ "Should have blanked the tab icon."
+ );
+ }
+ );
+});
diff --git a/browser/base/content/test/favicons/browser_mixed_content.js b/browser/base/content/test/favicons/browser_mixed_content.js
new file mode 100644
index 0000000000..37bc86f12f
--- /dev/null
+++ b/browser/base/content/test/favicons/browser_mixed_content.js
@@ -0,0 +1,26 @@
+add_task(async () => {
+ await SpecialPowers.pushPrefEnv({
+ set: [["security.mixed_content.upgrade_display_content", false]],
+ });
+
+ const testPath =
+ "https://example.com/browser/browser/base/content/test/favicons/file_insecure_favicon.html";
+ const expectedIcon =
+ "http://example.com/browser/browser/base/content/test/favicons/file_favicon.png";
+
+ let tab = BrowserTestUtils.addTab(gBrowser, testPath);
+ gBrowser.selectedTab = tab;
+ let browser = tab.linkedBrowser;
+
+ let faviconPromise = waitForLinkAvailable(browser);
+ await BrowserTestUtils.browserLoaded(browser);
+ let iconURI = await faviconPromise;
+ is(iconURI, expectedIcon, "Got correct icon.");
+
+ ok(
+ gIdentityHandler._isMixedPassiveContentLoaded,
+ "Should have seen mixed content."
+ );
+
+ BrowserTestUtils.removeTab(tab);
+});
diff --git a/browser/base/content/test/favicons/browser_multiple_icons_in_short_timeframe.js b/browser/base/content/test/favicons/browser_multiple_icons_in_short_timeframe.js
new file mode 100644
index 0000000000..80a45a9288
--- /dev/null
+++ b/browser/base/content/test/favicons/browser_multiple_icons_in_short_timeframe.js
@@ -0,0 +1,37 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+add_task(async function () {
+ const ROOT =
+ "http://mochi.test:8888/browser/browser/base/content/test/favicons/";
+ const URL = ROOT + "discovery.html";
+
+ let iconPromise = waitForFaviconMessage(
+ true,
+ "http://mochi.test:8888/favicon.ico"
+ );
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, URL);
+ let icon = await iconPromise;
+
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [ROOT], root => {
+ let doc = content.document;
+ let head = doc.head;
+ let link = doc.createElement("link");
+ link.rel = "icon";
+ link.href = root + "rich_moz_1.png";
+ link.type = "image/png";
+ head.appendChild(link);
+ let link2 = link.cloneNode(false);
+ link2.href = root + "rich_moz_2.png";
+ head.appendChild(link2);
+ });
+
+ icon = await waitForFaviconMessage();
+ Assert.equal(
+ icon.iconURL,
+ ROOT + "rich_moz_2.png",
+ "The expected icon has been set"
+ );
+
+ BrowserTestUtils.removeTab(tab);
+});
diff --git a/browser/base/content/test/favicons/browser_oversized.js b/browser/base/content/test/favicons/browser_oversized.js
new file mode 100644
index 0000000000..4756873a30
--- /dev/null
+++ b/browser/base/content/test/favicons/browser_oversized.js
@@ -0,0 +1,25 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const ROOT =
+ "http://mochi.test:8888/browser/browser/base/content/test/favicons/";
+
+add_task(async () => {
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: "about:blank" },
+ async browser => {
+ let faviconPromise = waitForFaviconMessage(true, `${ROOT}large.png`);
+
+ BrowserTestUtils.loadURIString(browser, ROOT + "large_favicon.html");
+ await BrowserTestUtils.browserLoaded(browser);
+
+ await Assert.rejects(
+ faviconPromise,
+ result => {
+ return result.iconURL == `${ROOT}large.png`;
+ },
+ "Should have failed to load the large icon."
+ );
+ }
+ );
+});
diff --git a/browser/base/content/test/favicons/browser_preferred_icons.js b/browser/base/content/test/favicons/browser_preferred_icons.js
new file mode 100644
index 0000000000..25f548c717
--- /dev/null
+++ b/browser/base/content/test/favicons/browser_preferred_icons.js
@@ -0,0 +1,140 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const ROOT =
+ "http://mochi.test:8888/browser/browser/base/content/test/favicons/";
+
+async function waitIcon(url) {
+ let icon = await waitForFaviconMessage(true, url);
+ is(icon.iconURL, url, "Should have seen the right icon.");
+}
+
+function createLinks(linkInfos) {
+ return SpecialPowers.spawn(gBrowser.selectedBrowser, [linkInfos], links => {
+ let doc = content.document;
+ let head = doc.head;
+ for (let l of links) {
+ let link = doc.createElement("link");
+ link.rel = "icon";
+ link.href = l.href;
+ if (l.type) {
+ link.type = l.type;
+ }
+ if (l.size) {
+ link.setAttribute("sizes", `${l.size}x${l.size}`);
+ }
+ head.appendChild(link);
+ }
+ });
+}
+
+add_setup(async function () {
+ const URL = ROOT + "discovery.html";
+ let iconPromise = waitIcon("http://mochi.test:8888/favicon.ico");
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, URL);
+ await iconPromise;
+ registerCleanupFunction(async function () {
+ BrowserTestUtils.removeTab(tab);
+ });
+});
+
+add_task(async function prefer_svg() {
+ let promise = waitIcon(ROOT + "icon.svg");
+ await createLinks([
+ { href: ROOT + "icon.ico", type: "image/x-icon" },
+ { href: ROOT + "icon.svg", type: "image/svg+xml" },
+ {
+ href: ROOT + "icon.png",
+ type: "image/png",
+ size: 16 * Math.ceil(window.devicePixelRatio),
+ },
+ ]);
+ await promise;
+});
+
+add_task(async function prefer_sized() {
+ let promise = waitIcon(ROOT + "moz.png");
+ await createLinks([
+ { href: ROOT + "icon.ico", type: "image/x-icon" },
+ {
+ href: ROOT + "moz.png",
+ type: "image/png",
+ size: 16 * Math.ceil(window.devicePixelRatio),
+ },
+ { href: ROOT + "icon2.ico", type: "image/x-icon" },
+ ]);
+ await promise;
+});
+
+add_task(async function prefer_last_ico() {
+ let promise = waitIcon(ROOT + "file_generic_favicon.ico");
+ await createLinks([
+ { href: ROOT + "icon.ico", type: "image/x-icon" },
+ { href: ROOT + "icon.png", type: "image/png" },
+ { href: ROOT + "file_generic_favicon.ico", type: "image/x-icon" },
+ ]);
+ await promise;
+});
+
+add_task(async function fuzzy_ico() {
+ let promise = waitIcon(ROOT + "file_generic_favicon.ico");
+ await createLinks([
+ { href: ROOT + "icon.ico", type: "image/x-icon" },
+ { href: ROOT + "icon.png", type: "image/png" },
+ {
+ href: ROOT + "file_generic_favicon.ico",
+ type: "image/vnd.microsoft.icon",
+ },
+ ]);
+ await promise;
+});
+
+add_task(async function guess_svg() {
+ let promise = waitIcon(ROOT + "icon.svg");
+ await createLinks([
+ { href: ROOT + "icon.svg" },
+ {
+ href: ROOT + "icon.png",
+ type: "image/png",
+ size: 16 * Math.ceil(window.devicePixelRatio),
+ },
+ { href: ROOT + "icon.ico", type: "image/x-icon" },
+ ]);
+ await promise;
+});
+
+add_task(async function guess_ico() {
+ let promise = waitIcon(ROOT + "file_generic_favicon.ico");
+ await createLinks([
+ { href: ROOT + "file_generic_favicon.ico" },
+ { href: ROOT + "icon.png", type: "image/png" },
+ ]);
+ await promise;
+});
+
+add_task(async function guess_invalid() {
+ let promise = waitIcon(ROOT + "icon.svg");
+ // Create strange links to make sure they don't break us
+ await createLinks([
+ { href: ROOT + "icon.svg" },
+ { href: ROOT + "icon" },
+ { href: ROOT + "icon?.svg" },
+ { href: ROOT + "icon#.svg" },
+ { href: "data:text/plain,icon" },
+ { href: "file:///icon" },
+ { href: "about:icon" },
+ ]);
+ await promise;
+});
+
+add_task(async function guess_bestSized() {
+ let preferredWidth = 16 * Math.ceil(window.devicePixelRatio);
+ let promise = waitIcon(ROOT + "moz.png");
+ await createLinks([
+ { href: ROOT + "icon.png", type: "image/png", size: preferredWidth - 1 },
+ { href: ROOT + "icon2.png", type: "image/png" },
+ { href: ROOT + "moz.png", type: "image/png", size: preferredWidth + 1 },
+ { href: ROOT + "icon4.png", type: "image/png", size: preferredWidth + 2 },
+ ]);
+ await promise;
+});
diff --git a/browser/base/content/test/favicons/browser_redirect.js b/browser/base/content/test/favicons/browser_redirect.js
new file mode 100644
index 0000000000..ea2b053be7
--- /dev/null
+++ b/browser/base/content/test/favicons/browser_redirect.js
@@ -0,0 +1,20 @@
+const ROOT =
+ "http://mochi.test:8888/browser/browser/base/content/test/favicons/";
+
+add_task(async () => {
+ const URL = ROOT + "file_favicon_redirect.html";
+ const EXPECTED_ICON = ROOT + "file_favicon_redirect.ico";
+
+ let promise = waitForFaviconMessage(true, EXPECTED_ICON);
+
+ const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, URL);
+ let tabIcon = await promise;
+
+ is(
+ tabIcon.iconURL,
+ EXPECTED_ICON,
+ "should use the redirected icon for the tab"
+ );
+
+ BrowserTestUtils.removeTab(tab);
+});
diff --git a/browser/base/content/test/favicons/browser_rich_icons.js b/browser/base/content/test/favicons/browser_rich_icons.js
new file mode 100644
index 0000000000..2020b7bdad
--- /dev/null
+++ b/browser/base/content/test/favicons/browser_rich_icons.js
@@ -0,0 +1,50 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/* eslint-disable mozilla/no-arbitrary-setTimeout */
+
+const ROOT =
+ "http://mochi.test:8888/browser/browser/base/content/test/favicons/";
+
+add_task(async function test_richIcons() {
+ const URL = ROOT + "file_rich_icon.html";
+ const EXPECTED_ICON = ROOT + "moz.png";
+ const EXPECTED_RICH_ICON = ROOT + "rich_moz_2.png";
+
+ let tabPromises = Promise.all([
+ waitForFaviconMessage(true, EXPECTED_ICON),
+ waitForFaviconMessage(false, EXPECTED_RICH_ICON),
+ ]);
+
+ const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, URL);
+ let [tabIcon, richIcon] = await tabPromises;
+
+ is(
+ richIcon.iconURL,
+ EXPECTED_RICH_ICON,
+ "should choose the largest rich icon"
+ );
+ is(
+ tabIcon.iconURL,
+ EXPECTED_ICON,
+ "should use the non-rich icon for the tab"
+ );
+
+ BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function test_maskIcons() {
+ const URL = ROOT + "file_mask_icon.html";
+ const EXPECTED_ICON = "http://mochi.test:8888/favicon.ico";
+
+ let promise = waitForFaviconMessage(true, EXPECTED_ICON);
+ const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, URL);
+ let tabIcon = await promise;
+ is(
+ tabIcon.iconURL,
+ EXPECTED_ICON,
+ "should ignore the mask icons and load the root favicon"
+ );
+
+ BrowserTestUtils.removeTab(tab);
+});
diff --git a/browser/base/content/test/favicons/browser_rooticon.js b/browser/base/content/test/favicons/browser_rooticon.js
new file mode 100644
index 0000000000..6e642070c7
--- /dev/null
+++ b/browser/base/content/test/favicons/browser_rooticon.js
@@ -0,0 +1,24 @@
+add_task(async () => {
+ const testPath =
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com/browser/browser/base/content/test/favicons/blank.html";
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ const expectedIcon = "http://example.com/favicon.ico";
+
+ let tab = BrowserTestUtils.addTab(gBrowser, testPath);
+ gBrowser.selectedTab = tab;
+ let browser = tab.linkedBrowser;
+
+ let faviconPromise = waitForLinkAvailable(browser);
+ await BrowserTestUtils.browserLoaded(browser);
+ let iconURI = await faviconPromise;
+ is(iconURI, expectedIcon, "Got correct initial icon.");
+
+ faviconPromise = waitForLinkAvailable(browser);
+ BrowserTestUtils.loadURIString(browser, testPath);
+ await BrowserTestUtils.browserLoaded(browser);
+ iconURI = await faviconPromise;
+ is(iconURI, expectedIcon, "Got correct icon on second load.");
+
+ BrowserTestUtils.removeTab(tab);
+});
diff --git a/browser/base/content/test/favicons/browser_subframe_favicons_not_used.js b/browser/base/content/test/favicons/browser_subframe_favicons_not_used.js
new file mode 100644
index 0000000000..ff48bcd475
--- /dev/null
+++ b/browser/base/content/test/favicons/browser_subframe_favicons_not_used.js
@@ -0,0 +1,22 @@
+/* Make sure <link rel="..."> isn't respected in sub-frames. */
+
+add_task(async function () {
+ const ROOT =
+ "http://mochi.test:8888/browser/browser/base/content/test/favicons/";
+ const URL = ROOT + "file_bug970276_popup1.html";
+
+ let promiseIcon = waitForFaviconMessage(
+ true,
+ ROOT + "file_bug970276_favicon1.ico"
+ );
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, URL);
+ let icon = await promiseIcon;
+
+ Assert.equal(
+ icon.iconURL,
+ ROOT + "file_bug970276_favicon1.ico",
+ "The expected icon has been set"
+ );
+
+ BrowserTestUtils.removeTab(tab);
+});
diff --git a/browser/base/content/test/favicons/browser_title_flicker.js b/browser/base/content/test/favicons/browser_title_flicker.js
new file mode 100644
index 0000000000..71fadce908
--- /dev/null
+++ b/browser/base/content/test/favicons/browser_title_flicker.js
@@ -0,0 +1,185 @@
+const TEST_PATH =
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com/browser/browser/base/content/test/favicons/";
+
+function waitForAttributeChange(tab, attr) {
+ info(`Waiting for attribute ${attr}`);
+ return new Promise(resolve => {
+ let listener = event => {
+ if (event.detail.changed.includes(attr)) {
+ tab.removeEventListener("TabAttrModified", listener);
+ resolve();
+ }
+ };
+
+ tab.addEventListener("TabAttrModified", listener);
+ });
+}
+
+function waitForPendingIcon() {
+ return new Promise(resolve => {
+ let listener = () => {
+ LinkHandlerParent.removeListenerForTests(listener);
+ resolve();
+ };
+
+ LinkHandlerParent.addListenerForTests(listener);
+ });
+}
+
+// Verify that the title doesn't flicker if the icon takes too long to load.
+// We expect to see events in the following order:
+// "label" added to tab
+// "busy" removed from tab
+// icon available
+// In all those cases the title should be in the same position.
+add_task(async () => {
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: "about:blank" },
+ async browser => {
+ let tab = gBrowser.getTabForBrowser(browser);
+ BrowserTestUtils.loadURIString(
+ browser,
+ TEST_PATH + "file_with_slow_favicon.html"
+ );
+
+ await waitForAttributeChange(tab, "label");
+ ok(tab.hasAttribute("busy"), "Should have seen the busy attribute");
+ let label = tab.textLabel;
+ let bounds = label.getBoundingClientRect();
+
+ await waitForAttributeChange(tab, "busy");
+ ok(
+ !tab.hasAttribute("busy"),
+ "Should have seen the busy attribute removed"
+ );
+ let newBounds = label.getBoundingClientRect();
+ is(
+ bounds.x,
+ newBounds.left,
+ "Should have seen the title in the same place."
+ );
+
+ await waitForFaviconMessage(true);
+ newBounds = label.getBoundingClientRect();
+ is(
+ bounds.x,
+ newBounds.left,
+ "Should have seen the title in the same place."
+ );
+ }
+ );
+});
+
+// Verify that the title doesn't flicker if a new icon is detected after load.
+add_task(async () => {
+ let iconAvailable = waitForFaviconMessage(true);
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: TEST_PATH + "blank.html" },
+ async browser => {
+ let icon = await iconAvailable;
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ is(icon.iconURL, "http://example.com/favicon.ico");
+
+ let tab = gBrowser.getTabForBrowser(browser);
+ let label = tab.textLabel;
+ let bounds = label.getBoundingClientRect();
+
+ await SpecialPowers.spawn(browser, [], () => {
+ let link = content.document.createElement("link");
+ link.setAttribute("href", "file_favicon.png");
+ link.setAttribute("rel", "icon");
+ link.setAttribute("type", "image/png");
+ content.document.head.appendChild(link);
+ });
+
+ ok(
+ !tab.hasAttribute("pendingicon"),
+ "Should not have marked a pending icon"
+ );
+ let newBounds = label.getBoundingClientRect();
+ is(
+ bounds.x,
+ newBounds.left,
+ "Should have seen the title in the same place."
+ );
+
+ await waitForPendingIcon();
+
+ ok(
+ !tab.hasAttribute("pendingicon"),
+ "Should not have marked a pending icon"
+ );
+ newBounds = label.getBoundingClientRect();
+ is(
+ bounds.x,
+ newBounds.left,
+ "Should have seen the title in the same place."
+ );
+
+ icon = await waitForFaviconMessage(true);
+ is(
+ icon.iconURL,
+ TEST_PATH + "file_favicon.png",
+ "Should have loaded the new icon."
+ );
+
+ ok(
+ !tab.hasAttribute("pendingicon"),
+ "Should not have marked a pending icon"
+ );
+ newBounds = label.getBoundingClientRect();
+ is(
+ bounds.x,
+ newBounds.left,
+ "Should have seen the title in the same place."
+ );
+ }
+ );
+});
+
+// Verify that pinned tabs don't change size when an icon is pending.
+add_task(async () => {
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: "about:blank" },
+ async browser => {
+ let tab = gBrowser.getTabForBrowser(browser);
+ gBrowser.pinTab(tab);
+
+ let bounds = tab.getBoundingClientRect();
+ BrowserTestUtils.loadURIString(
+ browser,
+ TEST_PATH + "file_with_slow_favicon.html"
+ );
+
+ await waitForAttributeChange(tab, "label");
+ ok(tab.hasAttribute("busy"), "Should have seen the busy attribute");
+ let newBounds = tab.getBoundingClientRect();
+ is(
+ bounds.width,
+ newBounds.width,
+ "Should have seen tab remain the same size."
+ );
+
+ await waitForAttributeChange(tab, "busy");
+ ok(
+ !tab.hasAttribute("busy"),
+ "Should have seen the busy attribute removed"
+ );
+ newBounds = tab.getBoundingClientRect();
+ is(
+ bounds.width,
+ newBounds.width,
+ "Should have seen tab remain the same size."
+ );
+
+ await waitForFaviconMessage(true);
+ newBounds = tab.getBoundingClientRect();
+ is(
+ bounds.width,
+ newBounds.width,
+ "Should have seen tab remain the same size."
+ );
+ }
+ );
+});
diff --git a/browser/base/content/test/favicons/cookie_favicon.html b/browser/base/content/test/favicons/cookie_favicon.html
new file mode 100644
index 0000000000..618ac1850b
--- /dev/null
+++ b/browser/base/content/test/favicons/cookie_favicon.html
@@ -0,0 +1,11 @@
+<!DOCTYPE HTML>
+<html>
+ <head>
+ <meta charset='utf-8'>
+ <title>Favicon Test for caching</title>
+ <link rel="icon" type="image/png" href="cookie_favicon.sjs" />
+ </head>
+ <body>
+ Favicon!!
+ </body>
+</html>
diff --git a/browser/base/content/test/favicons/cookie_favicon.sjs b/browser/base/content/test/favicons/cookie_favicon.sjs
new file mode 100644
index 0000000000..a00d48d09a
--- /dev/null
+++ b/browser/base/content/test/favicons/cookie_favicon.sjs
@@ -0,0 +1,26 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+function handleRequest(request, response) {
+ if (request.queryString == "reset") {
+ setState("cache_cookie", "0");
+ response.setStatusLine(request.httpVersion, 200, "Ok");
+ response.write("Reset");
+ return;
+ }
+
+ let state = getState("cache_cookie");
+ if (!state) {
+ state = 0;
+ }
+
+ response.setStatusLine(request.httpVersion, 302, "Moved Temporarily");
+ response.setHeader("Set-Cookie", `faviconCookie=${++state}`);
+ response.setHeader(
+ "Location",
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com/browser/browser/base/content/test/favicons/moz.png"
+ );
+ setState("cache_cookie", `${state}`);
+}
diff --git a/browser/base/content/test/favicons/credentials.png b/browser/base/content/test/favicons/credentials.png
new file mode 100644
index 0000000000..769c636340
--- /dev/null
+++ b/browser/base/content/test/favicons/credentials.png
Binary files differ
diff --git a/browser/base/content/test/favicons/credentials.png^headers^ b/browser/base/content/test/favicons/credentials.png^headers^
new file mode 100644
index 0000000000..72339d67f0
--- /dev/null
+++ b/browser/base/content/test/favicons/credentials.png^headers^
@@ -0,0 +1,3 @@
+Access-Control-Allow-Origin: https://example.net
+Access-Control-Allow-Credentials: true
+Set-Cookie: faviconCookie2=test; SameSite=None; Secure;
diff --git a/browser/base/content/test/favicons/credentials1.html b/browser/base/content/test/favicons/credentials1.html
new file mode 100644
index 0000000000..2ccfd00e79
--- /dev/null
+++ b/browser/base/content/test/favicons/credentials1.html
@@ -0,0 +1,10 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="UTF-8" />
+ <title>Favicon test for cross-origin credentials</title>
+ <link rel="icon" href="https://example.com/browser/browser/base/content/test/favicons/credentials.png" crossorigin />
+</head>
+<body>
+</body>
+</html>
diff --git a/browser/base/content/test/favicons/credentials2.html b/browser/base/content/test/favicons/credentials2.html
new file mode 100644
index 0000000000..cc28ca77bd
--- /dev/null
+++ b/browser/base/content/test/favicons/credentials2.html
@@ -0,0 +1,10 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="UTF-8" />
+ <title>Favicon test for cross-origin credentials</title>
+ <link rel="icon" href="https://example.com/browser/browser/base/content/test/favicons/credentials.png" crossorigin="use-credentials" />
+</head>
+<body>
+</body>
+</html>
diff --git a/browser/base/content/test/favicons/crossorigin.html b/browser/base/content/test/favicons/crossorigin.html
new file mode 100644
index 0000000000..26a6a85d17
--- /dev/null
+++ b/browser/base/content/test/favicons/crossorigin.html
@@ -0,0 +1,10 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="UTF-8" />
+ <title>Favicon test for the crossorigin attribute</title>
+ <link rel="icon" href="http://example.com/browser/browser/base/content/test/favicons/crossorigin.png" crossorigin />
+</head>
+<body>
+</body>
+</html>
diff --git a/browser/base/content/test/favicons/crossorigin.png b/browser/base/content/test/favicons/crossorigin.png
new file mode 100644
index 0000000000..769c636340
--- /dev/null
+++ b/browser/base/content/test/favicons/crossorigin.png
Binary files differ
diff --git a/browser/base/content/test/favicons/crossorigin.png^headers^ b/browser/base/content/test/favicons/crossorigin.png^headers^
new file mode 100644
index 0000000000..3a6a85d894
--- /dev/null
+++ b/browser/base/content/test/favicons/crossorigin.png^headers^
@@ -0,0 +1 @@
+Access-Control-Allow-Origin: http://mochi.test:8888
diff --git a/browser/base/content/test/favicons/datauri-favicon.html b/browser/base/content/test/favicons/datauri-favicon.html
new file mode 100644
index 0000000000..35954f67a1
--- /dev/null
+++ b/browser/base/content/test/favicons/datauri-favicon.html
@@ -0,0 +1,8 @@
+<!DOCTYPE HTML>
+<html>
+ <head>
+ <title>Favicon tab</title>
+ <link rel="icon" type="image/png" href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAATklEQVRYhe3SIQ4AIBADwf7/04elBAtrVlSduGnSTDJ7cuT1PQJwwO+Hl7sAGAA07gjAAfgIBeAAoHFHAA7ARygABwCNOwJwAD5CATRgAYXh+kypw86nAAAAAElFTkSuQmCC">
+ <head>
+ <body>Some page with a favicon</body>
+</html>
diff --git a/browser/base/content/test/favicons/discovery.html b/browser/base/content/test/favicons/discovery.html
new file mode 100644
index 0000000000..2ff2aaa5f2
--- /dev/null
+++ b/browser/base/content/test/favicons/discovery.html
@@ -0,0 +1,8 @@
+<!DOCTYPE HTML>
+<html>
+ <head>
+ <title>Autodiscovery Test</title>
+ </head>
+ <body>
+ </body>
+</html>
diff --git a/browser/base/content/test/favicons/file_bug970276_favicon1.ico b/browser/base/content/test/favicons/file_bug970276_favicon1.ico
new file mode 100644
index 0000000000..d44438903b
--- /dev/null
+++ b/browser/base/content/test/favicons/file_bug970276_favicon1.ico
Binary files differ
diff --git a/browser/base/content/test/favicons/file_bug970276_favicon2.ico b/browser/base/content/test/favicons/file_bug970276_favicon2.ico
new file mode 100644
index 0000000000..d44438903b
--- /dev/null
+++ b/browser/base/content/test/favicons/file_bug970276_favicon2.ico
Binary files differ
diff --git a/browser/base/content/test/favicons/file_bug970276_popup1.html b/browser/base/content/test/favicons/file_bug970276_popup1.html
new file mode 100644
index 0000000000..5ce7dab879
--- /dev/null
+++ b/browser/base/content/test/favicons/file_bug970276_popup1.html
@@ -0,0 +1,14 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test file for bug 970276.</title>
+
+ <!--Set a favicon; that's the whole point of this file.-->
+ <link rel="icon" href="file_bug970276_favicon1.ico">
+</head>
+<body>
+ Test file for bug 970276.
+
+ <iframe src="file_bug970276_popup2.html">
+</body>
+</html>
diff --git a/browser/base/content/test/favicons/file_bug970276_popup2.html b/browser/base/content/test/favicons/file_bug970276_popup2.html
new file mode 100644
index 0000000000..0b9e5294ef
--- /dev/null
+++ b/browser/base/content/test/favicons/file_bug970276_popup2.html
@@ -0,0 +1,12 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test file for bug 970276.</title>
+
+ <!--Set a favicon; that's the whole point of this file.-->
+ <link rel="icon" href="file_bug970276_favicon2.ico">
+</head>
+<body>
+ Test inner file for bug 970276.
+</body>
+</html>
diff --git a/browser/base/content/test/favicons/file_favicon.html b/browser/base/content/test/favicons/file_favicon.html
new file mode 100644
index 0000000000..f294b47758
--- /dev/null
+++ b/browser/base/content/test/favicons/file_favicon.html
@@ -0,0 +1,11 @@
+<!DOCTYPE HTML>
+<html>
+ <head>
+ <meta charset='utf-8'>
+ <title>Favicon Test for originAttributes</title>
+ <link rel="icon" type="image/png" href="file_favicon.png" />
+ </head>
+ <body>
+ Favicon!!
+ </body>
+</html>
diff --git a/browser/base/content/test/favicons/file_favicon.png b/browser/base/content/test/favicons/file_favicon.png
new file mode 100644
index 0000000000..5535363c94
--- /dev/null
+++ b/browser/base/content/test/favicons/file_favicon.png
Binary files differ
diff --git a/browser/base/content/test/favicons/file_favicon.png^headers^ b/browser/base/content/test/favicons/file_favicon.png^headers^
new file mode 100644
index 0000000000..9e23c73b7f
--- /dev/null
+++ b/browser/base/content/test/favicons/file_favicon.png^headers^
@@ -0,0 +1 @@
+Cache-Control: no-cache
diff --git a/browser/base/content/test/favicons/file_favicon_change.html b/browser/base/content/test/favicons/file_favicon_change.html
new file mode 100644
index 0000000000..035549c5aa
--- /dev/null
+++ b/browser/base/content/test/favicons/file_favicon_change.html
@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+<html><head>
+ <meta http-equiv="content-type" content="text/html; charset=utf-8">
+ <link rel="icon" href="file_bug970276_favicon1.ico" type="image/ico" id="i">
+</head>
+<body>
+ <script>
+ window.addEventListener("PleaseChangeFavicon", function() {
+ var ico = document.getElementById("i");
+ ico.setAttribute("href", "moz.png");
+ });
+ </script>
+</body></html>
diff --git a/browser/base/content/test/favicons/file_favicon_change_not_in_document.html b/browser/base/content/test/favicons/file_favicon_change_not_in_document.html
new file mode 100644
index 0000000000..c44a2f8153
--- /dev/null
+++ b/browser/base/content/test/favicons/file_favicon_change_not_in_document.html
@@ -0,0 +1,20 @@
+<!DOCTYPE html>
+<html><head>
+ <meta http-equiv="content-type" content="text/html; charset=utf-8">
+ <link rel="icon" href="file_bug970276_favicon1.ico" type="image/ico" id="i">
+</head>
+<body onload="onload()">
+ <script>
+ function onload() {
+ var ico = document.createElement("link");
+ ico.setAttribute("rel", "icon");
+ ico.setAttribute("type", "image/ico");
+ ico.setAttribute("href", "file_bug970276_favicon1.ico");
+ setTimeout(function() {
+ ico.setAttribute("href", "file_generic_favicon.ico");
+ document.getElementById("i").remove();
+ document.head.appendChild(ico);
+ }, 1000);
+ }
+ </script>
+</body></html>
diff --git a/browser/base/content/test/favicons/file_favicon_no_referrer.html b/browser/base/content/test/favicons/file_favicon_no_referrer.html
new file mode 100644
index 0000000000..4f363ffd04
--- /dev/null
+++ b/browser/base/content/test/favicons/file_favicon_no_referrer.html
@@ -0,0 +1,11 @@
+<!DOCTYPE HTML>
+<html>
+ <head>
+ <meta charset='utf-8'>
+ <title>Favicon Test for referrer</title>
+ <link rel="icon" type="image/png" referrerpolicy="origin" href="file_favicon.png" />
+ </head>
+ <body>
+ Favicon!!
+ </body>
+</html>
diff --git a/browser/base/content/test/favicons/file_favicon_redirect.html b/browser/base/content/test/favicons/file_favicon_redirect.html
new file mode 100644
index 0000000000..9da4777591
--- /dev/null
+++ b/browser/base/content/test/favicons/file_favicon_redirect.html
@@ -0,0 +1,12 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test file with an icon that redirects</title>
+
+ <!--Set a favicon; that's the whole point of this file.-->
+ <link rel="icon" href="file_favicon_redirect.ico">
+</head>
+<body>
+ Test file for bugs with favicons
+</body>
+</html>
diff --git a/browser/base/content/test/favicons/file_favicon_redirect.ico b/browser/base/content/test/favicons/file_favicon_redirect.ico
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/browser/base/content/test/favicons/file_favicon_redirect.ico
diff --git a/browser/base/content/test/favicons/file_favicon_redirect.ico^headers^ b/browser/base/content/test/favicons/file_favicon_redirect.ico^headers^
new file mode 100644
index 0000000000..380fa3d3a4
--- /dev/null
+++ b/browser/base/content/test/favicons/file_favicon_redirect.ico^headers^
@@ -0,0 +1,2 @@
+HTTP 302 Found
+Location: http://example.com/browser/browser/base/content/test/favicons/file_generic_favicon.ico
diff --git a/browser/base/content/test/favicons/file_favicon_thirdParty.html b/browser/base/content/test/favicons/file_favicon_thirdParty.html
new file mode 100644
index 0000000000..7d690e5981
--- /dev/null
+++ b/browser/base/content/test/favicons/file_favicon_thirdParty.html
@@ -0,0 +1,11 @@
+<!DOCTYPE HTML>
+<html>
+ <head>
+ <meta charset='utf-8'>
+ <title>Favicon Test for originAttributes</title>
+ <link rel="icon" type="image/png" href="http://mochi.test:8888/browser/browser/base/content/test/favicons/file_favicon.png" />
+ </head>
+ <body>
+ Third Party Favicon!!
+ </body>
+</html>
diff --git a/browser/base/content/test/favicons/file_generic_favicon.ico b/browser/base/content/test/favicons/file_generic_favicon.ico
new file mode 100644
index 0000000000..d44438903b
--- /dev/null
+++ b/browser/base/content/test/favicons/file_generic_favicon.ico
Binary files differ
diff --git a/browser/base/content/test/favicons/file_insecure_favicon.html b/browser/base/content/test/favicons/file_insecure_favicon.html
new file mode 100644
index 0000000000..7b13b47829
--- /dev/null
+++ b/browser/base/content/test/favicons/file_insecure_favicon.html
@@ -0,0 +1,11 @@
+<!DOCTYPE HTML>
+<html>
+ <head>
+ <meta charset='utf-8'>
+ <title>Favicon Test for mixed content</title>
+ <link rel="icon" type="image/png" href="http://example.com/browser/browser/base/content/test/favicons/file_favicon.png" />
+ </head>
+ <body>
+ Favicon!!
+ </body>
+</html>
diff --git a/browser/base/content/test/favicons/file_invalid_href.html b/browser/base/content/test/favicons/file_invalid_href.html
new file mode 100644
index 0000000000..087ff01403
--- /dev/null
+++ b/browser/base/content/test/favicons/file_invalid_href.html
@@ -0,0 +1,12 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test file for bugs with invalid hrefs for favicons</title>
+
+ <!--Empty href; that's the whole point of this file.-->
+ <link rel="icon" href="">
+</head>
+<body>
+ Test file for bugs with invalid hrefs for favicons
+</body>
+</html>
diff --git a/browser/base/content/test/favicons/file_mask_icon.html b/browser/base/content/test/favicons/file_mask_icon.html
new file mode 100644
index 0000000000..5bcd9e694f
--- /dev/null
+++ b/browser/base/content/test/favicons/file_mask_icon.html
@@ -0,0 +1,11 @@
+<!DOCTYPE HTML>
+<html>
+ <head>
+ <meta charset="UTF-8" />
+ <title>Mask Icon</title>
+ <link rel="icon" mask href="moz.png" type="image/png" />
+ <link rel="mask-icon" href="moz.png" type="image/png" />
+ </head>
+ <body>
+ </body>
+</html>
diff --git a/browser/base/content/test/favicons/file_rich_icon.html b/browser/base/content/test/favicons/file_rich_icon.html
new file mode 100644
index 0000000000..ce7550b611
--- /dev/null
+++ b/browser/base/content/test/favicons/file_rich_icon.html
@@ -0,0 +1,12 @@
+<!DOCTYPE HTML>
+<html>
+ <head>
+ <meta charset="UTF-8" />
+ <title>Rich Icons</title>
+ <link rel="icon" href="moz.png" type="image/png" />
+ <link rel="apple-touch-icon" sizes="96x96" href="rich_moz_1.png" type="image/png" />
+ <link rel="apple-touch-icon" sizes="256x256" href="rich_moz_2.png" type="image/png" />
+ </head>
+ <body>
+ </body>
+</html>
diff --git a/browser/base/content/test/favicons/file_with_favicon.html b/browser/base/content/test/favicons/file_with_favicon.html
new file mode 100644
index 0000000000..0702b4aaba
--- /dev/null
+++ b/browser/base/content/test/favicons/file_with_favicon.html
@@ -0,0 +1,12 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test file for bugs with favicons</title>
+
+ <!--Set a favicon; that's the whole point of this file.-->
+ <link rel="icon" href="file_generic_favicon.ico">
+</head>
+<body>
+ Test file for bugs with favicons
+</body>
+</html>
diff --git a/browser/base/content/test/favicons/file_with_slow_favicon.html b/browser/base/content/test/favicons/file_with_slow_favicon.html
new file mode 100644
index 0000000000..76fb015587
--- /dev/null
+++ b/browser/base/content/test/favicons/file_with_slow_favicon.html
@@ -0,0 +1,10 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test for title flicker</title>
+</head>
+<body>
+ <!-- Putting the icon down here means we won't start loading it until the doc is fully parsed -->
+ <link rel="icon" href="file_generic_favicon.ico">
+</body>
+</html>
diff --git a/browser/base/content/test/favicons/head.js b/browser/base/content/test/favicons/head.js
new file mode 100644
index 0000000000..ce16afd33f
--- /dev/null
+++ b/browser/base/content/test/favicons/head.js
@@ -0,0 +1,98 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+ChromeUtils.defineESModuleGetters(this, {
+ LinkHandlerParent: "resource:///actors/LinkHandlerParent.sys.mjs",
+ PlacesTestUtils: "resource://testing-common/PlacesTestUtils.sys.mjs",
+ PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
+
+ XPCShellContentUtils:
+ "resource://testing-common/XPCShellContentUtils.sys.mjs",
+});
+
+// Clear the network cache between every test to make sure we get a stable state
+Services.cache2.clear();
+
+function waitForFaviconMessage(isTabIcon = undefined, expectedURL = undefined) {
+ return new Promise((resolve, reject) => {
+ let listener = (name, data) => {
+ if (name != "SetIcon" && name != "SetFailedIcon") {
+ return; // Ignore unhandled messages
+ }
+
+ // If requested filter out loads of the wrong kind of icon.
+ if (isTabIcon != undefined && isTabIcon != data.canUseForTab) {
+ return;
+ }
+
+ if (expectedURL && data.originalURL != expectedURL) {
+ return;
+ }
+
+ LinkHandlerParent.removeListenerForTests(listener);
+
+ if (name == "SetIcon") {
+ resolve({
+ iconURL: data.originalURL,
+ dataURL: data.iconURL,
+ canUseForTab: data.canUseForTab,
+ });
+ } else {
+ reject({
+ iconURL: data.originalURL,
+ canUseForTab: data.canUseForTab,
+ });
+ }
+ };
+
+ LinkHandlerParent.addListenerForTests(listener);
+ });
+}
+
+function waitForFavicon(browser, url) {
+ return new Promise(resolve => {
+ let listener = {
+ onLinkIconAvailable(b, dataURI, iconURI) {
+ if (b !== browser || iconURI != url) {
+ return;
+ }
+
+ gBrowser.removeTabsProgressListener(listener);
+ resolve();
+ },
+ };
+
+ gBrowser.addTabsProgressListener(listener);
+ });
+}
+
+function waitForLinkAvailable(browser) {
+ let resolve, reject;
+
+ let listener = {
+ onLinkIconAvailable(b, dataURI, iconURI) {
+ // Ignore icons for other browsers or empty icons.
+ if (browser !== b || !iconURI) {
+ return;
+ }
+
+ gBrowser.removeTabsProgressListener(listener);
+ resolve(iconURI);
+ },
+ };
+
+ let promise = new Promise((res, rej) => {
+ resolve = res;
+ reject = rej;
+
+ gBrowser.addTabsProgressListener(listener);
+ });
+
+ promise.cancel = () => {
+ gBrowser.removeTabsProgressListener(listener);
+
+ reject();
+ };
+
+ return promise;
+}
diff --git a/browser/base/content/test/favicons/icon.svg b/browser/base/content/test/favicons/icon.svg
new file mode 100644
index 0000000000..6de9c64503
--- /dev/null
+++ b/browser/base/content/test/favicons/icon.svg
@@ -0,0 +1,11 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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/. -->
+
+<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
+ <circle cx="8" cy="8" r="8" fill="#8d20ae" />
+ <circle cx="8" cy="8" r="7.5" stroke="#7b149a" stroke-width="1" fill="none" />
+ <path d="M11.309,10.995C10.061,10.995,9.2,9.5,8,9.5s-2.135,1.5-3.309,1.5c-1.541,0-2.678-1.455-2.7-3.948C1.983,5.5,2.446,5.005,4.446,5.005S7.031,5.822,8,5.822s1.555-.817,3.555-0.817S14.017,5.5,14.006,7.047C13.987,9.54,12.85,10.995,11.309,10.995ZM5.426,6.911a1.739,1.739,0,0,0-1.716.953A2.049,2.049,0,0,0,5.3,8.544c0.788,0,1.716-.288,1.716-0.544A1.428,1.428,0,0,0,5.426,6.911Zm5.148,0A1.429,1.429,0,0,0,8.981,8c0,0.257.928,0.544,1.716,0.544a2.049,2.049,0,0,0,1.593-.681A1.739,1.739,0,0,0,10.574,6.911Z" stroke="#670c83" stroke-width="2" fill="none" />
+ <path d="M11.309,10.995C10.061,10.995,9.2,9.5,8,9.5s-2.135,1.5-3.309,1.5c-1.541,0-2.678-1.455-2.7-3.948C1.983,5.5,2.446,5.005,4.446,5.005S7.031,5.822,8,5.822s1.555-.817,3.555-0.817S14.017,5.5,14.006,7.047C13.987,9.54,12.85,10.995,11.309,10.995ZM5.426,6.911a1.739,1.739,0,0,0-1.716.953A2.049,2.049,0,0,0,5.3,8.544c0.788,0,1.716-.288,1.716-0.544A1.428,1.428,0,0,0,5.426,6.911Zm5.148,0A1.429,1.429,0,0,0,8.981,8c0,0.257.928,0.544,1.716,0.544a2.049,2.049,0,0,0,1.593-.681A1.739,1.739,0,0,0,10.574,6.911Z" fill="#fff" />
+</svg>
diff --git a/browser/base/content/test/favicons/large.png b/browser/base/content/test/favicons/large.png
new file mode 100644
index 0000000000..37012cf965
--- /dev/null
+++ b/browser/base/content/test/favicons/large.png
Binary files differ
diff --git a/browser/base/content/test/favicons/large_favicon.html b/browser/base/content/test/favicons/large_favicon.html
new file mode 100644
index 0000000000..48c5e8f19d
--- /dev/null
+++ b/browser/base/content/test/favicons/large_favicon.html
@@ -0,0 +1,12 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <title>Test file for bugs with favicons</title>
+
+ <!--Set a favicon; that's the whole point of this file.-->
+ <link rel="icon" href="large.png">
+</head>
+<body>
+ Test file for bugs with favicons
+</body>
+</html>
diff --git a/browser/base/content/test/favicons/moz.png b/browser/base/content/test/favicons/moz.png
new file mode 100644
index 0000000000..769c636340
--- /dev/null
+++ b/browser/base/content/test/favicons/moz.png
Binary files differ
diff --git a/browser/base/content/test/favicons/no-store.html b/browser/base/content/test/favicons/no-store.html
new file mode 100644
index 0000000000..0d5bbbb475
--- /dev/null
+++ b/browser/base/content/test/favicons/no-store.html
@@ -0,0 +1,11 @@
+<!DOCTYPE HTML>
+<html>
+ <head>
+ <meta charset='utf-8'>
+ <title>Favicon Test for Cache-Control: no-store</title>
+ <link rel="icon" type="image/png" href="no-store.png" />
+ </head>
+ <body>
+ Favicon!!
+ </body>
+</html>
diff --git a/browser/base/content/test/favicons/no-store.png b/browser/base/content/test/favicons/no-store.png
new file mode 100644
index 0000000000..769c636340
--- /dev/null
+++ b/browser/base/content/test/favicons/no-store.png
Binary files differ
diff --git a/browser/base/content/test/favicons/no-store.png^headers^ b/browser/base/content/test/favicons/no-store.png^headers^
new file mode 100644
index 0000000000..15a2442249
--- /dev/null
+++ b/browser/base/content/test/favicons/no-store.png^headers^
@@ -0,0 +1 @@
+Cache-Control: no-store, no-cache, must-revalidate
diff --git a/browser/base/content/test/favicons/rich_moz_1.png b/browser/base/content/test/favicons/rich_moz_1.png
new file mode 100644
index 0000000000..769c636340
--- /dev/null
+++ b/browser/base/content/test/favicons/rich_moz_1.png
Binary files differ
diff --git a/browser/base/content/test/favicons/rich_moz_2.png b/browser/base/content/test/favicons/rich_moz_2.png
new file mode 100644
index 0000000000..769c636340
--- /dev/null
+++ b/browser/base/content/test/favicons/rich_moz_2.png
Binary files differ
diff --git a/browser/base/content/test/forms/browser.ini b/browser/base/content/test/forms/browser.ini
new file mode 100644
index 0000000000..00c6cfe951
--- /dev/null
+++ b/browser/base/content/test/forms/browser.ini
@@ -0,0 +1,21 @@
+[DEFAULT]
+prefs =
+ gfx.font_loader.delay=0
+support-files =
+ head.js
+
+[browser_selectpopup.js]
+skip-if =
+ os == "linux" # Bug 1329991
+ os == "mac" # Bug 1661132, 1775896
+ verify && os == "win"
+[browser_selectpopup_colors.js]
+skip-if = os == "linux" # Bug 1329991 - test fails intermittently on Linux builds
+[browser_selectpopup_dir.js]
+[browser_selectpopup_large.js]
+[browser_selectpopup_searchfocus.js]
+[browser_selectpopup_text_transform.js]
+[browser_selectpopup_toplevel.js]
+[browser_selectpopup_user_input.js]
+[browser_selectpopup_width.js]
+[browser_selectpopup_xhtml.js]
diff --git a/browser/base/content/test/forms/browser_selectpopup.js b/browser/base/content/test/forms/browser_selectpopup.js
new file mode 100644
index 0000000000..7645f89b4a
--- /dev/null
+++ b/browser/base/content/test/forms/browser_selectpopup.js
@@ -0,0 +1,913 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+/* eslint-disable mozilla/no-arbitrary-setTimeout */
+
+// This test tests <select> in a child process. This is different than
+// single-process as a <menulist> is used to implement the dropdown list.
+
+// FIXME(bug 1774835): This test should be split.
+requestLongerTimeout(2);
+
+const XHTML_DTD =
+ '<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">';
+
+const PAGECONTENT =
+ "<html xmlns='http://www.w3.org/1999/xhtml'>" +
+ "<body onload='gChangeEvents = 0;gInputEvents = 0; gClickEvents = 0; document.getElementById(\"select\").focus();'>" +
+ "<select id='select' oninput='gInputEvents++' onchange='gChangeEvents++' onclick='if (event.target == this) gClickEvents++'>" +
+ " <optgroup label='First Group'>" +
+ " <option value='One'>One</option>" +
+ " <option value='Two'>Two</option>" +
+ " </optgroup>" +
+ " <option value='Three'>Three</option>" +
+ " <optgroup label='Second Group' disabled='true'>" +
+ " <option value='Four'>Four</option>" +
+ " <option value='Five'>Five</option>" +
+ " </optgroup>" +
+ " <option value='Six' disabled='true'>Six</option>" +
+ " <optgroup label='Third Group'>" +
+ " <option value='Seven'> Seven </option>" +
+ " <option value='Eight'>&nbsp;&nbsp;Eight&nbsp;&nbsp;</option>" +
+ " </optgroup></select><input />Text" +
+ "</body></html>";
+
+const PAGECONTENT_XSLT =
+ "<?xml-stylesheet type='text/xml' href='#style1'?>" +
+ "<xsl:stylesheet id='style1'" +
+ " version='1.0'" +
+ " xmlns:xsl='http://www.w3.org/1999/XSL/Transform'" +
+ " xmlns:html='http://www.w3.org/1999/xhtml'>" +
+ "<xsl:template match='xsl:stylesheet'>" +
+ PAGECONTENT +
+ "</xsl:template>" +
+ "</xsl:stylesheet>";
+
+const PAGECONTENT_SMALL =
+ "<html>" +
+ "<body><select id='one'>" +
+ " <option value='One'>One</option>" +
+ " <option value='Two'>Two</option>" +
+ "</select><select id='two'>" +
+ " <option value='Three'>Three</option>" +
+ " <option value='Four'>Four</option>" +
+ "</select><select id='three'>" +
+ " <option value='Five'>Five</option>" +
+ " <option value='Six'>Six</option>" +
+ "</select></body></html>";
+
+const PAGECONTENT_GROUPS =
+ "<html>" +
+ "<body><select id='one'>" +
+ " <optgroup label='Group 1'>" +
+ " <option value='G1 O1'>G1 O1</option>" +
+ " <option value='G1 O2'>G1 O2</option>" +
+ " <option value='G1 O3'>G1 O3</option>" +
+ " </optgroup>" +
+ " <optgroup label='Group 2'>" +
+ " <option value='G2 O1'>G2 O4</option>" +
+ " <option value='G2 O2'>G2 O5</option>" +
+ " <option value='Hidden' style='display: none;'>Hidden</option>" +
+ " </optgroup>" +
+ "</select></body></html>";
+
+const PAGECONTENT_SOMEHIDDEN =
+ "<html><head><style>.hidden { display: none; }</style></head>" +
+ "<body><select id='one'>" +
+ " <option value='One' style='display: none;'>OneHidden</option>" +
+ " <option value='Two' class='hidden'>TwoHidden</option>" +
+ " <option value='Three'>ThreeVisible</option>" +
+ " <option value='Four'style='display: table;'>FourVisible</option>" +
+ " <option value='Five'>FiveVisible</option>" +
+ " <optgroup label='GroupHidden' class='hidden'>" +
+ " <option value='Four'>Six.OneHidden</option>" +
+ " <option value='Five' style='display: block;'>Six.TwoHidden</option>" +
+ " </optgroup>" +
+ " <option value='Six' class='hidden' style='display: block;'>SevenVisible</option>" +
+ "</select></body></html>";
+
+const PAGECONTENT_TRANSLATED =
+ "<html><body>" +
+ "<div id='div'>" +
+ "<iframe id='frame' width='320' height='295' style='border: none;'" +
+ " src='data:text/html,<select id=select><option>he he he</option><option>boo boo</option><option>baz baz</option></select>'" +
+ "</iframe>" +
+ "</div></body></html>";
+
+function getInputEvents() {
+ return SpecialPowers.spawn(gBrowser.selectedBrowser, [], function () {
+ return content.wrappedJSObject.gInputEvents;
+ });
+}
+
+function getChangeEvents() {
+ return SpecialPowers.spawn(gBrowser.selectedBrowser, [], function () {
+ return content.wrappedJSObject.gChangeEvents;
+ });
+}
+
+function getClickEvents() {
+ return SpecialPowers.spawn(gBrowser.selectedBrowser, [], function () {
+ return content.wrappedJSObject.gClickEvents;
+ });
+}
+
+async function doSelectTests(contentType, content) {
+ const pageUrl = "data:" + contentType + "," + encodeURIComponent(content);
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, pageUrl);
+
+ let selectPopup = await openSelectPopup();
+ let menulist = selectPopup.parentNode;
+
+ let isWindows = navigator.platform.includes("Win");
+
+ is(menulist.selectedIndex, 1, "Initial selection");
+ is(
+ selectPopup.firstElementChild.localName,
+ "menucaption",
+ "optgroup is caption"
+ );
+ is(
+ selectPopup.firstElementChild.getAttribute("label"),
+ "First Group",
+ "optgroup label"
+ );
+ is(selectPopup.children[1].localName, "menuitem", "option is menuitem");
+ is(selectPopup.children[1].getAttribute("label"), "One", "option label");
+
+ EventUtils.synthesizeKey("KEY_ArrowDown");
+ is(menulist.activeChild, menulist.getItemAtIndex(2), "Select item 2");
+ is(menulist.selectedIndex, isWindows ? 2 : 1, "Select item 2 selectedIndex");
+
+ EventUtils.synthesizeKey("KEY_ArrowDown");
+ is(menulist.activeChild, menulist.getItemAtIndex(3), "Select item 3");
+ is(menulist.selectedIndex, isWindows ? 3 : 1, "Select item 3 selectedIndex");
+
+ EventUtils.synthesizeKey("KEY_ArrowDown");
+
+ // On Windows, one can navigate on disabled menuitems
+ is(
+ menulist.activeChild,
+ menulist.getItemAtIndex(9),
+ "Skip optgroup header and disabled items select item 7"
+ );
+ is(
+ menulist.selectedIndex,
+ isWindows ? 9 : 1,
+ "Select or skip disabled item selectedIndex"
+ );
+
+ for (let i = 0; i < 10; i++) {
+ is(
+ menulist.getItemAtIndex(i).disabled,
+ i >= 4 && i <= 7,
+ "item " + i + " disabled"
+ );
+ }
+
+ EventUtils.synthesizeKey("KEY_ArrowUp");
+ is(menulist.activeChild, menulist.getItemAtIndex(3), "Select item 3 again");
+ is(menulist.selectedIndex, isWindows ? 3 : 1, "Select item 3 selectedIndex");
+
+ is(await getInputEvents(), 0, "Before closed - number of input events");
+ is(await getChangeEvents(), 0, "Before closed - number of change events");
+ is(await getClickEvents(), 0, "Before closed - number of click events");
+
+ EventUtils.synthesizeKey("a", { accelKey: true });
+ await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [{ isWindows }],
+ function (args) {
+ Assert.equal(
+ String(content.getSelection()),
+ args.isWindows ? "Text" : "",
+ "Select all while popup is open"
+ );
+ }
+ );
+
+ // Backspace should not go back
+ let handleKeyPress = function (event) {
+ ok(false, "Should not get keypress event");
+ };
+ window.addEventListener("keypress", handleKeyPress);
+ EventUtils.synthesizeKey("KEY_Backspace");
+ window.removeEventListener("keypress", handleKeyPress);
+
+ await hideSelectPopup();
+
+ is(menulist.selectedIndex, 3, "Item 3 still selected");
+ is(await getInputEvents(), 1, "After closed - number of input events");
+ is(await getChangeEvents(), 1, "After closed - number of change events");
+ is(await getClickEvents(), 0, "After closed - number of click events");
+
+ // Opening and closing the popup without changing the value should not fire a change event.
+ await openSelectPopup("click");
+ await hideSelectPopup("escape");
+ is(
+ await getInputEvents(),
+ 1,
+ "Open and close with no change - number of input events"
+ );
+ is(
+ await getChangeEvents(),
+ 1,
+ "Open and close with no change - number of change events"
+ );
+ is(
+ await getClickEvents(),
+ 1,
+ "Open and close with no change - number of click events"
+ );
+ EventUtils.synthesizeKey("KEY_Tab");
+ EventUtils.synthesizeKey("KEY_Tab", { shiftKey: true });
+ is(
+ await getInputEvents(),
+ 1,
+ "Tab away from select with no change - number of input events"
+ );
+ is(
+ await getChangeEvents(),
+ 1,
+ "Tab away from select with no change - number of change events"
+ );
+ is(
+ await getClickEvents(),
+ 1,
+ "Tab away from select with no change - number of click events"
+ );
+
+ await openSelectPopup("click");
+ EventUtils.synthesizeKey("KEY_ArrowDown");
+ await hideSelectPopup("escape");
+ is(
+ await getInputEvents(),
+ isWindows ? 2 : 1,
+ "Open and close with change - number of input events"
+ );
+ is(
+ await getChangeEvents(),
+ isWindows ? 2 : 1,
+ "Open and close with change - number of change events"
+ );
+ is(
+ await getClickEvents(),
+ 2,
+ "Open and close with change - number of click events"
+ );
+ EventUtils.synthesizeKey("KEY_Tab");
+ EventUtils.synthesizeKey("KEY_Tab", { shiftKey: true });
+ is(
+ await getInputEvents(),
+ isWindows ? 2 : 1,
+ "Tab away from select with change - number of input events"
+ );
+ is(
+ await getChangeEvents(),
+ isWindows ? 2 : 1,
+ "Tab away from select with change - number of change events"
+ );
+ is(
+ await getClickEvents(),
+ 2,
+ "Tab away from select with change - number of click events"
+ );
+
+ is(
+ selectPopup.lastElementChild.previousElementSibling.label,
+ "Seven",
+ "Spaces collapsed"
+ );
+ is(
+ selectPopup.lastElementChild.label,
+ "\xA0\xA0Eight\xA0\xA0",
+ "Non-breaking spaces not collapsed"
+ );
+
+ BrowserTestUtils.removeTab(tab);
+}
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [["dom.forms.select.customstyling", true]],
+ });
+});
+
+add_task(async function () {
+ await doSelectTests("text/html", PAGECONTENT);
+});
+
+add_task(async function () {
+ await doSelectTests("application/xhtml+xml", XHTML_DTD + "\n" + PAGECONTENT);
+});
+
+add_task(async function () {
+ await doSelectTests("application/xml", XHTML_DTD + "\n" + PAGECONTENT_XSLT);
+});
+
+// This test opens a select popup and removes the content node of a popup while
+// The popup should close if its node is removed.
+add_task(async function () {
+ const pageUrl = "data:text/html," + escape(PAGECONTENT_SMALL);
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, pageUrl);
+
+ // First, try it when a different <select> element than the one that is open is removed
+ const selectPopup = await openSelectPopup("click", "#one");
+
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], function () {
+ content.document.body.removeChild(content.document.getElementById("two"));
+ });
+
+ // Wait a bit just to make sure the popup won't close.
+ await new Promise(resolve => setTimeout(resolve, 1000));
+
+ is(selectPopup.state, "open", "Different popup did not affect open popup");
+
+ await hideSelectPopup();
+
+ // Next, try it when the same <select> element than the one that is open is removed
+ await openSelectPopup("click", "#three");
+
+ let popupHiddenPromise = BrowserTestUtils.waitForEvent(
+ selectPopup,
+ "popuphidden"
+ );
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], function () {
+ content.document.body.removeChild(content.document.getElementById("three"));
+ });
+ await popupHiddenPromise;
+
+ ok(true, "Popup hidden when select is removed");
+
+ // Finally, try it when the tab is closed while the select popup is open.
+ await openSelectPopup("click", "#one");
+
+ popupHiddenPromise = BrowserTestUtils.waitForEvent(
+ selectPopup,
+ "popuphidden"
+ );
+ BrowserTestUtils.removeTab(tab);
+ await popupHiddenPromise;
+
+ ok(true, "Popup hidden when tab is closed");
+});
+
+// This test opens a select popup that is isn't a frame and has some translations applied.
+add_task(async function () {
+ const pageUrl = "data:text/html," + escape(PAGECONTENT_TRANSLATED);
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, pageUrl);
+
+ // We need to explicitly call Element.focus() since dataURL is treated as
+ // cross-origin, thus autofocus doesn't work there.
+ const iframe = await SpecialPowers.spawn(tab.linkedBrowser, [], () => {
+ return content.document.querySelector("iframe").browsingContext;
+ });
+ await SpecialPowers.spawn(iframe, [], async () => {
+ const input = content.document.getElementById("select");
+ const focusPromise = new Promise(resolve => {
+ input.addEventListener("focus", resolve, { once: true });
+ });
+ input.focus();
+ await focusPromise;
+ });
+
+ // First, get the position of the select popup when no translations have been applied.
+ const selectPopup = await openSelectPopup();
+
+ let rect = selectPopup.getBoundingClientRect();
+ let expectedX = rect.left;
+ let expectedY = rect.top;
+
+ await hideSelectPopup();
+
+ // Iterate through a set of steps which each add more translation to the select's expected position.
+ let steps = [
+ ["div", "transform: translateX(7px) translateY(13px);", 7, 13],
+ [
+ "frame",
+ "border-top: 5px solid green; border-left: 10px solid red; border-right: 35px solid blue;",
+ 10,
+ 5,
+ ],
+ [
+ "frame",
+ "border: none; padding-left: 6px; padding-right: 12px; padding-top: 2px;",
+ -4,
+ -3,
+ ],
+ ["select", "margin: 9px; transform: translateY(-3px);", 9, 6],
+ ];
+
+ for (let stepIndex = 0; stepIndex < steps.length; stepIndex++) {
+ let step = steps[stepIndex];
+
+ await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [step],
+ async function (contentStep) {
+ return new Promise(resolve => {
+ let changedWin = content;
+
+ let elem;
+ if (contentStep[0] == "select") {
+ changedWin = content.document.getElementById("frame").contentWindow;
+ elem = changedWin.document.getElementById("select");
+ } else {
+ elem = content.document.getElementById(contentStep[0]);
+ }
+
+ changedWin.addEventListener(
+ "MozAfterPaint",
+ function () {
+ resolve();
+ },
+ { once: true }
+ );
+
+ elem.style = contentStep[1];
+ elem.getBoundingClientRect();
+ });
+ }
+ );
+
+ await openSelectPopup();
+
+ expectedX += step[2];
+ expectedY += step[3];
+
+ let popupRect = selectPopup.getBoundingClientRect();
+ is(popupRect.left, expectedX, "step " + (stepIndex + 1) + " x");
+ is(popupRect.top, expectedY, "step " + (stepIndex + 1) + " y");
+
+ await hideSelectPopup();
+ }
+
+ BrowserTestUtils.removeTab(tab);
+});
+
+// Test that we get the right events when a select popup is changed.
+add_task(async function test_event_order() {
+ const URL = "data:text/html," + escape(PAGECONTENT_SMALL);
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: URL,
+ },
+ async function (browser) {
+ // According to https://html.spec.whatwg.org/#the-select-element,
+ // we want to fire input, change, and then click events on the
+ // <select> (in that order) when it has changed.
+ let expectedEnter = [
+ {
+ type: "input",
+ cancelable: false,
+ targetIsOption: false,
+ composed: true,
+ },
+ {
+ type: "change",
+ cancelable: false,
+ targetIsOption: false,
+ composed: false,
+ },
+ ];
+
+ let expectedClick = [
+ {
+ type: "mousedown",
+ cancelable: true,
+ targetIsOption: true,
+ composed: true,
+ },
+ {
+ type: "mouseup",
+ cancelable: true,
+ targetIsOption: true,
+ composed: true,
+ },
+ {
+ type: "input",
+ cancelable: false,
+ targetIsOption: false,
+ composed: true,
+ },
+ {
+ type: "change",
+ cancelable: false,
+ targetIsOption: false,
+ composed: false,
+ },
+ {
+ type: "click",
+ cancelable: true,
+ targetIsOption: true,
+ composed: true,
+ },
+ ];
+
+ for (let mode of ["enter", "click"]) {
+ let expected = mode == "enter" ? expectedEnter : expectedClick;
+ await openSelectPopup("click", mode == "enter" ? "#one" : "#two");
+
+ let eventsPromise = SpecialPowers.spawn(
+ browser,
+ [[mode, expected]],
+ async function ([contentMode, contentExpected]) {
+ return new Promise(resolve => {
+ function onEvent(event) {
+ select.removeEventListener(event.type, onEvent);
+ Assert.ok(
+ contentExpected.length,
+ "Unexpected event " + event.type
+ );
+ let expectation = contentExpected.shift();
+ Assert.equal(
+ event.type,
+ expectation.type,
+ "Expected the right event order"
+ );
+ Assert.ok(event.bubbles, "All of these events should bubble");
+ Assert.equal(
+ event.cancelable,
+ expectation.cancelable,
+ "Cancellation property should match"
+ );
+ Assert.equal(
+ event.target.localName,
+ expectation.targetIsOption ? "option" : "select",
+ "Target matches"
+ );
+ Assert.equal(
+ event.composed,
+ expectation.composed,
+ "Composed property should match"
+ );
+ if (!contentExpected.length) {
+ resolve();
+ }
+ }
+
+ let select = content.document.getElementById(
+ contentMode == "enter" ? "one" : "two"
+ );
+ for (let event of [
+ "input",
+ "change",
+ "mousedown",
+ "mouseup",
+ "click",
+ ]) {
+ select.addEventListener(event, onEvent);
+ }
+ });
+ }
+ );
+
+ EventUtils.synthesizeKey("KEY_ArrowDown");
+ await hideSelectPopup(mode);
+ await eventsPromise;
+ }
+ }
+ );
+});
+
+async function performSelectSearchTests(win) {
+ let browser = win.gBrowser.selectedBrowser;
+ await SpecialPowers.spawn(browser, [], async function () {
+ let doc = content.document;
+ let select = doc.getElementById("one");
+
+ for (var i = 0; i < 40; i++) {
+ select.add(new content.Option("Test" + i));
+ }
+
+ select.options[1].selected = true;
+ select.focus();
+ });
+
+ let selectPopup = await openSelectPopup(false, "select", win);
+
+ let searchElement = selectPopup.querySelector(
+ ".contentSelectDropdown-searchbox"
+ );
+ searchElement.focus();
+
+ EventUtils.synthesizeKey("O", {}, win);
+ is(selectPopup.children[2].hidden, false, "First option should be visible");
+ is(selectPopup.children[3].hidden, false, "Second option should be visible");
+
+ EventUtils.synthesizeKey("3", {}, win);
+ is(selectPopup.children[2].hidden, true, "First option should be hidden");
+ is(selectPopup.children[3].hidden, true, "Second option should be hidden");
+ is(selectPopup.children[4].hidden, false, "Third option should be visible");
+
+ EventUtils.synthesizeKey("Z", {}, win);
+ is(selectPopup.children[4].hidden, true, "Third option should be hidden");
+ is(
+ selectPopup.children[1].hidden,
+ true,
+ "First group header should be hidden"
+ );
+
+ EventUtils.synthesizeKey("KEY_Backspace", {}, win);
+ is(selectPopup.children[4].hidden, false, "Third option should be visible");
+
+ EventUtils.synthesizeKey("KEY_Backspace", {}, win);
+ is(
+ selectPopup.children[5].hidden,
+ false,
+ "Second group header should be visible"
+ );
+
+ EventUtils.synthesizeKey("KEY_Backspace", {}, win);
+ EventUtils.synthesizeKey("O", {}, win);
+ EventUtils.synthesizeKey("5", {}, win);
+ is(
+ selectPopup.children[5].hidden,
+ false,
+ "Second group header should be visible"
+ );
+ is(
+ selectPopup.children[1].hidden,
+ true,
+ "First group header should be hidden"
+ );
+
+ EventUtils.synthesizeKey("KEY_Backspace", {}, win);
+ is(
+ selectPopup.children[1].hidden,
+ false,
+ "First group header should be shown"
+ );
+
+ EventUtils.synthesizeKey("KEY_Backspace", {}, win);
+ is(
+ selectPopup.children[8].hidden,
+ true,
+ "Option hidden by content should remain hidden"
+ );
+
+ await hideSelectPopup("escape", win);
+}
+
+// This test checks the functionality of search in select elements with groups
+// and a large number of options.
+add_task(async function test_select_search() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["dom.forms.selectSearch", true]],
+ });
+ const pageUrl = "data:text/html," + escape(PAGECONTENT_GROUPS);
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, pageUrl);
+
+ await performSelectSearchTests(window);
+
+ BrowserTestUtils.removeTab(tab);
+
+ await SpecialPowers.popPrefEnv();
+});
+
+// This test checks that a mousemove event is fired correctly at the menu and
+// not at the browser, ensuring that any mouse capture has been cleared.
+add_task(async function test_mousemove_correcttarget() {
+ const pageUrl = "data:text/html," + escape(PAGECONTENT_SMALL);
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, pageUrl);
+
+ const selectPopup = await openSelectPopup("mousedown");
+
+ await new Promise(resolve => {
+ window.addEventListener(
+ "mousemove",
+ function (event) {
+ is(event.target.localName.indexOf("menu"), 0, "mouse over menu");
+ resolve();
+ },
+ { capture: true, once: true }
+ );
+
+ EventUtils.synthesizeMouseAtCenter(selectPopup.firstElementChild, {
+ type: "mousemove",
+ buttons: 1,
+ });
+ });
+
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#one",
+ { type: "mouseup" },
+ gBrowser.selectedBrowser
+ );
+
+ await hideSelectPopup();
+
+ // The popup should be closed when fullscreen mode is entered or exited.
+ for (let steps = 0; steps < 2; steps++) {
+ await openSelectPopup("click");
+ let popupHiddenPromise = BrowserTestUtils.waitForEvent(
+ selectPopup,
+ "popuphidden"
+ );
+ let sizeModeChanged = BrowserTestUtils.waitForEvent(
+ window,
+ "sizemodechange"
+ );
+ BrowserFullScreen();
+ await sizeModeChanged;
+ await popupHiddenPromise;
+ }
+
+ BrowserTestUtils.removeTab(tab);
+});
+
+// This test checks when a <select> element has some options with altered display values.
+add_task(async function test_somehidden() {
+ const pageUrl = "data:text/html," + escape(PAGECONTENT_SOMEHIDDEN);
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, pageUrl);
+
+ let selectPopup = await openSelectPopup("click");
+
+ // The exact number is not needed; just ensure the height is larger than 4 items to accommodate any popup borders.
+ ok(
+ selectPopup.getBoundingClientRect().height >=
+ selectPopup.lastElementChild.getBoundingClientRect().height * 4,
+ "Height contains at least 4 items"
+ );
+ ok(
+ selectPopup.getBoundingClientRect().height <
+ selectPopup.lastElementChild.getBoundingClientRect().height * 5,
+ "Height doesn't contain 5 items"
+ );
+
+ // The label contains the substring 'Visible' for items that are visible.
+ // Otherwise, it is expected to be display: none.
+ is(selectPopup.parentNode.itemCount, 9, "Correct number of items");
+ let child = selectPopup.firstElementChild;
+ let idx = 1;
+ while (child) {
+ is(
+ getComputedStyle(child).display,
+ child.label.indexOf("Visible") > 0 ? "flex" : "none",
+ "Item " + idx++ + " is visible"
+ );
+ child = child.nextElementSibling;
+ }
+
+ await hideSelectPopup("escape");
+ BrowserTestUtils.removeTab(tab);
+});
+
+// This test checks that the popup is closed when the select element is blurred.
+add_task(async function test_blur_hides_popup() {
+ const pageUrl = "data:text/html," + escape(PAGECONTENT_SMALL);
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, pageUrl);
+
+ await SpecialPowers.spawn(tab.linkedBrowser, [], async function () {
+ content.addEventListener(
+ "blur",
+ function (event) {
+ event.preventDefault();
+ event.stopPropagation();
+ },
+ true
+ );
+
+ content.document.getElementById("one").focus();
+ });
+
+ let selectPopup = await openSelectPopup();
+
+ let popupHiddenPromise = BrowserTestUtils.waitForEvent(
+ selectPopup,
+ "popuphidden"
+ );
+
+ await SpecialPowers.spawn(tab.linkedBrowser, [], async function () {
+ content.document.getElementById("one").blur();
+ });
+
+ await popupHiddenPromise;
+
+ ok(true, "Blur closed popup");
+
+ BrowserTestUtils.removeTab(tab);
+});
+
+// Test zoom handling.
+add_task(async function test_zoom() {
+ const pageUrl = "data:text/html," + escape(PAGECONTENT_SMALL);
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, pageUrl);
+
+ info("Opening the popup");
+ const selectPopup = await openSelectPopup("click");
+
+ info("Opened the popup");
+ let nonZoomedFontSize = parseFloat(
+ getComputedStyle(selectPopup.querySelector("menuitem")).fontSize,
+ 10
+ );
+
+ info("font-size is " + nonZoomedFontSize);
+ await hideSelectPopup();
+
+ info("Hide the popup");
+
+ for (let i = 0; i < 2; ++i) {
+ info("Testing with full zoom: " + ZoomManager.useFullZoom);
+
+ // This is confusing, but does the right thing.
+ FullZoom.setZoom(2.0, tab.linkedBrowser);
+
+ info("Opening popup again");
+ await openSelectPopup("click");
+
+ let zoomedFontSize = parseFloat(
+ getComputedStyle(selectPopup.querySelector("menuitem")).fontSize,
+ 10
+ );
+ info("Zoomed font-size is " + zoomedFontSize);
+
+ ok(
+ Math.abs(zoomedFontSize - nonZoomedFontSize * 2.0) < 0.01,
+ `Zoom should affect menu popup size, got ${zoomedFontSize}, ` +
+ `expected ${nonZoomedFontSize * 2.0}`
+ );
+
+ await hideSelectPopup();
+ info("Hid the popup again");
+
+ ZoomManager.toggleZoom();
+ }
+
+ FullZoom.setZoom(1.0, tab.linkedBrowser); // make sure the zoom level is reset
+ BrowserTestUtils.removeTab(tab);
+});
+
+// Test that input and change events are dispatched consistently (bug 1561882).
+add_task(async function test_event_destroys_popup() {
+ const PAGE_CONTENT = `
+<!doctype html>
+<select>
+ <option>a</option>
+ <option>b</option>
+</select>
+<script>
+gChangeEvents = 0;
+gInputEvents = 0;
+let select = document.querySelector("select");
+ select.addEventListener("input", function() {
+ gInputEvents++;
+ this.style.display = "none";
+ this.getBoundingClientRect();
+ })
+ select.addEventListener("change", function() {
+ gChangeEvents++;
+ })
+</script>`;
+
+ const pageUrl = "data:text/html," + escape(PAGE_CONTENT);
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, pageUrl);
+
+ // Test change and input events get handled consistently
+ await openSelectPopup("click");
+ EventUtils.synthesizeKey("KEY_ArrowDown");
+ await hideSelectPopup();
+
+ is(
+ await getChangeEvents(),
+ 1,
+ "Should get change and input events consistently"
+ );
+ is(
+ await getInputEvents(),
+ 1,
+ "Should get change and input events consistently (input)"
+ );
+
+ BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function test_label_not_text() {
+ const PAGE_CONTENT = `
+<!doctype html>
+<select>
+ <option label="Some nifty Label">Some Element Text Instead</option>
+ <option label="">Element Text</option>
+</select>
+`;
+
+ const pageUrl = "data:text/html," + escape(PAGE_CONTENT);
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, pageUrl);
+
+ const selectPopup = await openSelectPopup("click");
+
+ is(
+ selectPopup.children[0].label,
+ "Some nifty Label",
+ "Use the label not the text."
+ );
+
+ is(
+ selectPopup.children[1].label,
+ "Element Text",
+ "Uses the text if the label is empty, like HTMLOptionElement::GetRenderedLabel."
+ );
+
+ BrowserTestUtils.removeTab(tab);
+});
diff --git a/browser/base/content/test/forms/browser_selectpopup_colors.js b/browser/base/content/test/forms/browser_selectpopup_colors.js
new file mode 100644
index 0000000000..00b399c672
--- /dev/null
+++ b/browser/base/content/test/forms/browser_selectpopup_colors.js
@@ -0,0 +1,867 @@
+const gSelects = {
+ PAGECONTENT_COLORS:
+ "<html><head><style>" +
+ " .blue { color: #fff; background-color: #00f; }" +
+ " .green { color: #800080; background-color: green; }" +
+ " .defaultColor { color: -moz-ComboboxText; }" +
+ " .defaultBackground { background-color: -moz-Combobox; }" +
+ "</style>" +
+ "<body><select id='one'>" +
+ ' <option value="One" style="color: #fff; background-color: #f00;">{"color": "rgb(255, 255, 255)", "backgroundColor": "rgb(255, 0, 0)"}</option>' +
+ ' <option value="Two" class="blue">{"color": "rgb(255, 255, 255)", "backgroundColor": "rgb(0, 0, 255)"}</option>' +
+ ' <option value="Three" class="green">{"color": "rgb(128, 0, 128)", "backgroundColor": "rgb(0, 128, 0)"}</option>' +
+ ' <option value="Four" class="defaultColor defaultBackground">{"color": "-moz-ComboboxText", "backgroundColor": "-moz-Combobox"}</option>' +
+ ' <option value="Five" class="defaultColor">{"color": "-moz-ComboboxText", "backgroundColor": "rgba(0, 0, 0, 0)", "unstyled": "true"}</option>' +
+ ' <option value="Six" class="defaultBackground">{"color": "-moz-ComboboxText", "backgroundColor": "-moz-Combobox"}</option>' +
+ ' <option value="Seven" selected="true">{"unstyled": "true"}</option>' +
+ "</select></body></html>",
+
+ PAGECONTENT_COLORS_ON_SELECT:
+ "<html><head><style>" +
+ " #one { background-color: #7E3A3A; color: #fff }" +
+ "</style>" +
+ "<body><select id='one'>" +
+ ' <option value="One">{"color": "rgb(255, 255, 255)", "backgroundColor": "rgba(0, 0, 0, 0)"}</option>' +
+ ' <option value="Two">{"color": "rgb(255, 255, 255)", "backgroundColor": "rgba(0, 0, 0, 0)"}</option>' +
+ ' <option value="Three">{"color": "rgb(255, 255, 255)", "backgroundColor": "rgba(0, 0, 0, 0)"}</option>' +
+ ' <option value="Four" selected="true">{"end": "true"}</option>' +
+ "</select></body></html>",
+
+ TRANSPARENT_SELECT:
+ "<html><head><style>" +
+ " #one { background-color: transparent; }" +
+ "</style>" +
+ "<body><select id='one'>" +
+ ' <option value="One">{"unstyled": "true"}</option>' +
+ ' <option value="Two" selected="true">{"end": "true"}</option>' +
+ "</select></body></html>",
+
+ OPTION_COLOR_EQUAL_TO_UABACKGROUND_COLOR_SELECT:
+ "<html><head><style>" +
+ " #one { background-color: black; color: white; }" +
+ "</style>" +
+ "<body><select id='one'>" +
+ ' <option value="One" style="background-color: white; color: black;">{"color": "rgb(0, 0, 0)", "backgroundColor": "rgb(255, 255, 255)"}</option>' +
+ ' <option value="Two" selected="true">{"end": "true"}</option>' +
+ "</select></body></html>",
+
+ GENERIC_OPTION_STYLED_AS_IMPORTANT:
+ "<html><head><style>" +
+ " option { background-color: black !important; color: white !important; }" +
+ "</style>" +
+ "<body><select id='one'>" +
+ ' <option value="One">{"color": "rgb(255, 255, 255)", "backgroundColor": "rgb(0, 0, 0)"}</option>' +
+ ' <option value="Two" selected="true">{"end": "true"}</option>' +
+ "</select></body></html>",
+
+ TRANSLUCENT_SELECT_BECOMES_OPAQUE:
+ "<html><head>" +
+ "<body><select id='one' style='background-color: rgba(255,255,255,.55);'>" +
+ ' <option value="One">{"color": "rgb(0, 0, 0)", "backgroundColor": "rgba(0, 0, 0, 0)"}</option>' +
+ ' <option value="Two" selected="true">{"end": "true"}</option>' +
+ "</select></body></html>",
+
+ TRANSLUCENT_SELECT_APPLIES_ON_BASE_COLOR:
+ "<html><head>" +
+ "<body><select id='one' style='background-color: rgba(255,0,0,.55);'>" +
+ ' <option value="One">{"color": "rgb(0, 0, 0)", "backgroundColor": "rgba(0, 0, 0, 0)"}</option>' +
+ ' <option value="Two" selected="true">{"end": "true"}</option>' +
+ "</select></body></html>",
+
+ DISABLED_OPTGROUP_AND_OPTIONS:
+ "<html><head>" +
+ "<body><select id='one'>" +
+ " <optgroup label='{\"unstyled\": true}'>" +
+ ' <option disabled="">{"color": "GrayText", "backgroundColor": "-moz-Combobox"}</option>' +
+ ' <option>{"unstyled": true}</option>' +
+ ' <option disabled="">{"color": "GrayText", "backgroundColor": "-moz-Combobox"}</option>' +
+ ' <option>{"unstyled": true}</option>' +
+ ' <option>{"unstyled": true}</option>' +
+ ' <option>{"unstyled": true}</option>' +
+ ' <option>{"unstyled": true}</option>' +
+ " </optgroup>" +
+ ' <optgroup label=\'{"color": "GrayText", "backgroundColor": "-moz-Combobox"}\' disabled=\'\'>' +
+ ' <option>{"color": "GrayText", "backgroundColor": "-moz-Combobox"}</option>' +
+ ' <option>{"color": "GrayText", "backgroundColor": "-moz-Combobox"}</option>' +
+ ' <option>{"color": "GrayText", "backgroundColor": "-moz-Combobox"}</option>' +
+ ' <option>{"color": "GrayText", "backgroundColor": "-moz-Combobox"}</option>' +
+ ' <option>{"color": "GrayText", "backgroundColor": "-moz-Combobox"}</option>' +
+ ' <option>{"color": "GrayText", "backgroundColor": "-moz-Combobox"}</option>' +
+ ' <option>{"color": "GrayText", "backgroundColor": "-moz-Combobox"}</option>' +
+ " </optgroup>" +
+ ' <option value="Two" selected="true">{"end": "true"}</option>' +
+ "</select></body></html>",
+
+ SELECT_CHANGES_COLOR_ON_FOCUS:
+ "<html><head><style>" +
+ " select:focus { background-color: orange; color: black; }" +
+ "</style></head>" +
+ "<body><select id='one'>" +
+ ' <option>{"color": "rgb(0, 0, 0)", "backgroundColor": "rgba(0, 0, 0, 0)"}</option>' +
+ ' <option selected="true">{"end": "true"}</option>' +
+ "</select></body></html>",
+
+ SELECT_BGCOLOR_ON_SELECT_COLOR_ON_OPTIONS:
+ "<html><head><style>" +
+ " select { background-color: black; }" +
+ " option { color: white; }" +
+ "</style></head>" +
+ "<body><select id='one'>" +
+ ' <option>{"colorScheme": "dark", "color": "rgb(255, 255, 255)", "backgroundColor": "rgb(0, 0, 0)"}</option>' +
+ ' <option selected="true">{"end": "true"}</option>' +
+ "</select></body></html>",
+
+ SELECT_STYLE_OF_OPTION_IS_BASED_ON_FOCUS_OF_SELECT:
+ "<html><head><style>" +
+ " select:focus { background-color: #3a96dd; }" +
+ " select:focus option { background-color: #fff; }" +
+ "</style></head>" +
+ "<body><select id='one'>" +
+ ' <option>{"color": "-moz-ComboboxText", "backgroundColor": "rgb(255, 255, 255)"}</option>' +
+ ' <option selected="true">{"end": "true"}</option>' +
+ "</select></body></html>",
+
+ SELECT_STYLE_OF_OPTION_CHANGES_AFTER_FOCUS_EVENT:
+ "<html><body><select id='one'>" +
+ ' <option>{"color": "rgb(255, 0, 0)", "backgroundColor": "rgba(0, 0, 0, 0)"}</option>' +
+ ' <option selected="true">{"end": "true"}</option>' +
+ "</select></body><scr" +
+ "ipt>" +
+ " var select = document.getElementById('one');" +
+ " select.addEventListener('focus', () => select.style.color = 'red');" +
+ "</script></html>",
+
+ SELECT_COLOR_OF_OPTION_CHANGES_AFTER_TRANSITIONEND:
+ "<html><head><style>" +
+ " select { transition: all .1s; }" +
+ " select:focus { background-color: orange; }" +
+ "</style></head><body><select id='one'>" +
+ ' <option>{"color": "-moz-ComboboxText", "backgroundColor": "rgba(0, 0, 0, 0)"}</option>' +
+ ' <option selected="true">{"end": "true"}</option>' +
+ "</select></body></html>",
+
+ SELECT_TEXTSHADOW_OF_OPTION_CHANGES_AFTER_TRANSITIONEND:
+ "<html><head><style>" +
+ " select { transition: all .1s; }" +
+ " select:focus { text-shadow: 0 0 0 #303030; }" +
+ " option { color: red; /* It gets the default otherwise, which is fine but we don't have a good way to test for */ }" +
+ "</style></head><body><select id='one'>" +
+ ' <option>{"color": "rgb(255, 0, 0)", "backgroundColor": "-moz-Combobox", "textShadow": "rgb(48, 48, 48) 0px 0px 0px"}</option>' +
+ ' <option selected="true">{"end": "true"}</option>' +
+ "</select></body></html>",
+
+ SELECT_TRANSPARENT_COLOR_WITH_TEXT_SHADOW:
+ "<html><head><style>" +
+ " select { color: transparent; text-shadow: 0 0 0 #303030; }" +
+ "</style></head><body><select id='one'>" +
+ ' <option>{"color": "rgba(0, 0, 0, 0)", "backgroundColor": "rgba(0, 0, 0, 0)", "textShadow": "rgb(48, 48, 48) 0px 0px 0px"}</option>' +
+ ' <option selected="true">{"end": "true"}</option>' +
+ "</select></body></html>",
+
+ SELECT_LONG_WITH_TRANSITION:
+ "<html><head><style>" +
+ " select { transition: all .2s linear; }" +
+ " select:focus { color: purple; }" +
+ "</style></head><body><select id='one'>" +
+ (function () {
+ let rv = "";
+ for (let i = 0; i < 75; i++) {
+ rv +=
+ ' <option>{"color": "rgb(128, 0, 128)", "backgroundColor": "rgba(0, 0, 0, 0)"}</option>';
+ }
+ rv +=
+ ' <option selected="true">{"end": "true"}</option>' +
+ "</select></body></html>";
+ return rv;
+ })(),
+
+ SELECT_INHERITED_COLORS_ON_OPTIONS_DONT_GET_UNIQUE_RULES_IF_RULE_SET_ON_SELECT: `
+ <html><head><style>
+ select { color: blue; text-shadow: 1px 1px 2px blue; }
+ .redColor { color: red; }
+ .textShadow { text-shadow: 1px 1px 2px black; }
+ </style></head><body><select id='one'>
+ <option>{"color": "rgb(0, 0, 255)", "backgroundColor": "rgba(0, 0, 0, 0)"}</option>
+ <option>{"color": "rgb(0, 0, 255)", "backgroundColor": "rgba(0, 0, 0, 0)"}</option>
+ <option>{"color": "rgb(0, 0, 255)", "backgroundColor": "rgba(0, 0, 0, 0)"}</option>
+ <option class="redColor">{"color": "rgb(255, 0, 0)", "backgroundColor": "-moz-Combobox"}</option>
+ <option class="textShadow">{"color": "rgb(0, 0, 255)", "textShadow": "rgb(0, 0, 0) 1px 1px 2px", "backgroundColor": "rgba(0, 0, 0, 0)"}</option>
+ <option selected="true">{"end": "true"}</option>
+ </select></body></html>
+`,
+
+ SELECT_FONT_INHERITS_TO_OPTION: `
+ <html><head><style>
+ select { font-family: monospace }
+ </style></head><body><select id='one'>
+ <option>One</option>
+ <option style="font-family: sans-serif">Two</option>
+ </select></body></html>
+`,
+
+ SELECT_SCROLLBAR_PROPS: `
+ <html><head><style>
+ select { scrollbar-width: thin; scrollbar-color: red blue }
+ </style></head><body><select id='one'>
+ <option>One</option>
+ <option style="font-family: sans-serif">Two</option>
+ </select></body></html>
+`,
+ DEFAULT_DARKMODE: `
+ <html><body><select id='one'>
+ <option>{"unstyled": "true"}</option>
+ <option>{"unstyled": "true"}</option>
+ <option selected="true">{"end": "true"}</option>
+ </select></body></html>
+`,
+
+ DEFAULT_DARKMODE_DARK: `
+ <meta name=color-scheme content=dark>
+ <select id='one'>
+ <option>{"color": "MenuText", "backgroundColor": "rgba(0, 0, 0, 0)"}</option>
+ <option>{"color": "MenuText", "backgroundColor": "rgba(0, 0, 0, 0)"}</option>
+ <option selected="true">{"end": "true"}</option>
+ </select>
+`,
+
+ SPLIT_FG_BG_OPTION_DARKMODE: `
+ <html><head><style>
+ select { background-color: #fff; }
+ option { color: #2b2b2b; }
+ </style></head><body><select id='one'>
+ <option>{"color": "rgb(43, 43, 43)", "backgroundColor": "rgb(255, 255, 255)"}</option>
+ <option>{"color": "rgb(43, 43, 43)", "backgroundColor": "rgb(255, 255, 255)"}</option>
+ <option selected="true">{"end": "true"}</option>
+ </select></body></html>
+`,
+
+ IDENTICAL_BG_DIFF_FG_OPTION_DARKMODE: `
+ <html><head><style>
+ select { background-color: #fff; }
+ option { color: #2b2b2b; background-color: #fff; }
+ </style></head><body><select id='one'>
+ <option>{"colorScheme": "light", "color": "rgb(43, 43, 43)", "backgroundColor": "rgb(255, 255, 255)"}</option>
+ <option>{"colorScheme": "light", "color": "rgb(43, 43, 43)", "backgroundColor": "rgb(255, 255, 255)"}</option>
+ <option selected="true">{"end": "true"}</option>
+ </select></body></html>
+`,
+};
+
+function rgbaToString(parsedColor) {
+ let { r, g, b, a } = parsedColor;
+ if (a == 1) {
+ return `rgb(${r}, ${g}, ${b})`;
+ }
+ return `rgba(${r}, ${g}, ${b}, ${a})`;
+}
+
+function testOptionColors(test, index, item, menulist) {
+ // The label contains a JSON string of the expected colors for
+ // `color` and `background-color`.
+ let expected = JSON.parse(item.label);
+
+ // Press Down to move the selected item to the next item in the
+ // list and check the colors of this item when it's not selected.
+ EventUtils.synthesizeKey("KEY_ArrowDown");
+
+ if (expected.end) {
+ return;
+ }
+
+ if (expected.unstyled) {
+ ok(
+ !item.hasAttribute("customoptionstyling"),
+ `${test}: Item ${index} should not have any custom option styling: ${item.outerHTML}`
+ );
+ } else {
+ is(
+ getComputedStyle(item).color,
+ expected.color,
+ `${test}: Item ${index} has correct foreground color`
+ );
+ is(
+ getComputedStyle(item).backgroundColor,
+ expected.backgroundColor,
+ `${test}: Item ${index} has correct background color`
+ );
+ if (expected.textShadow) {
+ is(
+ getComputedStyle(item).textShadow,
+ expected.textShadow,
+ `${test}: Item ${index} has correct text-shadow color`
+ );
+ }
+ }
+}
+
+function computeLabels(tab) {
+ return SpecialPowers.spawn(tab.linkedBrowser, [], function () {
+ function _rgbaToString(parsedColor) {
+ let { r, g, b, a } = parsedColor;
+ if (a == 1) {
+ return `rgb(${r}, ${g}, ${b})`;
+ }
+ return `rgba(${r}, ${g}, ${b}, ${a})`;
+ }
+ function computeColors(expected) {
+ let any = false;
+ for (let color of Object.keys(expected)) {
+ if (
+ color != "colorScheme" &&
+ color.toLowerCase().includes("color") &&
+ !expected[color].startsWith("rgb")
+ ) {
+ any = true;
+ expected[color] = _rgbaToString(
+ InspectorUtils.colorToRGBA(expected[color], content.document)
+ );
+ }
+ }
+ return any;
+ }
+ for (let option of content.document.querySelectorAll("option,optgroup")) {
+ if (!option.label) {
+ continue;
+ }
+ let expected;
+ try {
+ expected = JSON.parse(option.label);
+ } catch (ex) {
+ continue;
+ }
+ if (computeColors(expected)) {
+ option.label = JSON.stringify(expected);
+ }
+ }
+ });
+}
+
+async function openSelectPopup(select) {
+ const pageUrl = "data:text/html," + escape(select);
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, pageUrl);
+
+ await computeLabels(tab);
+
+ let popupShownPromise = BrowserTestUtils.waitForSelectPopupShown(window);
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#one",
+ { type: "mousedown" },
+ gBrowser.selectedBrowser
+ );
+ let selectPopup = await popupShownPromise;
+ let menulist = selectPopup.parentNode;
+ return { tab, menulist, selectPopup };
+}
+
+async function testSelectColors(selectID, itemCount, options) {
+ let select = gSelects[selectID];
+ let { tab, menulist, selectPopup } = await openSelectPopup(select);
+ if (options.unstyled) {
+ ok(
+ !selectPopup.hasAttribute("customoptionstyling"),
+ `Shouldn't have custom option styling for ${selectID}`
+ );
+ }
+ let arrowSB = selectPopup.shadowRoot.querySelector(
+ ".menupopup-arrowscrollbox"
+ );
+ if (options.waitForComputedStyle) {
+ let property = options.waitForComputedStyle.property;
+ let expectedValue = options.waitForComputedStyle.value;
+ await TestUtils.waitForCondition(() => {
+ let node = ["background-image", "background-color"].includes(property)
+ ? arrowSB
+ : selectPopup;
+ let value = getComputedStyle(node).getPropertyValue(property);
+ info(`<${node.localName}> has ${property}: ${value}`);
+ return value == expectedValue;
+ }, `${selectID} - Waiting for <select> to have ${property}: ${expectedValue}`);
+ }
+
+ is(selectPopup.parentNode.itemCount, itemCount, "Correct number of items");
+ let child = selectPopup.firstElementChild;
+ let idx = 1;
+
+ if (typeof options.skipSelectColorTest != "object") {
+ let skip = !!options.skipSelectColorTest;
+ options.skipSelectColorTest = {
+ color: skip,
+ background: skip,
+ };
+ }
+ if (!options.skipSelectColorTest.color) {
+ is(
+ getComputedStyle(arrowSB).color,
+ options.selectColor,
+ selectID + " popup has expected foreground color"
+ );
+ }
+
+ if (options.selectTextShadow) {
+ is(
+ getComputedStyle(selectPopup).textShadow,
+ options.selectTextShadow,
+ selectID + " popup has expected text-shadow color"
+ );
+ }
+
+ if (!options.skipSelectColorTest.background) {
+ // Combine the select popup's backgroundColor and the
+ // backgroundImage color to get the color that is seen by
+ // the user.
+ let base = getComputedStyle(arrowSB).backgroundColor;
+ if (base == "rgba(0, 0, 0, 0)") {
+ base = getComputedStyle(selectPopup).backgroundColor;
+ }
+ info("Parsing background color: " + base);
+ let [, /* unused */ bR, bG, bB] = base.match(/rgb\((\d+), (\d+), (\d+)\)/);
+ bR = parseInt(bR, 10);
+ bG = parseInt(bG, 10);
+ bB = parseInt(bB, 10);
+ let topCoat = getComputedStyle(arrowSB).backgroundImage;
+ if (topCoat == "none") {
+ is(
+ `rgb(${bR}, ${bG}, ${bB})`,
+ options.selectBgColor,
+ selectID + " popup has expected background color (top coat)"
+ );
+ } else {
+ let [, , /* unused */ /* unused */ tR, tG, tB, tA] = topCoat.match(
+ /(rgba?\((\d+), (\d+), (\d+)(?:, (0\.\d+))?\)), \1/
+ );
+ tR = parseInt(tR, 10);
+ tG = parseInt(tG, 10);
+ tB = parseInt(tB, 10);
+ tA = parseFloat(tA) || 1;
+ let actualR = Math.round(tR * tA + bR * (1 - tA));
+ let actualG = Math.round(tG * tA + bG * (1 - tA));
+ let actualB = Math.round(tB * tA + bB * (1 - tA));
+ is(
+ `rgb(${actualR}, ${actualG}, ${actualB})`,
+ options.selectBgColor,
+ selectID + " popup has expected background color (no top coat)"
+ );
+ }
+ }
+
+ ok(!child.selected, "The first child should not be selected");
+ while (child) {
+ testOptionColors(selectID, idx, child, menulist);
+ idx++;
+ child = child.nextElementSibling;
+ }
+
+ if (!options.leaveOpen) {
+ await hideSelectPopup("escape");
+ BrowserTestUtils.removeTab(tab);
+ }
+}
+
+// System colors may be different in content pages and chrome pages.
+let kDefaultSelectStyles = {};
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [["dom.forms.select.customstyling", true]],
+ });
+ kDefaultSelectStyles = await BrowserTestUtils.withNewTab(
+ `data:text/html,<select>`,
+ function (browser) {
+ return SpecialPowers.spawn(browser, [], function () {
+ let cs = content.getComputedStyle(
+ content.document.querySelector("select")
+ );
+ return {
+ backgroundColor: cs.backgroundColor,
+ };
+ });
+ }
+ );
+});
+
+// This test checks when a <select> element has styles applied to <option>s within it.
+add_task(async function test_colors_applied_to_popup_items() {
+ await testSelectColors("PAGECONTENT_COLORS", 7, {
+ skipSelectColorTest: true,
+ });
+});
+
+// This test checks when a <select> element has styles applied to itself.
+add_task(async function test_colors_applied_to_popup() {
+ let options = {
+ selectColor: "rgb(255, 255, 255)",
+ selectBgColor: "rgb(126, 58, 58)",
+ };
+ await testSelectColors("PAGECONTENT_COLORS_ON_SELECT", 4, options);
+});
+
+// This test checks when a <select> element has a transparent background applied to itself.
+add_task(async function test_transparent_applied_to_popup() {
+ let options = {
+ unstyled: true,
+ skipSelectColorTest: true,
+ };
+ await testSelectColors("TRANSPARENT_SELECT", 2, options);
+});
+
+// This test checks when a <select> element has a background set, and the
+// options have their own background set which is equal to the default
+// user-agent background color, but should be used because the select
+// background color has been changed.
+add_task(async function test_options_inverted_from_select_background() {
+ // The popup has a black background and white text, but the
+ // options inside of it have flipped the colors.
+ let options = {
+ selectColor: "rgb(255, 255, 255)",
+ selectBgColor: "rgb(0, 0, 0)",
+ };
+ await testSelectColors(
+ "OPTION_COLOR_EQUAL_TO_UABACKGROUND_COLOR_SELECT",
+ 2,
+ options
+ );
+});
+
+// This test checks when a <select> element has a background set using !important,
+// which was affecting how we calculated the user-agent styling.
+add_task(async function test_select_background_using_important() {
+ await testSelectColors("GENERIC_OPTION_STYLED_AS_IMPORTANT", 2, {
+ skipSelectColorTest: true,
+ });
+});
+
+// This test checks when a <select> element has a background set, and the
+// options have their own background set which is equal to the default
+// user-agent background color, but should be used because the select
+// background color has been changed.
+add_task(async function test_translucent_select_becomes_opaque() {
+ // The popup is requested to show a translucent background
+ // but we apply the requested background color on the system's base color.
+ let options = {
+ selectColor: "rgb(0, 0, 0)",
+ selectBgColor: "rgb(255, 255, 255)",
+ };
+ await testSelectColors("TRANSLUCENT_SELECT_BECOMES_OPAQUE", 2, options);
+});
+
+// This test checks when a popup has a translucent background color,
+// and that the color painted to the screen of the translucent background
+// matches what the user expects.
+add_task(async function test_translucent_select_applies_on_base_color() {
+ // The popup is requested to show a translucent background
+ // but we apply the requested background color on the system's base color.
+ let options = {
+ selectColor: "rgb(0, 0, 0)",
+ selectBgColor: "rgb(255, 115, 115)",
+ };
+ await testSelectColors(
+ "TRANSLUCENT_SELECT_APPLIES_ON_BASE_COLOR",
+ 2,
+ options
+ );
+});
+
+add_task(async function test_disabled_optgroup_and_options() {
+ await testSelectColors("DISABLED_OPTGROUP_AND_OPTIONS", 17, {
+ skipSelectColorTest: true,
+ });
+});
+
+add_task(async function test_disabled_optgroup_and_options() {
+ let options = {
+ selectColor: "rgb(0, 0, 0)",
+ selectBgColor: "rgb(255, 165, 0)",
+ };
+
+ await testSelectColors("SELECT_CHANGES_COLOR_ON_FOCUS", 2, options);
+});
+
+add_task(async function test_bgcolor_on_select_color_on_options() {
+ let options = {
+ selectColor: "rgb(0, 0, 0)",
+ selectBgColor: "rgb(0, 0, 0)",
+ };
+
+ await testSelectColors(
+ "SELECT_BGCOLOR_ON_SELECT_COLOR_ON_OPTIONS",
+ 2,
+ options
+ );
+});
+
+add_task(
+ async function test_style_of_options_is_dependent_on_focus_of_select() {
+ let options = {
+ selectColor: "rgb(0, 0, 0)",
+ selectBgColor: "rgb(58, 150, 221)",
+ };
+
+ await testSelectColors(
+ "SELECT_STYLE_OF_OPTION_IS_BASED_ON_FOCUS_OF_SELECT",
+ 2,
+ options
+ );
+ }
+);
+
+add_task(
+ async function test_style_of_options_is_dependent_on_focus_of_select_after_event() {
+ let options = {
+ skipSelectColorTest: true,
+ waitForComputedStyle: {
+ property: "--panel-color",
+ value: "rgb(255, 0, 0)",
+ },
+ };
+ await testSelectColors(
+ "SELECT_STYLE_OF_OPTION_CHANGES_AFTER_FOCUS_EVENT",
+ 2,
+ options
+ );
+ }
+);
+
+add_task(async function test_color_of_options_is_dependent_on_transitionend() {
+ let options = {
+ selectColor: "rgb(0, 0, 0)",
+ selectBgColor: "rgb(255, 165, 0)",
+ waitForComputedStyle: {
+ property: "background-image",
+ value: "linear-gradient(rgb(255, 165, 0), rgb(255, 165, 0))",
+ },
+ };
+
+ await testSelectColors(
+ "SELECT_COLOR_OF_OPTION_CHANGES_AFTER_TRANSITIONEND",
+ 2,
+ options
+ );
+});
+
+add_task(
+ async function test_textshadow_of_options_is_dependent_on_transitionend() {
+ let options = {
+ skipSelectColorTest: true,
+ waitForComputedStyle: {
+ property: "text-shadow",
+ value: "rgb(48, 48, 48) 0px 0px 0px",
+ },
+ };
+
+ await testSelectColors(
+ "SELECT_TEXTSHADOW_OF_OPTION_CHANGES_AFTER_TRANSITIONEND",
+ 2,
+ options
+ );
+ }
+);
+
+add_task(async function test_transparent_color_with_text_shadow() {
+ let options = {
+ selectColor: "rgba(0, 0, 0, 0)",
+ selectTextShadow: "rgb(48, 48, 48) 0px 0px 0px",
+ selectBgColor: kDefaultSelectStyles.backgroundColor,
+ };
+
+ await testSelectColors(
+ "SELECT_TRANSPARENT_COLOR_WITH_TEXT_SHADOW",
+ 2,
+ options
+ );
+});
+
+add_task(
+ async function test_select_with_transition_doesnt_lose_scroll_position() {
+ let options = {
+ selectColor: "rgb(128, 0, 128)",
+ selectBgColor: kDefaultSelectStyles.backgroundColor,
+ waitForComputedStyle: {
+ property: "--panel-color",
+ value: "rgb(128, 0, 128)",
+ },
+ leaveOpen: true,
+ };
+
+ await testSelectColors("SELECT_LONG_WITH_TRANSITION", 76, options);
+
+ let selectPopup = document.getElementById(
+ "ContentSelectDropdown"
+ ).menupopup;
+ let scrollBox = selectPopup.scrollBox;
+ is(
+ scrollBox.scrollTop,
+ scrollBox.scrollTopMax,
+ "The popup should be scrolled to the bottom of the list (where the selected item is)"
+ );
+
+ await hideSelectPopup("escape");
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ }
+);
+
+add_task(
+ async function test_select_inherited_colors_on_options_dont_get_unique_rules_if_rule_set_on_select() {
+ let options = {
+ selectColor: "rgb(0, 0, 255)",
+ selectTextShadow: "rgb(0, 0, 255) 1px 1px 2px",
+ selectBgColor: kDefaultSelectStyles.backgroundColor,
+ leaveOpen: true,
+ };
+
+ await testSelectColors(
+ "SELECT_INHERITED_COLORS_ON_OPTIONS_DONT_GET_UNIQUE_RULES_IF_RULE_SET_ON_SELECT",
+ 6,
+ options
+ );
+
+ let stylesheetEl = document.getElementById(
+ "ContentSelectDropdownStylesheet"
+ );
+
+ let sheet = stylesheetEl.sheet;
+ /* Check that the rules are what we expect: There are three different option styles (even though there are 6 options, plus the select rules). */
+ let expectedSelectors = [
+ "#ContentSelectDropdown .ContentSelectDropdown-item-0",
+ "#ContentSelectDropdown .ContentSelectDropdown-item-1",
+ '#ContentSelectDropdown .ContentSelectDropdown-item-1:not([_moz-menuactive="true"])',
+ "#ContentSelectDropdown .ContentSelectDropdown-item-2",
+ '#ContentSelectDropdown .ContentSelectDropdown-item-2:not([_moz-menuactive="true"])',
+ '#ContentSelectDropdown > menupopup > :is(menuitem, menucaption):not([_moz-menuactive="true"])',
+ '#ContentSelectDropdown > menupopup > :is(menuitem, menucaption)[_moz-menuactive="true"]',
+ ].sort();
+
+ let actualSelectors = [...sheet.cssRules].map(r => r.selectorText).sort();
+ is(
+ actualSelectors.length,
+ expectedSelectors.length,
+ "Should have the expected number of rules"
+ );
+ for (let i = 0; i < expectedSelectors.length; ++i) {
+ is(
+ actualSelectors[i],
+ expectedSelectors[i],
+ `Selector ${i} should match`
+ );
+ }
+
+ await hideSelectPopup("escape");
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ }
+);
+
+add_task(async function test_select_font_inherits_to_option() {
+ let { tab, menulist, selectPopup } = await openSelectPopup(
+ gSelects.SELECT_FONT_INHERITS_TO_OPTION
+ );
+
+ let popupFont = getComputedStyle(selectPopup).fontFamily;
+ let items = menulist.querySelectorAll("menuitem");
+ is(items.length, 2, "Should have two options");
+ let firstItemFont = getComputedStyle(items[0]).fontFamily;
+ let secondItemFont = getComputedStyle(items[1]).fontFamily;
+
+ is(
+ popupFont,
+ firstItemFont,
+ "First menuitem's font should be inherited from the select"
+ );
+ isnot(
+ popupFont,
+ secondItemFont,
+ "Second menuitem's font should be the author specified one"
+ );
+
+ await hideSelectPopup("escape");
+ BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function test_scrollbar_props() {
+ let { tab, selectPopup } = await openSelectPopup(
+ gSelects.SELECT_SCROLLBAR_PROPS
+ );
+
+ let popupStyle = getComputedStyle(selectPopup);
+ is(popupStyle.getPropertyValue("--content-select-scrollbar-width"), "thin");
+ is(popupStyle.scrollbarColor, "rgb(255, 0, 0) rgb(0, 0, 255)");
+
+ let scrollBoxStyle = getComputedStyle(selectPopup.scrollBox.scrollbox);
+ is(scrollBoxStyle.overflow, "auto", "Should be the scrollable box");
+ is(scrollBoxStyle.scrollbarWidth, "thin");
+ is(scrollBoxStyle.scrollbarColor, "rgb(255, 0, 0) rgb(0, 0, 255)");
+
+ await hideSelectPopup("escape");
+ BrowserTestUtils.removeTab(tab);
+});
+
+if (AppConstants.isPlatformAndVersionAtLeast("win", "10")) {
+ add_task(async function test_darkmode() {
+ let lightSelectColor = rgbaToString(
+ InspectorUtils.colorToRGBA("MenuText", document)
+ );
+ let lightSelectBgColor = rgbaToString(
+ InspectorUtils.colorToRGBA("Menu", document)
+ );
+
+ // Force dark mode:
+ let darkModeQuery = matchMedia("(prefers-color-scheme: dark)");
+ let darkModeChange = BrowserTestUtils.waitForEvent(darkModeQuery, "change");
+ await SpecialPowers.pushPrefEnv({ set: [["ui.systemUsesDarkTheme", 1]] });
+ await darkModeChange;
+
+ // Determine colours from the main context menu:
+ let darkSelectColor = rgbaToString(
+ InspectorUtils.colorToRGBA("MenuText", document)
+ );
+ let darkSelectBgColor = rgbaToString(
+ InspectorUtils.colorToRGBA("Menu", document)
+ );
+
+ isnot(lightSelectColor, darkSelectColor);
+ isnot(lightSelectBgColor, darkSelectBgColor);
+
+ let { tab } = await openSelectPopup(gSelects.DEFAULT_DARKMODE);
+
+ await testSelectColors("DEFAULT_DARKMODE", 3, {
+ selectColor: lightSelectColor,
+ selectBgColor: lightSelectBgColor,
+ });
+
+ await hideSelectPopup("escape");
+
+ await testSelectColors("DEFAULT_DARKMODE_DARK", 3, {
+ selectColor: darkSelectColor,
+ selectBgColor: darkSelectBgColor,
+ });
+
+ await hideSelectPopup("escape");
+ BrowserTestUtils.removeTab(tab);
+
+ ({ tab } = await openSelectPopup(
+ gSelects.IDENTICAL_BG_DIFF_FG_OPTION_DARKMODE
+ ));
+
+ // Custom styling on the options enforces using the select styling, too,
+ // even if it matched the UA style. They'll be overridden on individual
+ // options where necessary.
+ await testSelectColors("IDENTICAL_BG_DIFF_FG_OPTION_DARKMODE", 3, {
+ selectColor: "rgb(0, 0, 0)",
+ selectBgColor: "rgb(255, 255, 255)",
+ });
+
+ await hideSelectPopup("escape");
+ BrowserTestUtils.removeTab(tab);
+
+ ({ tab } = await openSelectPopup(gSelects.SPLIT_FG_BG_OPTION_DARKMODE));
+
+ // Like the previous case, but here the bg colour is defined on the
+ // select, and the fg colour on the option. The behaviour should be the
+ // same.
+ await testSelectColors("SPLIT_FG_BG_OPTION_DARKMODE", 3, {
+ selectColor: "rgb(0, 0, 0)",
+ selectBgColor: "rgb(255, 255, 255)",
+ });
+
+ await hideSelectPopup("escape");
+ BrowserTestUtils.removeTab(tab);
+ });
+}
diff --git a/browser/base/content/test/forms/browser_selectpopup_dir.js b/browser/base/content/test/forms/browser_selectpopup_dir.js
new file mode 100644
index 0000000000..aaf4a61fc2
--- /dev/null
+++ b/browser/base/content/test/forms/browser_selectpopup_dir.js
@@ -0,0 +1,21 @@
+const PAGE = `
+<!doctype html>
+<select style="direction: rtl">
+ <option>ABC</option>
+ <option>DEFG</option>
+</select>
+`;
+
+add_task(async function () {
+ const url = "data:text/html," + encodeURI(PAGE);
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url,
+ },
+ async function (browser) {
+ let popup = await openSelectPopup("click");
+ is(popup.style.direction, "rtl", "Should be the right dir");
+ }
+ );
+});
diff --git a/browser/base/content/test/forms/browser_selectpopup_large.js b/browser/base/content/test/forms/browser_selectpopup_large.js
new file mode 100644
index 0000000000..0c88755b27
--- /dev/null
+++ b/browser/base/content/test/forms/browser_selectpopup_large.js
@@ -0,0 +1,338 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const PAGECONTENT_SMALL = `
+ <!doctype html>
+ <html>
+ <body><select id='one'>
+ <option value='One'>One</option>
+ <option value='Two'>Two</option>
+ </select><select id='two'>
+ <option value='Three'>Three</option>
+ <option value='Four'>Four</option>
+ </select><select id='three'>
+ <option value='Five'>Five</option>
+ <option value='Six'>Six</option>
+ </select></body></html>
+`;
+
+async function performLargePopupTests(win) {
+ let browser = win.gBrowser.selectedBrowser;
+
+ await SpecialPowers.spawn(browser, [], async function () {
+ let doc = content.document;
+ let select = doc.getElementById("one");
+ for (var i = 0; i < 180; i++) {
+ select.add(new content.Option("Test" + i));
+ }
+
+ select.options[60].selected = true;
+ select.focus();
+ });
+
+ // Check if a drag-select works and scrolls the list.
+ const selectPopup = await openSelectPopup("mousedown", "select", win);
+ const browserRect = browser.getBoundingClientRect();
+
+ let getScrollPos = () => selectPopup.scrollBox.scrollbox.scrollTop;
+ let scrollPos = getScrollPos();
+ let popupRect = selectPopup.getBoundingClientRect();
+
+ // First, check that scrolling does not occur when the mouse is moved over the
+ // anchor button but not the popup yet.
+ EventUtils.synthesizeMouseAtPoint(
+ popupRect.left + 5,
+ popupRect.top - 10,
+ {
+ type: "mousemove",
+ buttons: 1,
+ },
+ win
+ );
+ is(
+ getScrollPos(),
+ scrollPos,
+ "scroll position after mousemove over button should not change"
+ );
+
+ EventUtils.synthesizeMouseAtPoint(
+ popupRect.left + 20,
+ popupRect.top + 10,
+ {
+ type: "mousemove",
+ buttons: 1,
+ },
+ win
+ );
+
+ // Dragging above the popup scrolls it up.
+ let scrolledPromise = BrowserTestUtils.waitForEvent(
+ selectPopup,
+ "scroll",
+ false,
+ () => getScrollPos() < scrollPos - 5
+ );
+ EventUtils.synthesizeMouseAtPoint(
+ popupRect.left + 20,
+ popupRect.top - 20,
+ {
+ type: "mousemove",
+ buttons: 1,
+ },
+ win
+ );
+ await scrolledPromise;
+ ok(true, "scroll position at drag up");
+
+ // Dragging below the popup scrolls it down.
+ scrollPos = getScrollPos();
+ scrolledPromise = BrowserTestUtils.waitForEvent(
+ selectPopup,
+ "scroll",
+ false,
+ () => getScrollPos() > scrollPos + 5
+ );
+ EventUtils.synthesizeMouseAtPoint(
+ popupRect.left + 20,
+ popupRect.bottom + 20,
+ {
+ type: "mousemove",
+ buttons: 1,
+ },
+ win
+ );
+ await scrolledPromise;
+ ok(true, "scroll position at drag down");
+
+ // Releasing the mouse button and moving the mouse does not change the scroll position.
+ scrollPos = getScrollPos();
+ EventUtils.synthesizeMouseAtPoint(
+ popupRect.left + 20,
+ popupRect.bottom + 25,
+ { type: "mouseup" },
+ win
+ );
+ is(getScrollPos(), scrollPos, "scroll position at mouseup should not change");
+
+ EventUtils.synthesizeMouseAtPoint(
+ popupRect.left + 20,
+ popupRect.bottom + 20,
+ { type: "mousemove" },
+ win
+ );
+ is(
+ getScrollPos(),
+ scrollPos,
+ "scroll position at mousemove after mouseup should not change"
+ );
+
+ // Now check dragging with a mousedown on an item
+ let menuRect = selectPopup.children[51].getBoundingClientRect();
+ EventUtils.synthesizeMouseAtPoint(
+ menuRect.left + 5,
+ menuRect.top + 5,
+ { type: "mousedown" },
+ win
+ );
+
+ // Dragging below the popup scrolls it down.
+ scrolledPromise = BrowserTestUtils.waitForEvent(
+ selectPopup,
+ "scroll",
+ false,
+ () => getScrollPos() > scrollPos + 5
+ );
+ EventUtils.synthesizeMouseAtPoint(
+ popupRect.left + 20,
+ popupRect.bottom + 20,
+ {
+ type: "mousemove",
+ buttons: 1,
+ },
+ win
+ );
+ await scrolledPromise;
+ ok(true, "scroll position at drag down from option");
+
+ // Dragging above the popup scrolls it up.
+ scrolledPromise = BrowserTestUtils.waitForEvent(
+ selectPopup,
+ "scroll",
+ false,
+ () => getScrollPos() < scrollPos - 5
+ );
+ EventUtils.synthesizeMouseAtPoint(
+ popupRect.left + 20,
+ popupRect.top - 20,
+ {
+ type: "mousemove",
+ buttons: 1,
+ },
+ win
+ );
+ await scrolledPromise;
+ ok(true, "scroll position at drag up from option");
+
+ scrollPos = getScrollPos();
+ EventUtils.synthesizeMouseAtPoint(
+ popupRect.left + 20,
+ popupRect.bottom + 25,
+ { type: "mouseup" },
+ win
+ );
+ is(
+ getScrollPos(),
+ scrollPos,
+ "scroll position at mouseup from option should not change"
+ );
+
+ EventUtils.synthesizeMouseAtPoint(
+ popupRect.left + 20,
+ popupRect.bottom + 20,
+ { type: "mousemove" },
+ win
+ );
+ is(
+ getScrollPos(),
+ scrollPos,
+ "scroll position at mousemove after mouseup should not change"
+ );
+
+ await hideSelectPopup("escape", win);
+
+ let positions = [
+ "margin-top: 300px;",
+ "position: fixed; bottom: 200px;",
+ "width: 100%; height: 9999px;",
+ ];
+
+ let position;
+ while (positions.length) {
+ await openSelectPopup("key", "select", win);
+
+ let rect = selectPopup.getBoundingClientRect();
+ let marginBottom = parseFloat(getComputedStyle(selectPopup).marginBottom);
+ let marginTop = parseFloat(getComputedStyle(selectPopup).marginTop);
+ ok(
+ rect.top - marginTop >= browserRect.top,
+ "Popup top position in within browser area"
+ );
+ ok(
+ rect.bottom + marginBottom <= browserRect.bottom,
+ "Popup bottom position in within browser area"
+ );
+
+ let cs = win.getComputedStyle(selectPopup);
+ let csArrow = win.getComputedStyle(selectPopup.scrollBox);
+ let bpBottom =
+ parseFloat(cs.paddingBottom) +
+ parseFloat(cs.borderBottomWidth) +
+ parseFloat(csArrow.paddingBottom) +
+ parseFloat(csArrow.borderBottomWidth);
+ let selectedOption = 60;
+
+ if (Services.prefs.getBoolPref("dom.forms.selectSearch")) {
+ // Use option 61 instead of 60, as the 60th option element is actually the
+ // 61st child, since the first child is now the search input field.
+ selectedOption = 61;
+ }
+ // Some of the styles applied to the menuitems are percentages, meaning
+ // that the final layout calculations returned by getBoundingClientRect()
+ // might return floating point values. We don't care about sub-pixel
+ // accuracy, and only care about the final pixel value, so we add a
+ // fuzz-factor of 1.
+ //
+ // FIXME(emilio): In win7 scroll position is off by 20px more, but that's
+ // not reproducible in win10 even with the win7 "native" menus enabled.
+ const fuzzFactor = matchMedia("(-moz-platform: windows-win7)").matches
+ ? 21
+ : 1;
+ SimpleTest.isfuzzy(
+ selectPopup.children[selectedOption].getBoundingClientRect().bottom,
+ selectPopup.getBoundingClientRect().bottom - bpBottom + marginBottom,
+ fuzzFactor,
+ "Popup scroll at correct position " + bpBottom
+ );
+
+ await hideSelectPopup("enter", win);
+
+ position = positions.shift();
+
+ let contentPainted = BrowserTestUtils.waitForContentEvent(
+ browser,
+ "MozAfterPaint"
+ );
+ await SpecialPowers.spawn(
+ browser,
+ [position],
+ async function (contentPosition) {
+ let select = content.document.getElementById("one");
+ select.setAttribute("style", contentPosition || "");
+ select.getBoundingClientRect();
+ }
+ );
+ await contentPainted;
+ }
+
+ if (navigator.platform.indexOf("Mac") == 0) {
+ await SpecialPowers.spawn(browser, [], async function () {
+ let doc = content.document;
+ doc.body.style = "padding-top: 400px;";
+
+ let select = doc.getElementById("one");
+ select.options[41].selected = true;
+ select.focus();
+ });
+
+ await openSelectPopup("key", "select", win);
+
+ ok(
+ selectPopup.getBoundingClientRect().top >
+ browser.getBoundingClientRect().top,
+ "select popup appears over selected item"
+ );
+
+ await hideSelectPopup("escape", win);
+ }
+}
+
+// This test checks select elements with a large number of options to ensure that
+// the popup appears within the browser area.
+add_task(async function test_large_popup() {
+ const pageUrl = "data:text/html," + escape(PAGECONTENT_SMALL);
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, pageUrl);
+
+ await performLargePopupTests(window);
+
+ BrowserTestUtils.removeTab(tab);
+});
+
+// This test checks the same as the previous test but in a new, vertically smaller window.
+add_task(async function test_large_popup_in_small_window() {
+ let newWin = await BrowserTestUtils.openNewBrowserWindow();
+
+ let resizePromise = BrowserTestUtils.waitForEvent(
+ newWin,
+ "resize",
+ false,
+ e => {
+ info(`Got resize event (innerHeight: ${newWin.innerHeight})`);
+ return newWin.innerHeight <= 450;
+ }
+ );
+ newWin.resizeTo(600, 450);
+ await resizePromise;
+
+ const pageUrl = "data:text/html," + escape(PAGECONTENT_SMALL);
+ let browserLoadedPromise = BrowserTestUtils.browserLoaded(
+ newWin.gBrowser.selectedBrowser
+ );
+ BrowserTestUtils.loadURIString(newWin.gBrowser.selectedBrowser, pageUrl);
+ await browserLoadedPromise;
+
+ newWin.gBrowser.selectedBrowser.focus();
+
+ await performLargePopupTests(newWin);
+
+ await BrowserTestUtils.closeWindow(newWin);
+});
diff --git a/browser/base/content/test/forms/browser_selectpopup_searchfocus.js b/browser/base/content/test/forms/browser_selectpopup_searchfocus.js
new file mode 100644
index 0000000000..caae828668
--- /dev/null
+++ b/browser/base/content/test/forms/browser_selectpopup_searchfocus.js
@@ -0,0 +1,36 @@
+let SELECT = "<html><body><select id='one'>";
+for (let i = 0; i < 75; i++) {
+ SELECT += ` <option>${i}${i}${i}${i}${i}</option>`;
+}
+SELECT +=
+ ' <option selected="true">{"end": "true"}</option>' +
+ "</select></body></html>";
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [["dom.forms.selectSearch", true]],
+ });
+});
+
+add_task(async function test_focus_on_search_shouldnt_close_popup() {
+ const pageUrl = "data:text/html," + escape(SELECT);
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, pageUrl);
+ let selectPopup = await openSelectPopup("mousedown");
+
+ let searchInput = selectPopup.querySelector(
+ ".contentSelectDropdown-searchbox"
+ );
+ searchInput.scrollIntoView();
+ let searchFocused = BrowserTestUtils.waitForEvent(searchInput, "focus", true);
+ await EventUtils.synthesizeMouseAtCenter(searchInput, {}, window);
+ await searchFocused;
+
+ is(
+ selectPopup.state,
+ "open",
+ "select popup should still be open after clicking on the search field"
+ );
+
+ await hideSelectPopup("escape");
+ BrowserTestUtils.removeTab(tab);
+});
diff --git a/browser/base/content/test/forms/browser_selectpopup_text_transform.js b/browser/base/content/test/forms/browser_selectpopup_text_transform.js
new file mode 100644
index 0000000000..671f39e2a6
--- /dev/null
+++ b/browser/base/content/test/forms/browser_selectpopup_text_transform.js
@@ -0,0 +1,40 @@
+const PAGE = `
+<!doctype html>
+<select style="text-transform: uppercase">
+ <option>abc</option>
+ <option>defg</option>
+</select>
+`;
+
+add_task(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [["dom.forms.select.customstyling", true]],
+ });
+ const url = "data:text/html," + encodeURI(PAGE);
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url,
+ },
+ async function (browser) {
+ let popup = await openSelectPopup("click");
+ let menuitems = popup.querySelectorAll("menuitem");
+ is(menuitems[0].textContent, "abc", "Option text should be lowercase");
+ is(menuitems[1].textContent, "defg", "Option text should be lowercase");
+
+ let optionStyle = getComputedStyle(menuitems[0]);
+ is(
+ optionStyle.textTransform,
+ "uppercase",
+ "Option text should be transformed to uppercase"
+ );
+
+ optionStyle = getComputedStyle(menuitems[1]);
+ is(
+ optionStyle.textTransform,
+ "uppercase",
+ "Option text should be transformed to uppercase"
+ );
+ }
+ );
+});
diff --git a/browser/base/content/test/forms/browser_selectpopup_toplevel.js b/browser/base/content/test/forms/browser_selectpopup_toplevel.js
new file mode 100644
index 0000000000..85a77ea676
--- /dev/null
+++ b/browser/base/content/test/forms/browser_selectpopup_toplevel.js
@@ -0,0 +1,19 @@
+/* Any copyright is dedicated to the Public Domain.
+ * https://creativecommons.org/publicdomain/zero/1.0/ */
+
+add_task(async function () {
+ let select = document.createElement("select");
+ select.appendChild(new Option("abc"));
+ select.appendChild(new Option("defg"));
+ registerCleanupFunction(() => select.remove());
+ document.body.appendChild(select);
+ let popupShownPromise = BrowserTestUtils.waitForSelectPopupShown(window);
+ EventUtils.synthesizeMouseAtCenter(select, {});
+
+ let popup = await popupShownPromise;
+ ok(!!popup, "Should've shown the popup");
+ let items = popup.querySelectorAll("menuitem");
+ is(items.length, 2, "Should have two options");
+ is(items[0].textContent, "abc", "First option should be correct");
+ is(items[1].textContent, "defg", "First option should be correct");
+});
diff --git a/browser/base/content/test/forms/browser_selectpopup_user_input.js b/browser/base/content/test/forms/browser_selectpopup_user_input.js
new file mode 100644
index 0000000000..b3cdeaf7e6
--- /dev/null
+++ b/browser/base/content/test/forms/browser_selectpopup_user_input.js
@@ -0,0 +1,90 @@
+const PAGE = `
+<!doctype html>
+<select>
+ <option>ABC</option>
+ <option>DEFG</option>
+</select>
+`;
+
+function promiseChangeHandlingUserInput(browser) {
+ return SpecialPowers.spawn(browser, [], async function () {
+ content.document.clearUserGestureActivation();
+ let element = content.document.querySelector("select");
+ let reply = {};
+ function getUserInputState() {
+ return {
+ isHandlingUserInput: content.window.windowUtils.isHandlingUserInput,
+ hasValidTransientUserGestureActivation:
+ content.document.hasValidTransientUserGestureActivation,
+ };
+ }
+ reply.before = getUserInputState();
+ await ContentTaskUtils.waitForEvent(element, "change", false, () => {
+ reply.during = getUserInputState();
+ return true;
+ });
+ await new Promise(r => content.window.setTimeout(r));
+ reply.after = getUserInputState();
+ return reply;
+ });
+}
+
+async function testHandlingUserInputOnChange(aTriggerFn) {
+ const url = "data:text/html," + encodeURI(PAGE);
+ return BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url,
+ },
+ async function (browser) {
+ let popup = await openSelectPopup("click");
+ let userInputOnChange = promiseChangeHandlingUserInput(browser);
+ await aTriggerFn(popup);
+ let userInput = await userInputOnChange;
+ ok(
+ !userInput.before.isHandlingUserInput,
+ "Shouldn't be handling user input before test"
+ );
+ ok(
+ !userInput.before.hasValidTransientUserGestureActivation,
+ "transient activation should be cleared before test"
+ );
+ ok(
+ userInput.during.hasValidTransientUserGestureActivation,
+ "should provide transient activation during event"
+ );
+ ok(
+ userInput.during.isHandlingUserInput,
+ "isHandlingUserInput should be true during event"
+ );
+ ok(
+ userInput.after.hasValidTransientUserGestureActivation,
+ "should provide transient activation after event"
+ );
+ ok(
+ !userInput.after.isHandlingUserInput,
+ "isHandlingUserInput should be false after event"
+ );
+ }
+ );
+}
+
+// This test checks if the change/click event is considered as user input event.
+add_task(async function test_handling_user_input_key() {
+ return testHandlingUserInputOnChange(async function (popup) {
+ EventUtils.synthesizeKey("KEY_ArrowDown");
+ await hideSelectPopup();
+ });
+});
+
+add_task(async function test_handling_user_input_click() {
+ return testHandlingUserInputOnChange(async function (popup) {
+ EventUtils.synthesizeMouseAtCenter(popup.lastElementChild, {});
+ });
+});
+
+add_task(async function test_handling_user_input_click() {
+ return testHandlingUserInputOnChange(async function (popup) {
+ EventUtils.synthesizeMouseAtCenter(popup.lastElementChild, {});
+ });
+});
diff --git a/browser/base/content/test/forms/browser_selectpopup_width.js b/browser/base/content/test/forms/browser_selectpopup_width.js
new file mode 100644
index 0000000000..d8f748fb18
--- /dev/null
+++ b/browser/base/content/test/forms/browser_selectpopup_width.js
@@ -0,0 +1,49 @@
+const PAGE = `
+<!doctype html>
+<select style="width: 600px">
+ <option>ABC</option>
+ <option>DEFG</option>
+</select>
+`;
+
+function tick() {
+ return new Promise(r =>
+ requestAnimationFrame(() => requestAnimationFrame(r))
+ );
+}
+
+add_task(async function () {
+ const url = "data:text/html," + encodeURI(PAGE);
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url,
+ },
+ async function (browser) {
+ let popup = await openSelectPopup("click");
+ let arrowSB = popup.shadowRoot.querySelector(".menupopup-arrowscrollbox");
+ is(
+ arrowSB.getBoundingClientRect().width,
+ 600,
+ "Should be the right size"
+ );
+
+ // Trigger a layout change that would cause us to layout the popup again,
+ // and change our menulist to be zero-size so that the anchor rect
+ // codepath is used. We should still use the anchor rect to expand our
+ // size.
+ await tick();
+
+ popup.closest("menulist").style.width = "0";
+ popup.style.minWidth = "2px";
+
+ await tick();
+
+ is(
+ arrowSB.getBoundingClientRect().width,
+ 600,
+ "Should be the right size"
+ );
+ }
+ );
+});
diff --git a/browser/base/content/test/forms/browser_selectpopup_xhtml.js b/browser/base/content/test/forms/browser_selectpopup_xhtml.js
new file mode 100644
index 0000000000..091649be89
--- /dev/null
+++ b/browser/base/content/test/forms/browser_selectpopup_xhtml.js
@@ -0,0 +1,36 @@
+const PAGE = `<?xml version="1.0"?>
+<html id="main-window"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ xmlns="http://www.w3.org/1999/xhtml">
+<head/>
+<body>
+ <html:select>
+ <html:option>abc</html:option>
+ <html:optgroup>
+ <html:option>defg</html:option>
+ </html:optgroup>
+ </html:select>
+</body>
+</html>
+`;
+
+add_task(async function () {
+ const url = "data:application/xhtml+xml," + encodeURI(PAGE);
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url,
+ },
+ async function (browser) {
+ let popup = await openSelectPopup("click");
+ let menuitems = popup.querySelectorAll("menuitem");
+ is(menuitems.length, 2, "Should've properly detected two menu items");
+ is(menuitems[0].textContent, "abc", "Option text should be correct");
+ is(menuitems[1].textContent, "defg", "Second text should be correct");
+ ok(
+ !!popup.querySelector("menucaption"),
+ "Should've created a caption for the optgroup"
+ );
+ }
+ );
+});
diff --git a/browser/base/content/test/forms/head.js b/browser/base/content/test/forms/head.js
new file mode 100644
index 0000000000..1629c6a57c
--- /dev/null
+++ b/browser/base/content/test/forms/head.js
@@ -0,0 +1,51 @@
+async function openSelectPopup(
+ mode = "key",
+ selector = "select",
+ win = window
+) {
+ info("Opening select popup");
+ let popupShownPromise = BrowserTestUtils.waitForSelectPopupShown(win);
+ if (mode == "click" || mode == "mousedown") {
+ let mousePromise;
+ if (mode == "click") {
+ mousePromise = BrowserTestUtils.synthesizeMouseAtCenter(
+ selector,
+ {},
+ win.gBrowser.selectedBrowser
+ );
+ } else {
+ mousePromise = BrowserTestUtils.synthesizeMouse(
+ selector,
+ 5,
+ 5,
+ { type: "mousedown" },
+ win.gBrowser.selectedBrowser
+ );
+ }
+ await mousePromise;
+ } else {
+ EventUtils.synthesizeKey("KEY_ArrowDown", { altKey: true }, win);
+ }
+ return popupShownPromise;
+}
+
+function hideSelectPopup(mode = "enter", win = window) {
+ let browser = win.gBrowser.selectedBrowser;
+ let selectClosedPromise = SpecialPowers.spawn(browser, [], async function () {
+ let { SelectContentHelper } = ChromeUtils.importESModule(
+ "resource://gre/actors/SelectChild.sys.mjs"
+ );
+ return ContentTaskUtils.waitForCondition(() => !SelectContentHelper.open);
+ });
+
+ if (mode == "escape") {
+ EventUtils.synthesizeKey("KEY_Escape", {}, win);
+ } else if (mode == "enter") {
+ EventUtils.synthesizeKey("KEY_Enter", {}, win);
+ } else if (mode == "click") {
+ let popup = win.document.getElementById("ContentSelectDropdown").menupopup;
+ EventUtils.synthesizeMouseAtCenter(popup.lastElementChild, {}, win);
+ }
+
+ return selectClosedPromise;
+}
diff --git a/browser/base/content/test/fullscreen/FullscreenFrame.sys.mjs b/browser/base/content/test/fullscreen/FullscreenFrame.sys.mjs
new file mode 100644
index 0000000000..9821837b3f
--- /dev/null
+++ b/browser/base/content/test/fullscreen/FullscreenFrame.sys.mjs
@@ -0,0 +1,103 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * test helper JSWindowActors used by the browser_fullscreen_api_fission.js test.
+ */
+
+export class FullscreenFrameChild extends JSWindowActorChild {
+ actorCreated() {
+ this.fullscreen_events = [];
+ }
+
+ changed() {
+ return new Promise(resolve => {
+ this.contentWindow.document.addEventListener(
+ "fullscreenchange",
+ () => resolve(),
+ {
+ once: true,
+ }
+ );
+ });
+ }
+
+ requestFullscreen() {
+ let doc = this.contentWindow.document;
+ let button = doc.createElement("button");
+ doc.body.appendChild(button);
+
+ return new Promise(resolve => {
+ button.onclick = () => {
+ doc.body.requestFullscreen().then(resolve);
+ doc.body.removeChild(button);
+ };
+ button.click();
+ });
+ }
+
+ receiveMessage(msg) {
+ switch (msg.name) {
+ case "WaitForChange":
+ return this.changed();
+ case "ExitFullscreen":
+ return this.contentWindow.document.exitFullscreen();
+ case "RequestFullscreen":
+ this.browsingContext.isActive = true;
+ return Promise.all([this.changed(), this.requestFullscreen()]);
+ case "CreateChild":
+ let child = msg.data;
+ let iframe = this.contentWindow.document.createElement("iframe");
+ iframe.allow = child.allow_fullscreen ? "fullscreen" : "";
+ iframe.name = child.name;
+
+ let loaded = new Promise(resolve => {
+ iframe.addEventListener(
+ "load",
+ () => resolve(iframe.browsingContext),
+ { once: true }
+ );
+ });
+ iframe.src = child.url;
+ this.contentWindow.document.body.appendChild(iframe);
+ return loaded;
+ case "GetEvents":
+ return Promise.resolve(this.fullscreen_events);
+ case "ClearEvents":
+ this.fullscreen_events = [];
+ return Promise.resolve();
+ case "GetFullscreenElement":
+ let document = this.contentWindow.document;
+ let child_iframe = this.contentWindow.document.getElementsByTagName(
+ "iframe"
+ )
+ ? this.contentWindow.document.getElementsByTagName("iframe")[0]
+ : null;
+ switch (document.fullscreenElement) {
+ case null:
+ return Promise.resolve("null");
+ case document:
+ return Promise.resolve("document");
+ case document.body:
+ return Promise.resolve("body");
+ case child_iframe:
+ return Promise.resolve("child_iframe");
+ default:
+ return Promise.resolve("other");
+ }
+ }
+
+ return Promise.reject("Unexpected Message");
+ }
+
+ async handleEvent(event) {
+ switch (event.type) {
+ case "fullscreenchange":
+ this.fullscreen_events.push(true);
+ break;
+ case "fullscreenerror":
+ this.fullscreen_events.push(false);
+ break;
+ }
+ }
+}
diff --git a/browser/base/content/test/fullscreen/browser.ini b/browser/base/content/test/fullscreen/browser.ini
new file mode 100644
index 0000000000..8eecb3f99c
--- /dev/null
+++ b/browser/base/content/test/fullscreen/browser.ini
@@ -0,0 +1,31 @@
+[DEFAULT]
+support-files =
+ head.js
+ open_and_focus_helper.html
+
+[browser_bug1557041.js]
+[browser_bug1620341.js]
+support-files = fullscreen.html fullscreen_frame.html
+[browser_fullscreen_api_fission.js]
+https_first_disabled = true
+support-files = fullscreen.html FullscreenFrame.sys.mjs
+[browser_fullscreen_context_menu.js]
+[browser_fullscreen_cross_origin.js]
+support-files = fullscreen.html fullscreen_frame.html
+[browser_fullscreen_enterInUrlbar.js]
+skip-if = (os == 'mac') || (os == 'linux') # Bug 1648649
+[browser_fullscreen_from_minimize.js]
+skip-if = (os == 'linux') || (os == 'win') # Bug 1818795 and Bug 1818796
+[browser_fullscreen_keydown_reservation.js]
+[browser_fullscreen_menus.js]
+[browser_fullscreen_newtab.js]
+[browser_fullscreen_newwindow.js]
+[browser_fullscreen_permissions_prompt.js]
+[browser_fullscreen_warning.js]
+support-files = fullscreen.html
+skip-if =
+ (os == 'mac') # Bug 1848423
+[browser_fullscreen_window_focus.js]
+skip-if = (os == 'mac') && debug # Bug 1568570
+[browser_fullscreen_window_open.js]
+skip-if = (os == 'linux') && swgl # Bug 1795491
diff --git a/browser/base/content/test/fullscreen/browser_bug1557041.js b/browser/base/content/test/fullscreen/browser_bug1557041.js
new file mode 100644
index 0000000000..3f00de86a0
--- /dev/null
+++ b/browser/base/content/test/fullscreen/browser_bug1557041.js
@@ -0,0 +1,47 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// This test tends to trigger a race in the fullscreen time telemetry,
+// where the fullscreen enter and fullscreen exit events (which use the
+// same histogram ID) overlap. That causes TelemetryStopwatch to log an
+// error.
+SimpleTest.ignoreAllUncaughtExceptions(true);
+
+add_task(async function test_identityPopupCausesFSExit() {
+ let url = "https://example.com/";
+
+ await BrowserTestUtils.withNewTab("about:blank", async browser => {
+ let loaded = BrowserTestUtils.browserLoaded(browser, false, url);
+ BrowserTestUtils.loadURIString(browser, url);
+ await loaded;
+
+ let identityPermissionBox = document.getElementById(
+ "identity-permission-box"
+ );
+
+ info("Entering DOM fullscreen");
+ await changeFullscreen(browser, true);
+
+ let popupShown = BrowserTestUtils.waitForEvent(
+ window,
+ "popupshown",
+ true,
+ event => event.target == document.getElementById("permission-popup")
+ );
+ let fsExit = waitForFullScreenState(browser, false);
+
+ identityPermissionBox.click();
+
+ info("Waiting for fullscreen exit and permission popup to show");
+ await Promise.all([fsExit, popupShown]);
+
+ let identityPopup = document.getElementById("permission-popup");
+ ok(
+ identityPopup.hasAttribute("panelopen"),
+ "Identity popup should be open"
+ );
+ ok(!window.fullScreen, "Should not be in full-screen");
+ });
+});
diff --git a/browser/base/content/test/fullscreen/browser_bug1620341.js b/browser/base/content/test/fullscreen/browser_bug1620341.js
new file mode 100644
index 0000000000..bd836eb5a3
--- /dev/null
+++ b/browser/base/content/test/fullscreen/browser_bug1620341.js
@@ -0,0 +1,108 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const tab1URL = `data:text/html,
+ <html xmlns="http://www.w3.org/1999/xhtml">
+ <head>
+ <meta charset="utf-8"/>
+ <title>First tab to be loaded</title>
+ </head>
+ <body>
+ <button>JUST A BUTTON</button>
+ </body>
+ </html>`;
+
+const ORIGIN =
+ "https://example.com/browser/browser/base/content/test/fullscreen/fullscreen_frame.html";
+
+add_task(async function test_fullscreen_cross_origin() {
+ async function requestFullscreenThenCloseTab() {
+ await BrowserTestUtils.withNewTab(ORIGIN, async function (browser) {
+ info("Start fullscreen on iframe frameAllowed");
+
+ // Make sure there is no attribute "inDOMFullscreen" before requesting fullscreen.
+ await TestUtils.waitForCondition(
+ () => !document.documentElement.hasAttribute("inDOMFullscreen")
+ );
+
+ let tabbrowser = browser.ownerDocument.querySelector("#tabbrowser-tabs");
+ ok(
+ !tabbrowser.hasAttribute("closebuttons"),
+ "Close buttons should be visible on every tab"
+ );
+
+ // Request fullscreen from iframe
+ await SpecialPowers.spawn(browser, [], async function () {
+ let frame = content.document.getElementById("frameAllowed");
+ frame.focus();
+ await SpecialPowers.spawn(frame, [], async () => {
+ let frameDoc = content.document;
+ const waitForFullscreen = new Promise(resolve => {
+ const message = "fullscreenchange";
+ function handler(evt) {
+ frameDoc.removeEventListener(message, handler);
+ Assert.equal(evt.type, message, `Request should be allowed`);
+ resolve();
+ }
+ frameDoc.addEventListener(message, handler);
+ });
+
+ frameDoc.getElementById("request").click();
+ await waitForFullscreen;
+ });
+ });
+
+ // Make sure there is attribute "inDOMFullscreen" after requesting fullscreen.
+ await TestUtils.waitForCondition(() =>
+ document.documentElement.hasAttribute("inDOMFullscreen")
+ );
+
+ await TestUtils.waitForCondition(
+ () => tabbrowser.hasAttribute("closebuttons"),
+ "Close buttons should be visible only on the active tab (tabs have width=0 so closebuttons gets set on them)"
+ );
+ });
+ }
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["full-screen-api.enabled", true],
+ ["full-screen-api.allow-trusted-requests-only", false],
+ ["full-screen-api.transition-duration.enter", "0 0"],
+ ["full-screen-api.transition-duration.leave", "0 0"],
+ ["dom.security.featurePolicy.header.enabled", true],
+ ["dom.security.featurePolicy.webidl.enabled", true],
+ ],
+ });
+
+ // Open a tab with tab1URL.
+ let tab1 = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ tab1URL,
+ true
+ );
+
+ // 1. Open another tab and load a page with two iframes.
+ // 2. Request fullscreen from an iframe which is in a different origin.
+ // 3. Close the tab after receiving "fullscreenchange" message.
+ // Note that we don't do "doc.exitFullscreen()" before closing the tab
+ // on purpose.
+ await requestFullscreenThenCloseTab();
+
+ // Wait until attribute "inDOMFullscreen" is removed.
+ await TestUtils.waitForCondition(
+ () => !document.documentElement.hasAttribute("inDOMFullscreen")
+ );
+
+ await TestUtils.waitForCondition(
+ () => !gBrowser.tabContainer.hasAttribute("closebuttons"),
+ "Close buttons should come back to every tab"
+ );
+
+ // Remove the remaining tab and leave the test.
+ let tabClosed = BrowserTestUtils.waitForTabClosing(tab1);
+ BrowserTestUtils.removeTab(tab1);
+ await tabClosed;
+});
diff --git a/browser/base/content/test/fullscreen/browser_fullscreen_api_fission.js b/browser/base/content/test/fullscreen/browser_fullscreen_api_fission.js
new file mode 100644
index 0000000000..03b65ddc0e
--- /dev/null
+++ b/browser/base/content/test/fullscreen/browser_fullscreen_api_fission.js
@@ -0,0 +1,252 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/* This test checks that `document.fullscreenElement` is set correctly and
+ * proper fullscreenchange events fire when an element inside of a
+ * multi-origin tree of iframes calls `requestFullscreen()`. It is designed
+ * to make sure the fullscreen API is working properly in fission when the
+ * frame tree spans multiple processes.
+ *
+ * A similarly purposed Web Platform Test exists, but at the time of writing
+ * is manual, so it cannot be run in CI:
+ * `element-request-fullscreen-cross-origin-manual.sub.html`
+ */
+
+"use strict";
+
+const actorModuleURI = getRootDirectory(gTestPath) + "FullscreenFrame.sys.mjs";
+const actorName = "FullscreenFrame";
+
+const fullscreenPath =
+ getRootDirectory(gTestPath).replace("chrome://mochitests/content", "") +
+ "fullscreen.html";
+
+const fullscreenTarget = "D";
+// TOP
+// | \
+// A B
+// |
+// C
+// |
+// D
+// |
+// E
+const frameTree = {
+ name: "TOP",
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ url: `http://example.com${fullscreenPath}`,
+ allow_fullscreen: true,
+ children: [
+ {
+ name: "A",
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ url: `http://example.org${fullscreenPath}`,
+ allow_fullscreen: true,
+ children: [
+ {
+ name: "C",
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ url: `http://example.com${fullscreenPath}`,
+ allow_fullscreen: true,
+ children: [
+ {
+ name: "D",
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ url: `http://example.com${fullscreenPath}?different-uri=1`,
+ allow_fullscreen: true,
+ children: [
+ {
+ name: "E",
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ url: `http://example.org${fullscreenPath}`,
+ allow_fullscreen: true,
+ children: [],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ {
+ name: "B",
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ url: `http://example.net${fullscreenPath}`,
+ allow_fullscreen: true,
+ children: [],
+ },
+ ],
+};
+
+add_task(async function test_fullscreen_api_cross_origin_tree() {
+ await new Promise(r => {
+ SpecialPowers.pushPrefEnv(
+ {
+ set: [
+ ["full-screen-api.enabled", true],
+ ["full-screen-api.allow-trusted-requests-only", false],
+ ["full-screen-api.transition-duration.enter", "0 0"],
+ ["full-screen-api.transition-duration.leave", "0 0"],
+ ["dom.security.featurePolicy.header.enabled", true],
+ ["dom.security.featurePolicy.webidl.enabled", true],
+ ],
+ },
+ r
+ );
+ });
+
+ // Register a custom window actor to handle tracking events
+ // and constructing subframes
+ ChromeUtils.registerWindowActor(actorName, {
+ child: {
+ esModuleURI: actorModuleURI,
+ events: {
+ fullscreenchange: { mozSystemGroup: true, capture: true },
+ fullscreenerror: { mozSystemGroup: true, capture: true },
+ },
+ },
+ allFrames: true,
+ });
+
+ let tab = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ url: frameTree.url,
+ });
+
+ let frames = new Map();
+ async function construct_frame_children(browsingContext, tree) {
+ let actor = browsingContext.currentWindowGlobal.getActor(actorName);
+ frames.set(tree.name, {
+ browsingContext,
+ actor,
+ });
+
+ for (let child of tree.children) {
+ // Create the child IFrame and wait for it to load.
+ let childBC = await actor.sendQuery("CreateChild", child);
+ await construct_frame_children(childBC, child);
+ }
+ }
+
+ await construct_frame_children(tab.linkedBrowser.browsingContext, frameTree);
+
+ async function check_events(expected_events) {
+ for (let [name, expected] of expected_events) {
+ let actor = frames.get(name).actor;
+
+ // Each content process fires the fullscreenchange
+ // event independently and in parallel making it
+ // possible for the promises returned by
+ // `requestFullscreen` or `exitFullscreen` to
+ // resolve before all events have fired. We wait
+ // for the number of events to match before
+ // continuing to ensure we don't miss an expected
+ // event that hasn't fired yet.
+ let events;
+ await TestUtils.waitForCondition(async () => {
+ events = await actor.sendQuery("GetEvents");
+ return events.length == expected.length;
+ }, `Waiting for number of events to match`);
+
+ Assert.equal(events.length, expected.length, "Number of events equal");
+ events.forEach((value, i) => {
+ Assert.equal(value, expected[i], "Event type matches");
+ });
+ }
+ }
+
+ async function check_fullscreenElement(expected_elements) {
+ for (let [name, expected] of expected_elements) {
+ let element = await frames
+ .get(name)
+ .actor.sendQuery("GetFullscreenElement");
+ Assert.equal(element, expected, "The fullScreenElement matches");
+ }
+ }
+
+ // Trigger fullscreen from the target frame.
+ let target = frames.get(fullscreenTarget);
+ await target.actor.sendQuery("RequestFullscreen");
+ // true is fullscreenchange and false is fullscreenerror.
+ await check_events(
+ new Map([
+ ["TOP", [true]],
+ ["A", [true]],
+ ["B", []],
+ ["C", [true]],
+ ["D", [true]],
+ ["E", []],
+ ])
+ );
+ await check_fullscreenElement(
+ new Map([
+ ["TOP", "child_iframe"],
+ ["A", "child_iframe"],
+ ["B", "null"],
+ ["C", "child_iframe"],
+ ["D", "body"],
+ ["E", "null"],
+ ])
+ );
+
+ await target.actor.sendQuery("ExitFullscreen");
+ // fullscreenchange should have fired on exit as well.
+ // true is fullscreenchange and false is fullscreenerror.
+ await check_events(
+ new Map([
+ ["TOP", [true, true]],
+ ["A", [true, true]],
+ ["B", []],
+ ["C", [true, true]],
+ ["D", [true, true]],
+ ["E", []],
+ ])
+ );
+ await check_fullscreenElement(
+ new Map([
+ ["TOP", "null"],
+ ["A", "null"],
+ ["B", "null"],
+ ["C", "null"],
+ ["D", "null"],
+ ["E", "null"],
+ ])
+ );
+
+ // Clear previous events before testing exiting fullscreen with ESC.
+ for (const frame of frames.values()) {
+ frame.actor.sendQuery("ClearEvents");
+ }
+ await target.actor.sendQuery("RequestFullscreen");
+
+ // Escape should cause the proper events to fire and
+ // document.fullscreenElement should be cleared.
+ let finished_exiting = target.actor.sendQuery("WaitForChange");
+ EventUtils.sendKey("ESCAPE");
+ await finished_exiting;
+ // true is fullscreenchange and false is fullscreenerror.
+ await check_events(
+ new Map([
+ ["TOP", [true, true]],
+ ["A", [true, true]],
+ ["B", []],
+ ["C", [true, true]],
+ ["D", [true, true]],
+ ["E", []],
+ ])
+ );
+ await check_fullscreenElement(
+ new Map([
+ ["TOP", "null"],
+ ["A", "null"],
+ ["B", "null"],
+ ["C", "null"],
+ ["D", "null"],
+ ["E", "null"],
+ ])
+ );
+
+ // Remove the tests custom window actor.
+ ChromeUtils.unregisterWindowActor("FullscreenFrame");
+ BrowserTestUtils.removeTab(tab);
+});
diff --git a/browser/base/content/test/fullscreen/browser_fullscreen_context_menu.js b/browser/base/content/test/fullscreen/browser_fullscreen_context_menu.js
new file mode 100644
index 0000000000..ec874f1a3f
--- /dev/null
+++ b/browser/base/content/test/fullscreen/browser_fullscreen_context_menu.js
@@ -0,0 +1,142 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+async function openContextMenu(itemElement, win = window) {
+ let popupShownPromise = BrowserTestUtils.waitForEvent(
+ itemElement.ownerDocument,
+ "popupshown"
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ itemElement,
+ {
+ type: "contextmenu",
+ button: 2,
+ },
+ win
+ );
+ let { target } = await popupShownPromise;
+ return target;
+}
+
+async function testContextMenu() {
+ await BrowserTestUtils.withNewTab("about:blank", async () => {
+ let panelUIMenuButton = document.getElementById("PanelUI-menu-button");
+ let contextMenu = await openContextMenu(panelUIMenuButton);
+ let array1 = AppConstants.MENUBAR_CAN_AUTOHIDE
+ ? [
+ ".customize-context-moveToPanel",
+ ".customize-context-removeFromToolbar",
+ "#toolbarItemsMenuSeparator",
+ "#toggle_toolbar-menubar",
+ "#toggle_PersonalToolbar",
+ "#viewToolbarsMenuSeparator",
+ ".viewCustomizeToolbar",
+ ]
+ : [
+ ".customize-context-moveToPanel",
+ ".customize-context-removeFromToolbar",
+ "#toolbarItemsMenuSeparator",
+ "#toggle_PersonalToolbar",
+ "#viewToolbarsMenuSeparator",
+ ".viewCustomizeToolbar",
+ ];
+ let result1 = verifyContextMenu(contextMenu, array1);
+ ok(!result1, "Expected no errors verifying context menu items");
+ contextMenu.hidePopup();
+ let onFullscreen = Promise.all([
+ BrowserTestUtils.waitForEvent(window, "fullscreen"),
+ BrowserTestUtils.waitForEvent(
+ window,
+ "sizemodechange",
+ false,
+ e => window.fullScreen
+ ),
+ BrowserTestUtils.waitForPopupEvent(contextMenu, "hidden"),
+ ]);
+ document.getElementById("View:FullScreen").doCommand();
+ contextMenu.hidePopup();
+ info("waiting for fullscreen");
+ await onFullscreen;
+ // make sure the toolbox is visible if it's autohidden
+ document.getElementById("Browser:OpenLocation").doCommand();
+ info("trigger the context menu");
+ let contextMenu2 = await openContextMenu(panelUIMenuButton);
+ info("context menu should be open, verify its menu items");
+ let array2 = AppConstants.MENUBAR_CAN_AUTOHIDE
+ ? [
+ ".customize-context-moveToPanel",
+ ".customize-context-removeFromToolbar",
+ "#toolbarItemsMenuSeparator",
+ "#toggle_toolbar-menubar",
+ "#toggle_PersonalToolbar",
+ "#viewToolbarsMenuSeparator",
+ ".viewCustomizeToolbar",
+ `menuseparator[contexttype="fullscreen"]`,
+ `.fullscreen-context-autohide`,
+ `menuitem[contexttype="fullscreen"]`,
+ ]
+ : [
+ ".customize-context-moveToPanel",
+ ".customize-context-removeFromToolbar",
+ "#toolbarItemsMenuSeparator",
+ "#toggle_PersonalToolbar",
+ "#viewToolbarsMenuSeparator",
+ ".viewCustomizeToolbar",
+ `menuseparator[contexttype="fullscreen"]`,
+ `.fullscreen-context-autohide`,
+ `menuitem[contexttype="fullscreen"]`,
+ ];
+ let result2 = verifyContextMenu(contextMenu2, array2);
+ ok(!result2, "Expected no errors verifying context menu items");
+ let onExitFullscreen = Promise.all([
+ BrowserTestUtils.waitForEvent(window, "fullscreen"),
+ BrowserTestUtils.waitForEvent(
+ window,
+ "sizemodechange",
+ false,
+ e => !window.fullScreen
+ ),
+ BrowserTestUtils.waitForPopupEvent(contextMenu2, "hidden"),
+ ]);
+ document.getElementById("View:FullScreen").doCommand();
+ contextMenu2.hidePopup();
+ await onExitFullscreen;
+ });
+}
+
+function verifyContextMenu(contextMenu, itemSelectors) {
+ // Ignore hidden nodes
+ let items = Array.from(contextMenu.children).filter(n =>
+ BrowserTestUtils.is_visible(n)
+ );
+ let menuAsText = items
+ .map(n => {
+ return n.nodeName == "menuseparator"
+ ? "---"
+ : `${n.label} (${n.command})`;
+ })
+ .join("\n");
+ info("Got actual context menu items: \n" + menuAsText);
+
+ try {
+ is(
+ items.length,
+ itemSelectors.length,
+ "Context menu has the expected number of items"
+ );
+ for (let i = 0; i < items.length; i++) {
+ let selector = itemSelectors[i];
+ ok(
+ items[i].matches(selector),
+ `Item at ${i} matches expected selector: ${selector}`
+ );
+ }
+ } catch (ex) {
+ return ex;
+ }
+ return null;
+}
+
+add_task(testContextMenu);
diff --git a/browser/base/content/test/fullscreen/browser_fullscreen_cross_origin.js b/browser/base/content/test/fullscreen/browser_fullscreen_cross_origin.js
new file mode 100644
index 0000000000..0babb8b35e
--- /dev/null
+++ b/browser/base/content/test/fullscreen/browser_fullscreen_cross_origin.js
@@ -0,0 +1,64 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const ORIGIN =
+ "https://example.com/browser/browser/base/content/test/fullscreen/fullscreen_frame.html";
+
+add_task(async function test_fullscreen_cross_origin() {
+ async function requestFullscreen(aAllow, aExpect) {
+ await BrowserTestUtils.withNewTab(ORIGIN, async function (browser) {
+ const iframeId = aExpect == "allowed" ? "frameAllowed" : "frameDenied";
+
+ info("Start fullscreen on iframe " + iframeId);
+ await SpecialPowers.spawn(
+ browser,
+ [{ aExpect, iframeId }],
+ async function (args) {
+ let frame = content.document.getElementById(args.iframeId);
+ frame.focus();
+ await SpecialPowers.spawn(frame, [args.aExpect], async expect => {
+ let frameDoc = content.document;
+ const waitForFullscreen = new Promise(resolve => {
+ const message =
+ expect == "allowed" ? "fullscreenchange" : "fullscreenerror";
+ function handler(evt) {
+ frameDoc.removeEventListener(message, handler);
+ Assert.equal(evt.type, message, `Request should be ${expect}`);
+ frameDoc.exitFullscreen();
+ resolve();
+ }
+ frameDoc.addEventListener(message, handler);
+ });
+ frameDoc.getElementById("request").click();
+ await waitForFullscreen;
+ });
+ }
+ );
+
+ if (aExpect == "allowed") {
+ waitForFullScreenState(browser, false);
+ }
+ });
+ }
+
+ await new Promise(r => {
+ SpecialPowers.pushPrefEnv(
+ {
+ set: [
+ ["full-screen-api.enabled", true],
+ ["full-screen-api.allow-trusted-requests-only", false],
+ ["full-screen-api.transition-duration.enter", "0 0"],
+ ["full-screen-api.transition-duration.leave", "0 0"],
+ ["dom.security.featurePolicy.header.enabled", true],
+ ["dom.security.featurePolicy.webidl.enabled", true],
+ ],
+ },
+ r
+ );
+ });
+
+ await requestFullscreen(undefined, "denied");
+ await requestFullscreen("fullscreen", "allowed");
+});
diff --git a/browser/base/content/test/fullscreen/browser_fullscreen_enterInUrlbar.js b/browser/base/content/test/fullscreen/browser_fullscreen_enterInUrlbar.js
new file mode 100644
index 0000000000..6ece64a6f3
--- /dev/null
+++ b/browser/base/content/test/fullscreen/browser_fullscreen_enterInUrlbar.js
@@ -0,0 +1,56 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// This test makes sure that when the user presses enter in the urlbar in full
+// screen, the toolbars are hidden. This should not be run on macOS because we
+// don't hide the toolbars there.
+
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ UrlbarTestUtils: "resource://testing-common/UrlbarTestUtils.sys.mjs",
+});
+
+add_task(async function test() {
+ await BrowserTestUtils.withNewTab("about:blank", async () => {
+ // Do the View:FullScreen command and wait for the transition.
+ let onFullscreen = BrowserTestUtils.waitForEvent(window, "fullscreen");
+ document.getElementById("View:FullScreen").doCommand();
+ await onFullscreen;
+
+ // Do the Browser:OpenLocation command to show the nav toolbox and focus
+ // the urlbar.
+ let onToolboxShown = TestUtils.topicObserved(
+ "fullscreen-nav-toolbox",
+ (subject, data) => data == "shown"
+ );
+ document.getElementById("Browser:OpenLocation").doCommand();
+ info("Waiting for the nav toolbox to be shown");
+ await onToolboxShown;
+
+ // Enter a URL.
+ await UrlbarTestUtils.promiseAutocompleteResultPopup({
+ window,
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ value: "http://example.com/",
+ waitForFocus: SimpleTest.waitForFocus,
+ fireInputEvent: true,
+ });
+
+ // Press enter and wait for the nav toolbox to be hidden.
+ let onToolboxHidden = TestUtils.topicObserved(
+ "fullscreen-nav-toolbox",
+ (subject, data) => data == "hidden"
+ );
+ EventUtils.synthesizeKey("KEY_Enter");
+ info("Waiting for the nav toolbox to be hidden");
+ await onToolboxHidden;
+
+ Assert.ok(true, "Nav toolbox hidden");
+
+ info("Waiting for exiting from the fullscreen mode...");
+ onFullscreen = BrowserTestUtils.waitForEvent(window, "fullscreen");
+ document.getElementById("View:FullScreen").doCommand();
+ await onFullscreen;
+ });
+});
diff --git a/browser/base/content/test/fullscreen/browser_fullscreen_from_minimize.js b/browser/base/content/test/fullscreen/browser_fullscreen_from_minimize.js
new file mode 100644
index 0000000000..c4ef8fe642
--- /dev/null
+++ b/browser/base/content/test/fullscreen/browser_fullscreen_from_minimize.js
@@ -0,0 +1,82 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// This test checks whether fullscreen windows can transition to minimized windows,
+// and back again. This is sometimes not directly supported by the OS widgets. For
+// example, in macOS, the minimize button is greyed-out in the title bar of
+// fullscreen windows, making this transition impossible for users to initiate.
+// Still, web APIs do allow arbitrary combinations of window calls, and this test
+// exercises some of those combinations.
+
+const restoreWindowToNormal = async () => {
+ // Get the window to normal state by calling window.restore(). This may take
+ // multiple attempts since a call to restore could bring the window to either
+ // NORMAL or MAXIMIZED state.
+ while (window.windowState != window.STATE_NORMAL) {
+ info(
+ `Calling window.restore(), to try to reach "normal" state ${window.STATE_NORMAL}.`
+ );
+ let promiseSizeModeChange = BrowserTestUtils.waitForEvent(
+ window,
+ "sizemodechange"
+ );
+ window.restore();
+ await promiseSizeModeChange;
+ info(`Window reached state ${window.windowState}.`);
+ }
+};
+
+add_task(async function () {
+ registerCleanupFunction(function () {
+ window.restore();
+ });
+
+ // We reuse these variables to create new promises for each transition.
+ let promiseSizeModeChange;
+ let promiseFullscreen;
+
+ await restoreWindowToNormal();
+ ok(!window.fullScreen, "Window should not be fullscreen at start of test.");
+
+ // Get to fullscreen.
+ info("Requesting fullscreen.");
+ promiseFullscreen = document.documentElement.requestFullscreen();
+ await promiseFullscreen;
+ ok(window.fullScreen, "Window should be fullscreen before being minimized.");
+
+ // Transition between fullscreen and minimize states.
+ info("Requesting minimize on a fullscreen window.");
+ promiseSizeModeChange = BrowserTestUtils.waitForEvent(
+ window,
+ "sizemodechange"
+ );
+ window.minimize();
+ await promiseSizeModeChange;
+ is(
+ window.windowState,
+ window.STATE_MINIMIZED,
+ "Window should be minimized after fullscreen."
+ );
+
+ // Whether or not the previous transition worked, restore the window
+ // and then minimize it.
+ await restoreWindowToNormal();
+
+ info("Requesting minimize on a normal window.");
+ promiseSizeModeChange = BrowserTestUtils.waitForEvent(
+ window,
+ "sizemodechange"
+ );
+ window.minimize();
+ await promiseSizeModeChange;
+ is(
+ window.windowState,
+ window.STATE_MINIMIZED,
+ "Window should be minimized before fullscreen."
+ );
+
+ info("Requesting fullscreen on a minimized window.");
+ promiseFullscreen = document.documentElement.requestFullscreen();
+ await promiseFullscreen;
+ ok(window.fullScreen, "Window should be fullscreen after being minimized.");
+});
diff --git a/browser/base/content/test/fullscreen/browser_fullscreen_keydown_reservation.js b/browser/base/content/test/fullscreen/browser_fullscreen_keydown_reservation.js
new file mode 100644
index 0000000000..2d34ac6c7b
--- /dev/null
+++ b/browser/base/content/test/fullscreen/browser_fullscreen_keydown_reservation.js
@@ -0,0 +1,112 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// This test verifies that whether shortcut keys of toggling fullscreen modes
+// are reserved.
+add_task(async function test_keydown_event_reservation_toggling_fullscreen() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["full-screen-api.transition-duration.enter", "0 0"],
+ ["full-screen-api.transition-duration.leave", "0 0"],
+ ],
+ });
+
+ let shortcutKeys = [{ key: "KEY_F11", modifiers: {} }];
+ if (navigator.platform.startsWith("Mac")) {
+ shortcutKeys.push({
+ key: "f",
+ modifiers: { metaKey: true, ctrlKey: true },
+ });
+ shortcutKeys.push({
+ key: "F",
+ modifiers: { metaKey: true, shiftKey: true },
+ });
+ }
+ function shortcutDescription(aShortcutKey) {
+ return `${
+ aShortcutKey.metaKey ? "Meta + " : ""
+ }${aShortcutKey.shiftKey ? "Shift + " : ""}${aShortcutKey.ctrlKey ? "Ctrl + " : ""}${aShortcutKey.key.replace("KEY_", "")}`;
+ }
+ for (const shortcutKey of shortcutKeys) {
+ const tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "https://example.org/browser/browser/base/content/test/fullscreen/fullscreen.html"
+ );
+
+ await SimpleTest.promiseFocus(tab.linkedBrowser);
+
+ const fullScreenEntered = BrowserTestUtils.waitForEvent(
+ window,
+ "fullscreen"
+ );
+
+ await SpecialPowers.spawn(tab.linkedBrowser, [], async () => {
+ content.wrappedJSObject.keydown = null;
+ content.window.addEventListener("keydown", event => {
+ switch (event.key) {
+ case "Shift":
+ case "Meta":
+ case "Control":
+ break;
+ default:
+ content.wrappedJSObject.keydown = event;
+ }
+ });
+ });
+
+ EventUtils.synthesizeKey(shortcutKey.key, shortcutKey.modifiers);
+
+ info(
+ `Waiting for entering the fullscreen mode with synthesizing ${shortcutDescription(
+ shortcutKey
+ )}...`
+ );
+ await fullScreenEntered;
+
+ info("Retrieving the result...");
+ Assert.ok(
+ await SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [],
+ async () => !!content.wrappedJSObject.keydown
+ ),
+ `Entering the fullscreen mode with ${shortcutDescription(
+ shortcutKey
+ )} should cause "keydown" event`
+ );
+
+ const fullScreenExited = BrowserTestUtils.waitForEvent(
+ window,
+ "fullscreen"
+ );
+
+ await SpecialPowers.spawn(tab.linkedBrowser, [], async () => {
+ content.wrappedJSObject.keydown = null;
+ });
+
+ EventUtils.synthesizeKey(shortcutKey.key, shortcutKey.modifiers);
+
+ info(
+ `Waiting for exiting from the fullscreen mode with synthesizing ${shortcutDescription(
+ shortcutKey
+ )}...`
+ );
+ await fullScreenExited;
+
+ info("Retrieving the result...");
+ Assert.ok(
+ await SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [],
+ async () => !content.wrappedJSObject.keydown
+ ),
+ `Exiting from the fullscreen mode with ${shortcutDescription(
+ shortcutKey
+ )} should not cause "keydown" event`
+ );
+
+ BrowserTestUtils.removeTab(tab);
+ }
+});
diff --git a/browser/base/content/test/fullscreen/browser_fullscreen_menus.js b/browser/base/content/test/fullscreen/browser_fullscreen_menus.js
new file mode 100644
index 0000000000..90dd06192d
--- /dev/null
+++ b/browser/base/content/test/fullscreen/browser_fullscreen_menus.js
@@ -0,0 +1,72 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_shortcut_key_label_in_fullscreen_menu_item() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["full-screen-api.transition-duration.enter", "0 0"],
+ ["full-screen-api.transition-duration.leave", "0 0"],
+ ],
+ });
+
+ const isMac = AppConstants.platform == "macosx";
+ const shortCutKeyLabel = isMac ? "\u2303\u2318F" : "F11";
+ const enterMenuItemId = isMac ? "enterFullScreenItem" : "fullScreenItem";
+ const exitMenuItemId = isMac ? "exitFullScreenItem" : "fullScreenItem";
+ const accelKeyLabelSelector = ".menu-accel-container > label";
+
+ const tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "https://example.org/browser/browser/base/content/test/fullscreen/fullscreen.html"
+ );
+
+ await SimpleTest.promiseFocus(tab.linkedBrowser);
+
+ document.getElementById(enterMenuItemId).render();
+ Assert.equal(
+ document
+ .getElementById(enterMenuItemId)
+ .querySelector(accelKeyLabelSelector)
+ ?.getAttribute("value"),
+ shortCutKeyLabel,
+ `The menu item to enter into the fullscreen mode should show a shortcut key`
+ );
+
+ const fullScreenEntered = BrowserTestUtils.waitForEvent(window, "fullscreen");
+
+ EventUtils.synthesizeKey("KEY_F11", {});
+
+ info(`Waiting for entering the fullscreen mode...`);
+ await fullScreenEntered;
+
+ document.getElementById(exitMenuItemId).render();
+ Assert.equal(
+ document
+ .getElementById(exitMenuItemId)
+ .querySelector(accelKeyLabelSelector)
+ ?.getAttribute("value"),
+ shortCutKeyLabel,
+ `The menu item to exiting from the fullscreen mode should show a shortcut key`
+ );
+
+ const fullScreenExited = BrowserTestUtils.waitForEvent(window, "fullscreen");
+
+ EventUtils.synthesizeKey("KEY_F11", {});
+
+ info(`Waiting for exiting from the fullscreen mode...`);
+ await fullScreenExited;
+
+ document.getElementById(enterMenuItemId).render();
+ Assert.equal(
+ document
+ .getElementById(enterMenuItemId)
+ .querySelector(accelKeyLabelSelector)
+ ?.getAttribute("value"),
+ shortCutKeyLabel,
+ `After exiting from the fullscreen mode, the menu item to enter the fullscreen mode should show a shortcut key`
+ );
+
+ BrowserTestUtils.removeTab(tab);
+});
diff --git a/browser/base/content/test/fullscreen/browser_fullscreen_newtab.js b/browser/base/content/test/fullscreen/browser_fullscreen_newtab.js
new file mode 100644
index 0000000000..d5a74a0aa3
--- /dev/null
+++ b/browser/base/content/test/fullscreen/browser_fullscreen_newtab.js
@@ -0,0 +1,55 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// This test verifies that when in fullscreen mode, and a new tab is opened,
+// fullscreen mode is exited and the url bar is focused.
+add_task(async function test_fullscreen_display_none() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["full-screen-api.enabled", true],
+ ["full-screen-api.allow-trusted-requests-only", false],
+ ],
+ });
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "https://example.org/browser/browser/base/content/test/fullscreen/fullscreen.html"
+ );
+
+ let fullScreenEntered = BrowserTestUtils.waitForEvent(
+ document,
+ "fullscreenchange",
+ false,
+ () => document.fullscreenElement
+ );
+
+ await SpecialPowers.spawn(tab.linkedBrowser, [], async () => {
+ content.document.getElementById("request").click();
+ });
+
+ await fullScreenEntered;
+
+ let fullScreenExited = BrowserTestUtils.waitForEvent(
+ document,
+ "fullscreenchange",
+ false,
+ () => !document.fullscreenElement
+ );
+
+ let focusPromise = BrowserTestUtils.waitForEvent(window, "focus");
+ EventUtils.synthesizeKey("T", { accelKey: true });
+ await focusPromise;
+
+ is(
+ document.activeElement,
+ gURLBar.inputField,
+ "url bar is focused after new tab opened"
+ );
+
+ await fullScreenExited;
+
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ BrowserTestUtils.removeTab(tab);
+});
diff --git a/browser/base/content/test/fullscreen/browser_fullscreen_newwindow.js b/browser/base/content/test/fullscreen/browser_fullscreen_newwindow.js
new file mode 100644
index 0000000000..dee02e2db0
--- /dev/null
+++ b/browser/base/content/test/fullscreen/browser_fullscreen_newwindow.js
@@ -0,0 +1,83 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// This test verifies that when in fullscreen mode, and a new window is opened,
+// fullscreen mode should not exit and the url bar is focused.
+add_task(async function test_fullscreen_new_window() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["full-screen-api.enabled", true],
+ ["full-screen-api.allow-trusted-requests-only", false],
+ ],
+ });
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "https://example.org/browser/browser/base/content/test/fullscreen/fullscreen.html"
+ );
+
+ let fullScreenEntered = BrowserTestUtils.waitForEvent(
+ document,
+ "fullscreenchange",
+ false,
+ () => document.fullscreenElement
+ );
+
+ // Enter fullscreen.
+ await SpecialPowers.spawn(tab.linkedBrowser, [], async () => {
+ content.document.getElementById("request").click();
+ });
+
+ await fullScreenEntered;
+
+ // Open a new window via ctrl+n.
+ let newWindowPromise = BrowserTestUtils.waitForNewWindow({
+ url: "about:blank",
+ });
+ EventUtils.synthesizeKey("N", { accelKey: true });
+ let newWindow = await newWindowPromise;
+
+ // Check new window state.
+ is(
+ newWindow.document.activeElement,
+ newWindow.gURLBar.inputField,
+ "url bar is focused after new window opened"
+ );
+ ok(
+ !newWindow.fullScreen,
+ "The new chrome window should not be in fullscreen"
+ );
+ ok(
+ !newWindow.document.documentElement.hasAttribute("inDOMFullscreen"),
+ "The new chrome document should not be in fullscreen"
+ );
+
+ // Wait a bit then check the original window state.
+ await new Promise(resolve => TestUtils.executeSoon(resolve));
+ ok(
+ window.fullScreen,
+ "The original chrome window should be still in fullscreen"
+ );
+ ok(
+ document.documentElement.hasAttribute("inDOMFullscreen"),
+ "The original chrome document should be still in fullscreen"
+ );
+
+ // Close new window and move focus back to original window.
+ await BrowserTestUtils.closeWindow(newWindow);
+ await SimpleTest.promiseFocus(window);
+
+ // Exit fullscreen on original window.
+ let fullScreenExited = BrowserTestUtils.waitForEvent(
+ document,
+ "fullscreenchange",
+ false,
+ () => !document.fullscreenElement
+ );
+ EventUtils.synthesizeKey("KEY_Escape");
+ await fullScreenExited;
+
+ BrowserTestUtils.removeTab(tab);
+});
diff --git a/browser/base/content/test/fullscreen/browser_fullscreen_permissions_prompt.js b/browser/base/content/test/fullscreen/browser_fullscreen_permissions_prompt.js
new file mode 100644
index 0000000000..82f0c97631
--- /dev/null
+++ b/browser/base/content/test/fullscreen/browser_fullscreen_permissions_prompt.js
@@ -0,0 +1,160 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// This test tends to trigger a race in the fullscreen time telemetry,
+// where the fullscreen enter and fullscreen exit events (which use the
+// same histogram ID) overlap. That causes TelemetryStopwatch to log an
+// error.
+SimpleTest.ignoreAllUncaughtExceptions(true);
+const { PromiseTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PromiseTestUtils.sys.mjs"
+);
+PromiseTestUtils.allowMatchingRejectionsGlobally(/Not in fullscreen mode/);
+
+SimpleTest.requestCompleteLog();
+
+async function requestNotificationPermission(browser) {
+ return SpecialPowers.spawn(browser, [], () => {
+ return content.Notification.requestPermission();
+ });
+}
+
+async function requestCameraPermission(browser) {
+ return SpecialPowers.spawn(browser, [], () =>
+ content.navigator.mediaDevices
+ .getUserMedia({ video: true, fake: true })
+ .then(
+ () => true,
+ () => false
+ )
+ );
+}
+
+add_task(async function test_fullscreen_closes_permissionui_prompt() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["dom.webnotifications.requireuserinteraction", false],
+ ["permissions.fullscreen.allowed", false],
+ ],
+ });
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "https://example.com"
+ );
+ let browser = tab.linkedBrowser;
+
+ let popupShown, requestResult, popupHidden;
+
+ popupShown = BrowserTestUtils.waitForEvent(
+ window.PopupNotifications.panel,
+ "popupshown"
+ );
+
+ info("Requesting notification permission");
+ requestResult = requestNotificationPermission(browser);
+ await popupShown;
+
+ info("Entering DOM full-screen");
+ popupHidden = BrowserTestUtils.waitForEvent(
+ window.PopupNotifications.panel,
+ "popuphidden"
+ );
+
+ await changeFullscreen(browser, true);
+
+ await popupHidden;
+
+ is(
+ await requestResult,
+ "default",
+ "Expect permission request to be cancelled"
+ );
+
+ await changeFullscreen(browser, false);
+
+ BrowserTestUtils.removeTab(tab);
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function test_fullscreen_closes_webrtc_permission_prompt() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["media.navigator.permission.fake", true],
+ ["media.navigator.permission.force", true],
+ ["permissions.fullscreen.allowed", false],
+ ],
+ });
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "https://example.com"
+ );
+ let browser = tab.linkedBrowser;
+ let popupShown, requestResult, popupHidden;
+
+ popupShown = BrowserTestUtils.waitForEvent(
+ window.PopupNotifications.panel,
+ "popupshown"
+ );
+
+ info("Requesting camera permission");
+ requestResult = requestCameraPermission(browser);
+
+ await popupShown;
+
+ info("Entering DOM full-screen");
+ popupHidden = BrowserTestUtils.waitForEvent(
+ window.PopupNotifications.panel,
+ "popuphidden"
+ );
+ await changeFullscreen(browser, true);
+
+ await popupHidden;
+
+ is(
+ await requestResult,
+ false,
+ "Expect webrtc permission request to be cancelled"
+ );
+
+ await changeFullscreen(browser, false);
+
+ BrowserTestUtils.removeTab(tab);
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function test_permission_prompt_closes_fullscreen() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["dom.webnotifications.requireuserinteraction", false],
+ ["permissions.fullscreen.allowed", false],
+ ],
+ });
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "https://example.com"
+ );
+ let browser = tab.linkedBrowser;
+ info("Entering DOM full-screen");
+ await changeFullscreen(browser, true);
+
+ let popupShown = BrowserTestUtils.waitForEvent(
+ window.PopupNotifications.panel,
+ "popupshown"
+ );
+ let fullScreenExit = waitForFullScreenState(browser, false);
+
+ info("Requesting notification permission");
+ requestNotificationPermission(browser).catch(() => {});
+ await popupShown;
+
+ info("Waiting for full-screen exit");
+ await fullScreenExit;
+
+ BrowserTestUtils.removeTab(tab);
+ await SpecialPowers.popPrefEnv();
+});
diff --git a/browser/base/content/test/fullscreen/browser_fullscreen_warning.js b/browser/base/content/test/fullscreen/browser_fullscreen_warning.js
new file mode 100644
index 0000000000..b8bab5f90c
--- /dev/null
+++ b/browser/base/content/test/fullscreen/browser_fullscreen_warning.js
@@ -0,0 +1,280 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+function checkWarningState(aWarningElement, aExpectedState, aMsg) {
+ ["hidden", "ontop", "onscreen"].forEach(state => {
+ is(
+ aWarningElement.hasAttribute(state),
+ state == aExpectedState,
+ `${aMsg} - check ${state} attribute.`
+ );
+ });
+}
+
+async function waitForWarningState(aWarningElement, aExpectedState) {
+ await BrowserTestUtils.waitForAttribute(aExpectedState, aWarningElement, "");
+ checkWarningState(
+ aWarningElement,
+ aExpectedState,
+ `Wait for ${aExpectedState} state`
+ );
+}
+
+add_setup(async function init() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["full-screen-api.enabled", true],
+ ["full-screen-api.allow-trusted-requests-only", false],
+ ],
+ });
+});
+
+add_task(async function test_fullscreen_display_none() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: `data:text/html,
+ <html>
+ <head>
+ <meta charset="utf-8"/>
+ <title>Fullscreen Test</title>
+ </head>
+ <body id="body">
+ <iframe
+ src="https://example.org/browser/browser/base/content/test/fullscreen/fullscreen.html"
+ hidden
+ allowfullscreen></iframe>
+ </body>
+ </html>`,
+ },
+ async function (browser) {
+ let warning = document.getElementById("fullscreen-warning");
+ checkWarningState(
+ warning,
+ "hidden",
+ "Should not show full screen warning initially"
+ );
+
+ let warningShownPromise = waitForWarningState(warning, "onscreen");
+ // Enter fullscreen
+ await SpecialPowers.spawn(browser, [], async () => {
+ let frame = content.document.querySelector("iframe");
+ frame.focus();
+ await SpecialPowers.spawn(frame, [], () => {
+ content.document.getElementById("request").click();
+ });
+ });
+ await warningShownPromise;
+ ok(true, "Fullscreen warning shown");
+ // Exit fullscreen
+ let exitFullscreenPromise = BrowserTestUtils.waitForEvent(
+ document,
+ "fullscreenchange",
+ false,
+ () => !document.fullscreenElement
+ );
+ document.getElementById("fullscreen-exit-button").click();
+ await exitFullscreenPromise;
+
+ checkWarningState(
+ warning,
+ "hidden",
+ "Should hide fullscreen warning after exiting fullscreen"
+ );
+ }
+ );
+});
+
+add_task(async function test_fullscreen_pointerlock_conflict() {
+ await BrowserTestUtils.withNewTab("https://example.com", async browser => {
+ let fsWarning = document.getElementById("fullscreen-warning");
+ let plWarning = document.getElementById("pointerlock-warning");
+
+ checkWarningState(
+ fsWarning,
+ "hidden",
+ "Should not show full screen warning initially"
+ );
+ checkWarningState(
+ plWarning,
+ "hidden",
+ "Should not show pointer lock warning initially"
+ );
+
+ let fsWarningShownPromise = waitForWarningState(fsWarning, "onscreen");
+ info("Entering full screen and pointer lock.");
+ await SpecialPowers.spawn(browser, [], async () => {
+ await content.document.body.requestFullscreen();
+ await content.document.body.requestPointerLock();
+ });
+
+ await fsWarningShownPromise;
+ checkWarningState(
+ plWarning,
+ "hidden",
+ "Should not show pointer lock warning"
+ );
+
+ info("Exiting pointerlock");
+ await SpecialPowers.spawn(browser, [], async () => {
+ await content.document.exitPointerLock();
+ });
+
+ checkWarningState(
+ fsWarning,
+ "onscreen",
+ "Should still show full screen warning"
+ );
+ checkWarningState(
+ plWarning,
+ "hidden",
+ "Should not show pointer lock warning"
+ );
+
+ // Cleanup
+ info("Exiting fullscreen");
+ await document.exitFullscreen();
+ });
+});
+
+// https://bugzilla.mozilla.org/show_bug.cgi?id=1821884
+add_task(async function test_reshow_fullscreen_notification() {
+ await BrowserTestUtils.withNewTab("https://example.com", async browser => {
+ let newWin = await BrowserTestUtils.openNewBrowserWindow();
+ let fsWarning = document.getElementById("fullscreen-warning");
+
+ info("Entering full screen and wait for the fullscreen warning to appear.");
+ await SimpleTest.promiseFocus(window);
+ await Promise.all([
+ waitForWarningState(fsWarning, "onscreen"),
+ BrowserTestUtils.waitForEvent(fsWarning, "transitionend"),
+ SpecialPowers.spawn(browser, [], async () => {
+ content.document.body.requestFullscreen();
+ }),
+ ]);
+
+ info(
+ "Switch focus away from the fullscreen window, the fullscreen warning should still hide automatically."
+ );
+ await Promise.all([
+ waitForWarningState(fsWarning, "hidden"),
+ SimpleTest.promiseFocus(newWin),
+ ]);
+
+ info(
+ "Switch focus back to the fullscreen window, the fullscreen warning should show again."
+ );
+ await Promise.all([
+ waitForWarningState(fsWarning, "onscreen"),
+ SimpleTest.promiseFocus(window),
+ ]);
+
+ info("Wait for fullscreen warning timed out.");
+ await waitForWarningState(fsWarning, "hidden");
+
+ info("The fullscreen warning should not show again.");
+ await SimpleTest.promiseFocus(newWin);
+ await SimpleTest.promiseFocus(window);
+ await new Promise(resolve => {
+ requestAnimationFrame(() => {
+ requestAnimationFrame(resolve);
+ });
+ });
+ checkWarningState(
+ fsWarning,
+ "hidden",
+ "The fullscreen warning should not show."
+ );
+
+ info("Close new browser window.");
+ await BrowserTestUtils.closeWindow(newWin);
+
+ info("Exit fullscreen.");
+ await document.exitFullscreen();
+ });
+});
+
+add_task(async function test_fullscreen_reappear() {
+ await BrowserTestUtils.withNewTab("https://example.com", async browser => {
+ let fsWarning = document.getElementById("fullscreen-warning");
+
+ info("Entering full screen and wait for the fullscreen warning to appear.");
+ await Promise.all([
+ waitForWarningState(fsWarning, "onscreen"),
+ SpecialPowers.spawn(browser, [], async () => {
+ content.document.body.requestFullscreen();
+ }),
+ ]);
+
+ info("Wait for fullscreen warning timed out.");
+ await waitForWarningState(fsWarning, "hidden");
+
+ info("Move mouse to the top of screen.");
+ await Promise.all([
+ waitForWarningState(fsWarning, "ontop"),
+ EventUtils.synthesizeMouse(document.documentElement, 100, 0, {
+ type: "mousemove",
+ }),
+ ]);
+
+ info("Wait for fullscreen warning timed out again.");
+ await waitForWarningState(fsWarning, "hidden");
+
+ info("Exit fullscreen.");
+ await document.exitFullscreen();
+ });
+});
+
+// https://bugzilla.mozilla.org/show_bug.cgi?id=1847901
+add_task(async function test_fullscreen_warning_disabled() {
+ // Disable fullscreen warning
+ await SpecialPowers.pushPrefEnv({
+ set: [["full-screen-api.warning.timeout", 0]],
+ });
+
+ await BrowserTestUtils.withNewTab("https://example.com", async browser => {
+ let newWin = await BrowserTestUtils.openNewBrowserWindow();
+ let fsWarning = document.getElementById("fullscreen-warning");
+ let mut = new MutationObserver(mutations => {
+ ok(false, `${mutations[0].attributeName} attribute should not change`);
+ });
+ mut.observe(fsWarning, {
+ attributeFilter: ["hidden", "onscreen", "ontop"],
+ });
+
+ info("Entering full screen.");
+ await SimpleTest.promiseFocus(window);
+ await SpecialPowers.spawn(browser, [], async () => {
+ return content.document.body.requestFullscreen();
+ });
+ // Wait a bit to ensure no state change.
+ await new Promise(resolve => {
+ requestAnimationFrame(() => {
+ requestAnimationFrame(resolve);
+ });
+ });
+
+ info("The fullscreen warning should still not show after switching focus.");
+ await SimpleTest.promiseFocus(newWin);
+ await SimpleTest.promiseFocus(window);
+ // Wait a bit to ensure no state change.
+ await new Promise(resolve => {
+ requestAnimationFrame(() => {
+ requestAnimationFrame(resolve);
+ });
+ });
+
+ mut.disconnect();
+
+ info("Close new browser window.");
+ await BrowserTestUtils.closeWindow(newWin);
+
+ info("Exit fullscreen.");
+ await document.exitFullscreen();
+ });
+
+ // Revert the setting to avoid affecting subsequent tests.
+ await SpecialPowers.popPrefEnv();
+});
diff --git a/browser/base/content/test/fullscreen/browser_fullscreen_window_focus.js b/browser/base/content/test/fullscreen/browser_fullscreen_window_focus.js
new file mode 100644
index 0000000000..7c935f64d3
--- /dev/null
+++ b/browser/base/content/test/fullscreen/browser_fullscreen_window_focus.js
@@ -0,0 +1,110 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+async function pause() {
+ /* eslint-disable mozilla/no-arbitrary-setTimeout */
+ return new Promise(resolve => setTimeout(resolve, 500));
+}
+
+// This test tends to trigger a race in the fullscreen time telemetry,
+// where the fullscreen enter and fullscreen exit events (which use the
+// same histogram ID) overlap. That causes TelemetryStopwatch to log an
+// error.
+SimpleTest.ignoreAllUncaughtExceptions(true);
+
+const IFRAME_ID = "testIframe";
+
+async function testWindowFocus(isPopup, iframeID) {
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL);
+
+ info("Calling window.open()");
+ let openedWindow = await jsWindowOpen(tab.linkedBrowser, isPopup, iframeID);
+ info("Letting OOP focus to stabilize");
+ await pause(); // Bug 1719659 for proper fix
+ info("re-focusing main window");
+ await waitForFocus(tab.linkedBrowser);
+
+ info("Entering full-screen");
+ await changeFullscreen(tab.linkedBrowser, true);
+
+ await testExpectFullScreenExit(tab.linkedBrowser, true, async () => {
+ info("Calling window.focus()");
+ await jsWindowFocus(tab.linkedBrowser, iframeID);
+ });
+
+ // Cleanup
+ if (isPopup) {
+ openedWindow.close();
+ } else {
+ BrowserTestUtils.removeTab(openedWindow);
+ }
+ BrowserTestUtils.removeTab(tab);
+}
+
+async function testWindowElementFocus(isPopup) {
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL);
+
+ info("Calling window.open()");
+ let openedWindow = await jsWindowOpen(tab.linkedBrowser, isPopup);
+ info("Letting OOP focus to stabilize");
+ await pause(); // Bug 1719659 for proper fix
+ info("re-focusing main window");
+ await waitForFocus(tab.linkedBrowser);
+
+ info("Entering full-screen");
+ await changeFullscreen(tab.linkedBrowser, true);
+
+ await testExpectFullScreenExit(tab.linkedBrowser, false, async () => {
+ info("Calling element.focus() on popup");
+ await ContentTask.spawn(tab.linkedBrowser, {}, async args => {
+ await content.wrappedJSObject.sendMessage(
+ content.wrappedJSObject.openedWindow,
+ "elementfocus"
+ );
+ });
+ });
+
+ // Cleanup
+ await changeFullscreen(tab.linkedBrowser, false);
+ if (isPopup) {
+ openedWindow.close();
+ } else {
+ BrowserTestUtils.removeTab(openedWindow);
+ }
+ BrowserTestUtils.removeTab(tab);
+}
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["dom.disable_open_during_load", false], // Allow window.focus calls without user interaction
+ ["browser.link.open_newwindow.disabled_in_fullscreen", false],
+ ],
+ });
+});
+
+add_task(function test_popupWindowFocus() {
+ return testWindowFocus(true);
+});
+
+add_task(function test_iframePopupWindowFocus() {
+ return testWindowFocus(true, IFRAME_ID);
+});
+
+add_task(function test_popupWindowElementFocus() {
+ return testWindowElementFocus(true);
+});
+
+add_task(function test_backgroundTabFocus() {
+ return testWindowFocus(false);
+});
+
+add_task(function test_iframebackgroundTabFocus() {
+ return testWindowFocus(false, IFRAME_ID);
+});
+
+add_task(function test_backgroundTabElementFocus() {
+ return testWindowElementFocus(false);
+});
diff --git a/browser/base/content/test/fullscreen/browser_fullscreen_window_open.js b/browser/base/content/test/fullscreen/browser_fullscreen_window_open.js
new file mode 100644
index 0000000000..aafed57c75
--- /dev/null
+++ b/browser/base/content/test/fullscreen/browser_fullscreen_window_open.js
@@ -0,0 +1,102 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// This test tends to trigger a race in the fullscreen time telemetry,
+// where the fullscreen enter and fullscreen exit events (which use the
+// same histogram ID) overlap. That causes TelemetryStopwatch to log an
+// error.
+SimpleTest.ignoreAllUncaughtExceptions(true);
+SimpleTest.requestLongerTimeout(2);
+
+const IFRAME_ID = "testIframe";
+
+async function testWindowOpen(iframeID) {
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL);
+ info("Entering full-screen");
+ await changeFullscreen(tab.linkedBrowser, true);
+
+ let popup;
+ await testExpectFullScreenExit(tab.linkedBrowser, true, async () => {
+ info("Calling window.open()");
+ popup = await jsWindowOpen(tab.linkedBrowser, true, iframeID);
+ });
+
+ // Cleanup
+ await BrowserTestUtils.closeWindow(popup);
+ BrowserTestUtils.removeTab(tab);
+}
+
+async function testWindowOpenExistingWindow(funToOpenExitingWindow, iframeID) {
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL);
+ let popup = await jsWindowOpen(tab.linkedBrowser, true);
+
+ info("re-focusing main window");
+ await waitForFocus(tab.linkedBrowser);
+
+ info("Entering full-screen");
+ await changeFullscreen(tab.linkedBrowser, true);
+
+ info("open existing popup window");
+ await testExpectFullScreenExit(tab.linkedBrowser, true, async () => {
+ await funToOpenExitingWindow(tab.linkedBrowser, iframeID);
+ });
+
+ // Cleanup
+ await BrowserTestUtils.closeWindow(popup);
+ BrowserTestUtils.removeTab(tab);
+}
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["dom.disable_open_during_load", false], // Allow window.open calls without user interaction
+ ["browser.link.open_newwindow.disabled_in_fullscreen", false],
+ ],
+ });
+});
+
+add_task(function test_parentWindowOpen() {
+ return testWindowOpen();
+});
+
+add_task(function test_iframeWindowOpen() {
+ return testWindowOpen(IFRAME_ID);
+});
+
+add_task(async function test_parentWindowOpenExistWindow() {
+ await testWindowOpenExistingWindow(browser => {
+ info(
+ "Calling window.open() with same name again should reuse the existing window"
+ );
+ jsWindowOpen(browser, true);
+ });
+});
+
+add_task(async function test_iframeWindowOpenExistWindow() {
+ await testWindowOpenExistingWindow((browser, iframeID) => {
+ info(
+ "Calling window.open() with same name again should reuse the existing window"
+ );
+ jsWindowOpen(browser, true, iframeID);
+ }, IFRAME_ID);
+});
+
+add_task(async function test_parentWindowClickLinkOpenExistWindow() {
+ await testWindowOpenExistingWindow(browser => {
+ info(
+ "Clicking link with same target name should reuse the existing window"
+ );
+ jsClickLink(browser, true);
+ });
+});
+
+add_task(async function test_iframeWindowClickLinkOpenExistWindow() {
+ await testWindowOpenExistingWindow((browser, iframeID) => {
+ info(
+ "Clicking link with same target name should reuse the existing window"
+ );
+ jsClickLink(browser, true, iframeID);
+ }, IFRAME_ID);
+});
diff --git a/browser/base/content/test/fullscreen/fullscreen.html b/browser/base/content/test/fullscreen/fullscreen.html
new file mode 100644
index 0000000000..8b4289bb36
--- /dev/null
+++ b/browser/base/content/test/fullscreen/fullscreen.html
@@ -0,0 +1,12 @@
+<!DOCTYPE HTML>
+<html>
+<script>
+function requestFScreen() {
+ document.body.requestFullscreen();
+}
+</script>
+<body>
+<button id="request" onclick="requestFScreen()"> Fullscreen </button>
+<button id="focus"> Fullscreen </button>
+</body>
+</html>
diff --git a/browser/base/content/test/fullscreen/fullscreen_frame.html b/browser/base/content/test/fullscreen/fullscreen_frame.html
new file mode 100644
index 0000000000..ca1b1a4dd8
--- /dev/null
+++ b/browser/base/content/test/fullscreen/fullscreen_frame.html
@@ -0,0 +1,9 @@
+<!DOCTYPE HTML>
+<html>
+<body>
+ <iframe id="frameAllowed"
+ src="https://example.org/browser/browser/base/content/test/fullscreen/fullscreen.html"
+ allowfullscreen></iframe>
+ <iframe id="frameDenied" src="https://example.org/browser/browser/base/content/test/fullscreen/fullscreen.html"></iframe>
+</body>
+</html>
diff --git a/browser/base/content/test/fullscreen/head.js b/browser/base/content/test/fullscreen/head.js
new file mode 100644
index 0000000000..ce7a4b23b2
--- /dev/null
+++ b/browser/base/content/test/fullscreen/head.js
@@ -0,0 +1,164 @@
+const TEST_URL =
+ "https://example.com/browser/browser/base/content/test/fullscreen/open_and_focus_helper.html";
+
+function waitForFullScreenState(browser, state) {
+ return new Promise(resolve => {
+ let eventReceived = false;
+
+ let observe = (subject, topic, data) => {
+ if (!eventReceived) {
+ return;
+ }
+ Services.obs.removeObserver(observe, "fullscreen-painted");
+ resolve();
+ };
+ Services.obs.addObserver(observe, "fullscreen-painted");
+
+ browser.ownerGlobal.addEventListener(
+ `MozDOMFullscreen:${state ? "Entered" : "Exited"}`,
+ () => {
+ eventReceived = true;
+ },
+ { once: true }
+ );
+ });
+}
+
+/**
+ * Spawns content task in browser to enter / leave fullscreen
+ * @param browser - Browser to use for JS fullscreen requests
+ * @param {Boolean} fullscreenState - true to enter fullscreen, false to leave
+ * @returns {Promise} - Resolves once fullscreen change is applied
+ */
+async function changeFullscreen(browser, fullScreenState) {
+ await new Promise(resolve =>
+ SimpleTest.waitForFocus(resolve, browser.ownerGlobal)
+ );
+ let fullScreenChange = waitForFullScreenState(browser, fullScreenState);
+ SpecialPowers.spawn(browser, [fullScreenState], async state => {
+ // Wait for document focus before requesting full-screen
+ await ContentTaskUtils.waitForCondition(
+ () => content.browsingContext.isActive && content.document.hasFocus(),
+ "Waiting for document focus"
+ );
+ if (state) {
+ content.document.body.requestFullscreen();
+ } else {
+ content.document.exitFullscreen();
+ }
+ });
+ return fullScreenChange;
+}
+
+async function testExpectFullScreenExit(browser, leaveFS, action) {
+ let fsPromise = waitForFullScreenState(browser, false);
+ if (leaveFS) {
+ if (action) {
+ await action();
+ }
+ await fsPromise;
+ ok(true, "Should leave full-screen");
+ } else {
+ if (action) {
+ await action();
+ }
+ let result = await Promise.race([
+ fsPromise,
+ new Promise(resolve => {
+ SimpleTest.requestFlakyTimeout("Wait for failure condition");
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ setTimeout(() => resolve(true), 2500);
+ }),
+ ]);
+ ok(result, "Should not leave full-screen");
+ }
+}
+
+function jsWindowFocus(browser, iframeId) {
+ return ContentTask.spawn(browser, { iframeId }, async args => {
+ let destWin = content;
+ if (args.iframeId) {
+ let iframe = content.document.getElementById(args.iframeId);
+ if (!iframe) {
+ throw new Error("iframe not set");
+ }
+ destWin = iframe.contentWindow;
+ }
+ await content.wrappedJSObject.sendMessage(destWin, "focus");
+ });
+}
+
+function jsElementFocus(browser, iframeId) {
+ return ContentTask.spawn(browser, { iframeId }, async args => {
+ let destWin = content;
+ if (args.iframeId) {
+ let iframe = content.document.getElementById(args.iframeId);
+ if (!iframe) {
+ throw new Error("iframe not set");
+ }
+ destWin = iframe.contentWindow;
+ }
+ await content.wrappedJSObject.sendMessage(destWin, "elementfocus");
+ });
+}
+
+async function jsWindowOpen(browser, isPopup, iframeId) {
+ //let windowOpened = BrowserTestUtils.waitForNewWindow();
+ let windowOpened = isPopup
+ ? BrowserTestUtils.waitForNewWindow({ url: TEST_URL })
+ : BrowserTestUtils.waitForNewTab(gBrowser, TEST_URL, true);
+ ContentTask.spawn(browser, { isPopup, iframeId }, async args => {
+ let destWin = content;
+ if (args.iframeId) {
+ // Create a cross origin iframe
+ destWin = (
+ await content.wrappedJSObject.createIframe(args.iframeId, true)
+ ).contentWindow;
+ }
+ // Send message to either the iframe or the current page to open a popup
+ await content.wrappedJSObject.sendMessage(
+ destWin,
+ args.isPopup ? "openpopup" : "open"
+ );
+ });
+ return windowOpened;
+}
+
+async function jsClickLink(browser, isPopup, iframeId) {
+ //let windowOpened = BrowserTestUtils.waitForNewWindow();
+ let windowOpened = isPopup
+ ? BrowserTestUtils.waitForNewWindow({ url: TEST_URL })
+ : BrowserTestUtils.waitForNewTab(gBrowser, TEST_URL, true);
+ ContentTask.spawn(browser, { isPopup, iframeId }, async args => {
+ let destWin = content;
+ if (args.iframeId) {
+ // Create a cross origin iframe
+ destWin = (
+ await content.wrappedJSObject.createIframe(args.iframeId, true)
+ ).contentWindow;
+ }
+ // Send message to either the iframe or the current page to click a link
+ await content.wrappedJSObject.sendMessage(destWin, "clicklink");
+ });
+ return windowOpened;
+}
+
+function waitForFocus(...args) {
+ return new Promise(resolve => SimpleTest.waitForFocus(resolve, ...args));
+}
+
+function waitForBrowserWindowActive(win) {
+ return new Promise(resolve => {
+ if (Services.focus.activeWindow == win) {
+ resolve();
+ } else {
+ win.addEventListener(
+ "activate",
+ () => {
+ resolve();
+ },
+ { once: true }
+ );
+ }
+ });
+}
diff --git a/browser/base/content/test/fullscreen/open_and_focus_helper.html b/browser/base/content/test/fullscreen/open_and_focus_helper.html
new file mode 100644
index 0000000000..06d1800714
--- /dev/null
+++ b/browser/base/content/test/fullscreen/open_and_focus_helper.html
@@ -0,0 +1,56 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset='utf-8'>
+ <script src="/tests/SimpleTest/EventUtils.js"></script>
+</head>
+<body>
+ <input></input><br>
+ <a href="https://example.com" target="test">link</a>
+ <script>
+ const MY_ORIGIN = window.location.origin;
+ const CROSS_ORIGIN = "https://example.org";
+
+ // Creates an iframe with message channel to trigger window open and focus
+ window.createIframe = function(id, crossOrigin = false) {
+ return new Promise(resolve => {
+ const origin = crossOrigin ? CROSS_ORIGIN : MY_ORIGIN;
+ let iframe = document.createElement("iframe");
+ iframe.id = id;
+ iframe.src = origin + window.location.pathname;
+ iframe.onload = () => resolve(iframe);
+ document.body.appendChild(iframe);
+ });
+ }
+
+ window.sendMessage = function(destWin, msg) {
+ return new Promise(resolve => {
+ let channel = new MessageChannel();
+ channel.port1.onmessage = resolve;
+ destWin.postMessage(msg, "*", [channel.port2]);
+ });
+ }
+
+ window.onMessage = function(event) {
+ let canReply = event.ports && !!event.ports.length;
+ if(event.data === "open") {
+ window.openedWindow = window.open('https://example.com' + window.location.pathname);
+ if (canReply) event.ports[0].postMessage('opened');
+ } else if(event.data === "openpopup") {
+ window.openedWindow = window.open('https://example.com' + window.location.pathname, 'test', 'top=0,height=1, width=300');
+ if (canReply) event.ports[0].postMessage('popupopened');
+ } else if(event.data === "focus") {
+ window.openedWindow.focus();
+ if (canReply) event.ports[0].postMessage('focused');
+ } else if(event.data === "elementfocus") {
+ document.querySelector("input").focus();
+ if (canReply) event.ports[0].postMessage('elementfocused');
+ } else if(event.data === "clicklink") {
+ synthesizeMouseAtCenter(document.querySelector("a"), {});
+ if (canReply) event.ports[0].postMessage('linkclicked');
+ }
+ }
+ window.addEventListener('message', window.onMessage);
+ </script>
+</body>
+</html>
diff --git a/browser/base/content/test/general/alltabslistener.html b/browser/base/content/test/general/alltabslistener.html
new file mode 100644
index 0000000000..166c31037a
--- /dev/null
+++ b/browser/base/content/test/general/alltabslistener.html
@@ -0,0 +1,8 @@
+<html>
+<head>
+<title>Test page for bug 463387</title>
+</head>
+<body>
+<p>Test page for bug 463387</p>
+</body>
+</html>
diff --git a/browser/base/content/test/general/app_bug575561.html b/browser/base/content/test/general/app_bug575561.html
new file mode 100644
index 0000000000..13c525487e
--- /dev/null
+++ b/browser/base/content/test/general/app_bug575561.html
@@ -0,0 +1,18 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=575561
+-->
+ <head>
+ <title>Test for links in app tabs</title>
+ </head>
+ <body>
+ <a href="http://example.com/browser/browser/base/content/test/general/dummy_page.html">same domain</a>
+ <a href="http://test1.example.com/browser/browser/base/content/test/general/dummy_page.html">same domain (different subdomain)</a>
+ <a href="http://example.org/browser/browser/base/content/test/general/dummy_page.html">different domain</a>
+ <a href="http://example.org/browser/browser/base/content/test/general/dummy_page.html" target="foo">different domain (with target)</a>
+ <a href="http://www.example.com/browser/browser/base/content/test/general/dummy_page.html">same domain (www prefix)</a>
+ <a href="data:text/html,<!DOCTYPE html><html><body>Another Page</body></html>">data: URI</a>
+ <iframe src="app_subframe_bug575561.html"></iframe>
+ </body>
+</html>
diff --git a/browser/base/content/test/general/app_subframe_bug575561.html b/browser/base/content/test/general/app_subframe_bug575561.html
new file mode 100644
index 0000000000..8690497ffb
--- /dev/null
+++ b/browser/base/content/test/general/app_subframe_bug575561.html
@@ -0,0 +1,12 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=575561
+-->
+ <head>
+ <title>Test for links in app tab subframes</title>
+ </head>
+ <body>
+ <a href="http://example.org/browser/browser/base/content/test/general/dummy_page.html">different domain</a>
+ </body>
+</html>
diff --git a/browser/base/content/test/general/audio.ogg b/browser/base/content/test/general/audio.ogg
new file mode 100644
index 0000000000..477544875d
--- /dev/null
+++ b/browser/base/content/test/general/audio.ogg
Binary files differ
diff --git a/browser/base/content/test/general/browser.ini b/browser/base/content/test/general/browser.ini
new file mode 100644
index 0000000000..2e9438135a
--- /dev/null
+++ b/browser/base/content/test/general/browser.ini
@@ -0,0 +1,416 @@
+###############################################################################
+# DO NOT ADD MORE TESTS HERE. #
+# TRY ONE OF THE MORE TOPICAL SIBLING DIRECTORIES. #
+# THIS DIRECTORY HAS 200+ TESTS AND TAKES AGES TO RUN ON A DEBUG BUILD. #
+# PLEASE, FOR THE LOVE OF WHATEVER YOU HOLD DEAR, DO NOT ADD MORE TESTS HERE. #
+###############################################################################
+
+[DEFAULT]
+support-files =
+ alltabslistener.html
+ app_bug575561.html
+ app_subframe_bug575561.html
+ audio.ogg
+ browser_bug479408_sample.html
+ browser_star_hsts.sjs
+ browser_tab_dragdrop2_frame1.xhtml
+ browser_tab_dragdrop_embed.html
+ bug792517-2.html
+ bug792517.html
+ bug792517.sjs
+ clipboard_pastefile.html
+ download_page.html
+ download_page_1.txt
+ download_page_2.txt
+ download_with_content_disposition_header.sjs
+ dummy_page.html
+ file_documentnavigation_frameset.html
+ file_double_close_tab.html
+ file_fullscreen-window-open.html
+ file_with_link_to_http.html
+ head.js
+ moz.png
+ navigating_window_with_download.html
+ print_postdata.sjs
+ test_bug462673.html
+ test_bug628179.html
+ title_test.svg
+ unknownContentType_file.pif
+ unknownContentType_file.pif^headers^
+ video.ogg
+ web_video.html
+ web_video1.ogv
+ web_video1.ogv^headers^
+ !/image/test/mochitest/blue.png
+ !/toolkit/content/tests/browser/common/mockTransfer.js
+
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_accesskeys.js]
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_addCertException.js]
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_alltabslistener.js]
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_backButtonFitts.js]
+https_first_disabled = true
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_beforeunload_duplicate_dialogs.js]
+https_first_disabled = true
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_bug1261299.js]
+skip-if =
+ os != "mac" # Because of tests for supporting Service Menu of macOS, bug 1261299
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_bug1297539.js]
+skip-if =
+ os != "mac" # Because of tests for supporting pasting from Service Menu of macOS, bug 1297539
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_bug1299667.js]
+https_first_disabled = true
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_bug321000.js]
+skip-if = true # browser_bug321000.js is disabled because newline handling is shaky (bug 592528)
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_bug356571.js]
+skip-if =
+ verify && !debug && os == 'win'
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_bug380960.js]
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_bug406216.js]
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_bug417483.js]
+skip-if =
+ verify && debug && os == 'mac'
+ os == 'mac'
+ os == 'linux' #Bug 1444703
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_bug424101.js]
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_bug427559.js]
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_bug431826.js]
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_bug432599.js]
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_bug455852.js]
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_bug462289.js]
+skip-if =
+ os == "mac"
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_bug462673.js]
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_bug477014.js]
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_bug479408.js]
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_bug481560.js]
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_bug484315.js]
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_bug491431.js]
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_bug495058.js]
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_bug519216.js]
+skip-if = true # Bug 1478159
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_bug520538.js]
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_bug521216.js]
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_bug533232.js]
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_bug537013.js]
+skip-if = true # bug 1393813
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_bug537474.js]
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_bug563588.js]
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_bug565575.js]
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_bug567306.js]
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_bug575561.js]
+https_first_disabled = true
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_bug577121.js]
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_bug578534.js]
+https_first_disabled = true
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_bug579872.js]
+skip-if =
+ verify && debug && os == 'linux'
+ os == 'mac'
+ os == 'linux' && !debug #Bug 1448915
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_bug581253.js]
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_bug585785.js]
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_bug585830.js]
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_bug594131.js]
+skip-if =
+ verify && debug && os == 'linux'
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_bug596687.js]
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_bug597218.js]
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_bug609700.js]
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_bug623893.js]
+https_first_disabled = true
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_bug624734.js]
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_bug664672.js]
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_bug676619.js]
+support-files =
+ dummy.ics
+ dummy.ics^headers^
+ redirect_download.sjs
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_bug710878.js]
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_bug724239.js]
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_bug734076.js]
+skip-if =
+ verify && debug && os == 'linux'
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_bug749738.js]
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_bug763468_perwindowpb.js]
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_bug767836_perwindowpb.js]
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_bug817947.js]
+skip-if =
+ os == 'linux' && !debug # Bug 1556066
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_bug832435.js]
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_bug882977.js]
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_bug963945.js]
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_clipboard.js]
+https_first_disabled = true
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_clipboard_pastefile.js]
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_contentAltClick.js]
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_contentAreaClick.js]
+skip-if = true # Clicks in content don't go through contentAreaClick.
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_ctrlTab.js]
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_datachoices_notification.js]
+skip-if =
+ !datareporting
+ verify && !debug && os == 'win'
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_documentnavigation.js]
+skip-if =
+ verify && !debug && os == 'linux'
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_domFullscreen_fullscreenMode.js]
+tags = fullscreen
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_double_close_tab.js]
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_drag.js]
+skip-if = true # browser_drag.js is disabled, as it needs to be updated for the new behavior from bug 320638.
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_duplicateIDs.js]
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_findbarClose.js]
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_focusonkeydown.js]
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_fullscreen-window-open.js]
+tags = fullscreen
+skip-if =
+ os == "linux" # Linux: Intermittent failures - bug 941575.
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_gestureSupport.js]
+support-files =
+ !/gfx/layers/apz/test/mochitest/apz_test_native_event_utils.js
+ !/gfx/layers/apz/test/mochitest/apz_test_utils.js
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_hide_removing.js]
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_homeDrop.js]
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_invalid_uri_back_forward_manipulation.js]
+skip-if =
+ os == 'mac' && socketprocess_networking
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_lastAccessedTab.js]
+skip-if =
+ os == "windows" # Disabled on Windows due to frequent failures (bug 969405)
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_menuButtonFitts.js]
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_middleMouse_noJSPaste.js]
+https_first_disabled = true
+skip-if =
+ apple_silicon && !debug # Bug 1724711
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_minimize.js]
+skip-if =
+ apple_silicon && !debug # Bug 1725756
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_modifiedclick_inherit_principal.js]
+https_first_disabled = true
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_newTabDrop.js]
+https_first_disabled = true
+skip-if =
+ os == "linux" && fission && tsan # high frequency intermittent
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_newWindowDrop.js]
+https_first_disabled = true
+skip-if =
+ os == "win" && os_version == "6.1" # bug 1715862
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_new_http_window_opened_from_file_tab.js]
+https_first_disabled = true
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_newwindow_focus.js]
+https_first_disabled = true
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_plainTextLinks.js]
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_printpreview.js]
+skip-if =
+ os == 'win'
+ os == 'linux' && os_version == '18.04' # Bug 1384127
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_private_browsing_window.js]
+https_first_disabled = true
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_private_no_prompt.js]
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_refreshBlocker.js]
+skip-if =
+ os == "mac"
+ os == "linux" && !debug
+ os == "win" && bits == 32 # Bug 1559410 for all instances
+support-files =
+ refresh_header.sjs
+ refresh_meta.sjs
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_relatedTabs.js]
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_remoteTroubleshoot.js]
+https_first_disabled = true
+skip-if =
+ !updater
+ os == 'linux' && asan # Bug 1711507
+reason = depends on UpdateUtils .Locale
+support-files =
+ test_remoteTroubleshoot.html
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_remoteWebNavigation_postdata.js]
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_restore_isAppTab.js]
+skip-if =
+ !crashreporter # test requires crashreporter due to 1536221
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_save_link-perwindowpb.js]
+skip-if =
+ debug && os == "win"
+ verify # Bug 1280505
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_save_link_when_window_navigates.js]
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_save_private_link_perwindowpb.js]
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_save_video.js]
+skip-if =
+ os == 'mac'
+ verify && os == 'mac'
+ os == 'win' && debug
+ os =='linux' #Bug 1212419
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_save_video_frame.js]
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_selectTabAtIndex.js]
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_star_hsts.js]
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_storagePressure_notification.js]
+skip-if = verify
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_tabDrop.js]
+https_first_disabled = true
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_tab_close_dependent_window.js]
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_tab_detach_restore.js]
+https_first_disabled = true
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_tab_drag_drop_perwindow.js]
+skip-if =
+ os == "win" && os_version == "6.1" && bits == 32 # bug 1717587
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_tab_dragdrop.js]
+skip-if = true # Bug 1312436, Bug 1388973
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_tab_dragdrop2.js]
+skip-if =
+ os == "win" && bits == 32 && !debug # high frequency win7 intermittent: crash
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_tabfocus.js]
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_tabkeynavigation.js]
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_tabs_close_beforeunload.js]
+support-files =
+ close_beforeunload_opens_second_tab.html
+ close_beforeunload.html
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_tabs_isActive.js]
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_tabs_owner.js]
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_testOpenNewRemoteTabsFromNonRemoteBrowsers.js]
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_typeAheadFind.js]
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_unknownContentType_title.js]
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_unloaddialogs.js]
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_viewSourceInTabOnViewSource.js]
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_visibleFindSelection.js]
+skip-if = true # Bug 1409184 disabled because interactive find next is not automating properly
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_visibleTabs.js]
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_visibleTabs_bookmarkAllPages.js]
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_visibleTabs_tabPreview.js]
+skip-if =
+ os == "win" && !debug
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_windowactivation.js]
+skip-if =
+ verify
+ os == "linux" && debug # Bug 1678774
+support-files =
+ file_window_activation.html
+ file_window_activation2.html
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
+[browser_zbug569342.js]
+skip-if = true # Bug 1094240 - has findbar-related failures
+# DO NOT ADD MORE TESTS HERE. USE A TOPICAL DIRECTORY INSTEAD.
diff --git a/browser/base/content/test/general/browser_accesskeys.js b/browser/base/content/test/general/browser_accesskeys.js
new file mode 100644
index 0000000000..c8b27d6307
--- /dev/null
+++ b/browser/base/content/test/general/browser_accesskeys.js
@@ -0,0 +1,202 @@
+add_task(async function () {
+ await pushPrefs(["ui.key.contentAccess", 5], ["ui.key.chromeAccess", 5]);
+
+ const gPageURL1 =
+ "data:text/html,<body><p>" +
+ "<button id='button' accesskey='y'>Button</button>" +
+ "<input id='checkbox' type='checkbox' accesskey='z'>Checkbox" +
+ "</p></body>";
+ let tab1 = await BrowserTestUtils.openNewForegroundTab(gBrowser, gPageURL1);
+
+ Services.focus.clearFocus(window);
+
+ // Press an accesskey in the child document while the chrome is focused.
+ let focusedId = await performAccessKey(tab1.linkedBrowser, "y");
+ is(focusedId, "button", "button accesskey");
+
+ // Press an accesskey in the child document while the content document is focused.
+ focusedId = await performAccessKey(tab1.linkedBrowser, "z");
+ is(focusedId, "checkbox", "checkbox accesskey");
+
+ // Add an element with an accesskey to the chrome and press its accesskey while the chrome is focused.
+ let newButton = document.createXULElement("button");
+ newButton.id = "chromebutton";
+ newButton.setAttribute("accesskey", "z");
+ document.documentElement.appendChild(newButton);
+
+ Services.focus.clearFocus(window);
+
+ newButton.getBoundingClientRect(); // Accesskey registration happens during frame construction.
+
+ focusedId = await performAccessKeyForChrome("z");
+ is(focusedId, "chromebutton", "chromebutton accesskey");
+
+ // Add a second tab and ensure that accesskey from the first tab is not used.
+ const gPageURL2 =
+ "data:text/html,<body>" +
+ "<button id='tab2button' accesskey='y'>Button in Tab 2</button>" +
+ "</body>";
+ let tab2 = await BrowserTestUtils.openNewForegroundTab(gBrowser, gPageURL2);
+
+ Services.focus.clearFocus(window);
+
+ focusedId = await performAccessKey(tab2.linkedBrowser, "y");
+ is(focusedId, "tab2button", "button accesskey in tab2");
+
+ // Press the accesskey for the chrome element while the content document is focused.
+ focusedId = await performAccessKeyForChrome("z");
+ is(focusedId, "chromebutton", "chromebutton accesskey");
+
+ gBrowser.removeTab(tab1);
+ gBrowser.removeTab(tab2);
+
+ // Test whether access key for the newButton isn't available when content
+ // consumes the key event.
+
+ // When content in the tab3 consumes all keydown events.
+ const gPageURL3 =
+ "data:text/html,<body id='tab3body'>" +
+ "<button id='tab3button' accesskey='y'>Button in Tab 3</button>" +
+ "<script>" +
+ "document.body.addEventListener('keydown', (event)=>{ event.preventDefault(); });" +
+ "</script></body>";
+ let tab3 = await BrowserTestUtils.openNewForegroundTab(gBrowser, gPageURL3);
+
+ Services.focus.clearFocus(window);
+
+ focusedId = await performAccessKey(tab3.linkedBrowser, "y");
+ is(focusedId, "tab3button", "button accesskey in tab3 should be focused");
+
+ newButton.onfocus = () => {
+ ok(false, "chromebutton shouldn't get focus during testing with tab3");
+ };
+
+ // Press the accesskey for the chrome element while the content document is focused.
+ focusedId = await performAccessKey(tab3.linkedBrowser, "z");
+ is(
+ focusedId,
+ "tab3body",
+ "button accesskey in tab3 should keep having focus"
+ );
+
+ newButton.onfocus = null;
+
+ gBrowser.removeTab(tab3);
+
+ // When content in the tab4 consumes all keypress events.
+ const gPageURL4 =
+ "data:text/html,<body id='tab4body'>" +
+ "<button id='tab4button' accesskey='y'>Button in Tab 4</button>" +
+ "<script>" +
+ "document.body.addEventListener('keypress', (event)=>{ event.preventDefault(); });" +
+ "</script></body>";
+ let tab4 = await BrowserTestUtils.openNewForegroundTab(gBrowser, gPageURL4);
+
+ Services.focus.clearFocus(window);
+
+ focusedId = await performAccessKey(tab4.linkedBrowser, "y");
+ is(focusedId, "tab4button", "button accesskey in tab4 should be focused");
+
+ newButton.onfocus = () => {
+ // EventStateManager handles accesskey before dispatching keypress event
+ // into the DOM tree, therefore, chrome accesskey always wins focus from
+ // content. However, this is different from shortcut keys.
+ todo(false, "chromebutton shouldn't get focus during testing with tab4");
+ };
+
+ // Press the accesskey for the chrome element while the content document is focused.
+ focusedId = await performAccessKey(tab4.linkedBrowser, "z");
+ is(
+ focusedId,
+ "tab4body",
+ "button accesskey in tab4 should keep having focus"
+ );
+
+ newButton.onfocus = null;
+
+ gBrowser.removeTab(tab4);
+
+ newButton.remove();
+});
+
+function performAccessKey(browser, key) {
+ return new Promise(resolve => {
+ let removeFocus, removeKeyDown, removeKeyUp;
+ function callback(eventName, result) {
+ removeFocus();
+ removeKeyUp();
+ removeKeyDown();
+
+ SpecialPowers.spawn(browser, [], () => {
+ let oldFocusedElement = content._oldFocusedElement;
+ delete content._oldFocusedElement;
+ return oldFocusedElement.id;
+ }).then(oldFocus => resolve(oldFocus));
+ }
+
+ removeFocus = BrowserTestUtils.addContentEventListener(
+ browser,
+ "focus",
+ callback,
+ { capture: true },
+ event => {
+ if (!HTMLElement.isInstance(event.target)) {
+ return false; // ignore window and document focus events
+ }
+
+ event.target.ownerGlobal._sent = true;
+ let focusedElement = event.target.ownerGlobal.document.activeElement;
+ event.target.ownerGlobal._oldFocusedElement = focusedElement;
+ focusedElement.blur();
+ return true;
+ }
+ );
+
+ removeKeyDown = BrowserTestUtils.addContentEventListener(
+ browser,
+ "keydown",
+ () => {},
+ { capture: true },
+ event => {
+ event.target.ownerGlobal._sent = false;
+ return true;
+ }
+ );
+
+ removeKeyUp = BrowserTestUtils.addContentEventListener(
+ browser,
+ "keyup",
+ callback,
+ {},
+ event => {
+ if (!event.target.ownerGlobal._sent) {
+ event.target.ownerGlobal._sent = true;
+ let focusedElement = event.target.ownerGlobal.document.activeElement;
+ event.target.ownerGlobal._oldFocusedElement = focusedElement;
+ focusedElement.blur();
+ return true;
+ }
+
+ return false;
+ }
+ );
+
+ // Spawn an no-op content task to better ensure that the messages
+ // for adding the event listeners above get handled.
+ SpecialPowers.spawn(browser, [], () => {}).then(() => {
+ EventUtils.synthesizeKey(key, { altKey: true, shiftKey: true });
+ });
+ });
+}
+
+// This version is used when a chrome element is expected to be found for an accesskey.
+async function performAccessKeyForChrome(key, inChild) {
+ let waitFocusChangePromise = BrowserTestUtils.waitForEvent(
+ document,
+ "focus",
+ true
+ );
+ EventUtils.synthesizeKey(key, { altKey: true, shiftKey: true });
+ await waitFocusChangePromise;
+ return document.activeElement.id;
+}
diff --git a/browser/base/content/test/general/browser_addCertException.js b/browser/base/content/test/general/browser_addCertException.js
new file mode 100644
index 0000000000..d3d1ac1ce4
--- /dev/null
+++ b/browser/base/content/test/general/browser_addCertException.js
@@ -0,0 +1,77 @@
+/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim: set ts=8 sts=2 et sw=2 tw=80: */
+/* 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/. */
+
+// Test adding a certificate exception by attempting to browse to a site with
+// a bad certificate, being redirected to the internal about:certerror page,
+// using the button contained therein to load the certificate exception
+// dialog, using that to add an exception, and finally successfully visiting
+// the site, including showing the right identity box and control center icons.
+add_task(async function () {
+ await BrowserTestUtils.openNewForegroundTab(gBrowser);
+ await loadBadCertPage("https://expired.example.com");
+
+ let { gIdentityHandler } = gBrowser.ownerGlobal;
+ let promisePanelOpen = BrowserTestUtils.waitForEvent(
+ gBrowser.ownerGlobal,
+ "popupshown",
+ true,
+ event => event.target == gIdentityHandler._identityPopup
+ );
+ gIdentityHandler._identityIconBox.click();
+ await promisePanelOpen;
+
+ let promiseViewShown = BrowserTestUtils.waitForEvent(
+ gIdentityHandler._identityPopup,
+ "ViewShown"
+ );
+ document.getElementById("identity-popup-security-button").click();
+ await promiseViewShown;
+
+ is_element_visible(
+ document.getElementById("identity-icon"),
+ "Should see identity icon"
+ );
+ let identityIconImage = gBrowser.ownerGlobal
+ .getComputedStyle(document.getElementById("identity-icon"))
+ .getPropertyValue("list-style-image");
+ let securityViewBG = gBrowser.ownerGlobal
+ .getComputedStyle(
+ document
+ .getElementById("identity-popup-securityView")
+ .getElementsByClassName("identity-popup-security-connection")[0]
+ )
+ .getPropertyValue("list-style-image");
+ let securityContentBG = gBrowser.ownerGlobal
+ .getComputedStyle(
+ document
+ .getElementById("identity-popup-mainView")
+ .getElementsByClassName("identity-popup-security-connection")[0]
+ )
+ .getPropertyValue("list-style-image");
+ is(
+ identityIconImage,
+ 'url("chrome://global/skin/icons/security-warning.svg")',
+ "Using expected icon image in the identity block"
+ );
+ is(
+ securityViewBG,
+ 'url("chrome://global/skin/icons/security-warning.svg")',
+ "Using expected icon image in the Control Center main view"
+ );
+ is(
+ securityContentBG,
+ 'url("chrome://global/skin/icons/security-warning.svg")',
+ "Using expected icon image in the Control Center subview"
+ );
+
+ gIdentityHandler._identityPopup.hidePopup();
+
+ let certOverrideService = Cc[
+ "@mozilla.org/security/certoverride;1"
+ ].getService(Ci.nsICertOverrideService);
+ certOverrideService.clearValidityOverride("expired.example.com", -1, {});
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+});
diff --git a/browser/base/content/test/general/browser_alltabslistener.js b/browser/base/content/test/general/browser_alltabslistener.js
new file mode 100644
index 0000000000..0c9677306d
--- /dev/null
+++ b/browser/base/content/test/general/browser_alltabslistener.js
@@ -0,0 +1,331 @@
+const gCompleteState =
+ Ci.nsIWebProgressListener.STATE_STOP +
+ Ci.nsIWebProgressListener.STATE_IS_NETWORK;
+
+function getOriginalURL(request) {
+ return request && request.QueryInterface(Ci.nsIChannel).originalURI.spec;
+}
+
+var gFrontProgressListener = {
+ onProgressChange(
+ aWebProgress,
+ aRequest,
+ aCurSelfProgress,
+ aMaxSelfProgress,
+ aCurTotalProgress,
+ aMaxTotalProgress
+ ) {},
+
+ onStateChange(aWebProgress, aRequest, aStateFlags, aStatus) {
+ var url = getOriginalURL(aRequest);
+ if (url == "about:blank") {
+ return;
+ }
+ var state = "onStateChange";
+ info(
+ "FrontProgress (" + url + "): " + state + " 0x" + aStateFlags.toString(16)
+ );
+ assertCorrectBrowserAndEventOrderForFront(state);
+ },
+
+ onLocationChange(aWebProgress, aRequest, aLocationURI, aFlags) {
+ var url = getOriginalURL(aRequest);
+ if (url == "about:blank") {
+ return;
+ }
+ var state = "onLocationChange";
+ info("FrontProgress: " + state + " " + aLocationURI.spec);
+ assertCorrectBrowserAndEventOrderForFront(state);
+ },
+
+ onSecurityChange(aWebProgress, aRequest, aState) {
+ var url = getOriginalURL(aRequest);
+ if (url == "about:blank") {
+ return;
+ }
+ var state = "onSecurityChange";
+ info("FrontProgress (" + url + "): " + state + " 0x" + aState.toString(16));
+ assertCorrectBrowserAndEventOrderForFront(state);
+ },
+};
+
+function assertCorrectBrowserAndEventOrderForFront(aEventName) {
+ Assert.less(
+ gFrontNotificationsPos,
+ gFrontNotifications.length,
+ "Got an expected notification for the front notifications listener"
+ );
+ is(
+ aEventName,
+ gFrontNotifications[gFrontNotificationsPos],
+ "Got a notification for the front notifications listener"
+ );
+ gFrontNotificationsPos++;
+}
+
+var gAllProgressListener = {
+ onStateChange(aBrowser, aWebProgress, aRequest, aStateFlags, aStatus) {
+ var url = getOriginalURL(aRequest);
+ if (url == "about:blank") {
+ // ignore initial about blank
+ return;
+ }
+ var state = "onStateChange";
+ info(
+ "AllProgress (" + url + "): " + state + " 0x" + aStateFlags.toString(16)
+ );
+ assertCorrectBrowserAndEventOrderForAll(state, aBrowser);
+ assertReceivedFlags(
+ state,
+ gAllNotifications[gAllNotificationsPos],
+ aStateFlags
+ );
+ gAllNotificationsPos++;
+
+ if ((aStateFlags & gCompleteState) == gCompleteState) {
+ is(
+ gAllNotificationsPos,
+ gAllNotifications.length,
+ "Saw the expected number of notifications"
+ );
+ is(
+ gFrontNotificationsPos,
+ gFrontNotifications.length,
+ "Saw the expected number of frontnotifications"
+ );
+ executeSoon(gNextTest);
+ }
+ },
+
+ onLocationChange(aBrowser, aWebProgress, aRequest, aLocationURI, aFlags) {
+ var url = getOriginalURL(aRequest);
+ if (url == "about:blank") {
+ // ignore initial about blank
+ return;
+ }
+ var state = "onLocationChange";
+ info("AllProgress: " + state + " " + aLocationURI.spec);
+ assertCorrectBrowserAndEventOrderForAll(state, aBrowser);
+ assertReceivedFlags(
+ "onLocationChange",
+ gAllNotifications[gAllNotificationsPos],
+ aFlags
+ );
+ gAllNotificationsPos++;
+ },
+
+ onSecurityChange(aBrowser, aWebProgress, aRequest, aState) {
+ var url = getOriginalURL(aRequest);
+ if (url == "about:blank") {
+ // ignore initial about blank
+ return;
+ }
+ var state = "onSecurityChange";
+ info("AllProgress (" + url + "): " + state + " 0x" + aState.toString(16));
+ assertCorrectBrowserAndEventOrderForAll(state, aBrowser);
+ is(
+ state,
+ gAllNotifications[gAllNotificationsPos],
+ "Got a notification for the all notifications listener"
+ );
+ gAllNotificationsPos++;
+ },
+};
+
+function assertCorrectBrowserAndEventOrderForAll(aState, aBrowser) {
+ ok(
+ aBrowser == gTestBrowser,
+ aState + " notification came from the correct browser"
+ );
+ Assert.less(
+ gAllNotificationsPos,
+ gAllNotifications.length,
+ "Got an expected notification for the all notifications listener"
+ );
+}
+
+function assertReceivedFlags(aState, aObjOrEvent, aFlags) {
+ if (aObjOrEvent !== null && typeof aObjOrEvent === "object") {
+ is(
+ aState,
+ aObjOrEvent.state,
+ "Got a notification for the all notifications listener"
+ );
+ is(aFlags, aFlags & aObjOrEvent.flags, `Got correct flags for ${aState}`);
+ } else {
+ is(
+ aState,
+ aObjOrEvent,
+ "Got a notification for the all notifications listener"
+ );
+ }
+}
+
+var gFrontNotifications,
+ gAllNotifications,
+ gFrontNotificationsPos,
+ gAllNotificationsPos;
+var gBackgroundTab,
+ gForegroundTab,
+ gBackgroundBrowser,
+ gForegroundBrowser,
+ gTestBrowser;
+var gTestPage =
+ "/browser/browser/base/content/test/general/alltabslistener.html";
+const kBasePage =
+ "http://mochi.test:8888/browser/browser/base/content/test/general/dummy_page.html";
+var gNextTest;
+
+async function test() {
+ waitForExplicitFinish();
+
+ gBackgroundTab = BrowserTestUtils.addTab(gBrowser);
+ gForegroundTab = BrowserTestUtils.addTab(gBrowser);
+ gBackgroundBrowser = gBrowser.getBrowserForTab(gBackgroundTab);
+ gForegroundBrowser = gBrowser.getBrowserForTab(gForegroundTab);
+ gBrowser.selectedTab = gForegroundTab;
+
+ gAllNotifications = [
+ "onStateChange",
+ "onLocationChange",
+ "onSecurityChange",
+ "onStateChange",
+ ];
+
+ // We must wait until a page has completed loading before
+ // starting tests or we get notifications from that
+ let promises = [
+ BrowserTestUtils.browserStopped(gBackgroundBrowser, kBasePage),
+ BrowserTestUtils.browserStopped(gForegroundBrowser, kBasePage),
+ ];
+ BrowserTestUtils.loadURIString(gBackgroundBrowser, kBasePage);
+ BrowserTestUtils.loadURIString(gForegroundBrowser, kBasePage);
+ await Promise.all(promises);
+ // If we process switched, the tabbrowser may still be processing the state_stop
+ // notification here because of how microtasks work. Ensure that that has
+ // happened before starting to test (which would add listeners to the tabbrowser
+ // which would get confused by being called about kBasePage loading).
+ await new Promise(executeSoon);
+ startTest1();
+}
+
+function runTest(browser, url, next) {
+ gFrontNotificationsPos = 0;
+ gAllNotificationsPos = 0;
+ gNextTest = next;
+ gTestBrowser = browser;
+ BrowserTestUtils.loadURIString(browser, url);
+}
+
+function startTest1() {
+ info("\nTest 1");
+ gBrowser.addProgressListener(gFrontProgressListener);
+ gBrowser.addTabsProgressListener(gAllProgressListener);
+
+ gFrontNotifications = gAllNotifications;
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ runTest(gForegroundBrowser, "http://example.org" + gTestPage, startTest2);
+}
+
+function startTest2() {
+ info("\nTest 2");
+ gFrontNotifications = gAllNotifications;
+ runTest(gForegroundBrowser, "https://example.com" + gTestPage, startTest3);
+}
+
+function startTest3() {
+ info("\nTest 3");
+ gFrontNotifications = [];
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ runTest(gBackgroundBrowser, "http://example.org" + gTestPage, startTest4);
+}
+
+function startTest4() {
+ info("\nTest 4");
+ gFrontNotifications = [];
+ runTest(gBackgroundBrowser, "https://example.com" + gTestPage, startTest5);
+}
+
+function startTest5() {
+ info("\nTest 5");
+ // Switch the foreground browser
+ [gForegroundBrowser, gBackgroundBrowser] = [
+ gBackgroundBrowser,
+ gForegroundBrowser,
+ ];
+ [gForegroundTab, gBackgroundTab] = [gBackgroundTab, gForegroundTab];
+ // Avoid the onLocationChange this will fire
+ gBrowser.removeProgressListener(gFrontProgressListener);
+ gBrowser.selectedTab = gForegroundTab;
+ gBrowser.addProgressListener(gFrontProgressListener);
+
+ gFrontNotifications = gAllNotifications;
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ runTest(gForegroundBrowser, "http://example.org" + gTestPage, startTest6);
+}
+
+function startTest6() {
+ info("\nTest 6");
+ gFrontNotifications = [];
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ runTest(gBackgroundBrowser, "http://example.org" + gTestPage, startTest7);
+}
+
+// Navigate from remote to non-remote
+function startTest7() {
+ info("\nTest 7");
+ gFrontNotifications = [];
+ gAllNotifications = [
+ "onStateChange",
+ "onLocationChange",
+ "onSecurityChange",
+ {
+ state: "onLocationChange",
+ flags: Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT,
+ }, // dummy onLocationChange event
+ "onStateChange",
+ ];
+ runTest(gBackgroundBrowser, "about:preferences", startTest8);
+}
+
+// Navigate from non-remote to non-remote
+function startTest8() {
+ info("\nTest 8");
+ gFrontNotifications = [];
+ gAllNotifications = [
+ "onStateChange",
+ {
+ state: "onStateChange",
+ flags:
+ Ci.nsIWebProgressListener.STATE_IS_REDIRECTED_DOCUMENT |
+ Ci.nsIWebProgressListener.STATE_IS_REQUEST |
+ Ci.nsIWebProgressListener.STATE_START,
+ },
+ "onLocationChange",
+ "onSecurityChange",
+ "onStateChange",
+ ];
+ runTest(gBackgroundBrowser, "about:config", startTest9);
+}
+
+// Navigate from non-remote to remote
+function startTest9() {
+ info("\nTest 9");
+ gFrontNotifications = [];
+ gAllNotifications = [
+ "onStateChange",
+ "onLocationChange",
+ "onSecurityChange",
+ "onStateChange",
+ ];
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ runTest(gBackgroundBrowser, "http://example.org" + gTestPage, finishTest);
+}
+
+function finishTest() {
+ gBrowser.removeProgressListener(gFrontProgressListener);
+ gBrowser.removeTabsProgressListener(gAllProgressListener);
+ gBrowser.removeTab(gBackgroundTab);
+ gBrowser.removeTab(gForegroundTab);
+ finish();
+}
diff --git a/browser/base/content/test/general/browser_backButtonFitts.js b/browser/base/content/test/general/browser_backButtonFitts.js
new file mode 100644
index 0000000000..8ef3006a2c
--- /dev/null
+++ b/browser/base/content/test/general/browser_backButtonFitts.js
@@ -0,0 +1,40 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+add_task(async function () {
+ let firstLocation =
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.org/browser/browser/base/content/test/general/dummy_page.html";
+ await BrowserTestUtils.openNewForegroundTab(gBrowser, firstLocation);
+
+ await ContentTask.spawn(gBrowser.selectedBrowser, {}, async function () {
+ // Push the state before maximizing the window and clicking below.
+ content.history.pushState("page2", "page2", "page2");
+ });
+
+ window.maximize();
+
+ // Find where the nav-bar is vertically.
+ var navBar = document.getElementById("nav-bar");
+ var boundingRect = navBar.getBoundingClientRect();
+ var yPixel = boundingRect.top + Math.floor(boundingRect.height / 2);
+ var xPixel = 0; // Use the first pixel of the screen since it is maximized.
+
+ let popStatePromise = BrowserTestUtils.waitForContentEvent(
+ gBrowser.selectedBrowser,
+ "popstate",
+ true
+ );
+ EventUtils.synthesizeMouseAtPoint(xPixel, yPixel, {}, window);
+ await popStatePromise;
+
+ is(
+ gBrowser.selectedBrowser.currentURI.spec,
+ firstLocation,
+ "Clicking the first pixel should have navigated back."
+ );
+ window.restore();
+
+ gBrowser.removeCurrentTab();
+});
diff --git a/browser/base/content/test/general/browser_beforeunload_duplicate_dialogs.js b/browser/base/content/test/general/browser_beforeunload_duplicate_dialogs.js
new file mode 100644
index 0000000000..8a77f01ce4
--- /dev/null
+++ b/browser/base/content/test/general/browser_beforeunload_duplicate_dialogs.js
@@ -0,0 +1,114 @@
+const TEST_PAGE =
+ "http://mochi.test:8888/browser/browser/base/content/test/general/file_double_close_tab.html";
+
+const CONTENT_PROMPT_SUBDIALOG = Services.prefs.getBoolPref(
+ "prompts.contentPromptSubDialog",
+ false
+);
+
+var expectingDialog = false;
+var wantToClose = true;
+var resolveDialogPromise;
+
+function onTabModalDialogLoaded(node) {
+ ok(
+ !CONTENT_PROMPT_SUBDIALOG,
+ "Should not be using content prompt subdialogs."
+ );
+ ok(expectingDialog, "Should be expecting this dialog.");
+ expectingDialog = false;
+ if (wantToClose) {
+ // This accepts the dialog, closing it
+ node.querySelector(".tabmodalprompt-button0").click();
+ } else {
+ // This keeps the page open
+ node.querySelector(".tabmodalprompt-button1").click();
+ }
+ if (resolveDialogPromise) {
+ resolveDialogPromise();
+ }
+}
+
+function onCommonDialogLoaded(promptWindow) {
+ ok(CONTENT_PROMPT_SUBDIALOG, "Should be using content prompt subdialogs.");
+ ok(expectingDialog, "Should be expecting this dialog.");
+ expectingDialog = false;
+ let dialog = promptWindow.Dialog;
+ if (wantToClose) {
+ // This accepts the dialog, closing it.
+ dialog.ui.button0.click();
+ } else {
+ // This keeps the page open
+ dialog.ui.button1.click();
+ }
+ if (resolveDialogPromise) {
+ resolveDialogPromise();
+ }
+}
+
+SpecialPowers.pushPrefEnv({
+ set: [["dom.require_user_interaction_for_beforeunload", false]],
+});
+
+// Listen for the dialog being created
+Services.obs.addObserver(onTabModalDialogLoaded, "tabmodal-dialog-loaded");
+Services.obs.addObserver(onCommonDialogLoaded, "common-dialog-loaded");
+registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("browser.tabs.warnOnClose");
+ Services.obs.removeObserver(onTabModalDialogLoaded, "tabmodal-dialog-loaded");
+ Services.obs.removeObserver(onCommonDialogLoaded, "common-dialog-loaded");
+});
+
+add_task(async function closeLastTabInWindow() {
+ let newWin = await promiseOpenAndLoadWindow({}, true);
+ let firstTab = newWin.gBrowser.selectedTab;
+ await promiseTabLoadEvent(firstTab, TEST_PAGE);
+ let windowClosedPromise = BrowserTestUtils.domWindowClosed(newWin);
+ expectingDialog = true;
+ // close tab:
+ firstTab.closeButton.click();
+ await windowClosedPromise;
+ ok(!expectingDialog, "There should have been a dialog.");
+ ok(newWin.closed, "Window should be closed.");
+});
+
+add_task(async function closeWindowWithMultipleTabsIncludingOneBeforeUnload() {
+ Services.prefs.setBoolPref("browser.tabs.warnOnClose", false);
+ let newWin = await promiseOpenAndLoadWindow({}, true);
+ let firstTab = newWin.gBrowser.selectedTab;
+ await promiseTabLoadEvent(firstTab, TEST_PAGE);
+ await promiseTabLoadEvent(
+ BrowserTestUtils.addTab(newWin.gBrowser),
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com/"
+ );
+ let windowClosedPromise = BrowserTestUtils.domWindowClosed(newWin);
+ expectingDialog = true;
+ newWin.BrowserTryToCloseWindow();
+ await windowClosedPromise;
+ ok(!expectingDialog, "There should have been a dialog.");
+ ok(newWin.closed, "Window should be closed.");
+ Services.prefs.clearUserPref("browser.tabs.warnOnClose");
+});
+
+add_task(async function closeWindoWithSingleTabTwice() {
+ let newWin = await promiseOpenAndLoadWindow({}, true);
+ let firstTab = newWin.gBrowser.selectedTab;
+ await promiseTabLoadEvent(firstTab, TEST_PAGE);
+ let windowClosedPromise = BrowserTestUtils.domWindowClosed(newWin);
+ expectingDialog = true;
+ wantToClose = false;
+ let firstDialogShownPromise = new Promise((resolve, reject) => {
+ resolveDialogPromise = resolve;
+ });
+ firstTab.closeButton.click();
+ await firstDialogShownPromise;
+ info("Got initial dialog, now trying again");
+ expectingDialog = true;
+ wantToClose = true;
+ resolveDialogPromise = null;
+ firstTab.closeButton.click();
+ await windowClosedPromise;
+ ok(!expectingDialog, "There should have been a dialog.");
+ ok(newWin.closed, "Window should be closed.");
+});
diff --git a/browser/base/content/test/general/browser_bug1261299.js b/browser/base/content/test/general/browser_bug1261299.js
new file mode 100644
index 0000000000..47b82a5da0
--- /dev/null
+++ b/browser/base/content/test/general/browser_bug1261299.js
@@ -0,0 +1,112 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* 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/. */
+
+/**
+ * Tests for Bug 1261299
+ * Test that the service menu code path is called properly and the
+ * current selection (transferable) is cached properly on the parent process.
+ */
+
+add_task(async function test_content_and_chrome_selection() {
+ let testPage =
+ "data:text/html," +
+ '<textarea id="textarea">Write something here</textarea>';
+ let DOMWindowUtils = EventUtils._getDOMWindowUtils(window);
+ let selectedText;
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, testPage);
+ await BrowserTestUtils.synthesizeMouse(
+ "#textarea",
+ 0,
+ 0,
+ {},
+ gBrowser.selectedBrowser
+ );
+ await BrowserTestUtils.synthesizeKey(
+ "KEY_ArrowRight",
+ { shiftKey: true, ctrlKey: true },
+ gBrowser.selectedBrowser
+ );
+ selectedText = DOMWindowUtils.GetSelectionAsPlaintext();
+ is(
+ selectedText,
+ "Write something here",
+ "The macOS services got the selected content text"
+ );
+ gURLBar.value = "test.mozilla.org";
+ await gURLBar.editor.selectAll();
+ selectedText = DOMWindowUtils.GetSelectionAsPlaintext();
+ is(
+ selectedText,
+ "test.mozilla.org",
+ "The macOS services got the selected chrome text"
+ );
+
+ BrowserTestUtils.removeTab(tab);
+});
+
+// Test switching active selection.
+// Each tab has a content selection and when you switch to that tab, its selection becomes
+// active aka the current selection.
+// Expect: The active selection is what is being sent to OSX service menu.
+
+add_task(async function test_active_selection_switches_properly() {
+ let testPage1 =
+ // eslint-disable-next-line no-useless-concat
+ "data:text/html," +
+ '<textarea id="textarea">Write something here</textarea>';
+ let testPage2 =
+ // eslint-disable-next-line no-useless-concat
+ "data:text/html," + '<textarea id="textarea">Nothing available</textarea>';
+ let DOMWindowUtils = EventUtils._getDOMWindowUtils(window);
+ let selectedText;
+
+ let tab1 = await BrowserTestUtils.openNewForegroundTab(gBrowser, testPage1);
+ await BrowserTestUtils.synthesizeMouse(
+ "#textarea",
+ 0,
+ 0,
+ {},
+ gBrowser.selectedBrowser
+ );
+ await BrowserTestUtils.synthesizeKey(
+ "KEY_ArrowRight",
+ { shiftKey: true, ctrlKey: true },
+ gBrowser.selectedBrowser
+ );
+
+ let tab2 = await BrowserTestUtils.openNewForegroundTab(gBrowser, testPage2);
+ await BrowserTestUtils.synthesizeMouse(
+ "#textarea",
+ 0,
+ 0,
+ {},
+ gBrowser.selectedBrowser
+ );
+ await BrowserTestUtils.synthesizeKey(
+ "KEY_ArrowRight",
+ { shiftKey: true, ctrlKey: true },
+ gBrowser.selectedBrowser
+ );
+
+ await BrowserTestUtils.switchTab(gBrowser, tab1);
+ selectedText = DOMWindowUtils.GetSelectionAsPlaintext();
+ is(
+ selectedText,
+ "Write something here",
+ "The macOS services got the selected content text"
+ );
+
+ await BrowserTestUtils.switchTab(gBrowser, tab2);
+ selectedText = DOMWindowUtils.GetSelectionAsPlaintext();
+ is(
+ selectedText,
+ "Nothing available",
+ "The macOS services got the selected content text"
+ );
+
+ BrowserTestUtils.removeTab(tab1);
+ BrowserTestUtils.removeTab(tab2);
+});
diff --git a/browser/base/content/test/general/browser_bug1297539.js b/browser/base/content/test/general/browser_bug1297539.js
new file mode 100644
index 0000000000..7572d85eaf
--- /dev/null
+++ b/browser/base/content/test/general/browser_bug1297539.js
@@ -0,0 +1,122 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* 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/. */
+
+/**
+ * Test for Bug 1297539
+ * Test that the content event "pasteTransferable"
+ * (mozilla::EventMessage::eContentCommandPasteTransferable)
+ * is handled correctly for plain text and html in the remote case.
+ *
+ * Original test test_bug525389.html for command content event
+ * "pasteTransferable" runs only in the content process.
+ * This doesn't test the remote case.
+ *
+ */
+
+"use strict";
+
+function getLoadContext() {
+ return window.docShell.QueryInterface(Ci.nsILoadContext);
+}
+
+function getTransferableFromClipboard(asHTML) {
+ let trans = Cc["@mozilla.org/widget/transferable;1"].createInstance(
+ Ci.nsITransferable
+ );
+ trans.init(getLoadContext());
+ if (asHTML) {
+ trans.addDataFlavor("text/html");
+ } else {
+ trans.addDataFlavor("text/plain");
+ }
+ Services.clipboard.getData(trans, Ci.nsIClipboard.kGlobalClipboard);
+ return trans;
+}
+
+async function cutCurrentSelection(elementQueryString, property, browser) {
+ // Cut the current selection.
+ await BrowserTestUtils.synthesizeKey("x", { accelKey: true }, browser);
+
+ // The editor should be empty after cut.
+ await SpecialPowers.spawn(
+ browser,
+ [[elementQueryString, property]],
+ async function ([contentElementQueryString, contentProperty]) {
+ let element = content.document.querySelector(contentElementQueryString);
+ is(
+ element[contentProperty],
+ "",
+ `${contentElementQueryString} should be empty after cut (superkey + x)`
+ );
+ }
+ );
+}
+
+// Test that you are able to pasteTransferable for plain text
+// which is handled by TextEditor::PasteTransferable to paste into the editor.
+add_task(async function test_paste_transferable_plain_text() {
+ let testPage =
+ "data:text/html," +
+ '<textarea id="textarea">Write something here</textarea>';
+
+ await BrowserTestUtils.withNewTab(testPage, async function (browser) {
+ // Select all the content in your editor element.
+ await BrowserTestUtils.synthesizeMouse("#textarea", 0, 0, {}, browser);
+ await BrowserTestUtils.synthesizeKey("a", { accelKey: true }, browser);
+
+ await cutCurrentSelection("#textarea", "value", browser);
+
+ let trans = getTransferableFromClipboard(false);
+ let DOMWindowUtils = EventUtils._getDOMWindowUtils(window);
+ DOMWindowUtils.sendContentCommandEvent("pasteTransferable", trans);
+
+ await SpecialPowers.spawn(browser, [], async function () {
+ let textArea = content.document.querySelector("#textarea");
+ is(
+ textArea.value,
+ "Write something here",
+ "Send content command pasteTransferable successful"
+ );
+ });
+ });
+});
+
+// Test that you are able to pasteTransferable for html
+// which is handled by HTMLEditor::PasteTransferable to paste into the editor.
+//
+// On Linux,
+// BrowserTestUtils.synthesizeKey("a", {accelKey: true}, browser);
+// doesn't seem to trigger for contenteditable which is why we use
+// Selection to select the contenteditable contents.
+add_task(async function test_paste_transferable_html() {
+ let testPage =
+ "data:text/html," +
+ '<div contenteditable="true"><b>Bold Text</b><i>italics</i></div>';
+
+ await BrowserTestUtils.withNewTab(testPage, async function (browser) {
+ // Select all the content in your editor element.
+ await BrowserTestUtils.synthesizeMouse("div", 0, 0, {}, browser);
+ await SpecialPowers.spawn(browser, [], async function () {
+ let element = content.document.querySelector("div");
+ let selection = content.window.getSelection();
+ selection.selectAllChildren(element);
+ });
+
+ await cutCurrentSelection("div", "textContent", browser);
+
+ let trans = getTransferableFromClipboard(true);
+ let DOMWindowUtils = EventUtils._getDOMWindowUtils(window);
+ DOMWindowUtils.sendContentCommandEvent("pasteTransferable", trans);
+
+ await SpecialPowers.spawn(browser, [], async function () {
+ let textArea = content.document.querySelector("div");
+ is(
+ textArea.innerHTML,
+ "<b>Bold Text</b><i>italics</i>",
+ "Send content command pasteTransferable successful"
+ );
+ });
+ });
+});
diff --git a/browser/base/content/test/general/browser_bug1299667.js b/browser/base/content/test/general/browser_bug1299667.js
new file mode 100644
index 0000000000..d281652c44
--- /dev/null
+++ b/browser/base/content/test/general/browser_bug1299667.js
@@ -0,0 +1,70 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+add_task(async function () {
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ await BrowserTestUtils.openNewForegroundTab(gBrowser, "http://example.com");
+
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () {
+ content.history.pushState({}, "2", "2.html");
+ });
+
+ await TestUtils.topicObserved("sessionstore-state-write-complete");
+
+ // Wait for the session data to be flushed before continuing the test
+ await new Promise(resolve =>
+ SessionStore.getSessionHistory(gBrowser.selectedTab, resolve)
+ );
+
+ let backButton = document.getElementById("back-button");
+ let contextMenu = document.getElementById("backForwardMenu");
+
+ info("waiting for the history menu to open");
+
+ let popupShownPromise = BrowserTestUtils.waitForEvent(
+ contextMenu,
+ "popupshown"
+ );
+ EventUtils.synthesizeMouseAtCenter(backButton, {
+ type: "contextmenu",
+ button: 2,
+ });
+ let event = await popupShownPromise;
+
+ ok(true, "history menu opened");
+
+ // Wait for the session data to be flushed before continuing the test
+ await new Promise(resolve =>
+ SessionStore.getSessionHistory(gBrowser.selectedTab, resolve)
+ );
+
+ is(event.target.children.length, 2, "Two history items");
+
+ let node = event.target.firstElementChild;
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ is(node.getAttribute("uri"), "http://example.com/2.html", "first item uri");
+ is(node.getAttribute("index"), "1", "first item index");
+ is(node.getAttribute("historyindex"), "0", "first item historyindex");
+
+ node = event.target.lastElementChild;
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ is(node.getAttribute("uri"), "http://example.com/", "second item uri");
+ is(node.getAttribute("index"), "0", "second item index");
+ is(node.getAttribute("historyindex"), "-1", "second item historyindex");
+
+ let popupHiddenPromise = BrowserTestUtils.waitForEvent(
+ contextMenu,
+ "popuphidden"
+ );
+ event.target.hidePopup();
+ await popupHiddenPromise;
+ info("Hidden popup");
+
+ let onClose = BrowserTestUtils.waitForEvent(
+ gBrowser.tabContainer,
+ "TabClose"
+ );
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ await onClose;
+ info("Tab closed");
+});
diff --git a/browser/base/content/test/general/browser_bug321000.js b/browser/base/content/test/general/browser_bug321000.js
new file mode 100644
index 0000000000..78ab74e543
--- /dev/null
+++ b/browser/base/content/test/general/browser_bug321000.js
@@ -0,0 +1,91 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+ * vim: sw=2 ts=2 et lcs=trail\:.,tab\:>~ :
+ * 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/. */
+
+const kTestString = " hello hello \n world\nworld ";
+
+var gTests = [
+ {
+ desc: "Urlbar strips newlines and surrounding whitespace",
+ element: gURLBar,
+ expected: kTestString.replace(/\s*\n\s*/g, ""),
+ },
+
+ {
+ desc: "Searchbar replaces newlines with spaces",
+ element: document.getElementById("searchbar"),
+ expected: kTestString.replace(/\n/g, " "),
+ },
+];
+
+// Test for bug 23485 and bug 321000.
+// Urlbar should strip newlines,
+// search bar should replace newlines with spaces.
+function test() {
+ waitForExplicitFinish();
+
+ let cbHelper = Cc["@mozilla.org/widget/clipboardhelper;1"].getService(
+ Ci.nsIClipboardHelper
+ );
+
+ // Put a multi-line string in the clipboard.
+ // Setting the clipboard value is an async OS operation, so we need to poll
+ // the clipboard for valid data before going on.
+ waitForClipboard(
+ kTestString,
+ function () {
+ cbHelper.copyString(kTestString);
+ },
+ next_test,
+ finish
+ );
+}
+
+function next_test() {
+ if (gTests.length) {
+ test_paste(gTests.shift());
+ } else {
+ finish();
+ }
+}
+
+function test_paste(aCurrentTest) {
+ var element = aCurrentTest.element;
+
+ // Register input listener.
+ var inputListener = {
+ test: aCurrentTest,
+ handleEvent(event) {
+ element.removeEventListener(event.type, this);
+
+ is(element.value, this.test.expected, this.test.desc);
+
+ // Clear the field and go to next test.
+ element.value = "";
+ setTimeout(next_test, 0);
+ },
+ };
+ element.addEventListener("input", inputListener);
+
+ // Focus the window.
+ window.focus();
+ gBrowser.selectedBrowser.focus();
+
+ // Focus the element and wait for focus event.
+ info("About to focus " + element.id);
+ element.addEventListener(
+ "focus",
+ function () {
+ executeSoon(function () {
+ // Pasting is async because the Accel+V codepath ends up going through
+ // nsDocumentViewer::FireClipboardEvent.
+ info("Pasting into " + element.id);
+ EventUtils.synthesizeKey("v", { accelKey: true });
+ });
+ },
+ { once: true }
+ );
+ element.focus();
+}
diff --git a/browser/base/content/test/general/browser_bug356571.js b/browser/base/content/test/general/browser_bug356571.js
new file mode 100644
index 0000000000..185d59d8fd
--- /dev/null
+++ b/browser/base/content/test/general/browser_bug356571.js
@@ -0,0 +1,100 @@
+// Bug 356571 - loadOneOrMoreURIs gives up if one of the URLs has an unknown protocol
+
+var Cm = Components.manager;
+
+// Set to true when docShell alerts for unknown protocol error
+var didFail = false;
+
+// Override Alert to avoid blocking the test due to unknown protocol error
+const kPromptServiceUUID = "{6cc9c9fe-bc0b-432b-a410-253ef8bcc699}";
+const kPromptServiceContractID = "@mozilla.org/prompter;1";
+
+// Save original prompt service factory
+const kPromptServiceFactory = Cm.getClassObject(
+ Cc[kPromptServiceContractID],
+ Ci.nsIFactory
+);
+
+var fakePromptServiceFactory = {
+ createInstance(aIid) {
+ return promptService.QueryInterface(aIid);
+ },
+};
+
+var promptService = {
+ QueryInterface: ChromeUtils.generateQI(["nsIPromptService"]),
+ alert() {
+ didFail = true;
+ },
+};
+
+/* FIXME
+Cm.QueryInterface(Ci.nsIComponentRegistrar)
+ .registerFactory(Components.ID(kPromptServiceUUID), "Prompt Service",
+ kPromptServiceContractID, fakePromptServiceFactory);
+*/
+
+const kCompleteState =
+ Ci.nsIWebProgressListener.STATE_STOP +
+ Ci.nsIWebProgressListener.STATE_IS_NETWORK;
+
+const kDummyPage =
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.org/browser/browser/base/content/test/general/dummy_page.html";
+const kURIs = ["bad://www.mozilla.org/", kDummyPage, kDummyPage];
+
+var gProgressListener = {
+ _runCount: 0,
+ onStateChange(aBrowser, aWebProgress, aRequest, aStateFlags, aStatus) {
+ if ((aStateFlags & kCompleteState) == kCompleteState) {
+ if (++this._runCount != kURIs.length) {
+ return;
+ }
+ // Check we failed on unknown protocol (received an alert from docShell)
+ ok(didFail, "Correctly failed on unknown protocol");
+ // Check we opened all tabs
+ ok(
+ gBrowser.tabs.length == kURIs.length,
+ "Correctly opened all expected tabs"
+ );
+ finishTest();
+ }
+ },
+};
+
+function test() {
+ todo(false, "temp. disabled");
+ /* FIXME */
+ /*
+ waitForExplicitFinish();
+ // Wait for all tabs to finish loading
+ gBrowser.addTabsProgressListener(gProgressListener);
+ loadOneOrMoreURIs(kURIs.join("|"));
+ */
+}
+
+function finishTest() {
+ // Unregister the factory so we do not leak
+ Cm.QueryInterface(Ci.nsIComponentRegistrar).unregisterFactory(
+ Components.ID(kPromptServiceUUID),
+ fakePromptServiceFactory
+ );
+
+ // Restore the original factory
+ Cm.QueryInterface(Ci.nsIComponentRegistrar).registerFactory(
+ Components.ID(kPromptServiceUUID),
+ "Prompt Service",
+ kPromptServiceContractID,
+ kPromptServiceFactory
+ );
+
+ // Remove the listener
+ gBrowser.removeTabsProgressListener(gProgressListener);
+
+ // Close opened tabs
+ for (var i = gBrowser.tabs.length - 1; i > 0; i--) {
+ gBrowser.removeTab(gBrowser.tabs[i]);
+ }
+
+ finish();
+}
diff --git a/browser/base/content/test/general/browser_bug380960.js b/browser/base/content/test/general/browser_bug380960.js
new file mode 100644
index 0000000000..5571d8f08e
--- /dev/null
+++ b/browser/base/content/test/general/browser_bug380960.js
@@ -0,0 +1,18 @@
+function test() {
+ var tab = BrowserTestUtils.addTab(gBrowser, "about:blank", {
+ skipAnimation: true,
+ });
+ gBrowser.removeTab(tab);
+ is(tab.parentNode, null, "tab removed immediately");
+
+ tab = BrowserTestUtils.addTab(gBrowser, "about:blank", {
+ skipAnimation: true,
+ });
+ gBrowser.removeTab(tab, { animate: true });
+ gBrowser.removeTab(tab);
+ is(
+ tab.parentNode,
+ null,
+ "tab removed immediately when calling removeTab again after the animation was kicked off"
+ );
+}
diff --git a/browser/base/content/test/general/browser_bug406216.js b/browser/base/content/test/general/browser_bug406216.js
new file mode 100644
index 0000000000..bee262e4f8
--- /dev/null
+++ b/browser/base/content/test/general/browser_bug406216.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/. */
+
+/*
+ * "TabClose" event is possibly used for closing related tabs of the current.
+ * "removeTab" method should work correctly even if the number of tabs are
+ * changed while "TabClose" event.
+ */
+
+var count = 0;
+const URIS = [
+ "about:config",
+ "about:plugins",
+ "about:buildconfig",
+ "data:text/html,<title>OK</title>",
+];
+
+function test() {
+ waitForExplicitFinish();
+ URIS.forEach(addTab);
+}
+
+function addTab(aURI, aIndex) {
+ var tab = BrowserTestUtils.addTab(gBrowser, aURI);
+ if (aIndex == 0) {
+ gBrowser.removeTab(gBrowser.tabs[0], { skipPermitUnload: true });
+ }
+
+ BrowserTestUtils.browserLoaded(tab.linkedBrowser).then(() => {
+ if (++count == URIS.length) {
+ executeSoon(doTabsTest);
+ }
+ });
+}
+
+function doTabsTest() {
+ is(gBrowser.tabs.length, URIS.length, "Correctly opened all expected tabs");
+
+ // sample of "close related tabs" feature
+ gBrowser.tabContainer.addEventListener(
+ "TabClose",
+ function (event) {
+ var closedTab = event.originalTarget;
+ var scheme = closedTab.linkedBrowser.currentURI.scheme;
+ Array.from(gBrowser.tabs).forEach(function (aTab) {
+ if (
+ aTab != closedTab &&
+ aTab.linkedBrowser.currentURI.scheme == scheme
+ ) {
+ gBrowser.removeTab(aTab, { skipPermitUnload: true });
+ }
+ });
+ },
+ { capture: true, once: true }
+ );
+
+ gBrowser.removeTab(gBrowser.tabs[0], { skipPermitUnload: true });
+ is(gBrowser.tabs.length, 1, "Related tabs are not closed unexpectedly");
+
+ BrowserTestUtils.addTab(gBrowser, "about:blank");
+ gBrowser.removeTab(gBrowser.tabs[0], { skipPermitUnload: true });
+ finish();
+}
diff --git a/browser/base/content/test/general/browser_bug417483.js b/browser/base/content/test/general/browser_bug417483.js
new file mode 100644
index 0000000000..6c8619b532
--- /dev/null
+++ b/browser/base/content/test/general/browser_bug417483.js
@@ -0,0 +1,50 @@
+add_task(async function () {
+ let loadedPromise = BrowserTestUtils.browserLoaded(
+ gBrowser.selectedBrowser,
+ true
+ );
+ const htmlContent =
+ "data:text/html, <iframe src='data:text/html,text text'></iframe>";
+ BrowserTestUtils.loadURIString(gBrowser, htmlContent);
+ await loadedPromise;
+
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function (arg) {
+ let frame = content.frames[0];
+ let sel = frame.getSelection();
+ let range = frame.document.createRange();
+ let tn = frame.document.body.childNodes[0];
+ range.setStart(tn, 4);
+ range.setEnd(tn, 5);
+ sel.addRange(range);
+ frame.focus();
+ });
+
+ let contentAreaContextMenu = document.getElementById(
+ "contentAreaContextMenu"
+ );
+
+ let popupShownPromise = BrowserTestUtils.waitForEvent(
+ contentAreaContextMenu,
+ "popupshown"
+ );
+ await BrowserTestUtils.synthesizeMouse(
+ "frame",
+ 5,
+ 5,
+ { type: "contextmenu", button: 2 },
+ gBrowser.selectedBrowser
+ );
+ await popupShownPromise;
+
+ ok(
+ document.getElementById("frame-sep").hidden,
+ "'frame-sep' should be hidden if the selection contains only spaces"
+ );
+
+ let popupHiddenPromise = BrowserTestUtils.waitForEvent(
+ contentAreaContextMenu,
+ "popuphidden"
+ );
+ contentAreaContextMenu.hidePopup();
+ await popupHiddenPromise;
+});
diff --git a/browser/base/content/test/general/browser_bug424101.js b/browser/base/content/test/general/browser_bug424101.js
new file mode 100644
index 0000000000..ecaf7064ab
--- /dev/null
+++ b/browser/base/content/test/general/browser_bug424101.js
@@ -0,0 +1,72 @@
+/* Make sure that the context menu appears on form elements */
+
+add_task(async function () {
+ await BrowserTestUtils.openNewForegroundTab(gBrowser, "data:text/html,test");
+
+ let contentAreaContextMenu = document.getElementById(
+ "contentAreaContextMenu"
+ );
+
+ let tests = [
+ { element: "input", type: "text" },
+ { element: "input", type: "password" },
+ { element: "input", type: "image" },
+ { element: "input", type: "button" },
+ { element: "input", type: "submit" },
+ { element: "input", type: "reset" },
+ { element: "input", type: "checkbox" },
+ { element: "input", type: "radio" },
+ { element: "button" },
+ { element: "select" },
+ { element: "option" },
+ { element: "optgroup" },
+ ];
+
+ for (let index = 0; index < tests.length; index++) {
+ let test = tests[index];
+
+ await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [{ element: test.element, type: test.type, index }],
+ async function (arg) {
+ let element = content.document.createElement(arg.element);
+ element.id = "element" + arg.index;
+ if (arg.type) {
+ element.setAttribute("type", arg.type);
+ }
+ content.document.body.appendChild(element);
+ }
+ );
+
+ let popupShownPromise = BrowserTestUtils.waitForEvent(
+ contentAreaContextMenu,
+ "popupshown"
+ );
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#element" + index,
+ { type: "contextmenu", button: 2 },
+ gBrowser.selectedBrowser
+ );
+ await popupShownPromise;
+
+ let typeAttr = test.type ? "type=" + test.type + " " : "";
+ is(
+ gContextMenu.shouldDisplay,
+ true,
+ "context menu behavior for <" +
+ test.element +
+ " " +
+ typeAttr +
+ "> is wrong"
+ );
+
+ let popupHiddenPromise = BrowserTestUtils.waitForEvent(
+ contentAreaContextMenu,
+ "popuphidden"
+ );
+ contentAreaContextMenu.hidePopup();
+ await popupHiddenPromise;
+ }
+
+ gBrowser.removeCurrentTab();
+});
diff --git a/browser/base/content/test/general/browser_bug427559.js b/browser/base/content/test/general/browser_bug427559.js
new file mode 100644
index 0000000000..29acf0862b
--- /dev/null
+++ b/browser/base/content/test/general/browser_bug427559.js
@@ -0,0 +1,41 @@
+"use strict";
+
+/*
+ * Test bug 427559 to make sure focused elements that are no longer on the page
+ * will have focus transferred to the window when changing tabs back to that
+ * tab with the now-gone element.
+ */
+
+// Default focus on a button and have it kill itself on blur.
+const URL =
+ "data:text/html;charset=utf-8," +
+ '<body><button onblur="this.remove()">' +
+ "<script>document.body.firstElementChild.focus()</script></body>";
+
+function getFocusedLocalName(browser) {
+ return SpecialPowers.spawn(browser, [], async function () {
+ return content.document.activeElement.localName;
+ });
+}
+
+add_task(async function () {
+ let testTab = await BrowserTestUtils.openNewForegroundTab(gBrowser, URL);
+
+ let browser = testTab.linkedBrowser;
+
+ is(await getFocusedLocalName(browser), "button", "button is focused");
+
+ let blankTab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:blank"
+ );
+
+ await BrowserTestUtils.switchTab(gBrowser, testTab);
+
+ // Make sure focus is given to the window because the element is now gone.
+ is(await getFocusedLocalName(browser), "body", "body is focused");
+
+ // Cleanup.
+ gBrowser.removeTab(blankTab);
+ gBrowser.removeCurrentTab();
+});
diff --git a/browser/base/content/test/general/browser_bug431826.js b/browser/base/content/test/general/browser_bug431826.js
new file mode 100644
index 0000000000..704cd4a675
--- /dev/null
+++ b/browser/base/content/test/general/browser_bug431826.js
@@ -0,0 +1,56 @@
+function remote(task) {
+ return SpecialPowers.spawn(gBrowser.selectedBrowser, [], task);
+}
+
+add_task(async function () {
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+
+ let promise = BrowserTestUtils.waitForErrorPage(gBrowser.selectedBrowser);
+ BrowserTestUtils.loadURIString(gBrowser, "https://nocert.example.com/");
+ await promise;
+
+ await remote(() => {
+ // Confirm that we are displaying the contributed error page, not the default
+ let uri = content.document.documentURI;
+ Assert.ok(
+ uri.startsWith("about:certerror"),
+ "Broken page should go to about:certerror, not about:neterror"
+ );
+ });
+
+ await remote(() => {
+ let div = content.document.getElementById("badCertAdvancedPanel");
+ // Confirm that the expert section is collapsed
+ Assert.ok(div, "Advanced content div should exist");
+ Assert.equal(
+ div.ownerGlobal.getComputedStyle(div).display,
+ "none",
+ "Advanced content should not be visible by default"
+ );
+ });
+
+ // Tweak the expert mode pref
+ Services.prefs.setBoolPref("browser.xul.error_pages.expert_bad_cert", true);
+
+ promise = BrowserTestUtils.waitForErrorPage(gBrowser.selectedBrowser);
+ gBrowser.reload();
+ await promise;
+
+ await remote(() => {
+ let div = content.document.getElementById("badCertAdvancedPanel");
+ Assert.ok(div, "Advanced content div should exist");
+ Assert.equal(
+ div.ownerGlobal.getComputedStyle(div).display,
+ "block",
+ "Advanced content should be visible by default"
+ );
+ });
+
+ // Clean up
+ gBrowser.removeCurrentTab();
+ if (
+ Services.prefs.prefHasUserValue("browser.xul.error_pages.expert_bad_cert")
+ ) {
+ Services.prefs.clearUserPref("browser.xul.error_pages.expert_bad_cert");
+ }
+});
diff --git a/browser/base/content/test/general/browser_bug432599.js b/browser/base/content/test/general/browser_bug432599.js
new file mode 100644
index 0000000000..be4a4b8b5c
--- /dev/null
+++ b/browser/base/content/test/general/browser_bug432599.js
@@ -0,0 +1,109 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+function invokeUsingCtrlD(phase) {
+ switch (phase) {
+ case 1:
+ EventUtils.synthesizeKey("d", { accelKey: true });
+ break;
+ case 2:
+ case 4:
+ EventUtils.synthesizeKey("KEY_Escape");
+ break;
+ case 3:
+ EventUtils.synthesizeKey("d", { accelKey: true });
+ EventUtils.synthesizeKey("d", { accelKey: true });
+ break;
+ }
+}
+
+function invokeUsingStarButton(phase) {
+ switch (phase) {
+ case 1:
+ EventUtils.synthesizeMouseAtCenter(BookmarkingUI.star, {});
+ break;
+ case 2:
+ case 4:
+ EventUtils.synthesizeKey("KEY_Escape");
+ break;
+ case 3:
+ EventUtils.synthesizeMouseAtCenter(BookmarkingUI.star, { clickCount: 2 });
+ break;
+ }
+}
+
+add_task(async function () {
+ const TEST_URL = "data:text/plain,Content";
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL);
+ registerCleanupFunction(async () => {
+ await BrowserTestUtils.removeTab(tab);
+ await PlacesUtils.bookmarks.eraseEverything();
+ });
+
+ // Changing the location causes the star to asynchronously update, thus wait
+ // for it to be in a stable state before proceeding.
+ await TestUtils.waitForCondition(
+ () => BookmarkingUI.status == BookmarkingUI.STATUS_UNSTARRED
+ );
+
+ await PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ url: TEST_URL,
+ title: "Bug 432599 Test",
+ });
+ Assert.equal(
+ BookmarkingUI.status,
+ BookmarkingUI.STATUS_STARRED,
+ "The star state should be starred"
+ );
+
+ for (let invoker of [invokeUsingStarButton, invokeUsingCtrlD]) {
+ for (let phase = 1; phase < 5; ++phase) {
+ let promise = checkBookmarksPanel(phase);
+ invoker(phase);
+ await promise;
+ Assert.equal(
+ BookmarkingUI.status,
+ BookmarkingUI.STATUS_STARRED,
+ "The star state shouldn't change"
+ );
+ }
+ }
+});
+
+var initialValue;
+var initialRemoveHidden;
+async function checkBookmarksPanel(phase) {
+ StarUI._createPanelIfNeeded();
+ let popupElement = document.getElementById("editBookmarkPanel");
+ let titleElement = document.getElementById("editBookmarkPanelTitle");
+ let removeElement = document.getElementById("editBookmarkPanelRemoveButton");
+ await document.l10n.translateElements([titleElement]);
+ switch (phase) {
+ case 1:
+ case 3:
+ await promisePopupShown(popupElement);
+ break;
+ case 2:
+ initialValue = titleElement.textContent;
+ initialRemoveHidden = removeElement.hidden;
+ await promisePopupHidden(popupElement);
+ break;
+ case 4:
+ Assert.equal(
+ titleElement.textContent,
+ initialValue,
+ "The bookmark panel's title should be the same"
+ );
+ Assert.equal(
+ removeElement.hidden,
+ initialRemoveHidden,
+ "The bookmark panel's visibility should not change"
+ );
+ await promisePopupHidden(popupElement);
+ break;
+ default:
+ throw new Error("Unknown phase");
+ }
+}
diff --git a/browser/base/content/test/general/browser_bug455852.js b/browser/base/content/test/general/browser_bug455852.js
new file mode 100644
index 0000000000..567f655e99
--- /dev/null
+++ b/browser/base/content/test/general/browser_bug455852.js
@@ -0,0 +1,27 @@
+add_task(async function () {
+ is(gBrowser.tabs.length, 1, "one tab is open");
+
+ gBrowser.selectedBrowser.focus();
+ isnot(
+ document.activeElement,
+ gURLBar.inputField,
+ "location bar is not focused"
+ );
+
+ var tab = gBrowser.selectedTab;
+ Services.prefs.setBoolPref("browser.tabs.closeWindowWithLastTab", false);
+
+ EventUtils.synthesizeKey("w", { accelKey: true });
+
+ is(tab.parentNode, null, "ctrl+w removes the tab");
+ is(gBrowser.tabs.length, 1, "a new tab has been opened");
+ is(
+ document.activeElement,
+ gURLBar.inputField,
+ "location bar is focused for the new tab"
+ );
+
+ if (Services.prefs.prefHasUserValue("browser.tabs.closeWindowWithLastTab")) {
+ Services.prefs.clearUserPref("browser.tabs.closeWindowWithLastTab");
+ }
+});
diff --git a/browser/base/content/test/general/browser_bug462289.js b/browser/base/content/test/general/browser_bug462289.js
new file mode 100644
index 0000000000..c8be399639
--- /dev/null
+++ b/browser/base/content/test/general/browser_bug462289.js
@@ -0,0 +1,144 @@
+var tab1, tab2;
+
+function focus_in_navbar() {
+ var parent = document.activeElement.parentNode;
+ while (parent && parent.id != "nav-bar") {
+ parent = parent.parentNode;
+ }
+
+ return parent != null;
+}
+
+function test() {
+ // Put the home button in the pre-proton placement to test focus states.
+ CustomizableUI.addWidgetToArea(
+ "home-button",
+ "nav-bar",
+ CustomizableUI.getPlacementOfWidget("stop-reload-button").position + 1
+ );
+ registerCleanupFunction(async function resetToolbar() {
+ await CustomizableUI.reset();
+ });
+
+ waitForExplicitFinish();
+
+ tab1 = BrowserTestUtils.addTab(gBrowser, "about:blank", {
+ skipAnimation: true,
+ });
+ tab2 = BrowserTestUtils.addTab(gBrowser, "about:blank", {
+ skipAnimation: true,
+ });
+
+ EventUtils.synthesizeMouseAtCenter(tab1, {});
+ setTimeout(step2, 0);
+}
+
+function step2() {
+ is(gBrowser.selectedTab, tab1, "1st click on tab1 selects tab");
+ isnot(
+ document.activeElement,
+ tab1,
+ "1st click on tab1 does not activate tab"
+ );
+
+ EventUtils.synthesizeMouseAtCenter(tab1, {});
+ setTimeout(step3, 0);
+}
+
+async function step3() {
+ is(
+ gBrowser.selectedTab,
+ tab1,
+ "2nd click on selected tab1 keeps tab selected"
+ );
+ isnot(
+ document.activeElement,
+ tab1,
+ "2nd click on selected tab1 does not activate tab"
+ );
+
+ info("focusing URLBar then sending 3 Shift+Tab.");
+ gURLBar.focus();
+
+ let focused = BrowserTestUtils.waitForEvent(
+ document.getElementById("home-button"),
+ "focus"
+ );
+ EventUtils.synthesizeKey("VK_TAB", { shiftKey: true });
+ await focused;
+ info("Focus is now on Home button");
+
+ focused = BrowserTestUtils.waitForEvent(
+ document.getElementById("tabs-newtab-button"),
+ "focus"
+ );
+ EventUtils.synthesizeKey("VK_TAB", { shiftKey: true });
+ await focused;
+ info("Focus is now on the new tab button");
+
+ focused = BrowserTestUtils.waitForEvent(tab1, "focus");
+ EventUtils.synthesizeKey("VK_TAB", { shiftKey: true });
+ await focused;
+ is(gBrowser.selectedTab, tab1, "tab key to selected tab1 keeps tab selected");
+ is(document.activeElement, tab1, "tab key to selected tab1 activates tab");
+
+ EventUtils.synthesizeMouseAtCenter(tab1, {});
+ setTimeout(step4, 0);
+}
+
+function step4() {
+ is(
+ gBrowser.selectedTab,
+ tab1,
+ "3rd click on activated tab1 keeps tab selected"
+ );
+ is(
+ document.activeElement,
+ tab1,
+ "3rd click on activated tab1 keeps tab activated"
+ );
+
+ gBrowser.addEventListener("TabSwitchDone", step5);
+ EventUtils.synthesizeMouseAtCenter(tab2, {});
+}
+
+function step5() {
+ gBrowser.removeEventListener("TabSwitchDone", step5);
+
+ // The tabbox selects a tab within a setTimeout in a bubbling mousedown event
+ // listener, and focuses the current tab if another tab previously had focus.
+ is(
+ gBrowser.selectedTab,
+ tab2,
+ "click on tab2 while tab1 is activated selects tab"
+ );
+ is(
+ document.activeElement,
+ tab2,
+ "click on tab2 while tab1 is activated activates tab"
+ );
+
+ info("focusing content then sending middle-button mousedown to tab2.");
+ gBrowser.selectedBrowser.focus();
+
+ EventUtils.synthesizeMouseAtCenter(tab2, { button: 1, type: "mousedown" });
+ setTimeout(step6, 0);
+}
+
+function step6() {
+ is(
+ gBrowser.selectedTab,
+ tab2,
+ "middle-button mousedown on selected tab2 keeps tab selected"
+ );
+ isnot(
+ document.activeElement,
+ tab2,
+ "middle-button mousedown on selected tab2 does not activate tab"
+ );
+
+ gBrowser.removeTab(tab2);
+ gBrowser.removeTab(tab1);
+
+ finish();
+}
diff --git a/browser/base/content/test/general/browser_bug462673.js b/browser/base/content/test/general/browser_bug462673.js
new file mode 100644
index 0000000000..fb550cb2b5
--- /dev/null
+++ b/browser/base/content/test/general/browser_bug462673.js
@@ -0,0 +1,66 @@
+add_task(async function () {
+ var win = openDialog(
+ AppConstants.BROWSER_CHROME_URL,
+ "_blank",
+ "chrome,all,dialog=no"
+ );
+ await SimpleTest.promiseFocus(win);
+
+ let tab = win.gBrowser.tabs[0];
+ await promiseTabLoadEvent(
+ tab,
+ getRootDirectory(gTestPath) + "test_bug462673.html"
+ );
+
+ is(
+ win.gBrowser.browsers.length,
+ 2,
+ "test_bug462673.html has opened a second tab"
+ );
+ is(
+ win.gBrowser.selectedTab,
+ tab.nextElementSibling,
+ "dependent tab is selected"
+ );
+ win.gBrowser.removeTab(tab);
+
+ // Closing a tab will also close its parent chrome window, but async
+ await BrowserTestUtils.domWindowClosed(win);
+});
+
+add_task(async function () {
+ var win = openDialog(
+ AppConstants.BROWSER_CHROME_URL,
+ "_blank",
+ "chrome,all,dialog=no"
+ );
+ await SimpleTest.promiseFocus(win);
+
+ let tab = win.gBrowser.tabs[0];
+ await promiseTabLoadEvent(
+ tab,
+ getRootDirectory(gTestPath) + "test_bug462673.html"
+ );
+
+ var newTab = BrowserTestUtils.addTab(win.gBrowser);
+ var newBrowser = newTab.linkedBrowser;
+ win.gBrowser.removeTab(tab);
+ ok(!win.closed, "Window stays open");
+ if (!win.closed) {
+ is(win.gBrowser.tabs.length, 1, "Window has one tab");
+ is(win.gBrowser.browsers.length, 1, "Window has one browser");
+ is(win.gBrowser.selectedTab, newTab, "Remaining tab is selected");
+ is(
+ win.gBrowser.selectedBrowser,
+ newBrowser,
+ "Browser for remaining tab is selected"
+ );
+ is(
+ win.gBrowser.tabbox.selectedPanel,
+ newBrowser.parentNode.parentNode.parentNode,
+ "Panel for remaining tab is selected"
+ );
+ }
+
+ await promiseWindowClosed(win);
+});
diff --git a/browser/base/content/test/general/browser_bug479408.js b/browser/base/content/test/general/browser_bug479408.js
new file mode 100644
index 0000000000..f616fa0ee4
--- /dev/null
+++ b/browser/base/content/test/general/browser_bug479408.js
@@ -0,0 +1,23 @@
+function test() {
+ waitForExplicitFinish();
+ let tab = (gBrowser.selectedTab = BrowserTestUtils.addTab(
+ gBrowser,
+ "http://mochi.test:8888/browser/browser/base/content/test/general/browser_bug479408_sample.html"
+ ));
+
+ BrowserTestUtils.waitForContentEvent(
+ gBrowser.selectedBrowser,
+ "DOMLinkAdded",
+ true
+ ).then(() => {
+ executeSoon(function () {
+ ok(
+ !tab.linkedBrowser.engines,
+ "the subframe's search engine wasn't detected"
+ );
+
+ gBrowser.removeTab(tab);
+ finish();
+ });
+ });
+}
diff --git a/browser/base/content/test/general/browser_bug479408_sample.html b/browser/base/content/test/general/browser_bug479408_sample.html
new file mode 100644
index 0000000000..f83f02bb9d
--- /dev/null
+++ b/browser/base/content/test/general/browser_bug479408_sample.html
@@ -0,0 +1,4 @@
+<!DOCTYPE html>
+<title>Testcase for bug 479408</title>
+
+<iframe src='data:text/html,<link%20rel="search"%20type="application/opensearchdescription+xml"%20title="Search%20bug%20479408"%20href="http://example.com/search.xml">'>
diff --git a/browser/base/content/test/general/browser_bug481560.js b/browser/base/content/test/general/browser_bug481560.js
new file mode 100644
index 0000000000..737ac729a2
--- /dev/null
+++ b/browser/base/content/test/general/browser_bug481560.js
@@ -0,0 +1,16 @@
+add_task(async function testTabCloseShortcut() {
+ let win = await BrowserTestUtils.openNewBrowserWindow();
+ await SimpleTest.promiseFocus(win);
+
+ function onTabClose() {
+ ok(false, "shouldn't have gotten the TabClose event for the last tab");
+ }
+ var tab = win.gBrowser.selectedTab;
+ tab.addEventListener("TabClose", onTabClose);
+
+ EventUtils.synthesizeKey("w", { accelKey: true }, win);
+
+ ok(win.closed, "accel+w closed the window immediately");
+
+ tab.removeEventListener("TabClose", onTabClose);
+});
diff --git a/browser/base/content/test/general/browser_bug484315.js b/browser/base/content/test/general/browser_bug484315.js
new file mode 100644
index 0000000000..21b4e69a33
--- /dev/null
+++ b/browser/base/content/test/general/browser_bug484315.js
@@ -0,0 +1,14 @@
+add_task(async function test() {
+ window.open("about:blank", "", "width=100,height=100,noopener");
+
+ let win = Services.wm.getMostRecentWindow("navigator:browser");
+ Services.prefs.setBoolPref("browser.tabs.closeWindowWithLastTab", false);
+ win.gBrowser.removeCurrentTab();
+ ok(win.closed, "popup is closed");
+
+ // clean up
+ if (!win.closed) {
+ win.close();
+ }
+ Services.prefs.clearUserPref("browser.tabs.closeWindowWithLastTab");
+});
diff --git a/browser/base/content/test/general/browser_bug491431.js b/browser/base/content/test/general/browser_bug491431.js
new file mode 100644
index 0000000000..d8eaa15f45
--- /dev/null
+++ b/browser/base/content/test/general/browser_bug491431.js
@@ -0,0 +1,42 @@
+/* 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/. */
+
+var testPage = "data:text/plain,test bug 491431 Page";
+
+function test() {
+ waitForExplicitFinish();
+
+ let newWin, tabA, tabB;
+
+ // test normal close
+ tabA = BrowserTestUtils.addTab(gBrowser, testPage);
+ gBrowser.tabContainer.addEventListener(
+ "TabClose",
+ function (firstTabCloseEvent) {
+ ok(!firstTabCloseEvent.detail.adoptedBy, "This was a normal tab close");
+
+ // test tab close by moving
+ tabB = BrowserTestUtils.addTab(gBrowser, testPage);
+ gBrowser.tabContainer.addEventListener(
+ "TabClose",
+ function (secondTabCloseEvent) {
+ executeSoon(function () {
+ ok(
+ secondTabCloseEvent.detail.adoptedBy,
+ "This was a tab closed by moving"
+ );
+
+ // cleanup
+ newWin.close();
+ executeSoon(finish);
+ });
+ },
+ { capture: true, once: true }
+ );
+ newWin = gBrowser.replaceTabWithWindow(tabB);
+ },
+ { capture: true, once: true }
+ );
+ gBrowser.removeTab(tabA);
+}
diff --git a/browser/base/content/test/general/browser_bug495058.js b/browser/base/content/test/general/browser_bug495058.js
new file mode 100644
index 0000000000..95a444bf6a
--- /dev/null
+++ b/browser/base/content/test/general/browser_bug495058.js
@@ -0,0 +1,53 @@
+/**
+ * Tests that the right elements of a tab are focused when it is
+ * torn out into its own window.
+ */
+
+const URIS = [
+ "about:blank",
+ "about:home",
+ "about:sessionrestore",
+ "about:privatebrowsing",
+];
+
+add_task(async function () {
+ for (let uri of URIS) {
+ let tab = BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.loadURIString(tab.linkedBrowser, uri);
+ await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ let isRemote = tab.linkedBrowser.isRemoteBrowser;
+
+ let win = gBrowser.replaceTabWithWindow(tab);
+
+ await TestUtils.topicObserved(
+ "browser-delayed-startup-finished",
+ subject => subject == win
+ );
+ // In the e10s case, we wait for the content to first paint before we focus
+ // the URL in the new window, to optimize for content paint time.
+ if (isRemote) {
+ await win.gBrowserInit.firstContentWindowPaintPromise;
+ }
+
+ tab = win.gBrowser.selectedTab;
+
+ Assert.equal(
+ win.gBrowser.currentURI.spec,
+ uri,
+ uri + ": uri loaded in detached tab"
+ );
+
+ const expectedActiveElement = tab.isEmpty
+ ? win.gURLBar.inputField
+ : win.gBrowser.selectedBrowser;
+ Assert.equal(
+ win.document.activeElement,
+ expectedActiveElement,
+ `${uri}: the active element is expected: ${win.document.activeElement?.nodeName}`
+ );
+ Assert.equal(win.gURLBar.value, "", uri + ": urlbar is empty");
+ Assert.ok(win.gURLBar.placeholder, uri + ": placeholder text is present");
+
+ await BrowserTestUtils.closeWindow(win);
+ }
+});
diff --git a/browser/base/content/test/general/browser_bug519216.js b/browser/base/content/test/general/browser_bug519216.js
new file mode 100644
index 0000000000..d83d082556
--- /dev/null
+++ b/browser/base/content/test/general/browser_bug519216.js
@@ -0,0 +1,48 @@
+function test() {
+ waitForExplicitFinish();
+ gBrowser.addProgressListener(progressListener1);
+ gBrowser.addProgressListener(progressListener2);
+ gBrowser.addProgressListener(progressListener3);
+ BrowserTestUtils.loadURIString(gBrowser, "data:text/plain,bug519216");
+}
+
+var calledListener1 = false;
+var progressListener1 = {
+ onLocationChange: function onLocationChange() {
+ calledListener1 = true;
+ gBrowser.removeProgressListener(this);
+ },
+};
+
+var calledListener2 = false;
+var progressListener2 = {
+ onLocationChange: function onLocationChange() {
+ ok(calledListener1, "called progressListener1 before progressListener2");
+ calledListener2 = true;
+ gBrowser.removeProgressListener(this);
+ },
+};
+
+var progressListener3 = {
+ onLocationChange: function onLocationChange() {
+ ok(calledListener2, "called progressListener2 before progressListener3");
+ gBrowser.removeProgressListener(this);
+ gBrowser.addProgressListener(progressListener4);
+ executeSoon(function () {
+ expectListener4 = true;
+ gBrowser.reload();
+ });
+ },
+};
+
+var expectListener4 = false;
+var progressListener4 = {
+ onLocationChange: function onLocationChange() {
+ ok(
+ expectListener4,
+ "didn't call progressListener4 for the first location change"
+ );
+ gBrowser.removeProgressListener(this);
+ executeSoon(finish);
+ },
+};
diff --git a/browser/base/content/test/general/browser_bug520538.js b/browser/base/content/test/general/browser_bug520538.js
new file mode 100644
index 0000000000..234747fcbf
--- /dev/null
+++ b/browser/base/content/test/general/browser_bug520538.js
@@ -0,0 +1,27 @@
+function test() {
+ var tabCount = gBrowser.tabs.length;
+ gBrowser.selectedBrowser.focus();
+ window.browserDOMWindow.openURI(
+ makeURI("about:blank"),
+ null,
+ Ci.nsIBrowserDOMWindow.OPEN_NEWTAB,
+ Ci.nsIBrowserDOMWindow.OPEN_EXTERNAL,
+ Services.scriptSecurityManager.getSystemPrincipal()
+ );
+ is(
+ gBrowser.tabs.length,
+ tabCount + 1,
+ "'--new-tab about:blank' opens a new tab"
+ );
+ is(
+ gBrowser.selectedTab,
+ gBrowser.tabs[tabCount],
+ "'--new-tab about:blank' selects the new tab"
+ );
+ is(
+ document.activeElement,
+ gURLBar.inputField,
+ "'--new-tab about:blank' focuses the location bar"
+ );
+ gBrowser.removeCurrentTab();
+}
diff --git a/browser/base/content/test/general/browser_bug521216.js b/browser/base/content/test/general/browser_bug521216.js
new file mode 100644
index 0000000000..8c885bbcc8
--- /dev/null
+++ b/browser/base/content/test/general/browser_bug521216.js
@@ -0,0 +1,68 @@
+var expected = [
+ "TabOpen",
+ "onStateChange",
+ "onLocationChange",
+ "onLinkIconAvailable",
+];
+var actual = [];
+var tabIndex = -1;
+this.__defineGetter__("tab", () => gBrowser.tabs[tabIndex]);
+
+function test() {
+ waitForExplicitFinish();
+ tabIndex = gBrowser.tabs.length;
+ gBrowser.addTabsProgressListener(progressListener);
+ gBrowser.tabContainer.addEventListener("TabOpen", TabOpen);
+ BrowserTestUtils.addTab(
+ gBrowser,
+ "data:text/html,<html><head><link href='about:logo' rel='shortcut icon'>"
+ );
+}
+
+function recordEvent(aName) {
+ info("got " + aName);
+ if (!actual.includes(aName)) {
+ actual.push(aName);
+ }
+ if (actual.length == expected.length) {
+ is(
+ actual.toString(),
+ expected.toString(),
+ "got events and progress notifications in expected order"
+ );
+
+ executeSoon(
+ // eslint-disable-next-line no-shadow
+ function (tab) {
+ gBrowser.removeTab(tab);
+ gBrowser.removeTabsProgressListener(progressListener);
+ gBrowser.tabContainer.removeEventListener("TabOpen", TabOpen);
+ finish();
+ }.bind(null, tab)
+ );
+ }
+}
+
+function TabOpen(aEvent) {
+ if (aEvent.target == tab) {
+ recordEvent("TabOpen");
+ }
+}
+
+var progressListener = {
+ onLocationChange: function onLocationChange(aBrowser) {
+ if (aBrowser == tab.linkedBrowser) {
+ recordEvent("onLocationChange");
+ }
+ },
+ onStateChange: function onStateChange(aBrowser) {
+ if (aBrowser == tab.linkedBrowser) {
+ recordEvent("onStateChange");
+ }
+ },
+ onLinkIconAvailable: function onLinkIconAvailable(aBrowser) {
+ if (aBrowser == tab.linkedBrowser) {
+ recordEvent("onLinkIconAvailable");
+ }
+ },
+};
diff --git a/browser/base/content/test/general/browser_bug533232.js b/browser/base/content/test/general/browser_bug533232.js
new file mode 100644
index 0000000000..7f6225b519
--- /dev/null
+++ b/browser/base/content/test/general/browser_bug533232.js
@@ -0,0 +1,56 @@
+function test() {
+ var tab1 = gBrowser.selectedTab;
+ var tab2 = BrowserTestUtils.addTab(gBrowser);
+ var childTab1;
+ var childTab2;
+
+ childTab1 = BrowserTestUtils.addTab(gBrowser, "about:blank", {
+ relatedToCurrent: true,
+ });
+ gBrowser.selectedTab = childTab1;
+ gBrowser.removeTab(gBrowser.selectedTab, { skipPermitUnload: true });
+ is(
+ idx(gBrowser.selectedTab),
+ idx(tab1),
+ "closing a tab next to its parent selects the parent"
+ );
+
+ childTab1 = BrowserTestUtils.addTab(gBrowser, "about:blank", {
+ relatedToCurrent: true,
+ });
+ gBrowser.selectedTab = tab2;
+ gBrowser.selectedTab = childTab1;
+ gBrowser.removeTab(gBrowser.selectedTab, { skipPermitUnload: true });
+ is(
+ idx(gBrowser.selectedTab),
+ idx(tab2),
+ "closing a tab next to its parent doesn't select the parent if another tab had been selected ad interim"
+ );
+
+ gBrowser.selectedTab = tab1;
+ childTab1 = BrowserTestUtils.addTab(gBrowser, "about:blank", {
+ relatedToCurrent: true,
+ });
+ childTab2 = BrowserTestUtils.addTab(gBrowser, "about:blank", {
+ relatedToCurrent: true,
+ });
+ gBrowser.selectedTab = childTab1;
+ gBrowser.removeTab(gBrowser.selectedTab, { skipPermitUnload: true });
+ is(
+ idx(gBrowser.selectedTab),
+ idx(childTab2),
+ "closing a tab next to its parent selects the next tab with the same parent"
+ );
+ gBrowser.removeTab(gBrowser.selectedTab, { skipPermitUnload: true });
+ is(
+ idx(gBrowser.selectedTab),
+ idx(tab2),
+ "closing the last tab in a set of child tabs doesn't go back to the parent"
+ );
+
+ gBrowser.removeTab(tab2, { skipPermitUnload: true });
+}
+
+function idx(tab) {
+ return Array.prototype.indexOf.call(gBrowser.tabs, tab);
+}
diff --git a/browser/base/content/test/general/browser_bug537013.js b/browser/base/content/test/general/browser_bug537013.js
new file mode 100644
index 0000000000..5c871a759c
--- /dev/null
+++ b/browser/base/content/test/general/browser_bug537013.js
@@ -0,0 +1,168 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/* Tests for bug 537013 to ensure proper tab-sequestration of find bar. */
+
+var tabs = [];
+var texts = [
+ "This side up.",
+ "The world is coming to an end. Please log off.",
+ "Klein bottle for sale. Inquire within.",
+ "To err is human; to forgive is not company policy.",
+];
+
+var HasFindClipboard = Services.clipboard.isClipboardTypeSupported(
+ Services.clipboard.kFindClipboard
+);
+
+function addTabWithText(aText, aCallback) {
+ let newTab = BrowserTestUtils.addTab(
+ gBrowser,
+ "data:text/html;charset=utf-8,<h1 id='h1'>" + aText + "</h1>"
+ );
+ tabs.push(newTab);
+ gBrowser.selectedTab = newTab;
+}
+
+function setFindString(aString) {
+ gFindBar.open();
+ gFindBar._findField.focus();
+ gFindBar._findField.select();
+ EventUtils.sendString(aString);
+ is(gFindBar._findField.value, aString, "Set the field correctly!");
+}
+
+var newWindow;
+
+function test() {
+ waitForExplicitFinish();
+ registerCleanupFunction(function () {
+ while (tabs.length) {
+ gBrowser.removeTab(tabs.pop());
+ }
+ });
+ texts.forEach(aText => addTabWithText(aText));
+
+ // Set up the first tab
+ gBrowser.selectedTab = tabs[0];
+
+ gBrowser.getFindBar().then(initialTest);
+}
+
+function initialTest() {
+ setFindString(texts[0]);
+ // Turn on highlight for testing bug 891638
+ gFindBar.getElement("highlight").checked = true;
+
+ // Make sure the second tab is correct, then set it up
+ gBrowser.selectedTab = tabs[1];
+ gBrowser.selectedTab.addEventListener("TabFindInitialized", continueTests1, {
+ once: true,
+ });
+ // Initialize the findbar
+ gBrowser.getFindBar();
+}
+function continueTests1() {
+ ok(true, "'TabFindInitialized' event properly dispatched!");
+ ok(gFindBar.hidden, "Second tab doesn't show find bar!");
+ gFindBar.open();
+ is(
+ gFindBar._findField.value,
+ texts[0],
+ "Second tab kept old find value for new initialization!"
+ );
+ setFindString(texts[1]);
+
+ // Confirm the first tab is still correct, ensure re-hiding works as expected
+ gBrowser.selectedTab = tabs[0];
+ ok(!gFindBar.hidden, "First tab shows find bar!");
+ // When the Find Clipboard is supported, this test not relevant.
+ if (!HasFindClipboard) {
+ is(gFindBar._findField.value, texts[0], "First tab persists find value!");
+ }
+ ok(
+ gFindBar.getElement("highlight").checked,
+ "Highlight button state persists!"
+ );
+
+ // While we're here, let's test bug 253793
+ gBrowser.reload();
+ gBrowser.addEventListener("DOMContentLoaded", continueTests2, true);
+}
+
+function continueTests2() {
+ gBrowser.removeEventListener("DOMContentLoaded", continueTests2, true);
+ ok(gFindBar.getElement("highlight").checked, "Highlight never reset!");
+ continueTests3();
+}
+
+function continueTests3() {
+ ok(gFindBar.getElement("highlight").checked, "Highlight button reset!");
+ gFindBar.close();
+ ok(gFindBar.hidden, "First tab doesn't show find bar!");
+ gBrowser.selectedTab = tabs[1];
+ ok(!gFindBar.hidden, "Second tab shows find bar!");
+ // Test for bug 892384
+ is(
+ gFindBar._findField.getAttribute("focused"),
+ "true",
+ "Open findbar refocused on tab change!"
+ );
+ gURLBar.focus();
+ gBrowser.selectedTab = tabs[0];
+ ok(gFindBar.hidden, "First tab doesn't show find bar!");
+
+ // Set up a third tab, no tests here
+ gBrowser.selectedTab = tabs[2];
+ gBrowser.selectedTab.addEventListener("TabFindInitialized", continueTests4, {
+ once: true,
+ });
+ gBrowser.getFindBar();
+}
+
+function continueTests4() {
+ setFindString(texts[2]);
+
+ // Now we jump to the second, then first, and then fourth
+ gBrowser.selectedTab = tabs[1];
+ // Test for bug 892384
+ ok(
+ !gFindBar._findField.hasAttribute("focused"),
+ "Open findbar not refocused on tab change!"
+ );
+ gBrowser.selectedTab = tabs[0];
+ gBrowser.selectedTab = tabs[3];
+ ok(gFindBar.hidden, "Fourth tab doesn't show find bar!");
+ is(gFindBar, gBrowser.getFindBar(), "Find bar is right one!");
+ gFindBar.open();
+ // Disabled the following assertion due to intermittent failure on OSX 10.6 Debug.
+ if (!HasFindClipboard) {
+ is(
+ gFindBar._findField.value,
+ texts[1],
+ "Fourth tab has second tab's find value!"
+ );
+ }
+
+ newWindow = gBrowser.replaceTabWithWindow(tabs.pop());
+ whenDelayedStartupFinished(newWindow, checkNewWindow);
+}
+
+// Test that findbar gets restored when a tab is moved to a new window.
+function checkNewWindow() {
+ ok(!newWindow.gFindBar.hidden, "New window shows find bar!");
+ // Disabled the following assertion due to intermittent failure on OSX 10.6 Debug.
+ if (!HasFindClipboard) {
+ is(
+ newWindow.gFindBar._findField.value,
+ texts[1],
+ "New window find bar has correct find value!"
+ );
+ ok(
+ !newWindow.gFindBar.getElement("find-next").disabled,
+ "New window findbar has enabled buttons!"
+ );
+ }
+ newWindow.close();
+ finish();
+}
diff --git a/browser/base/content/test/general/browser_bug537474.js b/browser/base/content/test/general/browser_bug537474.js
new file mode 100644
index 0000000000..b890bf2fea
--- /dev/null
+++ b/browser/base/content/test/general/browser_bug537474.js
@@ -0,0 +1,20 @@
+add_task(async function () {
+ let browserLoadedPromise = BrowserTestUtils.browserLoaded(
+ gBrowser.selectedBrowser,
+ false,
+ "about:mozilla"
+ );
+ window.browserDOMWindow.openURI(
+ makeURI("about:mozilla"),
+ null,
+ Ci.nsIBrowserDOMWindow.OPEN_CURRENTWINDOW,
+ null,
+ Services.scriptSecurityManager.getSystemPrincipal()
+ );
+ await browserLoadedPromise;
+ is(
+ gBrowser.currentURI.spec,
+ "about:mozilla",
+ "page loads in the current content window"
+ );
+});
diff --git a/browser/base/content/test/general/browser_bug563588.js b/browser/base/content/test/general/browser_bug563588.js
new file mode 100644
index 0000000000..26c8fd1767
--- /dev/null
+++ b/browser/base/content/test/general/browser_bug563588.js
@@ -0,0 +1,42 @@
+function press(key, expectedPos) {
+ var originalSelectedTab = gBrowser.selectedTab;
+ EventUtils.synthesizeKey("VK_" + key.toUpperCase(), {
+ accelKey: true,
+ shiftKey: true,
+ });
+ is(
+ gBrowser.selectedTab,
+ originalSelectedTab,
+ "shift+accel+" + key + " doesn't change which tab is selected"
+ );
+ is(
+ gBrowser.tabContainer.selectedIndex,
+ expectedPos,
+ "shift+accel+" + key + " moves the tab to the expected position"
+ );
+ is(
+ document.activeElement,
+ gBrowser.selectedTab,
+ "shift+accel+" + key + " leaves the selected tab focused"
+ );
+}
+
+function test() {
+ BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.addTab(gBrowser);
+ is(gBrowser.tabs.length, 3, "got three tabs");
+ is(gBrowser.tabs[0], gBrowser.selectedTab, "first tab is selected");
+
+ gBrowser.selectedTab.focus();
+ is(document.activeElement, gBrowser.selectedTab, "selected tab is focused");
+
+ press("right", 1);
+ press("down", 2);
+ press("left", 1);
+ press("up", 0);
+ press("end", 2);
+ press("home", 0);
+
+ gBrowser.removeCurrentTab();
+ gBrowser.removeCurrentTab();
+}
diff --git a/browser/base/content/test/general/browser_bug565575.js b/browser/base/content/test/general/browser_bug565575.js
new file mode 100644
index 0000000000..6176c537e3
--- /dev/null
+++ b/browser/base/content/test/general/browser_bug565575.js
@@ -0,0 +1,21 @@
+add_task(async function () {
+ gBrowser.selectedBrowser.focus();
+
+ await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ () => BrowserOpenTab(),
+ false
+ );
+ ok(gURLBar.focused, "location bar is focused for a new tab");
+
+ await BrowserTestUtils.switchTab(gBrowser, gBrowser.tabs[0]);
+ ok(
+ !gURLBar.focused,
+ "location bar isn't focused for the previously selected tab"
+ );
+
+ await BrowserTestUtils.switchTab(gBrowser, gBrowser.tabs[1]);
+ ok(gURLBar.focused, "location bar is re-focused when selecting the new tab");
+
+ gBrowser.removeCurrentTab();
+});
diff --git a/browser/base/content/test/general/browser_bug567306.js b/browser/base/content/test/general/browser_bug567306.js
new file mode 100644
index 0000000000..3d3e47e17d
--- /dev/null
+++ b/browser/base/content/test/general/browser_bug567306.js
@@ -0,0 +1,65 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+var HasFindClipboard = Services.clipboard.isClipboardTypeSupported(
+ Services.clipboard.kFindClipboard
+);
+
+add_task(async function () {
+ let newwindow = await BrowserTestUtils.openNewBrowserWindow();
+
+ let selectedBrowser = newwindow.gBrowser.selectedBrowser;
+ await new Promise((resolve, reject) => {
+ BrowserTestUtils.waitForContentEvent(
+ selectedBrowser,
+ "pageshow",
+ true,
+ event => {
+ return event.target.location != "about:blank";
+ }
+ ).then(function pageshowListener() {
+ ok(
+ true,
+ "pageshow listener called: " + newwindow.gBrowser.currentURI.spec
+ );
+ resolve();
+ });
+ selectedBrowser.loadURI(
+ Services.io.newURI("data:text/html,<h1 id='h1'>Select Me</h1>"),
+ {
+ triggeringPrincipal:
+ Services.scriptSecurityManager.getSystemPrincipal(),
+ }
+ );
+ });
+
+ await SimpleTest.promiseFocus(newwindow);
+
+ ok(!newwindow.gFindBarInitialized, "find bar is not yet initialized");
+ let findBar = await newwindow.gFindBarPromise;
+
+ await SpecialPowers.spawn(selectedBrowser, [], async function () {
+ let elt = content.document.getElementById("h1");
+ let selection = content.getSelection();
+ let range = content.document.createRange();
+ range.setStart(elt, 0);
+ range.setEnd(elt, 1);
+ selection.removeAllRanges();
+ selection.addRange(range);
+ });
+
+ await findBar.onFindCommand();
+
+ // When the OS supports the Find Clipboard (OSX), the find field value is
+ // persisted across Fx sessions, thus not useful to test.
+ if (!HasFindClipboard) {
+ is(
+ findBar._findField.value,
+ "Select Me",
+ "Findbar is initialized with selection"
+ );
+ }
+ findBar.close();
+ await promiseWindowClosed(newwindow);
+});
diff --git a/browser/base/content/test/general/browser_bug575561.js b/browser/base/content/test/general/browser_bug575561.js
new file mode 100644
index 0000000000..a429cdf5c7
--- /dev/null
+++ b/browser/base/content/test/general/browser_bug575561.js
@@ -0,0 +1,118 @@
+requestLongerTimeout(2);
+
+const TEST_URL =
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com/browser/browser/base/content/test/general/app_bug575561.html";
+
+add_task(async function () {
+ SimpleTest.requestCompleteLog();
+
+ // allow top level data: URI navigations, otherwise clicking data: link fails
+ await SpecialPowers.pushPrefEnv({
+ set: [["security.data_uri.block_toplevel_data_uri_navigations", false]],
+ });
+
+ // Pinned: Link to the same domain should not open a new tab
+ // Tests link to http://example.com/browser/browser/base/content/test/general/dummy_page.html
+ await testLink(0, true, false);
+ // Pinned: Link to a different subdomain should open a new tab
+ // Tests link to http://test1.example.com/browser/browser/base/content/test/general/dummy_page.html
+ await testLink(1, true, true);
+
+ // Pinned: Link to a different domain should open a new tab
+ // Tests link to http://example.org/browser/browser/base/content/test/general/dummy_page.html
+ await testLink(2, true, true);
+
+ // Not Pinned: Link to a different domain should not open a new tab
+ // Tests link to http://example.org/browser/browser/base/content/test/general/dummy_page.html
+ await testLink(2, false, false);
+
+ // Pinned: Targetted link should open a new tab
+ // Tests link to http://example.org/browser/browser/base/content/test/general/dummy_page.html with target="foo"
+ await testLink(3, true, true);
+
+ // Pinned: Link in a subframe should not open a new tab
+ // Tests link to http://example.org/browser/browser/base/content/test/general/dummy_page.html in subframe
+ await testLink(0, true, false, true);
+
+ // Pinned: Link to the same domain (with www prefix) should not open a new tab
+ // Tests link to http://www.example.com/browser/browser/base/content/test/general/dummy_page.html
+ await testLink(4, true, false);
+
+ // Pinned: Link to a data: URI should not open a new tab
+ // Tests link to data:text/html,<!DOCTYPE html><html><body>Another Page</body></html>
+ await testLink(5, true, false);
+
+ // Pinned: Link to an about: URI should not open a new tab
+ // Tests link to about:logo
+ await testLink(
+ function (doc) {
+ let link = doc.createElement("a");
+ link.textContent = "Link to Mozilla";
+ link.href = "about:logo";
+ doc.body.appendChild(link);
+ return link;
+ },
+ true,
+ false,
+ false,
+ "about:robots"
+ );
+});
+
+async function testLink(
+ aLinkIndexOrFunction,
+ pinTab,
+ expectNewTab,
+ testSubFrame,
+ aURL = TEST_URL
+) {
+ let appTab = BrowserTestUtils.addTab(gBrowser, aURL, { skipAnimation: true });
+ if (pinTab) {
+ gBrowser.pinTab(appTab);
+ }
+ gBrowser.selectedTab = appTab;
+
+ let browser = appTab.linkedBrowser;
+ await BrowserTestUtils.browserLoaded(browser);
+
+ let promise;
+ if (expectNewTab) {
+ promise = BrowserTestUtils.waitForNewTab(gBrowser).then(tab => {
+ let loaded = tab.linkedBrowser.documentURI.spec;
+ BrowserTestUtils.removeTab(tab);
+ return loaded;
+ });
+ } else {
+ promise = BrowserTestUtils.browserLoaded(browser, testSubFrame);
+ }
+
+ let href;
+ if (typeof aLinkIndexOrFunction === "function") {
+ ok(!browser.isRemoteBrowser, "don't pass a function for a remote browser");
+ let link = aLinkIndexOrFunction(browser.contentDocument);
+ info("Clicking " + link.textContent);
+ link.click();
+ href = link.href;
+ } else {
+ href = await SpecialPowers.spawn(
+ browser,
+ [[testSubFrame, aLinkIndexOrFunction]],
+ function ([subFrame, index]) {
+ let doc = subFrame
+ ? content.document.querySelector("iframe").contentDocument
+ : content.document;
+ let link = doc.querySelectorAll("a")[index];
+
+ info("Clicking " + link.textContent);
+ link.click();
+ return link.href;
+ }
+ );
+ }
+
+ info(`Waiting on load of ${href}`);
+ let loaded = await promise;
+ is(loaded, href, "loaded the right document");
+ BrowserTestUtils.removeTab(appTab);
+}
diff --git a/browser/base/content/test/general/browser_bug577121.js b/browser/base/content/test/general/browser_bug577121.js
new file mode 100644
index 0000000000..cbaa379e85
--- /dev/null
+++ b/browser/base/content/test/general/browser_bug577121.js
@@ -0,0 +1,27 @@
+/* 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/. */
+
+function test() {
+ // Disable tab animations
+ gReduceMotionOverride = true;
+
+ // Open 2 other tabs, and pin the second one. Like that, the initial tab
+ // should get closed.
+ let testTab1 = BrowserTestUtils.addTab(gBrowser);
+ let testTab2 = BrowserTestUtils.addTab(gBrowser);
+ gBrowser.pinTab(testTab2);
+
+ // Now execute "Close other Tabs" on the first manually opened tab (tab1).
+ // -> tab2 ist pinned, tab1 should remain open and the initial tab should
+ // get closed.
+ gBrowser.removeAllTabsBut(testTab1);
+
+ is(gBrowser.tabs.length, 2, "there are two remaining tabs open");
+ is(gBrowser.tabs[0], testTab2, "pinned tab2 stayed open");
+ is(gBrowser.tabs[1], testTab1, "tab1 stayed open");
+
+ // Cleanup. Close only one tab because we need an opened tab at the end of
+ // the test.
+ gBrowser.removeTab(testTab2);
+}
diff --git a/browser/base/content/test/general/browser_bug578534.js b/browser/base/content/test/general/browser_bug578534.js
new file mode 100644
index 0000000000..04b5fe9cfd
--- /dev/null
+++ b/browser/base/content/test/general/browser_bug578534.js
@@ -0,0 +1,31 @@
+/* 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/. */
+
+const { PermissionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PermissionTestUtils.sys.mjs"
+);
+
+add_task(async function test() {
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ let uriString = "http://example.com/";
+ let cookieBehavior = "network.cookie.cookieBehavior";
+
+ await SpecialPowers.pushPrefEnv({ set: [[cookieBehavior, 2]] });
+ PermissionTestUtils.add(uriString, "cookie", Services.perms.ALLOW_ACTION);
+
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: uriString },
+ async function (browser) {
+ await SpecialPowers.spawn(browser, [], function () {
+ is(
+ content.navigator.cookieEnabled,
+ true,
+ "navigator.cookieEnabled should be true"
+ );
+ });
+ }
+ );
+
+ PermissionTestUtils.add(uriString, "cookie", Services.perms.UNKNOWN_ACTION);
+});
diff --git a/browser/base/content/test/general/browser_bug579872.js b/browser/base/content/test/general/browser_bug579872.js
new file mode 100644
index 0000000000..47de7ea240
--- /dev/null
+++ b/browser/base/content/test/general/browser_bug579872.js
@@ -0,0 +1,26 @@
+/* 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/. */
+
+add_task(async function () {
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ let newTab = BrowserTestUtils.addTab(gBrowser, "http://example.com");
+ await BrowserTestUtils.browserLoaded(newTab.linkedBrowser);
+
+ gBrowser.pinTab(newTab);
+ gBrowser.selectedTab = newTab;
+
+ openTrustedLinkIn("javascript:var x=0;", "current");
+ is(gBrowser.tabs.length, 2, "Should open in current tab");
+
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ openTrustedLinkIn("http://example.com/1", "current");
+ is(gBrowser.tabs.length, 2, "Should open in current tab");
+
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ openTrustedLinkIn("http://example.org/", "current");
+ is(gBrowser.tabs.length, 3, "Should open in new tab");
+
+ await BrowserTestUtils.removeTab(newTab);
+ await BrowserTestUtils.removeTab(gBrowser.tabs[1]); // example.org tab
+});
diff --git a/browser/base/content/test/general/browser_bug581253.js b/browser/base/content/test/general/browser_bug581253.js
new file mode 100644
index 0000000000..a901ce96e1
--- /dev/null
+++ b/browser/base/content/test/general/browser_bug581253.js
@@ -0,0 +1,74 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+var testURL = "data:text/plain,nothing but plain text";
+var testTag = "581253_tag";
+
+add_task(async function test_remove_bookmark_with_tag_via_edit_bookmark() {
+ waitForExplicitFinish();
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser);
+
+ registerCleanupFunction(async function () {
+ await PlacesUtils.bookmarks.eraseEverything();
+ BrowserTestUtils.removeTab(tab);
+ await PlacesUtils.history.clear();
+ });
+
+ await PlacesUtils.bookmarks.insert({
+ parentGuid: PlacesUtils.bookmarks.unfiledGuid,
+ title: "",
+ url: testURL,
+ });
+
+ Assert.ok(
+ await PlacesUtils.bookmarks.fetch({ url: testURL }),
+ "the test url is bookmarked"
+ );
+
+ BrowserTestUtils.loadURIString(gBrowser, testURL);
+
+ await TestUtils.waitForCondition(
+ () => BookmarkingUI.status == BookmarkingUI.STATUS_STARRED,
+ "star button indicates that the page is bookmarked"
+ );
+
+ PlacesUtils.tagging.tagURI(makeURI(testURL), [testTag]);
+
+ let popupShownPromise = BrowserTestUtils.waitForEvent(
+ StarUI.panel,
+ "popupshown"
+ );
+
+ BookmarkingUI.star.click();
+
+ await popupShownPromise;
+
+ let tagsField = document.getElementById("editBMPanel_tagsField");
+ Assert.ok(tagsField.value == testTag, "tags field value was set");
+ tagsField.focus();
+
+ let popupHiddenPromise = BrowserTestUtils.waitForEvent(
+ StarUI.panel,
+ "popuphidden"
+ );
+
+ let removeNotification = PlacesTestUtils.waitForNotification(
+ "bookmark-removed",
+ events => events.some(event => unescape(event.url) == testURL)
+ );
+
+ let removeButton = document.getElementById("editBookmarkPanelRemoveButton");
+ removeButton.click();
+
+ await popupHiddenPromise;
+
+ await removeNotification;
+
+ is(
+ BookmarkingUI.status,
+ BookmarkingUI.STATUS_UNSTARRED,
+ "star button indicates that the bookmark has been removed"
+ );
+});
diff --git a/browser/base/content/test/general/browser_bug585785.js b/browser/base/content/test/general/browser_bug585785.js
new file mode 100644
index 0000000000..23e0c5ddf5
--- /dev/null
+++ b/browser/base/content/test/general/browser_bug585785.js
@@ -0,0 +1,48 @@
+var tab;
+
+function test() {
+ waitForExplicitFinish();
+
+ // Force-enable tab animations
+ gReduceMotionOverride = false;
+
+ tab = BrowserTestUtils.addTab(gBrowser);
+ isnot(
+ tab.getAttribute("fadein"),
+ "true",
+ "newly opened tab is yet to fade in"
+ );
+
+ // Try to remove the tab right before the opening animation's first frame
+ window.requestAnimationFrame(checkAnimationState);
+}
+
+function checkAnimationState() {
+ is(tab.getAttribute("fadein"), "true", "tab opening animation initiated");
+
+ info(window.getComputedStyle(tab).maxWidth);
+ gBrowser.removeTab(tab, { animate: true });
+ if (!tab.parentNode) {
+ ok(
+ true,
+ "tab removed synchronously since the opening animation hasn't moved yet"
+ );
+ finish();
+ return;
+ }
+
+ info(
+ "tab didn't close immediately, so the tab opening animation must have started moving"
+ );
+ info("waiting for the tab to close asynchronously");
+ tab.addEventListener(
+ "TabAnimationEnd",
+ function listener() {
+ executeSoon(function () {
+ ok(!tab.parentNode, "tab removed asynchronously");
+ finish();
+ });
+ },
+ { once: true }
+ );
+}
diff --git a/browser/base/content/test/general/browser_bug585830.js b/browser/base/content/test/general/browser_bug585830.js
new file mode 100644
index 0000000000..2267a8b2ac
--- /dev/null
+++ b/browser/base/content/test/general/browser_bug585830.js
@@ -0,0 +1,27 @@
+/* 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/. */
+
+function test() {
+ let tab1 = gBrowser.selectedTab;
+ let tab2 = BrowserTestUtils.addTab(gBrowser, "about:blank", {
+ skipAnimation: true,
+ });
+ BrowserTestUtils.addTab(gBrowser);
+ gBrowser.selectedTab = tab2;
+
+ gBrowser.removeCurrentTab({ animate: true });
+ gBrowser.tabContainer.advanceSelectedTab(-1, true);
+ is(gBrowser.selectedTab, tab1, "First tab should be selected");
+ gBrowser.removeTab(tab2);
+
+ // test for "null has no properties" fix. See Bug 585830 Comment 13
+ gBrowser.removeCurrentTab({ animate: true });
+ try {
+ gBrowser.tabContainer.advanceSelectedTab(-1, false);
+ } catch (err) {
+ ok(false, "Shouldn't throw");
+ }
+
+ gBrowser.removeTab(tab1);
+}
diff --git a/browser/base/content/test/general/browser_bug594131.js b/browser/base/content/test/general/browser_bug594131.js
new file mode 100644
index 0000000000..db06b69425
--- /dev/null
+++ b/browser/base/content/test/general/browser_bug594131.js
@@ -0,0 +1,25 @@
+/* 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/. */
+
+function test() {
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ let newTab = BrowserTestUtils.addTab(gBrowser, "http://example.com");
+ waitForExplicitFinish();
+ BrowserTestUtils.browserLoaded(newTab.linkedBrowser).then(mainPart);
+
+ function mainPart() {
+ gBrowser.pinTab(newTab);
+ gBrowser.selectedTab = newTab;
+
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ openTrustedLinkIn("http://example.org/", "current", {
+ inBackground: true,
+ });
+ isnot(gBrowser.selectedTab, newTab, "shouldn't load in background");
+
+ gBrowser.removeTab(newTab);
+ gBrowser.removeTab(gBrowser.tabs[1]); // example.org tab
+ finish();
+ }
+}
diff --git a/browser/base/content/test/general/browser_bug596687.js b/browser/base/content/test/general/browser_bug596687.js
new file mode 100644
index 0000000000..8c68cd5a03
--- /dev/null
+++ b/browser/base/content/test/general/browser_bug596687.js
@@ -0,0 +1,28 @@
+add_task(async function test() {
+ var tab = await BrowserTestUtils.openNewForegroundTab(gBrowser);
+
+ var gotTabAttrModified = false;
+ var gotTabClose = false;
+
+ function onTabClose() {
+ gotTabClose = true;
+ tab.addEventListener("TabAttrModified", onTabAttrModified);
+ }
+
+ function onTabAttrModified() {
+ gotTabAttrModified = true;
+ }
+
+ tab.addEventListener("TabClose", onTabClose);
+
+ BrowserTestUtils.removeTab(tab);
+
+ ok(gotTabClose, "should have got the TabClose event");
+ ok(
+ !gotTabAttrModified,
+ "shouldn't have got the TabAttrModified event after TabClose"
+ );
+
+ tab.removeEventListener("TabClose", onTabClose);
+ tab.removeEventListener("TabAttrModified", onTabAttrModified);
+});
diff --git a/browser/base/content/test/general/browser_bug597218.js b/browser/base/content/test/general/browser_bug597218.js
new file mode 100644
index 0000000000..963912c9da
--- /dev/null
+++ b/browser/base/content/test/general/browser_bug597218.js
@@ -0,0 +1,40 @@
+/* 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/. */
+
+function test() {
+ waitForExplicitFinish();
+
+ // establish initial state
+ is(gBrowser.tabs.length, 1, "we start with one tab");
+
+ // create a tab
+ let tab = gBrowser.addTab("about:blank", {
+ triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
+ });
+ ok(!tab.hidden, "tab starts out not hidden");
+ is(gBrowser.tabs.length, 2, "we now have two tabs");
+
+ // make sure .hidden is read-only
+ tab.hidden = true;
+ ok(!tab.hidden, "can't set .hidden directly");
+
+ // hide the tab
+ gBrowser.hideTab(tab);
+ ok(tab.hidden, "tab is hidden");
+
+ // now pin it and make sure it gets unhidden
+ gBrowser.pinTab(tab);
+ ok(tab.pinned, "tab was pinned");
+ ok(!tab.hidden, "tab was unhidden");
+
+ // try hiding it now that it's pinned; shouldn't be able to
+ gBrowser.hideTab(tab);
+ ok(!tab.hidden, "tab did not hide");
+
+ // clean up
+ gBrowser.removeTab(tab);
+ is(gBrowser.tabs.length, 1, "we finish with one tab");
+
+ finish();
+}
diff --git a/browser/base/content/test/general/browser_bug609700.js b/browser/base/content/test/general/browser_bug609700.js
new file mode 100644
index 0000000000..8195eba4ec
--- /dev/null
+++ b/browser/base/content/test/general/browser_bug609700.js
@@ -0,0 +1,28 @@
+function test() {
+ waitForExplicitFinish();
+
+ Services.ww.registerNotification(function notification(
+ aSubject,
+ aTopic,
+ aData
+ ) {
+ if (aTopic == "domwindowopened") {
+ Services.ww.unregisterNotification(notification);
+
+ ok(true, "duplicateTabIn opened a new window");
+
+ whenDelayedStartupFinished(
+ aSubject,
+ function () {
+ executeSoon(function () {
+ aSubject.close();
+ finish();
+ });
+ },
+ false
+ );
+ }
+ });
+
+ duplicateTabIn(gBrowser.selectedTab, "window");
+}
diff --git a/browser/base/content/test/general/browser_bug623893.js b/browser/base/content/test/general/browser_bug623893.js
new file mode 100644
index 0000000000..27751e74ad
--- /dev/null
+++ b/browser/base/content/test/general/browser_bug623893.js
@@ -0,0 +1,50 @@
+add_task(async function test() {
+ await BrowserTestUtils.withNewTab(
+ "data:text/plain;charset=utf-8,1",
+ async function (browser) {
+ BrowserTestUtils.loadURIString(
+ browser,
+ "data:text/plain;charset=utf-8,2"
+ );
+ await BrowserTestUtils.browserLoaded(browser);
+
+ BrowserTestUtils.loadURIString(
+ browser,
+ "data:text/plain;charset=utf-8,3"
+ );
+ await BrowserTestUtils.browserLoaded(browser);
+
+ await duplicate(0, "maintained the original index");
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+
+ await duplicate(-1, "went back");
+ await duplicate(1, "went forward");
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ }
+ );
+});
+
+async function promiseGetIndex(browser) {
+ if (!SpecialPowers.Services.appinfo.sessionHistoryInParent) {
+ return SpecialPowers.spawn(browser, [], function () {
+ let shistory =
+ docShell.browsingContext.childSessionHistory.legacySHistory;
+ return shistory.index;
+ });
+ }
+
+ let shistory = browser.browsingContext.sessionHistory;
+ return shistory.index;
+}
+
+let duplicate = async function (delta, msg, cb) {
+ var startIndex = await promiseGetIndex(gBrowser.selectedBrowser);
+
+ duplicateTabIn(gBrowser.selectedTab, "tab", delta);
+
+ await BrowserTestUtils.waitForEvent(gBrowser.tabContainer, "SSTabRestored");
+
+ let endIndex = await promiseGetIndex(gBrowser.selectedBrowser);
+ is(endIndex, startIndex + delta, msg);
+};
diff --git a/browser/base/content/test/general/browser_bug624734.js b/browser/base/content/test/general/browser_bug624734.js
new file mode 100644
index 0000000000..962c62c5d8
--- /dev/null
+++ b/browser/base/content/test/general/browser_bug624734.js
@@ -0,0 +1,49 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// Bug 624734 - Star UI has no tooltip until bookmarked page is visited
+
+function finishTest() {
+ let elem = document.getElementById("context-bookmarkpage");
+ let l10n = document.l10n.getAttributes(elem);
+ ok(
+ [
+ "main-context-menu-bookmark-page",
+ "main-context-menu-bookmark-page-with-shortcut",
+ "main-context-menu-bookmark-page-mac",
+ ].includes(l10n.id)
+ );
+
+ gBrowser.removeCurrentTab();
+ finish();
+}
+
+function test() {
+ waitForExplicitFinish();
+
+ let tab = (gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser));
+ CustomizableUI.addWidgetToArea(
+ "bookmarks-menu-button",
+ CustomizableUI.AREA_NAVBAR,
+ 0
+ );
+ BrowserTestUtils.browserLoaded(tab.linkedBrowser).then(() => {
+ if (BookmarkingUI.status == BookmarkingUI.STATUS_UPDATING) {
+ waitForCondition(
+ () => BookmarkingUI.status != BookmarkingUI.STATUS_UPDATING,
+ finishTest,
+ "BookmarkingUI was updating for too long"
+ );
+ } else {
+ CustomizableUI.removeWidgetFromArea("bookmarks-menu-button");
+ finishTest();
+ }
+ });
+
+ BrowserTestUtils.loadURIString(
+ tab.linkedBrowser,
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com/browser/browser/base/content/test/general/dummy_page.html"
+ );
+}
diff --git a/browser/base/content/test/general/browser_bug664672.js b/browser/base/content/test/general/browser_bug664672.js
new file mode 100644
index 0000000000..4f9dbcea9f
--- /dev/null
+++ b/browser/base/content/test/general/browser_bug664672.js
@@ -0,0 +1,27 @@
+function test() {
+ waitForExplicitFinish();
+
+ var tab = BrowserTestUtils.addTab(gBrowser);
+
+ tab.addEventListener(
+ "TabClose",
+ function () {
+ ok(
+ tab.linkedBrowser,
+ "linkedBrowser should still exist during the TabClose event"
+ );
+
+ executeSoon(function () {
+ ok(
+ !tab.linkedBrowser,
+ "linkedBrowser should be gone after the TabClose event"
+ );
+
+ finish();
+ });
+ },
+ { once: true }
+ );
+
+ gBrowser.removeTab(tab);
+}
diff --git a/browser/base/content/test/general/browser_bug676619.js b/browser/base/content/test/general/browser_bug676619.js
new file mode 100644
index 0000000000..24d8d88447
--- /dev/null
+++ b/browser/base/content/test/general/browser_bug676619.js
@@ -0,0 +1,225 @@
+var MockFilePicker = SpecialPowers.MockFilePicker;
+MockFilePicker.init(window);
+
+function waitForNewWindow() {
+ return new Promise(resolve => {
+ var listener = {
+ onOpenWindow: aXULWindow => {
+ info("Download window shown...");
+ Services.wm.removeListener(listener);
+
+ function downloadOnLoad() {
+ domwindow.removeEventListener("load", downloadOnLoad, true);
+
+ is(
+ domwindow.document.location.href,
+ "chrome://mozapps/content/downloads/unknownContentType.xhtml",
+ "Download page appeared"
+ );
+ resolve(domwindow);
+ }
+
+ var domwindow = aXULWindow.docShell.domWindow;
+ domwindow.addEventListener("load", downloadOnLoad, true);
+ },
+ onCloseWindow: aXULWindow => {},
+ };
+
+ Services.wm.addListener(listener);
+ registerCleanupFunction(() => {
+ try {
+ Services.wm.removeListener(listener);
+ } catch (e) {}
+ });
+ });
+}
+
+async function waitForFilePickerTest(link, name) {
+ let filePickerShownPromise = new Promise(resolve => {
+ MockFilePicker.showCallback = function (fp) {
+ ok(true, "Filepicker shown.");
+ is(name, fp.defaultString, " filename matches download name");
+ setTimeout(resolve, 0);
+ return Ci.nsIFilePicker.returnCancel;
+ };
+ });
+
+ SpecialPowers.spawn(gBrowser.selectedBrowser, [link], contentLink => {
+ content.document.getElementById(contentLink).click();
+ });
+
+ await filePickerShownPromise;
+
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => {
+ Assert.equal(
+ content.document.getElementById("unload-flag").textContent,
+ "Okay",
+ "beforeunload shouldn't have fired"
+ );
+ });
+}
+
+async function testLink(link, name) {
+ info("Checking " + link + " with name: " + name);
+
+ if (
+ Services.prefs.getBoolPref(
+ "browser.download.always_ask_before_handling_new_types",
+ false
+ )
+ ) {
+ let winPromise = waitForNewWindow();
+
+ SpecialPowers.spawn(gBrowser.selectedBrowser, [link], contentLink => {
+ content.document.getElementById(contentLink).click();
+ });
+
+ let win = await winPromise;
+
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => {
+ Assert.equal(
+ content.document.getElementById("unload-flag").textContent,
+ "Okay",
+ "beforeunload shouldn't have fired"
+ );
+ });
+
+ is(
+ win.document.getElementById("location").value,
+ name,
+ `file name should match (${link})`
+ );
+
+ await BrowserTestUtils.closeWindow(win);
+ } else {
+ await waitForFilePickerTest(link, name);
+ }
+}
+
+// Cross-origin URL does not trigger a download
+async function testLocation(link, url) {
+ let tabPromise = BrowserTestUtils.waitForNewTab(gBrowser);
+
+ SpecialPowers.spawn(gBrowser.selectedBrowser, [link], contentLink => {
+ content.document.getElementById(contentLink).click();
+ });
+
+ let tab = await tabPromise;
+ BrowserTestUtils.removeTab(tab);
+}
+
+async function runTest(url) {
+ let tab = BrowserTestUtils.addTab(gBrowser, url);
+ gBrowser.selectedTab = tab;
+
+ let browser = gBrowser.getBrowserForTab(tab);
+ await BrowserTestUtils.browserLoaded(browser);
+
+ await testLink("link1", "test.txt");
+ await testLink("link2", "video.ogg");
+ await testLink("link3", "just some video.ogg");
+ await testLink("link4", "with-target.txt");
+ await testLink("link5", "javascript.html");
+ await testLink("link6", "test.blob");
+ await testLink("link7", "test.file");
+ await testLink("link8", "download_page_3.txt");
+ await testLink("link9", "download_page_3.txt");
+ await testLink("link10", "download_page_4.txt");
+ await testLink("link11", "download_page_4.txt");
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ await testLocation("link12", "http://example.com/");
+
+ // Check that we enforce the correct extension if the website's
+ // is bogus or missing. These extensions can differ slightly (ogx vs ogg,
+ // htm vs html) on different OSes.
+ let oggExtension = getMIMEInfoForType("application/ogg").primaryExtension;
+ await testLink("link13", "no file extension." + oggExtension);
+
+ // See https://bugzilla.mozilla.org/show_bug.cgi?id=1690051#c8
+ if (AppConstants.platform != "win") {
+ const PREF = "browser.download.sanitize_non_media_extensions";
+ ok(Services.prefs.getBoolPref(PREF), "pref is set before");
+
+ // Check that ics (iCal) extension is changed/fixed when the pref is true.
+ await testLink("link14", "dummy.ics");
+
+ // And not changed otherwise.
+ Services.prefs.setBoolPref(PREF, false);
+ await testLink("link14", "dummy.not-ics");
+ Services.prefs.clearUserPref(PREF);
+ }
+
+ await testLink("link15", "download_page_3.txt");
+ await testLink("link16", "download_page_3.txt");
+ await testLink("link17", "download_page_4.txt");
+ await testLink("link18", "download_page_4.txt");
+ await testLink("link19", "download_page_4.txt");
+ await testLink("link20", "download_page_4.txt");
+ await testLink("link21", "download_page_4.txt");
+ await testLink("link22", "download_page_4.txt");
+
+ BrowserTestUtils.removeTab(tab);
+}
+
+async function setDownloadDir() {
+ let tmpDir = PathUtils.join(
+ PathUtils.tempDir,
+ "testsavedir" + Math.floor(Math.random() * 2 ** 32)
+ );
+ // Create this dir if it doesn't exist (ignores existing dirs)
+ await IOUtils.makeDirectory(tmpDir);
+ registerCleanupFunction(async function () {
+ try {
+ await IOUtils.remove(tmpDir, { recursive: true });
+ } catch (e) {
+ console.error(e);
+ }
+ Services.prefs.clearUserPref("browser.download.folderList");
+ Services.prefs.clearUserPref("browser.download.dir");
+ });
+ Services.prefs.setIntPref("browser.download.folderList", 2);
+ Services.prefs.setCharPref("browser.download.dir", tmpDir);
+}
+
+add_task(async function () {
+ requestLongerTimeout(3);
+ waitForExplicitFinish();
+
+ await setDownloadDir();
+
+ info(
+ "Test with browser.download.always_ask_before_handling_new_types enabled."
+ );
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.download.always_ask_before_handling_new_types", true],
+ ["browser.download.useDownloadDir", true],
+ ],
+ });
+
+ await runTest(
+ "http://mochi.test:8888/browser/browser/base/content/test/general/download_page.html"
+ );
+ await runTest(
+ "https://example.com:443/browser/browser/base/content/test/general/download_page.html"
+ );
+
+ info(
+ "Test with browser.download.always_ask_before_handling_new_types disabled."
+ );
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.download.always_ask_before_handling_new_types", false],
+ ["browser.download.useDownloadDir", false],
+ ],
+ });
+
+ await runTest(
+ "http://mochi.test:8888/browser/browser/base/content/test/general/download_page.html"
+ );
+ await runTest(
+ "https://example.com:443/browser/browser/base/content/test/general/download_page.html"
+ );
+
+ MockFilePicker.cleanup();
+});
diff --git a/browser/base/content/test/general/browser_bug710878.js b/browser/base/content/test/general/browser_bug710878.js
new file mode 100644
index 0000000000..a91f8f9a1e
--- /dev/null
+++ b/browser/base/content/test/general/browser_bug710878.js
@@ -0,0 +1,49 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const PAGE =
+ "data:text/html;charset=utf-8,<a href='%23xxx'><span>word1 <span> word2 </span></span><span> word3</span></a>";
+
+/**
+ * Tests that we correctly compute the text for context menu
+ * selection of some content.
+ */
+add_task(async function () {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: PAGE,
+ },
+ async function (browser) {
+ let contextMenu = document.getElementById("contentAreaContextMenu");
+ let awaitPopupShown = BrowserTestUtils.waitForEvent(
+ contextMenu,
+ "popupshown"
+ );
+ let awaitPopupHidden = BrowserTestUtils.waitForEvent(
+ contextMenu,
+ "popuphidden"
+ );
+
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "a",
+ {
+ type: "contextmenu",
+ button: 2,
+ },
+ browser
+ );
+
+ await awaitPopupShown;
+
+ is(
+ gContextMenu.linkTextStr,
+ "word1 word2 word3",
+ "Text under link is correctly computed."
+ );
+
+ contextMenu.hidePopup();
+ await awaitPopupHidden;
+ }
+ );
+});
diff --git a/browser/base/content/test/general/browser_bug724239.js b/browser/base/content/test/general/browser_bug724239.js
new file mode 100644
index 0000000000..78290e21f5
--- /dev/null
+++ b/browser/base/content/test/general/browser_bug724239.js
@@ -0,0 +1,56 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const { TabStateFlusher } = ChromeUtils.importESModule(
+ "resource:///modules/sessionstore/TabStateFlusher.sys.mjs"
+);
+
+add_task(async function test_blank() {
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: "about:blank" },
+ async function (browser) {
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ BrowserTestUtils.loadURIString(browser, "http://example.com");
+ await BrowserTestUtils.browserLoaded(browser);
+ ok(!gBrowser.canGoBack, "about:blank wasn't added to session history");
+ }
+ );
+});
+
+add_task(async function test_newtab() {
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: "about:blank" },
+ async function (browser) {
+ // Can't load it directly because that'll use a preloaded tab if present.
+ let stopped = BrowserTestUtils.browserStopped(browser, "about:newtab");
+ BrowserTestUtils.loadURIString(browser, "about:newtab");
+ await stopped;
+
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ stopped = BrowserTestUtils.browserStopped(browser, "http://example.com/");
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ BrowserTestUtils.loadURIString(browser, "http://example.com/");
+ await stopped;
+
+ // This makes sure the parent process has the most up-to-date notion
+ // of the tab's session history.
+ await TabStateFlusher.flush(browser);
+
+ let tab = gBrowser.getTabForBrowser(browser);
+ let tabState = JSON.parse(SessionStore.getTabState(tab));
+ Assert.equal(
+ tabState.entries.length,
+ 2,
+ "We should have 2 entries in the session history."
+ );
+
+ Assert.equal(
+ tabState.entries[0].url,
+ "about:newtab",
+ "about:newtab should be the first entry."
+ );
+
+ Assert.ok(gBrowser.canGoBack, "Should be able to browse back.");
+ }
+ );
+});
diff --git a/browser/base/content/test/general/browser_bug734076.js b/browser/base/content/test/general/browser_bug734076.js
new file mode 100644
index 0000000000..9e7bcf5977
--- /dev/null
+++ b/browser/base/content/test/general/browser_bug734076.js
@@ -0,0 +1,195 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+add_task(async function () {
+ // allow top level data: URI navigations, otherwise loading data: URIs
+ // in toplevel windows fail.
+ await SpecialPowers.pushPrefEnv({
+ set: [["security.data_uri.block_toplevel_data_uri_navigations", false]],
+ });
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, null, false);
+
+ tab.linkedBrowser.stop(); // stop the about:blank load
+
+ let writeDomainURL = encodeURI(
+ "data:text/html,<script>document.write(document.domain);</script>"
+ );
+
+ let tests = [
+ {
+ name: "view image with background image",
+ url: "http://mochi.test:8888/",
+ element: "body",
+ opensNewTab: true,
+ go() {
+ return SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [{ writeDomainURL }],
+ async function (arg) {
+ let contentBody = content.document.body;
+ contentBody.style.backgroundImage =
+ "url('" + arg.writeDomainURL + "')";
+
+ return "context-viewimage";
+ }
+ );
+ },
+ verify(browser) {
+ return SpecialPowers.spawn(browser, [], async function (arg) {
+ Assert.equal(
+ content.document.body.textContent,
+ "",
+ "no domain was inherited for view image with background image"
+ );
+ });
+ },
+ },
+ {
+ name: "view image",
+ url: "http://mochi.test:8888/",
+ element: "img",
+ opensNewTab: true,
+ go() {
+ return SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [{ writeDomainURL }],
+ async function (arg) {
+ let doc = content.document;
+ let img = doc.createElement("img");
+ img.height = 100;
+ img.width = 100;
+ img.setAttribute("src", arg.writeDomainURL);
+ doc.body.insertBefore(img, doc.body.firstElementChild);
+
+ return "context-viewimage";
+ }
+ );
+ },
+ verify(browser) {
+ return SpecialPowers.spawn(browser, [], async function (arg) {
+ Assert.equal(
+ content.document.body.textContent,
+ "",
+ "no domain was inherited for view image"
+ );
+ });
+ },
+ },
+ {
+ name: "show only this frame",
+ url: "http://mochi.test:8888/",
+ element: "html",
+ frameIndex: 0,
+ go() {
+ return SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [{ writeDomainURL }],
+ async function (arg) {
+ let doc = content.document;
+ let iframe = doc.createElement("iframe");
+ iframe.setAttribute("src", arg.writeDomainURL);
+ doc.body.insertBefore(iframe, doc.body.firstElementChild);
+
+ // Wait for the iframe to load.
+ return new Promise(resolve => {
+ iframe.addEventListener(
+ "load",
+ function () {
+ resolve("context-showonlythisframe");
+ },
+ { capture: true, once: true }
+ );
+ });
+ }
+ );
+ },
+ verify(browser) {
+ return SpecialPowers.spawn(browser, [], async function (arg) {
+ Assert.equal(
+ content.document.body.textContent,
+ "",
+ "no domain was inherited for 'show only this frame'"
+ );
+ });
+ },
+ },
+ ];
+
+ let contentAreaContextMenu = document.getElementById(
+ "contentAreaContextMenu"
+ );
+
+ for (let test of tests) {
+ let loadedPromise = BrowserTestUtils.browserLoaded(
+ gBrowser.selectedBrowser
+ );
+ BrowserTestUtils.loadURIString(gBrowser, test.url);
+ await loadedPromise;
+
+ info("Run subtest " + test.name);
+ let commandToRun = await test.go();
+
+ let popupShownPromise = BrowserTestUtils.waitForEvent(
+ contentAreaContextMenu,
+ "popupshown"
+ );
+
+ let browsingContext = gBrowser.selectedBrowser.browsingContext;
+ if (test.frameIndex != null) {
+ browsingContext = browsingContext.children[test.frameIndex];
+ }
+
+ await new Promise(r => {
+ SimpleTest.executeSoon(r);
+ });
+
+ // Sometimes, the iframe test fails as the child iframe hasn't finishing layout
+ // yet. Try again in this case.
+ while (true) {
+ try {
+ await BrowserTestUtils.synthesizeMouse(
+ test.element,
+ 3,
+ 3,
+ { type: "contextmenu", button: 2 },
+ browsingContext
+ );
+ } catch (ex) {
+ continue;
+ }
+ break;
+ }
+
+ await popupShownPromise;
+ info("onImage: " + gContextMenu.onImage);
+
+ let loadedAfterCommandPromise = test.opensNewTab
+ ? BrowserTestUtils.waitForNewTab(gBrowser, null, true)
+ : BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+ let popupHiddenPromise = BrowserTestUtils.waitForEvent(
+ contentAreaContextMenu,
+ "popuphidden"
+ );
+ if (commandToRun == "context-showonlythisframe") {
+ let subMenu = document.getElementById("frame");
+ let subMenuShown = BrowserTestUtils.waitForEvent(subMenu, "popupshown");
+ subMenu.openMenu(true);
+ await subMenuShown;
+ }
+ contentAreaContextMenu.activateItem(document.getElementById(commandToRun));
+ let result = await loadedAfterCommandPromise;
+
+ await test.verify(
+ test.opensNewTab ? result.linkedBrowser : gBrowser.selectedBrowser
+ );
+
+ await popupHiddenPromise;
+
+ if (test.opensNewTab) {
+ gBrowser.removeCurrentTab();
+ }
+ }
+
+ gBrowser.removeCurrentTab();
+});
diff --git a/browser/base/content/test/general/browser_bug749738.js b/browser/base/content/test/general/browser_bug749738.js
new file mode 100644
index 0000000000..4430e5d8a7
--- /dev/null
+++ b/browser/base/content/test/general/browser_bug749738.js
@@ -0,0 +1,32 @@
+/* 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 DUMMY_PAGE =
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.org/browser/browser/base/content/test/general/dummy_page.html";
+
+/**
+ * This test checks that if you search for something on one tab, then close
+ * that tab and have the find bar open on the next tab you get switched to,
+ * closing the find bar in that tab works without exceptions.
+ */
+add_task(async function test_bug749738() {
+ // Open find bar on initial tab.
+ await gFindBarPromise;
+
+ await BrowserTestUtils.withNewTab(DUMMY_PAGE, async function () {
+ await gFindBarPromise;
+ gFindBar.onFindCommand();
+ EventUtils.sendString("Dummy");
+ });
+
+ try {
+ gFindBar.close();
+ ok(true, "findbar.close should not throw an exception");
+ } catch (e) {
+ ok(false, "findbar.close threw exception: " + e);
+ }
+});
diff --git a/browser/base/content/test/general/browser_bug763468_perwindowpb.js b/browser/base/content/test/general/browser_bug763468_perwindowpb.js
new file mode 100644
index 0000000000..bed03561ca
--- /dev/null
+++ b/browser/base/content/test/general/browser_bug763468_perwindowpb.js
@@ -0,0 +1,57 @@
+/* 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 makes sure that opening a new tab in private browsing mode opens about:privatebrowsing
+add_task(async function testPBNewTab() {
+ registerCleanupFunction(async function () {
+ for (let win of windowsToClose) {
+ await BrowserTestUtils.closeWindow(win);
+ }
+ });
+
+ let windowsToClose = [];
+
+ async function doTest(aIsPrivateMode) {
+ let newTabURL;
+ let mode;
+ let win = await BrowserTestUtils.openNewBrowserWindow({
+ private: aIsPrivateMode,
+ });
+ windowsToClose.push(win);
+
+ if (aIsPrivateMode) {
+ mode = "per window private browsing";
+ newTabURL = "about:privatebrowsing";
+ } else {
+ mode = "normal";
+ newTabURL = "about:newtab";
+ }
+ await openNewTab(win, newTabURL);
+
+ is(
+ win.gBrowser.currentURI.spec,
+ newTabURL,
+ "URL of NewTab should be " + newTabURL + " in " + mode + " mode"
+ );
+ }
+
+ await doTest(false);
+ await doTest(true);
+ await doTest(false);
+});
+
+async function openNewTab(aWindow, aExpectedURL) {
+ // Open a new tab
+ aWindow.BrowserOpenTab();
+ let browser = aWindow.gBrowser.selectedBrowser;
+
+ // We're already loaded.
+ if (browser.currentURI.spec === aExpectedURL) {
+ return;
+ }
+
+ // Wait for any location change.
+ await BrowserTestUtils.waitForLocationChange(aWindow.gBrowser);
+}
diff --git a/browser/base/content/test/general/browser_bug767836_perwindowpb.js b/browser/base/content/test/general/browser_bug767836_perwindowpb.js
new file mode 100644
index 0000000000..7fcc6ad565
--- /dev/null
+++ b/browser/base/content/test/general/browser_bug767836_perwindowpb.js
@@ -0,0 +1,72 @@
+/* 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";
+
+async function doTest(isPrivate) {
+ let win = await BrowserTestUtils.openNewBrowserWindow({ private: isPrivate });
+ let defaultURL = AboutNewTab.newTabURL;
+ let newTabURL;
+ let mode;
+ let testURL = "https://example.com/";
+ if (isPrivate) {
+ mode = "per window private browsing";
+ newTabURL = "about:privatebrowsing";
+ } else {
+ mode = "normal";
+ newTabURL = "about:newtab";
+ }
+
+ await openNewTab(win, newTabURL);
+ // Check the new tab opened while in normal/private mode
+ is(
+ win.gBrowser.selectedBrowser.currentURI.spec,
+ newTabURL,
+ "URL of NewTab should be " + newTabURL + " in " + mode + " mode"
+ );
+
+ // Set the custom newtab url
+ AboutNewTab.newTabURL = testURL;
+ is(AboutNewTab.newTabURL, testURL, "Custom newtab url is set");
+
+ // Open a newtab after setting the custom newtab url
+ await openNewTab(win, testURL);
+ is(
+ win.gBrowser.selectedBrowser.currentURI.spec,
+ testURL,
+ "URL of NewTab should be the custom url"
+ );
+
+ // Clear the custom url.
+ AboutNewTab.resetNewTabURL();
+ is(AboutNewTab.newTabURL, defaultURL, "No custom newtab url is set");
+
+ win.gBrowser.removeTab(win.gBrowser.selectedTab);
+ win.gBrowser.removeTab(win.gBrowser.selectedTab);
+ await BrowserTestUtils.closeWindow(win);
+}
+
+add_task(async function test_newTabService() {
+ // check whether any custom new tab url has been configured
+ ok(!AboutNewTab.newTabURLOverridden, "No custom newtab url is set");
+
+ // test normal mode
+ await doTest(false);
+
+ // test private mode
+ await doTest(true);
+});
+
+async function openNewTab(aWindow, aExpectedURL) {
+ // Open a new tab
+ aWindow.BrowserOpenTab();
+ let browser = aWindow.gBrowser.selectedBrowser;
+
+ // We're already loaded.
+ if (browser.currentURI.spec === aExpectedURL) {
+ return;
+ }
+
+ // Wait for any location change.
+ await BrowserTestUtils.waitForLocationChange(aWindow.gBrowser);
+}
diff --git a/browser/base/content/test/general/browser_bug817947.js b/browser/base/content/test/general/browser_bug817947.js
new file mode 100644
index 0000000000..eba54aea5b
--- /dev/null
+++ b/browser/base/content/test/general/browser_bug817947.js
@@ -0,0 +1,51 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const URL = "http://mochi.test:8888/browser/";
+const PREF = "browser.sessionstore.restore_on_demand";
+
+add_task(async () => {
+ Services.prefs.setBoolPref(PREF, true);
+ registerCleanupFunction(function () {
+ Services.prefs.clearUserPref(PREF);
+ });
+
+ let tab = await preparePendingTab();
+
+ let deferredTab = PromiseUtils.defer();
+
+ let win = gBrowser.replaceTabWithWindow(tab);
+ win.addEventListener(
+ "before-initial-tab-adopted",
+ async () => {
+ let [newTab] = win.gBrowser.tabs;
+ await BrowserTestUtils.browserLoaded(newTab.linkedBrowser);
+ deferredTab.resolve(newTab);
+ },
+ { once: true }
+ );
+
+ let newTab = await deferredTab.promise;
+ is(newTab.linkedBrowser.currentURI.spec, URL, "correct url should be loaded");
+ ok(!newTab.hasAttribute("pending"), "tab should not be pending");
+
+ win.close();
+});
+
+async function preparePendingTab(aCallback) {
+ let tab = BrowserTestUtils.addTab(gBrowser, URL);
+ await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+
+ let sessionUpdatePromise = BrowserTestUtils.waitForSessionStoreUpdate(tab);
+ BrowserTestUtils.removeTab(tab);
+ await sessionUpdatePromise;
+
+ let [{ state }] = SessionStore.getClosedTabDataForWindow(window);
+
+ tab = BrowserTestUtils.addTab(gBrowser, "about:blank");
+ await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ SessionStore.setTabState(tab, JSON.stringify(state));
+ ok(tab.hasAttribute("pending"), "tab should be pending");
+
+ return tab;
+}
diff --git a/browser/base/content/test/general/browser_bug832435.js b/browser/base/content/test/general/browser_bug832435.js
new file mode 100644
index 0000000000..c3140608c5
--- /dev/null
+++ b/browser/base/content/test/general/browser_bug832435.js
@@ -0,0 +1,26 @@
+/* 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/. */
+
+function test() {
+ waitForExplicitFinish();
+ ok(true, "Starting up");
+
+ gBrowser.selectedBrowser.focus();
+ gURLBar.addEventListener(
+ "focus",
+ function () {
+ ok(true, "Invoked onfocus handler");
+ EventUtils.synthesizeKey("VK_RETURN", { shiftKey: true });
+
+ // javscript: URIs are evaluated async.
+ SimpleTest.executeSoon(function () {
+ ok(true, "Evaluated without crashing");
+ finish();
+ });
+ },
+ { once: true }
+ );
+ gURLBar.inputField.value = "javascript: var foo = '11111111'; ";
+ gURLBar.focus();
+}
diff --git a/browser/base/content/test/general/browser_bug882977.js b/browser/base/content/test/general/browser_bug882977.js
new file mode 100644
index 0000000000..116f01b349
--- /dev/null
+++ b/browser/base/content/test/general/browser_bug882977.js
@@ -0,0 +1,33 @@
+"use strict";
+
+/**
+ * Tests that the identity-box shows the chromeUI styling
+ * when viewing such a page in a new window.
+ */
+add_task(async function () {
+ let homepage = "about:preferences";
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.startup.homepage", homepage],
+ ["browser.startup.page", 1],
+ ],
+ });
+
+ let win = OpenBrowserWindow();
+ await BrowserTestUtils.firstBrowserLoaded(win, false);
+
+ let browser = win.gBrowser.selectedBrowser;
+ is(browser.currentURI.spec, homepage, "Loaded the correct homepage");
+ checkIdentityMode(win);
+
+ await BrowserTestUtils.closeWindow(win);
+});
+
+function checkIdentityMode(win) {
+ let identityMode = win.document.getElementById("identity-box").className;
+ is(
+ identityMode,
+ "chromeUI",
+ "Identity state should be chromeUI for about:home in a new window"
+ );
+}
diff --git a/browser/base/content/test/general/browser_bug963945.js b/browser/base/content/test/general/browser_bug963945.js
new file mode 100644
index 0000000000..688d8b79ff
--- /dev/null
+++ b/browser/base/content/test/general/browser_bug963945.js
@@ -0,0 +1,26 @@
+/* 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/. */
+
+/*
+ * This test ensures the about:addons tab is only
+ * opened one time when in private browsing.
+ */
+
+add_task(async function test() {
+ let win = await BrowserTestUtils.openNewBrowserWindow({ private: true });
+
+ let tab = (win.gBrowser.selectedTab = BrowserTestUtils.addTab(
+ win.gBrowser,
+ "about:addons"
+ ));
+ await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ await promiseWaitForFocus(win);
+
+ EventUtils.synthesizeKey("a", { ctrlKey: true, shiftKey: true }, win);
+
+ is(win.gBrowser.tabs.length, 2, "about:addons tab was re-focused.");
+ is(win.gBrowser.currentURI.spec, "about:addons", "Addons tab was opened.");
+
+ await BrowserTestUtils.closeWindow(win);
+});
diff --git a/browser/base/content/test/general/browser_clipboard.js b/browser/base/content/test/general/browser_clipboard.js
new file mode 100644
index 0000000000..a4c823969f
--- /dev/null
+++ b/browser/base/content/test/general/browser_clipboard.js
@@ -0,0 +1,290 @@
+// This test is used to check copy and paste in editable areas to ensure that non-text
+// types (html and images) are copied to and pasted from the clipboard properly.
+
+var testPage =
+ "<body style='margin: 0'>" +
+ " <img id='img' tabindex='1' src='http://example.org/browser/browser/base/content/test/general/moz.png'>" +
+ " <div id='main' contenteditable='true'>Test <b>Bold</b> After Text</div>" +
+ "</body>";
+
+add_task(async function () {
+ let tab = BrowserTestUtils.addTab(gBrowser);
+ let browser = gBrowser.getBrowserForTab(tab);
+
+ gBrowser.selectedTab = tab;
+
+ await promiseTabLoadEvent(tab, "data:text/html," + escape(testPage));
+ await SimpleTest.promiseFocus(browser);
+
+ function sendKey(key, code) {
+ return BrowserTestUtils.synthesizeKey(
+ key,
+ { code, accelKey: true },
+ browser
+ );
+ }
+
+ // On windows, HTML clipboard includes extra data.
+ // The values are from widget/windows/nsDataObj.cpp.
+ const htmlPrefix = navigator.platform.includes("Win")
+ ? "<html><body>\n<!--StartFragment-->"
+ : "";
+ const htmlPostfix = navigator.platform.includes("Win")
+ ? "<!--EndFragment-->\n</body>\n</html>"
+ : "";
+
+ await SpecialPowers.spawn(browser, [], () => {
+ var doc = content.document;
+ var main = doc.getElementById("main");
+ main.focus();
+
+ // Select an area of the text.
+ let selection = doc.getSelection();
+ selection.modify("move", "left", "line");
+ selection.modify("move", "right", "character");
+ selection.modify("move", "right", "character");
+ selection.modify("move", "right", "character");
+ selection.modify("extend", "right", "word");
+ selection.modify("extend", "right", "word");
+ });
+
+ // The data is empty as the selection was copied during the event default phase.
+ let copyEventPromise = BrowserTestUtils.waitForContentEvent(
+ browser,
+ "copy",
+ false,
+ event => {
+ return event.clipboardData.mozItemCount == 0;
+ }
+ );
+ await SpecialPowers.spawn(browser, [], () => {});
+ await sendKey("c");
+ await copyEventPromise;
+
+ let pastePromise = SpecialPowers.spawn(
+ browser,
+ [htmlPrefix, htmlPostfix],
+ (htmlPrefixChild, htmlPostfixChild) => {
+ let selection = content.document.getSelection();
+ selection.modify("move", "right", "line");
+
+ return new Promise((resolve, reject) => {
+ content.addEventListener(
+ "paste",
+ event => {
+ let clipboardData = event.clipboardData;
+ Assert.equal(
+ clipboardData.mozItemCount,
+ 1,
+ "One item on clipboard"
+ );
+ Assert.equal(
+ clipboardData.types.length,
+ 2,
+ "Two types on clipboard"
+ );
+ Assert.equal(
+ clipboardData.types[0],
+ "text/html",
+ "text/html on clipboard"
+ );
+ Assert.equal(
+ clipboardData.types[1],
+ "text/plain",
+ "text/plain on clipboard"
+ );
+ Assert.equal(
+ clipboardData.getData("text/html"),
+ htmlPrefixChild + "t <b>Bold</b>" + htmlPostfixChild,
+ "text/html value"
+ );
+ Assert.equal(
+ clipboardData.getData("text/plain"),
+ "t Bold",
+ "text/plain value"
+ );
+ resolve();
+ },
+ { capture: true, once: true }
+ );
+ });
+ }
+ );
+
+ await SpecialPowers.spawn(browser, [], () => {});
+
+ await sendKey("v");
+ await pastePromise;
+
+ let copyPromise = SpecialPowers.spawn(browser, [], () => {
+ var main = content.document.getElementById("main");
+
+ Assert.equal(
+ main.innerHTML,
+ "Test <b>Bold</b> After Textt <b>Bold</b>",
+ "Copy and paste html"
+ );
+
+ let selection = content.document.getSelection();
+ selection.modify("extend", "left", "word");
+ selection.modify("extend", "left", "word");
+ selection.modify("extend", "left", "character");
+
+ return new Promise((resolve, reject) => {
+ content.addEventListener(
+ "cut",
+ event => {
+ event.clipboardData.setData("text/plain", "Some text");
+ event.clipboardData.setData("text/html", "<i>Italic</i> ");
+ selection.deleteFromDocument();
+ event.preventDefault();
+ resolve();
+ },
+ { capture: true, once: true }
+ );
+ });
+ });
+
+ await SpecialPowers.spawn(browser, [], () => {});
+
+ await sendKey("x");
+ await copyPromise;
+
+ pastePromise = SpecialPowers.spawn(
+ browser,
+ [htmlPrefix, htmlPostfix],
+ (htmlPrefixChild, htmlPostfixChild) => {
+ let selection = content.document.getSelection();
+ selection.modify("move", "left", "line");
+
+ return new Promise((resolve, reject) => {
+ content.addEventListener(
+ "paste",
+ event => {
+ let clipboardData = event.clipboardData;
+ Assert.equal(
+ clipboardData.mozItemCount,
+ 1,
+ "One item on clipboard 2"
+ );
+ Assert.equal(
+ clipboardData.types.length,
+ 2,
+ "Two types on clipboard 2"
+ );
+ Assert.equal(
+ clipboardData.types[0],
+ "text/html",
+ "text/html on clipboard 2"
+ );
+ Assert.equal(
+ clipboardData.types[1],
+ "text/plain",
+ "text/plain on clipboard 2"
+ );
+ Assert.equal(
+ clipboardData.getData("text/html"),
+ htmlPrefixChild + "<i>Italic</i> " + htmlPostfixChild,
+ "text/html value 2"
+ );
+ Assert.equal(
+ clipboardData.getData("text/plain"),
+ "Some text",
+ "text/plain value 2"
+ );
+ resolve();
+ },
+ { capture: true, once: true }
+ );
+ });
+ }
+ );
+
+ await SpecialPowers.spawn(browser, [], () => {});
+
+ await sendKey("v");
+ await pastePromise;
+
+ await SpecialPowers.spawn(browser, [], () => {
+ var main = content.document.getElementById("main");
+ Assert.equal(
+ main.innerHTML,
+ "<i>Italic</i> Test <b>Bold</b> After<b></b>",
+ "Copy and paste html 2"
+ );
+ });
+
+ // Next, check that the Copy Image command works.
+
+ // The context menu needs to be opened to properly initialize for the copy
+ // image command to run.
+ let contextMenu = document.getElementById("contentAreaContextMenu");
+ let contextMenuShown = promisePopupShown(contextMenu);
+ BrowserTestUtils.synthesizeMouseAtCenter(
+ "#img",
+ { type: "contextmenu", button: 2 },
+ gBrowser.selectedBrowser
+ );
+ await contextMenuShown;
+
+ document.getElementById("context-copyimage-contents").doCommand();
+
+ contextMenu.hidePopup();
+ await promisePopupHidden(contextMenu);
+
+ // Focus the content again
+ await SimpleTest.promiseFocus(browser);
+
+ pastePromise = SpecialPowers.spawn(
+ browser,
+ [htmlPrefix, htmlPostfix],
+ (htmlPrefixChild, htmlPostfixChild) => {
+ var doc = content.document;
+ var main = doc.getElementById("main");
+ main.focus();
+
+ return new Promise((resolve, reject) => {
+ content.addEventListener(
+ "paste",
+ event => {
+ let clipboardData = event.clipboardData;
+
+ // DataTransfer doesn't support the image types yet, so only text/html
+ // will be present.
+ if (
+ clipboardData.getData("text/html") !==
+ htmlPrefixChild +
+ '<img id="img" tabindex="1" src="http://example.org/browser/browser/base/content/test/general/moz.png">' +
+ htmlPostfixChild
+ ) {
+ reject(
+ "Clipboard Data did not contain an image, was " +
+ clipboardData.getData("text/html")
+ );
+ }
+ resolve();
+ },
+ { capture: true, once: true }
+ );
+ });
+ }
+ );
+
+ await SpecialPowers.spawn(browser, [], () => {});
+ await sendKey("v");
+ await pastePromise;
+
+ // The new content should now include an image.
+ await SpecialPowers.spawn(browser, [], () => {
+ var main = content.document.getElementById("main");
+ Assert.equal(
+ main.innerHTML,
+ '<i>Italic</i> <img id="img" tabindex="1" ' +
+ 'src="http://example.org/browser/browser/base/content/test/general/moz.png">' +
+ "Test <b>Bold</b> After<b></b>",
+ "Paste after copy image"
+ );
+ });
+
+ gBrowser.removeCurrentTab();
+});
diff --git a/browser/base/content/test/general/browser_clipboard_pastefile.js b/browser/base/content/test/general/browser_clipboard_pastefile.js
new file mode 100644
index 0000000000..f034883ef2
--- /dev/null
+++ b/browser/base/content/test/general/browser_clipboard_pastefile.js
@@ -0,0 +1,133 @@
+/* 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/. */
+
+// Test that (real) files can be pasted into chrome/content.
+// Pasting files should also hide all other data from content.
+
+function setClipboard(path) {
+ const file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+ file.initWithPath(path);
+
+ const trans = Cc["@mozilla.org/widget/transferable;1"].createInstance(
+ Ci.nsITransferable
+ );
+ trans.init(null);
+ trans.addDataFlavor("application/x-moz-file");
+ trans.setTransferData("application/x-moz-file", file);
+
+ trans.addDataFlavor("text/plain");
+ const str = Cc["@mozilla.org/supports-string;1"].createInstance(
+ Ci.nsISupportsString
+ );
+ str.data = "Alternate";
+ trans.setTransferData("text/plain", str);
+
+ // Write to clipboard.
+ Services.clipboard.setData(trans, null, Ci.nsIClipboard.kGlobalClipboard);
+}
+
+add_task(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [["dom.events.dataTransfer.mozFile.enabled", true]],
+ });
+
+ // Create a temporary file that will be pasted.
+ const file = await IOUtils.createUniqueFile(
+ PathUtils.tempDir,
+ "test-file.txt",
+ 0o600
+ );
+ await IOUtils.writeUTF8(file, "Hello World!");
+
+ // Put the data directly onto the native clipboard to make sure
+ // it isn't handled internally in Gecko somehow.
+ setClipboard(file);
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "https://example.com/browser/browser/base/content/test/general/clipboard_pastefile.html"
+ );
+ let browser = tab.linkedBrowser;
+
+ let resultPromise = SpecialPowers.spawn(browser, [], function (arg) {
+ return new Promise(resolve => {
+ content.document.addEventListener("testresult", event => {
+ resolve(event.detail.result);
+ });
+ });
+ });
+
+ // Focus <input> in content
+ await SpecialPowers.spawn(browser, [], async function () {
+ content.document.getElementById("input").focus();
+ });
+
+ // Paste file into <input> in content
+ await BrowserTestUtils.synthesizeKey("v", { accelKey: true }, browser);
+
+ let result = await resultPromise;
+ is(result, PathUtils.filename(file), "Correctly pasted file in content");
+
+ var input = document.createElement("input");
+ document.documentElement.appendChild(input);
+ input.focus();
+
+ await new Promise((resolve, reject) => {
+ input.addEventListener(
+ "paste",
+ function (event) {
+ let dt = event.clipboardData;
+ is(dt.types.length, 3, "number of types");
+ ok(dt.types.includes("text/plain"), "text/plain exists in types");
+ ok(
+ dt.types.includes("application/x-moz-file"),
+ "application/x-moz-file exists in types"
+ );
+ is(dt.types[2], "Files", "Last type should be 'Files'");
+ ok(
+ dt.mozTypesAt(0).contains("text/plain"),
+ "text/plain exists in mozTypesAt"
+ );
+ is(
+ dt.getData("text/plain"),
+ "Alternate",
+ "text/plain returned in getData"
+ );
+ is(
+ dt.mozGetDataAt("text/plain", 0),
+ "Alternate",
+ "text/plain returned in mozGetDataAt"
+ );
+
+ ok(
+ dt.mozTypesAt(0).contains("application/x-moz-file"),
+ "application/x-moz-file exists in mozTypesAt"
+ );
+ let mozFile = dt.mozGetDataAt("application/x-moz-file", 0);
+
+ ok(
+ mozFile instanceof Ci.nsIFile,
+ "application/x-moz-file returned nsIFile with mozGetDataAt"
+ );
+
+ is(
+ mozFile.leafName,
+ PathUtils.filename(file),
+ "nsIFile has correct leafName"
+ );
+
+ resolve();
+ },
+ { capture: true, once: true }
+ );
+
+ EventUtils.synthesizeKey("v", { accelKey: true });
+ });
+
+ input.remove();
+
+ BrowserTestUtils.removeTab(tab);
+
+ await IOUtils.remove(file);
+});
diff --git a/browser/base/content/test/general/browser_contentAltClick.js b/browser/base/content/test/general/browser_contentAltClick.js
new file mode 100644
index 0000000000..5f659d3351
--- /dev/null
+++ b/browser/base/content/test/general/browser_contentAltClick.js
@@ -0,0 +1,205 @@
+/* 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/. */
+
+/**
+ * Test for Bug 1109146.
+ * The tests opens a new tab and alt + clicks to download files
+ * and confirms those files are on the download list.
+ *
+ * The difference between this and the test "browser_contentAreaClick.js" is that
+ * the code path in e10s uses the ClickHandler actor instead of browser.js::contentAreaClick() util.
+ */
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ Downloads: "resource://gre/modules/Downloads.sys.mjs",
+});
+
+function setup() {
+ Services.prefs.setBoolPref("browser.altClickSave", true);
+
+ let testPage =
+ "data:text/html," +
+ '<p><a id="commonlink" href="http://mochi.test/moz/">Common link</a></p>' +
+ '<p><math id="mathlink" xmlns="http://www.w3.org/1998/Math/MathML" href="http://mochi.test/moz/"><mtext>MathML XLink</mtext></math></p>' +
+ '<p><svg id="svgxlink" xmlns="http://www.w3.org/2000/svg" width="100px" height="50px" version="1.1"><a xlink:type="simple" xlink:href="http://mochi.test/moz/"><text transform="translate(10, 25)">SVG XLink</text></a></svg></p><br>' +
+ '<span id="host"></span><script>document.getElementById("host").attachShadow({mode: "closed"}).appendChild(document.getElementById("commonlink").cloneNode(true));</script>' +
+ '<iframe id="frame" src="https://test2.example.com:443/browser/browser/base/content/test/general/file_with_link_to_http.html"></iframe>';
+
+ return BrowserTestUtils.openNewForegroundTab(gBrowser, testPage);
+}
+
+async function clean_up() {
+ // Remove downloads.
+ let downloadList = await Downloads.getList(Downloads.ALL);
+ let downloads = await downloadList.getAll();
+ for (let download of downloads) {
+ await downloadList.remove(download);
+ await download.finalize(true);
+ }
+ // Remove download history.
+ await PlacesUtils.history.clear();
+
+ Services.prefs.clearUserPref("browser.altClickSave");
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+}
+
+add_task(async function test_alt_click() {
+ await setup();
+
+ let downloadList = await Downloads.getList(Downloads.ALL);
+ let downloads = [];
+ let downloadView;
+ // When 1 download has been attempted then resolve the promise.
+ let finishedAllDownloads = new Promise(resolve => {
+ downloadView = {
+ onDownloadAdded(aDownload) {
+ downloads.push(aDownload);
+ resolve();
+ },
+ };
+ });
+ await downloadList.addView(downloadView);
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#commonlink",
+ { altKey: true },
+ gBrowser.selectedBrowser
+ );
+
+ // Wait for all downloads to be added to the download list.
+ await finishedAllDownloads;
+ await downloadList.removeView(downloadView);
+
+ is(downloads.length, 1, "1 downloads");
+ is(
+ downloads[0].source.url,
+ "http://mochi.test/moz/",
+ "Downloaded #commonlink element"
+ );
+
+ await clean_up();
+});
+
+add_task(async function test_alt_click_shadow_dom() {
+ await setup();
+
+ let downloadList = await Downloads.getList(Downloads.ALL);
+ let downloads = [];
+ let downloadView;
+ // When 1 download has been attempted then resolve the promise.
+ let finishedAllDownloads = new Promise(resolve => {
+ downloadView = {
+ onDownloadAdded(aDownload) {
+ downloads.push(aDownload);
+ resolve();
+ },
+ };
+ });
+ await downloadList.addView(downloadView);
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#host",
+ { altKey: true },
+ gBrowser.selectedBrowser
+ );
+
+ // Wait for all downloads to be added to the download list.
+ await finishedAllDownloads;
+ await downloadList.removeView(downloadView);
+
+ is(downloads.length, 1, "1 downloads");
+ is(
+ downloads[0].source.url,
+ "http://mochi.test/moz/",
+ "Downloaded #commonlink element in shadow DOM."
+ );
+
+ await clean_up();
+});
+
+add_task(async function test_alt_click_on_xlinks() {
+ await setup();
+
+ let downloadList = await Downloads.getList(Downloads.ALL);
+ let downloads = [];
+ let downloadView;
+ // When all 2 downloads have been attempted then resolve the promise.
+ let finishedAllDownloads = new Promise(resolve => {
+ downloadView = {
+ onDownloadAdded(aDownload) {
+ downloads.push(aDownload);
+ if (downloads.length == 2) {
+ resolve();
+ }
+ },
+ };
+ });
+ await downloadList.addView(downloadView);
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#mathlink",
+ { altKey: true },
+ gBrowser.selectedBrowser
+ );
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#svgxlink",
+ { altKey: true },
+ gBrowser.selectedBrowser
+ );
+
+ // Wait for all downloads to be added to the download list.
+ await finishedAllDownloads;
+ await downloadList.removeView(downloadView);
+
+ is(downloads.length, 2, "2 downloads");
+ is(
+ downloads[0].source.url,
+ "http://mochi.test/moz/",
+ "Downloaded #mathlink element"
+ );
+ is(
+ downloads[1].source.url,
+ "http://mochi.test/moz/",
+ "Downloaded #svgxlink element"
+ );
+
+ await clean_up();
+});
+
+// Alt+Click a link in a frame from another domain as the outer document.
+add_task(async function test_alt_click_in_frame() {
+ await setup();
+
+ let downloadList = await Downloads.getList(Downloads.ALL);
+ let downloads = [];
+ let downloadView;
+ // When the download has been attempted, resolve the promise.
+ let finishedAllDownloads = new Promise(resolve => {
+ downloadView = {
+ onDownloadAdded(aDownload) {
+ downloads.push(aDownload);
+ resolve();
+ },
+ };
+ });
+
+ await downloadList.addView(downloadView);
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#linkToExample",
+ { altKey: true },
+ gBrowser.selectedBrowser.browsingContext.children[0]
+ );
+
+ // Wait for all downloads to be added to the download list.
+ await finishedAllDownloads;
+ await downloadList.removeView(downloadView);
+
+ is(downloads.length, 1, "1 downloads");
+ is(
+ downloads[0].source.url,
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.org/",
+ "Downloaded link in iframe."
+ );
+
+ await clean_up();
+});
diff --git a/browser/base/content/test/general/browser_ctrlTab.js b/browser/base/content/test/general/browser_ctrlTab.js
new file mode 100644
index 0000000000..7c4a7b6c23
--- /dev/null
+++ b/browser/base/content/test/general/browser_ctrlTab.js
@@ -0,0 +1,464 @@
+/* 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/. */
+
+add_task(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.ctrlTab.sortByRecentlyUsed", true]],
+ });
+
+ BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.addTab(gBrowser);
+
+ // While doing this test, we should make sure the selected tab in the tab
+ // preview is not changed by mouse events. That may happen after closing
+ // the selected tab with ctrl+W. Disable all mouse events to prevent it.
+ for (let node of ctrlTab.previews) {
+ node.style.pointerEvents = "none";
+ }
+ registerCleanupFunction(function () {
+ for (let node of ctrlTab.previews) {
+ try {
+ node.style.removeProperty("pointer-events");
+ } catch (e) {}
+ }
+ });
+
+ checkTabs(4);
+
+ await ctrlTabTest([2], 1, 0);
+ await ctrlTabTest([2, 3, 1], 2, 2);
+ await ctrlTabTest([], 4, 2);
+
+ {
+ let selectedIndex = gBrowser.tabContainer.selectedIndex;
+ await pressCtrlTab();
+ await pressCtrlTab(true);
+ await releaseCtrl();
+ is(
+ gBrowser.tabContainer.selectedIndex,
+ selectedIndex,
+ "Ctrl+Tab -> Ctrl+Shift+Tab keeps the selected tab"
+ );
+ }
+
+ {
+ info("test for bug 445369");
+ let tabs = gBrowser.tabs.length;
+ await pressCtrlTab();
+ await synthesizeCtrlW();
+ is(gBrowser.tabs.length, tabs - 1, "Ctrl+Tab -> Ctrl+W removes one tab");
+ await releaseCtrl();
+ }
+
+ {
+ info("test for bug 667314");
+ let tabs = gBrowser.tabs.length;
+ await pressCtrlTab();
+ await pressCtrlTab(true);
+ await synthesizeCtrlW();
+ is(
+ gBrowser.tabs.length,
+ tabs - 1,
+ "Ctrl+Tab -> Ctrl+W removes the selected tab"
+ );
+ await releaseCtrl();
+ }
+
+ BrowserTestUtils.addTab(gBrowser);
+ checkTabs(3);
+ await ctrlTabTest([2, 1, 0], 7, 1);
+
+ {
+ info("test for bug 1292049");
+ let tabToClose = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:buildconfig"
+ );
+ checkTabs(4);
+ selectTabs([0, 1, 2, 3]);
+
+ let promise = BrowserTestUtils.waitForSessionStoreUpdate(tabToClose);
+ BrowserTestUtils.removeTab(tabToClose);
+ await promise;
+ checkTabs(3);
+ undoCloseTab();
+ checkTabs(4);
+ is(
+ gBrowser.tabContainer.selectedIndex,
+ 3,
+ "tab is selected after closing and restoring it"
+ );
+
+ await ctrlTabTest([], 1, 2);
+ }
+
+ {
+ info("test for bug 445369");
+ checkTabs(4);
+ selectTabs([1, 2, 0]);
+
+ let selectedTab = gBrowser.selectedTab;
+ let tabToRemove = gBrowser.tabs[1];
+
+ await pressCtrlTab();
+ await pressCtrlTab();
+ await synthesizeCtrlW();
+ ok(
+ !tabToRemove.parentNode,
+ "Ctrl+Tab*2 -> Ctrl+W removes the second most recently selected tab"
+ );
+
+ await pressCtrlTab(true);
+ await pressCtrlTab(true);
+ await releaseCtrl();
+ ok(
+ selectedTab.selected,
+ "Ctrl+Tab*2 -> Ctrl+W -> Ctrl+Shift+Tab*2 keeps the selected tab"
+ );
+ }
+ gBrowser.removeTab(gBrowser.tabs[gBrowser.tabs.length - 1]);
+ checkTabs(2);
+
+ await ctrlTabTest([1], 1, 0);
+
+ gBrowser.removeTab(gBrowser.tabs[gBrowser.tabs.length - 1]);
+ checkTabs(1);
+
+ {
+ info("test for bug 445768");
+ let focusedWindow = document.commandDispatcher.focusedWindow;
+ let eventConsumed = true;
+ let detectKeyEvent = function (event) {
+ eventConsumed = event.defaultPrevented;
+ };
+ document.addEventListener("keypress", detectKeyEvent);
+ await pressCtrlTab();
+ document.removeEventListener("keypress", detectKeyEvent);
+ ok(
+ eventConsumed,
+ "Ctrl+Tab consumed by the tabbed browser if one tab is open"
+ );
+ is(
+ focusedWindow,
+ document.commandDispatcher.focusedWindow,
+ "Ctrl+Tab doesn't change focus if one tab is open"
+ );
+ }
+
+ // eslint-disable-next-line no-lone-blocks
+ {
+ info("Bug 1731050: test hidden tabs");
+ checkTabs(1);
+ await BrowserTestUtils.addTab(gBrowser);
+ await BrowserTestUtils.addTab(gBrowser);
+ await BrowserTestUtils.addTab(gBrowser);
+ await BrowserTestUtils.addTab(gBrowser);
+ FirefoxViewHandler.tab = await BrowserTestUtils.addTab(gBrowser);
+
+ gBrowser.hideTab(FirefoxViewHandler.tab);
+ FirefoxViewHandler.openTab();
+ selectTabs([1, 2, 3, 4, 3]);
+ gBrowser.hideTab(gBrowser.tabs[4]);
+ selectTabs([2]);
+ gBrowser.hideTab(gBrowser.tabs[3]);
+
+ is(gBrowser.tabs[5].hidden, true, "Tab at index 5 is hidden");
+ is(gBrowser.tabs[4].hidden, true, "Tab at index 4 is hidden");
+ is(gBrowser.tabs[3].hidden, true, "Tab at index 3 is hidden");
+ is(gBrowser.tabs[2].hidden, false, "Tab at index 2 is still shown");
+ is(gBrowser.tabs[1].hidden, false, "Tab at index 1 is still shown");
+ is(gBrowser.tabs[0].hidden, false, "Tab at index 0 is still shown");
+
+ await ctrlTabTest([], 1, 1);
+ await ctrlTabTest([], 2, 0);
+ gBrowser.showTab(gBrowser.tabs[4]);
+ await ctrlTabTest([2], 3, 4);
+ await ctrlTabTest([], 4, 4);
+ gBrowser.showTab(gBrowser.tabs[3]);
+ await ctrlTabTest([], 4, 3);
+ await ctrlTabTest([], 6, 4);
+ FirefoxViewHandler.openTab();
+ // Fx View tab should be visible in the panel while selected.
+ await ctrlTabTest([], 5, 1);
+ // Fx View tab should no longer be visible.
+ await ctrlTabTest([], 1, 4);
+
+ for (let i = 5; i > 0; i--) {
+ await BrowserTestUtils.removeTab(gBrowser.tabs[i]);
+ }
+ FirefoxViewHandler.tab = null;
+ info("End hidden tabs test");
+ }
+
+ {
+ info("Bug 1293692: Test asynchronous tab previews");
+
+ checkTabs(1);
+
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.pagethumbnails.capturing_disabled", false]],
+ });
+
+ await BrowserTestUtils.addTab(gBrowser);
+ await BrowserTestUtils.addTab(gBrowser);
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ `${getRootDirectory(gTestPath)}dummy_page.html`
+ );
+
+ info("Pressing Ctrl+Tab to open the panel");
+ ok(canOpen(), "Ctrl+Tab can open the preview panel");
+ let panelShown = BrowserTestUtils.waitForEvent(ctrlTab.panel, "popupshown");
+ EventUtils.synthesizeKey("VK_TAB", { ctrlKey: true });
+ await TestUtils.waitForTick();
+
+ let observedPreview = ctrlTab.previews[0];
+ is(observedPreview._tab, tab, "The observed preview is for the new tab");
+ ok(
+ !observedPreview._canvas.firstElementChild,
+ "The preview <canvas> does not exist yet"
+ );
+
+ let emptyCanvas = PageThumbs.createCanvas(window);
+ let emptyImageData = emptyCanvas
+ .getContext("2d")
+ .getImageData(0, 0, ctrlTab.canvasWidth, ctrlTab.canvasHeight)
+ .data.slice(0, 15)
+ .toString();
+
+ info("Waiting for the preview <canvas> to be loaded");
+ await BrowserTestUtils.waitForMutationCondition(
+ observedPreview._canvas,
+ {
+ childList: true,
+ attributes: true,
+ subtree: true,
+ },
+ () =>
+ HTMLCanvasElement.isInstance(observedPreview._canvas.firstElementChild)
+ );
+
+ // Ensure the image is not blank (see bug 1293692). The canvas shouldn't be
+ // rendered at all until it has image data, but this will allow us to catch
+ // any regressions in the future.
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ emptyImageData !==
+ observedPreview._canvas.firstElementChild
+ .getContext("2d")
+ .getImageData(0, 0, ctrlTab.canvasWidth, ctrlTab.canvasHeight)
+ .data.slice(0, 15)
+ .toString(),
+ "The preview <canvas> should be filled with a thumbnail"
+ );
+
+ // Wait for the panel to be shown.
+ await panelShown;
+ ok(isOpen(), "The preview panel is open");
+
+ // Keep the same tab selected.
+ await pressCtrlTab(true);
+ await releaseCtrl();
+
+ // The next time the panel is open, our preview should now be an <img>, the
+ // thumbnail that was previously drawn in a <canvas> having been cached and
+ // now being immediately available for reuse.
+ info("Pressing Ctrl+Tab to open the panel again");
+ let imgExists = BrowserTestUtils.waitForMutationCondition(
+ observedPreview._canvas,
+ {
+ childList: true,
+ attributes: true,
+ subtree: true,
+ },
+ () => {
+ let img = observedPreview._canvas.firstElementChild;
+ return (
+ img &&
+ HTMLImageElement.isInstance(img) &&
+ img.src &&
+ img.complete &&
+ img.naturalWidth
+ );
+ }
+ );
+ let panelShownAgain = BrowserTestUtils.waitForEvent(
+ ctrlTab.panel,
+ "popupshown"
+ );
+ EventUtils.synthesizeKey("VK_TAB", { ctrlKey: true });
+
+ info("Waiting for the preview <img> to be loaded");
+ await imgExists;
+ ok(
+ true,
+ `The preview image is an <img> with src="${observedPreview._canvas.firstElementChild.src}"`
+ );
+ await panelShownAgain;
+ await releaseCtrl();
+
+ for (let i = gBrowser.tabs.length - 1; i > 0; i--) {
+ await BrowserTestUtils.removeTab(gBrowser.tabs[i]);
+ }
+ checkTabs(1);
+ }
+
+ /* private utility functions */
+
+ /**
+ * @return the number of times (Shift+)Ctrl+Tab was pressed
+ */
+ async function pressCtrlTab(aShiftKey = false) {
+ let promise;
+ if (!isOpen() && canOpen()) {
+ ok(
+ !aShiftKey,
+ "Shouldn't attempt to open the panel by pressing Shift+Ctrl+Tab"
+ );
+ info("Pressing Ctrl+Tab to open the panel");
+ promise = BrowserTestUtils.waitForEvent(ctrlTab.panel, "popupshown");
+ } else {
+ info(
+ `Pressing ${aShiftKey ? "Shift+" : ""}Ctrl+Tab while the panel is open`
+ );
+ promise = BrowserTestUtils.waitForEvent(document, "keyup");
+ }
+ EventUtils.synthesizeKey("VK_TAB", {
+ ctrlKey: true,
+ shiftKey: !!aShiftKey,
+ });
+ await promise;
+ if (document.activeElement == ctrlTab.showAllButton) {
+ info("Repeating keypress to skip over the 'List all tabs' button");
+ return 1 + (await pressCtrlTab(aShiftKey));
+ }
+ return 1;
+ }
+
+ async function releaseCtrl() {
+ let promise;
+ if (isOpen()) {
+ promise = BrowserTestUtils.waitForEvent(ctrlTab.panel, "popuphidden");
+ } else {
+ promise = BrowserTestUtils.waitForEvent(document, "keyup");
+ }
+ EventUtils.synthesizeKey("VK_CONTROL", { type: "keyup" });
+ await promise;
+ }
+
+ async function synthesizeCtrlW() {
+ let promise = BrowserTestUtils.waitForEvent(
+ gBrowser.tabContainer,
+ "TabClose"
+ );
+ EventUtils.synthesizeKey("w", { ctrlKey: true });
+ await promise;
+ }
+
+ function isOpen() {
+ return ctrlTab.isOpen;
+ }
+
+ function canOpen() {
+ return (
+ Services.prefs.getBoolPref("browser.ctrlTab.sortByRecentlyUsed") &&
+ gBrowser.tabs.length > 2
+ );
+ }
+
+ function checkTabs(aTabs) {
+ is(gBrowser.tabs.length, aTabs, "number of open tabs should be " + aTabs);
+ }
+
+ function selectTabs(tabs) {
+ tabs.forEach(function (index) {
+ gBrowser.selectedTab = gBrowser.tabs[index];
+ });
+ }
+
+ async function ctrlTabTest(tabsToSelect, tabTimes, expectedIndex) {
+ selectTabs(tabsToSelect);
+
+ var indexStart = gBrowser.tabContainer.selectedIndex;
+ var tabCount = gBrowser.visibleTabs.length;
+ var normalized = tabTimes % tabCount;
+ var where =
+ normalized == 1
+ ? "back to the previously selected tab"
+ : normalized + " tabs back in most-recently-selected order";
+
+ // Add keyup listener to all content documents.
+ await Promise.all(
+ gBrowser.tabs.map(tab =>
+ SpecialPowers.spawn(tab.linkedBrowser, [], () => {
+ if (!content.windowGlobalChild?.isInProcess) {
+ content.window.addEventListener("keyup", () => {
+ content.window._ctrlTabTestKeyupHappend = true;
+ });
+ }
+ })
+ )
+ );
+
+ let numTimesPressed = 0;
+ for (let i = 0; i < tabTimes; i++) {
+ numTimesPressed += await pressCtrlTab();
+
+ if (tabCount > 2) {
+ is(
+ gBrowser.tabContainer.selectedIndex,
+ indexStart,
+ "Selected tab doesn't change while tabbing"
+ );
+ }
+ }
+
+ if (tabCount > 2) {
+ ok(
+ isOpen(),
+ "With " + tabCount + " visible tabs, Ctrl+Tab opens the preview panel"
+ );
+
+ await releaseCtrl();
+
+ ok(!isOpen(), "Releasing Ctrl closes the preview panel");
+ } else {
+ ok(
+ !isOpen(),
+ "With " +
+ tabCount +
+ " visible tabs, Ctrl+Tab doesn't open the preview panel"
+ );
+ }
+
+ is(
+ gBrowser.tabContainer.selectedIndex,
+ expectedIndex,
+ "With " +
+ tabCount +
+ " visible tabs and tab " +
+ indexStart +
+ " selected, Ctrl+Tab*" +
+ numTimesPressed +
+ " goes " +
+ where
+ );
+
+ const keyupEvents = await Promise.all(
+ gBrowser.tabs.map(tab =>
+ SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [],
+ () => !!content.window._ctrlTabTestKeyupHappend
+ )
+ )
+ );
+ ok(
+ keyupEvents.every(isKeyupHappned => !isKeyupHappned),
+ "Content document doesn't capture Keyup event during cycling tabs"
+ );
+ }
+});
diff --git a/browser/base/content/test/general/browser_datachoices_notification.js b/browser/base/content/test/general/browser_datachoices_notification.js
new file mode 100644
index 0000000000..eb2ec5ee37
--- /dev/null
+++ b/browser/base/content/test/general/browser_datachoices_notification.js
@@ -0,0 +1,287 @@
+/* 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";
+
+var { Preferences } = ChromeUtils.importESModule(
+ "resource://gre/modules/Preferences.sys.mjs"
+);
+var { TelemetryReportingPolicy } = ChromeUtils.importESModule(
+ "resource://gre/modules/TelemetryReportingPolicy.sys.mjs"
+);
+
+const PREF_BRANCH = "datareporting.policy.";
+const PREF_FIRST_RUN = "toolkit.telemetry.reportingpolicy.firstRun";
+const PREF_BYPASS_NOTIFICATION =
+ PREF_BRANCH + "dataSubmissionPolicyBypassNotification";
+const PREF_CURRENT_POLICY_VERSION = PREF_BRANCH + "currentPolicyVersion";
+const PREF_ACCEPTED_POLICY_VERSION =
+ PREF_BRANCH + "dataSubmissionPolicyAcceptedVersion";
+const PREF_ACCEPTED_POLICY_DATE =
+ PREF_BRANCH + "dataSubmissionPolicyNotifiedTime";
+
+const PREF_TELEMETRY_LOG_LEVEL = "toolkit.telemetry.log.level";
+
+const TEST_POLICY_VERSION = 37;
+
+function fakeShowPolicyTimeout(set, clear) {
+ let reportingPolicy = ChromeUtils.importESModule(
+ "resource://gre/modules/TelemetryReportingPolicy.sys.mjs"
+ ).Policy;
+ reportingPolicy.setShowInfobarTimeout = set;
+ reportingPolicy.clearShowInfobarTimeout = clear;
+}
+
+function sendSessionRestoredNotification() {
+ let reportingPolicy = ChromeUtils.importESModule(
+ "resource://gre/modules/TelemetryReportingPolicy.sys.mjs"
+ ).Policy;
+
+ reportingPolicy.fakeSessionRestoreNotification();
+}
+
+/**
+ * Wait for a tick.
+ */
+function promiseNextTick() {
+ return new Promise(resolve => executeSoon(resolve));
+}
+
+/**
+ * Wait for a notification to be shown in a notification box.
+ * @param {Object} aNotificationBox The notification box.
+ * @return {Promise} Resolved when the notification is displayed.
+ */
+function promiseWaitForAlertActive(aNotificationBox) {
+ let deferred = PromiseUtils.defer();
+ aNotificationBox.stack.addEventListener(
+ "AlertActive",
+ function () {
+ deferred.resolve();
+ },
+ { once: true }
+ );
+ return deferred.promise;
+}
+
+/**
+ * Wait for a notification to be closed.
+ * @param {Object} aNotification The notification.
+ * @return {Promise} Resolved when the notification is closed.
+ */
+function promiseWaitForNotificationClose(aNotification) {
+ let deferred = PromiseUtils.defer();
+ waitForNotificationClose(aNotification, deferred.resolve);
+ return deferred.promise;
+}
+
+function triggerInfoBar(expectedTimeoutMs) {
+ let showInfobarCallback = null;
+ let timeoutMs = null;
+ fakeShowPolicyTimeout(
+ (callback, timeout) => {
+ showInfobarCallback = callback;
+ timeoutMs = timeout;
+ },
+ () => {}
+ );
+ sendSessionRestoredNotification();
+ Assert.ok(!!showInfobarCallback, "Must have a timer callback.");
+ if (expectedTimeoutMs !== undefined) {
+ Assert.equal(timeoutMs, expectedTimeoutMs, "Timeout should match");
+ }
+ showInfobarCallback();
+}
+
+var checkInfobarButton = async function (aNotification) {
+ // Check that the button on the data choices infobar does the right thing.
+ let buttons = aNotification.buttonContainer.getElementsByTagName("button");
+ Assert.equal(
+ buttons.length,
+ 1,
+ "There is 1 button in the data reporting notification."
+ );
+ let button = buttons[0];
+
+ // Click on the button.
+ button.click();
+
+ // Wait for the preferences panel to open.
+ await promiseNextTick();
+};
+
+add_setup(async function () {
+ const isFirstRun = Preferences.get(PREF_FIRST_RUN, true);
+ const bypassNotification = Preferences.get(PREF_BYPASS_NOTIFICATION, true);
+ const currentPolicyVersion = Preferences.get(PREF_CURRENT_POLICY_VERSION, 1);
+
+ // Register a cleanup function to reset our preferences.
+ registerCleanupFunction(() => {
+ Preferences.set(PREF_FIRST_RUN, isFirstRun);
+ Preferences.set(PREF_BYPASS_NOTIFICATION, bypassNotification);
+ Preferences.set(PREF_CURRENT_POLICY_VERSION, currentPolicyVersion);
+ Preferences.reset(PREF_TELEMETRY_LOG_LEVEL);
+
+ return closeAllNotifications();
+ });
+
+ // Don't skip the infobar visualisation.
+ Preferences.set(PREF_BYPASS_NOTIFICATION, false);
+ // Set the current policy version.
+ Preferences.set(PREF_CURRENT_POLICY_VERSION, TEST_POLICY_VERSION);
+ // Ensure this isn't the first run, because then we open the first run page.
+ Preferences.set(PREF_FIRST_RUN, false);
+ TelemetryReportingPolicy.testUpdateFirstRun();
+});
+
+function clearAcceptedPolicy() {
+ // Reset the accepted policy.
+ Preferences.reset(PREF_ACCEPTED_POLICY_VERSION);
+ Preferences.reset(PREF_ACCEPTED_POLICY_DATE);
+}
+
+function assertCoherentInitialState() {
+ // Make sure that we have a coherent initial state.
+ Assert.equal(
+ Preferences.get(PREF_ACCEPTED_POLICY_VERSION, 0),
+ 0,
+ "No version should be set on init."
+ );
+ Assert.equal(
+ Preferences.get(PREF_ACCEPTED_POLICY_DATE, 0),
+ 0,
+ "No date should be set on init."
+ );
+ Assert.ok(
+ !TelemetryReportingPolicy.testIsUserNotified(),
+ "User not notified about datareporting policy."
+ );
+}
+
+add_task(async function test_single_window() {
+ clearAcceptedPolicy();
+
+ // Close all the notifications, then try to trigger the data choices infobar.
+ await closeAllNotifications();
+
+ assertCoherentInitialState();
+
+ let alertShownPromise = promiseWaitForAlertActive(gNotificationBox);
+ Assert.ok(
+ !TelemetryReportingPolicy.canUpload(),
+ "User should not be allowed to upload."
+ );
+
+ // Wait for the infobar to be displayed.
+ triggerInfoBar(10 * 1000);
+ await alertShownPromise;
+
+ Assert.equal(
+ gNotificationBox.allNotifications.length,
+ 1,
+ "Notification Displayed."
+ );
+ Assert.ok(
+ TelemetryReportingPolicy.canUpload(),
+ "User should be allowed to upload now."
+ );
+
+ await promiseNextTick();
+ let promiseClosed = promiseWaitForNotificationClose(
+ gNotificationBox.currentNotification
+ );
+ await checkInfobarButton(gNotificationBox.currentNotification);
+ await promiseClosed;
+
+ Assert.equal(
+ gNotificationBox.allNotifications.length,
+ 0,
+ "No notifications remain."
+ );
+
+ // Check that we are still clear to upload and that the policy data is saved.
+ Assert.ok(TelemetryReportingPolicy.canUpload());
+ Assert.equal(
+ TelemetryReportingPolicy.testIsUserNotified(),
+ true,
+ "User notified about datareporting policy."
+ );
+ Assert.equal(
+ Preferences.get(PREF_ACCEPTED_POLICY_VERSION, 0),
+ TEST_POLICY_VERSION,
+ "Version pref set."
+ );
+ Assert.greater(
+ parseInt(Preferences.get(PREF_ACCEPTED_POLICY_DATE, null), 10),
+ -1,
+ "Date pref set."
+ );
+});
+
+/* See bug 1571932
+add_task(async function test_multiple_windows() {
+ clearAcceptedPolicy();
+
+ // Close all the notifications, then try to trigger the data choices infobar.
+ await closeAllNotifications();
+
+ // Ensure we see the notification on all windows and that action on one window
+ // results in dismiss on every window.
+ let otherWindow = await BrowserTestUtils.openNewBrowserWindow();
+
+ Assert.ok(
+ otherWindow.gNotificationBox,
+ "2nd window has a global notification box."
+ );
+
+ assertCoherentInitialState();
+
+ let showAlertPromises = [
+ promiseWaitForAlertActive(gNotificationBox),
+ promiseWaitForAlertActive(otherWindow.gNotificationBox),
+ ];
+
+ Assert.ok(
+ !TelemetryReportingPolicy.canUpload(),
+ "User should not be allowed to upload."
+ );
+
+ // Wait for the infobars.
+ triggerInfoBar(10 * 1000);
+ await Promise.all(showAlertPromises);
+
+ // Both notification were displayed. Close one and check that both gets closed.
+ let closeAlertPromises = [
+ promiseWaitForNotificationClose(gNotificationBox.currentNotification),
+ promiseWaitForNotificationClose(
+ otherWindow.gNotificationBox.currentNotification
+ ),
+ ];
+ gNotificationBox.currentNotification.close();
+ await Promise.all(closeAlertPromises);
+
+ // Close the second window we opened.
+ await BrowserTestUtils.closeWindow(otherWindow);
+
+ // Check that we are clear to upload and that the policy data us saved.
+ Assert.ok(
+ TelemetryReportingPolicy.canUpload(),
+ "User should be allowed to upload now."
+ );
+ Assert.equal(
+ TelemetryReportingPolicy.testIsUserNotified(),
+ true,
+ "User notified about datareporting policy."
+ );
+ Assert.equal(
+ Preferences.get(PREF_ACCEPTED_POLICY_VERSION, 0),
+ TEST_POLICY_VERSION,
+ "Version pref set."
+ );
+ Assert.greater(
+ parseInt(Preferences.get(PREF_ACCEPTED_POLICY_DATE, null), 10),
+ -1,
+ "Date pref set."
+ );
+});*/
diff --git a/browser/base/content/test/general/browser_documentnavigation.js b/browser/base/content/test/general/browser_documentnavigation.js
new file mode 100644
index 0000000000..8a4fd2ca6b
--- /dev/null
+++ b/browser/base/content/test/general/browser_documentnavigation.js
@@ -0,0 +1,493 @@
+/*
+ * This test checks that focus is adjusted properly in a browser when pressing F6 and Shift+F6.
+ * There are additional tests in dom/tests/mochitest/chrome/test_focus_docnav.xul which test
+ * non-browser cases.
+ */
+
+var testPage1 =
+ "data:text/html,<html id='html1'><body id='body1'><button id='button1'>Tab 1</button></body></html>";
+var testPage2 =
+ "data:text/html,<html id='html2'><body id='body2'><button id='button2'>Tab 2</button></body></html>";
+var testPage3 =
+ "data:text/html,<html id='html3'><body id='body3' contenteditable='true'><button id='button3'>Tab 3</button></body></html>";
+
+var fm = Services.focus;
+
+async function expectFocusOnF6(
+ backward,
+ expectedDocument,
+ expectedElement,
+ onContent,
+ desc
+) {
+ if (onContent) {
+ let success = await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [expectedElement],
+ async function (expectedElementId) {
+ content.lastResult = "";
+ let contentExpectedElement =
+ content.document.getElementById(expectedElementId);
+ if (!contentExpectedElement) {
+ // Element not found, so look in the child frames.
+ for (let f = 0; f < content.frames.length; f++) {
+ if (content.frames[f].document.getElementById(expectedElementId)) {
+ contentExpectedElement = content.frames[f].document;
+ break;
+ }
+ }
+ } else if (contentExpectedElement.localName == "html") {
+ contentExpectedElement = contentExpectedElement.ownerDocument;
+ }
+
+ if (!contentExpectedElement) {
+ return null;
+ }
+
+ contentExpectedElement.addEventListener(
+ "focus",
+ function () {
+ let details =
+ Services.focus.focusedWindow.document.documentElement.id;
+ if (Services.focus.focusedElement) {
+ details += "," + Services.focus.focusedElement.id;
+ }
+
+ // Assign the result to a temporary place, to be used
+ // by the next spawn call.
+ content.lastResult = details;
+ },
+ { capture: true, once: true }
+ );
+
+ return !!contentExpectedElement;
+ }
+ );
+
+ ok(success, "expected element " + expectedElement + " was found");
+
+ EventUtils.synthesizeKey("VK_F6", { shiftKey: backward });
+
+ let expected = expectedDocument;
+ if (!expectedElement.startsWith("html")) {
+ expected += "," + expectedElement;
+ }
+
+ let result = await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [],
+ async () => {
+ await ContentTaskUtils.waitForCondition(() => content.lastResult);
+ return content.lastResult;
+ }
+ );
+ is(result, expected, desc + " child focus matches");
+ } else {
+ let focusPromise = BrowserTestUtils.waitForEvent(window, "focus", true);
+ EventUtils.synthesizeKey("VK_F6", { shiftKey: backward });
+ await focusPromise;
+ }
+
+ if (typeof expectedElement == "string") {
+ expectedElement = fm.focusedWindow.document.getElementById(expectedElement);
+ }
+
+ if (gMultiProcessBrowser && onContent) {
+ expectedDocument = "main-window";
+ expectedElement = gBrowser.selectedBrowser;
+ }
+
+ is(
+ fm.focusedWindow.document.documentElement.id,
+ expectedDocument,
+ desc + " document matches"
+ );
+ is(
+ fm.focusedElement,
+ expectedElement,
+ desc +
+ " element matches (wanted: " +
+ expectedElement.id +
+ " got: " +
+ fm.focusedElement.id +
+ ")"
+ );
+}
+
+// Load a page and navigate between it and the chrome window.
+add_task(async function () {
+ let page1Promise = BrowserTestUtils.browserLoaded(
+ gBrowser.selectedBrowser,
+ false,
+ testPage1
+ );
+ BrowserTestUtils.loadURIString(gBrowser.selectedBrowser, testPage1);
+ await page1Promise;
+
+ // When the urlbar is focused, pressing F6 should focus the root of the content page.
+ gURLBar.focus();
+ await expectFocusOnF6(
+ false,
+ "html1",
+ "html1",
+ true,
+ "basic focus content page"
+ );
+
+ // When the content is focused, pressing F6 should focus the urlbar.
+ await expectFocusOnF6(
+ false,
+ "main-window",
+ gURLBar.inputField,
+ false,
+ "basic focus content page urlbar"
+ );
+
+ // When a button in content is focused, pressing F6 should focus the urlbar.
+ await expectFocusOnF6(
+ false,
+ "html1",
+ "html1",
+ true,
+ "basic focus content page with button focused"
+ );
+
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () {
+ return content.document.getElementById("button1").focus();
+ });
+
+ await expectFocusOnF6(
+ false,
+ "main-window",
+ gURLBar.inputField,
+ false,
+ "basic focus content page with button focused urlbar"
+ );
+
+ // The document root should be focused, not the button
+ await expectFocusOnF6(
+ false,
+ "html1",
+ "html1",
+ true,
+ "basic focus again content page with button focused"
+ );
+
+ // Check to ensure that the root element is focused
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () {
+ Assert.ok(
+ content.document.activeElement == content.document.documentElement,
+ "basic focus again content page with button focused child root is focused"
+ );
+ });
+});
+
+// Open a second tab. Document focus should skip the background tab.
+add_task(async function () {
+ await BrowserTestUtils.openNewForegroundTab(gBrowser, testPage2);
+
+ await expectFocusOnF6(
+ false,
+ "main-window",
+ gURLBar.inputField,
+ false,
+ "basic focus content page and second tab urlbar"
+ );
+ await expectFocusOnF6(
+ false,
+ "html2",
+ "html2",
+ true,
+ "basic focus content page with second tab"
+ );
+
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+});
+
+// Shift+F6 should navigate backwards. There's only one document here so the effect
+// is the same.
+add_task(async function () {
+ gURLBar.focus();
+ await expectFocusOnF6(
+ true,
+ "html1",
+ "html1",
+ true,
+ "back focus content page"
+ );
+ await expectFocusOnF6(
+ true,
+ "main-window",
+ gURLBar.inputField,
+ false,
+ "back focus content page urlbar"
+ );
+});
+
+// Open the sidebar and navigate between the sidebar, content and top-level window
+add_task(async function () {
+ let sidebar = document.getElementById("sidebar");
+
+ let loadPromise = BrowserTestUtils.waitForEvent(sidebar, "load", true);
+ SidebarUI.toggle("viewBookmarksSidebar");
+ await loadPromise;
+
+ gURLBar.focus();
+ await expectFocusOnF6(
+ false,
+ "bookmarksPanel",
+ sidebar.contentDocument.getElementById("search-box").inputField,
+ false,
+ "focus with sidebar open sidebar"
+ );
+ await expectFocusOnF6(
+ false,
+ "html1",
+ "html1",
+ true,
+ "focus with sidebar open content"
+ );
+ await expectFocusOnF6(
+ false,
+ "main-window",
+ gURLBar.inputField,
+ false,
+ "focus with sidebar urlbar"
+ );
+
+ // Now go backwards
+ await expectFocusOnF6(
+ true,
+ "html1",
+ "html1",
+ true,
+ "back focus with sidebar open content"
+ );
+ await expectFocusOnF6(
+ true,
+ "bookmarksPanel",
+ sidebar.contentDocument.getElementById("search-box").inputField,
+ false,
+ "back focus with sidebar open sidebar"
+ );
+ await expectFocusOnF6(
+ true,
+ "main-window",
+ gURLBar.inputField,
+ false,
+ "back focus with sidebar urlbar"
+ );
+
+ SidebarUI.toggle("viewBookmarksSidebar");
+});
+
+// Navigate when the downloads panel is open
+add_task(async function test_download_focus() {
+ await pushPrefs(
+ ["accessibility.tabfocus", 7],
+ ["browser.download.autohideButton", false],
+ ["security.dialog_enable_delay", 0]
+ );
+ await promiseButtonShown("downloads-button");
+
+ let popupShownPromise = BrowserTestUtils.waitForEvent(
+ document,
+ "popupshown",
+ true
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ document.getElementById("downloads-button"),
+ {}
+ );
+ await popupShownPromise;
+
+ gURLBar.focus();
+ await expectFocusOnF6(
+ false,
+ "main-window",
+ document.getElementById("downloadsHistory"),
+ false,
+ "focus with downloads panel open panel"
+ );
+ await expectFocusOnF6(
+ false,
+ "html1",
+ "html1",
+ true,
+ "focus with downloads panel open"
+ );
+ await expectFocusOnF6(
+ false,
+ "main-window",
+ gURLBar.inputField,
+ false,
+ "focus downloads panel open urlbar"
+ );
+
+ // Now go backwards
+ await expectFocusOnF6(
+ true,
+ "html1",
+ "html1",
+ true,
+ "back focus with downloads panel open"
+ );
+ await expectFocusOnF6(
+ true,
+ "main-window",
+ document.getElementById("downloadsHistory"),
+ false,
+ "back focus with downloads panel open"
+ );
+ await expectFocusOnF6(
+ true,
+ "main-window",
+ gURLBar.inputField,
+ false,
+ "back focus downloads panel open urlbar"
+ );
+
+ let downloadsPopup = document.getElementById("downloadsPanel");
+ let popupHiddenPromise = BrowserTestUtils.waitForEvent(
+ downloadsPopup,
+ "popuphidden",
+ true
+ );
+ downloadsPopup.hidePopup();
+ await popupHiddenPromise;
+});
+
+// Navigation with a contenteditable body
+add_task(async function () {
+ await BrowserTestUtils.openNewForegroundTab(gBrowser, testPage3);
+
+ // The body should be focused when it is editable, not the root.
+ gURLBar.focus();
+ await expectFocusOnF6(
+ false,
+ "html3",
+ "body3",
+ true,
+ "focus with contenteditable body"
+ );
+ await expectFocusOnF6(
+ false,
+ "main-window",
+ gURLBar.inputField,
+ false,
+ "focus with contenteditable body urlbar"
+ );
+
+ // Now go backwards
+
+ await expectFocusOnF6(
+ false,
+ "html3",
+ "body3",
+ true,
+ "back focus with contenteditable body"
+ );
+ await expectFocusOnF6(
+ false,
+ "main-window",
+ gURLBar.inputField,
+ false,
+ "back focus with contenteditable body urlbar"
+ );
+
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+});
+
+// Navigation with a frameset loaded
+add_task(async function () {
+ await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "http://mochi.test:8888/browser/browser/base/content/test/general/file_documentnavigation_frameset.html"
+ );
+
+ gURLBar.focus();
+ await expectFocusOnF6(
+ false,
+ "htmlframe1",
+ "htmlframe1",
+ true,
+ "focus on frameset frame 0"
+ );
+ await expectFocusOnF6(
+ false,
+ "htmlframe2",
+ "htmlframe2",
+ true,
+ "focus on frameset frame 1"
+ );
+ await expectFocusOnF6(
+ false,
+ "htmlframe3",
+ "htmlframe3",
+ true,
+ "focus on frameset frame 2"
+ );
+ await expectFocusOnF6(
+ false,
+ "htmlframe4",
+ "htmlframe4",
+ true,
+ "focus on frameset frame 3"
+ );
+ await expectFocusOnF6(
+ false,
+ "main-window",
+ gURLBar.inputField,
+ false,
+ "focus on frameset frame urlbar"
+ );
+
+ await expectFocusOnF6(
+ true,
+ "htmlframe4",
+ "htmlframe4",
+ true,
+ "back focus on frameset frame 3"
+ );
+ await expectFocusOnF6(
+ true,
+ "htmlframe3",
+ "htmlframe3",
+ true,
+ "back focus on frameset frame 2"
+ );
+ await expectFocusOnF6(
+ true,
+ "htmlframe2",
+ "htmlframe2",
+ true,
+ "back focus on frameset frame 1"
+ );
+ await expectFocusOnF6(
+ true,
+ "htmlframe1",
+ "htmlframe1",
+ true,
+ "back focus on frameset frame 0"
+ );
+ await expectFocusOnF6(
+ true,
+ "main-window",
+ gURLBar.inputField,
+ false,
+ "back focus on frameset frame urlbar"
+ );
+
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+});
+
+// XXXndeakin add tests for browsers inside of panels
+
+function promiseButtonShown(id) {
+ let dwu = window.windowUtils;
+ return TestUtils.waitForCondition(() => {
+ let target = document.getElementById(id);
+ let bounds = dwu.getBoundsWithoutFlushing(target);
+ return bounds.width > 0 && bounds.height > 0;
+ }, `Waiting for button ${id} to have non-0 size`);
+}
diff --git a/browser/base/content/test/general/browser_domFullscreen_fullscreenMode.js b/browser/base/content/test/general/browser_domFullscreen_fullscreenMode.js
new file mode 100644
index 0000000000..c96fa6cf7b
--- /dev/null
+++ b/browser/base/content/test/general/browser_domFullscreen_fullscreenMode.js
@@ -0,0 +1,237 @@
+/* eslint-disable mozilla/no-arbitrary-setTimeout */
+
+"use strict";
+
+// This test tends to trigger a race in the fullscreen time telemetry,
+// where the fullscreen enter and fullscreen exit events (which use the
+// same histogram ID) overlap. That causes TelemetryStopwatch to log an
+// error.
+SimpleTest.ignoreAllUncaughtExceptions(true);
+
+function listenOneEvent(aEvent, aListener) {
+ function listener(evt) {
+ removeEventListener(aEvent, listener);
+ aListener(evt);
+ }
+ addEventListener(aEvent, listener);
+}
+
+function queryFullscreenState(browser) {
+ return SpecialPowers.spawn(browser, [], () => {
+ return {
+ inDOMFullscreen: !!content.document.fullscreenElement,
+ inFullscreen: content.fullScreen,
+ };
+ });
+}
+
+function captureUnexpectedFullscreenChange() {
+ ok(false, "catched an unexpected fullscreen change");
+}
+
+const FS_CHANGE_DOM = 1 << 0;
+const FS_CHANGE_SIZE = 1 << 1;
+const FS_CHANGE_BOTH = FS_CHANGE_DOM | FS_CHANGE_SIZE;
+
+function waitForDocActivated(aBrowser) {
+ return SpecialPowers.spawn(aBrowser, [], () => {
+ return ContentTaskUtils.waitForCondition(
+ () => content.browsingContext.isActive && content.document.hasFocus()
+ );
+ });
+}
+
+function waitForFullscreenChanges(aBrowser, aFlags) {
+ return new Promise(resolve => {
+ let fullscreenData = null;
+ let sizemodeChanged = false;
+ function tryResolve() {
+ if (
+ (!(aFlags & FS_CHANGE_DOM) || fullscreenData) &&
+ (!(aFlags & FS_CHANGE_SIZE) || sizemodeChanged)
+ ) {
+ // In the platforms that support reporting occlusion state (e.g. Mac),
+ // enter/exit fullscreen mode will trigger docshell being set to
+ // non-activate and then set to activate back again.
+ // For those platform, we should wait until the docshell has been
+ // activated again, otherwise, the fullscreen request might be denied.
+ waitForDocActivated(aBrowser).then(() => {
+ if (!fullscreenData) {
+ queryFullscreenState(aBrowser).then(resolve);
+ } else {
+ resolve(fullscreenData);
+ }
+ });
+ }
+ }
+ if (aFlags & FS_CHANGE_SIZE) {
+ listenOneEvent("sizemodechange", () => {
+ sizemodeChanged = true;
+ tryResolve();
+ });
+ }
+ if (aFlags & FS_CHANGE_DOM) {
+ BrowserTestUtils.waitForContentEvent(aBrowser, "fullscreenchange").then(
+ async () => {
+ fullscreenData = await queryFullscreenState(aBrowser);
+ tryResolve();
+ }
+ );
+ }
+ });
+}
+
+var gTests = [
+ {
+ desc: "document method",
+ affectsFullscreenMode: false,
+ exitFunc: browser => {
+ SpecialPowers.spawn(browser, [], () => {
+ content.document.exitFullscreen();
+ });
+ },
+ },
+ {
+ desc: "escape key",
+ affectsFullscreenMode: false,
+ exitFunc: () => {
+ executeSoon(() => EventUtils.synthesizeKey("KEY_Escape"));
+ },
+ },
+ {
+ desc: "F11 key",
+ affectsFullscreenMode: true,
+ exitFunc() {
+ executeSoon(() => EventUtils.synthesizeKey("KEY_F11"));
+ },
+ },
+];
+
+function checkState(expectedStates, contentStates) {
+ is(
+ contentStates.inDOMFullscreen,
+ expectedStates.inDOMFullscreen,
+ "The DOM fullscreen state of the content should match"
+ );
+ // TODO window.fullScreen is not updated as soon as the fullscreen
+ // state flips in child process, hence checking it could cause
+ // anonying intermittent failure. As we just want to confirm the
+ // fullscreen state of the browser window, we can just check the
+ // that on the chrome window below.
+ // is(contentStates.inFullscreen, expectedStates.inFullscreen,
+ // "The fullscreen state of the content should match");
+ is(
+ !!document.fullscreenElement,
+ expectedStates.inDOMFullscreen,
+ "The DOM fullscreen state of the chrome should match"
+ );
+ is(
+ window.fullScreen,
+ expectedStates.inFullscreen,
+ "The fullscreen state of the chrome should match"
+ );
+}
+
+const kPage =
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.org/browser/browser/" +
+ "base/content/test/general/dummy_page.html";
+
+add_task(async function () {
+ await pushPrefs(
+ ["full-screen-api.transition-duration.enter", "0 0"],
+ ["full-screen-api.transition-duration.leave", "0 0"]
+ );
+
+ registerCleanupFunction(async function () {
+ if (window.fullScreen) {
+ let fullscreenPromise = waitForFullscreenChanges(
+ gBrowser.selectedBrowser,
+ FS_CHANGE_SIZE
+ );
+ executeSoon(() => BrowserFullScreen());
+ await fullscreenPromise;
+ }
+ });
+
+ let tab = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ url: kPage,
+ });
+ let browser = tab.linkedBrowser;
+
+ // As requestFullscreen checks the active state of the docshell,
+ // wait for the document to be activated, just to be sure that
+ // the fullscreen request won't be denied.
+ await waitForDocActivated(browser);
+
+ for (let test of gTests) {
+ let contentStates;
+ info("Testing exit DOM fullscreen via " + test.desc);
+
+ contentStates = await queryFullscreenState(browser);
+ checkState({ inDOMFullscreen: false, inFullscreen: false }, contentStates);
+
+ /* DOM fullscreen without fullscreen mode */
+
+ info("> Enter DOM fullscreen");
+ let fullscreenPromise = waitForFullscreenChanges(browser, FS_CHANGE_BOTH);
+ await SpecialPowers.spawn(browser, [], () => {
+ content.document.body.requestFullscreen();
+ });
+ contentStates = await fullscreenPromise;
+ checkState({ inDOMFullscreen: true, inFullscreen: true }, contentStates);
+
+ info("> Exit DOM fullscreen");
+ fullscreenPromise = waitForFullscreenChanges(browser, FS_CHANGE_BOTH);
+ test.exitFunc(browser);
+ contentStates = await fullscreenPromise;
+ checkState({ inDOMFullscreen: false, inFullscreen: false }, contentStates);
+
+ /* DOM fullscreen with fullscreen mode */
+
+ info("> Enter fullscreen mode");
+ // Need to be asynchronous because sizemodechange event could be
+ // dispatched synchronously, which would cause the event listener
+ // miss that event and wait infinitely.
+ fullscreenPromise = waitForFullscreenChanges(browser, FS_CHANGE_SIZE);
+ executeSoon(() => BrowserFullScreen());
+ contentStates = await fullscreenPromise;
+ checkState({ inDOMFullscreen: false, inFullscreen: true }, contentStates);
+
+ info("> Enter DOM fullscreen in fullscreen mode");
+ fullscreenPromise = waitForFullscreenChanges(browser, FS_CHANGE_DOM);
+ await SpecialPowers.spawn(browser, [], () => {
+ content.document.body.requestFullscreen();
+ });
+ contentStates = await fullscreenPromise;
+ checkState({ inDOMFullscreen: true, inFullscreen: true }, contentStates);
+
+ info("> Exit DOM fullscreen in fullscreen mode");
+ fullscreenPromise = waitForFullscreenChanges(
+ browser,
+ test.affectsFullscreenMode ? FS_CHANGE_BOTH : FS_CHANGE_DOM
+ );
+ test.exitFunc(browser);
+ contentStates = await fullscreenPromise;
+ checkState(
+ {
+ inDOMFullscreen: false,
+ inFullscreen: !test.affectsFullscreenMode,
+ },
+ contentStates
+ );
+
+ /* Cleanup */
+
+ // Exit fullscreen mode if we are still in
+ if (window.fullScreen) {
+ info("> Cleanup");
+ fullscreenPromise = waitForFullscreenChanges(browser, FS_CHANGE_SIZE);
+ executeSoon(() => BrowserFullScreen());
+ await fullscreenPromise;
+ }
+ }
+
+ BrowserTestUtils.removeTab(tab);
+});
diff --git a/browser/base/content/test/general/browser_double_close_tab.js b/browser/base/content/test/general/browser_double_close_tab.js
new file mode 100644
index 0000000000..554aeb8077
--- /dev/null
+++ b/browser/base/content/test/general/browser_double_close_tab.js
@@ -0,0 +1,120 @@
+/* eslint-disable mozilla/no-arbitrary-setTimeout */
+"use strict";
+const TEST_PAGE =
+ "http://mochi.test:8888/browser/browser/base/content/test/general/file_double_close_tab.html";
+var testTab;
+
+const CONTENT_PROMPT_SUBDIALOG = Services.prefs.getBoolPref(
+ "prompts.contentPromptSubDialog",
+ false
+);
+
+function waitForDialog(callback) {
+ function onDialogLoaded(nodeOrDialogWindow) {
+ let node = CONTENT_PROMPT_SUBDIALOG
+ ? nodeOrDialogWindow.document.querySelector("dialog")
+ : nodeOrDialogWindow;
+ Services.obs.removeObserver(onDialogLoaded, "tabmodal-dialog-loaded");
+ Services.obs.removeObserver(onDialogLoaded, "common-dialog-loaded");
+ // Allow dialog's onLoad call to run to completion
+ Promise.resolve().then(() => callback(node));
+ }
+
+ // Listen for the dialog being created
+ Services.obs.addObserver(onDialogLoaded, "tabmodal-dialog-loaded");
+ Services.obs.addObserver(onDialogLoaded, "common-dialog-loaded");
+}
+
+function waitForDialogDestroyed(node, callback) {
+ // Now listen for the dialog going away again...
+ let observer = new MutationObserver(function (muts) {
+ if (!node.parentNode) {
+ ok(true, "Dialog is gone");
+ done();
+ }
+ });
+ observer.observe(node.parentNode, { childList: true });
+
+ if (CONTENT_PROMPT_SUBDIALOG) {
+ node.ownerGlobal.addEventListener("unload", done);
+ }
+
+ let failureTimeout = setTimeout(function () {
+ ok(false, "Dialog should have been destroyed");
+ done();
+ }, 10000);
+
+ function done() {
+ clearTimeout(failureTimeout);
+ observer.disconnect();
+ observer = null;
+
+ if (CONTENT_PROMPT_SUBDIALOG) {
+ node.ownerGlobal.removeEventListener("unload", done);
+ SimpleTest.executeSoon(callback);
+ } else {
+ callback();
+ }
+ }
+}
+
+add_task(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [["dom.require_user_interaction_for_beforeunload", false]],
+ });
+
+ testTab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_PAGE);
+
+ // XXXgijs the reason this has nesting and callbacks rather than promises is
+ // that DOM promises resolve on the next tick. So they're scheduled
+ // in an event queue. So when we spin a new event queue for a modal dialog...
+ // everything gets messed up and the promise's .then callbacks never get
+ // called, despite resolve() being called just fine.
+ await new Promise(resolveOuter => {
+ waitForDialog(dialogNode => {
+ waitForDialogDestroyed(dialogNode, () => {
+ let doCompletion = () => setTimeout(resolveOuter, 0);
+ info("Now checking if dialog is destroyed");
+
+ if (CONTENT_PROMPT_SUBDIALOG) {
+ ok(
+ !dialogNode.ownerGlobal || dialogNode.ownerGlobal.closed,
+ "onbeforeunload dialog should be gone."
+ );
+ if (dialogNode.ownerGlobal && !dialogNode.ownerGlobal.closed) {
+ dialogNode.acceptDialog();
+ }
+ } else {
+ ok(!dialogNode.parentNode, "onbeforeunload dialog should be gone.");
+ if (dialogNode.parentNode) {
+ // Failed to remove onbeforeunload dialog, so do it ourselves:
+ let leaveBtn = dialogNode.querySelector(".tabmodalprompt-button0");
+ waitForDialogDestroyed(dialogNode, doCompletion);
+ EventUtils.synthesizeMouseAtCenter(leaveBtn, {});
+ return;
+ }
+ }
+
+ doCompletion();
+ });
+ // Click again:
+ testTab.closeButton.click();
+ });
+ // Click once:
+ testTab.closeButton.click();
+ });
+ await TestUtils.waitForCondition(() => !testTab.parentNode);
+ ok(!testTab.parentNode, "Tab should be closed completely");
+});
+
+registerCleanupFunction(async function () {
+ if (testTab.parentNode) {
+ // Remove the handler, or closing this tab will prove tricky:
+ try {
+ await SpecialPowers.spawn(testTab.linkedBrowser, [], function () {
+ content.window.onbeforeunload = null;
+ });
+ } catch (ex) {}
+ gBrowser.removeTab(testTab);
+ }
+});
diff --git a/browser/base/content/test/general/browser_drag.js b/browser/base/content/test/general/browser_drag.js
new file mode 100644
index 0000000000..04373e7ce2
--- /dev/null
+++ b/browser/base/content/test/general/browser_drag.js
@@ -0,0 +1,64 @@
+async function test() {
+ waitForExplicitFinish();
+
+ let EventUtils = {};
+ Services.scriptloader.loadSubScript(
+ "chrome://mochikit/content/tests/SimpleTest/EventUtils.js",
+ EventUtils
+ );
+
+ // ---- Test dragging the proxy icon ---
+ var value = content.location.href;
+ var urlString = value + "\n" + content.document.title;
+ var htmlString = '<a href="' + value + '">' + value + "</a>";
+ var expected = [
+ [
+ { type: "text/x-moz-url", data: urlString },
+ { type: "text/uri-list", data: value },
+ { type: "text/plain", data: value },
+ { type: "text/html", data: htmlString },
+ ],
+ ];
+ // set the valid attribute so dropping is allowed
+ var oldstate = gURLBar.getAttribute("pageproxystate");
+ gURLBar.setPageProxyState("valid");
+ let result = await EventUtils.synthesizePlainDragAndCancel(
+ {
+ srcElement: document.getElementById("identity-icon-box"),
+ },
+ expected
+ );
+ ok(result === true, "dragging dataTransfer should be expected");
+ gURLBar.setPageProxyState(oldstate);
+ // Now, the identity information panel is opened by the proxy icon click.
+ // We need to close it for next tests.
+ EventUtils.synthesizeKey("VK_ESCAPE", {}, window);
+
+ // now test dragging onto a tab
+ var tab = BrowserTestUtils.addTab(gBrowser, "about:blank", {
+ skipAnimation: true,
+ });
+ var browser = gBrowser.getBrowserForTab(tab);
+
+ browser.addEventListener(
+ "load",
+ function () {
+ is(
+ browser.contentWindow.location,
+ "http://mochi.test:8888/",
+ "drop on tab"
+ );
+ gBrowser.removeTab(tab);
+ finish();
+ },
+ true
+ );
+
+ EventUtils.synthesizeDrop(
+ tab,
+ tab,
+ [[{ type: "text/uri-list", data: "http://mochi.test:8888/" }]],
+ "copy",
+ window
+ );
+}
diff --git a/browser/base/content/test/general/browser_duplicateIDs.js b/browser/base/content/test/general/browser_duplicateIDs.js
new file mode 100644
index 0000000000..b0c65c6af6
--- /dev/null
+++ b/browser/base/content/test/general/browser_duplicateIDs.js
@@ -0,0 +1,11 @@
+function test() {
+ var ids = {};
+ Array.prototype.forEach.call(
+ document.querySelectorAll("[id]"),
+ function (node) {
+ var id = node.id;
+ ok(!(id in ids), id + " should be unique");
+ ids[id] = null;
+ }
+ );
+}
diff --git a/browser/base/content/test/general/browser_findbarClose.js b/browser/base/content/test/general/browser_findbarClose.js
new file mode 100644
index 0000000000..e0fe2fcb98
--- /dev/null
+++ b/browser/base/content/test/general/browser_findbarClose.js
@@ -0,0 +1,47 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests find bar auto-close behavior
+
+const TEST_PATH = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+);
+
+add_task(async function findbar_test() {
+ let newTab = BrowserTestUtils.addTab(gBrowser, "about:blank");
+ gBrowser.selectedTab = newTab;
+
+ let url = TEST_PATH + "test_bug628179.html";
+ let promise = BrowserTestUtils.browserLoaded(
+ newTab.linkedBrowser,
+ false,
+ url
+ );
+ BrowserTestUtils.loadURIString(newTab.linkedBrowser, url);
+ await promise;
+
+ await gFindBarPromise;
+ gFindBar.open();
+
+ await new ContentTask.spawn(newTab.linkedBrowser, null, async function () {
+ let iframe = content.document.getElementById("iframe");
+ let awaitLoad = ContentTaskUtils.waitForEvent(iframe, "load", false);
+ iframe.src = "https://example.org/";
+ await awaitLoad;
+ });
+
+ ok(
+ !gFindBar.hidden,
+ "the Find bar isn't hidden after the location of a subdocument changes"
+ );
+
+ let findBarClosePromise = BrowserTestUtils.waitForEvent(
+ gBrowser,
+ "findbarclose"
+ );
+ gFindBar.close();
+ await findBarClosePromise;
+
+ gBrowser.removeTab(newTab);
+});
diff --git a/browser/base/content/test/general/browser_focusonkeydown.js b/browser/base/content/test/general/browser_focusonkeydown.js
new file mode 100644
index 0000000000..9cf1f113f5
--- /dev/null
+++ b/browser/base/content/test/general/browser_focusonkeydown.js
@@ -0,0 +1,34 @@
+add_task(async function () {
+ let keyUps = 0;
+
+ await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "data:text/html,<body>"
+ );
+
+ gURLBar.focus();
+
+ window.addEventListener(
+ "keyup",
+ function (event) {
+ if (event.originalTarget == gURLBar.inputField) {
+ keyUps++;
+ }
+ },
+ { capture: true, once: true }
+ );
+
+ gURLBar.addEventListener(
+ "keydown",
+ function (event) {
+ gBrowser.selectedBrowser.focus();
+ },
+ { capture: true, once: true }
+ );
+
+ EventUtils.sendString("v");
+
+ is(keyUps, 1, "Key up fired at url bar");
+
+ gBrowser.removeCurrentTab();
+});
diff --git a/browser/base/content/test/general/browser_fullscreen-window-open.js b/browser/base/content/test/general/browser_fullscreen-window-open.js
new file mode 100644
index 0000000000..2b21e34e92
--- /dev/null
+++ b/browser/base/content/test/general/browser_fullscreen-window-open.js
@@ -0,0 +1,366 @@
+/* eslint-disable mozilla/no-arbitrary-setTimeout */
+
+const PREF_DISABLE_OPEN_NEW_WINDOW =
+ "browser.link.open_newwindow.disabled_in_fullscreen";
+const PREF_BLOCK_TOPLEVEL_DATA =
+ "security.data_uri.block_toplevel_data_uri_navigations";
+const isOSX = Services.appinfo.OS === "Darwin";
+
+const TEST_FILE = "file_fullscreen-window-open.html";
+const gHttpTestRoot = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content/",
+ "http://127.0.0.1:8888/"
+);
+
+var newWin;
+var newBrowser;
+
+async function test() {
+ waitForExplicitFinish();
+
+ Services.prefs.setBoolPref(PREF_DISABLE_OPEN_NEW_WINDOW, true);
+ Services.prefs.setBoolPref(PREF_BLOCK_TOPLEVEL_DATA, false);
+
+ newWin = await BrowserTestUtils.openNewBrowserWindow();
+ newBrowser = newWin.gBrowser;
+ await promiseTabLoadEvent(newBrowser.selectedTab, gHttpTestRoot + TEST_FILE);
+
+ // Enter browser fullscreen mode.
+ newWin.BrowserFullScreen();
+
+ runNextTest();
+}
+
+registerCleanupFunction(async function () {
+ // Exit browser fullscreen mode.
+ newWin.BrowserFullScreen();
+
+ await BrowserTestUtils.closeWindow(newWin);
+
+ Services.prefs.clearUserPref(PREF_DISABLE_OPEN_NEW_WINDOW);
+ Services.prefs.clearUserPref(PREF_BLOCK_TOPLEVEL_DATA);
+});
+
+var gTests = [
+ test_open,
+ test_open_with_size,
+ test_open_with_pos,
+ test_open_with_outerSize,
+ test_open_with_innerSize,
+ test_open_with_dialog,
+ test_open_when_open_new_window_by_pref,
+ test_open_with_pref_to_disable_in_fullscreen,
+ test_open_from_chrome,
+];
+
+function runNextTest() {
+ let testCase = gTests.shift();
+ if (testCase) {
+ executeSoon(testCase);
+ } else {
+ finish();
+ }
+}
+
+// Test for window.open() with no feature.
+function test_open() {
+ waitForTabOpen({
+ message: {
+ title: "test_open",
+ param: "",
+ },
+ finalizeFn() {},
+ });
+}
+
+// Test for window.open() with width/height.
+function test_open_with_size() {
+ waitForTabOpen({
+ message: {
+ title: "test_open_with_size",
+ param: "width=400,height=400",
+ },
+ finalizeFn() {},
+ });
+}
+
+// Test for window.open() with top/left.
+function test_open_with_pos() {
+ waitForTabOpen({
+ message: {
+ title: "test_open_with_pos",
+ param: "top=200,left=200",
+ },
+ finalizeFn() {},
+ });
+}
+
+// Test for window.open() with outerWidth/Height.
+function test_open_with_outerSize() {
+ let [outerWidth, outerHeight] = [newWin.outerWidth, newWin.outerHeight];
+ waitForTabOpen({
+ message: {
+ title: "test_open_with_outerSize",
+ param: "outerWidth=200,outerHeight=200",
+ },
+ successFn() {
+ is(newWin.outerWidth, outerWidth, "Don't change window.outerWidth.");
+ is(newWin.outerHeight, outerHeight, "Don't change window.outerHeight.");
+ },
+ finalizeFn() {},
+ });
+}
+
+// Test for window.open() with innerWidth/Height.
+function test_open_with_innerSize() {
+ let [innerWidth, innerHeight] = [newWin.innerWidth, newWin.innerHeight];
+ waitForTabOpen({
+ message: {
+ title: "test_open_with_innerSize",
+ param: "innerWidth=200,innerHeight=200",
+ },
+ successFn() {
+ is(newWin.innerWidth, innerWidth, "Don't change window.innerWidth.");
+ is(newWin.innerHeight, innerHeight, "Don't change window.innerHeight.");
+ },
+ finalizeFn() {},
+ });
+}
+
+// Test for window.open() with dialog.
+function test_open_with_dialog() {
+ waitForTabOpen({
+ message: {
+ title: "test_open_with_dialog",
+ param: "dialog=yes",
+ },
+ finalizeFn() {},
+ });
+}
+
+// Test for window.open()
+// when "browser.link.open_newwindow" is nsIBrowserDOMWindow.OPEN_NEWWINDOW
+function test_open_when_open_new_window_by_pref() {
+ const PREF_NAME = "browser.link.open_newwindow";
+ Services.prefs.setIntPref(PREF_NAME, Ci.nsIBrowserDOMWindow.OPEN_NEWWINDOW);
+ is(
+ Services.prefs.getIntPref(PREF_NAME),
+ Ci.nsIBrowserDOMWindow.OPEN_NEWWINDOW,
+ PREF_NAME + " is nsIBrowserDOMWindow.OPEN_NEWWINDOW at this time"
+ );
+
+ waitForTabOpen({
+ message: {
+ title: "test_open_when_open_new_window_by_pref",
+ param: "width=400,height=400",
+ },
+ finalizeFn() {
+ Services.prefs.clearUserPref(PREF_NAME);
+ },
+ });
+}
+
+// Test for the pref, "browser.link.open_newwindow.disabled_in_fullscreen"
+function test_open_with_pref_to_disable_in_fullscreen() {
+ Services.prefs.setBoolPref(PREF_DISABLE_OPEN_NEW_WINDOW, false);
+
+ waitForWindowOpen({
+ message: {
+ title: "test_open_with_pref_disabled_in_fullscreen",
+ param: "width=400,height=400",
+ },
+ finalizeFn() {
+ Services.prefs.setBoolPref(PREF_DISABLE_OPEN_NEW_WINDOW, true);
+ },
+ });
+}
+
+// Test for window.open() called from chrome context.
+function test_open_from_chrome() {
+ waitForWindowOpenFromChrome({
+ message: {
+ title: "test_open_from_chrome",
+ param: "",
+ option: "noopener",
+ },
+ finalizeFn() {},
+ });
+}
+
+function waitForTabOpen(aOptions) {
+ let message = aOptions.message;
+
+ if (!message.title) {
+ ok(false, "Can't get message.title.");
+ aOptions.finalizeFn();
+ runNextTest();
+ return;
+ }
+
+ info("Running test: " + message.title);
+
+ let onTabOpen = function onTabOpen(aEvent) {
+ newBrowser.tabContainer.removeEventListener("TabOpen", onTabOpen, true);
+
+ let tab = aEvent.target;
+ whenTabLoaded(tab, function () {
+ is(
+ tab.linkedBrowser.contentTitle,
+ message.title,
+ "Opened Tab is expected: " + message.title
+ );
+
+ if (aOptions.successFn) {
+ aOptions.successFn();
+ }
+
+ newBrowser.removeTab(tab);
+ finalize();
+ });
+ };
+ newBrowser.tabContainer.addEventListener("TabOpen", onTabOpen, true);
+
+ let finalize = function () {
+ aOptions.finalizeFn();
+ info("Finished: " + message.title);
+ runNextTest();
+ };
+
+ const URI =
+ "data:text/html;charset=utf-8,<!DOCTYPE html><html><head><title>" +
+ message.title +
+ "<%2Ftitle><%2Fhead><body><%2Fbody><%2Fhtml>";
+
+ executeWindowOpenInContent({
+ uri: URI,
+ title: message.title,
+ option: message.param,
+ });
+}
+
+function waitForWindowOpen(aOptions) {
+ let message = aOptions.message;
+ let url = aOptions.url || "about:blank";
+
+ if (!message.title) {
+ ok(false, "Can't get message.title");
+ aOptions.finalizeFn();
+ runNextTest();
+ return;
+ }
+
+ info("Running test: " + message.title);
+
+ let onFinalize = function () {
+ aOptions.finalizeFn();
+
+ info("Finished: " + message.title);
+ runNextTest();
+ };
+
+ let listener = new WindowListener(
+ message.title,
+ AppConstants.BROWSER_CHROME_URL,
+ {
+ onSuccess: aOptions.successFn,
+ onFinalize,
+ }
+ );
+ Services.wm.addListener(listener);
+
+ executeWindowOpenInContent({
+ uri: url,
+ title: message.title,
+ option: message.param,
+ });
+}
+
+function executeWindowOpenInContent(aParam) {
+ SpecialPowers.spawn(
+ newBrowser.selectedBrowser,
+ [JSON.stringify(aParam)],
+ async function (dataTestParam) {
+ let testElm = content.document.getElementById("test");
+ testElm.setAttribute("data-test-param", dataTestParam);
+ testElm.click();
+ }
+ );
+}
+
+function waitForWindowOpenFromChrome(aOptions) {
+ let message = aOptions.message;
+ let url = aOptions.url || "about:blank";
+
+ if (!message.title) {
+ ok(false, "Can't get message.title");
+ aOptions.finalizeFn();
+ runNextTest();
+ return;
+ }
+
+ info("Running test: " + message.title);
+
+ let onFinalize = function () {
+ aOptions.finalizeFn();
+
+ info("Finished: " + message.title);
+ runNextTest();
+ };
+
+ let listener = new WindowListener(
+ message.title,
+ AppConstants.BROWSER_CHROME_URL,
+ {
+ onSuccess: aOptions.successFn,
+ onFinalize,
+ }
+ );
+ Services.wm.addListener(listener);
+
+ newWin.open(url, message.title, message.option);
+}
+
+function WindowListener(aTitle, aUrl, aCallBackObj) {
+ this.test_title = aTitle;
+ this.test_url = aUrl;
+ this.callback_onSuccess = aCallBackObj.onSuccess;
+ this.callBack_onFinalize = aCallBackObj.onFinalize;
+}
+WindowListener.prototype = {
+ test_title: null,
+ test_url: null,
+ callback_onSuccess: null,
+ callBack_onFinalize: null,
+
+ onOpenWindow(aXULWindow) {
+ Services.wm.removeListener(this);
+
+ let domwindow = aXULWindow.docShell.domWindow;
+ let onLoad = aEvent => {
+ is(
+ domwindow.document.location.href,
+ this.test_url,
+ "Opened Window is expected: " + this.test_title
+ );
+ if (this.callback_onSuccess) {
+ this.callback_onSuccess();
+ }
+
+ domwindow.removeEventListener("load", onLoad, true);
+
+ // wait for trasition to fullscreen on OSX Lion later
+ if (isOSX) {
+ setTimeout(() => {
+ domwindow.close();
+ executeSoon(this.callBack_onFinalize);
+ }, 3000);
+ } else {
+ domwindow.close();
+ executeSoon(this.callBack_onFinalize);
+ }
+ };
+ domwindow.addEventListener("load", onLoad, true);
+ },
+ onCloseWindow(aXULWindow) {},
+ QueryInterface: ChromeUtils.generateQI(["nsIWindowMediatorListener"]),
+};
diff --git a/browser/base/content/test/general/browser_gestureSupport.js b/browser/base/content/test/general/browser_gestureSupport.js
new file mode 100644
index 0000000000..d8f0331268
--- /dev/null
+++ b/browser/base/content/test/general/browser_gestureSupport.js
@@ -0,0 +1,1132 @@
+/* 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/. */
+
+Services.scriptloader.loadSubScript(
+ "chrome://mochitests/content/browser/gfx/layers/apz/test/mochitest/apz_test_utils.js",
+ this
+);
+
+Services.scriptloader.loadSubScript(
+ "chrome://mochitests/content/browser/gfx/layers/apz/test/mochitest/apz_test_native_event_utils.js",
+ this
+);
+
+// Simple gestures tests
+//
+// Some of these tests require the ability to disable the fact that the
+// Firefox chrome intentionally prevents "simple gesture" events from
+// reaching web content.
+
+var test_utils;
+var test_commandset;
+var test_prefBranch = "browser.gesture.";
+var test_normalTab;
+
+async function test() {
+ waitForExplicitFinish();
+
+ // Disable the default gestures support during this part of the test
+ gGestureSupport.init(false);
+
+ test_utils = window.windowUtils;
+
+ // Run the tests of "simple gesture" events generally
+ test_EnsureConstantsAreDisjoint();
+ test_TestEventListeners();
+ test_TestEventCreation();
+
+ // Reenable the default gestures support. The remaining tests target
+ // the Firefox gesture functionality.
+ gGestureSupport.init(true);
+
+ const aPage = "about:about";
+ test_normalTab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ aPage,
+ true /* waitForLoad */
+ );
+
+ // Test Firefox's gestures support.
+ test_commandset = document.getElementById("mainCommandSet");
+ await test_swipeGestures();
+ await test_latchedGesture("pinch", "out", "in", "MozMagnifyGesture");
+ await test_thresholdGesture("pinch", "out", "in", "MozMagnifyGesture");
+ test_rotateGestures();
+}
+
+var test_eventCount = 0;
+var test_expectedType;
+var test_expectedDirection;
+var test_expectedDelta;
+var test_expectedModifiers;
+var test_expectedClickCount;
+var test_imageTab;
+
+function test_gestureListener(evt) {
+ is(
+ evt.type,
+ test_expectedType,
+ "evt.type (" + evt.type + ") does not match expected value"
+ );
+ is(
+ evt.target,
+ test_utils.elementFromPoint(60, 60, false, false),
+ "evt.target (" + evt.target + ") does not match expected value"
+ );
+ is(
+ evt.clientX,
+ 60,
+ "evt.clientX (" + evt.clientX + ") does not match expected value"
+ );
+ is(
+ evt.clientY,
+ 60,
+ "evt.clientY (" + evt.clientY + ") does not match expected value"
+ );
+ isnot(
+ evt.screenX,
+ 0,
+ "evt.screenX (" + evt.screenX + ") does not match expected value"
+ );
+ isnot(
+ evt.screenY,
+ 0,
+ "evt.screenY (" + evt.screenY + ") does not match expected value"
+ );
+
+ is(
+ evt.direction,
+ test_expectedDirection,
+ "evt.direction (" + evt.direction + ") does not match expected value"
+ );
+ is(
+ evt.delta,
+ test_expectedDelta,
+ "evt.delta (" + evt.delta + ") does not match expected value"
+ );
+
+ is(
+ evt.shiftKey,
+ (test_expectedModifiers & Event.SHIFT_MASK) != 0,
+ "evt.shiftKey did not match expected value"
+ );
+ is(
+ evt.ctrlKey,
+ (test_expectedModifiers & Event.CONTROL_MASK) != 0,
+ "evt.ctrlKey did not match expected value"
+ );
+ is(
+ evt.altKey,
+ (test_expectedModifiers & Event.ALT_MASK) != 0,
+ "evt.altKey did not match expected value"
+ );
+ is(
+ evt.metaKey,
+ (test_expectedModifiers & Event.META_MASK) != 0,
+ "evt.metaKey did not match expected value"
+ );
+
+ if (evt.type == "MozTapGesture") {
+ is(
+ evt.clickCount,
+ test_expectedClickCount,
+ "evt.clickCount does not match"
+ );
+ }
+
+ test_eventCount++;
+}
+
+function test_helper1(type, direction, delta, modifiers) {
+ // Setup the expected values
+ test_expectedType = type;
+ test_expectedDirection = direction;
+ test_expectedDelta = delta;
+ test_expectedModifiers = modifiers;
+
+ let expectedEventCount = test_eventCount + 1;
+
+ document.addEventListener(type, test_gestureListener, true);
+ test_utils.sendSimpleGestureEvent(type, 60, 60, direction, delta, modifiers);
+ document.removeEventListener(type, test_gestureListener, true);
+
+ is(
+ expectedEventCount,
+ test_eventCount,
+ "Event (" + type + ") was never received by event listener"
+ );
+}
+
+function test_clicks(type, clicks) {
+ // Setup the expected values
+ test_expectedType = type;
+ test_expectedDirection = 0;
+ test_expectedDelta = 0;
+ test_expectedModifiers = 0;
+ test_expectedClickCount = clicks;
+
+ let expectedEventCount = test_eventCount + 1;
+
+ document.addEventListener(type, test_gestureListener, true);
+ test_utils.sendSimpleGestureEvent(type, 60, 60, 0, 0, 0, clicks);
+ document.removeEventListener(type, test_gestureListener, true);
+
+ is(
+ expectedEventCount,
+ test_eventCount,
+ "Event (" + type + ") was never received by event listener"
+ );
+}
+
+function test_TestEventListeners() {
+ let e = test_helper1; // easier to type this name
+
+ // Swipe gesture animation events
+ e("MozSwipeGestureStart", 0, -0.7, 0);
+ e("MozSwipeGestureUpdate", 0, -0.4, 0);
+ e("MozSwipeGestureEnd", 0, 0, 0);
+ e("MozSwipeGestureStart", 0, 0.6, 0);
+ e("MozSwipeGestureUpdate", 0, 0.3, 0);
+ e("MozSwipeGestureEnd", 0, 1, 0);
+
+ // Swipe gesture event
+ e("MozSwipeGesture", SimpleGestureEvent.DIRECTION_LEFT, 0.0, 0);
+ e("MozSwipeGesture", SimpleGestureEvent.DIRECTION_RIGHT, 0.0, 0);
+ e("MozSwipeGesture", SimpleGestureEvent.DIRECTION_UP, 0.0, 0);
+ e("MozSwipeGesture", SimpleGestureEvent.DIRECTION_DOWN, 0.0, 0);
+ e(
+ "MozSwipeGesture",
+ SimpleGestureEvent.DIRECTION_UP | SimpleGestureEvent.DIRECTION_LEFT,
+ 0.0,
+ 0
+ );
+ e(
+ "MozSwipeGesture",
+ SimpleGestureEvent.DIRECTION_DOWN | SimpleGestureEvent.DIRECTION_RIGHT,
+ 0.0,
+ 0
+ );
+ e(
+ "MozSwipeGesture",
+ SimpleGestureEvent.DIRECTION_UP | SimpleGestureEvent.DIRECTION_RIGHT,
+ 0.0,
+ 0
+ );
+ e(
+ "MozSwipeGesture",
+ SimpleGestureEvent.DIRECTION_DOWN | SimpleGestureEvent.DIRECTION_LEFT,
+ 0.0,
+ 0
+ );
+
+ // magnify gesture events
+ e("MozMagnifyGestureStart", 0, 50.0, 0);
+ e("MozMagnifyGestureUpdate", 0, -25.0, 0);
+ e("MozMagnifyGestureUpdate", 0, 5.0, 0);
+ e("MozMagnifyGesture", 0, 30.0, 0);
+
+ // rotate gesture events
+ e("MozRotateGestureStart", SimpleGestureEvent.ROTATION_CLOCKWISE, 33.0, 0);
+ e(
+ "MozRotateGestureUpdate",
+ SimpleGestureEvent.ROTATION_COUNTERCLOCKWISE,
+ -13.0,
+ 0
+ );
+ e("MozRotateGestureUpdate", SimpleGestureEvent.ROTATION_CLOCKWISE, 13.0, 0);
+ e("MozRotateGesture", SimpleGestureEvent.ROTATION_CLOCKWISE, 33.0, 0);
+
+ // Tap and presstap gesture events
+ test_clicks("MozTapGesture", 1);
+ test_clicks("MozTapGesture", 2);
+ test_clicks("MozTapGesture", 3);
+ test_clicks("MozPressTapGesture", 1);
+
+ // simple delivery test for edgeui gestures
+ e("MozEdgeUIStarted", 0, 0, 0);
+ e("MozEdgeUICanceled", 0, 0, 0);
+ e("MozEdgeUICompleted", 0, 0, 0);
+
+ // event.shiftKey
+ let modifier = Event.SHIFT_MASK;
+ e("MozSwipeGesture", SimpleGestureEvent.DIRECTION_RIGHT, 0, modifier);
+
+ // event.metaKey
+ modifier = Event.META_MASK;
+ e("MozSwipeGesture", SimpleGestureEvent.DIRECTION_RIGHT, 0, modifier);
+
+ // event.altKey
+ modifier = Event.ALT_MASK;
+ e("MozSwipeGesture", SimpleGestureEvent.DIRECTION_RIGHT, 0, modifier);
+
+ // event.ctrlKey
+ modifier = Event.CONTROL_MASK;
+ e("MozSwipeGesture", SimpleGestureEvent.DIRECTION_RIGHT, 0, modifier);
+}
+
+function test_eventDispatchListener(evt) {
+ test_eventCount++;
+ evt.stopPropagation();
+}
+
+function test_helper2(
+ type,
+ direction,
+ delta,
+ altKey,
+ ctrlKey,
+ shiftKey,
+ metaKey
+) {
+ let event = null;
+ let successful;
+
+ try {
+ event = document.createEvent("SimpleGestureEvent");
+ successful = true;
+ } catch (ex) {
+ successful = false;
+ }
+ ok(successful, "Unable to create SimpleGestureEvent");
+
+ try {
+ event.initSimpleGestureEvent(
+ type,
+ true,
+ true,
+ window,
+ 1,
+ 10,
+ 10,
+ 10,
+ 10,
+ ctrlKey,
+ altKey,
+ shiftKey,
+ metaKey,
+ 1,
+ window,
+ 0,
+ direction,
+ delta,
+ 0
+ );
+ successful = true;
+ } catch (ex) {
+ successful = false;
+ }
+ ok(successful, "event.initSimpleGestureEvent should not fail");
+
+ // Make sure the event fields match the expected values
+ is(event.type, type, "Mismatch on evt.type");
+ is(event.direction, direction, "Mismatch on evt.direction");
+ is(event.delta, delta, "Mismatch on evt.delta");
+ is(event.altKey, altKey, "Mismatch on evt.altKey");
+ is(event.ctrlKey, ctrlKey, "Mismatch on evt.ctrlKey");
+ is(event.shiftKey, shiftKey, "Mismatch on evt.shiftKey");
+ is(event.metaKey, metaKey, "Mismatch on evt.metaKey");
+ is(event.view, window, "Mismatch on evt.view");
+ is(event.detail, 1, "Mismatch on evt.detail");
+ is(event.clientX, 10, "Mismatch on evt.clientX");
+ is(event.clientY, 10, "Mismatch on evt.clientY");
+ is(event.screenX, 10, "Mismatch on evt.screenX");
+ is(event.screenY, 10, "Mismatch on evt.screenY");
+ is(event.button, 1, "Mismatch on evt.button");
+ is(event.relatedTarget, window, "Mismatch on evt.relatedTarget");
+
+ // Test event dispatch
+ let expectedEventCount = test_eventCount + 1;
+ document.addEventListener(type, test_eventDispatchListener, true);
+ document.dispatchEvent(event);
+ document.removeEventListener(type, test_eventDispatchListener, true);
+ is(
+ expectedEventCount,
+ test_eventCount,
+ "Dispatched event was never received by listener"
+ );
+}
+
+function test_TestEventCreation() {
+ // Event creation
+ test_helper2(
+ "MozMagnifyGesture",
+ SimpleGestureEvent.DIRECTION_RIGHT,
+ 20.0,
+ true,
+ false,
+ true,
+ false
+ );
+ test_helper2(
+ "MozMagnifyGesture",
+ SimpleGestureEvent.DIRECTION_LEFT,
+ -20.0,
+ false,
+ true,
+ false,
+ true
+ );
+}
+
+function test_EnsureConstantsAreDisjoint() {
+ let up = SimpleGestureEvent.DIRECTION_UP;
+ let down = SimpleGestureEvent.DIRECTION_DOWN;
+ let left = SimpleGestureEvent.DIRECTION_LEFT;
+ let right = SimpleGestureEvent.DIRECTION_RIGHT;
+
+ let clockwise = SimpleGestureEvent.ROTATION_CLOCKWISE;
+ let cclockwise = SimpleGestureEvent.ROTATION_COUNTERCLOCKWISE;
+
+ ok(up ^ down, "DIRECTION_UP and DIRECTION_DOWN are not bitwise disjoint");
+ ok(up ^ left, "DIRECTION_UP and DIRECTION_LEFT are not bitwise disjoint");
+ ok(up ^ right, "DIRECTION_UP and DIRECTION_RIGHT are not bitwise disjoint");
+ ok(down ^ left, "DIRECTION_DOWN and DIRECTION_LEFT are not bitwise disjoint");
+ ok(
+ down ^ right,
+ "DIRECTION_DOWN and DIRECTION_RIGHT are not bitwise disjoint"
+ );
+ ok(
+ left ^ right,
+ "DIRECTION_LEFT and DIRECTION_RIGHT are not bitwise disjoint"
+ );
+ ok(
+ clockwise ^ cclockwise,
+ "ROTATION_CLOCKWISE and ROTATION_COUNTERCLOCKWISE are not bitwise disjoint"
+ );
+}
+
+// Helper for test of latched event processing. Emits the actual
+// gesture events to test whether the commands associated with the
+// gesture will only trigger once for each direction of movement.
+async function test_emitLatchedEvents(eventPrefix, initialDelta, cmd) {
+ let cumulativeDelta = 0;
+ let isIncreasing = initialDelta > 0;
+
+ let expect = {};
+ // Reset the call counters and initialize expected values
+ for (let dir in cmd) {
+ cmd[dir].callCount = expect[dir] = 0;
+ }
+
+ let check = (aDir, aMsg) => ok(cmd[aDir].callCount == expect[aDir], aMsg);
+ let checkBoth = function (aNum, aInc, aDec) {
+ let prefix = "Step " + aNum + ": ";
+ check("inc", prefix + aInc);
+ check("dec", prefix + aDec);
+ };
+
+ // Send the "Start" event.
+ await synthesizeSimpleGestureEvent(
+ test_normalTab.linkedBrowser,
+ eventPrefix + "Start",
+ 10,
+ 10,
+ 0,
+ initialDelta,
+ 0,
+ 0
+ );
+ cumulativeDelta += initialDelta;
+ if (isIncreasing) {
+ expect.inc++;
+ checkBoth(
+ 1,
+ "Increasing command was not triggered",
+ "Decreasing command was triggered"
+ );
+ } else {
+ expect.dec++;
+ checkBoth(
+ 1,
+ "Increasing command was triggered",
+ "Decreasing command was not triggered"
+ );
+ }
+
+ // Send random values in the same direction and ensure neither
+ // command triggers.
+ for (let i = 0; i < 5; i++) {
+ let delta = Math.random() * (isIncreasing ? 100 : -100);
+
+ await synthesizeSimpleGestureEvent(
+ test_normalTab.linkedBrowser,
+ eventPrefix + "Update",
+ 10,
+ 10,
+ 0,
+ delta,
+ 0,
+ 0
+ );
+ cumulativeDelta += delta;
+ checkBoth(
+ 2,
+ "Increasing command was triggered",
+ "Decreasing command was triggered"
+ );
+ }
+
+ // Now go back in the opposite direction.
+ await synthesizeSimpleGestureEvent(
+ test_normalTab.linkedBrowser,
+ eventPrefix + "Update",
+ 10,
+ 10,
+ 0,
+ -initialDelta,
+ 0,
+ 0
+ );
+ cumulativeDelta += -initialDelta;
+ if (isIncreasing) {
+ expect.dec++;
+ checkBoth(
+ 3,
+ "Increasing command was triggered",
+ "Decreasing command was not triggered"
+ );
+ } else {
+ expect.inc++;
+ checkBoth(
+ 3,
+ "Increasing command was not triggered",
+ "Decreasing command was triggered"
+ );
+ }
+
+ // Send random values in the opposite direction and ensure neither
+ // command triggers.
+ for (let i = 0; i < 5; i++) {
+ let delta = Math.random() * (isIncreasing ? -100 : 100);
+ await synthesizeSimpleGestureEvent(
+ test_normalTab.linkedBrowser,
+ eventPrefix + "Update",
+ 10,
+ 10,
+ 0,
+ delta,
+ 0,
+ 0
+ );
+ cumulativeDelta += delta;
+ checkBoth(
+ 4,
+ "Increasing command was triggered",
+ "Decreasing command was triggered"
+ );
+ }
+
+ // Go back to the original direction. The original command should trigger.
+ await synthesizeSimpleGestureEvent(
+ test_normalTab.linkedBrowser,
+ eventPrefix + "Update",
+ 10,
+ 10,
+ 0,
+ initialDelta,
+ 0,
+ 0
+ );
+ cumulativeDelta += initialDelta;
+ if (isIncreasing) {
+ expect.inc++;
+ checkBoth(
+ 5,
+ "Increasing command was not triggered",
+ "Decreasing command was triggered"
+ );
+ } else {
+ expect.dec++;
+ checkBoth(
+ 5,
+ "Increasing command was triggered",
+ "Decreasing command was not triggered"
+ );
+ }
+
+ // Send the wrap-up event. No commands should be triggered.
+ await synthesizeSimpleGestureEvent(
+ test_normalTab.linkedBrowser,
+ eventPrefix,
+ 10,
+ 10,
+ 0,
+ cumulativeDelta,
+ 0,
+ 0
+ );
+ checkBoth(
+ 6,
+ "Increasing command was triggered",
+ "Decreasing command was triggered"
+ );
+}
+
+function test_addCommand(prefName, id) {
+ let cmd = test_commandset.appendChild(document.createXULElement("command"));
+ cmd.setAttribute("id", id);
+ cmd.setAttribute("oncommand", "this.callCount++;");
+
+ cmd.origPrefName = prefName;
+ cmd.origPrefValue = Services.prefs.getCharPref(prefName);
+ Services.prefs.setCharPref(prefName, id);
+
+ return cmd;
+}
+
+function test_removeCommand(cmd) {
+ Services.prefs.setCharPref(cmd.origPrefName, cmd.origPrefValue);
+ test_commandset.removeChild(cmd);
+}
+
+// Test whether latched events are only called once per direction of motion.
+async function test_latchedGesture(gesture, inc, dec, eventPrefix) {
+ let branch = test_prefBranch + gesture + ".";
+
+ // Put the gesture into latched mode.
+ let oldLatchedValue = Services.prefs.getBoolPref(branch + "latched");
+ Services.prefs.setBoolPref(branch + "latched", true);
+
+ // Install the test commands for increasing and decreasing motion.
+ let cmd = {
+ inc: test_addCommand(branch + inc, "test:incMotion"),
+ dec: test_addCommand(branch + dec, "test:decMotion"),
+ };
+
+ // Test the gestures in each direction.
+ await test_emitLatchedEvents(eventPrefix, 500, cmd);
+ await test_emitLatchedEvents(eventPrefix, -500, cmd);
+
+ // Restore the gesture to its original configuration.
+ Services.prefs.setBoolPref(branch + "latched", oldLatchedValue);
+ for (let dir in cmd) {
+ test_removeCommand(cmd[dir]);
+ }
+}
+
+// Test whether non-latched events are triggered upon sufficient motion.
+async function test_thresholdGesture(gesture, inc, dec, eventPrefix) {
+ let branch = test_prefBranch + gesture + ".";
+
+ // Disable latched mode for this gesture.
+ let oldLatchedValue = Services.prefs.getBoolPref(branch + "latched");
+ Services.prefs.setBoolPref(branch + "latched", false);
+
+ // Set the triggering threshold value to 50.
+ let oldThresholdValue = Services.prefs.getIntPref(branch + "threshold");
+ Services.prefs.setIntPref(branch + "threshold", 50);
+
+ // Install the test commands for increasing and decreasing motion.
+ let cmdInc = test_addCommand(branch + inc, "test:incMotion");
+ let cmdDec = test_addCommand(branch + dec, "test:decMotion");
+
+ // Send the start event but stop short of triggering threshold.
+ cmdInc.callCount = cmdDec.callCount = 0;
+ await synthesizeSimpleGestureEvent(
+ test_normalTab.linkedBrowser,
+ eventPrefix + "Start",
+ 10,
+ 10,
+ 0,
+ 49.5,
+ 0,
+ 0
+ );
+ ok(cmdInc.callCount == 0, "Increasing command was triggered");
+ ok(cmdDec.callCount == 0, "Decreasing command was triggered");
+
+ // Now trigger the threshold.
+ cmdInc.callCount = cmdDec.callCount = 0;
+ await synthesizeSimpleGestureEvent(
+ test_normalTab.linkedBrowser,
+ eventPrefix + "Update",
+ 10,
+ 10,
+ 0,
+ 1,
+ 0,
+ 0
+ );
+ ok(cmdInc.callCount == 1, "Increasing command was not triggered");
+ ok(cmdDec.callCount == 0, "Decreasing command was triggered");
+
+ // The tracking counter should go to zero. Go back the other way and
+ // stop short of triggering the threshold.
+ cmdInc.callCount = cmdDec.callCount = 0;
+ await synthesizeSimpleGestureEvent(
+ test_normalTab.linkedBrowser,
+ eventPrefix + "Update",
+ 10,
+ 10,
+ 0,
+ -49.5,
+ 0,
+ 0
+ );
+ ok(cmdInc.callCount == 0, "Increasing command was triggered");
+ ok(cmdDec.callCount == 0, "Decreasing command was triggered");
+
+ // Now cross the threshold and trigger the decreasing command.
+ cmdInc.callCount = cmdDec.callCount = 0;
+ await synthesizeSimpleGestureEvent(
+ test_normalTab.linkedBrowser,
+ eventPrefix + "Update",
+ 10,
+ 10,
+ 0,
+ -1.5,
+ 0,
+ 0
+ );
+ ok(cmdInc.callCount == 0, "Increasing command was triggered");
+ ok(cmdDec.callCount == 1, "Decreasing command was not triggered");
+
+ // Send the wrap-up event. No commands should trigger.
+ cmdInc.callCount = cmdDec.callCount = 0;
+ await synthesizeSimpleGestureEvent(
+ test_normalTab.linkedBrowser,
+ eventPrefix,
+ 0,
+ 0,
+ 0,
+ -0.5,
+ 0,
+ 0
+ );
+ ok(cmdInc.callCount == 0, "Increasing command was triggered");
+ ok(cmdDec.callCount == 0, "Decreasing command was triggered");
+
+ // Restore the gesture to its original configuration.
+ Services.prefs.setBoolPref(branch + "latched", oldLatchedValue);
+ Services.prefs.setIntPref(branch + "threshold", oldThresholdValue);
+ test_removeCommand(cmdInc);
+ test_removeCommand(cmdDec);
+}
+
+async function test_swipeGestures() {
+ // easier to type names for the direction constants
+ let up = SimpleGestureEvent.DIRECTION_UP;
+ let down = SimpleGestureEvent.DIRECTION_DOWN;
+ let left = SimpleGestureEvent.DIRECTION_LEFT;
+ let right = SimpleGestureEvent.DIRECTION_RIGHT;
+
+ let branch = test_prefBranch + "swipe.";
+
+ // Install the test commands for the swipe gestures.
+ let cmdUp = test_addCommand(branch + "up", "test:swipeUp");
+ let cmdDown = test_addCommand(branch + "down", "test:swipeDown");
+ let cmdLeft = test_addCommand(branch + "left", "test:swipeLeft");
+ let cmdRight = test_addCommand(branch + "right", "test:swipeRight");
+
+ function resetCounts() {
+ cmdUp.callCount = 0;
+ cmdDown.callCount = 0;
+ cmdLeft.callCount = 0;
+ cmdRight.callCount = 0;
+ }
+
+ // UP
+ resetCounts();
+ await synthesizeSimpleGestureEvent(
+ test_normalTab.linkedBrowser,
+ "MozSwipeGesture",
+ 10,
+ 10,
+ up,
+ 0,
+ 0,
+ 0
+ );
+ ok(cmdUp.callCount == 1, "Step 1: Up command was not triggered");
+ ok(cmdDown.callCount == 0, "Step 1: Down command was triggered");
+ ok(cmdLeft.callCount == 0, "Step 1: Left command was triggered");
+ ok(cmdRight.callCount == 0, "Step 1: Right command was triggered");
+
+ // DOWN
+ resetCounts();
+ await synthesizeSimpleGestureEvent(
+ test_normalTab.linkedBrowser,
+ "MozSwipeGesture",
+ 10,
+ 10,
+ down,
+ 0,
+ 0,
+ 0
+ );
+ ok(cmdUp.callCount == 0, "Step 2: Up command was triggered");
+ ok(cmdDown.callCount == 1, "Step 2: Down command was not triggered");
+ ok(cmdLeft.callCount == 0, "Step 2: Left command was triggered");
+ ok(cmdRight.callCount == 0, "Step 2: Right command was triggered");
+
+ // LEFT
+ resetCounts();
+ await synthesizeSimpleGestureEvent(
+ test_normalTab.linkedBrowser,
+ "MozSwipeGesture",
+ 10,
+ 10,
+ left,
+ 0,
+ 0,
+ 0
+ );
+ ok(cmdUp.callCount == 0, "Step 3: Up command was triggered");
+ ok(cmdDown.callCount == 0, "Step 3: Down command was triggered");
+ ok(cmdLeft.callCount == 1, "Step 3: Left command was not triggered");
+ ok(cmdRight.callCount == 0, "Step 3: Right command was triggered");
+
+ // RIGHT
+ resetCounts();
+ await synthesizeSimpleGestureEvent(
+ test_normalTab.linkedBrowser,
+ "MozSwipeGesture",
+ 10,
+ 10,
+ right,
+ 0,
+ 0,
+ 0
+ );
+ ok(cmdUp.callCount == 0, "Step 4: Up command was triggered");
+ ok(cmdDown.callCount == 0, "Step 4: Down command was triggered");
+ ok(cmdLeft.callCount == 0, "Step 4: Left command was triggered");
+ ok(cmdRight.callCount == 1, "Step 4: Right command was not triggered");
+
+ // Make sure combinations do not trigger events.
+ let combos = [up | left, up | right, down | left, down | right];
+ for (let i = 0; i < combos.length; i++) {
+ resetCounts();
+ await synthesizeSimpleGestureEvent(
+ test_normalTab.linkedBrowser,
+ "MozSwipeGesture",
+ 10,
+ 10,
+ combos[i],
+ 0,
+ 0,
+ 0
+ );
+ ok(cmdUp.callCount == 0, "Step 5-" + i + ": Up command was triggered");
+ ok(cmdDown.callCount == 0, "Step 5-" + i + ": Down command was triggered");
+ ok(cmdLeft.callCount == 0, "Step 5-" + i + ": Left command was triggered");
+ ok(
+ cmdRight.callCount == 0,
+ "Step 5-" + i + ": Right command was triggered"
+ );
+ }
+
+ // Remove the test commands.
+ test_removeCommand(cmdUp);
+ test_removeCommand(cmdDown);
+ test_removeCommand(cmdLeft);
+ test_removeCommand(cmdRight);
+}
+
+function test_rotateHelperGetImageRotation(aImageElement) {
+ // Get the true image rotation from the transform matrix, bounded
+ // to 0 <= result < 360
+ let transformValue = content.window.getComputedStyle(aImageElement).transform;
+ if (transformValue == "none") {
+ return 0;
+ }
+
+ transformValue = transformValue.split("(")[1].split(")")[0].split(",");
+ var rotation = Math.round(
+ Math.atan2(transformValue[1], transformValue[0]) * (180 / Math.PI)
+ );
+ return rotation < 0 ? rotation + 360 : rotation;
+}
+
+async function test_rotateHelperOneGesture(
+ aImageElement,
+ aCurrentRotation,
+ aDirection,
+ aAmount,
+ aStop
+) {
+ if (aAmount <= 0 || aAmount > 90) {
+ // Bound to 0 < aAmount <= 90
+ return;
+ }
+
+ // easier to type names for the direction constants
+ let clockwise = SimpleGestureEvent.ROTATION_CLOCKWISE;
+
+ let delta = aAmount * (aDirection == clockwise ? 1 : -1);
+
+ // Kill transition time on image so test isn't wrong and doesn't take 10 seconds
+ aImageElement.style.transitionDuration = "0s";
+
+ // Start the gesture, perform an update, and force flush
+ await synthesizeSimpleGestureEvent(
+ test_imageTab.linkedBrowser,
+ "MozRotateGestureStart",
+ 10,
+ 10,
+ aDirection,
+ 0.001,
+ 0,
+ 0
+ );
+ await synthesizeSimpleGestureEvent(
+ test_imageTab.linkedBrowser,
+ "MozRotateGestureUpdate",
+ 10,
+ 10,
+ aDirection,
+ delta,
+ 0,
+ 0
+ );
+ aImageElement.clientTop;
+
+ // If stop, check intermediate
+ if (aStop) {
+ // Send near-zero-delta to stop, and force flush
+ await synthesizeSimpleGestureEvent(
+ test_imageTab.linkedBrowser,
+ "MozRotateGestureUpdate",
+ 10,
+ 10,
+ aDirection,
+ 0.001,
+ 0,
+ 0
+ );
+ aImageElement.clientTop;
+
+ let stopExpectedRotation = (aCurrentRotation + delta) % 360;
+ if (stopExpectedRotation < 0) {
+ stopExpectedRotation += 360;
+ }
+
+ is(
+ stopExpectedRotation,
+ test_rotateHelperGetImageRotation(aImageElement),
+ "Image rotation at gesture stop/hold: expected=" +
+ stopExpectedRotation +
+ ", observed=" +
+ test_rotateHelperGetImageRotation(aImageElement) +
+ ", init=" +
+ aCurrentRotation +
+ ", amt=" +
+ aAmount +
+ ", dir=" +
+ (aDirection == clockwise ? "cl" : "ccl")
+ );
+ }
+ // End it and force flush
+ await synthesizeSimpleGestureEvent(
+ test_imageTab.linkedBrowser,
+ "MozRotateGesture",
+ 10,
+ 10,
+ aDirection,
+ 0,
+ 0,
+ 0
+ );
+ aImageElement.clientTop;
+
+ let finalExpectedRotation;
+
+ if (aAmount < 45 && aStop) {
+ // Rotate a bit, then stop. Expect no change at end of gesture.
+ finalExpectedRotation = aCurrentRotation;
+ } else {
+ // Either not stopping (expect 90 degree change in aDirection), OR
+ // stopping but after 45, (expect 90 degree change in aDirection)
+ finalExpectedRotation =
+ (aCurrentRotation + (aDirection == clockwise ? 1 : -1) * 90) % 360;
+ if (finalExpectedRotation < 0) {
+ finalExpectedRotation += 360;
+ }
+ }
+
+ is(
+ finalExpectedRotation,
+ test_rotateHelperGetImageRotation(aImageElement),
+ "Image rotation gesture end: expected=" +
+ finalExpectedRotation +
+ ", observed=" +
+ test_rotateHelperGetImageRotation(aImageElement) +
+ ", init=" +
+ aCurrentRotation +
+ ", amt=" +
+ aAmount +
+ ", dir=" +
+ (aDirection == clockwise ? "cl" : "ccl")
+ );
+}
+
+async function test_rotateGesturesOnTab() {
+ gBrowser.selectedBrowser.removeEventListener(
+ "load",
+ test_rotateGesturesOnTab,
+ true
+ );
+
+ if (!ImageDocument.isInstance(content.document)) {
+ ok(false, "Image document failed to open for rotation testing");
+ gBrowser.removeTab(test_imageTab);
+ BrowserTestUtils.removeTab(test_normalTab);
+ test_imageTab = null;
+ test_normalTab = null;
+ finish();
+ return;
+ }
+
+ // easier to type names for the direction constants
+ let cl = SimpleGestureEvent.ROTATION_CLOCKWISE;
+ let ccl = SimpleGestureEvent.ROTATION_COUNTERCLOCKWISE;
+
+ let imgElem =
+ content.document.body && content.document.body.firstElementChild;
+
+ if (!imgElem) {
+ ok(false, "Could not get image element on ImageDocument for rotation!");
+ gBrowser.removeTab(test_imageTab);
+ BrowserTestUtils.removeTab(test_normalTab);
+ test_imageTab = null;
+ test_normalTab = null;
+ finish();
+ return;
+ }
+
+ // Quick function to normalize rotation to 0 <= r < 360
+ var normRot = function (rotation) {
+ rotation = rotation % 360;
+ if (rotation < 0) {
+ rotation += 360;
+ }
+ return rotation;
+ };
+
+ for (var initRot = 0; initRot < 360; initRot += 90) {
+ // Test each case: at each 90 degree snap; cl/ccl;
+ // amount more or less than 45; stop and hold or don't (32 total tests)
+ // The amount added to the initRot is where it is expected to be
+ await test_rotateHelperOneGesture(
+ imgElem,
+ normRot(initRot + 0),
+ cl,
+ 35,
+ true
+ );
+ await test_rotateHelperOneGesture(
+ imgElem,
+ normRot(initRot + 0),
+ cl,
+ 35,
+ false
+ );
+ await test_rotateHelperOneGesture(
+ imgElem,
+ normRot(initRot + 90),
+ cl,
+ 55,
+ true
+ );
+ await test_rotateHelperOneGesture(
+ imgElem,
+ normRot(initRot + 180),
+ cl,
+ 55,
+ false
+ );
+ await test_rotateHelperOneGesture(
+ imgElem,
+ normRot(initRot + 270),
+ ccl,
+ 35,
+ true
+ );
+ await test_rotateHelperOneGesture(
+ imgElem,
+ normRot(initRot + 270),
+ ccl,
+ 35,
+ false
+ );
+ await test_rotateHelperOneGesture(
+ imgElem,
+ normRot(initRot + 180),
+ ccl,
+ 55,
+ true
+ );
+ await test_rotateHelperOneGesture(
+ imgElem,
+ normRot(initRot + 90),
+ ccl,
+ 55,
+ false
+ );
+
+ // Manually rotate it 90 degrees clockwise to prepare for next iteration,
+ // and force flush
+ await synthesizeSimpleGestureEvent(
+ test_imageTab.linkedBrowser,
+ "MozRotateGestureStart",
+ 10,
+ 10,
+ cl,
+ 0.001,
+ 0,
+ 0
+ );
+ await synthesizeSimpleGestureEvent(
+ test_imageTab.linkedBrowser,
+ "MozRotateGestureUpdate",
+ 10,
+ 10,
+ cl,
+ 90,
+ 0,
+ 0
+ );
+ await synthesizeSimpleGestureEvent(
+ test_imageTab.linkedBrowser,
+ "MozRotateGestureUpdate",
+ 10,
+ 10,
+ cl,
+ 0.001,
+ 0,
+ 0
+ );
+ await synthesizeSimpleGestureEvent(
+ test_imageTab.linkedBrowser,
+ "MozRotateGesture",
+ 10,
+ 10,
+ cl,
+ 0,
+ 0,
+ 0
+ );
+ imgElem.clientTop;
+ }
+
+ gBrowser.removeTab(test_imageTab);
+ BrowserTestUtils.removeTab(test_normalTab);
+ test_imageTab = null;
+ test_normalTab = null;
+ finish();
+}
+
+function test_rotateGestures() {
+ test_imageTab = BrowserTestUtils.addTab(
+ gBrowser,
+ "chrome://branding/content/about-logo.png"
+ );
+ gBrowser.selectedTab = test_imageTab;
+
+ gBrowser.selectedBrowser.addEventListener(
+ "load",
+ test_rotateGesturesOnTab,
+ true
+ );
+}
diff --git a/browser/base/content/test/general/browser_hide_removing.js b/browser/base/content/test/general/browser_hide_removing.js
new file mode 100644
index 0000000000..24079c22e6
--- /dev/null
+++ b/browser/base/content/test/general/browser_hide_removing.js
@@ -0,0 +1,27 @@
+/* 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/. */
+
+// Bug 587922: tabs don't get removed if they're hidden
+
+add_task(async function () {
+ // Add a tab that will get removed and hidden
+ let testTab = BrowserTestUtils.addTab(gBrowser, "about:blank", {
+ skipAnimation: true,
+ });
+ is(gBrowser.visibleTabs.length, 2, "just added a tab, so 2 tabs");
+ await BrowserTestUtils.switchTab(gBrowser, testTab);
+
+ let numVisBeforeHide, numVisAfterHide;
+
+ // We have to animate the tab removal in order to get an async
+ // tab close.
+ BrowserTestUtils.removeTab(testTab, { animate: true });
+
+ numVisBeforeHide = gBrowser.visibleTabs.length;
+ gBrowser.hideTab(testTab);
+ numVisAfterHide = gBrowser.visibleTabs.length;
+
+ is(numVisBeforeHide, 1, "animated remove has in 1 tab left");
+ is(numVisAfterHide, 1, "hiding a removing tab also has 1 tab");
+});
diff --git a/browser/base/content/test/general/browser_homeDrop.js b/browser/base/content/test/general/browser_homeDrop.js
new file mode 100644
index 0000000000..81dc48d3e4
--- /dev/null
+++ b/browser/base/content/test/general/browser_homeDrop.js
@@ -0,0 +1,117 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+add_task(async function setupHomeButton() {
+ // Put the home button in the pre-proton placement to test focus states.
+ CustomizableUI.addWidgetToArea(
+ "home-button",
+ "nav-bar",
+ CustomizableUI.getPlacementOfWidget("stop-reload-button").position + 1
+ );
+ CustomizableUI.addWidgetToArea("sidebar-button", "nav-bar");
+ registerCleanupFunction(async function resetToolbar() {
+ await CustomizableUI.reset();
+ });
+});
+
+add_task(async function () {
+ let HOMEPAGE_PREF = "browser.startup.homepage";
+
+ await pushPrefs([HOMEPAGE_PREF, "about:mozilla"]);
+
+ let EventUtils = {};
+ Services.scriptloader.loadSubScript(
+ "chrome://mochikit/content/tests/SimpleTest/EventUtils.js",
+ EventUtils
+ );
+
+ // Since synthesizeDrop triggers the srcElement, need to use another button
+ // that should be visible.
+ let dragSrcElement = document.getElementById("sidebar-button");
+ ok(dragSrcElement, "Sidebar button exists");
+ let homeButton = document.getElementById("home-button");
+ ok(homeButton, "home button present");
+
+ async function drop(dragData, homepage) {
+ let setHomepageDialogPromise =
+ BrowserTestUtils.promiseAlertDialogOpen("accept");
+ let setHomepagePromise = TestUtils.waitForPrefChange(
+ HOMEPAGE_PREF,
+ newVal => newVal == homepage
+ );
+
+ EventUtils.synthesizeDrop(
+ dragSrcElement,
+ homeButton,
+ dragData,
+ "copy",
+ window
+ );
+
+ // Ensure dnd suppression is cleared.
+ EventUtils.synthesizeMouseAtCenter(homeButton, { type: "mouseup" }, window);
+
+ await setHomepageDialogPromise;
+ ok(true, "dialog appeared in response to home button drop");
+
+ await setHomepagePromise;
+
+ let modified = Services.prefs.getStringPref(HOMEPAGE_PREF);
+ is(modified, homepage, "homepage is set correctly");
+ Services.prefs.setStringPref(HOMEPAGE_PREF, "about:mozilla;");
+ }
+
+ function dropInvalidURI() {
+ return new Promise(resolve => {
+ let consoleListener = {
+ observe(m) {
+ if (m.message.includes("NS_ERROR_DOM_BAD_URI")) {
+ ok(true, "drop was blocked");
+ resolve();
+ }
+ },
+ };
+ Services.console.registerListener(consoleListener);
+ registerCleanupFunction(function () {
+ Services.console.unregisterListener(consoleListener);
+ });
+
+ executeSoon(function () {
+ info("Attempting second drop, of a javascript: URI");
+ // The drop handler throws an exception when dragging URIs that inherit
+ // principal, e.g. javascript:
+ expectUncaughtException();
+ EventUtils.synthesizeDrop(
+ dragSrcElement,
+ homeButton,
+ [[{ type: "text/plain", data: "javascript:8888" }]],
+ "copy",
+ window
+ );
+ // Ensure dnd suppression is cleared.
+ EventUtils.synthesizeMouseAtCenter(
+ homeButton,
+ { type: "mouseup" },
+ window
+ );
+ });
+ });
+ }
+
+ await drop(
+ [[{ type: "text/plain", data: "http://mochi.test:8888/" }]],
+ "http://mochi.test:8888/"
+ );
+ await drop(
+ [
+ [
+ {
+ type: "text/plain",
+ data: "http://mochi.test:8888/\nhttp://mochi.test:8888/b\nhttp://mochi.test:8888/c",
+ },
+ ],
+ ],
+ "http://mochi.test:8888/|http://mochi.test:8888/b|http://mochi.test:8888/c"
+ );
+ await dropInvalidURI();
+});
diff --git a/browser/base/content/test/general/browser_invalid_uri_back_forward_manipulation.js b/browser/base/content/test/general/browser_invalid_uri_back_forward_manipulation.js
new file mode 100644
index 0000000000..1624a1514d
--- /dev/null
+++ b/browser/base/content/test/general/browser_invalid_uri_back_forward_manipulation.js
@@ -0,0 +1,48 @@
+"use strict";
+
+/**
+ * Verify that loading an invalid URI does not clobber a previously-loaded page's history
+ * entry, but that the invalid URI gets its own history entry instead. We're checking this
+ * using nsIWebNavigation's canGoBack, as well as actually going back and then checking
+ * canGoForward.
+ */
+add_task(async function checkBackFromInvalidURI() {
+ await pushPrefs(["keyword.enabled", false]);
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:robots",
+ true
+ );
+ info("Loaded about:robots");
+
+ gURLBar.value = "::2600";
+
+ let promiseErrorPageLoaded = BrowserTestUtils.waitForErrorPage(
+ tab.linkedBrowser
+ );
+ gURLBar.handleCommand();
+ await promiseErrorPageLoaded;
+
+ ok(gBrowser.webNavigation.canGoBack, "Should be able to go back");
+ if (gBrowser.webNavigation.canGoBack) {
+ // Can't use DOMContentLoaded here because the page is bfcached. Can't use pageshow for
+ // the error page because it doesn't seem to fire for those.
+ let promiseOtherPageLoaded = BrowserTestUtils.waitForEvent(
+ tab.linkedBrowser,
+ "pageshow",
+ false,
+ // Be paranoid we *are* actually seeing this other page load, not some kind of race
+ // for if/when we do start firing pageshow for the error page...
+ function (e) {
+ return gBrowser.currentURI.spec == "about:robots";
+ }
+ );
+ gBrowser.goBack();
+ await promiseOtherPageLoaded;
+ ok(
+ gBrowser.webNavigation.canGoForward,
+ "Should be able to go forward from previous page."
+ );
+ }
+ BrowserTestUtils.removeTab(tab);
+});
diff --git a/browser/base/content/test/general/browser_lastAccessedTab.js b/browser/base/content/test/general/browser_lastAccessedTab.js
new file mode 100644
index 0000000000..631fcb3bfe
--- /dev/null
+++ b/browser/base/content/test/general/browser_lastAccessedTab.js
@@ -0,0 +1,62 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+/* eslint-disable mozilla/no-arbitrary-setTimeout */
+
+// gBrowser.selectedTab.lastAccessed and Date.now() called from this test can't
+// run concurrently, and therefore don't always match exactly.
+const CURRENT_TIME_TOLERANCE_MS = 15;
+
+function isCurrent(tab, msg) {
+ const DIFF = Math.abs(Date.now() - tab.lastAccessed);
+ ok(DIFF <= CURRENT_TIME_TOLERANCE_MS, msg + " (difference: " + DIFF + ")");
+}
+
+function nextStep(fn) {
+ setTimeout(fn, CURRENT_TIME_TOLERANCE_MS + 10);
+}
+
+var originalTab;
+var newTab;
+
+function test() {
+ waitForExplicitFinish();
+ // This test assumes that time passes between operations. But if the precision
+ // is low enough, and the test fast enough, an operation, and a successive call
+ // to Date.now() will have the same time value.
+ SpecialPowers.pushPrefEnv(
+ { set: [["privacy.reduceTimerPrecision", false]] },
+ function () {
+ originalTab = gBrowser.selectedTab;
+ nextStep(step2);
+ }
+ );
+}
+
+function step2() {
+ isCurrent(originalTab, "selected tab has the current timestamp");
+ newTab = BrowserTestUtils.addTab(gBrowser, "about:blank", {
+ skipAnimation: true,
+ });
+ nextStep(step3);
+}
+
+function step3() {
+ ok(newTab.lastAccessed < Date.now(), "new tab hasn't been selected so far");
+ gBrowser.selectedTab = newTab;
+ isCurrent(newTab, "new tab has the current timestamp after being selected");
+ nextStep(step4);
+}
+
+function step4() {
+ ok(
+ originalTab.lastAccessed < Date.now(),
+ "original tab has old timestamp after being deselected"
+ );
+ isCurrent(
+ newTab,
+ "new tab has the current timestamp since it's still selected"
+ );
+
+ gBrowser.removeTab(newTab);
+ finish();
+}
diff --git a/browser/base/content/test/general/browser_menuButtonFitts.js b/browser/base/content/test/general/browser_menuButtonFitts.js
new file mode 100644
index 0000000000..f56f46eb6c
--- /dev/null
+++ b/browser/base/content/test/general/browser_menuButtonFitts.js
@@ -0,0 +1,69 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+"use strict";
+
+function getNavBarEndPosition() {
+ let navBar = document.getElementById("nav-bar");
+ let boundingRect = navBar.getBoundingClientRect();
+
+ // Find where the nav-bar is vertically.
+ let y = boundingRect.top + Math.floor(boundingRect.height / 2);
+ // Use the last pixel of the screen since it is maximized.
+ let x = boundingRect.width - 1;
+ return { x, y };
+}
+
+/**
+ * Clicking the right end of a maximized window should open the hamburger menu.
+ */
+add_task(async function test_clicking_hamburger_edge_fitts() {
+ if (window.windowState != window.STATE_MAXIMIZED) {
+ info(`Waiting for maximize, current state: ${window.windowState}`);
+ let resizeDone = BrowserTestUtils.waitForEvent(
+ window,
+ "resize",
+ false,
+ () => window.outerWidth >= screen.width - 1
+ );
+ let maximizeDone = BrowserTestUtils.waitForEvent(window, "sizemodechange");
+ window.maximize();
+ await maximizeDone;
+ await resizeDone;
+ }
+
+ is(window.windowState, window.STATE_MAXIMIZED, "should be maximized");
+
+ let { x, y } = getNavBarEndPosition();
+ info(`Clicking in ${x}, ${y}`);
+
+ let popupHiddenResolve;
+ let popupHiddenPromise = new Promise(resolve => {
+ popupHiddenResolve = resolve;
+ });
+ async function onPopupHidden() {
+ PanelUI.panel.removeEventListener("popuphidden", onPopupHidden);
+
+ info("Waiting for restore");
+
+ let restoreDone = BrowserTestUtils.waitForEvent(window, "sizemodechange");
+ window.restore();
+ await restoreDone;
+
+ popupHiddenResolve();
+ }
+ function onPopupShown() {
+ PanelUI.panel.removeEventListener("popupshown", onPopupShown);
+ ok(true, "Clicking at the far edge of the window opened the menu popup.");
+ PanelUI.panel.addEventListener("popuphidden", onPopupHidden);
+ PanelUI.hide();
+ }
+ registerCleanupFunction(function () {
+ PanelUI.panel.removeEventListener("popupshown", onPopupShown);
+ PanelUI.panel.removeEventListener("popuphidden", onPopupHidden);
+ });
+ PanelUI.panel.addEventListener("popupshown", onPopupShown);
+ EventUtils.synthesizeMouseAtPoint(x, y, {}, window);
+ await popupHiddenPromise;
+});
diff --git a/browser/base/content/test/general/browser_middleMouse_noJSPaste.js b/browser/base/content/test/general/browser_middleMouse_noJSPaste.js
new file mode 100644
index 0000000000..f023b78909
--- /dev/null
+++ b/browser/base/content/test/general/browser_middleMouse_noJSPaste.js
@@ -0,0 +1,49 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const middleMousePastePref = "middlemouse.contentLoadURL";
+const autoScrollPref = "general.autoScroll";
+
+add_task(async function () {
+ await pushPrefs([middleMousePastePref, true], [autoScrollPref, false]);
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser);
+
+ let url = "javascript:http://www.example.com/";
+ await new Promise((resolve, reject) => {
+ SimpleTest.waitForClipboard(
+ url,
+ () => {
+ Cc["@mozilla.org/widget/clipboardhelper;1"]
+ .getService(Ci.nsIClipboardHelper)
+ .copyString(url);
+ },
+ resolve,
+ () => {
+ ok(false, "Clipboard copy failed");
+ reject();
+ }
+ );
+ });
+
+ let middlePagePromise = BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+
+ // Middle click on the content area
+ info("Middle clicking");
+ await BrowserTestUtils.synthesizeMouse(
+ null,
+ 10,
+ 10,
+ { button: 1 },
+ gBrowser.selectedBrowser
+ );
+ await middlePagePromise;
+
+ is(
+ gBrowser.currentURI.spec,
+ url.replace(/^javascript:/, ""),
+ "url loaded by middle click doesn't include JS"
+ );
+
+ gBrowser.removeTab(tab);
+});
diff --git a/browser/base/content/test/general/browser_minimize.js b/browser/base/content/test/general/browser_minimize.js
new file mode 100644
index 0000000000..a57fea079c
--- /dev/null
+++ b/browser/base/content/test/general/browser_minimize.js
@@ -0,0 +1,49 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+add_task(async function () {
+ registerCleanupFunction(function () {
+ window.restore();
+ });
+ function isActive() {
+ return gBrowser.selectedTab.linkedBrowser.docShellIsActive;
+ }
+
+ ok(isActive(), "Docshell should be active when starting the test");
+ ok(!document.hidden, "Top level window should be visible");
+
+ info("Calling window.minimize");
+ let promiseSizeModeChange = BrowserTestUtils.waitForEvent(
+ window,
+ "sizemodechange"
+ );
+ window.minimize();
+ await promiseSizeModeChange;
+ ok(!isActive(), "Docshell should be Inactive");
+ ok(document.hidden, "Top level window should be hidden");
+
+ info("Calling window.restore");
+ promiseSizeModeChange = BrowserTestUtils.waitForEvent(
+ window,
+ "sizemodechange"
+ );
+ window.restore();
+ // On Ubuntu `window.restore` doesn't seem to work, use a timer to make the
+ // test fail faster and more cleanly than with a test timeout.
+ await Promise.race([
+ promiseSizeModeChange,
+ new Promise((resolve, reject) =>
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ setTimeout(() => {
+ reject("timed out waiting for sizemodechange event");
+ }, 5000)
+ ),
+ ]);
+ // The sizemodechange event can sometimes be fired before the
+ // occlusionstatechange event, especially in chaos mode.
+ if (window.isFullyOccluded) {
+ await BrowserTestUtils.waitForEvent(window, "occlusionstatechange");
+ }
+ ok(isActive(), "Docshell should be active again");
+ ok(!document.hidden, "Top level window should be visible");
+});
diff --git a/browser/base/content/test/general/browser_modifiedclick_inherit_principal.js b/browser/base/content/test/general/browser_modifiedclick_inherit_principal.js
new file mode 100644
index 0000000000..be3de519d6
--- /dev/null
+++ b/browser/base/content/test/general/browser_modifiedclick_inherit_principal.js
@@ -0,0 +1,42 @@
+"use strict";
+
+const kURL =
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com/browser/browser/base/content/test/general/dummy_page.html";
+("data:text/html,<a href=''>Middle-click me</a>");
+
+/*
+ * Check that when manually opening content JS links in new tabs/windows,
+ * we use the correct principal, and we don't clear the URL bar.
+ */
+add_task(async function () {
+ await BrowserTestUtils.withNewTab(kURL, async function (browser) {
+ let newTabPromise = BrowserTestUtils.waitForNewTab(gBrowser);
+ await SpecialPowers.spawn(browser, [], async function () {
+ let a = content.document.createElement("a");
+ // newTabPromise won't resolve until it has a URL that's not "about:blank".
+ // But doing document.open() from inside that same document does not change
+ // the URL of the docshell. So we need to do some URL change to cause
+ // newTabPromise to resolve, since the document is at about:blank the whole
+ // time, URL-wise. Navigating to '#' should do the trick without changing
+ // anything else about the document involved.
+ a.href =
+ "javascript:document.write('spoof'); location.href='#'; void(0);";
+ a.textContent = "Some link";
+ content.document.body.appendChild(a);
+ });
+ info("Added element");
+ await BrowserTestUtils.synthesizeMouseAtCenter("a", { button: 1 }, browser);
+ let newTab = await newTabPromise;
+ is(
+ newTab.linkedBrowser.contentPrincipal.origin,
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com",
+ "Principal should be for example.com"
+ );
+ await BrowserTestUtils.switchTab(gBrowser, newTab);
+ info(gURLBar.value);
+ isnot(gURLBar.value, "", "URL bar should not be empty.");
+ BrowserTestUtils.removeTab(newTab);
+ });
+});
diff --git a/browser/base/content/test/general/browser_newTabDrop.js b/browser/base/content/test/general/browser_newTabDrop.js
new file mode 100644
index 0000000000..d0e7f35c1f
--- /dev/null
+++ b/browser/base/content/test/general/browser_newTabDrop.js
@@ -0,0 +1,221 @@
+const ANY_URL = undefined;
+
+const { SearchTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/SearchTestUtils.sys.mjs"
+);
+
+SearchTestUtils.init(this);
+
+registerCleanupFunction(async function cleanup() {
+ while (gBrowser.tabs.length > 1) {
+ BrowserTestUtils.removeTab(gBrowser.tabs[gBrowser.tabs.length - 1]);
+ }
+});
+
+add_task(async function test_setup() {
+ // This test opens multiple tabs and some confirm dialogs, that takes long.
+ requestLongerTimeout(2);
+
+ // Stop search-engine loads from hitting the network
+ await SearchTestUtils.installSearchExtension(
+ {
+ name: "MozSearch",
+ search_url: "https://example.com/",
+ search_url_get_params: "q={searchTerms}",
+ },
+ { setAsDefault: true }
+ );
+});
+
+// New Tab Button opens any link.
+add_task(async function single_url() {
+ await dropText("mochi.test/first", ["http://mochi.test/first"]);
+});
+add_task(async function single_url2() {
+ await dropText("mochi.test/second", ["http://mochi.test/second"]);
+});
+add_task(async function single_url3() {
+ await dropText("mochi.test/third", ["http://mochi.test/third"]);
+});
+
+// Single text/plain item, with multiple links.
+add_task(async function multiple_urls() {
+ await dropText("www.mochi.test/1\nmochi.test/2", [
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://www.mochi.test/1",
+ "http://mochi.test/2",
+ ]);
+});
+
+// Multiple text/plain items, with single and multiple links.
+add_task(async function multiple_items_single_and_multiple_links() {
+ await drop(
+ [
+ [{ type: "text/plain", data: "mochi.test/5" }],
+ [{ type: "text/plain", data: "mochi.test/6\nmochi.test/7" }],
+ ],
+ ["http://mochi.test/5", "http://mochi.test/6", "http://mochi.test/7"]
+ );
+});
+
+// Single text/x-moz-url item, with multiple links.
+// "text/x-moz-url" has titles in even-numbered lines.
+add_task(async function single_moz_url_multiple_links() {
+ await drop(
+ [
+ [
+ {
+ type: "text/x-moz-url",
+ data: "mochi.test/8\nTITLE8\nmochi.test/9\nTITLE9",
+ },
+ ],
+ ],
+ ["http://mochi.test/8", "http://mochi.test/9"]
+ );
+});
+
+// Single item with multiple types.
+add_task(async function single_item_multiple_types() {
+ await drop(
+ [
+ [
+ { type: "text/plain", data: "mochi.test/10" },
+ { type: "text/x-moz-url", data: "mochi.test/11\nTITLE11" },
+ ],
+ ],
+ ["http://mochi.test/11"]
+ );
+});
+
+// Warn when too many URLs are dropped.
+add_task(async function multiple_tabs_under_max() {
+ let urls = [];
+ for (let i = 0; i < 5; i++) {
+ urls.push("mochi.test/multi" + i);
+ }
+ await dropText(urls.join("\n"), [
+ "http://mochi.test/multi0",
+ "http://mochi.test/multi1",
+ "http://mochi.test/multi2",
+ "http://mochi.test/multi3",
+ "http://mochi.test/multi4",
+ ]);
+});
+add_task(async function multiple_tabs_over_max_accept() {
+ await pushPrefs(["browser.tabs.maxOpenBeforeWarn", 4]);
+
+ let confirmPromise = BrowserTestUtils.promiseAlertDialog("accept");
+
+ let urls = [];
+ for (let i = 0; i < 5; i++) {
+ urls.push("mochi.test/accept" + i);
+ }
+ await dropText(urls.join("\n"), [
+ "http://mochi.test/accept0",
+ "http://mochi.test/accept1",
+ "http://mochi.test/accept2",
+ "http://mochi.test/accept3",
+ "http://mochi.test/accept4",
+ ]);
+
+ await confirmPromise;
+
+ await popPrefs();
+});
+add_task(async function multiple_tabs_over_max_cancel() {
+ await pushPrefs(["browser.tabs.maxOpenBeforeWarn", 4]);
+
+ let confirmPromise = BrowserTestUtils.promiseAlertDialog("cancel");
+
+ let urls = [];
+ for (let i = 0; i < 5; i++) {
+ urls.push("mochi.test/cancel" + i);
+ }
+ await dropText(urls.join("\n"), []);
+
+ await confirmPromise;
+
+ await popPrefs();
+});
+
+// Open URLs ignoring non-URL.
+add_task(async function multiple_urls() {
+ await dropText(
+ `
+ mochi.test/urls0
+ mochi.test/urls1
+ mochi.test/urls2
+ non url0
+ mochi.test/urls3
+ non url1
+ non url2
+`,
+ [
+ "http://mochi.test/urls0",
+ "http://mochi.test/urls1",
+ "http://mochi.test/urls2",
+ "http://mochi.test/urls3",
+ ]
+ );
+});
+
+// Open single search if there's no URL.
+add_task(async function multiple_text() {
+ await dropText(
+ `
+ non url0
+ non url1
+ non url2
+`,
+ [ANY_URL]
+ );
+});
+
+function dropText(text, expectedURLs) {
+ return drop([[{ type: "text/plain", data: text }]], expectedURLs);
+}
+
+async function drop(dragData, expectedURLs) {
+ let dragDataString = JSON.stringify(dragData);
+ info(
+ `Starting test for dragData:${dragDataString}; expectedURLs.length:${expectedURLs.length}`
+ );
+ let EventUtils = {};
+ Services.scriptloader.loadSubScript(
+ "chrome://mochikit/content/tests/SimpleTest/EventUtils.js",
+ EventUtils
+ );
+
+ // Since synthesizeDrop triggers the srcElement, need to use another button
+ // that should be visible.
+ let dragSrcElement = document.getElementById("back-button");
+ ok(dragSrcElement, "Back button exists");
+ let newTabButton = document.getElementById(
+ gBrowser.tabContainer.hasAttribute("overflow")
+ ? "new-tab-button"
+ : "tabs-newtab-button"
+ );
+ ok(newTabButton, "New Tab button exists");
+
+ let awaitDrop = BrowserTestUtils.waitForEvent(newTabButton, "drop");
+
+ let loadedPromises = expectedURLs.map(url =>
+ BrowserTestUtils.waitForNewTab(gBrowser, url, false, true)
+ );
+
+ EventUtils.synthesizeDrop(
+ dragSrcElement,
+ newTabButton,
+ dragData,
+ "link",
+ window
+ );
+
+ let tabs = await Promise.all(loadedPromises);
+ for (let tab of tabs) {
+ BrowserTestUtils.removeTab(tab);
+ }
+
+ await awaitDrop;
+ ok(true, "Got drop event");
+}
diff --git a/browser/base/content/test/general/browser_newWindowDrop.js b/browser/base/content/test/general/browser_newWindowDrop.js
new file mode 100644
index 0000000000..243b691873
--- /dev/null
+++ b/browser/base/content/test/general/browser_newWindowDrop.js
@@ -0,0 +1,230 @@
+const { SearchTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/SearchTestUtils.sys.mjs"
+);
+
+SearchTestUtils.init(this);
+
+add_task(async function test_setup() {
+ // Opening multiple windows on debug build takes too long time.
+ requestLongerTimeout(10);
+
+ // Stop search-engine loads from hitting the network
+ await SearchTestUtils.installSearchExtension(
+ {
+ name: "MozSearch",
+ search_url: "https://example.com/",
+ search_url_get_params: "q={searchTerms}",
+ },
+ { setAsDefault: true }
+ );
+
+ // Move New Window button to nav bar, to make it possible to drag and drop.
+ let { CustomizableUI } = ChromeUtils.importESModule(
+ "resource:///modules/CustomizableUI.sys.mjs"
+ );
+ let origPlacement = CustomizableUI.getPlacementOfWidget("new-window-button");
+ if (!origPlacement || origPlacement.area != CustomizableUI.AREA_NAVBAR) {
+ CustomizableUI.addWidgetToArea(
+ "new-window-button",
+ CustomizableUI.AREA_NAVBAR,
+ 0
+ );
+ CustomizableUI.ensureWidgetPlacedInWindow("new-window-button", window);
+ registerCleanupFunction(function () {
+ CustomizableUI.removeWidgetFromArea("new-window-button");
+ });
+ }
+
+ CustomizableUI.addWidgetToArea("sidebar-button", "nav-bar");
+ registerCleanupFunction(() =>
+ CustomizableUI.removeWidgetFromArea("sidebar-button")
+ );
+});
+
+// New Window Button opens any link.
+add_task(async function single_url() {
+ await dropText("mochi.test/first", ["http://mochi.test/first"]);
+});
+add_task(async function single_javascript() {
+ await dropText("javascript:'bad'", ["about:blank"]);
+});
+add_task(async function single_javascript_capital() {
+ await dropText("jAvascript:'bad'", ["about:blank"]);
+});
+add_task(async function single_url2() {
+ await dropText("mochi.test/second", ["http://mochi.test/second"]);
+});
+add_task(async function single_data_url() {
+ await dropText("data:text/html,bad", ["data:text/html,bad"]);
+});
+add_task(async function single_url3() {
+ await dropText("mochi.test/third", ["http://mochi.test/third"]);
+});
+
+// Single text/plain item, with multiple links.
+add_task(async function multiple_urls() {
+ await dropText("mochi.test/1\nmochi.test/2", [
+ "http://mochi.test/1",
+ "http://mochi.test/2",
+ ]);
+});
+add_task(async function multiple_urls_javascript() {
+ await dropText("javascript:'bad1'\nmochi.test/3", [
+ "about:blank",
+ "http://mochi.test/3",
+ ]);
+});
+add_task(async function multiple_urls_data() {
+ await dropText("mochi.test/4\ndata:text/html,bad1", [
+ "http://mochi.test/4",
+ "data:text/html,bad1",
+ ]);
+});
+
+// Multiple text/plain items, with single and multiple links.
+add_task(async function multiple_items_single_and_multiple_links() {
+ await drop(
+ [
+ [{ type: "text/plain", data: "mochi.test/5" }],
+ [{ type: "text/plain", data: "mochi.test/6\nmochi.test/7" }],
+ ],
+ ["http://mochi.test/5", "http://mochi.test/6", "http://mochi.test/7"]
+ );
+});
+
+// Single text/x-moz-url item, with multiple links.
+// "text/x-moz-url" has titles in even-numbered lines.
+add_task(async function single_moz_url_multiple_links() {
+ await drop(
+ [
+ [
+ {
+ type: "text/x-moz-url",
+ data: "mochi.test/8\nTITLE8\nmochi.test/9\nTITLE9",
+ },
+ ],
+ ],
+ ["http://mochi.test/8", "http://mochi.test/9"]
+ );
+});
+
+// Single item with multiple types.
+add_task(async function single_item_multiple_types() {
+ await drop(
+ [
+ [
+ { type: "text/plain", data: "mochi.test/10" },
+ { type: "text/x-moz-url", data: "mochi.test/11\nTITLE11" },
+ ],
+ ],
+ ["http://mochi.test/11"]
+ );
+});
+
+// Warn when too many URLs are dropped.
+add_task(async function multiple_tabs_under_max() {
+ let urls = [];
+ for (let i = 0; i < 5; i++) {
+ urls.push("mochi.test/multi" + i);
+ }
+ await dropText(urls.join("\n"), [
+ "http://mochi.test/multi0",
+ "http://mochi.test/multi1",
+ "http://mochi.test/multi2",
+ "http://mochi.test/multi3",
+ "http://mochi.test/multi4",
+ ]);
+});
+add_task(async function multiple_tabs_over_max_accept() {
+ await pushPrefs(["browser.tabs.maxOpenBeforeWarn", 4]);
+
+ let confirmPromise = BrowserTestUtils.promiseAlertDialog("accept");
+
+ let urls = [];
+ for (let i = 0; i < 5; i++) {
+ urls.push("mochi.test/accept" + i);
+ }
+ await dropText(
+ urls.join("\n"),
+ [
+ "http://mochi.test/accept0",
+ "http://mochi.test/accept1",
+ "http://mochi.test/accept2",
+ "http://mochi.test/accept3",
+ "http://mochi.test/accept4",
+ ],
+ true
+ );
+
+ await confirmPromise;
+
+ await popPrefs();
+});
+add_task(async function multiple_tabs_over_max_cancel() {
+ await pushPrefs(["browser.tabs.maxOpenBeforeWarn", 4]);
+
+ let confirmPromise = BrowserTestUtils.promiseAlertDialog("cancel");
+
+ let urls = [];
+ for (let i = 0; i < 5; i++) {
+ urls.push("mochi.test/cancel" + i);
+ }
+ await dropText(urls.join("\n"), [], true);
+
+ await confirmPromise;
+
+ await popPrefs();
+});
+
+function dropText(text, expectedURLs, ignoreFirstWindow = false) {
+ return drop(
+ [[{ type: "text/plain", data: text }]],
+ expectedURLs,
+ ignoreFirstWindow
+ );
+}
+
+async function drop(dragData, expectedURLs, ignoreFirstWindow = false) {
+ let dragDataString = JSON.stringify(dragData);
+ info(
+ `Starting test for dragData:${dragDataString}; expectedURLs.length:${expectedURLs.length}`
+ );
+ let EventUtils = {};
+ Services.scriptloader.loadSubScript(
+ "chrome://mochikit/content/tests/SimpleTest/EventUtils.js",
+ EventUtils
+ );
+
+ // Since synthesizeDrop triggers the srcElement, need to use another button
+ // that should be visible.
+ let dragSrcElement = document.getElementById("sidebar-button");
+ ok(dragSrcElement, "Sidebar button exists");
+ let newWindowButton = document.getElementById("new-window-button");
+ ok(newWindowButton, "New Window button exists");
+
+ let awaitDrop = BrowserTestUtils.waitForEvent(newWindowButton, "drop");
+
+ let loadedPromises = expectedURLs.map(url =>
+ BrowserTestUtils.waitForNewWindow({
+ url,
+ anyWindow: true,
+ maybeErrorPage: true,
+ })
+ );
+
+ EventUtils.synthesizeDrop(
+ dragSrcElement,
+ newWindowButton,
+ dragData,
+ "link",
+ window
+ );
+
+ let windows = await Promise.all(loadedPromises);
+ for (let window of windows) {
+ await BrowserTestUtils.closeWindow(window);
+ }
+
+ await awaitDrop;
+ ok(true, "Got drop event");
+}
diff --git a/browser/base/content/test/general/browser_new_http_window_opened_from_file_tab.js b/browser/base/content/test/general/browser_new_http_window_opened_from_file_tab.js
new file mode 100644
index 0000000000..8e9f458073
--- /dev/null
+++ b/browser/base/content/test/general/browser_new_http_window_opened_from_file_tab.js
@@ -0,0 +1,63 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+
+const TEST_FILE = "file_with_link_to_http.html";
+// eslint-disable-next-line @microsoft/sdl/no-insecure-url
+const TEST_HTTP = "http://example.org/";
+
+// Test for bug 1338375.
+add_task(async function () {
+ // Open file:// page.
+ let dir = getChromeDir(getResolvedURI(gTestPath));
+ dir.append(TEST_FILE);
+ const uriString = Services.io.newFileURI(dir).spec;
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, uriString);
+ registerCleanupFunction(async function () {
+ BrowserTestUtils.removeTab(tab);
+ });
+ let browser = tab.linkedBrowser;
+
+ // Set pref to open in new window.
+ Services.prefs.setIntPref("browser.link.open_newwindow", 2);
+ registerCleanupFunction(function () {
+ Services.prefs.clearUserPref("browser.link.open_newwindow");
+ });
+
+ // Open new http window from JavaScript in file:// page and check that we get
+ // a new window with the correct page and features.
+ let promiseNewWindow = BrowserTestUtils.waitForNewWindow({ url: TEST_HTTP });
+ await SpecialPowers.spawn(browser, [TEST_HTTP], uri => {
+ content.open(uri, "_blank");
+ });
+ let win = await promiseNewWindow;
+ registerCleanupFunction(async function () {
+ await BrowserTestUtils.closeWindow(win);
+ });
+ ok(win, "Check that an http window loaded when using window.open.");
+ ok(
+ win.menubar.visible,
+ "Check that the menu bar on the new window is visible."
+ );
+ ok(
+ win.toolbar.visible,
+ "Check that the tool bar on the new window is visible."
+ );
+
+ // Open new http window from a link in file:// page and check that we get a
+ // new window with the correct page and features.
+ promiseNewWindow = BrowserTestUtils.waitForNewWindow({ url: TEST_HTTP });
+ await BrowserTestUtils.synthesizeMouseAtCenter("#linkToExample", {}, browser);
+ let win2 = await promiseNewWindow;
+ registerCleanupFunction(async function () {
+ await BrowserTestUtils.closeWindow(win2);
+ });
+ ok(win2, "Check that an http window loaded when using link.");
+ ok(
+ win2.menubar.visible,
+ "Check that the menu bar on the new window is visible."
+ );
+ ok(
+ win2.toolbar.visible,
+ "Check that the tool bar on the new window is visible."
+ );
+});
diff --git a/browser/base/content/test/general/browser_newwindow_focus.js b/browser/base/content/test/general/browser_newwindow_focus.js
new file mode 100644
index 0000000000..dbf99f1233
--- /dev/null
+++ b/browser/base/content/test/general/browser_newwindow_focus.js
@@ -0,0 +1,93 @@
+"use strict";
+
+/**
+ * These tests are for the auto-focus behaviour on the initial browser
+ * when a window is opened from content.
+ */
+
+const PAGE = `data:text/html,<a id="target" href="%23" onclick="window.open('http://www.example.com', '_blank', 'width=100,height=100');">Click me</a>`;
+
+/**
+ * Test that when a new window is opened from content, focus moves
+ * to the initial browser in that window once the window has finished
+ * painting.
+ */
+add_task(async function test_focus_browser() {
+ await BrowserTestUtils.withNewTab(
+ {
+ url: PAGE,
+ gBrowser,
+ },
+ async function (browser) {
+ let newWinPromise = BrowserTestUtils.domWindowOpenedAndLoaded(null);
+ let delayedStartupPromise = BrowserTestUtils.waitForNewWindow();
+
+ await BrowserTestUtils.synthesizeMouseAtCenter("#target", {}, browser);
+ let newWin = await newWinPromise;
+ await BrowserTestUtils.waitForContentEvent(
+ newWin.gBrowser.selectedBrowser,
+ "MozAfterPaint"
+ );
+ await delayedStartupPromise;
+
+ let focusedElement = Services.focus.getFocusedElementForWindow(
+ newWin,
+ false,
+ {}
+ );
+
+ Assert.equal(
+ focusedElement,
+ newWin.gBrowser.selectedBrowser,
+ "Initial browser should be focused"
+ );
+
+ await BrowserTestUtils.closeWindow(newWin);
+ }
+ );
+});
+
+/**
+ * Test that when a new window is opened from content and focus
+ * shifts in that window before the content has a chance to paint
+ * that we _don't_ steal focus once content has painted.
+ */
+add_task(async function test_no_steal_focus() {
+ await BrowserTestUtils.withNewTab(
+ {
+ url: PAGE,
+ gBrowser,
+ },
+ async function (browser) {
+ let newWinPromise = BrowserTestUtils.domWindowOpenedAndLoaded(null);
+ let delayedStartupPromise = BrowserTestUtils.waitForNewWindow();
+
+ await BrowserTestUtils.synthesizeMouseAtCenter("#target", {}, browser);
+ let newWin = await newWinPromise;
+
+ // Because we're switching focus, we shouldn't steal it once
+ // content paints.
+ newWin.gURLBar.focus();
+
+ await BrowserTestUtils.waitForContentEvent(
+ newWin.gBrowser.selectedBrowser,
+ "MozAfterPaint"
+ );
+ await delayedStartupPromise;
+
+ let focusedElement = Services.focus.getFocusedElementForWindow(
+ newWin,
+ false,
+ {}
+ );
+
+ Assert.equal(
+ focusedElement,
+ newWin.gURLBar.inputField,
+ "URLBar should be focused"
+ );
+
+ await BrowserTestUtils.closeWindow(newWin);
+ }
+ );
+});
diff --git a/browser/base/content/test/general/browser_plainTextLinks.js b/browser/base/content/test/general/browser_plainTextLinks.js
new file mode 100644
index 0000000000..706f21387c
--- /dev/null
+++ b/browser/base/content/test/general/browser_plainTextLinks.js
@@ -0,0 +1,237 @@
+/* eslint-disable mozilla/no-arbitrary-setTimeout */
+function testExpected(expected, msg) {
+ is(
+ document.getElementById("context-openlinkincurrent").hidden,
+ expected,
+ msg
+ );
+}
+
+function testLinkExpected(expected, msg) {
+ is(gContextMenu.linkURL, expected, msg);
+}
+
+add_task(async function () {
+ const url =
+ "data:text/html;charset=UTF-8,Test For Non-Hyperlinked url selection";
+ await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+
+ await SimpleTest.promiseFocus(gBrowser.selectedBrowser);
+
+ // Initial setup of the content area.
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function (arg) {
+ let doc = content.document;
+ let range = doc.createRange();
+ let selection = content.getSelection();
+
+ let mainDiv = doc.createElement("div");
+ let div = doc.createElement("div");
+ let div2 = doc.createElement("div");
+ let span1 = doc.createElement("span");
+ let span2 = doc.createElement("span");
+ let span3 = doc.createElement("span");
+ let span4 = doc.createElement("span");
+ let p1 = doc.createElement("p");
+ let p2 = doc.createElement("p");
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ span1.textContent = "http://index.";
+ span2.textContent = "example.com example.com";
+ span3.textContent = " - Test";
+ span4.innerHTML =
+ "<a href='http://www.example.com'>http://www.example.com/example</a>";
+ p1.textContent = "mailto:test.com ftp.example.com";
+ p2.textContent = "example.com -";
+ div.appendChild(span1);
+ div.appendChild(span2);
+ div.appendChild(span3);
+ div.appendChild(span4);
+ div.appendChild(p1);
+ div.appendChild(p2);
+ let p3 = doc.createElement("p");
+ p3.textContent = "main.example.com";
+ div2.appendChild(p3);
+ mainDiv.appendChild(div);
+ mainDiv.appendChild(div2);
+ doc.body.appendChild(mainDiv);
+
+ function setSelection(el1, el2, index1, index2) {
+ while (el1.nodeType != el1.TEXT_NODE) {
+ el1 = el1.firstChild;
+ }
+ while (el2.nodeType != el1.TEXT_NODE) {
+ el2 = el2.firstChild;
+ }
+
+ selection.removeAllRanges();
+ range.setStart(el1, index1);
+ range.setEnd(el2, index2);
+ selection.addRange(range);
+
+ return range;
+ }
+
+ // Each of these tests creates a selection and returns a range within it.
+ content.tests = [
+ () => setSelection(span1.firstChild, span2.firstChild, 0, 11),
+ () => setSelection(span1.firstChild, span2.firstChild, 7, 11),
+ () => setSelection(span1.firstChild, span2.firstChild, 8, 11),
+ () => setSelection(span2.firstChild, span2.firstChild, 0, 11),
+ () => setSelection(span2.firstChild, span2.firstChild, 11, 23),
+ () => setSelection(span2.firstChild, span2.firstChild, 0, 10),
+ () => setSelection(span2.firstChild, span3.firstChild, 12, 7),
+ () => setSelection(span2.firstChild, span2.firstChild, 12, 19),
+ () => setSelection(p1.firstChild, p1.firstChild, 0, 15),
+ () => setSelection(p1.firstChild, p1.firstChild, 16, 31),
+ () => setSelection(p2.firstChild, p2.firstChild, 0, 14),
+ () => {
+ selection.selectAllChildren(div2);
+ return selection.getRangeAt(0);
+ },
+ () => {
+ selection.selectAllChildren(span4);
+ return selection.getRangeAt(0);
+ },
+ () => {
+ mainDiv.innerHTML = "(open-suse.ru)";
+ return setSelection(mainDiv, mainDiv, 1, 13);
+ },
+ () => setSelection(mainDiv, mainDiv, 1, 14),
+ ];
+ });
+
+ let checks = [
+ () =>
+ testExpected(
+ false,
+ "The link context menu should show for http://www.example.com"
+ ),
+ () =>
+ testExpected(
+ false,
+ "The link context menu should show for www.example.com"
+ ),
+ () =>
+ testExpected(
+ true,
+ "The link context menu should not show for ww.example.com"
+ ),
+ () => {
+ testExpected(false, "The link context menu should show for example.com");
+ testLinkExpected(
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com/",
+ "url for example.com selection should not prepend www"
+ );
+ },
+ () =>
+ testExpected(false, "The link context menu should show for example.com"),
+ () =>
+ testExpected(
+ true,
+ "Link options should not show for selection that's not at a word boundary"
+ ),
+ () =>
+ testExpected(
+ true,
+ "Link options should not show for selection that has whitespace"
+ ),
+ () =>
+ testExpected(
+ true,
+ "Link options should not show unless a url is selected"
+ ),
+ () => testExpected(true, "Link options should not show for mailto: links"),
+ () => {
+ testExpected(false, "Link options should show for ftp.example.com");
+ testLinkExpected(
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://ftp.example.com/",
+ "ftp.example.com should be preceeded with http://"
+ );
+ },
+ () => testExpected(false, "Link options should show for www.example.com "),
+ () =>
+ testExpected(
+ false,
+ "Link options should show for triple-click selections"
+ ),
+ () =>
+ testLinkExpected(
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://www.example.com/",
+ "Linkified text should open the correct link"
+ ),
+ () => {
+ testExpected(false, "Link options should show for open-suse.ru");
+ testLinkExpected(
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://open-suse.ru/",
+ "Linkified text should open the correct link"
+ );
+ },
+ () =>
+ testExpected(true, "Link options should not show for 'open-suse.ru)'"),
+ ];
+
+ let contentAreaContextMenu = document.getElementById(
+ "contentAreaContextMenu"
+ );
+
+ for (let testid = 0; testid < checks.length; testid++) {
+ let menuPosition = await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [{ testid }],
+ async function (arg) {
+ let range = content.tests[arg.testid]();
+
+ // Get the range of the selection and determine its coordinates. These
+ // coordinates will be returned to the parent process and the context menu
+ // will be opened at that location.
+ let rangeRect = range.getBoundingClientRect();
+ return [rangeRect.x + 3, rangeRect.y + 3];
+ }
+ );
+
+ // Trigger a mouse event until we receive the popupshown event.
+ let sawPopup = false;
+ let popupShownPromise = BrowserTestUtils.waitForEvent(
+ contentAreaContextMenu,
+ "popupshown",
+ false,
+ () => {
+ sawPopup = true;
+ return true;
+ }
+ );
+ while (!sawPopup) {
+ await BrowserTestUtils.synthesizeMouseAtPoint(
+ menuPosition[0],
+ menuPosition[1],
+ { type: "contextmenu", button: 2 },
+ gBrowser.selectedBrowser
+ );
+ if (!sawPopup) {
+ await new Promise(r => setTimeout(r, 100));
+ }
+ }
+ await popupShownPromise;
+
+ checks[testid]();
+
+ // On Linux non-e10s it's possible the menu was closed by a focus-out event
+ // on the window. Work around this by calling hidePopup only if the menu
+ // hasn't been closed yet. See bug 1352709 comment 36.
+ if (contentAreaContextMenu.state === "closed") {
+ continue;
+ }
+
+ let popupHiddenPromise = BrowserTestUtils.waitForEvent(
+ contentAreaContextMenu,
+ "popuphidden"
+ );
+ contentAreaContextMenu.hidePopup();
+ await popupHiddenPromise;
+ }
+
+ gBrowser.removeCurrentTab();
+});
diff --git a/browser/base/content/test/general/browser_printpreview.js b/browser/base/content/test/general/browser_printpreview.js
new file mode 100644
index 0000000000..945e2bbd3a
--- /dev/null
+++ b/browser/base/content/test/general/browser_printpreview.js
@@ -0,0 +1,43 @@
+let ourTab;
+
+async function test() {
+ waitForExplicitFinish();
+
+ BrowserTestUtils.openNewForegroundTab(gBrowser, "about:home", true).then(
+ function (tab) {
+ ourTab = tab;
+ ok(
+ !document.querySelector(".printPreviewBrowser"),
+ "Should NOT be in print preview mode at starting this tests"
+ );
+ testClosePrintPreviewWithEscKey();
+ }
+ );
+}
+
+function tidyUp() {
+ BrowserTestUtils.removeTab(ourTab);
+ finish();
+}
+
+async function testClosePrintPreviewWithEscKey() {
+ await openPrintPreview();
+ EventUtils.synthesizeKey("KEY_Escape");
+ await checkPrintPreviewClosed();
+ ok(true, "print preview mode should be finished by Esc key press");
+ tidyUp();
+}
+
+async function openPrintPreview() {
+ document.getElementById("cmd_print").doCommand();
+ await BrowserTestUtils.waitForCondition(() => {
+ let preview = document.querySelector(".printPreviewBrowser");
+ return preview && BrowserTestUtils.is_visible(preview);
+ });
+}
+
+async function checkPrintPreviewClosed() {
+ await BrowserTestUtils.waitForCondition(
+ () => !document.querySelector(".printPreviewBrowser")
+ );
+}
diff --git a/browser/base/content/test/general/browser_private_browsing_window.js b/browser/base/content/test/general/browser_private_browsing_window.js
new file mode 100644
index 0000000000..34a4c8bbf0
--- /dev/null
+++ b/browser/base/content/test/general/browser_private_browsing_window.js
@@ -0,0 +1,133 @@
+// Make sure that we can open private browsing windows
+
+function test() {
+ waitForExplicitFinish();
+ var nonPrivateWin = OpenBrowserWindow();
+ ok(
+ !PrivateBrowsingUtils.isWindowPrivate(nonPrivateWin),
+ "OpenBrowserWindow() should open a normal window"
+ );
+ nonPrivateWin.close();
+
+ var privateWin = OpenBrowserWindow({ private: true });
+ ok(
+ PrivateBrowsingUtils.isWindowPrivate(privateWin),
+ "OpenBrowserWindow({private: true}) should open a private window"
+ );
+
+ nonPrivateWin = OpenBrowserWindow({ private: false });
+ ok(
+ !PrivateBrowsingUtils.isWindowPrivate(nonPrivateWin),
+ "OpenBrowserWindow({private: false}) should open a normal window"
+ );
+ nonPrivateWin.close();
+
+ whenDelayedStartupFinished(privateWin, function () {
+ nonPrivateWin = privateWin.OpenBrowserWindow({ private: false });
+ ok(
+ !PrivateBrowsingUtils.isWindowPrivate(nonPrivateWin),
+ "privateWin.OpenBrowserWindow({private: false}) should open a normal window"
+ );
+
+ nonPrivateWin.close();
+
+ [
+ {
+ normal: "menu_newNavigator",
+ private: "menu_newPrivateWindow",
+ accesskey: true,
+ },
+ {
+ normal: "appmenu_newNavigator",
+ private: "appmenu_newPrivateWindow",
+ accesskey: false,
+ },
+ ].forEach(function (menu) {
+ let newWindow = privateWin.document.getElementById(menu.normal);
+ let newPrivateWindow = privateWin.document.getElementById(menu.private);
+ if (newWindow && newPrivateWindow) {
+ ok(
+ !newPrivateWindow.hidden,
+ "New Private Window menu item should be hidden"
+ );
+ isnot(
+ newWindow.label,
+ newPrivateWindow.label,
+ "New Window's label shouldn't be overwritten"
+ );
+ if (menu.accesskey) {
+ isnot(
+ newWindow.accessKey,
+ newPrivateWindow.accessKey,
+ "New Window's accessKey shouldn't be overwritten"
+ );
+ }
+ isnot(
+ newWindow.command,
+ newPrivateWindow.command,
+ "New Window's command shouldn't be overwritten"
+ );
+ }
+ });
+
+ is(
+ privateWin.gBrowser.tabs[0].label,
+ "New Private Tab",
+ "New tabs in the private browsing windows should have 'New Private Tab' as the title."
+ );
+
+ privateWin.close();
+
+ Services.prefs.setBoolPref("browser.privatebrowsing.autostart", true);
+ privateWin = OpenBrowserWindow({ private: true });
+ whenDelayedStartupFinished(privateWin, function () {
+ [
+ {
+ normal: "menu_newNavigator",
+ private: "menu_newPrivateWindow",
+ accessKey: true,
+ },
+ {
+ normal: "appmenu_newNavigator",
+ private: "appmenu_newPrivateWindow",
+ accessKey: false,
+ },
+ ].forEach(function (menu) {
+ let newWindow = privateWin.document.getElementById(menu.normal);
+ let newPrivateWindow = privateWin.document.getElementById(menu.private);
+ if (newWindow && newPrivateWindow) {
+ ok(
+ newPrivateWindow.hidden,
+ "New Private Window menu item should be hidden"
+ );
+ is(
+ newWindow.label,
+ newPrivateWindow.label,
+ "New Window's label should be overwritten"
+ );
+ if (menu.accesskey) {
+ is(
+ newWindow.accessKey,
+ newPrivateWindow.accessKey,
+ "New Window's accessKey should be overwritten"
+ );
+ }
+ is(
+ newWindow.command,
+ newPrivateWindow.command,
+ "New Window's command should be overwritten"
+ );
+ }
+ });
+
+ is(
+ privateWin.gBrowser.tabs[0].label,
+ "New Tab",
+ "Normal tab title is used also in the permanent private browsing mode."
+ );
+ privateWin.close();
+ Services.prefs.clearUserPref("browser.privatebrowsing.autostart");
+ finish();
+ });
+ });
+}
diff --git a/browser/base/content/test/general/browser_private_no_prompt.js b/browser/base/content/test/general/browser_private_no_prompt.js
new file mode 100644
index 0000000000..d8c9f8e7b5
--- /dev/null
+++ b/browser/base/content/test/general/browser_private_no_prompt.js
@@ -0,0 +1,12 @@
+function test() {
+ waitForExplicitFinish();
+ var privateWin = OpenBrowserWindow({ private: true });
+
+ whenDelayedStartupFinished(privateWin, function () {
+ privateWin.BrowserOpenTab();
+ privateWin.BrowserTryToCloseWindow();
+ ok(true, "didn't prompt");
+
+ executeSoon(finish);
+ });
+}
diff --git a/browser/base/content/test/general/browser_refreshBlocker.js b/browser/base/content/test/general/browser_refreshBlocker.js
new file mode 100644
index 0000000000..0052282257
--- /dev/null
+++ b/browser/base/content/test/general/browser_refreshBlocker.js
@@ -0,0 +1,209 @@
+"use strict";
+
+const META_PAGE =
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.org/browser/browser/base/content/test/general/refresh_meta.sjs";
+const HEADER_PAGE =
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.org/browser/browser/base/content/test/general/refresh_header.sjs";
+const TARGET_PAGE =
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.org/browser/browser/base/content/test/general/dummy_page.html";
+const PREF = "accessibility.blockautorefresh";
+
+/**
+ * Goes into the content, and simulates a meta-refresh header at a very
+ * low level, and checks to see if it was blocked. This will always cancel
+ * the refresh, regardless of whether or not the refresh was blocked.
+ *
+ * @param browser (<xul:browser>)
+ * The browser to test for refreshing.
+ * @param expectRefresh (bool)
+ * Whether or not we expect the refresh attempt to succeed.
+ * @returns Promise
+ */
+async function attemptFakeRefresh(browser, expectRefresh) {
+ await SpecialPowers.spawn(
+ browser,
+ [expectRefresh],
+ async function (contentExpectRefresh) {
+ let URI = docShell.QueryInterface(Ci.nsIWebNavigation).currentURI;
+ let refresher = docShell.QueryInterface(Ci.nsIRefreshURI);
+ refresher.refreshURI(URI, null, 0);
+
+ Assert.equal(
+ refresher.refreshPending,
+ contentExpectRefresh,
+ "Got the right refreshPending state"
+ );
+
+ if (refresher.refreshPending) {
+ // Cancel the pending refresh
+ refresher.cancelRefreshURITimers();
+ }
+
+ // The RefreshBlocker will wait until onLocationChange has
+ // been fired before it will show any notifications (see bug
+ // 1246291), so we cause this to occur manually here.
+ content.location = URI.spec + "#foo";
+ }
+ );
+}
+
+/**
+ * Tests that we can enable the blocking pref and block a refresh
+ * from occurring while showing a notification bar. Also tests that
+ * when we disable the pref, that refreshes can go through again.
+ */
+add_task(async function test_can_enable_and_block() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TARGET_PAGE,
+ },
+ async function (browser) {
+ // By default, we should be able to reload the page.
+ await attemptFakeRefresh(browser, true);
+
+ await pushPrefs(["accessibility.blockautorefresh", true]);
+
+ let notificationPromise = BrowserTestUtils.waitForNotificationBar(
+ gBrowser,
+ browser,
+ "refresh-blocked"
+ );
+
+ await attemptFakeRefresh(browser, false);
+
+ await notificationPromise;
+
+ await pushPrefs(["accessibility.blockautorefresh", false]);
+
+ // Page reloads should go through again.
+ await attemptFakeRefresh(browser, true);
+ }
+ );
+});
+
+/**
+ * Attempts a "real" refresh by opening a tab, and then sending it to
+ * an SJS page that will attempt to cause a refresh. This will also pass
+ * a delay amount to the SJS page. The refresh should be blocked, and
+ * the notification should be shown. Once shown, the "Allow" button will
+ * be clicked, and the refresh will go through. Finally, the helper will
+ * close the tab and resolve the Promise.
+ *
+ * @param refreshPage (string)
+ * The SJS page to use. Use META_PAGE for the <meta> tag refresh
+ * case. Use HEADER_PAGE for the HTTP header case.
+ * @param delay (int)
+ * The amount, in ms, for the page to wait before attempting the
+ * refresh.
+ *
+ * @returns Promise
+ */
+async function testRealRefresh(refreshPage, delay) {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:blank",
+ },
+ async function (browser) {
+ await pushPrefs(["accessibility.blockautorefresh", true]);
+
+ BrowserTestUtils.loadURIString(
+ browser,
+ refreshPage + "?p=" + TARGET_PAGE + "&d=" + delay
+ );
+ await BrowserTestUtils.browserLoaded(browser);
+
+ // Once browserLoaded resolves, all nsIWebProgressListener callbacks
+ // should have fired, so the notification should be visible.
+ let notificationBox = gBrowser.getNotificationBox(browser);
+ let notification = notificationBox.currentNotification;
+
+ ok(notification, "Notification should be visible");
+ is(
+ notification.getAttribute("value"),
+ "refresh-blocked",
+ "Should be showing the right notification"
+ );
+
+ // Then click the button to allow the refresh.
+ let buttons = notification.buttonContainer.querySelectorAll(
+ ".notification-button"
+ );
+ is(buttons.length, 1, "Should have one button.");
+
+ // Prepare a Promise that should resolve when the refresh goes through
+ let refreshPromise = BrowserTestUtils.browserLoaded(browser);
+ buttons[0].click();
+
+ await refreshPromise;
+ }
+ );
+}
+
+/**
+ * Tests the meta-tag case for both short and longer delay times.
+ */
+add_task(async function test_can_allow_refresh() {
+ await testRealRefresh(META_PAGE, 0);
+ await testRealRefresh(META_PAGE, 100);
+ await testRealRefresh(META_PAGE, 500);
+});
+
+/**
+ * Tests that when a HTTP header case for both short and longer
+ * delay times.
+ */
+add_task(async function test_can_block_refresh_from_header() {
+ await testRealRefresh(HEADER_PAGE, 0);
+ await testRealRefresh(HEADER_PAGE, 100);
+ await testRealRefresh(HEADER_PAGE, 500);
+});
+
+/**
+ * Tests that we can update a notification when multiple reload/redirect
+ * attempts happen.
+ */
+add_task(async function test_can_update_notification() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:blank",
+ },
+ async function (browser) {
+ await pushPrefs(["accessibility.blockautorefresh", true]);
+
+ // First, attempt a redirect
+ BrowserTestUtils.loadURIString(
+ browser,
+ META_PAGE + "?d=0&p=" + TARGET_PAGE
+ );
+ await BrowserTestUtils.browserLoaded(browser);
+
+ // Once browserLoaded resolves, all nsIWebProgressListener callbacks
+ // should have fired, so the notification should be visible.
+ let notificationBox = gBrowser.getNotificationBox(browser);
+ let notification = notificationBox.currentNotification;
+
+ let message = notification.messageText.querySelector("span");
+ is(
+ message.dataset.l10nId,
+ "refresh-blocked-redirect-label",
+ "Should be showing the redirect message"
+ );
+
+ // Next, attempt a refresh
+ await attemptFakeRefresh(browser, false);
+
+ message = notification.messageText.querySelector("span");
+ is(
+ message.dataset.l10nId,
+ "refresh-blocked-refresh-label",
+ "Should be showing the refresh message"
+ );
+ }
+ );
+});
diff --git a/browser/base/content/test/general/browser_relatedTabs.js b/browser/base/content/test/general/browser_relatedTabs.js
new file mode 100644
index 0000000000..22ed8fbb1b
--- /dev/null
+++ b/browser/base/content/test/general/browser_relatedTabs.js
@@ -0,0 +1,74 @@
+/* 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/. */
+
+add_task(async function () {
+ is(gBrowser.tabs.length, 1, "one tab is open initially");
+
+ // Add several new tabs in sequence, interrupted by selecting a
+ // different tab, moving a tab around and closing a tab,
+ // returning a list of opened tabs for verifying the expected order.
+ // The new tab behaviour is documented in bug 465673
+ let tabs = [];
+ let ReferrerInfo = Components.Constructor(
+ "@mozilla.org/referrer-info;1",
+ "nsIReferrerInfo",
+ "init"
+ );
+
+ function addTab(aURL, aReferrer) {
+ let referrerInfo = new ReferrerInfo(
+ Ci.nsIReferrerInfo.EMPTY,
+ true,
+ aReferrer
+ );
+ let tab = BrowserTestUtils.addTab(gBrowser, aURL, { referrerInfo });
+ tabs.push(tab);
+ return BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ }
+
+ await addTab("http://mochi.test:8888/#0");
+ gBrowser.selectedTab = tabs[0];
+ await addTab("http://mochi.test:8888/#1");
+ await addTab("http://mochi.test:8888/#2", gBrowser.currentURI);
+ await addTab("http://mochi.test:8888/#3", gBrowser.currentURI);
+ gBrowser.selectedTab = tabs[tabs.length - 1];
+ gBrowser.selectedTab = tabs[0];
+ await addTab("http://mochi.test:8888/#4", gBrowser.currentURI);
+ gBrowser.selectedTab = tabs[3];
+ await addTab("http://mochi.test:8888/#5", gBrowser.currentURI);
+ gBrowser.removeTab(tabs.pop());
+ await addTab("about:blank", gBrowser.currentURI);
+ gBrowser.moveTabTo(gBrowser.selectedTab, 1);
+ await addTab("http://mochi.test:8888/#6", gBrowser.currentURI);
+ await addTab();
+ await addTab("http://mochi.test:8888/#7");
+
+ function testPosition(tabNum, expectedPosition, msg) {
+ is(
+ Array.prototype.indexOf.call(gBrowser.tabs, tabs[tabNum]),
+ expectedPosition,
+ msg
+ );
+ }
+
+ testPosition(0, 3, "tab without referrer was opened to the far right");
+ testPosition(1, 7, "tab without referrer was opened to the far right");
+ testPosition(2, 5, "tab with referrer opened immediately to the right");
+ testPosition(3, 1, "next tab with referrer opened further to the right");
+ testPosition(
+ 4,
+ 4,
+ "tab selection changed, tab opens immediately to the right"
+ );
+ testPosition(
+ 5,
+ 6,
+ "blank tab with referrer opens to the right of 3rd original tab where removed tab was"
+ );
+ testPosition(6, 2, "tab has moved, new tab opens immediately to the right");
+ testPosition(7, 8, "blank tab without referrer opens at the end");
+ testPosition(8, 9, "tab without referrer opens at the end");
+
+ tabs.forEach(gBrowser.removeTab, gBrowser);
+});
diff --git a/browser/base/content/test/general/browser_remoteTroubleshoot.js b/browser/base/content/test/general/browser_remoteTroubleshoot.js
new file mode 100644
index 0000000000..84722b2603
--- /dev/null
+++ b/browser/base/content/test/general/browser_remoteTroubleshoot.js
@@ -0,0 +1,130 @@
+/* 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/. */
+
+var { WebChannel } = ChromeUtils.importESModule(
+ "resource://gre/modules/WebChannel.sys.mjs"
+);
+const { PermissionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PermissionTestUtils.sys.mjs"
+);
+
+const TEST_URL_TAIL =
+ "example.com/browser/browser/base/content/test/general/test_remoteTroubleshoot.html";
+const TEST_URI_GOOD = Services.io.newURI("https://" + TEST_URL_TAIL);
+const TEST_URI_BAD = Services.io.newURI("http://" + TEST_URL_TAIL);
+const TEST_URI_GOOD_OBJECT = Services.io.newURI(
+ "https://" + TEST_URL_TAIL + "?object"
+);
+
+// Creates a one-shot web-channel for the test data to be sent back from the test page.
+function promiseChannelResponse(channelID, originOrPermission) {
+ return new Promise((resolve, reject) => {
+ let channel = new WebChannel(channelID, originOrPermission);
+ channel.listen((id, data, target) => {
+ channel.stopListening();
+ resolve(data);
+ });
+ });
+}
+
+// Loads the specified URI in a new tab and waits for it to send us data on our
+// test web-channel and resolves with that data.
+function promiseNewChannelResponse(uri) {
+ let channelPromise = promiseChannelResponse(
+ "test-remote-troubleshooting-backchannel",
+ uri
+ );
+ let tab = gBrowser.addTab(uri.spec, {
+ inBackground: false,
+ triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
+ });
+ return promiseTabLoaded(tab)
+ .then(() => channelPromise)
+ .then(data => {
+ gBrowser.removeTab(tab);
+ return data;
+ });
+}
+
+add_task(async function () {
+ // We haven't set a permission yet - so even the "good" URI should fail.
+ let got = await promiseNewChannelResponse(TEST_URI_GOOD);
+ // Should return an error.
+ Assert.ok(
+ got.message.errno === 2,
+ "should have failed with errno 2, no such channel"
+ );
+
+ // Add a permission manager entry for our URI.
+ PermissionTestUtils.add(
+ TEST_URI_GOOD,
+ "remote-troubleshooting",
+ Services.perms.ALLOW_ACTION
+ );
+ registerCleanupFunction(() => {
+ PermissionTestUtils.remove(TEST_URI_GOOD, "remote-troubleshooting");
+ });
+
+ // Try again - now we are expecting a response with the actual data.
+ got = await promiseNewChannelResponse(TEST_URI_GOOD);
+
+ // Check some keys we expect to always get.
+ Assert.ok(got.message.addons, "should have addons");
+ Assert.ok(got.message.graphics, "should have graphics");
+
+ // Check we have channel and build ID info:
+ Assert.equal(
+ got.message.application.buildID,
+ Services.appinfo.appBuildID,
+ "should have correct build ID"
+ );
+
+ let updateChannel = null;
+ try {
+ updateChannel = ChromeUtils.importESModule(
+ "resource://gre/modules/UpdateUtils.sys.mjs"
+ ).UpdateUtils.UpdateChannel;
+ } catch (ex) {}
+ if (!updateChannel) {
+ Assert.ok(
+ !("updateChannel" in got.message.application),
+ "should not have update channel where not available."
+ );
+ } else {
+ Assert.equal(
+ got.message.application.updateChannel,
+ updateChannel,
+ "should have correct update channel."
+ );
+ }
+
+ // And check some keys we know we decline to return.
+ Assert.ok(
+ !got.message.modifiedPreferences,
+ "should not have a modifiedPreferences key"
+ );
+ Assert.ok(
+ !got.message.printingPreferences,
+ "should not have a printingPreferences key"
+ );
+ Assert.ok(!got.message.crashes, "should not have crash info");
+
+ // Now a http:// URI - should receive an error
+ got = await promiseNewChannelResponse(TEST_URI_BAD);
+ Assert.ok(
+ got.message.errno === 2,
+ "should have failed with errno 2, no such channel"
+ );
+
+ // Check that the page can send an object as well if it's in the whitelist
+ let webchannelWhitelistPref = "webchannel.allowObject.urlWhitelist";
+ let origWhitelist = Services.prefs.getCharPref(webchannelWhitelistPref);
+ let newWhitelist = origWhitelist + " https://example.com";
+ Services.prefs.setCharPref(webchannelWhitelistPref, newWhitelist);
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref(webchannelWhitelistPref);
+ });
+ got = await promiseNewChannelResponse(TEST_URI_GOOD_OBJECT);
+ Assert.ok(got.message, "should have gotten some data back");
+});
diff --git a/browser/base/content/test/general/browser_remoteWebNavigation_postdata.js b/browser/base/content/test/general/browser_remoteWebNavigation_postdata.js
new file mode 100644
index 0000000000..3ae7c62105
--- /dev/null
+++ b/browser/base/content/test/general/browser_remoteWebNavigation_postdata.js
@@ -0,0 +1,53 @@
+/* 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/. */
+
+function makeInputStream(aString) {
+ let stream = Cc["@mozilla.org/io/string-input-stream;1"].createInstance(
+ Ci.nsIStringInputStream
+ );
+ stream.data = aString;
+ return stream; // XPConnect will QI this to nsIInputStream for us.
+}
+
+add_task(async function test_remoteWebNavigation_postdata() {
+ let { HttpServer } = ChromeUtils.import("resource://testing-common/httpd.js");
+ let { CommonUtils } = ChromeUtils.importESModule(
+ "resource://services-common/utils.sys.mjs"
+ );
+
+ let server = new HttpServer();
+ server.start(-1);
+
+ await new Promise(resolve => {
+ server.registerPathHandler("/test", (request, response) => {
+ let body = CommonUtils.readBytesFromInputStream(request.bodyInputStream);
+ is(body, "success", "request body is correct");
+ is(request.method, "POST", "request was a post");
+ response.write("Received from POST: " + body);
+ resolve();
+ });
+
+ let i = server.identity;
+ let path =
+ i.primaryScheme + "://" + i.primaryHost + ":" + i.primaryPort + "/test";
+
+ let postdata =
+ "Content-Length: 7\r\n" +
+ "Content-Type: application/x-www-form-urlencoded\r\n" +
+ "\r\n" +
+ "success";
+
+ openTrustedLinkIn(path, "tab", {
+ allowThirdPartyFixup: null,
+ postData: makeInputStream(postdata),
+ });
+ });
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+
+ await new Promise(resolve => {
+ server.stop(function () {
+ resolve();
+ });
+ });
+});
diff --git a/browser/base/content/test/general/browser_restore_isAppTab.js b/browser/base/content/test/general/browser_restore_isAppTab.js
new file mode 100644
index 0000000000..ab26342692
--- /dev/null
+++ b/browser/base/content/test/general/browser_restore_isAppTab.js
@@ -0,0 +1,87 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const { TabStateFlusher } = ChromeUtils.importESModule(
+ "resource:///modules/sessionstore/TabStateFlusher.sys.mjs"
+);
+
+const DUMMY =
+ "https://example.com/browser/browser/base/content/test/general/dummy_page.html";
+
+function isBrowserAppTab(browser) {
+ return browser.browsingContext.isAppTab;
+}
+
+// Restarts the child process by crashing it then reloading the tab
+var restart = async function (browser) {
+ // If the tab isn't remote this would crash the main process so skip it
+ if (!browser.isRemoteBrowser) {
+ return;
+ }
+
+ // Make sure the main process has all of the current tab state before crashing
+ await TabStateFlusher.flush(browser);
+
+ await BrowserTestUtils.crashFrame(browser);
+
+ let tab = gBrowser.getTabForBrowser(browser);
+ SessionStore.reviveCrashedTab(tab);
+
+ await promiseTabLoaded(tab);
+};
+
+add_task(async function navigate() {
+ let tab = BrowserTestUtils.addTab(gBrowser, "about:robots");
+ let browser = tab.linkedBrowser;
+ gBrowser.selectedTab = tab;
+ await BrowserTestUtils.browserStopped(gBrowser);
+ let isAppTab = isBrowserAppTab(browser);
+ ok(!isAppTab, "Docshell shouldn't think it is an app tab");
+
+ gBrowser.pinTab(tab);
+ isAppTab = isBrowserAppTab(browser);
+ ok(isAppTab, "Docshell should think it is an app tab");
+
+ BrowserTestUtils.loadURIString(gBrowser, DUMMY);
+ await BrowserTestUtils.browserStopped(gBrowser);
+ isAppTab = isBrowserAppTab(browser);
+ ok(isAppTab, "Docshell should think it is an app tab");
+
+ gBrowser.unpinTab(tab);
+ isAppTab = isBrowserAppTab(browser);
+ ok(!isAppTab, "Docshell shouldn't think it is an app tab");
+
+ gBrowser.pinTab(tab);
+ isAppTab = isBrowserAppTab(browser);
+ ok(isAppTab, "Docshell should think it is an app tab");
+
+ BrowserTestUtils.loadURIString(gBrowser, "about:robots");
+ await BrowserTestUtils.browserStopped(gBrowser);
+ isAppTab = isBrowserAppTab(browser);
+ ok(isAppTab, "Docshell should think it is an app tab");
+
+ gBrowser.removeCurrentTab();
+});
+
+add_task(async function crash() {
+ if (!gMultiProcessBrowser || !AppConstants.MOZ_CRASHREPORTER) {
+ return;
+ }
+
+ let tab = BrowserTestUtils.addTab(gBrowser, DUMMY);
+ let browser = tab.linkedBrowser;
+ gBrowser.selectedTab = tab;
+ await BrowserTestUtils.browserStopped(gBrowser);
+ let isAppTab = isBrowserAppTab(browser);
+ ok(!isAppTab, "Docshell shouldn't think it is an app tab");
+
+ gBrowser.pinTab(tab);
+ isAppTab = isBrowserAppTab(browser);
+ ok(isAppTab, "Docshell should think it is an app tab");
+
+ await restart(browser);
+ isAppTab = isBrowserAppTab(browser);
+ ok(isAppTab, "Docshell should think it is an app tab");
+
+ gBrowser.removeCurrentTab();
+});
diff --git a/browser/base/content/test/general/browser_save_link-perwindowpb.js b/browser/base/content/test/general/browser_save_link-perwindowpb.js
new file mode 100644
index 0000000000..4800c813b3
--- /dev/null
+++ b/browser/base/content/test/general/browser_save_link-perwindowpb.js
@@ -0,0 +1,214 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+var MockFilePicker = SpecialPowers.MockFilePicker;
+MockFilePicker.init(window);
+
+// Trigger a save of a link in public mode, then trigger an identical save
+// in private mode and ensure that the second request is differentiated from
+// the first by checking that cookies set by the first response are not sent
+// during the second request.
+function triggerSave(aWindow, aCallback) {
+ info("started triggerSave");
+ var fileName;
+ let testBrowser = aWindow.gBrowser.selectedBrowser;
+ // This page sets a cookie if and only if a cookie does not exist yet
+ let testURI =
+ "http://mochi.test:8888/browser/browser/base/content/test/general/bug792517-2.html";
+ BrowserTestUtils.loadURIString(testBrowser, testURI);
+ BrowserTestUtils.browserLoaded(testBrowser, false, testURI).then(() => {
+ waitForFocus(function () {
+ info("register to handle popupshown");
+ aWindow.document.addEventListener("popupshown", contextMenuOpened);
+
+ BrowserTestUtils.synthesizeMouseAtCenter(
+ "#fff",
+ { type: "contextmenu", button: 2 },
+ testBrowser
+ );
+ info("right clicked!");
+ }, aWindow);
+ });
+
+ function contextMenuOpened(event) {
+ info("contextMenuOpened");
+ aWindow.document.removeEventListener("popupshown", contextMenuOpened);
+
+ // Create the folder the link will be saved into.
+ var destDir = createTemporarySaveDirectory();
+ var destFile = destDir.clone();
+
+ MockFilePicker.displayDirectory = destDir;
+ MockFilePicker.showCallback = function (fp) {
+ info("showCallback");
+ fileName = fp.defaultString;
+ info("fileName: " + fileName);
+ destFile.append(fileName);
+ MockFilePicker.setFiles([destFile]);
+ MockFilePicker.filterIndex = 1; // kSaveAsType_URL
+ info("done showCallback");
+ };
+
+ mockTransferCallback = function (downloadSuccess) {
+ info("mockTransferCallback");
+ onTransferComplete(aWindow, downloadSuccess, destDir);
+ destDir.remove(true);
+ ok(!destDir.exists(), "Destination dir should be removed");
+ ok(!destFile.exists(), "Destination file should be removed");
+ mockTransferCallback = null;
+ info("done mockTransferCallback");
+ };
+
+ // Select "Save Link As" option from context menu
+ var saveLinkCommand = aWindow.document.getElementById("context-savelink");
+ info("saveLinkCommand: " + saveLinkCommand);
+ saveLinkCommand.doCommand();
+
+ event.target.hidePopup();
+ info("popup hidden");
+ }
+
+ function onTransferComplete(aWindow2, downloadSuccess, destDir) {
+ ok(downloadSuccess, "Link should have been downloaded successfully");
+ aWindow2.close();
+
+ executeSoon(() => aCallback());
+ }
+}
+
+function test() {
+ info("Start the test");
+ waitForExplicitFinish();
+
+ var gNumSet = 0;
+ function testOnWindow(options, callback) {
+ info("testOnWindow(" + options + ")");
+ var win = OpenBrowserWindow(options);
+ info("got " + win);
+ whenDelayedStartupFinished(win, () => callback(win));
+ }
+
+ function whenDelayedStartupFinished(aWindow, aCallback) {
+ info("whenDelayedStartupFinished");
+ Services.obs.addObserver(function obs(aSubject, aTopic) {
+ info(
+ "whenDelayedStartupFinished, got topic: " +
+ aTopic +
+ ", got subject: " +
+ aSubject +
+ ", waiting for " +
+ aWindow
+ );
+ if (aWindow == aSubject) {
+ Services.obs.removeObserver(obs, aTopic);
+ executeSoon(aCallback);
+ info("whenDelayedStartupFinished found our window");
+ }
+ }, "browser-delayed-startup-finished");
+ }
+
+ mockTransferRegisterer.register();
+
+ registerCleanupFunction(function () {
+ info("Running the cleanup code");
+ mockTransferRegisterer.unregister();
+ MockFilePicker.cleanup();
+ Services.obs.removeObserver(observer, "http-on-modify-request");
+ Services.obs.removeObserver(observer, "http-on-examine-response");
+ info("Finished running the cleanup code");
+ });
+
+ function observer(subject, topic, state) {
+ info("observer called with " + topic);
+ if (topic == "http-on-modify-request") {
+ onModifyRequest(subject);
+ } else if (topic == "http-on-examine-response") {
+ onExamineResponse(subject);
+ }
+ }
+
+ function onExamineResponse(subject) {
+ let channel = subject.QueryInterface(Ci.nsIHttpChannel);
+ info("onExamineResponse with " + channel.URI.spec);
+ if (
+ channel.URI.spec !=
+ "http://mochi.test:8888/browser/browser/base/content/test/general/bug792517.sjs"
+ ) {
+ info("returning");
+ return;
+ }
+ try {
+ let cookies = channel.getResponseHeader("set-cookie");
+ // From browser/base/content/test/general/bug792715.sjs, we receive a Set-Cookie
+ // header with foopy=1 when there are no cookies for that domain.
+ is(cookies, "foopy=1", "Cookie should be foopy=1");
+ gNumSet += 1;
+ info("gNumSet = " + gNumSet);
+ } catch (ex) {
+ if (ex.result == Cr.NS_ERROR_NOT_AVAILABLE) {
+ info("onExamineResponse caught NOTAVAIL" + ex);
+ } else {
+ info("ionExamineResponse caught " + ex);
+ }
+ }
+ }
+
+ function onModifyRequest(subject) {
+ let channel = subject.QueryInterface(Ci.nsIHttpChannel);
+ info("onModifyRequest with " + channel.URI.spec);
+ if (
+ channel.URI.spec !=
+ "http://mochi.test:8888/browser/browser/base/content/test/general/bug792517.sjs"
+ ) {
+ return;
+ }
+ try {
+ let cookies = channel.getRequestHeader("cookie");
+ info("cookies: " + cookies);
+ // From browser/base/content/test/general/bug792715.sjs, we should never send a
+ // cookie because we are making only 2 requests: one in public mode, and
+ // one in private mode.
+ throw new Error("We should never send a cookie in this test");
+ } catch (ex) {
+ if (ex.result == Cr.NS_ERROR_NOT_AVAILABLE) {
+ info("onModifyRequest caught NOTAVAIL" + ex);
+ } else {
+ info("ionModifyRequest caught " + ex);
+ }
+ }
+ }
+
+ Services.obs.addObserver(observer, "http-on-modify-request");
+ Services.obs.addObserver(observer, "http-on-examine-response");
+
+ testOnWindow(undefined, function (win) {
+ // The first save from a regular window sets a cookie.
+ triggerSave(win, function () {
+ is(gNumSet, 1, "1 cookie should be set");
+
+ // The second save from a private window also sets a cookie.
+ testOnWindow({ private: true }, function (win2) {
+ triggerSave(win2, function () {
+ is(gNumSet, 2, "2 cookies should be set");
+ finish();
+ });
+ });
+ });
+ });
+}
+
+Services.scriptloader.loadSubScript(
+ "chrome://mochitests/content/browser/toolkit/content/tests/browser/common/mockTransfer.js",
+ this
+);
+
+function createTemporarySaveDirectory() {
+ var saveDir = Services.dirsvc.get("TmpD", Ci.nsIFile);
+ saveDir.append("testsavedir");
+ if (!saveDir.exists()) {
+ info("create testsavedir!");
+ saveDir.create(Ci.nsIFile.DIRECTORY_TYPE, 0o755);
+ }
+ info("return from createTempSaveDir: " + saveDir.path);
+ return saveDir;
+}
diff --git a/browser/base/content/test/general/browser_save_link_when_window_navigates.js b/browser/base/content/test/general/browser_save_link_when_window_navigates.js
new file mode 100644
index 0000000000..49901e8bfa
--- /dev/null
+++ b/browser/base/content/test/general/browser_save_link_when_window_navigates.js
@@ -0,0 +1,197 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+var MockFilePicker = SpecialPowers.MockFilePicker;
+MockFilePicker.init(window);
+
+const SAVE_PER_SITE_PREF = "browser.download.lastDir.savePerSite";
+const ALWAYS_DOWNLOAD_DIR_PREF = "browser.download.useDownloadDir";
+const ALWAYS_ASK_PREF = "browser.download.always_ask_before_handling_new_types";
+const UCT_URI = "chrome://mozapps/content/downloads/unknownContentType.xhtml";
+
+Services.scriptloader.loadSubScript(
+ "chrome://mochitests/content/browser/toolkit/content/tests/browser/common/mockTransfer.js",
+ this
+);
+
+function createTemporarySaveDirectory() {
+ var saveDir = Services.dirsvc.get("TmpD", Ci.nsIFile);
+ saveDir.append("testsavedir");
+ if (!saveDir.exists()) {
+ info("create testsavedir!");
+ saveDir.create(Ci.nsIFile.DIRECTORY_TYPE, 0o755);
+ }
+
+ Services.prefs.setIntPref("browser.download.folderList", 2);
+ Services.prefs.setCharPref("browser.download.dir", saveDir);
+ info("return from createTempSaveDir: " + saveDir.path);
+ return saveDir;
+}
+
+function triggerSave(aWindow, aCallback) {
+ info(
+ "started triggerSave, persite downloads: " +
+ (Services.prefs.getBoolPref(SAVE_PER_SITE_PREF) ? "on" : "off")
+ );
+ var fileName;
+ let testBrowser = aWindow.gBrowser.selectedBrowser;
+ let testURI =
+ "http://mochi.test:8888/browser/browser/base/content/test/general/navigating_window_with_download.html";
+
+ // Only observe the UTC dialog if it's enabled by pref
+ if (Services.prefs.getBoolPref(ALWAYS_ASK_PREF)) {
+ windowObserver.setCallback(onUCTDialog);
+ }
+
+ BrowserTestUtils.loadURIString(testBrowser, testURI);
+
+ // Create the folder the link will be saved into.
+ var destDir = createTemporarySaveDirectory();
+ var destFile = destDir.clone();
+
+ MockFilePicker.displayDirectory = destDir;
+ MockFilePicker.showCallback = function (fp) {
+ info("showCallback");
+ fileName = fp.defaultString;
+ info("fileName: " + fileName);
+ destFile.append(fileName);
+ MockFilePicker.setFiles([destFile]);
+ MockFilePicker.filterIndex = 1; // kSaveAsType_URL
+ info("done showCallback");
+ };
+
+ mockTransferCallback = function (downloadSuccess) {
+ info("mockTransferCallback");
+ onTransferComplete(aWindow, downloadSuccess, destDir);
+ destDir.remove(true);
+ ok(!destDir.exists(), "Destination dir should be removed");
+ ok(!destFile.exists(), "Destination file should be removed");
+ mockTransferCallback = null;
+ info("done mockTransferCallback");
+ };
+
+ function onUCTDialog(dialog) {
+ SpecialPowers.spawn(testBrowser, [], async () => {
+ content.document.querySelector("iframe").remove();
+ }).then(() => executeSoon(continueDownloading));
+ }
+
+ function continueDownloading() {
+ for (let win of Services.wm.getEnumerator("")) {
+ if (win.location && win.location.href == UCT_URI) {
+ win.document
+ .getElementById("unknownContentType")
+ ._fireButtonEvent("accept");
+ win.close();
+ return;
+ }
+ }
+ ok(false, "No Unknown Content Type dialog yet?");
+ }
+
+ function onTransferComplete(aWindow2, downloadSuccess) {
+ ok(downloadSuccess, "Link should have been downloaded successfully");
+ aWindow2.close();
+
+ executeSoon(aCallback);
+ }
+}
+
+var windowObserver = {
+ setCallback(aCallback) {
+ if (this._callback) {
+ ok(false, "Should only be dealing with one callback at a time.");
+ }
+ this._callback = aCallback;
+ },
+ observe(aSubject, aTopic, aData) {
+ if (aTopic != "domwindowopened") {
+ return;
+ }
+
+ let win = aSubject;
+
+ win.addEventListener(
+ "load",
+ function (event) {
+ if (win.location == UCT_URI) {
+ SimpleTest.executeSoon(function () {
+ if (windowObserver._callback) {
+ windowObserver._callback(win);
+ delete windowObserver._callback;
+ } else {
+ ok(false, "Unexpected UCT dialog!");
+ }
+ });
+ }
+ },
+ { once: true }
+ );
+ },
+};
+
+Services.ww.registerNotification(windowObserver);
+
+function test() {
+ waitForExplicitFinish();
+ Services.prefs.setBoolPref(ALWAYS_ASK_PREF, false);
+
+ function testOnWindow(options, callback) {
+ info("testOnWindow(" + options + ")");
+ var win = OpenBrowserWindow(options);
+ info("got " + win);
+ whenDelayedStartupFinished(win, () => callback(win));
+ }
+
+ function whenDelayedStartupFinished(aWindow, aCallback) {
+ info("whenDelayedStartupFinished");
+ Services.obs.addObserver(function observer(aSubject, aTopic) {
+ info(
+ "whenDelayedStartupFinished, got topic: " +
+ aTopic +
+ ", got subject: " +
+ aSubject +
+ ", waiting for " +
+ aWindow
+ );
+ if (aWindow == aSubject) {
+ Services.obs.removeObserver(observer, aTopic);
+ executeSoon(aCallback);
+ info("whenDelayedStartupFinished found our window");
+ }
+ }, "browser-delayed-startup-finished");
+ }
+
+ mockTransferRegisterer.register();
+
+ registerCleanupFunction(function () {
+ info("Running the cleanup code");
+ mockTransferRegisterer.unregister();
+ MockFilePicker.cleanup();
+ Services.ww.unregisterNotification(windowObserver);
+ Services.prefs.clearUserPref(ALWAYS_DOWNLOAD_DIR_PREF);
+ Services.prefs.clearUserPref(SAVE_PER_SITE_PREF);
+ Services.prefs.clearUserPref(ALWAYS_ASK_PREF);
+ Services.prefs.clearUserPref("browser.download.folderList");
+ Services.prefs.clearUserPref("browser.download.dir");
+ info("Finished running the cleanup code");
+ });
+
+ info(
+ `Running test with ${ALWAYS_ASK_PREF} set to ${Services.prefs.getBoolPref(
+ ALWAYS_ASK_PREF,
+ false
+ )}`
+ );
+ testOnWindow(undefined, function (win) {
+ let windowGonePromise = BrowserTestUtils.domWindowClosed(win);
+ Services.prefs.setBoolPref(SAVE_PER_SITE_PREF, true);
+ triggerSave(win, async function () {
+ await windowGonePromise;
+ Services.prefs.setBoolPref(SAVE_PER_SITE_PREF, false);
+ testOnWindow(undefined, function (win2) {
+ triggerSave(win2, finish);
+ });
+ });
+ });
+}
diff --git a/browser/base/content/test/general/browser_save_private_link_perwindowpb.js b/browser/base/content/test/general/browser_save_private_link_perwindowpb.js
new file mode 100644
index 0000000000..8ede97e640
--- /dev/null
+++ b/browser/base/content/test/general/browser_save_private_link_perwindowpb.js
@@ -0,0 +1,127 @@
+/* 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/. */
+
+function createTemporarySaveDirectory() {
+ var saveDir = Services.dirsvc.get("TmpD", Ci.nsIFile);
+ saveDir.append("testsavedir");
+ if (!saveDir.exists()) {
+ saveDir.create(Ci.nsIFile.DIRECTORY_TYPE, 0o755);
+ }
+ return saveDir;
+}
+
+function promiseNoCacheEntry(filename) {
+ return new Promise((resolve, reject) => {
+ Visitor.prototype = {
+ onCacheStorageInfo(num, consumption) {
+ info("disk storage contains " + num + " entries");
+ },
+ onCacheEntryInfo(uri) {
+ let urispec = uri.asciiSpec;
+ info(urispec);
+ is(
+ urispec.includes(filename),
+ false,
+ "web content present in disk cache"
+ );
+ },
+ onCacheEntryVisitCompleted() {
+ resolve();
+ },
+ };
+ function Visitor() {}
+
+ let storage = Services.cache2.diskCacheStorage(
+ Services.loadContextInfo.default
+ );
+ storage.asyncVisitStorage(new Visitor(), true /* Do walk entries */);
+ });
+}
+
+function promiseImageDownloaded() {
+ return new Promise((resolve, reject) => {
+ let fileName;
+ let MockFilePicker = SpecialPowers.MockFilePicker;
+ MockFilePicker.init(window);
+
+ function onTransferComplete(downloadSuccess) {
+ ok(
+ downloadSuccess,
+ "Image file should have been downloaded successfully " + fileName
+ );
+
+ // Give the request a chance to finish and create a cache entry
+ resolve(fileName);
+ }
+
+ // Create the folder the image will be saved into.
+ var destDir = createTemporarySaveDirectory();
+ var destFile = destDir.clone();
+
+ MockFilePicker.displayDirectory = destDir;
+ MockFilePicker.showCallback = function (fp) {
+ fileName = fp.defaultString;
+ destFile.append(fileName);
+ MockFilePicker.setFiles([destFile]);
+ MockFilePicker.filterIndex = 1; // kSaveAsType_URL
+ };
+
+ mockTransferCallback = onTransferComplete;
+ mockTransferRegisterer.register();
+
+ registerCleanupFunction(function () {
+ mockTransferCallback = null;
+ mockTransferRegisterer.unregister();
+ MockFilePicker.cleanup();
+ destDir.remove(true);
+ });
+ });
+}
+
+add_task(async function () {
+ let testURI =
+ "http://mochi.test:8888/browser/browser/base/content/test/general/bug792517.html";
+ let privateWindow = await BrowserTestUtils.openNewBrowserWindow({
+ private: true,
+ });
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ privateWindow.gBrowser,
+ testURI
+ );
+
+ let contextMenu = privateWindow.document.getElementById(
+ "contentAreaContextMenu"
+ );
+ let popupShown = BrowserTestUtils.waitForEvent(contextMenu, "popupshown");
+ let popupHidden = BrowserTestUtils.waitForEvent(contextMenu, "popuphidden");
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#img",
+ {
+ type: "contextmenu",
+ button: 2,
+ },
+ tab.linkedBrowser
+ );
+ await popupShown;
+
+ Services.cache2.clear();
+
+ let imageDownloaded = promiseImageDownloaded();
+ // Select "Save Image As" option from context menu
+ privateWindow.document.getElementById("context-saveimage").doCommand();
+
+ contextMenu.hidePopup();
+ await popupHidden;
+
+ // wait for image download
+ let fileName = await imageDownloaded;
+ await promiseNoCacheEntry(fileName);
+
+ await BrowserTestUtils.closeWindow(privateWindow);
+});
+
+Services.scriptloader.loadSubScript(
+ "chrome://mochitests/content/browser/toolkit/content/tests/browser/common/mockTransfer.js",
+ this
+);
diff --git a/browser/base/content/test/general/browser_save_video.js b/browser/base/content/test/general/browser_save_video.js
new file mode 100644
index 0000000000..5456ac240f
--- /dev/null
+++ b/browser/base/content/test/general/browser_save_video.js
@@ -0,0 +1,99 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+var MockFilePicker = SpecialPowers.MockFilePicker;
+MockFilePicker.init(window);
+
+/**
+ * TestCase for bug 564387
+ * <https://bugzilla.mozilla.org/show_bug.cgi?id=564387>
+ */
+add_task(async function () {
+ var fileName;
+
+ let loadPromise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+ BrowserTestUtils.loadURIString(
+ gBrowser,
+ "http://mochi.test:8888/browser/browser/base/content/test/general/web_video.html"
+ );
+ await loadPromise;
+
+ let popupShownPromise = BrowserTestUtils.waitForEvent(document, "popupshown");
+
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#video1",
+ { type: "contextmenu", button: 2 },
+ gBrowser.selectedBrowser
+ );
+ info("context menu click on video1");
+
+ await popupShownPromise;
+
+ info("context menu opened on video1");
+
+ // Create the folder the video will be saved into.
+ var destDir = createTemporarySaveDirectory();
+ var destFile = destDir.clone();
+
+ MockFilePicker.displayDirectory = destDir;
+ MockFilePicker.showCallback = function (fp) {
+ fileName = fp.defaultString;
+ destFile.append(fileName);
+ MockFilePicker.setFiles([destFile]);
+ MockFilePicker.filterIndex = 1; // kSaveAsType_URL
+ };
+
+ let transferCompletePromise = new Promise(resolve => {
+ function onTransferComplete(downloadSuccess) {
+ ok(
+ downloadSuccess,
+ "Video file should have been downloaded successfully"
+ );
+
+ is(
+ fileName,
+ "web-video1-expectedName.ogv",
+ "Video file name is correctly retrieved from Content-Disposition http header"
+ );
+ resolve();
+ }
+
+ mockTransferCallback = onTransferComplete;
+ mockTransferRegisterer.register();
+ });
+
+ registerCleanupFunction(function () {
+ mockTransferRegisterer.unregister();
+ MockFilePicker.cleanup();
+ destDir.remove(true);
+ });
+
+ // Select "Save Video As" option from context menu
+ var saveVideoCommand = document.getElementById("context-savevideo");
+ saveVideoCommand.doCommand();
+ info("context-savevideo command executed");
+
+ let contextMenu = document.getElementById("contentAreaContextMenu");
+ let popupHiddenPromise = BrowserTestUtils.waitForEvent(
+ contextMenu,
+ "popuphidden"
+ );
+ contextMenu.hidePopup();
+ await popupHiddenPromise;
+
+ await transferCompletePromise;
+});
+
+Services.scriptloader.loadSubScript(
+ "chrome://mochitests/content/browser/toolkit/content/tests/browser/common/mockTransfer.js",
+ this
+);
+
+function createTemporarySaveDirectory() {
+ var saveDir = Services.dirsvc.get("TmpD", Ci.nsIFile);
+ saveDir.append("testsavedir");
+ if (!saveDir.exists()) {
+ saveDir.create(Ci.nsIFile.DIRECTORY_TYPE, 0o755);
+ }
+ return saveDir;
+}
diff --git a/browser/base/content/test/general/browser_save_video_frame.js b/browser/base/content/test/general/browser_save_video_frame.js
new file mode 100644
index 0000000000..877c33bcd3
--- /dev/null
+++ b/browser/base/content/test/general/browser_save_video_frame.js
@@ -0,0 +1,103 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const VIDEO_URL =
+ "http://mochi.test:8888/browser/browser/base/content/test/general/web_video.html";
+
+/**
+ * mockTransfer.js provides a utility that lets us mock out
+ * the "Save File" dialog.
+ */
+Services.scriptloader.loadSubScript(
+ "chrome://mochitests/content/browser/toolkit/content/tests/browser/common/mockTransfer.js",
+ this
+);
+
+/**
+ * Creates and returns an nsIFile for a new temporary save
+ * directory.
+ *
+ * @return nsIFile
+ */
+function createTemporarySaveDirectory() {
+ let saveDir = Services.dirsvc.get("TmpD", Ci.nsIFile);
+ saveDir.append("testsavedir");
+ if (!saveDir.exists()) {
+ saveDir.create(Ci.nsIFile.DIRECTORY_TYPE, 0o755);
+ }
+ return saveDir;
+}
+/**
+ * MockTransfer exposes a "mockTransferCallback" global which
+ * allows us to define a callback to be called once the mock file
+ * selector has selected where to save the file.
+ */
+function waitForTransferComplete() {
+ return new Promise(resolve => {
+ mockTransferCallback = () => {
+ ok(true, "Transfer completed");
+ mockTransferCallback = () => {};
+ resolve();
+ };
+ });
+}
+
+/**
+ * Loads a page with a <video> element, right-clicks it and chooses
+ * to save a frame screenshot to the disk. Completes once we've
+ * verified that the frame has been saved to disk.
+ */
+add_task(async function () {
+ let MockFilePicker = SpecialPowers.MockFilePicker;
+ MockFilePicker.init(window);
+
+ // Create the folder the video will be saved into.
+ let destDir = createTemporarySaveDirectory();
+ let destFile = destDir.clone();
+
+ MockFilePicker.displayDirectory = destDir;
+ MockFilePicker.showCallback = function (fp) {
+ destFile.append(fp.defaultString);
+ MockFilePicker.setFiles([destFile]);
+ MockFilePicker.filterIndex = 1; // kSaveAsType_URL
+ };
+
+ mockTransferRegisterer.register();
+
+ // Make sure that we clean these things up when we're done.
+ registerCleanupFunction(function () {
+ mockTransferRegisterer.unregister();
+ MockFilePicker.cleanup();
+ destDir.remove(true);
+ });
+
+ let tab = BrowserTestUtils.addTab(gBrowser);
+ gBrowser.selectedTab = tab;
+ let browser = tab.linkedBrowser;
+ info("Loading video tab");
+ await promiseTabLoadEvent(tab, VIDEO_URL);
+ info("Video tab loaded.");
+
+ let context = document.getElementById("contentAreaContextMenu");
+ let popupPromise = promisePopupShown(context);
+
+ info("Synthesizing right-click on video element");
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#video1",
+ { type: "contextmenu", button: 2 },
+ browser
+ );
+ info("Waiting for popup to fire popupshown.");
+ await popupPromise;
+ info("Popup fired popupshown");
+
+ let saveSnapshotCommand = document.getElementById("context-video-saveimage");
+ let promiseTransfer = waitForTransferComplete();
+ info("Firing save snapshot command");
+ saveSnapshotCommand.doCommand();
+ context.hidePopup();
+ info("Waiting for transfer completion");
+ await promiseTransfer;
+ info("Transfer complete");
+ gBrowser.removeTab(tab);
+});
diff --git a/browser/base/content/test/general/browser_selectTabAtIndex.js b/browser/base/content/test/general/browser_selectTabAtIndex.js
new file mode 100644
index 0000000000..5d2e8c739e
--- /dev/null
+++ b/browser/base/content/test/general/browser_selectTabAtIndex.js
@@ -0,0 +1,89 @@
+"use strict";
+
+function test() {
+ const isLinux = navigator.platform.indexOf("Linux") == 0;
+
+ function assertTab(expectedTab) {
+ is(
+ gBrowser.tabContainer.selectedIndex,
+ expectedTab,
+ `tab index ${expectedTab} should be selected`
+ );
+ }
+
+ function sendAccelKey(key) {
+ // Make sure the keystroke goes to chrome.
+ document.activeElement.blur();
+ EventUtils.synthesizeKey(key.toString(), {
+ altKey: isLinux,
+ accelKey: !isLinux,
+ });
+ }
+
+ function createTabs(count) {
+ for (let n = 0; n < count; n++) {
+ BrowserTestUtils.addTab(gBrowser);
+ }
+ }
+
+ function testKey(key, expectedTab) {
+ sendAccelKey(key);
+ assertTab(expectedTab);
+ }
+
+ function testIndex(index, expectedTab) {
+ gBrowser.selectTabAtIndex(index);
+ assertTab(expectedTab);
+ }
+
+ // Create fewer tabs than our 9 number keys.
+ is(gBrowser.tabs.length, 1, "should have 1 tab");
+ createTabs(4);
+ is(gBrowser.tabs.length, 5, "should have 5 tabs");
+
+ // Test keyboard shortcuts. Order tests so that no two test cases have the
+ // same expected tab in a row. This ensures that tab selection actually
+ // changed the selected tab.
+ testKey(8, 4);
+ testKey(1, 0);
+ testKey(2, 1);
+ testKey(4, 3);
+ testKey(9, 4);
+
+ // Test index selection.
+ testIndex(0, 0);
+ testIndex(4, 4);
+ testIndex(-5, 0);
+ testIndex(5, 4);
+ testIndex(-4, 1);
+ testIndex(1, 1);
+ testIndex(-1, 4);
+ testIndex(9, 4);
+
+ // Create more tabs than our 9 number keys.
+ createTabs(10);
+ is(gBrowser.tabs.length, 15, "should have 15 tabs");
+
+ // Test keyboard shortcuts.
+ testKey(2, 1);
+ testKey(1, 0);
+ testKey(4, 3);
+ testKey(8, 7);
+ testKey(9, 14);
+
+ // Test index selection.
+ testIndex(-15, 0);
+ testIndex(14, 14);
+ testIndex(-14, 1);
+ testIndex(15, 14);
+ testIndex(-1, 14);
+ testIndex(0, 0);
+ testIndex(1, 1);
+ testIndex(9, 9);
+
+ // Clean up tabs.
+ for (let n = 15; n > 1; n--) {
+ gBrowser.removeTab(gBrowser.selectedTab, { skipPermitUnload: true });
+ }
+ is(gBrowser.tabs.length, 1, "should have 1 tab");
+}
diff --git a/browser/base/content/test/general/browser_star_hsts.js b/browser/base/content/test/general/browser_star_hsts.js
new file mode 100644
index 0000000000..9452c61beb
--- /dev/null
+++ b/browser/base/content/test/general/browser_star_hsts.js
@@ -0,0 +1,87 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+/* eslint-disable mozilla/no-arbitrary-setTimeout */
+
+var secureURL =
+ "https://example.com/browser/browser/base/content/test/general/browser_star_hsts.sjs";
+var unsecureURL =
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com/browser/browser/base/content/test/general/browser_star_hsts.sjs";
+
+add_task(async function test_star_redirect() {
+ registerCleanupFunction(async () => {
+ // Ensure to remove example.com from the HSTS list.
+ let sss = Cc["@mozilla.org/ssservice;1"].getService(
+ Ci.nsISiteSecurityService
+ );
+ sss.resetState(
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ NetUtil.newURI("http://example.com/"),
+ Services.prefs.getBoolPref("privacy.partition.network_state")
+ ? { partitionKey: "(http,example.com)" }
+ : {}
+ );
+ await PlacesUtils.bookmarks.eraseEverything();
+ gBrowser.removeCurrentTab();
+ });
+
+ let tab = (gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser));
+ // This will add the page to the HSTS cache.
+ await promiseTabLoadEvent(tab, secureURL, secureURL);
+ // This should transparently be redirected to the secure page.
+ await promiseTabLoadEvent(tab, unsecureURL, secureURL);
+
+ await promiseStarState(BookmarkingUI.STATUS_UNSTARRED);
+
+ StarUI._createPanelIfNeeded();
+ let bookmarkPanel = document.getElementById("editBookmarkPanel");
+ let shownPromise = promisePopupShown(bookmarkPanel);
+ BookmarkingUI.star.click();
+ await shownPromise;
+
+ is(BookmarkingUI.status, BookmarkingUI.STATUS_STARRED, "The star is starred");
+});
+
+/**
+ * Waits for the star to reflect the expected state.
+ */
+function promiseStarState(aValue) {
+ return new Promise(resolve => {
+ let expectedStatus = aValue
+ ? BookmarkingUI.STATUS_STARRED
+ : BookmarkingUI.STATUS_UNSTARRED;
+ (function checkState() {
+ if (
+ BookmarkingUI.status == BookmarkingUI.STATUS_UPDATING ||
+ BookmarkingUI.status != expectedStatus
+ ) {
+ info("Waiting for star button change.");
+ setTimeout(checkState, 1000);
+ } else {
+ resolve();
+ }
+ })();
+ });
+}
+
+/**
+ * Starts a load in an existing tab and waits for it to finish (via some event).
+ *
+ * @param aTab
+ * The tab to load into.
+ * @param aUrl
+ * The url to load.
+ * @param [optional] aFinalURL
+ * The url to wait for, same as aURL if not defined.
+ * @return {Promise} resolved when the event is handled.
+ */
+function promiseTabLoadEvent(aTab, aURL, aFinalURL) {
+ if (!aFinalURL) {
+ aFinalURL = aURL;
+ }
+
+ info("Wait for load tab event");
+ BrowserTestUtils.loadURIString(aTab.linkedBrowser, aURL);
+ return BrowserTestUtils.browserLoaded(aTab.linkedBrowser, false, aFinalURL);
+}
diff --git a/browser/base/content/test/general/browser_star_hsts.sjs b/browser/base/content/test/general/browser_star_hsts.sjs
new file mode 100644
index 0000000000..64c4235288
--- /dev/null
+++ b/browser/base/content/test/general/browser_star_hsts.sjs
@@ -0,0 +1,12 @@
+/* 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/. */
+
+function handleRequest(request, response) {
+ let page = "<!DOCTYPE html><html><body><p>HSTS page</p></body></html>";
+ response.setStatusLine(request.httpVersion, "200", "OK");
+ response.setHeader("Strict-Transport-Security", "max-age=60");
+ response.setHeader("Content-Type", "text/html", false);
+ response.setHeader("Content-Length", page.length + "", false);
+ response.write(page);
+}
diff --git a/browser/base/content/test/general/browser_storagePressure_notification.js b/browser/base/content/test/general/browser_storagePressure_notification.js
new file mode 100644
index 0000000000..dcafbe8bf9
--- /dev/null
+++ b/browser/base/content/test/general/browser_storagePressure_notification.js
@@ -0,0 +1,182 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+/* eslint-disable mozilla/no-arbitrary-setTimeout */
+
+async function notifyStoragePressure(usage = 100) {
+ let notifyPromise = TestUtils.topicObserved(
+ "QuotaManager::StoragePressure",
+ () => true
+ );
+ let usageWrapper = Cc["@mozilla.org/supports-PRUint64;1"].createInstance(
+ Ci.nsISupportsPRUint64
+ );
+ usageWrapper.data = usage;
+ Services.obs.notifyObservers(usageWrapper, "QuotaManager::StoragePressure");
+ return notifyPromise;
+}
+
+function openAboutPrefPromise(win) {
+ let promises = [
+ BrowserTestUtils.waitForLocationChange(
+ win.gBrowser,
+ "about:preferences#privacy"
+ ),
+ TestUtils.topicObserved("privacy-pane-loaded", () => true),
+ TestUtils.topicObserved("sync-pane-loaded", () => true),
+ ];
+ return Promise.all(promises);
+}
+add_setup(async function () {
+ let win = await BrowserTestUtils.openNewBrowserWindow();
+ // Open a new tab to keep the window open.
+ await BrowserTestUtils.openNewForegroundTab(
+ win.gBrowser,
+ "https://example.com"
+ );
+});
+
+// Test only displaying notification once within the given interval
+add_task(async function () {
+ const win = Services.wm.getMostRecentWindow("navigator:browser");
+ const TEST_NOTIFICATION_INTERVAL_MS = 2000;
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "browser.storageManager.pressureNotification.minIntervalMS",
+ TEST_NOTIFICATION_INTERVAL_MS,
+ ],
+ ],
+ });
+ // Commenting this to see if we really need it
+ // await SpecialPowers.pushPrefEnv({set: [["privacy.reduceTimerPrecision", false]]});
+
+ await notifyStoragePressure();
+ await TestUtils.waitForCondition(() =>
+ win.gNotificationBox.getNotificationWithValue(
+ "storage-pressure-notification"
+ )
+ );
+ let notification = win.gNotificationBox.getNotificationWithValue(
+ "storage-pressure-notification"
+ );
+ is(
+ notification.localName,
+ "notification-message",
+ "Should display storage pressure notification"
+ );
+ notification.close();
+
+ await notifyStoragePressure();
+ notification = win.gNotificationBox.getNotificationWithValue(
+ "storage-pressure-notification"
+ );
+ is(
+ notification,
+ null,
+ "Should not display storage pressure notification more than once within the given interval"
+ );
+
+ await new Promise(resolve =>
+ setTimeout(resolve, TEST_NOTIFICATION_INTERVAL_MS + 1)
+ );
+ await notifyStoragePressure();
+ await TestUtils.waitForCondition(() =>
+ win.gNotificationBox.getNotificationWithValue(
+ "storage-pressure-notification"
+ )
+ );
+ notification = win.gNotificationBox.getNotificationWithValue(
+ "storage-pressure-notification"
+ );
+ is(
+ notification.localName,
+ "notification-message",
+ "Should display storage pressure notification after the given interval"
+ );
+ notification.close();
+});
+
+// Test guiding user to the about:preferences when usage exceeds the given threshold
+add_task(async function () {
+ const win = Services.wm.getMostRecentWindow("navigator:browser");
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.storageManager.pressureNotification.minIntervalMS", 0]],
+ });
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ win.gBrowser,
+ "https://example.com"
+ );
+
+ const BYTES_IN_GIGABYTE = 1073741824;
+ const USAGE_THRESHOLD_BYTES =
+ BYTES_IN_GIGABYTE *
+ Services.prefs.getIntPref(
+ "browser.storageManager.pressureNotification.usageThresholdGB"
+ );
+ await notifyStoragePressure(USAGE_THRESHOLD_BYTES);
+ await TestUtils.waitForCondition(() =>
+ win.gNotificationBox.getNotificationWithValue(
+ "storage-pressure-notification"
+ )
+ );
+ let notification = win.gNotificationBox.getNotificationWithValue(
+ "storage-pressure-notification"
+ );
+ is(
+ notification.localName,
+ "notification-message",
+ "Should display storage pressure notification"
+ );
+ await new Promise(r => setTimeout(r, 1000));
+
+ let prefBtn = notification.buttonContainer.getElementsByTagName("button")[0];
+ ok(prefBtn, "Should have an open preferences button");
+ let aboutPrefPromise = openAboutPrefPromise(win);
+ EventUtils.synthesizeMouseAtCenter(prefBtn, {}, win);
+ await aboutPrefPromise;
+ let aboutPrefTab = win.gBrowser.selectedTab;
+ let prefDoc = win.gBrowser.selectedBrowser.contentDocument;
+ let siteDataGroup = prefDoc.getElementById("siteDataGroup");
+ is_element_visible(
+ siteDataGroup,
+ "Should open to the siteDataGroup section in about:preferences"
+ );
+ BrowserTestUtils.removeTab(aboutPrefTab);
+ BrowserTestUtils.removeTab(tab);
+});
+
+// Test not displaying the 2nd notification if one is already being displayed
+add_task(async function () {
+ const win = Services.wm.getMostRecentWindow("navigator:browser");
+ const TEST_NOTIFICATION_INTERVAL_MS = 0;
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "browser.storageManager.pressureNotification.minIntervalMS",
+ TEST_NOTIFICATION_INTERVAL_MS,
+ ],
+ ],
+ });
+
+ await notifyStoragePressure();
+ await notifyStoragePressure();
+ let allNotifications = win.gNotificationBox.allNotifications;
+ let pressureNotificationCount = 0;
+ allNotifications.forEach(notification => {
+ if (notification.getAttribute("value") == "storage-pressure-notification") {
+ pressureNotificationCount++;
+ }
+ });
+ is(
+ pressureNotificationCount,
+ 1,
+ "Should not display the 2nd notification when there is already one"
+ );
+ win.gNotificationBox.removeAllNotifications();
+});
+
+add_task(async function cleanup() {
+ const win = Services.wm.getMostRecentWindow("navigator:browser");
+ await BrowserTestUtils.closeWindow(win);
+});
diff --git a/browser/base/content/test/general/browser_tabDrop.js b/browser/base/content/test/general/browser_tabDrop.js
new file mode 100644
index 0000000000..eddb405f46
--- /dev/null
+++ b/browser/base/content/test/general/browser_tabDrop.js
@@ -0,0 +1,207 @@
+// TODO (Bug 1680996): Investigate why this test takes a long time.
+requestLongerTimeout(2);
+
+const ANY_URL = undefined;
+
+const { SearchTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/SearchTestUtils.sys.mjs"
+);
+
+SearchTestUtils.init(this);
+
+registerCleanupFunction(async function cleanup() {
+ while (gBrowser.tabs.length > 1) {
+ BrowserTestUtils.removeTab(gBrowser.tabs[gBrowser.tabs.length - 1]);
+ }
+});
+
+add_task(async function test_setup() {
+ // Stop search-engine loads from hitting the network
+ await SearchTestUtils.installSearchExtension(
+ {
+ name: "MozSearch",
+ search_url: "https://example.com/",
+ search_url_get_params: "q={searchTerms}",
+ },
+ { setAsDefault: true }
+ );
+});
+
+add_task(async function single_url() {
+ await dropText("mochi.test/first", ["http://mochi.test/first"]);
+});
+add_task(async function single_javascript() {
+ await dropText("javascript:'bad'", []);
+});
+add_task(async function single_javascript_capital() {
+ await dropText("jAvascript:'bad'", []);
+});
+add_task(async function single_search() {
+ await dropText("search this", [ANY_URL]);
+});
+add_task(async function single_url2() {
+ await dropText("mochi.test/second", ["http://mochi.test/second"]);
+});
+add_task(async function single_data_url() {
+ await dropText("data:text/html,bad", []);
+});
+add_task(async function single_url3() {
+ await dropText("mochi.test/third", ["http://mochi.test/third"]);
+});
+
+// Single text/plain item, with multiple links.
+add_task(async function multiple_urls() {
+ await dropText("mochi.test/1\nmochi.test/2", [
+ "http://mochi.test/1",
+ "http://mochi.test/2",
+ ]);
+});
+add_task(async function multiple_urls_javascript() {
+ await dropText("javascript:'bad1'\nmochi.test/3", []);
+});
+add_task(async function multiple_urls_data() {
+ await dropText("mochi.test/4\ndata:text/html,bad1", []);
+});
+
+// Multiple text/plain items, with single and multiple links.
+add_task(async function multiple_items_single_and_multiple_links() {
+ await drop(
+ [
+ [{ type: "text/plain", data: "mochi.test/5" }],
+ [{ type: "text/plain", data: "mochi.test/6\nmochi.test/7" }],
+ ],
+ ["http://mochi.test/5", "http://mochi.test/6", "http://mochi.test/7"]
+ );
+});
+
+// Single text/x-moz-url item, with multiple links.
+// "text/x-moz-url" has titles in even-numbered lines.
+add_task(async function single_moz_url_multiple_links() {
+ await drop(
+ [
+ [
+ {
+ type: "text/x-moz-url",
+ data: "mochi.test/8\nTITLE8\nmochi.test/9\nTITLE9",
+ },
+ ],
+ ],
+ ["http://mochi.test/8", "http://mochi.test/9"]
+ );
+});
+
+// Single item with multiple types.
+add_task(async function single_item_multiple_types() {
+ await drop(
+ [
+ [
+ { type: "text/plain", data: "mochi.test/10" },
+ { type: "text/x-moz-url", data: "mochi.test/11\nTITLE11" },
+ ],
+ ],
+ ["http://mochi.test/11"]
+ );
+});
+
+// Warn when too many URLs are dropped.
+add_task(async function multiple_tabs_under_max() {
+ let urls = [];
+ for (let i = 0; i < 5; i++) {
+ urls.push("mochi.test/multi" + i);
+ }
+ await dropText(urls.join("\n"), [
+ "http://mochi.test/multi0",
+ "http://mochi.test/multi1",
+ "http://mochi.test/multi2",
+ "http://mochi.test/multi3",
+ "http://mochi.test/multi4",
+ ]);
+});
+add_task(async function multiple_tabs_over_max_accept() {
+ await pushPrefs(["browser.tabs.maxOpenBeforeWarn", 4]);
+
+ let confirmPromise = BrowserTestUtils.promiseAlertDialog("accept");
+
+ let urls = [];
+ for (let i = 0; i < 5; i++) {
+ urls.push("mochi.test/accept" + i);
+ }
+ await dropText(urls.join("\n"), [
+ "http://mochi.test/accept0",
+ "http://mochi.test/accept1",
+ "http://mochi.test/accept2",
+ "http://mochi.test/accept3",
+ "http://mochi.test/accept4",
+ ]);
+
+ await confirmPromise;
+
+ await popPrefs();
+});
+add_task(async function multiple_tabs_over_max_cancel() {
+ await pushPrefs(["browser.tabs.maxOpenBeforeWarn", 4]);
+
+ let confirmPromise = BrowserTestUtils.promiseAlertDialog("cancel");
+
+ let urls = [];
+ for (let i = 0; i < 5; i++) {
+ urls.push("mochi.test/cancel" + i);
+ }
+ await dropText(urls.join("\n"), []);
+
+ await confirmPromise;
+
+ await popPrefs();
+});
+
+function dropText(text, expectedURLs) {
+ return drop([[{ type: "text/plain", data: text }]], expectedURLs);
+}
+
+async function drop(dragData, expectedURLs) {
+ let dragDataString = JSON.stringify(dragData);
+ info(
+ `Starting test for dragData:${dragDataString}; expectedURLs.length:${expectedURLs.length}`
+ );
+ let EventUtils = {};
+ Services.scriptloader.loadSubScript(
+ "chrome://mochikit/content/tests/SimpleTest/EventUtils.js",
+ EventUtils
+ );
+
+ let awaitDrop = BrowserTestUtils.waitForEvent(gBrowser.tabContainer, "drop");
+
+ let loadedPromises = expectedURLs.map(url =>
+ BrowserTestUtils.waitForNewTab(gBrowser, url, false, true)
+ );
+
+ // A drop type of "link" onto an existing tab would normally trigger a
+ // load in that same tab, but tabbrowser code in _getDragTargetTab treats
+ // drops on the outer edges of a tab differently (loading a new tab
+ // instead). Make events created by synthesizeDrop have all of their
+ // coordinates set to 0 (screenX/screenY), so they're treated as drops
+ // on the outer edge of the tab, thus they open new tabs.
+ var event = {
+ clientX: 0,
+ clientY: 0,
+ screenX: 0,
+ screenY: 0,
+ };
+ EventUtils.synthesizeDrop(
+ gBrowser.selectedTab,
+ gBrowser.selectedTab,
+ dragData,
+ "link",
+ window,
+ undefined,
+ event
+ );
+
+ let tabs = await Promise.all(loadedPromises);
+ for (let tab of tabs) {
+ BrowserTestUtils.removeTab(tab);
+ }
+
+ await awaitDrop;
+ ok(true, "Got drop event");
+}
diff --git a/browser/base/content/test/general/browser_tab_close_dependent_window.js b/browser/base/content/test/general/browser_tab_close_dependent_window.js
new file mode 100644
index 0000000000..a9b9c1d999
--- /dev/null
+++ b/browser/base/content/test/general/browser_tab_close_dependent_window.js
@@ -0,0 +1,35 @@
+"use strict";
+
+add_task(async function closing_tab_with_dependents_should_close_window() {
+ info("Opening window");
+ let win = await BrowserTestUtils.openNewBrowserWindow();
+
+ info("Opening tab with data URI");
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ win.gBrowser,
+ `data:text/html,<html%20onclick="W=window.open()"><body%20onbeforeunload="W.close()">`
+ );
+ info("Closing original tab in this window.");
+ BrowserTestUtils.removeTab(win.gBrowser.tabs[0]);
+ info("Clicking into the window");
+ let depTabOpened = BrowserTestUtils.waitForEvent(
+ win.gBrowser.tabContainer,
+ "TabOpen"
+ );
+ await BrowserTestUtils.synthesizeMouse("html", 0, 0, {}, tab.linkedBrowser);
+
+ let openedTab = (await depTabOpened).target;
+ info("Got opened tab");
+
+ let windowClosedPromise = BrowserTestUtils.windowClosed(win);
+ BrowserTestUtils.removeTab(tab);
+ is(
+ Cu.isDeadWrapper(openedTab) || openedTab.linkedBrowser == null,
+ true,
+ "Opened tab should also have closed"
+ );
+ info(
+ "If we timeout now, the window failed to close - that shouldn't happen!"
+ );
+ await windowClosedPromise;
+});
diff --git a/browser/base/content/test/general/browser_tab_detach_restore.js b/browser/base/content/test/general/browser_tab_detach_restore.js
new file mode 100644
index 0000000000..d3f6a58aaa
--- /dev/null
+++ b/browser/base/content/test/general/browser_tab_detach_restore.js
@@ -0,0 +1,54 @@
+"use strict";
+
+const { TabStateFlusher } = ChromeUtils.importESModule(
+ "resource:///modules/sessionstore/TabStateFlusher.sys.mjs"
+);
+
+add_task(async function () {
+ let uri =
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com/browser/browser/base/content/test/general/dummy_page.html";
+
+ // Clear out the closed windows set to start
+ while (SessionStore.getClosedWindowCount() > 0) {
+ SessionStore.forgetClosedWindow(0);
+ }
+
+ let tab = BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.loadURIString(tab.linkedBrowser, uri);
+ await BrowserTestUtils.browserLoaded(tab.linkedBrowser, false, uri);
+ await TabStateFlusher.flush(tab.linkedBrowser);
+
+ let key = tab.linkedBrowser.permanentKey;
+ let win = gBrowser.replaceTabWithWindow(tab);
+ await new Promise(resolve => whenDelayedStartupFinished(win, resolve));
+
+ is(
+ win.gBrowser.selectedBrowser.permanentKey,
+ key,
+ "Should have properly copied the permanentKey"
+ );
+ await BrowserTestUtils.closeWindow(win);
+
+ is(
+ SessionStore.getClosedWindowCount(),
+ 1,
+ "Should have restore data for the closed window"
+ );
+
+ win = SessionStore.undoCloseWindow(0);
+ await BrowserTestUtils.waitForEvent(win, "load");
+ await BrowserTestUtils.waitForEvent(
+ win.gBrowser.tabContainer,
+ "SSTabRestored"
+ );
+
+ is(win.gBrowser.tabs.length, 1, "Should have restored one tab");
+ is(
+ win.gBrowser.selectedBrowser.currentURI.spec,
+ uri,
+ "Should have restored the right page"
+ );
+
+ await promiseWindowClosed(win);
+});
diff --git a/browser/base/content/test/general/browser_tab_drag_drop_perwindow.js b/browser/base/content/test/general/browser_tab_drag_drop_perwindow.js
new file mode 100644
index 0000000000..de4e17b97d
--- /dev/null
+++ b/browser/base/content/test/general/browser_tab_drag_drop_perwindow.js
@@ -0,0 +1,423 @@
+/* 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/. */
+
+requestLongerTimeout(2);
+
+const EVENTUTILS_URL =
+ "chrome://mochikit/content/tests/SimpleTest/EventUtils.js";
+var EventUtils = {};
+
+Services.scriptloader.loadSubScript(EVENTUTILS_URL, EventUtils);
+
+/**
+ * Tests that tabs from Private Browsing windows cannot be dragged
+ * into non-private windows, and vice-versa.
+ */
+add_task(async function test_dragging_private_windows() {
+ let normalWin = await BrowserTestUtils.openNewBrowserWindow();
+ let privateWin = await BrowserTestUtils.openNewBrowserWindow({
+ private: true,
+ });
+
+ let normalTab = await BrowserTestUtils.openNewForegroundTab(
+ normalWin.gBrowser
+ );
+ let privateTab = await BrowserTestUtils.openNewForegroundTab(
+ privateWin.gBrowser
+ );
+
+ let effect = EventUtils.synthesizeDrop(
+ normalTab,
+ privateTab,
+ [[{ type: TAB_DROP_TYPE, data: normalTab }]],
+ null,
+ normalWin,
+ privateWin
+ );
+ is(
+ effect,
+ "none",
+ "Should not be able to drag a normal tab to a private window"
+ );
+
+ effect = EventUtils.synthesizeDrop(
+ privateTab,
+ normalTab,
+ [[{ type: TAB_DROP_TYPE, data: privateTab }]],
+ null,
+ privateWin,
+ normalWin
+ );
+ is(
+ effect,
+ "none",
+ "Should not be able to drag a private tab to a normal window"
+ );
+
+ normalWin.gBrowser.swapBrowsersAndCloseOther(normalTab, privateTab);
+ is(
+ normalWin.gBrowser.tabs.length,
+ 2,
+ "Prevent moving a normal tab to a private tabbrowser"
+ );
+ is(
+ privateWin.gBrowser.tabs.length,
+ 2,
+ "Prevent accepting a normal tab in a private tabbrowser"
+ );
+
+ privateWin.gBrowser.swapBrowsersAndCloseOther(privateTab, normalTab);
+ is(
+ privateWin.gBrowser.tabs.length,
+ 2,
+ "Prevent moving a private tab to a normal tabbrowser"
+ );
+ is(
+ normalWin.gBrowser.tabs.length,
+ 2,
+ "Prevent accepting a private tab in a normal tabbrowser"
+ );
+
+ await BrowserTestUtils.closeWindow(normalWin);
+ await BrowserTestUtils.closeWindow(privateWin);
+});
+
+/**
+ * Tests that tabs from e10s windows cannot be dragged into non-e10s
+ * windows, and vice-versa.
+ */
+add_task(async function test_dragging_e10s_windows() {
+ if (!gMultiProcessBrowser) {
+ return;
+ }
+
+ let remoteWin = await BrowserTestUtils.openNewBrowserWindow({ remote: true });
+ let nonRemoteWin = await BrowserTestUtils.openNewBrowserWindow({
+ remote: false,
+ fission: false,
+ });
+
+ let remoteTab = await BrowserTestUtils.openNewForegroundTab(
+ remoteWin.gBrowser
+ );
+ let nonRemoteTab = await BrowserTestUtils.openNewForegroundTab(
+ nonRemoteWin.gBrowser
+ );
+
+ let effect = EventUtils.synthesizeDrop(
+ remoteTab,
+ nonRemoteTab,
+ [[{ type: TAB_DROP_TYPE, data: remoteTab }]],
+ null,
+ remoteWin,
+ nonRemoteWin
+ );
+ is(
+ effect,
+ "none",
+ "Should not be able to drag a remote tab to a non-e10s window"
+ );
+
+ effect = EventUtils.synthesizeDrop(
+ nonRemoteTab,
+ remoteTab,
+ [[{ type: TAB_DROP_TYPE, data: nonRemoteTab }]],
+ null,
+ nonRemoteWin,
+ remoteWin
+ );
+ is(
+ effect,
+ "none",
+ "Should not be able to drag a non-remote tab to an e10s window"
+ );
+
+ remoteWin.gBrowser.swapBrowsersAndCloseOther(remoteTab, nonRemoteTab);
+ is(
+ remoteWin.gBrowser.tabs.length,
+ 2,
+ "Prevent moving a normal tab to a private tabbrowser"
+ );
+ is(
+ nonRemoteWin.gBrowser.tabs.length,
+ 2,
+ "Prevent accepting a normal tab in a private tabbrowser"
+ );
+
+ nonRemoteWin.gBrowser.swapBrowsersAndCloseOther(nonRemoteTab, remoteTab);
+ is(
+ nonRemoteWin.gBrowser.tabs.length,
+ 2,
+ "Prevent moving a private tab to a normal tabbrowser"
+ );
+ is(
+ remoteWin.gBrowser.tabs.length,
+ 2,
+ "Prevent accepting a private tab in a normal tabbrowser"
+ );
+
+ await BrowserTestUtils.closeWindow(remoteWin);
+ await BrowserTestUtils.closeWindow(nonRemoteWin);
+});
+
+/**
+ * Tests that tabs from fission windows cannot be dragged into non-fission
+ * windows, and vice-versa.
+ */
+add_task(async function test_dragging_fission_windows() {
+ let fissionWin = await BrowserTestUtils.openNewBrowserWindow({
+ remote: true,
+ fission: true,
+ });
+ let nonFissionWin = await BrowserTestUtils.openNewBrowserWindow({
+ remote: true,
+ fission: false,
+ });
+
+ let fissionTab = await BrowserTestUtils.openNewForegroundTab(
+ fissionWin.gBrowser
+ );
+ let nonFissionTab = await BrowserTestUtils.openNewForegroundTab(
+ nonFissionWin.gBrowser
+ );
+
+ let effect = EventUtils.synthesizeDrop(
+ fissionTab,
+ nonFissionTab,
+ [[{ type: TAB_DROP_TYPE, data: fissionTab }]],
+ null,
+ fissionWin,
+ nonFissionWin
+ );
+ is(
+ effect,
+ "none",
+ "Should not be able to drag a fission tab to a non-fission window"
+ );
+
+ effect = EventUtils.synthesizeDrop(
+ nonFissionTab,
+ fissionTab,
+ [[{ type: TAB_DROP_TYPE, data: nonFissionTab }]],
+ null,
+ nonFissionWin,
+ fissionWin
+ );
+ is(
+ effect,
+ "none",
+ "Should not be able to drag a non-fission tab to an fission window"
+ );
+
+ let swapOk = fissionWin.gBrowser.swapBrowsersAndCloseOther(
+ fissionTab,
+ nonFissionTab
+ );
+ is(
+ swapOk,
+ false,
+ "Returns false swapping fission tab to a non-fission tabbrowser"
+ );
+ is(
+ fissionWin.gBrowser.tabs.length,
+ 2,
+ "Prevent moving a fission tab to a non-fission tabbrowser"
+ );
+ is(
+ nonFissionWin.gBrowser.tabs.length,
+ 2,
+ "Prevent accepting a fission tab in a non-fission tabbrowser"
+ );
+
+ swapOk = nonFissionWin.gBrowser.swapBrowsersAndCloseOther(
+ nonFissionTab,
+ fissionTab
+ );
+ is(
+ swapOk,
+ false,
+ "Returns false swapping non-fission tab to a fission tabbrowser"
+ );
+ is(
+ nonFissionWin.gBrowser.tabs.length,
+ 2,
+ "Prevent moving a non-fission tab to a fission tabbrowser"
+ );
+ is(
+ fissionWin.gBrowser.tabs.length,
+ 2,
+ "Prevent accepting a non-fission tab in a fission tabbrowser"
+ );
+
+ await BrowserTestUtils.closeWindow(fissionWin);
+ await BrowserTestUtils.closeWindow(nonFissionWin);
+});
+
+/**
+ * Tests that remoteness-blacklisted tabs from e10s windows can
+ * be dragged between e10s windows.
+ */
+add_task(async function test_dragging_blacklisted() {
+ if (!gMultiProcessBrowser) {
+ return;
+ }
+
+ let remoteWin1 = await BrowserTestUtils.openNewBrowserWindow({
+ remote: true,
+ });
+ remoteWin1.gBrowser.myID = "remoteWin1";
+ let remoteWin2 = await BrowserTestUtils.openNewBrowserWindow({
+ remote: true,
+ });
+ remoteWin2.gBrowser.myID = "remoteWin2";
+
+ // Anything under chrome://mochitests/content/ will be blacklisted, and
+ // open in the parent process.
+ const BLACKLISTED_URL =
+ getRootDirectory(gTestPath) + "browser_tab_drag_drop_perwindow.js";
+ let blacklistedTab = await BrowserTestUtils.openNewForegroundTab(
+ remoteWin1.gBrowser,
+ BLACKLISTED_URL
+ );
+
+ ok(blacklistedTab.linkedBrowser, "Newly created tab should have a browser.");
+
+ ok(
+ !blacklistedTab.linkedBrowser.isRemoteBrowser,
+ `Expected a non-remote browser for URL: ${BLACKLISTED_URL}`
+ );
+
+ let otherTab = await BrowserTestUtils.openNewForegroundTab(
+ remoteWin2.gBrowser
+ );
+
+ let effect = EventUtils.synthesizeDrop(
+ blacklistedTab,
+ otherTab,
+ [[{ type: TAB_DROP_TYPE, data: blacklistedTab }]],
+ null,
+ remoteWin1,
+ remoteWin2
+ );
+ is(effect, "move", "Should be able to drag the blacklisted tab.");
+
+ // The synthesized drop should also do the work of swapping the
+ // browsers, so no need to call swapBrowsersAndCloseOther manually.
+
+ is(
+ remoteWin1.gBrowser.tabs.length,
+ 1,
+ "Should have moved the blacklisted tab out of this window."
+ );
+ is(
+ remoteWin2.gBrowser.tabs.length,
+ 3,
+ "Should have inserted the blacklisted tab into the other window."
+ );
+
+ // The currently selected tab in the second window should be the
+ // one we just dragged in.
+ let draggedBrowser = remoteWin2.gBrowser.selectedBrowser;
+ ok(
+ !draggedBrowser.isRemoteBrowser,
+ "The browser we just dragged in should not be remote."
+ );
+
+ is(
+ draggedBrowser.currentURI.spec,
+ BLACKLISTED_URL,
+ `Expected the URL of the dragged in tab to be ${BLACKLISTED_URL}`
+ );
+
+ await BrowserTestUtils.closeWindow(remoteWin1);
+ await BrowserTestUtils.closeWindow(remoteWin2);
+});
+
+/**
+ * Tests that tabs dragged between windows dispatch TabOpen and TabClose
+ * events with the appropriate adoption details.
+ */
+add_task(async function test_dragging_adoption_events() {
+ let win1 = await BrowserTestUtils.openNewBrowserWindow();
+ let win2 = await BrowserTestUtils.openNewBrowserWindow();
+
+ let tab1 = await BrowserTestUtils.openNewForegroundTab(win1.gBrowser);
+ let tab2 = await BrowserTestUtils.openNewForegroundTab(win2.gBrowser);
+
+ let awaitCloseEvent = BrowserTestUtils.waitForEvent(tab1, "TabClose");
+ let awaitOpenEvent = BrowserTestUtils.waitForEvent(win2, "TabOpen");
+
+ let effect = EventUtils.synthesizeDrop(
+ tab1,
+ tab2,
+ [[{ type: TAB_DROP_TYPE, data: tab1 }]],
+ null,
+ win1,
+ win2
+ );
+ is(effect, "move", "Tab should be moved from win1 to win2.");
+
+ let closeEvent = await awaitCloseEvent;
+ let openEvent = await awaitOpenEvent;
+
+ is(openEvent.detail.adoptedTab, tab1, "New tab adopted old tab");
+ is(
+ closeEvent.detail.adoptedBy,
+ openEvent.target,
+ "Old tab adopted by new tab"
+ );
+
+ await BrowserTestUtils.closeWindow(win1);
+ await BrowserTestUtils.closeWindow(win2);
+});
+
+/**
+ * Tests that per-site zoom settings remain active after a tab is
+ * dragged between windows.
+ */
+add_task(async function test_dragging_zoom_handling() {
+ const ZOOM_FACTOR = 1.62;
+
+ let win1 = await BrowserTestUtils.openNewBrowserWindow();
+ let win2 = await BrowserTestUtils.openNewBrowserWindow();
+
+ let tab1 = await BrowserTestUtils.openNewForegroundTab(win1.gBrowser);
+ let tab2 = await BrowserTestUtils.openNewForegroundTab(
+ win2.gBrowser,
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com/"
+ );
+
+ win2.FullZoom.setZoom(ZOOM_FACTOR);
+ is(
+ ZoomManager.getZoomForBrowser(tab2.linkedBrowser),
+ ZOOM_FACTOR,
+ "Original tab should have correct zoom factor"
+ );
+
+ let effect = EventUtils.synthesizeDrop(
+ tab2,
+ tab1,
+ [[{ type: TAB_DROP_TYPE, data: tab2 }]],
+ null,
+ win2,
+ win1
+ );
+ is(effect, "move", "Tab should be moved from win2 to win1.");
+
+ // Delay slightly to make sure we've finished executing any promise
+ // chains in the zoom code.
+ await new Promise(resolve => setTimeout(resolve, 0));
+
+ is(
+ ZoomManager.getZoomForBrowser(win1.gBrowser.selectedBrowser),
+ ZOOM_FACTOR,
+ "Dragged tab should have correct zoom factor"
+ );
+
+ win1.FullZoom.reset();
+
+ await BrowserTestUtils.closeWindow(win1);
+ await BrowserTestUtils.closeWindow(win2);
+});
diff --git a/browser/base/content/test/general/browser_tab_dragdrop.js b/browser/base/content/test/general/browser_tab_dragdrop.js
new file mode 100644
index 0000000000..9ea05842f2
--- /dev/null
+++ b/browser/base/content/test/general/browser_tab_dragdrop.js
@@ -0,0 +1,257 @@
+// Swaps the content of tab a into tab b and then closes tab a.
+function swapTabsAndCloseOther(a, b) {
+ gBrowser.swapBrowsersAndCloseOther(gBrowser.tabs[b], gBrowser.tabs[a]);
+}
+
+// Mirrors the effect of the above function on an array.
+function swapArrayContentsAndRemoveOther(arr, a, b) {
+ arr[b] = arr[a];
+ arr.splice(a, 1);
+}
+
+function checkBrowserIds(expected) {
+ is(
+ gBrowser.tabs.length,
+ expected.length,
+ "Should have the right number of tabs."
+ );
+
+ for (let [i, tab] of gBrowser.tabs.entries()) {
+ is(
+ tab.linkedBrowser.browserId,
+ expected[i],
+ `Tab ${i} should have the right browser ID.`
+ );
+ is(
+ tab.linkedBrowser.browserId,
+ tab.linkedBrowser.browsingContext.browserId,
+ `Browser for tab ${i} has the same browserId as its BrowsingContext`
+ );
+ }
+}
+
+var getClicks = function (tab) {
+ return SpecialPowers.spawn(tab.linkedBrowser, [], function () {
+ return content.wrappedJSObject.clicks;
+ });
+};
+
+var clickTest = async function (tab) {
+ let clicks = await getClicks(tab);
+
+ await SpecialPowers.spawn(tab.linkedBrowser, [], function () {
+ let target = content.document.body;
+ let rect = target.getBoundingClientRect();
+ let left = (rect.left + rect.right) / 2;
+ let top = (rect.top + rect.bottom) / 2;
+
+ let utils = content.windowUtils;
+ utils.sendMouseEvent("mousedown", left, top, 0, 1, 0, false, 0, 0);
+ utils.sendMouseEvent("mouseup", left, top, 0, 1, 0, false, 0, 0);
+ });
+
+ let newClicks = await getClicks(tab);
+ is(newClicks, clicks + 1, "adding 1 more click on BODY");
+};
+
+function loadURI(tab, url) {
+ BrowserTestUtils.loadURIString(tab.linkedBrowser, url);
+ return BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+}
+
+// Creates a framescript which caches the current object value from the plugin
+// in the page. checkObjectValue below verifies that the framescript is still
+// active for the browser and that the cached value matches that from the plugin
+// in the page which tells us the plugin hasn't been reinitialized.
+async function cacheObjectValue(browser) {
+ await SpecialPowers.spawn(browser, [], () => {
+ let plugin = content.document.getElementById("p").wrappedJSObject;
+ info(`plugin is ${plugin}`);
+ let win = content.document.defaultView;
+ info(`win is ${win}`);
+ win.objectValue = plugin.getObjectValue();
+ info(`got objectValue: ${win.objectValue}`);
+ });
+}
+
+// Note, can't run this via registerCleanupFunction because it needs the
+// browser to still be alive and have a messageManager.
+async function cleanupObjectValue(browser) {
+ info("entered cleanupObjectValue");
+ await SpecialPowers.spawn(browser, [], () => {
+ info("in cleanup function");
+ let win = content.document.defaultView;
+ info(`about to delete objectValue: ${win.objectValue}`);
+ delete win.objectValue;
+ });
+ info("exiting cleanupObjectValue");
+}
+
+// See the notes for cacheObjectValue above.
+async function checkObjectValue(browser) {
+ let data = await SpecialPowers.spawn(browser, [], () => {
+ let plugin = content.document.getElementById("p").wrappedJSObject;
+ let win = content.document.defaultView;
+ let result, exception;
+ try {
+ result = plugin.checkObjectValue(win.objectValue);
+ } catch (e) {
+ exception = e.toString();
+ }
+ return {
+ result,
+ exception,
+ };
+ });
+
+ if (data.result === null) {
+ ok(false, "checkObjectValue threw an exception: " + data.exception);
+ throw new Error(data.exception);
+ } else {
+ return data.result;
+ }
+}
+
+add_task(async function () {
+ // create a few tabs
+ let tabs = [
+ gBrowser.tabs[0],
+ BrowserTestUtils.addTab(gBrowser, "about:blank", { skipAnimation: true }),
+ BrowserTestUtils.addTab(gBrowser, "about:blank", { skipAnimation: true }),
+ BrowserTestUtils.addTab(gBrowser, "about:blank", { skipAnimation: true }),
+ BrowserTestUtils.addTab(gBrowser, "about:blank", { skipAnimation: true }),
+ ];
+
+ // Initially 0 1 2 3 4
+ await loadURI(
+ tabs[1],
+ "data:text/html;charset=utf-8,<title>tab1</title><body>tab1<iframe>"
+ );
+ await loadURI(tabs[2], "data:text/plain;charset=utf-8,tab2");
+ await loadURI(
+ tabs[3],
+ "data:text/html;charset=utf-8,<title>tab3</title><body>tab3<iframe>"
+ );
+ await loadURI(
+ tabs[4],
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com/browser/browser/base/content/test/general/browser_tab_dragdrop_embed.html"
+ );
+ await BrowserTestUtils.switchTab(gBrowser, tabs[3]);
+
+ let browserIds = tabs.map(t => t.linkedBrowser.browserId);
+ checkBrowserIds(browserIds);
+
+ is(gBrowser.tabs[1], tabs[1], "tab1");
+ is(gBrowser.tabs[2], tabs[2], "tab2");
+ is(gBrowser.tabs[3], tabs[3], "tab3");
+ is(gBrowser.tabs[4], tabs[4], "tab4");
+
+ swapTabsAndCloseOther(2, 3); // now: 0 1 2 4
+ // Tab 2 is gone (what was tab 3 is displaying its content).
+ tabs.splice(2, 1);
+ swapArrayContentsAndRemoveOther(browserIds, 2, 3);
+
+ is(gBrowser.tabs[1], tabs[1], "tab1");
+ is(gBrowser.tabs[2], tabs[2], "tab2");
+ is(gBrowser.tabs[3], tabs[3], "tab4");
+
+ checkBrowserIds(browserIds);
+
+ info("about to cacheObjectValue");
+ await cacheObjectValue(tabs[3].linkedBrowser);
+ info("just finished cacheObjectValue");
+
+ swapTabsAndCloseOther(3, 2); // now: 0 1 4
+ tabs.splice(3, 1);
+ swapArrayContentsAndRemoveOther(browserIds, 3, 2);
+
+ is(
+ Array.prototype.indexOf.call(gBrowser.tabs, gBrowser.selectedTab),
+ 2,
+ "The third tab should be selected"
+ );
+
+ checkBrowserIds(browserIds);
+
+ ok(
+ await checkObjectValue(gBrowser.tabs[2].linkedBrowser),
+ "same plugin instance"
+ );
+
+ is(gBrowser.tabs[1], tabs[1], "tab1");
+ is(gBrowser.tabs[2], tabs[2], "tab4");
+
+ let clicks = await getClicks(gBrowser.tabs[2]);
+ is(clicks, 0, "no click on BODY so far");
+ await clickTest(gBrowser.tabs[2]);
+
+ swapTabsAndCloseOther(2, 1); // now: 0 4
+ tabs.splice(2, 1);
+ swapArrayContentsAndRemoveOther(browserIds, 2, 1);
+
+ is(gBrowser.tabs[1], tabs[1], "tab4");
+
+ checkBrowserIds(browserIds);
+
+ ok(
+ await checkObjectValue(gBrowser.tabs[1].linkedBrowser),
+ "same plugin instance"
+ );
+ await cleanupObjectValue(gBrowser.tabs[1].linkedBrowser);
+
+ await clickTest(gBrowser.tabs[1]);
+
+ // Load a new document (about:blank) in tab4, then detach that tab into a new window.
+ // In the new window, navigate back to the original document and click on its <body>,
+ // verify that its onclick was called.
+ is(
+ Array.prototype.indexOf.call(gBrowser.tabs, gBrowser.selectedTab),
+ 1,
+ "The second tab should be selected"
+ );
+ is(
+ gBrowser.tabs[1],
+ tabs[1],
+ "The second tab in gBrowser.tabs should be equal to the second tab in our array"
+ );
+ is(
+ gBrowser.selectedTab,
+ tabs[1],
+ "The second tab in our array is the selected tab"
+ );
+ await loadURI(tabs[1], "about:blank");
+ let key = tabs[1].linkedBrowser.permanentKey;
+
+ checkBrowserIds(browserIds);
+
+ let win = gBrowser.replaceTabWithWindow(tabs[1]);
+ await new Promise(resolve => whenDelayedStartupFinished(win, resolve));
+
+ let newWinBrowserId = browserIds[1];
+ browserIds.splice(1, 1);
+ checkBrowserIds(browserIds);
+
+ // Verify that the original window now only has the initial tab left in it.
+ is(gBrowser.tabs[0], tabs[0], "tab0");
+ is(gBrowser.tabs[0].linkedBrowser.currentURI.spec, "about:blank", "tab0 uri");
+
+ let tab = win.gBrowser.tabs[0];
+ is(tab.linkedBrowser.permanentKey, key, "Should have kept the key");
+ is(tab.linkedBrowser.browserId, newWinBrowserId, "Should have kept the ID");
+ is(
+ tab.linkedBrowser.browserId,
+ tab.linkedBrowser.browsingContext.browserId,
+ "Should have kept the ID"
+ );
+
+ let awaitPageShow = BrowserTestUtils.waitForContentEvent(
+ tab.linkedBrowser,
+ "pageshow"
+ );
+ win.gBrowser.goBack();
+ await awaitPageShow;
+
+ await clickTest(tab);
+ promiseWindowClosed(win);
+});
diff --git a/browser/base/content/test/general/browser_tab_dragdrop2.js b/browser/base/content/test/general/browser_tab_dragdrop2.js
new file mode 100644
index 0000000000..9c589922f5
--- /dev/null
+++ b/browser/base/content/test/general/browser_tab_dragdrop2.js
@@ -0,0 +1,65 @@
+"use strict";
+
+const ROOT = getRootDirectory(gTestPath);
+const URI = ROOT + "browser_tab_dragdrop2_frame1.xhtml";
+
+// Load the test page (which runs some child popup tests) in a new window.
+// After the tests were run, tear off the tab into a new window and run popup
+// tests a second time. We don't care about tests results, exceptions and
+// crashes will be caught.
+add_task(async function () {
+ // Open a new window.
+ let args = "chrome,all,dialog=no";
+ let win = window.openDialog(
+ AppConstants.BROWSER_CHROME_URL,
+ "_blank",
+ args,
+ URI
+ );
+
+ // Wait until the tests were run.
+ await promiseTestsDone(win);
+ ok(true, "tests succeeded");
+
+ // Create a second tab so that we can move the original one out.
+ BrowserTestUtils.addTab(win.gBrowser, "about:blank", { skipAnimation: true });
+
+ // Tear off the original tab.
+ let browser = win.gBrowser.selectedBrowser;
+ let tabClosed = BrowserTestUtils.waitForEvent(browser, "pagehide", true);
+ let win2 = win.gBrowser.replaceTabWithWindow(win.gBrowser.tabs[0]);
+
+ // Add a 'TestsDone' event listener to ensure that the docShells is properly
+ // swapped to the new window instead of the page being loaded again. If this
+ // works fine we should *NOT* see a TestsDone event.
+ let onTestsDone = () => ok(false, "shouldn't run tests when tearing off");
+ win2.addEventListener("TestsDone", onTestsDone);
+
+ // Wait until the original tab is gone and the new window is ready.
+ await Promise.all([tabClosed, promiseDelayedStartupFinished(win2)]);
+
+ // Remove the 'TestsDone' event listener as now
+ // we're kicking off a new test run manually.
+ win2.removeEventListener("TestsDone", onTestsDone);
+
+ // Run tests once again.
+ let promise = promiseTestsDone(win2);
+ let browser2 = win2.gBrowser.selectedBrowser;
+ await SpecialPowers.spawn(browser2, [], async () => {
+ content.test_panels();
+ });
+ await promise;
+ ok(true, "tests succeeded a second time");
+
+ // Cleanup.
+ await promiseWindowClosed(win2);
+ await promiseWindowClosed(win);
+});
+
+function promiseTestsDone(win) {
+ return BrowserTestUtils.waitForEvent(win, "TestsDone");
+}
+
+function promiseDelayedStartupFinished(win) {
+ return new Promise(resolve => whenDelayedStartupFinished(win, resolve));
+}
diff --git a/browser/base/content/test/general/browser_tab_dragdrop2_frame1.xhtml b/browser/base/content/test/general/browser_tab_dragdrop2_frame1.xhtml
new file mode 100644
index 0000000000..d64f37c289
--- /dev/null
+++ b/browser/base/content/test/general/browser_tab_dragdrop2_frame1.xhtml
@@ -0,0 +1,158 @@
+<?xml version="1.0"?>
+<?xml-stylesheet href="chrome://global/skin" type="text/css"?>
+<?xml-stylesheet href="chrome://mochikit/content/tests/SimpleTest/test.css" type="text/css"?>
+<!--
+ XUL Widget Test for panels
+ -->
+<window title="Titlebar" width="200" height="200"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+ <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js" />
+ <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"/>
+
+<tree id="tree" seltype="single" width="100" height="100">
+ <treecols>
+ <treecol flex="1"/>
+ <treecol flex="1"/>
+ </treecols>
+ <treechildren id="treechildren">
+ <treeitem><treerow><treecell label="One"/><treecell label="Two"/></treerow></treeitem>
+ <treeitem><treerow><treecell label="One"/><treecell label="Two"/></treerow></treeitem>
+ <treeitem><treerow><treecell label="One"/><treecell label="Two"/></treerow></treeitem>
+ <treeitem><treerow><treecell label="One"/><treecell label="Two"/></treerow></treeitem>
+ <treeitem><treerow><treecell label="One"/><treecell label="Two"/></treerow></treeitem>
+ <treeitem><treerow><treecell label="One"/><treecell label="Two"/></treerow></treeitem>
+ </treechildren>
+</tree>
+
+
+ <!-- test results are displayed in the html:body -->
+ <body xmlns="http://www.w3.org/1999/xhtml" style="height: 300px; overflow: auto;"/>
+
+ <!-- test code goes here -->
+ <script type="application/javascript"><![CDATA[
+
+SimpleTest.waitForExplicitFinish();
+
+var currentTest = null;
+
+var i, waitSteps;
+var my_debug = false;
+function test_panels()
+{
+ i = waitSteps = 0;
+ checkTreeCoords();
+
+ addEventListener("popupshown", popupShown, false);
+ addEventListener("popuphidden", nextTest, false);
+ return nextTest();
+}
+
+function nextTest()
+{
+ ok(true,"popuphidden " + i)
+ if (i == tests.length) {
+ let details = {bubbles: true, cancelable: false};
+ document.dispatchEvent(new CustomEvent("TestsDone", details));
+ return i;
+ }
+
+ currentTest = tests[i];
+ var panel = createPanel(currentTest.attrs);
+ SimpleTest.waitForFocus(() => currentTest.test(panel));
+ return i;
+}
+
+function popupShown(event)
+{
+ var panel = event.target;
+ if (waitSteps > 0 && navigator.platform.includes("Linux") &&
+ panel.screenY == 210) {
+ waitSteps--;
+ /* eslint-disable mozilla/no-arbitrary-setTimeout */
+ setTimeout(popupShown, 10, event);
+ return;
+ }
+ ++i;
+
+ currentTest.result(currentTest.testname + " ", panel);
+ panel.hidePopup();
+}
+
+function createPanel(attrs)
+{
+ var panel = document.createXULElement("panel");
+ for (var a in attrs) {
+ panel.setAttribute(a, attrs[a]);
+ }
+
+ var button = document.createXULElement("button");
+ panel.appendChild(button);
+ button.label = "OK";
+ button.setAttribute("style", "-moz-appearance: none; border: 0; margin: 0; height: 40px; width: 120px;");
+ panel.setAttribute("style", "-moz-appearance: none; border: 0; margin: 0;");
+ return document.documentElement.appendChild(panel);
+}
+
+function checkTreeCoords()
+{
+ var tree = $("tree");
+ var treechildren = $("treechildren");
+ tree.currentIndex = 0;
+ tree.scrollToRow(0);
+ synthesizeMouse(treechildren, 10, tree.rowHeight + 2, { });
+
+ tree.scrollToRow(2);
+ synthesizeMouse(treechildren, 10, tree.rowHeight + 2, { });
+}
+
+var tests = [
+ {
+ testname: "normal panel",
+ attrs: { },
+ test(panel) {
+ panel.openPopupAtScreen(200, 210);
+ },
+ result(testname, panel) {
+ if (my_debug) alert(testname);
+ panel.getBoundingClientRect();
+ }
+ },
+ {
+ // only noautohide panels support titlebars, so one shouldn't be shown here
+ testname: "autohide panel with titlebar",
+ attrs: { titlebar: "normal" },
+ test(panel) {
+ panel.openPopupAtScreen(200, 210);
+ },
+ result(testname, panel) {
+ if (my_debug) alert(testname);
+ panel.getBoundingClientRect();
+ }
+ },
+ {
+ testname: "noautohide panel with titlebar",
+ attrs: { noautohide: true, titlebar: "normal" },
+ test(panel) {
+ waitSteps = 25;
+ panel.openPopupAtScreen(200, 210);
+ },
+ result(testname, panel) {
+ if (my_debug) alert(testname);
+ panel.getBoundingClientRect();
+
+ synthesizeMouse(panel, 10, 10, { type: "mousemove" });
+
+ var tree = $("tree");
+ tree.currentIndex = 0;
+ panel.appendChild(tree);
+ checkTreeCoords();
+ }
+ }
+];
+
+SimpleTest.waitForFocus(test_panels);
+
+]]>
+</script>
+
+</window>
diff --git a/browser/base/content/test/general/browser_tab_dragdrop_embed.html b/browser/base/content/test/general/browser_tab_dragdrop_embed.html
new file mode 100644
index 0000000000..bad0650693
--- /dev/null
+++ b/browser/base/content/test/general/browser_tab_dragdrop_embed.html
@@ -0,0 +1,2 @@
+<body onload="clicks=0" onclick="++clicks">
+ <embed type="application/x-test" allowscriptaccess="always" allowfullscreen="true" wmode="window" width="640" height="480" id="p"></embed>
diff --git a/browser/base/content/test/general/browser_tabfocus.js b/browser/base/content/test/general/browser_tabfocus.js
new file mode 100644
index 0000000000..b057a504e5
--- /dev/null
+++ b/browser/base/content/test/general/browser_tabfocus.js
@@ -0,0 +1,811 @@
+/*
+ * This test checks that focus is adjusted properly when switching tabs.
+ */
+
+var testPage1 =
+ "<html id='html1'><body id='body1'><button id='button1'>Tab 1</button></body></html>";
+var testPage2 =
+ "<html id='html2'><body id='body2'><button id='button2'>Tab 2</button></body></html>";
+var testPage3 =
+ "<html id='html3'><body id='body3'><button id='button3'>Tab 3</button></body></html>";
+
+const fm = Services.focus;
+
+function EventStore() {
+ this["main-window"] = [];
+ this.window1 = [];
+ this.window2 = [];
+}
+
+EventStore.prototype = {
+ push(event) {
+ if (event.includes("browser1") || event.includes("browser2")) {
+ this["main-window"].push(event);
+ } else if (event.includes("1")) {
+ this.window1.push(event);
+ } else if (event.includes("2")) {
+ this.window2.push(event);
+ } else {
+ this["main-window"].push(event);
+ }
+ },
+};
+
+var tab1 = null;
+var tab2 = null;
+var browser1 = null;
+var browser2 = null;
+var _lastfocus;
+var _lastfocuswindow = null;
+var actualEvents = new EventStore();
+var expectedEvents = new EventStore();
+var currentTestName = "";
+var _expectedElement = null;
+var _expectedWindow = null;
+
+var currentPromiseResolver = null;
+
+function getFocusedElementForBrowser(browser, dontCheckExtraFocus = false) {
+ return SpecialPowers.spawn(
+ browser,
+ [dontCheckExtraFocus],
+ dontCheckExtraFocusChild => {
+ let focusedWindow = {};
+ let node = Services.focus.getFocusedElementForWindow(
+ content,
+ false,
+ focusedWindow
+ );
+ let details = "Focus is " + (node ? node.id : "<none>");
+
+ /* Check focus manager properties. Add an error onto the string if they are
+ not what is expected which will cause matching to fail in the parent process. */
+ let doc = content.document;
+ if (!dontCheckExtraFocusChild) {
+ if (Services.focus.focusedElement != node) {
+ details += "<ERROR: focusedElement doesn't match>";
+ }
+ if (
+ Services.focus.focusedWindow &&
+ Services.focus.focusedWindow != content
+ ) {
+ details += "<ERROR: focusedWindow doesn't match>";
+ }
+ if ((Services.focus.focusedWindow == content) != doc.hasFocus()) {
+ details += "<ERROR: child hasFocus() is not correct>";
+ }
+ if (
+ (Services.focus.focusedElement &&
+ doc.activeElement != Services.focus.focusedElement) ||
+ (!Services.focus.focusedElement && doc.activeElement != doc.body)
+ ) {
+ details += "<ERROR: child activeElement is not correct>";
+ }
+ }
+ return details;
+ }
+ );
+}
+
+function focusInChild(event) {
+ function getWindowDocId(target) {
+ return String(target.location).includes("1") ? "window1" : "window2";
+ }
+
+ // Stop the shim code from seeing this event process.
+ event.stopImmediatePropagation();
+
+ var id;
+ if (event.target instanceof Ci.nsIDOMWindow) {
+ id = getWindowDocId(event.originalTarget) + "-window";
+ } else if (event.target.nodeType == event.target.DOCUMENT_NODE) {
+ id = getWindowDocId(event.originalTarget) + "-document";
+ } else {
+ id = event.originalTarget.id;
+ }
+
+ let window = event.target.ownerGlobal;
+ if (!window._eventsOccurred) {
+ window._eventsOccurred = [];
+ }
+ window._eventsOccurred.push(event.type + ": " + id);
+ return true;
+}
+
+function focusElementInChild(elementid, elementtype) {
+ let browser = elementid.includes("1") ? browser1 : browser2;
+ return SpecialPowers.spawn(browser, [elementid, elementtype], (id, type) => {
+ content.document.getElementById(id)[type]();
+ });
+}
+
+add_task(async function () {
+ tab1 = BrowserTestUtils.addTab(gBrowser);
+ browser1 = gBrowser.getBrowserForTab(tab1);
+
+ tab2 = BrowserTestUtils.addTab(gBrowser);
+ browser2 = gBrowser.getBrowserForTab(tab2);
+
+ await promiseTabLoadEvent(tab1, "data:text/html," + escape(testPage1));
+ await promiseTabLoadEvent(tab2, "data:text/html," + escape(testPage2));
+
+ gURLBar.focus();
+ await SimpleTest.promiseFocus();
+
+ // In these listeners, focusInChild is used to cache details about the event
+ // on a temporary on the window (window._eventsOccurred), so that it can be
+ // retrieved later within compareFocusResults. focusInChild always returns true.
+ // compareFocusResults is called each time event occurs to check that the
+ // right events happened.
+ let listenersToRemove = [];
+ listenersToRemove.push(
+ BrowserTestUtils.addContentEventListener(
+ browser1,
+ "focus",
+ compareFocusResults,
+ { capture: true },
+ focusInChild
+ )
+ );
+ listenersToRemove.push(
+ BrowserTestUtils.addContentEventListener(
+ browser1,
+ "blur",
+ compareFocusResults,
+ { capture: true },
+ focusInChild
+ )
+ );
+ listenersToRemove.push(
+ BrowserTestUtils.addContentEventListener(
+ browser2,
+ "focus",
+ compareFocusResults,
+ { capture: true },
+ focusInChild
+ )
+ );
+ listenersToRemove.push(
+ BrowserTestUtils.addContentEventListener(
+ browser2,
+ "blur",
+ compareFocusResults,
+ { capture: true },
+ focusInChild
+ )
+ );
+
+ // Get the content processes to do something, so that we can better
+ // ensure that the listeners added above will have actually been added
+ // in the tabs.
+ await SpecialPowers.spawn(browser1, [], () => {});
+ await SpecialPowers.spawn(browser2, [], () => {});
+
+ _lastfocus = "urlbar";
+ _lastfocuswindow = "main-window";
+
+ window.addEventListener("focus", _browser_tabfocus_test_eventOccured, true);
+ window.addEventListener("blur", _browser_tabfocus_test_eventOccured, true);
+
+ // make sure that the focus initially starts out blank
+ var focusedWindow = {};
+
+ let focused = await getFocusedElementForBrowser(browser1);
+ is(focused, "Focus is <none>", "initial focus in tab 1");
+
+ focused = await getFocusedElementForBrowser(browser2);
+ is(focused, "Focus is <none>", "initial focus in tab 2");
+
+ is(
+ document.activeElement,
+ gURLBar.inputField,
+ "focus after loading two tabs"
+ );
+
+ await expectFocusShiftAfterTabSwitch(
+ tab2,
+ "window2",
+ null,
+ true,
+ "after tab change, focus in new tab"
+ );
+
+ focused = await getFocusedElementForBrowser(browser2);
+ is(
+ focused,
+ "Focus is <none>",
+ "focusedElement after tab change, focus in new tab"
+ );
+
+ // switching tabs when nothing in the new tab is focused
+ // should focus the browser
+ await expectFocusShiftAfterTabSwitch(
+ tab1,
+ "window1",
+ null,
+ true,
+ "after tab change, focus in original tab"
+ );
+
+ focused = await getFocusedElementForBrowser(browser1);
+ is(
+ focused,
+ "Focus is <none>",
+ "focusedElement after tab change, focus in original tab"
+ );
+
+ // focusing a button in the current tab should focus it
+ await expectFocusShift(
+ () => focusElementInChild("button1", "focus"),
+ "window1",
+ "button1",
+ true,
+ "after button focused"
+ );
+
+ focused = await getFocusedElementForBrowser(browser1);
+ is(
+ focused,
+ "Focus is button1",
+ "focusedElement in first browser after button focused"
+ );
+
+ // focusing a button in a background tab should not change the actual
+ // focus, but should set the focus that would be in that background tab to
+ // that button.
+ await expectFocusShift(
+ () => focusElementInChild("button2", "focus"),
+ "window1",
+ "button1",
+ false,
+ "after button focus in unfocused tab"
+ );
+
+ focused = await getFocusedElementForBrowser(browser1, false);
+ is(
+ focused,
+ "Focus is button1",
+ "focusedElement in first browser after button focus in unfocused tab"
+ );
+ focused = await getFocusedElementForBrowser(browser2, true);
+ is(
+ focused,
+ "Focus is button2",
+ "focusedElement in second browser after button focus in unfocused tab"
+ );
+
+ // switching tabs should now make the button in the other tab focused
+ await expectFocusShiftAfterTabSwitch(
+ tab2,
+ "window2",
+ "button2",
+ true,
+ "after tab change with button focused"
+ );
+
+ // blurring an element in a background tab should not change the active
+ // focus, but should clear the focus in that tab.
+ await expectFocusShift(
+ () => focusElementInChild("button1", "blur"),
+ "window2",
+ "button2",
+ false,
+ "focusedWindow after blur in unfocused tab"
+ );
+
+ focused = await getFocusedElementForBrowser(browser1, true);
+ is(
+ focused,
+ "Focus is <none>",
+ "focusedElement in first browser after focus in unfocused tab"
+ );
+ focused = await getFocusedElementForBrowser(browser2, false);
+ is(
+ focused,
+ "Focus is button2",
+ "focusedElement in second browser after focus in unfocused tab"
+ );
+
+ // When focus is in the tab bar, it should be retained there
+ await expectFocusShift(
+ () => gBrowser.selectedTab.focus(),
+ "main-window",
+ "tab2",
+ true,
+ "focusing tab element"
+ );
+ await expectFocusShiftAfterTabSwitch(
+ tab1,
+ "main-window",
+ "tab1",
+ true,
+ "tab change when selected tab element was focused"
+ );
+
+ let switchWaiter = new Promise((resolve, reject) => {
+ gBrowser.addEventListener(
+ "TabSwitchDone",
+ function () {
+ executeSoon(resolve);
+ },
+ { once: true }
+ );
+ });
+
+ await expectFocusShiftAfterTabSwitch(
+ tab2,
+ "main-window",
+ "tab2",
+ true,
+ "another tab change when selected tab element was focused"
+ );
+
+ // Wait for the paint on the second browser so that any post tab-switching
+ // stuff has time to complete before blurring the tab. Otherwise, the
+ // _adjustFocusAfterTabSwitch in tabbrowser gets confused and isn't sure
+ // what tab is really focused.
+ await switchWaiter;
+
+ await expectFocusShift(
+ () => gBrowser.selectedTab.blur(),
+ "main-window",
+ null,
+ true,
+ "blurring tab element"
+ );
+
+ // focusing the url field should switch active focus away from the browser but
+ // not clear what would be the focus in the browser
+ await focusElementInChild("button1", "focus");
+
+ await expectFocusShift(
+ () => gURLBar.focus(),
+ "main-window",
+ "urlbar",
+ true,
+ "focusedWindow after url field focused"
+ );
+ focused = await getFocusedElementForBrowser(browser1, true);
+ is(
+ focused,
+ "Focus is button1",
+ "focusedElement after url field focused, first browser"
+ );
+ focused = await getFocusedElementForBrowser(browser2, true);
+ is(
+ focused,
+ "Focus is button2",
+ "focusedElement after url field focused, second browser"
+ );
+
+ await expectFocusShift(
+ () => gURLBar.blur(),
+ "main-window",
+ null,
+ true,
+ "blurring url field"
+ );
+
+ // when a chrome element is focused, switching tabs to a tab with a button
+ // with the current focus should focus the button
+ await expectFocusShiftAfterTabSwitch(
+ tab1,
+ "window1",
+ "button1",
+ true,
+ "after tab change, focus in url field, button focused in new tab"
+ );
+
+ focused = await getFocusedElementForBrowser(browser1, false);
+ is(
+ focused,
+ "Focus is button1",
+ "after switch tab, focus in unfocused tab, first browser"
+ );
+ focused = await getFocusedElementForBrowser(browser2, true);
+ is(
+ focused,
+ "Focus is button2",
+ "after switch tab, focus in unfocused tab, second browser"
+ );
+
+ // blurring an element in the current tab should clear the active focus
+ await expectFocusShift(
+ () => focusElementInChild("button1", "blur"),
+ "window1",
+ null,
+ true,
+ "after blur in focused tab"
+ );
+
+ focused = await getFocusedElementForBrowser(browser1, false);
+ is(
+ focused,
+ "Focus is <none>",
+ "focusedWindow after blur in focused tab, child"
+ );
+ focusedWindow = {};
+ is(
+ fm.getFocusedElementForWindow(window, false, focusedWindow),
+ browser1,
+ "focusedElement after blur in focused tab, parent"
+ );
+
+ // blurring an non-focused url field should have no effect
+ await expectFocusShift(
+ () => gURLBar.blur(),
+ "window1",
+ null,
+ false,
+ "after blur in unfocused url field"
+ );
+
+ focusedWindow = {};
+ is(
+ fm.getFocusedElementForWindow(window, false, focusedWindow),
+ browser1,
+ "focusedElement after blur in unfocused url field"
+ );
+
+ // switch focus to a tab with a currently focused element
+ await expectFocusShiftAfterTabSwitch(
+ tab2,
+ "window2",
+ "button2",
+ true,
+ "after switch from unfocused to focused tab"
+ );
+ focused = await getFocusedElementForBrowser(browser2, true);
+ is(
+ focused,
+ "Focus is button2",
+ "focusedElement after switch from unfocused to focused tab"
+ );
+
+ // clearing focus on the chrome window should switch the focus to the
+ // chrome window
+ await expectFocusShift(
+ () => fm.clearFocus(window),
+ "main-window",
+ null,
+ true,
+ "after switch to chrome with no focused element"
+ );
+
+ focusedWindow = {};
+ is(
+ fm.getFocusedElementForWindow(window, false, focusedWindow),
+ null,
+ "focusedElement after switch to chrome with no focused element"
+ );
+
+ // switch focus to another tab when neither have an active focus
+ await expectFocusShiftAfterTabSwitch(
+ tab1,
+ "window1",
+ null,
+ true,
+ "focusedWindow after tab switch from no focus to no focus"
+ );
+
+ focused = await getFocusedElementForBrowser(browser1, false);
+ is(
+ focused,
+ "Focus is <none>",
+ "after tab switch from no focus to no focus, first browser"
+ );
+ focused = await getFocusedElementForBrowser(browser2, true);
+ is(
+ focused,
+ "Focus is button2",
+ "after tab switch from no focus to no focus, second browser"
+ );
+
+ // next, check whether navigating forward, focusing the urlbar and then
+ // navigating back maintains the focus in the urlbar.
+ await expectFocusShift(
+ () => focusElementInChild("button1", "focus"),
+ "window1",
+ "button1",
+ true,
+ "focus button"
+ );
+
+ await promiseTabLoadEvent(tab1, "data:text/html," + escape(testPage3));
+
+ // now go back again
+ gURLBar.focus();
+
+ await new Promise((resolve, reject) => {
+ BrowserTestUtils.waitForContentEvent(
+ window.gBrowser.selectedBrowser,
+ "pageshow",
+ true
+ ).then(() => resolve());
+ document.getElementById("Browser:Back").doCommand();
+ });
+
+ is(
+ window.document.activeElement,
+ gURLBar.inputField,
+ "urlbar still focused after navigating back"
+ );
+
+ for (let listener of listenersToRemove) {
+ listener();
+ }
+
+ window.removeEventListener(
+ "focus",
+ _browser_tabfocus_test_eventOccured,
+ true
+ );
+ window.removeEventListener("blur", _browser_tabfocus_test_eventOccured, true);
+
+ gBrowser.removeCurrentTab();
+ gBrowser.removeCurrentTab();
+
+ finish();
+});
+
+function _browser_tabfocus_test_eventOccured(event) {
+ function getWindowDocId(target) {
+ if (
+ target == browser1.contentWindow ||
+ target == browser1.contentDocument
+ ) {
+ return "window1";
+ }
+ if (
+ target == browser2.contentWindow ||
+ target == browser2.contentDocument
+ ) {
+ return "window2";
+ }
+ return "main-window";
+ }
+
+ var id;
+
+ if (Window.isInstance(event.target)) {
+ id = getWindowDocId(event.originalTarget) + "-window";
+ } else if (Document.isInstance(event.target)) {
+ id = getWindowDocId(event.originalTarget) + "-document";
+ } else if (
+ event.target.id == "urlbar" &&
+ event.originalTarget.localName == "input"
+ ) {
+ id = "urlbar";
+ } else if (event.originalTarget.localName == "browser") {
+ id = event.originalTarget == browser1 ? "browser1" : "browser2";
+ } else if (event.originalTarget.localName == "tab") {
+ id = event.originalTarget == tab1 ? "tab1" : "tab2";
+ } else {
+ id = event.originalTarget.id;
+ }
+
+ actualEvents.push(event.type + ": " + id);
+ compareFocusResults();
+}
+
+function getId(element) {
+ if (!element) {
+ return null;
+ }
+
+ if (element.localName == "browser") {
+ return element == browser1 ? "browser1" : "browser2";
+ }
+
+ if (element.localName == "tab") {
+ return element == tab1 ? "tab1" : "tab2";
+ }
+
+ return element.localName == "input" ? "urlbar" : element.id;
+}
+
+async function compareFocusResults() {
+ if (!currentPromiseResolver) {
+ return;
+ }
+
+ // Get the events that occurred in each child browser and store them
+ // in 'actualEvents'. This is a global so if different calls to
+ // compareFocusResults occur together, whichever one happens to get
+ // called first after pulling all the events from the child will
+ // perform the matching.
+ let events = await SpecialPowers.spawn(browser1, [], () => {
+ let eventsOccurred = content._eventsOccurred;
+ content._eventsOccurred = [];
+ return eventsOccurred || [];
+ });
+ actualEvents.window1.push(...events);
+
+ events = await SpecialPowers.spawn(browser2, [], () => {
+ let eventsOccurred = content._eventsOccurred;
+ content._eventsOccurred = [];
+ return eventsOccurred || [];
+ });
+ actualEvents.window2.push(...events);
+
+ // Another call to compareFocusResults may have happened in the meantime.
+ // If currentPromiseResolver is null, then that call was successful so no
+ // need to check the events again.
+ if (!currentPromiseResolver) {
+ return;
+ }
+
+ let winIds = ["main-window", "window1", "window2"];
+
+ for (let winId of winIds) {
+ if (actualEvents[winId].length < expectedEvents[winId].length) {
+ return;
+ }
+ }
+
+ for (let winId of winIds) {
+ for (let e = 0; e < expectedEvents.length; e++) {
+ is(
+ actualEvents[winId][e],
+ expectedEvents[winId][e],
+ currentTestName + " events [event " + e + "]"
+ );
+ }
+ actualEvents[winId] = [];
+ }
+
+ let matchWindow = window;
+ is(_expectedWindow, "main-window", "main-window is always expected");
+ if (_expectedWindow == "main-window") {
+ // The browser window's body doesn't have an id set usually - set one now
+ // so it can be used for id comparisons below.
+ matchWindow.document.body.id = "main-window-body";
+ }
+
+ var focusedElement = fm.focusedElement;
+ is(
+ getId(focusedElement),
+ _expectedElement,
+ currentTestName + " focusedElement"
+ );
+
+ is(fm.focusedWindow, matchWindow, currentTestName + " focusedWindow");
+ var focusedWindow = {};
+ is(
+ getId(fm.getFocusedElementForWindow(matchWindow, false, focusedWindow)),
+ _expectedElement,
+ currentTestName + " getFocusedElementForWindow"
+ );
+ is(
+ focusedWindow.value,
+ matchWindow,
+ currentTestName + " getFocusedElementForWindow frame"
+ );
+ is(matchWindow.document.hasFocus(), true, currentTestName + " hasFocus");
+ var expectedActive = _expectedElement;
+ if (!expectedActive) {
+ expectedActive = getId(matchWindow.document.body);
+ }
+ is(
+ getId(matchWindow.document.activeElement),
+ expectedActive,
+ currentTestName + " activeElement"
+ );
+
+ currentPromiseResolver();
+ currentPromiseResolver = null;
+}
+
+async function expectFocusShiftAfterTabSwitch(
+ tab,
+ expectedWindow,
+ expectedElement,
+ focusChanged,
+ testid
+) {
+ let tabSwitchPromise = null;
+ await expectFocusShift(
+ () => {
+ tabSwitchPromise = BrowserTestUtils.switchTab(gBrowser, tab);
+ },
+ expectedWindow,
+ expectedElement,
+ focusChanged,
+ testid
+ );
+ await tabSwitchPromise;
+}
+
+async function expectFocusShift(
+ callback,
+ expectedWindow,
+ expectedElement,
+ focusChanged,
+ testid
+) {
+ currentPromiseResolver = null;
+ currentTestName = testid;
+
+ expectedEvents = new EventStore();
+
+ if (focusChanged) {
+ _expectedElement = expectedElement;
+ _expectedWindow = expectedWindow;
+
+ // When the content is in a child process, the expected element in the chrome window
+ // will always be the urlbar or a browser element.
+ if (_expectedWindow == "window1") {
+ _expectedElement = "browser1";
+ } else if (_expectedWindow == "window2") {
+ _expectedElement = "browser2";
+ }
+ _expectedWindow = "main-window";
+
+ if (
+ _lastfocuswindow != "main-window" &&
+ _lastfocuswindow != expectedWindow
+ ) {
+ let browserid = _lastfocuswindow == "window1" ? "browser1" : "browser2";
+ expectedEvents.push("blur: " + browserid);
+ }
+
+ var newElementIsFocused =
+ expectedElement && !expectedElement.startsWith("html");
+ if (
+ newElementIsFocused &&
+ _lastfocuswindow != "main-window" &&
+ expectedWindow == "main-window"
+ ) {
+ // When switching from a child to a chrome element, the focus on the element will arrive first.
+ expectedEvents.push("focus: " + expectedElement);
+ newElementIsFocused = false;
+ }
+
+ if (_lastfocus && _lastfocus != _expectedElement) {
+ expectedEvents.push("blur: " + _lastfocus);
+ }
+
+ if (_lastfocuswindow && _lastfocuswindow != expectedWindow) {
+ if (_lastfocuswindow != "main-window") {
+ expectedEvents.push("blur: " + _lastfocuswindow + "-document");
+ expectedEvents.push("blur: " + _lastfocuswindow + "-window");
+ }
+ }
+
+ if (expectedWindow && _lastfocuswindow != expectedWindow) {
+ if (expectedWindow != "main-window") {
+ let browserid = expectedWindow == "window1" ? "browser1" : "browser2";
+ expectedEvents.push("focus: " + browserid);
+ }
+
+ if (expectedWindow != "main-window") {
+ expectedEvents.push("focus: " + expectedWindow + "-document");
+ expectedEvents.push("focus: " + expectedWindow + "-window");
+ }
+ }
+
+ if (newElementIsFocused) {
+ expectedEvents.push("focus: " + expectedElement);
+ }
+
+ _lastfocus = expectedElement;
+ _lastfocuswindow = expectedWindow;
+ }
+
+ // No events are expected, so return immediately. If events do occur, the following
+ // tests will fail.
+ if (
+ expectedEvents["main-window"].length +
+ expectedEvents.window1.length +
+ expectedEvents.window2.length ==
+ 0
+ ) {
+ await callback();
+ return undefined;
+ }
+
+ return new Promise(resolve => {
+ currentPromiseResolver = resolve;
+ callback();
+ });
+}
diff --git a/browser/base/content/test/general/browser_tabs_close_beforeunload.js b/browser/base/content/test/general/browser_tabs_close_beforeunload.js
new file mode 100644
index 0000000000..0534250970
--- /dev/null
+++ b/browser/base/content/test/general/browser_tabs_close_beforeunload.js
@@ -0,0 +1,69 @@
+"use strict";
+
+SimpleTest.requestCompleteLog();
+
+SpecialPowers.pushPrefEnv({
+ set: [["dom.require_user_interaction_for_beforeunload", false]],
+});
+
+const FIRST_TAB =
+ getRootDirectory(gTestPath) + "close_beforeunload_opens_second_tab.html";
+const SECOND_TAB = getRootDirectory(gTestPath) + "close_beforeunload.html";
+
+add_task(async function () {
+ info("Opening first tab");
+ let firstTab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ FIRST_TAB
+ );
+ let secondTabLoadedPromise;
+ let secondTab;
+ let tabOpened = new Promise(resolve => {
+ info("Adding tabopen listener");
+ gBrowser.tabContainer.addEventListener(
+ "TabOpen",
+ function tabOpenListener(e) {
+ info("Got tabopen, removing listener and waiting for load");
+ gBrowser.tabContainer.removeEventListener(
+ "TabOpen",
+ tabOpenListener,
+ false,
+ false
+ );
+ secondTab = e.target;
+ secondTabLoadedPromise = BrowserTestUtils.browserLoaded(
+ secondTab.linkedBrowser,
+ false,
+ SECOND_TAB
+ );
+ resolve();
+ },
+ false,
+ false
+ );
+ });
+ info("Opening second tab using a click");
+ await SpecialPowers.spawn(firstTab.linkedBrowser, [""], async function () {
+ content.document.getElementsByTagName("a")[0].click();
+ });
+ info("Waiting for the second tab to be opened");
+ await tabOpened;
+ info("Waiting for the load in that tab to finish");
+ await secondTabLoadedPromise;
+
+ let closeBtn = secondTab.closeButton;
+ info("closing second tab (which will self-close in beforeunload)");
+ closeBtn.click();
+ ok(
+ secondTab.closing,
+ "Second tab should be marked as closing synchronously."
+ );
+ ok(!secondTab.linkedBrowser, "Second tab's browser should be dead");
+ ok(!firstTab.closing, "First tab should not be closing");
+ ok(firstTab.linkedBrowser, "First tab's browser should be alive");
+ info("closing first tab");
+ BrowserTestUtils.removeTab(firstTab);
+
+ ok(firstTab.closing, "First tab should be marked as closing");
+ ok(!firstTab.linkedBrowser, "First tab's browser should be dead");
+});
diff --git a/browser/base/content/test/general/browser_tabs_isActive.js b/browser/base/content/test/general/browser_tabs_isActive.js
new file mode 100644
index 0000000000..3d485b01c1
--- /dev/null
+++ b/browser/base/content/test/general/browser_tabs_isActive.js
@@ -0,0 +1,235 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+/* eslint-disable mozilla/no-arbitrary-setTimeout */
+
+// Test for the docshell active state of local and remote browsers.
+
+const kTestPage =
+ "https://example.org/browser/browser/base/content/test/general/dummy_page.html";
+
+function promiseNewTabSwitched() {
+ return new Promise(resolve => {
+ gBrowser.addEventListener(
+ "TabSwitchDone",
+ function () {
+ executeSoon(resolve);
+ },
+ { once: true }
+ );
+ });
+}
+
+function getParentTabState(aTab) {
+ return aTab.linkedBrowser.docShellIsActive;
+}
+
+function getChildTabState(aTab) {
+ return ContentTask.spawn(
+ aTab.linkedBrowser,
+ null,
+ () => content.browsingContext.isActive
+ );
+}
+
+function checkState(parentSide, childSide, value, message) {
+ is(parentSide, value, message + " (parent side)");
+ is(childSide, value, message + " (child side)");
+}
+
+function waitForMs(aMs) {
+ return new Promise(resolve => {
+ setTimeout(done, aMs);
+ function done() {
+ resolve(true);
+ }
+ });
+}
+
+add_task(async function () {
+ let url = kTestPage;
+ let originalTab = gBrowser.selectedTab; // test tab
+ let newTab = BrowserTestUtils.addTab(gBrowser, url, { skipAnimation: true });
+ let parentSide, childSide;
+
+ // new tab added but not selected checks
+ parentSide = getParentTabState(newTab);
+ childSide = await getChildTabState(newTab);
+ checkState(
+ parentSide,
+ childSide,
+ false,
+ "newly added " + url + " tab is not active"
+ );
+ parentSide = getParentTabState(originalTab);
+ childSide = await getChildTabState(originalTab);
+ checkState(parentSide, childSide, true, "original tab is active initially");
+
+ // select the newly added tab and wait for TabSwitchDone event
+ let tabSwitchedPromise = promiseNewTabSwitched();
+ gBrowser.selectedTab = newTab;
+ await tabSwitchedPromise;
+
+ if (Services.appinfo.browserTabsRemoteAutostart) {
+ ok(
+ newTab.linkedBrowser.isRemoteBrowser,
+ "for testing we need a remote tab"
+ );
+ }
+
+ // check active state of both tabs
+ parentSide = getParentTabState(newTab);
+ childSide = await getChildTabState(newTab);
+ checkState(
+ parentSide,
+ childSide,
+ true,
+ "newly added " + url + " tab is active after selection"
+ );
+ parentSide = getParentTabState(originalTab);
+ childSide = await getChildTabState(originalTab);
+ checkState(
+ parentSide,
+ childSide,
+ false,
+ "original tab is not active while unselected"
+ );
+
+ // switch back to the original test tab and wait for TabSwitchDone event
+ tabSwitchedPromise = promiseNewTabSwitched();
+ gBrowser.selectedTab = originalTab;
+ await tabSwitchedPromise;
+
+ // check active state of both tabs
+ parentSide = getParentTabState(newTab);
+ childSide = await getChildTabState(newTab);
+ checkState(
+ parentSide,
+ childSide,
+ false,
+ "newly added " + url + " tab is not active after switch back"
+ );
+ parentSide = getParentTabState(originalTab);
+ childSide = await getChildTabState(originalTab);
+ checkState(
+ parentSide,
+ childSide,
+ true,
+ "original tab is active again after switch back"
+ );
+
+ // switch to the new tab and wait for TabSwitchDone event
+ tabSwitchedPromise = promiseNewTabSwitched();
+ gBrowser.selectedTab = newTab;
+ await tabSwitchedPromise;
+
+ // check active state of both tabs
+ parentSide = getParentTabState(newTab);
+ childSide = await getChildTabState(newTab);
+ checkState(
+ parentSide,
+ childSide,
+ true,
+ "newly added " + url + " tab is not active after switch back"
+ );
+ parentSide = getParentTabState(originalTab);
+ childSide = await getChildTabState(originalTab);
+ checkState(
+ parentSide,
+ childSide,
+ false,
+ "original tab is active again after switch back"
+ );
+
+ gBrowser.removeTab(newTab);
+});
+
+add_task(async function () {
+ let url = "about:about";
+ let originalTab = gBrowser.selectedTab; // test tab
+ let newTab = BrowserTestUtils.addTab(gBrowser, url, { skipAnimation: true });
+ let parentSide, childSide;
+
+ parentSide = getParentTabState(newTab);
+ childSide = await getChildTabState(newTab);
+ checkState(
+ parentSide,
+ childSide,
+ false,
+ "newly added " + url + " tab is not active"
+ );
+ parentSide = getParentTabState(originalTab);
+ childSide = await getChildTabState(originalTab);
+ checkState(parentSide, childSide, true, "original tab is active initially");
+
+ let tabSwitchedPromise = promiseNewTabSwitched();
+ gBrowser.selectedTab = newTab;
+ await tabSwitchedPromise;
+
+ if (Services.appinfo.browserTabsRemoteAutostart) {
+ ok(
+ !newTab.linkedBrowser.isRemoteBrowser,
+ "for testing we need a local tab"
+ );
+ }
+
+ parentSide = getParentTabState(newTab);
+ childSide = await getChildTabState(newTab);
+ checkState(
+ parentSide,
+ childSide,
+ true,
+ "newly added " + url + " tab is active after selection"
+ );
+ parentSide = getParentTabState(originalTab);
+ childSide = await getChildTabState(originalTab);
+ checkState(
+ parentSide,
+ childSide,
+ false,
+ "original tab is not active while unselected"
+ );
+
+ tabSwitchedPromise = promiseNewTabSwitched();
+ gBrowser.selectedTab = originalTab;
+ await tabSwitchedPromise;
+
+ parentSide = getParentTabState(newTab);
+ childSide = await getChildTabState(newTab);
+ checkState(
+ parentSide,
+ childSide,
+ false,
+ "newly added " + url + " tab is not active after switch back"
+ );
+ parentSide = getParentTabState(originalTab);
+ childSide = await getChildTabState(originalTab);
+ checkState(
+ parentSide,
+ childSide,
+ true,
+ "original tab is active again after switch back"
+ );
+
+ tabSwitchedPromise = promiseNewTabSwitched();
+ gBrowser.selectedTab = newTab;
+ await tabSwitchedPromise;
+
+ parentSide = getParentTabState(newTab);
+ childSide = await getChildTabState(newTab);
+ checkState(
+ parentSide,
+ childSide,
+ true,
+ "newly added " + url + " tab is not active after switch back"
+ );
+ parentSide = getParentTabState(originalTab);
+ childSide = await getChildTabState(originalTab);
+ checkState(
+ parentSide,
+ childSide,
+ false,
+ "original tab is active again after switch back"
+ );
+
+ gBrowser.removeTab(newTab);
+});
diff --git a/browser/base/content/test/general/browser_tabs_owner.js b/browser/base/content/test/general/browser_tabs_owner.js
new file mode 100644
index 0000000000..4a32da12f1
--- /dev/null
+++ b/browser/base/content/test/general/browser_tabs_owner.js
@@ -0,0 +1,40 @@
+function test() {
+ BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.addTab(gBrowser);
+ BrowserTestUtils.addTab(gBrowser);
+
+ var owner;
+
+ is(gBrowser.tabs.length, 4, "4 tabs are open");
+
+ owner = gBrowser.selectedTab = gBrowser.tabs[2];
+ BrowserOpenTab();
+ is(gBrowser.selectedTab, gBrowser.tabs[4], "newly opened tab is selected");
+ gBrowser.removeCurrentTab();
+ is(gBrowser.selectedTab, owner, "owner is selected");
+
+ owner = gBrowser.selectedTab;
+ BrowserOpenTab();
+ gBrowser.selectedTab = gBrowser.tabs[1];
+ gBrowser.selectedTab = gBrowser.tabs[4];
+ gBrowser.removeCurrentTab();
+ isnot(
+ gBrowser.selectedTab,
+ owner,
+ "selecting a different tab clears the owner relation"
+ );
+
+ owner = gBrowser.selectedTab;
+ BrowserOpenTab();
+ gBrowser.moveTabTo(gBrowser.selectedTab, 0);
+ gBrowser.removeCurrentTab();
+ is(
+ gBrowser.selectedTab,
+ owner,
+ "owner relationship persists when tab is moved"
+ );
+
+ while (gBrowser.tabs.length > 1) {
+ gBrowser.removeCurrentTab();
+ }
+}
diff --git a/browser/base/content/test/general/browser_testOpenNewRemoteTabsFromNonRemoteBrowsers.js b/browser/base/content/test/general/browser_testOpenNewRemoteTabsFromNonRemoteBrowsers.js
new file mode 100644
index 0000000000..d5144b47b0
--- /dev/null
+++ b/browser/base/content/test/general/browser_testOpenNewRemoteTabsFromNonRemoteBrowsers.js
@@ -0,0 +1,144 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+const OPEN_LOCATION_PREF = "browser.link.open_newwindow";
+const NON_REMOTE_PAGE = "about:welcomeback";
+
+requestLongerTimeout(2);
+
+function insertAndClickAnchor(browser) {
+ return SpecialPowers.spawn(browser, [], () => {
+ content.document.body.innerHTML = `
+ <a href="http://example.com/" target="_blank" rel="opener" id="testAnchor">Open a window</a>
+ `;
+
+ let element = content.document.getElementById("testAnchor");
+ element.click();
+ });
+}
+
+/**
+ * Takes some browser in some window, and forces that browser
+ * to become non-remote, and then navigates it to a page that
+ * we're not supposed to be displaying remotely. Returns a
+ * Promise that resolves when the browser is no longer remote.
+ */
+function prepareNonRemoteBrowser(aWindow, browser) {
+ BrowserTestUtils.loadURIString(browser, NON_REMOTE_PAGE);
+ return BrowserTestUtils.browserLoaded(browser);
+}
+
+registerCleanupFunction(() => {
+ Services.prefs.clearUserPref(OPEN_LOCATION_PREF);
+});
+
+/**
+ * Test that if we open a new tab from a link in a non-remote
+ * browser in an e10s window, that the new tab will load properly.
+ */
+add_task(async function test_new_tab() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["dom.security.skip_html_fragment_assertion", true]],
+ });
+
+ let normalWindow = await BrowserTestUtils.openNewBrowserWindow({
+ remote: true,
+ });
+ let privateWindow = await BrowserTestUtils.openNewBrowserWindow({
+ remote: true,
+ private: true,
+ });
+
+ for (let testWindow of [normalWindow, privateWindow]) {
+ await promiseWaitForFocus(testWindow);
+ let testBrowser = testWindow.gBrowser.selectedBrowser;
+ info("Preparing non-remote browser");
+ await prepareNonRemoteBrowser(testWindow, testBrowser);
+ info("Non-remote browser prepared");
+
+ let tabOpenEventPromise = waitForNewTabEvent(testWindow.gBrowser);
+ await insertAndClickAnchor(testBrowser);
+
+ let newTab = (await tabOpenEventPromise).target;
+ await promiseTabLoadEvent(newTab);
+
+ // insertAndClickAnchor causes an open to a web page which
+ // means that the tab should eventually become remote.
+ ok(
+ newTab.linkedBrowser.isRemoteBrowser,
+ "The opened browser never became remote."
+ );
+
+ testWindow.gBrowser.removeTab(newTab);
+ }
+
+ normalWindow.close();
+ privateWindow.close();
+});
+
+/**
+ * Test that if we open a new window from a link in a non-remote
+ * browser in an e10s window, that the new window is not an e10s
+ * window. Also tests with a private browsing window.
+ */
+add_task(async function test_new_window() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["dom.security.skip_html_fragment_assertion", true]],
+ });
+
+ let normalWindow = await BrowserTestUtils.openNewBrowserWindow(
+ {
+ remote: true,
+ },
+ true
+ );
+ let privateWindow = await BrowserTestUtils.openNewBrowserWindow(
+ {
+ remote: true,
+ private: true,
+ },
+ true
+ );
+
+ // Fiddle with the prefs so that we open target="_blank" links
+ // in new windows instead of new tabs.
+ Services.prefs.setIntPref(
+ OPEN_LOCATION_PREF,
+ Ci.nsIBrowserDOMWindow.OPEN_NEWWINDOW
+ );
+
+ for (let testWindow of [normalWindow, privateWindow]) {
+ await promiseWaitForFocus(testWindow);
+ let testBrowser = testWindow.gBrowser.selectedBrowser;
+ await prepareNonRemoteBrowser(testWindow, testBrowser);
+
+ await insertAndClickAnchor(testBrowser);
+
+ // Click on the link in the browser, and wait for the new window.
+ let [newWindow] = await TestUtils.topicObserved(
+ "browser-delayed-startup-finished"
+ );
+
+ is(
+ PrivateBrowsingUtils.isWindowPrivate(testWindow),
+ PrivateBrowsingUtils.isWindowPrivate(newWindow),
+ "Private browsing state of new window does not match the original!"
+ );
+
+ let newTab = newWindow.gBrowser.selectedTab;
+
+ await promiseTabLoadEvent(newTab);
+
+ // insertAndClickAnchor causes an open to a web page which
+ // means that the tab should eventually become remote.
+ ok(
+ newTab.linkedBrowser.isRemoteBrowser,
+ "The opened browser never became remote."
+ );
+ newWindow.close();
+ }
+
+ normalWindow.close();
+ privateWindow.close();
+});
diff --git a/browser/base/content/test/general/browser_typeAheadFind.js b/browser/base/content/test/general/browser_typeAheadFind.js
new file mode 100644
index 0000000000..d68de34333
--- /dev/null
+++ b/browser/base/content/test/general/browser_typeAheadFind.js
@@ -0,0 +1,31 @@
+/* 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/. */
+
+add_task(async function () {
+ let testWindow = await BrowserTestUtils.openNewBrowserWindow();
+ // The TabContextMenu initializes its strings only on a focus or mouseover event.
+ // Calls focus event on the TabContextMenu early in the test.
+ testWindow.gBrowser.selectedTab.focus();
+
+ BrowserTestUtils.loadURIString(
+ testWindow.gBrowser,
+ "data:text/html,<h1>A Page</h1>"
+ );
+ await BrowserTestUtils.browserLoaded(testWindow.gBrowser.selectedBrowser);
+
+ await SimpleTest.promiseFocus(testWindow.gBrowser.selectedBrowser);
+
+ ok(!testWindow.gFindBarInitialized, "find bar is not initialized");
+
+ let findBarOpenPromise = BrowserTestUtils.waitForEvent(
+ testWindow.gBrowser,
+ "findbaropen"
+ );
+ EventUtils.synthesizeKey("/", {}, testWindow);
+ await findBarOpenPromise;
+
+ ok(testWindow.gFindBarInitialized, "find bar is now initialized");
+
+ await BrowserTestUtils.closeWindow(testWindow);
+});
diff --git a/browser/base/content/test/general/browser_unknownContentType_title.js b/browser/base/content/test/general/browser_unknownContentType_title.js
new file mode 100644
index 0000000000..be55f06fae
--- /dev/null
+++ b/browser/base/content/test/general/browser_unknownContentType_title.js
@@ -0,0 +1,88 @@
+const url =
+ "data:text/html;charset=utf-8,%3C%21DOCTYPE%20html%3E%3Chtml%3E%3Chead%3E%3Ctitle%3ETest%20Page%3C%2Ftitle%3E%3C%2Fhead%3E%3C%2Fhtml%3E";
+const unknown_url =
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com/browser/browser/base/content/test/general/unknownContentType_file.pif";
+
+function waitForNewWindow() {
+ return new Promise(resolve => {
+ let listener = win => {
+ Services.obs.removeObserver(listener, "toplevel-window-ready");
+ win.addEventListener("load", () => {
+ resolve(win);
+ });
+ };
+
+ Services.obs.addObserver(listener, "toplevel-window-ready");
+ });
+}
+
+add_setup(async function () {
+ let tmpDir = PathUtils.join(
+ PathUtils.tempDir,
+ "testsavedir" + Math.floor(Math.random() * 2 ** 32)
+ );
+ // Create this dir if it doesn't exist (ignores existing dirs)
+ await IOUtils.makeDirectory(tmpDir);
+ registerCleanupFunction(async function () {
+ try {
+ await IOUtils.remove(tmpDir, { recursive: true });
+ } catch (e) {
+ console.error(e);
+ }
+ Services.prefs.clearUserPref("browser.download.folderList");
+ Services.prefs.clearUserPref("browser.download.dir");
+ });
+ Services.prefs.setIntPref("browser.download.folderList", 2);
+ Services.prefs.setCharPref("browser.download.dir", tmpDir);
+});
+
+add_task(async function unknownContentType_title_with_pref_enabled() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.download.always_ask_before_handling_new_types", true]],
+ });
+
+ let tab = (gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, url));
+ let browser = tab.linkedBrowser;
+ await promiseTabLoaded(gBrowser.selectedTab);
+
+ is(gBrowser.contentTitle, "Test Page", "Should have the right title.");
+
+ BrowserTestUtils.loadURIString(browser, unknown_url);
+ let win = await waitForNewWindow();
+ is(
+ win.location.href,
+ "chrome://mozapps/content/downloads/unknownContentType.xhtml",
+ "Should have seen the unknown content dialog."
+ );
+ is(gBrowser.contentTitle, "Test Page", "Should still have the right title.");
+
+ win.close();
+ await promiseWaitForFocus(window);
+ gBrowser.removeCurrentTab();
+});
+
+add_task(async function unknownContentType_title_with_pref_disabled() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.download.always_ask_before_handling_new_types", false]],
+ });
+
+ let tab = (gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, url));
+ let browser = tab.linkedBrowser;
+ await promiseTabLoaded(gBrowser.selectedTab);
+
+ is(gBrowser.contentTitle, "Test Page", "Should have the right title.");
+
+ BrowserTestUtils.loadURIString(browser, unknown_url);
+ // If the pref is disabled, then the downloads panel should open right away
+ // since there is no UCT window prompt to block it.
+ let waitForPanelShown = BrowserTestUtils.waitForCondition(() => {
+ return DownloadsPanel.isPanelShowing;
+ }).then(() => "panel-shown");
+
+ let panelShown = await waitForPanelShown;
+ is(panelShown, "panel-shown", "The downloads panel is shown");
+ is(gBrowser.contentTitle, "Test Page", "Should still have the right title.");
+
+ gBrowser.removeCurrentTab();
+});
diff --git a/browser/base/content/test/general/browser_unloaddialogs.js b/browser/base/content/test/general/browser_unloaddialogs.js
new file mode 100644
index 0000000000..7e0b48392b
--- /dev/null
+++ b/browser/base/content/test/general/browser_unloaddialogs.js
@@ -0,0 +1,40 @@
+var testUrls = [
+ "data:text/html,<script>" +
+ "function handle(evt) {" +
+ "evt.target.removeEventListener(evt.type, handle, true);" +
+ "try { alert('This should NOT appear'); } catch(e) { }" +
+ "}" +
+ "window.addEventListener('pagehide', handle, true);" +
+ "window.addEventListener('beforeunload', handle, true);" +
+ "window.addEventListener('unload', handle, true);" +
+ "</script><body>Testing alert during pagehide/beforeunload/unload</body>",
+ "data:text/html,<script>" +
+ "function handle(evt) {" +
+ "evt.target.removeEventListener(evt.type, handle, true);" +
+ "try { prompt('This should NOT appear'); } catch(e) { }" +
+ "}" +
+ "window.addEventListener('pagehide', handle, true);" +
+ "window.addEventListener('beforeunload', handle, true);" +
+ "window.addEventListener('unload', handle, true);" +
+ "</script><body>Testing prompt during pagehide/beforeunload/unload</body>",
+ "data:text/html,<script>" +
+ "function handle(evt) {" +
+ "evt.target.removeEventListener(evt.type, handle, true);" +
+ "try { confirm('This should NOT appear'); } catch(e) { }" +
+ "}" +
+ "window.addEventListener('pagehide', handle, true);" +
+ "window.addEventListener('beforeunload', handle, true);" +
+ "window.addEventListener('unload', handle, true);" +
+ "</script><body>Testing confirm during pagehide/beforeunload/unload</body>",
+];
+
+add_task(async function () {
+ for (let url of testUrls) {
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+ ok(true, "Loaded page " + url);
+ // Wait one turn of the event loop before closing, so everything settles.
+ await new Promise(resolve => setTimeout(resolve, 0));
+ BrowserTestUtils.removeTab(tab);
+ ok(true, "Closed page " + url + " without timeout");
+ }
+});
diff --git a/browser/base/content/test/general/browser_viewSourceInTabOnViewSource.js b/browser/base/content/test/general/browser_viewSourceInTabOnViewSource.js
new file mode 100644
index 0000000000..6c62670e6f
--- /dev/null
+++ b/browser/base/content/test/general/browser_viewSourceInTabOnViewSource.js
@@ -0,0 +1,60 @@
+function wait_while_tab_is_busy() {
+ return new Promise(resolve => {
+ let progressListener = {
+ onStateChange(aWebProgress, aRequest, aStateFlags, aStatus) {
+ if (aStateFlags & Ci.nsIWebProgressListener.STATE_STOP) {
+ gBrowser.removeProgressListener(this);
+ setTimeout(resolve, 0);
+ }
+ },
+ };
+ gBrowser.addProgressListener(progressListener);
+ });
+}
+
+// This function waits for the tab to stop being busy instead of waiting for it
+// to load, since the _elementsForViewSource change happens at that time.
+var with_new_tab_opened = async function (options, taskFn) {
+ let busyPromise = wait_while_tab_is_busy();
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ options.gBrowser,
+ options.url,
+ false
+ );
+ await busyPromise;
+ await taskFn(tab.linkedBrowser);
+ gBrowser.removeTab(tab);
+};
+
+add_task(async function test_regular_page() {
+ function test_expect_view_source_enabled(browser) {
+ for (let element of [...XULBrowserWindow._elementsForViewSource]) {
+ ok(!element.hasAttribute("disabled"), "View Source should be enabled");
+ }
+ }
+
+ await with_new_tab_opened(
+ {
+ gBrowser,
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ url: "http://example.com",
+ },
+ test_expect_view_source_enabled
+ );
+});
+
+add_task(async function test_view_source_page() {
+ function test_expect_view_source_disabled(browser) {
+ for (let element of [...XULBrowserWindow._elementsForViewSource]) {
+ ok(element.hasAttribute("disabled"), "View Source should be disabled");
+ }
+ }
+
+ await with_new_tab_opened(
+ {
+ gBrowser,
+ url: "view-source:http://example.com",
+ },
+ test_expect_view_source_disabled
+ );
+});
diff --git a/browser/base/content/test/general/browser_visibleFindSelection.js b/browser/base/content/test/general/browser_visibleFindSelection.js
new file mode 100644
index 0000000000..56099521e2
--- /dev/null
+++ b/browser/base/content/test/general/browser_visibleFindSelection.js
@@ -0,0 +1,62 @@
+add_task(async function () {
+ const childContent =
+ "<div style='position: absolute; left: 2200px; background: green; width: 200px; height: 200px;'>" +
+ "div</div><div style='position: absolute; left: 0px; background: red; width: 200px; height: 200px;'>" +
+ "<span id='s'>div</span></div>";
+
+ let tab = (gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser));
+
+ await promiseTabLoadEvent(
+ tab,
+ "data:text/html;charset=utf-8," + escape(childContent)
+ );
+ await SimpleTest.promiseFocus(gBrowser.selectedBrowser);
+
+ let remote = gBrowser.selectedBrowser.isRemoteBrowser;
+
+ let findBarOpenPromise = BrowserTestUtils.waitForEvent(
+ gBrowser,
+ "findbaropen"
+ );
+ EventUtils.synthesizeKey("f", { accelKey: true });
+ await findBarOpenPromise;
+
+ ok(gFindBarInitialized, "find bar is now initialized");
+
+ // Finds the div in the green box.
+ let scrollPromise = remote
+ ? BrowserTestUtils.waitForContentEvent(gBrowser.selectedBrowser, "scroll")
+ : BrowserTestUtils.waitForEvent(gBrowser, "scroll");
+ EventUtils.sendString("div");
+ await scrollPromise;
+
+ // Wait for one paint to ensure we've processed the previous key events and scrolling.
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () {
+ return new Promise(resolve => {
+ content.requestAnimationFrame(() => {
+ content.setTimeout(resolve, 0);
+ });
+ });
+ });
+
+ // Finds the div in the red box.
+ scrollPromise = remote
+ ? BrowserTestUtils.waitForContentEvent(gBrowser.selectedBrowser, "scroll")
+ : BrowserTestUtils.waitForEvent(gBrowser, "scroll");
+ EventUtils.synthesizeKey("g", { accelKey: true });
+ await scrollPromise;
+
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () {
+ Assert.ok(
+ content.document.getElementById("s").getBoundingClientRect().left >= 0,
+ "scroll should include find result"
+ );
+ });
+
+ // clear the find bar
+ EventUtils.synthesizeKey("a", { accelKey: true });
+ EventUtils.synthesizeKey("KEY_Delete");
+
+ gFindBar.close();
+ gBrowser.removeCurrentTab();
+});
diff --git a/browser/base/content/test/general/browser_visibleTabs.js b/browser/base/content/test/general/browser_visibleTabs.js
new file mode 100644
index 0000000000..7bf7dc1387
--- /dev/null
+++ b/browser/base/content/test/general/browser_visibleTabs.js
@@ -0,0 +1,125 @@
+/* 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 () {
+ // There should be one tab when we start the test
+ let [origTab] = gBrowser.visibleTabs;
+
+ // Add a tab that will get pinned
+ let pinned = BrowserTestUtils.addTab(gBrowser);
+ gBrowser.pinTab(pinned);
+
+ let testTab = BrowserTestUtils.addTab(gBrowser);
+
+ let firefoxViewTab = BrowserTestUtils.addTab(gBrowser, "about:firefoxview");
+ gBrowser.hideTab(firefoxViewTab);
+
+ let visible = gBrowser.visibleTabs;
+ is(visible.length, 3, "3 tabs should be visible");
+ is(visible[0], pinned, "the pinned tab is first");
+ is(visible[1], origTab, "original tab is next");
+ is(visible[2], testTab, "last created tab is next to last");
+
+ // Only show the test tab (but also get pinned and selected)
+ is(
+ gBrowser.selectedTab,
+ origTab,
+ "sanity check that we're on the original tab"
+ );
+ gBrowser.showOnlyTheseTabs([testTab]);
+ is(gBrowser.visibleTabs.length, 3, "all 3 tabs are still visible");
+
+ // Select the test tab and only show that (and pinned)
+ gBrowser.selectedTab = testTab;
+ gBrowser.showOnlyTheseTabs([testTab]);
+
+ visible = gBrowser.visibleTabs;
+ is(visible.length, 2, "2 tabs should be visible including the pinned");
+ is(visible[0], pinned, "first is pinned");
+ is(visible[1], testTab, "next is the test tab");
+ is(gBrowser.tabs.length, 4, "4 tabs should still be open");
+
+ gBrowser.selectTabAtIndex(1);
+ is(gBrowser.selectedTab, testTab, "second tab is the test tab");
+ gBrowser.selectTabAtIndex(0);
+ is(gBrowser.selectedTab, pinned, "first tab is pinned");
+ gBrowser.selectTabAtIndex(2);
+ is(gBrowser.selectedTab, testTab, "no third tab, so no change");
+ gBrowser.selectTabAtIndex(0);
+ is(gBrowser.selectedTab, pinned, "switch back to the pinned");
+ gBrowser.selectTabAtIndex(2);
+ is(gBrowser.selectedTab, testTab, "no third tab, so select last tab");
+ gBrowser.selectTabAtIndex(-2);
+ is(
+ gBrowser.selectedTab,
+ pinned,
+ "pinned tab is second from left (when orig tab is hidden)"
+ );
+ gBrowser.selectTabAtIndex(-1);
+ is(gBrowser.selectedTab, testTab, "last tab is the test tab");
+
+ gBrowser.tabContainer.advanceSelectedTab(1, true);
+ is(gBrowser.selectedTab, pinned, "wrapped around the end to pinned");
+ gBrowser.tabContainer.advanceSelectedTab(1, true);
+ is(gBrowser.selectedTab, testTab, "next to test tab");
+ gBrowser.tabContainer.advanceSelectedTab(1, true);
+ is(gBrowser.selectedTab, pinned, "next to pinned again");
+
+ gBrowser.tabContainer.advanceSelectedTab(-1, true);
+ is(gBrowser.selectedTab, testTab, "going backwards to last tab");
+ gBrowser.tabContainer.advanceSelectedTab(-1, true);
+ is(gBrowser.selectedTab, pinned, "next to pinned");
+ gBrowser.tabContainer.advanceSelectedTab(-1, true);
+ is(gBrowser.selectedTab, testTab, "next to test tab again");
+
+ // select a hidden tab thats selectable
+ gBrowser.selectedTab = firefoxViewTab;
+ gBrowser.tabContainer.advanceSelectedTab(1, true);
+ is(gBrowser.selectedTab, pinned, "next to first visible tab, the pinned tab");
+ gBrowser.tabContainer.advanceSelectedTab(1, true);
+ is(gBrowser.selectedTab, testTab, "next to second visible tab, the test tab");
+
+ // again select a hidden tab thats selectable
+ gBrowser.selectedTab = firefoxViewTab;
+ gBrowser.tabContainer.advanceSelectedTab(-1, true);
+ is(gBrowser.selectedTab, testTab, "next to last visible tab, the test tab");
+ gBrowser.tabContainer.advanceSelectedTab(-1, true);
+ is(gBrowser.selectedTab, pinned, "next to first visible tab, the pinned tab");
+
+ // Try showing all tabs except for the Firefox View tab
+ gBrowser.showOnlyTheseTabs(Array.from(gBrowser.tabs.slice(0, 3)));
+ is(gBrowser.visibleTabs.length, 3, "all 3 tabs are visible again");
+
+ // Select the pinned tab and show the testTab to make sure selection updates
+ gBrowser.selectedTab = pinned;
+ gBrowser.showOnlyTheseTabs([testTab]);
+ is(gBrowser.tabs[1], origTab, "make sure origTab is in the middle");
+ is(origTab.hidden, true, "make sure it's hidden");
+ gBrowser.removeTab(pinned);
+ is(gBrowser.selectedTab, testTab, "making sure origTab was skipped");
+ is(gBrowser.visibleTabs.length, 1, "only testTab is there");
+
+ // Only show one of the non-pinned tabs (but testTab is selected)
+ gBrowser.showOnlyTheseTabs([origTab]);
+ is(gBrowser.visibleTabs.length, 2, "got 2 tabs");
+
+ // Now really only show one of the tabs
+ gBrowser.showOnlyTheseTabs([testTab]);
+ visible = gBrowser.visibleTabs;
+ is(visible.length, 1, "only the original tab is visible");
+ is(visible[0], testTab, "it's the original tab");
+ is(gBrowser.tabs.length, 3, "still have 3 open tabs");
+
+ // Close the selectable hidden tab
+ gBrowser.removeTab(firefoxViewTab);
+
+ // Close the last visible tab and make sure we still get a visible tab
+ gBrowser.removeTab(testTab);
+ is(gBrowser.visibleTabs.length, 1, "only orig is left and visible");
+ is(gBrowser.tabs.length, 1, "sanity check that it matches");
+ is(gBrowser.selectedTab, origTab, "got the orig tab");
+ is(origTab.hidden, false, "and it's not hidden -- visible!");
+});
diff --git a/browser/base/content/test/general/browser_visibleTabs_bookmarkAllPages.js b/browser/base/content/test/general/browser_visibleTabs_bookmarkAllPages.js
new file mode 100644
index 0000000000..2c0002fc44
--- /dev/null
+++ b/browser/base/content/test/general/browser_visibleTabs_bookmarkAllPages.js
@@ -0,0 +1,35 @@
+/* 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/. */
+
+function test() {
+ waitForExplicitFinish();
+
+ let tabOne = BrowserTestUtils.addTab(gBrowser, "about:blank");
+ let tabTwo = BrowserTestUtils.addTab(gBrowser, "http://mochi.test:8888/");
+ gBrowser.selectedTab = tabTwo;
+
+ let browser = gBrowser.getBrowserForTab(tabTwo);
+ BrowserTestUtils.browserLoaded(browser).then(() => {
+ gBrowser.showOnlyTheseTabs([tabTwo]);
+
+ is(gBrowser.visibleTabs.length, 1, "Only one tab is visible");
+
+ let uris = PlacesCommandHook.uniqueCurrentPages;
+ is(uris.length, 1, "Only one uri is returned");
+
+ is(
+ uris[0].uri.spec,
+ tabTwo.linkedBrowser.currentURI.spec,
+ "It's the correct URI"
+ );
+
+ gBrowser.removeTab(tabOne);
+ gBrowser.removeTab(tabTwo);
+ for (let tab of gBrowser.tabs) {
+ gBrowser.showTab(tab);
+ }
+
+ finish();
+ });
+}
diff --git a/browser/base/content/test/general/browser_visibleTabs_tabPreview.js b/browser/base/content/test/general/browser_visibleTabs_tabPreview.js
new file mode 100644
index 0000000000..ecc7228d65
--- /dev/null
+++ b/browser/base/content/test/general/browser_visibleTabs_tabPreview.js
@@ -0,0 +1,52 @@
+/* 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/. */
+
+add_task(async function test() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.ctrlTab.sortByRecentlyUsed", true]],
+ });
+
+ let [origTab] = gBrowser.visibleTabs;
+ let tabOne = BrowserTestUtils.addTab(gBrowser);
+ let tabTwo = BrowserTestUtils.addTab(gBrowser);
+
+ // test the ctrlTab.tabList
+ pressCtrlTab();
+ ok(ctrlTab.isOpen, "With 3 tab open, Ctrl+Tab opens the preview panel");
+ is(ctrlTab.tabList.length, 3, "Ctrl+Tab panel displays all visible tabs");
+ releaseCtrl();
+
+ gBrowser.showOnlyTheseTabs([origTab]);
+ pressCtrlTab();
+ ok(
+ !ctrlTab.isOpen,
+ "With 1 tab open, Ctrl+Tab doesn't open the preview panel"
+ );
+ releaseCtrl();
+
+ gBrowser.showOnlyTheseTabs([origTab, tabOne, tabTwo]);
+ pressCtrlTab();
+ ok(
+ ctrlTab.isOpen,
+ "Ctrl+Tab opens the preview panel after re-showing hidden tabs"
+ );
+ is(
+ ctrlTab.tabList.length,
+ 3,
+ "Ctrl+Tab panel displays all visible tabs after re-showing hidden ones"
+ );
+ releaseCtrl();
+
+ // cleanup
+ gBrowser.removeTab(tabOne);
+ gBrowser.removeTab(tabTwo);
+});
+
+function pressCtrlTab(aShiftKey) {
+ EventUtils.synthesizeKey("VK_TAB", { ctrlKey: true, shiftKey: !!aShiftKey });
+}
+
+function releaseCtrl() {
+ EventUtils.synthesizeKey("VK_CONTROL", { type: "keyup" });
+}
diff --git a/browser/base/content/test/general/browser_windowactivation.js b/browser/base/content/test/general/browser_windowactivation.js
new file mode 100644
index 0000000000..f5d30d7ac9
--- /dev/null
+++ b/browser/base/content/test/general/browser_windowactivation.js
@@ -0,0 +1,112 @@
+/*
+ * This test checks that window activation state is set properly with multiple tabs.
+ */
+
+const testPageChrome =
+ getRootDirectory(gTestPath) + "file_window_activation.html";
+const testPageHttp = testPageChrome.replace(
+ "chrome://mochitests/content",
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com"
+);
+const testPageWindow =
+ getRootDirectory(gTestPath) + "file_window_activation2.html";
+
+add_task(async function reallyRunTests() {
+ let chromeTab1 = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ testPageChrome
+ );
+ let chromeBrowser1 = chromeTab1.linkedBrowser;
+
+ // This can't use openNewForegroundTab because if we focus chromeTab2 now, we
+ // won't send a focus event during test 6, further down in this file.
+ let chromeTab2 = BrowserTestUtils.addTab(gBrowser, testPageChrome);
+ let chromeBrowser2 = chromeTab2.linkedBrowser;
+ await BrowserTestUtils.browserLoaded(chromeBrowser2);
+
+ let httpTab = BrowserTestUtils.addTab(gBrowser, testPageHttp);
+ let httpBrowser = httpTab.linkedBrowser;
+ await BrowserTestUtils.browserLoaded(httpBrowser);
+
+ function failTest() {
+ ok(false, "Test received unexpected activate/deactivate event");
+ }
+
+ // chrome:// url tabs should not receive "activate" or "deactivate" events
+ // as they should be sent to the top-level window in the parent process.
+ for (let b of [chromeBrowser1, chromeBrowser2]) {
+ BrowserTestUtils.waitForContentEvent(b, "activate", true).then(failTest);
+ BrowserTestUtils.waitForContentEvent(b, "deactivate", true).then(failTest);
+ }
+
+ gURLBar.focus();
+
+ gBrowser.selectedTab = chromeTab1;
+
+ // The test performs four checks, using -moz-window-inactive on three child
+ // tabs (2 loading chrome:// urls and one loading an http:// url).
+ // First, the initial state should be transparent. The second check is done
+ // while another window is focused. The third check is done after that window
+ // is closed and the main window focused again. The fourth check is done after
+ // switching to the second tab.
+
+ // Step 1 - check the initial state
+ let colorChromeBrowser1 = await getBackgroundColor(chromeBrowser1, true);
+ let colorChromeBrowser2 = await getBackgroundColor(chromeBrowser2, true);
+ let colorHttpBrowser = await getBackgroundColor(httpBrowser, true);
+ is(colorChromeBrowser1, "rgba(0, 0, 0, 0)", "first tab initial");
+ is(colorChromeBrowser2, "rgba(0, 0, 0, 0)", "second tab initial");
+ is(colorHttpBrowser, "rgba(0, 0, 0, 0)", "third tab initial");
+
+ // Step 2 - open and focus another window
+ let otherWindow = window.open(testPageWindow, "", "chrome");
+ await SimpleTest.promiseFocus(otherWindow);
+ colorChromeBrowser1 = await getBackgroundColor(chromeBrowser1, false);
+ colorChromeBrowser2 = await getBackgroundColor(chromeBrowser2, false);
+ colorHttpBrowser = await getBackgroundColor(httpBrowser, false);
+ is(colorChromeBrowser1, "rgb(255, 0, 0)", "first tab lowered");
+ is(colorChromeBrowser2, "rgb(255, 0, 0)", "second tab lowered");
+ is(colorHttpBrowser, "rgb(255, 0, 0)", "third tab lowered");
+
+ // Step 3 - close the other window again
+ otherWindow.close();
+ colorChromeBrowser1 = await getBackgroundColor(chromeBrowser1, true);
+ colorChromeBrowser2 = await getBackgroundColor(chromeBrowser2, true);
+ colorHttpBrowser = await getBackgroundColor(httpBrowser, true);
+ is(colorChromeBrowser1, "rgba(0, 0, 0, 0)", "first tab raised");
+ is(colorChromeBrowser2, "rgba(0, 0, 0, 0)", "second tab raised");
+ is(colorHttpBrowser, "rgba(0, 0, 0, 0)", "third tab raised");
+
+ // Step 4 - switch to the second tab
+ gBrowser.selectedTab = chromeTab2;
+ colorChromeBrowser1 = await getBackgroundColor(chromeBrowser1, true);
+ colorChromeBrowser2 = await getBackgroundColor(chromeBrowser2, true);
+ colorHttpBrowser = await getBackgroundColor(httpBrowser, true);
+ is(colorChromeBrowser1, "rgba(0, 0, 0, 0)", "first tab after tab switch");
+ is(colorChromeBrowser2, "rgba(0, 0, 0, 0)", "second tab after tab switch");
+ is(colorHttpBrowser, "rgba(0, 0, 0, 0)", "third tab after tab switch");
+
+ BrowserTestUtils.removeTab(chromeTab1);
+ BrowserTestUtils.removeTab(chromeTab2);
+ BrowserTestUtils.removeTab(httpTab);
+ otherWindow = null;
+});
+
+function getBackgroundColor(browser, expectedActive) {
+ return SpecialPowers.spawn(
+ browser,
+ [!expectedActive],
+ async hasPseudoClass => {
+ let area = content.document.getElementById("area");
+ await ContentTaskUtils.waitForCondition(() => {
+ return area;
+ }, "Page has loaded");
+ await ContentTaskUtils.waitForCondition(() => {
+ return area.matches(":-moz-window-inactive") == hasPseudoClass;
+ }, `Window is considered ${hasPseudoClass ? "inactive" : "active"}`);
+
+ return content.getComputedStyle(area).backgroundColor;
+ }
+ );
+}
diff --git a/browser/base/content/test/general/browser_zbug569342.js b/browser/base/content/test/general/browser_zbug569342.js
new file mode 100644
index 0000000000..4aa6bfbb9c
--- /dev/null
+++ b/browser/base/content/test/general/browser_zbug569342.js
@@ -0,0 +1,77 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+add_task(async function findBarDisabledOnSomePages() {
+ ok(!gFindBar || gFindBar.hidden, "Find bar should not be visible by default");
+
+ let findbarOpenedPromise = BrowserTestUtils.waitForEvent(
+ gBrowser.selectedTab,
+ "TabFindInitialized"
+ );
+ document.documentElement.focus();
+ // Open the Find bar before we navigate to pages that shouldn't have it.
+ EventUtils.synthesizeKey("f", { accelKey: true });
+ await findbarOpenedPromise;
+ ok(!gFindBar.hidden, "Find bar should be visible");
+
+ let urls = ["about:preferences", "about:addons"];
+
+ for (let url of urls) {
+ await testFindDisabled(url);
+ }
+
+ // Make sure the find bar is re-enabled after disabled page is closed.
+ await testFindEnabled("about:about");
+ gFindBar.close();
+ ok(gFindBar.hidden, "Find bar should now be hidden");
+});
+
+function testFindDisabled(url) {
+ return BrowserTestUtils.withNewTab(url, async function (browser) {
+ let waitForFindBar = async () => {
+ await new Promise(r => requestAnimationFrame(r));
+ await new Promise(r => Services.tm.dispatchToMainThread(r));
+ };
+ ok(
+ !gFindBar || gFindBar.hidden,
+ "Find bar should not be visible at the start"
+ );
+ await BrowserTestUtils.synthesizeKey("/", {}, browser);
+ await waitForFindBar();
+ ok(
+ !gFindBar || gFindBar.hidden,
+ "Find bar should not be visible after fast find"
+ );
+ EventUtils.synthesizeKey("f", { accelKey: true });
+ await waitForFindBar();
+ ok(
+ !gFindBar || gFindBar.hidden,
+ "Find bar should not be visible after find command"
+ );
+ ok(
+ document.getElementById("cmd_find").getAttribute("disabled"),
+ "Find command should be disabled"
+ );
+ });
+}
+
+async function testFindEnabled(url) {
+ return BrowserTestUtils.withNewTab(url, async function (browser) {
+ ok(
+ !document.getElementById("cmd_find").getAttribute("disabled"),
+ "Find command should not be disabled"
+ );
+
+ // Open Find bar and then close it.
+ let findbarOpenedPromise = BrowserTestUtils.waitForEvent(
+ gBrowser.selectedTab,
+ "TabFindInitialized"
+ );
+ EventUtils.synthesizeKey("f", { accelKey: true });
+ await findbarOpenedPromise;
+ ok(!gFindBar.hidden, "Find bar should be visible again");
+ EventUtils.synthesizeKey("KEY_Escape");
+ ok(gFindBar.hidden, "Find bar should now be hidden");
+ });
+}
diff --git a/browser/base/content/test/general/bug792517-2.html b/browser/base/content/test/general/bug792517-2.html
new file mode 100644
index 0000000000..bfc24d817f
--- /dev/null
+++ b/browser/base/content/test/general/bug792517-2.html
@@ -0,0 +1,5 @@
+<html>
+<body>
+<a href="bug792517.sjs" id="fff">this is a link</a>
+</body>
+</html>
diff --git a/browser/base/content/test/general/bug792517.html b/browser/base/content/test/general/bug792517.html
new file mode 100644
index 0000000000..e7c040bf1f
--- /dev/null
+++ b/browser/base/content/test/general/bug792517.html
@@ -0,0 +1,5 @@
+<html>
+<body>
+<img src="moz.png" id="img">
+</body>
+</html>
diff --git a/browser/base/content/test/general/bug792517.sjs b/browser/base/content/test/general/bug792517.sjs
new file mode 100644
index 0000000000..c1f2b282fb
--- /dev/null
+++ b/browser/base/content/test/general/bug792517.sjs
@@ -0,0 +1,13 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+function handleRequest(aRequest, aResponse) {
+ aResponse.setStatusLine(aRequest.httpVersion, 200);
+ if (aRequest.hasHeader("Cookie")) {
+ aResponse.write("cookie-present");
+ } else {
+ aResponse.setHeader("Set-Cookie", "foopy=1");
+ aResponse.write("cookie-not-present");
+ }
+}
diff --git a/browser/base/content/test/general/clipboard_pastefile.html b/browser/base/content/test/general/clipboard_pastefile.html
new file mode 100644
index 0000000000..cffafcdb49
--- /dev/null
+++ b/browser/base/content/test/general/clipboard_pastefile.html
@@ -0,0 +1,52 @@
+<html><body>
+<script>
+async function checkPaste(event) {
+ let result = null;
+ try {
+ result = await checkPasteHelper(event);
+ } catch (e) {
+ result = e.toString();
+ }
+
+ document.dispatchEvent(new CustomEvent('testresult', {
+ detail: { result }
+ }));
+}
+
+function is(a, b, msg) {
+ if (!Object.is(a, b)) {
+ throw new Error(`FAIL: expected ${b} got ${a} - ${msg}`);
+ }
+}
+
+async function checkPasteHelper(event) {
+ let dt = event.clipboardData;
+
+ is(dt.types.length, 2, "Correct number of types");
+
+ // TODO: Remove application/x-moz-file from content.
+ is(dt.types[0], "application/x-moz-file", "First type")
+ is(dt.types[1], "Files", "Last type must be Files");
+
+ is(dt.getData("text/plain"), "", "text/plain found with getData");
+ is(dt.getData("application/x-moz-file"), "", "application/x-moz-file found with getData");
+
+ is(dt.files.length, 1, "Correct number of files");
+ is(dt.files[0].name, "test-file.txt", "Correct file name");
+ is(dt.files[0].type, "text/plain", "Correct file type");
+
+ is(dt.items.length, 1, "Correct number of items");
+ is(dt.items[0].kind, "file", "Correct item kind");
+ is(dt.items[0].type, "text/plain", "Correct item type");
+
+ let file = dt.files[0];
+ is(await file.text(), "Hello World!", "Pasted file contains right text");
+
+ return file.name;
+}
+</script>
+
+<input id="input" onpaste="checkPaste(event)">
+
+
+</body></html>
diff --git a/browser/base/content/test/general/close_beforeunload.html b/browser/base/content/test/general/close_beforeunload.html
new file mode 100644
index 0000000000..4b62002cc4
--- /dev/null
+++ b/browser/base/content/test/general/close_beforeunload.html
@@ -0,0 +1,8 @@
+<body>
+ <p>I will close myself if you close me.</p>
+ <script>
+ window.onbeforeunload = function() {
+ window.close();
+ };
+ </script>
+</body>
diff --git a/browser/base/content/test/general/close_beforeunload_opens_second_tab.html b/browser/base/content/test/general/close_beforeunload_opens_second_tab.html
new file mode 100644
index 0000000000..b17df8ee27
--- /dev/null
+++ b/browser/base/content/test/general/close_beforeunload_opens_second_tab.html
@@ -0,0 +1,3 @@
+<body>
+ <a href="#" onclick="window.open('close_beforeunload.html', '_blank')">Open second tab</a>
+</body>
diff --git a/browser/base/content/test/general/download_page.html b/browser/base/content/test/general/download_page.html
new file mode 100644
index 0000000000..300bacdb72
--- /dev/null
+++ b/browser/base/content/test/general/download_page.html
@@ -0,0 +1,72 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=676619
+-->
+ <head>
+ <title>Test for the download attribute</title>
+
+ </head>
+ <body>
+ <a href="https://bugzilla.mozilla.org/show_bug.cgi?id=676619">Bug 676619</a>
+ <br/>
+ <ul>
+ <li><a href="download_page_1.txt"
+ download="test.txt" id="link1">Download "test.txt"</a></li>
+ <li><a href="video.ogg"
+ download id="link2">Download "video.ogg"</a></li>
+ <li><a href="video.ogg"
+ download="just some video.ogg" id="link3">Download "just some video.ogg"</a></li>
+ <li><a href="download_page_2.txt"
+ download="with-target.txt" id="link4">Download "with-target.txt"</a></li>
+ <li><a href="javascript:(1+2)+''"
+ download="javascript.html" id="link5">Download "javascript.html"</a></li>
+ <li><a href="#" download="test.blob" id=link6>Download "test.blob"</a></li>
+ <li><a href="#" download="test.file" id=link7>Download "test.file"</a></li>
+ <li><a href="download_with_content_disposition_header.sjs?inline=download_page_3.txt"
+ download="not_used.txt" id="link8">Download "download_page_3.txt"</a></li>
+ <li><a href="download_with_content_disposition_header.sjs?attachment=download_page_3.txt"
+ download="not_used.txt" id="link9">Download "download_page_3.txt"</a></li>
+ <li><a href="download_with_content_disposition_header.sjs?inline=none"
+ download="download_page_4.txt" id="link10">Download "download_page_4.txt"</a></li>
+ <li><a href="download_with_content_disposition_header.sjs?attachment=none"
+ download="download_page_4.txt" id="link11">Download "download_page_4.txt"</a></li>
+ <li><a href="http://example.com/"
+ download="example.com" id="link12" target="_blank">Download "example.com"</a></li>
+ <li><a href="video.ogg"
+ download="no file extension" id="link13">Download "force extension"</a></li>
+ <li><a href="dummy.ics"
+ download="dummy.not-ics" id="link14">Download "dummy.not-ics"</a></li>
+ <li><a href="redirect_download.sjs?inline=download_page_3.txt"
+ download="not_used.txt" id="link15">Download "download_page_3.txt"</a></li>
+ <li><a href="redirect_download.sjs?attachment=download_page_3.txt"
+ download="not_used.txt" id="link16">Download "download_page_3.txt"</a></li>
+ <li><a href="redirect_download.sjs?inline=none"
+ download="download_page_4.txt" id="link17">Download "download_page_4.txt"</a></li>
+ <li><a href="redirect_download.sjs?attachment=none"
+ download="download_page_4.txt" id="link18">Download "download_page_4.txt"</a></li>
+ <li><a href="download_with_content_disposition_header.sjs?inline;attachment=none"
+ download="download_page_4.txt" id="link19">Download "download_page_4.txt"</a></li>
+ <li><a href="download_with_content_disposition_header.sjs?invalid=none"
+ download="download_page_4.txt" id="link20">Download "download_page_4.txt"</a></li>
+ <li><a href="download_with_content_disposition_header.sjs?inline;attachment=download_page_4.txt"
+ download="download_page_4.txt" id="link21">Download "download_page_4.txt"</a></li>
+ <li><a href="download_with_content_disposition_header.sjs?invalid=download_page_4.txt"
+ download="download_page_4.txt" id="link22">Download "download_page_4.txt"</a></li>
+ </ul>
+ <div id="unload-flag">Okay</div>
+
+ <script>
+ let blobURL = window.URL.createObjectURL(new Blob(["just text"], {type: "application/x-blob"}));
+ document.getElementById("link6").href = blobURL;
+
+ let fileURL = window.URL.createObjectURL(new File(["just text"],
+ "wrong-file-name", {type: "application/x-some-file"}));
+ document.getElementById("link7").href = fileURL;
+
+ window.addEventListener("beforeunload", function(evt) {
+ document.getElementById("unload-flag").textContent = "Fail";
+ });
+ </script>
+ </body>
+</html>
diff --git a/browser/base/content/test/general/download_page_1.txt b/browser/base/content/test/general/download_page_1.txt
new file mode 100644
index 0000000000..404b2da2ad
--- /dev/null
+++ b/browser/base/content/test/general/download_page_1.txt
@@ -0,0 +1 @@
+Hey What are you looking for?
diff --git a/browser/base/content/test/general/download_page_2.txt b/browser/base/content/test/general/download_page_2.txt
new file mode 100644
index 0000000000..9daeafb986
--- /dev/null
+++ b/browser/base/content/test/general/download_page_2.txt
@@ -0,0 +1 @@
+test
diff --git a/browser/base/content/test/general/download_with_content_disposition_header.sjs b/browser/base/content/test/general/download_with_content_disposition_header.sjs
new file mode 100644
index 0000000000..26be6c44b7
--- /dev/null
+++ b/browser/base/content/test/general/download_with_content_disposition_header.sjs
@@ -0,0 +1,19 @@
+/* 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/. */
+
+function handleRequest(request, response) {
+ let page = "download";
+ response.setStatusLine(request.httpVersion, "200", "OK");
+
+ let [first, second] = request.queryString.split("=");
+ let headerStr = first;
+ if (second !== "none") {
+ headerStr += "; filename=" + second;
+ }
+
+ response.setHeader("Content-Disposition", headerStr);
+ response.setHeader("Content-Type", "text/plain", false);
+ response.setHeader("Content-Length", page.length + "", false);
+ response.write(page);
+}
diff --git a/browser/base/content/test/general/dummy.ics b/browser/base/content/test/general/dummy.ics
new file mode 100644
index 0000000000..6100d46fb7
--- /dev/null
+++ b/browser/base/content/test/general/dummy.ics
@@ -0,0 +1,13 @@
+BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//hacksw/handcal//NONSGML v1.0//EN
+BEGIN:VEVENT
+UID:uid1@example.com
+DTSTAMP:19970714T170000Z
+ORGANIZER;CN=John Doe:MAILTO:john.doe@example.com
+DTSTART:19970714T170000Z
+DTEND:19970715T035959Z
+SUMMARY:Bastille Day Party
+GEO:48.85299;2.36885
+END:VEVENT
+END:VCALENDAR \ No newline at end of file
diff --git a/browser/base/content/test/general/dummy.ics^headers^ b/browser/base/content/test/general/dummy.ics^headers^
new file mode 100644
index 0000000000..93e1fca48d
--- /dev/null
+++ b/browser/base/content/test/general/dummy.ics^headers^
@@ -0,0 +1 @@
+Content-Type: text/calendar
diff --git a/browser/base/content/test/general/dummy_page.html b/browser/base/content/test/general/dummy_page.html
new file mode 100644
index 0000000000..1a87e28408
--- /dev/null
+++ b/browser/base/content/test/general/dummy_page.html
@@ -0,0 +1,9 @@
+<html>
+<head>
+<title>Dummy test page</title>
+<meta http-equiv="Content-Type" content="text/html;charset=utf-8"></meta>
+</head>
+<body>
+<p>Dummy test page</p>
+</body>
+</html>
diff --git a/browser/base/content/test/general/file_documentnavigation_frameset.html b/browser/base/content/test/general/file_documentnavigation_frameset.html
new file mode 100644
index 0000000000..beb01addfc
--- /dev/null
+++ b/browser/base/content/test/general/file_documentnavigation_frameset.html
@@ -0,0 +1,12 @@
+<html id="outer">
+
+<frameset rows="30%, 70%">
+ <frame src="data:text/html,&lt;html id='htmlframe1' &gt;&lt;body id='framebody1'&gt;&lt;input id='i1'&gt;&lt;body&gt;&lt;/html&gt;">
+ <frameset cols="30%, 33%, 34%">
+ <frame src="data:text/html,&lt;html id='htmlframe2'&gt;&lt;body id='framebody2'&gt;&lt;input id='i2'&gt;&lt;body&gt;&lt;/html&gt;">
+ <frame src="data:text/html,&lt;html id='htmlframe3'&gt;&lt;body id='framebody3'&gt;&lt;input id='i3'&gt;&lt;body&gt;&lt;/html&gt;">
+ <frame src="data:text/html,&lt;html id='htmlframe4'&gt;&lt;body id='framebody4'&gt;&lt;input id='i4'&gt;&lt;body&gt;&lt;/html&gt;">
+ </frameset>
+</frameset>
+
+</html>
diff --git a/browser/base/content/test/general/file_double_close_tab.html b/browser/base/content/test/general/file_double_close_tab.html
new file mode 100644
index 0000000000..0bead5efc6
--- /dev/null
+++ b/browser/base/content/test/general/file_double_close_tab.html
@@ -0,0 +1,15 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8">
+ <title>Test page that blocks beforeunload. Used in tests for bug 1050638 and bug 305085</title>
+ </head>
+ <body>
+ This page will block beforeunload. It should still be user-closable at all times.
+ <script>
+ window.onbeforeunload = function() {
+ return "stop";
+ };
+ </script>
+ </body>
+</html>
diff --git a/browser/base/content/test/general/file_fullscreen-window-open.html b/browser/base/content/test/general/file_fullscreen-window-open.html
new file mode 100644
index 0000000000..44ac3196a0
--- /dev/null
+++ b/browser/base/content/test/general/file_fullscreen-window-open.html
@@ -0,0 +1,22 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <title>Test for window.open() when browser is in fullscreen</title>
+ </head>
+ <body>
+ <script>
+ window.addEventListener("load", function() {
+ document.getElementById("test").addEventListener("click", onClick, true);
+ }, {capture: true, once: true});
+
+ function onClick(aEvent) {
+ aEvent.preventDefault();
+
+ var dataStr = aEvent.target.getAttribute("data-test-param");
+ var data = JSON.parse(dataStr);
+ window.open(data.uri, data.title, data.option);
+ }
+ </script>
+ <a id="test" href="" data-test-param="">Test</a>
+ </body>
+</html>
diff --git a/browser/base/content/test/general/file_window_activation.html b/browser/base/content/test/general/file_window_activation.html
new file mode 100644
index 0000000000..dda62986d1
--- /dev/null
+++ b/browser/base/content/test/general/file_window_activation.html
@@ -0,0 +1,4 @@
+<body>
+<style>:-moz-window-inactive { background-color: red; }</style>
+<div id='area'></div>
+</body>
diff --git a/browser/base/content/test/general/file_window_activation2.html b/browser/base/content/test/general/file_window_activation2.html
new file mode 100644
index 0000000000..e1b7ecf12f
--- /dev/null
+++ b/browser/base/content/test/general/file_window_activation2.html
@@ -0,0 +1 @@
+<body>Hi</body>
diff --git a/browser/base/content/test/general/file_with_link_to_http.html b/browser/base/content/test/general/file_with_link_to_http.html
new file mode 100644
index 0000000000..4c1a766a3a
--- /dev/null
+++ b/browser/base/content/test/general/file_with_link_to_http.html
@@ -0,0 +1,9 @@
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test page for Bug 1338375</title>
+</head>
+<body>
+ <a id="linkToExample" href="http://example.org" target="_blank">example.org</a>
+</body>
+</html>
diff --git a/browser/base/content/test/general/head.js b/browser/base/content/test/general/head.js
new file mode 100644
index 0000000000..fc3d2be19f
--- /dev/null
+++ b/browser/base/content/test/general/head.js
@@ -0,0 +1,347 @@
+ChromeUtils.defineModuleGetter(
+ this,
+ "AboutNewTab",
+ "resource:///modules/AboutNewTab.jsm"
+);
+ChromeUtils.defineESModuleGetters(this, {
+ PlacesTestUtils: "resource://testing-common/PlacesTestUtils.sys.mjs",
+ PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
+});
+ChromeUtils.defineModuleGetter(
+ this,
+ "TabCrashHandler",
+ "resource:///modules/ContentCrashHandlers.jsm"
+);
+
+/**
+ * Wait for a <notification> to be closed then call the specified callback.
+ */
+function waitForNotificationClose(notification, cb) {
+ let observer = new MutationObserver(function onMutatations(mutations) {
+ for (let mutation of mutations) {
+ for (let i = 0; i < mutation.removedNodes.length; i++) {
+ let node = mutation.removedNodes.item(i);
+ if (node != notification) {
+ continue;
+ }
+ observer.disconnect();
+ cb();
+ }
+ }
+ });
+ observer.observe(notification.control.stack, { childList: true });
+}
+
+function closeAllNotifications() {
+ if (!gNotificationBox.currentNotification) {
+ return Promise.resolve();
+ }
+
+ return new Promise(resolve => {
+ for (let notification of gNotificationBox.allNotifications) {
+ waitForNotificationClose(notification, function () {
+ if (gNotificationBox.allNotifications.length === 0) {
+ resolve();
+ }
+ });
+ notification.close();
+ }
+ });
+}
+
+function whenDelayedStartupFinished(aWindow, aCallback) {
+ Services.obs.addObserver(function observer(aSubject, aTopic) {
+ if (aWindow == aSubject) {
+ Services.obs.removeObserver(observer, aTopic);
+ executeSoon(aCallback);
+ }
+ }, "browser-delayed-startup-finished");
+}
+
+function openToolbarCustomizationUI(aCallback, aBrowserWin) {
+ if (!aBrowserWin) {
+ aBrowserWin = window;
+ }
+
+ aBrowserWin.gCustomizeMode.enter();
+
+ aBrowserWin.gNavToolbox.addEventListener(
+ "customizationready",
+ function () {
+ executeSoon(function () {
+ aCallback(aBrowserWin);
+ });
+ },
+ { once: true }
+ );
+}
+
+function closeToolbarCustomizationUI(aCallback, aBrowserWin) {
+ aBrowserWin.gNavToolbox.addEventListener(
+ "aftercustomization",
+ function () {
+ executeSoon(aCallback);
+ },
+ { once: true }
+ );
+
+ aBrowserWin.gCustomizeMode.exit();
+}
+
+function waitForCondition(condition, nextTest, errorMsg, retryTimes) {
+ retryTimes = typeof retryTimes !== "undefined" ? retryTimes : 30;
+ var tries = 0;
+ var interval = setInterval(function () {
+ if (tries >= retryTimes) {
+ ok(false, errorMsg);
+ moveOn();
+ }
+ var conditionPassed;
+ try {
+ conditionPassed = condition();
+ } catch (e) {
+ ok(false, e + "\n" + e.stack);
+ conditionPassed = false;
+ }
+ if (conditionPassed) {
+ moveOn();
+ }
+ tries++;
+ }, 100);
+ var moveOn = function () {
+ clearInterval(interval);
+ nextTest();
+ };
+}
+
+function promiseWaitForCondition(aConditionFn) {
+ return new Promise(resolve => {
+ waitForCondition(aConditionFn, resolve, "Condition didn't pass.");
+ });
+}
+
+function promiseWaitForEvent(
+ object,
+ eventName,
+ capturing = false,
+ chrome = false
+) {
+ return new Promise(resolve => {
+ function listener(event) {
+ info("Saw " + eventName);
+ object.removeEventListener(eventName, listener, capturing, chrome);
+ resolve(event);
+ }
+
+ info("Waiting for " + eventName);
+ object.addEventListener(eventName, listener, capturing, chrome);
+ });
+}
+
+/**
+ * Allows setting focus on a window, and waiting for that window to achieve
+ * focus.
+ *
+ * @param aWindow
+ * The window to focus and wait for.
+ *
+ * @return {Promise}
+ * @resolves When the window is focused.
+ * @rejects Never.
+ */
+function promiseWaitForFocus(aWindow) {
+ return new Promise(resolve => {
+ waitForFocus(resolve, aWindow);
+ });
+}
+
+function pushPrefs(...aPrefs) {
+ return SpecialPowers.pushPrefEnv({ set: aPrefs });
+}
+
+function popPrefs() {
+ return SpecialPowers.popPrefEnv();
+}
+
+function promiseWindowClosed(win) {
+ let promise = BrowserTestUtils.domWindowClosed(win);
+ win.close();
+ return promise;
+}
+
+function promiseOpenAndLoadWindow(aOptions, aWaitForDelayedStartup = false) {
+ return new Promise(resolve => {
+ let win = OpenBrowserWindow(aOptions);
+ if (aWaitForDelayedStartup) {
+ Services.obs.addObserver(function onDS(aSubject, aTopic, aData) {
+ if (aSubject != win) {
+ return;
+ }
+ Services.obs.removeObserver(onDS, "browser-delayed-startup-finished");
+ resolve(win);
+ }, "browser-delayed-startup-finished");
+ } else {
+ win.addEventListener(
+ "load",
+ function () {
+ resolve(win);
+ },
+ { once: true }
+ );
+ }
+ });
+}
+
+async function whenNewTabLoaded(aWindow, aCallback) {
+ aWindow.BrowserOpenTab();
+
+ let expectedURL = AboutNewTab.newTabURL;
+ let browser = aWindow.gBrowser.selectedBrowser;
+ let loadPromise = BrowserTestUtils.browserLoaded(browser, false, expectedURL);
+ let alreadyLoaded = await SpecialPowers.spawn(browser, [expectedURL], url => {
+ let doc = content.document;
+ return doc && doc.readyState === "complete" && doc.location.href == url;
+ });
+ if (!alreadyLoaded) {
+ await loadPromise;
+ }
+ aCallback();
+}
+
+function whenTabLoaded(aTab, aCallback) {
+ promiseTabLoadEvent(aTab).then(aCallback);
+}
+
+function promiseTabLoaded(aTab) {
+ return new Promise(resolve => {
+ whenTabLoaded(aTab, resolve);
+ });
+}
+
+/**
+ * Waits for a load (or custom) event to finish in a given tab. If provided
+ * load an uri into the tab.
+ *
+ * @param tab
+ * The tab to load into.
+ * @param [optional] url
+ * The url to load, or the current url.
+ * @return {Promise} resolved when the event is handled.
+ * @resolves to the received event
+ * @rejects if a valid load event is not received within a meaningful interval
+ */
+function promiseTabLoadEvent(tab, url) {
+ info("Wait tab event: load");
+
+ function handle(loadedUrl) {
+ if (loadedUrl === "about:blank" || (url && loadedUrl !== url)) {
+ info(`Skipping spurious load event for ${loadedUrl}`);
+ return false;
+ }
+
+ info("Tab event received: load");
+ return true;
+ }
+
+ let loaded = BrowserTestUtils.browserLoaded(tab.linkedBrowser, false, handle);
+
+ if (url) {
+ BrowserTestUtils.loadURIString(tab.linkedBrowser, url);
+ }
+
+ return loaded;
+}
+
+/**
+ * Returns a Promise that resolves once a new tab has been opened in
+ * a xul:tabbrowser.
+ *
+ * @param aTabBrowser
+ * The xul:tabbrowser to monitor for a new tab.
+ * @return {Promise}
+ * Resolved when the new tab has been opened.
+ * @resolves to the TabOpen event that was fired.
+ * @rejects Never.
+ */
+function waitForNewTabEvent(aTabBrowser) {
+ return BrowserTestUtils.waitForEvent(aTabBrowser.tabContainer, "TabOpen");
+}
+
+function is_hidden(element) {
+ var style = element.ownerGlobal.getComputedStyle(element);
+ if (style.display == "none") {
+ return true;
+ }
+ if (style.visibility != "visible") {
+ return true;
+ }
+ if (XULPopupElement.isInstance(element)) {
+ return ["hiding", "closed"].includes(element.state);
+ }
+
+ // Hiding a parent element will hide all its children
+ if (element.parentNode != element.ownerDocument) {
+ return is_hidden(element.parentNode);
+ }
+
+ return false;
+}
+
+function is_element_visible(element, msg) {
+ isnot(element, null, "Element should not be null, when checking visibility");
+ ok(BrowserTestUtils.is_visible(element), msg || "Element should be visible");
+}
+
+function is_element_hidden(element, msg) {
+ isnot(element, null, "Element should not be null, when checking visibility");
+ ok(is_hidden(element), msg || "Element should be hidden");
+}
+
+function promisePopupShown(popup) {
+ return BrowserTestUtils.waitForPopupEvent(popup, "shown");
+}
+
+function promisePopupHidden(popup) {
+ return BrowserTestUtils.waitForPopupEvent(popup, "hidden");
+}
+
+function promiseNotificationShown(notification) {
+ let win = notification.browser.ownerGlobal;
+ if (win.PopupNotifications.panel.state == "open") {
+ return Promise.resolve();
+ }
+ let panelPromise = promisePopupShown(win.PopupNotifications.panel);
+ notification.reshow();
+ return panelPromise;
+}
+
+/**
+ * Resolves when a bookmark with the given uri is added.
+ */
+function promiseOnBookmarkItemAdded(aExpectedURI) {
+ return new Promise((resolve, reject) => {
+ let listener = events => {
+ is(events.length, 1, "Should only receive one event.");
+ info("Added a bookmark to " + events[0].url);
+ PlacesUtils.observers.removeListener(["bookmark-added"], listener);
+ if (events[0].url == aExpectedURI.spec) {
+ resolve();
+ } else {
+ reject(new Error("Added an unexpected bookmark"));
+ }
+ };
+ info("Waiting for a bookmark to be added");
+ PlacesUtils.observers.addListener(["bookmark-added"], listener);
+ });
+}
+
+async function loadBadCertPage(url) {
+ let loaded = BrowserTestUtils.waitForErrorPage(gBrowser.selectedBrowser);
+ BrowserTestUtils.loadURIString(gBrowser.selectedBrowser, url);
+ await loaded;
+
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () {
+ content.document.getElementById("exceptionDialogButton").click();
+ });
+ await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+}
diff --git a/browser/base/content/test/general/moz.png b/browser/base/content/test/general/moz.png
new file mode 100644
index 0000000000..769c636340
--- /dev/null
+++ b/browser/base/content/test/general/moz.png
Binary files differ
diff --git a/browser/base/content/test/general/navigating_window_with_download.html b/browser/base/content/test/general/navigating_window_with_download.html
new file mode 100644
index 0000000000..6b0918941f
--- /dev/null
+++ b/browser/base/content/test/general/navigating_window_with_download.html
@@ -0,0 +1,7 @@
+<!DOCTYPE html>
+<html>
+ <head><title>This window will navigate while you're downloading something</title></head>
+ <body>
+ <iframe src="http://mochi.test:8888/browser/browser/base/content/test/general/unknownContentType_file.pif"></iframe>
+ </body>
+</html>
diff --git a/browser/base/content/test/general/print_postdata.sjs b/browser/base/content/test/general/print_postdata.sjs
new file mode 100644
index 0000000000..0e3ef38419
--- /dev/null
+++ b/browser/base/content/test/general/print_postdata.sjs
@@ -0,0 +1,25 @@
+const CC = Components.Constructor;
+const BinaryInputStream = CC(
+ "@mozilla.org/binaryinputstream;1",
+ "nsIBinaryInputStream",
+ "setInputStream"
+);
+
+function handleRequest(request, response) {
+ response.setHeader("Content-Type", "text/plain", false);
+ if (request.method == "GET") {
+ response.write(request.queryString);
+ } else {
+ var body = new BinaryInputStream(request.bodyInputStream);
+
+ var avail;
+ var bytes = [];
+
+ while ((avail = body.available()) > 0) {
+ Array.prototype.push.apply(bytes, body.readByteArray(avail));
+ }
+
+ var data = String.fromCharCode.apply(null, bytes);
+ response.bodyOutputStream.write(data, data.length);
+ }
+}
diff --git a/browser/base/content/test/general/redirect_download.sjs b/browser/base/content/test/general/redirect_download.sjs
new file mode 100644
index 0000000000..c2857b9338
--- /dev/null
+++ b/browser/base/content/test/general/redirect_download.sjs
@@ -0,0 +1,11 @@
+function handleRequest(request, response) {
+ response.setHeader("Cache-Control", "no-cache", false);
+ let queryStr = request.queryString;
+
+ response.setStatusLine("1.1", 302, "Found");
+ response.setHeader(
+ "Location",
+ `download_with_content_disposition_header.sjs?${queryStr}`,
+ false
+ );
+}
diff --git a/browser/base/content/test/general/refresh_header.sjs b/browser/base/content/test/general/refresh_header.sjs
new file mode 100644
index 0000000000..7d66e0a429
--- /dev/null
+++ b/browser/base/content/test/general/refresh_header.sjs
@@ -0,0 +1,24 @@
+/**
+ * Will cause an auto-refresh to the URL provided in the query string
+ * after some delay using the refresh HTTP header.
+ *
+ * Expects the query string to be in the format:
+ *
+ * ?p=[URL of the page to redirect to]&d=[delay]
+ *
+ * Example:
+ *
+ * ?p=http%3A%2F%2Fexample.org%2Fbrowser%2Fbrowser%2Fbase%2Fcontent%2Ftest%2Fgeneral%2Frefresh_meta.sjs&d=200
+ */
+function handleRequest(request, response) {
+ Cu.importGlobalProperties(["URLSearchParams"]);
+ let query = new URLSearchParams(request.queryString);
+
+ let page = query.get("p");
+ let delay = query.get("d");
+
+ response.setHeader("Content-Type", "text/html", false);
+ response.setStatusLine(request.httpVersion, "200", "Found");
+ response.setHeader("refresh", `${delay}; url=${page}`);
+ response.write("OK");
+}
diff --git a/browser/base/content/test/general/refresh_meta.sjs b/browser/base/content/test/general/refresh_meta.sjs
new file mode 100644
index 0000000000..0f91507c18
--- /dev/null
+++ b/browser/base/content/test/general/refresh_meta.sjs
@@ -0,0 +1,36 @@
+/**
+ * Will cause an auto-refresh to the URL provided in the query string
+ * after some delay using a <meta> tag.
+ *
+ * Expects the query string to be in the format:
+ *
+ * ?p=[URL of the page to redirect to]&d=[delay]
+ *
+ * Example:
+ *
+ * ?p=http%3A%2F%2Fexample.org%2Fbrowser%2Fbrowser%2Fbase%2Fcontent%2Ftest%2Fgeneral%2Frefresh_meta.sjs&d=200
+ */
+function handleRequest(request, response) {
+ Cu.importGlobalProperties(["URLSearchParams"]);
+ let query = new URLSearchParams(request.queryString);
+
+ let page = query.get("p");
+ let delay = query.get("d");
+
+ let html = `<!DOCTYPE HTML>
+ <html>
+ <head>
+ <meta charset='utf-8'>
+ <META http-equiv='refresh' content='${delay}; url=${page}'>
+ <title>Gonna refresh you, folks.</title>
+ </head>
+ <body>
+ <h1>Wait for it...</h1>
+ </body>
+ </html>`;
+
+ response.setHeader("Content-Type", "text/html", false);
+ response.setStatusLine(request.httpVersion, "200", "Found");
+ response.setHeader("Cache-Control", "no-cache", false);
+ response.write(html);
+}
diff --git a/browser/base/content/test/general/test_bug462673.html b/browser/base/content/test/general/test_bug462673.html
new file mode 100644
index 0000000000..d864990e4f
--- /dev/null
+++ b/browser/base/content/test/general/test_bug462673.html
@@ -0,0 +1,18 @@
+<html>
+<head>
+<script>
+var w;
+function openIt() {
+ w = window.open("", "window2");
+}
+function closeIt() {
+ if (w) {
+ w.close();
+ w = null;
+ }
+}
+</script>
+</head>
+<body onload="openIt();" onunload="closeIt();">
+</body>
+</html>
diff --git a/browser/base/content/test/general/test_bug628179.html b/browser/base/content/test/general/test_bug628179.html
new file mode 100644
index 0000000000..1136048d36
--- /dev/null
+++ b/browser/base/content/test/general/test_bug628179.html
@@ -0,0 +1,9 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <title>Test for closing the Find bar in subdocuments</title>
+ </head>
+ <body>
+ <iframe id=iframe src="http://example.com/" width=320 height=240></iframe>
+ </body>
+</html>
diff --git a/browser/base/content/test/general/test_remoteTroubleshoot.html b/browser/base/content/test/general/test_remoteTroubleshoot.html
new file mode 100644
index 0000000000..c0c3f5e604
--- /dev/null
+++ b/browser/base/content/test/general/test_remoteTroubleshoot.html
@@ -0,0 +1,50 @@
+<!DOCTYPE HTML>
+<html>
+<script>
+// This test is run multiple times, once with only strings allowed through the
+// WebChannel, and once with objects allowed. This function allows us to handle
+// both cases without too much pain.
+function makeDetails(object) {
+ if (window.location.search.includes("object")) {
+ return object;
+ }
+ return JSON.stringify(object);
+}
+// Add a listener for responses to our remote requests.
+window.addEventListener("WebChannelMessageToContent", function(event) {
+ if (event.detail.id == "remote-troubleshooting") {
+ // Send what we got back to the test.
+ var backEvent = new window.CustomEvent("WebChannelMessageToChrome", {
+ detail: makeDetails({
+ id: "test-remote-troubleshooting-backchannel",
+ message: {
+ message: event.detail.message,
+ },
+ }),
+ });
+ window.dispatchEvent(backEvent);
+ // and stick it in our DOM just for good measure/diagnostics.
+ document.getElementById("troubleshooting").textContent =
+ JSON.stringify(event.detail.message, null, 2);
+ }
+});
+
+// Make a request for the troubleshooting data as we load.
+window.onload = function() {
+ var event = new window.CustomEvent("WebChannelMessageToChrome", {
+ detail: makeDetails({
+ id: "remote-troubleshooting",
+ message: {
+ command: "request",
+ },
+ }),
+ });
+ window.dispatchEvent(event);
+};
+</script>
+
+<body>
+ <pre id="troubleshooting"/>
+</body>
+
+</html>
diff --git a/browser/base/content/test/general/title_test.svg b/browser/base/content/test/general/title_test.svg
new file mode 100644
index 0000000000..80390a3cca
--- /dev/null
+++ b/browser/base/content/test/general/title_test.svg
@@ -0,0 +1,59 @@
+<svg width="640px" height="480px" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
+ <title>This is a root SVG element's title</title>
+ <foreignObject>
+ <html xmlns="http://www.w3.org/1999/xhtml">
+ <body>
+ <svg xmlns="http://www.w3.org/2000/svg" id="svg1">
+ <title>This is a non-root SVG element title</title>
+ </svg>
+ </body>
+ </html>
+ </foreignObject>
+ <text id="text1" x="10px" y="32px" font-size="24px">
+ This contains only &lt;title&gt;
+ <title>
+
+
+ This is a title
+
+ </title>
+ </text>
+ <text id="text2" x="10px" y="96px" font-size="24px">
+ This contains only &lt;desc&gt;
+ <desc>This is a desc</desc>
+ </text>
+ <text id="text3" x="10px" y="128px" font-size="24px" title="ignored for SVG">
+ This contains nothing.
+ </text>
+ <a id="link1" href="#">
+ This link contains &lt;title&gt;
+ <title>
+ This is a title
+ </title>
+ <text id="text4" x="10px" y="192px" font-size="24px">
+ </text>
+ </a>
+ <a id="link2" href="#">
+ <text x="10px" y="192px" font-size="24px">
+ This text contains &lt;title&gt;
+ <title>
+ This is a title
+ </title>
+ </text>
+ </a>
+ <a id="link3" href="#" xlink:title="This is an xlink:title attribute">
+ <text x="10px" y="224px" font-size="24px">
+ This link contains &lt;title&gt; &amp; xlink:title attr.
+ <title>This is a title</title>
+ </text>
+ </a>
+ <a id="link4" href="#" xlink:title="This is an xlink:title attribute">
+ <text x="10px" y="256px" font-size="24px">
+ This link contains xlink:title attr.
+ </text>
+ </a>
+ <text id="text5" x="10px" y="160px" font-size="24px"
+ xlink:title="This is an xlink:title attribute but it isn't on a link" >
+ This contains nothing.
+ </text>
+</svg>
diff --git a/browser/base/content/test/general/unknownContentType_file.pif b/browser/base/content/test/general/unknownContentType_file.pif
new file mode 100644
index 0000000000..9353d13126
--- /dev/null
+++ b/browser/base/content/test/general/unknownContentType_file.pif
@@ -0,0 +1 @@
+Dummy content for unknownContentType_dialog_layout_data.pif
diff --git a/browser/base/content/test/general/unknownContentType_file.pif^headers^ b/browser/base/content/test/general/unknownContentType_file.pif^headers^
new file mode 100644
index 0000000000..09b22facc0
--- /dev/null
+++ b/browser/base/content/test/general/unknownContentType_file.pif^headers^
@@ -0,0 +1 @@
+Content-Type: application/octet-stream
diff --git a/browser/base/content/test/general/video.ogg b/browser/base/content/test/general/video.ogg
new file mode 100644
index 0000000000..ac7ece3519
--- /dev/null
+++ b/browser/base/content/test/general/video.ogg
Binary files differ
diff --git a/browser/base/content/test/general/web_video.html b/browser/base/content/test/general/web_video.html
new file mode 100644
index 0000000000..467fb0ce1c
--- /dev/null
+++ b/browser/base/content/test/general/web_video.html
@@ -0,0 +1,10 @@
+<html>
+ <head>
+ <title>Document with Web Video</title>
+ </head>
+ <body>
+ This document has some web video in it.
+ <br>
+ <video src="web_video1.ogv" id="video1"> </video>
+ </body>
+</html>
diff --git a/browser/base/content/test/general/web_video1.ogv b/browser/base/content/test/general/web_video1.ogv
new file mode 100644
index 0000000000..093158432a
--- /dev/null
+++ b/browser/base/content/test/general/web_video1.ogv
Binary files differ
diff --git a/browser/base/content/test/general/web_video1.ogv^headers^ b/browser/base/content/test/general/web_video1.ogv^headers^
new file mode 100644
index 0000000000..4511e92552
--- /dev/null
+++ b/browser/base/content/test/general/web_video1.ogv^headers^
@@ -0,0 +1,3 @@
+Content-Disposition: filename="web-video1-expectedName.ogv"
+Content-Type: video/ogg
+
diff --git a/browser/base/content/test/gesture/browser.ini b/browser/base/content/test/gesture/browser.ini
new file mode 100644
index 0000000000..1ae3ad6df5
--- /dev/null
+++ b/browser/base/content/test/gesture/browser.ini
@@ -0,0 +1 @@
+[browser_gesture_navigation.js]
diff --git a/browser/base/content/test/gesture/browser_gesture_navigation.js b/browser/base/content/test/gesture/browser_gesture_navigation.js
new file mode 100644
index 0000000000..667a1f07b6
--- /dev/null
+++ b/browser/base/content/test/gesture/browser_gesture_navigation.js
@@ -0,0 +1,233 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+"use strict";
+
+add_setup(async () => {
+ // Disable window occlusion. See bug 1733955 / bug 1779559.
+ if (navigator.platform.indexOf("Win") == 0) {
+ await SpecialPowers.pushPrefEnv({
+ set: [["widget.windows.window_occlusion_tracking.enabled", false]],
+ });
+ }
+});
+
+add_task(async () => {
+ // Open a new browser window to make sure there is no navigation history.
+ const newBrowser = await BrowserTestUtils.openNewBrowserWindow({});
+
+ let event = {
+ direction: SimpleGestureEvent.DIRECTION_LEFT,
+ };
+ ok(!newBrowser.gGestureSupport._shouldDoSwipeGesture(event));
+
+ event = {
+ direction: SimpleGestureEvent.DIRECTION_RIGHT,
+ };
+ ok(!newBrowser.gGestureSupport._shouldDoSwipeGesture(event));
+
+ await BrowserTestUtils.closeWindow(newBrowser);
+});
+
+function createSimpleGestureEvent(type, direction) {
+ let event = document.createEvent("SimpleGestureEvent");
+ event.initSimpleGestureEvent(
+ type,
+ false /* canBubble */,
+ false /* cancelableArg */,
+ window,
+ 0 /* detail */,
+ 0 /* screenX */,
+ 0 /* screenY */,
+ 0 /* clientX */,
+ 0 /* clientY */,
+ false /* ctrlKey */,
+ false /* altKey */,
+ false /* shiftKey */,
+ false /* metaKey */,
+ 0 /* button */,
+ null /* relatedTarget */,
+ 0 /* allowedDirections */,
+ direction,
+ 1 /* delta */
+ );
+ return event;
+}
+
+add_task(async () => {
+ await SpecialPowers.pushPrefEnv({
+ set: [["ui.swipeAnimationEnabled", false]],
+ });
+
+ // Open a new browser window and load two pages so that the browser can go
+ // back but can't go forward.
+ const newWindow = await BrowserTestUtils.openNewBrowserWindow({});
+
+ // gHistroySwipeAnimation gets initialized in a requestIdleCallback so we need
+ // to wait for the initialization.
+ await TestUtils.waitForCondition(() => {
+ return (
+ // There's no explicit notification for the initialization, so we wait
+ // until `isLTR` matches the browser locale state.
+ newWindow.gHistorySwipeAnimation.isLTR != Services.locale.isAppLocaleRTL
+ );
+ });
+
+ BrowserTestUtils.loadURIString(
+ newWindow.gBrowser.selectedBrowser,
+ "about:mozilla"
+ );
+ await BrowserTestUtils.browserLoaded(
+ newWindow.gBrowser.selectedBrowser,
+ false,
+ "about:mozilla"
+ );
+ BrowserTestUtils.loadURIString(
+ newWindow.gBrowser.selectedBrowser,
+ "about:about"
+ );
+ await BrowserTestUtils.browserLoaded(
+ newWindow.gBrowser.selectedBrowser,
+ false,
+ "about:about"
+ );
+
+ let event = createSimpleGestureEvent(
+ "MozSwipeGestureMayStart",
+ SimpleGestureEvent.DIRECTION_LEFT
+ );
+ newWindow.gGestureSupport._shouldDoSwipeGesture(event);
+
+ // Assuming we are on LTR environment.
+ is(
+ event.allowedDirections,
+ SimpleGestureEvent.DIRECTION_LEFT,
+ "Allows only swiping to left, i.e. backward"
+ );
+
+ event = createSimpleGestureEvent(
+ "MozSwipeGestureMayStart",
+ SimpleGestureEvent.DIRECTION_RIGHT
+ );
+ newWindow.gGestureSupport._shouldDoSwipeGesture(event);
+ is(
+ event.allowedDirections,
+ SimpleGestureEvent.DIRECTION_LEFT,
+ "Allows only swiping to left, i.e. backward"
+ );
+
+ await BrowserTestUtils.closeWindow(newWindow);
+});
+
+add_task(async () => {
+ await SpecialPowers.pushPrefEnv({
+ set: [["ui.swipeAnimationEnabled", true]],
+ });
+
+ // Open a new browser window and load two pages so that the browser can go
+ // back but can't go forward.
+ const newWindow = await BrowserTestUtils.openNewBrowserWindow({});
+
+ if (!newWindow.gHistorySwipeAnimation._isSupported()) {
+ await BrowserTestUtils.closeWindow(newWindow);
+ return;
+ }
+
+ function sendSwipeSequence(sendEnd) {
+ let event = createSimpleGestureEvent(
+ "MozSwipeGestureMayStart",
+ SimpleGestureEvent.DIRECTION_LEFT
+ );
+ newWindow.gGestureSupport.handleEvent(event);
+
+ event = createSimpleGestureEvent(
+ "MozSwipeGestureStart",
+ SimpleGestureEvent.DIRECTION_LEFT
+ );
+ newWindow.gGestureSupport.handleEvent(event);
+
+ event = createSimpleGestureEvent(
+ "MozSwipeGestureUpdate",
+ SimpleGestureEvent.DIRECTION_LEFT
+ );
+ newWindow.gGestureSupport.handleEvent(event);
+
+ event = createSimpleGestureEvent(
+ "MozSwipeGestureUpdate",
+ SimpleGestureEvent.DIRECTION_LEFT
+ );
+ newWindow.gGestureSupport.handleEvent(event);
+
+ if (sendEnd) {
+ sendSwipeEnd();
+ }
+ }
+ function sendSwipeEnd() {
+ let event = createSimpleGestureEvent(
+ "MozSwipeGestureEnd",
+ SimpleGestureEvent.DIRECTION_LEFT
+ );
+ newWindow.gGestureSupport.handleEvent(event);
+ }
+
+ // gHistroySwipeAnimation gets initialized in a requestIdleCallback so we need
+ // to wait for the initialization.
+ await TestUtils.waitForCondition(() => {
+ return (
+ // There's no explicit notification for the initialization, so we wait
+ // until `isLTR` matches the browser locale state.
+ newWindow.gHistorySwipeAnimation.isLTR != Services.locale.isAppLocaleRTL
+ );
+ });
+
+ BrowserTestUtils.loadURIString(
+ newWindow.gBrowser.selectedBrowser,
+ "about:mozilla"
+ );
+ await BrowserTestUtils.browserLoaded(
+ newWindow.gBrowser.selectedBrowser,
+ false,
+ "about:mozilla"
+ );
+ BrowserTestUtils.loadURIString(
+ newWindow.gBrowser.selectedBrowser,
+ "about:about"
+ );
+ await BrowserTestUtils.browserLoaded(
+ newWindow.gBrowser.selectedBrowser,
+ false,
+ "about:about"
+ );
+
+ // Start a swipe that's not enough to navigate
+ sendSwipeSequence(/* sendEnd = */ true);
+
+ // Wait two frames
+ await new Promise(r =>
+ window.requestAnimationFrame(() => window.requestAnimationFrame(r))
+ );
+
+ // The transition to fully stopped shouldn't have had enough time yet to
+ // become fully stopped.
+ ok(
+ newWindow.gHistorySwipeAnimation._isStoppingAnimation,
+ "should be stopping anim"
+ );
+
+ // Start another swipe.
+ sendSwipeSequence(/* sendEnd = */ false);
+
+ // Wait two frames
+ await new Promise(r =>
+ window.requestAnimationFrame(() => window.requestAnimationFrame(r))
+ );
+
+ // We should have started a new swipe, ie we shouldn't be stopping.
+ ok(
+ !newWindow.gHistorySwipeAnimation._isStoppingAnimation,
+ "should not be stopping anim"
+ );
+
+ sendSwipeEnd();
+
+ await BrowserTestUtils.closeWindow(newWindow);
+});
diff --git a/browser/base/content/test/historySwipeAnimation/browser.ini b/browser/base/content/test/historySwipeAnimation/browser.ini
new file mode 100644
index 0000000000..c9fb8cd246
--- /dev/null
+++ b/browser/base/content/test/historySwipeAnimation/browser.ini
@@ -0,0 +1 @@
+[browser_historySwipeAnimation.js]
diff --git a/browser/base/content/test/historySwipeAnimation/browser_historySwipeAnimation.js b/browser/base/content/test/historySwipeAnimation/browser_historySwipeAnimation.js
new file mode 100644
index 0000000000..a5910964e7
--- /dev/null
+++ b/browser/base/content/test/historySwipeAnimation/browser_historySwipeAnimation.js
@@ -0,0 +1,49 @@
+/* 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/. */
+
+function test() {
+ waitForExplicitFinish();
+
+ BrowserOpenTab();
+ let tab = gBrowser.selectedTab;
+ registerCleanupFunction(function () {
+ gBrowser.removeTab(tab);
+ });
+
+ ok(gHistorySwipeAnimation, "gHistorySwipeAnimation exists.");
+
+ if (!gHistorySwipeAnimation._isSupported()) {
+ is(
+ gHistorySwipeAnimation.active,
+ false,
+ "History swipe animation is not " +
+ "active when not supported by the platform."
+ );
+ finish();
+ return;
+ }
+
+ gHistorySwipeAnimation.init();
+
+ is(
+ gHistorySwipeAnimation.active,
+ true,
+ "History swipe animation support " +
+ "was successfully initialized when supported."
+ );
+
+ test0();
+
+ function test0() {
+ // Test uninit of gHistorySwipeAnimation.
+ // This test MUST be the last one to execute.
+ gHistorySwipeAnimation.uninit();
+ is(
+ gHistorySwipeAnimation.active,
+ false,
+ "History swipe animation support was successfully uninitialized"
+ );
+ finish();
+ }
+}
diff --git a/browser/base/content/test/keyboard/browser.ini b/browser/base/content/test/keyboard/browser.ini
new file mode 100644
index 0000000000..a92b0838ef
--- /dev/null
+++ b/browser/base/content/test/keyboard/browser.ini
@@ -0,0 +1,19 @@
+[DEFAULT]
+support-files = head.js
+
+[browser_bookmarks_shortcut.js]
+https_first_disabled = true
+[browser_cancel_caret_browsing_in_content.js]
+support-files = file_empty.html
+[browser_popup_keyNav.js]
+https_first_disabled = true
+support-files = focusableContent.html
+[browser_toolbarButtonKeyPress.js]
+skip-if =
+ os == "linux" #Bug 1532501
+ os == "win" && asan # Bug 1775712
+ os == "win" && os_version == "6.1" # Skip on Azure - frequent failure
+[browser_toolbarKeyNav.js]
+support-files = !/browser/base/content/test/permissions/permissions.html
+skip-if =
+ os == "win" && os_version == "6.1" # Skip on Azure - frequent failure
diff --git a/browser/base/content/test/keyboard/browser_bookmarks_shortcut.js b/browser/base/content/test/keyboard/browser_bookmarks_shortcut.js
new file mode 100644
index 0000000000..02aedfaf79
--- /dev/null
+++ b/browser/base/content/test/keyboard/browser_bookmarks_shortcut.js
@@ -0,0 +1,140 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Test the behavior of keypress shortcuts for the bookmarks toolbar.
+ */
+
+// Test that the bookmarks toolbar's visibility is toggled using the bookmarks-shortcut.
+add_task(async function testBookmarksToolbarShortcut() {
+ let blankTab = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ opening: "example.com",
+ waitForLoad: false,
+ });
+
+ info("Toggle toolbar visibility on");
+ let toolbar = document.getElementById("PersonalToolbar");
+ is(
+ toolbar.getAttribute("collapsed"),
+ "true",
+ "Toolbar bar should already be collapsed"
+ );
+
+ EventUtils.synthesizeKey("b", { shiftKey: true, accelKey: true });
+ toolbar = document.getElementById("PersonalToolbar");
+ await BrowserTestUtils.waitForAttribute("collapsed", toolbar, "false");
+ ok(true, "bookmarks toolbar is visible");
+
+ await testIsBookmarksMenuItemStateChecked("always");
+
+ info("Toggle toolbar visibility off");
+ EventUtils.synthesizeKey("b", { shiftKey: true, accelKey: true });
+ toolbar = document.getElementById("PersonalToolbar");
+ await BrowserTestUtils.waitForAttribute("collapsed", toolbar, "true");
+ ok(true, "bookmarks toolbar is not visible");
+
+ await testIsBookmarksMenuItemStateChecked("never");
+
+ await BrowserTestUtils.removeTab(blankTab);
+});
+
+// Test that the bookmarks library windows opens with the new keyboard shortcut.
+add_task(async function testNewBookmarksLibraryShortcut() {
+ let blankTab = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ opening: "example.com",
+ waitForLoad: false,
+ });
+
+ info("Check that the bookmarks library windows opens.");
+ let bookmarksLibraryOpened = promiseOpenBookmarksLibrary();
+
+ await EventUtils.synthesizeKey("o", { shiftKey: true, accelKey: true });
+
+ let win = await bookmarksLibraryOpened;
+
+ ok(true, "Bookmarks library successfully opened.");
+
+ win.close();
+
+ await BrowserTestUtils.removeTab(blankTab);
+});
+
+/**
+ * Tests whether or not the bookmarks' menuitem state is checked.
+ */
+async function testIsBookmarksMenuItemStateChecked(expected) {
+ info("Test that the toolbar menuitem state is correct.");
+ let contextMenu = document.getElementById("toolbar-context-menu");
+ let target = document.getElementById("PanelUI-menu-button");
+
+ await openContextMenu(contextMenu, target);
+
+ let menuitems = ["always", "never", "newtab"].map(e =>
+ document.querySelector(`menuitem[data-visibility-enum="${e}"]`)
+ );
+
+ let checkedItem = menuitems.filter(m => m.getAttribute("checked") == "true");
+ is(checkedItem.length, 1, "should have only one menuitem checked");
+ is(
+ checkedItem[0].dataset.visibilityEnum,
+ expected,
+ `checked menuitem should be ${expected}`
+ );
+
+ for (let menuitem of menuitems) {
+ if (menuitem.dataset.visibilityEnum == expected) {
+ ok(!menuitem.hasAttribute("key"), "dont show shortcut on current state");
+ } else {
+ is(
+ menuitem.hasAttribute("key"),
+ menuitem.dataset.visibilityEnum != "newtab",
+ "shortcut is on the menuitem opposite of the current state excluding newtab"
+ );
+ }
+ }
+
+ await closeContextMenu(contextMenu);
+}
+
+/**
+ * Returns a promise for opening the bookmarks library.
+ */
+async function promiseOpenBookmarksLibrary() {
+ return BrowserTestUtils.domWindowOpened(null, async win => {
+ await BrowserTestUtils.waitForEvent(win, "load");
+ await TestUtils.waitForCondition(
+ () =>
+ win.document.documentURI ===
+ "chrome://browser/content/places/places.xhtml"
+ );
+ return true;
+ });
+}
+
+/**
+ * Helper for opening the context menu.
+ */
+async function openContextMenu(contextMenu, target) {
+ info("Opening context menu.");
+ EventUtils.synthesizeMouseAtCenter(target, {
+ type: "contextmenu",
+ });
+ await BrowserTestUtils.waitForPopupEvent(contextMenu, "shown");
+ let bookmarksToolbarMenu = document.querySelector("#toggle_PersonalToolbar");
+ let subMenu = bookmarksToolbarMenu.querySelector("menupopup");
+ bookmarksToolbarMenu.openMenu(true);
+ await BrowserTestUtils.waitForPopupEvent(subMenu, "shown");
+}
+
+/**
+ * Helper for closing the context menu.
+ */
+async function closeContextMenu(contextMenu) {
+ info("Closing context menu.");
+ contextMenu.hidePopup();
+ await BrowserTestUtils.waitForPopupEvent(contextMenu, "hidden");
+}
diff --git a/browser/base/content/test/keyboard/browser_cancel_caret_browsing_in_content.js b/browser/base/content/test/keyboard/browser_cancel_caret_browsing_in_content.js
new file mode 100644
index 0000000000..719b92eed6
--- /dev/null
+++ b/browser/base/content/test/keyboard/browser_cancel_caret_browsing_in_content.js
@@ -0,0 +1,91 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function () {
+ const kPrefName_CaretBrowsingOn = "accessibility.browsewithcaret";
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["accessibility.browsewithcaret_shortcut.enabled", true],
+ ["accessibility.warn_on_browsewithcaret", false],
+ ["test.events.async.enabled", true],
+ [kPrefName_CaretBrowsingOn, false],
+ ],
+ });
+
+ await BrowserTestUtils.withNewTab(
+ "https://example.com/browser/browser/base/content/test/keyboard/file_empty.html",
+ async function (browser) {
+ await SpecialPowers.spawn(browser, [], () => {
+ content.document.documentElement.scrollTop; // Flush layout.
+ });
+ function promiseFirstAndReplyKeyEvents(aExpectedConsume) {
+ return new Promise(resolve => {
+ const eventType = aExpectedConsume ? "keydown" : "keypress";
+ let eventCount = 0;
+ let listener = () => {
+ if (++eventCount === 2) {
+ window.removeEventListener(eventType, listener, {
+ capture: true,
+ mozSystemGroup: true,
+ });
+ resolve();
+ }
+ };
+ window.addEventListener(eventType, listener, {
+ capture: true,
+ mozSystemGroup: true,
+ });
+ registerCleanupFunction(() => {
+ window.removeEventListener(eventType, listener, {
+ capture: true,
+ mozSystemGroup: true,
+ });
+ });
+ });
+ }
+ let promiseReplyF7KeyEvents = promiseFirstAndReplyKeyEvents(false);
+ EventUtils.synthesizeKey("KEY_F7");
+ info("Waiting reply F7 keypress event...");
+ await promiseReplyF7KeyEvents;
+ await TestUtils.waitForTick();
+ is(
+ Services.prefs.getBoolPref(kPrefName_CaretBrowsingOn),
+ true,
+ "F7 key should enable caret browsing mode"
+ );
+
+ await SpecialPowers.pushPrefEnv({
+ set: [[kPrefName_CaretBrowsingOn, false]],
+ });
+
+ await SpecialPowers.spawn(browser, [], () => {
+ content.document.documentElement.scrollTop; // Flush layout.
+ content.window.addEventListener(
+ "keydown",
+ event => event.preventDefault(),
+ { capture: true }
+ );
+ });
+ promiseReplyF7KeyEvents = promiseFirstAndReplyKeyEvents(true);
+ EventUtils.synthesizeKey("KEY_F7");
+ info("Waiting for reply F7 keydown event...");
+ await promiseReplyF7KeyEvents;
+ try {
+ info(`Checking reply keypress event is not fired...`);
+ await TestUtils.waitForCondition(
+ () => Services.prefs.getBoolPref(kPrefName_CaretBrowsingOn),
+ "",
+ 100, // interval
+ 5 // maxTries
+ );
+ } catch (e) {}
+ is(
+ Services.prefs.getBoolPref(kPrefName_CaretBrowsingOn),
+ false,
+ "F7 key shouldn't enable caret browsing mode because F7 keydown event is consumed by web content"
+ );
+ }
+ );
+});
diff --git a/browser/base/content/test/keyboard/browser_popup_keyNav.js b/browser/base/content/test/keyboard/browser_popup_keyNav.js
new file mode 100644
index 0000000000..bf3c1ae44a
--- /dev/null
+++ b/browser/base/content/test/keyboard/browser_popup_keyNav.js
@@ -0,0 +1,50 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TEST_PATH = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com"
+);
+
+/**
+ * Keyboard navigation has some edgecases in popups because
+ * there is no tabstrip or menubar. Check that tabbing forward
+ * and backward to/from the content document works:
+ */
+add_task(async function test_popup_keynav() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.toolbars.keyboard_navigation", true],
+ ["accessibility.tabfocus", 7],
+ ],
+ });
+
+ const kURL = TEST_PATH + "focusableContent.html";
+ await BrowserTestUtils.withNewTab(kURL, async browser => {
+ let windowPromise = BrowserTestUtils.waitForNewWindow({
+ url: kURL,
+ });
+ SpecialPowers.spawn(browser, [], () => {
+ content.window.open(
+ content.location.href,
+ "_blank",
+ "height=500,width=500,menubar=no,toolbar=no,status=1,resizable=1"
+ );
+ });
+ let win = await windowPromise;
+ let hamburgerButton = win.document.getElementById("PanelUI-menu-button");
+ forceFocus(hamburgerButton);
+ await expectFocusAfterKey("Tab", win.gBrowser.selectedBrowser, false, win);
+ // Focus the button inside the webpage.
+ EventUtils.synthesizeKey("KEY_Tab", {}, win);
+ // Focus the first item in the URL bar
+ let firstButton = win.document
+ .getElementById("urlbar-container")
+ .querySelector("toolbarbutton,[role=button]");
+ await expectFocusAfterKey("Tab", firstButton, false, win);
+ await BrowserTestUtils.closeWindow(win);
+ });
+});
diff --git a/browser/base/content/test/keyboard/browser_toolbarButtonKeyPress.js b/browser/base/content/test/keyboard/browser_toolbarButtonKeyPress.js
new file mode 100644
index 0000000000..01c829c581
--- /dev/null
+++ b/browser/base/content/test/keyboard/browser_toolbarButtonKeyPress.js
@@ -0,0 +1,336 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const kDevPanelID = "PanelUI-developer-tools";
+
+/**
+ * Test the behavior of key presses on various toolbar buttons.
+ */
+
+function waitForLocationChange() {
+ let promise = new Promise(resolve => {
+ let wpl = {
+ onLocationChange(aWebProgress, aRequest, aLocation) {
+ gBrowser.removeProgressListener(wpl);
+ resolve();
+ },
+ };
+ gBrowser.addProgressListener(wpl);
+ });
+ return promise;
+}
+
+add_task(async function setPref() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.toolbars.keyboard_navigation", true]],
+ });
+});
+
+// Test activation of the app menu button from the keyboard.
+// The app menu should appear and focus should move inside it.
+add_task(async function testAppMenuButtonPress() {
+ let button = document.getElementById("PanelUI-menu-button");
+ forceFocus(button);
+ let focused = BrowserTestUtils.waitForEvent(
+ window.PanelUI.mainView,
+ "focus",
+ true
+ );
+ EventUtils.synthesizeKey(" ");
+ await focused;
+ ok(true, "Focus inside app menu after toolbar button pressed");
+ let hidden = BrowserTestUtils.waitForEvent(
+ window.PanelUI.panel,
+ "popuphidden"
+ );
+ EventUtils.synthesizeKey("KEY_Escape");
+ await hidden;
+});
+
+// Test that the app menu doesn't open when a key other than space or enter is
+// pressed .
+add_task(async function testAppMenuButtonWrongKey() {
+ let button = document.getElementById("PanelUI-menu-button");
+ forceFocus(button);
+ EventUtils.synthesizeKey("KEY_Tab");
+ await TestUtils.waitForTick();
+ is(window.PanelUI.panel.state, "closed", "App menu is closed after tab");
+});
+
+// Test activation of the Library button from the keyboard.
+// The Library menu should appear and focus should move inside it.
+add_task(async function testLibraryButtonPress() {
+ CustomizableUI.addWidgetToArea("library-button", "nav-bar");
+ let button = document.getElementById("library-button");
+ forceFocus(button);
+ EventUtils.synthesizeKey(" ");
+ let view = document.getElementById("appMenu-libraryView");
+ let focused = BrowserTestUtils.waitForEvent(view, "focus", true);
+ await focused;
+ ok(true, "Focus inside Library menu after toolbar button pressed");
+ let hidden = BrowserTestUtils.waitForEvent(document, "popuphidden", true);
+ view.closest("panel").hidePopup();
+ await hidden;
+ CustomizableUI.removeWidgetFromArea("library-button");
+});
+
+// Test activation of the Developer button from the keyboard.
+// This is a customizable widget of type "view".
+// The Developer menu should appear and focus should move inside it.
+add_task(async function testDeveloperButtonPress() {
+ CustomizableUI.addWidgetToArea(
+ "developer-button",
+ CustomizableUI.AREA_NAVBAR
+ );
+ let button = document.getElementById("developer-button");
+ forceFocus(button);
+ EventUtils.synthesizeKey(" ");
+ let view = document.getElementById(kDevPanelID);
+ let focused = BrowserTestUtils.waitForEvent(view, "focus", true);
+ await focused;
+ ok(true, "Focus inside Developer menu after toolbar button pressed");
+ let hidden = BrowserTestUtils.waitForEvent(document, "popuphidden", true);
+ view.closest("panel").hidePopup();
+ await hidden;
+ CustomizableUI.reset();
+});
+
+// Test that the Developer menu doesn't open when a key other than space or
+// enter is pressed .
+add_task(async function testDeveloperButtonWrongKey() {
+ CustomizableUI.addWidgetToArea(
+ "developer-button",
+ CustomizableUI.AREA_NAVBAR
+ );
+ let button = document.getElementById("developer-button");
+ forceFocus(button);
+ EventUtils.synthesizeKey("KEY_Tab");
+ await TestUtils.waitForTick();
+ let panel = document.getElementById(kDevPanelID).closest("panel");
+ ok(!panel || panel.state == "closed", "Developer menu not open after tab");
+ CustomizableUI.reset();
+});
+
+// Test activation of the Page actions button from the keyboard.
+// The Page Actions menu should appear and focus should move inside it.
+add_task(async function testPageActionsButtonPress() {
+ // The page actions button is not normally visible, so we must
+ // unhide it.
+ BrowserPageActions.mainButtonNode.style.visibility = "visible";
+ registerCleanupFunction(() => {
+ BrowserPageActions.mainButtonNode.style.removeProperty("visibility");
+ });
+ await BrowserTestUtils.withNewTab("https://example.com", async function () {
+ let button = document.getElementById("pageActionButton");
+ forceFocus(button);
+ EventUtils.synthesizeKey(" ");
+ let view = document.getElementById("pageActionPanelMainView");
+ let focused = BrowserTestUtils.waitForEvent(view, "focus", true);
+ await focused;
+ ok(true, "Focus inside Page Actions menu after toolbar button pressed");
+ let hidden = BrowserTestUtils.waitForEvent(document, "popuphidden", true);
+ view.closest("panel").hidePopup();
+ await hidden;
+ });
+});
+
+// Test activation of the Back and Forward buttons from the keyboard.
+add_task(async function testBackForwardButtonPress() {
+ await BrowserTestUtils.withNewTab(
+ "https://example.com/1",
+ async function (aBrowser) {
+ BrowserTestUtils.loadURIString(aBrowser, "https://example.com/2");
+
+ await BrowserTestUtils.browserLoaded(aBrowser);
+ let backButton = document.getElementById("back-button");
+ forceFocus(backButton);
+ let onLocationChange = waitForLocationChange();
+ EventUtils.synthesizeKey(" ");
+ await onLocationChange;
+ ok(true, "Location changed after back button pressed");
+
+ let forwardButton = document.getElementById("forward-button");
+ forceFocus(forwardButton);
+ onLocationChange = waitForLocationChange();
+ EventUtils.synthesizeKey(" ");
+ await onLocationChange;
+ ok(true, "Location changed after forward button pressed");
+ }
+ );
+});
+
+// Test activation of the Reload button from the keyboard.
+// This is a toolbarbutton with a click handler and no command handler, but
+// the toolbar keyboard navigation code should handle keyboard activation.
+add_task(async function testReloadButtonPress() {
+ await BrowserTestUtils.withNewTab(
+ "https://example.com/1",
+ async function (aBrowser) {
+ let button = document.getElementById("reload-button");
+ info("Waiting for button to be enabled.");
+ await TestUtils.waitForCondition(() => !button.disabled);
+ let loaded = BrowserTestUtils.browserLoaded(aBrowser);
+ info("Focusing button");
+ forceFocus(button);
+ info("Pressing space on the button");
+ EventUtils.synthesizeKey(" ");
+ info("Waiting for load.");
+ await loaded;
+ ok(true, "Page loaded after Reload button pressed");
+ }
+ );
+});
+
+// Test activation of the Sidebars button from the keyboard.
+// This is a toolbarbutton with a command handler.
+add_task(async function testSidebarsButtonPress() {
+ CustomizableUI.addWidgetToArea("sidebar-button", "nav-bar");
+ let button = document.getElementById("sidebar-button");
+ ok(!button.checked, "Sidebars button not checked at start of test");
+ let sidebarBox = document.getElementById("sidebar-box");
+ ok(sidebarBox.hidden, "Sidebar hidden at start of test");
+ forceFocus(button);
+ EventUtils.synthesizeKey(" ");
+ await TestUtils.waitForCondition(() => button.checked);
+ ok(true, "Sidebars button checked after press");
+ ok(!sidebarBox.hidden, "Sidebar visible after press");
+ // Make sure the sidebar is fully loaded before we hide it.
+ // Otherwise, the unload event might call JS which isn't loaded yet.
+ // We can't use BrowserTestUtils.browserLoaded because it fails on non-tab
+ // docs. Instead, wait for something in the JS script.
+ let sidebarWin = document.getElementById("sidebar").contentWindow;
+ await TestUtils.waitForCondition(() => sidebarWin.PlacesUIUtils);
+ forceFocus(button);
+ EventUtils.synthesizeKey(" ");
+ await TestUtils.waitForCondition(() => !button.checked);
+ ok(true, "Sidebars button not checked after press");
+ ok(sidebarBox.hidden, "Sidebar hidden after press");
+ CustomizableUI.removeWidgetFromArea("sidebar-button");
+});
+
+// Test activation of the Bookmark this page button from the keyboard.
+// This is an image with a click handler on its parent and no command handler,
+// but the toolbar keyboard navigation code should handle keyboard activation.
+add_task(async function testBookmarkButtonPress() {
+ await BrowserTestUtils.withNewTab(
+ "https://example.com",
+ async function (aBrowser) {
+ let button = document.getElementById("star-button-box");
+ forceFocus(button);
+ StarUI._createPanelIfNeeded();
+ let panel = document.getElementById("editBookmarkPanel");
+ let focused = BrowserTestUtils.waitForEvent(panel, "focus", true);
+ // The button ignores activation while the bookmarked status is being
+ // updated. So, wait for it to finish updating.
+ await TestUtils.waitForCondition(
+ () => BookmarkingUI.status != BookmarkingUI.STATUS_UPDATING
+ );
+ EventUtils.synthesizeKey(" ");
+ await focused;
+ ok(
+ true,
+ "Focus inside edit bookmark panel after Bookmark button pressed"
+ );
+ let hidden = BrowserTestUtils.waitForEvent(panel, "popuphidden");
+ EventUtils.synthesizeKey("KEY_Escape");
+ await hidden;
+ }
+ );
+});
+
+// Test activation of the Bookmarks Menu button from the keyboard.
+// This is a button with type="menu".
+// The Bookmarks Menu should appear.
+add_task(async function testBookmarksmenuButtonPress() {
+ CustomizableUI.addWidgetToArea(
+ "bookmarks-menu-button",
+ CustomizableUI.AREA_NAVBAR
+ );
+ let button = document.getElementById("bookmarks-menu-button");
+ forceFocus(button);
+ let menu = document.getElementById("BMB_bookmarksPopup");
+ let shown = BrowserTestUtils.waitForEvent(menu, "popupshown");
+ EventUtils.synthesizeKey(" ");
+ await shown;
+ ok(true, "Bookmarks Menu shown after toolbar button pressed");
+ let hidden = BrowserTestUtils.waitForEvent(menu, "popuphidden");
+ menu.hidePopup();
+ await hidden;
+ CustomizableUI.reset();
+});
+
+// Test activation of the overflow button from the keyboard.
+// The overflow menu should appear and focus should move inside it.
+add_task(async function testOverflowButtonPress() {
+ // Move something to the overflow menu to make the button appear.
+ CustomizableUI.addWidgetToArea(
+ "developer-button",
+ CustomizableUI.AREA_FIXED_OVERFLOW_PANEL
+ );
+ let button = document.getElementById("nav-bar-overflow-button");
+ forceFocus(button);
+ let view = document.getElementById("widget-overflow-mainView");
+ let focused = BrowserTestUtils.waitForEvent(view, "focus", true);
+ EventUtils.synthesizeKey(" ");
+ await focused;
+ ok(true, "Focus inside overflow menu after toolbar button pressed");
+ let panel = document.getElementById("widget-overflow");
+ let hidden = BrowserTestUtils.waitForEvent(panel, "popuphidden");
+ panel.hidePopup();
+ await hidden;
+ CustomizableUI.reset();
+});
+
+// Test activation of the Downloads button from the keyboard.
+// The Downloads panel should appear and focus should move inside it.
+add_task(async function testDownloadsButtonPress() {
+ DownloadsButton.unhide();
+ let button = document.getElementById("downloads-button");
+ forceFocus(button);
+ let panel = document.getElementById("downloadsPanel");
+ let focused = BrowserTestUtils.waitForEvent(panel, "focus", true);
+ EventUtils.synthesizeKey(" ");
+ await focused;
+ ok(true, "Focus inside Downloads panel after toolbar button pressed");
+ let hidden = BrowserTestUtils.waitForEvent(panel, "popuphidden");
+ panel.hidePopup();
+ await hidden;
+ DownloadsButton.hide();
+});
+
+// Test activation of the Save to Pocket button from the keyboard.
+// This is a customizable widget button which shows an popup panel
+// with a browser element to embed the pocket UI into it.
+// The Pocket panel should appear and focus should move inside it.
+add_task(async function testPocketButtonPress() {
+ await BrowserTestUtils.withNewTab(
+ "https://example.com",
+ async function (aBrowser) {
+ let button = document.getElementById("save-to-pocket-button");
+ forceFocus(button);
+ // The panel is created on the fly, so we can't simply wait for focus
+ // inside it.
+ let showing = BrowserTestUtils.waitForEvent(
+ document,
+ "popupshowing",
+ true
+ );
+ EventUtils.synthesizeKey(" ");
+ let event = await showing;
+ let panel = event.target;
+ is(panel.id, "customizationui-widget-panel");
+ let focused = BrowserTestUtils.waitForEvent(panel, "focus", true);
+ await focused;
+ is(
+ document.activeElement.tagName,
+ "browser",
+ "Focus inside Pocket panel after Bookmark button pressed"
+ );
+ let hidden = BrowserTestUtils.waitForEvent(panel, "popuphidden");
+ EventUtils.synthesizeKey("KEY_Escape");
+ await hidden;
+ }
+ );
+});
diff --git a/browser/base/content/test/keyboard/browser_toolbarKeyNav.js b/browser/base/content/test/keyboard/browser_toolbarKeyNav.js
new file mode 100644
index 0000000000..7fb67a6a91
--- /dev/null
+++ b/browser/base/content/test/keyboard/browser_toolbarKeyNav.js
@@ -0,0 +1,641 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Test browser toolbar keyboard navigation.
+ * These tests assume the default browser configuration for toolbars unless
+ * otherwise specified.
+ */
+
+const PERMISSIONS_PAGE =
+ "https://example.com/browser/browser/base/content/test/permissions/permissions.html";
+const afterUrlBarButton = "save-to-pocket-button";
+
+// The DevEdition has the DevTools button in the toolbar by default. Remove it
+// to prevent branch-specific rules what button should be focused.
+function resetToolbarWithoutDevEditionButtons() {
+ CustomizableUI.reset();
+ CustomizableUI.removeWidgetFromArea("developer-button");
+}
+
+function AddHomeBesideReload() {
+ CustomizableUI.addWidgetToArea(
+ "home-button",
+ "nav-bar",
+ CustomizableUI.getPlacementOfWidget("stop-reload-button").position + 1
+ );
+}
+
+function RemoveHomeButton() {
+ CustomizableUI.removeWidgetFromArea("home-button");
+}
+
+function AddOldMenuSideButtons() {
+ // Make the FxA button visible even though we're signed out.
+ // We'll use oldfxastatus to restore the old state.
+ document.documentElement.setAttribute(
+ "oldfxastatus",
+ document.documentElement.getAttribute("fxastatus")
+ );
+ document.documentElement.setAttribute("fxastatus", "signed_in");
+ // The FxA button is supposed to be last, add these buttons before it.
+ CustomizableUI.addWidgetToArea(
+ "library-button",
+ "nav-bar",
+ CustomizableUI.getWidgetIdsInArea("nav-bar").length - 2
+ );
+ CustomizableUI.addWidgetToArea(
+ "sidebar-button",
+ "nav-bar",
+ CustomizableUI.getWidgetIdsInArea("nav-bar").length - 2
+ );
+ CustomizableUI.addWidgetToArea(
+ "unified-extensions-button",
+ "nav-bar",
+ CustomizableUI.getWidgetIdsInArea("nav-bar").length - 2
+ );
+}
+
+function RemoveOldMenuSideButtons() {
+ CustomizableUI.removeWidgetFromArea("library-button");
+ CustomizableUI.removeWidgetFromArea("sidebar-button");
+ document.documentElement.setAttribute(
+ "fxastatus",
+ document.documentElement.getAttribute("oldfxastatus")
+ );
+ document.documentElement.removeAttribute("oldfxastatus");
+}
+
+function startFromUrlBar(aWindow = window) {
+ aWindow.gURLBar.focus();
+ is(
+ aWindow.document.activeElement,
+ aWindow.gURLBar.inputField,
+ "URL bar focused for start of test"
+ );
+}
+
+// The Reload button is disabled for a short time even after the page finishes
+// loading. Wait for it to be enabled.
+async function waitUntilReloadEnabled() {
+ let button = document.getElementById("reload-button");
+ await TestUtils.waitForCondition(() => !button.disabled);
+}
+
+// Opens a new, blank tab, executes a task and closes the tab.
+function withNewBlankTab(taskFn) {
+ return BrowserTestUtils.withNewTab("about:blank", async function () {
+ // For a blank tab, the Reload button should be disabled. However, when we
+ // open about:blank with BrowserTestUtils.withNewTab, this is unreliable.
+ // Therefore, explicitly disable the reload command.
+ // We disable the command (rather than disabling the button directly) so the
+ // button will be updated correctly for future page loads.
+ document.getElementById("Browser:Reload").setAttribute("disabled", "true");
+ await taskFn();
+ });
+}
+
+function removeFirefoxViewButton() {
+ CustomizableUI.removeWidgetFromArea("firefox-view-button");
+}
+
+const BOOKMARKS_COUNT = 100;
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.toolbars.keyboard_navigation", true],
+ ["accessibility.tabfocus", 7],
+ ],
+ });
+ resetToolbarWithoutDevEditionButtons();
+
+ await PlacesUtils.bookmarks.eraseEverything();
+ // Add bookmarks.
+ let bookmarks = new Array(BOOKMARKS_COUNT);
+ for (let i = 0; i < BOOKMARKS_COUNT; ++i) {
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ bookmarks[i] = { url: `http://test.places.${i}/` };
+ }
+ await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.toolbarGuid,
+ children: bookmarks,
+ });
+
+ // The page actions button is not normally visible, so we must
+ // unhide it.
+ BrowserPageActions.mainButtonNode.style.visibility = "visible";
+ registerCleanupFunction(() => {
+ BrowserPageActions.mainButtonNode.style.removeProperty("visibility");
+ });
+});
+
+// Test tab stops with no page loaded.
+add_task(async function testTabStopsNoPageWithHomeButton() {
+ AddHomeBesideReload();
+ await withNewBlankTab(async function () {
+ startFromUrlBar();
+ await expectFocusAfterKey("Shift+Tab", "home-button");
+ await expectFocusAfterKey("Shift+Tab", "tabs-newtab-button");
+ await expectFocusAfterKey("Shift+Tab", gBrowser.selectedTab);
+ await expectFocusAfterKey("Tab", "tabs-newtab-button");
+ await expectFocusAfterKey("Tab", "home-button");
+ await expectFocusAfterKey("Tab", gURLBar.inputField);
+ await expectFocusAfterKey("Tab", afterUrlBarButton);
+ await expectFocusAfterKey("Tab", gBrowser.selectedBrowser);
+ });
+ RemoveHomeButton();
+});
+
+async function doTestTabStopsPageLoaded(aPageActionsVisible) {
+ info(`doTestTabStopsPageLoaded(${aPageActionsVisible})`);
+
+ BrowserPageActions.mainButtonNode.style.visibility = aPageActionsVisible
+ ? "visible"
+ : "";
+ await BrowserTestUtils.withNewTab("https://example.com", async function () {
+ await waitUntilReloadEnabled();
+ startFromUrlBar();
+ await expectFocusAfterKey(
+ "Shift+Tab",
+ "tracking-protection-icon-container"
+ );
+ await expectFocusAfterKey("Shift+Tab", "reload-button");
+ await expectFocusAfterKey("Shift+Tab", "tabs-newtab-button");
+ await expectFocusAfterKey("Shift+Tab", gBrowser.selectedTab);
+ await expectFocusAfterKey("Tab", "tabs-newtab-button");
+ await expectFocusAfterKey("Tab", "reload-button");
+ await expectFocusAfterKey("Tab", "tracking-protection-icon-container");
+ await expectFocusAfterKey("Tab", gURLBar.inputField);
+ await expectFocusAfterKey(
+ "Tab",
+ aPageActionsVisible ? "pageActionButton" : "star-button-box"
+ );
+ await expectFocusAfterKey("Tab", afterUrlBarButton);
+ await expectFocusAfterKey("Tab", gBrowser.selectedBrowser);
+ });
+}
+
+// Test tab stops with a page loaded.
+add_task(async function testTabStopsPageLoaded() {
+ is(
+ BrowserPageActions.mainButtonNode.style.visibility,
+ "visible",
+ "explicitly shown at the beginning of test"
+ );
+ await doTestTabStopsPageLoaded(false);
+ await doTestTabStopsPageLoaded(true);
+});
+
+// Test tab stops with a notification anchor visible.
+// The notification anchor should not get its own tab stop.
+add_task(async function testTabStopsWithNotification() {
+ await BrowserTestUtils.withNewTab(
+ PERMISSIONS_PAGE,
+ async function (aBrowser) {
+ let popupShown = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popupshown"
+ );
+ // Request a permission.
+ BrowserTestUtils.synthesizeMouseAtCenter("#geo", {}, aBrowser);
+ await popupShown;
+ startFromUrlBar();
+ // If the notification anchor were in the tab order, the next shift+tab
+ // would focus it instead of #tracking-protection-icon-container.
+ await expectFocusAfterKey(
+ "Shift+Tab",
+ "tracking-protection-icon-container"
+ );
+ }
+ );
+});
+
+// Test tab stops with the Bookmarks toolbar visible.
+add_task(async function testTabStopsWithBookmarksToolbar() {
+ await BrowserTestUtils.withNewTab("about:blank", async function () {
+ CustomizableUI.setToolbarVisibility("PersonalToolbar", true);
+ startFromUrlBar();
+ await expectFocusAfterKey("Tab", afterUrlBarButton);
+ await expectFocusAfterKey("Tab", "PersonalToolbar", true);
+ await expectFocusAfterKey("Tab", gBrowser.selectedBrowser);
+
+ // Make sure the Bookmarks toolbar is no longer tabbable once hidden.
+ CustomizableUI.setToolbarVisibility("PersonalToolbar", false);
+ startFromUrlBar();
+ await expectFocusAfterKey("Tab", afterUrlBarButton);
+ await expectFocusAfterKey("Tab", gBrowser.selectedBrowser);
+ });
+});
+
+// Test a focusable toolbartabstop which has no navigable buttons.
+add_task(async function testTabStopNoButtons() {
+ await withNewBlankTab(async function () {
+ // The Back, Forward and Reload buttons are all currently disabled.
+ // The Home button is the only other button at that tab stop.
+ CustomizableUI.removeWidgetFromArea("home-button");
+ startFromUrlBar();
+ await expectFocusAfterKey("Shift+Tab", "tabs-newtab-button");
+ await expectFocusAfterKey("Tab", gURLBar.inputField);
+ resetToolbarWithoutDevEditionButtons();
+ AddHomeBesideReload();
+ // Make sure the button is reachable now that it has been re-added.
+ await expectFocusAfterKey("Shift+Tab", "home-button", true);
+ RemoveHomeButton();
+ });
+});
+
+// Test that right/left arrows move through toolbarbuttons.
+// This also verifies that:
+// 1. Right/left arrows do nothing when at the edges; and
+// 2. The overflow menu button can't be reached by right arrow when it isn't
+// visible.
+add_task(async function testArrowsToolbarbuttons() {
+ AddOldMenuSideButtons();
+ await BrowserTestUtils.withNewTab("about:blank", async function () {
+ startFromUrlBar();
+ await expectFocusAfterKey("Tab", afterUrlBarButton);
+ EventUtils.synthesizeKey("KEY_ArrowLeft");
+ is(
+ document.activeElement.id,
+ afterUrlBarButton,
+ "ArrowLeft at end of button group does nothing"
+ );
+ await expectFocusAfterKey("ArrowRight", "library-button");
+ await expectFocusAfterKey("ArrowRight", "sidebar-button");
+ await expectFocusAfterKey("ArrowRight", "unified-extensions-button");
+ await expectFocusAfterKey("ArrowRight", "fxa-toolbar-menu-button");
+ // This next check also confirms that the overflow menu button is skipped,
+ // since it is currently invisible.
+ await expectFocusAfterKey("ArrowRight", "PanelUI-menu-button");
+ EventUtils.synthesizeKey("KEY_ArrowRight");
+ is(
+ document.activeElement.id,
+ "PanelUI-menu-button",
+ "ArrowRight at end of button group does nothing"
+ );
+ await expectFocusAfterKey("ArrowLeft", "fxa-toolbar-menu-button");
+ await expectFocusAfterKey("ArrowLeft", "unified-extensions-button");
+ await expectFocusAfterKey("ArrowLeft", "sidebar-button");
+ await expectFocusAfterKey("ArrowLeft", "library-button");
+ });
+ RemoveOldMenuSideButtons();
+});
+
+// Test that right/left arrows move through buttons which aren't toolbarbuttons
+// but have role="button".
+add_task(async function testArrowsRoleButton() {
+ await BrowserTestUtils.withNewTab("https://example.com", async function () {
+ startFromUrlBar();
+ await expectFocusAfterKey("Tab", "pageActionButton");
+ await expectFocusAfterKey("ArrowRight", "star-button-box");
+ await expectFocusAfterKey("ArrowLeft", "pageActionButton");
+ });
+});
+
+// Test that right/left arrows do not land on disabled buttons.
+add_task(async function testArrowsDisabledButtons() {
+ await BrowserTestUtils.withNewTab(
+ "https://example.com",
+ async function (aBrowser) {
+ await waitUntilReloadEnabled();
+ startFromUrlBar();
+ await expectFocusAfterKey(
+ "Shift+Tab",
+ "tracking-protection-icon-container"
+ );
+ // Back and Forward buttons are disabled.
+ await expectFocusAfterKey("Shift+Tab", "reload-button");
+ EventUtils.synthesizeKey("KEY_ArrowLeft");
+ is(
+ document.activeElement.id,
+ "reload-button",
+ "ArrowLeft on Reload button when prior buttons disabled does nothing"
+ );
+
+ BrowserTestUtils.loadURIString(aBrowser, "https://example.com/2");
+ await BrowserTestUtils.browserLoaded(aBrowser);
+ await waitUntilReloadEnabled();
+ startFromUrlBar();
+ await expectFocusAfterKey(
+ "Shift+Tab",
+ "tracking-protection-icon-container"
+ );
+ await expectFocusAfterKey("Shift+Tab", "back-button");
+ // Forward button is still disabled.
+ await expectFocusAfterKey("ArrowRight", "reload-button");
+ }
+ );
+});
+
+// Test that right arrow reaches the overflow menu button when it is visible.
+add_task(async function testArrowsOverflowButton() {
+ AddOldMenuSideButtons();
+ await BrowserTestUtils.withNewTab("about:blank", async function () {
+ // Move something to the overflow menu to make the button appear.
+ CustomizableUI.addWidgetToArea(
+ "home-button",
+ CustomizableUI.AREA_FIXED_OVERFLOW_PANEL
+ );
+ startFromUrlBar();
+ await expectFocusAfterKey("Tab", afterUrlBarButton);
+ await expectFocusAfterKey("ArrowRight", "library-button");
+ await expectFocusAfterKey("ArrowRight", "sidebar-button");
+ await expectFocusAfterKey("ArrowRight", "unified-extensions-button");
+ await expectFocusAfterKey("ArrowRight", "fxa-toolbar-menu-button");
+ await expectFocusAfterKey("ArrowRight", "nav-bar-overflow-button");
+ // Make sure the button is not reachable once it is invisible again.
+ await expectFocusAfterKey("ArrowRight", "PanelUI-menu-button");
+ resetToolbarWithoutDevEditionButtons();
+ // Flush layout so its invisibility can be detected.
+ document.getElementById("nav-bar-overflow-button").clientWidth;
+ // We reset the toolbar above so the unified extensions button is now the
+ // "last" button.
+ await expectFocusAfterKey("ArrowLeft", "unified-extensions-button");
+ await expectFocusAfterKey("ArrowLeft", "fxa-toolbar-menu-button");
+ });
+ RemoveOldMenuSideButtons();
+});
+
+// Test that toolbar keyboard navigation doesn't interfere with PanelMultiView
+// keyboard navigation.
+// We do this by opening the Library menu and ensuring that pressing left arrow
+// does nothing.
+add_task(async function testArrowsInPanelMultiView() {
+ AddOldMenuSideButtons();
+ let button = document.getElementById("library-button");
+ forceFocus(button);
+ EventUtils.synthesizeKey(" ");
+ let view = document.getElementById("appMenu-libraryView");
+ let focused = BrowserTestUtils.waitForEvent(view, "focus", true);
+ let focusEvt = await focused;
+ ok(true, "Focus inside Library menu after toolbar button pressed");
+ EventUtils.synthesizeKey("KEY_ArrowLeft");
+ is(
+ document.activeElement,
+ focusEvt.target,
+ "ArrowLeft inside panel does nothing"
+ );
+ let hidden = BrowserTestUtils.waitForEvent(document, "popuphidden", true);
+ view.closest("panel").hidePopup();
+ await hidden;
+ RemoveOldMenuSideButtons();
+});
+
+// Test that right/left arrows move in the expected direction for RTL locales.
+add_task(async function testArrowsRtl() {
+ AddOldMenuSideButtons();
+ await SpecialPowers.pushPrefEnv({ set: [["intl.l10n.pseudo", "bidi"]] });
+ // window.RTL_UI doesn't update in existing windows when this pref is changed,
+ // so we need to test in a new window.
+ let win = await BrowserTestUtils.openNewBrowserWindow();
+ startFromUrlBar(win);
+ await expectFocusAfterKey("Tab", afterUrlBarButton, false, win);
+ EventUtils.synthesizeKey("KEY_ArrowRight", {}, win);
+ is(
+ win.document.activeElement.id,
+ afterUrlBarButton,
+ "ArrowRight at end of button group does nothing"
+ );
+ await expectFocusAfterKey("ArrowLeft", "library-button", false, win);
+ await expectFocusAfterKey("ArrowLeft", "sidebar-button", false, win);
+ await BrowserTestUtils.closeWindow(win);
+ await SpecialPowers.popPrefEnv();
+ RemoveOldMenuSideButtons();
+});
+
+// Test that right arrow reaches the overflow menu button on the Bookmarks
+// toolbar when it is visible.
+add_task(async function testArrowsBookmarksOverflowButton() {
+ let toolbar = gNavToolbox.querySelector("#PersonalToolbar");
+ // Third parameter is 'persist' and true is the default.
+ // Fourth parameter is 'animated' and we want no animation.
+ setToolbarVisibility(toolbar, true, true, false);
+ Assert.ok(!toolbar.collapsed, "toolbar should be visible");
+
+ await BrowserTestUtils.waitForEvent(
+ toolbar,
+ "BookmarksToolbarVisibilityUpdated"
+ );
+ let items = document.getElementById("PlacesToolbarItems").children;
+ let lastVisible;
+ for (let item of items) {
+ if (item.style.visibility == "hidden") {
+ break;
+ }
+ lastVisible = item;
+ }
+ forceFocus(lastVisible);
+ await expectFocusAfterKey("ArrowRight", "PlacesChevron");
+ setToolbarVisibility(toolbar, false, true, false);
+});
+
+registerCleanupFunction(async function () {
+ CustomizableUI.reset();
+ await PlacesUtils.bookmarks.eraseEverything();
+});
+
+// Test that when a toolbar button opens a panel, closing the panel restores
+// focus to the button which opened it.
+add_task(async function testPanelCloseRestoresFocus() {
+ AddOldMenuSideButtons();
+ await withNewBlankTab(async function () {
+ // We can't use forceFocus because that removes focusability immediately.
+ // Instead, we must let ToolbarKeyboardNavigator handle this properly.
+ startFromUrlBar();
+ await expectFocusAfterKey("Tab", afterUrlBarButton);
+ await expectFocusAfterKey("ArrowRight", "library-button");
+ let view = document.getElementById("appMenu-libraryView");
+ let shown = BrowserTestUtils.waitForEvent(view, "ViewShown");
+ EventUtils.synthesizeKey(" ");
+ await shown;
+ let hidden = BrowserTestUtils.waitForEvent(document, "popuphidden", true);
+ view.closest("panel").hidePopup();
+ await hidden;
+ is(
+ document.activeElement.id,
+ "library-button",
+ "Focus restored to Library button after panel closed"
+ );
+ });
+ RemoveOldMenuSideButtons();
+});
+
+// Test that the arrow key works in the group of the
+// 'tracking-protection-icon-container' and the 'identity-box'.
+add_task(async function testArrowKeyForTPIconContainerandIdentityBox() {
+ await BrowserTestUtils.withNewTab(
+ "https://example.com",
+ async function (browser) {
+ // Simulate geo sharing so the permission box shows
+ gBrowser.updateBrowserSharing(browser, { geo: true });
+ await waitUntilReloadEnabled();
+ startFromUrlBar();
+ await expectFocusAfterKey(
+ "Shift+Tab",
+ "tracking-protection-icon-container"
+ );
+ await expectFocusAfterKey("ArrowRight", "identity-icon-box");
+ await expectFocusAfterKey("ArrowRight", "identity-permission-box");
+ await expectFocusAfterKey("ArrowLeft", "identity-icon-box");
+ await expectFocusAfterKey(
+ "ArrowLeft",
+ "tracking-protection-icon-container"
+ );
+ gBrowser.updateBrowserSharing(browser, { geo: false });
+ }
+ );
+});
+
+// Test navigation by typed characters.
+add_task(async function testCharacterNavigation() {
+ AddHomeBesideReload();
+ AddOldMenuSideButtons();
+ await BrowserTestUtils.withNewTab("https://example.com", async function () {
+ await waitUntilReloadEnabled();
+ startFromUrlBar();
+ await expectFocusAfterKey("Tab", "pageActionButton");
+ await expectFocusAfterKey("h", "home-button");
+ // There's no button starting with "hs", so pressing s should do nothing.
+ EventUtils.synthesizeKey("s");
+ is(
+ document.activeElement.id,
+ "home-button",
+ "home-button still focused after s pressed"
+ );
+ // Escape should reset the search.
+ EventUtils.synthesizeKey("KEY_Escape");
+ // Now that the search is reset, pressing s should focus Save to Pocket.
+ await expectFocusAfterKey("s", "save-to-pocket-button");
+ // Pressing i makes the search "si", so it should focus Sidebars.
+ await expectFocusAfterKey("i", "sidebar-button");
+ // Reset the search.
+ EventUtils.synthesizeKey("KEY_Escape");
+ await expectFocusAfterKey("s", "save-to-pocket-button");
+ // Pressing s again should find the next button starting with s: Sidebars.
+ await expectFocusAfterKey("s", "sidebar-button");
+ });
+ RemoveHomeButton();
+ RemoveOldMenuSideButtons();
+});
+
+// Test that toolbar character navigation doesn't trigger in PanelMultiView for
+// a panel anchored to the toolbar.
+// We do this by opening the Library menu and ensuring that pressing s
+// does nothing.
+// This test should be removed if PanelMultiView implements character
+// navigation.
+add_task(async function testCharacterInPanelMultiView() {
+ AddOldMenuSideButtons();
+ let button = document.getElementById("library-button");
+ forceFocus(button);
+ let view = document.getElementById("appMenu-libraryView");
+ let focused = BrowserTestUtils.waitForEvent(view, "focus", true);
+ EventUtils.synthesizeKey(" ");
+ let focusEvt = await focused;
+ ok(true, "Focus inside Library menu after toolbar button pressed");
+ EventUtils.synthesizeKey("s");
+ is(document.activeElement, focusEvt.target, "s inside panel does nothing");
+ let hidden = BrowserTestUtils.waitForEvent(document, "popuphidden", true);
+ view.closest("panel").hidePopup();
+ await hidden;
+ RemoveOldMenuSideButtons();
+});
+
+// Test tab stops after the search bar is added.
+add_task(async function testTabStopsAfterSearchBarAdded() {
+ AddOldMenuSideButtons();
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.search.widget.inNavBar", 1]],
+ });
+ await withNewBlankTab(async function () {
+ startFromUrlBar();
+ await expectFocusAfterKey("Tab", "searchbar", true);
+ await expectFocusAfterKey("Tab", afterUrlBarButton);
+ await expectFocusAfterKey("ArrowRight", "library-button");
+ });
+ await SpecialPowers.popPrefEnv();
+ RemoveOldMenuSideButtons();
+});
+
+// Test tab navigation when the Firefox View button is present
+// and when the button is not present.
+add_task(async function testFirefoxViewButtonNavigation() {
+ // Add enough tabs so that the new-tab-button appears in the toolbar
+ // and the tabs-newtab-button is hidden.
+ await BrowserTestUtils.overflowTabs(registerCleanupFunction, window);
+
+ // Assert that Firefox View button receives focus when tab navigating
+ // forward from the end of web content.
+ // Additionally, ensure that focus is not trapped between the
+ // selected tab and the new-tab button.
+ // Finally, assert that focus is restored to web content when
+ // navigating backwards from the Firefox View button.
+ await BrowserTestUtils.withNewTab(
+ PERMISSIONS_PAGE,
+ async function (aBrowser) {
+ await SpecialPowers.spawn(aBrowser, [], async () => {
+ content.document.querySelector("#camera").focus();
+ });
+
+ await expectFocusAfterKey("Tab", "firefox-view-button");
+ let selectedTab = document.querySelector("tab[selected]");
+ await expectFocusAfterKey("Tab", selectedTab);
+ await expectFocusAfterKey("Tab", "new-tab-button");
+ await expectFocusAfterKey("Shift+Tab", selectedTab);
+ await expectFocusAfterKey("Shift+Tab", "firefox-view-button");
+
+ // Moving from toolbar back into content
+ EventUtils.synthesizeKey("KEY_Tab", { shiftKey: true });
+ await SpecialPowers.spawn(aBrowser, [], async () => {
+ let activeElement = content.document.activeElement;
+ let expectedElement = content.document.querySelector("#camera");
+ is(
+ activeElement,
+ expectedElement,
+ "Focus should be returned to the 'camera' button"
+ );
+ });
+ }
+ );
+
+ // Assert that the selected tab receives focus before the new-tab button
+ // if there is no Firefox View button.
+ // Additionally, assert that navigating backwards from the selected tab
+ // restores focus to the last element in the web content.
+ await BrowserTestUtils.withNewTab(
+ PERMISSIONS_PAGE,
+ async function (aBrowser) {
+ removeFirefoxViewButton();
+
+ await SpecialPowers.spawn(aBrowser, [], async () => {
+ content.document.querySelector("#camera").focus();
+ });
+
+ let selectedTab = document.querySelector("tab[selected]");
+ await expectFocusAfterKey("Tab", selectedTab);
+ await expectFocusAfterKey("Tab", "new-tab-button");
+ await expectFocusAfterKey("Shift+Tab", selectedTab);
+
+ // Moving from toolbar back into content
+ EventUtils.synthesizeKey("KEY_Tab", { shiftKey: true });
+ await SpecialPowers.spawn(aBrowser, [], async () => {
+ let activeElement = content.document.activeElement;
+ let expectedElement = content.document.querySelector("#camera");
+ is(
+ activeElement,
+ expectedElement,
+ "Focus should be returned to the 'camera' button"
+ );
+ });
+ }
+ );
+
+ // Clean up extra tabs
+ while (gBrowser.tabs.length > 1) {
+ BrowserTestUtils.removeTab(gBrowser.tabs[0]);
+ }
+ CustomizableUI.reset();
+});
diff --git a/browser/base/content/test/keyboard/file_empty.html b/browser/base/content/test/keyboard/file_empty.html
new file mode 100644
index 0000000000..d2b0361f09
--- /dev/null
+++ b/browser/base/content/test/keyboard/file_empty.html
@@ -0,0 +1,8 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <title>Page left intentionally blank...</title>
+ </head>
+ <body>
+ </body>
+</html>
diff --git a/browser/base/content/test/keyboard/focusableContent.html b/browser/base/content/test/keyboard/focusableContent.html
new file mode 100644
index 0000000000..255512645c
--- /dev/null
+++ b/browser/base/content/test/keyboard/focusableContent.html
@@ -0,0 +1 @@
+<button>Just a button here to have something focusable.</button>
diff --git a/browser/base/content/test/keyboard/head.js b/browser/base/content/test/keyboard/head.js
new file mode 100644
index 0000000000..9d6f901f2c
--- /dev/null
+++ b/browser/base/content/test/keyboard/head.js
@@ -0,0 +1,55 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Force focus to an element that isn't focusable.
+ * Toolbar buttons aren't focusable because if they were, clicking them would
+ * focus them, which is undesirable. Therefore, they're only made focusable
+ * when a user is navigating with the keyboard. This function forces focus as
+ * is done during toolbar keyboard navigation.
+ */
+function forceFocus(aElem) {
+ aElem.setAttribute("tabindex", "-1");
+ aElem.focus();
+ aElem.removeAttribute("tabindex");
+}
+
+async function expectFocusAfterKey(
+ aKey,
+ aFocus,
+ aAncestorOk = false,
+ aWindow = window
+) {
+ let res = aKey.match(/^(Shift\+)?(?:(.)|(.+))$/);
+ let shift = Boolean(res[1]);
+ let key;
+ if (res[2]) {
+ key = res[2]; // Character.
+ } else {
+ key = "KEY_" + res[3]; // Tab, ArrowRight, etc.
+ }
+ let expected;
+ let friendlyExpected;
+ if (typeof aFocus == "string") {
+ expected = aWindow.document.getElementById(aFocus);
+ friendlyExpected = aFocus;
+ } else {
+ expected = aFocus;
+ if (aFocus == aWindow.gURLBar.inputField) {
+ friendlyExpected = "URL bar input";
+ } else if (aFocus == aWindow.gBrowser.selectedBrowser) {
+ friendlyExpected = "Web document";
+ }
+ }
+ info("Listening on item " + (expected.id || expected.className));
+ let focused = BrowserTestUtils.waitForEvent(expected, "focus", aAncestorOk);
+ EventUtils.synthesizeKey(key, { shiftKey: shift }, aWindow);
+ let receivedEvent = await focused;
+ info(
+ "Got focus on item: " +
+ (receivedEvent.target.id || receivedEvent.target.className)
+ );
+ ok(true, friendlyExpected + " focused after " + aKey + " pressed");
+}
diff --git a/browser/base/content/test/menubar/browser.ini b/browser/base/content/test/menubar/browser.ini
new file mode 100644
index 0000000000..e32dc6dffc
--- /dev/null
+++ b/browser/base/content/test/menubar/browser.ini
@@ -0,0 +1,9 @@
+[DEFAULT]
+
+[browser_file_close_tabs.js]
+[browser_file_menu_import_wizard.js]
+[browser_file_share.js]
+https_first_disabled = true
+run-if = os == "mac" # Mac only feature
+support-files =
+ file_shareurl.html
diff --git a/browser/base/content/test/menubar/browser_file_close_tabs.js b/browser/base/content/test/menubar/browser_file_close_tabs.js
new file mode 100644
index 0000000000..15abd92bba
--- /dev/null
+++ b/browser/base/content/test/menubar/browser_file_close_tabs.js
@@ -0,0 +1,60 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+/**
+ * This test verifies behavior from bug 1732375:
+ * https://bugzilla.mozilla.org/show_bug.cgi?id=1732375
+ *
+ * If there are multiple tabs selected, the 'Close' entry
+ * under the File menu should correctly reflect the number of
+ * selected tabs
+ */
+add_task(async function test_menu_close_tab_count() {
+ // Window should have one tab open already, so we
+ // just need to add one more to have a total of two
+ info("Adding new tabs");
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ await BrowserTestUtils.openNewForegroundTab(gBrowser, "http://example.com");
+
+ info("Selecting all tabs");
+ await gBrowser.selectAllTabs();
+ is(gBrowser.multiSelectedTabsCount, 2, "Two (2) tabs are selected");
+
+ let fileMenu = document.getElementById("menu_FilePopup");
+ await simulateMenuOpen(fileMenu);
+
+ let closeMenuEntry = document.getElementById("menu_close");
+ let closeMenuL10nArgsObject = document.l10n.getAttributes(closeMenuEntry);
+
+ is(
+ closeMenuL10nArgsObject.args.tabCount,
+ 2,
+ "Menu bar reflects multi-tab selection number (Close 2 Tabs)"
+ );
+
+ let onClose = BrowserTestUtils.waitForEvent(
+ gBrowser.tabContainer,
+ "TabClose"
+ );
+
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+
+ await onClose;
+
+ info("Tabs closed");
+});
+
+async function simulateMenuOpen(menu) {
+ return new Promise(resolve => {
+ menu.addEventListener("popupshown", resolve, { once: true });
+ menu.dispatchEvent(new MouseEvent("popupshowing"));
+ menu.dispatchEvent(new MouseEvent("popupshown"));
+ });
+}
+
+async function simulateMenuClosed(menu) {
+ return new Promise(resolve => {
+ menu.addEventListener("popuphidden", resolve, { once: true });
+ menu.dispatchEvent(new MouseEvent("popuphiding"));
+ menu.dispatchEvent(new MouseEvent("popuphidden"));
+ });
+}
diff --git a/browser/base/content/test/menubar/browser_file_menu_import_wizard.js b/browser/base/content/test/menubar/browser_file_menu_import_wizard.js
new file mode 100644
index 0000000000..7783d59da7
--- /dev/null
+++ b/browser/base/content/test/menubar/browser_file_menu_import_wizard.js
@@ -0,0 +1,27 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_setup(async () => {
+ // Load the initial tab at example.com. This makes it so that if
+ // we're using the new migration wizard, we'll load the about:preferences
+ // page in a new tab rather than overtaking the initial one. This
+ // makes it easier to be consistent with closing and opening
+ // behaviours between the two kinds of migration wizards.
+ let browser = gBrowser.selectedBrowser;
+ BrowserTestUtils.loadURIString(browser, "https://example.com");
+ await BrowserTestUtils.browserLoaded(browser);
+});
+
+add_task(async function file_menu_import_wizard() {
+ // We can't call this code directly or our JS execution will get blocked on Windows/Linux where
+ // the dialog is modal.
+ executeSoon(() =>
+ document.getElementById("menu_importFromAnotherBrowser").doCommand()
+ );
+
+ let wizard = await BrowserTestUtils.waitForMigrationWizard(window);
+ ok(wizard, "Migrator window opened");
+ await BrowserTestUtils.closeMigrationWizard(wizard);
+});
diff --git a/browser/base/content/test/menubar/browser_file_share.js b/browser/base/content/test/menubar/browser_file_share.js
new file mode 100644
index 0000000000..bd6a4c3f60
--- /dev/null
+++ b/browser/base/content/test/menubar/browser_file_share.js
@@ -0,0 +1,136 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { sinon } = ChromeUtils.importESModule(
+ "resource://testing-common/Sinon.sys.mjs"
+);
+const BASE = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com"
+);
+const TEST_URL = BASE + "file_shareurl.html";
+
+let mockShareData = [
+ {
+ name: "Test",
+ menuItemTitle: "Sharing Service Test",
+ image:
+ "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAIAAAD8GO2jAAAAKE" +
+ "lEQVR42u3NQQ0AAAgEoNP+nTWFDzcoQE1udQQCgUAgEAgEAsGTYAGjxAE/G/Q2tQAAAABJRU5ErkJggg==",
+ },
+];
+
+// Setup spies for observing function calls from MacSharingService
+let shareUrlSpy = sinon.spy();
+let openSharingPreferencesSpy = sinon.spy();
+let getSharingProvidersSpy = sinon.spy();
+
+let stub = sinon.stub(gBrowser, "MacSharingService").get(() => {
+ return {
+ getSharingProviders(url) {
+ getSharingProvidersSpy(url);
+ return mockShareData;
+ },
+ shareUrl(name, url, title) {
+ shareUrlSpy(name, url, title);
+ },
+ openSharingPreferences() {
+ openSharingPreferencesSpy();
+ },
+ };
+});
+
+registerCleanupFunction(async function () {
+ stub.restore();
+});
+
+/**
+ * Test the "Share" item menus in the tab contextmenu on MacOSX.
+ */
+add_task(async function test_file_menu_share() {
+ await BrowserTestUtils.withNewTab(TEST_URL, async () => {
+ // We can't toggle menubar items on OSX, so mocking instead.
+ let menu = document.getElementById("menu_FilePopup");
+ await simulateMenuOpen(menu);
+
+ await BrowserTestUtils.waitForMutationCondition(
+ menu,
+ { childList: true },
+ () => menu.querySelector(".share-tab-url-item")
+ );
+ ok(true, "Got Share item");
+
+ let popup = menu.querySelector(".share-tab-url-item").menupopup;
+ await simulateMenuOpen(popup);
+ ok(getSharingProvidersSpy.calledOnce, "getSharingProviders called");
+
+ info(
+ "Check we have a service and one extra menu item for the More... button"
+ );
+ let items = popup.querySelectorAll("menuitem");
+ is(items.length, 2, "There should be 2 sharing services.");
+
+ info("Click on the sharing service");
+ let shareButton = items[0];
+ is(
+ shareButton.label,
+ mockShareData[0].menuItemTitle,
+ "Share button's label should match the service's menu item title. "
+ );
+ is(
+ shareButton.getAttribute("share-name"),
+ mockShareData[0].name,
+ "Share button's share-name value should match the service's name. "
+ );
+
+ shareButton.doCommand();
+
+ ok(shareUrlSpy.calledOnce, "shareUrl called");
+
+ info("Check the correct data was shared.");
+ let [name, url, title] = shareUrlSpy.getCall(0).args;
+ is(name, mockShareData[0].name, "Shared correct service name");
+ is(url, TEST_URL, "Shared correct URL");
+ is(title, "Sharing URL", "Shared the correct title.");
+ await simulateMenuClosed(popup);
+ await simulateMenuClosed(menu);
+
+ info("Test the More... button");
+
+ await simulateMenuOpen(menu);
+ popup = menu.querySelector(".share-tab-url-item").menupopup;
+ await simulateMenuOpen(popup);
+ // Since the menu was collapsed previously, the popup needs to get the
+ // providers again.
+ ok(getSharingProvidersSpy.calledTwice, "getSharingProviders called again");
+ items = popup.querySelectorAll("menuitem");
+ is(items.length, 2, "There should be 2 sharing services.");
+
+ info("Click on the More Button");
+ let moreButton = items[1];
+ moreButton.doCommand();
+ ok(openSharingPreferencesSpy.calledOnce, "openSharingPreferences called");
+ // Tidy up:
+ await simulateMenuClosed(popup);
+ await simulateMenuClosed(menu);
+ });
+});
+
+async function simulateMenuOpen(menu) {
+ return new Promise(resolve => {
+ menu.addEventListener("popupshown", resolve, { once: true });
+ menu.dispatchEvent(new MouseEvent("popupshowing"));
+ menu.dispatchEvent(new MouseEvent("popupshown"));
+ });
+}
+
+async function simulateMenuClosed(menu) {
+ return new Promise(resolve => {
+ menu.addEventListener("popuphidden", resolve, { once: true });
+ menu.dispatchEvent(new MouseEvent("popuphiding"));
+ menu.dispatchEvent(new MouseEvent("popuphidden"));
+ });
+}
diff --git a/browser/base/content/test/menubar/file_shareurl.html b/browser/base/content/test/menubar/file_shareurl.html
new file mode 100644
index 0000000000..c7fb193972
--- /dev/null
+++ b/browser/base/content/test/menubar/file_shareurl.html
@@ -0,0 +1,2 @@
+<!DOCTYPE html>
+<title>Sharing URL</title>
diff --git a/browser/base/content/test/metaTags/bad_meta_tags.html b/browser/base/content/test/metaTags/bad_meta_tags.html
new file mode 100644
index 0000000000..ce687d7792
--- /dev/null
+++ b/browser/base/content/test/metaTags/bad_meta_tags.html
@@ -0,0 +1,14 @@
+<html>
+ <head>
+ <meta charset="UTF-8" />
+ <title>BadMetaTags</title>
+ <meta property="twitter:image" content="http://test.com/twitter-image.jpg" />
+ <meta property="og:image:url" content="ftp://test.com/og-image-url" />
+ <meta property="og:image" content="file:///Users/invalid/img.jpg" />
+ <meta property="twitter:description" />
+ <meta property="og:description" content="" />
+ <meta name="description" content="description" />
+ </head>
+ <body>
+ </body>
+</html>
diff --git a/browser/base/content/test/metaTags/browser.ini b/browser/base/content/test/metaTags/browser.ini
new file mode 100644
index 0000000000..4468d331f0
--- /dev/null
+++ b/browser/base/content/test/metaTags/browser.ini
@@ -0,0 +1,9 @@
+[DEFAULT]
+support-files =
+ head.js
+
+[browser_bad_meta_tags.js]
+support-files = bad_meta_tags.html
+[browser_meta_tags.js]
+skip-if = tsan # Bug 1403403
+support-files = meta_tags.html
diff --git a/browser/base/content/test/metaTags/browser_bad_meta_tags.js b/browser/base/content/test/metaTags/browser_bad_meta_tags.js
new file mode 100644
index 0000000000..00cc128ec0
--- /dev/null
+++ b/browser/base/content/test/metaTags/browser_bad_meta_tags.js
@@ -0,0 +1,37 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+const TEST_PATH =
+ getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+ ) + "bad_meta_tags.html";
+
+/**
+ * This tests that with the page bad_meta_tags.html, ContentMetaHandler.jsm parses
+ * out the meta tags available and does not store content provided by a malformed
+ * meta tag. In this case the best defined meta tags are malformed, so here we
+ * test that we store the next best ones - "description" and "twitter:image". The
+ * list of meta tags and order of preference is found in ContentMetaHandler.jsm.
+ */
+add_task(async function test_bad_meta_tags() {
+ const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_PATH);
+
+ // Wait until places has stored the page info
+ const pageInfo = await waitForPageInfo(TEST_PATH);
+ is(
+ pageInfo.description,
+ "description",
+ "did not collect a og:description because meta tag was malformed"
+ );
+ is(
+ pageInfo.previewImageURL.href,
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://test.com/twitter-image.jpg",
+ "did not collect og:image because of invalid loading principal"
+ );
+
+ BrowserTestUtils.removeTab(tab);
+ await PlacesUtils.history.clear();
+});
diff --git a/browser/base/content/test/metaTags/browser_meta_tags.js b/browser/base/content/test/metaTags/browser_meta_tags.js
new file mode 100644
index 0000000000..380a71214c
--- /dev/null
+++ b/browser/base/content/test/metaTags/browser_meta_tags.js
@@ -0,0 +1,57 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+const TEST_PATH =
+ getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+ ) + "meta_tags.html";
+/**
+ * This tests that with the page meta_tags.html, ContentMetaHandler.jsm parses
+ * out the meta tags avilable and only stores the best one for description and
+ * one for preview image url. In the case of this test, the best defined meta
+ * tags are "og:description" and "og:image:secure_url". The list of meta tags
+ * and order of preference is found in ContentMetaHandler.jsm.
+ */
+add_task(async function test_metadata() {
+ const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_PATH);
+
+ // Wait until places has stored the page info
+ const pageInfo = await waitForPageInfo(TEST_PATH);
+ is(pageInfo.description, "og:description", "got the correct description");
+ is(
+ pageInfo.previewImageURL.href,
+ "https://test.com/og-image-secure-url.jpg",
+ "got the correct preview image"
+ );
+
+ BrowserTestUtils.removeTab(tab);
+ await PlacesUtils.history.clear();
+});
+
+/**
+ * This test is almost like the previous one except it opens a second tab to
+ * make sure the extra tab does not cause the debounce logic to be skipped. If
+ * incorrectly skipped, the updated metadata would not include the delayed meta.
+ */
+add_task(async function multiple_tabs() {
+ const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_PATH);
+
+ // Add a background tab to cause another page to load *without* putting the
+ // desired URL in a background tab, which results in its timers being throttled.
+ BrowserTestUtils.addTab(gBrowser);
+
+ // Wait until places has stored the page info
+ const pageInfo = await waitForPageInfo(TEST_PATH);
+ is(pageInfo.description, "og:description", "got the correct description");
+ is(
+ pageInfo.previewImageURL.href,
+ "https://test.com/og-image-secure-url.jpg",
+ "got the correct preview image"
+ );
+
+ BrowserTestUtils.removeTab(tab);
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ await PlacesUtils.history.clear();
+});
diff --git a/browser/base/content/test/metaTags/head.js b/browser/base/content/test/metaTags/head.js
new file mode 100644
index 0000000000..1f292c4c03
--- /dev/null
+++ b/browser/base/content/test/metaTags/head.js
@@ -0,0 +1,19 @@
+ChromeUtils.defineESModuleGetters(this, {
+ PlacesTestUtils: "resource://testing-common/PlacesTestUtils.sys.mjs",
+ PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
+});
+
+/**
+ * Wait for url's page info (non-null description and preview url) to be set.
+ * Because there is debounce logic in ContentLinkHandler.jsm to only make one
+ * single SQL update, we have to wait for some time before checking that the page
+ * info was stored.
+ */
+async function waitForPageInfo(url) {
+ let pageInfo;
+ await BrowserTestUtils.waitForCondition(async () => {
+ pageInfo = await PlacesUtils.history.fetch(url, { includeMeta: true });
+ return pageInfo && pageInfo.description && pageInfo.previewImageURL;
+ });
+ return pageInfo;
+}
diff --git a/browser/base/content/test/metaTags/meta_tags.html b/browser/base/content/test/metaTags/meta_tags.html
new file mode 100644
index 0000000000..ad162da1f5
--- /dev/null
+++ b/browser/base/content/test/metaTags/meta_tags.html
@@ -0,0 +1,29 @@
+<!DOCTYPE HTML>
+<html>
+ <head>
+ <meta charset="UTF-8" />
+ <title>MetaTags</title>
+ <meta property="twitter:description" content="twitter:description" />
+ <meta property="og:description" content="og:description" />
+ <meta name="description" content="description" />
+ <meta name="unknown:tag" content="unknown:tag" />
+ <meta property="og:image" content="https://test.com/og-image.jpg" />
+ <meta property="twitter:image" content="https://test.com/twitter-image.jpg" />
+ <meta property="og:image:url" content="https://test.com/og-image-url" />
+ <meta name="thumbnail" content="https://test.com/thumbnail.jpg" />
+ </head>
+ <body>
+ <script>
+ function addMeta(tag) {
+ const meta = document.createElement("meta");
+ meta.content = "https://test.com/og-image-secure-url.jpg";
+ meta.setAttribute("property", tag);
+ document.head.appendChild(meta);
+ }
+
+ // Delay adding this "best" image tag to test that later tags are used.
+ // Use a delay that is long enough for tests to check for wrong metadata.
+ setTimeout(() => addMeta("og:image:secure_url"), 100);
+ </script>
+ </body>
+</html>
diff --git a/browser/base/content/test/notificationbox/browser.ini b/browser/base/content/test/notificationbox/browser.ini
new file mode 100644
index 0000000000..f5d296ca10
--- /dev/null
+++ b/browser/base/content/test/notificationbox/browser.ini
@@ -0,0 +1,3 @@
+[browser_notification_stacking.js]
+[browser_notificationbar_telemetry.js]
+[browser_tabnotificationbox_switch_tabs.js]
diff --git a/browser/base/content/test/notificationbox/browser_notification_stacking.js b/browser/base/content/test/notificationbox/browser_notification_stacking.js
new file mode 100644
index 0000000000..bd8817ea4b
--- /dev/null
+++ b/browser/base/content/test/notificationbox/browser_notification_stacking.js
@@ -0,0 +1,78 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+async function addNotification(box, label, value, priorityName) {
+ let added = BrowserTestUtils.waitForNotificationInNotificationBox(box, value);
+ let priority = gNotificationBox[`PRIORITY_${priorityName}_MEDIUM`];
+ let notification = box.appendNotification(value, { label, priority });
+ await added;
+ return notification;
+}
+
+add_task(async function testStackingOrder() {
+ const tabNotificationBox = gBrowser.getNotificationBox();
+ ok(
+ gNotificationBox.stack.hasAttribute("prepend-notifications"),
+ "Browser stack will prepend"
+ );
+ ok(
+ !tabNotificationBox.stack.hasAttribute("prepend-notifications"),
+ "Tab stack will append"
+ );
+
+ let browserOne = await addNotification(
+ gNotificationBox,
+ "My first browser notification",
+ "browser-one",
+ "INFO"
+ );
+
+ let tabOne = await addNotification(
+ tabNotificationBox,
+ "My first tab notification",
+ "tab-one",
+ "CRITICAL"
+ );
+
+ let browserTwo = await addNotification(
+ gNotificationBox,
+ "My second browser notification",
+ "browser-two",
+ "CRITICAL"
+ );
+ let browserThree = await addNotification(
+ gNotificationBox,
+ "My third browser notification",
+ "browser-three",
+ "WARNING"
+ );
+
+ let tabTwo = await addNotification(
+ tabNotificationBox,
+ "My second tab notification",
+ "tab-two",
+ "INFO"
+ );
+ let tabThree = await addNotification(
+ tabNotificationBox,
+ "My third tab notification",
+ "tab-three",
+ "WARNING"
+ );
+
+ Assert.deepEqual(
+ [browserThree, browserTwo, browserOne],
+ [...gNotificationBox.stack.children],
+ "Browser notifications prepended"
+ );
+ Assert.deepEqual(
+ [tabOne, tabTwo, tabThree],
+ [...tabNotificationBox.stack.children],
+ "Tab notifications appended"
+ );
+
+ gNotificationBox.removeAllNotifications(true);
+ tabNotificationBox.removeAllNotifications(true);
+});
diff --git a/browser/base/content/test/notificationbox/browser_notificationbar_telemetry.js b/browser/base/content/test/notificationbox/browser_notificationbar_telemetry.js
new file mode 100644
index 0000000000..7810d4022d
--- /dev/null
+++ b/browser/base/content/test/notificationbox/browser_notificationbar_telemetry.js
@@ -0,0 +1,219 @@
+const TELEMETRY_BASE = "notificationbar.";
+
+ChromeUtils.defineESModuleGetters(this, {
+ TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs",
+});
+
+add_task(async function showNotification() {
+ Services.telemetry.clearScalars();
+
+ let tab1 = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "https://example.com/"
+ );
+
+ ok(!gBrowser.readNotificationBox(), "no notificationbox created yet");
+
+ let box1 = gBrowser.getNotificationBox();
+
+ ok(gBrowser.readNotificationBox(), "notificationbox was created");
+
+ let tab2 = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "https://example.org/"
+ );
+
+ let tab3 = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "data:text/html,<body>Hello</body>"
+ );
+ let box3 = gBrowser.getNotificationBox();
+
+ verifyTelemetry("initial", 0, 0, 0, 0, 0, 0);
+
+ let notif3 = box3.appendNotification("infobar-testtwo-value", {
+ label: "Message for tab 3",
+ priority: box3.PRIORITY_INFO_HIGH,
+ telemetry: TELEMETRY_BASE + "testtwo",
+ });
+
+ verifyTelemetry("first notification", 0, 0, 0, 0, 0, 1);
+
+ let notif1 = box1.appendNotification(
+ "infobar-testone-value",
+ {
+ label: "Message for tab 1",
+ priority: box1.PRIORITY_INFO_HIGH,
+ telemetry: TELEMETRY_BASE + "testone",
+ },
+ [
+ {
+ label: "Button1",
+ telemetry: "button1-pressed",
+ },
+ {
+ label: "Button2",
+ telemetry: "button2-pressed",
+ },
+ {
+ label: "Button3",
+ },
+ ]
+ );
+ verifyTelemetry("second notification", 0, 0, 0, 0, 0, 1);
+
+ await BrowserTestUtils.switchTab(gBrowser, tab1);
+ verifyTelemetry("switch to first tab", 1, 0, 0, 0, 0, 1);
+
+ await BrowserTestUtils.switchTab(gBrowser, tab2);
+ verifyTelemetry("switch to second tab", 1, 0, 0, 0, 0, 1);
+
+ await BrowserTestUtils.switchTab(gBrowser, tab3);
+ verifyTelemetry("switch to third tab", 1, 0, 0, 0, 0, 1);
+
+ await BrowserTestUtils.switchTab(gBrowser, tab1);
+ verifyTelemetry("switch to first tab again", 1, 0, 0, 0, 0, 1);
+
+ notif1.buttonContainer.lastElementChild.click();
+ verifyTelemetry("press third button", 1, 1, 0, 0, 0, 1);
+
+ notif1.buttonContainer.lastElementChild.previousElementSibling.click();
+ verifyTelemetry("press second button", 1, 1, 0, 1, 0, 1);
+
+ notif1.buttonContainer.lastElementChild.previousElementSibling.previousElementSibling.click();
+ verifyTelemetry("press first button", 1, 1, 1, 1, 0, 1);
+
+ notif1.dismiss();
+ verifyTelemetry("dismiss notification for box 1", 1, 1, 1, 1, 1, 1);
+
+ notif3.dismiss();
+ verifyTelemetry("dismiss notification for box 3", 1, 1, 1, 1, 1, 1, 1);
+
+ let notif4 = box1.appendNotification(
+ "infobar-testtwo-value",
+ {
+ label: "Additional message for tab 1",
+ priority: box1.PRIORITY_INFO_HIGH,
+ telemetry: TELEMETRY_BASE + "testone",
+ telemetryFilter: ["shown"],
+ },
+ [
+ {
+ label: "Button1",
+ },
+ ]
+ );
+ verifyTelemetry("show first filtered notification", 2, 1, 1, 1, 1, 1, 1);
+
+ notif4.buttonContainer.lastElementChild.click();
+ notif4.dismiss();
+ verifyTelemetry("dismiss first filtered notification", 2, 1, 1, 1, 1, 1, 1);
+
+ let notif5 = box1.appendNotification(
+ "infobar-testtwo-value",
+ {
+ label: "Dimissed additional message for tab 1",
+ priority: box1.PRIORITY_INFO_HIGH,
+ telemetry: TELEMETRY_BASE + "testone",
+ telemetryFilter: ["dismissed"],
+ },
+ [
+ {
+ label: "Button1",
+ },
+ ]
+ );
+ verifyTelemetry("show second filtered notification", 2, 1, 1, 1, 1, 1, 1);
+
+ notif5.buttonContainer.lastElementChild.click();
+ notif5.dismiss();
+ verifyTelemetry("dismiss second filtered notification", 2, 1, 1, 1, 2, 1, 1);
+
+ let notif6 = box1.appendNotification(
+ "infobar-testtwo-value",
+ {
+ label: "Dimissed additional message for tab 1",
+ priority: box1.PRIORITY_INFO_HIGH,
+ telemetry: TELEMETRY_BASE + "testone",
+ telemetryFilter: ["button1-pressed", "dismissed"],
+ },
+ [
+ {
+ label: "Button1",
+ telemetry: "button1-pressed",
+ },
+ ]
+ );
+ verifyTelemetry("show third filtered notification", 2, 1, 1, 1, 2, 1, 1);
+
+ notif6.buttonContainer.lastElementChild.click();
+ verifyTelemetry(
+ "click button in third filtered notification",
+ 2,
+ 1,
+ 2,
+ 1,
+ 2,
+ 1,
+ 1
+ );
+ notif6.dismiss();
+ verifyTelemetry("dismiss third filtered notification", 2, 1, 2, 1, 3, 1, 1);
+
+ BrowserTestUtils.removeTab(tab1);
+ BrowserTestUtils.removeTab(tab2);
+ BrowserTestUtils.removeTab(tab3);
+});
+
+function verify(scalars, scalar, key, expected, exists) {
+ scalar = TELEMETRY_BASE + scalar;
+
+ if (expected > 0) {
+ TelemetryTestUtils.assertKeyedScalar(scalars, scalar, key, expected);
+ return;
+ }
+
+ Assert.equal(
+ scalar in scalars,
+ exists,
+ `expected ${scalar} to be ${exists ? "present" : "unset"}`
+ );
+
+ if (exists) {
+ Assert.ok(
+ !(key in scalars[scalar]),
+ "expected key " + key + " to be unset"
+ );
+ }
+}
+
+function verifyTelemetry(
+ desc,
+ box1shown,
+ box1action,
+ box1button1,
+ box1button2,
+ box1dismissed,
+ box3shown,
+ box3dismissed = 0
+) {
+ let scalars = TelemetryTestUtils.getProcessScalars("parent", true, false);
+
+ info(desc);
+ let n1exists =
+ box1shown || box1action || box1button1 || box1button2 || box1dismissed;
+
+ verify(scalars, "testone", "shown", box1shown, n1exists);
+ verify(scalars, "testone", "action", box1action, n1exists);
+ verify(scalars, "testone", "button1-pressed", box1button1, n1exists);
+ verify(scalars, "testone", "button2-pressed", box1button2, n1exists);
+ verify(scalars, "testone", "dismissed", box1dismissed, n1exists);
+ verify(scalars, "testtwo", "shown", box3shown, box3shown || box3dismissed);
+ verify(
+ scalars,
+ "testtwo",
+ "dismissed",
+ box3dismissed,
+ box3shown || box3dismissed
+ );
+}
diff --git a/browser/base/content/test/notificationbox/browser_tabnotificationbox_switch_tabs.js b/browser/base/content/test/notificationbox/browser_tabnotificationbox_switch_tabs.js
new file mode 100644
index 0000000000..f00916c773
--- /dev/null
+++ b/browser/base/content/test/notificationbox/browser_tabnotificationbox_switch_tabs.js
@@ -0,0 +1,142 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+function assertNotificationBoxHidden(reason, browser) {
+ let notificationBox = gBrowser.readNotificationBox(browser);
+
+ if (!notificationBox) {
+ ok(!notificationBox, `Notification box has not been created ${reason}`);
+ return;
+ }
+
+ let name = notificationBox._stack.getAttribute("name");
+ ok(name, `Notification box has a name ${reason}`);
+
+ let { selectedViewName } = notificationBox._stack.parentElement;
+ ok(
+ selectedViewName != name,
+ `Box is not shown ${reason} ${selectedViewName} != ${name}`
+ );
+}
+
+function assertNotificationBoxShown(reason, browser) {
+ let notificationBox = gBrowser.readNotificationBox(browser);
+ ok(notificationBox, `Notification box has been created ${reason}`);
+
+ let name = notificationBox._stack.getAttribute("name");
+ ok(name, `Notification box has a name ${reason}`);
+
+ let { selectedViewName } = notificationBox._stack.parentElement;
+ is(selectedViewName, name, `Box is shown ${reason}`);
+}
+
+function createNotification({ browser, label, value, priority }) {
+ let notificationBox = gBrowser.getNotificationBox(browser);
+ let notification = notificationBox.appendNotification(value, {
+ label,
+ priority: notificationBox[priority],
+ });
+ return notification;
+}
+
+add_task(async function testNotificationInBackgroundTab() {
+ let firstTab = gBrowser.selectedTab;
+
+ // Navigating to a page should not create the notification box
+ await BrowserTestUtils.withNewTab("https://example.com", async browser => {
+ let notificationBox = gBrowser.readNotificationBox(browser);
+ ok(!notificationBox, "The notification box has not been created");
+
+ gBrowser.selectedTab = firstTab;
+ assertNotificationBoxHidden("initial first tab");
+
+ createNotification({
+ browser,
+ label: "My notification body",
+ value: "test-notification",
+ priority: "PRIORITY_INFO_LOW",
+ });
+
+ gBrowser.selectedTab = gBrowser.getTabForBrowser(browser);
+ assertNotificationBoxShown("notification created");
+ });
+});
+
+add_task(async function testNotificationInActiveTab() {
+ // Open about:blank so the notification box isn't created on tab open.
+ await BrowserTestUtils.withNewTab("about:blank", async browser => {
+ ok(!gBrowser.readNotificationBox(browser), "No notifications for new tab");
+
+ createNotification({
+ browser,
+ label: "Notification!",
+ value: "test-notification",
+ priority: "PRIORITY_INFO_LOW",
+ });
+ assertNotificationBoxShown("after appendNotification");
+ });
+});
+
+add_task(async function testNotificationMultipleTabs() {
+ let tabOne = gBrowser.selectedTab;
+ let tabTwo = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ url: "about:blank",
+ });
+ let tabThree = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ url: "https://example.com",
+ });
+ let browserOne = tabOne.linkedBrowser;
+ let browserTwo = tabTwo.linkedBrowser;
+ let browserThree = tabThree.linkedBrowser;
+
+ is(gBrowser.selectedBrowser, browserThree, "example.com selected");
+
+ let notificationBoxOne = gBrowser.readNotificationBox(browserOne);
+ let notificationBoxTwo = gBrowser.readNotificationBox(browserTwo);
+ let notificationBoxThree = gBrowser.readNotificationBox(browserThree);
+
+ ok(!notificationBoxOne, "no initial tab box");
+ ok(!notificationBoxTwo, "no about:blank box");
+ ok(!notificationBoxThree, "no example.com box");
+
+ // Verify the correct box is shown after creating tabs.
+ assertNotificationBoxHidden("after open", browserOne);
+ assertNotificationBoxHidden("after open", browserTwo);
+ assertNotificationBoxHidden("after open", browserThree);
+
+ createNotification({
+ browser: browserTwo,
+ label: "Test blank",
+ value: "blank",
+ priority: "PRIORITY_INFO_LOW",
+ });
+ notificationBoxTwo = gBrowser.readNotificationBox(browserTwo);
+ ok(notificationBoxTwo, "Notification box was created");
+
+ // Verify the selected browser's notification box is still hidden.
+ assertNotificationBoxHidden("hidden create", browserTwo);
+ assertNotificationBoxHidden("other create", browserThree);
+
+ createNotification({
+ browser: browserThree,
+ label: "Test active tab",
+ value: "active",
+ priority: "PRIORITY_CRITICAL_LOW",
+ });
+ // Verify the selected browser's notification box is still shown.
+ assertNotificationBoxHidden("active create", browserTwo);
+ assertNotificationBoxShown("active create", browserThree);
+
+ gBrowser.selectedTab = tabTwo;
+
+ // Verify the notification box for the tab that has one gets shown.
+ assertNotificationBoxShown("tab switch", browserTwo);
+ assertNotificationBoxHidden("tab switch", browserThree);
+
+ BrowserTestUtils.removeTab(tabTwo);
+ BrowserTestUtils.removeTab(tabThree);
+});
diff --git a/browser/base/content/test/outOfProcess/browser.ini b/browser/base/content/test/outOfProcess/browser.ini
new file mode 100644
index 0000000000..de8ad0cb8b
--- /dev/null
+++ b/browser/base/content/test/outOfProcess/browser.ini
@@ -0,0 +1,15 @@
+[DEFAULT]
+support-files =
+ file_base.html
+ file_frame1.html
+ file_frame2.html
+ file_innerframe.html
+ head.js
+
+[browser_basic_outofprocess.js]
+[browser_controller.js]
+skip-if =
+ os == "linux" && bits == 64 # Bug 1663506
+ os == "mac" && debug # Bug 1663506
+ os == "win" && bits == 64 # Bug 1663506
+[browser_promisefocus.js]
diff --git a/browser/base/content/test/outOfProcess/browser_basic_outofprocess.js b/browser/base/content/test/outOfProcess/browser_basic_outofprocess.js
new file mode 100644
index 0000000000..50914a286c
--- /dev/null
+++ b/browser/base/content/test/outOfProcess/browser_basic_outofprocess.js
@@ -0,0 +1,149 @@
+/**
+ * Verify that the colors were set properly. This has the effect of
+ * verifying that the processes are assigned for child frames correctly.
+ */
+async function verifyBaseFrameStructure(
+ browsingContexts,
+ testname,
+ expectedHTML
+) {
+ function checkColorAndText(bc, desc, expectedColor, expectedText) {
+ return SpecialPowers.spawn(
+ bc,
+ [expectedColor, expectedText, desc],
+ (expectedColorChild, expectedTextChild, descChild) => {
+ Assert.equal(
+ content.document.documentElement.style.backgroundColor,
+ expectedColorChild,
+ descChild + " color"
+ );
+ Assert.equal(
+ content.document.getElementById("insertPoint").innerHTML,
+ expectedTextChild,
+ descChild + " text"
+ );
+ }
+ );
+ }
+
+ let useOOPFrames = gFissionBrowser;
+
+ is(
+ browsingContexts.length,
+ TOTAL_FRAME_COUNT,
+ "correct number of browsing contexts"
+ );
+ await checkColorAndText(
+ browsingContexts[0],
+ testname + " base",
+ "white",
+ expectedHTML.next().value
+ );
+ await checkColorAndText(
+ browsingContexts[1],
+ testname + " frame 1",
+ useOOPFrames ? "seashell" : "white",
+ expectedHTML.next().value
+ );
+ await checkColorAndText(
+ browsingContexts[2],
+ testname + " frame 1-1",
+ useOOPFrames ? "seashell" : "white",
+ expectedHTML.next().value
+ );
+ await checkColorAndText(
+ browsingContexts[3],
+ testname + " frame 2",
+ useOOPFrames ? "lightcyan" : "white",
+ expectedHTML.next().value
+ );
+ await checkColorAndText(
+ browsingContexts[4],
+ testname + " frame 2-1",
+ useOOPFrames ? "seashell" : "white",
+ expectedHTML.next().value
+ );
+ await checkColorAndText(
+ browsingContexts[5],
+ testname + " frame 2-2",
+ useOOPFrames ? "lightcyan" : "white",
+ expectedHTML.next().value
+ );
+ await checkColorAndText(
+ browsingContexts[6],
+ testname + " frame 2-3",
+ useOOPFrames ? "palegreen" : "white",
+ expectedHTML.next().value
+ );
+ await checkColorAndText(
+ browsingContexts[7],
+ testname + " frame 2-4",
+ "white",
+ expectedHTML.next().value
+ );
+}
+
+/**
+ * Test setting up all of the frames where a string of markup is passed
+ * to initChildFrames.
+ */
+add_task(async function test_subframes_string() {
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ OOP_BASE_PAGE_URI
+ );
+
+ const markup = "<p>Text</p>";
+
+ let browser = tab.linkedBrowser;
+ let browsingContexts = await initChildFrames(browser, markup);
+
+ function* getExpectedHTML() {
+ for (let c = 1; c <= TOTAL_FRAME_COUNT; c++) {
+ yield markup;
+ }
+ ok(false, "Frame count does not match actual number of frames");
+ }
+ await verifyBaseFrameStructure(browsingContexts, "string", getExpectedHTML());
+
+ BrowserTestUtils.removeTab(tab);
+});
+
+/**
+ * Test setting up all of the frames where a function that returns different markup
+ * is passed to initChildFrames.
+ */
+add_task(async function test_subframes_function() {
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ OOP_BASE_PAGE_URI
+ );
+ let browser = tab.linkedBrowser;
+
+ let counter = 0;
+ let browsingContexts = await initChildFrames(
+ browser,
+ function (browsingContext) {
+ return "<p>Text " + ++counter + "</p>";
+ }
+ );
+
+ is(
+ counter,
+ TOTAL_FRAME_COUNT,
+ "insert HTML function called the correct number of times"
+ );
+
+ function* getExpectedHTML() {
+ for (let c = 1; c <= TOTAL_FRAME_COUNT; c++) {
+ yield "<p>Text " + c + "</p>";
+ }
+ }
+ await verifyBaseFrameStructure(
+ browsingContexts,
+ "function",
+ getExpectedHTML()
+ );
+
+ BrowserTestUtils.removeTab(tab);
+});
diff --git a/browser/base/content/test/outOfProcess/browser_controller.js b/browser/base/content/test/outOfProcess/browser_controller.js
new file mode 100644
index 0000000000..f9d9ca8c93
--- /dev/null
+++ b/browser/base/content/test/outOfProcess/browser_controller.js
@@ -0,0 +1,127 @@
+function checkCommandState(testid, undoEnabled, copyEnabled, deleteEnabled) {
+ is(
+ !document.getElementById("cmd_undo").hasAttribute("disabled"),
+ undoEnabled,
+ testid + " undo"
+ );
+ is(
+ !document.getElementById("cmd_copy").hasAttribute("disabled"),
+ copyEnabled,
+ testid + " copy"
+ );
+ is(
+ !document.getElementById("cmd_delete").hasAttribute("disabled"),
+ deleteEnabled,
+ testid + " delete"
+ );
+}
+
+function keyAndUpdate(key, eventDetails, updateEventsCount) {
+ let updatePromise = BrowserTestUtils.waitForEvent(
+ window,
+ "commandupdate",
+ false,
+ () => {
+ return --updateEventsCount == 0;
+ }
+ );
+ EventUtils.synthesizeKey(key, eventDetails);
+ return updatePromise;
+}
+
+add_task(async function test_controllers_subframes() {
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ OOP_BASE_PAGE_URI
+ );
+ let browser = tab.linkedBrowser;
+ let browsingContexts = await initChildFrames(
+ browser,
+ "<input id='input'><br><br>"
+ );
+
+ gURLBar.focus();
+
+ for (let stepNum = 0; stepNum < browsingContexts.length; stepNum++) {
+ await keyAndUpdate(stepNum > 0 ? "VK_TAB" : "VK_F6", {}, 6);
+
+ // Since focus may be switching into a separate process here,
+ // need to wait for the focus to have been updated.
+ await SpecialPowers.spawn(browsingContexts[stepNum], [], () => {
+ return ContentTaskUtils.waitForCondition(
+ () => content.browsingContext.isActive && content.document.hasFocus()
+ );
+ });
+
+ // Force the UI to update on platforms that don't
+ // normally do so until menus are opened.
+ if (AppConstants.platform != "macosx") {
+ goUpdateGlobalEditMenuItems(true);
+ }
+
+ await SpecialPowers.spawn(browsingContexts[stepNum], [], () => {
+ // Both the tab key and document navigation with F6 will focus
+ // the root of the document within the frame.
+ let document = content.document;
+ Assert.equal(
+ document.activeElement,
+ document.documentElement,
+ "root focused"
+ );
+ });
+ // XXX Currently, Copy is always enabled when the root (not an editor element)
+ // is focused. Possibly that should only be true if a listener is present?
+ checkCommandState("step " + stepNum + " root focused", false, true, false);
+
+ // Tab to the textbox.
+ await keyAndUpdate("VK_TAB", {}, 1);
+
+ if (AppConstants.platform != "macosx") {
+ goUpdateGlobalEditMenuItems(true);
+ }
+
+ await SpecialPowers.spawn(browsingContexts[stepNum], [], () => {
+ Assert.equal(
+ content.document.activeElement,
+ content.document.getElementById("input"),
+ "input focused"
+ );
+ });
+ checkCommandState(
+ "step " + stepNum + " input focused",
+ false,
+ false,
+ false
+ );
+
+ // Type into the textbox.
+ await keyAndUpdate("a", {}, 1);
+ checkCommandState("step " + stepNum + " typed", true, false, false);
+
+ await SpecialPowers.spawn(browsingContexts[stepNum], [], () => {
+ Assert.equal(
+ content.document.activeElement,
+ content.document.getElementById("input"),
+ "input focused"
+ );
+ });
+
+ // Select all text; this causes the Copy and Delete commands to be enabled.
+ await keyAndUpdate("a", { accelKey: true }, 1);
+ if (AppConstants.platform != "macosx") {
+ goUpdateGlobalEditMenuItems(true);
+ }
+
+ checkCommandState("step " + stepNum + " selected", true, true, true);
+
+ // Now make sure that the text is selected.
+ await SpecialPowers.spawn(browsingContexts[stepNum], [], () => {
+ let input = content.document.getElementById("input");
+ Assert.equal(input.value, "a", "text matches");
+ Assert.equal(input.selectionStart, 0, "selectionStart matches");
+ Assert.equal(input.selectionEnd, 1, "selectionEnd matches");
+ });
+ }
+
+ BrowserTestUtils.removeTab(tab);
+});
diff --git a/browser/base/content/test/outOfProcess/browser_promisefocus.js b/browser/base/content/test/outOfProcess/browser_promisefocus.js
new file mode 100644
index 0000000000..9018c0f0ae
--- /dev/null
+++ b/browser/base/content/test/outOfProcess/browser_promisefocus.js
@@ -0,0 +1,262 @@
+// Opens another window and switches focus between them.
+add_task(async function test_window_focus() {
+ let window2 = await BrowserTestUtils.openNewBrowserWindow();
+ ok(!document.hasFocus(), "hasFocus after open second window");
+ ok(window2.document.hasFocus(), "hasFocus after open second window");
+ is(
+ Services.focus.activeWindow,
+ window2,
+ "activeWindow after open second window"
+ );
+ is(
+ Services.focus.focusedWindow,
+ window2,
+ "focusedWindow after open second window"
+ );
+
+ await SimpleTest.promiseFocus(window);
+ ok(document.hasFocus(), "hasFocus after promiseFocus on window");
+ ok(!window2.document.hasFocus(), "hasFocus after promiseFocus on window");
+ is(
+ Services.focus.activeWindow,
+ window,
+ "activeWindow after promiseFocus on window"
+ );
+ is(
+ Services.focus.focusedWindow,
+ window,
+ "focusedWindow after promiseFocus on window"
+ );
+
+ await SimpleTest.promiseFocus(window2);
+ ok(!document.hasFocus(), "hasFocus after promiseFocus on second window");
+ ok(
+ window2.document.hasFocus(),
+ "hasFocus after promiseFocus on second window"
+ );
+ is(
+ Services.focus.activeWindow,
+ window2,
+ "activeWindow after promiseFocus on second window"
+ );
+ is(
+ Services.focus.focusedWindow,
+ window2,
+ "focusedWindow after promiseFocus on second window"
+ );
+
+ await BrowserTestUtils.closeWindow(window2);
+
+ // If the window is already focused, this should just return.
+ await SimpleTest.promiseFocus(window);
+ await SimpleTest.promiseFocus(window);
+});
+
+// Opens two tabs and ensures that focus can be switched to the browser.
+add_task(async function test_tab_focus() {
+ let tab1 = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "data:text/html,<input>"
+ );
+
+ let tab2 = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "data:text/html,<input>"
+ );
+
+ gURLBar.focus();
+
+ await SimpleTest.promiseFocus(tab2.linkedBrowser);
+ is(
+ document.activeElement,
+ tab2.linkedBrowser,
+ "Browser is focused after promiseFocus"
+ );
+
+ await SpecialPowers.spawn(tab1.linkedBrowser, [], () => {
+ Assert.equal(
+ Services.focus.activeBrowsingContext,
+ null,
+ "activeBrowsingContext in child process in hidden tab"
+ );
+ Assert.equal(
+ Services.focus.focusedWindow,
+ null,
+ "focusedWindow in child process in hidden tab"
+ );
+ Assert.ok(
+ !content.document.hasFocus(),
+ "hasFocus in child process in hidden tab"
+ );
+ });
+
+ await SpecialPowers.spawn(tab2.linkedBrowser, [], () => {
+ Assert.equal(
+ Services.focus.activeBrowsingContext,
+ content.browsingContext,
+ "activeBrowsingContext in child process in visible tab"
+ );
+ Assert.equal(
+ Services.focus.focusedWindow,
+ content.window,
+ "focusedWindow in child process in visible tab"
+ );
+ Assert.ok(
+ content.document.hasFocus(),
+ "hasFocus in child process in visible tab"
+ );
+ });
+
+ BrowserTestUtils.removeTab(tab1);
+ BrowserTestUtils.removeTab(tab2);
+});
+
+// Opens a document with a nested hierarchy of frames using initChildFrames and
+// focuses each child iframe in turn.
+add_task(async function test_subframes_focus() {
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ OOP_BASE_PAGE_URI
+ );
+
+ const markup = "<input>";
+
+ let browser = tab.linkedBrowser;
+ let browsingContexts = await initChildFrames(browser, markup);
+
+ for (let blurSubframe of [true, false]) {
+ for (let index = browsingContexts.length - 1; index >= 0; index--) {
+ let bc = browsingContexts[index];
+
+ // Focus each browsing context in turn. Do this twice, once when the window
+ // is not already focused, and once when it is already focused.
+ for (let step = 0; step < 2; step++) {
+ let desc =
+ "within child frame " +
+ index +
+ " step " +
+ step +
+ " blur subframe " +
+ blurSubframe +
+ " ";
+
+ info(desc + "start");
+ await SimpleTest.promiseFocus(bc, false, blurSubframe);
+
+ let expectedFocusedBC = bc;
+ // Becuase we are iterating backwards through the iframes, when we get to a frame
+ // that contains the iframe we just tested, focusing it will keep the child
+ // iframe focused as well, so we need to account for this when verifying which
+ // child iframe is focused. For the root frame (index 0), the iframe nested
+ // two items down will actually be focused.
+ // If blurSubframe is true however, the iframe focus in the parent will be cleared,
+ // so the focused window should be the parent instead.
+ if (!blurSubframe) {
+ if (index == 0) {
+ expectedFocusedBC = browsingContexts[index + 2];
+ } else if (index == 3 || index == 1) {
+ expectedFocusedBC = browsingContexts[index + 1];
+ }
+ }
+ is(
+ Services.focus.focusedContentBrowsingContext,
+ expectedFocusedBC,
+ desc +
+ " focusedContentBrowsingContext" +
+ ":: " +
+ Services.focus.focusedContentBrowsingContext?.id +
+ "," +
+ expectedFocusedBC?.id
+ );
+
+ // If the processes don't match, then the child iframe is an out-of-process iframe.
+ let oop =
+ expectedFocusedBC.currentWindowGlobal.osPid !=
+ bc.currentWindowGlobal.osPid;
+ await SpecialPowers.spawn(
+ bc,
+ [
+ index,
+ desc,
+ expectedFocusedBC != bc ? expectedFocusedBC : null,
+ oop,
+ ],
+ (num, descChild, childBC, isOop) => {
+ Assert.equal(
+ Services.focus.activeBrowsingContext,
+ content.browsingContext.top,
+ descChild + "activeBrowsingContext"
+ );
+ Assert.ok(
+ content.document.hasFocus(),
+ descChild + "hasFocus: " + content.browsingContext.id
+ );
+
+ // If a child browsing context is expected to be focused, the focusedWindow
+ // should be set to that instead and the active element should be an iframe.
+ // Otherwise, the focused window should be this window, and the active
+ // element should be the document's body element.
+ if (childBC) {
+ // The frame structure is:
+ // A1
+ // -> B
+ // -> A2
+ // where A and B are two processes. The frame A2 starts out focused. When B is
+ // focused, A1's focus is updated correctly.
+
+ // In Fission mode, childBC.window returns a non-null proxy even if OOP
+ if (isOop) {
+ Assert.equal(
+ Services.focus.focusedWindow,
+ null,
+ descChild + "focusedWindow"
+ );
+ Assert.ok(!childBC.docShell, descChild + "childBC.docShell");
+ } else {
+ Assert.equal(
+ Services.focus.focusedWindow,
+ childBC.window,
+ descChild + "focusedWindow"
+ );
+ }
+ Assert.equal(
+ content.document.activeElement.localName,
+ "iframe",
+ descChild + "activeElement"
+ );
+ } else {
+ Assert.equal(
+ Services.focus.focusedWindow,
+ content.window,
+ descChild + "focusedWindow"
+ );
+ Assert.equal(
+ content.document.activeElement,
+ content.document.body,
+ descChild + "activeElement"
+ );
+ }
+ }
+ );
+ }
+ }
+ }
+
+ // Focus the top window without blurring the browser.
+ await SimpleTest.promiseFocus(window, false, false);
+ is(
+ document.activeElement.localName,
+ "browser",
+ "focus after blurring browser blur subframe false"
+ );
+
+ // Now, focus the top window, blurring the browser.
+ await SimpleTest.promiseFocus(window, false, true);
+ is(
+ document.activeElement,
+ document.body,
+ "focus after blurring browser blur subframe true"
+ );
+
+ BrowserTestUtils.removeTab(tab);
+});
diff --git a/browser/base/content/test/outOfProcess/file_base.html b/browser/base/content/test/outOfProcess/file_base.html
new file mode 100644
index 0000000000..03f0731a8e
--- /dev/null
+++ b/browser/base/content/test/outOfProcess/file_base.html
@@ -0,0 +1,5 @@
+<html><body>
+<div id="insertPoint"></div>
+<iframe src="https://www.mozilla.org:443/browser/browser/base/content/test/outOfProcess/file_frame1.html" width="320" height="700" style="border: 1px solid black;"></iframe></body>
+<iframe src="https://test1.example.org:443/browser/browser/base/content/test/outOfProcess/file_frame2.html" width="320" height="700" style="border: 1px solid black;"></iframe></body>
+</html>
diff --git a/browser/base/content/test/outOfProcess/file_frame1.html b/browser/base/content/test/outOfProcess/file_frame1.html
new file mode 100644
index 0000000000..d39e970c0f
--- /dev/null
+++ b/browser/base/content/test/outOfProcess/file_frame1.html
@@ -0,0 +1,5 @@
+<html><body>
+<div id="insertPoint"></div>
+Same domain:<br>
+<iframe src="file_innerframe.html" width="300" height="100" style="border: 1px solid black;"></iframe></body>
+</html>
diff --git a/browser/base/content/test/outOfProcess/file_frame2.html b/browser/base/content/test/outOfProcess/file_frame2.html
new file mode 100644
index 0000000000..f0bc91ba20
--- /dev/null
+++ b/browser/base/content/test/outOfProcess/file_frame2.html
@@ -0,0 +1,11 @@
+<html><body>
+<div id="insertPoint"></div>
+Same domain as to the left:<br>
+<iframe src="https://www.mozilla.org:443/browser/browser/base/content/test/outOfProcess/file_innerframe.html" width="300" height="100" style="border: 1px solid black;"></iframe></body>
+Same domain as parent:<br>
+<iframe src="https://test1.example.org:443/browser/browser/base/content/test/outOfProcess/file_innerframe.html" width="300" height="100" style="border: 1px solid black;"></iframe></body>
+Different domain:<br>
+<iframe src="https://w3c-test.org:443/browser/browser/base/content/test/outOfProcess/file_innerframe.html" width="300" height="100" style="border: 1px solid black;"></iframe></body>
+Same as top-level domain:<br>
+<iframe src="https://example.com/browser/browser/base/content/test/outOfProcess/file_innerframe.html" width="300" height="100" style="border: 1px solid black;"></iframe></body>
+</html>
diff --git a/browser/base/content/test/outOfProcess/file_innerframe.html b/browser/base/content/test/outOfProcess/file_innerframe.html
new file mode 100644
index 0000000000..23c516232c
--- /dev/null
+++ b/browser/base/content/test/outOfProcess/file_innerframe.html
@@ -0,0 +1,3 @@
+<html><body>
+<div id="insertPoint"></div>
+</html>
diff --git a/browser/base/content/test/outOfProcess/head.js b/browser/base/content/test/outOfProcess/head.js
new file mode 100644
index 0000000000..230e2e2cbc
--- /dev/null
+++ b/browser/base/content/test/outOfProcess/head.js
@@ -0,0 +1,85 @@
+const OOP_BASE_PAGE_URI =
+ "https://example.com/browser/browser/base/content/test/outOfProcess/file_base.html";
+
+// The number of frames and subframes that exist for the basic OOP test. If frames are
+// modified within file_base.html, update this value.
+const TOTAL_FRAME_COUNT = 8;
+
+// The frames are assigned different colors based on their process ids. If you add a
+// frame you might need to add more colors to this list.
+const FRAME_COLORS = ["white", "seashell", "lightcyan", "palegreen"];
+
+/**
+ * Set up a set of child frames for the given browser for testing
+ * out of process frames. 'OOP_BASE_PAGE_URI' is the base page and subframes
+ * contain pages from the same or other domains.
+ *
+ * @param browser browser containing frame hierarchy to set up
+ * @param insertHTML HTML or function that returns what to insert into each frame
+ * @returns array of all browsing contexts in depth-first order
+ *
+ * This function adds a browsing context and process id label to each
+ * child subframe. It also sets the background color of each frame to
+ * different colors based on the process id. The browser_basic_outofprocess.js
+ * test verifies these colors to ensure that the frame/process hierarchy
+ * has been set up as expected. Colors are used to help people visualize
+ * the process setup.
+ *
+ * The insertHTML argument may be either a fixed string of HTML to insert
+ * into each subframe, or a function that returns the string to insert. The
+ * function takes one argument, the browsing context being processed.
+ */
+async function initChildFrames(browser, insertHTML) {
+ let colors = FRAME_COLORS.slice();
+ let colorMap = new Map();
+
+ let browsingContexts = [];
+
+ async function processBC(bc) {
+ browsingContexts.push(bc);
+
+ let pid = bc.currentWindowGlobal.osPid;
+ let ident = "BrowsingContext: " + bc.id + "\nProcess: " + pid;
+
+ let color = colorMap.get(pid);
+ if (!color) {
+ if (!colors.length) {
+ ok(false, "ran out of available colors");
+ }
+
+ color = colors.shift();
+ colorMap.set(pid, color);
+ }
+
+ let insertHTMLString = insertHTML;
+ if (typeof insertHTML == "function") {
+ insertHTMLString = insertHTML(bc);
+ }
+
+ await SpecialPowers.spawn(
+ bc,
+ [ident, color, insertHTMLString],
+ (identChild, colorChild, insertHTMLChild) => {
+ let root = content.document.documentElement;
+ root.style = "background-color: " + colorChild;
+
+ let pre = content.document.createElement("pre");
+ pre.textContent = identChild;
+ root.insertBefore(pre, root.firstChild);
+
+ if (insertHTMLChild) {
+ // eslint-disable-next-line no-unsanitized/property
+ content.document.getElementById("insertPoint").innerHTML =
+ insertHTMLChild;
+ }
+ }
+ );
+
+ for (let childBC of bc.children) {
+ await processBC(childBC);
+ }
+ }
+ await processBC(browser.browsingContext);
+
+ return browsingContexts;
+}
diff --git a/browser/base/content/test/pageActions/browser.ini b/browser/base/content/test/pageActions/browser.ini
new file mode 100644
index 0000000000..b19464fc48
--- /dev/null
+++ b/browser/base/content/test/pageActions/browser.ini
@@ -0,0 +1,7 @@
+[DEFAULT]
+support-files =
+ head.js
+
+[browser_PageActions_bookmark.js]
+[browser_PageActions_overflow.js]
+[browser_PageActions_removeExtension.js]
diff --git a/browser/base/content/test/pageActions/browser_PageActions_bookmark.js b/browser/base/content/test/pageActions/browser_PageActions_bookmark.js
new file mode 100644
index 0000000000..a77095f2cd
--- /dev/null
+++ b/browser/base/content/test/pageActions/browser_PageActions_bookmark.js
@@ -0,0 +1,130 @@
+"use strict";
+
+add_task(async function starButtonCtrlClick() {
+ // Open a unique page.
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ let url = "http://example.com/browser_page_action_star_button";
+ await BrowserTestUtils.withNewTab(url, async () => {
+ StarUI._createPanelIfNeeded();
+ // The button ignores activation while the bookmarked status is being
+ // updated. So, wait for it to finish updating.
+ await TestUtils.waitForCondition(
+ () => BookmarkingUI.status != BookmarkingUI.STATUS_UPDATING
+ );
+
+ const popup = document.getElementById("editBookmarkPanel");
+ const starButtonBox = document.getElementById("star-button-box");
+
+ let shownPromise = promisePanelShown(popup);
+ EventUtils.synthesizeMouseAtCenter(starButtonBox, { ctrlKey: true });
+ await shownPromise;
+ ok(true, "Panel shown after button pressed");
+
+ let hiddenPromise = promisePanelHidden(popup);
+ document.getElementById("editBookmarkPanelRemoveButton").click();
+ await hiddenPromise;
+ });
+});
+
+add_task(async function bookmark() {
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ const url = "http://example.com/browser_page_action_menu";
+
+ const win = await BrowserTestUtils.openNewBrowserWindow();
+ registerCleanupFunction(async () => {
+ await BrowserTestUtils.closeWindow(win);
+ });
+
+ await BrowserTestUtils.withNewTab(
+ { gBrowser: win.gBrowser, url },
+ async () => {
+ // The bookmark button should not be starred.
+ const bookmarkButton =
+ win.BrowserPageActions.urlbarButtonNodeForActionID("bookmark");
+ Assert.ok(!bookmarkButton.hasAttribute("starred"));
+
+ info("Click the button.");
+ // The button ignores activation while the bookmarked status is being
+ // updated. So, wait for it to finish updating.
+ await TestUtils.waitForCondition(
+ () => BookmarkingUI.status != BookmarkingUI.STATUS_UPDATING
+ );
+ const starUIPanel = win.StarUI.panel;
+ let panelShown = BrowserTestUtils.waitForPopupEvent(starUIPanel, "shown");
+ EventUtils.synthesizeMouseAtCenter(bookmarkButton, {}, win);
+ await panelShown;
+ is(
+ await PlacesUtils.bookmarks.fetch({ url }),
+ null,
+ "Bookmark has not been created before save."
+ );
+
+ // The bookmark button should now be starred.
+ Assert.equal(bookmarkButton.firstChild.getAttribute("starred"), "true");
+
+ info("Save the bookmark.");
+ const onItemAddedPromise = PlacesTestUtils.waitForNotification(
+ "bookmark-added",
+ events => events.some(event => event.url == url)
+ );
+ starUIPanel.hidePopup();
+ await onItemAddedPromise;
+
+ info("Click it again.");
+ // The button ignores activation while the bookmarked status is being
+ // updated. So, wait for it to finish updating.
+ await TestUtils.waitForCondition(
+ () => BookmarkingUI.status != BookmarkingUI.STATUS_UPDATING
+ );
+ panelShown = BrowserTestUtils.waitForPopupEvent(starUIPanel, "shown");
+ EventUtils.synthesizeMouseAtCenter(bookmarkButton, {}, win);
+ await panelShown;
+
+ info("Remove the bookmark.");
+ const onItemRemovedPromise = PlacesTestUtils.waitForNotification(
+ "bookmark-removed",
+ events => events.some(event => event.url == url)
+ );
+ win.StarUI._element("editBookmarkPanelRemoveButton").click();
+ await onItemRemovedPromise;
+ }
+ );
+});
+
+add_task(async function bookmarkNoEditDialog() {
+ const url =
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com/browser_page_action_menu_no_edit_dialog";
+
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.bookmarks.editDialog.showForNewBookmarks", false]],
+ });
+ const win = await BrowserTestUtils.openNewBrowserWindow();
+ registerCleanupFunction(async () => {
+ await BrowserTestUtils.closeWindow(win);
+ await PlacesUtils.bookmarks.eraseEverything();
+ });
+
+ await BrowserTestUtils.withNewTab(
+ { gBrowser: win.gBrowser, url },
+ async () => {
+ info("Click the button.");
+ // The button ignores activation while the bookmarked status is being
+ // updated. So, wait for it to finish updating.
+ await TestUtils.waitForCondition(
+ () => BookmarkingUI.status != BookmarkingUI.STATUS_UPDATING
+ );
+ const bookmarkButton = win.document.getElementById(
+ BrowserPageActions.urlbarButtonNodeIDForActionID("bookmark")
+ );
+
+ // The bookmark should be saved immediately after clicking the star.
+ const onItemAddedPromise = PlacesTestUtils.waitForNotification(
+ "bookmark-added",
+ events => events.some(event => event.url == url)
+ );
+ EventUtils.synthesizeMouseAtCenter(bookmarkButton, {}, win);
+ await onItemAddedPromise;
+ }
+ );
+});
diff --git a/browser/base/content/test/pageActions/browser_PageActions_overflow.js b/browser/base/content/test/pageActions/browser_PageActions_overflow.js
new file mode 100644
index 0000000000..463dd336c4
--- /dev/null
+++ b/browser/base/content/test/pageActions/browser_PageActions_overflow.js
@@ -0,0 +1,257 @@
+/* Any copyright is dedicated to the Public Domain.
+ * https://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test() {
+ // We use an extension that shows a page action. We must add this additional
+ // action because otherwise the meatball menu would not appear as an overflow
+ // for a single action.
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ name: "Test contextMenu",
+ page_action: { show_matches: ["<all_urls>"] },
+ },
+
+ useAddonManager: "temporary",
+ });
+
+ await extension.startup();
+ let actionId = ExtensionCommon.makeWidgetId(extension.id);
+
+ let win = await BrowserTestUtils.openNewBrowserWindow();
+ await SimpleTest.promiseFocus(win);
+ Assert.greater(win.outerWidth, 700, "window is bigger than 700px");
+ BrowserTestUtils.loadURIString(
+ win.gBrowser,
+ "data:text/html,<h1>A Page</h1>"
+ );
+ await BrowserTestUtils.browserLoaded(win.gBrowser.selectedBrowser);
+
+ // The pageAction implementation enables the button at the next animation
+ // frame, so before we look for the button we should wait one animation frame
+ // as well.
+ await promiseAnimationFrame(win);
+
+ info("Check page action buttons are visible, the meatball button is not");
+ let addonButton =
+ win.BrowserPageActions.urlbarButtonNodeForActionID(actionId);
+ Assert.ok(BrowserTestUtils.is_visible(addonButton));
+ let starButton =
+ win.BrowserPageActions.urlbarButtonNodeForActionID("bookmark");
+ Assert.ok(BrowserTestUtils.is_visible(starButton));
+ let meatballButton = win.document.getElementById("pageActionButton");
+ Assert.ok(!BrowserTestUtils.is_visible(meatballButton));
+
+ info(
+ "Shrink the window, check page action buttons are not visible, the meatball menu is visible"
+ );
+ let originalOuterWidth = win.outerWidth;
+ await promiseStableResize(500, win);
+ Assert.ok(!BrowserTestUtils.is_visible(addonButton));
+ Assert.ok(!BrowserTestUtils.is_visible(starButton));
+ Assert.ok(BrowserTestUtils.is_visible(meatballButton));
+
+ info(
+ "Remove the extension, check the only page action button is visible, the meatball menu is not visible"
+ );
+ let promiseUninstalled = promiseAddonUninstalled(extension.id);
+ await extension.unload();
+ await promiseUninstalled;
+ Assert.ok(BrowserTestUtils.is_visible(starButton));
+ Assert.ok(!BrowserTestUtils.is_visible(meatballButton));
+ Assert.deepEqual(
+ win.BrowserPageActions.urlbarButtonNodeForActionID(actionId),
+ null
+ );
+
+ await promiseStableResize(originalOuterWidth, win);
+ await BrowserTestUtils.closeWindow(win);
+});
+
+add_task(async function bookmark() {
+ // We use an extension that shows a page action. We must add this additional
+ // action because otherwise the meatball menu would not appear as an overflow
+ // for a single action.
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ name: "Test contextMenu",
+ page_action: { show_matches: ["<all_urls>"] },
+ },
+
+ useAddonManager: "temporary",
+ });
+
+ await extension.startup();
+ const url = "data:text/html,<h1>A Page</h1>";
+ let win = await BrowserTestUtils.openNewBrowserWindow();
+ await SimpleTest.promiseFocus(win);
+ BrowserTestUtils.loadURIString(win.gBrowser, url);
+ await BrowserTestUtils.browserLoaded(win.gBrowser.selectedBrowser);
+
+ // The pageAction implementation enables the button at the next animation
+ // frame, so before we look for the button we should wait one animation frame
+ // as well.
+ await promiseAnimationFrame(win);
+
+ info("Shrink the window if necessary, check the meatball menu is visible");
+ let originalOuterWidth = win.outerWidth;
+ await promiseStableResize(500, win);
+
+ let meatballButton = win.document.getElementById("pageActionButton");
+ Assert.ok(BrowserTestUtils.is_visible(meatballButton));
+
+ // Open the panel.
+ await promisePageActionPanelOpen(win);
+
+ // The bookmark button should read "Bookmark Current Tab…" and not be starred.
+ let bookmarkButton = win.document.getElementById("pageAction-panel-bookmark");
+ await TestUtils.waitForCondition(
+ () => bookmarkButton.label === "Bookmark Current Tab…"
+ );
+ Assert.ok(!bookmarkButton.hasAttribute("starred"));
+
+ // Click the button.
+ let hiddenPromise = promisePageActionPanelHidden(win);
+ let showPromise = BrowserTestUtils.waitForPopupEvent(
+ win.StarUI.panel,
+ "shown"
+ );
+ EventUtils.synthesizeMouseAtCenter(bookmarkButton, {}, win);
+ await hiddenPromise;
+ await showPromise;
+ win.StarUI.panel.hidePopup();
+
+ // Open the panel again.
+ await promisePageActionPanelOpen(win);
+
+ // The bookmark button should now read "Edit This Bookmark…" and be starred.
+ await TestUtils.waitForCondition(
+ () => bookmarkButton.label === "Edit This Bookmark…"
+ );
+ Assert.ok(bookmarkButton.hasAttribute("starred"));
+ Assert.equal(bookmarkButton.getAttribute("starred"), "true");
+
+ // Click it again.
+ hiddenPromise = promisePageActionPanelHidden(win);
+ showPromise = BrowserTestUtils.waitForPopupEvent(win.StarUI.panel, "shown");
+ EventUtils.synthesizeMouseAtCenter(bookmarkButton, {}, win);
+ await hiddenPromise;
+ await showPromise;
+
+ let onItemRemovedPromise = PlacesTestUtils.waitForNotification(
+ "bookmark-removed",
+ events => events.some(event => event.url == url)
+ );
+
+ // Click the remove-bookmark button in the panel.
+ win.StarUI._element("editBookmarkPanelRemoveButton").click();
+
+ // Wait for the bookmark to be removed before continuing.
+ await onItemRemovedPromise;
+
+ // Open the panel again.
+ await promisePageActionPanelOpen(win);
+
+ // The bookmark button should read "Bookmark Current Tab…" and not be starred.
+ await TestUtils.waitForCondition(
+ () => bookmarkButton.label === "Bookmark Current Tab…"
+ );
+ Assert.ok(!bookmarkButton.hasAttribute("starred"));
+
+ // Done.
+ hiddenPromise = promisePageActionPanelHidden();
+ win.BrowserPageActions.panelNode.hidePopup();
+ await hiddenPromise;
+
+ info("Remove the extension");
+ let promiseUninstalled = promiseAddonUninstalled(extension.id);
+ await extension.unload();
+ await promiseUninstalled;
+
+ await promiseStableResize(originalOuterWidth, win);
+ await BrowserTestUtils.closeWindow(win);
+});
+
+add_task(async function test_disabledPageAction_hidden_in_protonOverflowMenu() {
+ // Make sure the overflow menu urlbar button is visible (indipendently from
+ // the current size of the Firefox window).
+ BrowserPageActions.mainButtonNode.style.visibility = "visible";
+ registerCleanupFunction(() => {
+ BrowserPageActions.mainButtonNode.style.removeProperty("visibility");
+ });
+
+ const extension = ExtensionTestUtils.loadExtension({
+ manifest: { page_action: {} },
+ async background() {
+ const { browser } = this;
+ const [tab] = await browser.tabs.query({
+ active: true,
+ currentWindow: true,
+ });
+ browser.test.assertTrue(tab, "Got an active tab as expected");
+ browser.test.onMessage.addListener(async msg => {
+ switch (msg) {
+ case "show-pageAction":
+ await browser.pageAction.show(tab.id);
+ break;
+ case "hide-pageAction":
+ await browser.pageAction.hide(tab.id);
+ break;
+ default:
+ browser.test.fail(`Unexpected message received: ${msg}`);
+ }
+ browser.test.sendMessage(`${msg}:done`);
+ });
+ },
+ });
+
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ await BrowserTestUtils.withNewTab("http://example.com", async browser => {
+ const win = browser.ownerGlobal;
+ const promisePageActionPanelClosed = async () => {
+ let popupHiddenPromise = promisePageActionPanelHidden(win);
+ win.BrowserPageActions.panelNode.hidePopup();
+ await popupHiddenPromise;
+ };
+
+ await extension.startup();
+ const widgetId = ExtensionCommon.makeWidgetId(extension.id);
+
+ info(
+ "Show pageAction and verify it is visible in the urlbar overflow menu"
+ );
+ extension.sendMessage("show-pageAction");
+ await extension.awaitMessage("show-pageAction:done");
+ await promisePageActionPanelOpen(win);
+ let pageActionNode =
+ win.BrowserPageActions.panelButtonNodeForActionID(widgetId);
+ ok(
+ pageActionNode && BrowserTestUtils.is_visible(pageActionNode),
+ "enabled pageAction should be visible in the urlbar overflow menu"
+ );
+
+ info("Hide pageAction and verify it is hidden in the urlbar overflow menu");
+ extension.sendMessage("hide-pageAction");
+ await extension.awaitMessage("hide-pageAction:done");
+
+ await BrowserTestUtils.waitForCondition(
+ () => !win.BrowserPageActions.panelButtonNodeForActionID(widgetId),
+ "Wait for the disabled pageAction to be removed from the urlbar overflow menu"
+ );
+
+ await promisePageActionPanelClosed();
+
+ info("Reopen the urlbar overflow menu");
+ await promisePageActionPanelOpen(win);
+ ok(
+ !win.BrowserPageActions.panelButtonNodeForActionID(widgetId),
+ "Disabled pageAction is still removed as expected"
+ );
+
+ await promisePageActionPanelClosed();
+ await extension.unload();
+ });
+
+ await SpecialPowers.popPrefEnv();
+});
diff --git a/browser/base/content/test/pageActions/browser_PageActions_removeExtension.js b/browser/base/content/test/pageActions/browser_PageActions_removeExtension.js
new file mode 100644
index 0000000000..329be2db17
--- /dev/null
+++ b/browser/base/content/test/pageActions/browser_PageActions_removeExtension.js
@@ -0,0 +1,338 @@
+"use strict";
+
+// Initialization. Must run first.
+add_setup(async function () {
+ // The page action urlbar button, and therefore the panel, is only shown when
+ // the current tab is actionable -- i.e., a normal web page. about:blank is
+ // not, so open a new tab first thing, and close it when this test is done.
+ let tab = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ url: "http://example.com/",
+ });
+
+ // The prompt service is mocked later, so set it up to be restored.
+ let { prompt } = Services;
+
+ registerCleanupFunction(async () => {
+ BrowserTestUtils.removeTab(tab);
+ Services.prompt = prompt;
+ });
+});
+
+add_task(async function contextMenu_removeExtension_panel() {
+ // We use an extension that shows a page action so that we can test the
+ // "remove extension" item in the context menu.
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ name: "Test contextMenu",
+ page_action: { show_matches: ["<all_urls>"] },
+ },
+
+ useAddonManager: "temporary",
+ });
+
+ await extension.startup();
+
+ let actionId = ExtensionCommon.makeWidgetId(extension.id);
+
+ const url = "data:text/html,<h1>A Page</h1>";
+ let win = await BrowserTestUtils.openNewBrowserWindow();
+ await SimpleTest.promiseFocus(win);
+ BrowserTestUtils.loadURIString(win.gBrowser, url);
+ await BrowserTestUtils.browserLoaded(win.gBrowser.selectedBrowser);
+
+ info("Shrink the window if necessary, check the meatball menu is visible");
+ let originalOuterWidth = win.outerWidth;
+ await promiseStableResize(500, win);
+
+ // The pageAction implementation enables the button at the next animation
+ // frame, so before we look for the button we should wait one animation frame
+ // as well.
+ await promiseAnimationFrame(win);
+
+ let meatballButton = win.document.getElementById("pageActionButton");
+ Assert.ok(BrowserTestUtils.is_visible(meatballButton));
+
+ // Open the panel.
+ await promisePageActionPanelOpen(win);
+
+ info("Open the context menu");
+ let panelButton = win.BrowserPageActions.panelButtonNodeForActionID(actionId);
+ let contextMenuPromise = promisePanelShown("pageActionContextMenu", win);
+ EventUtils.synthesizeMouseAtCenter(
+ panelButton,
+ {
+ type: "contextmenu",
+ button: 2,
+ },
+ win
+ );
+ let contextMenu = await contextMenuPromise;
+
+ let removeExtensionItem = getRemoveExtensionItem(win);
+ Assert.ok(removeExtensionItem, "'Remove' item exists");
+ Assert.ok(!removeExtensionItem.hidden, "'Remove' item is visible");
+ Assert.ok(!removeExtensionItem.disabled, "'Remove' item is not disabled");
+
+ // Click the "remove extension" item, a prompt should be displayed and then
+ // the add-on should be uninstalled. We mock the prompt service to confirm
+ // the removal of the add-on.
+ contextMenuPromise = promisePanelHidden("pageActionContextMenu", win);
+ let addonUninstalledPromise = promiseAddonUninstalled(extension.id);
+ mockPromptService();
+ contextMenu.activateItem(removeExtensionItem);
+ await Promise.all([contextMenuPromise, addonUninstalledPromise]);
+
+ // Done, clean up.
+ await extension.unload();
+
+ await promiseStableResize(originalOuterWidth, win);
+ await BrowserTestUtils.closeWindow(win);
+});
+
+add_task(async function contextMenu_removeExtension_urlbar() {
+ // We use an extension that shows a page action so that we can test the
+ // "remove extension" item in the context menu.
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ name: "Test contextMenu",
+ page_action: { show_matches: ["<all_urls>"] },
+ },
+
+ useAddonManager: "temporary",
+ });
+
+ await extension.startup();
+ // The pageAction implementation enables the button at the next animation
+ // frame, so before we look for the button we should wait one animation frame
+ // as well.
+ await promiseAnimationFrame();
+
+ let actionId = ExtensionCommon.makeWidgetId(extension.id);
+
+ // Open the context menu on the action's urlbar button.
+ let urlbarButton = BrowserPageActions.urlbarButtonNodeForActionID(actionId);
+ let contextMenuPromise = promisePanelShown("pageActionContextMenu");
+ EventUtils.synthesizeMouseAtCenter(urlbarButton, {
+ type: "contextmenu",
+ button: 2,
+ });
+ let contextMenu = await contextMenuPromise;
+
+ let menuItems = collectContextMenuItems();
+ Assert.equal(menuItems.length, 2, "Context menu has two children");
+ let removeExtensionItem = getRemoveExtensionItem();
+ Assert.ok(removeExtensionItem, "'Remove' item exists");
+ Assert.ok(!removeExtensionItem.hidden, "'Remove' item is visible");
+ Assert.ok(!removeExtensionItem.disabled, "'Remove' item is not disabled");
+ let manageExtensionItem = getManageExtensionItem();
+ Assert.ok(manageExtensionItem, "'Manage' item exists");
+ Assert.ok(!manageExtensionItem.hidden, "'Manage' item is visible");
+ Assert.ok(!manageExtensionItem.disabled, "'Manage' item is not disabled");
+
+ // Click the "remove extension" item, a prompt should be displayed and then
+ // the add-on should be uninstalled. We mock the prompt service to cancel the
+ // removal of the add-on.
+ contextMenuPromise = promisePanelHidden("pageActionContextMenu");
+ let promptService = mockPromptService();
+ let promptCancelledPromise = new Promise(resolve => {
+ promptService.confirmEx = () => resolve();
+ });
+ contextMenu.activateItem(removeExtensionItem);
+ await Promise.all([contextMenuPromise, promptCancelledPromise]);
+
+ // Done, clean up.
+ await extension.unload();
+
+ // urlbar tests that run after this one can break if the mouse is left over
+ // the area where the urlbar popup appears, which seems to happen due to the
+ // above synthesized mouse events. Move it over the urlbar.
+ EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, { type: "mousemove" });
+ gURLBar.focus();
+});
+
+add_task(async function contextMenu_removeExtension_disabled_in_urlbar() {
+ // We use an extension that shows a page action so that we can test the
+ // "remove extension" item in the context menu.
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ name: "Test contextMenu",
+ page_action: { show_matches: ["<all_urls>"] },
+ },
+
+ useAddonManager: "temporary",
+ });
+
+ await extension.startup();
+ // The pageAction implementation enables the button at the next animation
+ // frame, so before we look for the button we should wait one animation frame
+ // as well.
+ await promiseAnimationFrame();
+ // Add a policy to prevent the add-on from being uninstalled.
+ await EnterprisePolicyTesting.setupPolicyEngineWithJson({
+ policies: {
+ Extensions: {
+ Locked: [extension.id],
+ },
+ },
+ });
+
+ let actionId = ExtensionCommon.makeWidgetId(extension.id);
+
+ // Open the context menu on the action's urlbar button.
+ let urlbarButton = BrowserPageActions.urlbarButtonNodeForActionID(actionId);
+ let contextMenuPromise = promisePanelShown("pageActionContextMenu");
+ EventUtils.synthesizeMouseAtCenter(urlbarButton, {
+ type: "contextmenu",
+ button: 2,
+ });
+ let contextMenu = await contextMenuPromise;
+
+ let menuItems = collectContextMenuItems();
+ Assert.equal(menuItems.length, 2, "Context menu has two children");
+ let removeExtensionItem = getRemoveExtensionItem();
+ Assert.ok(removeExtensionItem, "'Remove' item exists");
+ Assert.ok(!removeExtensionItem.hidden, "'Remove' item is visible");
+ Assert.ok(removeExtensionItem.disabled, "'Remove' item is disabled");
+ let manageExtensionItem = getManageExtensionItem();
+ Assert.ok(manageExtensionItem, "'Manage' item exists");
+ Assert.ok(!manageExtensionItem.hidden, "'Manage' item is visible");
+ Assert.ok(!manageExtensionItem.disabled, "'Manage' item is not disabled");
+
+ // Hide the context menu.
+ contextMenuPromise = promisePanelHidden("pageActionContextMenu");
+ contextMenu.hidePopup();
+ await contextMenuPromise;
+
+ // Done, clean up.
+ await extension.unload();
+ await EnterprisePolicyTesting.setupPolicyEngineWithJson("");
+
+ // urlbar tests that run after this one can break if the mouse is left over
+ // the area where the urlbar popup appears, which seems to happen due to the
+ // above synthesized mouse events. Move it over the urlbar.
+ EventUtils.synthesizeMouseAtCenter(gURLBar.inputField, { type: "mousemove" });
+ gURLBar.focus();
+});
+
+add_task(async function contextMenu_removeExtension_disabled_in_panel() {
+ // We use an extension that shows a page action so that we can test the
+ // "remove extension" item in the context menu.
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ name: "Test contextMenu",
+ page_action: { show_matches: ["<all_urls>"] },
+ },
+
+ useAddonManager: "temporary",
+ });
+
+ await extension.startup();
+ // Add a policy to prevent the add-on from being uninstalled.
+ await EnterprisePolicyTesting.setupPolicyEngineWithJson({
+ policies: {
+ Extensions: {
+ Locked: [extension.id],
+ },
+ },
+ });
+
+ let actionId = ExtensionCommon.makeWidgetId(extension.id);
+
+ const url = "data:text/html,<h1>A Page</h1>";
+ let win = await BrowserTestUtils.openNewBrowserWindow();
+ await SimpleTest.promiseFocus(win);
+ BrowserTestUtils.loadURIString(win.gBrowser, url);
+ await BrowserTestUtils.browserLoaded(win.gBrowser.selectedBrowser);
+
+ info("Shrink the window if necessary, check the meatball menu is visible");
+ let originalOuterWidth = win.outerWidth;
+ await promiseStableResize(500, win);
+
+ // The pageAction implementation enables the button at the next animation
+ // frame, so before we look for the button we should wait one animation frame
+ // as well.
+ await promiseAnimationFrame(win);
+
+ let meatballButton = win.document.getElementById("pageActionButton");
+ Assert.ok(BrowserTestUtils.is_visible(meatballButton));
+
+ // Open the panel.
+ await promisePageActionPanelOpen(win);
+
+ info("Open the context menu");
+ let panelButton = win.BrowserPageActions.panelButtonNodeForActionID(actionId);
+ let contextMenuPromise = promisePanelShown("pageActionContextMenu", win);
+ EventUtils.synthesizeMouseAtCenter(
+ panelButton,
+ {
+ type: "contextmenu",
+ button: 2,
+ },
+ win
+ );
+ let contextMenu = await contextMenuPromise;
+
+ let removeExtensionItem = getRemoveExtensionItem(win);
+ Assert.ok(removeExtensionItem, "'Remove' item exists");
+ Assert.ok(!removeExtensionItem.hidden, "'Remove' item is visible");
+ Assert.ok(removeExtensionItem.disabled, "'Remove' item is disabled");
+
+ // Hide the context menu.
+ contextMenuPromise = promisePanelHidden("pageActionContextMenu", win);
+ contextMenu.hidePopup();
+ await contextMenuPromise;
+
+ // Done, clean up.
+ await extension.unload();
+ await EnterprisePolicyTesting.setupPolicyEngineWithJson("");
+
+ await promiseStableResize(originalOuterWidth, win);
+ await BrowserTestUtils.closeWindow(win);
+});
+
+function promiseAddonUninstalled(addonId) {
+ return new Promise(resolve => {
+ let listener = {};
+ listener.onUninstalled = addon => {
+ if (addon.id == addonId) {
+ AddonManager.removeAddonListener(listener);
+ resolve();
+ }
+ };
+ AddonManager.addAddonListener(listener);
+ });
+}
+
+function mockPromptService() {
+ let promptService = {
+ // The prompt returns 1 for cancelled and 0 for accepted.
+ _response: 0,
+ QueryInterface: ChromeUtils.generateQI(["nsIPromptService"]),
+ confirmEx: () => promptService._response,
+ };
+
+ Services.prompt = promptService;
+
+ return promptService;
+}
+
+function getRemoveExtensionItem(win = window) {
+ return win.document.querySelector(
+ "#pageActionContextMenu > menuitem[label='Remove Extension']"
+ );
+}
+
+function getManageExtensionItem(win = window) {
+ return win.document.querySelector(
+ "#pageActionContextMenu > menuitem[label='Manage Extension…']"
+ );
+}
+
+function collectContextMenuItems(win = window) {
+ let contextMenu = win.document.getElementById("pageActionContextMenu");
+ return Array.prototype.filter.call(contextMenu.children, node => {
+ return win.getComputedStyle(node).visibility == "visible";
+ });
+}
diff --git a/browser/base/content/test/pageActions/head.js b/browser/base/content/test/pageActions/head.js
new file mode 100644
index 0000000000..15801c490e
--- /dev/null
+++ b/browser/base/content/test/pageActions/head.js
@@ -0,0 +1,163 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+ChromeUtils.defineESModuleGetters(this, {
+ EnterprisePolicyTesting:
+ "resource://testing-common/EnterprisePolicyTesting.sys.mjs",
+ ExtensionCommon: "resource://gre/modules/ExtensionCommon.sys.mjs",
+ PlacesTestUtils: "resource://testing-common/PlacesTestUtils.sys.mjs",
+ TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs",
+ sinon: "resource://testing-common/Sinon.sys.mjs",
+});
+
+async function promisePageActionPanelOpen(win = window, eventDict = {}) {
+ await BrowserTestUtils.waitForCondition(() => {
+ // Wait for the main page action button to become visible. It's hidden for
+ // some URIs, so depending on when this is called, it may not yet be quite
+ // visible. It's up to the caller to make sure it will be visible.
+ info("Waiting for main page action button to have non-0 size");
+ let bounds = win.windowUtils.getBoundsWithoutFlushing(
+ win.BrowserPageActions.mainButtonNode
+ );
+ return bounds.width > 0 && bounds.height > 0;
+ });
+
+ // Wait for the panel to become open, by clicking the button if necessary.
+ info("Waiting for main page action panel to be open");
+ if (win.BrowserPageActions.panelNode.state == "open") {
+ return;
+ }
+ let shownPromise = promisePageActionPanelShown(win);
+ EventUtils.synthesizeMouseAtCenter(
+ win.BrowserPageActions.mainButtonNode,
+ eventDict,
+ win
+ );
+ await shownPromise;
+ info("Wait for items in the panel to become visible.");
+ await promisePageActionViewChildrenVisible(
+ win.BrowserPageActions.mainViewNode,
+ win
+ );
+}
+
+function promisePageActionPanelShown(win = window) {
+ return promisePanelShown(win.BrowserPageActions.panelNode, win);
+}
+
+function promisePageActionPanelHidden(win = window) {
+ return promisePanelHidden(win.BrowserPageActions.panelNode, win);
+}
+
+function promisePanelShown(panelIDOrNode, win = window) {
+ return promisePanelEvent(panelIDOrNode, "popupshown", win);
+}
+
+function promisePanelHidden(panelIDOrNode, win = window) {
+ return promisePanelEvent(panelIDOrNode, "popuphidden", win);
+}
+
+function promisePanelEvent(panelIDOrNode, eventType, win = window) {
+ return new Promise(resolve => {
+ let panel = panelIDOrNode;
+ if (typeof panel == "string") {
+ panel = win.document.getElementById(panelIDOrNode);
+ if (!panel) {
+ throw new Error(`Panel with ID "${panelIDOrNode}" does not exist.`);
+ }
+ }
+ if (
+ (eventType == "popupshown" && panel.state == "open") ||
+ (eventType == "popuphidden" && panel.state == "closed")
+ ) {
+ executeSoon(() => resolve(panel));
+ return;
+ }
+ panel.addEventListener(
+ eventType,
+ () => {
+ executeSoon(() => resolve(panel));
+ },
+ { once: true }
+ );
+ });
+}
+
+async function promisePageActionViewChildrenVisible(
+ panelViewNode,
+ win = window
+) {
+ info(
+ "promisePageActionViewChildrenVisible waiting for a child node to be visible"
+ );
+ await new Promise(win.requestAnimationFrame);
+ let dwu = win.windowUtils;
+ return TestUtils.waitForCondition(() => {
+ let bodyNode = panelViewNode.firstElementChild;
+ for (let childNode of bodyNode.children) {
+ let bounds = dwu.getBoundsWithoutFlushing(childNode);
+ if (bounds.width > 0 && bounds.height > 0) {
+ return true;
+ }
+ }
+ return false;
+ });
+}
+
+function promiseAddonUninstalled(addonId) {
+ return new Promise(resolve => {
+ let listener = {};
+ listener.onUninstalled = addon => {
+ if (addon.id == addonId) {
+ AddonManager.removeAddonListener(listener);
+ resolve();
+ }
+ };
+ AddonManager.addAddonListener(listener);
+ });
+}
+
+async function promiseAnimationFrame(win = window) {
+ await new Promise(resolve => win.requestAnimationFrame(resolve));
+ await win.promiseDocumentFlushed(() => {});
+}
+
+async function promisePopupNotShown(id, win = window) {
+ let deferred = PromiseUtils.defer();
+ function listener(e) {
+ deferred.reject("Unexpected popupshown");
+ }
+ let panel = win.document.getElementById(id);
+ panel.addEventListener("popupshown", listener);
+ try {
+ await Promise.race([
+ deferred.promise,
+ new Promise(resolve => {
+ /* eslint-disable mozilla/no-arbitrary-setTimeout */
+ win.setTimeout(resolve, 300);
+ }),
+ ]);
+ } finally {
+ panel.removeEventListener("popupshown", listener);
+ }
+}
+
+// TODO (Bug 1700780): Why is this necessary? Without this trick the test
+// fails intermittently on Ubuntu.
+function promiseStableResize(expectedWidth, win = window) {
+ let deferred = PromiseUtils.defer();
+ let id;
+ function listener() {
+ win.clearTimeout(id);
+ info(`Got resize event: ${win.innerWidth} x ${win.innerHeight}`);
+ if (win.innerWidth <= expectedWidth) {
+ id = win.setTimeout(() => {
+ win.removeEventListener("resize", listener);
+ deferred.resolve();
+ }, 100);
+ }
+ }
+ win.addEventListener("resize", listener);
+ win.resizeTo(expectedWidth, win.outerHeight);
+ return deferred.promise;
+}
diff --git a/browser/base/content/test/pageStyle/browser.ini b/browser/base/content/test/pageStyle/browser.ini
new file mode 100644
index 0000000000..4123fd7ec2
--- /dev/null
+++ b/browser/base/content/test/pageStyle/browser.ini
@@ -0,0 +1,16 @@
+[DEFAULT]
+support-files =
+ head.js
+ page_style_sample.html
+ style.css
+
+[browser_disable_author_style_oop.js]
+https_first_disabled = true
+support-files =
+ page_style.html
+
+[browser_page_style_menu.js]
+support-files =
+ page_style_only_alternates.html
+[browser_page_style_menu_update.js]
+
diff --git a/browser/base/content/test/pageStyle/browser_disable_author_style_oop.js b/browser/base/content/test/pageStyle/browser_disable_author_style_oop.js
new file mode 100644
index 0000000000..f89e08a220
--- /dev/null
+++ b/browser/base/content/test/pageStyle/browser_disable_author_style_oop.js
@@ -0,0 +1,100 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+async function getColor(aSpawnTarget) {
+ return SpecialPowers.spawn(aSpawnTarget, [], function () {
+ return content.document.defaultView.getComputedStyle(
+ content.document.querySelector("p")
+ ).color;
+ });
+}
+
+async function insertIFrame() {
+ let bc = gBrowser.selectedBrowser.browsingContext;
+ let len = bc.children.length;
+
+ const kURL =
+ WEB_ROOT.replace("example.com", "example.net") + "page_style.html";
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [kURL], function (url) {
+ return new Promise(function (resolve) {
+ let e = content.document.createElement("iframe");
+ e.src = url;
+ e.onload = () => resolve();
+ content.document.body.append(e);
+ });
+ });
+
+ // Wait for the new frame to get a pres shell and be styled.
+ await BrowserTestUtils.waitForCondition(async function () {
+ return (
+ bc.children.length == len + 1 && (await getColor(bc.children[len])) != ""
+ );
+ });
+}
+
+// Test that inserting an iframe with a URL that is loaded OOP with Fission
+// enabled correctly matches the tab's author style disabled state.
+add_task(async function test_disable_style() {
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ WEB_ROOT + "page_style.html",
+ /* waitForLoad = */ true
+ );
+
+ let bc = gBrowser.selectedBrowser.browsingContext;
+
+ await insertIFrame();
+
+ is(
+ await getColor(bc),
+ "rgb(0, 0, 255)",
+ "parent color before disabling style"
+ );
+ is(
+ await getColor(bc.children[0]),
+ "rgb(0, 0, 255)",
+ "first child color before disabling style"
+ );
+
+ gPageStyleMenu.disableStyle();
+
+ is(await getColor(bc), "rgb(0, 0, 0)", "parent color after disabling style");
+ is(
+ await getColor(bc.children[0]),
+ "rgb(0, 0, 0)",
+ "first child color after disabling style"
+ );
+
+ await insertIFrame();
+
+ is(
+ await getColor(bc.children[1]),
+ "rgb(0, 0, 0)",
+ "second child color after disabling style"
+ );
+
+ await BrowserTestUtils.reloadTab(tab, true);
+
+ // Check the menu:
+ let { menupopup } = document.getElementById("pageStyleMenu");
+ gPageStyleMenu.fillPopup(menupopup);
+ Assert.equal(
+ menupopup.querySelector("menuitem[checked='true']").dataset.l10nId,
+ "menu-view-page-style-no-style",
+ "No style menu should be checked."
+ );
+
+ // check the page content still has a disabled author style:
+ Assert.ok(
+ await SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [],
+ () => content.docShell.contentViewer.authorStyleDisabled
+ ),
+ "Author style should still be disabled."
+ );
+
+ BrowserTestUtils.removeTab(tab);
+});
diff --git a/browser/base/content/test/pageStyle/browser_page_style_menu.js b/browser/base/content/test/pageStyle/browser_page_style_menu.js
new file mode 100644
index 0000000000..2ae635f16b
--- /dev/null
+++ b/browser/base/content/test/pageStyle/browser_page_style_menu.js
@@ -0,0 +1,174 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+function fillPopupAndGetItems() {
+ let menupopup = document.getElementById("pageStyleMenu").menupopup;
+ gPageStyleMenu.fillPopup(menupopup);
+ return Array.from(menupopup.querySelectorAll("menuseparator ~ menuitem"));
+}
+
+function getRootColor() {
+ return SpecialPowers.spawn(gBrowser.selectedBrowser, [], function () {
+ return content.document.defaultView.getComputedStyle(
+ content.document.documentElement
+ ).color;
+ });
+}
+
+const RED = "rgb(255, 0, 0)";
+const LIME = "rgb(0, 255, 0)";
+const BLUE = "rgb(0, 0, 255)";
+
+const kStyleSheetsInPageStyleSample = 18;
+
+/*
+ * Test that the right stylesheets do (and others don't) show up
+ * in the page style menu.
+ */
+add_task(async function test_menu() {
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:blank",
+ false
+ );
+ let browser = tab.linkedBrowser;
+ BrowserTestUtils.loadURIString(browser, WEB_ROOT + "page_style_sample.html");
+ await promiseStylesheetsLoaded(browser, kStyleSheetsInPageStyleSample);
+
+ let menuitems = fillPopupAndGetItems();
+ let items = menuitems.map(el => ({
+ label: el.getAttribute("label"),
+ checked: el.getAttribute("checked") == "true",
+ }));
+
+ let validLinks = await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [items],
+ function (contentItems) {
+ let contentValidLinks = 0;
+ for (let el of content.document.querySelectorAll("link, style")) {
+ var title = el.getAttribute("title");
+ var rel = el.getAttribute("rel");
+ var media = el.getAttribute("media");
+ var idstring =
+ el.nodeName +
+ " " +
+ (title ? title : "without title and") +
+ ' with rel="' +
+ rel +
+ '"' +
+ (media ? ' and media="' + media + '"' : "");
+
+ var item = contentItems.filter(aItem => aItem.label == title);
+ var found = item.length == 1;
+ var checked = found && item[0].checked;
+
+ switch (el.getAttribute("data-state")) {
+ case "0":
+ ok(!found, idstring + " should not show up in page style menu");
+ break;
+ case "1":
+ contentValidLinks++;
+ ok(found, idstring + " should show up in page style menu");
+ ok(!checked, idstring + " should not be selected");
+ break;
+ case "2":
+ contentValidLinks++;
+ ok(found, idstring + " should show up in page style menu");
+ ok(checked, idstring + " should be selected");
+ break;
+ default:
+ throw new Error(
+ "data-state attribute is missing or has invalid value"
+ );
+ }
+ }
+ return contentValidLinks;
+ }
+ );
+
+ ok(menuitems.length, "At least one item in the menu");
+ is(menuitems.length, validLinks, "all valid links found");
+
+ is(await getRootColor(), LIME, "Root should be lime (styles should apply)");
+
+ let disableStyles = document.getElementById("menu_pageStyleNoStyle");
+ let defaultStyles = document.getElementById("menu_pageStylePersistentOnly");
+ let otherStyles = menuitems[0].parentNode.querySelector("[label='28']");
+
+ // Assert that the menu works as expected.
+ disableStyles.click();
+
+ await TestUtils.waitForCondition(async function () {
+ let color = await getRootColor();
+ return color != LIME && color != BLUE;
+ }, "ensuring disabled styles work");
+
+ otherStyles.click();
+
+ await TestUtils.waitForCondition(async function () {
+ let color = await getRootColor();
+ return color == BLUE;
+ }, "ensuring alternate styles work. clicking on: " + otherStyles.label);
+
+ defaultStyles.click();
+
+ info("ensuring default styles work");
+ await TestUtils.waitForCondition(async function () {
+ let color = await getRootColor();
+ return color == LIME;
+ }, "ensuring default styles work");
+
+ BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function test_default_style_with_no_sheets() {
+ const PAGE = WEB_ROOT + "page_style_only_alternates.html";
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: PAGE,
+ waitForLoad: true,
+ },
+ async function (browser) {
+ await promiseStylesheetsLoaded(browser, 2);
+
+ let menuitems = fillPopupAndGetItems();
+ is(menuitems.length, 2, "Should've found two style sets");
+ is(
+ await getRootColor(),
+ BLUE,
+ "First found style should become the preferred one and apply"
+ );
+
+ // Reset the styles.
+ document.getElementById("menu_pageStylePersistentOnly").click();
+ await TestUtils.waitForCondition(async function () {
+ let color = await getRootColor();
+ return color != BLUE && color != RED;
+ });
+
+ ok(
+ true,
+ "Should reset the style properly even if there are no non-alternate stylesheets"
+ );
+ }
+ );
+});
+
+add_task(async function test_page_style_file() {
+ const FILE_PAGE = Services.io.newFileURI(
+ new FileUtils.File(getTestFilePath("page_style_sample.html"))
+ ).spec;
+ await BrowserTestUtils.withNewTab(FILE_PAGE, async function (browser) {
+ await promiseStylesheetsLoaded(browser, kStyleSheetsInPageStyleSample);
+ let menuitems = fillPopupAndGetItems();
+ is(
+ menuitems.length,
+ kStyleSheetsInPageStyleSample,
+ "Should have the right amount of items even for file: URI."
+ );
+ });
+});
diff --git a/browser/base/content/test/pageStyle/browser_page_style_menu_update.js b/browser/base/content/test/pageStyle/browser_page_style_menu_update.js
new file mode 100644
index 0000000000..1cd0aadb6e
--- /dev/null
+++ b/browser/base/content/test/pageStyle/browser_page_style_menu_update.js
@@ -0,0 +1,49 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const PAGE = WEB_ROOT + "page_style_sample.html";
+
+/**
+ * Tests that the Page Style menu shows the currently
+ * selected Page Style after a new one has been selected.
+ */
+add_task(async function () {
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:blank",
+ false
+ );
+ let browser = tab.linkedBrowser;
+ BrowserTestUtils.loadURIString(browser, PAGE);
+ await promiseStylesheetsLoaded(browser, 18);
+
+ let menupopup = document.getElementById("pageStyleMenu").menupopup;
+ gPageStyleMenu.fillPopup(menupopup);
+
+ // page_style_sample.html should default us to selecting the stylesheet
+ // with the title "6" first.
+ let selected = menupopup.querySelector("menuitem[checked='true']");
+ is(
+ selected.getAttribute("label"),
+ "6",
+ "Should have '6' stylesheet selected by default"
+ );
+
+ // Now select stylesheet "1"
+ let target = menupopup.querySelector("menuitem[label='1']");
+ target.doCommand();
+
+ gPageStyleMenu.fillPopup(menupopup);
+ // gPageStyleMenu empties out the menu between opens, so we need
+ // to get a new reference to the selected menuitem
+ selected = menupopup.querySelector("menuitem[checked='true']");
+ is(
+ selected.getAttribute("label"),
+ "1",
+ "Should now have stylesheet 1 selected"
+ );
+
+ BrowserTestUtils.removeTab(tab);
+});
diff --git a/browser/base/content/test/pageStyle/head.js b/browser/base/content/test/pageStyle/head.js
new file mode 100644
index 0000000000..57d5947d50
--- /dev/null
+++ b/browser/base/content/test/pageStyle/head.js
@@ -0,0 +1,30 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const WEB_ROOT = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+);
+
+/**
+ * Waits for the stylesheets to be loaded into the browser menu.
+ *
+ * @param browser
+ * The browser that contains the webpage we're testing.
+ * @param styleSheetCount
+ * How many stylesheets we expect to be loaded.
+ * @return Promise
+ */
+function promiseStylesheetsLoaded(browser, styleSheetCount) {
+ return TestUtils.waitForCondition(() => {
+ let actor =
+ browser.browsingContext?.currentWindowGlobal?.getActor("PageStyle");
+ if (!actor) {
+ info("No jswindowactor (yet?)");
+ return false;
+ }
+ let sheetCount = actor.getSheetInfo().filteredStyleSheets.length;
+ info(`waiting for sheets: ${sheetCount}`);
+ return sheetCount >= styleSheetCount;
+ }, "waiting for style sheets to load");
+}
diff --git a/browser/base/content/test/pageStyle/page_style.html b/browser/base/content/test/pageStyle/page_style.html
new file mode 100644
index 0000000000..c16a9ea4aa
--- /dev/null
+++ b/browser/base/content/test/pageStyle/page_style.html
@@ -0,0 +1,8 @@
+<!DOCTYPE html>
+<style>
+p { color: blue; font-weight: bold; }
+</style>
+<p>Some text.</p>
+<script>
+let gFramesLoaded = 0;
+</script>
diff --git a/browser/base/content/test/pageStyle/page_style_only_alternates.html b/browser/base/content/test/pageStyle/page_style_only_alternates.html
new file mode 100644
index 0000000000..b5f4a8181c
--- /dev/null
+++ b/browser/base/content/test/pageStyle/page_style_only_alternates.html
@@ -0,0 +1,5 @@
+<!doctype html>
+<title>Test for the page style menu</title>
+<!-- We only have alternates here intentionally. "Basic Page Style" should still work and remove the blue / red colors -->
+<style title="blue">:root { color: blue }</style>
+<style title="red">:root { color: red}</style>
diff --git a/browser/base/content/test/pageStyle/page_style_sample.html b/browser/base/content/test/pageStyle/page_style_sample.html
new file mode 100644
index 0000000000..ec89e99bc9
--- /dev/null
+++ b/browser/base/content/test/pageStyle/page_style_sample.html
@@ -0,0 +1,45 @@
+<html>
+ <head>
+ <title>Test for page style menu</title>
+ <!-- data-state values:
+ 0: should not appear in the page style menu
+ 1: should appear in the page style menu
+ 2: should appear in the page style menu as the selected stylesheet -->
+ <style data-state="0">
+ /* Some default styles to ensure that disabling styles works */
+ :root { color: lime }
+ </style>
+ <link data-state="1" href="style.css" title="1" rel="alternate stylesheet">
+ <link data-state="0" title="2" rel="alternate stylesheet">
+ <link data-state="0" href="style.css" rel="alternate stylesheet">
+ <link data-state="0" href="style.css" title="" rel="alternate stylesheet">
+ <link data-state="1" href="style.css" title="3" rel="stylesheet alternate">
+ <link data-state="1" href="style.css" title="4" rel=" alternate stylesheet ">
+ <link data-state="1" href="style.css" title="5" rel="alternate stylesheet">
+ <link data-state="2" href="style.css" title="6" rel="stylesheet">
+ <link data-state="1" href="style.css" title="7" rel="foo stylesheet">
+ <link data-state="0" href="style.css" title="8" rel="alternate">
+ <link data-state="1" href="style.css" title="9" rel="alternate STYLEsheet">
+ <link data-state="1" href="style.css" title="10" rel="alternate stylesheet" media="">
+ <link data-state="1" href="style.css" title="11" rel="alternate stylesheet" media="all">
+ <link data-state="1" href="style.css" title="12" rel="alternate stylesheet" media="ALL ">
+ <link data-state="1" href="style.css" title="13" rel="alternate stylesheet" media="screen">
+ <link data-state="1" href="style.css" title="14" rel="alternate stylesheet" media=" Screen">
+ <link data-state="0" href="style.css" title="15" rel="alternate stylesheet" media="screen foo">
+ <link data-state="0" href="style.css" title="16" rel="alternate stylesheet" media="all screen">
+ <link data-state="0" href="style.css" title="17" rel="alternate stylesheet" media="foo bar">
+ <link data-state="1" href="style.css" title="18" rel="alternate stylesheet" media="all,screen">
+ <link data-state="1" href="style.css" title="19" rel="alternate stylesheet" media="all, screen">
+ <link data-state="0" href="style.css" title="20" rel="alternate stylesheet" media="all screen">
+ <link data-state="0" href="style.css" title="21" rel="alternate stylesheet" media="foo">
+ <link data-state="0" href="style.css" title="22" rel="alternate stylesheet" media="allscreen">
+ <link data-state="0" href="style.css" title="23" rel="alternate stylesheet" media="_all">
+ <link data-state="0" href="style.css" title="24" rel="alternate stylesheet" media="not screen">
+ <link data-state="1" href="style.css" title="25" rel="alternate stylesheet" media="only screen">
+ <link data-state="1" href="style.css" title="26" rel="alternate stylesheet" media="screen and (min-device-width: 1px)">
+ <link data-state="0" href="style.css" title="27" rel="alternate stylesheet" media="screen and (max-device-width: 1px)">
+ <style data-state="1" title="28">:root { color: blue }</style>
+ <link data-state="1" href="style.css" title="29" rel="alternate stylesheet" disabled>
+ </head>
+ <body></body>
+</html>
diff --git a/browser/base/content/test/pageStyle/style.css b/browser/base/content/test/pageStyle/style.css
new file mode 100644
index 0000000000..c8337cea19
--- /dev/null
+++ b/browser/base/content/test/pageStyle/style.css
@@ -0,0 +1 @@
+.unused { /* This sheet is here for testing purposes. */ }
diff --git a/browser/base/content/test/pageinfo/all_images.html b/browser/base/content/test/pageinfo/all_images.html
new file mode 100644
index 0000000000..c246e25519
--- /dev/null
+++ b/browser/base/content/test/pageinfo/all_images.html
@@ -0,0 +1,15 @@
+<html>
+ <head>
+ <title>Test for media tab</title>
+ <link rel='shortcut icon' href='dummy_icon.ico'>
+ </head>
+ <body style='background-image:url(about:logo?a);'>
+ <img src='dummy_image.gif'>
+ <ul>
+ <li style='list-style:url(about:logo?b);'>List Item 1</li>
+ </ul>
+ <div style='-moz-border-image: url(about:logo?c) 20 20 20 20;'>test</div>
+ <a href='' style='cursor: url(about:logo?d),default;'>test link</a>
+ <object type='image/svg+xml' width=20 height=20 data='dummy_object.svg'></object>
+ </body>
+</html>");
diff --git a/browser/base/content/test/pageinfo/browser.ini b/browser/base/content/test/pageinfo/browser.ini
new file mode 100644
index 0000000000..c109c6bf66
--- /dev/null
+++ b/browser/base/content/test/pageinfo/browser.ini
@@ -0,0 +1,27 @@
+[DEFAULT]
+
+[browser_pageinfo_firstPartyIsolation.js]
+support-files =
+ image.html
+ ../general/audio.ogg
+ ../general/moz.png
+ ../general/video.ogg
+[browser_pageinfo_iframe_media.js]
+support-files =
+ iframes.html
+[browser_pageinfo_image_info.js]
+skip-if = (os == 'linux') # bug 1161699
+[browser_pageinfo_images.js]
+support-files =
+ all_images.html
+[browser_pageinfo_permissions.js]
+[browser_pageinfo_rtl.js]
+[browser_pageinfo_security.js]
+https_first_disabled = true
+support-files =
+ ../general/moz.png
+[browser_pageinfo_separate_private.js]
+[browser_pageinfo_svg_image.js]
+support-files =
+ svg_image.html
+ ../general/title_test.svg
diff --git a/browser/base/content/test/pageinfo/browser_pageinfo_firstPartyIsolation.js b/browser/base/content/test/pageinfo/browser_pageinfo_firstPartyIsolation.js
new file mode 100644
index 0000000000..b280242b40
--- /dev/null
+++ b/browser/base/content/test/pageinfo/browser_pageinfo_firstPartyIsolation.js
@@ -0,0 +1,89 @@
+const Cm = Components.manager;
+
+async function testFirstPartyDomain(pageInfo) {
+ const EXPECTED_DOMAIN = "example.com";
+ await BrowserTestUtils.waitForEvent(pageInfo, "page-info-init");
+ info("pageInfo initialized");
+ let tree = pageInfo.document.getElementById("imagetree");
+ Assert.ok(!!tree, "should have imagetree element");
+
+ // i=0: <img>
+ // i=1: <video>
+ // i=2: <audio>
+ for (let i = 0; i < 3; i++) {
+ info("imagetree select " + i);
+ tree.view.selection.select(i);
+ tree.ensureRowIsVisible(i);
+ tree.focus();
+
+ let preview = pageInfo.document.getElementById("thepreviewimage");
+ info("preview.src=" + preview.src);
+
+ // For <img>, we will query imgIRequest.imagePrincipal later, so we wait
+ // for load event. For <audio> and <video>, so far we only can get
+ // the triggeringprincipal attribute on the node, so we simply wait for
+ // loadstart.
+ if (i == 0) {
+ await BrowserTestUtils.waitForEvent(preview, "load");
+ } else {
+ await BrowserTestUtils.waitForEvent(preview, "loadstart");
+ }
+
+ info("preview load " + i);
+
+ // Originally thepreviewimage is loaded with SystemPrincipal, therefore
+ // it won't have origin attributes, now we've changed to loadingPrincipal
+ // to the content in bug 1376971, it should have firstPartyDomain set.
+ if (i == 0) {
+ let req = preview.getRequest(Ci.nsIImageLoadingContent.CURRENT_REQUEST);
+ Assert.equal(
+ req.imagePrincipal.originAttributes.firstPartyDomain,
+ EXPECTED_DOMAIN,
+ "imagePrincipal should have firstPartyDomain set to " + EXPECTED_DOMAIN
+ );
+ }
+
+ // Check the node has the attribute 'triggeringprincipal'.
+ let loadingPrincipalStr = preview.getAttribute("triggeringprincipal");
+ let loadingPrincipal = E10SUtils.deserializePrincipal(loadingPrincipalStr);
+ Assert.equal(
+ loadingPrincipal.originAttributes.firstPartyDomain,
+ EXPECTED_DOMAIN,
+ "loadingPrincipal should have firstPartyDomain set to " + EXPECTED_DOMAIN
+ );
+ }
+}
+
+async function test() {
+ waitForExplicitFinish();
+
+ Services.prefs.setBoolPref("privacy.firstparty.isolate", true);
+ registerCleanupFunction(function () {
+ Services.prefs.clearUserPref("privacy.firstparty.isolate");
+ });
+
+ let url =
+ "https://example.com/browser/browser/base/content/test/pageinfo/image.html";
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+ let loadPromise = BrowserTestUtils.browserLoaded(
+ gBrowser.selectedBrowser,
+ false,
+ url
+ );
+ BrowserTestUtils.loadURIString(gBrowser.selectedBrowser, url);
+ await loadPromise;
+
+ // Pass a dummy imageElement, if there isn't an imageElement, pageInfo.js
+ // will do a preview, however this sometimes will cause intermittent failures,
+ // see bug 1403365.
+ let pageInfo = BrowserPageInfo(url, "mediaTab", {});
+ info("waitForEvent pageInfo");
+ await BrowserTestUtils.waitForEvent(pageInfo, "load");
+
+ info("calling testFirstPartyDomain");
+ await testFirstPartyDomain(pageInfo);
+
+ pageInfo.close();
+ gBrowser.removeCurrentTab();
+ finish();
+}
diff --git a/browser/base/content/test/pageinfo/browser_pageinfo_iframe_media.js b/browser/base/content/test/pageinfo/browser_pageinfo_iframe_media.js
new file mode 100644
index 0000000000..3040474f31
--- /dev/null
+++ b/browser/base/content/test/pageinfo/browser_pageinfo_iframe_media.js
@@ -0,0 +1,31 @@
+/* Check proper media data retrieval in case of iframe */
+
+const TEST_PATH = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com"
+);
+
+add_task(async function test_all_images_mentioned() {
+ await BrowserTestUtils.withNewTab(
+ TEST_PATH + "iframes.html",
+ async function () {
+ let pageInfo = BrowserPageInfo(
+ gBrowser.selectedBrowser.currentURI.spec,
+ "mediaTab"
+ );
+ await BrowserTestUtils.waitForEvent(pageInfo, "page-info-init");
+
+ let imageTree = pageInfo.document.getElementById("imagetree");
+ let imageRowsNum = imageTree.view.rowCount;
+
+ ok(imageTree, "Image tree is null (media tab is broken)");
+ ok(
+ imageRowsNum == 2,
+ "Number of media items listed: " + imageRowsNum + ", should be 2"
+ );
+
+ pageInfo.close();
+ }
+ );
+});
diff --git a/browser/base/content/test/pageinfo/browser_pageinfo_image_info.js b/browser/base/content/test/pageinfo/browser_pageinfo_image_info.js
new file mode 100644
index 0000000000..374cd5f032
--- /dev/null
+++ b/browser/base/content/test/pageinfo/browser_pageinfo_image_info.js
@@ -0,0 +1,57 @@
+/**
+ * Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ **/
+
+const URI =
+ "data:text/html," +
+ "<style type='text/css'>%23test-image,%23not-test-image {background-image: url('about:logo?c');}</style>" +
+ "<img src='about:logo?b' height=300 width=350 alt=2 id='not-test-image'>" +
+ "<img src='about:logo?b' height=300 width=350 alt=2>" +
+ "<img src='about:logo?a' height=200 width=250>" +
+ "<img src='about:logo?b' height=200 width=250 alt=1>" +
+ "<img src='about:logo?b' height=100 width=150 alt=2 id='test-image'>";
+
+add_task(async function () {
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, URI);
+ let browser = tab.linkedBrowser;
+
+ let imageInfo = await SpecialPowers.spawn(browser, [], async () => {
+ let testImg = content.document.getElementById("test-image");
+
+ return {
+ src: testImg.src,
+ currentSrc: testImg.currentSrc,
+ width: testImg.width,
+ height: testImg.height,
+ imageText: testImg.title || testImg.alt,
+ };
+ });
+
+ let pageInfo = BrowserPageInfo(
+ browser.currentURI.spec,
+ "mediaTab",
+ imageInfo
+ );
+ await BrowserTestUtils.waitForEvent(pageInfo, "page-info-init");
+
+ let pageInfoImg = pageInfo.document.getElementById("thepreviewimage");
+ await BrowserTestUtils.waitForEvent(pageInfoImg, "load");
+ Assert.equal(
+ pageInfoImg.src,
+ imageInfo.src,
+ "selected image has the correct source"
+ );
+ Assert.equal(
+ pageInfoImg.width,
+ imageInfo.width,
+ "selected image has the correct width"
+ );
+ Assert.equal(
+ pageInfoImg.height,
+ imageInfo.height,
+ "selected image has the correct height"
+ );
+ pageInfo.close();
+ BrowserTestUtils.removeTab(tab);
+});
diff --git a/browser/base/content/test/pageinfo/browser_pageinfo_images.js b/browser/base/content/test/pageinfo/browser_pageinfo_images.js
new file mode 100644
index 0000000000..5cb4c79bf3
--- /dev/null
+++ b/browser/base/content/test/pageinfo/browser_pageinfo_images.js
@@ -0,0 +1,93 @@
+/* Check proper image url retrieval from all kinds of elements/styles */
+
+const TEST_PATH = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com"
+);
+
+add_task(async function test_all_images_mentioned() {
+ await BrowserTestUtils.withNewTab(
+ TEST_PATH + "all_images.html",
+ async function () {
+ let pageInfo = BrowserPageInfo(
+ gBrowser.selectedBrowser.currentURI.spec,
+ "mediaTab"
+ );
+ await BrowserTestUtils.waitForEvent(pageInfo, "page-info-init");
+
+ let imageTree = pageInfo.document.getElementById("imagetree");
+ let imageRowsNum = imageTree.view.rowCount;
+
+ ok(imageTree, "Image tree is null (media tab is broken)");
+
+ ok(
+ imageRowsNum == 7,
+ "Number of images listed: " + imageRowsNum + ", should be 7"
+ );
+
+ // Check that select all works
+ imageTree.focus();
+ ok(
+ !pageInfo.document.getElementById("cmd_copy").hasAttribute("disabled"),
+ "copy is enabled"
+ );
+ ok(
+ !pageInfo.document
+ .getElementById("cmd_selectAll")
+ .hasAttribute("disabled"),
+ "select all is enabled"
+ );
+ pageInfo.goDoCommand("cmd_selectAll");
+ is(imageTree.view.selection.count, 7, "all rows selected");
+
+ pageInfo.close();
+ }
+ );
+});
+
+add_task(async function test_view_image_info() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.menu.showViewImageInfo", true]],
+ });
+
+ await BrowserTestUtils.withNewTab(
+ TEST_PATH + "all_images.html",
+
+ async function (browser) {
+ let contextMenu = document.getElementById("contentAreaContextMenu");
+ let viewImageInfo = document.getElementById("context-viewimageinfo");
+
+ let imageInfo = await SpecialPowers.spawn(browser, [], async () => {
+ let testImg = content.document.querySelector("img");
+ return {
+ src: testImg.src,
+ };
+ });
+
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "img",
+ { type: "contextmenu", button: 2 },
+ browser
+ );
+
+ await BrowserTestUtils.waitForEvent(contextMenu, "popupshown");
+
+ let promisePageInfoLoaded = BrowserTestUtils.domWindowOpened().then(win =>
+ BrowserTestUtils.waitForEvent(win, "page-info-init")
+ );
+
+ contextMenu.activateItem(viewImageInfo);
+
+ let pageInfo = (await promisePageInfoLoaded).target.ownerGlobal;
+ let pageInfoImg = pageInfo.document.getElementById("thepreviewimage");
+
+ Assert.equal(
+ pageInfoImg.src,
+ imageInfo.src,
+ "selected image is the correct"
+ );
+ await BrowserTestUtils.closeWindow(pageInfo);
+ }
+ );
+});
diff --git a/browser/base/content/test/pageinfo/browser_pageinfo_permissions.js b/browser/base/content/test/pageinfo/browser_pageinfo_permissions.js
new file mode 100644
index 0000000000..7e3e83b60d
--- /dev/null
+++ b/browser/base/content/test/pageinfo/browser_pageinfo_permissions.js
@@ -0,0 +1,258 @@
+const { PermissionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PermissionTestUtils.sys.mjs"
+);
+
+const TEST_ORIGIN = "https://example.com";
+const TEST_ORIGIN_CERT_ERROR = "https://expired.example.com";
+const LOW_TLS_VERSION = "https://tls1.example.com/";
+
+async function testPermissions(defaultPermission) {
+ await BrowserTestUtils.withNewTab(TEST_ORIGIN, async function (browser) {
+ let pageInfo = BrowserPageInfo(TEST_ORIGIN, "permTab");
+ await BrowserTestUtils.waitForEvent(pageInfo, "load");
+
+ let defaultCheckbox = await TestUtils.waitForCondition(() =>
+ pageInfo.document.getElementById("geoDef")
+ );
+ let radioGroup = pageInfo.document.getElementById("geoRadioGroup");
+ let defaultRadioButton = pageInfo.document.getElementById(
+ "geo#" + defaultPermission
+ );
+ let blockRadioButton = pageInfo.document.getElementById("geo#2");
+
+ ok(defaultCheckbox.checked, "The default checkbox should be checked.");
+
+ PermissionTestUtils.add(
+ gBrowser.currentURI,
+ "geo",
+ Services.perms.DENY_ACTION
+ );
+
+ ok(!defaultCheckbox.checked, "The default checkbox should not be checked.");
+
+ defaultCheckbox.checked = true;
+ defaultCheckbox.dispatchEvent(new Event("command"));
+
+ ok(
+ !PermissionTestUtils.getPermissionObject(gBrowser.currentURI, "geo"),
+ "Checking the default checkbox should reset the permission."
+ );
+
+ defaultCheckbox.checked = false;
+ defaultCheckbox.dispatchEvent(new Event("command"));
+
+ ok(
+ !PermissionTestUtils.getPermissionObject(gBrowser.currentURI, "geo"),
+ "Unchecking the default checkbox should pick the default permission."
+ );
+ is(
+ radioGroup.selectedItem,
+ defaultRadioButton,
+ "The unknown radio button should be selected."
+ );
+
+ radioGroup.selectedItem = blockRadioButton;
+ blockRadioButton.dispatchEvent(new Event("command"));
+
+ is(
+ PermissionTestUtils.getPermissionObject(gBrowser.currentURI, "geo")
+ .capability,
+ Services.perms.DENY_ACTION,
+ "Selecting a value in the radio group should set the corresponding permission"
+ );
+
+ radioGroup.selectedItem = defaultRadioButton;
+ defaultRadioButton.dispatchEvent(new Event("command"));
+
+ ok(
+ !PermissionTestUtils.getPermissionObject(gBrowser.currentURI, "geo"),
+ "Selecting the default value should reset the permission."
+ );
+ ok(defaultCheckbox.checked, "The default checkbox should be checked.");
+
+ pageInfo.close();
+ PermissionTestUtils.remove(gBrowser.currentURI, "geo");
+ });
+}
+
+// Test displaying website permissions on certificate error pages.
+add_task(async function test_CertificateError() {
+ let browser;
+ let pageLoaded;
+ await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ () => {
+ gBrowser.selectedTab = BrowserTestUtils.addTab(
+ gBrowser,
+ TEST_ORIGIN_CERT_ERROR
+ );
+ browser = gBrowser.selectedBrowser;
+ pageLoaded = BrowserTestUtils.waitForErrorPage(browser);
+ },
+ false
+ );
+
+ await pageLoaded;
+
+ let pageInfo = BrowserPageInfo(TEST_ORIGIN_CERT_ERROR, "permTab");
+ await BrowserTestUtils.waitForEvent(pageInfo, "load");
+ let permissionTab = pageInfo.document.getElementById("permTab");
+ await TestUtils.waitForCondition(
+ () => BrowserTestUtils.is_visible(permissionTab),
+ "Permission tab should be visible."
+ );
+
+ let hostText = pageInfo.document.getElementById("hostText");
+ let permList = pageInfo.document.getElementById("permList");
+ let excludedPermissions = pageInfo.window.getExcludedPermissions();
+ let permissions = SitePermissions.listPermissions().filter(
+ p =>
+ SitePermissions.getPermissionLabel(p) != null &&
+ !excludedPermissions.includes(p)
+ );
+
+ await TestUtils.waitForCondition(
+ () => hostText.value === browser.currentURI.displayPrePath,
+ `Value of owner should be "${browser.currentURI.displayPrePath}" instead got "${hostText.value}".`
+ );
+
+ await TestUtils.waitForCondition(
+ () => permList.childElementCount === permissions.length,
+ `Value of verifier should be ${permissions.length}, instead got ${permList.childElementCount}.`
+ );
+
+ pageInfo.close();
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+});
+
+// Test displaying website permissions on network error pages.
+add_task(async function test_NetworkError() {
+ // Setup for TLS error
+ Services.prefs.setIntPref("security.tls.version.max", 3);
+ Services.prefs.setIntPref("security.tls.version.min", 3);
+
+ let browser;
+ let pageLoaded;
+ await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ () => {
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, LOW_TLS_VERSION);
+ browser = gBrowser.selectedBrowser;
+ pageLoaded = BrowserTestUtils.waitForErrorPage(browser);
+ },
+ false
+ );
+
+ await pageLoaded;
+
+ let pageInfo = BrowserPageInfo(LOW_TLS_VERSION, "permTab");
+ await BrowserTestUtils.waitForEvent(pageInfo, "load");
+ let permissionTab = pageInfo.document.getElementById("permTab");
+ await TestUtils.waitForCondition(
+ () => BrowserTestUtils.is_visible(permissionTab),
+ "Permission tab should be visible."
+ );
+
+ let hostText = pageInfo.document.getElementById("hostText");
+ let permList = pageInfo.document.getElementById("permList");
+ let excludedPermissions = pageInfo.window.getExcludedPermissions();
+ let permissions = SitePermissions.listPermissions().filter(
+ p =>
+ SitePermissions.getPermissionLabel(p) != null &&
+ !excludedPermissions.includes(p)
+ );
+
+ await TestUtils.waitForCondition(
+ () => hostText.value === browser.currentURI.displayPrePath,
+ `Value of host should be should be "${browser.currentURI.displayPrePath}" instead got "${hostText.value}".`
+ );
+
+ await TestUtils.waitForCondition(
+ () => permList.childElementCount === permissions.length,
+ `Value of permissions list should be ${permissions.length}, instead got ${permList.childElementCount}.`
+ );
+
+ pageInfo.close();
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+});
+
+// Test some standard operations in the permission tab.
+add_task(async function test_geo_permission() {
+ await testPermissions(Services.perms.UNKNOWN_ACTION);
+});
+
+// Test some standard operations in the permission tab, falling back to a custom
+// default permission instead of UNKNOWN.
+add_task(async function test_default_geo_permission() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["permissions.default.geo", SitePermissions.ALLOW]],
+ });
+ await testPermissions(Services.perms.ALLOW_ACTION);
+});
+
+// Test special behavior for cookie permissions.
+add_task(async function test_cookie_permission() {
+ await BrowserTestUtils.withNewTab(TEST_ORIGIN, async function (browser) {
+ let pageInfo = BrowserPageInfo(TEST_ORIGIN, "permTab");
+ await BrowserTestUtils.waitForEvent(pageInfo, "load");
+
+ let defaultCheckbox = await TestUtils.waitForCondition(() =>
+ pageInfo.document.getElementById("cookieDef")
+ );
+ let radioGroup = pageInfo.document.getElementById("cookieRadioGroup");
+ let allowRadioButton = pageInfo.document.getElementById("cookie#1");
+ let blockRadioButton = pageInfo.document.getElementById("cookie#2");
+
+ ok(defaultCheckbox.checked, "The default checkbox should be checked.");
+
+ defaultCheckbox.checked = false;
+ defaultCheckbox.dispatchEvent(new Event("command"));
+
+ is(
+ PermissionTestUtils.testPermission(gBrowser.currentURI, "cookie"),
+ SitePermissions.ALLOW,
+ "Unchecking the default checkbox should pick the default permission."
+ );
+ is(
+ radioGroup.selectedItem,
+ allowRadioButton,
+ "The unknown radio button should be selected."
+ );
+
+ radioGroup.selectedItem = blockRadioButton;
+ blockRadioButton.dispatchEvent(new Event("command"));
+
+ is(
+ PermissionTestUtils.testPermission(gBrowser.currentURI, "cookie"),
+ SitePermissions.BLOCK,
+ "Selecting a value in the radio group should set the corresponding permission"
+ );
+
+ radioGroup.selectedItem = allowRadioButton;
+ allowRadioButton.dispatchEvent(new Event("command"));
+
+ is(
+ PermissionTestUtils.testPermission(gBrowser.currentURI, "cookie"),
+ SitePermissions.ALLOW,
+ "Selecting a value in the radio group should set the corresponding permission"
+ );
+ ok(!defaultCheckbox.checked, "The default checkbox should not be checked.");
+
+ defaultCheckbox.checked = true;
+ defaultCheckbox.dispatchEvent(new Event("command"));
+
+ is(
+ PermissionTestUtils.testPermission(gBrowser.currentURI, "cookie"),
+ SitePermissions.UNKNOWN,
+ "Checking the default checkbox should reset the permission."
+ );
+ is(
+ radioGroup.selectedItem,
+ null,
+ "For cookies, no item should be selected when the checkbox is checked."
+ );
+
+ pageInfo.close();
+ PermissionTestUtils.remove(gBrowser.currentURI, "cookie");
+ });
+});
diff --git a/browser/base/content/test/pageinfo/browser_pageinfo_rtl.js b/browser/base/content/test/pageinfo/browser_pageinfo_rtl.js
new file mode 100644
index 0000000000..d0c06a03ff
--- /dev/null
+++ b/browser/base/content/test/pageinfo/browser_pageinfo_rtl.js
@@ -0,0 +1,28 @@
+async function testPageInfo() {
+ await BrowserTestUtils.withNewTab(
+ "https://example.com",
+ async function (browser) {
+ let pageInfo = BrowserPageInfo();
+ await BrowserTestUtils.waitForEvent(pageInfo, "page-info-init");
+ is(
+ getComputedStyle(pageInfo.document.documentElement).direction,
+ "rtl",
+ "Should be RTL"
+ );
+ ok(true, "Didn't assert or crash");
+ pageInfo.close();
+ }
+ );
+}
+
+add_task(async function test_page_info_rtl() {
+ await SpecialPowers.pushPrefEnv({ set: [["intl.l10n.pseudo", "bidi"]] });
+
+ for (let useOverlayScrollbars of [0, 1]) {
+ info("Testing with overlay scrollbars: " + useOverlayScrollbars);
+ await SpecialPowers.pushPrefEnv({
+ set: [["ui.useOverlayScrollbars", useOverlayScrollbars]],
+ });
+ await testPageInfo();
+ }
+});
diff --git a/browser/base/content/test/pageinfo/browser_pageinfo_security.js b/browser/base/content/test/pageinfo/browser_pageinfo_security.js
new file mode 100644
index 0000000000..0a8c57a46d
--- /dev/null
+++ b/browser/base/content/test/pageinfo/browser_pageinfo_security.js
@@ -0,0 +1,354 @@
+ChromeUtils.defineESModuleGetters(this, {
+ DownloadUtils: "resource://gre/modules/DownloadUtils.sys.mjs",
+ SiteDataTestUtils: "resource://testing-common/SiteDataTestUtils.sys.mjs",
+});
+
+const TEST_ORIGIN = "https://example.com";
+// eslint-disable-next-line @microsoft/sdl/no-insecure-url
+const TEST_HTTP_ORIGIN = "http://example.com";
+const TEST_SUB_ORIGIN = "https://test1.example.com";
+const REMOVE_DIALOG_URL =
+ "chrome://browser/content/preferences/dialogs/siteDataRemoveSelected.xhtml";
+const TEST_ORIGIN_CERT_ERROR = "https://expired.example.com";
+
+const TEST_PATH = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+);
+
+// Test opening the correct certificate information when clicking "Show certificate".
+add_task(async function test_ShowCertificate() {
+ let tab1 = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_ORIGIN);
+ let tab2 = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ TEST_SUB_ORIGIN
+ );
+
+ let pageInfo = BrowserPageInfo(TEST_SUB_ORIGIN, "securityTab");
+ await BrowserTestUtils.waitForEvent(pageInfo, "load");
+ let pageInfoDoc = pageInfo.document;
+ let securityTab = pageInfoDoc.getElementById("securityTab");
+ await TestUtils.waitForCondition(
+ () => BrowserTestUtils.is_visible(securityTab),
+ "Security tab should be visible."
+ );
+
+ async function openAboutCertificate() {
+ let loaded = BrowserTestUtils.waitForNewTab(gBrowser, null, true);
+ let viewCertButton = pageInfoDoc.getElementById("security-view-cert");
+ await TestUtils.waitForCondition(
+ () => BrowserTestUtils.is_visible(viewCertButton),
+ "view cert button should be visible."
+ );
+ viewCertButton.click();
+ await loaded;
+
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () {
+ let certificateSection = await ContentTaskUtils.waitForCondition(() => {
+ return content.document.querySelector("certificate-section");
+ }, "Certificate section found");
+
+ let commonName = certificateSection.shadowRoot
+ .querySelector(".subject-name")
+ .shadowRoot.querySelector(".common-name")
+ .shadowRoot.querySelector(".info").textContent;
+ is(commonName, "example.com", "Should have the same common name.");
+ });
+
+ gBrowser.removeCurrentTab(); // closes about:certificate
+ }
+
+ await openAboutCertificate();
+
+ gBrowser.selectedTab = tab1;
+
+ await openAboutCertificate();
+
+ pageInfo.close();
+ BrowserTestUtils.removeTab(tab1);
+ BrowserTestUtils.removeTab(tab2);
+});
+
+// Test displaying website identity information when loading images.
+add_task(async function test_image() {
+ let url = TEST_PATH + "moz.png";
+ await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+
+ let pageInfo = BrowserPageInfo(url, "securityTab");
+ await BrowserTestUtils.waitForEvent(pageInfo, "load");
+ let pageInfoDoc = pageInfo.document;
+ let securityTab = pageInfoDoc.getElementById("securityTab");
+
+ await TestUtils.waitForCondition(
+ () => BrowserTestUtils.is_visible(securityTab),
+ "Security tab should be visible."
+ );
+
+ let owner = pageInfoDoc.getElementById("security-identity-owner-value");
+ let verifier = pageInfoDoc.getElementById("security-identity-verifier-value");
+ let domain = pageInfoDoc.getElementById("security-identity-domain-value");
+
+ await TestUtils.waitForCondition(
+ () => owner.value === "This website does not supply ownership information.",
+ `Value of owner should be should be "This website does not supply ownership information." instead got "${owner.value}".`
+ );
+
+ await TestUtils.waitForCondition(
+ () => verifier.value === "Mozilla Testing",
+ `Value of verifier should be "Mozilla Testing", instead got "${verifier.value}".`
+ );
+
+ let browser = gBrowser.selectedBrowser;
+
+ await TestUtils.waitForCondition(
+ () => domain.value === browser.currentURI.displayHost,
+ `Value of domain should be ${browser.currentURI.displayHost}, instead got "${domain.value}".`
+ );
+
+ pageInfo.close();
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+});
+
+// Test displaying website identity information on certificate error pages.
+add_task(async function test_CertificateError() {
+ let browser;
+ let pageLoaded;
+ await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ () => {
+ gBrowser.selectedTab = BrowserTestUtils.addTab(
+ gBrowser,
+ TEST_ORIGIN_CERT_ERROR
+ );
+ browser = gBrowser.selectedBrowser;
+ pageLoaded = BrowserTestUtils.waitForErrorPage(browser);
+ },
+ false
+ );
+
+ await pageLoaded;
+
+ let pageInfo = BrowserPageInfo(TEST_ORIGIN_CERT_ERROR, "securityTab");
+ await BrowserTestUtils.waitForEvent(pageInfo, "load");
+ let pageInfoDoc = pageInfo.document;
+ let securityTab = pageInfoDoc.getElementById("securityTab");
+
+ await TestUtils.waitForCondition(
+ () => BrowserTestUtils.is_visible(securityTab),
+ "Security tab should be visible."
+ );
+
+ let owner = pageInfoDoc.getElementById("security-identity-owner-value");
+ let verifier = pageInfoDoc.getElementById("security-identity-verifier-value");
+ let domain = pageInfoDoc.getElementById("security-identity-domain-value");
+
+ await TestUtils.waitForCondition(
+ () => owner.value === "This website does not supply ownership information.",
+ `Value of owner should be should be "This website does not supply ownership information." instead got "${owner.value}".`
+ );
+
+ await TestUtils.waitForCondition(
+ () => verifier.value === "Not specified",
+ `Value of verifier should be "Not specified", instead got "${verifier.value}".`
+ );
+
+ await TestUtils.waitForCondition(
+ () => domain.value === browser.currentURI.displayHost,
+ `Value of domain should be ${browser.currentURI.displayHost}, instead got "${domain.value}".`
+ );
+
+ pageInfo.close();
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+});
+
+// Test displaying website identity information on http pages.
+add_task(async function test_SecurityHTTP() {
+ await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_HTTP_ORIGIN);
+
+ let pageInfo = BrowserPageInfo(TEST_HTTP_ORIGIN, "securityTab");
+ await BrowserTestUtils.waitForEvent(pageInfo, "load");
+ let pageInfoDoc = pageInfo.document;
+ let securityTab = pageInfoDoc.getElementById("securityTab");
+ await TestUtils.waitForCondition(
+ () => BrowserTestUtils.is_visible(securityTab),
+ "Security tab should be visible."
+ );
+
+ let owner = pageInfoDoc.getElementById("security-identity-owner-value");
+ let verifier = pageInfoDoc.getElementById("security-identity-verifier-value");
+ let domain = pageInfoDoc.getElementById("security-identity-domain-value");
+
+ await TestUtils.waitForCondition(
+ () => owner.value === "This website does not supply ownership information.",
+ `Value of owner should be should be "This website does not supply ownership information." instead got "${owner.value}".`
+ );
+
+ await TestUtils.waitForCondition(
+ () => verifier.value === "Not specified",
+ `Value of verifier should be "Not specified", instead got "${verifier.value}".`
+ );
+
+ await TestUtils.waitForCondition(
+ () => domain.value === gBrowser.selectedBrowser.currentURI.displayHost,
+ `Value of domain should be ${gBrowser.selectedBrowser.currentURI.displayHost}, instead got "${domain.value}".`
+ );
+
+ pageInfo.close();
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+});
+
+// Test displaying valid certificate information in page info.
+add_task(async function test_ValidCert() {
+ await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_ORIGIN);
+
+ let pageInfo = BrowserPageInfo(TEST_ORIGIN, "securityTab");
+ await BrowserTestUtils.waitForEvent(pageInfo, "load");
+ let pageInfoDoc = pageInfo.document;
+ let securityTab = pageInfoDoc.getElementById("securityTab");
+ await TestUtils.waitForCondition(
+ () => BrowserTestUtils.is_visible(securityTab),
+ "Security tab should be visible."
+ );
+
+ let owner = pageInfoDoc.getElementById("security-identity-owner-value");
+ let verifier = pageInfoDoc.getElementById("security-identity-verifier-value");
+ let domain = pageInfoDoc.getElementById("security-identity-domain-value");
+
+ await TestUtils.waitForCondition(
+ () => owner.value === "This website does not supply ownership information.",
+ `Value of owner should be "This website does not supply ownership information.", got "${owner.value}".`
+ );
+
+ await TestUtils.waitForCondition(
+ () => verifier.value === "Mozilla Testing",
+ `Value of verifier should be "Mozilla Testing", got "${verifier.value}".`
+ );
+
+ await TestUtils.waitForCondition(
+ () => domain.value === gBrowser.selectedBrowser.currentURI.displayHost,
+ `Value of domain should be ${gBrowser.selectedBrowser.currentURI.displayHost}, instead got "${domain.value}".`
+ );
+
+ pageInfo.close();
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+});
+
+// Test displaying and removing quota managed data.
+add_task(async function test_SiteData() {
+ await SiteDataTestUtils.addToIndexedDB(TEST_ORIGIN);
+
+ await BrowserTestUtils.withNewTab(TEST_ORIGIN, async function (browser) {
+ let totalUsage = await SiteDataTestUtils.getQuotaUsage(TEST_ORIGIN);
+ Assert.greater(totalUsage, 0, "The total usage should not be 0");
+
+ let pageInfo = BrowserPageInfo(TEST_ORIGIN, "securityTab");
+ await BrowserTestUtils.waitForEvent(pageInfo, "load");
+ let pageInfoDoc = pageInfo.document;
+
+ let label = pageInfoDoc.getElementById("security-privacy-sitedata-value");
+ let clearButton = pageInfoDoc.getElementById("security-clear-sitedata");
+
+ let size = DownloadUtils.convertByteUnits(totalUsage);
+
+ // The usage details are filled asynchronously, so we assert that they're present by
+ // waiting for them to be filled in.
+ // We only wait for the right unit to appear, since this number is intermittently
+ // varying by slight amounts on infra machines.
+ await TestUtils.waitForCondition(
+ () => label.textContent.includes(size[1]),
+ "Should show site data usage in the security section."
+ );
+ let siteDataUpdated = TestUtils.topicObserved(
+ "sitedatamanager:sites-updated"
+ );
+
+ let removeDialogPromise = BrowserTestUtils.promiseAlertDialogOpen(
+ "accept",
+ REMOVE_DIALOG_URL
+ );
+ clearButton.click();
+ await removeDialogPromise;
+
+ await siteDataUpdated;
+
+ totalUsage = await SiteDataTestUtils.getQuotaUsage(TEST_ORIGIN);
+ is(totalUsage, 0, "The total usage should be 0");
+
+ await TestUtils.waitForCondition(
+ () => label.textContent == "No",
+ "Should show no site data usage in the security section."
+ );
+
+ pageInfo.close();
+ });
+});
+
+// Test displaying and removing cookies.
+add_task(async function test_Cookies() {
+ // Add some test cookies.
+ SiteDataTestUtils.addToCookies({
+ origin: TEST_ORIGIN,
+ name: "test1",
+ value: "1",
+ });
+ SiteDataTestUtils.addToCookies({
+ origin: TEST_ORIGIN,
+ name: "test2",
+ value: "2",
+ });
+ SiteDataTestUtils.addToCookies({
+ origin: TEST_SUB_ORIGIN,
+ name: "test1",
+ value: "1",
+ });
+
+ await BrowserTestUtils.withNewTab(TEST_ORIGIN, async function (browser) {
+ let pageInfo = BrowserPageInfo(TEST_ORIGIN, "securityTab");
+ await BrowserTestUtils.waitForEvent(pageInfo, "load");
+
+ let pageInfoDoc = pageInfo.document;
+
+ let label = pageInfoDoc.getElementById("security-privacy-sitedata-value");
+ let clearButton = pageInfoDoc.getElementById("security-clear-sitedata");
+
+ // The usage details are filled asynchronously, so we assert that they're present by
+ // waiting for them to be filled in.
+ await TestUtils.waitForCondition(
+ () => label.textContent.includes("cookies"),
+ "Should show cookies in the security section."
+ );
+
+ let cookiesCleared = TestUtils.topicObserved(
+ "cookie-changed",
+ (subj, data) => data == "deleted"
+ );
+
+ let removeDialogPromise = BrowserTestUtils.promiseAlertDialogOpen(
+ "accept",
+ REMOVE_DIALOG_URL
+ );
+ clearButton.click();
+ await removeDialogPromise;
+
+ await cookiesCleared;
+
+ let uri = Services.io.newURI(TEST_ORIGIN);
+ is(
+ Services.cookies.countCookiesFromHost(uri.host),
+ 0,
+ "Cookies from the base domain should be cleared"
+ );
+
+ await TestUtils.waitForCondition(
+ () => label.textContent == "No",
+ "Should show no cookies in the security section."
+ );
+
+ pageInfo.close();
+ });
+});
+
+// Clean up in case we missed anything...
+add_task(async function cleanup() {
+ await SiteDataTestUtils.clear();
+});
diff --git a/browser/base/content/test/pageinfo/browser_pageinfo_separate_private.js b/browser/base/content/test/pageinfo/browser_pageinfo_separate_private.js
new file mode 100644
index 0000000000..ac93b7ddb2
--- /dev/null
+++ b/browser/base/content/test/pageinfo/browser_pageinfo_separate_private.js
@@ -0,0 +1,49 @@
+/**
+ * Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ **/
+
+add_task(async function () {
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "https://example.com"
+ );
+ let browser = tab.linkedBrowser;
+ let pageInfo = BrowserPageInfo(browser.currentURI.spec);
+ await BrowserTestUtils.waitForEvent(pageInfo, "page-info-init");
+ Assert.strictEqual(
+ pageInfo.docShell.QueryInterface(Ci.nsILoadContext).usePrivateBrowsing,
+ false,
+ "non-private window opened private page info window"
+ );
+
+ let privateWindow = await BrowserTestUtils.openNewBrowserWindow({
+ private: true,
+ });
+ let privateTab = await BrowserTestUtils.openNewForegroundTab(
+ privateWindow.gBrowser,
+ "https://example.com"
+ );
+ let privateBrowser = privateTab.linkedBrowser;
+ let privatePageInfo = privateWindow.BrowserPageInfo(
+ privateBrowser.currentURI.spec
+ );
+ await BrowserTestUtils.waitForEvent(privatePageInfo, "page-info-init");
+ Assert.strictEqual(
+ privatePageInfo.docShell.QueryInterface(Ci.nsILoadContext)
+ .usePrivateBrowsing,
+ true,
+ "private window opened non-private page info window"
+ );
+
+ Assert.notEqual(
+ pageInfo,
+ privatePageInfo,
+ "private and non-private windows shouldn't have shared the same page info window"
+ );
+ pageInfo.close();
+ privatePageInfo.close();
+ BrowserTestUtils.removeTab(tab);
+ BrowserTestUtils.removeTab(privateTab);
+ await BrowserTestUtils.closeWindow(privateWindow);
+});
diff --git a/browser/base/content/test/pageinfo/browser_pageinfo_svg_image.js b/browser/base/content/test/pageinfo/browser_pageinfo_svg_image.js
new file mode 100644
index 0000000000..547c6158ad
--- /dev/null
+++ b/browser/base/content/test/pageinfo/browser_pageinfo_svg_image.js
@@ -0,0 +1,34 @@
+const URI =
+ "https://example.com/browser/browser/base/content/test/pageinfo/svg_image.html";
+
+add_task(async function () {
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+
+ BrowserTestUtils.loadURIString(gBrowser.selectedBrowser, URI);
+ await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser, false, URI);
+
+ const pageInfo = BrowserPageInfo(
+ gBrowser.selectedBrowser.currentURI.spec,
+ "mediaTab"
+ );
+ await BrowserTestUtils.waitForEvent(pageInfo, "page-info-init");
+
+ const imageTree = pageInfo.document.getElementById("imagetree");
+ const imageRowsNum = imageTree.view.rowCount;
+
+ ok(imageTree, "Image tree is null (media tab is broken)");
+
+ is(imageRowsNum, 1, "should have one image");
+
+ // Only bother running this if we've got the right number of rows.
+ if (imageRowsNum == 1) {
+ is(
+ imageTree.view.getCellText(0, imageTree.columns[0]),
+ "https://example.com/browser/browser/base/content/test/pageinfo/title_test.svg",
+ "The URL should be the svg image."
+ );
+ }
+
+ pageInfo.close();
+ gBrowser.removeCurrentTab();
+});
diff --git a/browser/base/content/test/pageinfo/iframes.html b/browser/base/content/test/pageinfo/iframes.html
new file mode 100644
index 0000000000..b29680cbd1
--- /dev/null
+++ b/browser/base/content/test/pageinfo/iframes.html
@@ -0,0 +1,8 @@
+<html>
+ <head>
+ <title>Test for media tab with iframe</title>
+ </head>
+ <body style='background-image:url(about:logo?a);'>
+ <iframe width="420" height="345" src="moz.png"></iframe>
+ </body>
+</html>");
diff --git a/browser/base/content/test/pageinfo/image.html b/browser/base/content/test/pageinfo/image.html
new file mode 100644
index 0000000000..1261be8e7b
--- /dev/null
+++ b/browser/base/content/test/pageinfo/image.html
@@ -0,0 +1,5 @@
+<html>
+ <img src='moz.png' height=100 width=150 id='test-image'>
+ <video src='video.ogg' id='test-video'></video>
+ <audio src='audio.ogg' id='test-audio'></audio>
+</html>
diff --git a/browser/base/content/test/pageinfo/svg_image.html b/browser/base/content/test/pageinfo/svg_image.html
new file mode 100644
index 0000000000..7ab17c33a0
--- /dev/null
+++ b/browser/base/content/test/pageinfo/svg_image.html
@@ -0,0 +1,11 @@
+<!DOCTYPE HTML>
+<html>
+ <head>
+ <title>Test for page info svg images</title>
+ </head>
+ <body>
+ <svg width="20" height="20">
+ <image xlink:href="title_test.svg" width="20" height="20">
+ </svg>
+ </body>
+</html>
diff --git a/browser/base/content/test/performance/PerfTestHelpers.sys.mjs b/browser/base/content/test/performance/PerfTestHelpers.sys.mjs
new file mode 100644
index 0000000000..075e436331
--- /dev/null
+++ b/browser/base/content/test/performance/PerfTestHelpers.sys.mjs
@@ -0,0 +1,79 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const lazy = {};
+
+ChromeUtils.defineModuleGetter(
+ lazy,
+ "NetUtil",
+ "resource://gre/modules/NetUtil.jsm"
+);
+
+export var PerfTestHelpers = {
+ /**
+ * Maps the entries in the given iterable to the given
+ * promise-returning task function, and waits for all returned
+ * promises to have resolved. At most `limit` promises may remain
+ * unresolved at a time. When the limit is reached, the function will
+ * wait for some to resolve before spawning more tasks.
+ */
+ async throttledMapPromises(iterable, task, limit = 64) {
+ let pending = new Set();
+ let promises = [];
+ for (let data of iterable) {
+ while (pending.size >= limit) {
+ await Promise.race(pending);
+ }
+
+ let promise = task(data);
+ promises.push(promise);
+ if (promise) {
+ promise.finally(() => pending.delete(promise));
+ pending.add(promise);
+ }
+ }
+
+ return Promise.all(promises);
+ },
+
+ /**
+ * Returns a promise which resolves to true if the resource at the
+ * given URI exists, false if it doesn't. This should only be used
+ * with local resources, such as from resource:/chrome:/jar:/file:
+ * URIs.
+ */
+ checkURIExists(uri) {
+ return new Promise(resolve => {
+ try {
+ let channel = lazy.NetUtil.newChannel({
+ uri,
+ loadUsingSystemPrincipal: true,
+ // Avoid crashing for non-existant files. If the file not existing
+ // is bad, we can deal with it in the test instead.
+ contentPolicyType: Ci.nsIContentPolicy.TYPE_FETCH,
+ });
+
+ channel.asyncOpen({
+ onStartRequest(request) {
+ resolve(Components.isSuccessCode(request.status));
+ request.cancel(Cr.NS_BINDING_ABORTED);
+ },
+
+ onStopRequest(request, status) {
+ // We should have already resolved from `onStartRequest`, but
+ // we resolve again here just as a failsafe.
+ resolve(Components.isSuccessCode(status));
+ },
+ });
+ } catch (e) {
+ if (
+ e.result != Cr.NS_ERROR_FILE_NOT_FOUND &&
+ e.result != Cr.NS_ERROR_NOT_AVAILABLE
+ ) {
+ throw e;
+ }
+ resolve(false);
+ }
+ });
+ },
+};
diff --git a/browser/base/content/test/performance/StartupContentSubframe.sys.mjs b/browser/base/content/test/performance/StartupContentSubframe.sys.mjs
new file mode 100644
index 0000000000..a78e456afb
--- /dev/null
+++ b/browser/base/content/test/performance/StartupContentSubframe.sys.mjs
@@ -0,0 +1,55 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * test helper JSWindowActors used by the browser_startup_content_subframe.js test.
+ */
+
+export class StartupContentSubframeParent extends JSWindowActorParent {
+ receiveMessage(msg) {
+ // Tell the test about the data we received from the content process.
+ Services.obs.notifyObservers(
+ msg.data,
+ "startup-content-subframe-loaded-scripts"
+ );
+ }
+}
+
+export class StartupContentSubframeChild extends JSWindowActorChild {
+ async handleEvent(event) {
+ // When the remote subframe is loaded, an event will be fired to this actor,
+ // which will cause us to send the `LoadedScripts` message to the parent
+ // process.
+ // Wait a spin of the event loop before doing so to ensure we don't
+ // miss any scripts loaded immediately after the load event.
+ await new Promise(resolve => Services.tm.dispatchToMainThread(resolve));
+
+ const Cm = Components.manager;
+ Cm.QueryInterface(Ci.nsIServiceManager);
+ const { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+ );
+ let collectStacks = AppConstants.NIGHTLY_BUILD || AppConstants.DEBUG;
+
+ let modules = {};
+ for (let module of Cu.loadedJSModules) {
+ modules[module] = collectStacks ? Cu.getModuleImportStack(module) : "";
+ }
+ for (let module of Cu.loadedESModules) {
+ modules[module] = collectStacks ? Cu.getModuleImportStack(module) : "";
+ }
+
+ let services = {};
+ for (let contractID of Object.keys(Cc)) {
+ try {
+ if (Cm.isServiceInstantiatedByContractID(contractID, Ci.nsISupports)) {
+ services[contractID] = "";
+ }
+ } catch (e) {}
+ }
+ this.sendAsyncMessage("LoadedScripts", {
+ modules,
+ services,
+ });
+ }
+}
diff --git a/browser/base/content/test/performance/browser.ini b/browser/base/content/test/performance/browser.ini
new file mode 100644
index 0000000000..d50b8debcc
--- /dev/null
+++ b/browser/base/content/test/performance/browser.ini
@@ -0,0 +1,90 @@
+[DEFAULT]
+# to avoid overhead when running the browser normally, StartupRecorder.sys.mjs will
+# do almost nothing unless browser.startup.record is true.
+# gfx.canvas.willReadFrequently.enable is just an optimization, but needs to be
+# set during early startup to have an impact as a canvas will be used by
+# StartupRecorder.sys.mjs
+prefs =
+ # Skip migration work in BG__migrateUI for browser_startup.js since it isn't
+ # representative of common startup.
+ browser.migration.version=9999999
+ browser.startup.record=true
+ gfx.canvas.willReadFrequently.enable=true
+ # The form autofill framescript is only used in certain locales if this
+ # pref is set to 'detect', which is the default value on non-Nightly.
+ extensions.formautofill.addresses.available='on'
+ extensions.formautofill.creditCards.available='on'
+ browser.urlbar.disableExtendForTests=true
+ # For perfomance tests do not enable the remote control cue, which gets set
+ # when Marionette is enabled, but users normally don't see.
+ browser.chrome.disableRemoteControlCueForTests=true
+ # The Screenshots extension is disabled by default in Mochitests. We re-enable
+ # it here, since it's a more realistic configuration.
+ extensions.screenshots.disabled=false
+support-files =
+ head.js
+
+[browser_appmenu.js]
+skip-if =
+ asan
+ debug
+ os == "win" # Bug 1775626
+ os == "linux" && socketprocess_networking # Bug 1382809, bug 1369959
+[browser_panel_vsync.js]
+support-files =
+ !/browser/components/downloads/test/browser/head.js
+[browser_preferences_usage.js]
+https_first_disabled = true
+skip-if =
+ !debug
+ apple_catalina # platform migration
+ socketprocess_networking
+[browser_startup.js]
+[browser_startup_content.js]
+support-files =
+ file_empty.html
+[browser_startup_content_subframe.js]
+skip-if = !fission
+support-files =
+ file_empty.html
+ StartupContentSubframe.sys.mjs
+[browser_startup_flicker.js]
+run-if =
+ debug
+ nightly_build # Requires StartupRecorder.sys.mjs, which isn't shipped everywhere by default
+[browser_startup_hiddenwindow.js]
+skip-if =
+ os == "mac"
+[browser_tabclose.js]
+skip-if =
+ os == "linux" && devedition # Bug 1737131
+ os == "mac" # Bug 1531417
+ os == "win" # Bug 1488537, Bug 1497713
+[browser_tabclose_grow.js]
+[browser_tabdetach.js]
+[browser_tabopen.js]
+skip-if =
+ apple_catalina # Bug 1594274
+ os == "mac" && !debug # Bug 1705492
+ os == "linux" && !debug # Bug 1705492
+[browser_tabopen_squeeze.js]
+[browser_tabstrip_overflow_underflow.js]
+skip-if =
+ os == "win" && verify && !debug
+ os == 'win' && bits == 32
+[browser_tabswitch.js]
+skip-if =
+ os == "win" #Bug 1455054
+[browser_toolbariconcolor_restyles.js]
+[browser_urlbar_keyed_search.js]
+skip-if =
+ os == "win" && bits == 32 # # Disabled on Win32 because of intermittent OOM failures (bug 1448241)
+[browser_urlbar_search.js]
+skip-if =
+ os == "linux" && (debug || ccov) # Disabled on Linux debug and ccov due to intermittent timeouts. Bug 1414126.
+ os == "win" && (debug || ccov) # Disabled on Windows debug and ccov due to intermittent timeouts. bug 1426611.
+ os == "win" && bits == 32
+[browser_vsync_accessibility.js]
+[browser_window_resize.js]
+[browser_windowclose.js]
+[browser_windowopen.js]
diff --git a/browser/base/content/test/performance/browser_appmenu.js b/browser/base/content/test/performance/browser_appmenu.js
new file mode 100644
index 0000000000..3d554d8881
--- /dev/null
+++ b/browser/base/content/test/performance/browser_appmenu.js
@@ -0,0 +1,129 @@
+"use strict";
+/* global PanelUI */
+
+const { CustomizableUITestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/CustomizableUITestUtils.sys.mjs"
+);
+let gCUITestUtils = new CustomizableUITestUtils(window);
+
+/**
+ * WHOA THERE: We should never be adding new things to
+ * EXPECTED_APPMENU_OPEN_REFLOWS. This list should slowly go
+ * away as we improve the performance of the front-end. Instead of adding more
+ * reflows to the list, you should be modifying your code to avoid the reflow.
+ *
+ * See https://firefox-source-docs.mozilla.org/performance/bestpractices.html
+ * for tips on how to do that.
+ */
+const EXPECTED_APPMENU_OPEN_REFLOWS = [
+ {
+ stack: [
+ "openPopup/this._openPopupPromise<@resource:///modules/PanelMultiView.sys.mjs",
+ ],
+ },
+
+ {
+ stack: [
+ "_calculateMaxHeight@resource:///modules/PanelMultiView.sys.mjs",
+ "handleEvent@resource:///modules/PanelMultiView.sys.mjs",
+ ],
+
+ maxCount: 7, // This number should only ever go down - never up.
+ },
+];
+
+add_task(async function () {
+ await ensureNoPreloadedBrowser();
+ await disableFxaBadge();
+
+ let textBoxRect = gURLBar
+ .querySelector("moz-input-box")
+ .getBoundingClientRect();
+ let menuButtonRect = document
+ .getElementById("PanelUI-menu-button")
+ .getBoundingClientRect();
+ let firstTabRect = gBrowser.selectedTab.getBoundingClientRect();
+ let frameExpectations = {
+ filter: rects => {
+ // We expect the menu button to get into the active state.
+ //
+ // XXX For some reason the menu panel isn't in our screenshots, but
+ // that's where we actually expect many changes.
+ return rects.filter(r => !rectInBoundingClientRect(r, menuButtonRect));
+ },
+ exceptions: [
+ {
+ name: "the urlbar placeholder moves up and down by a few pixels",
+ condition: r => rectInBoundingClientRect(r, textBoxRect),
+ },
+ {
+ name: "bug 1547341 - a first tab gets drawn early",
+ condition: r => rectInBoundingClientRect(r, firstTabRect),
+ },
+ ],
+ };
+
+ // First, open the appmenu.
+ await withPerfObserver(() => gCUITestUtils.openMainMenu(), {
+ expectedReflows: EXPECTED_APPMENU_OPEN_REFLOWS,
+ frames: frameExpectations,
+ });
+
+ // Now open a series of subviews, and then close the appmenu. We
+ // should not reflow during any of this.
+ await withPerfObserver(
+ async function () {
+ // This recursive function will take the current main or subview,
+ // find all of the buttons that navigate to subviews inside it,
+ // and click each one individually. Upon entering the new view,
+ // we recurse. When the subviews within a view have been
+ // exhausted, we go back up a level.
+ async function openSubViewsRecursively(currentView) {
+ let navButtons = Array.from(
+ // Ensure that only enabled buttons are tested
+ currentView.querySelectorAll(".subviewbutton-nav:not([disabled])")
+ );
+ if (!navButtons) {
+ return;
+ }
+
+ for (let button of navButtons) {
+ info("Click " + button.id);
+ let promiseViewShown = BrowserTestUtils.waitForEvent(
+ PanelUI.panel,
+ "ViewShown"
+ );
+ button.click();
+ let viewShownEvent = await promiseViewShown;
+
+ // Workaround until bug 1363756 is fixed, then this can be removed.
+ let container = PanelUI.multiView.querySelector(
+ ".panel-viewcontainer"
+ );
+ await TestUtils.waitForCondition(() => {
+ return !container.hasAttribute("width");
+ });
+
+ info("Shown " + viewShownEvent.originalTarget.id);
+ await openSubViewsRecursively(viewShownEvent.originalTarget);
+ promiseViewShown = BrowserTestUtils.waitForEvent(
+ currentView,
+ "ViewShown"
+ );
+ PanelUI.multiView.goBack();
+ await promiseViewShown;
+
+ // Workaround until bug 1363756 is fixed, then this can be removed.
+ await TestUtils.waitForCondition(() => {
+ return !container.hasAttribute("width");
+ });
+ }
+ }
+
+ await openSubViewsRecursively(PanelUI.mainView);
+
+ await gCUITestUtils.hideMainMenu();
+ },
+ { expectedReflows: [], frames: frameExpectations }
+ );
+});
diff --git a/browser/base/content/test/performance/browser_panel_vsync.js b/browser/base/content/test/performance/browser_panel_vsync.js
new file mode 100644
index 0000000000..73c56b9095
--- /dev/null
+++ b/browser/base/content/test/performance/browser_panel_vsync.js
@@ -0,0 +1,69 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+Services.scriptloader.loadSubScript(
+ "chrome://mochitests/content/browser/browser/components/downloads/test/browser/head.js",
+ this
+);
+
+add_task(
+ async function test_opening_panel_and_closing_should_not_leave_vsync() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.download.autohideButton", false]],
+ });
+ await promiseButtonShown("downloads-button");
+
+ const downloadsButton = document.getElementById("downloads-button");
+ const shownPromise = promisePanelOpened();
+ EventUtils.synthesizeNativeMouseEvent({
+ type: "click",
+ target: downloadsButton,
+ atCenter: true,
+ });
+ await shownPromise;
+
+ is(DownloadsPanel.panel.state, "open", "Check that panel state is 'open'");
+
+ await TestUtils.waitForCondition(
+ () => !ChromeUtils.vsyncEnabled(),
+ "Make sure vsync disabled"
+ );
+ // Should not already be using vsync
+ ok(!ChromeUtils.vsyncEnabled(), "vsync should be off initially");
+
+ if (
+ AppConstants.platform == "linux" &&
+ DownloadsPanel.panel.state != "open"
+ ) {
+ // Panels sometime receive spurious popuphiding events on Linux.
+ // Given the main target of this test is Windows, avoid causing
+ // intermittent failures and just make the test return early.
+ todo(
+ false,
+ "panel should still be 'open', current state: " +
+ DownloadsPanel.panel.state
+ );
+ return;
+ }
+
+ const hiddenPromise = BrowserTestUtils.waitForEvent(
+ DownloadsPanel.panel,
+ "popuphidden"
+ );
+ EventUtils.synthesizeKey("VK_ESCAPE", {}, window);
+ await hiddenPromise;
+ await TestUtils.waitForCondition(
+ () => !ChromeUtils.vsyncEnabled(),
+ "wait for vsync to be disabled again"
+ );
+
+ ok(!ChromeUtils.vsyncEnabled(), "vsync should still be off");
+ is(
+ DownloadsPanel.panel.state,
+ "closed",
+ "Check that panel state is 'closed'"
+ );
+ }
+);
diff --git a/browser/base/content/test/performance/browser_preferences_usage.js b/browser/base/content/test/performance/browser_preferences_usage.js
new file mode 100644
index 0000000000..d62cb2316b
--- /dev/null
+++ b/browser/base/content/test/performance/browser_preferences_usage.js
@@ -0,0 +1,282 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+if (SpecialPowers.useRemoteSubframes) {
+ requestLongerTimeout(2);
+}
+
+const DEFAULT_PROCESS_COUNT = Services.prefs
+ .getDefaultBranch(null)
+ .getIntPref("dom.ipc.processCount");
+
+/**
+ * A test that checks whether any preference getter from the given list
+ * of stats was called more often than the max parameter.
+ *
+ * @param {Array} stats - an array of [prefName, accessCount] tuples
+ * @param {Number} max - the maximum number of times any of the prefs should
+ * have been called.
+ * @param {Object} knownProblematicPrefs (optional) - an object that defines
+ * prefs that should be exempt from checking the
+ * maximum access. It looks like the following:
+ *
+ * pref_name: {
+ * min: [Number] the minimum amount of times this should have
+ * been called (to avoid keeping around dead items)
+ * max: [Number] the maximum amount of times this should have
+ * been called (to avoid this creeping up further)
+ * }
+ */
+function checkPrefGetters(stats, max, knownProblematicPrefs = {}) {
+ let getterStats = Object.entries(stats).sort(
+ ([, val1], [, val2]) => val2 - val1
+ );
+
+ // Clone the list to be able to delete entries to check if we
+ // forgot any later on.
+ knownProblematicPrefs = Object.assign({}, knownProblematicPrefs);
+
+ for (let [pref, count] of getterStats) {
+ let prefLimits = knownProblematicPrefs[pref];
+ if (!prefLimits) {
+ Assert.lessOrEqual(
+ count,
+ max,
+ `${pref} should not be accessed more than ${max} times.`
+ );
+ } else {
+ // Still record how much this pref was accessed even if we don't do any real assertions.
+ if (!prefLimits.min && !prefLimits.max) {
+ info(
+ `${pref} should not be accessed more than ${max} times and was accessed ${count} times.`
+ );
+ }
+
+ if (prefLimits.min) {
+ Assert.lessOrEqual(
+ prefLimits.min,
+ count,
+ `${pref} should be accessed at least ${prefLimits.min} times.`
+ );
+ }
+ if (prefLimits.max) {
+ Assert.lessOrEqual(
+ count,
+ prefLimits.max,
+ `${pref} should be accessed at most ${prefLimits.max} times.`
+ );
+ }
+ delete knownProblematicPrefs[pref];
+ }
+ }
+
+ // This pref will be accessed by mozJSComponentLoader when loading modules,
+ // which fails TV runs since they run the test multiple times without restarting.
+ // We just ignore this pref, since it's for testing only anyway.
+ if (knownProblematicPrefs["browser.startup.record"]) {
+ delete knownProblematicPrefs["browser.startup.record"];
+ }
+
+ let unusedPrefs = Object.keys(knownProblematicPrefs);
+ is(
+ unusedPrefs.length,
+ 0,
+ `Should have accessed all known problematic prefs. Remaining: ${unusedPrefs}`
+ );
+}
+
+/**
+ * A helper function to read preference access data
+ * using the Services.prefs.readStats() function.
+ */
+function getPreferenceStats() {
+ let stats = {};
+ Services.prefs.readStats((key, value) => (stats[key] = value));
+ return stats;
+}
+
+add_task(async function debug_only() {
+ ok(AppConstants.DEBUG, "You need to run this test on a debug build.");
+});
+
+// Just checks how many prefs were accessed during startup.
+add_task(async function startup() {
+ let max = 40;
+
+ let knownProblematicPrefs = {
+ "browser.startup.record": {
+ // This pref is accessed in Nighly and debug builds only.
+ min: 200,
+ max: 400,
+ },
+ "network.loadinfo.skip_type_assertion": {
+ // This is accessed in debug only.
+ },
+ "chrome.override_package.global": {
+ min: 0,
+ max: 50,
+ },
+ };
+
+ let startupRecorder =
+ Cc["@mozilla.org/test/startuprecorder;1"].getService().wrappedJSObject;
+ await startupRecorder.done;
+
+ ok(startupRecorder.data.prefStats, "startupRecorder has prefStats");
+
+ checkPrefGetters(startupRecorder.data.prefStats, max, knownProblematicPrefs);
+});
+
+// This opens 10 tabs and checks pref getters.
+add_task(async function open_10_tabs() {
+ // This is somewhat arbitrary. When we had a default of 4 content processes
+ // the value was 15. We need to scale it as we increase the number of
+ // content processes so we approximate with 4 * process_count.
+ const max = 4 * DEFAULT_PROCESS_COUNT;
+
+ let knownProblematicPrefs = {
+ "browser.startup.record": {
+ max: 20,
+ },
+ "browser.tabs.remote.logSwitchTiming": {
+ max: 35,
+ },
+ "network.loadinfo.skip_type_assertion": {
+ // This is accessed in debug only.
+ },
+ };
+
+ Services.prefs.resetStats();
+
+ let tabs = [];
+ while (tabs.length < 10) {
+ tabs.push(
+ await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com",
+ true,
+ true
+ )
+ );
+ }
+
+ for (let tab of tabs) {
+ await BrowserTestUtils.removeTab(tab);
+ }
+
+ checkPrefGetters(getPreferenceStats(), max, knownProblematicPrefs);
+});
+
+// This navigates to 50 sites and checks pref getters.
+add_task(async function navigate_around() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ // Disable bfcache so that we can measure more accurately the number of
+ // pref accesses in the child processes.
+ // If bfcache is enabled on Fission
+ // dom.ipc.keepProcessesAlive.webIsolated.perOrigin and
+ // security.sandbox.content.force-namespace are accessed only a couple of
+ // times.
+ ["browser.sessionhistory.max_total_viewers", 0],
+ ],
+ });
+
+ let max = 40;
+
+ let knownProblematicPrefs = {
+ "network.loadinfo.skip_type_assertion": {
+ // This is accessed in debug only.
+ },
+ };
+
+ if (Services.prefs.getBoolPref("browser.translations.enable")) {
+ // The translations pref logs the translation decision on each DOMContentLoaded,
+ // and only shows the log by the preferences set in the console.createInstance.
+ // See Bug 1835693. This means that it is invoked on each page load.
+ knownProblematicPrefs["browser.translations.logLevel"] = {
+ min: 50,
+ max: 50,
+ };
+ }
+
+ if (SpecialPowers.useRemoteSubframes) {
+ // We access this when considering starting a new content process.
+ // Because there is no complete list of content process types,
+ // caching this is not trivial. Opening 50 different content
+ // processes and throwing them away immediately is a bit artificial;
+ // we're more likely to keep some around so this shouldn't be quite
+ // this bad in practice. Fixing this is
+ // https://bugzilla.mozilla.org/show_bug.cgi?id=1600266
+ knownProblematicPrefs["dom.ipc.processCount.webIsolated"] = {
+ min: 50,
+ max: 51,
+ };
+ // This pref is only accessed in automation to speed up tests.
+ knownProblematicPrefs["dom.ipc.keepProcessesAlive.webIsolated.perOrigin"] =
+ {
+ min: 100,
+ max: 102,
+ };
+ if (AppConstants.platform == "linux") {
+ // The following sandbox pref is covered by
+ // https://bugzilla.mozilla.org/show_bug.cgi?id=1600189
+ knownProblematicPrefs["security.sandbox.content.force-namespace"] = {
+ min: 45,
+ max: 55,
+ };
+ } else if (AppConstants.platform == "win") {
+ // The following 2 graphics prefs are covered by
+ // https://bugzilla.mozilla.org/show_bug.cgi?id=1639497
+ knownProblematicPrefs["gfx.canvas.azure.backends"] = {
+ min: 90,
+ max: 110,
+ };
+ knownProblematicPrefs["gfx.content.azure.backends"] = {
+ min: 90,
+ max: 110,
+ };
+ // The following 2 sandbox prefs are covered by
+ // https://bugzilla.mozilla.org/show_bug.cgi?id=1639494
+ knownProblematicPrefs["security.sandbox.content.read_path_whitelist"] = {
+ min: 47,
+ max: 55,
+ };
+ knownProblematicPrefs["security.sandbox.logging.enabled"] = {
+ min: 47,
+ max: 55,
+ };
+ }
+ }
+
+ Services.prefs.resetStats();
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com",
+ true,
+ true
+ );
+
+ let urls = [
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com/",
+ "https://example.com/",
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.org/",
+ "https://example.org/",
+ ];
+
+ for (let i = 0; i < 50; i++) {
+ let url = urls[i % urls.length];
+ info(`Navigating to ${url}...`);
+ BrowserTestUtils.loadURIString(tab.linkedBrowser, url);
+ await BrowserTestUtils.browserLoaded(tab.linkedBrowser, false, url);
+ info(`Loaded ${url}.`);
+ }
+
+ await BrowserTestUtils.removeTab(tab);
+
+ checkPrefGetters(getPreferenceStats(), max, knownProblematicPrefs);
+});
diff --git a/browser/base/content/test/performance/browser_startup.js b/browser/base/content/test/performance/browser_startup.js
new file mode 100644
index 0000000000..83c949456c
--- /dev/null
+++ b/browser/base/content/test/performance/browser_startup.js
@@ -0,0 +1,245 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/* This test records at which phase of startup the JS modules are first
+ * loaded.
+ * If you made changes that cause this test to fail, it's likely because you
+ * are loading more JS code during startup.
+ * Most code has no reason to run off of the app-startup notification
+ * (this is very early, before we have selected the user profile, so
+ * preferences aren't accessible yet).
+ * If your code isn't strictly required to show the first browser window,
+ * it shouldn't be loaded before we are done with first paint.
+ * Finally, if your code isn't really needed during startup, it should not be
+ * loaded before we have started handling user events.
+ */
+
+"use strict";
+
+/* Set this to true only for debugging purpose; it makes the output noisy. */
+const kDumpAllStacks = false;
+
+const startupPhases = {
+ // For app-startup, we have an allowlist of acceptable JS files.
+ // Anything loaded during app-startup must have a compelling reason
+ // to run before we have even selected the user profile.
+ // Consider loading your code after first paint instead,
+ // eg. from BrowserGlue.sys.mjs' _onFirstWindowLoaded method).
+ "before profile selection": {
+ allowlist: {
+ modules: new Set([
+ "resource:///modules/BrowserGlue.sys.mjs",
+ "resource:///modules/StartupRecorder.sys.mjs",
+ "resource://gre/modules/AppConstants.sys.mjs",
+ "resource://gre/modules/ActorManagerParent.sys.mjs",
+ "resource://gre/modules/CustomElementsListener.sys.mjs",
+ "resource://gre/modules/MainProcessSingleton.sys.mjs",
+ "resource://gre/modules/XPCOMUtils.sys.mjs",
+ ]),
+ },
+ },
+
+ // For the following phases of startup we have only a list of files that
+ // are **not** allowed to load in this phase, as too many other scripts
+ // load during this time.
+
+ // We are at this phase after creating the first browser window (ie. after final-ui-startup).
+ "before opening first browser window": {
+ denylist: {
+ modules: new Set([]),
+ },
+ },
+
+ // We reach this phase right after showing the first browser window.
+ // This means that anything already loaded at this point has been loaded
+ // before first paint and delayed it.
+ "before first paint": {
+ denylist: {
+ modules: new Set([
+ "resource:///modules/AboutNewTab.jsm",
+ "resource:///modules/BrowserUsageTelemetry.jsm",
+ "resource:///modules/ContentCrashHandlers.jsm",
+ "resource:///modules/ShellService.sys.mjs",
+ "resource://gre/modules/NewTabUtils.sys.mjs",
+ "resource://gre/modules/PageThumbs.sys.mjs",
+ "resource://gre/modules/PlacesUtils.sys.mjs",
+ "resource://gre/modules/Preferences.sys.mjs",
+ "resource://gre/modules/SearchService.sys.mjs",
+ "resource://gre/modules/Sqlite.sys.mjs",
+ ]),
+ services: new Set(["@mozilla.org/browser/search-service;1"]),
+ },
+ },
+
+ // We are at this phase once we are ready to handle user events.
+ // Anything loaded at this phase or before gets in the way of the user
+ // interacting with the first browser window.
+ "before handling user events": {
+ denylist: {
+ modules: new Set([
+ "resource://gre/modules/Blocklist.sys.mjs",
+ // Bug 1391495 - BrowserWindowTracker.jsm is intermittently used.
+ // "resource:///modules/BrowserWindowTracker.jsm",
+ "resource://gre/modules/BookmarkHTMLUtils.sys.mjs",
+ "resource://gre/modules/Bookmarks.sys.mjs",
+ "resource://gre/modules/ContextualIdentityService.sys.mjs",
+ "resource://gre/modules/FxAccounts.sys.mjs",
+ "resource://gre/modules/FxAccountsStorage.sys.mjs",
+ "resource://gre/modules/PlacesBackups.sys.mjs",
+ "resource://gre/modules/PlacesExpiration.sys.mjs",
+ "resource://gre/modules/PlacesSyncUtils.sys.mjs",
+ "resource://gre/modules/PushComponents.sys.mjs",
+ ]),
+ services: new Set(["@mozilla.org/browser/nav-bookmarks-service;1"]),
+ },
+ },
+
+ // Things that are expected to be completely out of the startup path
+ // and loaded lazily when used for the first time by the user should
+ // be listed here.
+ "before becoming idle": {
+ denylist: {
+ modules: new Set([
+ "resource://gre/modules/AsyncPrefs.sys.mjs",
+ "resource://gre/modules/LoginManagerContextMenu.sys.mjs",
+ "resource://pdf.js/PdfStreamConverter.sys.mjs",
+ ]),
+ },
+ },
+};
+
+if (
+ Services.prefs.getBoolPref("browser.startup.blankWindow") &&
+ Services.prefs.getCharPref(
+ "extensions.activeThemeID",
+ "default-theme@mozilla.org"
+ ) == "default-theme@mozilla.org"
+) {
+ startupPhases["before profile selection"].allowlist.modules.add(
+ "resource://gre/modules/XULStore.sys.mjs"
+ );
+}
+
+if (AppConstants.MOZ_CRASHREPORTER) {
+ startupPhases["before handling user events"].denylist.modules.add(
+ "resource://gre/modules/CrashSubmit.sys.mjs"
+ );
+}
+
+add_task(async function () {
+ if (
+ !AppConstants.NIGHTLY_BUILD &&
+ !AppConstants.MOZ_DEV_EDITION &&
+ !AppConstants.DEBUG
+ ) {
+ ok(
+ !("@mozilla.org/test/startuprecorder;1" in Cc),
+ "the startup recorder component shouldn't exist in this non-nightly/non-devedition/" +
+ "non-debug build."
+ );
+ return;
+ }
+
+ let startupRecorder =
+ Cc["@mozilla.org/test/startuprecorder;1"].getService().wrappedJSObject;
+ await startupRecorder.done;
+
+ let data = Cu.cloneInto(startupRecorder.data.code, {});
+ function getStack(scriptType, name) {
+ if (scriptType == "modules") {
+ return Cu.getModuleImportStack(name);
+ }
+ return "";
+ }
+
+ // This block only adds debug output to help find the next bugs to file,
+ // it doesn't contribute to the actual test.
+ SimpleTest.requestCompleteLog();
+ let previous;
+ for (let phase in data) {
+ for (let scriptType in data[phase]) {
+ for (let f of data[phase][scriptType]) {
+ // phases are ordered, so if a script wasn't loaded yet at the immediate
+ // previous phase, it wasn't loaded during any of the previous phases
+ // either, and is new in the current phase.
+ if (!previous || !data[previous][scriptType].includes(f)) {
+ info(`${scriptType} loaded ${phase}: ${f}`);
+ if (kDumpAllStacks) {
+ info(getStack(scriptType, f));
+ }
+ }
+ }
+ }
+ previous = phase;
+ }
+
+ for (let phase in startupPhases) {
+ let loadedList = data[phase];
+ let allowlist = startupPhases[phase].allowlist || null;
+ if (allowlist) {
+ for (let scriptType in allowlist) {
+ loadedList[scriptType] = loadedList[scriptType].filter(c => {
+ if (!allowlist[scriptType].has(c)) {
+ return true;
+ }
+ allowlist[scriptType].delete(c);
+ return false;
+ });
+ is(
+ loadedList[scriptType].length,
+ 0,
+ `should have no unexpected ${scriptType} loaded ${phase}`
+ );
+ for (let script of loadedList[scriptType]) {
+ let message = `unexpected ${scriptType}: ${script}`;
+ record(false, message, undefined, getStack(scriptType, script));
+ }
+ is(
+ allowlist[scriptType].size,
+ 0,
+ `all ${scriptType} allowlist entries should have been used`
+ );
+ for (let script of allowlist[scriptType]) {
+ ok(false, `unused ${scriptType} allowlist entry: ${script}`);
+ }
+ }
+ }
+ let denylist = startupPhases[phase].denylist || null;
+ if (denylist) {
+ for (let scriptType in denylist) {
+ for (let file of denylist[scriptType]) {
+ let loaded = loadedList[scriptType].includes(file);
+ let message = `${file} is not allowed ${phase}`;
+ if (!loaded) {
+ ok(true, message);
+ } else {
+ record(false, message, undefined, getStack(scriptType, file));
+ }
+ }
+ }
+
+ if (denylist.modules) {
+ let results = await PerfTestHelpers.throttledMapPromises(
+ denylist.modules,
+ async uri => ({
+ uri,
+ exists: await PerfTestHelpers.checkURIExists(uri),
+ })
+ );
+
+ for (let { uri, exists } of results) {
+ ok(exists, `denylist entry ${uri} for phase "${phase}" must exist`);
+ }
+ }
+
+ if (denylist.services) {
+ for (let contract of denylist.services) {
+ ok(
+ contract in Cc,
+ `denylist entry ${contract} for phase "${phase}" must exist`
+ );
+ }
+ }
+ }
+ }
+});
diff --git a/browser/base/content/test/performance/browser_startup_content.js b/browser/base/content/test/performance/browser_startup_content.js
new file mode 100644
index 0000000000..330f9b7655
--- /dev/null
+++ b/browser/base/content/test/performance/browser_startup_content.js
@@ -0,0 +1,196 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/* This test records which services, frame scripts, process scripts, and
+ * JS modules are loaded when creating a new content process.
+ *
+ * If you made changes that cause this test to fail, it's likely because you
+ * are loading more JS code during content process startup. Please try to
+ * avoid this.
+ *
+ * If your code isn't strictly required to show a page, consider loading it
+ * lazily. If you can't, consider delaying its load until after we have started
+ * handling user events.
+ */
+
+"use strict";
+
+/* Set this to true only for debugging purpose; it makes the output noisy. */
+const kDumpAllStacks = false;
+
+const known_scripts = {
+ modules: new Set([
+ "chrome://mochikit/content/ShutdownLeaksCollector.sys.mjs",
+
+ // General utilities
+ "resource://gre/modules/AppConstants.sys.mjs",
+ "resource://gre/modules/Timer.sys.mjs",
+ "resource://gre/modules/XPCOMUtils.sys.mjs",
+
+ // Logging related
+ "resource://gre/modules/Log.sys.mjs",
+
+ // Browser front-end
+ "resource:///actors/AboutReaderChild.sys.mjs",
+ "resource:///actors/LinkHandlerChild.sys.mjs",
+ "resource:///actors/SearchSERPTelemetryChild.sys.mjs",
+ "resource://gre/actors/ContentMetaChild.sys.mjs",
+ "resource://gre/modules/Readerable.sys.mjs",
+
+ // Telemetry
+ "resource://gre/modules/TelemetryControllerBase.sys.mjs", // bug 1470339
+ "resource://gre/modules/TelemetryControllerContent.sys.mjs", // bug 1470339
+
+ // Extensions
+ "resource://gre/modules/ExtensionProcessScript.sys.mjs",
+ "resource://gre/modules/ExtensionUtils.sys.mjs",
+ ]),
+ frameScripts: new Set([
+ // Test related
+ "chrome://mochikit/content/shutdown-leaks-collector.js",
+ ]),
+ processScripts: new Set([
+ "chrome://global/content/process-content.js",
+ "resource://gre/modules/extensionProcessScriptLoader.js",
+ ]),
+};
+
+if (!Services.appinfo.sessionHistoryInParent) {
+ known_scripts.modules.add(
+ "resource:///modules/sessionstore/ContentSessionStore.sys.mjs"
+ );
+}
+
+if (AppConstants.NIGHTLY_BUILD) {
+ // Browser front-end.
+ known_scripts.modules.add("resource:///actors/InteractionsChild.sys.mjs");
+}
+
+// Items on this list *might* load when creating the process, as opposed to
+// items in the main list, which we expect will always load.
+const intermittently_loaded_scripts = {
+ modules: new Set([
+ "resource://gre/modules/nsAsyncShutdown.sys.mjs",
+ "resource://gre/modules/sessionstore/Utils.sys.mjs",
+
+ // Translations code which may be preffed on.
+ "resource://gre/actors/TranslationsChild.sys.mjs",
+ "resource://gre/modules/ConsoleAPIStorage.sys.mjs", // Logging related.
+
+ // Session store.
+ "resource://gre/modules/sessionstore/SessionHistory.sys.mjs",
+
+ // Webcompat about:config front-end. This is part of a system add-on which
+ // may not load early enough for the test.
+ "resource://webcompat/AboutCompat.jsm",
+
+ // Cookie banner handling.
+ "resource://gre/actors/CookieBannerChild.sys.mjs",
+ "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
+
+ // Test related
+ "chrome://remote/content/marionette/actors/MarionetteEventsChild.sys.mjs",
+ "chrome://remote/content/shared/Log.sys.mjs",
+ "resource://testing-common/BrowserTestUtilsChild.sys.mjs",
+ "resource://testing-common/ContentEventListenerChild.sys.mjs",
+ "resource://specialpowers/AppTestDelegateChild.sys.mjs",
+ "resource://testing-common/SpecialPowersChild.sys.mjs",
+ "resource://testing-common/WrapPrivileged.sys.mjs",
+ ]),
+ frameScripts: new Set([]),
+ processScripts: new Set([
+ // Webcompat about:config front-end. This is presently nightly-only and
+ // part of a system add-on which may not load early enough for the test.
+ "resource://webcompat/aboutPageProcessScript.js",
+ ]),
+};
+
+const forbiddenScripts = {
+ services: new Set([
+ "@mozilla.org/base/telemetry-startup;1",
+ "@mozilla.org/embedcomp/default-tooltiptextprovider;1",
+ "@mozilla.org/push/Service;1",
+ ]),
+};
+
+add_task(async function () {
+ SimpleTest.requestCompleteLog();
+
+ let tab = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ url:
+ getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+ ) + "file_empty.html",
+ forceNewProcess: true,
+ });
+
+ let mm = gBrowser.selectedBrowser.messageManager;
+ let promise = BrowserTestUtils.waitForMessage(mm, "Test:LoadedScripts");
+
+ // Load a custom frame script to avoid using ContentTask which loads Task.jsm
+ mm.loadFrameScript(
+ "data:text/javascript,(" +
+ function () {
+ /* eslint-env mozilla/frame-script */
+ const Cm = Components.manager;
+ Cm.QueryInterface(Ci.nsIServiceManager);
+ const { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+ );
+ let collectStacks = AppConstants.NIGHTLY_BUILD || AppConstants.DEBUG;
+ let modules = {};
+ for (let module of Cu.loadedJSModules) {
+ modules[module] = collectStacks
+ ? Cu.getModuleImportStack(module)
+ : "";
+ }
+ for (let module of Cu.loadedESModules) {
+ modules[module] = collectStacks
+ ? Cu.getModuleImportStack(module)
+ : "";
+ }
+ let services = {};
+ for (let contractID of Object.keys(Cc)) {
+ try {
+ if (
+ Cm.isServiceInstantiatedByContractID(contractID, Ci.nsISupports)
+ ) {
+ services[contractID] = "";
+ }
+ } catch (e) {}
+ }
+ sendAsyncMessage("Test:LoadedScripts", {
+ modules,
+ services,
+ });
+ } +
+ ")()",
+ false
+ );
+
+ let loadedInfo = await promise;
+
+ // Gather loaded frame scripts.
+ loadedInfo.frameScripts = {};
+ for (let [uri] of Services.mm.getDelayedFrameScripts()) {
+ loadedInfo.frameScripts[uri] = "";
+ }
+
+ // Gather loaded process scripts.
+ loadedInfo.processScripts = {};
+ for (let [uri] of Services.ppmm.getDelayedProcessScripts()) {
+ loadedInfo.processScripts[uri] = "";
+ }
+
+ await checkLoadedScripts({
+ loadedInfo,
+ known: known_scripts,
+ intermittent: intermittently_loaded_scripts,
+ forbidden: forbiddenScripts,
+ dumpAllStacks: kDumpAllStacks,
+ });
+
+ BrowserTestUtils.removeTab(tab);
+});
diff --git a/browser/base/content/test/performance/browser_startup_content_mainthreadio.js b/browser/base/content/test/performance/browser_startup_content_mainthreadio.js
new file mode 100644
index 0000000000..bf200b940d
--- /dev/null
+++ b/browser/base/content/test/performance/browser_startup_content_mainthreadio.js
@@ -0,0 +1,438 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/* This test records I/O syscalls done on the main thread during startup.
+ *
+ * To run this test similar to try server, you need to run:
+ * ./mach package
+ * ./mach test --appname=dist <path to test>
+ *
+ * If you made changes that cause this test to fail, it's likely because you
+ * are touching more files or directories during startup.
+ * Most code has no reason to use main thread I/O.
+ * If for some reason accessing the file system on the main thread is currently
+ * unavoidable, consider defering the I/O as long as you can, ideally after
+ * the end of startup.
+ */
+
+"use strict";
+
+/* Set this to true only for debugging purpose; it makes the output noisy. */
+const kDumpAllStacks = false;
+
+// Shortcuts for conditions.
+const LINUX = AppConstants.platform == "linux";
+const WIN = AppConstants.platform == "win";
+const MAC = AppConstants.platform == "macosx";
+
+/* This is an object mapping string process types to lists of known cases
+ * of IO happening on the main thread. Ideally, IO should not be on the main
+ * thread, and should happen as late as possible (see above).
+ *
+ * Paths in the entries in these lists can:
+ * - be a full path, eg. "/etc/mime.types"
+ * - have a prefix which will be resolved using Services.dirsvc
+ * eg. "GreD:omni.ja"
+ * It's possible to have only a prefix, in thise case the directory will
+ * still be resolved, eg. "UAppData:"
+ * - use * at the begining and/or end as a wildcard
+ * The folder separator is '/' even for Windows paths, where it'll be
+ * automatically converted to '\'.
+ *
+ * Specifying 'ignoreIfUnused: true' will make the test ignore unused entries;
+ * without this the test is strict and will fail if the described IO does not
+ * happen.
+ *
+ * Each entry specifies the maximum number of times an operation is expected to
+ * occur.
+ * The operations currently reported by the I/O interposer are:
+ * create/open: only supported on Windows currently. The test currently
+ * ignores these markers to have a shorter initial list of IO operations.
+ * Adding Unix support is bug 1533779.
+ * stat: supported on all platforms when checking the last modified date or
+ * file size. Supported only on Windows when checking if a file exists;
+ * fixing this inconsistency is bug 1536109.
+ * read: supported on all platforms, but unix platforms will only report read
+ * calls going through NSPR.
+ * write: supported on all platforms, but Linux will only report write calls
+ * going through NSPR.
+ * close: supported only on Unix, and only for close calls going through NSPR.
+ * Adding Windows support is bug 1524574.
+ * fsync: supported only on Windows.
+ *
+ * If an entry specifies more than one operation, if at least one of them is
+ * encountered, the test won't report a failure for the entry if other
+ * operations are not encountered. This helps when listing cases where the
+ * reported operations aren't the same on all platforms due to the I/O
+ * interposer inconsistencies across platforms documented above.
+ */
+const processes = {
+ "Web Content": [
+ {
+ path: "GreD:omni.ja",
+ condition: !WIN, // Visible on Windows with an open marker
+ stat: 1,
+ },
+ {
+ // bug 1376994
+ path: "XCurProcD:omni.ja",
+ condition: !WIN, // Visible on Windows with an open marker
+ stat: 1,
+ },
+ {
+ // Exists call in ScopedXREEmbed::SetAppDir
+ path: "XCurProcD:",
+ condition: WIN,
+ stat: 1,
+ },
+ {
+ // bug 1357205
+ path: "XREAppFeat:formautofill@mozilla.org.xpi",
+ condition: !WIN,
+ ignoreIfUnused: true,
+ stat: 1,
+ },
+ {
+ path: "*ShaderCache*", // Bug 1660480 - seen on hardware
+ condition: WIN,
+ ignoreIfUnused: true,
+ stat: 3,
+ },
+ ],
+ "Privileged Content": [
+ {
+ path: "GreD:omni.ja",
+ condition: !WIN, // Visible on Windows with an open marker
+ stat: 1,
+ },
+ {
+ // bug 1376994
+ path: "XCurProcD:omni.ja",
+ condition: !WIN, // Visible on Windows with an open marker
+ stat: 1,
+ },
+ {
+ // Exists call in ScopedXREEmbed::SetAppDir
+ path: "XCurProcD:",
+ condition: WIN,
+ stat: 1,
+ },
+ ],
+ WebExtensions: [
+ {
+ path: "GreD:omni.ja",
+ condition: !WIN, // Visible on Windows with an open marker
+ stat: 1,
+ },
+ {
+ // bug 1376994
+ path: "XCurProcD:omni.ja",
+ condition: !WIN, // Visible on Windows with an open marker
+ stat: 1,
+ },
+ {
+ // Exists call in ScopedXREEmbed::SetAppDir
+ path: "XCurProcD:",
+ condition: WIN,
+ stat: 1,
+ },
+ ],
+};
+
+function expandPathWithDirServiceKey(path) {
+ if (path.includes(":")) {
+ let [prefix, suffix] = path.split(":");
+ let [key, property] = prefix.split(".");
+ let dir = Services.dirsvc.get(key, Ci.nsIFile);
+ if (property) {
+ dir = dir[property];
+ }
+
+ // Resolve symLinks.
+ let dirPath = dir.path;
+ while (dir && !dir.isSymlink()) {
+ dir = dir.parent;
+ }
+ if (dir) {
+ dirPath = dirPath.replace(dir.path, dir.target);
+ }
+
+ path = dirPath;
+
+ if (suffix) {
+ path += "/" + suffix;
+ }
+ }
+ if (AppConstants.platform == "win") {
+ path = path.replace(/\//g, "\\");
+ }
+ return path;
+}
+
+function getStackFromProfile(profile, stack) {
+ const stackPrefixCol = profile.stackTable.schema.prefix;
+ const stackFrameCol = profile.stackTable.schema.frame;
+ const frameLocationCol = profile.frameTable.schema.location;
+
+ let result = [];
+ while (stack) {
+ let sp = profile.stackTable.data[stack];
+ let frame = profile.frameTable.data[sp[stackFrameCol]];
+ stack = sp[stackPrefixCol];
+ frame = profile.stringTable[frame[frameLocationCol]];
+ if (frame != "js::RunScript" && !frame.startsWith("next (self-hosted:")) {
+ result.push(frame);
+ }
+ }
+ return result;
+}
+
+function getIOMarkersFromProfile(profile) {
+ const nameCol = profile.markers.schema.name;
+ const dataCol = profile.markers.schema.data;
+
+ let markers = [];
+ for (let m of profile.markers.data) {
+ let markerName = profile.stringTable[m[nameCol]];
+
+ if (markerName != "FileIO") {
+ continue;
+ }
+
+ let markerData = m[dataCol];
+ if (markerData.source == "sqlite-mainthread") {
+ continue;
+ }
+
+ let samples = markerData.stack.samples;
+ let stack = samples.data[0][samples.schema.stack];
+ markers.push({
+ operation: markerData.operation,
+ filename: markerData.filename,
+ source: markerData.source,
+ stackId: stack,
+ });
+ }
+
+ return markers;
+}
+
+function pathMatches(path, filename) {
+ path = path.toLowerCase();
+ return (
+ path == filename || // Full match
+ // Wildcard on both sides of the path
+ (path.startsWith("*") &&
+ path.endsWith("*") &&
+ filename.includes(path.slice(1, -1))) ||
+ // Wildcard suffix
+ (path.endsWith("*") && filename.startsWith(path.slice(0, -1))) ||
+ // Wildcard prefix
+ (path.startsWith("*") && filename.endsWith(path.slice(1)))
+ );
+}
+
+add_task(async function () {
+ if (
+ !AppConstants.NIGHTLY_BUILD &&
+ !AppConstants.MOZ_DEV_EDITION &&
+ !AppConstants.DEBUG
+ ) {
+ ok(
+ !("@mozilla.org/test/startuprecorder;1" in Cc),
+ "the startup recorder component shouldn't exist in this non-nightly/non-devedition/" +
+ "non-debug build."
+ );
+ return;
+ }
+
+ TestUtils.assertPackagedBuild();
+
+ let startupRecorder =
+ Cc["@mozilla.org/test/startuprecorder;1"].getService().wrappedJSObject;
+ await startupRecorder.done;
+
+ for (let process in processes) {
+ processes[process] = processes[process].filter(
+ entry => !("condition" in entry) || entry.condition
+ );
+ processes[process].forEach(entry => {
+ entry.listedPath = entry.path;
+ entry.path = expandPathWithDirServiceKey(entry.path);
+ });
+ }
+
+ let tmpPath = expandPathWithDirServiceKey("TmpD:").toLowerCase();
+ let shouldPass = true;
+ for (let procName in processes) {
+ let knownIOList = processes[procName];
+ info(
+ `known main thread IO paths for ${procName} process:\n` +
+ knownIOList
+ .map(e => {
+ let operations = Object.keys(e)
+ .filter(k => !["path", "condition"].includes(k))
+ .map(k => `${k}: ${e[k]}`);
+ return ` ${e.path} - ${operations.join(", ")}`;
+ })
+ .join("\n")
+ );
+
+ let profile;
+ for (let process of startupRecorder.data.profile.processes) {
+ if (process.threads[0].processName == procName) {
+ profile = process.threads[0];
+ break;
+ }
+ }
+ if (procName == "Privileged Content" && !profile) {
+ // The Privileged Content is started from an idle task that may not have
+ // been executed yet at the time we captured the startup profile in
+ // startupRecorder.
+ todo(false, `profile for ${procName} process not found`);
+ } else {
+ ok(profile, `Found profile for ${procName} process`);
+ }
+ if (!profile) {
+ continue;
+ }
+
+ let markers = getIOMarkersFromProfile(profile);
+ for (let marker of markers) {
+ if (marker.operation == "create/open") {
+ // TODO: handle these I/O markers once they are supported on
+ // non-Windows platforms.
+ continue;
+ }
+
+ if (!marker.filename) {
+ // We are still missing the filename on some mainthreadio markers,
+ // these markers are currently useless for the purpose of this test.
+ continue;
+ }
+
+ // Convert to lower case before comparing because the OS X test machines
+ // have the 'Firefox' folder in 'Library/Application Support' created
+ // as 'firefox' for some reason.
+ let filename = marker.filename.toLowerCase();
+
+ if (!WIN && filename == "/dev/urandom") {
+ continue;
+ }
+
+ // /dev/shm is always tmpfs (a memory filesystem); this isn't
+ // really I/O any more than mmap/munmap are.
+ if (LINUX && filename.startsWith("/dev/shm/")) {
+ continue;
+ }
+
+ // "Files" from memfd_create() are similar to tmpfs but never
+ // exist in the filesystem; however, they have names which are
+ // exposed in procfs, and the I/O interposer observes when
+ // they're close()d.
+ if (LINUX && filename.startsWith("/memfd:")) {
+ continue;
+ }
+
+ // Shared memory uses temporary files on MacOS <= 10.11 to avoid
+ // a kernel security bug that will never be patched (see
+ // https://crbug.com/project-zero/1671 for details). This can
+ // be removed when we no longer support those OS versions.
+ if (MAC && filename.startsWith(tmpPath + "/org.mozilla.ipc.")) {
+ continue;
+ }
+
+ let expected = false;
+ for (let entry of knownIOList) {
+ if (pathMatches(entry.path, filename)) {
+ entry[marker.operation] = (entry[marker.operation] || 0) - 1;
+ entry._used = true;
+ expected = true;
+ break;
+ }
+ }
+ if (!expected) {
+ record(
+ false,
+ `unexpected ${marker.operation} on ${marker.filename} in ${procName} process`,
+ undefined,
+ " " + getStackFromProfile(profile, marker.stackId).join("\n ")
+ );
+ shouldPass = false;
+ }
+ info(`(${marker.source}) ${marker.operation} - ${marker.filename}`);
+ if (kDumpAllStacks) {
+ info(
+ getStackFromProfile(profile, marker.stackId)
+ .map(f => " " + f)
+ .join("\n")
+ );
+ }
+ }
+
+ if (!knownIOList.length) {
+ continue;
+ }
+ // The I/O interposer is disabled if !RELEASE_OR_BETA, so we expect to have
+ // no I/O marker in that case, but it's good to keep the test running to check
+ // that we are still able to produce startup profiles.
+ is(
+ !!markers.length,
+ !AppConstants.RELEASE_OR_BETA,
+ procName +
+ " startup profiles should have IO markers in builds that are not RELEASE_OR_BETA"
+ );
+ if (!markers.length) {
+ // If a profile unexpectedly contains no I/O marker, it's better to return
+ // early to avoid having a lot of of confusing "no main thread IO when we
+ // expected some" failures.
+ continue;
+ }
+
+ for (let entry of knownIOList) {
+ for (let op in entry) {
+ if (
+ [
+ "listedPath",
+ "path",
+ "condition",
+ "ignoreIfUnused",
+ "_used",
+ ].includes(op)
+ ) {
+ continue;
+ }
+ let message = `${op} on ${entry.path} `;
+ if (entry[op] == 0) {
+ message += "as many times as expected";
+ } else if (entry[op] > 0) {
+ message += `allowed ${entry[op]} more times`;
+ } else {
+ message += `${entry[op] * -1} more times than expected`;
+ }
+ ok(entry[op] >= 0, `${message} in ${procName} process`);
+ }
+ if (!("_used" in entry) && !entry.ignoreIfUnused) {
+ ok(
+ false,
+ `no main thread IO when we expected some for process ${procName}: ${entry.path} (${entry.listedPath})`
+ );
+ shouldPass = false;
+ }
+ }
+ }
+
+ if (shouldPass) {
+ ok(shouldPass, "No unexpected main thread I/O during startup");
+ } else {
+ const filename = "profile_startup_content_mainthreadio.json";
+ let path = Services.env.get("MOZ_UPLOAD_DIR");
+ let profilePath = PathUtils.join(path, filename);
+ await IOUtils.writeJSON(profilePath, startupRecorder.data.profile);
+ ok(
+ false,
+ "Unexpected main thread I/O behavior during child process startup; " +
+ `open the ${filename} artifact in the Firefox Profiler to see what happened`
+ );
+ }
+});
diff --git a/browser/base/content/test/performance/browser_startup_content_subframe.js b/browser/base/content/test/performance/browser_startup_content_subframe.js
new file mode 100644
index 0000000000..204d0ac1ba
--- /dev/null
+++ b/browser/base/content/test/performance/browser_startup_content_subframe.js
@@ -0,0 +1,150 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/* This test records which services, JS components, frame scripts, process
+ * scripts, and JS modules are loaded when creating a new content process for a
+ * subframe.
+ *
+ * If you made changes that cause this test to fail, it's likely because you
+ * are loading more JS code during content process startup. Please try to
+ * avoid this.
+ *
+ * If your code isn't strictly required to show an iframe, consider loading it
+ * lazily. If you can't, consider delaying its load until after we have started
+ * handling user events.
+ *
+ * This test differs from browser_startup_content.js in that it tests a process
+ * with no toplevel browsers opened, but with a single subframe document
+ * loaded. This leads to a different set of scripts being loaded.
+ */
+
+"use strict";
+
+const actorModuleURI =
+ getRootDirectory(gTestPath) + "StartupContentSubframe.sys.mjs";
+const subframeURI =
+ getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+ ) + "file_empty.html";
+
+// Set this to true only for debugging purpose; it makes the output noisy.
+const kDumpAllStacks = false;
+
+const known_scripts = {
+ modules: new Set([
+ // Loaded by this test
+ actorModuleURI,
+
+ // General utilities
+ "resource://gre/modules/AppConstants.sys.mjs",
+ "resource://gre/modules/XPCOMUtils.sys.mjs",
+
+ // Logging related
+ "resource://gre/modules/Log.sys.mjs",
+
+ // Telemetry
+ "resource://gre/modules/TelemetryControllerBase.sys.mjs", // bug 1470339
+ "resource://gre/modules/TelemetryControllerContent.sys.mjs", // bug 1470339
+
+ // Extensions
+ "resource://gre/modules/ExtensionProcessScript.sys.mjs",
+ "resource://gre/modules/ExtensionUtils.sys.mjs",
+ ]),
+ processScripts: new Set([
+ "chrome://global/content/process-content.js",
+ "resource://gre/modules/extensionProcessScriptLoader.js",
+ ]),
+};
+
+// Items on this list *might* load when creating the process, as opposed to
+// items in the main list, which we expect will always load.
+const intermittently_loaded_scripts = {
+ modules: new Set([
+ "resource://gre/modules/nsAsyncShutdown.sys.mjs",
+
+ // Cookie banner handling.
+ "resource://gre/actors/CookieBannerChild.sys.mjs",
+ "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
+
+ // Test related
+ "chrome://remote/content/marionette/actors/MarionetteEventsChild.sys.mjs",
+ "chrome://remote/content/shared/Log.sys.mjs",
+ "resource://testing-common/BrowserTestUtilsChild.sys.mjs",
+ "resource://testing-common/ContentEventListenerChild.sys.mjs",
+ "resource://testing-common/SpecialPowersChild.sys.mjs",
+ "resource://specialpowers/AppTestDelegateChild.sys.mjs",
+ "resource://testing-common/WrapPrivileged.sys.mjs",
+ ]),
+ processScripts: new Set([]),
+};
+
+const forbiddenScripts = {
+ services: new Set([
+ "@mozilla.org/base/telemetry-startup;1",
+ "@mozilla.org/embedcomp/default-tooltiptextprovider;1",
+ "@mozilla.org/push/Service;1",
+ ]),
+};
+
+add_task(async function () {
+ SimpleTest.requestCompleteLog();
+
+ // Increase the maximum number of webIsolated content processes to make sure
+ // our newly-created iframe is spawned into a new content process.
+ //
+ // Unfortunately, we don't have something like `forceNewProcess` for subframe
+ // loads.
+ await SpecialPowers.pushPrefEnv({
+ set: [["dom.ipc.processCount.webIsolated", 10]],
+ });
+ Services.ppmm.releaseCachedProcesses();
+
+ // Register a custom window actor which will send us a notification when the
+ // script loading information is available.
+ ChromeUtils.registerWindowActor("StartupContentSubframe", {
+ parent: {
+ esModuleURI: actorModuleURI,
+ },
+ child: {
+ esModuleURI: actorModuleURI,
+ events: {
+ load: { mozSystemGroup: true, capture: true },
+ },
+ },
+ matches: [subframeURI],
+ allFrames: true,
+ });
+
+ // Create a tab, and load a remote subframe with the specific URI in it.
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser);
+
+ SpecialPowers.spawn(tab.linkedBrowser, [subframeURI], uri => {
+ let iframe = content.document.createElement("iframe");
+ iframe.src = uri;
+ content.document.body.appendChild(iframe);
+ });
+
+ // Wait for the reply to come in, remove the XPCOM wrapper, and unregister our actor.
+ let [subject] = await TestUtils.topicObserved(
+ "startup-content-subframe-loaded-scripts"
+ );
+ let loadedInfo = subject.wrappedJSObject;
+
+ ChromeUtils.unregisterWindowActor("StartupContentSubframe");
+ BrowserTestUtils.removeTab(tab);
+
+ // Gather loaded process scripts.
+ loadedInfo.processScripts = {};
+ for (let [uri] of Services.ppmm.getDelayedProcessScripts()) {
+ loadedInfo.processScripts[uri] = "";
+ }
+
+ await checkLoadedScripts({
+ loadedInfo,
+ known: known_scripts,
+ intermittent: intermittently_loaded_scripts,
+ forbidden: forbiddenScripts,
+ dumpAllStacks: kDumpAllStacks,
+ });
+});
diff --git a/browser/base/content/test/performance/browser_startup_flicker.js b/browser/base/content/test/performance/browser_startup_flicker.js
new file mode 100644
index 0000000000..8279a0a601
--- /dev/null
+++ b/browser/base/content/test/performance/browser_startup_flicker.js
@@ -0,0 +1,85 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/*
+ * This test ensures that there is no unexpected flicker
+ * on the first window opened during startup.
+ */
+
+add_task(async function () {
+ let startupRecorder =
+ Cc["@mozilla.org/test/startuprecorder;1"].getService().wrappedJSObject;
+ await startupRecorder.done;
+
+ // Ensure all the frame data is in the test compartment to avoid traversing
+ // a cross compartment wrapper for each pixel.
+ let frames = Cu.cloneInto(startupRecorder.data.frames, {});
+ ok(!!frames.length, "Should have captured some frames.");
+
+ let unexpectedRects = 0;
+ let alreadyFocused = false;
+ for (let i = 1; i < frames.length; ++i) {
+ let frame = frames[i],
+ previousFrame = frames[i - 1];
+ let rects = compareFrames(frame, previousFrame);
+
+ if (!alreadyFocused) {
+ // The first screenshot we get shows an unfocused browser window for some
+ // reason. See bug 1445161.
+ //
+ // We'll assume the changes we are seeing are due to this focus change if
+ // there are at least 5 areas that changed near the top of the screen,
+ // but will only ignore this once (hence the alreadyFocused variable).
+ //
+ // On Linux we expect just one rect because we don't draw titlebar
+ // buttons in the tab bar, so we just get a whole-tab-bar color-switch.
+ const minRects = AppConstants.platform == "linux" ? 0 : 5;
+ if (rects.length > minRects && rects.every(r => r.y2 < 100)) {
+ alreadyFocused = true;
+ todo(
+ false,
+ "bug 1445161 - the window should be focused at first paint, " +
+ rects.toSource()
+ );
+ continue;
+ }
+ }
+
+ rects = rects.filter(rect => {
+ let width = frame.width;
+
+ let exceptions = [
+ /**
+ * Please don't add anything new unless justified!
+ */
+ ];
+
+ let rectText = `${rect.toSource()}, window width: ${width}`;
+ for (let e of exceptions) {
+ if (e.condition(rect)) {
+ todo(false, e.name + ", " + rectText);
+ return false;
+ }
+ }
+
+ ok(false, "unexpected changed rect: " + rectText);
+ return true;
+ });
+ if (!rects.length) {
+ info("ignoring identical frame");
+ continue;
+ }
+
+ // Before dumping a frame with unexpected differences for the first time,
+ // ensure at least one previous frame has been logged so that it's possible
+ // to see the differences when examining the log.
+ if (!unexpectedRects) {
+ dumpFrame(previousFrame);
+ }
+ unexpectedRects += rects.length;
+ dumpFrame(frame);
+ }
+ is(unexpectedRects, 0, "should have 0 unknown flickering areas");
+});
diff --git a/browser/base/content/test/performance/browser_startup_hiddenwindow.js b/browser/base/content/test/performance/browser_startup_hiddenwindow.js
new file mode 100644
index 0000000000..7ad611be94
--- /dev/null
+++ b/browser/base/content/test/performance/browser_startup_hiddenwindow.js
@@ -0,0 +1,50 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function () {
+ if (
+ !AppConstants.NIGHTLY_BUILD &&
+ !AppConstants.MOZ_DEV_EDITION &&
+ !AppConstants.DEBUG
+ ) {
+ ok(
+ !("@mozilla.org/test/startuprecorder;1" in Cc),
+ "the startup recorder component shouldn't exist in this non-nightly/non-devedition/" +
+ "non-debug build."
+ );
+ return;
+ }
+
+ let startupRecorder =
+ Cc["@mozilla.org/test/startuprecorder;1"].getService().wrappedJSObject;
+ await startupRecorder.done;
+
+ let extras = Cu.cloneInto(startupRecorder.data.extras, {});
+
+ let phasesExpectations = {
+ "before profile selection": false,
+ "before opening first browser window": false,
+ "before first paint": !Services.prefs.getBoolPref(
+ "toolkit.lazyHiddenWindow"
+ ),
+
+ // Bug 1531854
+ "before handling user events": true,
+ "before becoming idle": true,
+ };
+
+ for (let phase in extras) {
+ if (!(phase in phasesExpectations)) {
+ ok(false, `Startup phase '${phase}' should be specified.`);
+ continue;
+ }
+
+ is(
+ extras[phase].hiddenWindowLoaded,
+ phasesExpectations[phase],
+ `Hidden window loaded at '${phase}': ${phasesExpectations[phase]}`
+ );
+ }
+});
diff --git a/browser/base/content/test/performance/browser_startup_images.js b/browser/base/content/test/performance/browser_startup_images.js
new file mode 100644
index 0000000000..5a27b9a8dd
--- /dev/null
+++ b/browser/base/content/test/performance/browser_startup_images.js
@@ -0,0 +1,136 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * This test checks that any images we load on startup are actually used,
+ * so we don't waste IO and cycles loading images the user doesn't see.
+ * It has a list of known problematic images that we aim to reduce to
+ * empty.
+ */
+
+/* A list of images that are loaded at startup but not shown.
+ * List items support the following attributes:
+ * - file: The location of the loaded image file.
+ * - hidpi: An alternative hidpi file location for retina screens, if one exists.
+ * May be the magic string <not loaded> in strange cases where
+ * only the low-resolution image is loaded but not shown.
+ * - platforms: An array of the platforms where the issue is occurring.
+ * Possible values are linux, win, macosx.
+ * - intermittentNotLoaded: an array of platforms where this image is
+ * intermittently not loaded, e.g. because it is
+ * loaded during the time we stop recording.
+ * - intermittentShown: An array of platforms where this image is
+ * intermittently shown, even though the list implies
+ * it might not be shown.
+ *
+ * PLEASE do not add items to this list.
+ *
+ * PLEASE DO remove items from this list.
+ */
+const knownUnshownImages = [
+ {
+ file: "chrome://global/skin/icons/arrow-left.svg",
+ platforms: ["linux", "win", "macosx"],
+ },
+
+ {
+ file: "chrome://browser/skin/toolbar-drag-indicator.svg",
+ platforms: ["linux", "win", "macosx"],
+ },
+
+ {
+ file: "chrome://global/skin/icons/chevron.svg",
+ platforms: ["win", "linux", "macosx"],
+ intermittentShown: ["win", "linux"],
+ },
+
+ {
+ file: "chrome://browser/skin/window-controls/maximize.svg",
+ platforms: ["win"],
+ // This is to prevent perma-fails in case Windows machines
+ // go back to running tests in non-maximized windows.
+ intermittentShown: ["win"],
+ // This file is not loaded on Windows 7/8.
+ intermittentNotLoaded: ["win"],
+ },
+];
+
+add_task(async function () {
+ if (!AppConstants.DEBUG) {
+ ok(false, "You need to run this test on a debug build.");
+ }
+
+ let startupRecorder =
+ Cc["@mozilla.org/test/startuprecorder;1"].getService().wrappedJSObject;
+ await startupRecorder.done;
+
+ let data = Cu.cloneInto(startupRecorder.data.images, {});
+ let knownImagesForPlatform = knownUnshownImages.filter(el => {
+ return el.platforms.includes(AppConstants.platform);
+ });
+
+ {
+ let results = await PerfTestHelpers.throttledMapPromises(
+ knownImagesForPlatform,
+ async image => ({
+ uri: image.file,
+ exists: await PerfTestHelpers.checkURIExists(image.file),
+ })
+ );
+ for (let { uri, exists } of results) {
+ ok(exists, `Unshown image entry ${uri} must exist`);
+ }
+ }
+
+ let loadedImages = data["image-loading"];
+ let shownImages = data["image-drawing"];
+
+ for (let loaded of loadedImages.values()) {
+ let knownImage = knownImagesForPlatform.find(el => {
+ if (window.devicePixelRatio >= 2 && el.hidpi && el.hidpi == loaded) {
+ return true;
+ }
+ return el.file == loaded;
+ });
+ if (knownImage) {
+ if (
+ !knownImage.intermittentShown ||
+ !knownImage.intermittentShown.includes(AppConstants.platform)
+ ) {
+ todo(
+ shownImages.has(loaded),
+ `Image ${loaded} should not have been shown.`
+ );
+ }
+ continue;
+ }
+ ok(
+ shownImages.has(loaded),
+ `Loaded image ${loaded} should have been shown.`
+ );
+ }
+
+ // Check for known images that are no longer used.
+ for (let item of knownImagesForPlatform) {
+ if (
+ !item.intermittentNotLoaded ||
+ !item.intermittentNotLoaded.includes(AppConstants.platform)
+ ) {
+ if (window.devicePixelRatio >= 2 && item.hidpi) {
+ if (item.hidpi != "<not loaded>") {
+ ok(
+ loadedImages.has(item.hidpi),
+ `Image ${item.hidpi} should have been loaded.`
+ );
+ }
+ } else {
+ ok(
+ loadedImages.has(item.file),
+ `Image ${item.file} should have been loaded.`
+ );
+ }
+ }
+ }
+});
diff --git a/browser/base/content/test/performance/browser_startup_mainthreadio.js b/browser/base/content/test/performance/browser_startup_mainthreadio.js
new file mode 100644
index 0000000000..3b34af5ad0
--- /dev/null
+++ b/browser/base/content/test/performance/browser_startup_mainthreadio.js
@@ -0,0 +1,881 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/* This test records I/O syscalls done on the main thread during startup.
+ *
+ * To run this test similar to try server, you need to run:
+ * ./mach package
+ * ./mach test --appname=dist <path to test>
+ *
+ * If you made changes that cause this test to fail, it's likely because you
+ * are touching more files or directories during startup.
+ * Most code has no reason to use main thread I/O.
+ * If for some reason accessing the file system on the main thread is currently
+ * unavoidable, consider defering the I/O as long as you can, ideally after
+ * the end of startup.
+ * If your code isn't strictly required to show the first browser window,
+ * it shouldn't be loaded before we are done with first paint.
+ * Finally, if your code isn't really needed during startup, it should not be
+ * loaded before we have started handling user events.
+ */
+
+"use strict";
+
+/* Set this to true only for debugging purpose; it makes the output noisy. */
+const kDumpAllStacks = false;
+
+// Shortcuts for conditions.
+const LINUX = AppConstants.platform == "linux";
+const WIN = AppConstants.platform == "win";
+const MAC = AppConstants.platform == "macosx";
+
+const kSharedFontList = SpecialPowers.getBoolPref("gfx.e10s.font-list.shared");
+
+/* This is an object mapping string phases of startup to lists of known cases
+ * of IO happening on the main thread. Ideally, IO should not be on the main
+ * thread, and should happen as late as possible (see above).
+ *
+ * Paths in the entries in these lists can:
+ * - be a full path, eg. "/etc/mime.types"
+ * - have a prefix which will be resolved using Services.dirsvc
+ * eg. "GreD:omni.ja"
+ * It's possible to have only a prefix, in thise case the directory will
+ * still be resolved, eg. "UAppData:"
+ * - use * at the begining and/or end as a wildcard
+ * The folder separator is '/' even for Windows paths, where it'll be
+ * automatically converted to '\'.
+ *
+ * Specifying 'ignoreIfUnused: true' will make the test ignore unused entries;
+ * without this the test is strict and will fail if the described IO does not
+ * happen.
+ *
+ * Each entry specifies the maximum number of times an operation is expected to
+ * occur.
+ * The operations currently reported by the I/O interposer are:
+ * create/open: only supported on Windows currently. The test currently
+ * ignores these markers to have a shorter initial list of IO operations.
+ * Adding Unix support is bug 1533779.
+ * stat: supported on all platforms when checking the last modified date or
+ * file size. Supported only on Windows when checking if a file exists;
+ * fixing this inconsistency is bug 1536109.
+ * read: supported on all platforms, but unix platforms will only report read
+ * calls going through NSPR.
+ * write: supported on all platforms, but Linux will only report write calls
+ * going through NSPR.
+ * close: supported only on Unix, and only for close calls going through NSPR.
+ * Adding Windows support is bug 1524574.
+ * fsync: supported only on Windows.
+ *
+ * If an entry specifies more than one operation, if at least one of them is
+ * encountered, the test won't report a failure for the entry if other
+ * operations are not encountered. This helps when listing cases where the
+ * reported operations aren't the same on all platforms due to the I/O
+ * interposer inconsistencies across platforms documented above.
+ */
+const startupPhases = {
+ // Anything done before or during app-startup must have a compelling reason
+ // to run before we have even selected the user profile.
+ "before profile selection": [
+ {
+ // bug 1541200
+ path: "UAppData:Crash Reports/InstallTime20*",
+ condition: AppConstants.MOZ_CRASHREPORTER,
+ stat: 1, // only caught on Windows.
+ read: 1,
+ write: 2,
+ close: 1,
+ },
+ {
+ // bug 1541200
+ path: "UAppData:Crash Reports/LastCrash",
+ condition: WIN && AppConstants.MOZ_CRASHREPORTER,
+ stat: 1, // only caught on Windows.
+ read: 1,
+ },
+ {
+ // bug 1541200
+ path: "UAppData:Crash Reports/LastCrash",
+ condition: !WIN && AppConstants.MOZ_CRASHREPORTER,
+ ignoreIfUnused: true, // only if we ever crashed on this machine
+ read: 1,
+ close: 1,
+ },
+ {
+ // At least the read seems unavoidable for a regular startup.
+ path: "UAppData:profiles.ini",
+ ignoreIfUnused: true,
+ condition: MAC,
+ stat: 1,
+ read: 1,
+ close: 1,
+ },
+ {
+ // At least the read seems unavoidable for a regular startup.
+ path: "UAppData:profiles.ini",
+ condition: WIN,
+ ignoreIfUnused: true, // only if a real profile exists on the system.
+ read: 1,
+ stat: 1,
+ },
+ {
+ // bug 1541226, bug 1363586, bug 1541593
+ path: "ProfD:",
+ condition: WIN,
+ stat: 1,
+ },
+ {
+ path: "ProfLD:.startup-incomplete",
+ condition: !WIN, // Visible on Windows with an open marker
+ close: 1,
+ },
+ {
+ // bug 1541491 to stop using this file, bug 1541494 to write correctly.
+ path: "ProfLD:compatibility.ini",
+ write: 18,
+ close: 1,
+ },
+ {
+ path: "GreD:omni.ja",
+ condition: !WIN, // Visible on Windows with an open marker
+ stat: 1,
+ },
+ {
+ // bug 1376994
+ path: "XCurProcD:omni.ja",
+ condition: !WIN, // Visible on Windows with an open marker
+ stat: 1,
+ },
+ {
+ path: "ProfD:parent.lock",
+ condition: WIN,
+ stat: 1,
+ },
+ {
+ // bug 1541603
+ path: "ProfD:minidumps",
+ condition: WIN,
+ stat: 1,
+ },
+ {
+ // bug 1543746
+ path: "XCurProcD:defaults/preferences",
+ condition: WIN,
+ stat: 1,
+ },
+ {
+ // bug 1544034
+ path: "ProfLDS:startupCache/scriptCache-child-current.bin",
+ condition: WIN,
+ stat: 1,
+ },
+ {
+ // bug 1544034
+ path: "ProfLDS:startupCache/scriptCache-child.bin",
+ condition: WIN,
+ stat: 1,
+ },
+ {
+ // bug 1544034
+ path: "ProfLDS:startupCache/scriptCache-current.bin",
+ condition: WIN,
+ stat: 1,
+ },
+ {
+ // bug 1544034
+ path: "ProfLDS:startupCache/scriptCache.bin",
+ condition: WIN,
+ stat: 1,
+ },
+ {
+ // bug 1541601
+ path: "PrfDef:channel-prefs.js",
+ stat: 1,
+ read: 1,
+ close: 1,
+ },
+ {
+ // At least the read seems unavoidable
+ path: "PrefD:prefs.js",
+ stat: 1,
+ read: 1,
+ close: 1,
+ },
+ {
+ // bug 1543752
+ path: "PrefD:user.js",
+ stat: 1,
+ read: 1,
+ close: 1,
+ },
+ ],
+
+ "before opening first browser window": [
+ {
+ // bug 1541226
+ path: "ProfD:",
+ condition: WIN,
+ ignoreIfUnused: true, // Sometimes happens in the next phase
+ stat: 1,
+ },
+ {
+ // bug 1534745
+ path: "ProfD:cookies.sqlite-journal",
+ condition: !LINUX,
+ ignoreIfUnused: true, // Sometimes happens in the next phase
+ stat: 3,
+ write: 4,
+ },
+ {
+ // bug 1534745
+ path: "ProfD:cookies.sqlite",
+ condition: !LINUX,
+ ignoreIfUnused: true, // Sometimes happens in the next phase
+ stat: 2,
+ read: 3,
+ write: 1,
+ },
+ {
+ // bug 1534745
+ path: "ProfD:cookies.sqlite-wal",
+ ignoreIfUnused: true, // Sometimes happens in the next phase
+ condition: WIN,
+ stat: 2,
+ },
+ {
+ // Seems done by OS X and outside of our control.
+ path: "*.savedState/restorecount.plist",
+ condition: MAC,
+ ignoreIfUnused: true,
+ write: 1,
+ },
+ {
+ // Side-effect of bug 1412090, via sandboxing (but the real
+ // problem there is main-thread CPU use; see bug 1439412)
+ path: "*ld.so.conf*",
+ condition: LINUX && !AppConstants.MOZ_CODE_COVERAGE && !kSharedFontList,
+ read: 22,
+ close: 11,
+ },
+ {
+ // bug 1541246
+ path: "ProfD:extensions",
+ ignoreIfUnused: true, // bug 1649590
+ condition: WIN,
+ stat: 1,
+ },
+ {
+ // bug 1541246
+ path: "UAppData:",
+ ignoreIfUnused: true, // sometimes before opening first browser window,
+ // sometimes before first paint
+ condition: WIN,
+ stat: 1,
+ },
+ {
+ // bug 1833104 has context - this is artifact-only so doesn't affect
+ // any real users, will just show up for developer builds and
+ // artifact trypushes so we include it here.
+ path: "GreD:jogfile.json",
+ condition:
+ WIN && Services.prefs.getBoolPref("telemetry.fog.artifact_build"),
+ stat: 1,
+ },
+ ],
+
+ // We reach this phase right after showing the first browser window.
+ // This means that any I/O at this point delayed first paint.
+ "before first paint": [
+ {
+ // bug 1545119
+ path: "OldUpdRootD:",
+ condition: WIN,
+ stat: 1,
+ },
+ {
+ // bug 1446012
+ path: "UpdRootD:updates/0/update.status",
+ condition: WIN,
+ stat: 1,
+ },
+ {
+ path: "XREAppFeat:formautofill@mozilla.org.xpi",
+ condition: !WIN,
+ stat: 1,
+ close: 1,
+ },
+ {
+ path: "XREAppFeat:webcompat@mozilla.org.xpi",
+ condition: LINUX,
+ ignoreIfUnused: true, // Sometimes happens in the previous phase
+ close: 1,
+ },
+ {
+ // We only hit this for new profiles.
+ path: "XREAppDist:distribution.ini",
+ // check we're not msix to disambiguate from the next entry...
+ condition: WIN && !Services.sysinfo.getProperty("hasWinPackageId"),
+ stat: 1,
+ },
+ {
+ // On MSIX, we actually read this file - bug 1833341.
+ path: "XREAppDist:distribution.ini",
+ condition: WIN && Services.sysinfo.getProperty("hasWinPackageId"),
+ stat: 1,
+ read: 1,
+ },
+ {
+ // bug 1545139
+ path: "*Fonts/StaticCache.dat",
+ condition: WIN,
+ ignoreIfUnused: true, // Only on Win7
+ read: 1,
+ },
+ {
+ // Bug 1626738
+ path: "SysD:spool/drivers/color/*",
+ condition: WIN,
+ read: 1,
+ },
+ {
+ // Sandbox policy construction
+ path: "*ld.so.conf*",
+ condition: LINUX && !AppConstants.MOZ_CODE_COVERAGE,
+ read: 22,
+ close: 11,
+ },
+ {
+ // bug 1541246
+ path: "UAppData:",
+ ignoreIfUnused: true, // sometimes before opening first browser window,
+ // sometimes before first paint
+ condition: WIN,
+ stat: 1,
+ },
+ {
+ // Not in packaged builds; useful for artifact builds.
+ path: "GreD:ScalarArtifactDefinitions.json",
+ condition: WIN && !AppConstants.MOZILLA_OFFICIAL,
+ stat: 1,
+ },
+ {
+ // Not in packaged builds; useful for artifact builds.
+ path: "GreD:EventArtifactDefinitions.json",
+ condition: WIN && !AppConstants.MOZILLA_OFFICIAL,
+ stat: 1,
+ },
+ {
+ // bug 1541226
+ path: "ProfD:",
+ condition: WIN,
+ ignoreIfUnused: true, // Usually happens in the previous phase
+ stat: 1,
+ },
+ {
+ // bug 1534745
+ path: "ProfD:cookies.sqlite-journal",
+ condition: WIN,
+ ignoreIfUnused: true, // Usually happens in the previous phase
+ stat: 3,
+ write: 4,
+ },
+ {
+ // bug 1534745
+ path: "ProfD:cookies.sqlite",
+ condition: WIN,
+ ignoreIfUnused: true, // Usually happens in the previous phase
+ stat: 2,
+ read: 3,
+ write: 1,
+ },
+ {
+ // bug 1534745
+ path: "ProfD:cookies.sqlite-wal",
+ condition: WIN,
+ ignoreIfUnused: true, // Usually happens in the previous phase
+ stat: 2,
+ },
+ ],
+
+ // We are at this phase once we are ready to handle user events.
+ // Any IO at this phase or before gets in the way of the user
+ // interacting with the first browser window.
+ "before handling user events": [
+ {
+ path: "GreD:update.test",
+ ignoreIfUnused: true,
+ condition: LINUX,
+ close: 1,
+ },
+ {
+ path: "XREAppFeat:webcompat-reporter@mozilla.org.xpi",
+ condition: !WIN,
+ ignoreIfUnused: true,
+ stat: 1,
+ close: 1,
+ },
+ {
+ // Bug 1660582 - access while running on windows10 hardware.
+ path: "ProfD:wmfvpxvideo.guard",
+ condition: WIN,
+ ignoreIfUnused: true,
+ stat: 1,
+ close: 1,
+ },
+ {
+ // Bug 1649590
+ path: "ProfD:extensions",
+ ignoreIfUnused: true,
+ condition: WIN,
+ stat: 1,
+ },
+ ],
+
+ // Things that are expected to be completely out of the startup path
+ // and loaded lazily when used for the first time by the user should
+ // be listed here.
+ "before becoming idle": [
+ {
+ // bug 1370516 - NSS should be initialized off main thread.
+ path: `ProfD:cert9.db`,
+ condition: WIN,
+ read: 5,
+ stat: AppConstants.NIGHTLY_BUILD ? 5 : 4,
+ },
+ {
+ // bug 1370516 - NSS should be initialized off main thread.
+ path: `ProfD:cert9.db-journal`,
+ condition: WIN,
+ stat: 3,
+ },
+ {
+ // bug 1370516 - NSS should be initialized off main thread.
+ path: `ProfD:cert9.db-wal`,
+ condition: WIN,
+ stat: 3,
+ },
+ {
+ // bug 1370516 - NSS should be initialized off main thread.
+ path: "ProfD:pkcs11.txt",
+ condition: WIN,
+ read: 2,
+ },
+ {
+ // bug 1370516 - NSS should be initialized off main thread.
+ path: `ProfD:key4.db`,
+ condition: WIN,
+ read: 10,
+ stat: AppConstants.NIGHTLY_BUILD ? 5 : 4,
+ },
+ {
+ // bug 1370516 - NSS should be initialized off main thread.
+ path: `ProfD:key4.db-journal`,
+ condition: WIN,
+ stat: 7,
+ },
+ {
+ // bug 1370516 - NSS should be initialized off main thread.
+ path: `ProfD:key4.db-wal`,
+ condition: WIN,
+ stat: 7,
+ },
+ {
+ path: "XREAppFeat:screenshots@mozilla.org.xpi",
+ ignoreIfUnused: true,
+ close: 1,
+ },
+ {
+ path: "XREAppFeat:webcompat-reporter@mozilla.org.xpi",
+ ignoreIfUnused: true,
+ stat: 1,
+ close: 1,
+ },
+ {
+ // bug 1391590
+ path: "ProfD:places.sqlite-journal",
+ ignoreIfUnused: true,
+ fsync: 1,
+ stat: 4,
+ read: 1,
+ write: 2,
+ },
+ {
+ // bug 1391590
+ path: "ProfD:places.sqlite-wal",
+ ignoreIfUnused: true,
+ stat: 4,
+ fsync: 3,
+ read: 51,
+ write: 178,
+ },
+ {
+ // bug 1391590
+ path: "ProfD:places.sqlite-shm",
+ condition: WIN,
+ ignoreIfUnused: true,
+ stat: 1,
+ },
+ {
+ // bug 1391590
+ path: "ProfD:places.sqlite",
+ ignoreIfUnused: true,
+ fsync: 2,
+ read: 4,
+ stat: 3,
+ write: 1324,
+ },
+ {
+ // bug 1391590
+ path: "ProfD:favicons.sqlite-journal",
+ ignoreIfUnused: true,
+ fsync: 2,
+ stat: 7,
+ read: 2,
+ write: 7,
+ },
+ {
+ // bug 1391590
+ path: "ProfD:favicons.sqlite-wal",
+ ignoreIfUnused: true,
+ fsync: 2,
+ stat: 7,
+ read: 7,
+ write: 15,
+ },
+ {
+ // bug 1391590
+ path: "ProfD:favicons.sqlite-shm",
+ condition: WIN,
+ ignoreIfUnused: true,
+ stat: 2,
+ },
+ {
+ // bug 1391590
+ path: "ProfD:favicons.sqlite",
+ ignoreIfUnused: true,
+ fsync: 3,
+ read: 8,
+ stat: 4,
+ write: 1300,
+ },
+ {
+ path: "ProfD:",
+ condition: WIN,
+ ignoreIfUnused: true,
+ stat: 3,
+ },
+ ],
+};
+
+for (let name of ["d3d11layers", "glcontext", "wmfvpxvideo"]) {
+ startupPhases["before first paint"].push({
+ path: `ProfD:${name}.guard`,
+ ignoreIfUnused: true,
+ stat: 1,
+ });
+}
+
+function expandPathWithDirServiceKey(path) {
+ if (path.includes(":")) {
+ let [prefix, suffix] = path.split(":");
+ let [key, property] = prefix.split(".");
+ let dir = Services.dirsvc.get(key, Ci.nsIFile);
+ if (property) {
+ dir = dir[property];
+ }
+
+ // Resolve symLinks.
+ let dirPath = dir.path;
+ while (dir && !dir.isSymlink()) {
+ dir = dir.parent;
+ }
+ if (dir) {
+ dirPath = dirPath.replace(dir.path, dir.target);
+ }
+
+ path = dirPath;
+
+ if (suffix) {
+ path += "/" + suffix;
+ }
+ }
+ if (AppConstants.platform == "win") {
+ path = path.replace(/\//g, "\\");
+ }
+ return path;
+}
+
+function getStackFromProfile(profile, stack) {
+ const stackPrefixCol = profile.stackTable.schema.prefix;
+ const stackFrameCol = profile.stackTable.schema.frame;
+ const frameLocationCol = profile.frameTable.schema.location;
+
+ let result = [];
+ while (stack) {
+ let sp = profile.stackTable.data[stack];
+ let frame = profile.frameTable.data[sp[stackFrameCol]];
+ stack = sp[stackPrefixCol];
+ frame = profile.stringTable[frame[frameLocationCol]];
+ if (frame != "js::RunScript" && !frame.startsWith("next (self-hosted:")) {
+ result.push(frame);
+ }
+ }
+ return result;
+}
+
+function pathMatches(path, filename) {
+ path = path.toLowerCase();
+ return (
+ path == filename || // Full match
+ // Wildcard on both sides of the path
+ (path.startsWith("*") &&
+ path.endsWith("*") &&
+ filename.includes(path.slice(1, -1))) ||
+ // Wildcard suffix
+ (path.endsWith("*") && filename.startsWith(path.slice(0, -1))) ||
+ // Wildcard prefix
+ (path.startsWith("*") && filename.endsWith(path.slice(1)))
+ );
+}
+
+add_task(async function () {
+ if (
+ !AppConstants.NIGHTLY_BUILD &&
+ !AppConstants.MOZ_DEV_EDITION &&
+ !AppConstants.DEBUG
+ ) {
+ ok(
+ !("@mozilla.org/test/startuprecorder;1" in Cc),
+ "the startup recorder component shouldn't exist in this non-nightly/non-devedition/" +
+ "non-debug build."
+ );
+ return;
+ }
+
+ TestUtils.assertPackagedBuild();
+
+ let startupRecorder =
+ Cc["@mozilla.org/test/startuprecorder;1"].getService().wrappedJSObject;
+ await startupRecorder.done;
+
+ // Add system add-ons to the list of known IO dynamically.
+ // They should go in the omni.ja file (bug 1357205).
+ {
+ let addons = await AddonManager.getAddonsByTypes(["extension"]);
+ for (let addon of addons) {
+ if (addon.isSystem) {
+ startupPhases["before opening first browser window"].push({
+ path: `XREAppFeat:${addon.id}.xpi`,
+ stat: 3,
+ close: 2,
+ });
+ startupPhases["before handling user events"].push({
+ path: `XREAppFeat:${addon.id}.xpi`,
+ condition: WIN,
+ stat: 2,
+ });
+ }
+ }
+ }
+
+ // Check for main thread I/O markers in the startup profile.
+ let profile = startupRecorder.data.profile.threads[0];
+
+ let phases = {};
+ {
+ const nameCol = profile.markers.schema.name;
+ const dataCol = profile.markers.schema.data;
+
+ let markersForCurrentPhase = [];
+ let foundIOMarkers = false;
+
+ for (let m of profile.markers.data) {
+ let markerName = profile.stringTable[m[nameCol]];
+ if (markerName.startsWith("startupRecorder:")) {
+ phases[markerName.split("startupRecorder:")[1]] =
+ markersForCurrentPhase;
+ markersForCurrentPhase = [];
+ continue;
+ }
+
+ if (markerName != "FileIO") {
+ continue;
+ }
+
+ let markerData = m[dataCol];
+ if (markerData.source == "sqlite-mainthread") {
+ continue;
+ }
+
+ let samples = markerData.stack.samples;
+ let stack = samples.data[0][samples.schema.stack];
+ markersForCurrentPhase.push({
+ operation: markerData.operation,
+ filename: markerData.filename,
+ source: markerData.source,
+ stackId: stack,
+ });
+ foundIOMarkers = true;
+ }
+
+ // The I/O interposer is disabled if !RELEASE_OR_BETA, so we expect to have
+ // no I/O marker in that case, but it's good to keep the test running to check
+ // that we are still able to produce startup profiles.
+ is(
+ foundIOMarkers,
+ !AppConstants.RELEASE_OR_BETA,
+ "The IO interposer should be enabled in builds that are not RELEASE_OR_BETA"
+ );
+ if (!foundIOMarkers) {
+ // If a profile unexpectedly contains no I/O marker, it's better to return
+ // early to avoid having a lot of of confusing "no main thread IO when we
+ // expected some" failures.
+ return;
+ }
+ }
+
+ for (let phase in startupPhases) {
+ startupPhases[phase] = startupPhases[phase].filter(
+ entry => !("condition" in entry) || entry.condition
+ );
+ startupPhases[phase].forEach(entry => {
+ entry.listedPath = entry.path;
+ entry.path = expandPathWithDirServiceKey(entry.path);
+ });
+ }
+
+ let tmpPath = expandPathWithDirServiceKey("TmpD:").toLowerCase();
+ let shouldPass = true;
+ for (let phase in phases) {
+ let knownIOList = startupPhases[phase];
+ info(
+ `known main thread IO paths during ${phase}:\n` +
+ knownIOList
+ .map(e => {
+ let operations = Object.keys(e)
+ .filter(k => k != "path")
+ .map(k => `${k}: ${e[k]}`);
+ return ` ${e.path} - ${operations.join(", ")}`;
+ })
+ .join("\n")
+ );
+
+ let markers = phases[phase];
+ for (let marker of markers) {
+ if (marker.operation == "create/open") {
+ // TODO: handle these I/O markers once they are supported on
+ // non-Windows platforms.
+ continue;
+ }
+
+ if (!marker.filename) {
+ // We are still missing the filename on some mainthreadio markers,
+ // these markers are currently useless for the purpose of this test.
+ continue;
+ }
+
+ // Convert to lower case before comparing because the OS X test machines
+ // have the 'Firefox' folder in 'Library/Application Support' created
+ // as 'firefox' for some reason.
+ let filename = marker.filename.toLowerCase();
+
+ if (!WIN && filename == "/dev/urandom") {
+ continue;
+ }
+
+ // /dev/shm is always tmpfs (a memory filesystem); this isn't
+ // really I/O any more than mmap/munmap are.
+ if (LINUX && filename.startsWith("/dev/shm/")) {
+ continue;
+ }
+
+ // "Files" from memfd_create() are similar to tmpfs but never
+ // exist in the filesystem; however, they have names which are
+ // exposed in procfs, and the I/O interposer observes when
+ // they're close()d.
+ if (LINUX && filename.startsWith("/memfd:")) {
+ continue;
+ }
+
+ // Shared memory uses temporary files on MacOS <= 10.11 to avoid
+ // a kernel security bug that will never be patched (see
+ // https://crbug.com/project-zero/1671 for details). This can
+ // be removed when we no longer support those OS versions.
+ if (MAC && filename.startsWith(tmpPath + "/org.mozilla.ipc.")) {
+ continue;
+ }
+
+ let expected = false;
+ for (let entry of knownIOList) {
+ if (pathMatches(entry.path, filename)) {
+ entry[marker.operation] = (entry[marker.operation] || 0) - 1;
+ entry._used = true;
+ expected = true;
+ break;
+ }
+ }
+ if (!expected) {
+ record(
+ false,
+ `unexpected ${marker.operation} on ${marker.filename} ${phase}`,
+ undefined,
+ " " + getStackFromProfile(profile, marker.stackId).join("\n ")
+ );
+ shouldPass = false;
+ }
+ info(`(${marker.source}) ${marker.operation} - ${marker.filename}`);
+ if (kDumpAllStacks) {
+ info(
+ getStackFromProfile(profile, marker.stackId)
+ .map(f => " " + f)
+ .join("\n")
+ );
+ }
+ }
+
+ for (let entry of knownIOList) {
+ for (let op in entry) {
+ if (
+ [
+ "listedPath",
+ "path",
+ "condition",
+ "ignoreIfUnused",
+ "_used",
+ ].includes(op)
+ ) {
+ continue;
+ }
+ let message = `${op} on ${entry.path} `;
+ if (entry[op] == 0) {
+ message += "as many times as expected";
+ } else if (entry[op] > 0) {
+ message += `allowed ${entry[op]} more times`;
+ } else {
+ message += `${entry[op] * -1} more times than expected`;
+ }
+ ok(entry[op] >= 0, `${message} ${phase}`);
+ }
+ if (!("_used" in entry) && !entry.ignoreIfUnused) {
+ ok(
+ false,
+ `no main thread IO when we expected some during ${phase}: ${entry.path} (${entry.listedPath})`
+ );
+ shouldPass = false;
+ }
+ }
+ }
+
+ if (shouldPass) {
+ ok(shouldPass, "No unexpected main thread I/O during startup");
+ } else {
+ const filename = "profile_startup_mainthreadio.json";
+ let path = Services.env.get("MOZ_UPLOAD_DIR");
+ let profilePath = PathUtils.join(path, filename);
+ await IOUtils.writeJSON(profilePath, startupRecorder.data.profile);
+ ok(
+ false,
+ "Unexpected main thread I/O behavior during startup; open the " +
+ `${filename} artifact in the Firefox Profiler to see what happened`
+ );
+ }
+});
diff --git a/browser/base/content/test/performance/browser_startup_syncIPC.js b/browser/base/content/test/performance/browser_startup_syncIPC.js
new file mode 100644
index 0000000000..41744cf4b8
--- /dev/null
+++ b/browser/base/content/test/performance/browser_startup_syncIPC.js
@@ -0,0 +1,449 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/* This test sync IPC done on the main thread during startup. */
+
+"use strict";
+
+// Shortcuts for conditions.
+const LINUX = AppConstants.platform == "linux";
+const WIN = AppConstants.platform == "win";
+const MAC = AppConstants.platform == "macosx";
+const WEBRENDER = window.windowUtils.layerManagerType.startsWith("WebRender");
+const SKELETONUI = Services.prefs.getBoolPref(
+ "browser.startup.preXulSkeletonUI",
+ false
+);
+
+/*
+ * Specifying 'ignoreIfUnused: true' will make the test ignore unused entries;
+ * without this the test is strict and will fail if a list entry isn't used.
+ */
+const startupPhases = {
+ // Anything done before or during app-startup must have a compelling reason
+ // to run before we have even selected the user profile.
+ "before profile selection": [],
+
+ "before opening first browser window": [],
+
+ // We reach this phase right after showing the first browser window.
+ // This means that any I/O at this point delayed first paint.
+ "before first paint": [
+ {
+ name: "PLayerTransaction::Msg_GetTextureFactoryIdentifier",
+ condition: (MAC || LINUX) && !WEBRENDER,
+ maxCount: 1,
+ },
+ {
+ name: "PLayerTransaction::Msg_GetTextureFactoryIdentifier",
+ condition: WIN && !WEBRENDER,
+ maxCount: 3,
+ },
+ {
+ name: "PWebRenderBridge::Msg_EnsureConnected",
+ condition: WIN && WEBRENDER,
+ maxCount: 3,
+ },
+ {
+ name: "PWebRenderBridge::Msg_EnsureConnected",
+ condition: (MAC || LINUX) && WEBRENDER,
+ maxCount: 1,
+ },
+ {
+ // bug 1373773
+ name: "PCompositorBridge::Msg_NotifyChildCreated",
+ condition: !WIN,
+ maxCount: 1,
+ },
+ {
+ name: "PCompositorBridge::Msg_NotifyChildCreated",
+ condition: WIN,
+ ignoreIfUnused: true, // Only on Win7 32
+ maxCount: 2,
+ },
+ {
+ name: "PCompositorBridge::Msg_MapAndNotifyChildCreated",
+ condition: WIN,
+ ignoreIfUnused: true, // Only on Win10 64
+ maxCount: 2,
+ },
+ {
+ name: "PCompositorBridge::Msg_FlushRendering",
+ condition: MAC,
+ maxCount: 1,
+ },
+ {
+ name: "PCompositorBridge::Msg_FlushRendering",
+ condition: WIN,
+ ignoreIfUnused: true, // Only on Win7 32
+ maxCount: 1,
+ },
+ {
+ name: "PCompositorBridge::Msg_Initialize",
+ condition: WIN,
+ ignoreIfUnused: true, // Only on Win10 64
+ maxCount: 3,
+ },
+ {
+ name: "PCompositorWidget::Msg_Initialize",
+ condition: WIN,
+ ignoreIfUnused: true, // Only on Win10 64
+ maxCount: 3,
+ },
+ {
+ name: "PGPU::Msg_AddLayerTreeIdMapping",
+ condition: WIN,
+ ignoreIfUnused: true, // Only on Win10 64
+ maxCount: 5,
+ },
+ {
+ name: "PCompositorBridge::Msg_MakeSnapshot",
+ condition: WIN && !WEBRENDER,
+ ignoreIfUnused: true, // Only on Win10 64
+ maxCount: 1,
+ },
+ {
+ name: "PWebRenderBridge::Msg_GetSnapshot",
+ condition: WIN && WEBRENDER,
+ ignoreIfUnused: true, // Sometimes in the next phase on Windows10 QR
+ maxCount: 1,
+ },
+ {
+ name: "PCompositorBridge::Msg_WillClose",
+ condition: WIN,
+ ignoreIfUnused: true, // Only on Win10 64
+ maxCount: 2,
+ },
+ {
+ name: "PAPZInputBridge::Msg_ProcessUnhandledEvent",
+ condition: WIN,
+ ignoreIfUnused: true, // Only on Win10 64
+ maxCount: 1,
+ },
+ {
+ name: "PGPU::Msg_GetDeviceStatus",
+ // bug 1553740 might want to drop the WEBRENDER clause here.
+ // Additionally, the skeleton UI causes us to attach "before first paint" to a
+ // later event, which lets this sneak in.
+ condition: WIN && (WEBRENDER || SKELETONUI),
+ // If Init() completes before we call EnsureGPUReady we won't send GetDeviceStatus
+ // so we can safely ignore if unused.
+ ignoreIfUnused: true,
+ maxCount: 1,
+ },
+ {
+ // bug 1784869
+ // We use Resume signal to propagate correct XWindow/wl_surface
+ // to EGL compositor.
+ name: "PCompositorBridge::Msg_Resume",
+ condition: LINUX,
+ ignoreIfUnused: true, // intermittently occurs in "before handling user events"
+ maxCount: 1,
+ },
+ ],
+
+ // We are at this phase once we are ready to handle user events.
+ // Any IO at this phase or before gets in the way of the user
+ // interacting with the first browser window.
+ "before handling user events": [
+ {
+ name: "PCompositorBridge::Msg_FlushRendering",
+ condition: MAC,
+ ignoreIfUnused: true,
+ maxCount: 1,
+ },
+ {
+ name: "PCompositorBridge::Msg_FlushRendering",
+ condition: LINUX,
+ ignoreIfUnused: true, // intermittently occurs in "before becoming idle"
+ maxCount: 2,
+ },
+ {
+ name: "PLayerTransaction::Msg_GetTextureFactoryIdentifier",
+ condition: (!MAC && !WEBRENDER) || (WIN && WEBRENDER),
+ ignoreIfUnused: true, // intermittently occurs in "before becoming idle"
+ maxCount: 1,
+ },
+ {
+ name: "PCompositorBridge::Msg_Initialize",
+ condition: WIN,
+ ignoreIfUnused: true, // Only on Win10 64
+ maxCount: 1,
+ },
+ {
+ name: "PCompositorWidget::Msg_Initialize",
+ condition: WIN,
+ ignoreIfUnused: true, // Only on Win10 64
+ maxCount: 1,
+ },
+ {
+ name: "PCompositorBridge::Msg_WillClose",
+ condition: WIN,
+ ignoreIfUnused: true, // Only on Win7 32
+ maxCount: 2,
+ },
+ {
+ name: "PCompositorBridge::Msg_MakeSnapshot",
+ condition: WIN,
+ ignoreIfUnused: true, // Only on Win7 32
+ maxCount: 1,
+ },
+ {
+ name: "PWebRenderBridge::Msg_GetSnapshot",
+ condition: WIN && WEBRENDER,
+ ignoreIfUnused: true, // Sometimes in the next phase on Windows10 QR
+ maxCount: 1,
+ },
+ {
+ name: "PAPZInputBridge::Msg_ProcessUnhandledEvent",
+ condition: WIN,
+ ignoreIfUnused: true, // intermittently occurs in "before becoming idle"
+ maxCount: 1,
+ },
+ {
+ name: "PAPZInputBridge::Msg_ReceiveMouseInputEvent",
+ condition: WIN,
+ ignoreIfUnused: true, // intermittently occurs in "before becoming idle"
+ maxCount: 1,
+ },
+ {
+ name: "PWebRenderBridge::Msg_EnsureConnected",
+ condition: WIN && WEBRENDER,
+ ignoreIfUnused: true,
+ maxCount: 1,
+ },
+ {
+ name: "PContent::Reply_BeginDriverCrashGuard",
+ condition: WIN,
+ ignoreIfUnused: true, // Bug 1660590 - found while running test on windows hardware
+ maxCount: 1,
+ },
+ {
+ name: "PContent::Reply_EndDriverCrashGuard",
+ condition: WIN,
+ ignoreIfUnused: true, // Bug 1660590 - found while running test on windows hardware
+ maxCount: 1,
+ },
+ {
+ // bug 1784869
+ // We use Resume signal to propagate correct XWindow/wl_surface
+ // to EGL compositor.
+ name: "PCompositorBridge::Msg_Resume",
+ condition: LINUX,
+ ignoreIfUnused: true, // intermittently occurs in "before first paint"
+ maxCount: 1,
+ },
+ ],
+
+ // Things that are expected to be completely out of the startup path
+ // and loaded lazily when used for the first time by the user should
+ // be listed here.
+ "before becoming idle": [
+ {
+ // bug 1373773
+ name: "PCompositorBridge::Msg_NotifyChildCreated",
+ ignoreIfUnused: true,
+ maxCount: 1,
+ },
+ {
+ name: "PAPZInputBridge::Msg_ProcessUnhandledEvent",
+ condition: WIN,
+ ignoreIfUnused: true, // Only on Win10 64
+ maxCount: 1,
+ },
+ {
+ name: "PAPZInputBridge::Msg_ReceiveMouseInputEvent",
+ condition: WIN,
+ ignoreIfUnused: true, // Only on Win10 64
+ maxCount: 1,
+ },
+ {
+ // bug 1554234
+ name: "PLayerTransaction::Msg_GetTextureFactoryIdentifier",
+ condition: WIN || LINUX,
+ ignoreIfUnused: true, // intermittently occurs in "before handling user events"
+ maxCount: 1,
+ },
+ {
+ name: "PWebRenderBridge::Msg_EnsureConnected",
+ condition: (WIN || LINUX) && WEBRENDER,
+ ignoreIfUnused: true,
+ maxCount: 1,
+ },
+ {
+ name: "PCompositorBridge::Msg_Initialize",
+ condition: WIN,
+ ignoreIfUnused: true, // Intermittently occurs in "before handling user events"
+ maxCount: 1,
+ },
+ {
+ name: "PCompositorWidget::Msg_Initialize",
+ condition: WIN,
+ ignoreIfUnused: true, // Intermittently occurs in "before handling user events"
+ maxCount: 1,
+ },
+ {
+ name: "PCompositorBridge::Msg_MapAndNotifyChildCreated",
+ condition: WIN,
+ ignoreIfUnused: true,
+ maxCount: 1,
+ },
+ {
+ name: "PCompositorBridge::Msg_FlushRendering",
+ condition: MAC || SKELETONUI,
+ ignoreIfUnused: true,
+ maxCount: 1,
+ },
+ {
+ name: "PCompositorBridge::Msg_FlushRendering",
+ condition: LINUX,
+ ignoreIfUnused: true, // intermittently occurs in "before handling user events"
+ maxCount: 1,
+ },
+ {
+ name: "PWebRenderBridge::Msg_GetSnapshot",
+ condition: WIN && WEBRENDER,
+ ignoreIfUnused: true,
+ maxCount: 1,
+ },
+ {
+ name: "PCompositorBridge::Msg_MakeSnapshot",
+ condition: WIN,
+ ignoreIfUnused: true,
+ maxCount: 1,
+ },
+ {
+ name: "PCompositorBridge::Msg_WillClose",
+ condition: WIN,
+ ignoreIfUnused: true,
+ maxCount: 2,
+ },
+ // Added for the search-detection built-in add-on.
+ {
+ name: "PGPU::Msg_AddLayerTreeIdMapping",
+ condition: WIN,
+ ignoreIfUnused: true,
+ maxCount: 1,
+ },
+ ],
+};
+
+add_task(async function () {
+ if (
+ !AppConstants.NIGHTLY_BUILD &&
+ !AppConstants.MOZ_DEV_EDITION &&
+ !AppConstants.DEBUG
+ ) {
+ ok(
+ !("@mozilla.org/test/startuprecorder;1" in Cc),
+ "the startup recorder component shouldn't exist in this non-nightly/non-devedition/" +
+ "non-debug build."
+ );
+ return;
+ }
+
+ let startupRecorder =
+ Cc["@mozilla.org/test/startuprecorder;1"].getService().wrappedJSObject;
+ await startupRecorder.done;
+
+ // Check for sync IPC markers in the startup profile.
+ let profile = startupRecorder.data.profile.threads[0];
+
+ let phases = {};
+ {
+ const nameCol = profile.markers.schema.name;
+ const dataCol = profile.markers.schema.data;
+ const startTimeCol = profile.markers.schema.startTime;
+
+ let markersForCurrentPhase = [];
+ for (let m of profile.markers.data) {
+ let markerName = profile.stringTable[m[nameCol]];
+ if (markerName.startsWith("startupRecorder:")) {
+ phases[markerName.split("startupRecorder:")[1]] =
+ markersForCurrentPhase;
+ markersForCurrentPhase = [];
+ continue;
+ }
+
+ let markerData = m[dataCol];
+ if (
+ !markerData ||
+ markerData.category != "Sync IPC" ||
+ !m[startTimeCol]
+ ) {
+ continue;
+ }
+
+ markersForCurrentPhase.push(markerName);
+ }
+ }
+
+ for (let phase in startupPhases) {
+ startupPhases[phase] = startupPhases[phase].filter(
+ entry => !("condition" in entry) || entry.condition
+ );
+ }
+
+ let shouldPass = true;
+ for (let phase in phases) {
+ let knownIPCList = startupPhases[phase];
+ if (knownIPCList.length) {
+ info(
+ `known sync IPC ${phase}:\n` +
+ knownIPCList
+ .map(e => ` ${e.name} - at most ${e.maxCount} times`)
+ .join("\n")
+ );
+ }
+
+ let markers = phases[phase];
+ for (let marker of markers) {
+ let expected = false;
+ for (let entry of knownIPCList) {
+ if (marker == entry.name) {
+ entry.useCount = (entry.useCount || 0) + 1;
+ expected = true;
+ break;
+ }
+ }
+ if (!expected) {
+ ok(false, `unexpected ${marker} sync IPC ${phase}`);
+ shouldPass = false;
+ }
+ }
+
+ for (let entry of knownIPCList) {
+ // Make sure useCount has been defined.
+ entry.useCount = entry.useCount || 0;
+ let message = `sync IPC ${entry.name} `;
+ if (entry.useCount == entry.maxCount) {
+ message += "happened as many times as expected";
+ } else if (entry.useCount < entry.maxCount) {
+ message += `allowed ${entry.maxCount} but only happened ${entry.useCount} times`;
+ } else {
+ message += `happened ${entry.useCount} but max is ${entry.maxCount}`;
+ shouldPass = false;
+ }
+ ok(entry.useCount <= entry.maxCount, `${message} ${phase}`);
+
+ if (entry.useCount == 0 && !entry.ignoreIfUnused) {
+ ok(false, `unused known IPC entry ${phase}: ${entry.name}`);
+ shouldPass = false;
+ }
+ }
+ }
+
+ if (shouldPass) {
+ ok(shouldPass, "No unexpected sync IPC during startup");
+ } else {
+ const filename = "profile_startup_syncIPC.json";
+ let path = Services.env.get("MOZ_UPLOAD_DIR");
+ let profilePath = PathUtils.join(path, filename);
+ await IOUtils.writeJSON(profilePath, startupRecorder.data.profile);
+ ok(
+ false,
+ `Unexpected sync IPC behavior during startup; open the ${filename} ` +
+ "artifact in the Firefox Profiler to see what happened"
+ );
+ }
+});
diff --git a/browser/base/content/test/performance/browser_tabclose.js b/browser/base/content/test/performance/browser_tabclose.js
new file mode 100644
index 0000000000..961686587f
--- /dev/null
+++ b/browser/base/content/test/performance/browser_tabclose.js
@@ -0,0 +1,108 @@
+"use strict";
+
+/**
+ * WHOA THERE: We should never be adding new things to EXPECTED_REFLOWS.
+ * Instead of adding reflows to the list, you should be modifying your code to
+ * avoid the reflow.
+ *
+ * See https://firefox-source-docs.mozilla.org/performance/bestpractices.html
+ * for tips on how to do that.
+ */
+const EXPECTED_REFLOWS = [
+ /**
+ * Nothing here! Please don't add anything new!
+ */
+];
+
+/*
+ * This test ensures that there are no unexpected
+ * uninterruptible reflows when closing new tabs.
+ */
+add_task(async function () {
+ // Force-enable tab animations
+ gReduceMotionOverride = false;
+
+ await ensureNoPreloadedBrowser();
+ await disableFxaBadge();
+
+ // The test starts on about:blank and opens an about:blank
+ // tab which triggers opening the toolbar since
+ // ensureNoPreloadedBrowser sets AboutNewTab.newTabURL to about:blank.
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.toolbars.bookmarks.visibility", "never"]],
+ });
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser);
+ await TestUtils.waitForCondition(() => tab._fullyOpen);
+
+ let tabStripRect =
+ gBrowser.tabContainer.arrowScrollbox.getBoundingClientRect();
+ let newTabButtonRect =
+ gBrowser.tabContainer.newTabButton.getBoundingClientRect();
+ let inRange = (val, min, max) => min <= val && val <= max;
+
+ // Add a reflow observer and open a new tab.
+ await withPerfObserver(
+ async function () {
+ let switchDone = BrowserTestUtils.waitForEvent(window, "TabSwitchDone");
+ gBrowser.removeTab(tab, { animate: true });
+ await BrowserTestUtils.waitForEvent(tab, "TabAnimationEnd");
+ await switchDone;
+ },
+ {
+ expectedReflows: EXPECTED_REFLOWS,
+ frames: {
+ filter: rects =>
+ rects.filter(
+ r =>
+ !(
+ // We expect all changes to be within the tab strip.
+ (
+ r.y1 >= tabStripRect.top &&
+ r.y2 <= tabStripRect.bottom &&
+ r.x1 >= tabStripRect.left &&
+ r.x2 <= tabStripRect.right &&
+ // The closed tab should disappear at the same time as the previous
+ // tab gets selected, causing both tab areas to change color at once:
+ // this should be a single rect of the width of 2 tabs, and can
+ // include the '+' button if it starts its animation.
+ ((r.w > gBrowser.selectedTab.clientWidth &&
+ r.x2 <= newTabButtonRect.right) ||
+ // The '+' icon moves with an animation. At the end of the animation
+ // the former and new positions can touch each other causing the rect
+ // to have twice the icon's width.
+ (r.h == 13 && r.w <= 2 * 13 + kMaxEmptyPixels) ||
+ // We sometimes have a rect for the right most 2px of the '+' button.
+ (r.h == 2 && r.w == 2))
+ )
+ )
+ ),
+ exceptions: [
+ {
+ name:
+ "bug 1444886 - the next tab should be selected at the same time" +
+ " as the closed one disappears",
+ condition: r =>
+ // In tab strip
+ r.y1 >= tabStripRect.top &&
+ r.y2 <= tabStripRect.bottom &&
+ r.x1 >= tabStripRect.left &&
+ r.x2 <= tabStripRect.right &&
+ // Width of one tab plus tab separator(s)
+ inRange(gBrowser.selectedTab.clientWidth - r.w, 0, 2),
+ },
+ {
+ name: "bug 1446449 - spurious tab switch spinner",
+ condition: r =>
+ AppConstants.DEBUG &&
+ // In the content area
+ r.y1 >=
+ document.getElementById("appcontent").getBoundingClientRect()
+ .top,
+ },
+ ],
+ },
+ }
+ );
+ is(EXPECTED_REFLOWS.length, 0, "No reflows are expected when closing a tab");
+});
diff --git a/browser/base/content/test/performance/browser_tabclose_grow.js b/browser/base/content/test/performance/browser_tabclose_grow.js
new file mode 100644
index 0000000000..7ad43809cd
--- /dev/null
+++ b/browser/base/content/test/performance/browser_tabclose_grow.js
@@ -0,0 +1,91 @@
+"use strict";
+
+/**
+ * WHOA THERE: We should never be adding new things to EXPECTED_REFLOWS.
+ * Instead of adding reflows to the list, you should be modifying your code to
+ * avoid the reflow.
+ *
+ * See https://firefox-source-docs.mozilla.org/performance/bestpractices.html
+ * for tips on how to do that.
+ */
+const EXPECTED_REFLOWS = [
+ /**
+ * Nothing here! Please don't add anything new!
+ */
+];
+
+/*
+ * This test ensures that there are no unexpected
+ * uninterruptible reflows when closing a tab that will
+ * cause the existing tabs to grow bigger.
+ */
+add_task(async function () {
+ // Force-enable tab animations
+ gReduceMotionOverride = false;
+
+ await ensureNoPreloadedBrowser();
+ await disableFxaBadge();
+
+ // The test starts on about:blank and opens an about:blank
+ // tab which triggers opening the toolbar since
+ // ensureNoPreloadedBrowser sets AboutNewTab.newTabURL to about:blank.
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.toolbars.bookmarks.visibility", "never"]],
+ });
+
+ // At the time of writing, there are no reflows on tab closing with
+ // tab growth. Mochitest will fail if we have no assertions, so we
+ // add one here to make sure nobody adds any new ones.
+ Assert.equal(
+ EXPECTED_REFLOWS.length,
+ 0,
+ "We shouldn't have added any new expected reflows."
+ );
+
+ // Compute the number of tabs we can put into the strip without
+ // overflowing. If we remove one of the tabs, we know that the
+ // remaining tabs will grow to fill the remaining space in the
+ // tabstrip.
+ const TAB_COUNT_FOR_GROWTH = computeMaxTabCount();
+ await createTabs(TAB_COUNT_FOR_GROWTH);
+
+ let lastTab = gBrowser.tabs[gBrowser.tabs.length - 1];
+ await BrowserTestUtils.switchTab(gBrowser, lastTab);
+
+ let tabStripRect =
+ gBrowser.tabContainer.arrowScrollbox.getBoundingClientRect();
+
+ function isInTabStrip(r) {
+ return (
+ r.y1 >= tabStripRect.top &&
+ r.y2 <= tabStripRect.bottom &&
+ r.x1 >= tabStripRect.left &&
+ r.x2 <= tabStripRect.right &&
+ // It would make sense for each rect to have a width smaller than
+ // a tab (ie. tabstrip.width / tabcount), but tabs are small enough
+ // that they sometimes get reported in the same rect.
+ // So we accept up to the width of n-1 tabs.
+ r.w <=
+ (gBrowser.tabs.length - 1) *
+ Math.ceil(tabStripRect.width / gBrowser.tabs.length)
+ );
+ }
+
+ await withPerfObserver(
+ async function () {
+ let switchDone = BrowserTestUtils.waitForEvent(window, "TabSwitchDone");
+ let tab = gBrowser.tabs[gBrowser.tabs.length - 1];
+ gBrowser.removeTab(tab, { animate: true });
+ await BrowserTestUtils.waitForEvent(tab, "TabAnimationEnd");
+ await switchDone;
+ },
+ {
+ expectedReflows: EXPECTED_REFLOWS,
+ frames: {
+ filter: rects => rects.filter(r => !isInTabStrip(r)),
+ },
+ }
+ );
+
+ await removeAllButFirstTab();
+});
diff --git a/browser/base/content/test/performance/browser_tabdetach.js b/browser/base/content/test/performance/browser_tabdetach.js
new file mode 100644
index 0000000000..a860362f1f
--- /dev/null
+++ b/browser/base/content/test/performance/browser_tabdetach.js
@@ -0,0 +1,118 @@
+"use strict";
+
+/**
+ * WHOA THERE: We should never be adding new things to EXPECTED_REFLOWS. This
+ * list should slowly go away as we improve the performance of the front-end.
+ * Instead of adding more reflows to the list, you should be modifying your code
+ * to avoid the reflow.
+ *
+ * See https://firefox-source-docs.mozilla.org/performance/bestpractices.html
+ * for tips on how to do that.
+ */
+const EXPECTED_REFLOWS = [
+ {
+ stack: [
+ "clientX@chrome://browser/content/tabbrowser-tabs.js",
+ "startTabDrag@chrome://browser/content/tabbrowser-tabs.js",
+ "on_dragstart@chrome://browser/content/tabbrowser-tabs.js",
+ "handleEvent@chrome://browser/content/tabbrowser-tabs.js",
+ "synthesizeMouseAtPoint@chrome://mochikit/content/tests/SimpleTest/EventUtils.js",
+ "synthesizeMouse@chrome://mochikit/content/tests/SimpleTest/EventUtils.js",
+ "synthesizePlainDragAndDrop@chrome://mochikit/content/tests/SimpleTest/EventUtils.js",
+ ],
+ maxCount: 2,
+ },
+
+ {
+ stack: [
+ "startTabDrag@chrome://browser/content/tabbrowser-tabs.js",
+ "on_dragstart@chrome://browser/content/tabbrowser-tabs.js",
+ "handleEvent@chrome://browser/content/tabbrowser-tabs.js",
+ "synthesizeMouseAtPoint@chrome://mochikit/content/tests/SimpleTest/EventUtils.js",
+ "synthesizeMouse@chrome://mochikit/content/tests/SimpleTest/EventUtils.js",
+ "synthesizePlainDragAndDrop@chrome://mochikit/content/tests/SimpleTest/EventUtils.js",
+ ],
+ },
+];
+
+/**
+ * This test ensures that there are no unexpected uninterruptible reflows when
+ * detaching a tab via drag and drop. The first testcase tests a non-overflowed
+ * tab strip, and the second tests an overflowed one.
+ */
+
+add_task(async function test_detach_not_overflowed() {
+ await ensureNoPreloadedBrowser();
+ await createTabs(1);
+
+ // Make sure we didn't overflow, as expected
+ await TestUtils.waitForCondition(() => {
+ return !gBrowser.tabContainer.hasAttribute("overflow");
+ });
+
+ let win;
+ await withPerfObserver(
+ async function () {
+ win = await detachTab(gBrowser.tabs[1]);
+ },
+ {
+ expectedReflows: EXPECTED_REFLOWS,
+ // we are opening a whole new window, so there's no point in tracking
+ // rects being painted
+ frames: { filter: rects => [] },
+ }
+ );
+
+ await BrowserTestUtils.closeWindow(win);
+ win = null;
+});
+
+add_task(async function test_detach_overflowed() {
+ const TAB_COUNT_FOR_OVERFLOW = computeMaxTabCount();
+ await createTabs(TAB_COUNT_FOR_OVERFLOW + 1);
+
+ // Make sure we overflowed, as expected
+ await TestUtils.waitForCondition(() => {
+ return gBrowser.tabContainer.hasAttribute("overflow");
+ });
+
+ let win;
+ await withPerfObserver(
+ async function () {
+ win = await detachTab(
+ gBrowser.tabs[Math.floor(TAB_COUNT_FOR_OVERFLOW / 2)]
+ );
+ },
+ {
+ expectedReflows: EXPECTED_REFLOWS,
+ // we are opening a whole new window, so there's no point in tracking
+ // rects being painted
+ frames: { filter: rects => [] },
+ }
+ );
+
+ await BrowserTestUtils.closeWindow(win);
+ win = null;
+
+ await removeAllButFirstTab();
+});
+
+async function detachTab(tab) {
+ let newWindowPromise = BrowserTestUtils.waitForNewWindow();
+
+ await EventUtils.synthesizePlainDragAndDrop({
+ srcElement: tab,
+
+ // destElement is null because tab detaching happens due
+ // to a drag'n'drop on an invalid drop target.
+ destElement: null,
+
+ // don't move horizontally because that could cause a tab move
+ // animation, and there's code to prevent a tab detaching if
+ // the dragged tab is released while the animation is running.
+ stepX: 0,
+ stepY: 100,
+ });
+
+ return newWindowPromise;
+}
diff --git a/browser/base/content/test/performance/browser_tabopen.js b/browser/base/content/test/performance/browser_tabopen.js
new file mode 100644
index 0000000000..2457812cb7
--- /dev/null
+++ b/browser/base/content/test/performance/browser_tabopen.js
@@ -0,0 +1,201 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * WHOA THERE: We should never be adding new things to EXPECTED_REFLOWS.
+ * Instead of adding reflows to the list, you should be modifying your code to
+ * avoid the reflow.
+ *
+ * See https://firefox-source-docs.mozilla.org/performance/bestpractices.html
+ * for tips on how to do that.
+ */
+const EXPECTED_REFLOWS = [
+ /**
+ * Nothing here! Please don't add anything new!
+ */
+];
+
+/*
+ * This test ensures that there are no unexpected
+ * uninterruptible reflows when opening new tabs.
+ */
+add_task(async function () {
+ // Force-enable tab animations
+ gReduceMotionOverride = false;
+
+ // TODO (bug 1702653): Disable tab shadows for tests since the shadow
+ // can extend outside of the boundingClientRect. The tabRect will need
+ // to grow to include the shadow size.
+ gBrowser.tabContainer.setAttribute("noshadowfortests", "true");
+
+ await ensureNoPreloadedBrowser();
+ await disableFxaBadge();
+
+ // The test starts on about:blank and opens an about:blank
+ // tab which triggers opening the toolbar since
+ // ensureNoPreloadedBrowser sets AboutNewTab.newTabURL to about:blank.
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.toolbars.bookmarks.visibility", "never"]],
+ });
+
+ // Prepare the window to avoid flicker and reflow that's unrelated to our
+ // tab opening operation.
+ gURLBar.focus();
+
+ let tabStripRect =
+ gBrowser.tabContainer.arrowScrollbox.getBoundingClientRect();
+
+ let firstTabRect = gBrowser.selectedTab.getBoundingClientRect();
+ let tabPaddingStart = parseFloat(
+ getComputedStyle(gBrowser.selectedTab).paddingInlineStart
+ );
+ let minTabWidth = firstTabRect.width - 2 * tabPaddingStart;
+ let maxTabWidth = firstTabRect.width;
+ let firstTabLabelRect =
+ gBrowser.selectedTab.textLabel.getBoundingClientRect();
+ let newTabButtonRect = document
+ .getElementById("tabs-newtab-button")
+ .getBoundingClientRect();
+ let textBoxRect = gURLBar
+ .querySelector("moz-input-box")
+ .getBoundingClientRect();
+
+ let inRange = (val, min, max) => min <= val && val <= max;
+
+ info(`tabStripRect=${JSON.stringify(tabStripRect)}`);
+ info(`firstTabRect=${JSON.stringify(firstTabRect)}`);
+ info(`tabPaddingStart=${JSON.stringify(tabPaddingStart)}`);
+ info(`firstTabLabelRect=${JSON.stringify(firstTabLabelRect)}`);
+ info(`newTabButtonRect=${JSON.stringify(newTabButtonRect)}`);
+ info(`textBoxRect=${JSON.stringify(textBoxRect)}`);
+
+ let inTabStrip = function (r) {
+ return (
+ r.y1 >= tabStripRect.top &&
+ r.y2 <= tabStripRect.bottom &&
+ r.x1 >= tabStripRect.left &&
+ r.x2 <= tabStripRect.right
+ );
+ };
+
+ const kTabCloseIconWidth = 13;
+
+ let isExpectedChange = function (r) {
+ // We expect all changes to be within the tab strip.
+ if (!inTabStrip(r)) {
+ return false;
+ }
+
+ // The first tab should get deselected at the same time as the next tab
+ // starts appearing, so we should have one rect that includes the first tab
+ // but is wider.
+ if (
+ inRange(r.w, minTabWidth, maxTabWidth * 2) &&
+ inRange(r.x1, firstTabRect.x, firstTabRect.x + tabPaddingStart)
+ ) {
+ return true;
+ }
+
+ // The second tab gets painted several times due to tabopen animation.
+ let isSecondTabRect =
+ inRange(
+ r.x1,
+ // When the animation starts the tab close icon overflows.
+ // -1 for the border on Win7
+ firstTabRect.right - kTabCloseIconWidth - 1,
+ firstTabRect.right + firstTabRect.width
+ ) &&
+ r.x2 <
+ firstTabRect.right +
+ firstTabRect.width +
+ // Sometimes the '+' is in the same rect.
+ newTabButtonRect.width;
+
+ if (isSecondTabRect) {
+ return true;
+ }
+ // The '+' icon moves with an animation. At the end of the animation
+ // the former and new positions can touch each other causing the rect
+ // to have twice the icon's width.
+ if (
+ r.h == kTabCloseIconWidth &&
+ r.w <= 2 * kTabCloseIconWidth + kMaxEmptyPixels
+ ) {
+ return true;
+ }
+
+ // We sometimes have a rect for the right most 2px of the '+' button.
+ if (r.h == 2 && r.w == 2) {
+ return true;
+ }
+
+ // Same for the 'X' icon.
+ if (r.h == 10 && r.w <= 2 * 10) {
+ return true;
+ }
+
+ // Other changes are unexpected.
+ return false;
+ };
+
+ // Add a reflow observer and open a new tab.
+ await withPerfObserver(
+ async function () {
+ let switchDone = BrowserTestUtils.waitForEvent(window, "TabSwitchDone");
+ BrowserOpenTab();
+ await BrowserTestUtils.waitForEvent(
+ gBrowser.selectedTab,
+ "TabAnimationEnd"
+ );
+ await switchDone;
+ },
+ {
+ expectedReflows: EXPECTED_REFLOWS,
+ frames: {
+ filter: rects => rects.filter(r => !isExpectedChange(r)),
+ exceptions: [
+ {
+ name:
+ "bug 1446452 - the new tab should appear at the same time as the" +
+ " previous one gets deselected",
+ condition: r =>
+ // In tab strip
+ r.y1 >= tabStripRect.top &&
+ r.y2 <= tabStripRect.bottom &&
+ // Position and size of the first tab.
+ r.x1 == firstTabRect.left &&
+ inRange(
+ r.w,
+ firstTabRect.width - 1, // -1 as the border doesn't change
+ firstTabRect.width
+ ),
+ },
+ {
+ name: "the urlbar placeolder moves up and down by a few pixels",
+ // This seems to only happen on the second run in --verify
+ condition: r =>
+ r.x1 >= textBoxRect.left &&
+ r.x2 <= textBoxRect.right &&
+ r.y1 >= textBoxRect.top &&
+ r.y2 <= textBoxRect.bottom,
+ },
+ {
+ name: "bug 1477966 - the name of a deselected tab should appear immediately",
+ condition: r =>
+ AppConstants.platform == "macosx" &&
+ r.x1 >= firstTabLabelRect.x &&
+ r.x2 <= firstTabLabelRect.right &&
+ r.y1 >= firstTabLabelRect.y &&
+ r.y2 <= firstTabLabelRect.bottom,
+ },
+ ],
+ },
+ }
+ );
+
+ let switchDone = BrowserTestUtils.waitForEvent(window, "TabSwitchDone");
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ await switchDone;
+});
diff --git a/browser/base/content/test/performance/browser_tabopen_squeeze.js b/browser/base/content/test/performance/browser_tabopen_squeeze.js
new file mode 100644
index 0000000000..f92bdc2ea4
--- /dev/null
+++ b/browser/base/content/test/performance/browser_tabopen_squeeze.js
@@ -0,0 +1,100 @@
+"use strict";
+
+/**
+ * WHOA THERE: We should never be adding new things to EXPECTED_REFLOWS.
+ * Instead of adding reflows to the list, you should be modifying your code to
+ * avoid the reflow.
+ *
+ * See https://firefox-source-docs.mozilla.org/performance/bestpractices.html
+ * for tips on how to do that.
+ */
+const EXPECTED_REFLOWS = [
+ /**
+ * Nothing here! Please don't add anything new!
+ */
+];
+
+/*
+ * This test ensures that there are no unexpected
+ * uninterruptible reflows when opening a new tab that will
+ * cause the existing tabs to squeeze smaller.
+ */
+add_task(async function () {
+ // Force-enable tab animations
+ gReduceMotionOverride = false;
+
+ await ensureNoPreloadedBrowser();
+ await disableFxaBadge();
+
+ // The test starts on about:blank and opens an about:blank
+ // tab which triggers opening the toolbar since
+ // ensureNoPreloadedBrowser sets AboutNewTab.newTabURL to about:blank.
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.toolbars.bookmarks.visibility", "never"]],
+ });
+
+ // Compute the number of tabs we can put into the strip without
+ // overflowing, and remove one, so that we can create
+ // TAB_COUNT_FOR_SQUEEE tabs, and then one more, which should
+ // cause the tab to squeeze to a smaller size rather than overflow.
+ const TAB_COUNT_FOR_SQUEEZE = computeMaxTabCount() - 1;
+
+ await createTabs(TAB_COUNT_FOR_SQUEEZE);
+
+ gURLBar.focus();
+
+ let tabStripRect =
+ gBrowser.tabContainer.arrowScrollbox.getBoundingClientRect();
+ let textBoxRect = gURLBar
+ .querySelector("moz-input-box")
+ .getBoundingClientRect();
+
+ await withPerfObserver(
+ async function () {
+ let switchDone = BrowserTestUtils.waitForEvent(window, "TabSwitchDone");
+ BrowserOpenTab();
+ await BrowserTestUtils.waitForEvent(
+ gBrowser.selectedTab,
+ "TabAnimationEnd"
+ );
+ await switchDone;
+ },
+ {
+ expectedReflows: EXPECTED_REFLOWS,
+ frames: {
+ filter: rects =>
+ rects.filter(
+ r =>
+ !(
+ // We expect plenty of changed rects within the tab strip.
+ (
+ r.y1 >= tabStripRect.top &&
+ r.y2 <= tabStripRect.bottom &&
+ r.x1 >= tabStripRect.left &&
+ r.x2 <= tabStripRect.right &&
+ // It would make sense for each rect to have a width smaller than
+ // a tab (ie. tabstrip.width / tabcount), but tabs are small enough
+ // that they sometimes get reported in the same rect.
+ // So we accept up to the width of n-1 tabs.
+ r.w <=
+ (gBrowser.tabs.length - 1) *
+ Math.ceil(tabStripRect.width / gBrowser.tabs.length)
+ )
+ )
+ ),
+ exceptions: [
+ {
+ name: "the urlbar placeolder moves up and down by a few pixels",
+ condition: r =>
+ r.x1 >= textBoxRect.left &&
+ r.x2 <= textBoxRect.right &&
+ r.y1 >= textBoxRect.top &&
+ r.y2 <= textBoxRect.bottom,
+ },
+ ],
+ },
+ }
+ );
+
+ await removeAllButFirstTab();
+});
diff --git a/browser/base/content/test/performance/browser_tabstrip_overflow_underflow.js b/browser/base/content/test/performance/browser_tabstrip_overflow_underflow.js
new file mode 100644
index 0000000000..1fd33ed836
--- /dev/null
+++ b/browser/base/content/test/performance/browser_tabstrip_overflow_underflow.js
@@ -0,0 +1,200 @@
+"use strict";
+
+/**
+ * WHOA THERE: We should never be adding new things to EXPECTED_*_REFLOWS.
+ * This is a (now empty) list of known reflows.
+ * Instead of adding more reflows to the lists, you should be modifying your
+ * code to avoid the reflow.
+ *
+ * See https://firefox-source-docs.mozilla.org/performance/bestpractices.html
+ * for tips on how to do that.
+ */
+const EXPECTED_OVERFLOW_REFLOWS = [
+ /**
+ * Nothing here! Please don't add anything new!
+ */
+];
+
+const EXPECTED_UNDERFLOW_REFLOWS = [
+ /**
+ * Nothing here! Please don't add anything new!
+ */
+];
+
+/**
+ * This test ensures that there are no unexpected uninterruptible reflows when
+ * opening a new tab that will cause the existing tabs to overflow and the tab
+ * strip to become scrollable. It also tests that there are no unexpected
+ * uninterruptible reflows when closing that tab, which causes the tab strip to
+ * underflow.
+ */
+add_task(async function () {
+ // Force-enable tab animations
+ gReduceMotionOverride = false;
+
+ await ensureNoPreloadedBrowser();
+
+ // The test starts on about:blank and opens an about:blank
+ // tab which triggers opening the toolbar since
+ // ensureNoPreloadedBrowser sets AboutNewTab.newTabURL to about:blank.
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.toolbars.bookmarks.visibility", "never"]],
+ });
+
+ const TAB_COUNT_FOR_OVERFLOW = computeMaxTabCount();
+
+ await createTabs(TAB_COUNT_FOR_OVERFLOW);
+
+ gURLBar.focus();
+ await disableFxaBadge();
+
+ let tabStripRect =
+ gBrowser.tabContainer.arrowScrollbox.getBoundingClientRect();
+ let textBoxRect = gURLBar
+ .querySelector("moz-input-box")
+ .getBoundingClientRect();
+
+ let ignoreTabstripRects = {
+ filter: rects =>
+ rects.filter(
+ r =>
+ !(
+ // We expect plenty of changed rects within the tab strip.
+ (
+ r.y1 >= tabStripRect.top &&
+ r.y2 <= tabStripRect.bottom &&
+ r.x1 >= tabStripRect.left &&
+ r.x2 <= tabStripRect.right
+ )
+ )
+ ),
+ exceptions: [
+ {
+ name: "the urlbar placeolder moves up and down by a few pixels",
+ condition: r =>
+ r.x1 >= textBoxRect.left &&
+ r.x2 <= textBoxRect.right &&
+ r.y1 >= textBoxRect.top &&
+ r.y2 <= textBoxRect.bottom,
+ },
+ {
+ name: "bug 1446449 - spurious tab switch spinner",
+ condition: r =>
+ // In the content area
+ r.y1 >=
+ document.getElementById("appcontent").getBoundingClientRect().top,
+ },
+ ],
+ };
+
+ await withPerfObserver(
+ async function () {
+ let switchDone = BrowserTestUtils.waitForEvent(window, "TabSwitchDone");
+ BrowserOpenTab();
+ await BrowserTestUtils.waitForEvent(
+ gBrowser.selectedTab,
+ "TabAnimationEnd"
+ );
+ await switchDone;
+ await TestUtils.waitForCondition(() => {
+ return gBrowser.tabContainer.arrowScrollbox.hasAttribute(
+ "scrolledtoend"
+ );
+ });
+ },
+ { expectedReflows: EXPECTED_OVERFLOW_REFLOWS, frames: ignoreTabstripRects }
+ );
+
+ Assert.ok(
+ gBrowser.tabContainer.hasAttribute("overflow"),
+ "Tabs should now be overflowed."
+ );
+
+ // Now test that opening and closing a tab while overflowed doesn't cause
+ // us to reflow.
+ await withPerfObserver(
+ async function () {
+ let switchDone = BrowserTestUtils.waitForEvent(window, "TabSwitchDone");
+ BrowserOpenTab();
+ await switchDone;
+ await TestUtils.waitForCondition(() => {
+ return gBrowser.tabContainer.arrowScrollbox.hasAttribute(
+ "scrolledtoend"
+ );
+ });
+ },
+ { expectedReflows: [], frames: ignoreTabstripRects }
+ );
+
+ await withPerfObserver(
+ async function () {
+ let switchDone = BrowserTestUtils.waitForEvent(window, "TabSwitchDone");
+ BrowserTestUtils.removeTab(gBrowser.selectedTab, { animate: true });
+ await switchDone;
+ },
+ { expectedReflows: [], frames: ignoreTabstripRects }
+ );
+
+ // At this point, we have an overflowed tab strip, and we've got the last tab
+ // selected. This should mean that the first tab is scrolled out of view.
+ // Let's test that we don't reflow when switching to that first tab.
+ let lastTab = gBrowser.selectedTab;
+ let arrowScrollbox = gBrowser.tabContainer.arrowScrollbox;
+
+ // First, we'll check that the first tab is actually scrolled
+ // at least partially out of view.
+ Assert.ok(
+ arrowScrollbox.scrollPosition > 0,
+ "First tab should be partially scrolled out of view."
+ );
+
+ // Now switch to the first tab. We shouldn't flush layout at all.
+ await withPerfObserver(
+ async function () {
+ let firstTab = gBrowser.tabs[0];
+ await BrowserTestUtils.switchTab(gBrowser, firstTab);
+ await TestUtils.waitForCondition(() => {
+ return gBrowser.tabContainer.arrowScrollbox.hasAttribute(
+ "scrolledtostart"
+ );
+ });
+ },
+ { expectedReflows: [], frames: ignoreTabstripRects }
+ );
+
+ // Okay, now close the last tab. The tabstrip should stay overflowed, but removing
+ // one more after that should underflow it.
+ BrowserTestUtils.removeTab(lastTab);
+
+ Assert.ok(
+ gBrowser.tabContainer.hasAttribute("overflow"),
+ "Tabs should still be overflowed."
+ );
+
+ // Depending on the size of the window, it might take one or more tab
+ // removals to put the tab strip out of the overflow state, so we'll just
+ // keep testing removals until that occurs.
+ while (gBrowser.tabContainer.hasAttribute("overflow")) {
+ lastTab = gBrowser.tabs[gBrowser.tabs.length - 1];
+ if (gBrowser.selectedTab !== lastTab) {
+ await BrowserTestUtils.switchTab(gBrowser, lastTab);
+ }
+
+ // ... and make sure we don't flush layout when closing it, and exiting
+ // the overflowed state.
+ await withPerfObserver(
+ async function () {
+ let switchDone = BrowserTestUtils.waitForEvent(window, "TabSwitchDone");
+ BrowserTestUtils.removeTab(lastTab, { animate: true });
+ await switchDone;
+ await TestUtils.waitForCondition(() => !lastTab.isConnected);
+ },
+ {
+ expectedReflows: EXPECTED_UNDERFLOW_REFLOWS,
+ frames: ignoreTabstripRects,
+ }
+ );
+ }
+
+ await removeAllButFirstTab();
+});
diff --git a/browser/base/content/test/performance/browser_tabswitch.js b/browser/base/content/test/performance/browser_tabswitch.js
new file mode 100644
index 0000000000..bbbbac3a21
--- /dev/null
+++ b/browser/base/content/test/performance/browser_tabswitch.js
@@ -0,0 +1,123 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * WHOA THERE: We should never be adding new things to EXPECTED_REFLOWS.
+ * Instead of adding reflows to the list, you should be modifying your code to
+ * avoid the reflow.
+ *
+ * See https://firefox-source-docs.mozilla.org/performance/bestpractices.html
+ * for tips on how to do that.
+ */
+const EXPECTED_REFLOWS = [
+ /**
+ * Nothing here! Please don't add anything new!
+ */
+];
+
+/*
+ * This test ensures that there are no unexpected
+ * uninterruptible reflows when switching between two
+ * tabs that are both fully visible.
+ */
+add_task(async function () {
+ // TODO (bug 1702653): Disable tab shadows for tests since the shadow
+ // can extend outside of the boundingClientRect. The tabRect will need
+ // to grow to include the shadow size.
+ gBrowser.tabContainer.setAttribute("noshadowfortests", "true");
+
+ await ensureNoPreloadedBrowser();
+ await disableFxaBadge();
+
+ // The test starts on about:blank and opens an about:blank
+ // tab which triggers opening the toolbar since
+ // ensureNoPreloadedBrowser sets AboutNewTab.newTabURL to about:blank.
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.toolbars.bookmarks.visibility", "never"]],
+ });
+
+ // At the time of writing, there are no reflows on simple tab switching.
+ // Mochitest will fail if we have no assertions, so we add one here
+ // to make sure nobody adds any new ones.
+ Assert.equal(
+ EXPECTED_REFLOWS.length,
+ 0,
+ "We shouldn't have added any new expected reflows."
+ );
+
+ let origTab = gBrowser.selectedTab;
+ let firstSwitchDone = BrowserTestUtils.waitForEvent(window, "TabSwitchDone");
+ let otherTab = await BrowserTestUtils.openNewForegroundTab(gBrowser);
+ await firstSwitchDone;
+
+ let tabStripRect =
+ gBrowser.tabContainer.arrowScrollbox.getBoundingClientRect();
+ let firstTabRect = origTab.getBoundingClientRect();
+ let tabPaddingStart = parseFloat(
+ getComputedStyle(gBrowser.selectedTab).paddingInlineStart
+ );
+ let minTabWidth = firstTabRect.width - 2 * tabPaddingStart;
+ let maxTabWidth = firstTabRect.width;
+ let inRange = (val, min, max) => min <= val && val <= max;
+
+ await withPerfObserver(
+ async function () {
+ let switchDone = BrowserTestUtils.waitForEvent(window, "TabSwitchDone");
+ gBrowser.selectedTab = origTab;
+ await switchDone;
+ },
+ {
+ expectedReflows: EXPECTED_REFLOWS,
+ frames: {
+ filter: rects =>
+ rects.filter(
+ r =>
+ !(
+ // We expect all changes to be within the tab strip.
+ (
+ r.y1 >= tabStripRect.top &&
+ r.y2 <= tabStripRect.bottom &&
+ r.x1 >= tabStripRect.left &&
+ r.x2 <= tabStripRect.right &&
+ // The tab selection changes between 2 adjacent tabs, so we expect
+ // both to change color at once: this should be a single rect of the
+ // width of 2 tabs.
+ inRange(
+ r.w,
+ minTabWidth - 1, // -1 for the border on Win7
+ maxTabWidth * 2
+ )
+ )
+ )
+ ),
+ exceptions: [
+ {
+ name:
+ "bug 1446454 - the border between tabs should be painted at" +
+ " the same time as the tab switch",
+ condition: r =>
+ // In tab strip
+ r.y1 >= tabStripRect.top &&
+ r.y2 <= tabStripRect.bottom &&
+ // 1px border, 1px before the end of the first tab.
+ r.w == 1 &&
+ r.x1 == firstTabRect.right - 1,
+ },
+ {
+ name: "bug 1446449 - spurious tab switch spinner",
+ condition: r =>
+ AppConstants.DEBUG &&
+ // In the content area
+ r.y1 >=
+ document.getElementById("appcontent").getBoundingClientRect()
+ .top,
+ },
+ ],
+ },
+ }
+ );
+
+ BrowserTestUtils.removeTab(otherTab);
+});
diff --git a/browser/base/content/test/performance/browser_toolbariconcolor_restyles.js b/browser/base/content/test/performance/browser_toolbariconcolor_restyles.js
new file mode 100644
index 0000000000..890c8f3c80
--- /dev/null
+++ b/browser/base/content/test/performance/browser_toolbariconcolor_restyles.js
@@ -0,0 +1,65 @@
+"use strict";
+
+/**
+ * Ensure redundant style flushes are not triggered when switching between windows
+ */
+add_task(async function test_toolbar_element_restyles_on_activation() {
+ let restyles = {
+ win1: {},
+ win2: {},
+ };
+
+ // create a window and snapshot the elementsStyled
+ let win1 = await BrowserTestUtils.openNewBrowserWindow();
+ await new Promise(resolve => waitForFocus(resolve, win1));
+
+ // create a 2nd window and snapshot the elementsStyled
+ let win2 = await BrowserTestUtils.openNewBrowserWindow();
+ await new Promise(resolve => waitForFocus(resolve, win2));
+
+ // (De)-activate both windows once before we take a measurement. The first
+ // (de-)activation may flush styles, after that the style data should be
+ // cached.
+ win1.focus();
+ win2.focus();
+
+ // Flush any pending styles before we take a measurement.
+ win1.getComputedStyle(win1.document.firstElementChild);
+ win2.getComputedStyle(win2.document.firstElementChild);
+
+ // Clear the focused element from each window so that when
+ // we raise them, the focus of the element doesn't cause an
+ // unrelated style flush.
+ Services.focus.clearFocus(win1);
+ Services.focus.clearFocus(win2);
+
+ let utils1 = SpecialPowers.getDOMWindowUtils(win1);
+ restyles.win1.initial = utils1.restyleGeneration;
+
+ let utils2 = SpecialPowers.getDOMWindowUtils(win2);
+ restyles.win2.initial = utils2.restyleGeneration;
+
+ // switch back to 1st window, and snapshot elementsStyled
+ win1.focus();
+ restyles.win1.activate = utils1.restyleGeneration;
+ restyles.win2.deactivate = utils2.restyleGeneration;
+
+ // switch back to 2nd window, and snapshot elementsStyled
+ win2.focus();
+ restyles.win2.activate = utils2.restyleGeneration;
+ restyles.win1.deactivate = utils1.restyleGeneration;
+
+ is(
+ restyles.win1.activate - restyles.win1.deactivate,
+ 0,
+ "No elements restyled when re-activating/deactivating a window"
+ );
+ is(
+ restyles.win2.activate - restyles.win2.deactivate,
+ 0,
+ "No elements restyled when re-activating/deactivating a window"
+ );
+
+ await BrowserTestUtils.closeWindow(win1);
+ await BrowserTestUtils.closeWindow(win2);
+});
diff --git a/browser/base/content/test/performance/browser_urlbar_keyed_search.js b/browser/base/content/test/performance/browser_urlbar_keyed_search.js
new file mode 100644
index 0000000000..a44e5d4822
--- /dev/null
+++ b/browser/base/content/test/performance/browser_urlbar_keyed_search.js
@@ -0,0 +1,27 @@
+"use strict";
+
+// This tests searching in the urlbar (a.k.a. the quantumbar).
+
+/**
+ * WHOA THERE: We should never be adding new things to
+ * EXPECTED_REFLOWS_FIRST_OPEN or EXPECTED_REFLOWS_SECOND_OPEN.
+ * Instead of adding reflows to these lists, you should be modifying your code
+ * to avoid the reflow.
+ *
+ * See https://firefox-source-docs.mozilla.org/performance/bestpractices.html
+ * for tips on how to do that.
+ */
+
+/* These reflows happen only the first time the panel opens. */
+const EXPECTED_REFLOWS_FIRST_OPEN = [];
+
+/* These reflows happen every time the panel opens. */
+const EXPECTED_REFLOWS_SECOND_OPEN = [];
+
+add_task(async function quantumbar() {
+ await runUrlbarTest(
+ true,
+ EXPECTED_REFLOWS_FIRST_OPEN,
+ EXPECTED_REFLOWS_SECOND_OPEN
+ );
+});
diff --git a/browser/base/content/test/performance/browser_urlbar_search.js b/browser/base/content/test/performance/browser_urlbar_search.js
new file mode 100644
index 0000000000..35961c641f
--- /dev/null
+++ b/browser/base/content/test/performance/browser_urlbar_search.js
@@ -0,0 +1,27 @@
+"use strict";
+
+// This tests searching in the urlbar (a.k.a. the quantumbar).
+
+/**
+ * WHOA THERE: We should never be adding new things to
+ * EXPECTED_REFLOWS_FIRST_OPEN or EXPECTED_REFLOWS_SECOND_OPEN.
+ * Instead of adding reflows to these lists, you should be modifying your code
+ * to avoid the reflow.
+ *
+ * See https://firefox-source-docs.mozilla.org/performance/bestpractices.html
+ * for tips on how to do that.
+ */
+
+/* These reflows happen only the first time the panel opens. */
+const EXPECTED_REFLOWS_FIRST_OPEN = [];
+
+/* These reflows happen every time the panel opens. */
+const EXPECTED_REFLOWS_SECOND_OPEN = [];
+
+add_task(async function quantumbar() {
+ await runUrlbarTest(
+ false,
+ EXPECTED_REFLOWS_FIRST_OPEN,
+ EXPECTED_REFLOWS_SECOND_OPEN
+ );
+});
diff --git a/browser/base/content/test/performance/browser_vsync_accessibility.js b/browser/base/content/test/performance/browser_vsync_accessibility.js
new file mode 100644
index 0000000000..64e3dc0b85
--- /dev/null
+++ b/browser/base/content/test/performance/browser_vsync_accessibility.js
@@ -0,0 +1,20 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function () {
+ await TestUtils.waitForCondition(
+ () => !ChromeUtils.vsyncEnabled(),
+ "wait for vsync to be disabled at the start of the test"
+ );
+ Assert.ok(!ChromeUtils.vsyncEnabled(), "vsync should be disabled");
+ Cc["@mozilla.org/accessibilityService;1"].getService(
+ Ci.nsIAccessibilityService
+ );
+ await TestUtils.waitForCondition(
+ () => !ChromeUtils.vsyncEnabled(),
+ "wait for vsync to be disabled after initializing the accessibility service"
+ );
+ Assert.ok(!ChromeUtils.vsyncEnabled(), "vsync should still be disabled");
+});
diff --git a/browser/base/content/test/performance/browser_window_resize.js b/browser/base/content/test/performance/browser_window_resize.js
new file mode 100644
index 0000000000..0838ae9f8f
--- /dev/null
+++ b/browser/base/content/test/performance/browser_window_resize.js
@@ -0,0 +1,132 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * WHOA THERE: We should never be adding new things to EXPECTED_REFLOWS.
+ * Instead of adding reflows to the list, you should be modifying your code to
+ * avoid the reflow.
+ *
+ * See https://firefox-source-docs.mozilla.org/performance/bestpractices.html
+ * for tips on how to do that.
+ */
+const EXPECTED_REFLOWS = [
+ /**
+ * Nothing here! Please don't add anything new!
+ */
+];
+
+const gToolbar = document.getElementById("PersonalToolbar");
+
+/**
+ * Sets the visibility state on the Bookmarks Toolbar, and
+ * waits for it to transition to fully visible.
+ *
+ * @param visible (bool)
+ * Whether or not the bookmarks toolbar should be made visible.
+ * @returns Promise
+ */
+async function toggleBookmarksToolbar(visible) {
+ let transitionPromise = BrowserTestUtils.waitForEvent(
+ gToolbar,
+ "transitionend",
+ e => e.propertyName == "max-height"
+ );
+
+ setToolbarVisibility(gToolbar, visible);
+ await transitionPromise;
+}
+
+/**
+ * Resizes a browser window to a particular width and height, and
+ * waits for it to reach a "steady state" with respect to its overflowing
+ * toolbars.
+ * @param win (browser window)
+ * The window to resize.
+ * @param width (int)
+ * The width to resize the window to.
+ * @param height (int)
+ * The height to resize the window to.
+ * @returns Promise
+ */
+async function resizeWindow(win, width, height) {
+ let toolbarEvent = BrowserTestUtils.waitForEvent(
+ win,
+ "BookmarksToolbarVisibilityUpdated"
+ );
+ let resizeEvent = BrowserTestUtils.waitForEvent(win, "resize");
+ win.windowUtils.ensureDirtyRootFrame();
+ win.resizeTo(width, height);
+ await resizeEvent;
+ await toolbarEvent;
+}
+
+/*
+ * This test ensures that there are no unexpected
+ * uninterruptible reflows when resizing windows.
+ */
+add_task(async function () {
+ const BOOKMARKS_COUNT = 150;
+ const STARTING_WIDTH = 600;
+ const STARTING_HEIGHT = 400;
+ const SMALL_WIDTH = 150;
+ const SMALL_HEIGHT = 150;
+
+ await PlacesUtils.bookmarks.eraseEverything();
+
+ // Add a bunch of bookmarks to display in the Bookmarks toolbar
+ await PlacesUtils.bookmarks.insertTree({
+ guid: PlacesUtils.bookmarks.toolbarGuid,
+ children: Array(BOOKMARKS_COUNT)
+ .fill("")
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ .map((_, i) => ({ url: `http://test.places.${i}/` })),
+ });
+
+ let wasCollapsed = gToolbar.collapsed;
+ Assert.ok(wasCollapsed, "The toolbar is collapsed by default");
+ if (wasCollapsed) {
+ let promiseReady = BrowserTestUtils.waitForEvent(
+ gToolbar,
+ "BookmarksToolbarVisibilityUpdated"
+ );
+ await toggleBookmarksToolbar(true);
+ await promiseReady;
+ }
+
+ registerCleanupFunction(async () => {
+ if (wasCollapsed) {
+ await toggleBookmarksToolbar(false);
+ }
+ await PlacesUtils.bookmarks.eraseEverything();
+ await PlacesUtils.history.clear();
+ });
+
+ let win = await prepareSettledWindow();
+
+ if (
+ win.screen.availWidth < STARTING_WIDTH ||
+ win.screen.availHeight < STARTING_HEIGHT
+ ) {
+ Assert.ok(
+ false,
+ "This test is running on too small a display - " +
+ `(${STARTING_WIDTH}x${STARTING_HEIGHT} min)`
+ );
+ return;
+ }
+
+ await resizeWindow(win, STARTING_WIDTH, STARTING_HEIGHT);
+
+ await withPerfObserver(
+ async function () {
+ await resizeWindow(win, SMALL_WIDTH, SMALL_HEIGHT);
+ await resizeWindow(win, STARTING_WIDTH, STARTING_HEIGHT);
+ },
+ { expectedReflows: EXPECTED_REFLOWS, frames: { filter: () => [] } },
+ win
+ );
+
+ await BrowserTestUtils.closeWindow(win);
+});
diff --git a/browser/base/content/test/performance/browser_windowclose.js b/browser/base/content/test/performance/browser_windowclose.js
new file mode 100644
index 0000000000..26eecf9539
--- /dev/null
+++ b/browser/base/content/test/performance/browser_windowclose.js
@@ -0,0 +1,58 @@
+"use strict";
+
+/**
+ * WHOA THERE: We should never be adding new things to EXPECTED_REFLOWS.
+ * Instead of adding reflows to the list, you should be modifying your code to
+ * avoid the reflow.
+ *
+ * See https://firefox-source-docs.mozilla.org/performance/bestpractices.html
+ * for tips on how to do that.
+ */
+const EXPECTED_REFLOWS = [
+ /**
+ * Nothing here! Please don't add anything new!
+ */
+];
+
+/**
+ * This test ensures that there are no unexpected
+ * uninterruptible reflows when closing windows. When the
+ * window is closed, the test waits until the original window
+ * has activated.
+ */
+add_task(async function () {
+ // Ensure that this browser window starts focused. This seems to be
+ // necessary to avoid intermittent failures when running this test
+ // on repeat.
+ await new Promise(resolve => {
+ waitForFocus(resolve, window);
+ });
+
+ let win = await BrowserTestUtils.openNewBrowserWindow();
+ await new Promise(resolve => {
+ waitForFocus(resolve, win);
+ });
+
+ // At the time of writing, there are no reflows on window closing.
+ // Mochitest will fail if we have no assertions, so we add one here
+ // to make sure nobody adds any new ones.
+ Assert.equal(
+ EXPECTED_REFLOWS.length,
+ 0,
+ "We shouldn't have added any new expected reflows for window close."
+ );
+
+ await withPerfObserver(
+ async function () {
+ let promiseOrigBrowserFocused = TestUtils.waitForCondition(() => {
+ return Services.focus.activeWindow == window;
+ });
+ await BrowserTestUtils.closeWindow(win);
+ await promiseOrigBrowserFocused;
+ },
+ {
+ expectedReflows: EXPECTED_REFLOWS,
+ },
+ win
+ );
+});
diff --git a/browser/base/content/test/performance/browser_windowopen.js b/browser/base/content/test/performance/browser_windowopen.js
new file mode 100644
index 0000000000..ebc924c3c6
--- /dev/null
+++ b/browser/base/content/test/performance/browser_windowopen.js
@@ -0,0 +1,182 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * WHOA THERE: We should never be adding new things to EXPECTED_REFLOWS.
+ * Instead of adding reflows to the list, you should be modifying your code to
+ * avoid the reflow.
+ *
+ * See https://firefox-source-docs.mozilla.org/performance/bestpractices.html
+ * for tips on how to do that.
+ */
+const EXPECTED_REFLOWS = [
+ /**
+ * Nothing here! Please don't add anything new!
+ */
+];
+
+// We'll assume the changes we are seeing are due to this focus change if
+// there are at least 5 areas that changed near the top of the screen, or if
+// the toolbar background is involved on OSX, but will only ignore this once.
+function isLikelyFocusChange(rects) {
+ if (rects.length > 5 && rects.every(r => r.y2 < 100)) {
+ return true;
+ }
+ if (
+ Services.appinfo.OS == "Darwin" &&
+ rects.length == 2 &&
+ rects.every(r => r.y1 == 0 && r.h == 33)
+ ) {
+ return true;
+ }
+ return false;
+}
+
+/*
+ * This test ensures that there are no unexpected
+ * uninterruptible reflows or flickering areas when opening new windows.
+ */
+add_task(async function () {
+ // Flushing all caches helps to ensure that we get consistent
+ // behaviour when opening a new window, even if windows have been
+ // opened in previous tests.
+ Services.obs.notifyObservers(null, "startupcache-invalidate");
+ Services.obs.notifyObservers(null, "chrome-flush-caches");
+
+ let bookmarksToolbarRect = await getBookmarksToolbarRect();
+
+ let win = window.openDialog(
+ AppConstants.BROWSER_CHROME_URL,
+ "_blank",
+ "chrome,all,dialog=no,remote,suppressanimation",
+ "about:home"
+ );
+
+ await disableFxaBadge();
+
+ let alreadyFocused = false;
+ let inRange = (val, min, max) => min <= val && val <= max;
+ let expectations = {
+ expectedReflows: EXPECTED_REFLOWS,
+ frames: {
+ filter(rects, frame, previousFrame) {
+ // The first screenshot we get in OSX / Windows shows an unfocused browser
+ // window for some reason. See bug 1445161.
+ if (!alreadyFocused && isLikelyFocusChange(rects)) {
+ alreadyFocused = true;
+ todo(
+ false,
+ "bug 1445161 - the window should be focused at first paint, " +
+ rects.toSource()
+ );
+ return [];
+ }
+
+ return rects;
+ },
+ exceptions: [
+ {
+ name: "bug 1421463 - reload toolbar icon shouldn't flicker",
+ condition: r =>
+ inRange(r.h, 13, 14) &&
+ inRange(r.w, 14, 16) && // icon size
+ inRange(r.y1, 40, 80) && // in the toolbar
+ inRange(r.x1, 65, 100), // near the left side of the screen
+ },
+ {
+ name: "bug 1555842 - the urlbar shouldn't flicker",
+ condition: r => {
+ let inputFieldRect = win.gURLBar.inputField.getBoundingClientRect();
+
+ return (
+ (!AppConstants.DEBUG ||
+ (AppConstants.platform == "linux" && AppConstants.ASAN)) &&
+ r.x1 >= inputFieldRect.left &&
+ r.x2 <= inputFieldRect.right &&
+ r.y1 >= inputFieldRect.top &&
+ r.y2 <= inputFieldRect.bottom
+ );
+ },
+ },
+ {
+ name: "Initial bookmark icon appearing after startup",
+ condition: r =>
+ r.w == 16 &&
+ r.h == 16 && // icon size
+ inRange(
+ r.y1,
+ bookmarksToolbarRect.top,
+ bookmarksToolbarRect.top + bookmarksToolbarRect.height / 2
+ ) && // in the toolbar
+ inRange(r.x1, 11, 13), // very close to the left of the screen
+ },
+ {
+ // Note that the length and x values here are a bit weird because on
+ // some fonts, we appear to detect the two words separately.
+ name: "Initial bookmark text ('Getting Started' or 'Get Involved') appearing after startup",
+ condition: r =>
+ inRange(r.w, 25, 120) && // length of text
+ inRange(r.h, 9, 15) && // height of text
+ inRange(
+ r.y1,
+ bookmarksToolbarRect.top,
+ bookmarksToolbarRect.top + bookmarksToolbarRect.height / 2
+ ) && // in the toolbar
+ inRange(r.x1, 30, 90), // close to the left of the screen
+ },
+ ],
+ },
+ };
+
+ await withPerfObserver(
+ async function () {
+ // Avoid showing the remotecontrol UI.
+ await new Promise(resolve => {
+ win.addEventListener(
+ "DOMContentLoaded",
+ () => {
+ delete win.Marionette;
+ win.Marionette = { running: false };
+ resolve();
+ },
+ { once: true }
+ );
+ });
+
+ await TestUtils.topicObserved(
+ "browser-delayed-startup-finished",
+ subject => subject == win
+ );
+
+ let promises = [
+ BrowserTestUtils.firstBrowserLoaded(win, false),
+ BrowserTestUtils.browserStopped(
+ win.gBrowser.selectedBrowser,
+ "about:home"
+ ),
+ ];
+
+ await Promise.all(promises);
+
+ await new Promise(resolve => {
+ // 10 is an arbitrary value here, it needs to be at least 2 to avoid
+ // races with code initializing itself using idle callbacks.
+ (function waitForIdle(count = 10) {
+ if (!count) {
+ resolve();
+ return;
+ }
+ Services.tm.idleDispatchToMainThread(() => {
+ waitForIdle(count - 1);
+ });
+ })();
+ });
+ },
+ expectations,
+ win
+ );
+
+ await BrowserTestUtils.closeWindow(win);
+});
diff --git a/browser/base/content/test/performance/file_empty.html b/browser/base/content/test/performance/file_empty.html
new file mode 100644
index 0000000000..865879c583
--- /dev/null
+++ b/browser/base/content/test/performance/file_empty.html
@@ -0,0 +1 @@
+<!-- this file intentionally left blank -->
diff --git a/browser/base/content/test/performance/head.js b/browser/base/content/test/performance/head.js
new file mode 100644
index 0000000000..bcfe4ba9be
--- /dev/null
+++ b/browser/base/content/test/performance/head.js
@@ -0,0 +1,971 @@
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ PerfTestHelpers: "resource://testing-common/PerfTestHelpers.sys.mjs",
+ PlacesTestUtils: "resource://testing-common/PlacesTestUtils.sys.mjs",
+ PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
+ UrlbarTestUtils: "resource://testing-common/UrlbarTestUtils.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ AboutNewTab: "resource:///modules/AboutNewTab.jsm",
+});
+
+/**
+ * This function can be called if the test needs to trigger frame dirtying
+ * outside of the normal mechanism.
+ *
+ * @param win (dom window)
+ * The window in which the frame tree needs to be marked as dirty.
+ */
+function dirtyFrame(win) {
+ let dwu = win.windowUtils;
+ try {
+ dwu.ensureDirtyRootFrame();
+ } catch (e) {
+ // If this fails, we should probably make note of it, but it's not fatal.
+ info("Note: ensureDirtyRootFrame threw an exception:" + e);
+ }
+}
+
+/**
+ * Async utility function to collect the stacks of uninterruptible reflows
+ * occuring during some period of time in a window.
+ *
+ * @param testPromise (Promise)
+ * A promise that is resolved when the data collection should stop.
+ *
+ * @param win (browser window, optional)
+ * The browser window to monitor. Defaults to the current window.
+ *
+ * @return An array of reflow stacks
+ */
+async function recordReflows(testPromise, win = window) {
+ // Collect all reflow stacks, we'll process them later.
+ let reflows = [];
+
+ let observer = {
+ reflow(start, end) {
+ // Gather information about the current code path.
+ reflows.push(new Error().stack);
+
+ // Just in case, dirty the frame now that we've reflowed.
+ dirtyFrame(win);
+ },
+
+ reflowInterruptible(start, end) {
+ // Interruptible reflows are the reflows caused by the refresh
+ // driver ticking. These are fine.
+ },
+
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIReflowObserver",
+ "nsISupportsWeakReference",
+ ]),
+ };
+
+ let docShell = win.docShell;
+ docShell.addWeakReflowObserver(observer);
+
+ let dirtyFrameFn = event => {
+ if (event.type != "MozAfterPaint") {
+ dirtyFrame(win);
+ }
+ };
+ Services.els.addListenerForAllEvents(win, dirtyFrameFn, true);
+
+ try {
+ dirtyFrame(win);
+ await testPromise;
+ } finally {
+ Services.els.removeListenerForAllEvents(win, dirtyFrameFn, true);
+ docShell.removeWeakReflowObserver(observer);
+ }
+
+ return reflows;
+}
+
+/**
+ * Utility function to report unexpected reflows.
+ *
+ * @param reflows (Array)
+ * An array of reflow stacks returned by recordReflows.
+ *
+ * @param expectedReflows (Array, optional)
+ * An Array of Objects representing reflows.
+ *
+ * Example:
+ *
+ * [
+ * {
+ * // This reflow is caused by lorem ipsum.
+ * // Sometimes, due to unpredictable timings, the reflow may be hit
+ * // less times.
+ * stack: [
+ * "select@chrome://global/content/bindings/textbox.xml",
+ * "focusAndSelectUrlBar@chrome://browser/content/browser.js",
+ * "openLinkIn@chrome://browser/content/utilityOverlay.js",
+ * "openUILinkIn@chrome://browser/content/utilityOverlay.js",
+ * "BrowserOpenTab@chrome://browser/content/browser.js",
+ * ],
+ * // We expect this particular reflow to happen up to 2 times.
+ * maxCount: 2,
+ * },
+ *
+ * {
+ * // This reflow is caused by lorem ipsum. We expect this reflow
+ * // to only happen once, so we can omit the "maxCount" property.
+ * stack: [
+ * "get_scrollPosition@chrome://global/content/bindings/scrollbox.xml",
+ * "_fillTrailingGap@chrome://browser/content/tabbrowser.xml",
+ * "_handleNewTab@chrome://browser/content/tabbrowser.xml",
+ * "onxbltransitionend@chrome://browser/content/tabbrowser.xml",
+ * ],
+ * }
+ * ]
+ *
+ * Note that line numbers are not included in the stacks.
+ *
+ * Order of the reflows doesn't matter. Expected reflows that aren't seen
+ * will cause an assertion failure. When this argument is not passed,
+ * it defaults to the empty Array, meaning no reflows are expected.
+ */
+function reportUnexpectedReflows(reflows, expectedReflows = []) {
+ let knownReflows = expectedReflows.map(r => {
+ return {
+ stack: r.stack,
+ path: r.stack.join("|"),
+ count: 0,
+ maxCount: r.maxCount || 1,
+ actualStacks: new Map(),
+ };
+ });
+ let unexpectedReflows = new Map();
+
+ if (knownReflows.some(r => r.path.includes("*"))) {
+ Assert.ok(
+ false,
+ "Do not include async frames in the stack, as " +
+ "that feature is not available on all trees."
+ );
+ }
+
+ for (let stack of reflows) {
+ let path = stack
+ .split("\n")
+ .slice(1) // the first frame which is our test code.
+ .map(line => line.replace(/:\d+:\d+$/, "")) // strip line numbers.
+ .join("|");
+
+ // Stack trace is empty. Reflow was triggered by native code, which
+ // we ignore.
+ if (path === "") {
+ continue;
+ }
+
+ // Functions from EventUtils.js calculate coordinates and
+ // dimensions, causing us to reflow. That's the test
+ // harness and we don't care about that, so we'll filter that out.
+ if (
+ /^(synthesize|send|createDragEventObject).*?@chrome:\/\/mochikit.*?EventUtils\.js/.test(
+ path
+ )
+ ) {
+ continue;
+ }
+
+ let index = knownReflows.findIndex(reflow => path.startsWith(reflow.path));
+ if (index != -1) {
+ let reflow = knownReflows[index];
+ ++reflow.count;
+ reflow.actualStacks.set(stack, (reflow.actualStacks.get(stack) || 0) + 1);
+ } else {
+ unexpectedReflows.set(stack, (unexpectedReflows.get(stack) || 0) + 1);
+ }
+ }
+
+ let formatStack = stack =>
+ stack
+ .split("\n")
+ .slice(1)
+ .map(frame => " " + frame)
+ .join("\n");
+ for (let reflow of knownReflows) {
+ let firstFrame = reflow.stack[0];
+ if (!reflow.count) {
+ Assert.ok(
+ false,
+ `Unused expected reflow at ${firstFrame}:\nStack:\n` +
+ reflow.stack.map(frame => " " + frame).join("\n") +
+ "\n" +
+ "This is probably a good thing - just remove it from the list of reflows."
+ );
+ } else {
+ if (reflow.count > reflow.maxCount) {
+ Assert.ok(
+ false,
+ `reflow at ${firstFrame} was encountered ${reflow.count} times,\n` +
+ `it was expected to happen up to ${reflow.maxCount} times.`
+ );
+ } else {
+ todo(
+ false,
+ `known reflow at ${firstFrame} was encountered ${reflow.count} times`
+ );
+ }
+ for (let [stack, count] of reflow.actualStacks) {
+ info(
+ "Full stack" +
+ (count > 1 ? ` (hit ${count} times)` : "") +
+ ":\n" +
+ formatStack(stack)
+ );
+ }
+ }
+ }
+
+ for (let [stack, count] of unexpectedReflows) {
+ let location = stack.split("\n")[1].replace(/:\d+:\d+$/, "");
+ Assert.ok(
+ false,
+ `unexpected reflow at ${location} hit ${count} times\n` +
+ "Stack:\n" +
+ formatStack(stack)
+ );
+ }
+ Assert.ok(
+ !unexpectedReflows.size,
+ unexpectedReflows.size + " unexpected reflows"
+ );
+}
+
+async function ensureNoPreloadedBrowser(win = window) {
+ // If we've got a preloaded browser, get rid of it so that it
+ // doesn't interfere with the test if it's loading. We have to
+ // do this before we disable preloading or changing the new tab
+ // URL, otherwise _getPreloadedBrowser will return null, despite
+ // the preloaded browser existing.
+ NewTabPagePreloading.removePreloadedBrowser(win);
+
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.newtab.preload", false]],
+ });
+
+ AboutNewTab.newTabURL = "about:blank";
+
+ registerCleanupFunction(() => {
+ AboutNewTab.resetNewTabURL();
+ });
+}
+
+// Onboarding puts a badge on the fxa toolbar button a while after startup
+// which confuses tests that look at repaints in the toolbar. Use this
+// function to cancel the badge update.
+function disableFxaBadge() {
+ let { ToolbarBadgeHub } = ChromeUtils.import(
+ "resource://activity-stream/lib/ToolbarBadgeHub.jsm"
+ );
+ ToolbarBadgeHub.removeAllNotifications();
+
+ // Also prevent a new timer from being set
+ return SpecialPowers.pushPrefEnv({
+ set: [["identity.fxaccounts.toolbar.accessed", true]],
+ });
+}
+
+function rectInBoundingClientRect(r, bcr) {
+ return (
+ bcr.x <= r.x1 &&
+ bcr.y <= r.y1 &&
+ bcr.x + bcr.width >= r.x2 &&
+ bcr.y + bcr.height >= r.y2
+ );
+}
+
+async function getBookmarksToolbarRect() {
+ // Temporarily open the bookmarks toolbar to measure its rect
+ let bookmarksToolbar = gNavToolbox.querySelector("#PersonalToolbar");
+ let wasVisible = !bookmarksToolbar.collapsed;
+ if (!wasVisible) {
+ setToolbarVisibility(bookmarksToolbar, true, false, false);
+ await TestUtils.waitForCondition(
+ () => bookmarksToolbar.getBoundingClientRect().height > 0,
+ "wait for non-zero bookmarks toolbar height"
+ );
+ }
+ let bookmarksToolbarRect = bookmarksToolbar.getBoundingClientRect();
+ if (!wasVisible) {
+ setToolbarVisibility(bookmarksToolbar, false, false, false);
+ await TestUtils.waitForCondition(
+ () => bookmarksToolbar.getBoundingClientRect().height == 0,
+ "wait for zero bookmarks toolbar height"
+ );
+ }
+ return bookmarksToolbarRect;
+}
+
+async function prepareSettledWindow() {
+ let win = await BrowserTestUtils.openNewBrowserWindow();
+ await ensureNoPreloadedBrowser(win);
+ return win;
+}
+
+/**
+ * Calculate and return how many additional tabs can be fit into the
+ * tabstrip without causing it to overflow.
+ *
+ * @return int
+ * The maximum additional tabs that can be fit into the
+ * tabstrip without causing it to overflow.
+ */
+function computeMaxTabCount() {
+ let currentTabCount = gBrowser.tabs.length;
+ let newTabButton = gBrowser.tabContainer.newTabButton;
+ let newTabRect = newTabButton.getBoundingClientRect();
+ let tabStripRect =
+ gBrowser.tabContainer.arrowScrollbox.getBoundingClientRect();
+ let availableTabStripWidth = tabStripRect.width - newTabRect.width;
+
+ let tabMinWidth = parseInt(
+ getComputedStyle(gBrowser.selectedTab, null).minWidth,
+ 10
+ );
+
+ let maxTabCount =
+ Math.floor(availableTabStripWidth / tabMinWidth) - currentTabCount;
+ Assert.ok(
+ maxTabCount > 0,
+ "Tabstrip needs to be wide enough to accomodate at least 1 more tab " +
+ "without overflowing."
+ );
+ return maxTabCount;
+}
+
+/**
+ * Helper function that opens up some number of about:blank tabs, and wait
+ * until they're all fully open.
+ *
+ * @param howMany (int)
+ * How many about:blank tabs to open.
+ */
+async function createTabs(howMany) {
+ let uris = [];
+ while (howMany--) {
+ uris.push("about:blank");
+ }
+
+ gBrowser.loadTabs(uris, {
+ inBackground: true,
+ triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
+ });
+
+ await TestUtils.waitForCondition(() => {
+ return Array.from(gBrowser.tabs).every(tab => tab._fullyOpen);
+ });
+}
+
+/**
+ * Removes all of the tabs except the originally selected
+ * tab, and waits until all of the DOM nodes have been
+ * completely removed from the tab strip.
+ */
+async function removeAllButFirstTab() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.tabs.warnOnCloseOtherTabs", false]],
+ });
+ gBrowser.removeAllTabsBut(gBrowser.tabs[0]);
+ await TestUtils.waitForCondition(() => gBrowser.tabs.length == 1);
+ await SpecialPowers.popPrefEnv();
+}
+
+/**
+ * Adds some entries to the Places database so that we can
+ * do semi-realistic look-ups in the URL bar.
+ *
+ * @param searchStr (string)
+ * Optional text to add to the search history items.
+ */
+async function addDummyHistoryEntries(searchStr = "") {
+ await PlacesUtils.history.clear();
+ const NUM_VISITS = 10;
+ let visits = [];
+
+ for (let i = 0; i < NUM_VISITS; ++i) {
+ visits.push({
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ uri: `http://example.com/urlbar-reflows-${i}`,
+ title: `Reflow test for URL bar entry #${i} - ${searchStr}`,
+ });
+ }
+
+ await PlacesTestUtils.addVisits(visits);
+
+ registerCleanupFunction(async function () {
+ await PlacesUtils.history.clear();
+ });
+}
+
+/**
+ * Async utility function to capture a screenshot of each painted frame.
+ *
+ * @param testPromise (Promise)
+ * A promise that is resolved when the data collection should stop.
+ *
+ * @param win (browser window, optional)
+ * The browser window to monitor. Defaults to the current window.
+ *
+ * @return An array of screenshots
+ */
+async function recordFrames(testPromise, win = window) {
+ let canvas = win.document.createElementNS(
+ "http://www.w3.org/1999/xhtml",
+ "canvas"
+ );
+ canvas.mozOpaque = true;
+ let ctx = canvas.getContext("2d", { alpha: false, willReadFrequently: true });
+
+ let frames = [];
+
+ let afterPaintListener = event => {
+ let width, height;
+ canvas.width = width = win.innerWidth;
+ canvas.height = height = win.innerHeight;
+ ctx.drawWindow(
+ win,
+ 0,
+ 0,
+ width,
+ height,
+ "white",
+ ctx.DRAWWINDOW_DO_NOT_FLUSH |
+ ctx.DRAWWINDOW_DRAW_VIEW |
+ ctx.DRAWWINDOW_ASYNC_DECODE_IMAGES |
+ ctx.DRAWWINDOW_USE_WIDGET_LAYERS
+ );
+ let data = Cu.cloneInto(ctx.getImageData(0, 0, width, height).data, {});
+ if (frames.length) {
+ // Compare this frame with the previous one to avoid storing duplicate
+ // frames and running out of memory.
+ let previous = frames[frames.length - 1];
+ if (previous.width == width && previous.height == height) {
+ let equals = true;
+ for (let i = 0; i < data.length; ++i) {
+ if (data[i] != previous.data[i]) {
+ equals = false;
+ break;
+ }
+ }
+ if (equals) {
+ return;
+ }
+ }
+ }
+ frames.push({ data, width, height });
+ };
+ win.addEventListener("MozAfterPaint", afterPaintListener);
+
+ // If the test is using an existing window, capture a frame immediately.
+ if (win.document.readyState == "complete") {
+ afterPaintListener();
+ }
+
+ try {
+ await testPromise;
+ } finally {
+ win.removeEventListener("MozAfterPaint", afterPaintListener);
+ }
+
+ return frames;
+}
+
+// How many identical pixels to accept between 2 rects when deciding to merge
+// them.
+const kMaxEmptyPixels = 3;
+function compareFrames(frame, previousFrame) {
+ // Accessing the Math global is expensive as the test executes in a
+ // non-syntactic scope. Accessing it as a lexical variable is enough
+ // to make the code JIT well.
+ const M = Math;
+
+ function expandRect(x, y, rect) {
+ if (rect.x2 < x) {
+ rect.x2 = x;
+ } else if (rect.x1 > x) {
+ rect.x1 = x;
+ }
+ if (rect.y2 < y) {
+ rect.y2 = y;
+ }
+ }
+
+ function isInRect(x, y, rect) {
+ return (
+ (rect.y2 == y || rect.y2 == y - 1) && rect.x1 - 1 <= x && x <= rect.x2 + 1
+ );
+ }
+
+ if (
+ frame.height != previousFrame.height ||
+ frame.width != previousFrame.width
+ ) {
+ // If the frames have different sizes, assume the whole window has
+ // been repainted when the window was resized.
+ return [{ x1: 0, x2: frame.width, y1: 0, y2: frame.height }];
+ }
+
+ let l = frame.data.length;
+ let different = [];
+ let rects = [];
+ for (let i = 0; i < l; i += 4) {
+ let x = (i / 4) % frame.width;
+ let y = M.floor(i / 4 / frame.width);
+ for (let j = 0; j < 4; ++j) {
+ let index = i + j;
+
+ if (frame.data[index] != previousFrame.data[index]) {
+ let found = false;
+ for (let rect of rects) {
+ if (isInRect(x, y, rect)) {
+ expandRect(x, y, rect);
+ found = true;
+ break;
+ }
+ }
+ if (!found) {
+ rects.unshift({ x1: x, x2: x, y1: y, y2: y });
+ }
+
+ different.push(i);
+ break;
+ }
+ }
+ }
+ rects.reverse();
+
+ // The following code block merges rects that are close to each other
+ // (less than kMaxEmptyPixels away).
+ // This is needed to avoid having a rect for each letter when a label moves.
+ let areRectsContiguous = function (r1, r2) {
+ return (
+ r1.y2 >= r2.y1 - 1 - kMaxEmptyPixels &&
+ r2.x1 - 1 - kMaxEmptyPixels <= r1.x2 &&
+ r2.x2 >= r1.x1 - 1 - kMaxEmptyPixels
+ );
+ };
+ let hasMergedRects;
+ do {
+ hasMergedRects = false;
+ for (let r = rects.length - 1; r > 0; --r) {
+ let rr = rects[r];
+ for (let s = r - 1; s >= 0; --s) {
+ let rs = rects[s];
+ if (areRectsContiguous(rs, rr)) {
+ rs.x1 = Math.min(rs.x1, rr.x1);
+ rs.y1 = Math.min(rs.y1, rr.y1);
+ rs.x2 = Math.max(rs.x2, rr.x2);
+ rs.y2 = Math.max(rs.y2, rr.y2);
+ rects.splice(r, 1);
+ hasMergedRects = true;
+ break;
+ }
+ }
+ }
+ } while (hasMergedRects);
+
+ // For convenience, pre-compute the width and height of each rect.
+ rects.forEach(r => {
+ r.w = r.x2 - r.x1 + 1;
+ r.h = r.y2 - r.y1 + 1;
+ });
+
+ return rects;
+}
+
+function dumpFrame({ data, width, height }) {
+ let canvas = document.createElementNS(
+ "http://www.w3.org/1999/xhtml",
+ "canvas"
+ );
+ canvas.mozOpaque = true;
+ canvas.width = width;
+ canvas.height = height;
+
+ canvas
+ .getContext("2d", { alpha: false, willReadFrequently: true })
+ .putImageData(new ImageData(data, width, height), 0, 0);
+
+ info(canvas.toDataURL());
+}
+
+/**
+ * Utility function to report unexpected changed areas on screen.
+ *
+ * @param frames (Array)
+ * An array of frames captured by recordFrames.
+ *
+ * @param expectations (Object)
+ * An Object indicating which changes on screen are expected.
+ * If can contain the following optional fields:
+ * - filter: a function used to exclude changed rects that are expected.
+ * It takes the following parameters:
+ * - rects: an array of changed rects
+ * - frame: the current frame
+ * - previousFrame: the previous frame
+ * It returns an array of rects. This array is typically a copy of
+ * the rects parameter, from which identified expected changes have
+ * been excluded.
+ * - exceptions: an array of objects describing known flicker bugs.
+ * Example:
+ * exceptions: [
+ * {name: "bug 1nnnnnn - the foo icon shouldn't flicker",
+ * condition: r => r.w == 14 && r.y1 == 0 && ... }
+ * },
+ * {name: "bug ...
+ * ]
+ */
+function reportUnexpectedFlicker(frames, expectations) {
+ info("comparing " + frames.length + " frames");
+
+ let unexpectedRects = 0;
+ for (let i = 1; i < frames.length; ++i) {
+ let frame = frames[i],
+ previousFrame = frames[i - 1];
+ let rects = compareFrames(frame, previousFrame);
+
+ if (expectations.filter) {
+ rects = expectations.filter(rects, frame, previousFrame);
+ }
+
+ rects = rects.filter(rect => {
+ let rectText = `${rect.toSource()}, window width: ${frame.width}`;
+ for (let e of expectations.exceptions || []) {
+ if (e.condition(rect)) {
+ todo(false, e.name + ", " + rectText);
+ return false;
+ }
+ }
+
+ ok(false, "unexpected changed rect: " + rectText);
+ return true;
+ });
+
+ if (!rects.length) {
+ continue;
+ }
+
+ // Before dumping a frame with unexpected differences for the first time,
+ // ensure at least one previous frame has been logged so that it's possible
+ // to see the differences when examining the log.
+ if (!unexpectedRects) {
+ dumpFrame(previousFrame);
+ }
+ unexpectedRects += rects.length;
+ dumpFrame(frame);
+ }
+ is(unexpectedRects, 0, "should have 0 unknown flickering areas");
+}
+
+/**
+ * This is the main function that performance tests in this folder will call.
+ *
+ * The general idea is that individual tests provide a test function (testFn)
+ * that will perform some user interactions we care about (eg. open a tab), and
+ * this withPerfObserver function takes care of setting up and removing the
+ * observers and listener we need to detect common performance issues.
+ *
+ * Once testFn is done, withPerfObserver will analyse the collected data and
+ * report anything unexpected.
+ *
+ * @param testFn (async function)
+ * An async function that exercises some part of the browser UI.
+ *
+ * @param exceptions (object, optional)
+ * An Array of Objects representing expectations and known issues.
+ * It can contain the following fields:
+ * - expectedReflows: an array of expected reflow stacks.
+ * (see the comment above reportUnexpectedReflows for an example)
+ * - frames: an object setting expectations for what will change
+ * on screen during the test, and the known flicker bugs.
+ * (see the comment above reportUnexpectedFlicker for an example)
+ */
+async function withPerfObserver(testFn, exceptions = {}, win = window) {
+ let resolveFn, rejectFn;
+ let promiseTestDone = new Promise((resolve, reject) => {
+ resolveFn = resolve;
+ rejectFn = reject;
+ });
+
+ let promiseReflows = recordReflows(promiseTestDone, win);
+ let promiseFrames = recordFrames(promiseTestDone, win);
+
+ testFn().then(resolveFn, rejectFn);
+ await promiseTestDone;
+
+ let reflows = await promiseReflows;
+ reportUnexpectedReflows(reflows, exceptions.expectedReflows);
+
+ let frames = await promiseFrames;
+ reportUnexpectedFlicker(frames, exceptions.frames);
+}
+
+/**
+ * This test ensures that there are no unexpected
+ * uninterruptible reflows when typing into the URL bar
+ * with the default values in Places.
+ *
+ * @param {bool} keyed
+ * Pass true to synthesize typing the search string one key at a time.
+ * @param {array} expectedReflowsFirstOpen
+ * The array of expected reflow stacks when the panel is first opened.
+ * @param {array} [expectedReflowsSecondOpen]
+ * The array of expected reflow stacks when the panel is subsequently
+ * opened, if you're testing opening the panel twice.
+ */
+async function runUrlbarTest(
+ keyed,
+ expectedReflowsFirstOpen,
+ expectedReflowsSecondOpen = null
+) {
+ const SEARCH_TERM = keyed ? "" : "urlbar-reflows-" + Date.now();
+ await addDummyHistoryEntries(SEARCH_TERM);
+
+ let win = await prepareSettledWindow();
+
+ let URLBar = win.gURLBar;
+
+ URLBar.focus();
+ URLBar.value = SEARCH_TERM;
+ let testFn = async function () {
+ let popup = URLBar.view;
+ let oldOnQueryResults = popup.onQueryResults.bind(popup);
+ let oldOnQueryFinished = popup.onQueryFinished.bind(popup);
+
+ // We need to invalidate the frame tree outside of the normal
+ // mechanism since invalidations and result additions to the
+ // URL bar occur without firing JS events (which is how we
+ // normally know to dirty the frame tree).
+ popup.onQueryResults = context => {
+ dirtyFrame(win);
+ oldOnQueryResults(context);
+ };
+
+ popup.onQueryFinished = context => {
+ dirtyFrame(win);
+ oldOnQueryFinished(context);
+ };
+
+ let waitExtra = async () => {
+ // There are several setTimeout(fn, 0); calls inside autocomplete.xml
+ // that we need to wait for. Since those have higher priority than
+ // idle callbacks, we can be sure they will have run once this
+ // idle callback is called. The timeout seems to be required in
+ // automation - presumably because the machines can be pretty busy
+ // especially if it's GC'ing from previous tests.
+ await new Promise(resolve =>
+ win.requestIdleCallback(resolve, { timeout: 1000 })
+ );
+ };
+
+ if (keyed) {
+ // Only keying in 6 characters because the number of reflows triggered
+ // is so high that we risk timing out the test if we key in any more.
+ let searchTerm = "ows-10";
+ for (let i = 0; i < searchTerm.length; ++i) {
+ let char = searchTerm[i];
+ EventUtils.synthesizeKey(char, {}, win);
+ await UrlbarTestUtils.promiseSearchComplete(win);
+ await waitExtra();
+ }
+ } else {
+ await UrlbarTestUtils.promiseAutocompleteResultPopup({
+ window: win,
+ waitForFocus: SimpleTest.waitForFocus,
+ value: URLBar.value,
+ });
+ await waitExtra();
+ }
+
+ await UrlbarTestUtils.promisePopupClose(win);
+ };
+
+ let urlbarRect = URLBar.textbox.getBoundingClientRect();
+ const SHADOW_SIZE = 17;
+ let expectedRects = {
+ filter: rects => {
+ // We put text into the urlbar so expect its textbox to change.
+ // We expect many changes in the results view.
+ // So we just allow changes anywhere in the urlbar. We don't check the
+ // bottom of the rect because the result view height varies depending on
+ // the results.
+ // We use floor/ceil because the Urlbar dimensions aren't always
+ // integers.
+ return rects.filter(
+ r =>
+ !(
+ r.x1 >= Math.floor(urlbarRect.left) - SHADOW_SIZE &&
+ r.x2 <= Math.ceil(urlbarRect.right) + SHADOW_SIZE &&
+ r.y1 >= Math.floor(urlbarRect.top) - SHADOW_SIZE
+ )
+ );
+ },
+ };
+
+ info("First opening");
+ await withPerfObserver(
+ testFn,
+ { expectedReflows: expectedReflowsFirstOpen, frames: expectedRects },
+ win
+ );
+
+ if (expectedReflowsSecondOpen) {
+ info("Second opening");
+ await withPerfObserver(
+ testFn,
+ { expectedReflows: expectedReflowsSecondOpen, frames: expectedRects },
+ win
+ );
+ }
+
+ await BrowserTestUtils.closeWindow(win);
+}
+
+/**
+ * Helper method for checking which scripts are loaded on content process
+ * startup, used by `browser_startup_content.js` and
+ * `browser_startup_content_subframe.js`.
+ *
+ * Parameters to this function are passed in an object literal to avoid
+ * confusion about parameter order.
+ *
+ * @param loadedInfo (Object)
+ * Mapping from script type to a set of scripts which have been loaded
+ * of that type.
+ *
+ * @param known (Object)
+ * Mapping from script type to a set of scripts which must have been
+ * loaded of that type.
+ *
+ * @param intermittent (Object)
+ * Mapping from script type to a set of scripts which may have been
+ * loaded of that type. There must be a script type map for every type
+ * in `known`.
+ *
+ * @param forbidden (Object)
+ * Mapping from script type to a set of scripts which must not have been
+ * loaded of that type.
+ *
+ * @param dumpAllStacks (bool)
+ * If true, dump the stacks for all loaded modules. Makes the output
+ * noisy.
+ */
+async function checkLoadedScripts({
+ loadedInfo,
+ known,
+ intermittent,
+ forbidden,
+ dumpAllStacks,
+}) {
+ let loadedList = {};
+
+ async function checkAllExist(scriptType, list, listType) {
+ if (scriptType == "services") {
+ for (let contract of list) {
+ ok(
+ contract in Cc,
+ `${listType} entry ${contract} for content process startup must exist`
+ );
+ }
+ } else {
+ let results = await PerfTestHelpers.throttledMapPromises(
+ list,
+ async uri => ({
+ uri,
+ exists: await PerfTestHelpers.checkURIExists(uri),
+ })
+ );
+
+ for (let { uri, exists } of results) {
+ ok(
+ exists,
+ `${listType} entry ${uri} for content process startup must exist`
+ );
+ }
+ }
+ }
+
+ for (let scriptType in known) {
+ loadedList[scriptType] = Object.keys(loadedInfo[scriptType]).filter(c => {
+ if (!known[scriptType].has(c)) {
+ return true;
+ }
+ known[scriptType].delete(c);
+ return false;
+ });
+
+ loadedList[scriptType] = loadedList[scriptType].filter(c => {
+ return !intermittent[scriptType].has(c);
+ });
+
+ if (loadedList[scriptType].length) {
+ console.log("Unexpected scripts:", loadedList[scriptType]);
+ }
+ is(
+ loadedList[scriptType].length,
+ 0,
+ `should have no unexpected ${scriptType} loaded on content process startup`
+ );
+
+ for (let script of loadedList[scriptType]) {
+ record(
+ false,
+ `Unexpected ${scriptType} loaded during content process startup: ${script}`,
+ undefined,
+ loadedInfo[scriptType][script]
+ );
+ }
+
+ await checkAllExist(scriptType, intermittent[scriptType], "intermittent");
+
+ is(
+ known[scriptType].size,
+ 0,
+ `all known ${scriptType} scripts should have been loaded`
+ );
+
+ for (let script of known[scriptType]) {
+ ok(
+ false,
+ `${scriptType} is expected to load for content process startup but wasn't: ${script}`
+ );
+ }
+
+ if (dumpAllStacks) {
+ info(`Stacks for all loaded ${scriptType}:`);
+ for (let file in loadedInfo[scriptType]) {
+ if (loadedInfo[scriptType][file]) {
+ info(
+ `${file}\n------------------------------------\n` +
+ loadedInfo[scriptType][file] +
+ "\n"
+ );
+ }
+ }
+ }
+ }
+
+ for (let scriptType in forbidden) {
+ for (let script of forbidden[scriptType]) {
+ let loaded = script in loadedInfo[scriptType];
+ if (loaded) {
+ record(
+ false,
+ `Forbidden ${scriptType} loaded during content process startup: ${script}`,
+ undefined,
+ loadedInfo[scriptType][script]
+ );
+ }
+ }
+
+ await checkAllExist(scriptType, forbidden[scriptType], "forbidden");
+ }
+}
diff --git a/browser/base/content/test/performance/hidpi/browser.ini b/browser/base/content/test/performance/hidpi/browser.ini
new file mode 100644
index 0000000000..5375700ee8
--- /dev/null
+++ b/browser/base/content/test/performance/hidpi/browser.ini
@@ -0,0 +1,7 @@
+[DEFAULT]
+prefs =
+ browser.startup.recordImages=true
+ layout.css.devPixelsPerPx='2'
+
+[../browser_startup_images.js]
+skip-if = !debug || (os == 'win' && (os_version == '6.1'))
diff --git a/browser/base/content/test/performance/io/browser.ini b/browser/base/content/test/performance/io/browser.ini
new file mode 100644
index 0000000000..7f4b66365e
--- /dev/null
+++ b/browser/base/content/test/performance/io/browser.ini
@@ -0,0 +1,33 @@
+[DEFAULT]
+# Currently disabled on debug due to debug-only failures, see bug 1549723.
+# Disabled on Linux asan due to bug 1549729.
+# Disabled on Windows asan due to intermittent startup hangs, bug 1629824.
+skip-if =
+ debug
+ tsan
+ asan
+# to avoid overhead when running the browser normally, StartupRecorder.sys.mjs will
+# do almost nothing unless browser.startup.record is true.
+# gfx.canvas.willReadFrequently.enable is just an optimization, but needs to be
+# set during early startup to have an impact as a canvas will be used by
+# StartupRecorder.sys.mjs
+prefs =
+ browser.startup.record=true
+ gfx.canvas.willReadFrequently.enable=true
+ # The Screenshots extension is disabled by default in Mochitests. We re-enable
+ # it here, since it's a more realistic configuration.
+ extensions.screenshots.disabled=false
+environment =
+ GNOME_ACCESSIBILITY=0
+ MOZ_PROFILER_STARTUP=1
+ MOZ_PROFILER_STARTUP_PERFORMANCE_TEST=1
+ MOZ_PROFILER_STARTUP_FEATURES=js,mainthreadio
+ MOZ_PROFILER_STARTUP_ENTRIES=10000000
+[../browser_startup_content_mainthreadio.js]
+[../browser_startup_mainthreadio.js]
+skip-if =
+ apple_silicon # bug 1707724
+ socketprocess_networking
+ os == 'win' && bits == 32
+ os == 'win' && msix # Bug 1833639
+[../browser_startup_syncIPC.js]
diff --git a/browser/base/content/test/performance/lowdpi/browser.ini b/browser/base/content/test/performance/lowdpi/browser.ini
new file mode 100644
index 0000000000..5dc4efc5ac
--- /dev/null
+++ b/browser/base/content/test/performance/lowdpi/browser.ini
@@ -0,0 +1,8 @@
+[DEFAULT]
+prefs =
+ browser.startup.recordImages=true
+ layout.css.devPixelsPerPx='1'
+
+[../browser_startup_images.js]
+skip-if = !debug
+
diff --git a/browser/base/content/test/performance/moz.build b/browser/base/content/test/performance/moz.build
new file mode 100644
index 0000000000..eb5df95729
--- /dev/null
+++ b/browser/base/content/test/performance/moz.build
@@ -0,0 +1,17 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+
+BROWSER_CHROME_MANIFESTS += [
+ "browser.ini",
+ "hidpi/browser.ini",
+ "io/browser.ini",
+ "lowdpi/browser.ini",
+]
+
+TESTING_JS_MODULES += [
+ "PerfTestHelpers.sys.mjs",
+]
diff --git a/browser/base/content/test/performance/triage.json b/browser/base/content/test/performance/triage.json
new file mode 100644
index 0000000000..a5f367c745
--- /dev/null
+++ b/browser/base/content/test/performance/triage.json
@@ -0,0 +1,62 @@
+{
+ "triagers": {
+ "Gijs": {
+ "bzmail": "gijskruitbosch+bugs@gmail.com"
+ },
+ "Mike Conley": {
+ "bzmail": "mconley@mozilla.com"
+ },
+ "Florian Quèze": {
+ "bzmail": "florian@mozilla.com"
+ },
+ "Doug Thayer": {
+ "bzmail": "dothayer@mozilla.com"
+ }
+ },
+ "duty-start-dates": {
+ "2023-03-23": "Gijs Kruitbosch",
+ "2023-03-30": "Mike Conley",
+ "2023-04-06": "Florian Quèze",
+ "2023-04-13": "Doug Thayer",
+ "2023-04-20": "Gijs Kruitbosch",
+ "2023-04-27": "Mike Conley",
+ "2023-05-04": "Doug Thayer",
+ "2023-05-11": "Gijs Kruitbosch",
+ "2023-05-18": "Mike Conley",
+ "2023-05-25": "Doug Thayer",
+ "2023-06-01": "Gijs Kruitbosch",
+ "2023-06-08": "Mike Conley",
+ "2023-06-15": "Doug Thayer",
+ "2023-06-22": "Gijs Kruitbosch",
+ "2023-06-29": "Mike Conley",
+ "2023-07-06": "Doug Thayer",
+ "2023-07-13": "Gijs Kruitbosch",
+ "2023-07-20": "Mike Conley",
+ "2023-07-27": "Florian Quèze",
+ "2023-08-03": "Doug Thayer",
+ "2023-08-10": "Gijs Kruitbosch",
+ "2023-08-17": "Mike Conley",
+ "2023-08-24": "Florian Quèze",
+ "2023-08-31": "Doug Thayer",
+ "2023-09-07": "Gijs Kruitbosch",
+ "2023-09-14": "Mike Conley",
+ "2023-09-21": "Florian Quèze",
+ "2023-09-28": "Doug Thayer",
+ "2023-10-05": "Gijs Kruitbosch",
+ "2023-10-12": "Mike Conley",
+ "2023-10-19": "Florian Quèze",
+ "2023-10-26": "Doug Thayer",
+ "2023-11-02": "Gijs Kruitbosch",
+ "2023-11-09": "Mike Conley",
+ "2023-11-16": "Florian Quèze",
+ "2023-11-23": "Doug Thayer",
+ "2023-11-30": "Gijs Kruitbosch",
+ "2023-12-07": "Mike Conley",
+ "2023-12-14": "Florian Quèze",
+ "2023-12-21": "Doug Thayer",
+ "2023-12-28": "Gijs Kruitbosch",
+ "2024-01-04": "Mike Conley",
+ "2024-01-11": "Florian Quèze",
+ "2024-01-18": "Doug Thayer"
+ }
+}
diff --git a/browser/base/content/test/perftest.ini b/browser/base/content/test/perftest.ini
new file mode 100644
index 0000000000..d4351967e4
--- /dev/null
+++ b/browser/base/content/test/perftest.ini
@@ -0,0 +1 @@
+[perftest_browser_xhtml_dom.js]
diff --git a/browser/base/content/test/perftest_browser_xhtml_dom.js b/browser/base/content/test/perftest_browser_xhtml_dom.js
new file mode 100644
index 0000000000..f3cc00ec49
--- /dev/null
+++ b/browser/base/content/test/perftest_browser_xhtml_dom.js
@@ -0,0 +1,85 @@
+/* Any copyright is dedicated to the Public Domain.
+ * https://creativecommons.org/publicdomain/zero/1.0/ */
+/* eslint-env node */
+"use strict";
+
+/* global module */
+async function test(context, commands) {
+ await context.selenium.driver.setContext("chrome");
+ let elementData = await context.selenium.driver.executeAsyncScript(
+ function () {
+ let callback = arguments[arguments.length - 1];
+ (async function () {
+ let lightDOM = document.querySelectorAll("*");
+ let elementsWithoutIDs = {};
+ let idElements = [];
+ let lightDOMDetails = { idElements, elementsWithoutIDs };
+ lightDOM.forEach(n => {
+ if (n.id) {
+ idElements.push(n.id);
+ } else {
+ if (!elementsWithoutIDs.hasOwnProperty(n.localName)) {
+ elementsWithoutIDs[n.localName] = 0;
+ }
+ elementsWithoutIDs[n.localName]++;
+ }
+ });
+ let lightDOMCount = lightDOM.length;
+
+ // Recursively explore shadow DOM:
+ function getShadowElements(root) {
+ let allElems = Array.from(root.querySelectorAll("*"));
+ let shadowRoots = allElems.map(n => n.openOrClosedShadowRoot);
+ for (let innerRoot of shadowRoots) {
+ if (innerRoot) {
+ allElems.push(getShadowElements(innerRoot));
+ }
+ }
+ return allElems;
+ }
+ let totalDOMCount = Array.from(lightDOM, node => {
+ if (node.openOrClosedShadowRoot) {
+ return [node].concat(
+ getShadowElements(node.openOrClosedShadowRoot)
+ );
+ }
+ return node;
+ }).flat().length;
+ let panelMenuCount = document.querySelectorAll(
+ "panel,menupopup,popup,popupnotification"
+ ).length;
+ return {
+ panelMenuCount,
+ lightDOMCount,
+ totalDOMCount,
+ lightDOMDetails,
+ };
+ })().then(callback);
+ }
+ );
+ let { lightDOMDetails } = elementData;
+ delete elementData.lightDOMDetails;
+ lightDOMDetails.idElements.sort();
+ for (let id of lightDOMDetails.idElements) {
+ console.log(id);
+ }
+ console.log("Elements without ids:");
+ for (let [localName, count] of Object.entries(
+ lightDOMDetails.elementsWithoutIDs
+ )) {
+ console.log(count.toString().padStart(4) + " " + localName);
+ }
+ console.log(elementData);
+ await context.selenium.driver.setContext("content");
+ await commands.measure.start("data:text/html,BrowserDOM");
+ commands.measure.addObject(elementData);
+}
+
+module.exports = {
+ test,
+ owner: "Browser Front-end team",
+ name: "Dom-size",
+ description: "Measures the size of the DOM",
+ supportedBrowsers: ["Desktop"],
+ supportedPlatforms: ["Windows", "Linux", "macOS"],
+};
diff --git a/browser/base/content/test/permissions/browser.ini b/browser/base/content/test/permissions/browser.ini
new file mode 100644
index 0000000000..216c0efb05
--- /dev/null
+++ b/browser/base/content/test/permissions/browser.ini
@@ -0,0 +1,41 @@
+[DEFAULT]
+support-files=
+ head.js
+ permissions.html
+ temporary_permissions_subframe.html
+ temporary_permissions_frame.html
+[browser_autoplay_blocked.js]
+support-files =
+ browser_autoplay_blocked.html
+ browser_autoplay_blocked_slow.sjs
+ browser_autoplay_js.html
+ browser_autoplay_muted.html
+ ../general/audio.ogg
+skip-if = true # Bug 1538602
+[browser_canvas_fingerprinting_resistance.js]
+skip-if =
+ debug
+ os == "linux" && asan # Bug 1522069
+ os == "win" && os_version == "6.1" # Skip on Azure - frequent failure
+[browser_canvas_rfp_exclusion.js]
+[browser_permission_delegate_geo.js]
+skip-if =
+ os == "win" && os_version == "6.1" # Skip on Azure - frequent failure
+[browser_permissions.js]
+[browser_permissions_delegate_vibrate.js]
+support-files=
+ empty.html
+[browser_permissions_handling_user_input.js]
+support-files=
+ dummy.js
+[browser_permissions_postPrompt.js]
+support-files=
+ dummy.js
+[browser_reservedkey.js]
+[browser_site_scoped_permissions.js]
+[browser_temporary_permissions.js]
+support-files =
+ ../webrtc/get_user_media.html
+[browser_temporary_permissions_expiry.js]
+[browser_temporary_permissions_navigation.js]
+[browser_temporary_permissions_tabs.js]
diff --git a/browser/base/content/test/permissions/browser_autoplay_blocked.html b/browser/base/content/test/permissions/browser_autoplay_blocked.html
new file mode 100644
index 0000000000..8c3b058890
--- /dev/null
+++ b/browser/base/content/test/permissions/browser_autoplay_blocked.html
@@ -0,0 +1,14 @@
+<!DOCTYPE HTML>
+<!-- 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/. -->
+<html dir="ltr" xml:lang="en-US" lang="en-US">
+ <head>
+ <meta charset="utf8">
+ </head>
+ <body>
+ <audio autoplay="autoplay" >
+ <source src="audio.ogg" />
+ </audio>
+ </body>
+</html>
diff --git a/browser/base/content/test/permissions/browser_autoplay_blocked.js b/browser/base/content/test/permissions/browser_autoplay_blocked.js
new file mode 100644
index 0000000000..04b0316345
--- /dev/null
+++ b/browser/base/content/test/permissions/browser_autoplay_blocked.js
@@ -0,0 +1,357 @@
+/*
+ * Test that a blocked request to autoplay media is shown to the user
+ */
+
+const AUTOPLAY_PAGE =
+ getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+ ) + "browser_autoplay_blocked.html";
+
+const AUTOPLAY_JS_PAGE =
+ getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+ ) + "browser_autoplay_js.html";
+
+const SLOW_AUTOPLAY_PAGE =
+ getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+ ) + "browser_autoplay_blocked_slow.sjs";
+
+const MUTED_AUTOPLAY_PAGE =
+ getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+ ) + "browser_autoplay_muted.html";
+
+const EMPTY_PAGE =
+ getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+ ) + "empty.html";
+
+const AUTOPLAY_PREF = "media.autoplay.default";
+const AUTOPLAY_PERM = "autoplay-media";
+
+function autoplayBlockedIcon() {
+ return document.querySelector(
+ "#blocked-permissions-container " +
+ ".blocked-permission-icon.autoplay-media-icon"
+ );
+}
+
+function permissionListBlockedIcons() {
+ return document.querySelectorAll(
+ "image.permission-popup-permission-icon.blocked-permission-icon"
+ );
+}
+
+function sleep(ms) {
+ /* eslint-disable mozilla/no-arbitrary-setTimeout */
+ return new Promise(resolve => setTimeout(resolve, ms));
+}
+
+async function blockedIconShown() {
+ await TestUtils.waitForCondition(() => {
+ return BrowserTestUtils.is_visible(autoplayBlockedIcon());
+ }, "Blocked icon is shown");
+}
+
+async function blockedIconHidden() {
+ await TestUtils.waitForCondition(() => {
+ return BrowserTestUtils.is_hidden(autoplayBlockedIcon());
+ }, "Blocked icon is hidden");
+}
+
+function testPermListHasEntries(expectEntries) {
+ let permissionsList = document.getElementById(
+ "permission-popup-permission-list"
+ );
+ let listEntryCount = permissionsList.querySelectorAll(
+ ".permission-popup-permission-item"
+ ).length;
+ if (expectEntries) {
+ ok(listEntryCount, "List of permissions is not empty");
+ return;
+ }
+ ok(!listEntryCount, "List of permissions is empty");
+}
+
+add_setup(async function () {
+ registerCleanupFunction(() => {
+ Services.perms.removeAll();
+ Services.prefs.clearUserPref(AUTOPLAY_PREF);
+ });
+});
+
+add_task(async function testMainViewVisible() {
+ Services.prefs.setIntPref(AUTOPLAY_PREF, Ci.nsIAutoplay.ALLOWED);
+
+ await BrowserTestUtils.withNewTab(AUTOPLAY_PAGE, async function () {
+ ok(
+ BrowserTestUtils.is_hidden(autoplayBlockedIcon()),
+ "Blocked icon not shown"
+ );
+
+ await openPermissionPopup();
+ testPermListHasEntries(false);
+ await closePermissionPopup();
+ });
+
+ Services.prefs.setIntPref(AUTOPLAY_PREF, Ci.nsIAutoplay.BLOCKED);
+
+ await BrowserTestUtils.withNewTab(AUTOPLAY_PAGE, async function (browser) {
+ let permissionsList = document.getElementById(
+ "permission-popup-permission-list"
+ );
+
+ await blockedIconShown();
+
+ await openPermissionPopup();
+ testPermListHasEntries(true);
+
+ let labelText = SitePermissions.getPermissionLabel(AUTOPLAY_PERM);
+ let labels = permissionsList.querySelectorAll(
+ ".permission-popup-permission-label"
+ );
+ is(labels.length, 1, "One permission visible in main view");
+ is(labels[0].textContent, labelText, "Correct value");
+
+ let menulist = document.getElementById("permission-popup-menulist");
+ Assert.equal(menulist.label, "Block Audio");
+
+ await EventUtils.synthesizeMouseAtCenter(menulist, { type: "mousedown" });
+ await TestUtils.waitForCondition(() => {
+ return (
+ menulist.getElementsByTagName("menuitem")[0].label ===
+ "Allow Audio and Video"
+ );
+ });
+
+ let menuitem = menulist.getElementsByTagName("menuitem")[0];
+ Assert.equal(menuitem.getAttribute("label"), "Allow Audio and Video");
+
+ menuitem.click();
+ menulist.menupopup.hidePopup();
+ await closePermissionPopup();
+
+ let uri = Services.io.newURI(AUTOPLAY_PAGE);
+ let state = PermissionTestUtils.getPermissionObject(
+ uri,
+ AUTOPLAY_PERM
+ ).capability;
+ Assert.equal(state, Services.perms.ALLOW_ACTION);
+ });
+
+ Services.perms.removeAll();
+});
+
+add_task(async function testGloballyBlockedOnNewWindow() {
+ Services.prefs.setIntPref(AUTOPLAY_PREF, Ci.nsIAutoplay.BLOCKED);
+
+ let principal =
+ Services.scriptSecurityManager.createContentPrincipalFromOrigin(
+ AUTOPLAY_PAGE
+ );
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ AUTOPLAY_PAGE
+ );
+ await blockedIconShown();
+
+ Assert.deepEqual(
+ SitePermissions.getForPrincipal(
+ principal,
+ AUTOPLAY_PERM,
+ tab.linkedBrowser
+ ),
+ {
+ state: SitePermissions.BLOCK,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ }
+ );
+
+ let promiseWin = BrowserTestUtils.waitForNewWindow();
+ gBrowser.replaceTabWithWindow(tab);
+ let win = await promiseWin;
+ tab = win.gBrowser.selectedTab;
+
+ Assert.deepEqual(
+ SitePermissions.getForPrincipal(
+ principal,
+ AUTOPLAY_PERM,
+ tab.linkedBrowser
+ ),
+ {
+ state: SitePermissions.BLOCK,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ }
+ );
+
+ SitePermissions.removeFromPrincipal(
+ principal,
+ AUTOPLAY_PERM,
+ tab.linkedBrowser
+ );
+ await BrowserTestUtils.closeWindow(win);
+});
+
+add_task(async function testBFCache() {
+ Services.prefs.setIntPref(AUTOPLAY_PREF, Ci.nsIAutoplay.BLOCKED);
+
+ await BrowserTestUtils.withNewTab("about:home", async function (browser) {
+ BrowserTestUtils.loadURIString(browser, AUTOPLAY_PAGE);
+ await blockedIconShown();
+
+ gBrowser.goBack();
+ await blockedIconHidden();
+
+ // Not sure why using `gBrowser.goForward()` doesn't trigger document's
+ // visibility changes in some debug build on try server, which makes us not
+ // to receive the blocked event.
+ await SpecialPowers.spawn(browser, [], () => {
+ content.history.forward();
+ });
+ await blockedIconShown();
+ });
+
+ Services.perms.removeAll();
+});
+
+add_task(async function testBlockedIconFromCORSIframe() {
+ Services.prefs.setIntPref(AUTOPLAY_PREF, Ci.nsIAutoplay.BLOCKED);
+
+ await BrowserTestUtils.withNewTab(EMPTY_PAGE, async browser => {
+ const blockedIconShownPromise = blockedIconShown();
+ const CORS_AUTOPLAY_PAGE = AUTOPLAY_PAGE.replace(
+ "example.com",
+ "example.org"
+ );
+ info(`Load CORS autoplay on an iframe`);
+ await SpecialPowers.spawn(browser, [CORS_AUTOPLAY_PAGE], async url => {
+ const iframe = content.document.createElement("iframe");
+ iframe.src = url;
+ content.document.body.appendChild(iframe);
+ info("Wait until iframe finishes loading");
+ await new Promise(r => (iframe.onload = r));
+ });
+ await blockedIconShownPromise;
+ ok(true, "Blocked icon shown for the CORS autoplay iframe");
+ });
+
+ Services.perms.removeAll();
+});
+
+add_task(async function testChangingBlockingSettingDuringNavigation() {
+ Services.prefs.setIntPref(AUTOPLAY_PREF, Ci.nsIAutoplay.BLOCKED);
+
+ await BrowserTestUtils.withNewTab("about:home", async function (browser) {
+ await blockedIconHidden();
+ BrowserTestUtils.loadURIString(browser, AUTOPLAY_PAGE);
+ await blockedIconShown();
+ Services.prefs.setIntPref(AUTOPLAY_PREF, Ci.nsIAutoplay.ALLOWED);
+
+ gBrowser.goBack();
+ await blockedIconHidden();
+
+ gBrowser.goForward();
+
+ // Sleep here to prevent false positives, the icon gets shown with an
+ // async `GloballyAutoplayBlocked` event. The sleep gives it a little
+ // time for it to show otherwise there is a chance it passes before it
+ // would have shown.
+ await sleep(100);
+ ok(
+ BrowserTestUtils.is_hidden(autoplayBlockedIcon()),
+ "Blocked icon is hidden"
+ );
+ });
+
+ Services.perms.removeAll();
+});
+
+add_task(async function testSlowLoadingPage() {
+ Services.prefs.setIntPref(AUTOPLAY_PREF, Ci.nsIAutoplay.BLOCKED);
+
+ let tab1 = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:home"
+ );
+ let tab2 = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ SLOW_AUTOPLAY_PAGE
+ );
+ await blockedIconShown();
+
+ await BrowserTestUtils.switchTab(gBrowser, tab1);
+ // Wait until the blocked icon is hidden by switching tabs
+ await blockedIconHidden();
+ await BrowserTestUtils.switchTab(gBrowser, tab2);
+ await blockedIconShown();
+
+ BrowserTestUtils.removeTab(tab1);
+ BrowserTestUtils.removeTab(tab2);
+
+ Services.perms.removeAll();
+});
+
+add_task(async function testBlockedAll() {
+ Services.prefs.setIntPref(AUTOPLAY_PREF, Ci.nsIAutoplay.BLOCKED_ALL);
+
+ await BrowserTestUtils.withNewTab("about:home", async function (browser) {
+ await blockedIconHidden();
+ BrowserTestUtils.loadURIString(browser, MUTED_AUTOPLAY_PAGE);
+ await blockedIconShown();
+
+ await openPermissionPopup();
+
+ Assert.equal(
+ permissionListBlockedIcons().length,
+ 1,
+ "Blocked icon is shown"
+ );
+
+ let menulist = document.getElementById("permission-popup-menulist");
+ await EventUtils.synthesizeMouseAtCenter(menulist, { type: "mousedown" });
+ await TestUtils.waitForCondition(() => {
+ return (
+ menulist.getElementsByTagName("menuitem")[1].label === "Block Audio"
+ );
+ });
+
+ let menuitem = menulist.getElementsByTagName("menuitem")[0];
+ menuitem.click();
+ menulist.menupopup.hidePopup();
+ await closePermissionPopup();
+ gBrowser.reload();
+ await blockedIconHidden();
+ });
+ Services.perms.removeAll();
+});
+
+add_task(async function testMultiplePlayNotificationsFromJS() {
+ Services.prefs.setIntPref(AUTOPLAY_PREF, Ci.nsIAutoplay.BLOCKED);
+
+ await BrowserTestUtils.withNewTab("about:home", async function (browser) {
+ let count = 0;
+ browser.addEventListener("GloballyAutoplayBlocked", function () {
+ is(++count, 1, "Shouldn't get more than one autoplay blocked event");
+ });
+
+ await blockedIconHidden();
+
+ BrowserTestUtils.loadURIString(browser, AUTOPLAY_JS_PAGE);
+
+ await blockedIconShown();
+
+ // Sleep here a bit to ensure that multiple events don't arrive.
+ await sleep(100);
+
+ is(count, 1, "Shouldn't have got more events");
+ });
+
+ Services.perms.removeAll();
+});
diff --git a/browser/base/content/test/permissions/browser_autoplay_blocked_slow.sjs b/browser/base/content/test/permissions/browser_autoplay_blocked_slow.sjs
new file mode 100644
index 0000000000..12929760f7
--- /dev/null
+++ b/browser/base/content/test/permissions/browser_autoplay_blocked_slow.sjs
@@ -0,0 +1,36 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const DELAY_MS = 200;
+
+const AUTOPLAY_HTML = `<!DOCTYPE HTML>
+<html dir="ltr" xml:lang="en-US" lang="en-US">
+ <head>
+ <meta charset="utf8">
+ </head>
+ <body>
+ <audio autoplay="autoplay" >
+ <source src="audio.ogg" />
+ </audio>
+ <script>
+ document.location.href = '#foo';
+ </script>
+ </body>
+</html>`;
+
+function handleRequest(req, resp) {
+ resp.processAsync();
+ resp.setHeader("Cache-Control", "no-cache", false);
+ resp.setHeader("Content-Type", "text/html;charset=utf-8", false);
+
+ let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+ resp.write(AUTOPLAY_HTML);
+ timer.init(
+ () => {
+ resp.write("");
+ resp.finish();
+ },
+ DELAY_MS,
+ Ci.nsITimer.TYPE_ONE_SHOT
+ );
+}
diff --git a/browser/base/content/test/permissions/browser_autoplay_js.html b/browser/base/content/test/permissions/browser_autoplay_js.html
new file mode 100644
index 0000000000..9782487ee9
--- /dev/null
+++ b/browser/base/content/test/permissions/browser_autoplay_js.html
@@ -0,0 +1,16 @@
+<!DOCTYPE HTML>
+<!-- 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/. -->
+<meta charset="utf8">
+<audio>
+ <source src="audio.ogg" />
+</audio>
+<script>
+onload = function() {
+ let audio = document.querySelector("audio");
+ for (let i = 0; i < 100; ++i) {
+ audio.play();
+ }
+};
+</script>
diff --git a/browser/base/content/test/permissions/browser_autoplay_muted.html b/browser/base/content/test/permissions/browser_autoplay_muted.html
new file mode 100644
index 0000000000..4f9d1ca846
--- /dev/null
+++ b/browser/base/content/test/permissions/browser_autoplay_muted.html
@@ -0,0 +1,14 @@
+<!DOCTYPE HTML>
+<!-- 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/. -->
+<html dir="ltr" xml:lang="en-US" lang="en-US">
+ <head>
+ <meta charset="utf8">
+ </head>
+ <body>
+ <audio autoplay="autoplay" muted>
+ <source src="audio.ogg" />
+ </audio>
+ </body>
+</html>
diff --git a/browser/base/content/test/permissions/browser_canvas_fingerprinting_resistance.js b/browser/base/content/test/permissions/browser_canvas_fingerprinting_resistance.js
new file mode 100644
index 0000000000..dbb2d1ea32
--- /dev/null
+++ b/browser/base/content/test/permissions/browser_canvas_fingerprinting_resistance.js
@@ -0,0 +1,383 @@
+/**
+ * When "privacy.resistFingerprinting" is set to true, user permission is
+ * required for canvas data extraction.
+ * This tests whether the site permission prompt for canvas data extraction
+ * works properly.
+ * When "privacy.resistFingerprinting.randomDataOnCanvasExtract" is true,
+ * canvas data extraction results in random data, and when it is false, canvas
+ * data extraction results in all-white data.
+ */
+"use strict";
+
+const kUrl = "https://example.com/";
+const kPrincipal = Services.scriptSecurityManager.createContentPrincipal(
+ Services.io.newURI(kUrl),
+ {}
+);
+const kPermission = "canvas";
+
+function initTab() {
+ let contentWindow = content.wrappedJSObject;
+
+ let drawCanvas = (fillStyle, id) => {
+ let contentDocument = contentWindow.document;
+ let width = 64,
+ height = 64;
+ let canvas = contentDocument.createElement("canvas");
+ if (id) {
+ canvas.setAttribute("id", id);
+ }
+ canvas.setAttribute("width", width);
+ canvas.setAttribute("height", height);
+ contentDocument.body.appendChild(canvas);
+
+ let context = canvas.getContext("2d");
+ context.fillStyle = fillStyle;
+ context.fillRect(0, 0, width, height);
+
+ if (id) {
+ let button = contentDocument.createElement("button");
+ button.addEventListener("click", function () {
+ canvas.toDataURL();
+ });
+ button.setAttribute("id", "clickme");
+ button.innerHTML = "Click Me!";
+ contentDocument.body.appendChild(button);
+ }
+
+ return canvas;
+ };
+
+ let placeholder = drawCanvas("white");
+ contentWindow.kPlaceholderData = placeholder.toDataURL();
+ let canvas = drawCanvas("cyan", "canvas-id-canvas");
+ contentWindow.kPlacedData = canvas.toDataURL();
+ is(
+ canvas.toDataURL(),
+ contentWindow.kPlacedData,
+ "privacy.resistFingerprinting = false, canvas data == placed data"
+ );
+ isnot(
+ canvas.toDataURL(),
+ contentWindow.kPlaceholderData,
+ "privacy.resistFingerprinting = false, canvas data != placeholder data"
+ );
+}
+
+function enableResistFingerprinting(
+ randomDataOnCanvasExtract,
+ autoDeclineNoInput
+) {
+ return SpecialPowers.pushPrefEnv({
+ set: [
+ ["privacy.resistFingerprinting", true],
+ [
+ "privacy.resistFingerprinting.randomDataOnCanvasExtract",
+ randomDataOnCanvasExtract,
+ ],
+ [
+ "privacy.resistFingerprinting.autoDeclineNoUserInputCanvasPrompts",
+ autoDeclineNoInput,
+ ],
+ ],
+ });
+}
+
+function promisePopupShown() {
+ return BrowserTestUtils.waitForEvent(PopupNotifications.panel, "popupshown");
+}
+
+function promisePopupHidden() {
+ return BrowserTestUtils.waitForEvent(PopupNotifications.panel, "popuphidden");
+}
+
+function extractCanvasData(randomDataOnCanvasExtract, grantPermission) {
+ let contentWindow = content.wrappedJSObject;
+ let canvas = contentWindow.document.getElementById("canvas-id-canvas");
+ let canvasData = canvas.toDataURL();
+ if (grantPermission) {
+ is(
+ canvasData,
+ contentWindow.kPlacedData,
+ "privacy.resistFingerprinting = true, permission granted, canvas data == placed data"
+ );
+ if (!randomDataOnCanvasExtract) {
+ isnot(
+ canvasData,
+ contentWindow.kPlaceholderData,
+ "privacy.resistFingerprinting = true and randomDataOnCanvasExtract = false, permission granted, canvas data != placeholderdata"
+ );
+ }
+ } else if (grantPermission === false) {
+ isnot(
+ canvasData,
+ contentWindow.kPlacedData,
+ "privacy.resistFingerprinting = true, permission denied, canvas data != placed data"
+ );
+ if (!randomDataOnCanvasExtract) {
+ is(
+ canvasData,
+ contentWindow.kPlaceholderData,
+ "privacy.resistFingerprinting = true and randomDataOnCanvasExtract = false, permission denied, canvas data == placeholderdata"
+ );
+ } else {
+ isnot(
+ canvasData,
+ contentWindow.kPlaceholderData,
+ "privacy.resistFingerprinting = true and randomDataOnCanvasExtract = true, permission denied, canvas data != placeholderdata"
+ );
+ }
+ } else {
+ isnot(
+ canvasData,
+ contentWindow.kPlacedData,
+ "privacy.resistFingerprinting = true, requesting permission, canvas data != placed data"
+ );
+ if (!randomDataOnCanvasExtract) {
+ is(
+ canvasData,
+ contentWindow.kPlaceholderData,
+ "privacy.resistFingerprinting = true and randomDataOnCanvasExtract = false, requesting permission, canvas data == placeholderdata"
+ );
+ } else {
+ isnot(
+ canvasData,
+ contentWindow.kPlaceholderData,
+ "privacy.resistFingerprinting = true and randomDataOnCanvasExtract = true, requesting permission, canvas data != placeholderdata"
+ );
+ }
+ }
+}
+
+function triggerCommand(button) {
+ let notifications = PopupNotifications.panel.children;
+ let notification = notifications[0];
+ EventUtils.synthesizeMouseAtCenter(notification[button], {});
+}
+
+function triggerMainCommand() {
+ triggerCommand("button");
+}
+
+function triggerSecondaryCommand() {
+ triggerCommand("secondaryButton");
+}
+
+function testPermission() {
+ return Services.perms.testPermissionFromPrincipal(kPrincipal, kPermission);
+}
+
+async function withNewTabNoInput(
+ randomDataOnCanvasExtract,
+ grantPermission,
+ browser
+) {
+ await SpecialPowers.spawn(browser, [], initTab);
+ await enableResistFingerprinting(randomDataOnCanvasExtract, false);
+ let popupShown = promisePopupShown();
+ await SpecialPowers.spawn(
+ browser,
+ [randomDataOnCanvasExtract],
+ extractCanvasData
+ );
+ await popupShown;
+ let popupHidden = promisePopupHidden();
+ if (grantPermission) {
+ triggerMainCommand();
+ await popupHidden;
+ is(testPermission(), Services.perms.ALLOW_ACTION, "permission granted");
+ } else {
+ triggerSecondaryCommand();
+ await popupHidden;
+ is(testPermission(), Services.perms.DENY_ACTION, "permission denied");
+ }
+ await SpecialPowers.spawn(
+ browser,
+ [randomDataOnCanvasExtract, grantPermission],
+ extractCanvasData
+ );
+ await SpecialPowers.popPrefEnv();
+}
+
+async function doTestNoInput(randomDataOnCanvasExtract, grantPermission) {
+ await BrowserTestUtils.withNewTab(
+ kUrl,
+ withNewTabNoInput.bind(null, randomDataOnCanvasExtract, grantPermission)
+ );
+ Services.perms.removeFromPrincipal(kPrincipal, kPermission);
+}
+
+// With auto-declining disabled (not the default)
+// Tests clicking "Don't Allow" button of the permission prompt.
+add_task(doTestNoInput.bind(null, true, false));
+add_task(doTestNoInput.bind(null, false, false));
+
+// Tests clicking "Allow" button of the permission prompt.
+add_task(doTestNoInput.bind(null, true, true));
+add_task(doTestNoInput.bind(null, false, true));
+
+async function withNewTabAutoBlockNoInput(randomDataOnCanvasExtract, browser) {
+ await SpecialPowers.spawn(browser, [], initTab);
+ await enableResistFingerprinting(randomDataOnCanvasExtract, true);
+
+ let noShowHandler = () => {
+ ok(false, "The popup notification should not show in this case.");
+ };
+ PopupNotifications.panel.addEventListener("popupshown", noShowHandler, {
+ once: true,
+ });
+
+ let promisePopupObserver = TestUtils.topicObserved(
+ "PopupNotifications-updateNotShowing"
+ );
+
+ // Try to extract canvas data without user inputs.
+ await SpecialPowers.spawn(
+ browser,
+ [randomDataOnCanvasExtract],
+ extractCanvasData
+ );
+
+ await promisePopupObserver;
+ info("There should be no popup shown on the panel.");
+
+ // Check that the icon of canvas permission is shown.
+ let canvasNotification = PopupNotifications.getNotification(
+ "canvas-permissions-prompt",
+ browser
+ );
+
+ is(
+ canvasNotification.anchorElement.getAttribute("showing"),
+ "true",
+ "The canvas permission icon is correctly shown."
+ );
+ PopupNotifications.panel.removeEventListener("popupshown", noShowHandler);
+
+ await SpecialPowers.popPrefEnv();
+}
+
+async function doTestAutoBlockNoInput(randomDataOnCanvasExtract) {
+ await BrowserTestUtils.withNewTab(
+ kUrl,
+ withNewTabAutoBlockNoInput.bind(null, randomDataOnCanvasExtract)
+ );
+}
+
+add_task(doTestAutoBlockNoInput.bind(null, true));
+add_task(doTestAutoBlockNoInput.bind(null, false));
+
+function extractCanvasDataUserInput(
+ randomDataOnCanvasExtract,
+ grantPermission
+) {
+ let contentWindow = content.wrappedJSObject;
+ let canvas = contentWindow.document.getElementById("canvas-id-canvas");
+ let canvasData = canvas.toDataURL();
+ if (grantPermission) {
+ is(
+ canvasData,
+ contentWindow.kPlacedData,
+ "privacy.resistFingerprinting = true, permission granted, canvas data == placed data"
+ );
+ if (!randomDataOnCanvasExtract) {
+ isnot(
+ canvasData,
+ contentWindow.kPlaceholderData,
+ "privacy.resistFingerprinting = true and randomDataOnCanvasExtract = false, permission granted, canvas data != placeholderdata"
+ );
+ }
+ } else if (grantPermission === false) {
+ isnot(
+ canvasData,
+ contentWindow.kPlacedData,
+ "privacy.resistFingerprinting = true, permission denied, canvas data != placed data"
+ );
+ if (!randomDataOnCanvasExtract) {
+ is(
+ canvasData,
+ contentWindow.kPlaceholderData,
+ "privacy.resistFingerprinting = true and randomDataOnCanvasExtract = false, permission denied, canvas data == placeholderdata"
+ );
+ } else {
+ isnot(
+ canvasData,
+ contentWindow.kPlaceholderData,
+ "privacy.resistFingerprinting = true and randomDataOnCanvasExtract = true, permission denied, canvas data != placeholderdata"
+ );
+ }
+ } else {
+ isnot(
+ canvasData,
+ contentWindow.kPlacedData,
+ "privacy.resistFingerprinting = true, requesting permission, canvas data != placed data"
+ );
+ if (!randomDataOnCanvasExtract) {
+ is(
+ canvasData,
+ contentWindow.kPlaceholderData,
+ "privacy.resistFingerprinting = true and randomDataOnCanvasExtract = false, requesting permission, canvas data == placeholderdata"
+ );
+ } else {
+ isnot(
+ canvasData,
+ contentWindow.kPlaceholderData,
+ "privacy.resistFingerprinting = true and randomDataOnCanvasExtract = true, requesting permission, canvas data != placeholderdata"
+ );
+ }
+ }
+}
+
+async function withNewTabInput(
+ randomDataOnCanvasExtract,
+ grantPermission,
+ browser
+) {
+ await SpecialPowers.spawn(browser, [], initTab);
+ await enableResistFingerprinting(randomDataOnCanvasExtract, true);
+ let popupShown = promisePopupShown();
+ await SpecialPowers.spawn(browser, [], function (host) {
+ E10SUtils.wrapHandlingUserInput(content, true, function () {
+ var button = content.document.getElementById("clickme");
+ button.click();
+ });
+ });
+ await popupShown;
+ let popupHidden = promisePopupHidden();
+ if (grantPermission) {
+ triggerMainCommand();
+ await popupHidden;
+ is(testPermission(), Services.perms.ALLOW_ACTION, "permission granted");
+ } else {
+ triggerSecondaryCommand();
+ await popupHidden;
+ is(testPermission(), Services.perms.DENY_ACTION, "permission denied");
+ }
+ await SpecialPowers.spawn(
+ browser,
+ [randomDataOnCanvasExtract, grantPermission],
+ extractCanvasDataUserInput
+ );
+ await SpecialPowers.popPrefEnv();
+}
+
+async function doTestInput(
+ randomDataOnCanvasExtract,
+ grantPermission,
+ autoDeclineNoInput
+) {
+ await BrowserTestUtils.withNewTab(
+ kUrl,
+ withNewTabInput.bind(null, randomDataOnCanvasExtract, grantPermission)
+ );
+ Services.perms.removeFromPrincipal(kPrincipal, kPermission);
+}
+
+// With auto-declining enabled (the default)
+// Tests clicking "Don't Allow" button of the permission prompt.
+add_task(doTestInput.bind(null, true, false));
+add_task(doTestInput.bind(null, false, false));
+
+// Tests clicking "Allow" button of the permission prompt.
+add_task(doTestInput.bind(null, true, true));
+add_task(doTestInput.bind(null, false, true));
diff --git a/browser/base/content/test/permissions/browser_canvas_rfp_exclusion.js b/browser/base/content/test/permissions/browser_canvas_rfp_exclusion.js
new file mode 100644
index 0000000000..61c9c5bf84
--- /dev/null
+++ b/browser/base/content/test/permissions/browser_canvas_rfp_exclusion.js
@@ -0,0 +1,194 @@
+/* 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/.
+ *
+ * Adapted from browser_canvas_fingerprinting_resistance.js
+ */
+"use strict";
+
+const kUrl = "https://example.com/";
+var gPlacedData = false;
+
+function initTab(performReadbackTest) {
+ let contentWindow = content.wrappedJSObject;
+
+ let drawCanvas = (fillStyle, id) => {
+ let contentDocument = contentWindow.document;
+ let width = 64,
+ height = 64;
+ let canvas = contentDocument.createElement("canvas");
+ if (id) {
+ canvas.setAttribute("id", id);
+ }
+ canvas.setAttribute("width", width);
+ canvas.setAttribute("height", height);
+ contentDocument.body.appendChild(canvas);
+
+ let context = canvas.getContext("2d");
+ context.fillStyle = fillStyle;
+ context.fillRect(0, 0, width, height);
+ return canvas;
+ };
+
+ let canvas = drawCanvas("cyan", "canvas-id-canvas");
+
+ let placedData = canvas.toDataURL();
+ if (performReadbackTest) {
+ is(
+ canvas.toDataURL(),
+ placedData,
+ "Reading the placed data twice didn't match"
+ );
+ return placedData;
+ }
+ return undefined;
+}
+
+function disableResistFingerprinting() {
+ return SpecialPowers.pushPrefEnv({
+ set: [
+ ["privacy.resistFingerprinting", false],
+ ["privacy.resistFingerprinting.pbmode", false],
+ ],
+ });
+}
+
+function enableResistFingerprinting(RfpNonPbmExclusion, RfpDomainExclusion) {
+ if (RfpNonPbmExclusion && RfpDomainExclusion) {
+ return SpecialPowers.pushPrefEnv({
+ set: [
+ ["privacy.resistFingerprinting.pbmode", true],
+ ["privacy.resistFingerprinting.exemptedDomains", "example.com"],
+ ],
+ });
+ } else if (RfpNonPbmExclusion) {
+ return SpecialPowers.pushPrefEnv({
+ set: [["privacy.resistFingerprinting.pbmode", true]],
+ });
+ } else if (RfpDomainExclusion) {
+ return SpecialPowers.pushPrefEnv({
+ set: [
+ ["privacy.resistFingerprinting", true],
+ ["privacy.resistFingerprinting.exemptedDomains", "example.com"],
+ ],
+ });
+ }
+ return SpecialPowers.pushPrefEnv({
+ set: [["privacy.resistFingerprinting", true]],
+ });
+}
+
+function extractCanvasData(
+ placedData,
+ isPbm,
+ RfpNonPbmExclusion,
+ RfpDomainExclusion
+) {
+ let contentWindow = content.wrappedJSObject;
+ let canvas = contentWindow.document.getElementById("canvas-id-canvas");
+ let canvasData = canvas.toDataURL();
+
+ if (RfpDomainExclusion) {
+ is(
+ canvasData,
+ placedData,
+ `A: RFP, domain exempted, canvas data == placed data (isPbm: ${isPbm}, RfpNonPbmExclusion: ${RfpNonPbmExclusion}, RfpDomainExclusion: ${RfpDomainExclusion})`
+ );
+ } else if (!isPbm && RfpNonPbmExclusion) {
+ is(
+ canvasData,
+ placedData,
+ `B: RFP, nonPBM exempted, not in PBM, canvas data == placed data (isPbm: ${isPbm}, RfpNonPbmExclusion: ${RfpNonPbmExclusion}, RfpDomainExclusion: ${RfpDomainExclusion})`
+ );
+ } else if (isPbm && RfpNonPbmExclusion) {
+ isnot(
+ canvasData,
+ placedData,
+ `C: RFP, nonPBM exempted, in PBM, canvas data != placed data (isPbm: ${isPbm}, RfpNonPbmExclusion: ${RfpNonPbmExclusion}, RfpDomainExclusion: ${RfpDomainExclusion})`
+ );
+ } else {
+ isnot(
+ canvasData,
+ placedData,
+ `D: RFP, domain not exempted, nonPBM not exempted, canvas data != placed data (isPbm: ${isPbm}, RfpNonPbmExclusion: ${RfpNonPbmExclusion}, RfpDomainExclusion: ${RfpDomainExclusion})`
+ );
+ }
+}
+
+async function populatePlacedData() {
+ let win = await BrowserTestUtils.openNewBrowserWindow();
+ await disableResistFingerprinting();
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser: win.gBrowser,
+ url: kUrl,
+ },
+ async function () {
+ let browser = win.gBrowser.selectedBrowser;
+ gPlacedData = await SpecialPowers.spawn(
+ browser,
+ [/* performReadbackTest= */ true],
+ initTab
+ );
+ }
+ );
+ await BrowserTestUtils.closeWindow(win);
+ await SpecialPowers.popPrefEnv();
+}
+
+async function rfpExclusionTestOnCanvas(
+ win,
+ placedData,
+ isPbm,
+ RfpNonPbmExclusion,
+ RfpDomainExclusion
+) {
+ let browser = win.gBrowser.selectedBrowser;
+ await SpecialPowers.spawn(
+ browser,
+ [/* performReadbackTest= */ false],
+ initTab
+ );
+ await SpecialPowers.spawn(
+ browser,
+ [placedData, isPbm, RfpNonPbmExclusion, RfpDomainExclusion],
+ extractCanvasData
+ );
+}
+
+async function testCanvasRfpExclusion(
+ isPbm,
+ RfpNonPbmExclusion,
+ RfpDomainExclusion
+) {
+ let win = await BrowserTestUtils.openNewBrowserWindow({
+ private: isPbm,
+ });
+ await enableResistFingerprinting(RfpNonPbmExclusion, RfpDomainExclusion);
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser: win.gBrowser,
+ url: kUrl,
+ },
+ rfpExclusionTestOnCanvas.bind(
+ null,
+ win,
+ gPlacedData,
+ isPbm,
+ RfpNonPbmExclusion,
+ RfpDomainExclusion
+ )
+ );
+ await BrowserTestUtils.closeWindow(win);
+ await SpecialPowers.popPrefEnv();
+}
+
+add_task(populatePlacedData.bind(null));
+add_task(testCanvasRfpExclusion.bind(null, false, false, false));
+add_task(testCanvasRfpExclusion.bind(null, false, false, true));
+add_task(testCanvasRfpExclusion.bind(null, false, true, false));
+add_task(testCanvasRfpExclusion.bind(null, false, true, true));
+add_task(testCanvasRfpExclusion.bind(null, true, false, false));
+add_task(testCanvasRfpExclusion.bind(null, true, false, true));
+add_task(testCanvasRfpExclusion.bind(null, true, true, false));
+add_task(testCanvasRfpExclusion.bind(null, true, true, true));
diff --git a/browser/base/content/test/permissions/browser_permission_delegate_geo.js b/browser/base/content/test/permissions/browser_permission_delegate_geo.js
new file mode 100644
index 0000000000..45e78d4519
--- /dev/null
+++ b/browser/base/content/test/permissions/browser_permission_delegate_geo.js
@@ -0,0 +1,279 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const ORIGIN = "https://example.com";
+const CROSS_SUBFRAME_PAGE =
+ getRootDirectory(gTestPath).replace("chrome://mochitests/content", ORIGIN) +
+ "temporary_permissions_subframe.html";
+
+const CROSS_FRAME_PAGE =
+ getRootDirectory(gTestPath).replace("chrome://mochitests/content", ORIGIN) +
+ "temporary_permissions_frame.html";
+
+const PromptResult = {
+ ALLOW: "allow",
+ DENY: "deny",
+ PROMPT: "prompt",
+};
+
+var Perms = Services.perms;
+var uri = NetUtil.newURI(ORIGIN);
+var principal = Services.scriptSecurityManager.createContentPrincipal(uri, {});
+
+async function checkNotificationBothOrigins(
+ firstPartyOrigin,
+ thirdPartyOrigin
+) {
+ // Notification is shown, check label and deny to clean
+ let popuphidden = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popuphidden"
+ );
+
+ let notification = PopupNotifications.panel.firstElementChild;
+ // Check the label of the notificaiton should be the first party
+ is(
+ PopupNotifications.getNotification("geolocation").options.name,
+ firstPartyOrigin,
+ "Use first party's origin"
+ );
+
+ // Check the second name of the notificaiton should be the third party
+ is(
+ PopupNotifications.getNotification("geolocation").options.secondName,
+ thirdPartyOrigin,
+ "Use third party's origin"
+ );
+
+ // Check remember checkbox is hidden
+ let checkbox = notification.checkbox;
+ ok(!!checkbox, "checkbox is present");
+ ok(checkbox.hidden, "checkbox is not visible");
+ ok(!checkbox.checked, "checkbox not checked");
+
+ EventUtils.synthesizeMouseAtCenter(notification.secondaryButton, {});
+ await popuphidden;
+}
+
+async function checkGeolocation(browser, frameId, expect) {
+ let isPrompt = expect == PromptResult.PROMPT;
+ let waitForPrompt;
+ if (isPrompt) {
+ waitForPrompt = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popupshown"
+ );
+ }
+
+ await SpecialPowers.spawn(
+ browser,
+ [{ frameId, expect, isPrompt }],
+ async args => {
+ let frame = content.document.getElementById(args.frameId);
+
+ let waitForNoPrompt = new Promise(resolve => {
+ function onMessage(event) {
+ // Check the result right here because there's no notification
+ Assert.equal(
+ event.data,
+ args.expect,
+ "Correct expectation for third party"
+ );
+ content.window.removeEventListener("message", onMessage);
+ resolve();
+ }
+
+ if (!args.isPrompt) {
+ content.window.addEventListener("message", onMessage);
+ }
+ });
+
+ await content.SpecialPowers.spawn(frame, [], async () => {
+ const { E10SUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/E10SUtils.sys.mjs"
+ );
+
+ E10SUtils.wrapHandlingUserInput(this.content, true, function () {
+ let frameDoc = this.content.document;
+ frameDoc.getElementById("geo").click();
+ });
+ });
+
+ if (!args.isPrompt) {
+ await waitForNoPrompt;
+ }
+ }
+ );
+
+ if (isPrompt) {
+ await waitForPrompt;
+ }
+}
+
+add_setup(async function () {
+ await new Promise(r => {
+ SpecialPowers.pushPrefEnv(
+ {
+ set: [
+ ["dom.security.featurePolicy.header.enabled", true],
+ ["dom.security.featurePolicy.webidl.enabled", true],
+ ["permissions.delegation.enabled", true],
+ // This is the amount of time before the repeating
+ // NetworkGeolocationProvider timer is stopped.
+ // It needs to be less than 5000ms, or the timer will be
+ // reported as left behind by the test.
+ ["geo.timeout", 4000],
+ ],
+ },
+ r
+ );
+ });
+});
+
+// Test that temp blocked permissions in first party affect the third party
+// iframe.
+add_task(async function testUseTempPermissionsFirstParty() {
+ await BrowserTestUtils.withNewTab(
+ CROSS_SUBFRAME_PAGE,
+ async function (browser) {
+ SitePermissions.setForPrincipal(
+ principal,
+ "geo",
+ SitePermissions.BLOCK,
+ SitePermissions.SCOPE_TEMPORARY,
+ browser
+ );
+
+ await checkGeolocation(browser, "frame", PromptResult.DENY);
+
+ SitePermissions.removeFromPrincipal(principal, "geo", browser);
+ }
+ );
+});
+
+// Test that persistent permissions in first party affect the third party
+// iframe.
+add_task(async function testUsePersistentPermissionsFirstParty() {
+ await BrowserTestUtils.withNewTab(
+ CROSS_SUBFRAME_PAGE,
+ async function (browser) {
+ async function checkPermission(aPermission, aExpect) {
+ PermissionTestUtils.add(uri, "geo", aPermission);
+ await checkGeolocation(browser, "frame", aExpect);
+
+ if (aExpect == PromptResult.PROMPT) {
+ // Notification is shown, check label and deny to clean
+ let popuphidden = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popuphidden"
+ );
+
+ let notification = PopupNotifications.panel.firstElementChild;
+ // Check the label of the notificaiton should be the first party
+ is(
+ PopupNotifications.getNotification("geolocation").options.name,
+ uri.host,
+ "Use first party's origin"
+ );
+
+ EventUtils.synthesizeMouseAtCenter(notification.secondaryButton, {});
+
+ await popuphidden;
+ SitePermissions.removeFromPrincipal(null, "geo", browser);
+ }
+
+ PermissionTestUtils.remove(uri, "geo");
+ }
+
+ await checkPermission(Perms.PROMPT_ACTION, PromptResult.PROMPT);
+ await checkPermission(Perms.DENY_ACTION, PromptResult.DENY);
+ await checkPermission(Perms.ALLOW_ACTION, PromptResult.ALLOW);
+ }
+ );
+});
+
+// Test that we do not prompt for maybe unsafe permission delegation if the
+// origin of the page is the original src origin.
+add_task(async function testPromptInMaybeUnsafePermissionDelegation() {
+ await BrowserTestUtils.withNewTab(
+ CROSS_SUBFRAME_PAGE,
+ async function (browser) {
+ // Persistent allow top level origin
+ PermissionTestUtils.add(uri, "geo", Perms.ALLOW_ACTION);
+
+ await checkGeolocation(browser, "frameAllowsAll", PromptResult.ALLOW);
+
+ SitePermissions.removeFromPrincipal(null, "geo", browser);
+ PermissionTestUtils.remove(uri, "geo");
+ }
+ );
+});
+
+// Test that we should prompt if we are in unsafe permission delegation and
+// change location to origin which is not explicitly trusted. The prompt popup
+// should include both first and third party origin.
+add_task(async function testPromptChangeLocationUnsafePermissionDelegation() {
+ await BrowserTestUtils.withNewTab(
+ CROSS_SUBFRAME_PAGE,
+ async function (browser) {
+ // Persistent allow top level origin
+ PermissionTestUtils.add(uri, "geo", Perms.ALLOW_ACTION);
+
+ let iframe = await SpecialPowers.spawn(browser, [], () => {
+ return content.document.getElementById("frameAllowsAll")
+ .browsingContext;
+ });
+
+ let otherURI =
+ "https://test1.example.com/browser/browser/base/content/test/permissions/permissions.html";
+ let loaded = BrowserTestUtils.browserLoaded(browser, true, otherURI);
+ await SpecialPowers.spawn(iframe, [otherURI], async function (_otherURI) {
+ content.location = _otherURI;
+ });
+ await loaded;
+
+ await checkGeolocation(browser, "frameAllowsAll", PromptResult.PROMPT);
+ await checkNotificationBothOrigins(uri.host, "test1.example.com");
+
+ SitePermissions.removeFromPrincipal(null, "geo", browser);
+ PermissionTestUtils.remove(uri, "geo");
+ }
+ );
+});
+
+// If we are in unsafe permission delegation and the origin is explicitly
+// trusted in ancestor chain. Do not need prompt
+add_task(async function testExplicitlyAllowedInChain() {
+ await BrowserTestUtils.withNewTab(CROSS_FRAME_PAGE, async function (browser) {
+ // Persistent allow top level origin
+ PermissionTestUtils.add(uri, "geo", Perms.ALLOW_ACTION);
+
+ let iframeAncestor = await SpecialPowers.spawn(browser, [], () => {
+ return content.document.getElementById("frameAncestor").browsingContext;
+ });
+
+ let iframe = await SpecialPowers.spawn(iframeAncestor, [], () => {
+ return content.document.getElementById("frameAllowsAll").browsingContext;
+ });
+
+ // Change location to check that we actually look at the ancestor chain
+ // instead of just considering the "same origin as src" rule.
+ let otherURI =
+ "https://test2.example.com/browser/browser/base/content/test/permissions/permissions.html";
+ let loaded = BrowserTestUtils.browserLoaded(browser, true, otherURI);
+ await SpecialPowers.spawn(iframe, [otherURI], async function (_otherURI) {
+ content.location = _otherURI;
+ });
+ await loaded;
+
+ await checkGeolocation(
+ iframeAncestor,
+ "frameAllowsAll",
+ PromptResult.ALLOW
+ );
+
+ PermissionTestUtils.remove(uri, "geo");
+ });
+});
diff --git a/browser/base/content/test/permissions/browser_permissions.js b/browser/base/content/test/permissions/browser_permissions.js
new file mode 100644
index 0000000000..0843ed9119
--- /dev/null
+++ b/browser/base/content/test/permissions/browser_permissions.js
@@ -0,0 +1,569 @@
+/*
+ * Test the Permissions section in the Control Center.
+ */
+
+const PERMISSIONS_PAGE =
+ getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+ ) + "permissions.html";
+
+function testPermListHasEntries(expectEntries) {
+ let permissionsList = document.getElementById(
+ "permission-popup-permission-list"
+ );
+ let listEntryCount = permissionsList.querySelectorAll(
+ ".permission-popup-permission-item"
+ ).length;
+ if (expectEntries) {
+ ok(listEntryCount, "List of permissions is not empty");
+ return;
+ }
+ ok(!listEntryCount, "List of permissions is empty");
+}
+
+add_task(async function testMainViewVisible() {
+ await BrowserTestUtils.withNewTab(PERMISSIONS_PAGE, async function () {
+ await openPermissionPopup();
+
+ let permissionsList = document.getElementById(
+ "permission-popup-permission-list"
+ );
+ testPermListHasEntries(false);
+
+ await closePermissionPopup();
+
+ PermissionTestUtils.add(
+ gBrowser.currentURI,
+ "camera",
+ Services.perms.ALLOW_ACTION
+ );
+
+ await openPermissionPopup();
+
+ testPermListHasEntries(true);
+
+ let labelText = SitePermissions.getPermissionLabel("camera");
+ let labels = permissionsList.querySelectorAll(
+ ".permission-popup-permission-label"
+ );
+ is(labels.length, 1, "One permission visible in main view");
+ is(labels[0].innerHTML, labelText, "Correct value");
+
+ let img = permissionsList.querySelector(
+ "image.permission-popup-permission-icon"
+ );
+ ok(img, "There is an image for the permissions");
+ ok(img.classList.contains("camera-icon"), "proper class is in image class");
+
+ await closePermissionPopup();
+
+ PermissionTestUtils.remove(gBrowser.currentURI, "camera");
+
+ await openPermissionPopup();
+
+ testPermListHasEntries(false);
+
+ await closePermissionPopup();
+ });
+});
+
+add_task(async function testIdentityIcon() {
+ await BrowserTestUtils.withNewTab(PERMISSIONS_PAGE, function () {
+ PermissionTestUtils.add(
+ gBrowser.currentURI,
+ "geo",
+ Services.perms.ALLOW_ACTION
+ );
+
+ ok(
+ gPermissionPanel._identityPermissionBox.hasAttribute("hasPermissions"),
+ "identity-box signals granted permissions"
+ );
+
+ PermissionTestUtils.remove(gBrowser.currentURI, "geo");
+
+ ok(
+ !gPermissionPanel._identityPermissionBox.hasAttribute("hasPermissions"),
+ "identity-box doesn't signal granted permissions"
+ );
+
+ PermissionTestUtils.add(
+ gBrowser.currentURI,
+ "not-a-site-permission",
+ Services.perms.ALLOW_ACTION
+ );
+
+ ok(
+ !gPermissionPanel._identityPermissionBox.hasAttribute("hasPermissions"),
+ "identity-box doesn't signal granted permissions"
+ );
+
+ PermissionTestUtils.add(
+ gBrowser.currentURI,
+ "cookie",
+ Ci.nsICookiePermission.ACCESS_SESSION
+ );
+
+ ok(
+ gPermissionPanel._identityPermissionBox.hasAttribute("hasPermissions"),
+ "identity-box signals granted permissions"
+ );
+
+ PermissionTestUtils.remove(gBrowser.currentURI, "cookie");
+
+ PermissionTestUtils.add(
+ gBrowser.currentURI,
+ "cookie",
+ Ci.nsICookiePermission.ACCESS_DENY
+ );
+
+ ok(
+ gPermissionPanel._identityPermissionBox.hasAttribute("hasPermissions"),
+ "identity-box signals granted permissions"
+ );
+
+ PermissionTestUtils.add(
+ gBrowser.currentURI,
+ "cookie",
+ Ci.nsICookiePermission.ACCESS_DEFAULT
+ );
+
+ ok(
+ !gPermissionPanel._identityPermissionBox.hasAttribute("hasPermissions"),
+ "identity-box doesn't signal granted permissions"
+ );
+
+ PermissionTestUtils.remove(gBrowser.currentURI, "geo");
+ PermissionTestUtils.remove(gBrowser.currentURI, "not-a-site-permission");
+ PermissionTestUtils.remove(gBrowser.currentURI, "cookie");
+ });
+});
+
+add_task(async function testCancelPermission() {
+ await BrowserTestUtils.withNewTab(PERMISSIONS_PAGE, async function () {
+ let permissionsList = document.getElementById(
+ "permission-popup-permission-list"
+ );
+
+ PermissionTestUtils.add(
+ gBrowser.currentURI,
+ "geo",
+ Services.perms.ALLOW_ACTION
+ );
+ PermissionTestUtils.add(
+ gBrowser.currentURI,
+ "camera",
+ Services.perms.DENY_ACTION
+ );
+
+ await openPermissionPopup();
+
+ testPermListHasEntries(true);
+
+ permissionsList
+ .querySelector(".permission-popup-permission-remove-button")
+ .click();
+
+ is(
+ permissionsList.querySelectorAll(".permission-popup-permission-label")
+ .length,
+ 1,
+ "First permission should be removed"
+ );
+
+ permissionsList
+ .querySelector(".permission-popup-permission-remove-button")
+ .click();
+
+ is(
+ permissionsList.querySelectorAll(".permission-popup-permission-label")
+ .length,
+ 0,
+ "Second permission should be removed"
+ );
+
+ await closePermissionPopup();
+ });
+});
+
+add_task(async function testPermissionHints() {
+ await BrowserTestUtils.withNewTab(PERMISSIONS_PAGE, async function (browser) {
+ let permissionsList = document.getElementById(
+ "permission-popup-permission-list"
+ );
+ let reloadHint = document.getElementById(
+ "permission-popup-permission-reload-hint"
+ );
+
+ await openPermissionPopup();
+
+ ok(BrowserTestUtils.is_hidden(reloadHint), "Reload hint is hidden");
+
+ await closePermissionPopup();
+
+ PermissionTestUtils.add(
+ gBrowser.currentURI,
+ "geo",
+ Services.perms.ALLOW_ACTION
+ );
+ PermissionTestUtils.add(
+ gBrowser.currentURI,
+ "camera",
+ Services.perms.DENY_ACTION
+ );
+
+ await openPermissionPopup();
+
+ ok(BrowserTestUtils.is_hidden(reloadHint), "Reload hint is hidden");
+
+ let cancelButtons = permissionsList.querySelectorAll(
+ ".permission-popup-permission-remove-button"
+ );
+ PermissionTestUtils.remove(gBrowser.currentURI, "camera");
+
+ cancelButtons[0].click();
+ ok(!BrowserTestUtils.is_hidden(reloadHint), "Reload hint is visible");
+
+ cancelButtons[1].click();
+ ok(!BrowserTestUtils.is_hidden(reloadHint), "Reload hint is visible");
+
+ await closePermissionPopup();
+ let loaded = BrowserTestUtils.browserLoaded(browser);
+ BrowserTestUtils.loadURIString(browser, PERMISSIONS_PAGE);
+ await loaded;
+ await openPermissionPopup();
+
+ ok(
+ BrowserTestUtils.is_hidden(reloadHint),
+ "Reload hint is hidden after reloading"
+ );
+
+ await closePermissionPopup();
+ });
+});
+
+add_task(async function testPermissionIcons() {
+ await BrowserTestUtils.withNewTab(PERMISSIONS_PAGE, function () {
+ PermissionTestUtils.add(
+ gBrowser.currentURI,
+ "camera",
+ Services.perms.ALLOW_ACTION
+ );
+ PermissionTestUtils.add(
+ gBrowser.currentURI,
+ "geo",
+ Services.perms.DENY_ACTION
+ );
+
+ let geoIcon = gPermissionPanel._identityPermissionBox.querySelector(
+ ".blocked-permission-icon[data-permission-id='geo']"
+ );
+ ok(geoIcon.hasAttribute("showing"), "blocked permission icon is shown");
+
+ let cameraIcon = gPermissionPanel._identityPermissionBox.querySelector(
+ ".blocked-permission-icon[data-permission-id='camera']"
+ );
+ ok(
+ !cameraIcon.hasAttribute("showing"),
+ "allowed permission icon is not shown"
+ );
+
+ PermissionTestUtils.remove(gBrowser.currentURI, "geo");
+
+ ok(
+ !geoIcon.hasAttribute("showing"),
+ "blocked permission icon is not shown after reset"
+ );
+
+ PermissionTestUtils.remove(gBrowser.currentURI, "camera");
+ });
+});
+
+add_task(async function testPermissionShortcuts() {
+ await BrowserTestUtils.withNewTab(PERMISSIONS_PAGE, async function (browser) {
+ browser.focus();
+
+ await new Promise(r => {
+ SpecialPowers.pushPrefEnv(
+ { set: [["permissions.default.shortcuts", 0]] },
+ r
+ );
+ });
+
+ async function tryKey(desc, expectedValue) {
+ await EventUtils.synthesizeAndWaitKey("c", { accelKey: true });
+ let result = await SpecialPowers.spawn(browser, [], function () {
+ return {
+ keydowns: content.wrappedJSObject.gKeyDowns,
+ keypresses: content.wrappedJSObject.gKeyPresses,
+ };
+ });
+ is(
+ result.keydowns,
+ expectedValue,
+ "keydown event was fired or not fired as expected, " + desc
+ );
+ is(
+ result.keypresses,
+ 0,
+ "keypress event shouldn't be fired for shortcut key, " + desc
+ );
+ }
+
+ await tryKey("pressed with default permissions", 1);
+
+ PermissionTestUtils.add(
+ gBrowser.currentURI,
+ "shortcuts",
+ Services.perms.DENY_ACTION
+ );
+ await tryKey("pressed when site blocked", 1);
+
+ PermissionTestUtils.add(
+ gBrowser.currentURI,
+ "shortcuts",
+ PermissionTestUtils.ALLOW
+ );
+ await tryKey("pressed when site allowed", 2);
+
+ PermissionTestUtils.remove(gBrowser.currentURI, "shortcuts");
+ await new Promise(r => {
+ SpecialPowers.pushPrefEnv(
+ { set: [["permissions.default.shortcuts", 2]] },
+ r
+ );
+ });
+
+ await tryKey("pressed when globally blocked", 2);
+ PermissionTestUtils.add(
+ gBrowser.currentURI,
+ "shortcuts",
+ Services.perms.ALLOW_ACTION
+ );
+ await tryKey("pressed when globally blocked but site allowed", 3);
+
+ PermissionTestUtils.add(
+ gBrowser.currentURI,
+ "shortcuts",
+ Services.perms.DENY_ACTION
+ );
+ await tryKey("pressed when globally blocked and site blocked", 3);
+
+ PermissionTestUtils.remove(gBrowser.currentURI, "shortcuts");
+ });
+});
+
+// Test the control center UI when policy permissions are set.
+add_task(async function testPolicyPermission() {
+ await BrowserTestUtils.withNewTab(PERMISSIONS_PAGE, async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [["dom.disable_open_during_load", true]],
+ });
+
+ let permissionsList = document.getElementById(
+ "permission-popup-permission-list"
+ );
+ PermissionTestUtils.add(
+ gBrowser.currentURI,
+ "popup",
+ Services.perms.ALLOW_ACTION,
+ Services.perms.EXPIRE_POLICY
+ );
+
+ await openPermissionPopup();
+
+ // Check if the icon, nameLabel and stateLabel are visible.
+ let img, labelText, labels;
+
+ img = permissionsList.querySelector(
+ "image.permission-popup-permission-icon"
+ );
+ ok(img, "There is an image for the popup permission");
+ ok(img.classList.contains("popup-icon"), "proper class is in image class");
+
+ labelText = SitePermissions.getPermissionLabel("popup");
+ labels = permissionsList.querySelectorAll(
+ ".permission-popup-permission-label"
+ );
+ is(labels.length, 1, "One permission visible in main view");
+ is(labels[0].innerHTML, labelText, "Correct name label value");
+
+ labelText = SitePermissions.getCurrentStateLabel(
+ SitePermissions.ALLOW,
+ SitePermissions.SCOPE_POLICY
+ );
+ labels = permissionsList.querySelectorAll(
+ ".permission-popup-permission-state-label"
+ );
+ is(labels[0].innerHTML, labelText, "Correct state label value");
+
+ // Check if the menulist and the remove button are hidden.
+ // The menulist is specific to the "popup" permission.
+ let menulist = document.getElementById("permission-popup-menulist");
+ ok(menulist == null, "The popup permission menulist is not visible");
+
+ let removeButton = permissionsList.querySelector(
+ ".permission-popup-permission-remove-button"
+ );
+ ok(removeButton == null, "The permission remove button is not visible");
+
+ Services.perms.removeAll();
+ await closePermissionPopup();
+ });
+});
+
+add_task(async function testHiddenAfterRefresh() {
+ await BrowserTestUtils.withNewTab(PERMISSIONS_PAGE, async function (browser) {
+ ok(
+ BrowserTestUtils.is_hidden(gPermissionPanel._permissionPopup),
+ "Popup is hidden"
+ );
+
+ await openPermissionPopup();
+
+ ok(
+ !BrowserTestUtils.is_hidden(gPermissionPanel._permissionPopup),
+ "Popup is shown"
+ );
+
+ let reloaded = BrowserTestUtils.browserLoaded(
+ browser,
+ false,
+ PERMISSIONS_PAGE
+ );
+ EventUtils.synthesizeKey("VK_F5", {}, browser.ownerGlobal);
+ await reloaded;
+
+ ok(
+ BrowserTestUtils.is_hidden(gPermissionPanel._permissionPopup),
+ "Popup is hidden"
+ );
+ });
+});
+
+add_task(async function test3rdPartyStoragePermission() {
+ // 3rdPartyStorage permissions are listed under an anchor container - test
+ // that this works correctly, i.e. the permission items are added to the
+ // anchor when relevant, and other permission items are added to the default
+ // anchor, and adding/removing permissions preserves this behavior correctly.
+
+ await BrowserTestUtils.withNewTab(PERMISSIONS_PAGE, async function (browser) {
+ await openPermissionPopup();
+
+ let permissionsList = document.getElementById(
+ "permission-popup-permission-list"
+ );
+ let storagePermissionAnchor = permissionsList.querySelector(
+ `.permission-popup-permission-list-anchor[anchorfor="3rdPartyStorage"]`
+ );
+
+ testPermListHasEntries(false);
+
+ ok(
+ BrowserTestUtils.is_hidden(storagePermissionAnchor.firstElementChild),
+ "Anchor header is hidden"
+ );
+
+ await closePermissionPopup();
+
+ let storagePermissionID = "3rdPartyStorage^example2.com";
+ PermissionTestUtils.add(
+ browser.currentURI,
+ storagePermissionID,
+ Services.perms.ALLOW_ACTION
+ );
+
+ await openPermissionPopup();
+
+ testPermListHasEntries(true);
+ ok(
+ BrowserTestUtils.is_visible(storagePermissionAnchor.firstElementChild),
+ "Anchor header is visible"
+ );
+
+ let labelText = SitePermissions.getPermissionLabel(storagePermissionID);
+ let labels = storagePermissionAnchor.querySelectorAll(
+ ".permission-popup-permission-label"
+ );
+ is(labels.length, 1, "One permission visible in 3rdPartyStorage anchor");
+ is(
+ labels[0].getAttribute("value"),
+ labelText,
+ "Permission label has the correct value"
+ );
+
+ await closePermissionPopup();
+
+ PermissionTestUtils.add(
+ browser.currentURI,
+ "camera",
+ Services.perms.ALLOW_ACTION
+ );
+
+ await openPermissionPopup();
+
+ testPermListHasEntries(true);
+ ok(
+ BrowserTestUtils.is_visible(storagePermissionAnchor.firstElementChild),
+ "Anchor header is visible"
+ );
+
+ labels = permissionsList.querySelectorAll(
+ ".permission-popup-permission-label"
+ );
+ is(labels.length, 2, "Two permissions visible in main view");
+ labels = storagePermissionAnchor.querySelectorAll(
+ ".permission-popup-permission-label"
+ );
+ is(labels.length, 1, "One permission visible in 3rdPartyStorage anchor");
+
+ storagePermissionAnchor
+ .querySelector(".permission-popup-permission-remove-button")
+ .click();
+ is(
+ storagePermissionAnchor.querySelectorAll(
+ ".permission-popup-permission-label"
+ ).length,
+ 0,
+ "Permission item should be removed"
+ );
+ is(
+ PermissionTestUtils.testPermission(
+ browser.currentURI,
+ storagePermissionID
+ ),
+ SitePermissions.UNKNOWN,
+ "Permission removed from permission manager"
+ );
+
+ await closePermissionPopup();
+
+ await openPermissionPopup();
+
+ testPermListHasEntries(true);
+ ok(
+ BrowserTestUtils.is_hidden(storagePermissionAnchor.firstElementChild),
+ "Anchor header is hidden"
+ );
+
+ labels = permissionsList.querySelectorAll(
+ ".permission-popup-permission-label"
+ );
+ is(labels.length, 1, "One permission visible in main view");
+
+ await closePermissionPopup();
+
+ PermissionTestUtils.remove(browser.currentURI, "camera");
+
+ await openPermissionPopup();
+
+ testPermListHasEntries(false);
+ ok(
+ BrowserTestUtils.is_hidden(storagePermissionAnchor.firstElementChild),
+ "Anchor header is hidden"
+ );
+
+ await closePermissionPopup();
+ });
+});
diff --git a/browser/base/content/test/permissions/browser_permissions_delegate_vibrate.js b/browser/base/content/test/permissions/browser_permissions_delegate_vibrate.js
new file mode 100644
index 0000000000..53be5cc175
--- /dev/null
+++ b/browser/base/content/test/permissions/browser_permissions_delegate_vibrate.js
@@ -0,0 +1,46 @@
+/**
+ * Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+const TEST_PAGE =
+ "https://example.com/browser/browser/base/content/test/permissions/empty.html";
+
+add_task(async function testNoPermissionPrompt() {
+ info("Creating tab");
+ await BrowserTestUtils.withNewTab(TEST_PAGE, async function (browser) {
+ await new Promise(r => {
+ SpecialPowers.pushPrefEnv(
+ {
+ set: [
+ ["permissions.delegation.enabled", true],
+ ["dom.vibrator.enabled", true],
+ ["dom.security.featurePolicy.header.enabled", true],
+ ["dom.security.featurePolicy.webidl.enabled", true],
+ ],
+ },
+ r
+ );
+ });
+
+ await ContentTask.spawn(browser, null, async function () {
+ let frame = content.document.createElement("iframe");
+ // Cross origin src
+ frame.src =
+ "https://example.org/browser/browser/base/content/test/permissions/empty.html";
+ await new Promise(resolve => {
+ frame.addEventListener("load", () => {
+ resolve();
+ });
+ content.document.body.appendChild(frame);
+ });
+
+ await content.SpecialPowers.spawn(frame, [], async function () {
+ // Request a permission.
+ let result = this.content.navigator.vibrate([100, 100]);
+ Assert.equal(result, false, "navigator.vibrate has been denied");
+ });
+ content.document.body.removeChild(frame);
+ });
+ });
+});
diff --git a/browser/base/content/test/permissions/browser_permissions_handling_user_input.js b/browser/base/content/test/permissions/browser_permissions_handling_user_input.js
new file mode 100644
index 0000000000..94b69c4998
--- /dev/null
+++ b/browser/base/content/test/permissions/browser_permissions_handling_user_input.js
@@ -0,0 +1,99 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const ORIGIN = "https://example.com";
+const PERMISSIONS_PAGE =
+ getRootDirectory(gTestPath).replace("chrome://mochitests/content", ORIGIN) +
+ "permissions.html";
+
+function assertShown(task) {
+ return BrowserTestUtils.withNewTab(
+ PERMISSIONS_PAGE,
+ async function (browser) {
+ let popupshown = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popupshown"
+ );
+
+ await SpecialPowers.spawn(browser, [], task);
+
+ await popupshown;
+
+ ok(true, "Notification permission prompt was shown");
+ }
+ );
+}
+
+function assertNotShown(task) {
+ return BrowserTestUtils.withNewTab(
+ PERMISSIONS_PAGE,
+ async function (browser) {
+ let popupshown = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popupshown"
+ );
+
+ await SpecialPowers.spawn(browser, [], task);
+
+ let sawPrompt = await Promise.race([
+ popupshown.then(() => true),
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ new Promise(c => setTimeout(() => c(false), 1000)),
+ ]);
+
+ is(sawPrompt, false, "Notification permission prompt was not shown");
+ }
+ );
+}
+
+// Tests that notification permissions are automatically denied without user interaction.
+add_task(async function testNotificationPermission() {
+ Services.prefs.setBoolPref(
+ "dom.webnotifications.requireuserinteraction",
+ true
+ );
+
+ // First test that when user interaction is required, requests
+ // with user interaction will show the permission prompt.
+
+ await assertShown(function () {
+ content.document.notifyUserGestureActivation();
+ content.document.getElementById("desktop-notification").click();
+ });
+
+ await assertShown(function () {
+ content.document.notifyUserGestureActivation();
+ content.document.getElementById("push").click();
+ });
+
+ // Now test that requests without user interaction will fail.
+
+ await assertNotShown(function () {
+ content.postMessage("push", "*");
+ });
+
+ await assertNotShown(async function () {
+ let response = await content.Notification.requestPermission();
+ is(response, "default", "The request was automatically denied");
+ });
+
+ Services.prefs.setBoolPref(
+ "dom.webnotifications.requireuserinteraction",
+ false
+ );
+
+ // Finally test that those requests will show a prompt again
+ // if the pref has been set to false.
+
+ await assertShown(function () {
+ content.postMessage("push", "*");
+ });
+
+ await assertShown(function () {
+ content.Notification.requestPermission();
+ });
+
+ Services.prefs.clearUserPref("dom.webnotifications.requireuserinteraction");
+});
diff --git a/browser/base/content/test/permissions/browser_permissions_postPrompt.js b/browser/base/content/test/permissions/browser_permissions_postPrompt.js
new file mode 100644
index 0000000000..8434f1fbb3
--- /dev/null
+++ b/browser/base/content/test/permissions/browser_permissions_postPrompt.js
@@ -0,0 +1,104 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const ORIGIN = "https://example.com";
+const PERMISSIONS_PAGE =
+ getRootDirectory(gTestPath).replace("chrome://mochitests/content", ORIGIN) +
+ "permissions.html";
+
+function testPostPrompt(task) {
+ let uri = Services.io.newURI(PERMISSIONS_PAGE);
+ return BrowserTestUtils.withNewTab(
+ PERMISSIONS_PAGE,
+ async function (browser) {
+ let icon = document.getElementById("web-notifications-notification-icon");
+ ok(
+ !BrowserTestUtils.is_visible(icon),
+ "notifications icon is not visible at first"
+ );
+
+ await SpecialPowers.spawn(browser, [], task);
+
+ await TestUtils.waitForCondition(
+ () => BrowserTestUtils.is_visible(icon),
+ "notifications icon is visible"
+ );
+ ok(
+ !PopupNotifications.panel.hasAttribute("panelopen"),
+ "only the icon is showing, the panel is not open"
+ );
+
+ let popupshown = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popupshown"
+ );
+ icon.click();
+ await popupshown;
+
+ ok(true, "Notification permission prompt was shown");
+
+ let notification = PopupNotifications.panel.firstElementChild;
+ EventUtils.synthesizeMouseAtCenter(notification.button, {});
+
+ is(
+ PermissionTestUtils.testPermission(uri, "desktop-notification"),
+ Ci.nsIPermissionManager.ALLOW_ACTION,
+ "User can override the default deny by using the prompt"
+ );
+
+ PermissionTestUtils.remove(uri, "desktop-notification");
+ }
+ );
+}
+
+add_task(async function testNotificationPermission() {
+ Services.prefs.setBoolPref(
+ "dom.webnotifications.requireuserinteraction",
+ true
+ );
+ Services.prefs.setBoolPref(
+ "permissions.desktop-notification.postPrompt.enabled",
+ true
+ );
+
+ Services.prefs.setIntPref(
+ "permissions.default.desktop-notification",
+ Ci.nsIPermissionManager.DENY_ACTION
+ );
+
+ // First test that all requests (even with user interaction) will cause a post-prompt
+ // if the global default is "deny".
+
+ await testPostPrompt(function () {
+ E10SUtils.wrapHandlingUserInput(content, true, function () {
+ content.document.getElementById("desktop-notification").click();
+ });
+ });
+
+ await testPostPrompt(function () {
+ E10SUtils.wrapHandlingUserInput(content, true, function () {
+ content.document.getElementById("push").click();
+ });
+ });
+
+ Services.prefs.clearUserPref("permissions.default.desktop-notification");
+
+ // Now test that requests without user interaction will post-prompt when the
+ // user interaction requirement is set.
+
+ await testPostPrompt(function () {
+ content.postMessage("push", "*");
+ });
+
+ await testPostPrompt(async function () {
+ let response = await content.Notification.requestPermission();
+ is(response, "default", "The request was automatically denied");
+ });
+
+ Services.prefs.clearUserPref("dom.webnotifications.requireuserinteraction");
+ Services.prefs.clearUserPref(
+ "permissions.desktop-notification.postPrompt.enabled"
+ );
+});
diff --git a/browser/base/content/test/permissions/browser_reservedkey.js b/browser/base/content/test/permissions/browser_reservedkey.js
new file mode 100644
index 0000000000..c8eb0ab6c6
--- /dev/null
+++ b/browser/base/content/test/permissions/browser_reservedkey.js
@@ -0,0 +1,312 @@
+add_task(async function test_reserved_shortcuts() {
+ let keyset = document.createXULElement("keyset");
+ let key1 = document.createXULElement("key");
+ key1.setAttribute("id", "kt_reserved");
+ key1.setAttribute("modifiers", "shift");
+ key1.setAttribute("key", "O");
+ key1.setAttribute("reserved", "true");
+ key1.setAttribute("count", "0");
+ key1.addEventListener("command", () => {
+ let attribute = key1.getAttribute("count");
+ key1.setAttribute("count", Number(attribute) + 1);
+ });
+
+ let key2 = document.createXULElement("key");
+ key2.setAttribute("id", "kt_notreserved");
+ key2.setAttribute("modifiers", "shift");
+ key2.setAttribute("key", "P");
+ key2.setAttribute("reserved", "false");
+ key2.setAttribute("count", "0");
+ key2.addEventListener("command", () => {
+ let attribute = key2.getAttribute("count");
+ key2.setAttribute("count", Number(attribute) + 1);
+ });
+
+ let key3 = document.createXULElement("key");
+ key3.setAttribute("id", "kt_reserveddefault");
+ key3.setAttribute("modifiers", "shift");
+ key3.setAttribute("key", "Q");
+ key3.setAttribute("count", "0");
+ key3.addEventListener("command", () => {
+ let attribute = key3.getAttribute("count");
+ key3.setAttribute("count", Number(attribute) + 1);
+ });
+
+ keyset.appendChild(key1);
+ keyset.appendChild(key2);
+ keyset.appendChild(key3);
+ let container = document.createXULElement("box");
+ container.appendChild(keyset);
+ document.documentElement.appendChild(container);
+
+ const pageUrl =
+ "data:text/html,<body onload='document.body.firstElementChild.focus();'><div onkeydown='event.preventDefault();' tabindex=0>Test</div></body>";
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, pageUrl);
+
+ EventUtils.sendString("OPQ");
+
+ is(
+ document.getElementById("kt_reserved").getAttribute("count"),
+ "1",
+ "reserved='true' with preference off"
+ );
+ is(
+ document.getElementById("kt_notreserved").getAttribute("count"),
+ "0",
+ "reserved='false' with preference off"
+ );
+ is(
+ document.getElementById("kt_reserveddefault").getAttribute("count"),
+ "0",
+ "default reserved with preference off"
+ );
+
+ // Now try with reserved shortcut key handling enabled.
+ await new Promise(resolve => {
+ SpecialPowers.pushPrefEnv(
+ { set: [["permissions.default.shortcuts", 2]] },
+ resolve
+ );
+ });
+
+ EventUtils.sendString("OPQ");
+
+ is(
+ document.getElementById("kt_reserved").getAttribute("count"),
+ "2",
+ "reserved='true' with preference on"
+ );
+ is(
+ document.getElementById("kt_notreserved").getAttribute("count"),
+ "0",
+ "reserved='false' with preference on"
+ );
+ is(
+ document.getElementById("kt_reserveddefault").getAttribute("count"),
+ "1",
+ "default reserved with preference on"
+ );
+
+ document.documentElement.removeChild(container);
+
+ BrowserTestUtils.removeTab(tab);
+});
+
+// This test checks that Alt+<key> and F10 cannot be blocked when the preference is set.
+if (!navigator.platform.includes("Mac")) {
+ add_task(async function test_accesskeys_menus() {
+ await new Promise(resolve => {
+ SpecialPowers.pushPrefEnv(
+ { set: [["permissions.default.shortcuts", 2]] },
+ resolve
+ );
+ });
+
+ const uri =
+ 'data:text/html,<body onkeydown=\'if (event.key == "H" || event.key == "F10") event.preventDefault();\'>';
+ let tab1 = await BrowserTestUtils.openNewForegroundTab(gBrowser, uri);
+
+ // Pressing Alt+H should open the Help menu.
+ let helpPopup = document.getElementById("menu_HelpPopup");
+ let popupShown = BrowserTestUtils.waitForEvent(helpPopup, "popupshown");
+ EventUtils.synthesizeKey("KEY_Alt", { type: "keydown" });
+ EventUtils.synthesizeKey("h", { altKey: true });
+ EventUtils.synthesizeKey("KEY_Alt", { type: "keyup" });
+ await popupShown;
+
+ ok(true, "Help menu opened");
+
+ let popupHidden = BrowserTestUtils.waitForEvent(helpPopup, "popuphidden");
+ helpPopup.hidePopup();
+ await popupHidden;
+
+ // Pressing F10 should focus the menubar. On Linux, the file menu should open, but on Windows,
+ // pressing Down will open the file menu.
+ let menubar = document.getElementById("main-menubar");
+ let menubarActive = BrowserTestUtils.waitForEvent(
+ menubar,
+ "DOMMenuBarActive"
+ );
+ EventUtils.synthesizeKey("KEY_F10");
+ await menubarActive;
+
+ let filePopup = document.getElementById("menu_FilePopup");
+ popupShown = BrowserTestUtils.waitForEvent(filePopup, "popupshown");
+ if (navigator.platform.includes("Win")) {
+ EventUtils.synthesizeKey("KEY_ArrowDown");
+ }
+ await popupShown;
+
+ ok(true, "File menu opened");
+
+ popupHidden = BrowserTestUtils.waitForEvent(filePopup, "popuphidden");
+ filePopup.hidePopup();
+ await popupHidden;
+
+ BrowserTestUtils.removeTab(tab1);
+ });
+}
+
+// There is a <key> element for Backspace and delete with reserved="false",
+// so make sure that it is not treated as a blocked shortcut key.
+add_task(async function test_backspace_delete() {
+ await new Promise(resolve => {
+ SpecialPowers.pushPrefEnv(
+ { set: [["permissions.default.shortcuts", 2]] },
+ resolve
+ );
+ });
+
+ // The input field is autofocused. If this test fails, backspace can go back
+ // in history so cancel the beforeunload event and adjust the field to make the test fail.
+ const uri =
+ 'data:text/html,<body onbeforeunload=\'document.getElementById("field").value = "failed";\'>' +
+ "<input id='field' value='something'></body>";
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, uri);
+
+ await SpecialPowers.spawn(tab.linkedBrowser, [], async function () {
+ content.document.getElementById("field").focus();
+
+ // Add a promise that resolves when the backspace key gets received
+ // so we can ensure the key gets received before checking the result.
+ content.keysPromise = new Promise(resolve => {
+ content.addEventListener("keyup", event => {
+ if (event.code == "Backspace") {
+ resolve(content.document.getElementById("field").value);
+ }
+ });
+ });
+ });
+
+ // Move the caret so backspace will delete the first character.
+ EventUtils.synthesizeKey("KEY_ArrowRight", {});
+ EventUtils.synthesizeKey("KEY_Backspace", {});
+
+ let fieldValue = await SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [],
+ async function () {
+ return content.keysPromise;
+ }
+ );
+ is(fieldValue, "omething", "backspace not prevented");
+
+ // now do the same thing for the delete key:
+ await SpecialPowers.spawn(tab.linkedBrowser, [], async function () {
+ content.document.getElementById("field").focus();
+
+ // Add a promise that resolves when the backspace key gets received
+ // so we can ensure the key gets received before checking the result.
+ content.keysPromise = new Promise(resolve => {
+ content.addEventListener("keyup", event => {
+ if (event.code == "Delete") {
+ resolve(content.document.getElementById("field").value);
+ }
+ });
+ });
+ });
+
+ // Move the caret so backspace will delete the first character.
+ EventUtils.synthesizeKey("KEY_Delete", {});
+
+ fieldValue = await SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [],
+ async function () {
+ return content.keysPromise;
+ }
+ );
+ is(fieldValue, "mething", "delete not prevented");
+
+ BrowserTestUtils.removeTab(tab);
+});
+
+// TODO: Make this to run on Windows too to have automated tests also there.
+if (
+ navigator.platform.includes("Mac") ||
+ navigator.platform.includes("Linux")
+) {
+ add_task(
+ async function test_reserved_shortcuts_conflict_with_user_settings() {
+ await new Promise(resolve => {
+ SpecialPowers.pushPrefEnv(
+ { set: [["test.events.async.enabled", true]] },
+ resolve
+ );
+ });
+
+ const keyset = document.createXULElement("keyset");
+ const key = document.createXULElement("key");
+ key.setAttribute("id", "conflict_with_known_native_key_binding");
+ if (navigator.platform.includes("Mac")) {
+ // Select to end of the paragraph
+ key.setAttribute("modifiers", "ctrl,shift");
+ key.setAttribute("key", "E");
+ } else {
+ // Select All
+ key.setAttribute("modifiers", "ctrl");
+ key.setAttribute("key", "a");
+ }
+ key.setAttribute("reserved", "true");
+ key.setAttribute("count", "0");
+ key.addEventListener("command", () => {
+ const attribute = key.getAttribute("count");
+ key.setAttribute("count", Number(attribute) + 1);
+ });
+
+ keyset.appendChild(key);
+ const container = document.createXULElement("box");
+ container.appendChild(keyset);
+ document.documentElement.appendChild(container);
+
+ const pageUrl =
+ "data:text/html,<body onload='document.body.firstChild.focus(); getSelection().collapse(document.body.firstChild, 0)'><div contenteditable>Test</div></body>";
+ const tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ pageUrl
+ );
+
+ await SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [key.getAttribute("key")],
+ async function (aExpectedKeyValue) {
+ content.promiseTestResult = new Promise(resolve => {
+ content.addEventListener("keyup", event => {
+ if (event.key.toLowerCase() == aExpectedKeyValue.toLowerCase()) {
+ resolve(content.getSelection().getRangeAt(0).toString());
+ }
+ });
+ });
+ }
+ );
+
+ EventUtils.synthesizeKey(key.getAttribute("key"), {
+ ctrlKey: key.getAttribute("modifiers").includes("ctrl"),
+ shiftKey: key.getAttribute("modifiers").includes("shift"),
+ });
+
+ const selectedText = await SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [],
+ async function () {
+ return content.promiseTestResult;
+ }
+ );
+ is(
+ selectedText,
+ "Test",
+ "The shortcut key should select all text in the editor"
+ );
+
+ is(
+ key.getAttribute("count"),
+ "0",
+ "The reserved shortcut key should be consumed by the focused editor instead"
+ );
+
+ document.documentElement.removeChild(container);
+
+ BrowserTestUtils.removeTab(tab);
+ }
+ );
+}
diff --git a/browser/base/content/test/permissions/browser_site_scoped_permissions.js b/browser/base/content/test/permissions/browser_site_scoped_permissions.js
new file mode 100644
index 0000000000..560b1fff4c
--- /dev/null
+++ b/browser/base/content/test/permissions/browser_site_scoped_permissions.js
@@ -0,0 +1,106 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const EMPTY_PAGE =
+ getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+ ) + "empty.html";
+
+const SUBDOMAIN_EMPTY_PAGE =
+ getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://www.example.com"
+ ) + "empty.html";
+
+add_task(async function testSiteScopedPermissionSubdomainAffectsBaseDomain() {
+ let subdomainOrigin = "https://www.example.com";
+ let subdomainPrincipal =
+ Services.scriptSecurityManager.createContentPrincipalFromOrigin(
+ subdomainOrigin
+ );
+ let id = "3rdPartyStorage^https://example.org";
+
+ await BrowserTestUtils.withNewTab(EMPTY_PAGE, async function (browser) {
+ Services.perms.addFromPrincipal(
+ subdomainPrincipal,
+ id,
+ SitePermissions.ALLOW
+ );
+
+ await openPermissionPopup();
+
+ let permissionsList = document.getElementById(
+ "permission-popup-permission-list"
+ );
+ let listEntryCount = permissionsList.querySelectorAll(
+ ".permission-popup-permission-item"
+ ).length;
+ is(
+ listEntryCount,
+ 1,
+ "Permission exists on base domain when set on subdomain"
+ );
+
+ closePermissionPopup();
+
+ Services.perms.removeFromPrincipal(subdomainPrincipal, id);
+
+ await openPermissionPopup();
+
+ listEntryCount = permissionsList.querySelectorAll(
+ ".permission-popup-permission-item-3rdPartyStorage"
+ ).length;
+ is(
+ listEntryCount,
+ 0,
+ "Permission removed on base domain when removed on subdomain"
+ );
+
+ await closePermissionPopup();
+ });
+});
+
+add_task(async function testSiteScopedPermissionBaseDomainAffectsSubdomain() {
+ let origin = "https://example.com";
+ let principal =
+ Services.scriptSecurityManager.createContentPrincipalFromOrigin(origin);
+ let id = "3rdPartyStorage^https://example.org";
+
+ await BrowserTestUtils.withNewTab(
+ SUBDOMAIN_EMPTY_PAGE,
+ async function (browser) {
+ Services.perms.addFromPrincipal(principal, id, SitePermissions.ALLOW);
+ await openPermissionPopup();
+
+ let permissionsList = document.getElementById(
+ "permission-popup-permission-list"
+ );
+ let listEntryCount = permissionsList.querySelectorAll(
+ ".permission-popup-permission-item"
+ ).length;
+ is(
+ listEntryCount,
+ 1,
+ "Permission exists on base domain when set on subdomain"
+ );
+
+ closePermissionPopup();
+
+ Services.perms.removeFromPrincipal(principal, id);
+
+ await openPermissionPopup();
+
+ listEntryCount = permissionsList.querySelectorAll(
+ ".permission-popup-permission-item-3rdPartyStorage"
+ ).length;
+ is(
+ listEntryCount,
+ 0,
+ "Permission removed on base domain when removed on subdomain"
+ );
+
+ await closePermissionPopup();
+ }
+ );
+});
diff --git a/browser/base/content/test/permissions/browser_temporary_permissions.js b/browser/base/content/test/permissions/browser_temporary_permissions.js
new file mode 100644
index 0000000000..83f7e49d56
--- /dev/null
+++ b/browser/base/content/test/permissions/browser_temporary_permissions.js
@@ -0,0 +1,118 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const ORIGIN = "https://example.com";
+const PERMISSIONS_PAGE =
+ getRootDirectory(gTestPath).replace("chrome://mochitests/content", ORIGIN) +
+ "permissions.html";
+const SUBFRAME_PAGE =
+ getRootDirectory(gTestPath).replace("chrome://mochitests/content", ORIGIN) +
+ "temporary_permissions_subframe.html";
+
+// Test that setting temp permissions triggers a change in the identity block.
+add_task(async function testTempPermissionChangeEvents() {
+ let principal =
+ Services.scriptSecurityManager.createContentPrincipalFromOrigin(ORIGIN);
+ let id = "geo";
+
+ await BrowserTestUtils.withNewTab(ORIGIN, function (browser) {
+ SitePermissions.setForPrincipal(
+ principal,
+ id,
+ SitePermissions.BLOCK,
+ SitePermissions.SCOPE_TEMPORARY,
+ browser
+ );
+
+ Assert.deepEqual(SitePermissions.getForPrincipal(principal, id, browser), {
+ state: SitePermissions.BLOCK,
+ scope: SitePermissions.SCOPE_TEMPORARY,
+ });
+
+ let geoIcon = document.querySelector(
+ ".blocked-permission-icon[data-permission-id=geo]"
+ );
+
+ Assert.notEqual(
+ geoIcon.getBoundingClientRect().width,
+ 0,
+ "geo anchor should be visible"
+ );
+
+ SitePermissions.removeFromPrincipal(principal, id, browser);
+
+ Assert.equal(
+ geoIcon.getBoundingClientRect().width,
+ 0,
+ "geo anchor should not be visible"
+ );
+ });
+});
+
+// Test that temp blocked permissions requested by subframes (with a different URI) affect the whole page.
+add_task(async function testTempPermissionSubframes() {
+ let uri = NetUtil.newURI(ORIGIN);
+ let principal = Services.scriptSecurityManager.createContentPrincipal(
+ uri,
+ {}
+ );
+ let id = "geo";
+
+ await BrowserTestUtils.withNewTab(SUBFRAME_PAGE, async function (browser) {
+ let popupshown = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popupshown"
+ );
+
+ await new Promise(r => {
+ SpecialPowers.pushPrefEnv(
+ {
+ set: [
+ ["dom.security.featurePolicy.header.enabled", true],
+ ["dom.security.featurePolicy.webidl.enabled", true],
+ ],
+ },
+ r
+ );
+ });
+
+ // Request a permission.
+ await SpecialPowers.spawn(browser, [uri.host], async function (host0) {
+ let frame = content.document.getElementById("frame");
+
+ await content.SpecialPowers.spawn(frame, [host0], async function (host) {
+ const { E10SUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/E10SUtils.sys.mjs"
+ );
+
+ E10SUtils.wrapHandlingUserInput(this.content, true, function () {
+ let frameDoc = this.content.document;
+
+ // Make sure that the origin of our test page is different.
+ Assert.notEqual(frameDoc.location.host, host);
+
+ frameDoc.getElementById("geo").click();
+ });
+ });
+ });
+
+ await popupshown;
+
+ let popuphidden = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popuphidden"
+ );
+
+ let notification = PopupNotifications.panel.firstElementChild;
+ EventUtils.synthesizeMouseAtCenter(notification.secondaryButton, {});
+
+ await popuphidden;
+
+ Assert.deepEqual(SitePermissions.getForPrincipal(principal, id, browser), {
+ state: SitePermissions.BLOCK,
+ scope: SitePermissions.SCOPE_TEMPORARY,
+ });
+ });
+});
diff --git a/browser/base/content/test/permissions/browser_temporary_permissions_expiry.js b/browser/base/content/test/permissions/browser_temporary_permissions_expiry.js
new file mode 100644
index 0000000000..e323f769cd
--- /dev/null
+++ b/browser/base/content/test/permissions/browser_temporary_permissions_expiry.js
@@ -0,0 +1,208 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+/* eslint-disable mozilla/no-arbitrary-setTimeout */
+
+"use strict";
+
+const ORIGIN = "https://example.com";
+const PERMISSIONS_PAGE =
+ getRootDirectory(gTestPath).replace("chrome://mochitests/content", ORIGIN) +
+ "permissions.html";
+
+// Ignore promise rejection caused by clicking Deny button.
+const { PromiseTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PromiseTestUtils.sys.mjs"
+);
+PromiseTestUtils.allowMatchingRejectionsGlobally(/The request is not allowed/);
+
+const EXPIRE_TIME_MS = 100;
+const TIMEOUT_MS = 500;
+
+const EXPIRE_TIME_CUSTOM_MS = 1000;
+const TIMEOUT_CUSTOM_MS = 1500;
+
+const kVREnabled = SpecialPowers.getBoolPref("dom.vr.enabled");
+
+// Test that temporary permissions can be re-requested after they expired
+// and that the identity block is updated accordingly.
+add_task(async function testTempPermissionRequestAfterExpiry() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["privacy.temporary_permission_expire_time_ms", EXPIRE_TIME_MS],
+ ["media.navigator.permission.fake", true],
+ ["dom.vr.always_support_vr", true],
+ ],
+ });
+
+ let principal =
+ Services.scriptSecurityManager.createContentPrincipalFromOrigin(ORIGIN);
+ let ids = ["geo", "camera"];
+
+ if (kVREnabled) {
+ ids.push("xr");
+ }
+
+ for (let id of ids) {
+ await BrowserTestUtils.withNewTab(
+ PERMISSIONS_PAGE,
+ async function (browser) {
+ let blockedIcon = gPermissionPanel._identityPermissionBox.querySelector(
+ `.blocked-permission-icon[data-permission-id='${id}']`
+ );
+
+ SitePermissions.setForPrincipal(
+ principal,
+ id,
+ SitePermissions.BLOCK,
+ SitePermissions.SCOPE_TEMPORARY,
+ browser
+ );
+
+ Assert.deepEqual(
+ SitePermissions.getForPrincipal(principal, id, browser),
+ {
+ state: SitePermissions.BLOCK,
+ scope: SitePermissions.SCOPE_TEMPORARY,
+ }
+ );
+
+ ok(
+ blockedIcon.hasAttribute("showing"),
+ "blocked permission icon is shown"
+ );
+
+ await new Promise(c => setTimeout(c, TIMEOUT_MS));
+
+ Assert.deepEqual(
+ SitePermissions.getForPrincipal(principal, id, browser),
+ {
+ state: SitePermissions.UNKNOWN,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ }
+ );
+
+ let popupshown = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popupshown"
+ );
+
+ // Request a permission;
+ await BrowserTestUtils.synthesizeMouseAtCenter(`#${id}`, {}, browser);
+
+ await popupshown;
+
+ ok(
+ !blockedIcon.hasAttribute("showing"),
+ "blocked permission icon is not shown"
+ );
+
+ let popuphidden = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popuphidden"
+ );
+
+ let notification = PopupNotifications.panel.firstElementChild;
+ EventUtils.synthesizeMouseAtCenter(notification.secondaryButton, {});
+
+ await popuphidden;
+
+ SitePermissions.removeFromPrincipal(principal, id, browser);
+ }
+ );
+ }
+});
+
+/**
+ * Test whether the identity UI shows the permission granted state.
+ * @param {boolean} state - true = Shows permission granted, false otherwise.
+ */
+async function testIdentityPermissionGrantedState(state) {
+ let hasAttribute;
+ let msg = `Identity permission box ${
+ state ? "shows" : "does not show"
+ } granted permissions.`;
+ await TestUtils.waitForCondition(() => {
+ hasAttribute =
+ gPermissionPanel._identityPermissionBox.hasAttribute("hasPermissions");
+ return hasAttribute == state;
+ }, msg);
+ is(hasAttribute, state, msg);
+}
+
+// Test that temporary permissions can have custom expiry time and the identity
+// block is updated correctly on expiry.
+add_task(async function testTempPermissionCustomExpiry() {
+ const TEST_ID = "geo";
+ // Set a default expiry time which is lower than the custom one we'll set.
+ await SpecialPowers.pushPrefEnv({
+ set: [["privacy.temporary_permission_expire_time_ms", EXPIRE_TIME_MS]],
+ });
+
+ await BrowserTestUtils.withNewTab(PERMISSIONS_PAGE, async browser => {
+ Assert.deepEqual(
+ SitePermissions.getForPrincipal(null, TEST_ID, browser),
+ {
+ state: SitePermissions.UNKNOWN,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ },
+ "Permission not set initially"
+ );
+
+ await testIdentityPermissionGrantedState(false);
+
+ // Set permission with custom expiry time.
+ SitePermissions.setForPrincipal(
+ null,
+ "geo",
+ SitePermissions.ALLOW,
+ SitePermissions.SCOPE_TEMPORARY,
+ browser,
+ EXPIRE_TIME_CUSTOM_MS
+ );
+
+ await testIdentityPermissionGrantedState(true);
+
+ // We've set the permission, start the timer promise.
+ let timeout = new Promise(resolve =>
+ setTimeout(resolve, TIMEOUT_CUSTOM_MS)
+ );
+
+ Assert.deepEqual(
+ SitePermissions.getForPrincipal(null, TEST_ID, browser),
+ {
+ state: SitePermissions.ALLOW,
+ scope: SitePermissions.SCOPE_TEMPORARY,
+ },
+ "We should see the temporary permission we just set."
+ );
+
+ // Wait for half of the expiry time.
+ await new Promise(resolve =>
+ setTimeout(resolve, EXPIRE_TIME_CUSTOM_MS / 2)
+ );
+ Assert.deepEqual(
+ SitePermissions.getForPrincipal(null, TEST_ID, browser),
+ {
+ state: SitePermissions.ALLOW,
+ scope: SitePermissions.SCOPE_TEMPORARY,
+ },
+ "Temporary permission should not have expired yet."
+ );
+
+ // Wait until permission expiry.
+ await timeout;
+
+ // Identity permission section should have updated by now. It should do this
+ // without relying on side-effects of the SitePermissions getter.
+ await testIdentityPermissionGrantedState(false);
+
+ Assert.deepEqual(
+ SitePermissions.getForPrincipal(null, TEST_ID, browser),
+ {
+ state: SitePermissions.UNKNOWN,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ },
+ "Permission should have expired"
+ );
+ });
+});
diff --git a/browser/base/content/test/permissions/browser_temporary_permissions_navigation.js b/browser/base/content/test/permissions/browser_temporary_permissions_navigation.js
new file mode 100644
index 0000000000..7da79b1810
--- /dev/null
+++ b/browser/base/content/test/permissions/browser_temporary_permissions_navigation.js
@@ -0,0 +1,239 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that temporary permissions are removed on user initiated reload only.
+add_task(async function testTempPermissionOnReload() {
+ let origin = "https://example.com/";
+ let principal =
+ Services.scriptSecurityManager.createContentPrincipalFromOrigin(origin);
+ let id = "geo";
+
+ await BrowserTestUtils.withNewTab(origin, async function (browser) {
+ SitePermissions.setForPrincipal(
+ principal,
+ id,
+ SitePermissions.BLOCK,
+ SitePermissions.SCOPE_TEMPORARY,
+ browser
+ );
+
+ let reloaded = BrowserTestUtils.browserLoaded(browser, false, origin);
+
+ Assert.deepEqual(SitePermissions.getForPrincipal(principal, id, browser), {
+ state: SitePermissions.BLOCK,
+ scope: SitePermissions.SCOPE_TEMPORARY,
+ });
+
+ // Reload through the page (should not remove the temp permission).
+ await SpecialPowers.spawn(browser, [], () =>
+ content.document.location.reload()
+ );
+
+ await reloaded;
+
+ Assert.deepEqual(SitePermissions.getForPrincipal(principal, id, browser), {
+ state: SitePermissions.BLOCK,
+ scope: SitePermissions.SCOPE_TEMPORARY,
+ });
+
+ reloaded = BrowserTestUtils.browserLoaded(browser, false, origin);
+
+ // Reload as a user (should remove the temp permission).
+ BrowserReload();
+
+ await reloaded;
+
+ Assert.deepEqual(SitePermissions.getForPrincipal(principal, id, browser), {
+ state: SitePermissions.UNKNOWN,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ });
+
+ // Set the permission again.
+ SitePermissions.setForPrincipal(
+ principal,
+ id,
+ SitePermissions.BLOCK,
+ SitePermissions.SCOPE_TEMPORARY,
+ browser
+ );
+
+ // Open the tab context menu.
+ let contextMenu = document.getElementById("tabContextMenu");
+ // The TabContextMenu initializes its strings only on a focus or mouseover event.
+ // Calls focus event on the TabContextMenu early in the test.
+ gBrowser.selectedTab.focus();
+ let popupShownPromise = BrowserTestUtils.waitForEvent(
+ contextMenu,
+ "popupshown"
+ );
+ EventUtils.synthesizeMouseAtCenter(gBrowser.selectedTab, {
+ type: "contextmenu",
+ button: 2,
+ });
+ await popupShownPromise;
+
+ let reloadMenuItem = document.getElementById("context_reloadTab");
+
+ reloaded = BrowserTestUtils.browserLoaded(browser, false, origin);
+
+ // Reload as a user through the context menu (should remove the temp permission).
+ contextMenu.activateItem(reloadMenuItem);
+
+ await reloaded;
+
+ Assert.deepEqual(SitePermissions.getForPrincipal(principal, id, browser), {
+ state: SitePermissions.UNKNOWN,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ });
+
+ // Set the permission again.
+ SitePermissions.setForPrincipal(
+ principal,
+ id,
+ SitePermissions.BLOCK,
+ SitePermissions.SCOPE_TEMPORARY,
+ browser
+ );
+
+ // Reload as user via return key in urlbar (should remove the temp permission)
+ let urlBarInput = document.getElementById("urlbar-input");
+ await EventUtils.synthesizeMouseAtCenter(urlBarInput, {});
+
+ reloaded = BrowserTestUtils.browserLoaded(browser, false, origin);
+
+ EventUtils.synthesizeAndWaitKey("VK_RETURN", {});
+
+ await reloaded;
+
+ Assert.deepEqual(SitePermissions.getForPrincipal(principal, id, browser), {
+ state: SitePermissions.UNKNOWN,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ });
+
+ SitePermissions.removeFromPrincipal(principal, id, browser);
+ });
+});
+
+// Test that temporary permissions are not removed when reloading all tabs.
+add_task(async function testTempPermissionOnReloadAllTabs() {
+ let origin = "https://example.com/";
+ let principal =
+ Services.scriptSecurityManager.createContentPrincipalFromOrigin(origin);
+ let id = "geo";
+
+ await BrowserTestUtils.withNewTab(origin, async function (browser) {
+ SitePermissions.setForPrincipal(
+ principal,
+ id,
+ SitePermissions.BLOCK,
+ SitePermissions.SCOPE_TEMPORARY,
+ browser
+ );
+
+ // Select all tabs before opening the context menu.
+ gBrowser.selectAllTabs();
+
+ // Open the tab context menu.
+ let contextMenu = document.getElementById("tabContextMenu");
+ // The TabContextMenu initializes its strings only on a focus or mouseover event.
+ // Calls focus event on the TabContextMenu early in the test.
+ gBrowser.selectedTab.focus();
+ let popupShownPromise = BrowserTestUtils.waitForEvent(
+ contextMenu,
+ "popupshown"
+ );
+ EventUtils.synthesizeMouseAtCenter(gBrowser.selectedTab, {
+ type: "contextmenu",
+ button: 2,
+ });
+ await popupShownPromise;
+
+ let reloadMenuItem = document.getElementById("context_reloadSelectedTabs");
+
+ let reloaded = Promise.all(
+ gBrowser.visibleTabs.map(tab =>
+ BrowserTestUtils.browserLoaded(gBrowser.getBrowserForTab(tab))
+ )
+ );
+ contextMenu.activateItem(reloadMenuItem);
+ await reloaded;
+
+ Assert.deepEqual(SitePermissions.getForPrincipal(principal, id, browser), {
+ state: SitePermissions.BLOCK,
+ scope: SitePermissions.SCOPE_TEMPORARY,
+ });
+
+ SitePermissions.removeFromPrincipal(principal, id, browser);
+ });
+});
+
+// Test that temporary permissions are persisted through navigation in a tab.
+add_task(async function testTempPermissionOnNavigation() {
+ let origin = "https://example.com/";
+ let principal =
+ Services.scriptSecurityManager.createContentPrincipalFromOrigin(origin);
+ let id = "geo";
+
+ await BrowserTestUtils.withNewTab(origin, async function (browser) {
+ SitePermissions.setForPrincipal(
+ principal,
+ id,
+ SitePermissions.BLOCK,
+ SitePermissions.SCOPE_TEMPORARY,
+ browser
+ );
+
+ Assert.deepEqual(SitePermissions.getForPrincipal(principal, id, browser), {
+ state: SitePermissions.BLOCK,
+ scope: SitePermissions.SCOPE_TEMPORARY,
+ });
+
+ let loaded = BrowserTestUtils.browserLoaded(
+ browser,
+ false,
+ "https://example.org/"
+ );
+
+ // Navigate to another domain.
+ await SpecialPowers.spawn(
+ browser,
+ [],
+ () => (content.document.location = "https://example.org/")
+ );
+
+ await loaded;
+
+ // The temporary permissions for the current URI should be reset.
+ Assert.deepEqual(
+ SitePermissions.getForPrincipal(browser.contentPrincipal, id, browser),
+ {
+ state: SitePermissions.UNKNOWN,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ }
+ );
+
+ loaded = BrowserTestUtils.browserLoaded(browser, false, origin);
+
+ // Navigate to the original domain.
+ await SpecialPowers.spawn(
+ browser,
+ [],
+ () => (content.document.location = "https://example.com/")
+ );
+
+ await loaded;
+
+ // The temporary permissions for the original URI should still exist.
+ Assert.deepEqual(
+ SitePermissions.getForPrincipal(browser.contentPrincipal, id, browser),
+ {
+ state: SitePermissions.BLOCK,
+ scope: SitePermissions.SCOPE_TEMPORARY,
+ }
+ );
+
+ SitePermissions.removeFromPrincipal(browser.contentPrincipal, id, browser);
+ });
+});
diff --git a/browser/base/content/test/permissions/browser_temporary_permissions_tabs.js b/browser/base/content/test/permissions/browser_temporary_permissions_tabs.js
new file mode 100644
index 0000000000..a4347f9671
--- /dev/null
+++ b/browser/base/content/test/permissions/browser_temporary_permissions_tabs.js
@@ -0,0 +1,148 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// Test that temp permissions are persisted through moving tabs to new windows.
+add_task(async function testTempPermissionOnTabMove() {
+ let origin = "https://example.com/";
+ let principal =
+ Services.scriptSecurityManager.createContentPrincipalFromOrigin(origin);
+ let id = "geo";
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, origin);
+
+ SitePermissions.setForPrincipal(
+ principal,
+ id,
+ SitePermissions.BLOCK,
+ SitePermissions.SCOPE_TEMPORARY,
+ tab.linkedBrowser
+ );
+
+ Assert.deepEqual(
+ SitePermissions.getForPrincipal(principal, id, tab.linkedBrowser),
+ {
+ state: SitePermissions.BLOCK,
+ scope: SitePermissions.SCOPE_TEMPORARY,
+ }
+ );
+
+ let promiseWin = BrowserTestUtils.waitForNewWindow();
+ gBrowser.replaceTabWithWindow(tab);
+ let win = await promiseWin;
+ tab = win.gBrowser.selectedTab;
+
+ Assert.deepEqual(
+ SitePermissions.getForPrincipal(principal, id, tab.linkedBrowser),
+ {
+ state: SitePermissions.BLOCK,
+ scope: SitePermissions.SCOPE_TEMPORARY,
+ }
+ );
+
+ SitePermissions.removeFromPrincipal(principal, id, tab.linkedBrowser);
+ await BrowserTestUtils.closeWindow(win);
+});
+
+// Test that temp permissions don't affect other tabs of the same URI.
+add_task(async function testTempPermissionMultipleTabs() {
+ let origin = "https://example.com/";
+ let principal =
+ Services.scriptSecurityManager.createContentPrincipalFromOrigin(origin);
+ let id = "geo";
+
+ let tab1 = await BrowserTestUtils.openNewForegroundTab(gBrowser, origin);
+ let tab2 = await BrowserTestUtils.openNewForegroundTab(gBrowser, origin);
+
+ SitePermissions.setForPrincipal(
+ principal,
+ id,
+ SitePermissions.BLOCK,
+ SitePermissions.SCOPE_TEMPORARY,
+ tab2.linkedBrowser
+ );
+
+ Assert.deepEqual(
+ SitePermissions.getForPrincipal(principal, id, tab2.linkedBrowser),
+ {
+ state: SitePermissions.BLOCK,
+ scope: SitePermissions.SCOPE_TEMPORARY,
+ }
+ );
+
+ Assert.deepEqual(
+ SitePermissions.getForPrincipal(principal, id, tab1.linkedBrowser),
+ {
+ state: SitePermissions.UNKNOWN,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ }
+ );
+
+ let geoIcon = document.querySelector(
+ ".blocked-permission-icon[data-permission-id=geo]"
+ );
+
+ Assert.notEqual(
+ geoIcon.getBoundingClientRect().width,
+ 0,
+ "geo anchor should be visible"
+ );
+
+ await BrowserTestUtils.switchTab(gBrowser, tab1);
+
+ Assert.equal(
+ geoIcon.getBoundingClientRect().width,
+ 0,
+ "geo anchor should not be visible"
+ );
+
+ SitePermissions.removeFromPrincipal(principal, id, tab2.linkedBrowser);
+ BrowserTestUtils.removeTab(tab1);
+ BrowserTestUtils.removeTab(tab2);
+});
+
+// Test that temp permissions are cleared when closing tabs.
+add_task(async function testTempPermissionOnTabClose() {
+ let origin = "https://example.com/";
+ let principal =
+ Services.scriptSecurityManager.createContentPrincipalFromOrigin(origin);
+ let id = "geo";
+
+ ok(
+ !SitePermissions._temporaryPermissions._stateByBrowser.size,
+ "Temporary permission map should be empty initially."
+ );
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, origin);
+
+ SitePermissions.setForPrincipal(
+ principal,
+ id,
+ SitePermissions.BLOCK,
+ SitePermissions.SCOPE_TEMPORARY,
+ tab.linkedBrowser
+ );
+
+ Assert.deepEqual(
+ SitePermissions.getForPrincipal(principal, id, tab.linkedBrowser),
+ {
+ state: SitePermissions.BLOCK,
+ scope: SitePermissions.SCOPE_TEMPORARY,
+ }
+ );
+
+ ok(
+ SitePermissions._temporaryPermissions._stateByBrowser.has(
+ tab.linkedBrowser
+ ),
+ "Temporary permission map should have an entry for the browser."
+ );
+
+ BrowserTestUtils.removeTab(tab);
+
+ ok(
+ !SitePermissions._temporaryPermissions._stateByBrowser.size,
+ "Temporary permission map should be empty after closing the tab."
+ );
+});
diff --git a/browser/base/content/test/permissions/dummy.js b/browser/base/content/test/permissions/dummy.js
new file mode 100644
index 0000000000..c45ec0a714
--- /dev/null
+++ b/browser/base/content/test/permissions/dummy.js
@@ -0,0 +1 @@
+// Just a dummy file for testing.
diff --git a/browser/base/content/test/permissions/empty.html b/browser/base/content/test/permissions/empty.html
new file mode 100644
index 0000000000..1ad28bb1f7
--- /dev/null
+++ b/browser/base/content/test/permissions/empty.html
@@ -0,0 +1,8 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+<title>Empty file</title>
+</head>
+<body>
+</body>
+</html>
diff --git a/browser/base/content/test/permissions/head.js b/browser/base/content/test/permissions/head.js
new file mode 100644
index 0000000000..847386b7e2
--- /dev/null
+++ b/browser/base/content/test/permissions/head.js
@@ -0,0 +1,28 @@
+const { PermissionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PermissionTestUtils.sys.mjs"
+);
+
+SpecialPowers.addTaskImport(
+ "E10SUtils",
+ "resource://gre/modules/E10SUtils.sys.mjs"
+);
+
+function openPermissionPopup() {
+ let promise = BrowserTestUtils.waitForEvent(
+ gBrowser.ownerGlobal,
+ "popupshown",
+ true,
+ event => event.target == gPermissionPanel._permissionPopup
+ );
+ gPermissionPanel._identityPermissionBox.click();
+ return promise;
+}
+
+function closePermissionPopup() {
+ let promise = BrowserTestUtils.waitForEvent(
+ gPermissionPanel._permissionPopup,
+ "popuphidden"
+ );
+ gPermissionPanel._permissionPopup.hidePopup();
+ return promise;
+}
diff --git a/browser/base/content/test/permissions/permissions.html b/browser/base/content/test/permissions/permissions.html
new file mode 100644
index 0000000000..97286914e7
--- /dev/null
+++ b/browser/base/content/test/permissions/permissions.html
@@ -0,0 +1,49 @@
+<!DOCTYPE HTML>
+<!-- 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/. -->
+<html dir="ltr" xml:lang="en-US" lang="en-US">
+ <head>
+ <meta charset="utf8">
+ </head>
+<script>
+var gKeyDowns = 0;
+var gKeyPresses = 0;
+
+navigator.serviceWorker.register("dummy.js");
+
+function requestPush() {
+ return navigator.serviceWorker.ready.then(function(serviceWorkerRegistration) {
+ serviceWorkerRegistration.pushManager.subscribe();
+ });
+}
+
+function requestGeo() {
+ return navigator.geolocation.getCurrentPosition(() => {
+ parent.postMessage("allow", "*");
+ }, error => {
+ // PERMISSION_DENIED = 1
+ parent.postMessage(error.code == 1 ? "deny" : "allow", "*");
+ });
+}
+
+
+window.onmessage = function(event) {
+ switch (event.data) {
+ case "push":
+ requestPush();
+ break;
+ }
+};
+
+</script>
+ <body onkeydown="gKeyDowns++;" onkeypress="gKeyPresses++">
+ <!-- This page could eventually request permissions from content
+ and make sure that chrome responds appropriately -->
+ <button id="geo" onclick="requestGeo()">Geolocation</button>
+ <button id="xr" onclick="navigator.getVRDisplays()">XR</button>
+ <button id="desktop-notification" onclick="Notification.requestPermission()">Notifications</button>
+ <button id="push" onclick="requestPush()">Push Notifications</button>
+ <button id="camera" onclick="navigator.mediaDevices.getUserMedia({video: true, fake: true})">Camera</button>
+ </body>
+</html>
diff --git a/browser/base/content/test/permissions/temporary_permissions_frame.html b/browser/base/content/test/permissions/temporary_permissions_frame.html
new file mode 100644
index 0000000000..25aede980f
--- /dev/null
+++ b/browser/base/content/test/permissions/temporary_permissions_frame.html
@@ -0,0 +1,12 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+<title>Permissions Subframe Test</title>
+<meta http-equiv="Content-Type" content="text/html;charset=utf-8"></meta>
+</head>
+<body>
+ <iframe id="frameAncestor"
+ src="https://test1.example.com/browser/browser/base/content/test/permissions/temporary_permissions_subframe.html"
+ allow="geolocation https://test1.example.com https://test2.example.com"></iframe>
+</body>
+</html>
diff --git a/browser/base/content/test/permissions/temporary_permissions_subframe.html b/browser/base/content/test/permissions/temporary_permissions_subframe.html
new file mode 100644
index 0000000000..4ff13f2e91
--- /dev/null
+++ b/browser/base/content/test/permissions/temporary_permissions_subframe.html
@@ -0,0 +1,11 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+<title>Temporary Permissions Subframe Test</title>
+<meta http-equiv="Content-Type" content="text/html;charset=utf-8"></meta>
+</head>
+<body>
+ <iframe id="frame" src="https://example.org/browser/browser/base/content/test/permissions/permissions.html" allow="geolocation"></iframe>
+ <iframe id="frameAllowsAll" src="https://example.org/browser/browser/base/content/test/permissions/permissions.html" allow="geolocation *"></iframe>
+</body>
+</html>
diff --git a/browser/base/content/test/plugins/browser.ini b/browser/base/content/test/plugins/browser.ini
new file mode 100644
index 0000000000..c0e065c5d0
--- /dev/null
+++ b/browser/base/content/test/plugins/browser.ini
@@ -0,0 +1,14 @@
+[DEFAULT]
+support-files =
+ empty_file.html
+ head.js
+ plugin_bug797677.html
+ plugin_test.html
+
+[browser_bug797677.js]
+[browser_enable_DRM_prompt.js]
+skip-if = (os == 'win' && processor == 'aarch64') # bug 1533164
+[browser_globalplugin_crashinfobar.js]
+skip-if = !crashreporter
+[browser_private_browsing_eme_persistent_state.js]
+
diff --git a/browser/base/content/test/plugins/browser_bug797677.js b/browser/base/content/test/plugins/browser_bug797677.js
new file mode 100644
index 0000000000..acc728d77e
--- /dev/null
+++ b/browser/base/content/test/plugins/browser_bug797677.js
@@ -0,0 +1,45 @@
+var gTestRoot = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content/",
+ "http://127.0.0.1:8888/"
+);
+var gTestBrowser = null;
+var gConsoleErrors = 0;
+
+add_task(async function () {
+ registerCleanupFunction(function () {
+ Services.console.unregisterListener(errorListener);
+ gBrowser.removeCurrentTab();
+ window.focus();
+ gTestBrowser = null;
+ });
+
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser);
+ gTestBrowser = gBrowser.selectedBrowser;
+
+ let errorListener = {
+ observe(aMessage) {
+ if (aMessage.message.includes("NS_ERROR_FAILURE")) {
+ gConsoleErrors++;
+ }
+ },
+ };
+ Services.console.registerListener(errorListener);
+
+ await promiseTabLoadEvent(
+ gBrowser.selectedTab,
+ gTestRoot + "plugin_bug797677.html"
+ );
+
+ let pluginInfo = await promiseForPluginInfo("plugin");
+ is(
+ pluginInfo.displayedType,
+ Ci.nsIObjectLoadingContent.TYPE_NULL,
+ "plugin should not have been found."
+ );
+
+ await SpecialPowers.spawn(gTestBrowser, [], function () {
+ let plugin = content.document.getElementById("plugin");
+ ok(plugin, "plugin should be in the page");
+ });
+ is(gConsoleErrors, 0, "should have no console errors");
+});
diff --git a/browser/base/content/test/plugins/browser_enable_DRM_prompt.js b/browser/base/content/test/plugins/browser_enable_DRM_prompt.js
new file mode 100644
index 0000000000..e77455ddd0
--- /dev/null
+++ b/browser/base/content/test/plugins/browser_enable_DRM_prompt.js
@@ -0,0 +1,232 @@
+const TEST_URL =
+ getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+ ) + "empty_file.html";
+
+/*
+ * Register cleanup function to reset prefs after other tasks have run.
+ */
+
+add_task(async function () {
+ // Note: SpecialPowers.pushPrefEnv has problems with the "Enable DRM"
+ // button on the notification box toggling the prefs. So manually
+ // set/unset the prefs the UI we're testing toggles.
+ let emeWasEnabled = Services.prefs.getBoolPref("media.eme.enabled", false);
+ let cdmWasEnabled = Services.prefs.getBoolPref(
+ "media.gmp-widevinecdm.enabled",
+ false
+ );
+
+ // Restore the preferences to their pre-test state on test finish.
+ registerCleanupFunction(function () {
+ // Unlock incase lock test threw and didn't unlock.
+ Services.prefs.unlockPref("media.eme.enabled");
+ Services.prefs.setBoolPref("media.eme.enabled", emeWasEnabled);
+ Services.prefs.setBoolPref("media.gmp-widevinecdm.enabled", cdmWasEnabled);
+ });
+});
+
+/*
+ * Bug 1366167 - Tests that the "Enable DRM" prompt shows if EME is requested while EME is disabled.
+ */
+
+add_task(async function test_drm_prompt_shows_for_toplevel() {
+ await BrowserTestUtils.withNewTab(TEST_URL, async function (browser) {
+ // Turn off EME and Widevine CDM.
+ Services.prefs.setBoolPref("media.eme.enabled", false);
+ Services.prefs.setBoolPref("media.gmp-widevinecdm.enabled", false);
+
+ // Have content request access to Widevine, UI should drop down to
+ // prompt user to enable DRM.
+ let result = await SpecialPowers.spawn(browser, [], async function () {
+ try {
+ let config = [
+ {
+ initDataTypes: ["webm"],
+ videoCapabilities: [{ contentType: 'video/webm; codecs="vp9"' }],
+ },
+ ];
+ await content.navigator.requestMediaKeySystemAccess(
+ "com.widevine.alpha",
+ config
+ );
+ } catch (ex) {
+ return { rejected: true };
+ }
+ return { rejected: false };
+ });
+ is(
+ result.rejected,
+ true,
+ "EME request should be denied because EME disabled."
+ );
+
+ // Verify the UI prompt showed.
+ let box = gBrowser.getNotificationBox(browser);
+ let notification = box.currentNotification;
+
+ ok(notification, "Notification should be visible");
+ is(
+ notification.getAttribute("value"),
+ "drmContentDisabled",
+ "Should be showing the right notification"
+ );
+
+ // Verify the "Enable DRM" button is there.
+ let buttons = notification.buttonContainer.querySelectorAll(
+ ".notification-button"
+ );
+ is(buttons.length, 1, "Should have one button.");
+
+ // Prepare a Promise that should resolve when the "Enable DRM" button's
+ // page reload completes.
+ let refreshPromise = BrowserTestUtils.browserLoaded(browser);
+ buttons[0].click();
+
+ // Wait for the reload to complete.
+ await refreshPromise;
+
+ // Verify clicking the "Enable DRM" button enabled DRM.
+ let enabled = Services.prefs.getBoolPref("media.eme.enabled", true);
+ is(
+ enabled,
+ true,
+ "EME should be enabled after click on 'Enable DRM' button"
+ );
+ });
+});
+
+add_task(async function test_eme_locked() {
+ await BrowserTestUtils.withNewTab(TEST_URL, async function (browser) {
+ // Turn off EME and Widevine CDM.
+ Services.prefs.setBoolPref("media.eme.enabled", false);
+ Services.prefs.lockPref("media.eme.enabled");
+
+ // Have content request access to Widevine, UI should drop down to
+ // prompt user to enable DRM.
+ let result = await SpecialPowers.spawn(browser, [], async function () {
+ try {
+ let config = [
+ {
+ initDataTypes: ["webm"],
+ videoCapabilities: [{ contentType: 'video/webm; codecs="vp9"' }],
+ },
+ ];
+ await content.navigator.requestMediaKeySystemAccess(
+ "com.widevine.alpha",
+ config
+ );
+ } catch (ex) {
+ return { rejected: true };
+ }
+ return { rejected: false };
+ });
+ is(
+ result.rejected,
+ true,
+ "EME request should be denied because EME disabled."
+ );
+
+ // Verify the UI prompt did not show.
+ let box = gBrowser.getNotificationBox(browser);
+ let notification = box.currentNotification;
+
+ is(
+ notification,
+ null,
+ "Notification should not be displayed since pref is locked"
+ );
+
+ // Unlock the pref for any tests that follow.
+ Services.prefs.unlockPref("media.eme.enabled");
+ });
+});
+
+/*
+ * Bug 1642465 - Ensure cross origin frames requesting access prompt in the same way as same origin.
+ */
+
+add_task(async function test_drm_prompt_shows_for_cross_origin_iframe() {
+ await BrowserTestUtils.withNewTab(TEST_URL, async function (browser) {
+ // Turn off EME and Widevine CDM.
+ Services.prefs.setBoolPref("media.eme.enabled", false);
+ Services.prefs.setBoolPref("media.gmp-widevinecdm.enabled", false);
+
+ // Have content request access to Widevine, UI should drop down to
+ // prompt user to enable DRM.
+ const CROSS_ORIGIN_URL = TEST_URL.replace("example.com", "example.org");
+ let result = await SpecialPowers.spawn(
+ browser,
+ [CROSS_ORIGIN_URL],
+ async function (crossOriginUrl) {
+ let frame = content.document.createElement("iframe");
+ frame.src = crossOriginUrl;
+ await new Promise(resolve => {
+ frame.addEventListener("load", () => {
+ resolve();
+ });
+ content.document.body.appendChild(frame);
+ });
+
+ return content.SpecialPowers.spawn(frame, [], async function () {
+ try {
+ let config = [
+ {
+ initDataTypes: ["webm"],
+ videoCapabilities: [
+ { contentType: 'video/webm; codecs="vp9"' },
+ ],
+ },
+ ];
+ await content.navigator.requestMediaKeySystemAccess(
+ "com.widevine.alpha",
+ config
+ );
+ } catch (ex) {
+ return { rejected: true };
+ }
+ return { rejected: false };
+ });
+ }
+ );
+ is(
+ result.rejected,
+ true,
+ "EME request should be denied because EME disabled."
+ );
+
+ // Verify the UI prompt showed.
+ let box = gBrowser.getNotificationBox(browser);
+ let notification = box.currentNotification;
+
+ ok(notification, "Notification should be visible");
+ is(
+ notification.getAttribute("value"),
+ "drmContentDisabled",
+ "Should be showing the right notification"
+ );
+
+ // Verify the "Enable DRM" button is there.
+ let buttons = notification.buttonContainer.querySelectorAll(
+ ".notification-button"
+ );
+ is(buttons.length, 1, "Should have one button.");
+
+ // Prepare a Promise that should resolve when the "Enable DRM" button's
+ // page reload completes.
+ let refreshPromise = BrowserTestUtils.browserLoaded(browser);
+ buttons[0].click();
+
+ // Wait for the reload to complete.
+ await refreshPromise;
+
+ // Verify clicking the "Enable DRM" button enabled DRM.
+ let enabled = Services.prefs.getBoolPref("media.eme.enabled", true);
+ is(
+ enabled,
+ true,
+ "EME should be enabled after click on 'Enable DRM' button"
+ );
+ });
+});
diff --git a/browser/base/content/test/plugins/browser_globalplugin_crashinfobar.js b/browser/base/content/test/plugins/browser_globalplugin_crashinfobar.js
new file mode 100644
index 0000000000..483c2b4032
--- /dev/null
+++ b/browser/base/content/test/plugins/browser_globalplugin_crashinfobar.js
@@ -0,0 +1,63 @@
+"use strict";
+
+let { PluginManager } = ChromeUtils.importESModule(
+ "resource:///actors/PluginParent.sys.mjs"
+);
+
+/**
+ * Test that the notification bar for crashed GMPs works.
+ */
+add_task(async function () {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:blank",
+ },
+ async function (browser) {
+ // Ensure the parent has heard before the client.
+ // In practice, this is always true for GMP crashes (but not for NPAPI ones!)
+ let props = Cc["@mozilla.org/hash-property-bag;1"].createInstance(
+ Ci.nsIWritablePropertyBag2
+ );
+ props.setPropertyAsUint32("pluginID", 1);
+ props.setPropertyAsACString("pluginName", "GlobalTestPlugin");
+ props.setPropertyAsACString("pluginDumpID", "1234");
+ Services.obs.notifyObservers(props, "gmp-plugin-crash");
+
+ await SpecialPowers.spawn(browser, [], async function () {
+ const GMP_CRASH_EVENT = {
+ pluginID: 1,
+ pluginName: "GlobalTestPlugin",
+ submittedCrashReport: false,
+ bubbles: true,
+ cancelable: true,
+ gmpPlugin: true,
+ };
+
+ let crashEvent = new content.PluginCrashedEvent(
+ "PluginCrashed",
+ GMP_CRASH_EVENT
+ );
+ content.dispatchEvent(crashEvent);
+ });
+
+ let notification = await waitForNotificationBar(
+ "plugin-crashed",
+ browser
+ );
+
+ let notificationBox = gBrowser.getNotificationBox(browser);
+ ok(notification, "Infobar was shown.");
+ is(
+ notification.priority,
+ notificationBox.PRIORITY_WARNING_MEDIUM,
+ "Correct priority."
+ );
+ is(
+ notification.messageText.textContent,
+ "The GlobalTestPlugin plugin has crashed.",
+ "Correct message."
+ );
+ }
+ );
+});
diff --git a/browser/base/content/test/plugins/browser_private_browsing_eme_persistent_state.js b/browser/base/content/test/plugins/browser_private_browsing_eme_persistent_state.js
new file mode 100644
index 0000000000..fba4bb552c
--- /dev/null
+++ b/browser/base/content/test/plugins/browser_private_browsing_eme_persistent_state.js
@@ -0,0 +1,59 @@
+/* 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/. */
+
+/*
+ * This test ensures that navigator.requestMediaKeySystemAccess() requests
+ * to run EME with persistent state are rejected in private browsing windows.
+ * Bug 1334111.
+ */
+
+const TEST_URL =
+ getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+ ) + "empty_file.html";
+
+async function isEmePersistentStateSupported(mode) {
+ let win = await BrowserTestUtils.openNewBrowserWindow(mode);
+ let tab = await BrowserTestUtils.openNewForegroundTab(win.gBrowser, TEST_URL);
+ let persistentStateSupported = await SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [],
+ async function () {
+ try {
+ let config = [
+ {
+ initDataTypes: ["webm"],
+ videoCapabilities: [{ contentType: 'video/webm; codecs="vp9"' }],
+ persistentState: "required",
+ },
+ ];
+ await content.navigator.requestMediaKeySystemAccess(
+ "org.w3.clearkey",
+ config
+ );
+ } catch (ex) {
+ return false;
+ }
+ return true;
+ }
+ );
+
+ await BrowserTestUtils.closeWindow(win);
+
+ return persistentStateSupported;
+}
+
+add_task(async function test() {
+ is(
+ await isEmePersistentStateSupported({ private: true }),
+ false,
+ "EME persistentState should *NOT* be supported in private browsing window."
+ );
+ is(
+ await isEmePersistentStateSupported({ private: false }),
+ true,
+ "EME persistentState *SHOULD* be supported in non private browsing window."
+ );
+});
diff --git a/browser/base/content/test/plugins/empty_file.html b/browser/base/content/test/plugins/empty_file.html
new file mode 100644
index 0000000000..af8440ac16
--- /dev/null
+++ b/browser/base/content/test/plugins/empty_file.html
@@ -0,0 +1,9 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="UTF-8">
+ </head>
+ <body>
+ This page is intentionally left blank.
+ </body>
+</html>
diff --git a/browser/base/content/test/plugins/head.js b/browser/base/content/test/plugins/head.js
new file mode 100644
index 0000000000..5f1939080e
--- /dev/null
+++ b/browser/base/content/test/plugins/head.js
@@ -0,0 +1,205 @@
+ChromeUtils.defineESModuleGetters(this, {
+ PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
+ PromiseUtils: "resource://gre/modules/PromiseUtils.sys.mjs",
+});
+
+// Various tests in this directory may define gTestBrowser, to use as the
+// default browser under test in some of the functions below.
+/* global gTestBrowser:true */
+
+/**
+ * Waits a specified number of miliseconds.
+ *
+ * Usage:
+ * let wait = yield waitForMs(2000);
+ * ok(wait, "2 seconds should now have elapsed");
+ *
+ * @param aMs the number of miliseconds to wait for
+ * @returns a Promise that resolves to true after the time has elapsed
+ */
+function waitForMs(aMs) {
+ return new Promise(resolve => {
+ setTimeout(done, aMs);
+ function done() {
+ resolve(true);
+ }
+ });
+}
+
+/**
+ * Waits for a load (or custom) event to finish in a given tab. If provided
+ * load an uri into the tab.
+ *
+ * @param tab
+ * The tab to load into.
+ * @param [optional] url
+ * The url to load, or the current url.
+ * @return {Promise} resolved when the event is handled.
+ * @resolves to the received event
+ * @rejects if a valid load event is not received within a meaningful interval
+ */
+function promiseTabLoadEvent(tab, url) {
+ info("Wait tab event: load");
+
+ function handle(loadedUrl) {
+ if (loadedUrl === "about:blank" || (url && loadedUrl !== url)) {
+ info(`Skipping spurious load event for ${loadedUrl}`);
+ return false;
+ }
+
+ info("Tab event received: load");
+ return true;
+ }
+
+ let loaded = BrowserTestUtils.browserLoaded(tab.linkedBrowser, false, handle);
+
+ if (url) {
+ BrowserTestUtils.loadURIString(tab.linkedBrowser, url);
+ }
+
+ return loaded;
+}
+
+function waitForCondition(condition, nextTest, errorMsg, aTries, aWait) {
+ let tries = 0;
+ let maxTries = aTries || 100; // 100 tries
+ let maxWait = aWait || 100; // 100 msec x 100 tries = ten seconds
+ let interval = setInterval(function () {
+ if (tries >= maxTries) {
+ ok(false, errorMsg);
+ moveOn();
+ }
+ let conditionPassed;
+ try {
+ conditionPassed = condition();
+ } catch (e) {
+ ok(false, e + "\n" + e.stack);
+ conditionPassed = false;
+ }
+ if (conditionPassed) {
+ moveOn();
+ }
+ tries++;
+ }, maxWait);
+ let moveOn = function () {
+ clearInterval(interval);
+ nextTest();
+ };
+}
+
+// Waits for a conditional function defined by the caller to return true.
+function promiseForCondition(aConditionFn, aMessage, aTries, aWait) {
+ return new Promise(resolve => {
+ waitForCondition(
+ aConditionFn,
+ resolve,
+ aMessage || "Condition didn't pass.",
+ aTries,
+ aWait
+ );
+ });
+}
+
+// Returns a promise for nsIObjectLoadingContent props data.
+function promiseForPluginInfo(aId, aBrowser) {
+ let browser = aBrowser || gTestBrowser;
+ return SpecialPowers.spawn(browser, [aId], async function (contentId) {
+ let plugin = content.document.getElementById(contentId);
+ if (!(plugin instanceof Ci.nsIObjectLoadingContent)) {
+ throw new Error("no plugin found");
+ }
+ return {
+ activated: plugin.activated,
+ hasRunningPlugin: plugin.hasRunningPlugin,
+ displayedType: plugin.displayedType,
+ };
+ });
+}
+
+/**
+ * Allows setting focus on a window, and waiting for that window to achieve
+ * focus.
+ *
+ * @param aWindow
+ * The window to focus and wait for.
+ *
+ * @return {Promise}
+ * @resolves When the window is focused.
+ * @rejects Never.
+ */
+function promiseWaitForFocus(aWindow) {
+ return new Promise(resolve => {
+ waitForFocus(resolve, aWindow);
+ });
+}
+
+/**
+ * Returns a Promise that resolves when a notification bar
+ * for a browser is shown. Alternatively, for old-style callers,
+ * can automatically call a callback before it resolves.
+ *
+ * @param notificationID
+ * The ID of the notification to look for.
+ * @param browser
+ * The browser to check for the notification bar.
+ * @param callback (optional)
+ * A function to be called just before the Promise resolves.
+ *
+ * @return Promise
+ */
+function waitForNotificationBar(notificationID, browser, callback) {
+ return new Promise((resolve, reject) => {
+ let notification;
+ let notificationBox = gBrowser.getNotificationBox(browser);
+ waitForCondition(
+ () =>
+ (notification =
+ notificationBox.getNotificationWithValue(notificationID)),
+ () => {
+ ok(
+ notification,
+ `Successfully got the ${notificationID} notification bar`
+ );
+ if (callback) {
+ callback(notification);
+ }
+ resolve(notification);
+ },
+ `Waited too long for the ${notificationID} notification bar`
+ );
+ });
+}
+
+function promiseForNotificationBar(notificationID, browser) {
+ return new Promise(resolve => {
+ waitForNotificationBar(notificationID, browser, resolve);
+ });
+}
+
+/**
+ * Reshow a notification and call a callback when it is reshown.
+ * @param notification
+ * The notification to reshow
+ * @param callback
+ * A function to be called when the notification has been reshown
+ */
+function waitForNotificationShown(notification, callback) {
+ if (PopupNotifications.panel.state == "open") {
+ executeSoon(callback);
+ return;
+ }
+ PopupNotifications.panel.addEventListener(
+ "popupshown",
+ function (e) {
+ callback();
+ },
+ { once: true }
+ );
+ notification.reshow();
+}
+
+function promiseForNotificationShown(notification) {
+ return new Promise(resolve => {
+ waitForNotificationShown(notification, resolve);
+ });
+}
diff --git a/browser/base/content/test/plugins/plugin_bug797677.html b/browser/base/content/test/plugins/plugin_bug797677.html
new file mode 100644
index 0000000000..1545f36475
--- /dev/null
+++ b/browser/base/content/test/plugins/plugin_bug797677.html
@@ -0,0 +1,5 @@
+<!DOCTYPE html>
+<html>
+<head><meta charset="utf-8"/></head>
+<body><embed id="plugin" type="9000"></embed></body>
+</html>
diff --git a/browser/base/content/test/plugins/plugin_test.html b/browser/base/content/test/plugins/plugin_test.html
new file mode 100644
index 0000000000..3d4f43e6a5
--- /dev/null
+++ b/browser/base/content/test/plugins/plugin_test.html
@@ -0,0 +1,9 @@
+<!DOCTYPE html>
+<html>
+<head>
+<meta charset="utf-8">
+</head>
+<body>
+<embed id="test" style="width: 300px; height: 300px" type="application/x-test">
+</body>
+</html>
diff --git a/browser/base/content/test/popupNotifications/browser.ini b/browser/base/content/test/popupNotifications/browser.ini
new file mode 100644
index 0000000000..a5a8ab4eb9
--- /dev/null
+++ b/browser/base/content/test/popupNotifications/browser.ini
@@ -0,0 +1,38 @@
+[DEFAULT]
+support-files =
+ head.js
+
+[browser_displayURI.js]
+skip-if = (os == "linux" && (debug || asan))
+[browser_popupNotification.js]
+skip-if = (os == "linux" && (debug || asan))
+[browser_popupNotification_2.js]
+https_first_disabled = true
+skip-if = (os == "linux" && (debug || asan)) || (os == "linux" && bits == 64 && os_version == "18.04") # bug 1251135
+[browser_popupNotification_3.js]
+https_first_disabled = true
+skip-if = (os == "linux" && (debug || asan)) || verify
+[browser_popupNotification_4.js]
+skip-if = (os == "linux" && (debug || asan))
+[browser_popupNotification_5.js]
+skip-if = true # bug 1332646
+[browser_popupNotification_accesskey.js]
+skip-if = (os == "linux" && (debug || asan)) || os == "mac"
+[browser_popupNotification_checkbox.js]
+skip-if = (os == "linux" && (debug || asan))
+[browser_popupNotification_hide_after_identity_panel.js]
+skip-if = (os == "linux" && (debug || asan))
+[browser_popupNotification_hide_after_protections_panel.js]
+skip-if = (os == "linux" && (debug || asan))
+[browser_popupNotification_keyboard.js]
+skip-if = (os == "linux" && (debug || asan))
+[browser_popupNotification_learnmore.js]
+skip-if = (os == "linux" && (debug || asan))
+[browser_popupNotification_no_anchors.js]
+https_first_disabled = true
+skip-if = (os == "linux" && (debug || asan))
+[browser_popupNotification_security_delay.js]
+[browser_popupNotification_selection_required.js]
+skip-if = (os == "linux" && (debug || asan))
+[browser_reshow_in_background.js]
+skip-if = (os == "linux" && (debug || asan))
diff --git a/browser/base/content/test/popupNotifications/browser_displayURI.js b/browser/base/content/test/popupNotifications/browser_displayURI.js
new file mode 100644
index 0000000000..c9e677cd45
--- /dev/null
+++ b/browser/base/content/test/popupNotifications/browser_displayURI.js
@@ -0,0 +1,159 @@
+/*
+ * Make sure that the correct origin is shown for permission prompts.
+ */
+
+async function check(contentTask, options = {}) {
+ await BrowserTestUtils.withNewTab(
+ "https://test1.example.com/",
+ async function (browser) {
+ let popupShownPromise = waitForNotificationPanel();
+ await SpecialPowers.spawn(browser, [], contentTask);
+ let panel = await popupShownPromise;
+ let notification = panel.children[0];
+ let body = notification.querySelector(".popup-notification-body");
+ ok(
+ body.innerHTML.includes("example.com"),
+ "Check that at least the eTLD+1 is present in the markup"
+ );
+ }
+ );
+
+ let channel = NetUtil.newChannel({
+ uri: getRootDirectory(gTestPath),
+ loadUsingSystemPrincipal: true,
+ });
+ channel = channel.QueryInterface(Ci.nsIFileChannel);
+
+ await BrowserTestUtils.withNewTab(
+ channel.file.path,
+ async function (browser) {
+ let popupShownPromise = waitForNotificationPanel();
+ await SpecialPowers.spawn(browser, [], contentTask);
+ let panel = await popupShownPromise;
+ let notification = panel.children[0];
+ let body = notification.querySelector(".popup-notification-body");
+ if (
+ notification.id == "geolocation-notification" ||
+ notification.id == "xr-notification"
+ ) {
+ ok(
+ body.innerHTML.includes("local file"),
+ `file:// URIs should be displayed as local file.`
+ );
+ } else {
+ ok(
+ body.innerHTML.includes("Unknown origin"),
+ "file:// URIs should be displayed as unknown origin."
+ );
+ }
+ }
+ );
+
+ if (!options.skipOnExtension) {
+ // Test the scenario also on the extension page if not explicitly unsupported
+ // (e.g. an extension page can't be navigated on a blob URL).
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ name: "Test Extension Name",
+ },
+ background() {
+ let { browser } = this;
+ browser.test.sendMessage(
+ "extension-tab-url",
+ browser.runtime.getURL("extension-tab-page.html")
+ );
+ },
+ files: {
+ "extension-tab-page.html": `<!DOCTYPE html><html><body></body></html>`,
+ },
+ });
+
+ await extension.startup();
+ let extensionURI = await extension.awaitMessage("extension-tab-url");
+
+ await BrowserTestUtils.withNewTab(extensionURI, async function (browser) {
+ let popupShownPromise = waitForNotificationPanel();
+ await SpecialPowers.spawn(browser, [], contentTask);
+ let panel = await popupShownPromise;
+ let notification = panel.children[0];
+ let body = notification.querySelector(".popup-notification-body");
+ ok(
+ body.innerHTML.includes("Test Extension Name"),
+ "Check the the extension name is present in the markup"
+ );
+ });
+
+ await extension.unload();
+ }
+}
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["media.navigator.permission.fake", true],
+ ["media.navigator.permission.force", true],
+ ["dom.vr.always_support_vr", true],
+ ],
+ });
+});
+
+add_task(async function test_displayURI_geo() {
+ await check(async function () {
+ content.navigator.geolocation.getCurrentPosition(() => {});
+ });
+});
+
+const kVREnabled = SpecialPowers.getBoolPref("dom.vr.enabled");
+if (kVREnabled) {
+ add_task(async function test_displayURI_xr() {
+ await check(async function () {
+ content.navigator.getVRDisplays();
+ });
+ });
+}
+
+add_task(async function test_displayURI_camera() {
+ await check(async function () {
+ content.navigator.mediaDevices.getUserMedia({ video: true, fake: true });
+ });
+});
+
+add_task(async function test_displayURI_geo_blob() {
+ await check(
+ async function () {
+ let text =
+ "<script>navigator.geolocation.getCurrentPosition(() => {})</script>";
+ let blob = new Blob([text], { type: "text/html" });
+ let url = content.URL.createObjectURL(blob);
+ content.location.href = url;
+ },
+ { skipOnExtension: true }
+ );
+});
+
+if (kVREnabled) {
+ add_task(async function test_displayURI_xr_blob() {
+ await check(
+ async function () {
+ let text = "<script>navigator.getVRDisplays()</script>";
+ let blob = new Blob([text], { type: "text/html" });
+ let url = content.URL.createObjectURL(blob);
+ content.location.href = url;
+ },
+ { skipOnExtension: true }
+ );
+ });
+}
+
+add_task(async function test_displayURI_camera_blob() {
+ await check(
+ async function () {
+ let text =
+ "<script>navigator.mediaDevices.getUserMedia({video: true, fake: true})</script>";
+ let blob = new Blob([text], { type: "text/html" });
+ let url = content.URL.createObjectURL(blob);
+ content.location.href = url;
+ },
+ { skipOnExtension: true }
+ );
+});
diff --git a/browser/base/content/test/popupNotifications/browser_popupNotification.js b/browser/base/content/test/popupNotifications/browser_popupNotification.js
new file mode 100644
index 0000000000..235aa90b5f
--- /dev/null
+++ b/browser/base/content/test/popupNotifications/browser_popupNotification.js
@@ -0,0 +1,394 @@
+/* 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/. */
+
+// These are shared between test #4 to #5
+var wrongBrowserNotificationObject = new BasicNotification("wrongBrowser");
+var wrongBrowserNotification;
+
+function test() {
+ waitForExplicitFinish();
+
+ ok(PopupNotifications, "PopupNotifications object exists");
+ ok(PopupNotifications.panel, "PopupNotifications panel exists");
+
+ setup();
+}
+
+var tests = [
+ {
+ id: "Test#1",
+ run() {
+ this.notifyObj = new BasicNotification(this.id);
+ showNotification(this.notifyObj);
+ },
+ onShown(popup) {
+ checkPopup(popup, this.notifyObj);
+ triggerMainCommand(popup);
+ },
+ onHidden(popup) {
+ ok(this.notifyObj.mainActionClicked, "mainAction was clicked");
+ ok(
+ !this.notifyObj.dismissalCallbackTriggered,
+ "dismissal callback wasn't triggered"
+ );
+ ok(this.notifyObj.removedCallbackTriggered, "removed callback triggered");
+ is(
+ this.notifyObj.mainActionSource,
+ "button",
+ "main action should have been triggered by button."
+ );
+ is(
+ this.notifyObj.secondaryActionSource,
+ undefined,
+ "shouldn't have a secondary action source."
+ );
+ },
+ },
+ {
+ id: "Test#2",
+ run() {
+ this.notifyObj = new BasicNotification(this.id);
+ showNotification(this.notifyObj);
+ },
+ onShown(popup) {
+ checkPopup(popup, this.notifyObj);
+ triggerSecondaryCommand(popup, 0);
+ },
+ onHidden(popup) {
+ ok(this.notifyObj.secondaryActionClicked, "secondaryAction was clicked");
+ ok(
+ !this.notifyObj.dismissalCallbackTriggered,
+ "dismissal callback wasn't triggered"
+ );
+ ok(this.notifyObj.removedCallbackTriggered, "removed callback triggered");
+ is(
+ this.notifyObj.mainActionSource,
+ undefined,
+ "shouldn't have a main action source."
+ );
+ is(
+ this.notifyObj.secondaryActionSource,
+ "button",
+ "secondary action should have been triggered by button."
+ );
+ },
+ },
+ {
+ id: "Test#2b",
+ run() {
+ this.notifyObj = new BasicNotification(this.id);
+ this.notifyObj.secondaryActions.push({
+ label: "Extra Secondary Action",
+ accessKey: "E",
+ callback: () => (this.extraSecondaryActionClicked = true),
+ });
+ showNotification(this.notifyObj);
+ },
+ onShown(popup) {
+ checkPopup(popup, this.notifyObj);
+ triggerSecondaryCommand(popup, 1);
+ },
+ onHidden(popup) {
+ ok(
+ this.extraSecondaryActionClicked,
+ "extra secondary action was clicked"
+ );
+ ok(
+ !this.notifyObj.dismissalCallbackTriggered,
+ "dismissal callback wasn't triggered"
+ );
+ ok(this.notifyObj.removedCallbackTriggered, "removed callback triggered");
+ },
+ },
+ {
+ id: "Test#2c",
+ run() {
+ this.notifyObj = new BasicNotification(this.id);
+ this.notifyObj.secondaryActions.push(
+ {
+ label: "Extra Secondary Action",
+ accessKey: "E",
+ callback: () => ok(false, "unexpected callback invocation"),
+ },
+ {
+ label: "Other Extra Secondary Action",
+ accessKey: "O",
+ callback: () => (this.extraSecondaryActionClicked = true),
+ }
+ );
+ showNotification(this.notifyObj);
+ },
+ onShown(popup) {
+ checkPopup(popup, this.notifyObj);
+ triggerSecondaryCommand(popup, 2);
+ },
+ onHidden(popup) {
+ ok(
+ this.extraSecondaryActionClicked,
+ "extra secondary action was clicked"
+ );
+ ok(
+ !this.notifyObj.dismissalCallbackTriggered,
+ "dismissal callback wasn't triggered"
+ );
+ ok(this.notifyObj.removedCallbackTriggered, "removed callback triggered");
+ },
+ },
+ {
+ id: "Test#3",
+ run() {
+ this.notifyObj = new BasicNotification(this.id);
+ this.notification = showNotification(this.notifyObj);
+ },
+ onShown(popup) {
+ checkPopup(popup, this.notifyObj);
+ dismissNotification(popup);
+ },
+ onHidden(popup) {
+ ok(
+ this.notifyObj.dismissalCallbackTriggered,
+ "dismissal callback triggered"
+ );
+ this.notification.remove();
+ ok(this.notifyObj.removedCallbackTriggered, "removed callback triggered");
+ },
+ },
+ // test opening a notification for a background browser
+ // Note: test 4 to 6 share a tab.
+ {
+ id: "Test#4",
+ async run() {
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ let tab = BrowserTestUtils.addTab(gBrowser, "http://example.com/");
+ await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ isnot(gBrowser.selectedTab, tab, "new tab isn't selected");
+ wrongBrowserNotificationObject.browser = gBrowser.getBrowserForTab(tab);
+ let promiseTopic = TestUtils.topicObserved(
+ "PopupNotifications-backgroundShow"
+ );
+ wrongBrowserNotification = showNotification(
+ wrongBrowserNotificationObject
+ );
+ await promiseTopic;
+ is(PopupNotifications.isPanelOpen, false, "panel isn't open");
+ ok(
+ !wrongBrowserNotificationObject.mainActionClicked,
+ "main action wasn't clicked"
+ );
+ ok(
+ !wrongBrowserNotificationObject.secondaryActionClicked,
+ "secondary action wasn't clicked"
+ );
+ ok(
+ !wrongBrowserNotificationObject.dismissalCallbackTriggered,
+ "dismissal callback wasn't called"
+ );
+ goNext();
+ },
+ },
+ // now select that browser and test to see that the notification appeared
+ {
+ id: "Test#5",
+ run() {
+ this.oldSelectedTab = gBrowser.selectedTab;
+ gBrowser.selectedTab = gBrowser.tabs[gBrowser.tabs.length - 1];
+ },
+ onShown(popup) {
+ checkPopup(popup, wrongBrowserNotificationObject);
+ is(
+ PopupNotifications.isPanelOpen,
+ true,
+ "isPanelOpen getter doesn't lie"
+ );
+
+ // switch back to the old browser
+ gBrowser.selectedTab = this.oldSelectedTab;
+ },
+ onHidden(popup) {
+ // actually remove the notification to prevent it from reappearing
+ ok(
+ wrongBrowserNotificationObject.dismissalCallbackTriggered,
+ "dismissal callback triggered due to tab switch"
+ );
+ wrongBrowserNotification.remove();
+ ok(
+ wrongBrowserNotificationObject.removedCallbackTriggered,
+ "removed callback triggered"
+ );
+ wrongBrowserNotification = null;
+ },
+ },
+ // test that the removed notification isn't shown on browser re-select
+ {
+ id: "Test#6",
+ async run() {
+ let promiseTopic = TestUtils.topicObserved(
+ "PopupNotifications-updateNotShowing"
+ );
+ gBrowser.selectedTab = gBrowser.tabs[gBrowser.tabs.length - 1];
+ await promiseTopic;
+ is(PopupNotifications.isPanelOpen, false, "panel isn't open");
+ gBrowser.removeTab(gBrowser.selectedTab);
+ goNext();
+ },
+ },
+ // Test that two notifications with the same ID result in a single displayed
+ // notification.
+ {
+ id: "Test#7",
+ run() {
+ this.notifyObj = new BasicNotification(this.id);
+ // Show the same notification twice
+ this.notification1 = showNotification(this.notifyObj);
+ this.notification2 = showNotification(this.notifyObj);
+ },
+ onShown(popup) {
+ checkPopup(popup, this.notifyObj);
+ this.notification2.remove();
+ },
+ onHidden(popup) {
+ ok(
+ !this.notifyObj.dismissalCallbackTriggered,
+ "dismissal callback wasn't triggered"
+ );
+ ok(this.notifyObj.removedCallbackTriggered, "removed callback triggered");
+ },
+ },
+ // Test that two notifications with different IDs are displayed
+ {
+ id: "Test#8",
+ run() {
+ this.testNotif1 = new BasicNotification(this.id);
+ this.testNotif1.message += " 1";
+ showNotification(this.testNotif1);
+ this.testNotif2 = new BasicNotification(this.id);
+ this.testNotif2.message += " 2";
+ this.testNotif2.id += "-2";
+ showNotification(this.testNotif2);
+ },
+ onShown(popup) {
+ is(popup.children.length, 2, "two notifications are shown");
+ // Trigger the main command for the first notification, and the secondary
+ // for the second. Need to do mainCommand first since the secondaryCommand
+ // triggering is async.
+ triggerMainCommand(popup);
+ is(popup.children.length, 1, "only one notification left");
+ triggerSecondaryCommand(popup, 0);
+ },
+ onHidden(popup) {
+ ok(this.testNotif1.mainActionClicked, "main action #1 was clicked");
+ ok(
+ !this.testNotif1.secondaryActionClicked,
+ "secondary action #1 wasn't clicked"
+ );
+ ok(
+ !this.testNotif1.dismissalCallbackTriggered,
+ "dismissal callback #1 wasn't called"
+ );
+
+ ok(!this.testNotif2.mainActionClicked, "main action #2 wasn't clicked");
+ ok(
+ this.testNotif2.secondaryActionClicked,
+ "secondary action #2 was clicked"
+ );
+ ok(
+ !this.testNotif2.dismissalCallbackTriggered,
+ "dismissal callback #2 wasn't called"
+ );
+ },
+ },
+ // Test notification without mainAction or secondaryActions, it should fall back
+ // to a default button that dismisses the notification in place of the main action.
+ {
+ id: "Test#9",
+ run() {
+ this.notifyObj = new BasicNotification(this.id);
+ this.notifyObj.mainAction = null;
+ this.notifyObj.secondaryActions = null;
+ this.notification = showNotification(this.notifyObj);
+ },
+ onShown(popup) {
+ let notification = popup.children[0];
+ ok(
+ notification.hasAttribute("buttonhighlight"),
+ "default action is highlighted"
+ );
+ triggerMainCommand(popup);
+ },
+ onHidden(popup) {
+ ok(!this.notifyObj.mainActionClicked, "mainAction was not clicked");
+ ok(
+ !this.notifyObj.dismissalCallbackTriggered,
+ "dismissal callback wasn't triggered"
+ );
+ ok(this.notifyObj.removedCallbackTriggered, "removed callback triggered");
+ },
+ },
+ // Test notification without mainAction but with secondaryActions, it should fall back
+ // to a default button that dismisses the notification in place of the main action
+ // and ignore the passed secondaryActions.
+ {
+ id: "Test#10",
+ run() {
+ this.notifyObj = new BasicNotification(this.id);
+ this.notifyObj.mainAction = null;
+ this.notification = showNotification(this.notifyObj);
+ },
+ onShown(popup) {
+ let notification = popup.children[0];
+ is(
+ notification.getAttribute("secondarybuttonhidden"),
+ "true",
+ "secondary button is hidden"
+ );
+ ok(
+ notification.hasAttribute("buttonhighlight"),
+ "default action is highlighted"
+ );
+ triggerMainCommand(popup);
+ },
+ onHidden(popup) {
+ ok(!this.notifyObj.mainActionClicked, "mainAction was not clicked");
+ ok(
+ !this.notifyObj.dismissalCallbackTriggered,
+ "dismissal callback wasn't triggered"
+ );
+ ok(this.notifyObj.removedCallbackTriggered, "removed callback triggered");
+ },
+ },
+ // Test two notifications with different anchors
+ {
+ id: "Test#11",
+ run() {
+ this.notifyObj = new BasicNotification(this.id);
+ this.firstNotification = showNotification(this.notifyObj);
+ this.notifyObj2 = new BasicNotification(this.id);
+ this.notifyObj2.id += "-2";
+ this.notifyObj2.anchorID = "addons-notification-icon";
+ // Second showNotification() overrides the first
+ this.secondNotification = showNotification(this.notifyObj2);
+ },
+ onShown(popup) {
+ // This also checks that only one element is shown.
+ checkPopup(popup, this.notifyObj2);
+ is(
+ document.getElementById("geo-notification-icon").getBoundingClientRect()
+ .width,
+ 0,
+ "geo anchor shouldn't be visible"
+ );
+ dismissNotification(popup);
+ },
+ onHidden(popup) {
+ // Remove the notifications
+ this.firstNotification.remove();
+ this.secondNotification.remove();
+ ok(this.notifyObj.removedCallbackTriggered, "removed callback triggered");
+ ok(
+ this.notifyObj2.removedCallbackTriggered,
+ "removed callback triggered"
+ );
+ },
+ },
+];
diff --git a/browser/base/content/test/popupNotifications/browser_popupNotification_2.js b/browser/base/content/test/popupNotifications/browser_popupNotification_2.js
new file mode 100644
index 0000000000..8738a3b605
--- /dev/null
+++ b/browser/base/content/test/popupNotifications/browser_popupNotification_2.js
@@ -0,0 +1,315 @@
+/* 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/. */
+
+function test() {
+ waitForExplicitFinish();
+
+ ok(PopupNotifications, "PopupNotifications object exists");
+ ok(PopupNotifications.panel, "PopupNotifications panel exists");
+
+ setup();
+}
+
+var tests = [
+ // Test optional params
+ {
+ id: "Test#1",
+ run() {
+ this.notifyObj = new BasicNotification(this.id);
+ this.notifyObj.secondaryActions = undefined;
+ this.notification = showNotification(this.notifyObj);
+ },
+ onShown(popup) {
+ checkPopup(popup, this.notifyObj);
+ dismissNotification(popup);
+ },
+ onHidden(popup) {
+ ok(
+ this.notifyObj.dismissalCallbackTriggered,
+ "dismissal callback triggered"
+ );
+ this.notification.remove();
+ ok(this.notifyObj.removedCallbackTriggered, "removed callback triggered");
+ },
+ },
+ // Test that icons appear
+ {
+ id: "Test#2",
+ run() {
+ this.notifyObj = new BasicNotification(this.id);
+ this.notifyObj.id = "geolocation";
+ this.notifyObj.anchorID = "geo-notification-icon";
+ this.notification = showNotification(this.notifyObj);
+ },
+ onShown(popup) {
+ checkPopup(popup, this.notifyObj);
+ isnot(
+ document.getElementById("geo-notification-icon").getBoundingClientRect()
+ .width,
+ 0,
+ "geo anchor should be visible"
+ );
+ dismissNotification(popup);
+ },
+ onHidden(popup) {
+ let icon = document.getElementById("geo-notification-icon");
+ isnot(
+ icon.getBoundingClientRect().width,
+ 0,
+ "geo anchor should be visible after dismissal"
+ );
+ this.notification.remove();
+ is(
+ icon.getBoundingClientRect().width,
+ 0,
+ "geo anchor should not be visible after removal"
+ );
+ },
+ },
+
+ // Test that persistence allows the notification to persist across reloads
+ {
+ id: "Test#3",
+ async run() {
+ this.oldSelectedTab = gBrowser.selectedTab;
+ await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com/"
+ );
+ this.notifyObj = new BasicNotification(this.id);
+ this.notifyObj.addOptions({
+ persistence: 2,
+ });
+ this.notification = showNotification(this.notifyObj);
+ },
+ async onShown(popup) {
+ this.complete = false;
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ await promiseTabLoadEvent(gBrowser.selectedTab, "http://example.org/");
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ await promiseTabLoadEvent(gBrowser.selectedTab, "http://example.com/");
+ // Next load will remove the notification
+ this.complete = true;
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ await promiseTabLoadEvent(gBrowser.selectedTab, "http://example.org/");
+ },
+ onHidden(popup) {
+ ok(
+ this.complete,
+ "Should only have hidden the notification after 3 page loads"
+ );
+ ok(this.notifyObj.removedCallbackTriggered, "removal callback triggered");
+ gBrowser.removeTab(gBrowser.selectedTab);
+ gBrowser.selectedTab = this.oldSelectedTab;
+ },
+ },
+ // Test that a timeout allows the notification to persist across reloads
+ {
+ id: "Test#4",
+ async run() {
+ this.oldSelectedTab = gBrowser.selectedTab;
+ await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com/"
+ );
+ this.notifyObj = new BasicNotification(this.id);
+ // Set a timeout of 10 minutes that should never be hit
+ this.notifyObj.addOptions({
+ timeout: Date.now() + 600000,
+ });
+ this.notification = showNotification(this.notifyObj);
+ },
+ async onShown(popup) {
+ this.complete = false;
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ await promiseTabLoadEvent(gBrowser.selectedTab, "http://example.org/");
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ await promiseTabLoadEvent(gBrowser.selectedTab, "http://example.com/");
+ // Next load will hide the notification
+ this.notification.options.timeout = Date.now() - 1;
+ this.complete = true;
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ await promiseTabLoadEvent(gBrowser.selectedTab, "http://example.org/");
+ },
+ onHidden(popup) {
+ ok(
+ this.complete,
+ "Should only have hidden the notification after the timeout was passed"
+ );
+ this.notification.remove();
+ gBrowser.removeTab(gBrowser.selectedTab);
+ gBrowser.selectedTab = this.oldSelectedTab;
+ },
+ },
+ // Test that setting persistWhileVisible allows a visible notification to
+ // persist across location changes
+ {
+ id: "Test#5",
+ async run() {
+ this.oldSelectedTab = gBrowser.selectedTab;
+ await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com/"
+ );
+ this.notifyObj = new BasicNotification(this.id);
+ this.notifyObj.addOptions({
+ persistWhileVisible: true,
+ });
+ this.notification = showNotification(this.notifyObj);
+ },
+ async onShown(popup) {
+ this.complete = false;
+
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ await promiseTabLoadEvent(gBrowser.selectedTab, "http://example.org/");
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ await promiseTabLoadEvent(gBrowser.selectedTab, "http://example.com/");
+ // Notification should persist across location changes
+ this.complete = true;
+ dismissNotification(popup);
+ },
+ onHidden(popup) {
+ ok(
+ this.complete,
+ "Should only have hidden the notification after it was dismissed"
+ );
+ this.notification.remove();
+ gBrowser.removeTab(gBrowser.selectedTab);
+ gBrowser.selectedTab = this.oldSelectedTab;
+ },
+ },
+
+ // Test that nested icon nodes correctly activate popups
+ {
+ id: "Test#6",
+ run() {
+ // Add a temporary box as the anchor with a button
+ this.box = document.createXULElement("box");
+ PopupNotifications.iconBox.appendChild(this.box);
+
+ let button = document.createXULElement("button");
+ button.setAttribute("label", "Please click me!");
+ this.box.appendChild(button);
+
+ // The notification should open up on the box
+ this.notifyObj = new BasicNotification(this.id);
+ this.notifyObj.anchorID = this.box.id = "nested-box";
+ this.notifyObj.addOptions({ dismissed: true });
+ this.notification = showNotification(this.notifyObj);
+
+ // This test places a normal button in the notification area, which has
+ // standard GTK styling and dimensions. Due to the clip-path, this button
+ // gets clipped off, which makes it necessary to synthesize the mouse click
+ // a little bit downward. To be safe, I adjusted the x-offset with the same
+ // amount.
+ EventUtils.synthesizeMouse(button, 4, 4, {});
+ },
+ onShown(popup) {
+ checkPopup(popup, this.notifyObj);
+ dismissNotification(popup);
+ },
+ onHidden(popup) {
+ this.notification.remove();
+ this.box.remove();
+ },
+ },
+ // Test that popupnotifications without popups have anchor icons shown
+ {
+ id: "Test#7",
+ async run() {
+ let notifyObj = new BasicNotification(this.id);
+ notifyObj.anchorID = "geo-notification-icon";
+ notifyObj.addOptions({ neverShow: true });
+ let promiseTopic = TestUtils.topicObserved(
+ "PopupNotifications-updateNotShowing"
+ );
+ showNotification(notifyObj);
+ await promiseTopic;
+ isnot(
+ document.getElementById("geo-notification-icon").getBoundingClientRect()
+ .width,
+ 0,
+ "geo anchor should be visible"
+ );
+ goNext();
+ },
+ },
+ // Test that autoplay media icon is shown
+ {
+ id: "Test#8",
+ async run() {
+ let notifyObj = new BasicNotification(this.id);
+ notifyObj.anchorID = "autoplay-media-notification-icon";
+ notifyObj.addOptions({ neverShow: true });
+ let promiseTopic = TestUtils.topicObserved(
+ "PopupNotifications-updateNotShowing"
+ );
+ showNotification(notifyObj);
+ await promiseTopic;
+ isnot(
+ document
+ .getElementById("autoplay-media-notification-icon")
+ .getBoundingClientRect().width,
+ 0,
+ "autoplay media icon should be visible"
+ );
+ goNext();
+ },
+ },
+ // Test notification close button
+ {
+ id: "Test#9",
+ run() {
+ this.notifyObj = new BasicNotification(this.id);
+ this.notification = showNotification(this.notifyObj);
+ },
+ onShown(popup) {
+ checkPopup(popup, this.notifyObj);
+ let notification = popup.children[0];
+ EventUtils.synthesizeMouseAtCenter(notification.closebutton, {});
+ },
+ onHidden(popup) {
+ ok(
+ this.notifyObj.dismissalCallbackTriggered,
+ "dismissal callback triggered"
+ );
+ this.notification.remove();
+ ok(this.notifyObj.removedCallbackTriggered, "removed callback triggered");
+ ok(
+ !this.notifyObj.secondaryActionClicked,
+ "secondary action not clicked"
+ );
+ },
+ },
+ // Test notification when chrome is hidden
+ {
+ id: "Test#11",
+ run() {
+ window.locationbar.visible = false;
+ this.notifyObj = new BasicNotification(this.id);
+ this.notification = showNotification(this.notifyObj);
+ },
+ onShown(popup) {
+ checkPopup(popup, this.notifyObj);
+ is(
+ popup.anchorNode.className,
+ "tabbrowser-tab",
+ "notification anchored to tab"
+ );
+ dismissNotification(popup);
+ },
+ onHidden(popup) {
+ ok(
+ this.notifyObj.dismissalCallbackTriggered,
+ "dismissal callback triggered"
+ );
+ this.notification.remove();
+ ok(this.notifyObj.removedCallbackTriggered, "removed callback triggered");
+ window.locationbar.visible = true;
+ },
+ },
+];
diff --git a/browser/base/content/test/popupNotifications/browser_popupNotification_3.js b/browser/base/content/test/popupNotifications/browser_popupNotification_3.js
new file mode 100644
index 0000000000..e0954e39ca
--- /dev/null
+++ b/browser/base/content/test/popupNotifications/browser_popupNotification_3.js
@@ -0,0 +1,377 @@
+/* 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/. */
+/* eslint-disable mozilla/no-arbitrary-setTimeout */
+
+function test() {
+ waitForExplicitFinish();
+
+ ok(PopupNotifications, "PopupNotifications object exists");
+ ok(PopupNotifications.panel, "PopupNotifications panel exists");
+
+ setup();
+}
+
+var tests = [
+ // Test notification is removed when dismissed if removeOnDismissal is true
+ {
+ id: "Test#1",
+ run() {
+ this.notifyObj = new BasicNotification(this.id);
+ this.notifyObj.addOptions({
+ removeOnDismissal: true,
+ });
+ this.notification = showNotification(this.notifyObj);
+ },
+ onShown(popup) {
+ checkPopup(popup, this.notifyObj);
+ dismissNotification(popup);
+ },
+ onHidden(popup) {
+ ok(
+ !this.notifyObj.dismissalCallbackTriggered,
+ "dismissal callback wasn't triggered"
+ );
+ ok(this.notifyObj.removedCallbackTriggered, "removed callback triggered");
+ },
+ },
+ // Test multiple notification icons are shown
+ {
+ id: "Test#2",
+ run() {
+ this.notifyObj1 = new BasicNotification(this.id);
+ this.notifyObj1.id += "_1";
+ this.notifyObj1.anchorID = "default-notification-icon";
+ this.notification1 = showNotification(this.notifyObj1);
+
+ this.notifyObj2 = new BasicNotification(this.id);
+ this.notifyObj2.id += "_2";
+ this.notifyObj2.anchorID = "geo-notification-icon";
+ this.notification2 = showNotification(this.notifyObj2);
+ },
+ onShown(popup) {
+ checkPopup(popup, this.notifyObj2);
+
+ // check notifyObj1 anchor icon is showing
+ isnot(
+ document
+ .getElementById("default-notification-icon")
+ .getBoundingClientRect().width,
+ 0,
+ "default anchor should be visible"
+ );
+ // check notifyObj2 anchor icon is showing
+ isnot(
+ document.getElementById("geo-notification-icon").getBoundingClientRect()
+ .width,
+ 0,
+ "geo anchor should be visible"
+ );
+
+ dismissNotification(popup);
+ },
+ onHidden(popup) {
+ this.notification1.remove();
+ ok(
+ this.notifyObj1.removedCallbackTriggered,
+ "removed callback triggered"
+ );
+
+ this.notification2.remove();
+ ok(
+ this.notifyObj2.removedCallbackTriggered,
+ "removed callback triggered"
+ );
+ },
+ },
+ // Test that multiple notification icons are removed when switching tabs
+ {
+ id: "Test#3",
+ async run() {
+ // show the notification on old tab.
+ this.notifyObjOld = new BasicNotification(this.id);
+ this.notifyObjOld.anchorID = "default-notification-icon";
+ this.notificationOld = showNotification(this.notifyObjOld);
+
+ // switch tab
+ this.oldSelectedTab = gBrowser.selectedTab;
+ await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com/"
+ );
+
+ // show the notification on new tab.
+ this.notifyObjNew = new BasicNotification(this.id);
+ this.notifyObjNew.anchorID = "geo-notification-icon";
+ this.notificationNew = showNotification(this.notifyObjNew);
+ },
+ onShown(popup) {
+ checkPopup(popup, this.notifyObjNew);
+
+ // check notifyObjOld anchor icon is removed
+ is(
+ document
+ .getElementById("default-notification-icon")
+ .getBoundingClientRect().width,
+ 0,
+ "default anchor shouldn't be visible"
+ );
+ // check notifyObjNew anchor icon is showing
+ isnot(
+ document.getElementById("geo-notification-icon").getBoundingClientRect()
+ .width,
+ 0,
+ "geo anchor should be visible"
+ );
+
+ dismissNotification(popup);
+ },
+ onHidden(popup) {
+ this.notificationNew.remove();
+ gBrowser.removeTab(gBrowser.selectedTab);
+
+ gBrowser.selectedTab = this.oldSelectedTab;
+ this.notificationOld.remove();
+ },
+ },
+ // test security delay - too early
+ {
+ id: "Test#4",
+ async run() {
+ // Set the security delay to 100s
+ await SpecialPowers.pushPrefEnv({
+ set: [["security.notification_enable_delay", 100000]],
+ });
+
+ this.notifyObj = new BasicNotification(this.id);
+ showNotification(this.notifyObj);
+ },
+ onShown(popup) {
+ checkPopup(popup, this.notifyObj);
+ triggerMainCommand(popup);
+
+ // Wait to see if the main command worked
+ executeSoon(function delayedDismissal() {
+ dismissNotification(popup);
+ });
+ },
+ onHidden(popup) {
+ ok(
+ !this.notifyObj.mainActionClicked,
+ "mainAction was not clicked because it was too soon"
+ );
+ ok(
+ this.notifyObj.dismissalCallbackTriggered,
+ "dismissal callback was triggered"
+ );
+ },
+ },
+ // test security delay - after delay
+ {
+ id: "Test#5",
+ async run() {
+ // Set the security delay to 10ms
+
+ await SpecialPowers.pushPrefEnv({
+ set: [["security.notification_enable_delay", 10]],
+ });
+
+ this.notifyObj = new BasicNotification(this.id);
+ showNotification(this.notifyObj);
+ },
+ onShown(popup) {
+ checkPopup(popup, this.notifyObj);
+
+ // Wait until after the delay to trigger the main action
+ setTimeout(function delayedDismissal() {
+ triggerMainCommand(popup);
+ }, 500);
+ },
+ onHidden(popup) {
+ ok(
+ this.notifyObj.mainActionClicked,
+ "mainAction was clicked after the delay"
+ );
+ ok(
+ !this.notifyObj.dismissalCallbackTriggered,
+ "dismissal callback was not triggered"
+ );
+ },
+ },
+ // reload removes notification
+ {
+ id: "Test#6",
+ async run() {
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ await promiseTabLoadEvent(gBrowser.selectedTab, "http://example.com/");
+ let notifyObj = new BasicNotification(this.id);
+ notifyObj.options.eventCallback = function (eventName) {
+ if (eventName == "removed") {
+ ok(true, "Notification removed in background tab after reloading");
+ goNext();
+ }
+ };
+ showNotification(notifyObj);
+ executeSoon(function () {
+ gBrowser.selectedBrowser.reload();
+ });
+ },
+ },
+ // location change in background tab removes notification
+ {
+ id: "Test#7",
+ async run() {
+ let oldSelectedTab = gBrowser.selectedTab;
+ let newTab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com/"
+ );
+ gBrowser.selectedTab = oldSelectedTab;
+ let browser = gBrowser.getBrowserForTab(newTab);
+
+ let notifyObj = new BasicNotification(this.id);
+ notifyObj.browser = browser;
+ notifyObj.options.eventCallback = function (eventName) {
+ if (eventName == "removed") {
+ ok(true, "Notification removed in background tab after reloading");
+ executeSoon(function () {
+ gBrowser.removeTab(newTab);
+ goNext();
+ });
+ }
+ };
+ showNotification(notifyObj);
+ executeSoon(function () {
+ browser.reload();
+ });
+ },
+ },
+ // Popup notification anchor shouldn't disappear when a notification with the same ID is re-added in a background tab
+ {
+ id: "Test#8",
+ async run() {
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ await promiseTabLoadEvent(gBrowser.selectedTab, "http://example.com/");
+ let originalTab = gBrowser.selectedTab;
+ let bgTab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com/"
+ );
+ let anchor = document.createXULElement("box");
+ anchor.id = "test26-anchor";
+ anchor.className = "notification-anchor-icon";
+ PopupNotifications.iconBox.appendChild(anchor);
+
+ gBrowser.selectedTab = originalTab;
+
+ let fgNotifyObj = new BasicNotification(this.id);
+ fgNotifyObj.anchorID = anchor.id;
+ fgNotifyObj.options.dismissed = true;
+ let fgNotification = showNotification(fgNotifyObj);
+
+ let bgNotifyObj = new BasicNotification(this.id);
+ bgNotifyObj.anchorID = anchor.id;
+ bgNotifyObj.browser = gBrowser.getBrowserForTab(bgTab);
+ // show the notification in the background tab ...
+ let bgNotification = showNotification(bgNotifyObj);
+ // ... and re-show it
+ bgNotification = showNotification(bgNotifyObj);
+
+ ok(fgNotification.id, "notification has id");
+ is(fgNotification.id, bgNotification.id, "notification ids are the same");
+ is(anchor.getAttribute("showing"), "true", "anchor still showing");
+
+ fgNotification.remove();
+ gBrowser.removeTab(bgTab);
+ goNext();
+ },
+ },
+ // location change in an embedded frame should not remove a notification
+ {
+ id: "Test#9",
+ async run() {
+ await promiseTabLoadEvent(
+ gBrowser.selectedTab,
+ "data:text/html;charset=utf8,<iframe%20id='iframe'%20src='http://example.com/'>"
+ );
+ this.notifyObj = new BasicNotification(this.id);
+ this.notifyObj.options.eventCallback = function (eventName) {
+ if (eventName == "removed") {
+ ok(
+ false,
+ "Notification removed from browser when subframe navigated"
+ );
+ }
+ };
+ showNotification(this.notifyObj);
+ },
+ async onShown(popup) {
+ info("Adding observer and performing navigation");
+
+ await Promise.all([
+ BrowserUtils.promiseObserved("window-global-created", wgp =>
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ wgp.documentURI.spec.startsWith("http://example.org/")
+ ),
+ SpecialPowers.spawn(gBrowser.selectedBrowser, [], function () {
+ content.document
+ .getElementById("iframe")
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ .setAttribute("src", "http://example.org/");
+ }),
+ ]);
+
+ executeSoon(() => {
+ let notification = PopupNotifications.getNotification(
+ this.notifyObj.id,
+ this.notifyObj.browser
+ );
+ ok(
+ notification != null,
+ "Notification remained when subframe navigated"
+ );
+ this.notifyObj.options.eventCallback = undefined;
+
+ notification.remove();
+ });
+ },
+ onHidden() {},
+ },
+ // Popup Notifications should catch exceptions from callbacks
+ {
+ id: "Test#10",
+ run() {
+ this.testNotif1 = new BasicNotification(this.id);
+ this.testNotif1.message += " 1";
+ this.notification1 = showNotification(this.testNotif1);
+ this.testNotif1.options.eventCallback = function (eventName) {
+ info("notifyObj1.options.eventCallback: " + eventName);
+ if (eventName == "dismissed") {
+ throw new Error("Oops 1!");
+ }
+ };
+
+ this.testNotif2 = new BasicNotification(this.id);
+ this.testNotif2.message += " 2";
+ this.testNotif2.id += "-2";
+ this.testNotif2.options.eventCallback = function (eventName) {
+ info("notifyObj2.options.eventCallback: " + eventName);
+ if (eventName == "dismissed") {
+ throw new Error("Oops 2!");
+ }
+ };
+ this.notification2 = showNotification(this.testNotif2);
+ },
+ onShown(popup) {
+ is(popup.children.length, 2, "two notifications are shown");
+ dismissNotification(popup);
+ },
+ onHidden() {
+ this.notification1.remove();
+ this.notification2.remove();
+ },
+ },
+];
diff --git a/browser/base/content/test/popupNotifications/browser_popupNotification_4.js b/browser/base/content/test/popupNotifications/browser_popupNotification_4.js
new file mode 100644
index 0000000000..b0e8f016ef
--- /dev/null
+++ b/browser/base/content/test/popupNotifications/browser_popupNotification_4.js
@@ -0,0 +1,290 @@
+/* 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/. */
+
+function test() {
+ waitForExplicitFinish();
+
+ ok(PopupNotifications, "PopupNotifications object exists");
+ ok(PopupNotifications.panel, "PopupNotifications panel exists");
+
+ setup();
+}
+
+var tests = [
+ // Popup Notifications main actions should catch exceptions from callbacks
+ {
+ id: "Test#1",
+ run() {
+ this.testNotif = new ErrorNotification(this.id);
+ showNotification(this.testNotif);
+ },
+ onShown(popup) {
+ checkPopup(popup, this.testNotif);
+ triggerMainCommand(popup);
+ },
+ onHidden(popup) {
+ ok(this.testNotif.mainActionClicked, "main action has been triggered");
+ },
+ },
+ // Popup Notifications secondary actions should catch exceptions from callbacks
+ {
+ id: "Test#2",
+ run() {
+ this.testNotif = new ErrorNotification(this.id);
+ showNotification(this.testNotif);
+ },
+ onShown(popup) {
+ checkPopup(popup, this.testNotif);
+ triggerSecondaryCommand(popup, 0);
+ },
+ onHidden(popup) {
+ ok(
+ this.testNotif.secondaryActionClicked,
+ "secondary action has been triggered"
+ );
+ },
+ },
+ // Existing popup notification shouldn't disappear when adding a dismissed notification
+ {
+ id: "Test#3",
+ run() {
+ this.notifyObj1 = new BasicNotification(this.id);
+ this.notifyObj1.id += "_1";
+ this.notifyObj1.anchorID = "default-notification-icon";
+ this.notification1 = showNotification(this.notifyObj1);
+ },
+ onShown(popup) {
+ // Now show a dismissed notification, and check that it doesn't clobber
+ // the showing one.
+ this.notifyObj2 = new BasicNotification(this.id);
+ this.notifyObj2.id += "_2";
+ this.notifyObj2.anchorID = "geo-notification-icon";
+ this.notifyObj2.options.dismissed = true;
+ this.notification2 = showNotification(this.notifyObj2);
+
+ checkPopup(popup, this.notifyObj1);
+
+ // check that both anchor icons are showing
+ is(
+ document
+ .getElementById("default-notification-icon")
+ .getAttribute("showing"),
+ "true",
+ "notification1 anchor should be visible"
+ );
+ is(
+ document
+ .getElementById("geo-notification-icon")
+ .getAttribute("showing"),
+ "true",
+ "notification2 anchor should be visible"
+ );
+
+ dismissNotification(popup);
+ },
+ onHidden(popup) {
+ this.notification1.remove();
+ this.notification2.remove();
+ },
+ },
+ // Showing should be able to modify the popup data
+ {
+ id: "Test#4",
+ run() {
+ this.notifyObj = new BasicNotification(this.id);
+ let normalCallback = this.notifyObj.options.eventCallback;
+ this.notifyObj.options.eventCallback = function (eventName) {
+ if (eventName == "showing") {
+ this.mainAction.label = "Alternate Label";
+ }
+ normalCallback.call(this, eventName);
+ };
+ showNotification(this.notifyObj);
+ },
+ onShown(popup) {
+ // checkPopup checks for the matching label. Note that this assumes that
+ // this.notifyObj.mainAction is the same as notification.mainAction,
+ // which could be a problem if we ever decided to deep-copy.
+ checkPopup(popup, this.notifyObj);
+ triggerMainCommand(popup);
+ },
+ onHidden() {},
+ },
+ // Moving a tab to a new window should remove non-swappable notifications.
+ {
+ id: "Test#5",
+ async run() {
+ await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com/"
+ );
+
+ let notifyObj = new BasicNotification(this.id);
+
+ let shown = waitForNotificationPanel();
+ showNotification(notifyObj);
+ await shown;
+
+ let promiseWin = BrowserTestUtils.waitForNewWindow();
+ gBrowser.replaceTabWithWindow(gBrowser.selectedTab);
+ let win = await promiseWin;
+
+ let anchor = win.document.getElementById("default-notification-icon");
+ win.PopupNotifications._reshowNotifications(anchor);
+ ok(
+ !win.PopupNotifications.panel.children.length,
+ "no notification displayed in new window"
+ );
+ ok(
+ notifyObj.swappingCallbackTriggered,
+ "the swapping callback was triggered"
+ );
+ ok(
+ notifyObj.removedCallbackTriggered,
+ "the removed callback was triggered"
+ );
+
+ await BrowserTestUtils.closeWindow(win);
+ await waitForWindowReadyForPopupNotifications(window);
+
+ goNext();
+ },
+ },
+ // Moving a tab to a new window should preserve swappable notifications.
+ {
+ id: "Test#6",
+ async run() {
+ await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com/"
+ );
+ let notifyObj = new BasicNotification(this.id);
+ let originalCallback = notifyObj.options.eventCallback;
+ notifyObj.options.eventCallback = function (eventName) {
+ originalCallback(eventName);
+ return eventName == "swapping";
+ };
+
+ let shown = waitForNotificationPanel();
+ let notification = showNotification(notifyObj);
+ await shown;
+
+ let promiseWin = BrowserTestUtils.waitForNewWindow();
+ gBrowser.replaceTabWithWindow(gBrowser.selectedTab);
+ let win = await promiseWin;
+ await waitForWindowReadyForPopupNotifications(win);
+
+ await new Promise(resolve => {
+ let callback = notification.options.eventCallback;
+ notification.options.eventCallback = function (eventName) {
+ callback(eventName);
+ if (eventName == "shown") {
+ resolve();
+ }
+ };
+ info("Showing the notification again");
+ notification.reshow();
+ });
+
+ checkPopup(win.PopupNotifications.panel, notifyObj);
+ ok(
+ notifyObj.swappingCallbackTriggered,
+ "the swapping callback was triggered"
+ );
+
+ await BrowserTestUtils.closeWindow(win);
+ await waitForWindowReadyForPopupNotifications(window);
+
+ goNext();
+ },
+ },
+ // the main action callback can keep the notification.
+ {
+ id: "Test#8",
+ run() {
+ this.notifyObj = new BasicNotification(this.id);
+ this.notifyObj.mainAction.dismiss = true;
+ this.notification = showNotification(this.notifyObj);
+ },
+ onShown(popup) {
+ checkPopup(popup, this.notifyObj);
+ triggerMainCommand(popup);
+ },
+ onHidden(popup) {
+ ok(
+ this.notifyObj.dismissalCallbackTriggered,
+ "dismissal callback was triggered"
+ );
+ ok(
+ !this.notifyObj.removedCallbackTriggered,
+ "removed callback wasn't triggered"
+ );
+ this.notification.remove();
+ },
+ },
+ // a secondary action callback can keep the notification.
+ {
+ id: "Test#9",
+ run() {
+ this.notifyObj = new BasicNotification(this.id);
+ this.notifyObj.secondaryActions[0].dismiss = true;
+ this.notification = showNotification(this.notifyObj);
+ },
+ onShown(popup) {
+ checkPopup(popup, this.notifyObj);
+ triggerSecondaryCommand(popup, 0);
+ },
+ onHidden(popup) {
+ ok(
+ this.notifyObj.dismissalCallbackTriggered,
+ "dismissal callback was triggered"
+ );
+ ok(
+ !this.notifyObj.removedCallbackTriggered,
+ "removed callback wasn't triggered"
+ );
+ this.notification.remove();
+ },
+ },
+ // returning true in the showing callback should dismiss the notification.
+ {
+ id: "Test#10",
+ run() {
+ let notifyObj = new BasicNotification(this.id);
+ let originalCallback = notifyObj.options.eventCallback;
+ notifyObj.options.eventCallback = function (eventName) {
+ originalCallback(eventName);
+ return eventName == "showing";
+ };
+
+ let notification = showNotification(notifyObj);
+ ok(
+ notifyObj.showingCallbackTriggered,
+ "the showing callback was triggered"
+ );
+ ok(
+ !notifyObj.shownCallbackTriggered,
+ "the shown callback wasn't triggered"
+ );
+ notification.remove();
+ goNext();
+ },
+ },
+ // the main action button should apply non-default(no highlight) style.
+ {
+ id: "Test#11",
+ run() {
+ this.notifyObj = new BasicNotification(this.id);
+ this.notifyObj.secondaryActions = undefined;
+ this.notification = showNotification(this.notifyObj);
+ },
+ onShown(popup) {
+ checkPopup(popup, this.notifyObj);
+ dismissNotification(popup);
+ },
+ onHidden() {},
+ },
+];
diff --git a/browser/base/content/test/popupNotifications/browser_popupNotification_5.js b/browser/base/content/test/popupNotifications/browser_popupNotification_5.js
new file mode 100644
index 0000000000..839262caa0
--- /dev/null
+++ b/browser/base/content/test/popupNotifications/browser_popupNotification_5.js
@@ -0,0 +1,501 @@
+/* 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/. */
+
+function test() {
+ waitForExplicitFinish();
+
+ ok(PopupNotifications, "PopupNotifications object exists");
+ ok(PopupNotifications.panel, "PopupNotifications panel exists");
+
+ setup();
+}
+
+var gNotification;
+
+var tests = [
+ // panel updates should fire the showing and shown callbacks again.
+ {
+ id: "Test#1",
+ run() {
+ this.notifyObj = new BasicNotification(this.id);
+ this.notification = showNotification(this.notifyObj);
+ },
+ onShown(popup) {
+ checkPopup(popup, this.notifyObj);
+
+ this.notifyObj.showingCallbackTriggered = false;
+ this.notifyObj.shownCallbackTriggered = false;
+
+ // Force an update of the panel. This is typically called
+ // automatically when receiving 'activate' or 'TabSelect' events,
+ // but from a setTimeout, which is inconvenient for the test.
+ PopupNotifications._update();
+
+ checkPopup(popup, this.notifyObj);
+
+ this.notification.remove();
+ },
+ onHidden() {},
+ },
+ // A first dismissed notification shouldn't stop _update from showing a second notification
+ {
+ id: "Test#2",
+ run() {
+ this.notifyObj1 = new BasicNotification(this.id);
+ this.notifyObj1.id += "_1";
+ this.notifyObj1.anchorID = "default-notification-icon";
+ this.notifyObj1.options.dismissed = true;
+ this.notification1 = showNotification(this.notifyObj1);
+
+ this.notifyObj2 = new BasicNotification(this.id);
+ this.notifyObj2.id += "_2";
+ this.notifyObj2.anchorID = "geo-notification-icon";
+ this.notifyObj2.options.dismissed = true;
+ this.notification2 = showNotification(this.notifyObj2);
+
+ this.notification2.dismissed = false;
+ PopupNotifications._update();
+ },
+ onShown(popup) {
+ checkPopup(popup, this.notifyObj2);
+ this.notification1.remove();
+ this.notification2.remove();
+ },
+ onHidden(popup) {},
+ },
+ // The anchor icon should be shown for notifications in background windows.
+ {
+ id: "Test#3",
+ async run() {
+ let notifyObj = new BasicNotification(this.id);
+ notifyObj.options.dismissed = true;
+
+ let win = await BrowserTestUtils.openNewBrowserWindow();
+
+ // Open the notification in the original window, now in the background.
+ let notification = showNotification(notifyObj);
+ let anchor = document.getElementById("default-notification-icon");
+ is(anchor.getAttribute("showing"), "true", "the anchor is shown");
+ notification.remove();
+
+ await BrowserTestUtils.closeWindow(win);
+ await waitForWindowReadyForPopupNotifications(window);
+
+ goNext();
+ },
+ },
+ // Test that persistent doesn't allow the notification to persist after
+ // navigation.
+ {
+ id: "Test#4",
+ async run() {
+ this.oldSelectedTab = gBrowser.selectedTab;
+ await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com/"
+ );
+ this.notifyObj = new BasicNotification(this.id);
+ this.notifyObj.addOptions({
+ persistent: true,
+ });
+ this.notification = showNotification(this.notifyObj);
+ },
+ async onShown(popup) {
+ this.complete = false;
+
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ await promiseTabLoadEvent(gBrowser.selectedTab, "http://example.org/");
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ await promiseTabLoadEvent(gBrowser.selectedTab, "http://example.com/");
+
+ // This code should not be executed.
+ ok(false, "Should have removed the notification after navigation");
+ // Properly dismiss and cleanup in case the unthinkable happens.
+ this.complete = true;
+ triggerSecondaryCommand(popup, 0);
+ },
+ onHidden(popup) {
+ ok(
+ !this.complete,
+ "Should have hidden the notification after navigation"
+ );
+ this.notification.remove();
+ gBrowser.removeTab(gBrowser.selectedTab);
+ gBrowser.selectedTab = this.oldSelectedTab;
+ },
+ },
+ // Test that persistent allows the notification to persist until explicitly
+ // dismissed.
+ {
+ id: "Test#5",
+ async run() {
+ this.oldSelectedTab = gBrowser.selectedTab;
+ await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com/"
+ );
+ this.notifyObj = new BasicNotification(this.id);
+ this.notifyObj.addOptions({
+ persistent: true,
+ });
+ this.notification = showNotification(this.notifyObj);
+ },
+ async onShown(popup) {
+ this.complete = false;
+
+ // Notification should persist after attempt to dismiss by clicking on the
+ // content area.
+ let browser = gBrowser.selectedBrowser;
+ await BrowserTestUtils.synthesizeMouseAtCenter("body", {}, browser);
+
+ // Notification should be hidden after dismissal via Don't Allow.
+ this.complete = true;
+ triggerSecondaryCommand(popup, 0);
+ },
+ onHidden(popup) {
+ ok(
+ this.complete,
+ "Should have hidden the notification after clicking Not Now"
+ );
+ this.notification.remove();
+ gBrowser.removeTab(gBrowser.selectedTab);
+ gBrowser.selectedTab = this.oldSelectedTab;
+ },
+ },
+ // Test that persistent panels are still open after switching to another tab
+ // and back.
+ {
+ id: "Test#6a",
+ run() {
+ this.notifyObj = new BasicNotification(this.id);
+ this.notifyObj.options.persistent = true;
+ gNotification = showNotification(this.notifyObj);
+ },
+ async onShown(popup) {
+ this.oldSelectedTab = gBrowser.selectedTab;
+ await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com/"
+ );
+ },
+ onHidden(popup) {
+ ok(true, "Should have hidden the notification after tab switch");
+ gBrowser.removeTab(gBrowser.selectedTab);
+ gBrowser.selectedTab = this.oldSelectedTab;
+ },
+ },
+ // Second part of the previous test that compensates for the limitation in
+ // runNextTest that expects a single onShown/onHidden invocation per test.
+ {
+ id: "Test#6b",
+ run() {
+ let id =
+ PopupNotifications.panel.firstElementChild.getAttribute("popupid");
+ ok(
+ id.endsWith("Test#6a"),
+ "Should have found the notification from Test6a"
+ );
+ ok(
+ PopupNotifications.isPanelOpen,
+ "Should have shown the popup again after getting back to the tab"
+ );
+ gNotification.remove();
+ gNotification = null;
+ goNext();
+ },
+ },
+ // Test that persistent panels are still open after switching to another
+ // window and back.
+ {
+ id: "Test#7",
+ async run() {
+ this.oldSelectedTab = gBrowser.selectedTab;
+ await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com/"
+ );
+ let firstTab = gBrowser.selectedTab;
+
+ await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com/"
+ );
+
+ let shown = waitForNotificationPanel();
+ let notifyObj = new BasicNotification(this.id);
+ notifyObj.options.persistent = true;
+ this.notification = showNotification(notifyObj);
+ await shown;
+
+ ok(
+ notifyObj.shownCallbackTriggered,
+ "Should have triggered the shown event"
+ );
+ ok(
+ notifyObj.showingCallbackTriggered,
+ "Should have triggered the showing event"
+ );
+ // Reset to false so that we can ensure these are not fired a second time.
+ notifyObj.shownCallbackTriggered = false;
+ notifyObj.showingCallbackTriggered = false;
+ let timeShown = this.notification.timeShown;
+
+ let promiseWin = BrowserTestUtils.waitForNewWindow();
+ gBrowser.replaceTabWithWindow(firstTab);
+ let win = await promiseWin;
+
+ let anchor = win.document.getElementById("default-notification-icon");
+ win.PopupNotifications._reshowNotifications(anchor);
+ ok(
+ !win.PopupNotifications.panel.children.length,
+ "no notification displayed in new window"
+ );
+
+ await BrowserTestUtils.closeWindow(win);
+ await waitForWindowReadyForPopupNotifications(window);
+
+ let id =
+ PopupNotifications.panel.firstElementChild.getAttribute("popupid");
+ ok(
+ id.endsWith("Test#7"),
+ "Should have found the notification from Test7"
+ );
+ ok(
+ PopupNotifications.isPanelOpen,
+ "Should have kept the popup on the first window"
+ );
+ ok(
+ !notifyObj.dismissalCallbackTriggered,
+ "Should not have triggered a dismissed event"
+ );
+ ok(
+ !notifyObj.shownCallbackTriggered,
+ "Should not have triggered a second shown event"
+ );
+ ok(
+ !notifyObj.showingCallbackTriggered,
+ "Should not have triggered a second showing event"
+ );
+ ok(
+ this.notification.timeShown > timeShown,
+ "should have updated timeShown to restart the security delay"
+ );
+
+ this.notification.remove();
+ gBrowser.removeTab(gBrowser.selectedTab);
+ gBrowser.selectedTab = this.oldSelectedTab;
+
+ goNext();
+ },
+ },
+ // Test that only the first persistent notification is shown on update
+ {
+ id: "Test#8",
+ run() {
+ this.notifyObj1 = new BasicNotification(this.id);
+ this.notifyObj1.id += "_1";
+ this.notifyObj1.anchorID = "default-notification-icon";
+ this.notifyObj1.options.persistent = true;
+ this.notification1 = showNotification(this.notifyObj1);
+
+ this.notifyObj2 = new BasicNotification(this.id);
+ this.notifyObj2.id += "_2";
+ this.notifyObj2.anchorID = "geo-notification-icon";
+ this.notifyObj2.options.persistent = true;
+ this.notification2 = showNotification(this.notifyObj2);
+
+ PopupNotifications._update();
+ },
+ onShown(popup) {
+ checkPopup(popup, this.notifyObj1);
+ this.notification1.remove();
+ this.notification2.remove();
+ },
+ onHidden(popup) {},
+ },
+ // Test that persistent notifications are shown stacked by anchor on update
+ {
+ id: "Test#9",
+ run() {
+ this.notifyObj1 = new BasicNotification(this.id);
+ this.notifyObj1.id += "_1";
+ this.notifyObj1.anchorID = "default-notification-icon";
+ this.notifyObj1.options.persistent = true;
+ this.notification1 = showNotification(this.notifyObj1);
+
+ this.notifyObj2 = new BasicNotification(this.id);
+ this.notifyObj2.id += "_2";
+ this.notifyObj2.anchorID = "geo-notification-icon";
+ this.notifyObj2.options.persistent = true;
+ this.notification2 = showNotification(this.notifyObj2);
+
+ this.notifyObj3 = new BasicNotification(this.id);
+ this.notifyObj3.id += "_3";
+ this.notifyObj3.anchorID = "default-notification-icon";
+ this.notifyObj3.options.persistent = true;
+ this.notification3 = showNotification(this.notifyObj3);
+
+ PopupNotifications._update();
+ },
+ onShown(popup) {
+ let notifications = popup.children;
+ is(notifications.length, 2, "two notifications displayed");
+ let [notification1, notification2] = notifications;
+ is(
+ notification1.id,
+ this.notifyObj1.id + "-notification",
+ "id 1 matches"
+ );
+ is(
+ notification2.id,
+ this.notifyObj3.id + "-notification",
+ "id 2 matches"
+ );
+
+ this.notification1.remove();
+ this.notification2.remove();
+ this.notification3.remove();
+ },
+ onHidden(popup) {},
+ },
+ // Test that on closebutton click, only the persistent notification
+ // that contained the closebutton loses its persistent status.
+ {
+ id: "Test#10",
+ run() {
+ this.notifyObj1 = new BasicNotification(this.id);
+ this.notifyObj1.id += "_1";
+ this.notifyObj1.anchorID = "geo-notification-icon";
+ this.notifyObj1.options.persistent = true;
+ this.notifyObj1.options.hideClose = false;
+ this.notification1 = showNotification(this.notifyObj1);
+
+ this.notifyObj2 = new BasicNotification(this.id);
+ this.notifyObj2.id += "_2";
+ this.notifyObj2.anchorID = "geo-notification-icon";
+ this.notifyObj2.options.persistent = true;
+ this.notifyObj2.options.hideClose = false;
+ this.notification2 = showNotification(this.notifyObj2);
+
+ this.notifyObj3 = new BasicNotification(this.id);
+ this.notifyObj3.id += "_3";
+ this.notifyObj3.anchorID = "geo-notification-icon";
+ this.notifyObj3.options.persistent = true;
+ this.notifyObj3.options.hideClose = false;
+ this.notification3 = showNotification(this.notifyObj3);
+
+ PopupNotifications._update();
+ },
+ onShown(popup) {
+ let notifications = popup.children;
+ is(notifications.length, 3, "three notifications displayed");
+ EventUtils.synthesizeMouseAtCenter(notifications[1].closebutton, {});
+ },
+ onHidden(popup) {
+ let notifications = popup.children;
+ is(notifications.length, 2, "two notifications displayed");
+
+ ok(this.notification1.options.persistent, "notification 1 is persistent");
+ ok(
+ !this.notification2.options.persistent,
+ "notification 2 is not persistent"
+ );
+ ok(this.notification3.options.persistent, "notification 3 is persistent");
+
+ this.notification1.remove();
+ this.notification2.remove();
+ this.notification3.remove();
+ },
+ },
+ // Test clicking the anchor icon.
+ // Clicking the anchor of an already visible persistent notification should
+ // focus the main action button, but not cause additional showing/shown event
+ // callback calls.
+ // Clicking the anchor of a dismissed notification should show it, even when
+ // the currently displayed notification is a persistent one.
+ {
+ id: "Test#11",
+ async run() {
+ await SpecialPowers.pushPrefEnv({ set: [["accessibility.tabfocus", 7]] });
+
+ function clickAnchor(notifyObj) {
+ let anchor = document.getElementById(notifyObj.anchorID);
+ EventUtils.synthesizeMouseAtCenter(anchor, {});
+ }
+
+ let popup = PopupNotifications.panel;
+
+ let notifyObj1 = new BasicNotification(this.id);
+ notifyObj1.id += "_1";
+ notifyObj1.anchorID = "default-notification-icon";
+ notifyObj1.options.persistent = true;
+ let shown = waitForNotificationPanel();
+ let notification1 = showNotification(notifyObj1);
+ await shown;
+ checkPopup(popup, notifyObj1);
+ ok(
+ !notifyObj1.dismissalCallbackTriggered,
+ "Should not have dismissed the notification"
+ );
+ notifyObj1.shownCallbackTriggered = false;
+ notifyObj1.showingCallbackTriggered = false;
+
+ // Click the anchor. This should focus the closebutton
+ // (because it's the first focusable element), but not
+ // call event callbacks on the notification object.
+ clickAnchor(notifyObj1);
+ is(document.activeElement, popup.children[0].closebutton);
+ ok(
+ !notifyObj1.dismissalCallbackTriggered,
+ "Should not have dismissed the notification"
+ );
+ ok(
+ !notifyObj1.shownCallbackTriggered,
+ "Should have triggered the shown event again"
+ );
+ ok(
+ !notifyObj1.showingCallbackTriggered,
+ "Should have triggered the showing event again"
+ );
+
+ // Add another notification.
+ let notifyObj2 = new BasicNotification(this.id);
+ notifyObj2.id += "_2";
+ notifyObj2.anchorID = "geo-notification-icon";
+ notifyObj2.options.dismissed = true;
+ let notification2 = showNotification(notifyObj2);
+
+ // Click the anchor of the second notification, this should dismiss the
+ // first notification.
+ shown = waitForNotificationPanel();
+ clickAnchor(notifyObj2);
+ await shown;
+ checkPopup(popup, notifyObj2);
+ ok(
+ notifyObj1.dismissalCallbackTriggered,
+ "Should have dismissed the first notification"
+ );
+
+ // Click the anchor of the first notification, it should be shown again.
+ shown = waitForNotificationPanel();
+ clickAnchor(notifyObj1);
+ await shown;
+ checkPopup(popup, notifyObj1);
+ ok(
+ notifyObj2.dismissalCallbackTriggered,
+ "Should have dismissed the second notification"
+ );
+
+ // Cleanup.
+ notification1.remove();
+ notification2.remove();
+ goNext();
+ },
+ },
+];
diff --git a/browser/base/content/test/popupNotifications/browser_popupNotification_accesskey.js b/browser/base/content/test/popupNotifications/browser_popupNotification_accesskey.js
new file mode 100644
index 0000000000..4a68105e27
--- /dev/null
+++ b/browser/base/content/test/popupNotifications/browser_popupNotification_accesskey.js
@@ -0,0 +1,44 @@
+/* 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/. */
+
+function test() {
+ waitForExplicitFinish();
+
+ ok(PopupNotifications, "PopupNotifications object exists");
+ ok(PopupNotifications.panel, "PopupNotifications panel exists");
+
+ setup();
+}
+
+let buttonPressed = false;
+
+function commandTriggered() {
+ buttonPressed = true;
+}
+
+var tests = [
+ // This test ensures that the accesskey closes the popup.
+ {
+ id: "Test#1",
+ run() {
+ this.notifyObj = new BasicNotification(this.id);
+ showNotification(this.notifyObj);
+ },
+ onShown(popup) {
+ window.addEventListener("command", commandTriggered, true);
+ checkPopup(popup, this.notifyObj);
+ EventUtils.synthesizeKey("VK_ALT", { type: "keydown" });
+ EventUtils.synthesizeKey("M", { altKey: true });
+ EventUtils.synthesizeKey("VK_ALT", { type: "keyup" });
+
+ // If bug xxx was present, then the popup would be in the
+ // process of being hidden right now.
+ isnot(popup.state, "hiding", "popup is not hiding");
+ },
+ onHidden(popup) {
+ window.removeEventListener("command", commandTriggered, true);
+ ok(buttonPressed, "button pressed");
+ },
+ },
+];
diff --git a/browser/base/content/test/popupNotifications/browser_popupNotification_checkbox.js b/browser/base/content/test/popupNotifications/browser_popupNotification_checkbox.js
new file mode 100644
index 0000000000..c1d82042c8
--- /dev/null
+++ b/browser/base/content/test/popupNotifications/browser_popupNotification_checkbox.js
@@ -0,0 +1,248 @@
+/* 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/. */
+
+function test() {
+ waitForExplicitFinish();
+
+ ok(PopupNotifications, "PopupNotifications object exists");
+ ok(PopupNotifications.panel, "PopupNotifications panel exists");
+
+ setup();
+}
+
+function checkCheckbox(checkbox, label, checked = false, hidden = false) {
+ is(checkbox.label, label, "Checkbox should have the correct label");
+ is(checkbox.hidden, hidden, "Checkbox should be shown");
+ is(checkbox.checked, checked, "Checkbox should be checked by default");
+}
+
+function checkMainAction(notification, disabled = false) {
+ let mainAction = notification.button;
+ let warningLabel = notification.querySelector(".popup-notification-warning");
+ is(warningLabel.hidden, !disabled, "Warning label should be shown");
+ is(mainAction.disabled, disabled, "MainAction should be disabled");
+}
+
+function promiseElementVisible(element) {
+ // HTMLElement.offsetParent is null when the element is not visisble
+ // (or if the element has |position: fixed|). See:
+ // https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/offsetParent
+ return TestUtils.waitForCondition(
+ () => element.offsetParent !== null,
+ "Waiting for element to be visible"
+ );
+}
+
+var gNotification;
+
+var tests = [
+ // Test that passing the checkbox field shows the checkbox.
+ {
+ id: "show_checkbox",
+ run() {
+ this.notifyObj = new BasicNotification(this.id);
+ this.notifyObj.options.checkbox = {
+ label: "This is a checkbox",
+ };
+ gNotification = showNotification(this.notifyObj);
+ },
+ onShown(popup) {
+ checkPopup(popup, this.notifyObj);
+ let notification = popup.children[0];
+ checkCheckbox(notification.checkbox, "This is a checkbox");
+ triggerMainCommand(popup);
+ },
+ onHidden() {},
+ },
+
+ // Test checkbox being checked by default
+ {
+ id: "checkbox_checked",
+ run() {
+ this.notifyObj = new BasicNotification(this.id);
+ this.notifyObj.options.checkbox = {
+ label: "Check this",
+ checked: true,
+ };
+ gNotification = showNotification(this.notifyObj);
+ },
+ onShown(popup) {
+ checkPopup(popup, this.notifyObj);
+ let notification = popup.children[0];
+ checkCheckbox(notification.checkbox, "Check this", true);
+ triggerMainCommand(popup);
+ },
+ onHidden() {},
+ },
+
+ // Test checkbox passing the checkbox state on mainAction
+ {
+ id: "checkbox_passCheckboxChecked_mainAction",
+ run() {
+ this.notifyObj = new BasicNotification(this.id);
+ this.notifyObj.mainAction.callback = ({ checkboxChecked }) =>
+ (this.mainActionChecked = checkboxChecked);
+ this.notifyObj.options.checkbox = {
+ label: "This is a checkbox",
+ };
+ gNotification = showNotification(this.notifyObj);
+ },
+ async onShown(popup) {
+ checkPopup(popup, this.notifyObj);
+ let notification = popup.children[0];
+ let checkbox = notification.checkbox;
+ checkCheckbox(checkbox, "This is a checkbox");
+ await promiseElementVisible(checkbox);
+ EventUtils.synthesizeMouseAtCenter(checkbox, {});
+ checkCheckbox(checkbox, "This is a checkbox", true);
+ triggerMainCommand(popup);
+ },
+ onHidden() {
+ is(
+ this.mainActionChecked,
+ true,
+ "mainAction callback is passed the correct checkbox value"
+ );
+ },
+ },
+
+ // Test checkbox passing the checkbox state on secondaryAction
+ {
+ id: "checkbox_passCheckboxChecked_secondaryAction",
+ run() {
+ this.notifyObj = new BasicNotification(this.id);
+ this.notifyObj.secondaryActions = [
+ {
+ label: "Test Secondary",
+ accessKey: "T",
+ callback: ({ checkboxChecked }) =>
+ (this.secondaryActionChecked = checkboxChecked),
+ },
+ ];
+ this.notifyObj.options.checkbox = {
+ label: "This is a checkbox",
+ };
+ gNotification = showNotification(this.notifyObj);
+ },
+ async onShown(popup) {
+ checkPopup(popup, this.notifyObj);
+ let notification = popup.children[0];
+ let checkbox = notification.checkbox;
+ checkCheckbox(checkbox, "This is a checkbox");
+ await promiseElementVisible(checkbox);
+ EventUtils.synthesizeMouseAtCenter(checkbox, {});
+ checkCheckbox(checkbox, "This is a checkbox", true);
+ triggerSecondaryCommand(popup, 0);
+ },
+ onHidden() {
+ is(
+ this.secondaryActionChecked,
+ true,
+ "secondaryAction callback is passed the correct checkbox value"
+ );
+ },
+ },
+
+ // Test checkbox preserving its state through re-opening the doorhanger
+ {
+ id: "checkbox_reopen",
+ run() {
+ this.notifyObj = new BasicNotification(this.id);
+ this.notifyObj.options.checkbox = {
+ label: "This is a checkbox",
+ checkedState: {
+ disableMainAction: true,
+ warningLabel: "Testing disable",
+ },
+ };
+ gNotification = showNotification(this.notifyObj);
+ },
+ async onShown(popup) {
+ checkPopup(popup, this.notifyObj);
+ let notification = popup.children[0];
+ let checkbox = notification.checkbox;
+ checkCheckbox(checkbox, "This is a checkbox");
+ await promiseElementVisible(checkbox);
+ EventUtils.synthesizeMouseAtCenter(checkbox, {});
+ dismissNotification(popup);
+ },
+ async onHidden(popup) {
+ let icon = document.getElementById("default-notification-icon");
+ let shown = waitForNotificationPanel();
+ EventUtils.synthesizeMouseAtCenter(icon, {});
+ await shown;
+ let notification = popup.children[0];
+ let checkbox = notification.checkbox;
+ checkCheckbox(checkbox, "This is a checkbox", true);
+ checkMainAction(notification, true);
+ gNotification.remove();
+ },
+ },
+
+ // Test no checkbox hides warning label
+ {
+ id: "no_checkbox",
+ run() {
+ this.notifyObj = new BasicNotification(this.id);
+ this.notifyObj.options.checkbox = null;
+ gNotification = showNotification(this.notifyObj);
+ },
+ onShown(popup) {
+ checkPopup(popup, this.notifyObj);
+ let notification = popup.children[0];
+ checkCheckbox(notification.checkbox, "", false, true);
+ checkMainAction(notification);
+ triggerMainCommand(popup);
+ },
+ onHidden() {},
+ },
+];
+
+// Test checkbox disabling the main action in different combinations
+["checkedState", "uncheckedState"].forEach(function (state) {
+ [true, false].forEach(function (checked) {
+ tests.push({
+ id: `checkbox_disableMainAction_${state}_${
+ checked ? "checked" : "unchecked"
+ }`,
+ run() {
+ this.notifyObj = new BasicNotification(this.id);
+ this.notifyObj.options.checkbox = {
+ label: "This is a checkbox",
+ checked,
+ [state]: {
+ disableMainAction: true,
+ warningLabel: "Testing disable",
+ },
+ };
+ gNotification = showNotification(this.notifyObj);
+ },
+ async onShown(popup) {
+ checkPopup(popup, this.notifyObj);
+ let notification = popup.children[0];
+ let checkbox = notification.checkbox;
+ let disabled =
+ (state === "checkedState" && checked) ||
+ (state === "uncheckedState" && !checked);
+
+ checkCheckbox(checkbox, "This is a checkbox", checked);
+ checkMainAction(notification, disabled);
+ await promiseElementVisible(checkbox);
+ EventUtils.synthesizeMouseAtCenter(checkbox, {});
+ checkCheckbox(checkbox, "This is a checkbox", !checked);
+ checkMainAction(notification, !disabled);
+ EventUtils.synthesizeMouseAtCenter(checkbox, {});
+ checkCheckbox(checkbox, "This is a checkbox", checked);
+ checkMainAction(notification, disabled);
+
+ // Unblock the main command if it's currently disabled.
+ if (disabled) {
+ EventUtils.synthesizeMouseAtCenter(checkbox, {});
+ }
+ triggerMainCommand(popup);
+ },
+ onHidden() {},
+ });
+ });
+});
diff --git a/browser/base/content/test/popupNotifications/browser_popupNotification_hide_after_identity_panel.js b/browser/base/content/test/popupNotifications/browser_popupNotification_hide_after_identity_panel.js
new file mode 100644
index 0000000000..de930375f6
--- /dev/null
+++ b/browser/base/content/test/popupNotifications/browser_popupNotification_hide_after_identity_panel.js
@@ -0,0 +1,36 @@
+/* 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/. */
+
+add_task(async function test_displayURI_geo() {
+ await BrowserTestUtils.withNewTab(
+ "https://test1.example.com/",
+ async function (browser) {
+ let popupShownPromise = waitForNotificationPanel();
+ await SpecialPowers.spawn(browser, [], async function () {
+ content.navigator.geolocation.getCurrentPosition(() => {});
+ });
+ await popupShownPromise;
+
+ popupShownPromise = BrowserTestUtils.waitForEvent(
+ window,
+ "popupshown",
+ true,
+ event => event.target == gIdentityHandler._identityPopup
+ );
+ EventUtils.synthesizeMouseAtCenter(gIdentityHandler._identityIconBox, {});
+ await popupShownPromise;
+
+ Assert.ok(!PopupNotifications.isPanelOpen, "Geolocation popup is hidden");
+
+ let popupHidden = BrowserTestUtils.waitForEvent(
+ gIdentityHandler._identityPopup,
+ "popuphidden"
+ );
+ gIdentityHandler._identityPopup.hidePopup();
+ await popupHidden;
+
+ Assert.ok(PopupNotifications.isPanelOpen, "Geolocation popup is showing");
+ }
+ );
+});
diff --git a/browser/base/content/test/popupNotifications/browser_popupNotification_hide_after_protections_panel.js b/browser/base/content/test/popupNotifications/browser_popupNotification_hide_after_protections_panel.js
new file mode 100644
index 0000000000..f47f20a2d7
--- /dev/null
+++ b/browser/base/content/test/popupNotifications/browser_popupNotification_hide_after_protections_panel.js
@@ -0,0 +1,44 @@
+/* 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/. */
+
+add_task(async function test_hide_popup_with_protections_panel_showing() {
+ await BrowserTestUtils.withNewTab(
+ "https://test1.example.com/",
+ async function (browser) {
+ // Request location permissions and wait for that prompt to appear.
+ let popupShownPromise = waitForNotificationPanel();
+ await SpecialPowers.spawn(browser, [], async function () {
+ content.navigator.geolocation.getCurrentPosition(() => {});
+ });
+ await popupShownPromise;
+
+ // Click on the icon for the protections panel, to show the panel.
+ popupShownPromise = BrowserTestUtils.waitForEvent(
+ window,
+ "popupshown",
+ true,
+ event => event.target == gProtectionsHandler._protectionsPopup
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ document.getElementById("tracking-protection-icon-container"),
+ {}
+ );
+ await popupShownPromise;
+
+ // Make sure the location permission prompt closed.
+ Assert.ok(!PopupNotifications.isPanelOpen, "Geolocation popup is hidden");
+
+ // Close the protections panel.
+ let popupHidden = BrowserTestUtils.waitForEvent(
+ gProtectionsHandler._protectionsPopup,
+ "popuphidden"
+ );
+ gProtectionsHandler._protectionsPopup.hidePopup();
+ await popupHidden;
+
+ // Make sure the location permission prompt came back.
+ Assert.ok(PopupNotifications.isPanelOpen, "Geolocation popup is showing");
+ }
+ );
+});
diff --git a/browser/base/content/test/popupNotifications/browser_popupNotification_keyboard.js b/browser/base/content/test/popupNotifications/browser_popupNotification_keyboard.js
new file mode 100644
index 0000000000..5c20751c3f
--- /dev/null
+++ b/browser/base/content/test/popupNotifications/browser_popupNotification_keyboard.js
@@ -0,0 +1,273 @@
+/* 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/. */
+
+function test() {
+ waitForExplicitFinish();
+
+ ok(PopupNotifications, "PopupNotifications object exists");
+ ok(PopupNotifications.panel, "PopupNotifications panel exists");
+
+ // Force tabfocus for all elements on OSX.
+ SpecialPowers.pushPrefEnv({ set: [["accessibility.tabfocus", 7]] }).then(
+ setup
+ );
+}
+
+// Focusing on notification icon buttons is handled by the ToolbarKeyboardNavigator
+// component and arrow keys (see browser/base/content/browser-toolbarKeyNav.js).
+function focusNotificationAnchor(anchor) {
+ let urlbarContainer = anchor.closest("#urlbar-container");
+ urlbarContainer.querySelector("toolbartabstop").focus();
+ const trackingProtectionIconContainer = urlbarContainer.querySelector(
+ "#tracking-protection-icon-container"
+ );
+ is(
+ document.activeElement,
+ trackingProtectionIconContainer,
+ "tracking protection icon container is focused."
+ );
+ while (document.activeElement !== anchor) {
+ EventUtils.synthesizeKey("ArrowRight");
+ }
+}
+
+var tests = [
+ // Test that for persistent notifications,
+ // the secondary action is triggered by pressing the escape key.
+ {
+ id: "Test#1",
+ run() {
+ this.notifyObj = new BasicNotification(this.id);
+ this.notifyObj.options.persistent = true;
+ showNotification(this.notifyObj);
+ },
+ onShown(popup) {
+ checkPopup(popup, this.notifyObj);
+ EventUtils.synthesizeKey("KEY_Escape");
+ },
+ onHidden(popup) {
+ ok(!this.notifyObj.mainActionClicked, "mainAction was not clicked");
+ ok(this.notifyObj.secondaryActionClicked, "secondaryAction was clicked");
+ ok(
+ !this.notifyObj.dismissalCallbackTriggered,
+ "dismissal callback wasn't triggered"
+ );
+ ok(this.notifyObj.removedCallbackTriggered, "removed callback triggered");
+ is(
+ this.notifyObj.mainActionSource,
+ undefined,
+ "shouldn't have a main action source."
+ );
+ is(
+ this.notifyObj.secondaryActionSource,
+ "esc-press",
+ "secondary action should be from ESC key press"
+ );
+ },
+ },
+ // Test that for non-persistent notifications, the escape key dismisses the notification.
+ {
+ id: "Test#2",
+ run() {
+ this.notifyObj = new BasicNotification(this.id);
+ this.notification = showNotification(this.notifyObj);
+ },
+ onShown(popup) {
+ checkPopup(popup, this.notifyObj);
+ EventUtils.synthesizeKey("KEY_Escape");
+ },
+ onHidden(popup) {
+ ok(!this.notifyObj.mainActionClicked, "mainAction was not clicked");
+ ok(
+ !this.notifyObj.secondaryActionClicked,
+ "secondaryAction was not clicked"
+ );
+ ok(
+ this.notifyObj.dismissalCallbackTriggered,
+ "dismissal callback triggered"
+ );
+ ok(
+ !this.notifyObj.removedCallbackTriggered,
+ "removed callback was not triggered"
+ );
+ is(
+ this.notifyObj.mainActionSource,
+ undefined,
+ "shouldn't have a main action source."
+ );
+ is(
+ this.notifyObj.secondaryActionSource,
+ undefined,
+ "shouldn't have a secondary action source."
+ );
+ this.notification.remove();
+ },
+ },
+ // Test that the space key on an anchor element focuses an active notification
+ {
+ id: "Test#3",
+ run() {
+ this.notifyObj = new BasicNotification(this.id);
+ this.notifyObj.anchorID = "geo-notification-icon";
+ this.notifyObj.addOptions({
+ persistent: true,
+ });
+ this.notification = showNotification(this.notifyObj);
+ },
+ onShown(popup) {
+ checkPopup(popup, this.notifyObj);
+ let anchor = document.getElementById(this.notifyObj.anchorID);
+ focusNotificationAnchor(anchor);
+ EventUtils.sendString(" ");
+ is(document.activeElement, popup.children[0].closebutton);
+ this.notification.remove();
+ },
+ onHidden(popup) {},
+ },
+ // Test that you can switch between active notifications with the space key
+ // and that the notification is focused on selection.
+ {
+ id: "Test#4",
+ async run() {
+ let notifyObj1 = new BasicNotification(this.id);
+ notifyObj1.id += "_1";
+ notifyObj1.anchorID = "default-notification-icon";
+ notifyObj1.addOptions({
+ hideClose: true,
+ checkbox: {
+ label: "Test that elements inside the panel can be focused",
+ },
+ persistent: true,
+ });
+ let opened = waitForNotificationPanel();
+ let notification1 = showNotification(notifyObj1);
+ await opened;
+
+ let notifyObj2 = new BasicNotification(this.id);
+ notifyObj2.id += "_2";
+ notifyObj2.anchorID = "geo-notification-icon";
+ notifyObj2.addOptions({
+ persistent: true,
+ });
+ opened = waitForNotificationPanel();
+ let notification2 = showNotification(notifyObj2);
+ let popup = await opened;
+
+ // Make sure notification 2 is visible
+ checkPopup(popup, notifyObj2);
+
+ // Activate the anchor for notification 1 and wait until it's shown.
+ let anchor = document.getElementById(notifyObj1.anchorID);
+ focusNotificationAnchor(anchor);
+ is(document.activeElement, anchor);
+ opened = waitForNotificationPanel();
+ EventUtils.sendString(" ");
+ popup = await opened;
+ checkPopup(popup, notifyObj1);
+
+ is(document.activeElement, popup.children[0].checkbox);
+
+ // Activate the anchor for notification 2 and wait until it's shown.
+ anchor = document.getElementById(notifyObj2.anchorID);
+ focusNotificationAnchor(anchor);
+ is(document.activeElement, anchor);
+ opened = waitForNotificationPanel();
+ EventUtils.sendString(" ");
+ popup = await opened;
+ checkPopup(popup, notifyObj2);
+
+ is(document.activeElement, popup.children[0].closebutton);
+
+ notification1.remove();
+ notification2.remove();
+ goNext();
+ },
+ },
+ // Test that passing the autofocus option will focus an opened notification.
+ {
+ id: "Test#5",
+ run() {
+ this.notifyObj = new BasicNotification(this.id);
+ this.notifyObj.anchorID = "geo-notification-icon";
+ this.notifyObj.addOptions({
+ autofocus: true,
+ });
+ this.notification = showNotification(this.notifyObj);
+ },
+ onShown(popup) {
+ checkPopup(popup, this.notifyObj);
+
+ // Initial focus on open is null because a panel itself
+ // can not be focused, next tab focus will be inside the panel.
+ is(Services.focus.focusedElement, null);
+
+ EventUtils.synthesizeKey("KEY_Tab");
+ is(Services.focus.focusedElement, popup.children[0].closebutton);
+ dismissNotification(popup);
+ },
+ async onHidden() {
+ // Focus the urlbar to check that it stays focused.
+ gURLBar.focus();
+
+ // Show another notification and make sure it's not autofocused.
+ let notifyObj = new BasicNotification(this.id);
+ notifyObj.id += "_2";
+ notifyObj.anchorID = "default-notification-icon";
+
+ let opened = waitForNotificationPanel();
+ let notification = showNotification(notifyObj);
+ let popup = await opened;
+ checkPopup(popup, notifyObj);
+
+ // Check that the urlbar is still focused.
+ is(Services.focus.focusedElement, gURLBar.inputField);
+
+ this.notification.remove();
+ notification.remove();
+ },
+ },
+ // Test that focus is not moved out of a content element if autofocus is not set.
+ {
+ id: "Test#6",
+ async run() {
+ let id = this.id;
+ await BrowserTestUtils.withNewTab(
+ "data:text/html,<input id='test-input'/>",
+ async function (browser) {
+ let notifyObj = new BasicNotification(id);
+ await SpecialPowers.spawn(browser, [], function () {
+ content.document.getElementById("test-input").focus();
+ });
+
+ let opened = waitForNotificationPanel();
+ let notification = showNotification(notifyObj);
+ await opened;
+
+ // Check that the focused element in the chrome window
+ // is either the browser in case we're running on e10s
+ // or the input field in case of non-e10s.
+ if (gMultiProcessBrowser) {
+ is(Services.focus.focusedElement, browser);
+ } else {
+ is(
+ Services.focus.focusedElement,
+ browser.contentDocument.getElementById("test-input")
+ );
+ }
+
+ // Check that the input field is still focused inside the browser.
+ await SpecialPowers.spawn(browser, [], function () {
+ is(
+ content.document.activeElement,
+ content.document.getElementById("test-input")
+ );
+ });
+
+ notification.remove();
+ }
+ );
+ goNext();
+ },
+ },
+];
diff --git a/browser/base/content/test/popupNotifications/browser_popupNotification_learnmore.js b/browser/base/content/test/popupNotifications/browser_popupNotification_learnmore.js
new file mode 100644
index 0000000000..fc3946598c
--- /dev/null
+++ b/browser/base/content/test/popupNotifications/browser_popupNotification_learnmore.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/. */
+
+function test() {
+ waitForExplicitFinish();
+
+ ok(PopupNotifications, "PopupNotifications object exists");
+ ok(PopupNotifications.panel, "PopupNotifications panel exists");
+
+ setup();
+}
+
+var tests = [
+ // Test checkbox being checked by default
+ {
+ id: "without_learn_more",
+ run() {
+ this.notifyObj = new BasicNotification(this.id);
+ showNotification(this.notifyObj);
+ },
+ onShown(popup) {
+ checkPopup(popup, this.notifyObj);
+ let notification = popup.children[0];
+ let link = notification.querySelector(
+ ".popup-notification-learnmore-link"
+ );
+ ok(!link.href, "no href");
+ is(
+ window.getComputedStyle(link).getPropertyValue("display"),
+ "none",
+ "link hidden"
+ );
+ dismissNotification(popup);
+ },
+ onHidden() {},
+ },
+
+ // Test that passing the learnMoreURL field sets up the link.
+ {
+ id: "with_learn_more",
+ run() {
+ this.notifyObj = new BasicNotification(this.id);
+ this.notifyObj.options.learnMoreURL = "https://mozilla.org";
+ showNotification(this.notifyObj);
+ },
+ onShown(popup) {
+ checkPopup(popup, this.notifyObj);
+ let notification = popup.children[0];
+ let link = notification.querySelector(
+ ".popup-notification-learnmore-link"
+ );
+ is(link.textContent, "Learn more", "correct label");
+ is(link.href, "https://mozilla.org", "correct href");
+ isnot(
+ window.getComputedStyle(link).getPropertyValue("display"),
+ "none",
+ "link not hidden"
+ );
+ dismissNotification(popup);
+ },
+ onHidden() {},
+ },
+];
diff --git a/browser/base/content/test/popupNotifications/browser_popupNotification_no_anchors.js b/browser/base/content/test/popupNotifications/browser_popupNotification_no_anchors.js
new file mode 100644
index 0000000000..a73e1f5948
--- /dev/null
+++ b/browser/base/content/test/popupNotifications/browser_popupNotification_no_anchors.js
@@ -0,0 +1,288 @@
+/* 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/. */
+
+function test() {
+ waitForExplicitFinish();
+
+ ok(PopupNotifications, "PopupNotifications object exists");
+ ok(PopupNotifications.panel, "PopupNotifications panel exists");
+
+ setup();
+}
+
+const FALLBACK_ANCHOR = gURLBar.searchButton
+ ? "urlbar-search-button"
+ : "identity-icon";
+
+var tests = [
+ // Test that popupnotifications are anchored to the fallback anchor on
+ // about:blank, where anchor icons are hidden.
+ {
+ id: "Test#1",
+ async run() {
+ this.oldSelectedTab = gBrowser.selectedTab;
+ await BrowserTestUtils.openNewForegroundTab(gBrowser, "about:blank");
+
+ this.notifyObj = new BasicNotification(this.id);
+ this.notifyObj.anchorID = "geo-notification-icon";
+ this.notification = showNotification(this.notifyObj);
+ },
+ onShown(popup) {
+ checkPopup(popup, this.notifyObj);
+ is(
+ document.getElementById("geo-notification-icon").getBoundingClientRect()
+ .width,
+ 0,
+ "geo anchor shouldn't be visible"
+ );
+ is(
+ popup.anchorNode.id,
+ FALLBACK_ANCHOR,
+ "notification anchored to fallback anchor"
+ );
+ dismissNotification(popup);
+ },
+ onHidden(popup) {
+ this.notification.remove();
+ gBrowser.removeTab(gBrowser.selectedTab);
+ gBrowser.selectedTab = this.oldSelectedTab;
+ },
+ },
+ // Test that popupnotifications are anchored to the fallback anchor after
+ // navigation to about:blank.
+ {
+ id: "Test#2",
+ async run() {
+ this.oldSelectedTab = gBrowser.selectedTab;
+ await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com/"
+ );
+
+ this.notifyObj = new BasicNotification(this.id);
+ this.notifyObj.anchorID = "geo-notification-icon";
+ this.notifyObj.addOptions({
+ persistence: 1,
+ });
+ this.notification = showNotification(this.notifyObj);
+ },
+ async onShown(popup) {
+ await promiseTabLoadEvent(gBrowser.selectedTab, "about:blank");
+
+ checkPopup(popup, this.notifyObj);
+ is(
+ document.getElementById("geo-notification-icon").getBoundingClientRect()
+ .width,
+ 0,
+ "geo anchor shouldn't be visible"
+ );
+ is(
+ popup.anchorNode.id,
+ FALLBACK_ANCHOR,
+ "notification anchored to fallback anchor"
+ );
+ dismissNotification(popup);
+ },
+ onHidden(popup) {
+ this.notification.remove();
+ gBrowser.removeTab(gBrowser.selectedTab);
+ gBrowser.selectedTab = this.oldSelectedTab;
+ },
+ },
+ // Test that dismissed popupnotifications cannot be opened on about:blank, but
+ // can be opened after navigation.
+ {
+ id: "Test#3",
+ async run() {
+ this.oldSelectedTab = gBrowser.selectedTab;
+ await BrowserTestUtils.openNewForegroundTab(gBrowser, "about:blank");
+
+ this.notifyObj = new BasicNotification(this.id);
+ this.notifyObj.anchorID = "geo-notification-icon";
+ this.notifyObj.addOptions({
+ dismissed: true,
+ persistence: 1,
+ });
+ this.notification = showNotification(this.notifyObj);
+
+ is(
+ document.getElementById("geo-notification-icon").getBoundingClientRect()
+ .width,
+ 0,
+ "geo anchor shouldn't be visible"
+ );
+
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ await promiseTabLoadEvent(gBrowser.selectedTab, "http://example.com/");
+
+ isnot(
+ document.getElementById("geo-notification-icon").getBoundingClientRect()
+ .width,
+ 0,
+ "geo anchor should be visible"
+ );
+
+ EventUtils.synthesizeMouse(
+ document.getElementById("geo-notification-icon"),
+ 2,
+ 2,
+ {}
+ );
+ },
+ onShown(popup) {
+ checkPopup(popup, this.notifyObj);
+ dismissNotification(popup);
+ },
+ onHidden(popup) {
+ this.notification.remove();
+ gBrowser.removeTab(gBrowser.selectedTab);
+ gBrowser.selectedTab = this.oldSelectedTab;
+ },
+ },
+ // Test that popupnotifications are hidden while editing the URL in the
+ // location bar, anchored to the fallback anchor when the focus is moved away
+ // from the location bar, and restored when the URL is reverted.
+ {
+ id: "Test#4",
+ async run() {
+ for (let persistent of [false, true]) {
+ let shown = waitForNotificationPanel();
+ this.notifyObj = new BasicNotification(this.id);
+ this.notifyObj.anchorID = "geo-notification-icon";
+ this.notifyObj.addOptions({ persistent });
+ this.notification = showNotification(this.notifyObj);
+ await shown;
+
+ checkPopup(PopupNotifications.panel, this.notifyObj);
+
+ // Typing in the location bar should hide the notification.
+ let hidden = waitForNotificationPanelHidden();
+ gURLBar.select();
+ EventUtils.sendString("*");
+ await hidden;
+
+ is(
+ document
+ .getElementById("geo-notification-icon")
+ .getBoundingClientRect().width,
+ 0,
+ "geo anchor shouldn't be visible"
+ );
+
+ // Moving focus to the next control should show the notifications again,
+ // anchored to the fallback anchor. We clear the URL bar before moving the
+ // focus so that the awesomebar popup doesn't get in the way.
+ shown = waitForNotificationPanel();
+ EventUtils.synthesizeKey("KEY_Backspace");
+ EventUtils.synthesizeKey("KEY_Tab");
+ await shown;
+
+ is(
+ PopupNotifications.panel.anchorNode.id,
+ FALLBACK_ANCHOR,
+ "notification anchored to fallback anchor"
+ );
+
+ // Moving focus to the location bar should hide the notification again.
+ hidden = waitForNotificationPanelHidden();
+ EventUtils.synthesizeKey("KEY_Tab", { shiftKey: true });
+ await hidden;
+
+ // Reverting the URL should show the notification again.
+ shown = waitForNotificationPanel();
+ EventUtils.synthesizeKey("KEY_Escape");
+ await shown;
+
+ checkPopup(PopupNotifications.panel, this.notifyObj);
+
+ hidden = waitForNotificationPanelHidden();
+ this.notification.remove();
+ await hidden;
+ }
+ goNext();
+ },
+ },
+ // Test that popupnotifications triggered while editing the URL in the
+ // location bar are only shown later when the URL is reverted.
+ {
+ id: "Test#5",
+ async run() {
+ for (let persistent of [false, true]) {
+ // Start editing the URL, ensuring that the awesomebar popup is hidden.
+ gURLBar.select();
+ EventUtils.sendString("*");
+ EventUtils.synthesizeKey("KEY_Backspace");
+ // autoOpen behavior will show the panel, so it must be closed.
+ gURLBar.view.close();
+
+ // Trying to show a notification should display nothing.
+ let notShowing = TestUtils.topicObserved(
+ "PopupNotifications-updateNotShowing"
+ );
+ this.notifyObj = new BasicNotification(this.id);
+ this.notifyObj.anchorID = "geo-notification-icon";
+ this.notifyObj.addOptions({ persistent });
+ this.notification = showNotification(this.notifyObj);
+ await notShowing;
+
+ // Reverting the URL should show the notification.
+ let shown = waitForNotificationPanel();
+ EventUtils.synthesizeKey("KEY_Escape");
+ await shown;
+
+ checkPopup(PopupNotifications.panel, this.notifyObj);
+
+ let hidden = waitForNotificationPanelHidden();
+ this.notification.remove();
+ await hidden;
+ }
+
+ goNext();
+ },
+ },
+ // Test that persistent panels are still open after switching to another tab
+ // and back, even while editing the URL in the new tab.
+ {
+ id: "Test#6",
+ async run() {
+ let shown = waitForNotificationPanel();
+ this.notifyObj = new BasicNotification(this.id);
+ this.notifyObj.anchorID = "geo-notification-icon";
+ this.notifyObj.addOptions({
+ persistent: true,
+ });
+ this.notification = showNotification(this.notifyObj);
+ await shown;
+
+ // Switching to a new tab should hide the notification.
+ let hidden = waitForNotificationPanelHidden();
+ this.oldSelectedTab = gBrowser.selectedTab;
+ await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com/"
+ );
+ await hidden;
+
+ // Start editing the URL.
+ gURLBar.select();
+ EventUtils.sendString("*");
+
+ // Switching to the old tab should show the notification again.
+ shown = waitForNotificationPanel();
+ gBrowser.removeTab(gBrowser.selectedTab);
+ gBrowser.selectedTab = this.oldSelectedTab;
+ await shown;
+
+ checkPopup(PopupNotifications.panel, this.notifyObj);
+
+ hidden = waitForNotificationPanelHidden();
+ this.notification.remove();
+ await hidden;
+
+ goNext();
+ },
+ },
+];
diff --git a/browser/base/content/test/popupNotifications/browser_popupNotification_security_delay.js b/browser/base/content/test/popupNotifications/browser_popupNotification_security_delay.js
new file mode 100644
index 0000000000..515895f35a
--- /dev/null
+++ b/browser/base/content/test/popupNotifications/browser_popupNotification_security_delay.js
@@ -0,0 +1,296 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TEST_SECURITY_DELAY = 5000;
+
+/**
+ * Shows a test PopupNotification.
+ */
+function showNotification() {
+ PopupNotifications.show(
+ gBrowser.selectedBrowser,
+ "foo",
+ "Hello, World!",
+ "default-notification-icon",
+ {
+ label: "ok",
+ accessKey: "o",
+ callback: () => {},
+ },
+ [
+ {
+ label: "cancel",
+ accessKey: "c",
+ callback: () => {},
+ },
+ ],
+ {
+ // Make test notifications persistent to ensure they are only closed
+ // explicitly by test actions and survive tab switches.
+ persistent: true,
+ }
+ );
+}
+
+add_setup(async function () {
+ // Set a longer security delay for PopupNotification actions so we can test
+ // the delay even if the test runs slowly.
+ await SpecialPowers.pushPrefEnv({
+ set: [["security.notification_enable_delay", TEST_SECURITY_DELAY]],
+ });
+});
+
+async function ensureSecurityDelayReady() {
+ /**
+ * The security delay calculation in PopupNotification.sys.mjs is dependent on
+ * the monotonically increasing value of performance.now. This timestamp is
+ * not relative to a fixed date, but to runtime.
+ * We need to wait for the value performance.now() to be larger than the
+ * security delay in order to observe the bug. Only then does the
+ * timeSinceShown check in PopupNotifications.sys.mjs lead to a timeSinceShown
+ * value that is unconditionally greater than lazy.buttonDelay for
+ * notification.timeShown = null = 0.
+ * See: https://searchfox.org/mozilla-central/rev/f32d5f3949a3f4f185122142b29f2e3ab776836e/toolkit/modules/PopupNotifications.sys.mjs#1870-1872
+ *
+ * When running in automation as part of a larger test suite performance.now()
+ * should usually be already sufficiently high in which case this check should
+ * directly resolve.
+ */
+ await TestUtils.waitForCondition(
+ () => performance.now() > TEST_SECURITY_DELAY,
+ "Wait for performance.now() > SECURITY_DELAY",
+ 500,
+ 50
+ );
+}
+
+/**
+ * Tests that when we show a second notification while the panel is open the
+ * timeShown attribute is correctly set and the security delay is enforced
+ * properly.
+ */
+add_task(async function test_timeShownMultipleNotifications() {
+ await ensureSecurityDelayReady();
+
+ ok(
+ !PopupNotifications.isPanelOpen,
+ "PopupNotification panel should not be open initially."
+ );
+
+ info("Open the first notification.");
+ let popupShownPromise = waitForNotificationPanel();
+ showNotification();
+ await popupShownPromise;
+ ok(
+ PopupNotifications.isPanelOpen,
+ "PopupNotification should be open after first show call."
+ );
+
+ is(
+ PopupNotifications._currentNotifications.length,
+ 1,
+ "There should only be one notification"
+ );
+
+ let notification = PopupNotifications.getNotification(
+ "foo",
+ gBrowser.selectedBrowser
+ );
+ is(notification?.id, "foo", "There should be a notification with id foo");
+ ok(notification.timeShown, "The notification should have timeShown set");
+
+ info(
+ "Call show again with the same notification id while the PopupNotification panel is still open."
+ );
+ showNotification();
+ ok(
+ PopupNotifications.isPanelOpen,
+ "PopupNotification should still open after second show call."
+ );
+ notification = PopupNotifications.getNotification(
+ "foo",
+ gBrowser.selectedBrowser
+ );
+ is(
+ PopupNotifications._currentNotifications.length,
+ 1,
+ "There should still only be one notification"
+ );
+
+ is(
+ notification?.id,
+ "foo",
+ "There should still be a notification with id foo"
+ );
+ ok(notification.timeShown, "The notification should have timeShown set");
+
+ let notificationHiddenPromise = waitForNotificationPanelHidden();
+
+ info("Trigger main action via button click during security delay");
+ triggerMainCommand(PopupNotifications.panel);
+
+ await new Promise(resolve => setTimeout(resolve, 0));
+
+ ok(PopupNotifications.isPanelOpen, "PopupNotification should still be open.");
+ notification = PopupNotifications.getNotification(
+ "foo",
+ gBrowser.selectedBrowser
+ );
+ ok(
+ notification,
+ "Notification should still be open because we clicked during the security delay."
+ );
+
+ // If the notification is no longer shown (test failure) skip the remaining
+ // checks.
+ if (!notification) {
+ return;
+ }
+
+ // Ensure that once the security delay has passed the notification can be
+ // closed again.
+ let fakeTimeShown = TEST_SECURITY_DELAY + 500;
+ info(`Manually set timeShown to ${fakeTimeShown}ms in the past.`);
+ notification.timeShown = performance.now() - fakeTimeShown;
+
+ info("Trigger main action via button click outside security delay");
+ triggerMainCommand(PopupNotifications.panel);
+
+ info("Wait for panel to be hidden.");
+ await notificationHiddenPromise;
+
+ ok(
+ !PopupNotifications.getNotification("foo", gBrowser.selectedBrowser),
+ "Should not longer see the notification."
+ );
+});
+
+/**
+ * Tests that when we reshow a notification after a tab switch the timeShown
+ * attribute is correctly reset and the security delay is enforced.
+ */
+add_task(async function test_notificationReshowTabSwitch() {
+ await ensureSecurityDelayReady();
+
+ ok(
+ !PopupNotifications.isPanelOpen,
+ "PopupNotification panel should not be open initially."
+ );
+
+ info("Open the first notification.");
+ let popupShownPromise = waitForNotificationPanel();
+ showNotification();
+ await popupShownPromise;
+ ok(
+ PopupNotifications.isPanelOpen,
+ "PopupNotification should be open after first show call."
+ );
+
+ let notification = PopupNotifications.getNotification(
+ "foo",
+ gBrowser.selectedBrowser
+ );
+ is(notification?.id, "foo", "There should be a notification with id foo");
+ ok(notification.timeShown, "The notification should have timeShown set");
+
+ info("Trigger main action via button click during security delay");
+ triggerMainCommand(PopupNotifications.panel);
+
+ await new Promise(resolve => setTimeout(resolve, 0));
+
+ ok(PopupNotifications.isPanelOpen, "PopupNotification should still be open.");
+ notification = PopupNotifications.getNotification(
+ "foo",
+ gBrowser.selectedBrowser
+ );
+ ok(
+ notification,
+ "Notification should still be open because we clicked during the security delay."
+ );
+
+ // If the notification is no longer shown (test failure) skip the remaining
+ // checks.
+ if (!notification) {
+ return;
+ }
+
+ let panelHiddenPromise = waitForNotificationPanelHidden();
+ let panelShownPromise;
+
+ info("Open a new tab which hides the notification panel.");
+ await BrowserTestUtils.withNewTab("https://example.com", async browser => {
+ info("Wait for panel to be hidden by tab switch.");
+ await panelHiddenPromise;
+ info(
+ "Keep the tab open until the security delay for the original notification show has expired."
+ );
+ await new Promise(resolve =>
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ setTimeout(resolve, TEST_SECURITY_DELAY + 500)
+ );
+
+ panelShownPromise = waitForNotificationPanel();
+ });
+ info(
+ "Wait for the panel to show again after the tab close. We're showing the original tab again."
+ );
+ await panelShownPromise;
+
+ ok(
+ PopupNotifications.isPanelOpen,
+ "PopupNotification should be shown after tab close."
+ );
+ notification = PopupNotifications.getNotification(
+ "foo",
+ gBrowser.selectedBrowser
+ );
+ is(
+ notification?.id,
+ "foo",
+ "There should still be a notification with id foo"
+ );
+
+ let notificationHiddenPromise = waitForNotificationPanelHidden();
+
+ info(
+ "Because we re-show the panel after tab close / switch the security delay should have reset."
+ );
+ info("Trigger main action via button click during the new security delay.");
+ triggerMainCommand(PopupNotifications.panel);
+
+ await new Promise(resolve => setTimeout(resolve, 0));
+
+ ok(PopupNotifications.isPanelOpen, "PopupNotification should still be open.");
+ notification = PopupNotifications.getNotification(
+ "foo",
+ gBrowser.selectedBrowser
+ );
+ ok(
+ notification,
+ "Notification should still be open because we clicked during the security delay."
+ );
+ // If the notification is no longer shown (test failure) skip the remaining
+ // checks.
+ if (!notification) {
+ return;
+ }
+
+ // Ensure that once the security delay has passed the notification can be
+ // closed again.
+ let fakeTimeShown = TEST_SECURITY_DELAY + 500;
+ info(`Manually set timeShown to ${fakeTimeShown}ms in the past.`);
+ notification.timeShown = performance.now() - fakeTimeShown;
+
+ info("Trigger main action via button click outside security delay");
+ triggerMainCommand(PopupNotifications.panel);
+
+ info("Wait for panel to be hidden.");
+ await notificationHiddenPromise;
+
+ ok(
+ !PopupNotifications.getNotification("foo", gBrowser.selectedBrowser),
+ "Should not longer see the notification."
+ );
+});
diff --git a/browser/base/content/test/popupNotifications/browser_popupNotification_selection_required.js b/browser/base/content/test/popupNotifications/browser_popupNotification_selection_required.js
new file mode 100644
index 0000000000..31463f5345
--- /dev/null
+++ b/browser/base/content/test/popupNotifications/browser_popupNotification_selection_required.js
@@ -0,0 +1,57 @@
+/* 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/. */
+
+function test() {
+ waitForExplicitFinish();
+
+ ok(PopupNotifications, "PopupNotifications object exists");
+ ok(PopupNotifications.panel, "PopupNotifications panel exists");
+
+ setup();
+}
+
+function promiseElementVisible(element) {
+ // HTMLElement.offsetParent is null when the element is not visisble
+ // (or if the element has |position: fixed|). See:
+ // https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/offsetParent
+ return TestUtils.waitForCondition(
+ () => element.offsetParent !== null,
+ "Waiting for element to be visible"
+ );
+}
+
+var gNotification;
+
+var tests = [
+ // Test that passing selection required prevents the button from clicking
+ {
+ id: "require_selection_check",
+ run() {
+ this.notifyObj = new BasicNotification(this.id);
+ this.notifyObj.options.checkbox = {
+ label: "This is a checkbox",
+ };
+ gNotification = showNotification(this.notifyObj);
+ },
+ async onShown(popup) {
+ checkPopup(popup, this.notifyObj);
+ let notification = popup.children[0];
+ notification.setAttribute("invalidselection", true);
+ await promiseElementVisible(notification.checkbox);
+ EventUtils.synthesizeMouseAtCenter(notification.checkbox, {});
+ ok(
+ notification.button.disabled,
+ "should be disabled when invalidselection"
+ );
+ notification.removeAttribute("invalidselection");
+ EventUtils.synthesizeMouseAtCenter(notification.checkbox, {});
+ ok(
+ !notification.button.disabled,
+ "should not be disabled when invalidselection is not present"
+ );
+ triggerMainCommand(popup);
+ },
+ onHidden() {},
+ },
+];
diff --git a/browser/base/content/test/popupNotifications/browser_reshow_in_background.js b/browser/base/content/test/popupNotifications/browser_reshow_in_background.js
new file mode 100644
index 0000000000..bb2494a5b5
--- /dev/null
+++ b/browser/base/content/test/popupNotifications/browser_reshow_in_background.js
@@ -0,0 +1,72 @@
+"use strict";
+
+/**
+ * Tests that when PopupNotifications for background tabs are reshown, they
+ * don't show up in the foreground tab, but only in the background tab that
+ * they belong to.
+ */
+add_task(
+ async function test_background_notifications_dont_reshow_in_foreground() {
+ // Our initial tab will be A. Let's open two more tabs B and C, but keep
+ // A selected. Then, we'll trigger a PopupNotification in C, and then make
+ // it reshow.
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ let tabB = BrowserTestUtils.addTab(gBrowser, "http://example.com/");
+ await BrowserTestUtils.browserLoaded(tabB.linkedBrowser);
+
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ let tabC = BrowserTestUtils.addTab(gBrowser, "http://example.com/");
+ await BrowserTestUtils.browserLoaded(tabC.linkedBrowser);
+
+ let seenEvents = [];
+
+ let options = {
+ dismissed: false,
+ eventCallback(popupEvent) {
+ seenEvents.push(popupEvent);
+ },
+ };
+
+ let notification = PopupNotifications.show(
+ tabC.linkedBrowser,
+ "test-notification",
+ "",
+ "plugins-notification-icon",
+ null,
+ null,
+ options
+ );
+ Assert.deepEqual(seenEvents, [], "Should have seen no events yet.");
+
+ await BrowserTestUtils.switchTab(gBrowser, tabB);
+ Assert.deepEqual(seenEvents, [], "Should have seen no events yet.");
+
+ notification.reshow();
+ Assert.deepEqual(seenEvents, [], "Should have seen no events yet.");
+
+ let panelShown = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popupshown"
+ );
+ await BrowserTestUtils.switchTab(gBrowser, tabC);
+ await panelShown;
+
+ Assert.equal(seenEvents.length, 2, "Should have seen two events.");
+ Assert.equal(
+ seenEvents[0],
+ "showing",
+ "Should have said popup was showing."
+ );
+ Assert.equal(seenEvents[1], "shown", "Should have said popup was shown.");
+
+ let panelHidden = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popuphidden"
+ );
+ PopupNotifications.remove(notification);
+ await panelHidden;
+
+ BrowserTestUtils.removeTab(tabB);
+ BrowserTestUtils.removeTab(tabC);
+ }
+);
diff --git a/browser/base/content/test/popupNotifications/head.js b/browser/base/content/test/popupNotifications/head.js
new file mode 100644
index 0000000000..f347f8dbf2
--- /dev/null
+++ b/browser/base/content/test/popupNotifications/head.js
@@ -0,0 +1,367 @@
+ChromeUtils.defineESModuleGetters(this, {
+ PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
+});
+
+/**
+ * Called after opening a new window or switching windows, this will wait until
+ * we are sure that an attempt to display a notification will not fail.
+ */
+async function waitForWindowReadyForPopupNotifications(win) {
+ // These are the same checks that PopupNotifications.sys.mjs makes before it
+ // allows a notification to open.
+ await TestUtils.waitForCondition(
+ () => win.gBrowser.selectedBrowser.docShellIsActive,
+ "The browser should be active"
+ );
+ await TestUtils.waitForCondition(
+ () => Services.focus.activeWindow == win,
+ "The window should be active"
+ );
+}
+
+/**
+ * Waits for a load (or custom) event to finish in a given tab. If provided
+ * load an uri into the tab.
+ *
+ * @param tab
+ * The tab to load into.
+ * @param [optional] url
+ * The url to load, or the current url.
+ * @return {Promise} resolved when the event is handled.
+ * @resolves to the received event
+ * @rejects if a valid load event is not received within a meaningful interval
+ */
+function promiseTabLoadEvent(tab, url) {
+ let browser = tab.linkedBrowser;
+
+ if (url) {
+ BrowserTestUtils.loadURIString(browser, url);
+ }
+
+ return BrowserTestUtils.browserLoaded(browser, false, url);
+}
+
+// Tests that call setup() should have a `tests` array defined for the actual
+// tests to be run.
+/* global tests */
+function setup() {
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ BrowserTestUtils.openNewForegroundTab(gBrowser, "http://example.com/").then(
+ goNext
+ );
+ registerCleanupFunction(() => {
+ gBrowser.removeTab(gBrowser.selectedTab);
+ });
+}
+
+function goNext() {
+ executeSoon(() => executeSoon(runNextTest));
+}
+
+async function runNextTest() {
+ if (!tests.length) {
+ executeSoon(finish);
+ return;
+ }
+
+ let nextTest = tests.shift();
+ if (nextTest.onShown) {
+ let shownState = false;
+ onPopupEvent("popupshowing", function () {
+ info("[" + nextTest.id + "] popup showing");
+ });
+ onPopupEvent("popupshown", function () {
+ shownState = true;
+ info("[" + nextTest.id + "] popup shown");
+ (nextTest.onShown(this) || Promise.resolve()).then(undefined, ex =>
+ Assert.ok(false, "onShown failed: " + ex)
+ );
+ });
+ onPopupEvent(
+ "popuphidden",
+ function () {
+ info("[" + nextTest.id + "] popup hidden");
+ (nextTest.onHidden(this) || Promise.resolve()).then(
+ () => goNext(),
+ ex => Assert.ok(false, "onHidden failed: " + ex)
+ );
+ },
+ () => shownState
+ );
+ info(
+ "[" +
+ nextTest.id +
+ "] added listeners; panel is open: " +
+ PopupNotifications.isPanelOpen
+ );
+ }
+
+ info("[" + nextTest.id + "] running test");
+ await nextTest.run();
+}
+
+function showNotification(notifyObj) {
+ info("Showing notification " + notifyObj.id);
+ return PopupNotifications.show(
+ notifyObj.browser,
+ notifyObj.id,
+ notifyObj.message,
+ notifyObj.anchorID,
+ notifyObj.mainAction,
+ notifyObj.secondaryActions,
+ notifyObj.options
+ );
+}
+
+function dismissNotification(popup) {
+ info("Dismissing notification " + popup.childNodes[0].id);
+ executeSoon(() => EventUtils.synthesizeKey("KEY_Escape"));
+}
+
+function BasicNotification(testId) {
+ this.browser = gBrowser.selectedBrowser;
+ this.id = "test-notification-" + testId;
+ this.message = testId + ": Will you allow <> to perform this action?";
+ this.anchorID = null;
+ this.mainAction = {
+ label: "Main Action",
+ accessKey: "M",
+ callback: ({ source }) => {
+ this.mainActionClicked = true;
+ this.mainActionSource = source;
+ },
+ };
+ this.secondaryActions = [
+ {
+ label: "Secondary Action",
+ accessKey: "S",
+ callback: ({ source }) => {
+ this.secondaryActionClicked = true;
+ this.secondaryActionSource = source;
+ },
+ },
+ ];
+ this.options = {
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ name: "http://example.com",
+ eventCallback: eventName => {
+ switch (eventName) {
+ case "dismissed":
+ this.dismissalCallbackTriggered = true;
+ break;
+ case "showing":
+ this.showingCallbackTriggered = true;
+ break;
+ case "shown":
+ this.shownCallbackTriggered = true;
+ break;
+ case "removed":
+ this.removedCallbackTriggered = true;
+ break;
+ case "swapping":
+ this.swappingCallbackTriggered = true;
+ break;
+ }
+ },
+ };
+}
+
+BasicNotification.prototype.addOptions = function (options) {
+ for (let [name, value] of Object.entries(options)) {
+ this.options[name] = value;
+ }
+};
+
+function ErrorNotification(testId) {
+ BasicNotification.call(this, testId);
+ this.mainAction.callback = () => {
+ this.mainActionClicked = true;
+ throw new Error("Oops!");
+ };
+ this.secondaryActions[0].callback = () => {
+ this.secondaryActionClicked = true;
+ throw new Error("Oops!");
+ };
+}
+
+ErrorNotification.prototype = BasicNotification.prototype;
+
+function checkPopup(popup, notifyObj) {
+ info("Checking notification " + notifyObj.id);
+
+ ok(notifyObj.showingCallbackTriggered, "showing callback was triggered");
+ ok(notifyObj.shownCallbackTriggered, "shown callback was triggered");
+
+ let notifications = popup.childNodes;
+ is(notifications.length, 1, "one notification displayed");
+ let notification = notifications[0];
+ if (!notification) {
+ return;
+ }
+
+ // PopupNotifications are not expected to show icons
+ // unless popupIconURL or popupIconClass is passed in the options object.
+ if (notifyObj.options.popupIconURL || notifyObj.options.popupIconClass) {
+ let icon = notification.querySelector(".popup-notification-icon");
+ if (notifyObj.id == "geolocation") {
+ isnot(icon.getBoundingClientRect().width, 0, "icon for geo displayed");
+ ok(
+ popup.anchorNode.classList.contains("notification-anchor-icon"),
+ "notification anchored to icon"
+ );
+ }
+ }
+
+ let description = notifyObj.message.split("<>");
+ let text = {};
+ text.start = description[0];
+ text.end = description[1];
+ is(notification.getAttribute("label"), text.start, "message matches");
+ is(
+ notification.getAttribute("name"),
+ notifyObj.options.name,
+ "message matches"
+ );
+ is(notification.getAttribute("endlabel"), text.end, "message matches");
+
+ is(notification.id, notifyObj.id + "-notification", "id matches");
+ if (notifyObj.mainAction) {
+ is(
+ notification.getAttribute("buttonlabel"),
+ notifyObj.mainAction.label,
+ "main action label matches"
+ );
+ is(
+ notification.getAttribute("buttonaccesskey"),
+ notifyObj.mainAction.accessKey,
+ "main action accesskey matches"
+ );
+ }
+ if (notifyObj.secondaryActions && notifyObj.secondaryActions.length) {
+ let secondaryAction = notifyObj.secondaryActions[0];
+ is(
+ notification.getAttribute("secondarybuttonlabel"),
+ secondaryAction.label,
+ "secondary action label matches"
+ );
+ is(
+ notification.getAttribute("secondarybuttonaccesskey"),
+ secondaryAction.accessKey,
+ "secondary action accesskey matches"
+ );
+ }
+ // Additional secondary actions appear as menu items.
+ let actualExtraSecondaryActions = Array.prototype.filter.call(
+ notification.menupopup.childNodes,
+ child => child.nodeName == "menuitem"
+ );
+ let extraSecondaryActions = notifyObj.secondaryActions
+ ? notifyObj.secondaryActions.slice(1)
+ : [];
+ is(
+ actualExtraSecondaryActions.length,
+ extraSecondaryActions.length,
+ "number of extra secondary actions matches"
+ );
+ extraSecondaryActions.forEach(function (a, i) {
+ is(
+ actualExtraSecondaryActions[i].getAttribute("label"),
+ a.label,
+ "label for extra secondary action " + i + " matches"
+ );
+ is(
+ actualExtraSecondaryActions[i].getAttribute("accesskey"),
+ a.accessKey,
+ "accessKey for extra secondary action " + i + " matches"
+ );
+ });
+}
+
+XPCOMUtils.defineLazyGetter(this, "gActiveListeners", () => {
+ let listeners = new Map();
+ registerCleanupFunction(() => {
+ for (let [listener, eventName] of listeners) {
+ PopupNotifications.panel.removeEventListener(eventName, listener);
+ }
+ });
+ return listeners;
+});
+
+function onPopupEvent(eventName, callback, condition) {
+ let listener = event => {
+ if (
+ event.target != PopupNotifications.panel ||
+ (condition && !condition())
+ ) {
+ return;
+ }
+ PopupNotifications.panel.removeEventListener(eventName, listener);
+ gActiveListeners.delete(listener);
+ executeSoon(() => callback.call(PopupNotifications.panel));
+ };
+ gActiveListeners.set(listener, eventName);
+ PopupNotifications.panel.addEventListener(eventName, listener);
+}
+
+function waitForNotificationPanel() {
+ return new Promise(resolve => {
+ onPopupEvent("popupshown", function () {
+ resolve(this);
+ });
+ });
+}
+
+function waitForNotificationPanelHidden() {
+ return new Promise(resolve => {
+ onPopupEvent("popuphidden", function () {
+ resolve(this);
+ });
+ });
+}
+
+function triggerMainCommand(popup) {
+ let notifications = popup.childNodes;
+ ok(!!notifications.length, "at least one notification displayed");
+ let notification = notifications[0];
+ info("Triggering main command for notification " + notification.id);
+ EventUtils.synthesizeMouseAtCenter(notification.button, {});
+}
+
+function triggerSecondaryCommand(popup, index) {
+ let notifications = popup.childNodes;
+ ok(!!notifications.length, "at least one notification displayed");
+ let notification = notifications[0];
+ info("Triggering secondary command for notification " + notification.id);
+
+ if (index == 0) {
+ EventUtils.synthesizeMouseAtCenter(notification.secondaryButton, {});
+ return;
+ }
+
+ // Extra secondary actions appear in a menu.
+ notification.secondaryButton.nextElementSibling.focus();
+
+ popup.addEventListener(
+ "popupshown",
+ function () {
+ info("Command popup open for notification " + notification.id);
+ // Press down until the desired command is selected. Decrease index by one
+ // since the secondary action was handled above.
+ for (let i = 0; i <= index - 1; i++) {
+ EventUtils.synthesizeKey("KEY_ArrowDown");
+ }
+ // Activate
+ EventUtils.synthesizeKey("KEY_Enter");
+ },
+ { once: true }
+ );
+
+ // One down event to open the popup
+ info(
+ "Open the popup to trigger secondary command for notification " +
+ notification.id
+ );
+ EventUtils.synthesizeKey("KEY_ArrowDown", {
+ altKey: !navigator.platform.includes("Mac"),
+ });
+}
diff --git a/browser/base/content/test/popups/browser.ini b/browser/base/content/test/popups/browser.ini
new file mode 100644
index 0000000000..710ea28633
--- /dev/null
+++ b/browser/base/content/test/popups/browser.ini
@@ -0,0 +1,69 @@
+[DEFAULT]
+support-files =
+ head.js
+ popup_blocker_a.html # used as dummy file
+prefs =
+ # TODO: Port browser_popup_{move,move_instant,resize}.js to use move/resizeTo
+ # instead of individual properties.
+ dom.window_position_size_properties_replaceable.enabled=false
+[browser_popupUI.js]
+[browser_popup_blocker.js]
+support-files =
+ popup_blocker.html
+ popup_blocker_a.html
+ popup_blocker_b.html
+ popup_blocker_10_popups.html
+skip-if = (os == 'linux') || debug # Frequent bug 1081925 and bug 1125520 failures
+[browser_popup_blocker_frames.js]
+https_first_disabled = true
+support-files =
+ popup_blocker.html
+ popup_blocker_a.html
+ popup_blocker_b.html
+[browser_popup_blocker_identity_block.js]
+https_first_disabled = true
+support-files =
+ popup_blocker2.html
+ popup_blocker_a.html
+[browser_popup_blocker_iframes.js]
+https_first_disabled = true
+support-files =
+ popup_blocker.html
+ popup_blocker_frame.html
+ popup_blocker_a.html
+ popup_blocker_b.html
+skip-if =
+ debug # This test triggers Bug 1578794 due to opening many popups.
+ os == "win" && os_version == "6.1" # Skip on Azure - frequent failure
+[browser_popup_close_main_window.js]
+[browser_popup_frames.js]
+https_first_disabled = true
+support-files =
+ popup_blocker.html
+ popup_blocker_a.html
+ popup_blocker_b.html
+[browser_popup_inner_outer_size.js]
+[browser_popup_linux_move.js]
+run-if = os == 'linux' && !headless # subset of other move tests
+[browser_popup_linux_resize.js]
+run-if = os == 'linux' && !headless # subset of other resize tests
+[browser_popup_move.js]
+skip-if = os == 'linux' && !headless # Wayland doesn't like moving windows, X11/XWayland unreliable current positions
+[browser_popup_move_instant.js]
+skip-if = os == 'linux' && !headless # Wayland doesn't like moving windows, X11/XWayland unreliable current positions
+[browser_popup_new_window_resize.js]
+[browser_popup_new_window_size.js]
+support-files =
+ popup_size.html
+[browser_popup_resize.js]
+skip-if = os == 'linux' && !headless # outdated current sizes
+[browser_popup_resize_instant.js]
+skip-if = os == 'linux' && !headless # outdated current sizes
+[browser_popup_resize_repeat.js]
+skip-if = os == 'linux' && !headless # outdated current sizes
+[browser_popup_resize_repeat_instant.js]
+skip-if = os == 'linux' && !headless # outdated current sizes
+[browser_popup_resize_revert.js]
+skip-if = os == 'linux' && !headless # outdated current sizes
+[browser_popup_resize_revert_instant.js]
+skip-if = os == 'linux' && !headless # outdated current sizes
diff --git a/browser/base/content/test/popups/browser_popupUI.js b/browser/base/content/test/popups/browser_popupUI.js
new file mode 100644
index 0000000000..27423b5868
--- /dev/null
+++ b/browser/base/content/test/popups/browser_popupUI.js
@@ -0,0 +1,192 @@
+/* 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 toolbar_ui_visibility() {
+ SpecialPowers.pushPrefEnv({ set: [["dom.disable_open_during_load", false]] });
+
+ let popupOpened = BrowserTestUtils.waitForNewWindow({ url: "about:blank" });
+ BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "data:text/html,<html><script>popup=open('about:blank','','width=300,height=200')</script>"
+ );
+ const win = await popupOpened;
+ const doc = win.document;
+
+ ok(win.gURLBar, "location bar exists in the popup");
+ isnot(win.gURLBar.clientWidth, 0, "location bar is visible in the popup");
+ ok(win.gURLBar.readOnly, "location bar is read-only in the popup");
+ isnot(
+ doc.getElementById("Browser:OpenLocation").getAttribute("disabled"),
+ "true",
+ "'open location' command is not disabled in the popup"
+ );
+
+ EventUtils.synthesizeKey("t", { accelKey: true }, win);
+ is(
+ win.gBrowser.browsers.length,
+ 1,
+ "Accel+T doesn't open a new tab in the popup"
+ );
+ is(
+ gBrowser.browsers.length,
+ 3,
+ "Accel+T opened a new tab in the parent window"
+ );
+ gBrowser.removeCurrentTab();
+
+ EventUtils.synthesizeKey("w", { accelKey: true }, win);
+ ok(win.closed, "Accel+W closes the popup");
+
+ if (!win.closed) {
+ win.close();
+ }
+ gBrowser.removeCurrentTab();
+});
+
+add_task(async function titlebar_buttons_visibility() {
+ if (!navigator.platform.startsWith("Win")) {
+ ok(true, "Testing only on Windows");
+ return;
+ }
+
+ const BUTTONS_MAY_VISIBLE = true;
+ const BUTTONS_NEVER_VISIBLE = false;
+
+ // Always open a new window.
+ // With default behavior, it opens a new tab, that doesn't affect button
+ // visibility at all.
+ Services.prefs.setIntPref("browser.link.open_newwindow", 2);
+
+ const drawInTitlebarValues = [
+ [1, BUTTONS_MAY_VISIBLE],
+ [0, BUTTONS_NEVER_VISIBLE],
+ ];
+ const windowFeaturesValues = [
+ // Opens a popup
+ ["width=300,height=100", BUTTONS_NEVER_VISIBLE],
+ ["toolbar", BUTTONS_NEVER_VISIBLE],
+ ["menubar", BUTTONS_NEVER_VISIBLE],
+ ["menubar,toolbar", BUTTONS_NEVER_VISIBLE],
+
+ // Opens a new window
+ ["", BUTTONS_MAY_VISIBLE],
+ ];
+ const menuBarShownValues = [true, false];
+
+ for (const [drawInTitlebar, drawInTitlebarButtons] of drawInTitlebarValues) {
+ Services.prefs.setIntPref("browser.tabs.inTitlebar", drawInTitlebar);
+
+ for (const [
+ windowFeatures,
+ windowFeaturesButtons,
+ ] of windowFeaturesValues) {
+ for (const menuBarShown of menuBarShownValues) {
+ CustomizableUI.setToolbarVisibility("toolbar-menubar", menuBarShown);
+
+ const popupPromise = BrowserTestUtils.waitForNewWindow("about:blank");
+ BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ `data:text/html;charset=UTF-8,<html><script>window.open("about:blank","","${windowFeatures}")</script>`
+ );
+ const popupWin = await popupPromise;
+
+ const menubar = popupWin.document.querySelector("#toolbar-menubar");
+ const menubarIsShown =
+ menubar.getAttribute("autohide") != "true" ||
+ menubar.getAttribute("inactive") != "true";
+ const buttonsInMenubar = menubar.querySelector(
+ ".titlebar-buttonbox-container"
+ );
+ const buttonsInMenubarShown =
+ menubarIsShown &&
+ popupWin.getComputedStyle(buttonsInMenubar).display != "none";
+
+ const buttonsInTabbar = popupWin.document.querySelector(
+ "#TabsToolbar .titlebar-buttonbox-container"
+ );
+ const buttonsInTabbarShown =
+ popupWin.getComputedStyle(buttonsInTabbar).display != "none";
+
+ const params = `drawInTitlebar=${drawInTitlebar}, windowFeatures=${windowFeatures}, menuBarShown=${menuBarShown}`;
+ if (
+ drawInTitlebarButtons == BUTTONS_MAY_VISIBLE &&
+ windowFeaturesButtons == BUTTONS_MAY_VISIBLE
+ ) {
+ ok(
+ buttonsInMenubarShown || buttonsInTabbarShown,
+ `Titlebar buttons should be visible: ${params}`
+ );
+ } else {
+ ok(
+ !buttonsInMenubarShown,
+ `Titlebar buttons should not be visible: ${params}`
+ );
+ ok(
+ !buttonsInTabbarShown,
+ `Titlebar buttons should not be visible: ${params}`
+ );
+ }
+
+ const closedPopupPromise = BrowserTestUtils.windowClosed(popupWin);
+ popupWin.close();
+ await closedPopupPromise;
+ gBrowser.removeCurrentTab();
+ }
+ }
+ }
+
+ CustomizableUI.setToolbarVisibility("toolbar-menubar", false);
+ Services.prefs.clearUserPref("browser.tabs.inTitlebar");
+ Services.prefs.clearUserPref("browser.link.open_newwindow");
+});
+
+// Test only `visibility` rule here, to verify bug 1636229 fix.
+// Other styles and ancestors can be different for each OS.
+function isVisible(element) {
+ const style = element.ownerGlobal.getComputedStyle(element);
+ return style.visibility == "visible";
+}
+
+async function testTabBarVisibility() {
+ SpecialPowers.pushPrefEnv({ set: [["dom.disable_open_during_load", false]] });
+
+ const popupOpened = BrowserTestUtils.waitForNewWindow({ url: "about:blank" });
+ BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "data:text/html,<html><script>popup=open('about:blank','','width=300,height=200')</script>"
+ );
+ const win = await popupOpened;
+ const doc = win.document;
+
+ ok(
+ !isVisible(doc.getElementById("TabsToolbar")),
+ "tabbar should be hidden for popup"
+ );
+
+ const closedPopupPromise = BrowserTestUtils.windowClosed(win);
+ win.close();
+ await closedPopupPromise;
+
+ gBrowser.removeCurrentTab();
+}
+
+add_task(async function tabbar_visibility() {
+ await testTabBarVisibility();
+});
+
+add_task(async function tabbar_visibility_with_theme() {
+ const extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ theme: {},
+ },
+ });
+
+ await extension.startup();
+
+ await testTabBarVisibility();
+
+ await extension.unload();
+});
diff --git a/browser/base/content/test/popups/browser_popup_blocker.js b/browser/base/content/test/popups/browser_popup_blocker.js
new file mode 100644
index 0000000000..bfda12331e
--- /dev/null
+++ b/browser/base/content/test/popups/browser_popup_blocker.js
@@ -0,0 +1,155 @@
+/* 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/. */
+
+const baseURL = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+);
+
+function clearAllPermissionsByPrefix(aPrefix) {
+ for (let perm of Services.perms.all) {
+ if (perm.type.startsWith(aPrefix)) {
+ Services.perms.removePermission(perm);
+ }
+ }
+}
+
+add_setup(async function () {
+ // Enable the popup blocker.
+ await SpecialPowers.pushPrefEnv({
+ set: [["dom.disable_open_during_load", true]],
+ });
+});
+
+// Tests that we show a special message when popup blocking exceeds
+// a certain maximum of popups per page.
+add_task(async function test_maximum_reported_blocks() {
+ Services.prefs.setIntPref("privacy.popups.maxReported", 5);
+
+ // Open the test page.
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ baseURL + "popup_blocker_10_popups.html"
+ );
+
+ // Wait for the popup-blocked notification.
+ let notification = await TestUtils.waitForCondition(() =>
+ gBrowser.getNotificationBox().getNotificationWithValue("popup-blocked")
+ );
+
+ // Slightly hacky way to ensure we show the correct message in this case.
+ ok(
+ notification.messageText.textContent.includes("more than"),
+ "Notification label has 'more than'"
+ );
+ ok(
+ notification.messageText.textContent.includes("5"),
+ "Notification label shows the maximum number of popups"
+ );
+
+ gBrowser.removeTab(tab);
+
+ Services.prefs.clearUserPref("privacy.popups.maxReported");
+});
+
+add_task(async function test_opening_blocked_popups() {
+ // Open the test page.
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ baseURL + "popup_blocker.html"
+ );
+
+ await testPopupBlockingToolbar(tab);
+});
+
+add_task(async function test_opening_blocked_popups_privateWindow() {
+ let win = await BrowserTestUtils.openNewBrowserWindow({
+ private: true,
+ });
+ // Open the test page.
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ win.gBrowser,
+ baseURL + "popup_blocker.html"
+ );
+ await testPopupBlockingToolbar(tab);
+ await BrowserTestUtils.closeWindow(win);
+});
+
+async function testPopupBlockingToolbar(tab) {
+ let win = tab.ownerGlobal;
+ // Wait for the popup-blocked notification.
+ let notification;
+ await TestUtils.waitForCondition(
+ () =>
+ (notification = win.gBrowser
+ .getNotificationBox()
+ .getNotificationWithValue("popup-blocked"))
+ );
+
+ // Show the menu.
+ let popupShown = BrowserTestUtils.waitForEvent(win, "popupshown");
+ let popupFilled = waitForBlockedPopups(2, {
+ doc: win.document,
+ });
+ EventUtils.synthesizeMouseAtCenter(
+ notification.buttonContainer.querySelector("button"),
+ {},
+ win
+ );
+ let popup_event = await popupShown;
+ let menu = popup_event.target;
+ is(menu.id, "blockedPopupOptions", "Blocked popup menu shown");
+
+ await popupFilled;
+
+ // Pressing "allow" should open all blocked popups.
+ let popupTabs = [];
+ function onTabOpen(event) {
+ popupTabs.push(event.target);
+ }
+ win.gBrowser.tabContainer.addEventListener("TabOpen", onTabOpen);
+
+ // Press the button.
+ let allow = win.document.getElementById("blockedPopupAllowSite");
+ allow.doCommand();
+ await TestUtils.waitForCondition(
+ () =>
+ popupTabs.length == 2 &&
+ popupTabs.every(
+ aTab => aTab.linkedBrowser.currentURI.spec != "about:blank"
+ )
+ );
+
+ win.gBrowser.tabContainer.removeEventListener("TabOpen", onTabOpen);
+
+ ok(
+ popupTabs[0].linkedBrowser.currentURI.spec.endsWith("popup_blocker_a.html"),
+ "Popup a"
+ );
+ ok(
+ popupTabs[1].linkedBrowser.currentURI.spec.endsWith("popup_blocker_b.html"),
+ "Popup b"
+ );
+
+ let popupPerms = Services.perms.getAllByTypeSince("popup", 0);
+ is(popupPerms.length, 1, "One popup permission added");
+ let popupPerm = popupPerms[0];
+ let expectedExpireType = PrivateBrowsingUtils.isWindowPrivate(win)
+ ? Services.perms.EXPIRE_SESSION
+ : Services.perms.EXPIRE_NEVER;
+ is(
+ popupPerm.expireType,
+ expectedExpireType,
+ "Check expireType is appropriate for the window"
+ );
+
+ // Clean up.
+ win.gBrowser.removeTab(tab);
+ for (let popup of popupTabs) {
+ win.gBrowser.removeTab(popup);
+ }
+ clearAllPermissionsByPrefix("popup");
+ // Ensure the menu closes.
+ menu.hidePopup();
+}
diff --git a/browser/base/content/test/popups/browser_popup_blocker_frames.js b/browser/base/content/test/popups/browser_popup_blocker_frames.js
new file mode 100644
index 0000000000..163fa4a0bb
--- /dev/null
+++ b/browser/base/content/test/popups/browser_popup_blocker_frames.js
@@ -0,0 +1,100 @@
+/* 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/. */
+
+const baseURL = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com"
+);
+
+async function test_opening_blocked_popups(testURL) {
+ // Enable the popup blocker.
+ await SpecialPowers.pushPrefEnv({
+ set: [["dom.disable_open_during_load", true]],
+ });
+
+ // Open the test page.
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, testURL);
+
+ await SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [baseURL + "popup_blocker.html"],
+ uri => {
+ let iframe = content.document.createElement("iframe");
+ iframe.id = "popupframe";
+ iframe.src = uri;
+ content.document.body.appendChild(iframe);
+ }
+ );
+
+ // Wait for the popup-blocked notification.
+ await TestUtils.waitForCondition(
+ () =>
+ gBrowser.getNotificationBox().getNotificationWithValue("popup-blocked"),
+ "Waiting for the popup-blocked notification."
+ );
+
+ let popupTabs = [];
+ function onTabOpen(event) {
+ popupTabs.push(event.target);
+ }
+ gBrowser.tabContainer.addEventListener("TabOpen", onTabOpen);
+
+ await SpecialPowers.pushPermissions([
+ { type: "popup", allow: true, context: testURL },
+ ]);
+
+ await SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [baseURL + "popup_blocker.html"],
+ uri => {
+ content.document.getElementById("popupframe").remove();
+ let iframe = content.document.createElement("iframe");
+ iframe.id = "popupframe";
+ iframe.src = uri;
+ content.document.body.appendChild(iframe);
+ }
+ );
+
+ await TestUtils.waitForCondition(
+ () =>
+ popupTabs.length == 2 &&
+ popupTabs.every(
+ aTab => aTab.linkedBrowser.currentURI.spec != "about:blank"
+ ),
+ "Waiting for two tabs to be opened."
+ );
+
+ ok(
+ popupTabs[0].linkedBrowser.currentURI.spec.endsWith("popup_blocker_a.html"),
+ "Popup a"
+ );
+ ok(
+ popupTabs[1].linkedBrowser.currentURI.spec.endsWith("popup_blocker_b.html"),
+ "Popup b"
+ );
+
+ await SpecialPowers.popPermissions();
+
+ gBrowser.tabContainer.removeEventListener("TabOpen", onTabOpen);
+
+ await SpecialPowers.spawn(tab.linkedBrowser, [], () => {
+ content.document.getElementById("popupframe").remove();
+ });
+
+ BrowserTestUtils.removeTab(tab);
+ for (let popup of popupTabs) {
+ gBrowser.removeTab(popup);
+ }
+}
+
+add_task(async function () {
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ await test_opening_blocked_popups("http://example.com/");
+});
+
+add_task(async function () {
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ await test_opening_blocked_popups("http://w3c-test.org/");
+});
diff --git a/browser/base/content/test/popups/browser_popup_blocker_identity_block.js b/browser/base/content/test/popups/browser_popup_blocker_identity_block.js
new file mode 100644
index 0000000000..c277be2c40
--- /dev/null
+++ b/browser/base/content/test/popups/browser_popup_blocker_identity_block.js
@@ -0,0 +1,242 @@
+"use strict";
+
+/* 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/. */
+
+const { PermissionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PermissionTestUtils.sys.mjs"
+);
+
+const baseURL = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com"
+);
+const URL = baseURL + "popup_blocker2.html";
+const URI = Services.io.newURI(URL);
+const PRINCIPAL = Services.scriptSecurityManager.createContentPrincipal(
+ URI,
+ {}
+);
+
+function openPermissionPopup() {
+ let promise = BrowserTestUtils.waitForEvent(
+ window,
+ "popupshown",
+ true,
+ event => event.target == gPermissionPanel._permissionPopup
+ );
+ gPermissionPanel._identityPermissionBox.click();
+ return promise;
+}
+
+function closePermissionPopup() {
+ let promise = BrowserTestUtils.waitForEvent(
+ gPermissionPanel._permissionPopup,
+ "popuphidden"
+ );
+ gPermissionPanel._permissionPopup.hidePopup();
+ return promise;
+}
+
+add_task(async function enable_popup_blocker() {
+ // Enable popup blocker.
+ await SpecialPowers.pushPrefEnv({
+ set: [["dom.disable_open_during_load", true]],
+ });
+ await SpecialPowers.pushPrefEnv({
+ set: [["dom.disable_open_click_delay", 0]],
+ });
+});
+
+add_task(async function check_blocked_popup_indicator() {
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, URL);
+
+ // Blocked popup indicator should not exist in the identity popup when there are no blocked popups.
+ await openPermissionPopup();
+ Assert.equal(document.getElementById("blocked-popup-indicator-item"), null);
+ await closePermissionPopup();
+
+ // Blocked popup notification icon should be hidden in the identity block when no popups are blocked.
+ let icon = gPermissionPanel._identityPermissionBox.querySelector(
+ ".blocked-permission-icon[data-permission-id='popup']"
+ );
+ Assert.equal(icon.hasAttribute("showing"), false);
+
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => {
+ let open = content.document.getElementById("pop");
+ open.click();
+ });
+
+ // Wait for popup block.
+ await TestUtils.waitForCondition(() =>
+ gBrowser.getNotificationBox().getNotificationWithValue("popup-blocked")
+ );
+
+ // Check if blocked popup indicator text is visible in the identity popup. It should be visible.
+ document.getElementById("identity-permission-box").click();
+ await openPermissionPopup();
+ await TestUtils.waitForCondition(
+ () => document.getElementById("blocked-popup-indicator-item") !== null
+ );
+
+ // Check that the default state is correctly set to "Block".
+ let menulist = document.getElementById("permission-popup-menulist");
+ Assert.equal(menulist.value, "0");
+ Assert.equal(menulist.label, "Block");
+
+ await closePermissionPopup();
+
+ // Check if blocked popup icon is visible in the identity block.
+ Assert.equal(icon.getAttribute("showing"), "true");
+
+ gBrowser.removeTab(tab);
+});
+
+// Check if clicking on "Show blocked popups" shows blocked popups.
+add_task(async function check_popup_showing() {
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, URL);
+
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => {
+ let open = content.document.getElementById("pop");
+ open.click();
+ });
+
+ // Wait for popup block.
+ await TestUtils.waitForCondition(() =>
+ gBrowser.getNotificationBox().getNotificationWithValue("popup-blocked")
+ );
+
+ // Store the popup that opens in this array.
+ let popup;
+ function onTabOpen(event) {
+ popup = event.target;
+ }
+ gBrowser.tabContainer.addEventListener("TabOpen", onTabOpen);
+
+ // Open identity popup and click on "Show blocked popups".
+ await openPermissionPopup();
+ let e = document.getElementById("blocked-popup-indicator-item");
+ let text = e.getElementsByTagName("label")[0];
+ text.click();
+
+ await BrowserTestUtils.waitForEvent(gBrowser.tabContainer, "TabOpen");
+ await TestUtils.waitForCondition(
+ () => popup.linkedBrowser.currentURI.spec != "about:blank"
+ );
+
+ gBrowser.tabContainer.removeEventListener("TabOpen", onTabOpen);
+
+ ok(
+ popup.linkedBrowser.currentURI.spec.endsWith("popup_blocker_a.html"),
+ "Popup a"
+ );
+
+ gBrowser.removeTab(popup);
+ gBrowser.removeTab(tab);
+});
+
+// Test if changing menulist values of blocked popup indicator changes permission state and popup behavior.
+add_task(async function check_permission_state_change() {
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, URL);
+
+ // Initially the permission state is BLOCK for popups (set by the prefs).
+ let state = SitePermissions.getForPrincipal(
+ PRINCIPAL,
+ "popup",
+ gBrowser
+ ).state;
+ Assert.equal(state, SitePermissions.BLOCK);
+
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => {
+ let open = content.document.getElementById("pop");
+ open.click();
+ });
+
+ // Wait for popup block.
+ await TestUtils.waitForCondition(() =>
+ gBrowser.getNotificationBox().getNotificationWithValue("popup-blocked")
+ );
+
+ // Open identity popup and change permission state to allow.
+ await openPermissionPopup();
+ let menulist = document.getElementById("permission-popup-menulist");
+ menulist.menupopup.openPopup(); // Open the allow/block menu
+ let menuitem = menulist.getElementsByTagName("menuitem")[0];
+ menuitem.click();
+ await closePermissionPopup();
+
+ state = SitePermissions.getForPrincipal(PRINCIPAL, "popup", gBrowser).state;
+ Assert.equal(state, SitePermissions.ALLOW);
+
+ // Store the popup that opens in this array.
+ let popup;
+ function onTabOpen(event) {
+ popup = event.target;
+ }
+ gBrowser.tabContainer.addEventListener("TabOpen", onTabOpen);
+
+ // Check if a popup opens.
+ await Promise.all([
+ SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => {
+ let open = content.document.getElementById("pop");
+ open.click();
+ }),
+ BrowserTestUtils.waitForEvent(gBrowser.tabContainer, "TabOpen"),
+ ]);
+ await TestUtils.waitForCondition(
+ () => popup.linkedBrowser.currentURI.spec != "about:blank"
+ );
+
+ gBrowser.tabContainer.removeEventListener("TabOpen", onTabOpen);
+
+ ok(
+ popup.linkedBrowser.currentURI.spec.endsWith("popup_blocker_a.html"),
+ "Popup a"
+ );
+
+ gBrowser.removeTab(popup);
+
+ // Open identity popup and change permission state to block.
+ await openPermissionPopup();
+ menulist = document.getElementById("permission-popup-menulist");
+ menulist.menupopup.openPopup(); // Open the allow/block menu
+ menuitem = menulist.getElementsByTagName("menuitem")[1];
+ menuitem.click();
+ await closePermissionPopup();
+
+ // Clicking on the "Block" menuitem should remove the permission object(same behavior as UNKNOWN state).
+ // We have already confirmed that popups are blocked when the permission state is BLOCK.
+ state = SitePermissions.getForPrincipal(PRINCIPAL, "popup", gBrowser).state;
+ Assert.equal(state, SitePermissions.BLOCK);
+
+ gBrowser.removeTab(tab);
+});
+
+// Explicitly set the permission to the otherwise default state and check that
+// the label still displays correctly.
+add_task(async function check_explicit_default_permission() {
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, URL);
+
+ // DENY only works if triggered through Services.perms (it's very edge-casey),
+ // since SitePermissions.sys.mjs considers setting default permissions to be removal.
+ PermissionTestUtils.add(URI, "popup", Ci.nsIPermissionManager.DENY_ACTION);
+
+ await openPermissionPopup();
+ let menulist = document.getElementById("permission-popup-menulist");
+ Assert.equal(menulist.value, "0");
+ Assert.equal(menulist.label, "Block");
+ await closePermissionPopup();
+
+ PermissionTestUtils.add(URI, "popup", Services.perms.ALLOW_ACTION);
+
+ await openPermissionPopup();
+ menulist = document.getElementById("permission-popup-menulist");
+ Assert.equal(menulist.value, "1");
+ Assert.equal(menulist.label, "Allow");
+ await closePermissionPopup();
+
+ PermissionTestUtils.remove(URI, "popup");
+ gBrowser.removeTab(tab);
+});
diff --git a/browser/base/content/test/popups/browser_popup_blocker_iframes.js b/browser/base/content/test/popups/browser_popup_blocker_iframes.js
new file mode 100644
index 0000000000..aa93a7acac
--- /dev/null
+++ b/browser/base/content/test/popups/browser_popup_blocker_iframes.js
@@ -0,0 +1,186 @@
+/* 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";
+
+requestLongerTimeout(2);
+
+const testURL = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.org"
+);
+
+const examplecomURL = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com"
+);
+
+const w3cURL = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://w3c-test.org"
+);
+
+const examplenetURL = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.net"
+);
+
+const prefixexamplecomURL = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://prefixexample.com"
+);
+
+class TestCleanup {
+ constructor() {
+ this.tabs = [];
+ }
+
+ count() {
+ return this.tabs.length;
+ }
+
+ static setup() {
+ let cleaner = new TestCleanup();
+ this.onTabOpen = event => {
+ cleaner.tabs.push(event.target);
+ };
+ gBrowser.tabContainer.addEventListener("TabOpen", this.onTabOpen);
+ return cleaner;
+ }
+
+ clean() {
+ gBrowser.tabContainer.removeEventListener("TabOpen", this.onTabOpen);
+ for (let tab of this.tabs) {
+ gBrowser.removeTab(tab);
+ }
+ }
+}
+
+async function runTest(count, urls, permissions, delayedAllow) {
+ let cleaner = TestCleanup.setup();
+
+ // Enable the popup blocker.
+ await SpecialPowers.pushPrefEnv({
+ set: [["dom.disable_open_during_load", true]],
+ });
+
+ await SpecialPowers.pushPermissions(permissions);
+
+ // Open the test page.
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, testURL);
+
+ let contexts = await SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [...urls, !!delayedAllow],
+ async (url1, url2, url3, url4, delay) => {
+ let iframe1 = content.document.createElement("iframe");
+ let iframe2 = content.document.createElement("iframe");
+ iframe1.id = "iframe1";
+ iframe2.id = "iframe2";
+ iframe1.src = new URL(
+ `popup_blocker_frame.html?delayed=${delay}&base=${url3}`,
+ url1
+ );
+ iframe2.src = new URL(
+ `popup_blocker_frame.html?delayed=${delay}&base=${url4}`,
+ url2
+ );
+
+ let promises = [
+ new Promise(resolve => (iframe1.onload = resolve)),
+ new Promise(resolve => (iframe2.onload = resolve)),
+ ];
+
+ content.document.body.appendChild(iframe1);
+ content.document.body.appendChild(iframe2);
+
+ await Promise.all(promises);
+ return [iframe1.browsingContext, iframe2.browsingContext];
+ }
+ );
+
+ if (delayedAllow) {
+ await delayedAllow();
+ await SpecialPowers.spawn(
+ tab.linkedBrowser,
+ contexts,
+ async function (bc1, bc2) {
+ bc1.window.postMessage("allow", "*");
+ bc2.window.postMessage("allow", "*");
+ }
+ );
+ }
+
+ await TestUtils.waitForCondition(
+ () => cleaner.count() == count,
+ `waiting for ${count} tabs, got ${cleaner.count()}`
+ );
+
+ ok(cleaner.count() == count, `should have ${count} tabs`);
+
+ await SpecialPowers.popPermissions();
+ cleaner.clean();
+}
+
+add_task(async function () {
+ let permission = {
+ type: "popup",
+ allow: true,
+ context: "",
+ };
+
+ let expected = [];
+
+ let tests = [
+ [examplecomURL, w3cURL, prefixexamplecomURL, examplenetURL],
+ [examplecomURL, examplecomURL, prefixexamplecomURL, examplenetURL],
+ [examplecomURL, examplecomURL, prefixexamplecomURL, prefixexamplecomURL],
+ [examplecomURL, w3cURL, prefixexamplecomURL, prefixexamplecomURL],
+ ];
+
+ permission.context = testURL;
+ expected = [5, 5, 3, 3];
+ for (let test in tests) {
+ await runTest(expected[test], tests[test], [permission]);
+ }
+
+ permission.context = examplecomURL;
+ expected = [3, 5, 3, 3];
+ for (let test in tests) {
+ await runTest(expected[test], tests[test], [permission]);
+ }
+
+ permission.context = prefixexamplecomURL;
+ expected = [3, 3, 3, 3];
+ for (let test in tests) {
+ await runTest(expected[test], tests[test], [permission]);
+ }
+
+ async function allowPopup() {
+ await SpecialPowers.pushPermissions([permission]);
+ }
+
+ permission.context = testURL;
+ expected = [5, 5, 3, 3];
+ for (let test in tests) {
+ await runTest(expected[test], tests[test], [], allowPopup);
+ }
+
+ permission.context = examplecomURL;
+ expected = [3, 5, 3, 3];
+ for (let test in tests) {
+ await runTest(expected[test], tests[test], [], allowPopup);
+ }
+
+ permission.context = prefixexamplecomURL;
+ expected = [3, 3, 3, 3];
+ for (let test in tests) {
+ await runTest(expected[test], tests[test], [], allowPopup);
+ }
+});
diff --git a/browser/base/content/test/popups/browser_popup_close_main_window.js b/browser/base/content/test/popups/browser_popup_close_main_window.js
new file mode 100644
index 0000000000..148e937bca
--- /dev/null
+++ b/browser/base/content/test/popups/browser_popup_close_main_window.js
@@ -0,0 +1,84 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+function muffleMainWindowType() {
+ let oldWinType = document.documentElement.getAttribute("windowtype");
+ // Check if we've already done this to allow calling multiple times:
+ if (oldWinType != "navigator:testrunner") {
+ // Make the main test window not count as a browser window any longer
+ document.documentElement.setAttribute("windowtype", "navigator:testrunner");
+
+ registerCleanupFunction(() => {
+ document.documentElement.setAttribute("windowtype", oldWinType);
+ });
+ }
+}
+
+/**
+ * Check that if we close the 1 remaining window, we treat it as quitting on
+ * non-mac.
+ *
+ * Sets the window type for the main browser test window to something else to
+ * avoid having to actually close the main browser window.
+ */
+add_task(async function closing_last_window_equals_quitting() {
+ if (navigator.platform.startsWith("Mac")) {
+ ok(true, "Not testing on mac");
+ return;
+ }
+ muffleMainWindowType();
+
+ let observed = 0;
+ function obs() {
+ observed++;
+ }
+ Services.obs.addObserver(obs, "browser-lastwindow-close-requested");
+ let newWin = await BrowserTestUtils.openNewBrowserWindow();
+ let closedPromise = BrowserTestUtils.windowClosed(newWin);
+ newWin.BrowserTryToCloseWindow();
+ await closedPromise;
+ is(observed, 1, "Got a notification for closing the normal window.");
+ Services.obs.removeObserver(obs, "browser-lastwindow-close-requested");
+});
+
+/**
+ * Check that if we close the 1 remaining window and also have a popup open,
+ * we don't treat it as quitting.
+ *
+ * Sets the window type for the main browser test window to something else to
+ * avoid having to actually close the main browser window.
+ */
+add_task(async function closing_last_window_equals_quitting() {
+ if (navigator.platform.startsWith("Mac")) {
+ ok(true, "Not testing on mac");
+ return;
+ }
+ muffleMainWindowType();
+ let observed = 0;
+ function obs() {
+ observed++;
+ }
+ Services.obs.addObserver(obs, "browser-lastwindow-close-requested");
+ let newWin = await BrowserTestUtils.openNewBrowserWindow();
+ let popupPromise = BrowserTestUtils.waitForNewWindow("https://example.com/");
+ SpecialPowers.spawn(newWin.gBrowser.selectedBrowser, [], function () {
+ content.open("https://example.com/", "_blank", "height=500");
+ });
+ let popupWin = await popupPromise;
+ let closedPromise = BrowserTestUtils.windowClosed(newWin);
+ newWin.BrowserTryToCloseWindow();
+ await closedPromise;
+ is(observed, 0, "Got no notification for closing the normal window.");
+
+ closedPromise = BrowserTestUtils.windowClosed(popupWin);
+ popupWin.BrowserTryToCloseWindow();
+ await closedPromise;
+ is(
+ observed,
+ 0,
+ "Got no notification now that we're closing the last window, as it's a popup."
+ );
+ Services.obs.removeObserver(obs, "browser-lastwindow-close-requested");
+});
diff --git a/browser/base/content/test/popups/browser_popup_frames.js b/browser/base/content/test/popups/browser_popup_frames.js
new file mode 100644
index 0000000000..838eb5c045
--- /dev/null
+++ b/browser/base/content/test/popups/browser_popup_frames.js
@@ -0,0 +1,128 @@
+/* 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/. */
+
+const baseURL = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com"
+);
+
+async function test_opening_blocked_popups(testURL) {
+ // Enable the popup blocker.
+ await SpecialPowers.pushPrefEnv({
+ set: [["dom.disable_open_during_load", true]],
+ });
+
+ // Open the test page.
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, testURL);
+
+ let popupframeBC = await SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [baseURL + "popup_blocker.html"],
+ uri => {
+ let iframe = content.document.createElement("iframe");
+ iframe.id = "popupframe";
+ iframe.src = uri;
+ content.document.body.appendChild(iframe);
+ return iframe.browsingContext;
+ }
+ );
+
+ // Wait for the popup-blocked notification.
+ let notification;
+ await TestUtils.waitForCondition(
+ () =>
+ (notification = gBrowser
+ .getNotificationBox()
+ .getNotificationWithValue("popup-blocked")),
+ "Waiting for the popup-blocked notification."
+ );
+
+ ok(notification, "Should have notification.");
+
+ let pageHideHappened = BrowserTestUtils.waitForContentEvent(
+ tab.linkedBrowser,
+ "pagehide",
+ true
+ );
+ await SpecialPowers.spawn(tab.linkedBrowser, [baseURL], async function (uri) {
+ let iframe = content.document.createElement("iframe");
+ content.document.body.appendChild(iframe);
+ iframe.src = uri;
+ });
+
+ await pageHideHappened;
+ notification = gBrowser
+ .getNotificationBox()
+ .getNotificationWithValue("popup-blocked");
+ ok(notification, "Should still have notification");
+
+ pageHideHappened = BrowserTestUtils.waitForContentEvent(
+ tab.linkedBrowser,
+ "pagehide",
+ true
+ );
+ // Now navigate the subframe.
+ await SpecialPowers.spawn(popupframeBC, [], async function () {
+ content.document.location.href = "about:blank";
+ });
+ await pageHideHappened;
+ await TestUtils.waitForCondition(
+ () =>
+ !gBrowser.getNotificationBox().getNotificationWithValue("popup-blocked"),
+ "Notification should go away"
+ );
+ ok(
+ !gBrowser.getNotificationBox().getNotificationWithValue("popup-blocked"),
+ "Should no longer have notification"
+ );
+
+ // Remove the frame and add another one:
+ await SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [baseURL + "popup_blocker.html"],
+ uri => {
+ content.document.getElementById("popupframe").remove();
+ let iframe = content.document.createElement("iframe");
+ iframe.id = "popupframe";
+ iframe.src = uri;
+ content.document.body.appendChild(iframe);
+ }
+ );
+
+ // Wait for the popup-blocked notification.
+ await TestUtils.waitForCondition(
+ () =>
+ (notification = gBrowser
+ .getNotificationBox()
+ .getNotificationWithValue("popup-blocked"))
+ );
+
+ ok(notification, "Should have notification.");
+
+ await SpecialPowers.spawn(tab.linkedBrowser, [], () => {
+ content.document.getElementById("popupframe").remove();
+ });
+
+ await TestUtils.waitForCondition(
+ () =>
+ !gBrowser.getNotificationBox().getNotificationWithValue("popup-blocked")
+ );
+ ok(
+ !gBrowser.getNotificationBox().getNotificationWithValue("popup-blocked"),
+ "Should no longer have notification"
+ );
+
+ BrowserTestUtils.removeTab(tab);
+}
+
+add_task(async function () {
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ await test_opening_blocked_popups("http://example.com/");
+});
+
+add_task(async function () {
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ await test_opening_blocked_popups("http://w3c-test.org/");
+});
diff --git a/browser/base/content/test/popups/browser_popup_inner_outer_size.js b/browser/base/content/test/popups/browser_popup_inner_outer_size.js
new file mode 100644
index 0000000000..7e2e0e43fe
--- /dev/null
+++ b/browser/base/content/test/popups/browser_popup_inner_outer_size.js
@@ -0,0 +1,120 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+async function checkForDeltaMismatch(aMsg) {
+ let getDelta = () => {
+ return {
+ width: this.content.outerWidth - this.content.innerWidth,
+ height: this.content.outerHeight - this.content.innerHeight,
+ };
+ };
+
+ let initialDelta = getDelta();
+ let latestDelta = initialDelta;
+
+ this.content.testerPromise = new Promise(resolve => {
+ // Called from stopCheck
+ this.content.resolveFunc = resolve;
+ info(`[${aMsg}] Starting interval tester.`);
+ this.content.intervalID = this.content.setInterval(() => {
+ let currentDelta = getDelta();
+ if (
+ latestDelta.width != currentDelta.width ||
+ latestDelta.height != currentDelta.height
+ ) {
+ latestDelta = currentDelta;
+
+ let { innerWidth: iW, outerWidth: oW } = this.content;
+ let { innerHeight: iH, outerHeight: oH } = this.content;
+ info(`[${aMsg}] Delta changed. (inner ${iW}x${iH}, outer ${oW}x${oH})`);
+
+ let { width: w, height: h } = currentDelta;
+ is(w, initialDelta.width, `[${aMsg}] Inner to outer width delta.`);
+ is(h, initialDelta.height, `[${aMsg}] Inner to outer height delta.`);
+ }
+ }, 0);
+ }).then(() => {
+ let { width: w, height: h } = latestDelta;
+ is(w, initialDelta.width, `[${aMsg}] Final inner to outer width delta.`);
+ is(h, initialDelta.height, `[${aMsg}] Final Inner to outer height delta.`);
+ });
+}
+
+async function stopCheck(aMsg) {
+ info(`[${aMsg}] Stopping interval tester.`);
+ this.content.clearInterval(this.content.intervalID);
+ info(`[${aMsg}] Resolving interval tester.`);
+ this.content.resolveFunc();
+ await this.content.testerPromise;
+}
+
+add_task(async function test_innerToOuterDelta() {
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ window.gBrowser,
+ "https://example.net"
+ );
+ let popupBrowsingContext = await SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [],
+ async () => {
+ info("Opening popup.");
+ let popup = this.content.open(
+ "https://example.net",
+ "",
+ "width=200,height=200"
+ );
+ info("Waiting for load event.");
+ await ContentTaskUtils.waitForEvent(popup, "load");
+ return popup.browsingContext;
+ }
+ );
+
+ await SpecialPowers.spawn(
+ popupBrowsingContext,
+ ["Content"],
+ checkForDeltaMismatch
+ );
+ let popupChrome = popupBrowsingContext.topChromeWindow;
+ await SpecialPowers.spawn(popupChrome, ["Chrome"], checkForDeltaMismatch);
+
+ let numResizes = 3;
+ let resizeStep = 5;
+ let { outerWidth: width, outerHeight: height } = popupChrome;
+ let finalWidth = width + numResizes * resizeStep;
+ let finalHeight = height + numResizes * resizeStep;
+
+ info(`Starting ${numResizes} resizes.`);
+ await new Promise(resolve => {
+ let resizeListener = () => {
+ if (
+ popupChrome.outerWidth == finalWidth &&
+ popupChrome.outerHeight == finalHeight
+ ) {
+ popupChrome.removeEventListener("resize", resizeListener);
+ resolve();
+ }
+ };
+ popupChrome.addEventListener("resize", resizeListener);
+
+ let resizeNext = () => {
+ width += resizeStep;
+ height += resizeStep;
+ info(`Resizing to ${width}x${height}`);
+ popupChrome.resizeTo(width, height);
+ numResizes--;
+ if (numResizes > 0) {
+ info(`${numResizes} resizes remaining.`);
+ popupChrome.requestAnimationFrame(resizeNext);
+ }
+ };
+ resizeNext();
+ });
+
+ await SpecialPowers.spawn(popupBrowsingContext, ["Content"], stopCheck);
+ await SpecialPowers.spawn(popupChrome, ["Chrome"], stopCheck);
+
+ await SpecialPowers.spawn(popupBrowsingContext, [], () => {
+ this.content.close();
+ });
+ await BrowserTestUtils.removeTab(tab);
+});
diff --git a/browser/base/content/test/popups/browser_popup_linux_move.js b/browser/base/content/test/popups/browser_popup_linux_move.js
new file mode 100644
index 0000000000..f318ee1873
--- /dev/null
+++ b/browser/base/content/test/popups/browser_popup_linux_move.js
@@ -0,0 +1,56 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+function createLinuxMoveTests(aFirstValue, aSecondValue, aMsg) {
+ for (let prop of ["screenX", "screenY"]) {
+ let first = {};
+ first[prop] = aFirstValue;
+ let second = {};
+ second[prop] = aSecondValue;
+ new ResizeMoveTest(
+ [first, second],
+ /* aInstant */ true,
+ `${aMsg} ${prop},${prop}`
+ );
+ new ResizeMoveTest(
+ [first, second],
+ /* aInstant */ false,
+ `${aMsg} ${prop},${prop}`
+ );
+ }
+}
+
+if (AppConstants.platform == "linux" && gfxInfo.windowProtocol == "wayland") {
+ add_task(async () => {
+ let tab = await ResizeMoveTest.GetOrCreateTab();
+ let browsingContext =
+ await ResizeMoveTest.GetOrCreatePopupBrowsingContext();
+ let win = browsingContext.topChromeWindow;
+ let targetX = win.screenX + 10;
+ win.moveTo(targetX, win.screenY);
+ await BrowserTestUtils.waitForCondition(() => win.screenX == targetX).catch(
+ () => {}
+ );
+ todo(win.screenX == targetX, "Moving windows on wayland.");
+ win.close();
+ await BrowserTestUtils.removeTab(tab);
+ });
+} else {
+ createLinuxMoveTests(9, 10, "Move");
+ createLinuxMoveTests(10, 0, "Move revert");
+ createLinuxMoveTests(10, 10, "Move repeat");
+
+ new ResizeMoveTest(
+ [{ screenX: 10 }, { screenY: 10 }, { screenX: 20 }],
+ /* aInstant */ true,
+ "Move sequence",
+ /* aWaitForCompletion */ true
+ );
+
+ new ResizeMoveTest(
+ [{ screenX: 10 }, { screenY: 10 }, { screenX: 20 }],
+ /* aInstant */ false,
+ "Move sequence",
+ /* aWaitForCompletion */ true
+ );
+}
diff --git a/browser/base/content/test/popups/browser_popup_linux_resize.js b/browser/base/content/test/popups/browser_popup_linux_resize.js
new file mode 100644
index 0000000000..6917c5823d
--- /dev/null
+++ b/browser/base/content/test/popups/browser_popup_linux_resize.js
@@ -0,0 +1,53 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+function createLinuxResizeTests(aFirstValue, aSecondValue, aMsg) {
+ for (let prop of ResizeMoveTest.PropInfo.sizeProps) {
+ // For e.g 'outerWidth' this will be 'innerWidth'.
+ let otherProp = ResizeMoveTest.PropInfo.crossBoundsMapping[prop];
+ let first = {};
+ first[prop] = aFirstValue;
+ let second = {};
+ second[otherProp] = aSecondValue;
+ new ResizeMoveTest(
+ [first, second],
+ /* aInstant */ true,
+ `${aMsg} ${prop},${otherProp}`
+ );
+ new ResizeMoveTest(
+ [first, second],
+ /* aInstant */ false,
+ `${aMsg} ${prop},${otherProp}`
+ );
+ }
+}
+
+createLinuxResizeTests(9, 10, "Resize");
+createLinuxResizeTests(10, 0, "Resize revert");
+createLinuxResizeTests(10, 10, "Resize repeat");
+
+new ResizeMoveTest(
+ [
+ { outerWidth: 10 },
+ { innerHeight: 10 },
+ { innerWidth: 20 },
+ { outerHeight: 20 },
+ { outerWidth: 30 },
+ ],
+ /* aInstant */ true,
+ "Resize sequence",
+ /* aWaitForCompletion */ true
+);
+
+new ResizeMoveTest(
+ [
+ { outerWidth: 10 },
+ { innerHeight: 10 },
+ { innerWidth: 20 },
+ { outerHeight: 20 },
+ { outerWidth: 30 },
+ ],
+ /* aInstant */ false,
+ "Resize sequence",
+ /* aWaitForCompletion */ true
+);
diff --git a/browser/base/content/test/popups/browser_popup_move.js b/browser/base/content/test/popups/browser_popup_move.js
new file mode 100644
index 0000000000..d7d47e12f5
--- /dev/null
+++ b/browser/base/content/test/popups/browser_popup_move.js
@@ -0,0 +1,6 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+createGenericMoveTests(/* aInstant */ false, "Move");
diff --git a/browser/base/content/test/popups/browser_popup_move_instant.js b/browser/base/content/test/popups/browser_popup_move_instant.js
new file mode 100644
index 0000000000..c8a9219d82
--- /dev/null
+++ b/browser/base/content/test/popups/browser_popup_move_instant.js
@@ -0,0 +1,6 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+createGenericMoveTests(/* aInstant */ true, "Move");
diff --git a/browser/base/content/test/popups/browser_popup_new_window_resize.js b/browser/base/content/test/popups/browser_popup_new_window_resize.js
new file mode 100644
index 0000000000..81d3cf5a9a
--- /dev/null
+++ b/browser/base/content/test/popups/browser_popup_new_window_resize.js
@@ -0,0 +1,51 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+add_task(async function test_new_window_resize() {
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ window.gBrowser,
+ "https://example.net"
+ );
+
+ await SpecialPowers.spawn(tab.linkedBrowser, [], async () => {
+ info("Opening popup.");
+ let win = this.content.open(
+ "https://example.net",
+ "",
+ "width=200,height=200"
+ );
+
+ await ContentTaskUtils.waitForEvent(win, "load");
+
+ let { outerWidth: initialWidth, outerHeight: initialHeight } = win;
+ let targetWidth = initialWidth + 100;
+ let targetHeight = initialHeight + 100;
+
+ let observedOurResizeEvent = false;
+ let resizeListener = () => {
+ let { outerWidth: currentWidth, outerHeight: currentHeight } = win;
+ info(`Resize event for ${currentWidth}x${currentHeight}.`);
+ if (currentWidth == targetWidth && currentHeight == targetHeight) {
+ ok(!observedOurResizeEvent, "First time we receive our resize event.");
+ observedOurResizeEvent = true;
+ }
+ };
+ win.addEventListener("resize", resizeListener);
+ win.resizeTo(targetWidth, targetHeight);
+
+ await ContentTaskUtils.waitForCondition(
+ () => observedOurResizeEvent,
+ `Waiting for our resize event (${targetWidth}x${targetHeight}).`
+ );
+
+ info("Waiting for potentially incoming resize events.");
+ for (let i = 0; i < 10; i++) {
+ await new Promise(r => win.requestAnimationFrame(r));
+ }
+ win.removeEventListener("resize", resizeListener);
+ info("Closing popup.");
+ win.close();
+ });
+
+ await BrowserTestUtils.removeTab(tab);
+});
diff --git a/browser/base/content/test/popups/browser_popup_new_window_size.js b/browser/base/content/test/popups/browser_popup_new_window_size.js
new file mode 100644
index 0000000000..5f5d57e31e
--- /dev/null
+++ b/browser/base/content/test/popups/browser_popup_new_window_size.js
@@ -0,0 +1,90 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const baseURL = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+);
+
+add_task(async function test_new_window_size() {
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ window.gBrowser,
+ baseURL
+ );
+
+ await SpecialPowers.spawn(tab.linkedBrowser, [], async () => {
+ info("Opening popup.");
+ let requestedWidth = 200;
+ let requestedHeight = 200;
+ let win = this.content.open(
+ "popup_size.html",
+ "",
+ `width=${requestedWidth},height=${requestedHeight}`
+ );
+
+ let loadPromise = ContentTaskUtils.waitForEvent(win, "load");
+
+ let { innerWidth: preLoadWidth, innerHeight: preLoadHeight } = win;
+ is(preLoadWidth, requestedWidth, "Width before load event.");
+ is(preLoadHeight, requestedHeight, "Height before load event.");
+
+ await loadPromise;
+
+ let { innerWidth: postLoadWidth, innerHeight: postLoadHeight } = win;
+ is(postLoadWidth, requestedWidth, "Width after load event.");
+ is(postLoadHeight, requestedHeight, "Height after load event.");
+
+ await ContentTaskUtils.waitForCondition(
+ () =>
+ win.innerWidth == requestedWidth && win.innerHeight == requestedHeight,
+ "Waiting for window to become request size."
+ );
+
+ let { innerWidth: finalWidth, innerHeight: finalHeight } = win;
+ is(finalWidth, requestedWidth, "Final width.");
+ is(finalHeight, requestedHeight, "Final height.");
+
+ await SpecialPowers.spawn(
+ win,
+ [{ requestedWidth, requestedHeight }],
+ async input => {
+ let { initialSize, loadSize } = this.content.wrappedJSObject;
+ is(
+ initialSize.width,
+ input.requestedWidth,
+ "Content width before load event."
+ );
+ is(
+ initialSize.height,
+ input.requestedHeight,
+ "Content height before load event."
+ );
+ is(
+ loadSize.width,
+ input.requestedWidth,
+ "Content width after load event."
+ );
+ is(
+ loadSize.height,
+ input.requestedHeight,
+ "Content height after load event."
+ );
+ is(
+ this.content.innerWidth,
+ input.requestedWidth,
+ "Content final width."
+ );
+ is(
+ this.content.innerHeight,
+ input.requestedHeight,
+ "Content final height."
+ );
+ }
+ );
+
+ info("Closing popup.");
+ win.close();
+ });
+
+ await BrowserTestUtils.removeTab(tab);
+});
diff --git a/browser/base/content/test/popups/browser_popup_resize.js b/browser/base/content/test/popups/browser_popup_resize.js
new file mode 100644
index 0000000000..c73ffb58c5
--- /dev/null
+++ b/browser/base/content/test/popups/browser_popup_resize.js
@@ -0,0 +1,6 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+createGenericResizeTests(9, 10, /* aInstant */ false, "Resize");
diff --git a/browser/base/content/test/popups/browser_popup_resize_instant.js b/browser/base/content/test/popups/browser_popup_resize_instant.js
new file mode 100644
index 0000000000..4613e272d6
--- /dev/null
+++ b/browser/base/content/test/popups/browser_popup_resize_instant.js
@@ -0,0 +1,6 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+createGenericResizeTests(9, 10, /* aInstant */ true, "Resize");
diff --git a/browser/base/content/test/popups/browser_popup_resize_repeat.js b/browser/base/content/test/popups/browser_popup_resize_repeat.js
new file mode 100644
index 0000000000..d76f0fd3a2
--- /dev/null
+++ b/browser/base/content/test/popups/browser_popup_resize_repeat.js
@@ -0,0 +1,6 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+createGenericResizeTests(10, 10, /* aInstant */ false, "Resize repeat");
diff --git a/browser/base/content/test/popups/browser_popup_resize_repeat_instant.js b/browser/base/content/test/popups/browser_popup_resize_repeat_instant.js
new file mode 100644
index 0000000000..9870813d87
--- /dev/null
+++ b/browser/base/content/test/popups/browser_popup_resize_repeat_instant.js
@@ -0,0 +1,6 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+createGenericResizeTests(10, 10, /* aInstant */ true, "Resize repeat");
diff --git a/browser/base/content/test/popups/browser_popup_resize_revert.js b/browser/base/content/test/popups/browser_popup_resize_revert.js
new file mode 100644
index 0000000000..e87ef30f69
--- /dev/null
+++ b/browser/base/content/test/popups/browser_popup_resize_revert.js
@@ -0,0 +1,6 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+createGenericResizeTests(10, 0, /* aInstant */ false, "Resize revert");
diff --git a/browser/base/content/test/popups/browser_popup_resize_revert_instant.js b/browser/base/content/test/popups/browser_popup_resize_revert_instant.js
new file mode 100644
index 0000000000..5fa2ce7d5d
--- /dev/null
+++ b/browser/base/content/test/popups/browser_popup_resize_revert_instant.js
@@ -0,0 +1,6 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+createGenericResizeTests(10, 0, /* aInstant */ true, "Resize revert");
diff --git a/browser/base/content/test/popups/head.js b/browser/base/content/test/popups/head.js
new file mode 100644
index 0000000000..f72bba7dca
--- /dev/null
+++ b/browser/base/content/test/popups/head.js
@@ -0,0 +1,574 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const gfxInfo = Cc["@mozilla.org/gfx/info;1"].getService(
+ SpecialPowers.Ci.nsIGfxInfo
+);
+
+async function waitForBlockedPopups(numberOfPopups, { doc }) {
+ let toolbarDoc = doc || document;
+ let menupopup = toolbarDoc.getElementById("blockedPopupOptions");
+ await BrowserTestUtils.waitForCondition(() => {
+ let popups = menupopup.querySelectorAll("[popupReportIndex]");
+ return popups.length == numberOfPopups;
+ }, `Waiting for ${numberOfPopups} popups`);
+}
+
+/*
+ * Tests that a sequence of size changes ultimately results in the latest
+ * requested size. The test also fails when an unexpected window size is
+ * observed in a resize event.
+ *
+ * aPropertyDeltas List of objects where keys describe the name of a window
+ * property and the values the difference to its initial
+ * value.
+ *
+ * aInstant Issue changes without additional waiting in between.
+ *
+ * A brief example of the resutling code that is effectively run for the
+ * following list of deltas:
+ * [{outerWidth: 5, outerHeight: 10}, {outerWidth: 10}]
+ *
+ * let initialWidth = win.outerWidth;
+ * let initialHeight = win.outerHeight;
+ *
+ * if (aInstant) {
+ * win.outerWidth = initialWidth + 5;
+ * win.outerHeight = initialHeight + 10;
+ *
+ * win.outerWidth = initialWidth + 10;
+ * } else {
+ * win.requestAnimationFrame(() => {
+ * win.outerWidth = initialWidth + 5;
+ * win.outerHeight = initialHeight + 10;
+ *
+ * win.requestAnimationFrame(() => {
+ * win.outerWidth = initialWidth + 10;
+ * });
+ * });
+ * }
+ */
+async function testPropertyDeltas(
+ aPropertyDeltas,
+ aInstant,
+ aPropInfo,
+ aMsg,
+ aWaitForCompletion
+) {
+ let msg = `[${aMsg}]`;
+
+ let win = this.content.popup || this.content.wrappedJSObject;
+
+ // Property names and mapping from ResizeMoveTest
+ let {
+ sizeProps,
+ positionProps /* can be empty/incomplete as workaround on Linux */,
+ readonlyProps,
+ crossBoundsMapping,
+ } = aPropInfo;
+
+ let stringifyState = state => {
+ let stateMsg = sizeProps
+ .concat(positionProps)
+ .filter(prop => state[prop] !== undefined)
+ .map(prop => `${prop}: ${state[prop]}`)
+ .join(", ");
+ return `{ ${stateMsg} }`;
+ };
+
+ let initialState = {};
+ let finalState = {};
+
+ info("Initializing all values to current state.");
+ for (let prop of sizeProps.concat(positionProps)) {
+ let value = win[prop];
+ initialState[prop] = value;
+ finalState[prop] = value;
+ }
+
+ // List of potential states during resize events. The current state is also
+ // considered valid, as the resize event might still be outstanding.
+ let validResizeStates = [initialState];
+
+ let updateFinalState = (aProp, aDelta) => {
+ if (
+ readonlyProps.includes(aProp) ||
+ !sizeProps.concat(positionProps).includes(aProp)
+ ) {
+ throw new Error(`Unexpected property "${aProp}".`);
+ }
+
+ // Update both properties of the same axis.
+ let otherProp = crossBoundsMapping[aProp];
+ finalState[aProp] = initialState[aProp] + aDelta;
+ finalState[otherProp] = initialState[otherProp] + aDelta;
+
+ // Mark size as valid in resize event.
+ if (sizeProps.includes(aProp)) {
+ let state = {};
+ sizeProps.forEach(p => (state[p] = finalState[p]));
+ validResizeStates.push(state);
+ }
+ };
+
+ info("Adding resize event listener.");
+ let resizeCount = 0;
+ let resizeListener = evt => {
+ resizeCount++;
+
+ let currentSizeState = {};
+ sizeProps.forEach(p => (currentSizeState[p] = win[p]));
+
+ info(
+ `${msg} ${resizeCount}. resize event: ${stringifyState(currentSizeState)}`
+ );
+ let matchingIndex = validResizeStates.findIndex(state =>
+ sizeProps.every(p => state[p] == currentSizeState[p])
+ );
+ if (matchingIndex < 0) {
+ info(`${msg} Size state should have been one of:`);
+ for (let state of validResizeStates) {
+ info(stringifyState(state));
+ }
+ }
+
+ if (win.gBrowser && evt.target != win) {
+ // Without e10s we receive content resize events in chrome windows.
+ todo(false, `${msg} Resize event target is our window.`);
+ return;
+ }
+
+ ok(
+ matchingIndex >= 0,
+ `${msg} Valid intermediate state. Current: ` +
+ stringifyState(currentSizeState)
+ );
+
+ // No longer allow current and preceding states.
+ validResizeStates.splice(0, matchingIndex + 1);
+ };
+ win.addEventListener("resize", resizeListener);
+
+ const useProperties = !Services.prefs.getBoolPref(
+ "dom.window_position_size_properties_replaceable.enabled",
+ true
+ );
+
+ info("Starting property changes.");
+ await new Promise(resolve => {
+ let index = 0;
+ let next = async () => {
+ let pre = `${msg} [${index + 1}/${aPropertyDeltas.length}]`;
+
+ let deltaObj = aPropertyDeltas[index];
+ for (let prop in deltaObj) {
+ updateFinalState(prop, deltaObj[prop]);
+
+ let targetValue = initialState[prop] + deltaObj[prop];
+ info(`${pre} Setting ${prop} to ${targetValue}.`);
+ if (useProperties) {
+ win[prop] = targetValue;
+ } else if (sizeProps.includes(prop)) {
+ win.resizeTo(finalState.outerWidth, finalState.outerHeight);
+ } else {
+ win.moveTo(finalState.screenX, finalState.screenY);
+ }
+ if (aWaitForCompletion) {
+ await ContentTaskUtils.waitForCondition(
+ () => win[prop] == targetValue,
+ `${msg} Waiting for ${prop} to be ${targetValue}.`
+ );
+ }
+ }
+
+ index++;
+ if (index < aPropertyDeltas.length) {
+ scheduleNext();
+ } else {
+ resolve();
+ }
+ };
+
+ let scheduleNext = () => {
+ if (aInstant) {
+ next();
+ } else {
+ info(`${msg} Requesting animation frame.`);
+ win.requestAnimationFrame(next);
+ }
+ };
+ scheduleNext();
+ });
+
+ try {
+ info(`${msg} Waiting for window to match the final state.`);
+ await ContentTaskUtils.waitForCondition(
+ () => sizeProps.concat(positionProps).every(p => win[p] == finalState[p]),
+ "Waiting for final state."
+ );
+ } catch (e) {}
+
+ info(`${msg} Checking final state.`);
+ info(`${msg} Exepected: ${stringifyState(finalState)}`);
+ info(`${msg} Actual: ${stringifyState(win)}`);
+ for (let prop of sizeProps.concat(positionProps)) {
+ is(win[prop], finalState[prop], `${msg} Expected final value for ${prop}`);
+ }
+
+ win.removeEventListener("resize", resizeListener);
+}
+
+function roundedCenter(aDimension, aOrigin) {
+ let center = aOrigin + Math.floor(aDimension / 2);
+ return center - (center % 100);
+}
+
+class ResizeMoveTest {
+ static WindowWidth = 200;
+ static WindowHeight = 200;
+ static WindowLeft = roundedCenter(screen.availWidth - 200, screen.left);
+ static WindowTop = roundedCenter(screen.availHeight - 200, screen.top);
+
+ static PropInfo = {
+ sizeProps: ["outerWidth", "outerHeight", "innerWidth", "innerHeight"],
+ positionProps: [
+ "screenX",
+ "screenY",
+ /* readonly */ "mozInnerScreenX",
+ /* readonly */ "mozInnerScreenY",
+ ],
+ readonlyProps: ["mozInnerScreenX", "mozInnerScreenY"],
+ crossAxisMapping: {
+ outerWidth: "outerHeight",
+ outerHeight: "outerWidth",
+ innerWidth: "innerHeight",
+ innerHeight: "innerWidth",
+ screenX: "screenY",
+ screenY: "screenX",
+ mozInnerScreenX: "mozInnerScreenY",
+ mozInnerScreenY: "mozInnerScreenX",
+ },
+ crossBoundsMapping: {
+ outerWidth: "innerWidth",
+ outerHeight: "innerHeight",
+ innerWidth: "outerWidth",
+ innerHeight: "outerHeight",
+ screenX: "mozInnerScreenX",
+ screenY: "mozInnerScreenY",
+ mozInnerScreenX: "screenX",
+ mozInnerScreenY: "screenY",
+ },
+ };
+
+ constructor(
+ aPropertyDeltas,
+ aInstant = false,
+ aMsg = "ResizeMoveTest",
+ aWaitForCompletion = false
+ ) {
+ this.propertyDeltas = aPropertyDeltas;
+ this.instant = aInstant;
+ this.msg = aMsg;
+ this.waitForCompletion = aWaitForCompletion;
+
+ // Allows to ignore positions while testing.
+ this.ignorePositions = false;
+ // Allows to ignore only mozInnerScreenX/Y properties while testing.
+ this.ignoreMozInnerScreen = false;
+ // Allows to skip checking the restored position after testing.
+ this.ignoreRestoredPosition = false;
+
+ if (AppConstants.platform == "linux" && !SpecialPowers.isHeadless) {
+ // We can occasionally start the test while nsWindow reports a wrong
+ // client offset (gdk origin and root_origin are out of sync). This
+ // results in false expectations for the final mozInnerScreenX/Y values.
+ this.ignoreMozInnerScreen = !ResizeMoveTest.hasCleanUpTask;
+
+ let { positionProps } = ResizeMoveTest.PropInfo;
+ let resizeOnlyTest = aPropertyDeltas.every(deltaObj =>
+ positionProps.every(prop => deltaObj[prop] === undefined)
+ );
+
+ let isWayland = gfxInfo.windowProtocol == "wayland";
+ if (resizeOnlyTest && isWayland) {
+ // On Wayland we can't move the window in general. The window also
+ // doesn't necessarily open our specified position.
+ this.ignoreRestoredPosition = true;
+ // We can catch bad screenX/Y at the start of the first test in a
+ // window.
+ this.ignorePositions = !ResizeMoveTest.hasCleanUpTask;
+ }
+ }
+
+ if (!ResizeMoveTest.hasCleanUpTask) {
+ ResizeMoveTest.hasCleanUpTask = true;
+ registerCleanupFunction(ResizeMoveTest.Cleanup);
+ }
+
+ add_task(async () => {
+ let tab = await ResizeMoveTest.GetOrCreateTab();
+ let browsingContext =
+ await ResizeMoveTest.GetOrCreatePopupBrowsingContext();
+ if (!browsingContext) {
+ return;
+ }
+
+ info("=== Running in content. ===");
+ await this.run(browsingContext, `${this.msg} (content)`);
+ await this.restorePopupState(browsingContext);
+
+ info("=== Running in chrome. ===");
+ let popupChrome = browsingContext.topChromeWindow;
+ await this.run(popupChrome.browsingContext, `${this.msg} (chrome)`);
+ await this.restorePopupState(browsingContext);
+
+ info("=== Running in opener. ===");
+ await this.run(tab.linkedBrowser, `${this.msg} (opener)`);
+ await this.restorePopupState(browsingContext);
+ });
+ }
+
+ async run(aBrowsingContext, aMsg) {
+ let testType = this.instant ? "instant" : "fanned out";
+ let msg = `${aMsg} (${testType})`;
+
+ let propInfo = {};
+ for (let k in ResizeMoveTest.PropInfo) {
+ propInfo[k] = ResizeMoveTest.PropInfo[k];
+ }
+ if (this.ignoreMozInnerScreen) {
+ todo(false, `[${aMsg}] Shouldn't ignore mozInnerScreenX/Y.`);
+ propInfo.positionProps = propInfo.positionProps.filter(
+ prop => !["mozInnerScreenX", "mozInnerScreenY"].includes(prop)
+ );
+ }
+ if (this.ignorePositions) {
+ todo(false, `[${aMsg}] Shouldn't ignore position.`);
+ propInfo.positionProps = [];
+ }
+
+ info(`${msg}: ` + JSON.stringify(this.propertyDeltas));
+ await SpecialPowers.spawn(
+ aBrowsingContext,
+ [
+ this.propertyDeltas,
+ this.instant,
+ propInfo,
+ msg,
+ this.waitForCompletion,
+ ],
+ testPropertyDeltas
+ );
+ }
+
+ async restorePopupState(aBrowsingContext) {
+ info("Restore popup state.");
+
+ let { deltaWidth, deltaHeight } = await SpecialPowers.spawn(
+ aBrowsingContext,
+ [],
+ () => {
+ return {
+ deltaWidth: this.content.outerWidth - this.content.innerWidth,
+ deltaHeight: this.content.outerHeight - this.content.innerHeight,
+ };
+ }
+ );
+
+ let chromeWindow = aBrowsingContext.topChromeWindow;
+ let {
+ WindowLeft: left,
+ WindowTop: top,
+ WindowWidth: width,
+ WindowHeight: height,
+ } = ResizeMoveTest;
+
+ chromeWindow.resizeTo(width + deltaWidth, height + deltaHeight);
+ chromeWindow.moveTo(left, top);
+
+ await SpecialPowers.spawn(
+ aBrowsingContext,
+ [left, top, width, height, this.ignoreRestoredPosition],
+ async (aLeft, aTop, aWidth, aHeight, aIgnorePosition) => {
+ let win = this.content.wrappedJSObject;
+
+ info("Waiting for restored size.");
+ await ContentTaskUtils.waitForCondition(
+ () => win.innerWidth == aWidth && win.innerHeight === aHeight,
+ "Waiting for restored size."
+ );
+ is(win.innerWidth, aWidth, "Restored width.");
+ is(win.innerHeight, aHeight, "Restored height.");
+
+ if (!aIgnorePosition) {
+ info("Waiting for restored position.");
+ await ContentTaskUtils.waitForCondition(
+ () => win.screenX == aLeft && win.screenY === aTop,
+ "Waiting for restored position."
+ );
+ is(win.screenX, aLeft, "Restored screenX.");
+ is(win.screenY, aTop, "Restored screenY.");
+ } else {
+ todo(false, "Shouldn't ignore restored position.");
+ }
+ }
+ );
+ }
+
+ static async GetOrCreateTab() {
+ if (ResizeMoveTest.tab) {
+ return ResizeMoveTest.tab;
+ }
+
+ info("Opening tab.");
+ ResizeMoveTest.tab = await BrowserTestUtils.openNewForegroundTab(
+ window.gBrowser,
+ "https://example.net/browser/browser/base/content/test/popups/popup_blocker_a.html"
+ );
+ return ResizeMoveTest.tab;
+ }
+
+ static async GetOrCreatePopupBrowsingContext() {
+ if (ResizeMoveTest.popupBrowsingContext) {
+ if (!ResizeMoveTest.popupBrowsingContext.isActive) {
+ return undefined;
+ }
+ return ResizeMoveTest.popupBrowsingContext;
+ }
+
+ let tab = await ResizeMoveTest.GetOrCreateTab();
+ info("Opening popup.");
+ ResizeMoveTest.popupBrowsingContext = await SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [
+ ResizeMoveTest.WindowWidth,
+ ResizeMoveTest.WindowHeight,
+ ResizeMoveTest.WindowLeft,
+ ResizeMoveTest.WindowTop,
+ ],
+ async (aWidth, aHeight, aLeft, aTop) => {
+ let win = this.content.open(
+ this.content.document.location.href,
+ "_blank",
+ `left=${aLeft},top=${aTop},width=${aWidth},height=${aHeight}`
+ );
+ this.content.popup = win;
+
+ await new Promise(r => (win.onload = r));
+
+ return win.browsingContext;
+ }
+ );
+
+ return ResizeMoveTest.popupBrowsingContext;
+ }
+
+ static async Cleanup() {
+ let browsingContext = ResizeMoveTest.popupBrowsingContext;
+ if (browsingContext) {
+ await SpecialPowers.spawn(browsingContext, [], () => {
+ this.content.close();
+ });
+ delete ResizeMoveTest.popupBrowsingContext;
+ }
+
+ let tab = ResizeMoveTest.tab;
+ if (tab) {
+ await BrowserTestUtils.removeTab(tab);
+ delete ResizeMoveTest.tab;
+ }
+ ResizeMoveTest.hasCleanUpTask = false;
+ }
+}
+
+function chaosRequestLongerTimeout(aDoRequest) {
+ if (aDoRequest && parseInt(Services.env.get("MOZ_CHAOSMODE"), 16)) {
+ requestLongerTimeout(2);
+ }
+}
+
+function createGenericResizeTests(aFirstValue, aSecondValue, aInstant, aMsg) {
+ // Runtime almost doubles in chaos mode on Mac.
+ chaosRequestLongerTimeout(AppConstants.platform == "macosx");
+
+ let { crossBoundsMapping, crossAxisMapping } = ResizeMoveTest.PropInfo;
+
+ for (let prop of ["innerWidth", "outerHeight"]) {
+ // Mixing inner and outer property.
+ for (let secondProp of [prop, crossBoundsMapping[prop]]) {
+ let first = {};
+ first[prop] = aFirstValue;
+ let second = {};
+ second[secondProp] = aSecondValue;
+ new ResizeMoveTest(
+ [first, second],
+ aInstant,
+ `${aMsg} ${prop},${secondProp}`
+ );
+ }
+ }
+
+ for (let prop of ["innerHeight", "outerWidth"]) {
+ let first = {};
+ first[prop] = aFirstValue;
+ let second = {};
+ second[prop] = aSecondValue;
+
+ // Setting property of other axis before/between two changes.
+ let otherProps = [
+ crossAxisMapping[prop],
+ crossAxisMapping[crossBoundsMapping[prop]],
+ ];
+ for (let interferenceProp of otherProps) {
+ let interference = {};
+ interference[interferenceProp] = 20;
+ new ResizeMoveTest(
+ [first, interference, second],
+ aInstant,
+ `${aMsg} ${prop},${interferenceProp},${prop}`
+ );
+ new ResizeMoveTest(
+ [interference, first, second],
+ aInstant,
+ `${aMsg} ${interferenceProp},${prop},${prop}`
+ );
+ }
+ }
+}
+
+function createGenericMoveTests(aInstant, aMsg) {
+ // Runtime almost doubles in chaos mode on Mac.
+ chaosRequestLongerTimeout(AppConstants.platform == "macosx");
+
+ let { crossAxisMapping } = ResizeMoveTest.PropInfo;
+
+ for (let prop of ["screenX", "screenY"]) {
+ for (let [v1, v2, msg] of [
+ [9, 10, `${aMsg}`],
+ [11, 11, `${aMsg} repeat`],
+ [12, 0, `${aMsg} revert`],
+ ]) {
+ let first = {};
+ first[prop] = v1;
+ let second = {};
+ second[prop] = v2;
+ new ResizeMoveTest([first, second], aInstant, `${msg} ${prop},${prop}`);
+
+ let interferenceProp = crossAxisMapping[prop];
+ let interference = {};
+ interference[interferenceProp] = 20;
+ new ResizeMoveTest(
+ [first, interference, second],
+ aInstant,
+ `${aMsg} ${prop},${interferenceProp},${prop}`
+ );
+ new ResizeMoveTest(
+ [interference, first, second],
+ aInstant,
+ `${msg} ${interferenceProp},${prop},${prop}`
+ );
+ }
+ }
+}
diff --git a/browser/base/content/test/popups/popup_blocker.html b/browser/base/content/test/popups/popup_blocker.html
new file mode 100644
index 0000000000..8e2d958059
--- /dev/null
+++ b/browser/base/content/test/popups/popup_blocker.html
@@ -0,0 +1,13 @@
+<!doctype html>
+<html>
+ <head>
+ <meta charset="UTF-8">
+ <title>Page creating two popups</title>
+ </head>
+ <body>
+ <script type="text/javascript">
+ window.open("popup_blocker_a.html", "a");
+ window.open("popup_blocker_b.html", "b");
+ </script>
+ </body>
+</html>
diff --git a/browser/base/content/test/popups/popup_blocker2.html b/browser/base/content/test/popups/popup_blocker2.html
new file mode 100644
index 0000000000..ec880c0821
--- /dev/null
+++ b/browser/base/content/test/popups/popup_blocker2.html
@@ -0,0 +1,10 @@
+<!doctype html>
+<html>
+ <head>
+ <meta charset="UTF-8">
+ <title>Page creating a popup</title>
+ </head>
+ <body>
+ <button id="pop" onclick='window.setTimeout(() => {window.open("popup_blocker_a.html", "a");}, 10);'>Open Popup</button>
+ </body>
+</html>
diff --git a/browser/base/content/test/popups/popup_blocker_10_popups.html b/browser/base/content/test/popups/popup_blocker_10_popups.html
new file mode 100644
index 0000000000..9dc288f472
--- /dev/null
+++ b/browser/base/content/test/popups/popup_blocker_10_popups.html
@@ -0,0 +1,14 @@
+<!doctype html>
+<html>
+ <head>
+ <meta charset="UTF-8">
+ <title>Page creating ten popups</title>
+ </head>
+ <body>
+ <script type="text/javascript">
+ for (let i = 0; i < 10; i++) {
+ window.open("https://example.com");
+ }
+ </script>
+ </body>
+</html>
diff --git a/browser/base/content/test/popups/popup_blocker_a.html b/browser/base/content/test/popups/popup_blocker_a.html
new file mode 100644
index 0000000000..b6f94b5b26
--- /dev/null
+++ b/browser/base/content/test/popups/popup_blocker_a.html
@@ -0,0 +1 @@
+<html><body>a</body></html>
diff --git a/browser/base/content/test/popups/popup_blocker_b.html b/browser/base/content/test/popups/popup_blocker_b.html
new file mode 100644
index 0000000000..954061e2ce
--- /dev/null
+++ b/browser/base/content/test/popups/popup_blocker_b.html
@@ -0,0 +1 @@
+<html><body>b</body></html>
diff --git a/browser/base/content/test/popups/popup_blocker_frame.html b/browser/base/content/test/popups/popup_blocker_frame.html
new file mode 100644
index 0000000000..e29452fb63
--- /dev/null
+++ b/browser/base/content/test/popups/popup_blocker_frame.html
@@ -0,0 +1,27 @@
+<!doctype html>
+<html>
+ <head>
+ <meta charset="UTF-8">
+ <title>Page with iframe that contains page that opens two popups</title>
+ </head>
+ <body>
+ <iframe id="iframe"></iframe>
+ <script type="text/javascript">
+ let params = new URLSearchParams(location.search);
+ let base = params.get('base') || location.href;
+ let frame = document.getElementById('iframe');
+
+ function addPopupOpeningFrame() {
+ frame.src = new URL("popup_blocker.html", base);
+ }
+
+ if (params.get('delayed') !== 'true') {
+ addPopupOpeningFrame();
+ } else {
+ addEventListener("message", () => {
+ addPopupOpeningFrame();
+ }, {once: true});
+ }
+ </script>
+ </body>
+</html>
diff --git a/browser/base/content/test/popups/popup_size.html b/browser/base/content/test/popups/popup_size.html
new file mode 100644
index 0000000000..c214486dee
--- /dev/null
+++ b/browser/base/content/test/popups/popup_size.html
@@ -0,0 +1,16 @@
+<!doctype html>
+<html>
+ <head>
+ <meta charset="UTF-8">
+ <title>Page recording its size</title>
+ </head>
+ <body>
+ <script>
+ var initialSize = { width: innerWidth, height: innerHeight };
+ var loadSize;
+ onload = () => {
+ loadSize = { width: innerWidth, height: innerHeight };
+ };
+ </script>
+ </body>
+</html>
diff --git a/browser/base/content/test/protectionsUI/benignPage.html b/browser/base/content/test/protectionsUI/benignPage.html
new file mode 100644
index 0000000000..0be1cbc1c7
--- /dev/null
+++ b/browser/base/content/test/protectionsUI/benignPage.html
@@ -0,0 +1,18 @@
+<!DOCTYPE HTML>
+<!-- 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/. -->
+<html dir="ltr" xml:lang="en-US" lang="en-US">
+ <head>
+ <meta charset="utf8">
+ </head>
+ <body>
+ <!--TODO: We used to have an iframe here, to double-check that benign-->
+ <!--iframes may be included in pages. However, the cookie restrictions-->
+ <!--project introduced a change that declared blockable content to be-->
+ <!--found on any page that embeds iframes, rendering this unusable for-->
+ <!--our purposes. That's not ideal and we intend to restore this iframe.-->
+ <!--(See bug 1511303 for a more detailed technical explanation.)-->
+ <!--<iframe src="http://not-tracking.example.com/"></iframe>-->
+ </body>
+</html>
diff --git a/browser/base/content/test/protectionsUI/browser.ini b/browser/base/content/test/protectionsUI/browser.ini
new file mode 100644
index 0000000000..acf3f7125b
--- /dev/null
+++ b/browser/base/content/test/protectionsUI/browser.ini
@@ -0,0 +1,63 @@
+[DEFAULT]
+tags = trackingprotection
+support-files =
+ head.js
+ benignPage.html
+ containerPage.html
+ cookiePage.html
+ cookieSetterPage.html
+ cookieServer.sjs
+ emailTrackingPage.html
+ embeddedPage.html
+ trackingAPI.js
+ trackingPage.html
+
+[browser_protectionsUI.js]
+https_first_disabled = true
+skip-if =
+ os == 'linux' && !debug && tsan # Bug 1675107
+[browser_protectionsUI_3.js]
+[browser_protectionsUI_background_tabs.js]
+https_first_disabled = true
+[browser_protectionsUI_categories.js]
+https_first_disabled = true
+[browser_protectionsUI_cookie_banner.js]
+[browser_protectionsUI_cookies_subview.js]
+https_first_disabled = true
+[browser_protectionsUI_cryptominers.js]
+https_first_disabled = true
+[browser_protectionsUI_email_trackers_subview.js]
+[browser_protectionsUI_fetch.js]
+https_first_disabled = true
+support-files =
+ file_protectionsUI_fetch.html
+ file_protectionsUI_fetch.js
+ file_protectionsUI_fetch.js^headers^
+[browser_protectionsUI_fingerprinters.js]
+https_first_disabled = true
+[browser_protectionsUI_icon_state.js]
+https_first_disabled = true
+[browser_protectionsUI_milestones.js]
+[browser_protectionsUI_open_preferences.js]
+https_first_disabled = true
+[browser_protectionsUI_pbmode_exceptions.js]
+https_first_disabled = true
+[browser_protectionsUI_report_breakage.js]
+https_first_disabled = true
+skip-if = debug || asan # Bug 1546797
+[browser_protectionsUI_shield_visibility.js]
+support-files =
+ sandboxed.html
+ sandboxed.html^headers^
+[browser_protectionsUI_socialtracking.js]
+https_first_disabled = true
+[browser_protectionsUI_state.js]
+https_first_disabled = true
+[browser_protectionsUI_state_reset.js]
+https_first_disabled = true
+[browser_protectionsUI_subview_shim.js]
+https_first_disabled = true
+[browser_protectionsUI_telemetry.js]
+https_first_disabled = true
+[browser_protectionsUI_trackers_subview.js]
+https_first_disabled = true
diff --git a/browser/base/content/test/protectionsUI/browser_protectionsUI.js b/browser/base/content/test/protectionsUI/browser_protectionsUI.js
new file mode 100644
index 0000000000..ea8737f28a
--- /dev/null
+++ b/browser/base/content/test/protectionsUI/browser_protectionsUI.js
@@ -0,0 +1,713 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/* Basic UI tests for the protections panel */
+
+"use strict";
+
+const TRACKING_PAGE =
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://tracking.example.org/browser/browser/base/content/test/protectionsUI/trackingPage.html";
+
+ChromeUtils.defineESModuleGetters(this, {
+ ContentBlockingAllowList:
+ "resource://gre/modules/ContentBlockingAllowList.sys.mjs",
+});
+
+const { CustomizableUITestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/CustomizableUITestUtils.sys.mjs"
+);
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ // Set the auto hide timing to 100ms for blocking the test less.
+ ["browser.protections_panel.toast.timeout", 100],
+ // Hide protections cards so as not to trigger more async messaging
+ // when landing on the page.
+ ["browser.contentblocking.report.monitor.enabled", false],
+ ["browser.contentblocking.report.lockwise.enabled", false],
+ ["browser.contentblocking.report.proxy.enabled", false],
+ ["privacy.trackingprotection.enabled", true],
+ ],
+ });
+ let oldCanRecord = Services.telemetry.canRecordExtended;
+ Services.telemetry.canRecordExtended = true;
+ Services.telemetry.clearEvents();
+
+ registerCleanupFunction(() => {
+ Services.telemetry.canRecordExtended = oldCanRecord;
+ Services.telemetry.clearEvents();
+ });
+});
+
+add_task(async function testToggleSwitch() {
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ TRACKING_PAGE
+ );
+
+ await openProtectionsPanel();
+
+ await TestUtils.waitForCondition(() => {
+ return gProtectionsHandler._protectionsPopup.hasAttribute("blocking");
+ });
+
+ let events = Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS
+ ).parent;
+ let buttonEvents = events.filter(
+ e =>
+ e[1] == "security.ui.protectionspopup" &&
+ e[2] == "open" &&
+ e[3] == "protections_popup"
+ );
+ is(buttonEvents.length, 1, "recorded telemetry for opening the popup");
+
+ // Check the visibility of the "Site not working?" link.
+ ok(
+ BrowserTestUtils.is_visible(
+ gProtectionsHandler._protectionsPopupTPSwitchBreakageLink
+ ),
+ "The 'Site not working?' link should be visible."
+ );
+
+ // The 'Site Fixed?' link should be hidden.
+ ok(
+ BrowserTestUtils.is_hidden(
+ gProtectionsHandler._protectionsPopupTPSwitchBreakageFixedLink
+ ),
+ "The 'Site Fixed?' link should be hidden."
+ );
+
+ // Navigate through the 'Site Not Working?' flow and back to the main view,
+ // checking for telemetry on the way.
+ let siteNotWorkingView = document.getElementById(
+ "protections-popup-siteNotWorkingView"
+ );
+ let viewShown = BrowserTestUtils.waitForEvent(
+ siteNotWorkingView,
+ "ViewShown"
+ );
+ gProtectionsHandler._protectionsPopupTPSwitchBreakageLink.click();
+ await viewShown;
+
+ checkClickTelemetry("sitenotworking_link");
+
+ let sendReportButton = document.getElementById(
+ "protections-popup-siteNotWorkingView-sendReport"
+ );
+ let sendReportView = document.getElementById(
+ "protections-popup-sendReportView"
+ );
+ viewShown = BrowserTestUtils.waitForEvent(sendReportView, "ViewShown");
+ sendReportButton.click();
+ await viewShown;
+
+ checkClickTelemetry("send_report_link");
+
+ viewShown = BrowserTestUtils.waitForEvent(siteNotWorkingView, "ViewShown");
+ sendReportView.querySelector(".subviewbutton-back").click();
+ await viewShown;
+
+ let mainView = document.getElementById("protections-popup-mainView");
+
+ viewShown = BrowserTestUtils.waitForEvent(mainView, "ViewShown");
+ siteNotWorkingView.querySelector(".subviewbutton-back").click();
+ await viewShown;
+
+ ok(
+ gProtectionsHandler._protectionsPopupTPSwitch.hasAttribute("enabled"),
+ "TP Switch should be enabled"
+ );
+ let popuphiddenPromise = BrowserTestUtils.waitForEvent(
+ gProtectionsHandler._protectionsPopup,
+ "popuphidden"
+ );
+ let browserLoadedPromise = BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ gProtectionsHandler._protectionsPopupTPSwitch.click();
+
+ // The 'Site not working?' link should be hidden after clicking the TP switch.
+ ok(
+ BrowserTestUtils.is_hidden(
+ gProtectionsHandler._protectionsPopupTPSwitchBreakageLink
+ ),
+ "The 'Site not working?' link should be hidden after TP switch turns to off."
+ );
+ // Same for the 'Site Fixed?' link
+ ok(
+ BrowserTestUtils.is_hidden(
+ gProtectionsHandler._protectionsPopupTPSwitchBreakageFixedLink
+ ),
+ "The 'Site Fixed?' link should be hidden."
+ );
+
+ await popuphiddenPromise;
+ checkClickTelemetry("etp_toggle_off");
+
+ // We need to wait toast's popup shown and popup hidden events. It won't fire
+ // the popup shown event if we open the protections panel while the toast is
+ // opening.
+ let popupShownPromise = BrowserTestUtils.waitForEvent(
+ gProtectionsHandler._protectionsPopup,
+ "popupshown"
+ );
+ popuphiddenPromise = BrowserTestUtils.waitForEvent(
+ gProtectionsHandler._protectionsPopup,
+ "popuphidden"
+ );
+
+ await browserLoadedPromise;
+
+ // Wait until the toast is shown and hidden.
+ await popupShownPromise;
+ await popuphiddenPromise;
+
+ await openProtectionsPanel();
+ ok(
+ !gProtectionsHandler._protectionsPopupTPSwitch.hasAttribute("enabled"),
+ "TP Switch should be disabled"
+ );
+
+ // The 'Site not working?' link should be hidden if the TP is off.
+ ok(
+ BrowserTestUtils.is_hidden(
+ gProtectionsHandler._protectionsPopupTPSwitchBreakageLink
+ ),
+ "The 'Site not working?' link should be hidden if TP is off."
+ );
+
+ // The 'Site Fixed?' link should be shown if TP is off.
+ ok(
+ BrowserTestUtils.is_visible(
+ gProtectionsHandler._protectionsPopupTPSwitchBreakageFixedLink
+ ),
+ "The 'Site Fixed?' link should be visible."
+ );
+
+ // Check telemetry for 'Site Fixed?' link.
+ viewShown = BrowserTestUtils.waitForEvent(sendReportView, "ViewShown");
+ gProtectionsHandler._protectionsPopupTPSwitchBreakageFixedLink.click();
+ await viewShown;
+
+ checkClickTelemetry("sitenotworking_link", "sitefixed");
+
+ viewShown = BrowserTestUtils.waitForEvent(mainView, "ViewShown");
+ sendReportView.querySelector(".subviewbutton-back").click();
+ await viewShown;
+
+ // Click the TP switch again and check the visibility of the 'Site not
+ // Working?'. It should be hidden after toggling the TP switch.
+ browserLoadedPromise = BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ gProtectionsHandler._protectionsPopupTPSwitch.click();
+
+ ok(
+ BrowserTestUtils.is_hidden(
+ gProtectionsHandler._protectionsPopupTPSwitchBreakageLink
+ ),
+ `The 'Site not working?' link should be still hidden after toggling TP
+ switch to on from off.`
+ );
+ ok(
+ BrowserTestUtils.is_hidden(
+ gProtectionsHandler._protectionsPopupTPSwitchBreakageFixedLink
+ ),
+ "The 'Site Fixed?' link should be hidden."
+ );
+
+ await browserLoadedPromise;
+ checkClickTelemetry("etp_toggle_on");
+
+ ContentBlockingAllowList.remove(tab.linkedBrowser);
+ BrowserTestUtils.removeTab(tab);
+});
+
+/**
+ * A test for the protection settings button.
+ */
+add_task(async function testSettingsButton() {
+ // Open a tab and its protection panel.
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "https://example.com"
+ );
+ await openProtectionsPanel();
+
+ let popuphiddenPromise = BrowserTestUtils.waitForEvent(
+ gProtectionsHandler._protectionsPopup,
+ "popuphidden"
+ );
+ let newTabPromise = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ "about:preferences#privacy"
+ );
+ gProtectionsHandler._protectionsPopupSettingsButton.click();
+
+ // The protection popup should be hidden after clicking settings button.
+ await popuphiddenPromise;
+ // Wait until the about:preferences has been opened correctly.
+ let newTab = await newTabPromise;
+
+ ok(true, "about:preferences has been opened successfully");
+ checkClickTelemetry("settings");
+
+ BrowserTestUtils.removeTab(newTab);
+ BrowserTestUtils.removeTab(tab);
+});
+
+/**
+ * A test for ensuring Tracking Protection label is shown correctly
+ */
+add_task(async function testTrackingProtectionLabel() {
+ // Open a tab.
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "https://example.com"
+ );
+ await openProtectionsPanel();
+
+ let trackingProtectionLabel = document.getElementById(
+ "protections-popup-footer-protection-type-label"
+ );
+
+ is(
+ trackingProtectionLabel.textContent,
+ "Custom",
+ "The label is correctly set to Custom."
+ );
+ await closeProtectionsPanel();
+
+ Services.prefs.setStringPref("browser.contentblocking.category", "standard");
+ await openProtectionsPanel();
+
+ is(
+ trackingProtectionLabel.textContent,
+ "Standard",
+ "The label is correctly set to Standard."
+ );
+ await closeProtectionsPanel();
+
+ Services.prefs.setStringPref("browser.contentblocking.category", "strict");
+ await openProtectionsPanel();
+ is(
+ trackingProtectionLabel.textContent,
+ "Strict",
+ "The label is correctly set to Strict."
+ );
+
+ await closeProtectionsPanel();
+ Services.prefs.setStringPref("browser.contentblocking.category", "custom");
+ BrowserTestUtils.removeTab(tab);
+});
+
+/**
+ * A test for the 'Show Full Report' button in the footer section.
+ */
+add_task(async function testShowFullReportButton() {
+ // Open a tab and its protection panel.
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "https://example.com"
+ );
+ await openProtectionsPanel();
+
+ let popuphiddenPromise = BrowserTestUtils.waitForEvent(
+ gProtectionsHandler._protectionsPopup,
+ "popuphidden"
+ );
+ let newTabPromise = waitForAboutProtectionsTab();
+ let showFullReportButton = document.getElementById(
+ "protections-popup-show-report-button"
+ );
+
+ showFullReportButton.click();
+
+ // The protection popup should be hidden after clicking the link.
+ await popuphiddenPromise;
+ // Wait until the 'about:protections' has been opened correctly.
+ let newTab = await newTabPromise;
+
+ ok(true, "about:protections has been opened successfully");
+
+ checkClickTelemetry("full_report");
+
+ BrowserTestUtils.removeTab(newTab);
+ BrowserTestUtils.removeTab(tab);
+});
+
+/**
+ * A test for ensuring the mini panel is working correctly
+ */
+add_task(async function testMiniPanel() {
+ // Open a tab.
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "https://example.com"
+ );
+
+ // Open the mini panel.
+ await openProtectionsPanel(true);
+ let popuphiddenPromise = BrowserTestUtils.waitForEvent(
+ gProtectionsHandler._protectionsPopup,
+ "popuphidden"
+ );
+
+ // Check that only the header is displayed.
+ let mainView = document.getElementById("protections-popup-mainView");
+ for (let item of mainView.childNodes) {
+ if (item.id !== "protections-popup-mainView-panel-header-section") {
+ ok(
+ !BrowserTestUtils.is_visible(item),
+ `The section '${item.id}' is hidden in the toast.`
+ );
+ } else {
+ ok(
+ BrowserTestUtils.is_visible(item),
+ "The panel header is displayed as the content of the toast."
+ );
+ }
+ }
+
+ // Wait until the auto hide is happening.
+ await popuphiddenPromise;
+
+ ok(true, "The mini panel hides automatically.");
+
+ BrowserTestUtils.removeTab(tab);
+});
+
+/**
+ * A test for the toggle switch flow
+ */
+add_task(async function testToggleSwitchFlow() {
+ // Open a tab.
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "https://example.com"
+ );
+ await openProtectionsPanel();
+
+ let popuphiddenPromise = BrowserTestUtils.waitForEvent(
+ gProtectionsHandler._protectionsPopup,
+ "popuphidden"
+ );
+ let popupShownPromise = BrowserTestUtils.waitForEvent(
+ gProtectionsHandler._protectionsPopup,
+ "popupshown"
+ );
+ let browserLoadedPromise = BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+
+ // Click the TP switch, from On -> Off.
+ gProtectionsHandler._protectionsPopupTPSwitch.click();
+
+ // Check that the icon state has been changed.
+ ok(
+ gProtectionsHandler.iconBox.hasAttribute("hasException"),
+ "The tracking protection icon state has been changed to disabled."
+ );
+
+ // The panel should be closed and the mini panel will show up after refresh.
+ await popuphiddenPromise;
+ await browserLoadedPromise;
+ await popupShownPromise;
+
+ ok(
+ gProtectionsHandler._protectionsPopup.hasAttribute("toast"),
+ "The protections popup should have the 'toast' attribute."
+ );
+
+ // Click on the mini panel and making sure the protection popup shows up.
+ popupShownPromise = BrowserTestUtils.waitForEvent(
+ gProtectionsHandler._protectionsPopup,
+ "popupshown"
+ );
+ popuphiddenPromise = BrowserTestUtils.waitForEvent(
+ gProtectionsHandler._protectionsPopup,
+ "popuphidden"
+ );
+ document.getElementById("protections-popup-mainView-panel-header").click();
+ await popuphiddenPromise;
+ await popupShownPromise;
+
+ ok(
+ !gProtectionsHandler._protectionsPopup.hasAttribute("toast"),
+ "The 'toast' attribute should be cleared on the protections popup."
+ );
+
+ // Click the TP switch again, from Off -> On.
+ popuphiddenPromise = BrowserTestUtils.waitForEvent(
+ gProtectionsHandler._protectionsPopup,
+ "popuphidden"
+ );
+ popupShownPromise = BrowserTestUtils.waitForEvent(
+ gProtectionsHandler._protectionsPopup,
+ "popupshown"
+ );
+ browserLoadedPromise = BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ gProtectionsHandler._protectionsPopupTPSwitch.click();
+
+ // Check that the icon state has been changed.
+ ok(
+ !gProtectionsHandler.iconBox.hasAttribute("hasException"),
+ "The tracking protection icon state has been changed to enabled."
+ );
+
+ // Protections popup hidden -> Page refresh -> Mini panel shows up.
+ await popuphiddenPromise;
+ popuphiddenPromise = BrowserTestUtils.waitForEvent(
+ gProtectionsHandler._protectionsPopup,
+ "popuphidden"
+ );
+ await browserLoadedPromise;
+ await popupShownPromise;
+
+ ok(
+ gProtectionsHandler._protectionsPopup.hasAttribute("toast"),
+ "The protections popup should have the 'toast' attribute."
+ );
+
+ // Wait until the auto hide is happening.
+ await popuphiddenPromise;
+
+ // Clean up the TP state.
+ ContentBlockingAllowList.remove(tab.linkedBrowser);
+ BrowserTestUtils.removeTab(tab);
+});
+
+/**
+ * A test for ensuring the tracking protection icon will show a correct
+ * icon according to the TP enabling state.
+ */
+add_task(async function testTrackingProtectionIcon() {
+ // Open a tab and its protection panel.
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "https://example.com"
+ );
+
+ let TPIcon = document.getElementById("tracking-protection-icon");
+ // Check the icon url. It will show a shield icon if TP is enabled.
+ is(
+ gBrowser.ownerGlobal
+ .getComputedStyle(TPIcon)
+ .getPropertyValue("list-style-image"),
+ `url("chrome://browser/skin/tracking-protection.svg")`,
+ "The tracking protection icon shows a shield icon."
+ );
+
+ // Disable the tracking protection.
+ let browserLoadedPromise = BrowserTestUtils.browserLoaded(
+ tab.linkedBrowser,
+ false,
+ "https://example.com/"
+ );
+ gProtectionsHandler.disableForCurrentPage();
+ await browserLoadedPromise;
+
+ // Check that the tracking protection icon should show a strike-through shield
+ // icon after page is reloaded.
+ is(
+ gBrowser.ownerGlobal
+ .getComputedStyle(TPIcon)
+ .getPropertyValue("list-style-image"),
+ `url("chrome://browser/skin/tracking-protection-disabled.svg")`,
+ "The tracking protection icon shows a strike through shield icon."
+ );
+
+ // Clean up the TP state.
+ ContentBlockingAllowList.remove(tab.linkedBrowser);
+ BrowserTestUtils.removeTab(tab);
+});
+
+/**
+ * A test for ensuring the number of blocked trackers is displayed properly.
+ */
+add_task(async function testNumberOfBlockedTrackers() {
+ // First, clear the tracking database.
+ await TrackingDBService.clearAll();
+
+ // Open a tab.
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "https://example.com"
+ );
+ await openProtectionsPanel();
+
+ let trackerCounterBox = document.getElementById(
+ "protections-popup-trackers-blocked-counter-box"
+ );
+ let trackerCounterDesc = document.getElementById(
+ "protections-popup-trackers-blocked-counter-description"
+ );
+
+ // Check that whether the counter is not shown if the number of blocked
+ // trackers is zero.
+ ok(
+ BrowserTestUtils.is_hidden(trackerCounterBox),
+ "The blocked tracker counter is hidden if there is no blocked tracker."
+ );
+
+ await closeProtectionsPanel();
+
+ // Add one tracker into the database and check that the tracker counter is
+ // properly shown.
+ await addTrackerDataIntoDB(1);
+
+ // A promise for waiting the `showing` attributes has been set to the counter
+ // box. This means the database access is finished.
+ let counterShownPromise = BrowserTestUtils.waitForAttribute(
+ "showing",
+ trackerCounterBox
+ );
+
+ await openProtectionsPanel();
+ await counterShownPromise;
+
+ // Check that the number of blocked trackers is shown.
+ ok(
+ BrowserTestUtils.is_visible(trackerCounterBox),
+ "The blocked tracker counter is shown if there is one blocked tracker."
+ );
+ is(
+ trackerCounterDesc.textContent,
+ "1 Blocked",
+ "The blocked tracker counter is correct."
+ );
+
+ await closeProtectionsPanel();
+ await TrackingDBService.clearAll();
+
+ // Add trackers into the database and check that the tracker counter is
+ // properly shown as well as whether the pre-fetch is triggered by the
+ // keyboard navigation.
+ await addTrackerDataIntoDB(10);
+
+ // We cannot wait for the change of "showing" attribute here since this
+ // attribute will only be set if the previous counter is zero. Instead, we
+ // wait for the change of the text content of the counter.
+ let updateCounterPromise = new Promise(resolve => {
+ let mut = new MutationObserver(mutations => {
+ resolve();
+ mut.disconnect();
+ });
+
+ mut.observe(trackerCounterDesc, {
+ childList: true,
+ });
+ });
+
+ await openProtectionsPanelWithKeyNav();
+ await updateCounterPromise;
+
+ // Check that the number of blocked trackers is shown.
+ ok(
+ BrowserTestUtils.is_visible(trackerCounterBox),
+ "The blocked tracker counter is shown if there are more than one blocked tracker."
+ );
+ is(
+ trackerCounterDesc.textContent,
+ "10 Blocked",
+ "The blocked tracker counter is correct."
+ );
+
+ await closeProtectionsPanel();
+ await TrackingDBService.clearAll();
+ BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function testSubViewTelemetry() {
+ let items = [
+ ["protections-popup-category-trackers", "trackers"],
+ ["protections-popup-category-socialblock", "social"],
+ ["protections-popup-category-cookies", "cookies"],
+ ["protections-popup-category-cryptominers", "cryptominers"],
+ ["protections-popup-category-fingerprinters", "fingerprinters"],
+ ].map(item => [document.getElementById(item[0]), item[1]]);
+
+ for (let [item, telemetryId] of items) {
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ await BrowserTestUtils.withNewTab("http://www.example.com", async () => {
+ await openProtectionsPanel();
+
+ item.classList.remove("notFound"); // Force visible for test
+ gProtectionsHandler._categoryItemOrderInvalidated = true;
+ gProtectionsHandler.reorderCategoryItems();
+
+ let viewShownEvent = BrowserTestUtils.waitForEvent(
+ gProtectionsHandler._protectionsPopupMultiView,
+ "ViewShown"
+ );
+ item.click();
+ let panelView = (await viewShownEvent).originalTarget;
+ checkClickTelemetry(telemetryId);
+ let prefsTabPromise = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ "about:preferences#privacy"
+ );
+ panelView.querySelector(".panel-subview-footer-button").click();
+ let prefsTab = await prefsTabPromise;
+ BrowserTestUtils.removeTab(prefsTab);
+ checkClickTelemetry("subview_settings", telemetryId);
+ });
+ }
+});
+
+/**
+ * A test to make sure the TP state won't apply incorrectly if we quickly switch
+ * tab after toggling the TP switch.
+ */
+add_task(async function testQuickSwitchTabAfterTogglingTPSwitch() {
+ const FIRST_TEST_SITE = "https://example.com/";
+ const SECOND_TEST_SITE = "https://example.org/";
+
+ // First, clear the tracking database.
+ await TrackingDBService.clearAll();
+
+ // Open two tabs with different origins.
+ let tabOne = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ FIRST_TEST_SITE
+ );
+ let tabTwo = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ SECOND_TEST_SITE
+ );
+
+ // Open the protection panel of the second tab.
+ await openProtectionsPanel();
+
+ // A promise to check the reload happens on the second tab.
+ let browserLoadedPromise = BrowserTestUtils.browserLoaded(
+ tabTwo.linkedBrowser,
+ false,
+ SECOND_TEST_SITE
+ );
+
+ // Toggle the TP state and switch tab without waiting it to be finished.
+ gProtectionsHandler._protectionsPopupTPSwitch.click();
+ gBrowser.selectedTab = tabOne;
+
+ // Wait for the second tab to be reloaded.
+ await browserLoadedPromise;
+
+ // Check that the first tab is still with ETP enabled.
+ ok(
+ !ContentBlockingAllowList.includes(gBrowser.selectedBrowser),
+ "The ETP state of the first tab is still enabled."
+ );
+
+ // Check the ETP is disabled on the second origin.
+ ok(
+ ContentBlockingAllowList.includes(tabTwo.linkedBrowser),
+ "The ETP state of the second tab has been changed to disabled."
+ );
+
+ // Clean up the state of the allow list for the second tab.
+ ContentBlockingAllowList.remove(tabTwo.linkedBrowser);
+
+ BrowserTestUtils.removeTab(tabOne);
+ BrowserTestUtils.removeTab(tabTwo);
+
+ // Finally, clear the tracking database.
+ await TrackingDBService.clearAll();
+});
diff --git a/browser/base/content/test/protectionsUI/browser_protectionsUI_3.js b/browser/base/content/test/protectionsUI/browser_protectionsUI_3.js
new file mode 100644
index 0000000000..353c6a099b
--- /dev/null
+++ b/browser/base/content/test/protectionsUI/browser_protectionsUI_3.js
@@ -0,0 +1,224 @@
+/*
+ * Test that the Tracking Protection is correctly enabled / disabled
+ * in both normal and private windows given all possible states of the prefs:
+ * privacy.trackingprotection.enabled
+ * privacy.trackingprotection.pbmode.enabled
+ * privacy.trackingprotection.emailtracking.enabled
+ * privacy.trackingprotection.emailtracking.pbmode.enabled
+ * See also Bug 1178985, Bug 1819662.
+ */
+
+const PREF = "privacy.trackingprotection.enabled";
+const PB_PREF = "privacy.trackingprotection.pbmode.enabled";
+const EMAIL_PREF = "privacy.trackingprotection.emailtracking.enabled";
+const EMAIL_PB_PREF = "privacy.trackingprotection.emailtracking.pbmode.enabled";
+
+registerCleanupFunction(function () {
+ Services.prefs.clearUserPref(PREF);
+ Services.prefs.clearUserPref(PB_PREF);
+ Services.prefs.clearUserPref(EMAIL_PREF);
+ Services.prefs.clearUserPref(EMAIL_PB_PREF);
+});
+
+add_task(async function testNormalBrowsing() {
+ let { TrackingProtection } =
+ gBrowser.ownerGlobal.gProtectionsHandler.blockers;
+ ok(
+ TrackingProtection,
+ "Normal window gProtectionsHandler should have TrackingProtection blocker."
+ );
+
+ Services.prefs.setBoolPref(PREF, true);
+ Services.prefs.setBoolPref(PB_PREF, false);
+ Services.prefs.setBoolPref(EMAIL_PREF, false);
+ Services.prefs.setBoolPref(EMAIL_PB_PREF, false);
+ ok(
+ TrackingProtection.enabled,
+ "TP is enabled (ENABLED=true,PB=false,EmailEnabled=false,EmailPB=false)"
+ );
+ Services.prefs.setBoolPref(PB_PREF, true);
+ ok(
+ TrackingProtection.enabled,
+ "TP is enabled (ENABLED=true,PB=true,EmailEnabled=false,EmailPB=false)"
+ );
+ Services.prefs.setBoolPref(EMAIL_PREF, true);
+ ok(
+ TrackingProtection.enabled,
+ "TP is enabled (ENABLED=true,PB=true,EmailEnabled=true,EmailPB=false)"
+ );
+ Services.prefs.setBoolPref(EMAIL_PB_PREF, true);
+ ok(
+ TrackingProtection.enabled,
+ "TP is enabled (ENABLED=true,PB=true,EmailEnabled=true,EmailPB=true)"
+ );
+ Services.prefs.setBoolPref(EMAIL_PREF, false);
+ ok(
+ TrackingProtection.enabled,
+ "TP is enabled (ENABLED=true,PB=true,EmailEnabled=false,EmailPB=true)"
+ );
+ Services.prefs.setBoolPref(PB_PREF, false);
+ ok(
+ TrackingProtection.enabled,
+ "TP is enabled (ENABLED=true,PB=false,EmailEnabled=false,EmailPB=true)"
+ );
+ Services.prefs.setBoolPref(EMAIL_PREF, true);
+ Services.prefs.setBoolPref(EMAIL_PB_PREF, false);
+ ok(
+ TrackingProtection.enabled,
+ "TP is enabled (ENABLED=true,PB=false,EmailEnabled=true,EmailPB=false)"
+ );
+ Services.prefs.setBoolPref(EMAIL_PB_PREF, true);
+ ok(
+ TrackingProtection.enabled,
+ "TP is enabled (ENABLED=true,PB=false,EmailEnabled=true,EmailPB=true)"
+ );
+
+ Services.prefs.setBoolPref(PREF, false);
+ Services.prefs.setBoolPref(PB_PREF, false);
+ Services.prefs.setBoolPref(EMAIL_PREF, false);
+ Services.prefs.setBoolPref(EMAIL_PB_PREF, false);
+ ok(
+ !TrackingProtection.enabled,
+ "TP is disabled (ENABLED=false,PB=false,EmailEnabled=false,EmailPB=false)"
+ );
+ Services.prefs.setBoolPref(PB_PREF, true);
+ ok(
+ !TrackingProtection.enabled,
+ "TP is disabled (ENABLED=false,PB=true,EmailEnabled=false,EmailPB=false)"
+ );
+ Services.prefs.setBoolPref(EMAIL_PREF, true);
+ ok(
+ TrackingProtection.enabled,
+ "TP is enabled (ENABLED=false,PB=true,EmailEnabled=true,EmailPB=false)"
+ );
+ Services.prefs.setBoolPref(EMAIL_PB_PREF, true);
+ ok(
+ TrackingProtection.enabled,
+ "TP is enabled (ENABLED=false,PB=true,EmailEnabled=true,EmailPB=true)"
+ );
+ Services.prefs.setBoolPref(EMAIL_PREF, false);
+ ok(
+ !TrackingProtection.enabled,
+ "TP is disabled (ENABLED=false,PB=true,EmailEnabled=false,EmailPB=true)"
+ );
+ Services.prefs.setBoolPref(PB_PREF, false);
+ ok(
+ !TrackingProtection.enabled,
+ "TP is disabled (ENABLED=false,PB=false,EmailEnabled=false,EmailPB=true)"
+ );
+ Services.prefs.setBoolPref(EMAIL_PREF, true);
+ Services.prefs.setBoolPref(EMAIL_PB_PREF, false);
+ ok(
+ TrackingProtection.enabled,
+ "TP is enabled (ENABLED=false,PB=false,EmailEnabled=true,EmailPB=false)"
+ );
+ Services.prefs.setBoolPref(EMAIL_PB_PREF, true);
+ ok(
+ TrackingProtection.enabled,
+ "TP is enabled (ENABLED=false,PB=false,EmailEnabled=true,EmailPB=true)"
+ );
+});
+
+add_task(async function testPrivateBrowsing() {
+ let privateWin = await BrowserTestUtils.openNewBrowserWindow({
+ private: true,
+ });
+ let { TrackingProtection } =
+ privateWin.gBrowser.ownerGlobal.gProtectionsHandler.blockers;
+ ok(
+ TrackingProtection,
+ "Private window gProtectionsHandler should have TrackingProtection blocker."
+ );
+
+ Services.prefs.setBoolPref(PREF, true);
+ Services.prefs.setBoolPref(PB_PREF, false);
+ Services.prefs.setBoolPref(EMAIL_PREF, false);
+ Services.prefs.setBoolPref(EMAIL_PB_PREF, false);
+ ok(
+ TrackingProtection.enabled,
+ "TP is enabled (ENABLED=true,PB=false,EmailEnabled=false,EmailPB=false)"
+ );
+ Services.prefs.setBoolPref(PB_PREF, true);
+ ok(
+ TrackingProtection.enabled,
+ "TP is enabled (ENABLED=true,PB=true,EmailEnabled=false,EmailPB=false)"
+ );
+ Services.prefs.setBoolPref(EMAIL_PREF, true);
+ ok(
+ TrackingProtection.enabled,
+ "TP is enabled (ENABLED=true,PB=true,EmailEnabled=true,EmailPB=false)"
+ );
+ Services.prefs.setBoolPref(EMAIL_PB_PREF, true);
+ ok(
+ TrackingProtection.enabled,
+ "TP is enabled (ENABLED=true,PB=true,EmailEnabled=true,EmailPB=true)"
+ );
+ Services.prefs.setBoolPref(EMAIL_PREF, false);
+ ok(
+ TrackingProtection.enabled,
+ "TP is enabled (ENABLED=true,PB=true,EmailEnabled=false,EmailPB=true)"
+ );
+ Services.prefs.setBoolPref(PB_PREF, false);
+ ok(
+ TrackingProtection.enabled,
+ "TP is enabled (ENABLED=true,PB=false,EmailEnabled=false,EmailPB=true)"
+ );
+ Services.prefs.setBoolPref(EMAIL_PREF, true);
+ Services.prefs.setBoolPref(EMAIL_PB_PREF, false);
+ ok(
+ TrackingProtection.enabled,
+ "TP is enabled (ENABLED=true,PB=false,EmailEnabled=true,EmailPB=false)"
+ );
+ Services.prefs.setBoolPref(EMAIL_PB_PREF, true);
+ ok(
+ TrackingProtection.enabled,
+ "TP is enabled (ENABLED=true,PB=false,EmailEnabled=true,EmailPB=true)"
+ );
+
+ Services.prefs.setBoolPref(PREF, false);
+ Services.prefs.setBoolPref(PB_PREF, false);
+ Services.prefs.setBoolPref(EMAIL_PREF, false);
+ Services.prefs.setBoolPref(EMAIL_PB_PREF, false);
+ ok(
+ !TrackingProtection.enabled,
+ "TP is disabled (ENABLED=false,PB=false,EmailEnabled=false,EmailPB=false)"
+ );
+ Services.prefs.setBoolPref(PB_PREF, true);
+ ok(
+ TrackingProtection.enabled,
+ "TP is enabled (ENABLED=false,PB=true,EmailEnabled=false,EmailPB=false)"
+ );
+ Services.prefs.setBoolPref(EMAIL_PREF, true);
+ ok(
+ TrackingProtection.enabled,
+ "TP is enabled (ENABLED=false,PB=true,EmailEnabled=true,EmailPB=false)"
+ );
+ Services.prefs.setBoolPref(EMAIL_PB_PREF, true);
+ ok(
+ TrackingProtection.enabled,
+ "TP is enabled (ENABLED=false,PB=true,EmailEnabled=true,EmailPB=true)"
+ );
+ Services.prefs.setBoolPref(EMAIL_PREF, false);
+ ok(
+ TrackingProtection.enabled,
+ "TP is enabled (ENABLED=false,PB=true,EmailEnabled=false,EmailPB=true)"
+ );
+ Services.prefs.setBoolPref(PB_PREF, false);
+ ok(
+ TrackingProtection.enabled,
+ "TP is enabled (ENABLED=false,PB=false,EmailEnabled=false,EmailPB=true)"
+ );
+ Services.prefs.setBoolPref(EMAIL_PREF, true);
+ Services.prefs.setBoolPref(EMAIL_PB_PREF, false);
+ ok(
+ TrackingProtection.enabled,
+ "TP is enabled (ENABLED=false,PB=false,EmailEnabled=true,EmailPB=false)"
+ );
+ Services.prefs.setBoolPref(EMAIL_PB_PREF, true);
+ ok(
+ TrackingProtection.enabled,
+ "TP is enabled (ENABLED=false,PB=false,EmailEnabled=true,EmailPB=true)"
+ );
+
+ privateWin.close();
+});
diff --git a/browser/base/content/test/protectionsUI/browser_protectionsUI_background_tabs.js b/browser/base/content/test/protectionsUI/browser_protectionsUI_background_tabs.js
new file mode 100644
index 0000000000..24a83c9588
--- /dev/null
+++ b/browser/base/content/test/protectionsUI/browser_protectionsUI_background_tabs.js
@@ -0,0 +1,74 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TRACKING_PAGE =
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://tracking.example.org/browser/browser/base/content/test/protectionsUI/trackingPage.html";
+const BENIGN_PAGE =
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://tracking.example.org/browser/browser/base/content/test/protectionsUI/benignPage.html";
+
+const TP_PREF = "privacy.trackingprotection.enabled";
+
+add_task(async function testBackgroundTabs() {
+ info(
+ "Testing receiving and storing content blocking events in non-selected tabs."
+ );
+
+ await SpecialPowers.pushPrefEnv({
+ set: [[TP_PREF, true]],
+ });
+ await UrlClassifierTestUtils.addTestTrackers();
+
+ registerCleanupFunction(() => {
+ UrlClassifierTestUtils.cleanupTestTrackers();
+ });
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, BENIGN_PAGE);
+
+ let backgroundTab = BrowserTestUtils.addTab(gBrowser);
+ let browser = backgroundTab.linkedBrowser;
+ let hasContentBlockingEvent = TestUtils.waitForCondition(
+ () => browser.getContentBlockingEvents() != 0
+ );
+ await promiseTabLoadEvent(backgroundTab, TRACKING_PAGE);
+ await hasContentBlockingEvent;
+
+ is(
+ browser.getContentBlockingEvents(),
+ Ci.nsIWebProgressListener.STATE_BLOCKED_TRACKING_CONTENT,
+ "Background tab has the correct content blocking event."
+ );
+
+ is(
+ tab.linkedBrowser.getContentBlockingEvents(),
+ 0,
+ "Foreground tab has the correct content blocking event."
+ );
+
+ ok(
+ !gProtectionsHandler.iconBox.hasAttribute("active"),
+ "shield is not active"
+ );
+
+ await BrowserTestUtils.switchTab(gBrowser, backgroundTab);
+
+ is(
+ browser.getContentBlockingEvents(),
+ Ci.nsIWebProgressListener.STATE_BLOCKED_TRACKING_CONTENT,
+ "Background tab still has the correct content blocking event."
+ );
+
+ is(
+ tab.linkedBrowser.getContentBlockingEvents(),
+ 0,
+ "Foreground tab still has the correct content blocking event."
+ );
+
+ ok(gProtectionsHandler.iconBox.hasAttribute("active"), "shield is active");
+
+ gBrowser.removeTab(backgroundTab);
+ gBrowser.removeTab(tab);
+});
diff --git a/browser/base/content/test/protectionsUI/browser_protectionsUI_categories.js b/browser/base/content/test/protectionsUI/browser_protectionsUI_categories.js
new file mode 100644
index 0000000000..b647b44d64
--- /dev/null
+++ b/browser/base/content/test/protectionsUI/browser_protectionsUI_categories.js
@@ -0,0 +1,300 @@
+const CAT_PREF = "browser.contentblocking.category";
+const TP_PREF = "privacy.trackingprotection.enabled";
+const TP_PB_PREF = "privacy.trackingprotection.pbmode.enabled";
+const TPC_PREF = "network.cookie.cookieBehavior";
+const CM_PREF = "privacy.trackingprotection.cryptomining.enabled";
+const FP_PREF = "privacy.trackingprotection.fingerprinting.enabled";
+const ST_PREF = "privacy.trackingprotection.socialtracking.enabled";
+const STC_PREF = "privacy.socialtracking.block_cookies.enabled";
+
+const { CustomizableUITestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/CustomizableUITestUtils.sys.mjs"
+);
+
+registerCleanupFunction(function () {
+ Services.prefs.clearUserPref(CAT_PREF);
+ Services.prefs.clearUserPref(TP_PREF);
+ Services.prefs.clearUserPref(TP_PB_PREF);
+ Services.prefs.clearUserPref(TPC_PREF);
+ Services.prefs.clearUserPref(CM_PREF);
+ Services.prefs.clearUserPref(FP_PREF);
+ Services.prefs.clearUserPref(ST_PREF);
+ Services.prefs.clearUserPref(STC_PREF);
+});
+
+add_task(async function testCookieCategoryLabel() {
+ await BrowserTestUtils.withNewTab(
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://www.example.com",
+ async function () {
+ // Ensure the category nodes exist.
+ await openProtectionsPanel();
+ await closeProtectionsPanel();
+ let categoryItem = document.getElementById(
+ "protections-popup-category-cookies"
+ );
+ let categoryLabel = document.getElementById(
+ "protections-popup-cookies-category-label"
+ );
+
+ Services.prefs.setIntPref(TPC_PREF, Ci.nsICookieService.BEHAVIOR_ACCEPT);
+ await TestUtils.waitForCondition(
+ () => !categoryItem.classList.contains("blocked"),
+ "The category label has updated correctly"
+ );
+ ok(!categoryItem.classList.contains("blocked"));
+
+ Services.prefs.setIntPref(TPC_PREF, Ci.nsICookieService.BEHAVIOR_REJECT);
+ await TestUtils.waitForCondition(
+ () => categoryItem.classList.contains("blocked"),
+ "The category label has updated correctly"
+ );
+ ok(categoryItem.classList.contains("blocked"));
+ await TestUtils.waitForCondition(
+ () =>
+ categoryLabel.textContent ==
+ gNavigatorBundle.getString(
+ "contentBlocking.cookies.blockingAll2.label"
+ ),
+ "The category label has updated correctly"
+ );
+ ok(
+ categoryLabel.textContent ==
+ gNavigatorBundle.getString(
+ "contentBlocking.cookies.blockingAll2.label"
+ )
+ );
+
+ Services.prefs.setIntPref(
+ TPC_PREF,
+ Ci.nsICookieService.BEHAVIOR_REJECT_FOREIGN
+ );
+ await TestUtils.waitForCondition(
+ () => categoryItem.classList.contains("blocked"),
+ "The category label has updated correctly"
+ );
+ ok(categoryItem.classList.contains("blocked"));
+ await TestUtils.waitForCondition(
+ () =>
+ categoryLabel.textContent ==
+ gNavigatorBundle.getString(
+ "contentBlocking.cookies.blocking3rdParty2.label"
+ ),
+ "The category label has updated correctly"
+ );
+ ok(
+ categoryLabel.textContent ==
+ gNavigatorBundle.getString(
+ "contentBlocking.cookies.blocking3rdParty2.label"
+ )
+ );
+
+ Services.prefs.setIntPref(
+ TPC_PREF,
+ Ci.nsICookieService.BEHAVIOR_REJECT_TRACKER
+ );
+ await TestUtils.waitForCondition(
+ () => categoryItem.classList.contains("blocked"),
+ "The category label has updated correctly"
+ );
+ ok(categoryItem.classList.contains("blocked"));
+ await TestUtils.waitForCondition(
+ () =>
+ categoryLabel.textContent ==
+ gNavigatorBundle.getString(
+ "contentBlocking.cookies.blockingTrackers3.label"
+ ),
+ "The category label has updated correctly"
+ );
+ ok(
+ categoryLabel.textContent ==
+ gNavigatorBundle.getString(
+ "contentBlocking.cookies.blockingTrackers3.label"
+ )
+ );
+
+ Services.prefs.setIntPref(
+ TPC_PREF,
+ Ci.nsICookieService.BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN
+ );
+ await TestUtils.waitForCondition(
+ () => categoryItem.classList.contains("blocked"),
+ "The category label has updated correctly"
+ );
+ ok(categoryItem.classList.contains("blocked"));
+ await TestUtils.waitForCondition(
+ () =>
+ categoryLabel.textContent ==
+ gNavigatorBundle.getString(
+ "contentBlocking.cookies.blockingTrackers3.label"
+ ),
+ "The category label has updated correctly"
+ );
+ ok(
+ categoryLabel.textContent ==
+ gNavigatorBundle.getString(
+ "contentBlocking.cookies.blockingTrackers3.label"
+ )
+ );
+
+ Services.prefs.setIntPref(
+ TPC_PREF,
+ Ci.nsICookieService.BEHAVIOR_LIMIT_FOREIGN
+ );
+ await TestUtils.waitForCondition(
+ () => !categoryItem.classList.contains("blocked"),
+ "The category label has updated correctly"
+ );
+ ok(!categoryItem.classList.contains("blocked"));
+ }
+ );
+});
+
+let categoryEnabledPrefs = [TP_PREF, STC_PREF, TPC_PREF, CM_PREF, FP_PREF];
+
+let detectedStateFlags = [
+ Ci.nsIWebProgressListener.STATE_BLOCKED_TRACKING_CONTENT,
+ Ci.nsIWebProgressListener.STATE_BLOCKED_SOCIALTRACKING_CONTENT,
+ Ci.nsIWebProgressListener.STATE_COOKIES_LOADED,
+ Ci.nsIWebProgressListener.STATE_BLOCKED_CRYPTOMINING_CONTENT,
+ Ci.nsIWebProgressListener.STATE_BLOCKED_FINGERPRINTING_CONTENT,
+];
+
+async function waitForClass(item, className, shouldBePresent = true) {
+ await TestUtils.waitForCondition(() => {
+ return item.classList.contains(className) == shouldBePresent;
+ }, `Target class ${className} should be ${shouldBePresent ? "present" : "not present"} on item ${item.id}`);
+
+ ok(
+ item.classList.contains(className) == shouldBePresent,
+ `item.classList.contains(${className}) is ${shouldBePresent} for ${item.id}`
+ );
+}
+
+add_task(async function testCategorySections() {
+ Services.prefs.setBoolPref(ST_PREF, true);
+
+ for (let pref of categoryEnabledPrefs) {
+ if (pref == TPC_PREF) {
+ Services.prefs.setIntPref(TPC_PREF, Ci.nsICookieService.BEHAVIOR_ACCEPT);
+ } else {
+ Services.prefs.setBoolPref(pref, false);
+ }
+ }
+
+ await BrowserTestUtils.withNewTab(
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://www.example.com",
+ async function () {
+ // Ensure the category nodes exist.
+ await openProtectionsPanel();
+ await closeProtectionsPanel();
+
+ let categoryItems = [
+ "protections-popup-category-trackers",
+ "protections-popup-category-socialblock",
+ "protections-popup-category-cookies",
+ "protections-popup-category-cryptominers",
+ "protections-popup-category-fingerprinters",
+ ].map(id => document.getElementById(id));
+
+ for (let item of categoryItems) {
+ await waitForClass(item, "notFound");
+ await waitForClass(item, "blocked", false);
+ }
+
+ // For every item, we enable the category and spoof a content blocking event,
+ // and check that .notFound goes away and .blocked is set. Then we disable the
+ // category and checks that .blocked goes away, and .notFound is still unset.
+ let contentBlockingState = 0;
+ for (let i = 0; i < categoryItems.length; i++) {
+ let itemToTest = categoryItems[i];
+ let enabledPref = categoryEnabledPrefs[i];
+ contentBlockingState |= detectedStateFlags[i];
+ if (enabledPref == TPC_PREF) {
+ Services.prefs.setIntPref(
+ TPC_PREF,
+ Ci.nsICookieService.BEHAVIOR_REJECT
+ );
+ } else {
+ Services.prefs.setBoolPref(enabledPref, true);
+ }
+ gProtectionsHandler.onContentBlockingEvent(contentBlockingState);
+ gProtectionsHandler.updatePanelForBlockingEvent(contentBlockingState);
+ await waitForClass(itemToTest, "notFound", false);
+ await waitForClass(itemToTest, "blocked", true);
+ if (enabledPref == TPC_PREF) {
+ Services.prefs.setIntPref(
+ TPC_PREF,
+ Ci.nsICookieService.BEHAVIOR_ACCEPT
+ );
+ } else {
+ Services.prefs.setBoolPref(enabledPref, false);
+ }
+ await waitForClass(itemToTest, "notFound", false);
+ await waitForClass(itemToTest, "blocked", false);
+ }
+ }
+ );
+});
+
+/**
+ * Check that when we open the popup in a new window, the initial state is correct
+ * wrt the pref.
+ */
+add_task(async function testCategorySectionInitial() {
+ let categoryItems = [
+ "protections-popup-category-trackers",
+ "protections-popup-category-socialblock",
+ "protections-popup-category-cookies",
+ "protections-popup-category-cryptominers",
+ "protections-popup-category-fingerprinters",
+ ];
+ for (let i = 0; i < categoryItems.length; i++) {
+ for (let shouldBlock of [true, false]) {
+ let win = await BrowserTestUtils.openNewBrowserWindow();
+ // Open non-about: page so our protections are active.
+ await BrowserTestUtils.openNewForegroundTab(
+ win.gBrowser,
+ "https://example.com/"
+ );
+ let enabledPref = categoryEnabledPrefs[i];
+ let contentBlockingState = detectedStateFlags[i];
+ if (enabledPref == TPC_PREF) {
+ Services.prefs.setIntPref(
+ TPC_PREF,
+ shouldBlock
+ ? Ci.nsICookieService.BEHAVIOR_REJECT
+ : Ci.nsICookieService.BEHAVIOR_ACCEPT
+ );
+ } else {
+ Services.prefs.setBoolPref(enabledPref, shouldBlock);
+ }
+ win.gProtectionsHandler.onContentBlockingEvent(contentBlockingState);
+ await openProtectionsPanel(false, win);
+ let categoryItem = win.document.getElementById(categoryItems[i]);
+ let expectedFound = true;
+ // Accepting cookies outright won't mark this as found.
+ if (i == 2 && !shouldBlock) {
+ // See bug 1653019
+ expectedFound = false;
+ }
+ is(
+ categoryItem.classList.contains("notFound"),
+ !expectedFound,
+ `Should have found ${categoryItems[i]} when it was ${
+ shouldBlock ? "blocked" : "allowed"
+ }`
+ );
+ is(
+ categoryItem.classList.contains("blocked"),
+ shouldBlock,
+ `Should ${shouldBlock ? "have blocked" : "not have blocked"} ${
+ categoryItems[i]
+ }`
+ );
+ await closeProtectionsPanel(win);
+ await BrowserTestUtils.closeWindow(win);
+ }
+ }
+});
diff --git a/browser/base/content/test/protectionsUI/browser_protectionsUI_cookie_banner.js b/browser/base/content/test/protectionsUI/browser_protectionsUI_cookie_banner.js
new file mode 100644
index 0000000000..1d71f718fb
--- /dev/null
+++ b/browser/base/content/test/protectionsUI/browser_protectionsUI_cookie_banner.js
@@ -0,0 +1,475 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests the cookie banner handling section in the protections panel.
+ */
+
+const { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+const { sinon } = ChromeUtils.importESModule(
+ "resource://testing-common/Sinon.sys.mjs"
+);
+
+const { MODE_DISABLED, MODE_REJECT, MODE_REJECT_OR_ACCEPT, MODE_UNSET } =
+ Ci.nsICookieBannerService;
+
+const exampleRules = JSON.stringify([
+ {
+ id: "4b18afb0-76db-4f9e-a818-ed9a783fae6a",
+ cookies: {},
+ click: {
+ optIn: "#foo",
+ presence: "#bar",
+ },
+ domains: ["example.com"],
+ },
+]);
+
+/**
+ * Determines whether the cookie banner section in the protections panel should
+ * be visible with the given configuration.
+ * @param {*} options - Configuration to test.
+ * @param {Number} options.featureMode - nsICookieBannerService::Modes value for
+ * normal browsing.
+ * @param {Number} options.featureModePBM - nsICookieBannerService::Modes value
+ * for private browsing.
+ * @param {boolean} options.visibilityPref - State of the cookie banner UI
+ * visibility pref.
+ * @param {boolean} options.testPBM - Whether the window is in private browsing
+ * mode (true) or not (false).
+ * @returns {boolean} Whether the section should be visible for the given
+ * config.
+ */
+function cookieBannerSectionIsVisible({
+ featureMode,
+ featureModePBM,
+ detectOnly,
+ visibilityPref,
+ testPBM,
+}) {
+ if (!visibilityPref) {
+ return false;
+ }
+ if (detectOnly) {
+ return false;
+ }
+
+ return (
+ (testPBM && featureModePBM != MODE_DISABLED) ||
+ (!testPBM && featureMode != MODE_DISABLED)
+ );
+}
+
+/**
+ * Runs a visibility test of the cookie banner section in the protections panel.
+ * @param {*} options - Test options.
+ * @param {Window} options.win - Browser window to use for testing. It's
+ * browsing mode should match the testPBM variable.
+ * @param {Number} options.featureMode - nsICookieBannerService::Modes value for
+ * normal browsing.
+ * @param {Number} options.featureModePBM - nsICookieBannerService::Modes value
+ * for private browsing.
+ * @param {boolean} options.visibilityPref - State of the cookie banner UI
+ * visibility pref.
+ * @param {boolean} options.testPBM - Whether the window is in private browsing
+ * mode (true) or not (false).
+ * @returns {Promise} Resolves once the test is complete.
+ */
+async function testSectionVisibility({
+ win,
+ featureMode,
+ featureModePBM,
+ visibilityPref,
+ testPBM,
+}) {
+ info(
+ "testSectionVisibility " +
+ JSON.stringify({ featureMode, featureModePBM, visibilityPref, testPBM })
+ );
+ // initialize the pref environment
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["cookiebanners.service.mode", featureMode],
+ ["cookiebanners.service.mode.privateBrowsing", featureModePBM],
+ ["cookiebanners.ui.desktop.enabled", visibilityPref],
+ ],
+ });
+
+ // Open a tab with example.com so the protections panel can be opened.
+ await BrowserTestUtils.withNewTab(
+ { gBrowser: win.gBrowser, url: "https://example.com" },
+ async () => {
+ await openProtectionsPanel(null, win);
+
+ // Get panel elements to test
+ let el = {
+ section: win.document.getElementById(
+ "protections-popup-cookie-banner-section"
+ ),
+ sectionSeparator: win.document.getElementById(
+ "protections-popup-cookie-banner-section-separator"
+ ),
+ switch: win.document.getElementById(
+ "protections-popup-cookie-banner-switch"
+ ),
+ };
+
+ let expectVisible = cookieBannerSectionIsVisible({
+ featureMode,
+ featureModePBM,
+ visibilityPref,
+ testPBM,
+ });
+ is(
+ BrowserTestUtils.is_visible(el.section),
+ expectVisible,
+ `Cookie banner section should be ${
+ expectVisible ? "visible" : "not visible"
+ }.`
+ );
+ is(
+ BrowserTestUtils.is_visible(el.sectionSeparator),
+ expectVisible,
+ `Cookie banner section separator should be ${
+ expectVisible ? "visible" : "not visible"
+ }.`
+ );
+ is(
+ BrowserTestUtils.is_visible(el.switch),
+ expectVisible,
+ `Cookie banner switch should be ${
+ expectVisible ? "visible" : "not visible"
+ }.`
+ );
+ }
+ );
+
+ await SpecialPowers.popPrefEnv();
+}
+
+/**
+ * Tests cookie banner section visibility state in different configurations.
+ */
+add_task(async function test_section_visibility() {
+ // Test all combinations of cookie banner service modes and normal and
+ // private browsing.
+
+ for (let testPBM of [false, true]) {
+ let win = window;
+ // Open a new private window to test the panel in for testing PBM, otherwise
+ // reuse the existing window.
+ if (testPBM) {
+ win = await BrowserTestUtils.openNewBrowserWindow({
+ private: true,
+ });
+ win.focus();
+ }
+
+ for (let featureMode of [
+ MODE_DISABLED,
+ MODE_REJECT,
+ MODE_REJECT_OR_ACCEPT,
+ ]) {
+ for (let featureModePBM of [
+ MODE_DISABLED,
+ MODE_REJECT,
+ MODE_REJECT_OR_ACCEPT,
+ ]) {
+ for (let detectOnly of [false, true]) {
+ // Testing detect only mode for normal browsing is sufficient.
+ if (detectOnly && featureModePBM != MODE_DISABLED) {
+ continue;
+ }
+ await testSectionVisibility({
+ win,
+ featureMode,
+ featureModePBM,
+ detectOnly,
+ testPBM,
+ visibilityPref: true,
+ });
+ }
+ }
+ }
+
+ if (testPBM) {
+ await BrowserTestUtils.closeWindow(win);
+ }
+ }
+});
+
+/**
+ * Tests that the cookie banner section is only visible if enabled by UI pref.
+ */
+add_task(async function test_section_visibility_pref() {
+ for (let visibilityPref of [false, true]) {
+ await testSectionVisibility({
+ win: window,
+ featureMode: MODE_REJECT,
+ featureModePBM: MODE_DISABLED,
+ testPBM: false,
+ visibilityPref,
+ });
+ }
+});
+
+/**
+ * Test the state of the per-site exception switch in the cookie banner section
+ * and whether a matching per-site exception is set.
+ * @param {*} options
+ * @param {Window} options.win - Chrome window to test exception for (selected
+ * tab).
+ * @param {boolean} options.isPBM - Whether the given window is in private
+ * browsing mode.
+ * @param {string} options.expectedSwitchState - Whether the switch is expected to be
+ * "on" (CBH enabled), "off" (user added exception), or "unsupported" (no rules for site).
+ */
+function assertSwitchAndPrefState({ win, isPBM, expectedSwitchState }) {
+ let el = {
+ section: win.document.getElementById(
+ "protections-popup-cookie-banner-section"
+ ),
+ switch: win.document.getElementById(
+ "protections-popup-cookie-banner-switch"
+ ),
+ labelON: win.document.querySelector(
+ "#protections-popup-cookie-banner-detected"
+ ),
+ labelOFF: win.document.querySelector(
+ "#protections-popup-cookie-banner-site-disabled"
+ ),
+ labelUNDETECTED: win.document.querySelector(
+ "#protections-popup-cookie-banner-undetected"
+ ),
+ };
+
+ let currentURI = win.gBrowser.currentURI;
+ let pref = Services.cookieBanners.getDomainPref(currentURI, isPBM);
+ if (expectedSwitchState == "on") {
+ ok(el.section.dataset.state == "detected", "CBH switch is set to ON");
+
+ ok(BrowserTestUtils.is_visible(el.labelON), "ON label should be visible");
+ ok(
+ !BrowserTestUtils.is_visible(el.labelOFF),
+ "OFF label should not be visible"
+ );
+ ok(
+ !BrowserTestUtils.is_visible(el.labelUNDETECTED),
+ "UNDETECTED label should not be visible"
+ );
+
+ is(
+ pref,
+ MODE_UNSET,
+ `There should be no per-site exception for ${currentURI.spec}.`
+ );
+ } else if (expectedSwitchState === "off") {
+ ok(el.section.dataset.state == "site-disabled", "CBH switch is set to OFF");
+
+ ok(
+ !BrowserTestUtils.is_visible(el.labelON),
+ "ON label should not be visible"
+ );
+ ok(BrowserTestUtils.is_visible(el.labelOFF), "OFF label should be visible");
+ ok(
+ !BrowserTestUtils.is_visible(el.labelUNDETECTED),
+ "UNDETECTED label should not be visible"
+ );
+
+ is(
+ pref,
+ MODE_DISABLED,
+ `There should be a per-site exception for ${currentURI.spec}.`
+ );
+ } else {
+ ok(el.section.dataset.state == "undetected", "CBH not supported for site");
+
+ ok(
+ !BrowserTestUtils.is_visible(el.labelON),
+ "ON label should not be visible"
+ );
+ ok(
+ !BrowserTestUtils.is_visible(el.labelOFF),
+ "OFF label should not be visible"
+ );
+ ok(
+ BrowserTestUtils.is_visible(el.labelUNDETECTED),
+ "UNDETECTED label should be visible"
+ );
+ }
+}
+
+/**
+ * Test the telemetry associated with the cookie banner toggle. To be called
+ * after interacting with the toggle.
+ * @param {*} options
+ * @param {boolean|null} - Expected telemetry state matching the button state.
+ * button on = true = cookieb_toggle_on event. Pass null to expect no event
+ * recorded.
+ */
+function assertTelemetryState({ expectEnabled = null } = {}) {
+ info("Test telemetry state.");
+
+ let events = [];
+ const CATEGORY = "security.ui.protectionspopup";
+ const METHOD = "click";
+
+ if (expectEnabled != null) {
+ events.push({
+ category: CATEGORY,
+ method: METHOD,
+ object: expectEnabled ? "cookieb_toggle_on" : "cookieb_toggle_off",
+ });
+ }
+
+ // Assert event state and clear event list.
+ TelemetryTestUtils.assertEvents(events, {
+ category: CATEGORY,
+ method: METHOD,
+ });
+}
+
+/**
+ * Test the cookie banner enable / disable by clicking the switch, then
+ * clicking the on/off button in the cookie banner subview. Assumes the
+ * protections panel is already open.
+ *
+ * @param {boolean} enable - Whether we want to enable or disable.
+ * @param {Window} win - Current chrome window under test.
+ */
+async function toggleCookieBannerHandling(enable, win) {
+ let switchEl = win.document.getElementById(
+ "protections-popup-cookie-banner-switch"
+ );
+ let enableButton = win.document.getElementById(
+ "protections-popup-cookieBannerView-enable-button"
+ );
+ let disableButton = win.document.getElementById(
+ "protections-popup-cookieBannerView-disable-button"
+ );
+ let subView = win.document.getElementById(
+ "protections-popup-cookieBannerView"
+ );
+
+ let subViewShownPromise = BrowserTestUtils.waitForEvent(subView, "ViewShown");
+ switchEl.click();
+ await subViewShownPromise;
+
+ if (enable) {
+ ok(BrowserTestUtils.is_visible(enableButton), "Enable button is visible");
+ enableButton.click();
+ } else {
+ ok(BrowserTestUtils.is_visible(disableButton), "Disable button is visible");
+ disableButton.click();
+ }
+}
+
+function waitForProtectionsPopupHide(win = window) {
+ return BrowserTestUtils.waitForEvent(
+ win.document.getElementById("protections-popup"),
+ "popuphidden"
+ );
+}
+
+/**
+ * Tests the cookie banner section per-site preference toggle.
+ */
+add_task(async function test_section_toggle() {
+ // initialize the pref environment
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["cookiebanners.service.mode", MODE_REJECT_OR_ACCEPT],
+ ["cookiebanners.service.mode.privateBrowsing", MODE_REJECT_OR_ACCEPT],
+ ["cookiebanners.ui.desktop.enabled", true],
+ ["cookiebanners.listService.testRules", exampleRules],
+ ["cookiebanners.listService.testSkipRemoteSettings", true],
+ ],
+ });
+
+ Services.cookieBanners.resetRules(false);
+ await BrowserTestUtils.waitForCondition(
+ () => !!Services.cookieBanners.rules.length,
+ "waiting for Services.cookieBanners.rules.length to be greater than 0"
+ );
+
+ // Test both normal and private browsing windows. For normal windows we reuse
+ // the existing one, for private windows we need to open a new window.
+ for (let testPBM of [false, true]) {
+ let win = window;
+ if (testPBM) {
+ win = await BrowserTestUtils.openNewBrowserWindow({
+ private: true,
+ });
+ }
+
+ await BrowserTestUtils.withNewTab(
+ { gBrowser: win.gBrowser, url: "https://example.com" },
+ async () => {
+ let clearSiteDataSpy = sinon.spy(window.SiteDataManager, "remove");
+
+ await openProtectionsPanel(null, win);
+ let switchEl = win.document.getElementById(
+ "protections-popup-cookie-banner-switch"
+ );
+ info("Testing initial switch ON state.");
+ assertSwitchAndPrefState({
+ win,
+ isPBM: testPBM,
+ switchEl,
+ expectedSwitchState: "on",
+ });
+ assertTelemetryState();
+
+ info("Testing switch state after toggle OFF");
+ let closePromise = waitForProtectionsPopupHide(win);
+ await toggleCookieBannerHandling(false, win);
+ await closePromise;
+ if (testPBM) {
+ Assert.ok(
+ clearSiteDataSpy.notCalled,
+ "clearSiteData should not be called in private browsing mode"
+ );
+ } else {
+ Assert.ok(
+ clearSiteDataSpy.calledOnce,
+ "clearSiteData should be called in regular browsing mode"
+ );
+ }
+ clearSiteDataSpy.restore();
+
+ await openProtectionsPanel(null, win);
+ assertSwitchAndPrefState({
+ win,
+ isPBM: testPBM,
+ switchEl,
+ expectedSwitchState: "off",
+ });
+ assertTelemetryState({ expectEnabled: false });
+
+ info("Testing switch state after toggle ON.");
+ closePromise = waitForProtectionsPopupHide(win);
+ await toggleCookieBannerHandling(true, win);
+ await closePromise;
+
+ await openProtectionsPanel(null, win);
+ assertSwitchAndPrefState({
+ win,
+ isPBM: testPBM,
+ switchEl,
+ expectedSwitchState: "on",
+ });
+ assertTelemetryState({ expectEnabled: true });
+ }
+ );
+
+ if (testPBM) {
+ await BrowserTestUtils.closeWindow(win);
+ }
+ }
+
+ await SpecialPowers.popPrefEnv();
+});
diff --git a/browser/base/content/test/protectionsUI/browser_protectionsUI_cookies_subview.js b/browser/base/content/test/protectionsUI/browser_protectionsUI_cookies_subview.js
new file mode 100644
index 0000000000..5eb16dbac7
--- /dev/null
+++ b/browser/base/content/test/protectionsUI/browser_protectionsUI_cookies_subview.js
@@ -0,0 +1,537 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/* eslint-disable mozilla/no-arbitrary-setTimeout */
+
+"use strict";
+
+const COOKIE_PAGE =
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://not-tracking.example.com/browser/browser/base/content/test/protectionsUI/cookiePage.html";
+const CONTAINER_PAGE =
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://not-tracking.example.com/browser/browser/base/content/test/protectionsUI/containerPage.html";
+
+const TPC_PREF = "network.cookie.cookieBehavior";
+
+add_setup(async function () {
+ await UrlClassifierTestUtils.addTestTrackers();
+
+ registerCleanupFunction(() => {
+ UrlClassifierTestUtils.cleanupTestTrackers();
+ });
+});
+
+/*
+ * Accepts an array containing 6 elements that identify the testcase:
+ * [0] - boolean indicating whether trackers are blocked.
+ * [1] - boolean indicating whether third party cookies are blocked.
+ * [2] - boolean indicating whether first party cookies are blocked.
+ * [3] - integer indicating number of expected content blocking events.
+ * [4] - integer indicating number of expected subview list headers.
+ * [5] - integer indicating number of expected cookie list items.
+ * [6] - integer indicating number of expected cookie list items
+ * after loading a cookie-setting third party URL in an iframe
+ * [7] - integer indicating number of expected cookie list items
+ * after loading a cookie-setting first party URL in an iframe
+ */
+async function assertSitesListed(testCase) {
+ let sitesListedTestCases = [
+ [true, false, false, 4, 1, 1, 1, 1],
+ [true, true, false, 5, 1, 1, 2, 2],
+ [true, true, true, 6, 2, 2, 3, 3],
+ [false, false, false, 3, 1, 1, 1, 1],
+ ];
+ let [
+ trackersBlocked,
+ thirdPartyBlocked,
+ firstPartyBlocked,
+ contentBlockingEventCount,
+ listHeaderCount,
+ cookieItemsCount1,
+ cookieItemsCount2,
+ cookieItemsCount3,
+ ] = sitesListedTestCases[testCase];
+ let promise = BrowserTestUtils.openNewForegroundTab({
+ url: COOKIE_PAGE,
+ gBrowser,
+ });
+ let [tab] = await Promise.all([
+ promise,
+ waitForContentBlockingEvent(contentBlockingEventCount),
+ ]);
+ let browser = tab.linkedBrowser;
+
+ await openProtectionsPanel();
+
+ let categoryItem = document.getElementById(
+ "protections-popup-category-cookies"
+ );
+
+ // Explicitly waiting for the category item becoming visible.
+ await TestUtils.waitForCondition(() => {
+ return BrowserTestUtils.is_visible(categoryItem);
+ });
+
+ ok(BrowserTestUtils.is_visible(categoryItem), "TP category item is visible");
+ let cookiesView = document.getElementById("protections-popup-cookiesView");
+ let viewShown = BrowserTestUtils.waitForEvent(cookiesView, "ViewShown");
+ categoryItem.click();
+ await viewShown;
+
+ ok(true, "Cookies view was shown");
+
+ let listHeaders = cookiesView.querySelectorAll(
+ ".protections-popup-cookiesView-list-header"
+ );
+ is(
+ listHeaders.length,
+ listHeaderCount,
+ `We have ${listHeaderCount} list headers.`
+ );
+ if (listHeaderCount == 1) {
+ ok(
+ !BrowserTestUtils.is_visible(listHeaders[0]),
+ "Only one header, should be hidden"
+ );
+ } else {
+ for (let header of listHeaders) {
+ ok(
+ BrowserTestUtils.is_visible(header),
+ "Multiple list headers - all should be visible."
+ );
+ }
+ }
+
+ let emptyLabels = cookiesView.querySelectorAll(
+ ".protections-popup-empty-label"
+ );
+ is(emptyLabels.length, 0, `We have no empty labels`);
+
+ let listItems = cookiesView.querySelectorAll(".protections-popup-list-item");
+ is(
+ listItems.length,
+ cookieItemsCount1,
+ `We have ${cookieItemsCount1} cookies in the list`
+ );
+
+ if (trackersBlocked) {
+ let trackerTestItem;
+ for (let item of listItems) {
+ let label = item.querySelector(".protections-popup-list-host-label");
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ if (label.value == "http://trackertest.org") {
+ trackerTestItem = item;
+ break;
+ }
+ }
+ ok(trackerTestItem, "Has an item for trackertest.org");
+ ok(BrowserTestUtils.is_visible(trackerTestItem), "List item is visible");
+ }
+
+ if (firstPartyBlocked) {
+ let notTrackingExampleItem;
+ for (let item of listItems) {
+ let label = item.querySelector(".protections-popup-list-host-label");
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ if (label.value == "http://not-tracking.example.com") {
+ notTrackingExampleItem = item;
+ break;
+ }
+ }
+ ok(notTrackingExampleItem, "Has an item for not-tracking.example.com");
+ ok(
+ BrowserTestUtils.is_visible(notTrackingExampleItem),
+ "List item is visible"
+ );
+ }
+ let mainView = document.getElementById("protections-popup-mainView");
+ viewShown = BrowserTestUtils.waitForEvent(mainView, "ViewShown");
+ let backButton = cookiesView.querySelector(".subviewbutton-back");
+ backButton.click();
+ await viewShown;
+
+ ok(true, "Main view was shown");
+
+ let change = waitForContentBlockingEvent();
+ let timeoutPromise = new Promise(resolve => setTimeout(resolve, 1000));
+
+ await SpecialPowers.spawn(browser, [], function () {
+ content.postMessage("third-party-cookie", "*");
+ });
+
+ let result = await Promise.race([change, timeoutPromise]);
+ is(result, undefined, "No contentBlockingEvent events should be received");
+
+ viewShown = BrowserTestUtils.waitForEvent(cookiesView, "ViewShown");
+ categoryItem.click();
+ await viewShown;
+
+ ok(true, "Cookies view was shown");
+
+ emptyLabels = cookiesView.querySelectorAll(".protections-popup-empty-label");
+ is(emptyLabels.length, 0, `We have no empty labels`);
+
+ listItems = cookiesView.querySelectorAll(".protections-popup-list-item");
+ is(
+ listItems.length,
+ cookieItemsCount2,
+ `We have ${cookieItemsCount2} cookies in the list`
+ );
+
+ if (thirdPartyBlocked) {
+ let test1ExampleItem;
+ for (let item of listItems) {
+ let label = item.querySelector(".protections-popup-list-host-label");
+ if (label.value == "https://test1.example.org") {
+ test1ExampleItem = item;
+ break;
+ }
+ }
+ ok(test1ExampleItem, "Has an item for test1.example.org");
+ ok(BrowserTestUtils.is_visible(test1ExampleItem), "List item is visible");
+ }
+
+ if (trackersBlocked || thirdPartyBlocked || firstPartyBlocked) {
+ let trackerTestItem;
+ for (let item of listItems) {
+ let label = item.querySelector(".protections-popup-list-host-label");
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ if (label.value == "http://trackertest.org") {
+ trackerTestItem = item;
+ break;
+ }
+ }
+ ok(trackerTestItem, "List item should exist for http://trackertest.org");
+ ok(BrowserTestUtils.is_visible(trackerTestItem), "List item is visible");
+ }
+
+ viewShown = BrowserTestUtils.waitForEvent(mainView, "ViewShown");
+ backButton.click();
+ await viewShown;
+
+ ok(true, "Main view was shown");
+
+ change = waitForSecurityChange();
+ timeoutPromise = new Promise(resolve => setTimeout(resolve, 1000));
+
+ await SpecialPowers.spawn(browser, [], function () {
+ content.postMessage("first-party-cookie", "*");
+ });
+
+ result = await Promise.race([change, timeoutPromise]);
+ is(result, undefined, "No securityChange events should be received");
+
+ viewShown = BrowserTestUtils.waitForEvent(cookiesView, "ViewShown");
+ categoryItem.click();
+ await viewShown;
+
+ ok(true, "Cookies view was shown");
+
+ emptyLabels = cookiesView.querySelectorAll(".protections-popup-empty-label");
+ is(emptyLabels.length, 0, "We have no empty labels");
+
+ listItems = cookiesView.querySelectorAll(".protections-popup-list-item");
+ is(
+ listItems.length,
+ cookieItemsCount3,
+ `We have ${cookieItemsCount3} cookies in the list`
+ );
+
+ if (firstPartyBlocked) {
+ let notTrackingExampleItem;
+ for (let item of listItems) {
+ let label = item.querySelector(".protections-popup-list-host-label");
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ if (label.value == "http://not-tracking.example.com") {
+ notTrackingExampleItem = item;
+ break;
+ }
+ }
+ ok(notTrackingExampleItem, "Has an item for not-tracking.example.com");
+ ok(
+ BrowserTestUtils.is_visible(notTrackingExampleItem),
+ "List item is visible"
+ );
+ }
+
+ BrowserTestUtils.removeTab(tab);
+}
+
+add_task(async function testCookiesSubView() {
+ info("Testing cookies subview with reject tracking cookies.");
+ Services.prefs.setIntPref(
+ TPC_PREF,
+ Ci.nsICookieService.BEHAVIOR_REJECT_TRACKER
+ );
+ let testCaseIndex = 0;
+ await assertSitesListed(testCaseIndex++);
+ info("Testing cookies subview with reject third party cookies.");
+ Services.prefs.setIntPref(
+ TPC_PREF,
+ Ci.nsICookieService.BEHAVIOR_REJECT_FOREIGN
+ );
+ await assertSitesListed(testCaseIndex++);
+ info("Testing cookies subview with reject all cookies.");
+ Services.prefs.setIntPref(TPC_PREF, Ci.nsICookieService.BEHAVIOR_REJECT);
+ await assertSitesListed(testCaseIndex++);
+ info("Testing cookies subview with accept all cookies.");
+ Services.prefs.setIntPref(TPC_PREF, Ci.nsICookieService.BEHAVIOR_ACCEPT);
+ await assertSitesListed(testCaseIndex++);
+
+ Services.prefs.clearUserPref(TPC_PREF);
+});
+
+add_task(async function testCookiesSubViewAllowed() {
+ Services.prefs.setIntPref(
+ TPC_PREF,
+ Ci.nsICookieService.BEHAVIOR_REJECT_TRACKER
+ );
+ let principal =
+ Services.scriptSecurityManager.createContentPrincipalFromOrigin(
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://trackertest.org/"
+ );
+ Services.perms.addFromPrincipal(
+ principal,
+ "cookie",
+ Services.perms.ALLOW_ACTION
+ );
+
+ let promise = BrowserTestUtils.openNewForegroundTab({
+ url: COOKIE_PAGE,
+ gBrowser,
+ });
+ let [tab] = await Promise.all([promise, waitForContentBlockingEvent(3)]);
+
+ await openProtectionsPanel();
+
+ let categoryItem = document.getElementById(
+ "protections-popup-category-cookies"
+ );
+
+ // Explicitly waiting for the category item becoming visible.
+ await TestUtils.waitForCondition(() => {
+ return BrowserTestUtils.is_visible(categoryItem);
+ });
+
+ ok(BrowserTestUtils.is_visible(categoryItem), "TP category item is visible");
+ let cookiesView = document.getElementById("protections-popup-cookiesView");
+ let viewShown = BrowserTestUtils.waitForEvent(cookiesView, "ViewShown");
+ categoryItem.click();
+ await viewShown;
+
+ ok(true, "Cookies view was shown");
+
+ let listItems = cookiesView.querySelectorAll(".protections-popup-list-item");
+ is(listItems.length, 1, "We have 1 cookie in the list");
+
+ let listItem = listItems[0];
+ let label = listItem.querySelector(".protections-popup-list-host-label");
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ is(label.value, "http://trackertest.org", "has an item for trackertest.org");
+ ok(BrowserTestUtils.is_visible(listItem), "list item is visible");
+ ok(
+ listItem.classList.contains("allowed"),
+ "indicates whether the cookie was blocked or allowed"
+ );
+
+ let stateLabel = listItem.querySelector(
+ ".protections-popup-list-state-label"
+ );
+ ok(stateLabel, "List item has a state label");
+ ok(BrowserTestUtils.is_visible(stateLabel), "State label is visible");
+ is(
+ stateLabel.value,
+ gNavigatorBundle.getString("contentBlocking.cookiesView.allowed.label"),
+ "State label has correct text"
+ );
+
+ let button = listItem.querySelector(
+ ".permission-popup-permission-remove-button"
+ );
+ ok(
+ BrowserTestUtils.is_visible(button),
+ "Permission remove button is visible"
+ );
+ button.click();
+ is(
+ Services.perms.testExactPermissionFromPrincipal(principal, "cookie"),
+ Services.perms.UNKNOWN_ACTION,
+ "Button click should remove cookie pref."
+ );
+ ok(!listItem.classList.contains("allowed"), "Has removed the allowed class");
+
+ BrowserTestUtils.removeTab(tab);
+
+ Services.prefs.clearUserPref(TPC_PREF);
+});
+
+add_task(async function testCookiesSubViewAllowedHeuristic() {
+ Services.prefs.setIntPref(
+ TPC_PREF,
+ Ci.nsICookieService.BEHAVIOR_REJECT_TRACKER
+ );
+ let principal =
+ Services.scriptSecurityManager.createContentPrincipalFromOrigin(
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://not-tracking.example.com/"
+ );
+
+ // Pretend that the tracker has already been interacted with
+ let trackerPrincipal =
+ Services.scriptSecurityManager.createContentPrincipalFromOrigin(
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://trackertest.org/"
+ );
+ Services.perms.addFromPrincipal(
+ trackerPrincipal,
+ "storageAccessAPI",
+ Services.perms.ALLOW_ACTION
+ );
+
+ let promise = BrowserTestUtils.openNewForegroundTab({
+ url: COOKIE_PAGE,
+ gBrowser,
+ });
+ let [tab] = await Promise.all([promise, waitForContentBlockingEvent(5)]);
+ let browser = tab.linkedBrowser;
+
+ let popup;
+ let windowCreated = TestUtils.topicObserved(
+ "chrome-document-global-created",
+ (subject, data) => {
+ popup = subject;
+ return true;
+ }
+ );
+ let permChanged = TestUtils.topicObserved("perm-changed", (subject, data) => {
+ return (
+ subject &&
+ subject.QueryInterface(Ci.nsIPermission).type ==
+ "3rdPartyStorage^http://trackertest.org" &&
+ subject.principal.origin == principal.origin &&
+ data == "added"
+ );
+ });
+
+ await SpecialPowers.spawn(browser, [], function () {
+ content.postMessage("window-open", "*");
+ });
+ await Promise.all([windowCreated, permChanged]);
+
+ await new Promise(resolve => waitForFocus(resolve, popup));
+ await new Promise(resolve => waitForFocus(resolve, window));
+
+ await openProtectionsPanel();
+
+ let categoryItem = document.getElementById(
+ "protections-popup-category-cookies"
+ );
+
+ // Explicitly waiting for the category item becoming visible.
+ await TestUtils.waitForCondition(() => {
+ return BrowserTestUtils.is_visible(categoryItem);
+ });
+
+ ok(BrowserTestUtils.is_visible(categoryItem), "TP category item is visible");
+ let cookiesView = document.getElementById("protections-popup-cookiesView");
+ let viewShown = BrowserTestUtils.waitForEvent(cookiesView, "ViewShown");
+ categoryItem.click();
+ await viewShown;
+
+ ok(true, "Cookies view was shown");
+
+ let listItems = cookiesView.querySelectorAll(".protections-popup-list-item");
+ is(listItems.length, 1, "We have 1 cookie in the list");
+
+ let listItem = listItems[0];
+ let label = listItem.querySelector(".protections-popup-list-host-label");
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ is(label.value, "http://trackertest.org", "has an item for trackertest.org");
+ ok(BrowserTestUtils.is_visible(listItem), "list item is visible");
+ ok(
+ listItem.classList.contains("allowed"),
+ "indicates whether the cookie was blocked or allowed"
+ );
+
+ let button = listItem.querySelector(
+ ".permission-popup-permission-remove-button"
+ );
+ ok(
+ BrowserTestUtils.is_visible(button),
+ "Permission remove button is visible"
+ );
+ button.click();
+ is(
+ Services.perms.testExactPermissionFromPrincipal(
+ principal,
+ "3rdPartyStorage^http://trackertest.org"
+ ),
+ Services.perms.UNKNOWN_ACTION,
+ "Button click should remove the storage pref."
+ );
+ ok(!listItem.classList.contains("allowed"), "Has removed the allowed class");
+
+ await SpecialPowers.spawn(browser, [], function () {
+ content.postMessage("window-close", "*");
+ });
+
+ BrowserTestUtils.removeTab(tab);
+
+ Services.prefs.clearUserPref(TPC_PREF);
+});
+
+add_task(async function testCookiesSubViewBlockedDoublyNested() {
+ Services.prefs.setIntPref(
+ TPC_PREF,
+ Ci.nsICookieService.BEHAVIOR_REJECT_TRACKER
+ );
+
+ let promise = BrowserTestUtils.openNewForegroundTab({
+ url: CONTAINER_PAGE,
+ gBrowser,
+ });
+ let [tab] = await Promise.all([promise, waitForContentBlockingEvent(3)]);
+
+ await openProtectionsPanel();
+
+ let categoryItem = document.getElementById(
+ "protections-popup-category-cookies"
+ );
+
+ // Explicitly waiting for the category item becoming visible.
+ await TestUtils.waitForCondition(() => {
+ return BrowserTestUtils.is_visible(categoryItem);
+ });
+
+ ok(BrowserTestUtils.is_visible(categoryItem), "TP category item is visible");
+ let cookiesView = document.getElementById("protections-popup-cookiesView");
+ let viewShown = BrowserTestUtils.waitForEvent(cookiesView, "ViewShown");
+ categoryItem.click();
+ await viewShown;
+
+ ok(true, "Cookies view was shown");
+
+ let listItems = cookiesView.querySelectorAll(".protections-popup-list-item");
+ is(listItems.length, 1, "We have 1 cookie in the list");
+
+ let listItem = listItems[0];
+ let label = listItem.querySelector(".protections-popup-list-host-label");
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ is(label.value, "http://trackertest.org", "has an item for trackertest.org");
+ ok(BrowserTestUtils.is_visible(listItem), "list item is visible");
+ ok(
+ !listItem.classList.contains("allowed"),
+ "indicates whether the cookie was blocked or allowed"
+ );
+
+ let button = listItem.querySelector(
+ ".permission-popup-permission-remove-button"
+ );
+ ok(!button, "Permission remove button doesn't exist");
+
+ BrowserTestUtils.removeTab(tab);
+
+ Services.prefs.clearUserPref(TPC_PREF);
+});
diff --git a/browser/base/content/test/protectionsUI/browser_protectionsUI_cryptominers.js b/browser/base/content/test/protectionsUI/browser_protectionsUI_cryptominers.js
new file mode 100644
index 0000000000..13fb3e08ee
--- /dev/null
+++ b/browser/base/content/test/protectionsUI/browser_protectionsUI_cryptominers.js
@@ -0,0 +1,306 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TRACKING_PAGE =
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.org/browser/browser/base/content/test/protectionsUI/trackingPage.html";
+const CM_PROTECTION_PREF = "privacy.trackingprotection.cryptomining.enabled";
+let cmHistogram;
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "urlclassifier.features.cryptomining.blacklistHosts",
+ "cryptomining.example.com",
+ ],
+ [
+ "urlclassifier.features.cryptomining.annotate.blacklistHosts",
+ "cryptomining.example.com",
+ ],
+ ["privacy.trackingprotection.enabled", false],
+ ["privacy.trackingprotection.annotate_channels", false],
+ ["privacy.trackingprotection.fingerprinting.enabled", false],
+ ["urlclassifier.features.fingerprinting.annotate.blacklistHosts", ""],
+ ],
+ });
+ cmHistogram = Services.telemetry.getHistogramById(
+ "CRYPTOMINERS_BLOCKED_COUNT"
+ );
+ registerCleanupFunction(() => {
+ cmHistogram.clear();
+ });
+});
+
+async function testIdentityState(hasException) {
+ cmHistogram.clear();
+ let promise = BrowserTestUtils.openNewForegroundTab({
+ url: TRACKING_PAGE,
+ gBrowser,
+ });
+ let [tab] = await Promise.all([promise, waitForContentBlockingEvent()]);
+
+ if (hasException) {
+ let loaded = BrowserTestUtils.browserLoaded(
+ tab.linkedBrowser,
+ false,
+ TRACKING_PAGE
+ );
+ gProtectionsHandler.disableForCurrentPage();
+ await loaded;
+ }
+
+ await openProtectionsPanel();
+
+ ok(
+ !gProtectionsHandler._protectionsPopup.hasAttribute("detected"),
+ "cryptominers are not detected"
+ );
+
+ ok(
+ BrowserTestUtils.is_visible(gProtectionsHandler.iconBox),
+ "icon box is visible regardless the exception"
+ );
+
+ promise = waitForContentBlockingEvent();
+
+ await SpecialPowers.spawn(tab.linkedBrowser, [], function () {
+ content.postMessage("cryptomining", "*");
+ });
+
+ await promise;
+
+ ok(
+ gProtectionsHandler._protectionsPopup.hasAttribute("detected"),
+ "trackers are detected"
+ );
+ ok(
+ BrowserTestUtils.is_visible(gProtectionsHandler.iconBox),
+ "icon box is visible"
+ );
+ is(
+ gProtectionsHandler.iconBox.hasAttribute("hasException"),
+ hasException,
+ "Shows an exception when appropriate"
+ );
+
+ if (hasException) {
+ let loaded = BrowserTestUtils.browserLoaded(
+ tab.linkedBrowser,
+ false,
+ TRACKING_PAGE
+ );
+ gProtectionsHandler.enableForCurrentPage();
+ await loaded;
+ }
+
+ let loads = hasException ? 3 : 1;
+ testTelemetry(loads, 1, hasException);
+
+ BrowserTestUtils.removeTab(tab);
+}
+
+async function testSubview(hasException) {
+ cmHistogram.clear();
+ let promise = BrowserTestUtils.openNewForegroundTab({
+ url: TRACKING_PAGE,
+ gBrowser,
+ });
+ let [tab] = await Promise.all([promise, waitForContentBlockingEvent()]);
+
+ if (hasException) {
+ let loaded = BrowserTestUtils.browserLoaded(
+ tab.linkedBrowser,
+ false,
+ TRACKING_PAGE
+ );
+ gProtectionsHandler.disableForCurrentPage();
+ await loaded;
+ }
+
+ promise = waitForContentBlockingEvent();
+ await SpecialPowers.spawn(tab.linkedBrowser, [], function () {
+ content.postMessage("cryptomining", "*");
+ });
+ await promise;
+
+ await openProtectionsPanel();
+
+ let categoryItem = document.getElementById(
+ "protections-popup-category-cryptominers"
+ );
+
+ // Explicitly waiting for the category item becoming visible.
+ await TestUtils.waitForCondition(() => {
+ return BrowserTestUtils.is_visible(categoryItem);
+ });
+
+ ok(BrowserTestUtils.is_visible(categoryItem), "TP category item is visible");
+
+ /* eslint-disable mozilla/no-arbitrary-setTimeout */
+ // We have to wait until the ContentBlockingLog gets updated in the content.
+ // Unfortunately, we need to use the setTimeout here since we don't have an
+ // easy to know whether the log is updated in the content. This should be
+ // removed after the log been removed in the content (Bug 1599046).
+ await new Promise(resolve => {
+ setTimeout(resolve, 500);
+ });
+ /* eslint-enable mozilla/no-arbitrary-setTimeout */
+
+ let subview = document.getElementById("protections-popup-cryptominersView");
+ let viewShown = BrowserTestUtils.waitForEvent(subview, "ViewShown");
+ categoryItem.click();
+ await viewShown;
+
+ let trackersViewShimHint = document.getElementById(
+ "protections-popup-cryptominersView-shim-allow-hint"
+ );
+ ok(trackersViewShimHint.hidden, "Shim hint is hidden");
+
+ let listItems = subview.querySelectorAll(".protections-popup-list-item");
+ is(listItems.length, 1, "We have 1 item in the list");
+ let listItem = listItems[0];
+ ok(BrowserTestUtils.is_visible(listItem), "List item is visible");
+ is(
+ listItem.querySelector("label").value,
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://cryptomining.example.com",
+ "Has the correct host"
+ );
+ is(
+ listItem.classList.contains("allowed"),
+ hasException,
+ "Indicates the miner was blocked or allowed"
+ );
+
+ let mainView = document.getElementById("protections-popup-mainView");
+ viewShown = BrowserTestUtils.waitForEvent(mainView, "ViewShown");
+ let backButton = subview.querySelector(".subviewbutton-back");
+ backButton.click();
+ await viewShown;
+
+ ok(true, "Main view was shown");
+
+ if (hasException) {
+ let loaded = BrowserTestUtils.browserLoaded(
+ tab.linkedBrowser,
+ false,
+ TRACKING_PAGE
+ );
+ gProtectionsHandler.enableForCurrentPage();
+ await loaded;
+ }
+
+ let loads = hasException ? 3 : 1;
+ testTelemetry(loads, 1, hasException);
+
+ BrowserTestUtils.removeTab(tab);
+}
+
+async function testCategoryItem() {
+ Services.prefs.setBoolPref(CM_PROTECTION_PREF, false);
+ let promise = BrowserTestUtils.openNewForegroundTab({
+ url: TRACKING_PAGE,
+ gBrowser,
+ });
+ let [tab] = await Promise.all([promise, waitForContentBlockingEvent()]);
+
+ await openProtectionsPanel();
+
+ let categoryItem = document.getElementById(
+ "protections-popup-category-cryptominers"
+ );
+
+ ok(
+ !categoryItem.classList.contains("blocked"),
+ "Category not marked as blocked"
+ );
+ ok(
+ categoryItem.classList.contains("notFound"),
+ "Category marked as not found"
+ );
+ Services.prefs.setBoolPref(CM_PROTECTION_PREF, true);
+ ok(categoryItem.classList.contains("blocked"), "Category marked as blocked");
+ ok(
+ categoryItem.classList.contains("notFound"),
+ "Category marked as not found"
+ );
+ Services.prefs.setBoolPref(CM_PROTECTION_PREF, false);
+ ok(
+ !categoryItem.classList.contains("blocked"),
+ "Category not marked as blocked"
+ );
+ ok(
+ categoryItem.classList.contains("notFound"),
+ "Category marked as not found"
+ );
+ await closeProtectionsPanel();
+
+ promise = waitForContentBlockingEvent();
+
+ await SpecialPowers.spawn(tab.linkedBrowser, [], function () {
+ content.postMessage("cryptomining", "*");
+ });
+
+ await promise;
+
+ await openProtectionsPanel();
+ ok(
+ !categoryItem.classList.contains("blocked"),
+ "Category not marked as blocked"
+ );
+ ok(
+ !categoryItem.classList.contains("notFound"),
+ "Category not marked as not found"
+ );
+ Services.prefs.setBoolPref(CM_PROTECTION_PREF, true);
+ ok(categoryItem.classList.contains("blocked"), "Category marked as blocked");
+ ok(
+ !categoryItem.classList.contains("notFound"),
+ "Category not marked as not found"
+ );
+ Services.prefs.setBoolPref(CM_PROTECTION_PREF, false);
+ ok(
+ !categoryItem.classList.contains("blocked"),
+ "Category not marked as blocked"
+ );
+ ok(
+ !categoryItem.classList.contains("notFound"),
+ "Category not marked as not found"
+ );
+ await closeProtectionsPanel();
+
+ BrowserTestUtils.removeTab(tab);
+}
+
+function testTelemetry(pagesVisited, pagesWithBlockableContent, hasException) {
+ let results = cmHistogram.snapshot();
+ Assert.equal(
+ results.values[0],
+ pagesVisited,
+ "The correct number of page loads have been recorded"
+ );
+ let expectedValue = hasException ? 2 : 1;
+ Assert.equal(
+ results.values[expectedValue],
+ pagesWithBlockableContent,
+ "The correct number of cryptominers have been recorded as blocked or allowed."
+ );
+}
+
+add_task(async function test() {
+ Services.prefs.setBoolPref(CM_PROTECTION_PREF, true);
+
+ await testIdentityState(false);
+ await testIdentityState(true);
+
+ await testSubview(false);
+ await testSubview(true);
+
+ await testCategoryItem();
+
+ Services.prefs.clearUserPref(CM_PROTECTION_PREF);
+ Services.prefs.setStringPref("browser.contentblocking.category", "standard");
+});
diff --git a/browser/base/content/test/protectionsUI/browser_protectionsUI_email_trackers_subview.js b/browser/base/content/test/protectionsUI/browser_protectionsUI_email_trackers_subview.js
new file mode 100644
index 0000000000..6b97b83087
--- /dev/null
+++ b/browser/base/content/test/protectionsUI/browser_protectionsUI_email_trackers_subview.js
@@ -0,0 +1,179 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Bug 1819662 - Testing the tracking category of the protection panel shows the
+ * email tracker domain if the email tracking protection is
+ * enabled
+ */
+
+const { PermissionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PermissionTestUtils.sys.mjs"
+);
+
+const TEST_PAGE =
+ "https://www.example.com/browser/browser/base/content/test/protectionsUI/emailTrackingPage.html";
+const TEST_TRACKER_PAGE = "https://itisatracker.org/";
+
+const TP_PREF = "privacy.trackingprotection.enabled";
+const EMAIL_TP_PREF = "privacy.trackingprotection.emailtracking.enabled";
+
+/**
+ * A helper function to check whether or not an element has "notFound" class.
+ *
+ * @param {String} id The id of the testing element.
+ * @returns {Boolean} true when the element has "notFound" class.
+ */
+function notFound(id) {
+ return document.getElementById(id).classList.contains("notFound");
+}
+
+/**
+ * A helper function to test the protection UI tracker category.
+ *
+ * @param {Boolean} blocked - true if the email tracking protection is enabled.
+ */
+async function assertSitesListed(blocked) {
+ let tab = await BrowserTestUtils.openNewForegroundTab({
+ url: TEST_PAGE,
+ gBrowser,
+ });
+
+ await openProtectionsPanel();
+
+ let categoryItem = document.getElementById(
+ "protections-popup-category-trackers"
+ );
+
+ if (!blocked) {
+ // The tracker category should have the 'notFound' class to indicate that
+ // no tracker was blocked in the page.
+ ok(
+ notFound("protections-popup-category-trackers"),
+ "Tracker category is not found"
+ );
+
+ ok(
+ !BrowserTestUtils.is_visible(categoryItem),
+ "TP category item is not visible"
+ );
+ BrowserTestUtils.removeTab(tab);
+
+ return;
+ }
+
+ // Testing if the tracker category is visible.
+
+ // Explicitly waiting for the category item becoming visible.
+ await BrowserTestUtils.waitForMutationCondition(categoryItem, {}, () =>
+ BrowserTestUtils.is_visible(categoryItem)
+ );
+
+ ok(BrowserTestUtils.is_visible(categoryItem), "TP category item is visible");
+
+ // Click the tracker category and wait until the tracker view is shown.
+ let trackersView = document.getElementById("protections-popup-trackersView");
+ let viewShown = BrowserTestUtils.waitForEvent(trackersView, "ViewShown");
+ categoryItem.click();
+ await viewShown;
+
+ ok(true, "Trackers view was shown");
+
+ // Ensure the email tracker is listed on the tracker list.
+ let listItems = Array.from(
+ trackersView.querySelectorAll(".protections-popup-list-item")
+ );
+ is(listItems.length, 1, "We have 1 trackers in the list");
+
+ let listItem = listItems.find(
+ item =>
+ item.querySelector("label").value == "https://email-tracking.example.org"
+ );
+ ok(listItem, "Has an item for email-tracking.example.org");
+ ok(BrowserTestUtils.is_visible(listItem), "List item is visible");
+
+ // Back to the popup main view.
+ let mainView = document.getElementById("protections-popup-mainView");
+ viewShown = BrowserTestUtils.waitForEvent(mainView, "ViewShown");
+ let backButton = trackersView.querySelector(".subviewbutton-back");
+ backButton.click();
+ await viewShown;
+
+ ok(true, "Main view was shown");
+
+ // Add an iframe to a tracker domain and wait until the content event files.
+ let contentBlockingEventPromise = waitForContentBlockingEvent(1);
+ await SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [TEST_TRACKER_PAGE],
+ test_url => {
+ let ifr = content.document.createElement("iframe");
+
+ content.document.body.appendChild(ifr);
+ ifr.src = test_url;
+ }
+ );
+ await contentBlockingEventPromise;
+
+ // Click the tracker category again.
+ viewShown = BrowserTestUtils.waitForEvent(trackersView, "ViewShown");
+ categoryItem.click();
+ await viewShown;
+
+ // Ensure both the email tracker and the tracker are listed on the tracker
+ // list.
+ listItems = Array.from(
+ trackersView.querySelectorAll(".protections-popup-list-item")
+ );
+ is(listItems.length, 2, "We have 2 trackers in the list");
+
+ listItem = listItems.find(
+ item =>
+ item.querySelector("label").value == "https://email-tracking.example.org"
+ );
+ ok(listItem, "Has an item for email-tracking.example.org");
+ ok(BrowserTestUtils.is_visible(listItem), "List item is visible");
+
+ listItem = listItems.find(
+ item => item.querySelector("label").value == "https://itisatracker.org"
+ );
+ ok(listItem, "Has an item for itisatracker.org");
+ ok(BrowserTestUtils.is_visible(listItem), "List item is visible");
+
+ BrowserTestUtils.removeTab(tab);
+}
+
+add_setup(async function () {
+ Services.prefs.setBoolPref(TP_PREF, true);
+
+ await UrlClassifierTestUtils.addTestTrackers();
+
+ registerCleanupFunction(() => {
+ Services.prefs.clearUserPref(TP_PREF);
+ UrlClassifierTestUtils.cleanupTestTrackers();
+ });
+});
+
+add_task(async function testTrackersSubView() {
+ info("Testing trackers subview with TP disabled.");
+ Services.prefs.setBoolPref(EMAIL_TP_PREF, false);
+ await assertSitesListed(false);
+ info("Testing trackers subview with TP enabled.");
+ Services.prefs.setBoolPref(EMAIL_TP_PREF, true);
+ await assertSitesListed(true);
+ info("Testing trackers subview with TP enabled and a CB exception.");
+ let uri = Services.io.newURI("https://www.example.com");
+ PermissionTestUtils.add(
+ uri,
+ "trackingprotection",
+ Services.perms.ALLOW_ACTION
+ );
+ await assertSitesListed(false);
+ info("Testing trackers subview with TP enabled and a CB exception removed.");
+ PermissionTestUtils.remove(uri, "trackingprotection");
+ await assertSitesListed(true);
+
+ Services.prefs.clearUserPref(EMAIL_TP_PREF);
+});
diff --git a/browser/base/content/test/protectionsUI/browser_protectionsUI_fetch.js b/browser/base/content/test/protectionsUI/browser_protectionsUI_fetch.js
new file mode 100644
index 0000000000..639d8982fc
--- /dev/null
+++ b/browser/base/content/test/protectionsUI/browser_protectionsUI_fetch.js
@@ -0,0 +1,39 @@
+const URL =
+ "http://mochi.test:8888/browser/browser/base/content/test/protectionsUI/file_protectionsUI_fetch.html";
+
+add_task(async function test_fetch() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["privacy.trackingprotection.enabled", true]],
+ });
+
+ await BrowserTestUtils.withNewTab(
+ { gBrowser, url: URL },
+ async function (newTabBrowser) {
+ let contentBlockingEvent = waitForContentBlockingEvent();
+ await SpecialPowers.spawn(newTabBrowser, [], async function () {
+ await content.wrappedJSObject
+ .test_fetch()
+ .then(response => Assert.ok(false, "should have denied the request"))
+ .catch(e => Assert.ok(true, `Caught exception: ${e}`));
+ });
+ await contentBlockingEvent;
+
+ let gProtectionsHandler = newTabBrowser.ownerGlobal.gProtectionsHandler;
+ ok(gProtectionsHandler, "got CB object");
+
+ ok(
+ gProtectionsHandler._protectionsPopup.hasAttribute("detected"),
+ "has detected content blocking"
+ );
+ ok(
+ gProtectionsHandler.iconBox.hasAttribute("active"),
+ "icon box is active"
+ );
+ is(
+ gProtectionsHandler._trackingProtectionIconTooltipLabel.textContent,
+ gNavigatorBundle.getString("trackingProtection.icon.activeTooltip2"),
+ "correct tooltip"
+ );
+ }
+ );
+});
diff --git a/browser/base/content/test/protectionsUI/browser_protectionsUI_fingerprinters.js b/browser/base/content/test/protectionsUI/browser_protectionsUI_fingerprinters.js
new file mode 100644
index 0000000000..aaa6745628
--- /dev/null
+++ b/browser/base/content/test/protectionsUI/browser_protectionsUI_fingerprinters.js
@@ -0,0 +1,303 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TRACKING_PAGE =
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.org/browser/browser/base/content/test/protectionsUI/trackingPage.html";
+const FP_PROTECTION_PREF = "privacy.trackingprotection.fingerprinting.enabled";
+let fpHistogram;
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "urlclassifier.features.fingerprinting.blacklistHosts",
+ "fingerprinting.example.com",
+ ],
+ [
+ "urlclassifier.features.fingerprinting.annotate.blacklistHosts",
+ "fingerprinting.example.com",
+ ],
+ ["privacy.trackingprotection.enabled", false],
+ ["privacy.trackingprotection.annotate_channels", false],
+ ["privacy.trackingprotection.cryptomining.enabled", false],
+ ["urlclassifier.features.cryptomining.annotate.blacklistHosts", ""],
+ ["urlclassifier.features.cryptomining.annotate.blacklistTables", ""],
+ ],
+ });
+ fpHistogram = Services.telemetry.getHistogramById(
+ "FINGERPRINTERS_BLOCKED_COUNT"
+ );
+ registerCleanupFunction(() => {
+ fpHistogram.clear();
+ });
+});
+
+async function testIdentityState(hasException) {
+ fpHistogram.clear();
+ let promise = BrowserTestUtils.openNewForegroundTab({
+ url: TRACKING_PAGE,
+ gBrowser,
+ });
+ let [tab] = await Promise.all([promise, waitForContentBlockingEvent()]);
+
+ if (hasException) {
+ let loaded = BrowserTestUtils.browserLoaded(
+ tab.linkedBrowser,
+ false,
+ TRACKING_PAGE
+ );
+ gProtectionsHandler.disableForCurrentPage();
+ await loaded;
+ }
+
+ await openProtectionsPanel();
+
+ ok(
+ !gProtectionsHandler._protectionsPopup.hasAttribute("detected"),
+ "fingerprinters are not detected"
+ );
+ ok(
+ !BrowserTestUtils.is_hidden(gProtectionsHandler.iconBox),
+ "icon box is visible regardless the exception"
+ );
+
+ promise = waitForContentBlockingEvent();
+
+ await SpecialPowers.spawn(tab.linkedBrowser, [], function () {
+ content.postMessage("fingerprinting", "*");
+ });
+
+ await promise;
+
+ ok(
+ gProtectionsHandler._protectionsPopup.hasAttribute("detected"),
+ "trackers are detected"
+ );
+ ok(
+ BrowserTestUtils.is_visible(gProtectionsHandler.iconBox),
+ "icon box is visible"
+ );
+ is(
+ gProtectionsHandler.iconBox.hasAttribute("hasException"),
+ hasException,
+ "Shows an exception when appropriate"
+ );
+
+ if (hasException) {
+ let loaded = BrowserTestUtils.browserLoaded(
+ tab.linkedBrowser,
+ false,
+ TRACKING_PAGE
+ );
+ gProtectionsHandler.enableForCurrentPage();
+ await loaded;
+ }
+
+ let loads = hasException ? 3 : 1;
+ testTelemetry(loads, 1, hasException);
+
+ BrowserTestUtils.removeTab(tab);
+}
+
+async function testCategoryItem() {
+ Services.prefs.setBoolPref(FP_PROTECTION_PREF, false);
+ let promise = BrowserTestUtils.openNewForegroundTab({
+ url: TRACKING_PAGE,
+ gBrowser,
+ });
+ let [tab] = await Promise.all([promise, waitForContentBlockingEvent()]);
+
+ await openProtectionsPanel();
+ let categoryItem = document.getElementById(
+ "protections-popup-category-fingerprinters"
+ );
+
+ ok(
+ !categoryItem.classList.contains("blocked"),
+ "Category not marked as blocked"
+ );
+ ok(
+ categoryItem.classList.contains("notFound"),
+ "Category marked as not found"
+ );
+ Services.prefs.setBoolPref(FP_PROTECTION_PREF, true);
+ ok(categoryItem.classList.contains("blocked"), "Category marked as blocked");
+ ok(
+ categoryItem.classList.contains("notFound"),
+ "Category marked as not found"
+ );
+ Services.prefs.setBoolPref(FP_PROTECTION_PREF, false);
+ ok(
+ !categoryItem.classList.contains("blocked"),
+ "Category not marked as blocked"
+ );
+ ok(
+ categoryItem.classList.contains("notFound"),
+ "Category marked as not found"
+ );
+ await closeProtectionsPanel();
+
+ promise = waitForContentBlockingEvent();
+
+ await SpecialPowers.spawn(tab.linkedBrowser, [], function () {
+ content.postMessage("fingerprinting", "*");
+ });
+
+ await promise;
+
+ await openProtectionsPanel();
+ ok(
+ !categoryItem.classList.contains("blocked"),
+ "Category not marked as blocked"
+ );
+ ok(
+ !categoryItem.classList.contains("notFound"),
+ "Category not marked as not found"
+ );
+ Services.prefs.setBoolPref(FP_PROTECTION_PREF, true);
+ ok(categoryItem.classList.contains("blocked"), "Category marked as blocked");
+ ok(
+ !categoryItem.classList.contains("notFound"),
+ "Category not marked as not found"
+ );
+ Services.prefs.setBoolPref(FP_PROTECTION_PREF, false);
+ ok(
+ !categoryItem.classList.contains("blocked"),
+ "Category not marked as blocked"
+ );
+ ok(
+ !categoryItem.classList.contains("notFound"),
+ "Category not marked as not found"
+ );
+ await closeProtectionsPanel();
+
+ BrowserTestUtils.removeTab(tab);
+}
+
+async function testSubview(hasException) {
+ fpHistogram.clear();
+ let promise = BrowserTestUtils.openNewForegroundTab({
+ url: TRACKING_PAGE,
+ gBrowser,
+ });
+ let [tab] = await Promise.all([promise, waitForContentBlockingEvent()]);
+
+ if (hasException) {
+ let loaded = BrowserTestUtils.browserLoaded(
+ tab.linkedBrowser,
+ false,
+ TRACKING_PAGE
+ );
+ gProtectionsHandler.disableForCurrentPage();
+ await loaded;
+ }
+
+ promise = waitForContentBlockingEvent();
+ await SpecialPowers.spawn(tab.linkedBrowser, [], function () {
+ content.postMessage("fingerprinting", "*");
+ });
+ await promise;
+
+ await openProtectionsPanel();
+
+ let categoryItem = document.getElementById(
+ "protections-popup-category-fingerprinters"
+ );
+
+ // Explicitly waiting for the category item becoming visible.
+ await TestUtils.waitForCondition(() => {
+ return BrowserTestUtils.is_visible(categoryItem);
+ });
+
+ ok(BrowserTestUtils.is_visible(categoryItem), "TP category item is visible");
+
+ /* eslint-disable mozilla/no-arbitrary-setTimeout */
+ // We have to wait until the ContentBlockingLog gets updated in the content.
+ // Unfortunately, we need to use the setTimeout here since we don't have an
+ // easy to know whether the log is updated in the content. This should be
+ // removed after the log been removed in the content (Bug 1599046).
+ await new Promise(resolve => {
+ setTimeout(resolve, 500);
+ });
+ /* eslint-enable mozilla/no-arbitrary-setTimeout */
+
+ let subview = document.getElementById("protections-popup-fingerprintersView");
+ let viewShown = BrowserTestUtils.waitForEvent(subview, "ViewShown");
+ categoryItem.click();
+ await viewShown;
+
+ let trackersViewShimHint = document.getElementById(
+ "protections-popup-fingerprintersView-shim-allow-hint"
+ );
+ ok(trackersViewShimHint.hidden, "Shim hint is hidden");
+
+ let listItems = subview.querySelectorAll(".protections-popup-list-item");
+ is(listItems.length, 1, "We have 1 item in the list");
+ let listItem = listItems[0];
+ ok(BrowserTestUtils.is_visible(listItem), "List item is visible");
+ is(
+ listItem.querySelector("label").value,
+ "https://fingerprinting.example.com",
+ "Has the correct host"
+ );
+ is(
+ listItem.classList.contains("allowed"),
+ hasException,
+ "Indicates the fingerprinter was blocked or allowed"
+ );
+
+ let mainView = document.getElementById("protections-popup-mainView");
+ viewShown = BrowserTestUtils.waitForEvent(mainView, "ViewShown");
+ let backButton = subview.querySelector(".subviewbutton-back");
+ backButton.click();
+ await viewShown;
+
+ ok(true, "Main view was shown");
+
+ if (hasException) {
+ let loaded = BrowserTestUtils.browserLoaded(
+ tab.linkedBrowser,
+ false,
+ TRACKING_PAGE
+ );
+ gProtectionsHandler.enableForCurrentPage();
+ await loaded;
+ }
+
+ let loads = hasException ? 3 : 1;
+ testTelemetry(loads, 1, hasException);
+
+ BrowserTestUtils.removeTab(tab);
+}
+
+function testTelemetry(pagesVisited, pagesWithBlockableContent, hasException) {
+ let results = fpHistogram.snapshot();
+ Assert.equal(
+ results.values[0],
+ pagesVisited,
+ "The correct number of page loads have been recorded"
+ );
+ let expectedValue = hasException ? 2 : 1;
+ Assert.equal(
+ results.values[expectedValue],
+ pagesWithBlockableContent,
+ "The correct number of fingerprinters have been recorded as blocked or allowed."
+ );
+}
+
+add_task(async function test() {
+ Services.prefs.setBoolPref(FP_PROTECTION_PREF, true);
+
+ await testIdentityState(false);
+ await testIdentityState(true);
+
+ await testSubview(false);
+ await testSubview(true);
+
+ await testCategoryItem();
+
+ Services.prefs.clearUserPref(FP_PROTECTION_PREF);
+});
diff --git a/browser/base/content/test/protectionsUI/browser_protectionsUI_icon_state.js b/browser/base/content/test/protectionsUI/browser_protectionsUI_icon_state.js
new file mode 100644
index 0000000000..187a777850
--- /dev/null
+++ b/browser/base/content/test/protectionsUI/browser_protectionsUI_icon_state.js
@@ -0,0 +1,223 @@
+/* eslint-disable mozilla/no-arbitrary-setTimeout */
+/*
+ * Test that the Content Blocking icon state is properly updated in the identity
+ * block when loading tabs and switching between tabs.
+ * See also Bug 1175858.
+ */
+
+const TP_PREF = "privacy.trackingprotection.enabled";
+const TP_PB_PREF = "privacy.trackingprotection.pbmode.enabled";
+const APS_PREF =
+ "privacy.partition.always_partition_third_party_non_cookie_storage";
+const NCB_PREF = "network.cookie.cookieBehavior";
+const BENIGN_PAGE =
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://tracking.example.org/browser/browser/base/content/test/protectionsUI/benignPage.html";
+const TRACKING_PAGE =
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://tracking.example.org/browser/browser/base/content/test/protectionsUI/trackingPage.html";
+const COOKIE_PAGE =
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://tracking.example.org/browser/browser/base/content/test/protectionsUI/cookiePage.html";
+const DTSCBN_PREF = "dom.testing.sync-content-blocking-notifications";
+
+registerCleanupFunction(function () {
+ UrlClassifierTestUtils.cleanupTestTrackers();
+ Services.prefs.clearUserPref(TP_PREF);
+ Services.prefs.clearUserPref(TP_PB_PREF);
+ Services.prefs.clearUserPref(NCB_PREF);
+ Services.prefs.clearUserPref(DTSCBN_PREF);
+});
+
+async function testTrackingProtectionIconState(tabbrowser) {
+ Services.prefs.setBoolPref(DTSCBN_PREF, true);
+
+ info("Load a test page not containing tracking elements");
+ let benignTab = await BrowserTestUtils.openNewForegroundTab(
+ tabbrowser,
+ BENIGN_PAGE
+ );
+ let gProtectionsHandler = tabbrowser.ownerGlobal.gProtectionsHandler;
+
+ ok(!gProtectionsHandler.iconBox.hasAttribute("active"), "iconBox not active");
+
+ info("Load a test page containing tracking elements");
+ let trackingTab = await BrowserTestUtils.openNewForegroundTab(
+ tabbrowser,
+ TRACKING_PAGE
+ );
+
+ ok(gProtectionsHandler.iconBox.hasAttribute("active"), "iconBox active");
+
+ info("Load a test page containing tracking cookies");
+ let trackingCookiesTab = await BrowserTestUtils.openNewForegroundTab(
+ tabbrowser,
+ COOKIE_PAGE
+ );
+
+ ok(gProtectionsHandler.iconBox.hasAttribute("active"), "iconBox active");
+
+ info("Switch from tracking cookie -> benign tab");
+ let securityChanged = waitForSecurityChange(1, tabbrowser.ownerGlobal);
+ tabbrowser.selectedTab = benignTab;
+ await securityChanged;
+
+ ok(!gProtectionsHandler.iconBox.hasAttribute("active"), "iconBox not active");
+
+ info("Switch from benign -> tracking tab");
+ securityChanged = waitForSecurityChange(1, tabbrowser.ownerGlobal);
+ tabbrowser.selectedTab = trackingTab;
+ await securityChanged;
+
+ ok(gProtectionsHandler.iconBox.hasAttribute("active"), "iconBox active");
+
+ info("Switch from tracking -> tracking cookies tab");
+ securityChanged = waitForSecurityChange(1, tabbrowser.ownerGlobal);
+ tabbrowser.selectedTab = trackingCookiesTab;
+ await securityChanged;
+
+ ok(gProtectionsHandler.iconBox.hasAttribute("active"), "iconBox active");
+
+ info("Reload tracking cookies tab");
+ securityChanged = waitForSecurityChange(1, tabbrowser.ownerGlobal);
+ let contentBlockingEvent = waitForContentBlockingEvent(
+ 2,
+ tabbrowser.ownerGlobal
+ );
+ tabbrowser.reload();
+ await Promise.all([securityChanged, contentBlockingEvent]);
+
+ ok(gProtectionsHandler.iconBox.hasAttribute("active"), "iconBox active");
+
+ info("Reload tracking tab");
+ securityChanged = waitForSecurityChange(2, tabbrowser.ownerGlobal);
+ contentBlockingEvent = waitForContentBlockingEvent(3, tabbrowser.ownerGlobal);
+ tabbrowser.selectedTab = trackingTab;
+ tabbrowser.reload();
+ await Promise.all([securityChanged, contentBlockingEvent]);
+
+ ok(gProtectionsHandler.iconBox.hasAttribute("active"), "iconBox active");
+
+ info("Inject tracking cookie inside tracking tab");
+ securityChanged = waitForSecurityChange(1, tabbrowser.ownerGlobal);
+ let timeoutPromise = new Promise(resolve => setTimeout(resolve, 500));
+ await SpecialPowers.spawn(tabbrowser.selectedBrowser, [], function () {
+ content.postMessage("cookie", "*");
+ });
+ let result = await Promise.race([securityChanged, timeoutPromise]);
+ is(result, undefined, "No securityChange events should be received");
+
+ ok(gProtectionsHandler.iconBox.hasAttribute("active"), "iconBox active");
+
+ info("Inject tracking element inside tracking tab");
+ securityChanged = waitForSecurityChange(1, tabbrowser.ownerGlobal);
+ timeoutPromise = new Promise(resolve => setTimeout(resolve, 500));
+ await SpecialPowers.spawn(tabbrowser.selectedBrowser, [], function () {
+ content.postMessage("tracking", "*");
+ });
+ result = await Promise.race([securityChanged, timeoutPromise]);
+ is(result, undefined, "No securityChange events should be received");
+
+ ok(gProtectionsHandler.iconBox.hasAttribute("active"), "iconBox active");
+
+ tabbrowser.selectedTab = trackingCookiesTab;
+
+ info("Inject tracking cookie inside tracking cookies tab");
+ securityChanged = waitForSecurityChange(1, tabbrowser.ownerGlobal);
+ timeoutPromise = new Promise(resolve => setTimeout(resolve, 500));
+ await SpecialPowers.spawn(tabbrowser.selectedBrowser, [], function () {
+ content.postMessage("cookie", "*");
+ });
+ result = await Promise.race([securityChanged, timeoutPromise]);
+ is(result, undefined, "No securityChange events should be received");
+
+ ok(gProtectionsHandler.iconBox.hasAttribute("active"), "iconBox active");
+
+ info("Inject tracking element inside tracking cookies tab");
+ securityChanged = waitForSecurityChange(1, tabbrowser.ownerGlobal);
+ timeoutPromise = new Promise(resolve => setTimeout(resolve, 500));
+ await SpecialPowers.spawn(tabbrowser.selectedBrowser, [], function () {
+ content.postMessage("tracking", "*");
+ });
+ result = await Promise.race([securityChanged, timeoutPromise]);
+ is(result, undefined, "No securityChange events should be received");
+
+ ok(gProtectionsHandler.iconBox.hasAttribute("active"), "iconBox active");
+
+ while (tabbrowser.tabs.length > 1) {
+ tabbrowser.removeCurrentTab();
+ }
+}
+
+add_task(async function testNormalBrowsing() {
+ await SpecialPowers.pushPrefEnv({ set: [[APS_PREF, false]] });
+
+ await UrlClassifierTestUtils.addTestTrackers();
+
+ let gProtectionsHandler = gBrowser.ownerGlobal.gProtectionsHandler;
+ ok(
+ gProtectionsHandler,
+ "gProtectionsHandler is attached to the browser window"
+ );
+
+ let { TrackingProtection } =
+ gBrowser.ownerGlobal.gProtectionsHandler.blockers;
+ ok(TrackingProtection, "TP is attached to the browser window");
+
+ let { ThirdPartyCookies } = gBrowser.ownerGlobal.gProtectionsHandler.blockers;
+ ok(ThirdPartyCookies, "TPC is attached to the browser window");
+
+ Services.prefs.setBoolPref(TP_PREF, true);
+ ok(TrackingProtection.enabled, "TP is enabled after setting the pref");
+ Services.prefs.setIntPref(
+ NCB_PREF,
+ Ci.nsICookieService.BEHAVIOR_REJECT_TRACKER
+ );
+ ok(
+ ThirdPartyCookies.enabled,
+ "ThirdPartyCookies is enabled after setting the pref"
+ );
+
+ await testTrackingProtectionIconState(gBrowser);
+});
+
+add_task(async function testPrivateBrowsing() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [APS_PREF, false],
+ ["dom.security.https_first_pbm", false],
+ ],
+ });
+
+ let privateWin = await BrowserTestUtils.openNewBrowserWindow({
+ private: true,
+ });
+ let tabbrowser = privateWin.gBrowser;
+
+ let gProtectionsHandler = tabbrowser.ownerGlobal.gProtectionsHandler;
+ ok(
+ gProtectionsHandler,
+ "gProtectionsHandler is attached to the private window"
+ );
+ let { TrackingProtection } =
+ tabbrowser.ownerGlobal.gProtectionsHandler.blockers;
+ ok(TrackingProtection, "TP is attached to the private window");
+ let { ThirdPartyCookies } =
+ tabbrowser.ownerGlobal.gProtectionsHandler.blockers;
+ ok(ThirdPartyCookies, "TPC is attached to the browser window");
+
+ Services.prefs.setBoolPref(TP_PB_PREF, true);
+ ok(TrackingProtection.enabled, "TP is enabled after setting the pref");
+ Services.prefs.setIntPref(
+ NCB_PREF,
+ Ci.nsICookieService.BEHAVIOR_REJECT_TRACKER
+ );
+ ok(
+ ThirdPartyCookies.enabled,
+ "ThirdPartyCookies is enabled after setting the pref"
+ );
+
+ await testTrackingProtectionIconState(tabbrowser);
+
+ privateWin.close();
+});
diff --git a/browser/base/content/test/protectionsUI/browser_protectionsUI_milestones.js b/browser/base/content/test/protectionsUI/browser_protectionsUI_milestones.js
new file mode 100644
index 0000000000..9909e5b876
--- /dev/null
+++ b/browser/base/content/test/protectionsUI/browser_protectionsUI_milestones.js
@@ -0,0 +1,95 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ // Hide protections cards so as not to trigger more async messaging
+ // when landing on the page.
+ ["browser.contentblocking.report.monitor.enabled", false],
+ ["browser.contentblocking.report.lockwise.enabled", false],
+ ["browser.contentblocking.report.proxy.enabled", false],
+ ["browser.contentblocking.cfr-milestone.update-interval", 0],
+ ],
+ });
+});
+
+add_task(async function doTest() {
+ // This also ensures that the DB tables have been initialized.
+ await TrackingDBService.clearAll();
+
+ let milestones = JSON.parse(
+ Services.prefs.getStringPref(
+ "browser.contentblocking.cfr-milestone.milestones"
+ )
+ );
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "https://example.com"
+ );
+
+ for (let milestone of milestones) {
+ Services.telemetry.clearEvents();
+ // Trigger the milestone feature.
+ Services.prefs.setIntPref(
+ "browser.contentblocking.cfr-milestone.milestone-achieved",
+ milestone
+ );
+ await TestUtils.waitForCondition(
+ () => gProtectionsHandler._milestoneTextSet
+ );
+ // We set the shown-time pref to pretend that the CFR has been
+ // shown, so that we can test the panel.
+ // TODO: Full integration test for robustness.
+ Services.prefs.setStringPref(
+ "browser.contentblocking.cfr-milestone.milestone-shown-time",
+ Date.now().toString()
+ );
+ await openProtectionsPanel();
+
+ ok(
+ BrowserTestUtils.is_visible(
+ gProtectionsHandler._protectionsPopupMilestonesText
+ ),
+ "Milestones section should be visible in the panel."
+ );
+
+ await closeProtectionsPanel();
+ await openProtectionsPanel();
+
+ ok(
+ BrowserTestUtils.is_visible(
+ gProtectionsHandler._protectionsPopupMilestonesText
+ ),
+ "Milestones section should still be visible in the panel."
+ );
+
+ let newTabPromise = waitForAboutProtectionsTab();
+ await EventUtils.synthesizeMouseAtCenter(
+ document.getElementById("protections-popup-milestones-content"),
+ {}
+ );
+ let protectionsTab = await newTabPromise;
+
+ ok(true, "about:protections has been opened as expected.");
+
+ BrowserTestUtils.removeTab(protectionsTab);
+
+ await openProtectionsPanel();
+
+ ok(
+ !BrowserTestUtils.is_visible(
+ gProtectionsHandler._protectionsPopupMilestonesText
+ ),
+ "Milestones section should no longer be visible in the panel."
+ );
+
+ checkClickTelemetry("milestone_message");
+
+ await closeProtectionsPanel();
+ }
+
+ BrowserTestUtils.removeTab(tab);
+ await TrackingDBService.clearAll();
+});
diff --git a/browser/base/content/test/protectionsUI/browser_protectionsUI_open_preferences.js b/browser/base/content/test/protectionsUI/browser_protectionsUI_open_preferences.js
new file mode 100644
index 0000000000..c50e93b9d2
--- /dev/null
+++ b/browser/base/content/test/protectionsUI/browser_protectionsUI_open_preferences.js
@@ -0,0 +1,155 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TP_PREF = "privacy.trackingprotection.enabled";
+const TPC_PREF = "network.cookie.cookieBehavior";
+const TRACKING_PAGE =
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://tracking.example.org/browser/browser/base/content/test/protectionsUI/trackingPage.html";
+const COOKIE_PAGE =
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://tracking.example.com/browser/browser/base/content/test/protectionsUI/cookiePage.html";
+
+async function waitAndAssertPreferencesShown(_spotlight) {
+ await BrowserTestUtils.waitForEvent(
+ gProtectionsHandler._protectionsPopup,
+ "popuphidden"
+ );
+ await TestUtils.waitForCondition(
+ () => gBrowser.currentURI.spec == "about:preferences#privacy",
+ "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_setup(async function () {
+ await UrlClassifierTestUtils.addTestTrackers();
+ let oldCanRecord = Services.telemetry.canRecordExtended;
+ Services.telemetry.canRecordExtended = true;
+
+ registerCleanupFunction(() => {
+ Services.telemetry.canRecordExtended = oldCanRecord;
+ UrlClassifierTestUtils.cleanupTestTrackers();
+ });
+});
+
+// Tests that pressing the preferences button in the trackers subview
+// links to about:preferences
+add_task(async function testOpenPreferencesFromTrackersSubview() {
+ Services.prefs.setBoolPref(TP_PREF, true);
+
+ let promise = BrowserTestUtils.openNewForegroundTab({
+ url: TRACKING_PAGE,
+ gBrowser,
+ });
+
+ // Wait for 2 content blocking events - one for the load and one for the tracker.
+ let [tab] = await Promise.all([promise, waitForContentBlockingEvent(2)]);
+
+ await openProtectionsPanel();
+
+ let categoryItem = document.getElementById(
+ "protections-popup-category-trackers"
+ );
+
+ // Explicitly waiting for the category item becoming visible.
+ await TestUtils.waitForCondition(() => {
+ return BrowserTestUtils.is_visible(categoryItem);
+ });
+
+ ok(BrowserTestUtils.is_visible(categoryItem), "TP category item is visible");
+ let trackersView = document.getElementById("protections-popup-trackersView");
+ let viewShown = BrowserTestUtils.waitForEvent(trackersView, "ViewShown");
+ categoryItem.click();
+ await viewShown;
+
+ ok(true, "Trackers view was shown");
+
+ let preferencesButton = document.getElementById(
+ "protections-popup-trackersView-settings-button"
+ );
+
+ ok(
+ BrowserTestUtils.is_visible(preferencesButton),
+ "The preferences button is shown."
+ );
+
+ let shown = waitAndAssertPreferencesShown("trackingprotection");
+ preferencesButton.click();
+ await shown;
+ BrowserTestUtils.removeTab(tab);
+
+ Services.prefs.clearUserPref(TP_PREF);
+});
+
+// Tests that pressing the preferences button in the cookies subview
+// links to about:preferences
+add_task(async function testOpenPreferencesFromCookiesSubview() {
+ Services.prefs.setIntPref(
+ TPC_PREF,
+ Ci.nsICookieService.BEHAVIOR_REJECT_TRACKER
+ );
+
+ let promise = BrowserTestUtils.openNewForegroundTab({
+ url: COOKIE_PAGE,
+ gBrowser,
+ });
+
+ // Wait for 2 content blocking events - one for the load and one for the tracker.
+ let [tab] = await Promise.all([promise, waitForContentBlockingEvent(2)]);
+
+ await openProtectionsPanel();
+
+ let categoryItem = document.getElementById(
+ "protections-popup-category-cookies"
+ );
+
+ // Explicitly waiting for the category item becoming visible.
+ await TestUtils.waitForCondition(() => {
+ return BrowserTestUtils.is_visible(categoryItem);
+ });
+
+ ok(BrowserTestUtils.is_visible(categoryItem), "TP category item is visible");
+ let cookiesView = document.getElementById("protections-popup-cookiesView");
+ let viewShown = BrowserTestUtils.waitForEvent(cookiesView, "ViewShown");
+ categoryItem.click();
+ await viewShown;
+
+ ok(true, "Cookies view was shown");
+
+ let preferencesButton = document.getElementById(
+ "protections-popup-cookiesView-settings-button"
+ );
+
+ ok(
+ BrowserTestUtils.is_visible(preferencesButton),
+ "The preferences button is shown."
+ );
+
+ let shown = waitAndAssertPreferencesShown("trackingprotection");
+ preferencesButton.click();
+ await shown;
+ BrowserTestUtils.removeTab(tab);
+
+ Services.prefs.clearUserPref(TPC_PREF);
+});
diff --git a/browser/base/content/test/protectionsUI/browser_protectionsUI_pbmode_exceptions.js b/browser/base/content/test/protectionsUI/browser_protectionsUI_pbmode_exceptions.js
new file mode 100644
index 0000000000..ebe67ea0c0
--- /dev/null
+++ b/browser/base/content/test/protectionsUI/browser_protectionsUI_pbmode_exceptions.js
@@ -0,0 +1,175 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Test that sites added to the Tracking Protection whitelist in private
+// browsing mode don't persist once the private browsing window closes.
+
+const TP_PB_PREF = "privacy.trackingprotection.enabled";
+const TRACKING_PAGE =
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://tracking.example.org/browser/browser/base/content/test/protectionsUI/trackingPage.html";
+const DTSCBN_PREF = "dom.testing.sync-content-blocking-notifications";
+var TrackingProtection = null;
+var gProtectionsHandler = null;
+var browser = null;
+
+registerCleanupFunction(function () {
+ Services.prefs.clearUserPref(TP_PB_PREF);
+ Services.prefs.clearUserPref(DTSCBN_PREF);
+ gProtectionsHandler = TrackingProtection = browser = null;
+ UrlClassifierTestUtils.cleanupTestTrackers();
+});
+
+function hidden(sel) {
+ let win = browser.ownerGlobal;
+ let el = win.document.querySelector(sel);
+ let display = win.getComputedStyle(el).getPropertyValue("display", null);
+ return display === "none";
+}
+
+function protectionsPopupState() {
+ let win = browser.ownerGlobal;
+ return win.document.getElementById("protections-popup")?.state || "closed";
+}
+
+function clickButton(sel) {
+ let win = browser.ownerGlobal;
+ let el = win.document.querySelector(sel);
+ el.doCommand();
+}
+
+function testTrackingPage() {
+ info("Tracking content must be blocked");
+ ok(gProtectionsHandler.anyDetected, "trackers are detected");
+ ok(!gProtectionsHandler.hasException, "content shows no exception");
+
+ ok(
+ BrowserTestUtils.is_visible(gProtectionsHandler.iconBox),
+ "icon box is visible"
+ );
+ ok(gProtectionsHandler.iconBox.hasAttribute("active"), "shield is active");
+ ok(
+ !gProtectionsHandler.iconBox.hasAttribute("hasException"),
+ "icon box shows no exception"
+ );
+ is(
+ gProtectionsHandler._trackingProtectionIconTooltipLabel.textContent,
+ gNavigatorBundle.getString("trackingProtection.icon.activeTooltip2"),
+ "correct tooltip"
+ );
+}
+
+function testTrackingPageUnblocked() {
+ info("Tracking content must be allowlisted and not blocked");
+ ok(gProtectionsHandler.anyDetected, "trackers are detected");
+ ok(gProtectionsHandler.hasException, "content shows exception");
+
+ ok(!gProtectionsHandler.iconBox.hasAttribute("active"), "shield is active");
+ ok(
+ gProtectionsHandler.iconBox.hasAttribute("hasException"),
+ "shield shows exception"
+ );
+ is(
+ gProtectionsHandler._trackingProtectionIconTooltipLabel.textContent,
+ gNavigatorBundle.getString("trackingProtection.icon.disabledTooltip2"),
+ "correct tooltip"
+ );
+
+ ok(
+ BrowserTestUtils.is_visible(gProtectionsHandler.iconBox),
+ "icon box is visible"
+ );
+}
+
+add_task(async function testExceptionAddition() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["dom.security.https_first_pbm", false]],
+ });
+
+ await UrlClassifierTestUtils.addTestTrackers();
+ Services.prefs.setBoolPref(DTSCBN_PREF, true);
+ let privateWin = await BrowserTestUtils.openNewBrowserWindow({
+ private: true,
+ });
+ browser = privateWin.gBrowser;
+ let tab = await BrowserTestUtils.openNewForegroundTab(browser);
+
+ gProtectionsHandler = browser.ownerGlobal.gProtectionsHandler;
+ ok(gProtectionsHandler, "CB is attached to the private window");
+
+ TrackingProtection =
+ browser.ownerGlobal.gProtectionsHandler.blockers.TrackingProtection;
+ ok(TrackingProtection, "TP is attached to the private window");
+
+ Services.prefs.setBoolPref(TP_PB_PREF, true);
+ ok(TrackingProtection.enabled, "TP is enabled after setting the pref");
+
+ info("Load a test page containing tracking elements");
+ await Promise.all([
+ promiseTabLoadEvent(tab, TRACKING_PAGE),
+ waitForContentBlockingEvent(2, tab.ownerGlobal),
+ ]);
+
+ testTrackingPage(tab.ownerGlobal);
+
+ info("Disable TP for the page (which reloads the page)");
+ let tabReloadPromise = promiseTabLoadEvent(tab);
+ gProtectionsHandler.disableForCurrentPage();
+ is(protectionsPopupState(), "closed", "protections popup is closed");
+
+ await tabReloadPromise;
+ testTrackingPageUnblocked();
+
+ info(
+ "Test that the exception is remembered across tabs in the same private window"
+ );
+ tab = browser.selectedTab = BrowserTestUtils.addTab(browser);
+
+ info("Load a test page containing tracking elements");
+ await promiseTabLoadEvent(tab, TRACKING_PAGE);
+ testTrackingPageUnblocked();
+
+ await BrowserTestUtils.closeWindow(privateWin);
+});
+
+add_task(async function testExceptionPersistence() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["dom.security.https_first_pbm", false]],
+ });
+
+ info("Open another private browsing window");
+ let privateWin = await BrowserTestUtils.openNewBrowserWindow({
+ private: true,
+ });
+ browser = privateWin.gBrowser;
+ let tab = await BrowserTestUtils.openNewForegroundTab(browser);
+
+ gProtectionsHandler = browser.ownerGlobal.gProtectionsHandler;
+ ok(gProtectionsHandler, "CB is attached to the private window");
+ TrackingProtection =
+ browser.ownerGlobal.gProtectionsHandler.blockers.TrackingProtection;
+ ok(TrackingProtection, "TP is attached to the private window");
+
+ ok(TrackingProtection.enabled, "TP is still enabled");
+
+ info("Load a test page containing tracking elements");
+ await Promise.all([
+ promiseTabLoadEvent(tab, TRACKING_PAGE),
+ waitForContentBlockingEvent(2, tab.ownerGlobal),
+ ]);
+
+ testTrackingPage(tab.ownerGlobal);
+
+ info("Disable TP for the page (which reloads the page)");
+ let tabReloadPromise = promiseTabLoadEvent(tab);
+ gProtectionsHandler.disableForCurrentPage();
+ is(protectionsPopupState(), "closed", "protections popup is closed");
+
+ await Promise.all([
+ tabReloadPromise,
+ waitForContentBlockingEvent(2, tab.ownerGlobal),
+ ]);
+ testTrackingPageUnblocked();
+
+ await BrowserTestUtils.closeWindow(privateWin);
+});
diff --git a/browser/base/content/test/protectionsUI/browser_protectionsUI_report_breakage.js b/browser/base/content/test/protectionsUI/browser_protectionsUI_report_breakage.js
new file mode 100644
index 0000000000..b5400954db
--- /dev/null
+++ b/browser/base/content/test/protectionsUI/browser_protectionsUI_report_breakage.js
@@ -0,0 +1,404 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TRACKING_PAGE =
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://tracking.example.org/browser/browser/base/content/test/protectionsUI/trackingPage.html";
+const BENIGN_PAGE =
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://tracking.example.org/browser/browser/base/content/test/protectionsUI/benignPage.html";
+const COOKIE_PAGE =
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://not-tracking.example.com/browser/browser/base/content/test/protectionsUI/cookiePage.html";
+
+const CM_PREF = "privacy.trackingprotection.cryptomining.enabled";
+const FP_PREF = "privacy.trackingprotection.fingerprinting.enabled";
+const TP_PREF = "privacy.trackingprotection.enabled";
+const CB_PREF = "network.cookie.cookieBehavior";
+
+const PREF_REPORT_BREAKAGE_URL = "browser.contentblocking.reportBreakage.url";
+
+let { HttpServer } = ChromeUtils.import("resource://testing-common/httpd.js");
+let { CommonUtils } = ChromeUtils.importESModule(
+ "resource://services-common/utils.sys.mjs"
+);
+let { Preferences } = ChromeUtils.importESModule(
+ "resource://gre/modules/Preferences.sys.mjs"
+);
+
+add_setup(async function () {
+ await UrlClassifierTestUtils.addTestTrackers();
+
+ registerCleanupFunction(() => {
+ // Clear prefs that are touched in this test again for sanity.
+ Services.prefs.clearUserPref(TP_PREF);
+ Services.prefs.clearUserPref(CB_PREF);
+ Services.prefs.clearUserPref(FP_PREF);
+ Services.prefs.clearUserPref(CM_PREF);
+ Services.prefs.clearUserPref(PREF_REPORT_BREAKAGE_URL);
+
+ UrlClassifierTestUtils.cleanupTestTrackers();
+ });
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [
+ "urlclassifier.features.fingerprinting.blacklistHosts",
+ "fingerprinting.example.com",
+ ],
+ [
+ "urlclassifier.features.fingerprinting.annotate.blacklistHosts",
+ "fingerprinting.example.com",
+ ],
+ ["privacy.trackingprotection.cryptomining.enabled", true],
+ [
+ "urlclassifier.features.cryptomining.blacklistHosts",
+ "cryptomining.example.com",
+ ],
+ [
+ "urlclassifier.features.cryptomining.annotate.blacklistHosts",
+ "cryptomining.example.com",
+ ],
+ ],
+ });
+});
+
+add_task(async function testReportBreakageCancel() {
+ Services.prefs.setBoolPref(TP_PREF, true);
+
+ await BrowserTestUtils.withNewTab(TRACKING_PAGE, async function () {
+ await openProtectionsPanel();
+ await TestUtils.waitForCondition(() =>
+ gProtectionsHandler._protectionsPopup.hasAttribute("blocking")
+ );
+
+ let siteNotWorkingButton = document.getElementById(
+ "protections-popup-tp-switch-breakage-link"
+ );
+ ok(
+ BrowserTestUtils.is_visible(siteNotWorkingButton),
+ "site not working button is visible"
+ );
+ let siteNotWorkingView = document.getElementById(
+ "protections-popup-siteNotWorkingView"
+ );
+ let viewShown = BrowserTestUtils.waitForEvent(
+ siteNotWorkingView,
+ "ViewShown"
+ );
+ siteNotWorkingButton.click();
+ await viewShown;
+
+ let sendReportButton = document.getElementById(
+ "protections-popup-siteNotWorkingView-sendReport"
+ );
+ let sendReportView = document.getElementById(
+ "protections-popup-sendReportView"
+ );
+ viewShown = BrowserTestUtils.waitForEvent(sendReportView, "ViewShown");
+ sendReportButton.click();
+ await viewShown;
+
+ ok(true, "Report breakage view was shown");
+
+ viewShown = BrowserTestUtils.waitForEvent(siteNotWorkingView, "ViewShown");
+ let cancelButton = document.getElementById(
+ "protections-popup-sendReportView-cancel"
+ );
+ cancelButton.click();
+ await viewShown;
+
+ ok(true, "Main view was shown");
+ });
+
+ Services.prefs.clearUserPref(TP_PREF);
+});
+
+add_task(async function testReportBreakageSiteException() {
+ Services.prefs.setBoolPref(TP_PREF, true);
+
+ let url = TRACKING_PAGE + "?a=b&1=abc&unicode=🦊";
+
+ await BrowserTestUtils.withNewTab(url, async browser => {
+ let loaded = BrowserTestUtils.browserLoaded(browser, false);
+ gProtectionsHandler.disableForCurrentPage();
+ await loaded;
+
+ await openProtectionsPanel();
+
+ let siteFixedButton = document.getElementById(
+ "protections-popup-tp-switch-breakage-fixed-link"
+ );
+ ok(
+ BrowserTestUtils.is_visible(siteFixedButton),
+ "site fixed button is visible"
+ );
+ let sendReportView = document.getElementById(
+ "protections-popup-sendReportView"
+ );
+ let viewShown = BrowserTestUtils.waitForEvent(sendReportView, "ViewShown");
+ siteFixedButton.click();
+ await viewShown;
+
+ ok(true, "Report breakage view was shown");
+
+ await testReportBreakageSubmit(
+ TRACKING_PAGE,
+ "trackingprotection",
+ false,
+ true
+ );
+
+ // Pass false for shouldReload - there's no need since the tab is going away.
+ gProtectionsHandler.enableForCurrentPage(false);
+ });
+
+ Services.prefs.clearUserPref(TP_PREF);
+});
+
+add_task(async function testNoTracking() {
+ await BrowserTestUtils.withNewTab(BENIGN_PAGE, async function () {
+ await openProtectionsPanel();
+
+ let siteNotWorkingButton = document.getElementById(
+ "protections-popup-tp-switch-breakage-link"
+ );
+ ok(
+ BrowserTestUtils.is_hidden(siteNotWorkingButton),
+ "site not working button is not visible"
+ );
+ });
+});
+
+add_task(async function testReportBreakageError() {
+ Services.prefs.setBoolPref(TP_PREF, true);
+ // Make sure that we correctly strip the query.
+ let url = TRACKING_PAGE + "?a=b&1=abc&unicode=🦊";
+ await BrowserTestUtils.withNewTab(url, async function () {
+ await openAndTestReportBreakage(TRACKING_PAGE, "trackingprotection", true);
+ });
+
+ Services.prefs.clearUserPref(TP_PREF);
+});
+
+add_task(async function testTP() {
+ Services.prefs.setBoolPref(TP_PREF, true);
+ // Make sure that we correctly strip the query.
+ let url = TRACKING_PAGE + "?a=b&1=abc&unicode=🦊";
+ await BrowserTestUtils.withNewTab(url, async function () {
+ await openAndTestReportBreakage(TRACKING_PAGE, "trackingprotection");
+ });
+
+ Services.prefs.clearUserPref(TP_PREF);
+});
+
+add_task(async function testCR() {
+ Services.prefs.setIntPref(
+ CB_PREF,
+ Ci.nsICookieService.BEHAVIOR_REJECT_TRACKER
+ );
+ // Make sure that we correctly strip the query.
+ let url = COOKIE_PAGE + "?a=b&1=abc&unicode=🦊";
+ await BrowserTestUtils.withNewTab(url, async function () {
+ await openAndTestReportBreakage(COOKIE_PAGE, "cookierestrictions");
+ });
+
+ Services.prefs.clearUserPref(CB_PREF);
+});
+
+add_task(async function testFP() {
+ Services.prefs.setIntPref(CB_PREF, Ci.nsICookieService.BEHAVIOR_ACCEPT);
+ Services.prefs.setBoolPref(FP_PREF, true);
+ // Make sure that we correctly strip the query.
+ let url = TRACKING_PAGE + "?a=b&1=abc&unicode=🦊";
+ await BrowserTestUtils.withNewTab(url, async function (browser) {
+ let promise = waitForContentBlockingEvent();
+ await SpecialPowers.spawn(browser, [], function () {
+ content.postMessage("fingerprinting", "*");
+ });
+ await promise;
+
+ await openAndTestReportBreakage(TRACKING_PAGE, "fingerprinting", true);
+ });
+
+ Services.prefs.clearUserPref(FP_PREF);
+ Services.prefs.clearUserPref(CB_PREF);
+});
+
+add_task(async function testCM() {
+ Services.prefs.setIntPref(CB_PREF, Ci.nsICookieService.BEHAVIOR_ACCEPT);
+ Services.prefs.setBoolPref(CM_PREF, true);
+ // Make sure that we correctly strip the query.
+ let url = TRACKING_PAGE + "?a=b&1=abc&unicode=🦊";
+ await BrowserTestUtils.withNewTab(url, async function (browser) {
+ let promise = waitForContentBlockingEvent();
+ await SpecialPowers.spawn(browser, [], function () {
+ content.postMessage("cryptomining", "*");
+ });
+ await promise;
+
+ await openAndTestReportBreakage(TRACKING_PAGE, "cryptomining", true);
+ });
+
+ Services.prefs.clearUserPref(CM_PREF);
+ Services.prefs.clearUserPref(CB_PREF);
+});
+
+async function openAndTestReportBreakage(url, tags, error = false) {
+ await openProtectionsPanel();
+
+ let siteNotWorkingButton = document.getElementById(
+ "protections-popup-tp-switch-breakage-link"
+ );
+ ok(
+ BrowserTestUtils.is_visible(siteNotWorkingButton),
+ "site not working button is visible"
+ );
+ let siteNotWorkingView = document.getElementById(
+ "protections-popup-siteNotWorkingView"
+ );
+ let viewShown = BrowserTestUtils.waitForEvent(
+ siteNotWorkingView,
+ "ViewShown"
+ );
+ siteNotWorkingButton.click();
+ await viewShown;
+
+ let sendReportButton = document.getElementById(
+ "protections-popup-siteNotWorkingView-sendReport"
+ );
+ let sendReportView = document.getElementById(
+ "protections-popup-sendReportView"
+ );
+ viewShown = BrowserTestUtils.waitForEvent(sendReportView, "ViewShown");
+ sendReportButton.click();
+ await viewShown;
+
+ ok(true, "Report breakage view was shown");
+
+ await testReportBreakageSubmit(url, tags, error, false);
+}
+
+// This function assumes that the breakage report view is ready.
+async function testReportBreakageSubmit(url, tags, error, hasException) {
+ // Setup a mock server for receiving breakage reports.
+ let server = new HttpServer();
+ server.start(-1);
+ let i = server.identity;
+ let path =
+ i.primaryScheme + "://" + i.primaryHost + ":" + i.primaryPort + "/";
+
+ Services.prefs.setStringPref(PREF_REPORT_BREAKAGE_URL, path);
+
+ let comments = document.getElementById(
+ "protections-popup-sendReportView-collection-comments"
+ );
+ is(comments.value, "", "Comments textarea should initially be empty");
+
+ let submitButton = document.getElementById(
+ "protections-popup-sendReportView-submit"
+ );
+ let reportURL = document.getElementById(
+ "protections-popup-sendReportView-collection-url"
+ ).value;
+
+ is(reportURL, url, "Shows the correct URL in the report UI.");
+
+ // Make sure that sending the report closes the identity popup.
+ let popuphidden = BrowserTestUtils.waitForEvent(
+ gProtectionsHandler._protectionsPopup,
+ "popuphidden"
+ );
+
+ // Check that we're receiving a good report.
+ await new Promise(resolve => {
+ server.registerPathHandler("/", async (request, response) => {
+ is(request.method, "POST", "request was a post");
+
+ // Extract and "parse" the form data in the request body.
+ let body = CommonUtils.readBytesFromInputStream(request.bodyInputStream);
+ let boundary = request
+ .getHeader("Content-Type")
+ .match(/boundary=-+([^-]*)/i)[1];
+ let regex = new RegExp("-+" + boundary + "-*\\s+");
+ let sections = body.split(regex);
+
+ let prefs = [
+ "privacy.trackingprotection.enabled",
+ "privacy.trackingprotection.pbmode.enabled",
+ "urlclassifier.trackingTable",
+ "network.http.referer.defaultPolicy",
+ "network.http.referer.defaultPolicy.pbmode",
+ "network.cookie.cookieBehavior",
+ "privacy.annotate_channels.strict_list.enabled",
+ "privacy.restrict3rdpartystorage.expiration",
+ "privacy.trackingprotection.fingerprinting.enabled",
+ "privacy.trackingprotection.cryptomining.enabled",
+ ];
+ let prefsBody = "";
+
+ for (let pref of prefs) {
+ prefsBody += `${pref}: ${Preferences.get(pref)}\r\n`;
+ }
+
+ Assert.deepEqual(
+ sections,
+ [
+ "",
+ `Content-Disposition: form-data; name=\"title\"\r\n\r\n${
+ Services.io.newURI(reportURL).host
+ }\r\n`,
+ 'Content-Disposition: form-data; name="body"\r\n\r\n' +
+ `Full URL: ${reportURL + "?"}\r\n` +
+ `userAgent: ${navigator.userAgent}\r\n\r\n` +
+ "**Preferences**\r\n" +
+ `${prefsBody}\r\n` +
+ `hasException: ${hasException}\r\n\r\n` +
+ "**Comments**\r\n" +
+ "This is a comment\r\n",
+ 'Content-Disposition: form-data; name="labels"\r\n\r\n' +
+ `${hasException ? "" : tags}\r\n`,
+ "",
+ ],
+ "Should send the correct form data"
+ );
+
+ if (error) {
+ response.setStatusLine(request.httpVersion, 500, "Request failed");
+ } else {
+ response.setStatusLine(request.httpVersion, 201, "Entry created");
+ }
+
+ resolve();
+ });
+
+ comments.value = "This is a comment";
+ submitButton.click();
+ });
+
+ let errorMessage = document.getElementById(
+ "protections-popup-sendReportView-report-error"
+ );
+ if (error) {
+ await TestUtils.waitForCondition(() =>
+ BrowserTestUtils.is_visible(errorMessage)
+ );
+ is(
+ comments.value,
+ "This is a comment",
+ "Comment not cleared in case of an error"
+ );
+ gProtectionsHandler._protectionsPopup.hidePopup();
+ } else {
+ ok(BrowserTestUtils.is_hidden(errorMessage), "Error message not shown");
+ }
+
+ await popuphidden;
+
+ // Stop the server.
+ await new Promise(r => server.stop(r));
+
+ Services.prefs.clearUserPref(PREF_REPORT_BREAKAGE_URL);
+}
diff --git a/browser/base/content/test/protectionsUI/browser_protectionsUI_shield_visibility.js b/browser/base/content/test/protectionsUI/browser_protectionsUI_shield_visibility.js
new file mode 100644
index 0000000000..539ac077ac
--- /dev/null
+++ b/browser/base/content/test/protectionsUI/browser_protectionsUI_shield_visibility.js
@@ -0,0 +1,124 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/* This test checks pages of different URL variants (mostly differing in scheme)
+ * and verifies that the shield is only shown when content blocking can deal
+ * with the specific variant. */
+
+const TEST_CASES = [
+ {
+ type: "http",
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ testURL: "http://example.com",
+ hidden: false,
+ },
+ {
+ type: "https",
+ testURL: "https://example.com",
+ hidden: false,
+ },
+ {
+ type: "chrome page",
+ testURL: "chrome://global/skin/in-content/info-pages.css",
+ hidden: true,
+ },
+ {
+ type: "content-privileged about page",
+ testURL: "about:robots",
+ hidden: true,
+ },
+ {
+ type: "non-chrome about page",
+ testURL: "about:about",
+ hidden: true,
+ },
+ {
+ type: "chrome about page",
+ testURL: "about:preferences",
+ hidden: true,
+ },
+ {
+ type: "file",
+ testURL: "benignPage.html",
+ hidden: true,
+ },
+ {
+ type: "certificateError",
+ testURL: "https://self-signed.example.com",
+ hidden: true,
+ },
+ {
+ type: "localhost",
+ testURL: "http://127.0.0.1",
+ hidden: false,
+ },
+ {
+ type: "data URI",
+ testURL: "data:text/html,<div>",
+ hidden: true,
+ },
+ {
+ type: "view-source HTTP",
+ testURL: "view-source:http://example.com/",
+ hidden: true,
+ },
+ {
+ type: "view-source HTTPS",
+ testURL: "view-source:https://example.com/",
+ hidden: true,
+ },
+ {
+ type: "top level sandbox",
+ testURL:
+ "https://example.com/browser/browser/base/content/test/protectionsUI/sandboxed.html",
+ hidden: false,
+ },
+];
+
+add_task(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ // By default, proxies don't apply to 127.0.0.1. We need them to for this test, though:
+ ["network.proxy.allow_hijacking_localhost", true],
+ ],
+ });
+
+ for (let testData of TEST_CASES) {
+ info(`Testing for ${testData.type}`);
+ let testURL = testData.testURL;
+
+ // Overwrite the url if it is testing the file url.
+ if (testData.type === "file") {
+ let dir = getChromeDir(getResolvedURI(gTestPath));
+ dir.append(testURL);
+ dir.normalize();
+ testURL = Services.io.newFileURI(dir).spec;
+ }
+
+ let pageLoaded;
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ () => {
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, testURL);
+ let browser = gBrowser.selectedBrowser;
+ if (testData.type === "certificateError") {
+ pageLoaded = BrowserTestUtils.waitForErrorPage(browser);
+ } else {
+ pageLoaded = BrowserTestUtils.browserLoaded(browser, true);
+ }
+ },
+ false
+ );
+ await pageLoaded;
+
+ is(
+ BrowserTestUtils.is_hidden(
+ gProtectionsHandler._trackingProtectionIconContainer
+ ),
+ testData.hidden,
+ "tracking protection icon container is correctly displayed"
+ );
+
+ BrowserTestUtils.removeTab(tab);
+ }
+});
diff --git a/browser/base/content/test/protectionsUI/browser_protectionsUI_socialtracking.js b/browser/base/content/test/protectionsUI/browser_protectionsUI_socialtracking.js
new file mode 100644
index 0000000000..8506016067
--- /dev/null
+++ b/browser/base/content/test/protectionsUI/browser_protectionsUI_socialtracking.js
@@ -0,0 +1,321 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TRACKING_PAGE =
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com/browser/browser/base/content/test/protectionsUI/trackingPage.html";
+
+const ST_PROTECTION_PREF = "privacy.trackingprotection.socialtracking.enabled";
+const ST_BLOCK_COOKIES_PREF = "privacy.socialtracking.block_cookies.enabled";
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [ST_BLOCK_COOKIES_PREF, true],
+ [
+ "urlclassifier.features.socialtracking.blacklistHosts",
+ "social-tracking.example.org",
+ ],
+ [
+ "urlclassifier.features.socialtracking.annotate.blacklistHosts",
+ "social-tracking.example.org",
+ ],
+ // Whitelist trackertest.org loaded by default in trackingPage.html
+ ["urlclassifier.trackingSkipURLs", "trackertest.org"],
+ ["urlclassifier.trackingAnnotationSkipURLs", "trackertest.org"],
+ ["privacy.trackingprotection.enabled", false],
+ ["privacy.trackingprotection.annotate_channels", true],
+ ],
+ });
+});
+
+async function testIdentityState(hasException) {
+ let promise = BrowserTestUtils.openNewForegroundTab({
+ url: TRACKING_PAGE,
+ gBrowser,
+ });
+ let [tab] = await Promise.all([promise, waitForContentBlockingEvent()]);
+
+ if (hasException) {
+ let loaded = BrowserTestUtils.browserLoaded(
+ tab.linkedBrowser,
+ false,
+ TRACKING_PAGE
+ );
+ gProtectionsHandler.disableForCurrentPage();
+ await loaded;
+ }
+
+ await openProtectionsPanel();
+ let categoryItem = document.getElementById(
+ "protections-popup-category-socialblock"
+ );
+
+ ok(
+ categoryItem.classList.contains("notFound"),
+ "socialtrackings are not detected"
+ );
+
+ ok(
+ BrowserTestUtils.is_visible(gProtectionsHandler.iconBox),
+ "icon box is visible regardless the exception"
+ );
+ await closeProtectionsPanel();
+
+ await SpecialPowers.spawn(tab.linkedBrowser, [], function () {
+ content.postMessage("socialtracking", "*");
+ });
+ await openProtectionsPanel();
+
+ await TestUtils.waitForCondition(() => {
+ return !categoryItem.classList.contains("notFound");
+ });
+
+ ok(
+ gProtectionsHandler._protectionsPopup.hasAttribute("detected"),
+ "trackers are detected"
+ );
+ ok(
+ !categoryItem.classList.contains("notFound"),
+ "social trackers are detected"
+ );
+ ok(
+ BrowserTestUtils.is_visible(gProtectionsHandler.iconBox),
+ "icon box is visible"
+ );
+ is(
+ gProtectionsHandler.iconBox.hasAttribute("hasException"),
+ hasException,
+ "Shows an exception when appropriate"
+ );
+ await closeProtectionsPanel();
+
+ if (hasException) {
+ let loaded = BrowserTestUtils.browserLoaded(
+ tab.linkedBrowser,
+ false,
+ TRACKING_PAGE
+ );
+ gProtectionsHandler.enableForCurrentPage();
+ await loaded;
+ }
+
+ BrowserTestUtils.removeTab(tab);
+}
+
+async function testSubview(hasException) {
+ let promise = BrowserTestUtils.openNewForegroundTab({
+ url: TRACKING_PAGE,
+ gBrowser,
+ });
+ let [tab] = await Promise.all([promise, waitForContentBlockingEvent()]);
+
+ if (hasException) {
+ let loaded = BrowserTestUtils.browserLoaded(
+ tab.linkedBrowser,
+ false,
+ TRACKING_PAGE
+ );
+ gProtectionsHandler.disableForCurrentPage();
+ await loaded;
+ }
+
+ promise = waitForContentBlockingEvent();
+ await SpecialPowers.spawn(tab.linkedBrowser, [], function () {
+ content.postMessage("socialtracking", "*");
+ });
+ await promise;
+
+ await openProtectionsPanel();
+
+ let categoryItem = document.getElementById(
+ "protections-popup-category-socialblock"
+ );
+
+ // Explicitly waiting for the category item becoming visible.
+ await TestUtils.waitForCondition(() => {
+ return BrowserTestUtils.is_visible(categoryItem);
+ });
+
+ ok(BrowserTestUtils.is_visible(categoryItem), "STP category item is visible");
+ ok(
+ categoryItem.classList.contains("blocked"),
+ "STP category item is blocked"
+ );
+
+ /* eslint-disable mozilla/no-arbitrary-setTimeout */
+ // We have to wait until the ContentBlockingLog gets updated in the content.
+ // Unfortunately, we need to use the setTimeout here since we don't have an
+ // easy to know whether the log is updated in the content. This should be
+ // removed after the log been removed in the content (Bug 1599046).
+ await new Promise(resolve => {
+ setTimeout(resolve, 500);
+ });
+ /* eslint-enable mozilla/no-arbitrary-setTimeout */
+
+ let subview = document.getElementById("protections-popup-socialblockView");
+ let viewShown = BrowserTestUtils.waitForEvent(subview, "ViewShown");
+ categoryItem.click();
+ await viewShown;
+
+ let trackersViewShimHint = document.getElementById(
+ "protections-popup-socialblockView-shim-allow-hint"
+ );
+ ok(trackersViewShimHint.hidden, "Shim hint is hidden");
+
+ let listItems = subview.querySelectorAll(".protections-popup-list-item");
+ is(listItems.length, 1, "We have 1 item in the list");
+ let listItem = listItems[0];
+ ok(BrowserTestUtils.is_visible(listItem), "List item is visible");
+ is(
+ listItem.querySelector("label").value,
+ "https://social-tracking.example.org",
+ "Has the correct host"
+ );
+
+ let mainView = document.getElementById("protections-popup-mainView");
+ viewShown = BrowserTestUtils.waitForEvent(mainView, "ViewShown");
+ let backButton = subview.querySelector(".subviewbutton-back");
+ backButton.click();
+ await viewShown;
+
+ ok(true, "Main view was shown");
+
+ if (hasException) {
+ let loaded = BrowserTestUtils.browserLoaded(
+ tab.linkedBrowser,
+ false,
+ TRACKING_PAGE
+ );
+ gProtectionsHandler.enableForCurrentPage();
+ await loaded;
+ }
+
+ BrowserTestUtils.removeTab(tab);
+}
+
+async function testCategoryItem(blockLoads) {
+ if (blockLoads) {
+ Services.prefs.setBoolPref(ST_PROTECTION_PREF, true);
+ }
+
+ Services.prefs.setBoolPref(ST_BLOCK_COOKIES_PREF, false);
+
+ let promise = BrowserTestUtils.openNewForegroundTab({
+ url: TRACKING_PAGE,
+ gBrowser,
+ });
+ let [tab] = await Promise.all([promise, waitForContentBlockingEvent()]);
+
+ await openProtectionsPanel();
+
+ let categoryItem = document.getElementById(
+ "protections-popup-category-socialblock"
+ );
+
+ let noTrackersDetectedDesc = document.getElementById(
+ "protections-popup-no-trackers-found-description"
+ );
+
+ ok(categoryItem.hasAttribute("uidisabled"), "Category should be uidisabled");
+
+ ok(
+ !categoryItem.classList.contains("blocked"),
+ "Category not marked as blocked"
+ );
+ ok(!BrowserTestUtils.is_visible(categoryItem), "Item should be hidden");
+ ok(
+ !gProtectionsHandler._protectionsPopup.hasAttribute("detected"),
+ "trackers are not detected"
+ );
+
+ await SpecialPowers.spawn(tab.linkedBrowser, [], function () {
+ content.postMessage("socialtracking", "*");
+ });
+
+ ok(
+ !categoryItem.classList.contains("blocked"),
+ "Category not marked as blocked"
+ );
+ ok(!BrowserTestUtils.is_visible(categoryItem), "Item should be hidden");
+ ok(
+ !gProtectionsHandler._protectionsPopup.hasAttribute("detected"),
+ "trackers are not detected"
+ );
+ ok(
+ BrowserTestUtils.is_visible(noTrackersDetectedDesc),
+ "No Trackers detected should be shown"
+ );
+
+ BrowserTestUtils.removeTab(tab);
+
+ Services.prefs.setBoolPref(ST_BLOCK_COOKIES_PREF, true);
+
+ promise = BrowserTestUtils.openNewForegroundTab({
+ url: TRACKING_PAGE,
+ gBrowser,
+ });
+ [tab] = await Promise.all([promise, waitForContentBlockingEvent()]);
+
+ await openProtectionsPanel();
+
+ ok(!categoryItem.hasAttribute("uidisabled"), "Item shouldn't be uidisabled");
+
+ ok(categoryItem.classList.contains("blocked"), "Category marked as blocked");
+ ok(
+ categoryItem.classList.contains("notFound"),
+ "Category marked as not found"
+ );
+ // At this point we should still be showing "No Trackers Detected"
+ ok(!BrowserTestUtils.is_visible(categoryItem), "Item should not be visible");
+ ok(
+ BrowserTestUtils.is_visible(noTrackersDetectedDesc),
+ "No Trackers detected should be shown"
+ );
+ ok(
+ !gProtectionsHandler._protectionsPopup.hasAttribute("detected"),
+ "trackers are not detected"
+ );
+
+ await SpecialPowers.spawn(tab.linkedBrowser, [], function () {
+ content.postMessage("socialtracking", "*");
+ });
+
+ await TestUtils.waitForCondition(() => {
+ return !categoryItem.classList.contains("notFound");
+ });
+
+ ok(categoryItem.classList.contains("blocked"), "Category marked as blocked");
+ ok(
+ !categoryItem.classList.contains("notFound"),
+ "Category not marked as not found"
+ );
+ ok(BrowserTestUtils.is_visible(categoryItem), "Item should be visible");
+ ok(
+ !BrowserTestUtils.is_visible(noTrackersDetectedDesc),
+ "No Trackers detected should be hidden"
+ );
+ ok(
+ gProtectionsHandler._protectionsPopup.hasAttribute("detected"),
+ "trackers are not detected"
+ );
+
+ BrowserTestUtils.removeTab(tab);
+
+ Services.prefs.clearUserPref(ST_PROTECTION_PREF);
+}
+
+add_task(async function testIdentityUI() {
+ requestLongerTimeout(2);
+
+ await testIdentityState(false);
+ await testIdentityState(true);
+
+ await testSubview(false);
+ await testSubview(true);
+
+ await testCategoryItem(false);
+ await testCategoryItem(true);
+});
diff --git a/browser/base/content/test/protectionsUI/browser_protectionsUI_state.js b/browser/base/content/test/protectionsUI/browser_protectionsUI_state.js
new file mode 100644
index 0000000000..b524d2d7c7
--- /dev/null
+++ b/browser/base/content/test/protectionsUI/browser_protectionsUI_state.js
@@ -0,0 +1,405 @@
+/*
+ * Test that the Tracking Protection section is visible in the Control Center
+ * and has the correct state for the cases when:
+ *
+ * In a normal window as well as a private window,
+ * With TP enabled
+ * 1) A page with no tracking elements is loaded.
+ * 2) A page with tracking elements is loaded and they are blocked.
+ * 3) A page with tracking elements is loaded and they are not blocked.
+ * With TP disabled
+ * 1) A page with no tracking elements is loaded.
+ * 2) A page with tracking elements is loaded.
+ *
+ * See also Bugs 1175327, 1043801, 1178985
+ */
+
+const TP_PREF = "privacy.trackingprotection.enabled";
+const TP_PB_PREF = "privacy.trackingprotection.pbmode.enabled";
+const APS_PREF =
+ "privacy.partition.always_partition_third_party_non_cookie_storage";
+const TPC_PREF = "network.cookie.cookieBehavior";
+const DTSCBN_PREF = "dom.testing.sync-content-blocking-notifications";
+const BENIGN_PAGE =
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://tracking.example.org/browser/browser/base/content/test/protectionsUI/benignPage.html";
+const TRACKING_PAGE =
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://tracking.example.org/browser/browser/base/content/test/protectionsUI/trackingPage.html";
+const COOKIE_PAGE =
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://not-tracking.example.com/browser/browser/base/content/test/protectionsUI/cookiePage.html";
+var gProtectionsHandler = null;
+var TrackingProtection = null;
+var ThirdPartyCookies = null;
+var tabbrowser = null;
+var gTrackingPageURL = TRACKING_PAGE;
+
+const sBrandBundle = Services.strings.createBundle(
+ "chrome://branding/locale/brand.properties"
+);
+const sNoTrackerIconTooltip = gNavigatorBundle.getFormattedString(
+ "trackingProtection.icon.noTrackersDetectedTooltip",
+ [sBrandBundle.GetStringFromName("brandShortName")]
+);
+const sActiveIconTooltip = gNavigatorBundle.getString(
+ "trackingProtection.icon.activeTooltip2"
+);
+const sDisabledIconTooltip = gNavigatorBundle.getString(
+ "trackingProtection.icon.disabledTooltip2"
+);
+
+registerCleanupFunction(function () {
+ TrackingProtection =
+ gProtectionsHandler =
+ ThirdPartyCookies =
+ tabbrowser =
+ null;
+ UrlClassifierTestUtils.cleanupTestTrackers();
+ Services.prefs.clearUserPref(TP_PREF);
+ Services.prefs.clearUserPref(TP_PB_PREF);
+ Services.prefs.clearUserPref(TPC_PREF);
+ Services.prefs.clearUserPref(DTSCBN_PREF);
+});
+
+function notFound(id) {
+ let doc = tabbrowser.ownerGlobal.document;
+ return doc.getElementById(id).classList.contains("notFound");
+}
+
+async function testBenignPage() {
+ info("Non-tracking content must not be blocked");
+ ok(!gProtectionsHandler.anyDetected, "no trackers are detected");
+ ok(!gProtectionsHandler.hasException, "content shows no exception");
+
+ ok(
+ !gProtectionsHandler.iconBox.hasAttribute("active"),
+ "shield is not active"
+ );
+ ok(
+ !gProtectionsHandler.iconBox.hasAttribute("hasException"),
+ "icon box shows no exception"
+ );
+ is(
+ gProtectionsHandler._trackingProtectionIconTooltipLabel.textContent,
+ sNoTrackerIconTooltip,
+ "correct tooltip"
+ );
+ ok(
+ BrowserTestUtils.is_visible(gProtectionsHandler.iconBox),
+ "icon box is visible"
+ );
+
+ let win = tabbrowser.ownerGlobal;
+ await openProtectionsPanel(false, win);
+ ok(
+ notFound("protections-popup-category-cookies"),
+ "Cookie restrictions category is not found"
+ );
+ ok(
+ notFound("protections-popup-category-trackers"),
+ "Trackers category is not found"
+ );
+ await closeProtectionsPanel(win);
+}
+
+async function testBenignPageWithException() {
+ info("Non-tracking content must not be blocked");
+ ok(!gProtectionsHandler.anyDetected, "no trackers are detected");
+ ok(gProtectionsHandler.hasException, "content shows exception");
+
+ ok(
+ !gProtectionsHandler.iconBox.hasAttribute("active"),
+ "shield is not active"
+ );
+ ok(
+ gProtectionsHandler.iconBox.hasAttribute("hasException"),
+ "shield shows exception"
+ );
+ is(
+ gProtectionsHandler._trackingProtectionIconTooltipLabel.textContent,
+ sDisabledIconTooltip,
+ "correct tooltip"
+ );
+
+ ok(
+ !BrowserTestUtils.is_hidden(gProtectionsHandler.iconBox),
+ "icon box is not hidden"
+ );
+
+ let win = tabbrowser.ownerGlobal;
+ await openProtectionsPanel(false, win);
+ ok(
+ notFound("protections-popup-category-cookies"),
+ "Cookie restrictions category is not found"
+ );
+ ok(
+ notFound("protections-popup-category-trackers"),
+ "Trackers category is not found"
+ );
+ await closeProtectionsPanel(win);
+}
+
+function areTrackersBlocked(isPrivateBrowsing) {
+ let blockedByTP = Services.prefs.getBoolPref(
+ isPrivateBrowsing ? TP_PB_PREF : TP_PREF
+ );
+ let blockedByTPC = [
+ Ci.nsICookieService.BEHAVIOR_REJECT_TRACKER,
+ Ci.nsICookieService.BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN,
+ ].includes(Services.prefs.getIntPref(TPC_PREF));
+ return blockedByTP || blockedByTPC;
+}
+
+async function testTrackingPage(window) {
+ info("Tracking content must be blocked");
+ ok(gProtectionsHandler.anyDetected, "trackers are detected");
+ ok(!gProtectionsHandler.hasException, "content shows no exception");
+
+ let isWindowPrivate = PrivateBrowsingUtils.isWindowPrivate(window);
+ let blockedByTP = areTrackersBlocked(isWindowPrivate);
+ ok(
+ BrowserTestUtils.is_visible(gProtectionsHandler.iconBox),
+ "icon box is always visible"
+ );
+ is(
+ gProtectionsHandler.iconBox.hasAttribute("active"),
+ blockedByTP,
+ "shield is" + (blockedByTP ? "" : " not") + " active"
+ );
+ ok(
+ !gProtectionsHandler.iconBox.hasAttribute("hasException"),
+ "icon box shows no exception"
+ );
+ is(
+ gProtectionsHandler._trackingProtectionIconTooltipLabel.textContent,
+ blockedByTP ? sActiveIconTooltip : sNoTrackerIconTooltip,
+ "correct tooltip"
+ );
+
+ await openProtectionsPanel(false, window);
+ ok(
+ !notFound("protections-popup-category-trackers"),
+ "Trackers category is detected"
+ );
+ if (gTrackingPageURL == COOKIE_PAGE) {
+ ok(
+ !notFound("protections-popup-category-cookies"),
+ "Cookie restrictions category is detected"
+ );
+ } else {
+ ok(
+ notFound("protections-popup-category-cookies"),
+ "Cookie restrictions category is not found"
+ );
+ }
+ await closeProtectionsPanel(window);
+}
+
+async function testTrackingPageUnblocked(blockedByTP, window) {
+ info("Tracking content must be in the exception list and not blocked");
+ ok(gProtectionsHandler.anyDetected, "trackers are detected");
+ ok(gProtectionsHandler.hasException, "content shows exception");
+
+ ok(
+ !gProtectionsHandler.iconBox.hasAttribute("active"),
+ "shield is not active"
+ );
+ ok(
+ gProtectionsHandler.iconBox.hasAttribute("hasException"),
+ "shield shows exception"
+ );
+ is(
+ gProtectionsHandler._trackingProtectionIconTooltipLabel.textContent,
+ sDisabledIconTooltip,
+ "correct tooltip"
+ );
+
+ ok(
+ BrowserTestUtils.is_visible(gProtectionsHandler.iconBox),
+ "icon box is visible"
+ );
+
+ await openProtectionsPanel(false, window);
+ ok(
+ !notFound("protections-popup-category-trackers"),
+ "Trackers category is detected"
+ );
+ if (gTrackingPageURL == COOKIE_PAGE) {
+ ok(
+ !notFound("protections-popup-category-cookies"),
+ "Cookie restrictions category is detected"
+ );
+ } else {
+ ok(
+ notFound("protections-popup-category-cookies"),
+ "Cookie restrictions category is not found"
+ );
+ }
+ await closeProtectionsPanel(window);
+}
+
+async function testContentBlocking(tab) {
+ info("Testing with Tracking Protection ENABLED.");
+
+ info("Load a test page not containing tracking elements");
+ await promiseTabLoadEvent(tab, BENIGN_PAGE);
+ await testBenignPage();
+
+ info(
+ "Load a test page not containing tracking elements which has an exception."
+ );
+
+ await promiseTabLoadEvent(tab, "https://example.org/?round=1");
+
+ ContentBlockingAllowList.add(tab.linkedBrowser);
+ // Load another page from the same origin to ensure there is an onlocationchange
+ // notification which would trigger an oncontentblocking notification for us.
+ await promiseTabLoadEvent(tab, "https://example.org/?round=2");
+
+ await testBenignPageWithException();
+
+ ContentBlockingAllowList.remove(tab.linkedBrowser);
+
+ info("Load a test page containing tracking elements");
+ await promiseTabLoadEvent(tab, gTrackingPageURL);
+ await testTrackingPage(tab.ownerGlobal);
+
+ info("Disable CB for the page (which reloads the page)");
+ let tabReloadPromise = promiseTabLoadEvent(tab);
+ tab.ownerGlobal.gProtectionsHandler.disableForCurrentPage();
+ await tabReloadPromise;
+ let isPrivateBrowsing = PrivateBrowsingUtils.isWindowPrivate(tab.ownerGlobal);
+ let blockedByTP = areTrackersBlocked(isPrivateBrowsing);
+ await testTrackingPageUnblocked(blockedByTP, tab.ownerGlobal);
+
+ info("Re-enable TP for the page (which reloads the page)");
+ tabReloadPromise = promiseTabLoadEvent(tab);
+ tab.ownerGlobal.gProtectionsHandler.enableForCurrentPage();
+ await tabReloadPromise;
+ await testTrackingPage(tab.ownerGlobal);
+}
+
+add_task(async function testNormalBrowsing() {
+ await SpecialPowers.pushPrefEnv({ set: [[APS_PREF, false]] });
+
+ await UrlClassifierTestUtils.addTestTrackers();
+
+ Services.prefs.setBoolPref(DTSCBN_PREF, true);
+
+ tabbrowser = gBrowser;
+ let tab = (tabbrowser.selectedTab = BrowserTestUtils.addTab(tabbrowser));
+
+ gProtectionsHandler = gBrowser.ownerGlobal.gProtectionsHandler;
+ ok(
+ gProtectionsHandler,
+ "gProtectionsHandler is attached to the browser window"
+ );
+
+ TrackingProtection =
+ gBrowser.ownerGlobal.gProtectionsHandler.blockers.TrackingProtection;
+ ok(TrackingProtection, "TP is attached to the browser window");
+ is(
+ TrackingProtection.enabled,
+ Services.prefs.getBoolPref(TP_PREF),
+ "TP.enabled is based on the original pref value"
+ );
+
+ Services.prefs.setIntPref(TPC_PREF, Ci.nsICookieService.BEHAVIOR_ACCEPT);
+
+ await testContentBlocking(tab);
+
+ Services.prefs.setBoolPref(TP_PREF, true);
+ ok(TrackingProtection.enabled, "TP is enabled after setting the pref");
+
+ await testContentBlocking(tab);
+
+ gBrowser.removeCurrentTab();
+
+ Services.prefs.clearUserPref(TPC_PREF);
+});
+
+add_task(async function testPrivateBrowsing() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["dom.security.https_first_pbm", false],
+ [APS_PREF, false],
+ ],
+ });
+ let privateWin = await BrowserTestUtils.openNewBrowserWindow({
+ private: true,
+ });
+ tabbrowser = privateWin.gBrowser;
+ let tab = (tabbrowser.selectedTab = BrowserTestUtils.addTab(tabbrowser));
+
+ // Set the normal mode pref to false to check the pbmode pref.
+ Services.prefs.setBoolPref(TP_PREF, false);
+
+ Services.prefs.setIntPref(TPC_PREF, Ci.nsICookieService.BEHAVIOR_ACCEPT);
+
+ gProtectionsHandler = tabbrowser.ownerGlobal.gProtectionsHandler;
+ ok(
+ gProtectionsHandler,
+ "gProtectionsHandler is attached to the private window"
+ );
+
+ TrackingProtection =
+ tabbrowser.ownerGlobal.gProtectionsHandler.blockers.TrackingProtection;
+ ok(TrackingProtection, "TP is attached to the private window");
+ is(
+ TrackingProtection.enabled,
+ Services.prefs.getBoolPref(TP_PB_PREF),
+ "TP.enabled is based on the pb pref value"
+ );
+
+ await testContentBlocking(tab);
+
+ Services.prefs.setBoolPref(TP_PB_PREF, true);
+ ok(TrackingProtection.enabled, "TP is enabled after setting the pref");
+
+ await testContentBlocking(tab);
+
+ privateWin.close();
+
+ Services.prefs.clearUserPref(TPC_PREF);
+});
+
+add_task(async function testThirdPartyCookies() {
+ await SpecialPowers.pushPrefEnv({ set: [[APS_PREF, false]] });
+
+ await UrlClassifierTestUtils.addTestTrackers();
+ gTrackingPageURL = COOKIE_PAGE;
+
+ tabbrowser = gBrowser;
+ let tab = (tabbrowser.selectedTab = BrowserTestUtils.addTab(tabbrowser));
+
+ gProtectionsHandler = gBrowser.ownerGlobal.gProtectionsHandler;
+ ok(
+ gProtectionsHandler,
+ "gProtectionsHandler is attached to the browser window"
+ );
+ ThirdPartyCookies =
+ gBrowser.ownerGlobal.gProtectionsHandler.blockers.ThirdPartyCookies;
+ ok(ThirdPartyCookies, "TP is attached to the browser window");
+ is(
+ ThirdPartyCookies.enabled,
+ [
+ Ci.nsICookieService.BEHAVIOR_REJECT_TRACKER,
+ Ci.nsICookieService.BEHAVIOR_REJECT_TRACKER_AND_PARTITION_FOREIGN,
+ ].includes(Services.prefs.getIntPref(TPC_PREF)),
+ "TPC.enabled is based on the original pref value"
+ );
+
+ await testContentBlocking(tab);
+
+ Services.prefs.setIntPref(
+ TPC_PREF,
+ Ci.nsICookieService.BEHAVIOR_REJECT_TRACKER
+ );
+ ok(ThirdPartyCookies.enabled, "TPC is enabled after setting the pref");
+
+ await testContentBlocking(tab);
+
+ Services.prefs.clearUserPref(TPC_PREF);
+ gBrowser.removeCurrentTab();
+});
diff --git a/browser/base/content/test/protectionsUI/browser_protectionsUI_state_reset.js b/browser/base/content/test/protectionsUI/browser_protectionsUI_state_reset.js
new file mode 100644
index 0000000000..020733cc72
--- /dev/null
+++ b/browser/base/content/test/protectionsUI/browser_protectionsUI_state_reset.js
@@ -0,0 +1,129 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const TP_PREF = "privacy.trackingprotection.enabled";
+const TRACKING_PAGE =
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://tracking.example.org/browser/browser/base/content/test/protectionsUI/trackingPage.html";
+const BENIGN_PAGE =
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://tracking.example.org/browser/browser/base/content/test/protectionsUI/benignPage.html";
+const ABOUT_PAGE = "about:preferences";
+
+/* This asserts that the content blocking event state is correctly reset
+ * when navigating to a new location, and that the user is correctly
+ * reset when switching between tabs. */
+
+add_task(async function testResetOnLocationChange() {
+ Services.prefs.setBoolPref(TP_PREF, true);
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, BENIGN_PAGE);
+ let browser = tab.linkedBrowser;
+
+ is(
+ browser.getContentBlockingEvents(),
+ 0,
+ "Benign page has no content blocking event"
+ );
+ ok(
+ !gProtectionsHandler.iconBox.hasAttribute("active"),
+ "shield is not active"
+ );
+
+ await Promise.all([
+ promiseTabLoadEvent(tab, TRACKING_PAGE),
+ waitForContentBlockingEvent(2),
+ ]);
+
+ is(
+ browser.getContentBlockingEvents(),
+ Ci.nsIWebProgressListener.STATE_BLOCKED_TRACKING_CONTENT,
+ "Tracking page has a content blocking event"
+ );
+ ok(gProtectionsHandler.iconBox.hasAttribute("active"), "shield is active");
+
+ await promiseTabLoadEvent(tab, BENIGN_PAGE);
+
+ is(
+ browser.getContentBlockingEvents(),
+ 0,
+ "Benign page has no content blocking event"
+ );
+ ok(
+ !gProtectionsHandler.iconBox.hasAttribute("active"),
+ "shield is not active"
+ );
+
+ let contentBlockingEvent = waitForContentBlockingEvent(3);
+ let trackingTab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ TRACKING_PAGE
+ );
+ await contentBlockingEvent;
+
+ is(
+ trackingTab.linkedBrowser.getContentBlockingEvents(),
+ Ci.nsIWebProgressListener.STATE_BLOCKED_TRACKING_CONTENT,
+ "Tracking page has a content blocking event"
+ );
+ ok(gProtectionsHandler.iconBox.hasAttribute("active"), "shield is active");
+
+ gBrowser.selectedTab = tab;
+ is(
+ browser.getContentBlockingEvents(),
+ 0,
+ "Benign page has no content blocking event"
+ );
+ ok(
+ !gProtectionsHandler.iconBox.hasAttribute("active"),
+ "shield is not active"
+ );
+
+ gBrowser.removeTab(trackingTab);
+ gBrowser.removeTab(tab);
+
+ Services.prefs.clearUserPref(TP_PREF);
+});
+
+/* Test that the content blocking icon is correctly reset
+ * when changing tabs or navigating to an about: page */
+add_task(async function testResetOnTabChange() {
+ Services.prefs.setBoolPref(TP_PREF, true);
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, ABOUT_PAGE);
+ ok(
+ !gProtectionsHandler.iconBox.hasAttribute("active"),
+ "shield is not active"
+ );
+
+ await Promise.all([
+ promiseTabLoadEvent(tab, TRACKING_PAGE),
+ waitForContentBlockingEvent(3),
+ ]);
+ ok(gProtectionsHandler.iconBox.hasAttribute("active"), "shield is active");
+
+ await promiseTabLoadEvent(tab, ABOUT_PAGE);
+ ok(
+ !gProtectionsHandler.iconBox.hasAttribute("active"),
+ "shield is not active"
+ );
+
+ let contentBlockingEvent = waitForContentBlockingEvent(3);
+ let trackingTab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ TRACKING_PAGE
+ );
+ await contentBlockingEvent;
+ ok(gProtectionsHandler.iconBox.hasAttribute("active"), "shield is active");
+
+ gBrowser.selectedTab = tab;
+ ok(
+ !gProtectionsHandler.iconBox.hasAttribute("active"),
+ "shield is not active"
+ );
+
+ gBrowser.removeTab(trackingTab);
+ gBrowser.removeTab(tab);
+
+ Services.prefs.clearUserPref(TP_PREF);
+});
diff --git a/browser/base/content/test/protectionsUI/browser_protectionsUI_subview_shim.js b/browser/base/content/test/protectionsUI/browser_protectionsUI_subview_shim.js
new file mode 100644
index 0000000000..cf120a33da
--- /dev/null
+++ b/browser/base/content/test/protectionsUI/browser_protectionsUI_subview_shim.js
@@ -0,0 +1,403 @@
+/* eslint-disable mozilla/no-arbitrary-setTimeout */
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests the warning and list indicators that are shown in the protections panel
+ * subview when a tracking channel is allowed via the
+ * "urlclassifier-before-block-channel" event.
+ */
+
+// Choose origin so that all tracking origins used are third-parties.
+const TRACKING_PAGE =
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.net/browser/browser/base/content/test/protectionsUI/trackingPage.html";
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["privacy.trackingprotection.enabled", true],
+ ["privacy.trackingprotection.annotate_channels", true],
+ ["privacy.trackingprotection.cryptomining.enabled", true],
+ ["privacy.trackingprotection.socialtracking.enabled", true],
+ ["privacy.trackingprotection.fingerprinting.enabled", true],
+ ["privacy.socialtracking.block_cookies.enabled", true],
+ // Allowlist trackertest.org loaded by default in trackingPage.html
+ ["urlclassifier.trackingSkipURLs", "trackertest.org"],
+ ["urlclassifier.trackingAnnotationSkipURLs", "trackertest.org"],
+ // Additional denylisted hosts.
+ [
+ "urlclassifier.trackingAnnotationTable.testEntries",
+ "tracking.example.com",
+ ],
+ [
+ "urlclassifier.features.cryptomining.blacklistHosts",
+ "cryptomining.example.com",
+ ],
+ [
+ "urlclassifier.features.cryptomining.annotate.blacklistHosts",
+ "cryptomining.example.com",
+ ],
+ [
+ "urlclassifier.features.fingerprinting.blacklistHosts",
+ "fingerprinting.example.com",
+ ],
+ [
+ "urlclassifier.features.fingerprinting.annotate.blacklistHosts",
+ "fingerprinting.example.com",
+ ],
+ ],
+ });
+
+ await UrlClassifierTestUtils.addTestTrackers();
+ registerCleanupFunction(() => {
+ UrlClassifierTestUtils.cleanupTestTrackers();
+ });
+});
+
+async function assertSubViewState(category, expectedState) {
+ await openProtectionsPanel();
+
+ // Sort the expected state by origin and transform it into an array.
+ let expectedStateSorted = Object.keys(expectedState)
+ .sort()
+ .reduce((stateArr, key) => {
+ let obj = expectedState[key];
+ obj.origin = key;
+ stateArr.push(obj);
+ return stateArr;
+ }, []);
+
+ if (!expectedStateSorted.length) {
+ ok(
+ BrowserTestUtils.is_visible(
+ document.getElementById(
+ "protections-popup-no-trackers-found-description"
+ )
+ ),
+ "No Trackers detected should be shown"
+ );
+ return;
+ }
+
+ let categoryItem = document.getElementById(
+ `protections-popup-category-${category}`
+ );
+
+ // Explicitly waiting for the category item becoming visible.
+ await TestUtils.waitForCondition(() => {
+ return BrowserTestUtils.is_visible(categoryItem);
+ });
+
+ ok(
+ BrowserTestUtils.is_visible(categoryItem),
+ `${category} category item is visible`
+ );
+
+ ok(!categoryItem.disabled, `${category} category item is enabled`);
+
+ let subView = document.getElementById(`protections-popup-${category}View`);
+ let viewShown = BrowserTestUtils.waitForEvent(subView, "ViewShown");
+ categoryItem.click();
+ await viewShown;
+
+ ok(true, `${category} subView was shown`);
+
+ info("Testing tracker list");
+
+ // Get the listed trackers in the UI and sort them by origin.
+ let items = Array.from(
+ subView.querySelectorAll(
+ `#protections-popup-${category}View-list .protections-popup-list-item`
+ )
+ ).sort((a, b) => {
+ let originA = a.querySelector("label").value;
+ let originB = b.querySelector("label").value;
+ return originA.localeCompare(originB);
+ });
+
+ is(
+ items.length,
+ expectedStateSorted.length,
+ "List has expected amount of entries"
+ );
+
+ for (let i = 0; i < expectedStateSorted.length; i += 1) {
+ let expected = expectedStateSorted[i];
+ let item = items[i];
+
+ let label = item.querySelector(".protections-popup-list-host-label");
+ ok(label, "Item has label.");
+ is(label.tooltipText, expected.origin, "Label has correct tooltip.");
+ is(label.value, expected.origin, "Label has correct text.");
+
+ is(
+ item.classList.contains("allowed"),
+ !expected.block,
+ "Item has allowed class if tracker is not blocked"
+ );
+
+ let shimAllowIndicator = item.querySelector(
+ ".protections-popup-list-host-shim-allow-indicator"
+ );
+
+ if (expected.shimAllow) {
+ is(item.childNodes.length, 2, "Item has two childNodes.");
+ ok(shimAllowIndicator, "Item has shim allow indicator icon.");
+ ok(
+ shimAllowIndicator.tooltipText,
+ "Shim allow indicator icon has tooltip text"
+ );
+ } else {
+ is(item.childNodes.length, 1, "Item has one childNode.");
+ ok(!shimAllowIndicator, "Item does not have shim allow indicator icon.");
+ }
+ }
+
+ let shimAllowSection = document.getElementById(
+ `protections-popup-${category}View-shim-allow-hint`
+ );
+ ok(shimAllowSection, `Category ${category} has shim-allow hint.`);
+
+ if (Object.values(expectedState).some(entry => entry.shimAllow)) {
+ BrowserTestUtils.is_visible(
+ shimAllowSection,
+ "Shim allow hint is visible."
+ );
+ } else {
+ BrowserTestUtils.is_hidden(shimAllowSection, "Shim allow hint is hidden.");
+ }
+
+ await closeProtectionsPanel();
+}
+
+async function runTestForCategoryAndState(category, action) {
+ // Maps the protection categories to the test tracking origins defined in
+ // ./trackingAPI.js and the UI class identifiers to look for in the
+ // protections UI.
+ let categoryToTestData = {
+ tracking: {
+ apiMessage: "more-tracking",
+ origin: "https://itisatracker.org",
+ elementId: "trackers",
+ },
+ socialtracking: {
+ origin: "https://social-tracking.example.org",
+ elementId: "socialblock",
+ },
+ cryptomining: {
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ origin: "http://cryptomining.example.com",
+ elementId: "cryptominers",
+ },
+ fingerprinting: {
+ origin: "https://fingerprinting.example.com",
+ elementId: "fingerprinters",
+ },
+ };
+
+ let promise = BrowserTestUtils.openNewForegroundTab({
+ url: TRACKING_PAGE,
+ gBrowser,
+ });
+ // Wait for the tab to load and the initial blocking events from the
+ // classifier.
+ let [tab] = await Promise.all([promise, waitForContentBlockingEvent()]);
+
+ let {
+ origin: trackingOrigin,
+ elementId: categoryElementId,
+ apiMessage,
+ } = categoryToTestData[category];
+ if (!apiMessage) {
+ apiMessage = category;
+ }
+
+ // For allow or replace actions we need to hook into before-block-channel.
+ // If we don't hook into the event, the tracking channel will be blocked.
+ let beforeBlockChannelPromise;
+ if (action != "block") {
+ beforeBlockChannelPromise = UrlClassifierTestUtils.handleBeforeBlockChannel(
+ {
+ filterOrigin: trackingOrigin,
+ action,
+ }
+ );
+ }
+ // Load the test tracker matching the category.
+ await SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [{ apiMessage }],
+ function (args) {
+ content.postMessage(args.apiMessage, "*");
+ }
+ );
+ await beforeBlockChannelPromise;
+
+ // Next, test if the UI state is correct for the given category and action.
+ let expectedState = {};
+ expectedState[trackingOrigin] = {
+ block: action == "block",
+ shimAllow: action == "allow",
+ };
+
+ await assertSubViewState(categoryElementId, expectedState);
+
+ BrowserTestUtils.removeTab(tab);
+}
+
+/**
+ * Test mixed allow/block/replace states for the tracking protection category.
+ * @param {Object} options - States to test.
+ * @param {boolean} options.block - Test tracker block state.
+ * @param {boolean} options.allow - Test tracker allow state.
+ * @param {boolean} options.replace - Test tracker replace state.
+ */
+async function runTestMixed({ block, allow, replace }) {
+ const ORIGIN_BLOCK = "https://trackertest.org";
+ const ORIGIN_ALLOW = "https://itisatracker.org";
+ const ORIGIN_REPLACE = "https://tracking.example.com";
+
+ let promise = BrowserTestUtils.openNewForegroundTab({
+ url: TRACKING_PAGE,
+ gBrowser,
+ });
+
+ let [tab] = await Promise.all([promise, waitForContentBlockingEvent()]);
+
+ if (block) {
+ // Temporarily remove trackertest.org from the allowlist.
+ await SpecialPowers.pushPrefEnv({
+ clear: [
+ ["urlclassifier.trackingSkipURLs"],
+ ["urlclassifier.trackingAnnotationSkipURLs"],
+ ],
+ });
+ let blockEventPromise = waitForContentBlockingEvent();
+ await SpecialPowers.spawn(tab.linkedBrowser, [], function () {
+ content.postMessage("tracking", "*");
+ });
+ await blockEventPromise;
+ await SpecialPowers.popPrefEnv();
+ }
+
+ if (allow) {
+ let promiseEvent = waitForContentBlockingEvent();
+ let promiseAllow = UrlClassifierTestUtils.handleBeforeBlockChannel({
+ filterOrigin: ORIGIN_ALLOW,
+ action: "allow",
+ });
+
+ await SpecialPowers.spawn(tab.linkedBrowser, [], function () {
+ content.postMessage("more-tracking", "*");
+ });
+
+ await promiseAllow;
+ await promiseEvent;
+ }
+
+ if (replace) {
+ let promiseReplace = UrlClassifierTestUtils.handleBeforeBlockChannel({
+ filterOrigin: ORIGIN_REPLACE,
+ action: "replace",
+ });
+
+ await SpecialPowers.spawn(tab.linkedBrowser, [], function () {
+ content.postMessage("more-tracking-2", "*");
+ });
+
+ await promiseReplace;
+ }
+
+ let expectedState = {};
+
+ if (block) {
+ expectedState[ORIGIN_BLOCK] = {
+ shimAllow: false,
+ block: true,
+ };
+ }
+
+ if (replace) {
+ expectedState[ORIGIN_REPLACE] = {
+ shimAllow: false,
+ block: false,
+ };
+ }
+
+ if (allow) {
+ expectedState[ORIGIN_ALLOW] = {
+ shimAllow: true,
+ block: false,
+ };
+ }
+
+ // Check the protection categories subview with the block list.
+ await assertSubViewState("trackers", expectedState);
+
+ BrowserTestUtils.removeTab(tab);
+}
+
+add_task(async function testNoShim() {
+ await runTestMixed({
+ allow: false,
+ replace: false,
+ block: false,
+ });
+ await runTestMixed({
+ allow: false,
+ replace: false,
+ block: true,
+ });
+});
+
+add_task(async function testShimAllow() {
+ await runTestMixed({
+ allow: true,
+ replace: false,
+ block: false,
+ });
+ await runTestMixed({
+ allow: true,
+ replace: false,
+ block: true,
+ });
+});
+
+add_task(async function testShimReplace() {
+ await runTestMixed({
+ allow: false,
+ replace: true,
+ block: false,
+ });
+ await runTestMixed({
+ allow: false,
+ replace: true,
+ block: true,
+ });
+});
+
+add_task(async function testShimMixed() {
+ await runTestMixed({
+ allow: true,
+ replace: true,
+ block: true,
+ });
+});
+
+add_task(async function testShimCategorySubviews() {
+ let categories = [
+ "tracking",
+ "socialtracking",
+ "cryptomining",
+ "fingerprinting",
+ ];
+ for (let category of categories) {
+ for (let action of ["block", "allow", "replace"]) {
+ info(`Test category subview. category: ${category}, action: ${action}`);
+ await runTestForCategoryAndState(category, action);
+ }
+ }
+});
diff --git a/browser/base/content/test/protectionsUI/browser_protectionsUI_telemetry.js b/browser/base/content/test/protectionsUI/browser_protectionsUI_telemetry.js
new file mode 100644
index 0000000000..6bac0ce9b6
--- /dev/null
+++ b/browser/base/content/test/protectionsUI/browser_protectionsUI_telemetry.js
@@ -0,0 +1,89 @@
+/*
+ * Test telemetry for Tracking Protection
+ */
+
+const PREF = "privacy.trackingprotection.enabled";
+const DTSCBN_PREF = "dom.testing.sync-content-blocking-notifications";
+const BENIGN_PAGE =
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://tracking.example.org/browser/browser/base/content/test/protectionsUI/benignPage.html";
+const TRACKING_PAGE =
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://tracking.example.org/browser/browser/base/content/test/protectionsUI/trackingPage.html";
+
+/**
+ * Enable local telemetry recording for the duration of the tests.
+ */
+var oldCanRecord = Services.telemetry.canRecordExtended;
+Services.telemetry.canRecordExtended = true;
+registerCleanupFunction(function () {
+ UrlClassifierTestUtils.cleanupTestTrackers();
+ Services.telemetry.canRecordExtended = oldCanRecord;
+ Services.prefs.clearUserPref(PREF);
+ Services.prefs.clearUserPref(DTSCBN_PREF);
+});
+
+function getShieldHistogram() {
+ return Services.telemetry.getHistogramById("TRACKING_PROTECTION_SHIELD");
+}
+
+function getShieldCounts() {
+ return getShieldHistogram().snapshot().values;
+}
+
+add_setup(async function () {
+ await UrlClassifierTestUtils.addTestTrackers();
+ Services.prefs.setBoolPref(DTSCBN_PREF, true);
+
+ let TrackingProtection =
+ gBrowser.ownerGlobal.gProtectionsHandler.blockers.TrackingProtection;
+ ok(TrackingProtection, "TP is attached to the browser window");
+ ok(!TrackingProtection.enabled, "TP is not enabled");
+
+ let enabledCounts = Services.telemetry
+ .getHistogramById("TRACKING_PROTECTION_ENABLED")
+ .snapshot().values;
+ is(enabledCounts[0], 1, "TP was not enabled on start up");
+});
+
+add_task(async function testShieldHistogram() {
+ Services.prefs.setBoolPref(PREF, true);
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser);
+
+ // Reset these to make counting easier
+ getShieldHistogram().clear();
+
+ await promiseTabLoadEvent(tab, BENIGN_PAGE);
+ is(getShieldCounts()[0], 1, "Page loads without tracking");
+
+ await promiseTabLoadEvent(tab, TRACKING_PAGE);
+ is(getShieldCounts()[0], 2, "Adds one more page load");
+ is(getShieldCounts()[2], 1, "Counts one instance of the shield being shown");
+
+ info("Disable TP for the page (which reloads the page)");
+ let tabReloadPromise = promiseTabLoadEvent(tab);
+ gProtectionsHandler.disableForCurrentPage();
+ await tabReloadPromise;
+ is(getShieldCounts()[0], 3, "Adds one more page load");
+ is(
+ getShieldCounts()[1],
+ 1,
+ "Counts one instance of the shield being crossed out"
+ );
+
+ info("Re-enable TP for the page (which reloads the page)");
+ tabReloadPromise = promiseTabLoadEvent(tab);
+ gProtectionsHandler.enableForCurrentPage();
+ await tabReloadPromise;
+ is(getShieldCounts()[0], 4, "Adds one more page load");
+ is(
+ getShieldCounts()[2],
+ 2,
+ "Adds one more instance of the shield being shown"
+ );
+
+ gBrowser.removeCurrentTab();
+
+ // Reset these to make counting easier for the next test
+ getShieldHistogram().clear();
+});
diff --git a/browser/base/content/test/protectionsUI/browser_protectionsUI_trackers_subview.js b/browser/base/content/test/protectionsUI/browser_protectionsUI_trackers_subview.js
new file mode 100644
index 0000000000..7fe52065ea
--- /dev/null
+++ b/browser/base/content/test/protectionsUI/browser_protectionsUI_trackers_subview.js
@@ -0,0 +1,134 @@
+/* eslint-disable mozilla/no-arbitrary-setTimeout */
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { PermissionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PermissionTestUtils.sys.mjs"
+);
+
+const TRACKING_PAGE =
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://tracking.example.org/browser/browser/base/content/test/protectionsUI/trackingPage.html";
+
+const TP_PREF = "privacy.trackingprotection.enabled";
+
+add_setup(async function () {
+ await UrlClassifierTestUtils.addTestTrackers();
+
+ registerCleanupFunction(() => {
+ UrlClassifierTestUtils.cleanupTestTrackers();
+ });
+});
+
+async function assertSitesListed(blocked) {
+ let promise = BrowserTestUtils.openNewForegroundTab({
+ url: TRACKING_PAGE,
+ gBrowser,
+ });
+
+ // Wait for 2 content blocking events - one for the load and one for the tracker.
+ let [tab] = await Promise.all([promise, waitForContentBlockingEvent(2)]);
+
+ await openProtectionsPanel();
+
+ let categoryItem = document.getElementById(
+ "protections-popup-category-trackers"
+ );
+
+ // Explicitly waiting for the category item becoming visible.
+ await TestUtils.waitForCondition(() => {
+ return BrowserTestUtils.is_visible(categoryItem);
+ });
+
+ ok(BrowserTestUtils.is_visible(categoryItem), "TP category item is visible");
+ let trackersView = document.getElementById("protections-popup-trackersView");
+ let viewShown = BrowserTestUtils.waitForEvent(trackersView, "ViewShown");
+ categoryItem.click();
+ await viewShown;
+
+ ok(true, "Trackers view was shown");
+
+ let trackersViewShimHint = document.getElementById(
+ "protections-popup-trackersView-shim-allow-hint"
+ );
+ ok(trackersViewShimHint.hidden, "Shim hint is hidden");
+ let listItems = trackersView.querySelectorAll(".protections-popup-list-item");
+ is(listItems.length, 1, "We have 1 tracker in the list");
+
+ let mainView = document.getElementById("protections-popup-mainView");
+ viewShown = BrowserTestUtils.waitForEvent(mainView, "ViewShown");
+ let backButton = trackersView.querySelector(".subviewbutton-back");
+ backButton.click();
+ await viewShown;
+
+ ok(true, "Main view was shown");
+
+ let change = waitForSecurityChange(1);
+ let timeoutPromise = new Promise(resolve => setTimeout(resolve, 1000));
+
+ await SpecialPowers.spawn(tab.linkedBrowser, [], function () {
+ content.postMessage("more-tracking", "*");
+ });
+
+ let result = await Promise.race([change, timeoutPromise]);
+ is(result, undefined, "No securityChange events should be received");
+
+ viewShown = BrowserTestUtils.waitForEvent(trackersView, "ViewShown");
+ categoryItem.click();
+ await viewShown;
+
+ ok(true, "Trackers view was shown");
+
+ listItems = Array.from(
+ trackersView.querySelectorAll(".protections-popup-list-item")
+ );
+ is(listItems.length, 2, "We have 2 trackers in the list");
+
+ let listItem = listItems.find(
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ item => item.querySelector("label").value == "http://trackertest.org"
+ );
+ ok(listItem, "Has an item for trackertest.org");
+ ok(BrowserTestUtils.is_visible(listItem), "List item is visible");
+ is(
+ listItem.classList.contains("allowed"),
+ !blocked,
+ "Indicates whether the tracker was blocked or allowed"
+ );
+
+ listItem = listItems.find(
+ item => item.querySelector("label").value == "https://itisatracker.org"
+ );
+ ok(listItem, "Has an item for itisatracker.org");
+ ok(BrowserTestUtils.is_visible(listItem), "List item is visible");
+ is(
+ listItem.classList.contains("allowed"),
+ !blocked,
+ "Indicates whether the tracker was blocked or allowed"
+ );
+ BrowserTestUtils.removeTab(tab);
+}
+
+add_task(async function testTrackersSubView() {
+ info("Testing trackers subview with TP disabled.");
+ Services.prefs.setBoolPref(TP_PREF, false);
+ await assertSitesListed(false);
+ info("Testing trackers subview with TP enabled.");
+ Services.prefs.setBoolPref(TP_PREF, true);
+ await assertSitesListed(true);
+ info("Testing trackers subview with TP enabled and a CB exception.");
+ let uri = Services.io.newURI("https://tracking.example.org");
+ PermissionTestUtils.add(
+ uri,
+ "trackingprotection",
+ Services.perms.ALLOW_ACTION
+ );
+ await assertSitesListed(false);
+ info("Testing trackers subview with TP enabled and a CB exception removed.");
+ PermissionTestUtils.remove(uri, "trackingprotection");
+ await assertSitesListed(true);
+
+ Services.prefs.clearUserPref(TP_PREF);
+});
diff --git a/browser/base/content/test/protectionsUI/containerPage.html b/browser/base/content/test/protectionsUI/containerPage.html
new file mode 100644
index 0000000000..f68f7325c1
--- /dev/null
+++ b/browser/base/content/test/protectionsUI/containerPage.html
@@ -0,0 +1,6 @@
+<!DOCTYPE html>
+<html>
+<body>
+ <iframe src="http://not-tracking.example.com/browser/browser/base/content/test/protectionsUI/embeddedPage.html"></iframe>
+</body>
+</html>
diff --git a/browser/base/content/test/protectionsUI/cookiePage.html b/browser/base/content/test/protectionsUI/cookiePage.html
new file mode 100644
index 0000000000..e7ef2aafa1
--- /dev/null
+++ b/browser/base/content/test/protectionsUI/cookiePage.html
@@ -0,0 +1,13 @@
+<!DOCTYPE HTML>
+<!-- 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/. -->
+<html dir="ltr" xml:lang="en-US" lang="en-US">
+ <head>
+ <meta charset="utf8">
+ <script src="trackingAPI.js" type="text/javascript"></script>
+ </head>
+ <body>
+ <iframe src="http://trackertest.org/browser/browser/base/content/test/protectionsUI/cookieServer.sjs"></iframe>
+ </body>
+</html>
diff --git a/browser/base/content/test/protectionsUI/cookieServer.sjs b/browser/base/content/test/protectionsUI/cookieServer.sjs
new file mode 100644
index 0000000000..44341b9a71
--- /dev/null
+++ b/browser/base/content/test/protectionsUI/cookieServer.sjs
@@ -0,0 +1,24 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+// A 1x1 PNG image.
+// Source: https://commons.wikimedia.org/wiki/File:1x1.png (Public Domain)
+const IMAGE = atob(
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEUAA" +
+ "ACnej3aAAAAAXRSTlMAQObYZgAAAApJREFUCNdjYAAAAAIAAeIhvDMAAAAASUVORK5CYII="
+);
+
+function handleRequest(request, response) {
+ response.setStatusLine(request.httpVersion, 200);
+ if (
+ request.queryString &&
+ request.queryString.includes("type=image-no-cookie")
+ ) {
+ response.setHeader("Content-Type", "image/png", false);
+ response.write(IMAGE);
+ } else {
+ response.setHeader("Set-Cookie", "foopy=1");
+ response.write("cookie served");
+ }
+}
diff --git a/browser/base/content/test/protectionsUI/cookieSetterPage.html b/browser/base/content/test/protectionsUI/cookieSetterPage.html
new file mode 100644
index 0000000000..aab18e0aff
--- /dev/null
+++ b/browser/base/content/test/protectionsUI/cookieSetterPage.html
@@ -0,0 +1,6 @@
+<!DOCTYPE html>
+<html>
+<body>
+ <script> document.cookie = "foo=bar"; </script>
+</body>
+</html>
diff --git a/browser/base/content/test/protectionsUI/emailTrackingPage.html b/browser/base/content/test/protectionsUI/emailTrackingPage.html
new file mode 100644
index 0000000000..85b48cbbcb
--- /dev/null
+++ b/browser/base/content/test/protectionsUI/emailTrackingPage.html
@@ -0,0 +1,12 @@
+<!DOCTYPE HTML>
+<!-- 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/. -->
+<html dir="ltr" xml:lang="en-US" lang="en-US">
+ <head>
+ <meta charset="utf8">
+ </head>
+ <body>
+ <iframe src="https://email-tracking.example.org/"></iframe>
+ </body>
+</html>
diff --git a/browser/base/content/test/protectionsUI/embeddedPage.html b/browser/base/content/test/protectionsUI/embeddedPage.html
new file mode 100644
index 0000000000..6003d49300
--- /dev/null
+++ b/browser/base/content/test/protectionsUI/embeddedPage.html
@@ -0,0 +1,6 @@
+<!DOCTYPE html>
+<html>
+<body>
+ <iframe src="http://trackertest.org/browser/browser/base/content/test/protectionsUI/cookieSetterPage.html"></iframe>
+</body>
+</html>
diff --git a/browser/base/content/test/protectionsUI/file_protectionsUI_fetch.html b/browser/base/content/test/protectionsUI/file_protectionsUI_fetch.html
new file mode 100644
index 0000000000..4a7f1a1682
--- /dev/null
+++ b/browser/base/content/test/protectionsUI/file_protectionsUI_fetch.html
@@ -0,0 +1,17 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="UTF-8">
+ <title>Testing the shield from fetch and XHR</title>
+</head>
+<body>
+ <p>Hello there!</p>
+ <script type="application/javascript">
+ function test_fetch() {
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ let url = "http://trackertest.org/browser/browser/base/content/test/protectionsUI/file_protectionsUI_fetch.js";
+ return fetch(url);
+ }
+ </script>
+</body>
+</html>
diff --git a/browser/base/content/test/protectionsUI/file_protectionsUI_fetch.js b/browser/base/content/test/protectionsUI/file_protectionsUI_fetch.js
new file mode 100644
index 0000000000..f7ac687cfc
--- /dev/null
+++ b/browser/base/content/test/protectionsUI/file_protectionsUI_fetch.js
@@ -0,0 +1,2 @@
+/* Some code goes here! */
+void 0;
diff --git a/browser/base/content/test/protectionsUI/file_protectionsUI_fetch.js^headers^ b/browser/base/content/test/protectionsUI/file_protectionsUI_fetch.js^headers^
new file mode 100644
index 0000000000..cb762eff80
--- /dev/null
+++ b/browser/base/content/test/protectionsUI/file_protectionsUI_fetch.js^headers^
@@ -0,0 +1 @@
+Access-Control-Allow-Origin: *
diff --git a/browser/base/content/test/protectionsUI/head.js b/browser/base/content/test/protectionsUI/head.js
new file mode 100644
index 0000000000..28395ad732
--- /dev/null
+++ b/browser/base/content/test/protectionsUI/head.js
@@ -0,0 +1,221 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const { Sqlite } = ChromeUtils.importESModule(
+ "resource://gre/modules/Sqlite.sys.mjs"
+);
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "TrackingDBService",
+ "@mozilla.org/tracking-db-service;1",
+ "nsITrackingDBService"
+);
+
+XPCOMUtils.defineLazyGetter(this, "TRACK_DB_PATH", function () {
+ return PathUtils.join(PathUtils.profileDir, "protections.sqlite");
+});
+
+ChromeUtils.defineESModuleGetters(this, {
+ ContentBlockingAllowList:
+ "resource://gre/modules/ContentBlockingAllowList.sys.mjs",
+});
+
+var { UrlClassifierTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/UrlClassifierTestUtils.sys.mjs"
+);
+
+async function openProtectionsPanel(toast, win = window) {
+ let popupShownPromise = BrowserTestUtils.waitForEvent(
+ win,
+ "popupshown",
+ true,
+ e => e.target.id == "protections-popup"
+ );
+ let shieldIconContainer = win.document.getElementById(
+ "tracking-protection-icon-container"
+ );
+
+ // Move out than move over the shield icon to trigger the hover event in
+ // order to fetch tracker count.
+ EventUtils.synthesizeMouseAtCenter(
+ win.gURLBar.textbox,
+ {
+ type: "mousemove",
+ },
+ win
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ shieldIconContainer,
+ {
+ type: "mousemove",
+ },
+ win
+ );
+
+ if (!toast) {
+ EventUtils.synthesizeMouseAtCenter(shieldIconContainer, {}, win);
+ } else {
+ win.gProtectionsHandler.showProtectionsPopup({ toast });
+ }
+
+ await popupShownPromise;
+}
+
+async function openProtectionsPanelWithKeyNav() {
+ let popupShownPromise = BrowserTestUtils.waitForEvent(
+ window,
+ "popupshown",
+ true,
+ e => e.target.id == "protections-popup"
+ );
+
+ gURLBar.focus();
+
+ // This will trigger the focus event for the shield icon for pre-fetching
+ // the tracker count.
+ EventUtils.synthesizeKey("KEY_Tab", { shiftKey: true });
+ EventUtils.synthesizeKey("KEY_Enter", {});
+
+ await popupShownPromise;
+}
+
+async function closeProtectionsPanel(win = window) {
+ let protectionsPopup = win.document.getElementById("protections-popup");
+ if (!protectionsPopup) {
+ return;
+ }
+ let popuphiddenPromise = BrowserTestUtils.waitForEvent(
+ protectionsPopup,
+ "popuphidden"
+ );
+
+ PanelMultiView.hidePopup(protectionsPopup);
+ await popuphiddenPromise;
+}
+
+function checkClickTelemetry(objectName, value, source = "protectionspopup") {
+ let events = Services.telemetry.snapshotEvents(
+ Ci.nsITelemetry.DATASET_PRERELEASE_CHANNELS
+ ).parent;
+ let buttonEvents = events.filter(
+ e =>
+ e[1] == `security.ui.${source}` &&
+ e[2] == "click" &&
+ e[3] == objectName &&
+ e[4] === value
+ );
+ is(buttonEvents.length, 1, `recorded ${objectName} telemetry event`);
+}
+
+async function addTrackerDataIntoDB(count) {
+ const insertSQL =
+ "INSERT INTO events (type, count, timestamp)" +
+ "VALUES (:type, :count, date(:timestamp));";
+
+ let db = await Sqlite.openConnection({ path: TRACK_DB_PATH });
+ let date = new Date().toISOString();
+
+ await db.execute(insertSQL, {
+ type: TrackingDBService.TRACKERS_ID,
+ count,
+ timestamp: date,
+ });
+
+ await db.close();
+}
+
+async function waitForAboutProtectionsTab() {
+ let tab = await BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ "about:protections",
+ true
+ );
+
+ // When the graph is built it means the messaging has finished,
+ // we can close the tab.
+ await SpecialPowers.spawn(tab.linkedBrowser, [], async function () {
+ await ContentTaskUtils.waitForCondition(() => {
+ let bars = content.document.querySelectorAll(".graph-bar");
+ return bars.length;
+ }, "The graph has been built");
+ });
+
+ return tab;
+}
+
+/**
+ * Waits for a load (or custom) event to finish in a given tab. If provided
+ * load an uri into the tab.
+ *
+ * @param tab
+ * The tab to load into.
+ * @param [optional] url
+ * The url to load, or the current url.
+ * @return {Promise} resolved when the event is handled.
+ * @resolves to the received event
+ * @rejects if a valid load event is not received within a meaningful interval
+ */
+function promiseTabLoadEvent(tab, url) {
+ info("Wait tab event: load");
+
+ function handle(loadedUrl) {
+ if (loadedUrl === "about:blank" || (url && loadedUrl !== url)) {
+ info(`Skipping spurious load event for ${loadedUrl}`);
+ return false;
+ }
+
+ info("Tab event received: load");
+ return true;
+ }
+
+ let loaded = BrowserTestUtils.browserLoaded(tab.linkedBrowser, false, handle);
+
+ if (url) {
+ BrowserTestUtils.loadURIString(tab.linkedBrowser, url);
+ }
+
+ return loaded;
+}
+
+function waitForSecurityChange(numChanges = 1, win = null) {
+ if (!win) {
+ win = window;
+ }
+ return new Promise(resolve => {
+ let n = 0;
+ let listener = {
+ onSecurityChange() {
+ n = n + 1;
+ info("Received onSecurityChange event " + n + " of " + numChanges);
+ if (n >= numChanges) {
+ win.gBrowser.removeProgressListener(listener);
+ resolve(n);
+ }
+ },
+ };
+ win.gBrowser.addProgressListener(listener);
+ });
+}
+
+function waitForContentBlockingEvent(numChanges = 1, win = null) {
+ if (!win) {
+ win = window;
+ }
+ return new Promise(resolve => {
+ let n = 0;
+ let listener = {
+ onContentBlockingEvent(webProgress, request, event) {
+ n = n + 1;
+ info(
+ `Received onContentBlockingEvent event: ${event} (${n} of ${numChanges})`
+ );
+ if (n >= numChanges) {
+ win.gBrowser.removeProgressListener(listener);
+ resolve(n);
+ }
+ },
+ };
+ win.gBrowser.addProgressListener(listener);
+ });
+}
diff --git a/browser/base/content/test/protectionsUI/sandboxed.html b/browser/base/content/test/protectionsUI/sandboxed.html
new file mode 100644
index 0000000000..661fb0b8e2
--- /dev/null
+++ b/browser/base/content/test/protectionsUI/sandboxed.html
@@ -0,0 +1,12 @@
+<!DOCTYPE HTML>
+<!-- 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/. -->
+<html dir="ltr" xml:lang="en-US" lang="en-US">
+ <head>
+ <meta charset="utf8">
+ </head>
+ <body>
+ <iframe src="http://trackertest.org/browser/browser/base/content/test/protectionsUI/cookieServer.sjs"></iframe>
+ </body>
+</html>
diff --git a/browser/base/content/test/protectionsUI/sandboxed.html^headers^ b/browser/base/content/test/protectionsUI/sandboxed.html^headers^
new file mode 100644
index 0000000000..4705ce9ded
--- /dev/null
+++ b/browser/base/content/test/protectionsUI/sandboxed.html^headers^
@@ -0,0 +1 @@
+Content-Security-Policy: sandbox allow-scripts;
diff --git a/browser/base/content/test/protectionsUI/trackingAPI.js b/browser/base/content/test/protectionsUI/trackingAPI.js
new file mode 100644
index 0000000000..de5479a70f
--- /dev/null
+++ b/browser/base/content/test/protectionsUI/trackingAPI.js
@@ -0,0 +1,77 @@
+function createIframe(src) {
+ let ifr = document.createElement("iframe");
+ ifr.src = src;
+ document.body.appendChild(ifr);
+}
+
+function createImage(src) {
+ let img = document.createElement("img");
+ img.src = src;
+ img.onload = () => {
+ parent.postMessage("done", "*");
+ };
+ document.body.appendChild(img);
+}
+
+onmessage = event => {
+ switch (event.data) {
+ case "tracking":
+ createIframe("https://trackertest.org/");
+ break;
+ case "socialtracking":
+ createIframe(
+ "https://social-tracking.example.org/browser/browser/base/content/test/protectionsUI/cookieServer.sjs"
+ );
+ break;
+ case "cryptomining":
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ createIframe("http://cryptomining.example.com/");
+ break;
+ case "fingerprinting":
+ createIframe("https://fingerprinting.example.com/");
+ break;
+ case "more-tracking":
+ createIframe("https://itisatracker.org/");
+ break;
+ case "more-tracking-2":
+ createIframe("https://tracking.example.com/");
+ break;
+ case "cookie":
+ createIframe(
+ "https://trackertest.org/browser/browser/base/content/test/protectionsUI/cookieServer.sjs"
+ );
+ break;
+ case "first-party-cookie":
+ // Since the content blocking log doesn't seem to get updated for
+ // top-level cookies right now, we just create an iframe with the
+ // first party domain...
+ createIframe(
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://not-tracking.example.com/browser/browser/base/content/test/protectionsUI/cookieServer.sjs"
+ );
+ break;
+ case "third-party-cookie":
+ createIframe(
+ "https://test1.example.org/browser/browser/base/content/test/protectionsUI/cookieServer.sjs"
+ );
+ break;
+ case "image":
+ createImage(
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://trackertest.org/browser/browser/base/content/test/protectionsUI/cookieServer.sjs?type=image-no-cookie"
+ );
+ break;
+ case "window-open":
+ window.win = window.open(
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://trackertest.org/browser/browser/base/content/test/protectionsUI/cookieServer.sjs",
+ "_blank",
+ "width=100,height=100"
+ );
+ break;
+ case "window-close":
+ window.win.close();
+ window.win = null;
+ break;
+ }
+};
diff --git a/browser/base/content/test/protectionsUI/trackingPage.html b/browser/base/content/test/protectionsUI/trackingPage.html
new file mode 100644
index 0000000000..60ee20203b
--- /dev/null
+++ b/browser/base/content/test/protectionsUI/trackingPage.html
@@ -0,0 +1,13 @@
+<!DOCTYPE HTML>
+<!-- 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/. -->
+<html dir="ltr" xml:lang="en-US" lang="en-US">
+ <head>
+ <meta charset="utf8">
+ <script src="trackingAPI.js" type="text/javascript"></script>
+ </head>
+ <body>
+ <iframe src="http://trackertest.org/"></iframe>
+ </body>
+</html>
diff --git a/browser/base/content/test/referrer/browser.ini b/browser/base/content/test/referrer/browser.ini
new file mode 100644
index 0000000000..cfd1638771
--- /dev/null
+++ b/browser/base/content/test/referrer/browser.ini
@@ -0,0 +1,35 @@
+[DEFAULT]
+support-files =
+ file_referrer_policyserver.sjs
+ file_referrer_policyserver_attr.sjs
+ file_referrer_testserver.sjs
+ head.js
+
+[browser_referrer_click_pinned_tab.js]
+https_first_disabled = true
+[browser_referrer_middle_click.js]
+https_first_disabled = true
+[browser_referrer_middle_click_in_container.js]
+https_first_disabled = true
+[browser_referrer_open_link_in_container_tab.js]
+skip-if = os == 'linux' # Bug 1144816
+[browser_referrer_open_link_in_container_tab2.js]
+https_first_disabled = true
+skip-if =
+ os == 'linux' # Bug 1144816
+[browser_referrer_open_link_in_container_tab3.js]
+skip-if = os == 'linux' # Bug 1144816
+[browser_referrer_open_link_in_private.js]
+skip-if = os == 'linux' # Bug 1145199
+[browser_referrer_open_link_in_tab.js]
+https_first_disabled = true
+skip-if =
+ os == 'linux' # Bug 1144816
+[browser_referrer_open_link_in_window.js]
+https_first_disabled = true
+skip-if = os == 'linux' # Bug 1145199
+[browser_referrer_open_link_in_window_in_container.js]
+https_first_disabled = true
+skip-if = os == 'linux' # Bug 1145199
+[browser_referrer_simple_click.js]
+https_first_disabled = true
diff --git a/browser/base/content/test/referrer/browser_referrer_click_pinned_tab.js b/browser/base/content/test/referrer/browser_referrer_click_pinned_tab.js
new file mode 100644
index 0000000000..2c0d14d687
--- /dev/null
+++ b/browser/base/content/test/referrer/browser_referrer_click_pinned_tab.js
@@ -0,0 +1,82 @@
+/* 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/. */
+
+// We will open a new tab if clicking on a cross domain link in pinned tab
+// So, override the tests data in head.js, adding "cross: true".
+
+_referrerTests = [
+ {
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ fromScheme: "http://",
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ toScheme: "http://",
+ cross: true,
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ result: "http://test1.example.com/", // origin
+ },
+ {
+ fromScheme: "https://",
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ toScheme: "http://",
+ cross: true,
+ result: "", // no referrer when downgrade
+ },
+ {
+ fromScheme: "https://",
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ toScheme: "http://",
+ policy: "origin",
+ cross: true,
+ result: "https://test1.example.com/", // origin, even on downgrade
+ },
+ {
+ fromScheme: "https://",
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ toScheme: "http://",
+ policy: "origin",
+ rel: "noreferrer",
+ cross: true,
+ result: "", // rel=noreferrer trumps meta-referrer
+ },
+ {
+ fromScheme: "https://",
+ toScheme: "https://",
+ policy: "no-referrer",
+ cross: true,
+ result: "", // same origin https://test1.example.com/browser
+ },
+ {
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ fromScheme: "http://",
+ toScheme: "https://",
+ policy: "no-referrer",
+ cross: true,
+ result: "", // cross origin http://test1.example.com
+ },
+];
+
+async function startClickPinnedTabTestCase(aTestNumber) {
+ info(
+ "browser_referrer_click_pinned_tab: " +
+ getReferrerTestDescription(aTestNumber)
+ );
+ let browser = gTestWindow.gBrowser;
+
+ browser.pinTab(browser.selectedTab);
+ someTabLoaded(gTestWindow).then(function (aNewTab) {
+ checkReferrerAndStartNextTest(
+ aTestNumber,
+ null,
+ aNewTab,
+ startClickPinnedTabTestCase
+ );
+ });
+
+ clickTheLink(gTestWindow, "testlink", {});
+}
+
+function test() {
+ requestLongerTimeout(10); // slowwww shutdown on e10s
+ startReferrerTest(startClickPinnedTabTestCase);
+}
diff --git a/browser/base/content/test/referrer/browser_referrer_middle_click.js b/browser/base/content/test/referrer/browser_referrer_middle_click.js
new file mode 100644
index 0000000000..7686e461d0
--- /dev/null
+++ b/browser/base/content/test/referrer/browser_referrer_middle_click.js
@@ -0,0 +1,25 @@
+// Tests referrer on middle-click navigation.
+// Middle-clicks on the link, which opens it in a new tab.
+
+function startMiddleClickTestCase(aTestNumber) {
+ info(
+ "browser_referrer_middle_click: " + getReferrerTestDescription(aTestNumber)
+ );
+ someTabLoaded(gTestWindow).then(function (aNewTab) {
+ BrowserTestUtils.switchTab(gTestWindow.gBrowser, aNewTab).then(() => {
+ checkReferrerAndStartNextTest(
+ aTestNumber,
+ null,
+ aNewTab,
+ startMiddleClickTestCase
+ );
+ });
+ });
+
+ clickTheLink(gTestWindow, "testlink", { button: 1 });
+}
+
+function test() {
+ requestLongerTimeout(10); // slowwww shutdown on e10s
+ startReferrerTest(startMiddleClickTestCase);
+}
diff --git a/browser/base/content/test/referrer/browser_referrer_middle_click_in_container.js b/browser/base/content/test/referrer/browser_referrer_middle_click_in_container.js
new file mode 100644
index 0000000000..ec61a99804
--- /dev/null
+++ b/browser/base/content/test/referrer/browser_referrer_middle_click_in_container.js
@@ -0,0 +1,33 @@
+// Tests referrer on middle-click navigation.
+// Middle-clicks on the link, which opens it in a new tab, same container.
+
+function startMiddleClickTestCase(aTestNumber) {
+ info(
+ "browser_referrer_middle_click: " + getReferrerTestDescription(aTestNumber)
+ );
+ someTabLoaded(gTestWindow).then(function (aNewTab) {
+ BrowserTestUtils.switchTab(gTestWindow.gBrowser, aNewTab).then(() => {
+ checkReferrerAndStartNextTest(
+ aTestNumber,
+ null,
+ aNewTab,
+ startMiddleClickTestCase,
+ { userContextId: 3 }
+ );
+ });
+ });
+
+ clickTheLink(gTestWindow, "testlink", { button: 1 });
+}
+
+function test() {
+ waitForExplicitFinish();
+
+ SpecialPowers.pushPrefEnv(
+ { set: [["privacy.userContext.enabled", true]] },
+ function () {
+ requestLongerTimeout(10); // slowwww shutdown on e10s
+ startReferrerTest(startMiddleClickTestCase, { userContextId: 3 });
+ }
+ );
+}
diff --git a/browser/base/content/test/referrer/browser_referrer_open_link_in_container_tab.js b/browser/base/content/test/referrer/browser_referrer_open_link_in_container_tab.js
new file mode 100644
index 0000000000..3fe5df53c4
--- /dev/null
+++ b/browser/base/content/test/referrer/browser_referrer_open_link_in_container_tab.js
@@ -0,0 +1,80 @@
+// Tests referrer on context menu navigation - open link in new container tab.
+// Selects "open link in new container tab" from the context menu.
+
+function getReferrerTest(aTestNumber) {
+ let testCase = _referrerTests[aTestNumber];
+ if (testCase) {
+ // We want all the referrer tests to fail!
+ testCase.result = "";
+ }
+
+ return testCase;
+}
+
+function startNewTabTestCase(aTestNumber) {
+ info(
+ "browser_referrer_open_link_in_container_tab: " +
+ getReferrerTestDescription(aTestNumber)
+ );
+ contextMenuOpened(gTestWindow, "testlink").then(function (aContextMenu) {
+ someTabLoaded(gTestWindow).then(function (aNewTab) {
+ gTestWindow.gBrowser.selectedTab = aNewTab;
+
+ checkReferrerAndStartNextTest(
+ aTestNumber,
+ null,
+ aNewTab,
+ startNewTabTestCase
+ );
+ });
+
+ let menu = gTestWindow.document.getElementById(
+ "context-openlinkinusercontext-menu"
+ );
+
+ let menupopup = menu.menupopup;
+ menu.addEventListener(
+ "popupshown",
+ function () {
+ is(menupopup.nodeType, Node.ELEMENT_NODE, "We have a menupopup.");
+ ok(menupopup.firstElementChild, "We have a first container entry.");
+
+ let firstContext = menupopup.firstElementChild;
+ is(
+ firstContext.nodeType,
+ Node.ELEMENT_NODE,
+ "We have a first container entry."
+ );
+ ok(
+ firstContext.hasAttribute("data-usercontextid"),
+ "We have a usercontextid value."
+ );
+
+ aContextMenu.addEventListener(
+ "popuphidden",
+ function () {
+ firstContext.doCommand();
+ },
+ { once: true }
+ );
+
+ aContextMenu.hidePopup();
+ },
+ { once: true }
+ );
+
+ menu.openMenu(true);
+ });
+}
+
+function test() {
+ waitForExplicitFinish();
+
+ SpecialPowers.pushPrefEnv(
+ { set: [["privacy.userContext.enabled", true]] },
+ function () {
+ requestLongerTimeout(10); // slowwww shutdown on e10s
+ startReferrerTest(startNewTabTestCase);
+ }
+ );
+}
diff --git a/browser/base/content/test/referrer/browser_referrer_open_link_in_container_tab2.js b/browser/base/content/test/referrer/browser_referrer_open_link_in_container_tab2.js
new file mode 100644
index 0000000000..660b946e03
--- /dev/null
+++ b/browser/base/content/test/referrer/browser_referrer_open_link_in_container_tab2.js
@@ -0,0 +1,43 @@
+// Tests referrer on context menu navigation - open link in new container tab.
+// Selects "open link in new container tab" from the context menu.
+
+// The test runs from a container ID 1.
+// Output: we have the correct referrer policy applied.
+
+function startNewTabTestCase(aTestNumber) {
+ info(
+ "browser_referrer_open_link_in_container_tab: " +
+ getReferrerTestDescription(aTestNumber)
+ );
+ contextMenuOpened(gTestWindow, "testlink").then(function (aContextMenu) {
+ someTabLoaded(gTestWindow).then(function (aNewTab) {
+ gTestWindow.gBrowser.selectedTab = aNewTab;
+
+ checkReferrerAndStartNextTest(
+ aTestNumber,
+ null,
+ aNewTab,
+ startNewTabTestCase,
+ { userContextId: 1 }
+ );
+ });
+
+ doContextMenuCommand(
+ gTestWindow,
+ aContextMenu,
+ "context-openlinkincontainertab"
+ );
+ });
+}
+
+function test() {
+ waitForExplicitFinish();
+
+ SpecialPowers.pushPrefEnv(
+ { set: [["privacy.userContext.enabled", true]] },
+ function () {
+ requestLongerTimeout(10); // slowwww shutdown on e10s
+ startReferrerTest(startNewTabTestCase, { userContextId: 1 });
+ }
+ );
+}
diff --git a/browser/base/content/test/referrer/browser_referrer_open_link_in_container_tab3.js b/browser/base/content/test/referrer/browser_referrer_open_link_in_container_tab3.js
new file mode 100644
index 0000000000..9233d7cf7a
--- /dev/null
+++ b/browser/base/content/test/referrer/browser_referrer_open_link_in_container_tab3.js
@@ -0,0 +1,81 @@
+// Tests referrer on context menu navigation - open link in new container tab.
+// Selects "open link in new container tab" from the context menu.
+
+// The test runs from a container ID 2.
+// Output: we have no referrer.
+
+getReferrerTest = getRemovedReferrerTest;
+
+function startNewTabTestCase(aTestNumber) {
+ info(
+ "browser_referrer_open_link_in_container_tab: " +
+ getReferrerTestDescription(aTestNumber)
+ );
+ contextMenuOpened(gTestWindow, "testlink").then(function (aContextMenu) {
+ someTabLoaded(gTestWindow).then(function (aNewTab) {
+ gTestWindow.gBrowser.selectedTab = aNewTab;
+
+ checkReferrerAndStartNextTest(
+ aTestNumber,
+ null,
+ aNewTab,
+ startNewTabTestCase,
+ { userContextId: 2 }
+ );
+ });
+
+ let menu = gTestWindow.document.getElementById(
+ "context-openlinkinusercontext-menu"
+ );
+
+ let menupopup = menu.menupopup;
+ menu.addEventListener(
+ "popupshown",
+ function () {
+ is(menupopup.nodeType, Node.ELEMENT_NODE, "We have a menupopup.");
+ ok(menupopup.firstElementChild, "We have a first container entry.");
+
+ let firstContext = menupopup.firstElementChild;
+ is(
+ firstContext.nodeType,
+ Node.ELEMENT_NODE,
+ "We have a first container entry."
+ );
+ ok(
+ firstContext.hasAttribute("data-usercontextid"),
+ "We have a usercontextid value."
+ );
+ is(
+ "0",
+ firstContext.getAttribute("data-usercontextid"),
+ "We have the right usercontextid value."
+ );
+
+ aContextMenu.addEventListener(
+ "popuphidden",
+ function () {
+ firstContext.doCommand();
+ },
+ { once: true }
+ );
+
+ aContextMenu.hidePopup();
+ },
+ { once: true }
+ );
+
+ menu.openMenu(true);
+ });
+}
+
+function test() {
+ waitForExplicitFinish();
+
+ SpecialPowers.pushPrefEnv(
+ { set: [["privacy.userContext.enabled", true]] },
+ function () {
+ requestLongerTimeout(10); // slowwww shutdown on e10s
+ startReferrerTest(startNewTabTestCase, { userContextId: 2 });
+ }
+ );
+}
diff --git a/browser/base/content/test/referrer/browser_referrer_open_link_in_private.js b/browser/base/content/test/referrer/browser_referrer_open_link_in_private.js
new file mode 100644
index 0000000000..486b505565
--- /dev/null
+++ b/browser/base/content/test/referrer/browser_referrer_open_link_in_private.js
@@ -0,0 +1,33 @@
+// Tests referrer on context menu navigation - open link in new private window.
+// Selects "open link in new private window" from the context menu.
+
+// The test runs from a regular browsing window.
+// Output: we have no referrer.
+
+getReferrerTest = getRemovedReferrerTest;
+
+function startNewPrivateWindowTestCase(aTestNumber) {
+ info(
+ "browser_referrer_open_link_in_private: " +
+ getReferrerTestDescription(aTestNumber)
+ );
+ contextMenuOpened(gTestWindow, "testlink").then(function (aContextMenu) {
+ newWindowOpened().then(function (aNewWindow) {
+ BrowserTestUtils.firstBrowserLoaded(aNewWindow, false).then(function () {
+ checkReferrerAndStartNextTest(
+ aTestNumber,
+ aNewWindow,
+ null,
+ startNewPrivateWindowTestCase
+ );
+ });
+ });
+
+ doContextMenuCommand(gTestWindow, aContextMenu, "context-openlinkprivate");
+ });
+}
+
+function test() {
+ requestLongerTimeout(10); // slowwww shutdown on e10s
+ startReferrerTest(startNewPrivateWindowTestCase);
+}
diff --git a/browser/base/content/test/referrer/browser_referrer_open_link_in_tab.js b/browser/base/content/test/referrer/browser_referrer_open_link_in_tab.js
new file mode 100644
index 0000000000..d790bd371b
--- /dev/null
+++ b/browser/base/content/test/referrer/browser_referrer_open_link_in_tab.js
@@ -0,0 +1,27 @@
+// Tests referrer on context menu navigation - open link in new tab.
+// Selects "open link in new tab" from the context menu.
+
+function startNewTabTestCase(aTestNumber) {
+ info(
+ "browser_referrer_open_link_in_tab: " +
+ getReferrerTestDescription(aTestNumber)
+ );
+ contextMenuOpened(gTestWindow, "testlink").then(function (aContextMenu) {
+ someTabLoaded(gTestWindow).then(function (aNewTab) {
+ gTestWindow.gBrowser.selectedTab = aNewTab;
+ checkReferrerAndStartNextTest(
+ aTestNumber,
+ null,
+ aNewTab,
+ startNewTabTestCase
+ );
+ });
+
+ doContextMenuCommand(gTestWindow, aContextMenu, "context-openlinkintab");
+ });
+}
+
+function test() {
+ requestLongerTimeout(10); // slowwww shutdown on e10s
+ startReferrerTest(startNewTabTestCase);
+}
diff --git a/browser/base/content/test/referrer/browser_referrer_open_link_in_window.js b/browser/base/content/test/referrer/browser_referrer_open_link_in_window.js
new file mode 100644
index 0000000000..5c36470ded
--- /dev/null
+++ b/browser/base/content/test/referrer/browser_referrer_open_link_in_window.js
@@ -0,0 +1,28 @@
+// Tests referrer on context menu navigation - open link in new window.
+// Selects "open link in new window" from the context menu.
+
+function startNewWindowTestCase(aTestNumber) {
+ info(
+ "browser_referrer_open_link_in_window: " +
+ getReferrerTestDescription(aTestNumber)
+ );
+ contextMenuOpened(gTestWindow, "testlink").then(function (aContextMenu) {
+ newWindowOpened().then(function (aNewWindow) {
+ BrowserTestUtils.firstBrowserLoaded(aNewWindow, false).then(function () {
+ checkReferrerAndStartNextTest(
+ aTestNumber,
+ aNewWindow,
+ null,
+ startNewWindowTestCase
+ );
+ });
+ });
+
+ doContextMenuCommand(gTestWindow, aContextMenu, "context-openlink");
+ });
+}
+
+function test() {
+ requestLongerTimeout(10); // slowwww shutdown on e10s
+ startReferrerTest(startNewWindowTestCase);
+}
diff --git a/browser/base/content/test/referrer/browser_referrer_open_link_in_window_in_container.js b/browser/base/content/test/referrer/browser_referrer_open_link_in_window_in_container.js
new file mode 100644
index 0000000000..2ba17cd449
--- /dev/null
+++ b/browser/base/content/test/referrer/browser_referrer_open_link_in_window_in_container.js
@@ -0,0 +1,39 @@
+// Tests referrer on context menu navigation - open link in new window.
+// Selects "open link in new window" from the context menu.
+
+// This test runs from a container tab. The new tab/window will be loaded in
+// the same container.
+
+function startNewWindowTestCase(aTestNumber) {
+ info(
+ "browser_referrer_open_link_in_window: " +
+ getReferrerTestDescription(aTestNumber)
+ );
+ contextMenuOpened(gTestWindow, "testlink").then(function (aContextMenu) {
+ newWindowOpened().then(function (aNewWindow) {
+ BrowserTestUtils.firstBrowserLoaded(aNewWindow, false).then(function () {
+ checkReferrerAndStartNextTest(
+ aTestNumber,
+ aNewWindow,
+ null,
+ startNewWindowTestCase,
+ { userContextId: 1 }
+ );
+ });
+ });
+
+ doContextMenuCommand(gTestWindow, aContextMenu, "context-openlink");
+ });
+}
+
+function test() {
+ waitForExplicitFinish();
+
+ SpecialPowers.pushPrefEnv(
+ { set: [["privacy.userContext.enabled", true]] },
+ function () {
+ requestLongerTimeout(10); // slowwww shutdown on e10s
+ startReferrerTest(startNewWindowTestCase, { userContextId: 1 });
+ }
+ );
+}
diff --git a/browser/base/content/test/referrer/browser_referrer_simple_click.js b/browser/base/content/test/referrer/browser_referrer_simple_click.js
new file mode 100644
index 0000000000..a9c3cb8d6f
--- /dev/null
+++ b/browser/base/content/test/referrer/browser_referrer_simple_click.js
@@ -0,0 +1,27 @@
+// Tests referrer on simple click navigation.
+// Clicks on the link, which opens it in the same tab.
+
+function startSimpleClickTestCase(aTestNumber) {
+ info(
+ "browser_referrer_simple_click: " + getReferrerTestDescription(aTestNumber)
+ );
+ BrowserTestUtils.browserLoaded(
+ gTestWindow.gBrowser.selectedBrowser,
+ false,
+ url => url.endsWith("file_referrer_testserver.sjs")
+ ).then(function () {
+ checkReferrerAndStartNextTest(
+ aTestNumber,
+ null,
+ null,
+ startSimpleClickTestCase
+ );
+ });
+
+ clickTheLink(gTestWindow, "testlink", {});
+}
+
+function test() {
+ requestLongerTimeout(10); // slowwww shutdown on e10s
+ startReferrerTest(startSimpleClickTestCase);
+}
diff --git a/browser/base/content/test/referrer/file_referrer_policyserver.sjs b/browser/base/content/test/referrer/file_referrer_policyserver.sjs
new file mode 100644
index 0000000000..6695d417f4
--- /dev/null
+++ b/browser/base/content/test/referrer/file_referrer_policyserver.sjs
@@ -0,0 +1,41 @@
+/**
+ * Renders a link with the provided referrer policy.
+ * Used in browser_referrer_*.js, bug 1113431.
+ * Arguments: ?scheme=http://&policy=origin&rel=noreferrer
+ */
+function handleRequest(request, response) {
+ Cu.importGlobalProperties(["URLSearchParams"]);
+ let query = new URLSearchParams(request.queryString);
+
+ let scheme = query.get("scheme");
+ let policy = query.get("policy");
+ let rel = query.get("rel");
+ let cross = query.get("cross");
+
+ let host = cross ? "example.com" : "test1.example.com";
+ let linkUrl =
+ scheme +
+ host +
+ "/browser/browser/base/content/test/referrer/" +
+ "file_referrer_testserver.sjs";
+ let metaReferrerTag = policy
+ ? `<meta name='referrer' content='${policy}'>`
+ : "";
+
+ let html = `<!DOCTYPE HTML>
+ <html>
+ <head>
+ <meta charset='utf-8'>
+ ${metaReferrerTag}
+ <title>Test referrer</title>
+ </head>
+ <body>
+ <a id='testlink' href='${linkUrl}' ${rel ? ` rel='${rel}'` : ""}>
+ referrer test link</a>
+ </body>
+ </html>`;
+
+ response.setHeader("Cache-Control", "no-cache", false);
+ response.setHeader("Content-Type", "text/html", false);
+ response.write(html);
+}
diff --git a/browser/base/content/test/referrer/file_referrer_policyserver_attr.sjs b/browser/base/content/test/referrer/file_referrer_policyserver_attr.sjs
new file mode 100644
index 0000000000..b0104f292e
--- /dev/null
+++ b/browser/base/content/test/referrer/file_referrer_policyserver_attr.sjs
@@ -0,0 +1,41 @@
+/**
+ * Renders a link with the provided referrer policy.
+ * Used in browser_referrer_*.js, bug 1113431.
+ * Arguments: ?scheme=http://&policy=origin&rel=noreferrer
+ */
+function handleRequest(request, response) {
+ Cu.importGlobalProperties(["URLSearchParams"]);
+ let query = new URLSearchParams(request.queryString);
+
+ let scheme = query.get("scheme");
+ let policy = query.get("policy");
+ let rel = query.get("rel");
+ let cross = query.get("cross");
+
+ let host = cross ? "example.com" : "test1.example.com";
+ let linkUrl =
+ scheme +
+ host +
+ "/browser/browser/base/content/test/referrer/" +
+ "file_referrer_testserver.sjs";
+
+ let referrerPolicy = policy ? `referrerpolicy="${policy}"` : "";
+
+ let html = `<!DOCTYPE HTML>
+ <html>
+ <head>
+ <meta charset='utf-8'>
+ <title>Test referrer</title>
+ </head>
+ <body>
+ <a id='testlink' href='${linkUrl}' ${referrerPolicy} ${
+ rel ? ` rel='${rel}'` : ""
+ }>
+ referrer test link</a>
+ </body>
+ </html>`;
+
+ response.setHeader("Cache-Control", "no-cache", false);
+ response.setHeader("Content-Type", "text/html", false);
+ response.write(html);
+}
diff --git a/browser/base/content/test/referrer/file_referrer_testserver.sjs b/browser/base/content/test/referrer/file_referrer_testserver.sjs
new file mode 100644
index 0000000000..3dac7811b1
--- /dev/null
+++ b/browser/base/content/test/referrer/file_referrer_testserver.sjs
@@ -0,0 +1,30 @@
+/**
+ * Renders the HTTP Referer header up to the second path slash.
+ * Used in browser_referrer_*.js, bug 1113431.
+ */
+function handleRequest(request, response) {
+ let referrer = "";
+ try {
+ referrer = request.getHeader("referer");
+ } catch (e) {
+ referrer = "";
+ }
+
+ // Strip it past the first path slash. Makes tests easier to read.
+ referrer = referrer.split("/").slice(0, 4).join("/");
+
+ let html = `<!DOCTYPE HTML>
+ <html>
+ <head>
+ <meta charset='utf-8'>
+ <title>Test referrer</title>
+ </head>
+ <body>
+ <div id='testdiv'>${referrer}</div>
+ </body>
+ </html>`;
+
+ response.setHeader("Cache-Control", "no-cache", false);
+ response.setHeader("Content-Type", "text/html", false);
+ response.write(html);
+}
diff --git a/browser/base/content/test/referrer/head.js b/browser/base/content/test/referrer/head.js
new file mode 100644
index 0000000000..c812d73e80
--- /dev/null
+++ b/browser/base/content/test/referrer/head.js
@@ -0,0 +1,311 @@
+const REFERRER_URL_BASE = "/browser/browser/base/content/test/referrer/";
+const REFERRER_POLICYSERVER_URL =
+ "test1.example.com" + REFERRER_URL_BASE + "file_referrer_policyserver.sjs";
+const REFERRER_POLICYSERVER_URL_ATTRIBUTE =
+ "test1.example.com" +
+ REFERRER_URL_BASE +
+ "file_referrer_policyserver_attr.sjs";
+
+var gTestWindow = null;
+var rounds = 0;
+
+// We test that the UI code propagates three pieces of state - the referrer URI
+// itself, the referrer policy, and the triggering principal. After that, we
+// trust nsIWebNavigation to do the right thing with the info it's given, which
+// is covered more exhaustively by dom/base/test/test_bug704320.html (which is
+// a faster content-only test). So, here, we limit ourselves to cases that
+// would break when the UI code drops either of these pieces; we don't try to
+// duplicate the entire cross-product test in bug 704320 - that would be slow,
+// especially when we're opening a new window for each case.
+var _referrerTests = [
+ // 1. Normal cases - no referrer policy, no special attributes.
+ // We expect a full referrer normally, and no referrer on downgrade.
+ {
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ fromScheme: "http://",
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ toScheme: "http://",
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ result: "http://test1.example.com/browser", // full referrer
+ },
+ {
+ fromScheme: "https://",
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ toScheme: "http://",
+ result: "", // no referrer when downgrade
+ },
+ // 2. Origin referrer policy - we expect an origin referrer,
+ // even on downgrade. But rel=noreferrer trumps this.
+ {
+ fromScheme: "https://",
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ toScheme: "http://",
+ policy: "origin",
+ result: "https://test1.example.com/", // origin, even on downgrade
+ },
+ {
+ fromScheme: "https://",
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ toScheme: "http://",
+ policy: "origin",
+ rel: "noreferrer",
+ result: "", // rel=noreferrer trumps meta-referrer
+ },
+ // 3. XXX: using no-referrer here until we support all attribute values (bug 1178337)
+ // Origin-when-cross-origin policy - this depends on the triggering
+ // principal. We expect full referrer for same-origin requests,
+ // and origin referrer for cross-origin requests.
+ {
+ fromScheme: "https://",
+ toScheme: "https://",
+ policy: "no-referrer",
+ result: "", // same origin https://test1.example.com/browser
+ },
+ {
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ fromScheme: "http://",
+ toScheme: "https://",
+ policy: "no-referrer",
+ result: "", // cross origin http://test1.example.com
+ },
+];
+
+/**
+ * Returns the test object for a given test number.
+ * @param aTestNumber The test number - 0, 1, 2, ...
+ * @return The test object, or undefined if the number is out of range.
+ */
+function getReferrerTest(aTestNumber) {
+ return _referrerTests[aTestNumber];
+}
+
+/**
+ * Returns shimmed test object for a given test number.
+ *
+ * @param aTestNumber The test number - 0, 1, 2, ...
+ * @return The test object with result hard-coded to "",
+ * or undefined if the number is out of range.
+ */
+function getRemovedReferrerTest(aTestNumber) {
+ let testCase = _referrerTests[aTestNumber];
+ if (testCase) {
+ // We want all the referrer tests to fail!
+ testCase.result = "";
+ }
+
+ return testCase;
+}
+
+/**
+ * Returns a brief summary of the test, for logging.
+ * @param aTestNumber The test number - 0, 1, 2...
+ * @return The test description.
+ */
+function getReferrerTestDescription(aTestNumber) {
+ let test = getReferrerTest(aTestNumber);
+ return (
+ "policy=[" +
+ test.policy +
+ "] " +
+ "rel=[" +
+ test.rel +
+ "] " +
+ test.fromScheme +
+ " -> " +
+ test.toScheme
+ );
+}
+
+/**
+ * Clicks the link.
+ * @param aWindow The window to click the link in.
+ * @param aLinkId The id of the link element.
+ * @param aOptions The options for synthesizeMouseAtCenter.
+ */
+function clickTheLink(aWindow, aLinkId, aOptions) {
+ return BrowserTestUtils.synthesizeMouseAtCenter(
+ "#" + aLinkId,
+ aOptions,
+ aWindow.gBrowser.selectedBrowser
+ );
+}
+
+/**
+ * Extracts the referrer result from the target window.
+ * @param aWindow The window where the referrer target has loaded.
+ * @return {Promise}
+ * @resolves When extacted, with the text of the (trimmed) referrer.
+ */
+function referrerResultExtracted(aWindow) {
+ return SpecialPowers.spawn(aWindow.gBrowser.selectedBrowser, [], function () {
+ return content.document.getElementById("testdiv").textContent;
+ });
+}
+
+/**
+ * Waits for browser delayed startup to finish.
+ * @param aWindow The window to wait for.
+ * @return {Promise}
+ * @resolves When the window is loaded.
+ */
+function delayedStartupFinished(aWindow) {
+ return new Promise(function (resolve) {
+ Services.obs.addObserver(function observer(aSubject, aTopic) {
+ if (aWindow == aSubject) {
+ Services.obs.removeObserver(observer, aTopic);
+ resolve();
+ }
+ }, "browser-delayed-startup-finished");
+ });
+}
+
+/**
+ * Waits for some (any) tab to load. The caller triggers the load.
+ * @param aWindow The window where to wait for a tab to load.
+ * @return {Promise}
+ * @resolves With the tab once it's loaded.
+ */
+function someTabLoaded(aWindow) {
+ return BrowserTestUtils.waitForNewTab(gTestWindow.gBrowser, null, true);
+}
+
+/**
+ * Waits for a new window to open and load. The caller triggers the open.
+ * @return {Promise}
+ * @resolves With the new window once it's open and loaded.
+ */
+function newWindowOpened() {
+ return TestUtils.topicObserved("browser-delayed-startup-finished").then(
+ ([win]) => win
+ );
+}
+
+/**
+ * Opens the context menu.
+ * @param aWindow The window to open the context menu in.
+ * @param aLinkId The id of the link to open the context menu on.
+ * @return {Promise}
+ * @resolves With the menu popup when the context menu is open.
+ */
+function contextMenuOpened(aWindow, aLinkId) {
+ let popupShownPromise = BrowserTestUtils.waitForEvent(
+ aWindow.document,
+ "popupshown"
+ );
+ // Simulate right-click.
+ clickTheLink(aWindow, aLinkId, { type: "contextmenu", button: 2 });
+ return popupShownPromise.then(e => e.target);
+}
+
+/**
+ * Performs a context menu command.
+ * @param aWindow The window with the already open context menu.
+ * @param aMenu The menu popup to hide.
+ * @param aItemId The id of the menu item to activate.
+ */
+function doContextMenuCommand(aWindow, aMenu, aItemId) {
+ let command = aWindow.document.getElementById(aItemId);
+ command.doCommand();
+ aMenu.hidePopup();
+}
+
+/**
+ * Loads a single test case, i.e., a source url into gTestWindow.
+ * @param aTestNumber The test case number - 0, 1, 2...
+ * @return {Promise}
+ * @resolves When the source url for this test case is loaded.
+ */
+function referrerTestCaseLoaded(aTestNumber, aParams) {
+ let test = getReferrerTest(aTestNumber);
+ let server =
+ rounds == 0
+ ? REFERRER_POLICYSERVER_URL
+ : REFERRER_POLICYSERVER_URL_ATTRIBUTE;
+ let url =
+ test.fromScheme +
+ server +
+ "?scheme=" +
+ escape(test.toScheme) +
+ "&policy=" +
+ escape(test.policy || "") +
+ "&rel=" +
+ escape(test.rel || "") +
+ "&cross=" +
+ escape(test.cross || "");
+ let browser = gTestWindow.gBrowser;
+ return BrowserTestUtils.openNewForegroundTab(
+ browser,
+ () => {
+ browser.selectedTab = BrowserTestUtils.addTab(browser, url, aParams);
+ },
+ false,
+ true
+ );
+}
+
+/**
+ * Checks the result of the referrer test, and moves on to the next test.
+ * @param aTestNumber The test number - 0, 1, 2, ...
+ * @param aNewWindow The new window where the referrer target opened, or null.
+ * @param aNewTab The new tab where the referrer target opened, or null.
+ * @param aStartTestCase The callback to start the next test, aTestNumber + 1.
+ */
+function checkReferrerAndStartNextTest(
+ aTestNumber,
+ aNewWindow,
+ aNewTab,
+ aStartTestCase,
+ aParams = {}
+) {
+ referrerResultExtracted(aNewWindow || gTestWindow).then(function (result) {
+ // Compare the actual result against the expected one.
+ let test = getReferrerTest(aTestNumber);
+ let desc = getReferrerTestDescription(aTestNumber);
+ is(result, test.result, desc);
+
+ // Clean up - close new tab / window, and then the source tab.
+ aNewTab && (aNewWindow || gTestWindow).gBrowser.removeTab(aNewTab);
+ aNewWindow && aNewWindow.close();
+ is(gTestWindow.gBrowser.tabs.length, 2, "two tabs open");
+ gTestWindow.gBrowser.removeTab(gTestWindow.gBrowser.tabs[1]);
+
+ // Move on to the next test. Or finish if we're done.
+ var nextTestNumber = aTestNumber + 1;
+ if (getReferrerTest(nextTestNumber)) {
+ referrerTestCaseLoaded(nextTestNumber, aParams).then(function () {
+ aStartTestCase(nextTestNumber);
+ });
+ } else if (rounds == 0) {
+ nextTestNumber = 0;
+ rounds = 1;
+ referrerTestCaseLoaded(nextTestNumber, aParams).then(function () {
+ aStartTestCase(nextTestNumber);
+ });
+ } else {
+ finish();
+ }
+ });
+}
+
+/**
+ * Fires up the complete referrer test.
+ * @param aStartTestCase The callback to start a single test case, called with
+ * the test number - 0, 1, 2... Needs to trigger the navigation from the source
+ * page, and call checkReferrerAndStartNextTest() when the target is loaded.
+ */
+function startReferrerTest(aStartTestCase, params = {}) {
+ waitForExplicitFinish();
+
+ // Open the window where we'll load the source URLs.
+ gTestWindow = openDialog(location, "", "chrome,all,dialog=no", "about:blank");
+ registerCleanupFunction(function () {
+ gTestWindow && gTestWindow.close();
+ });
+
+ // Load and start the first test.
+ delayedStartupFinished(gTestWindow).then(function () {
+ referrerTestCaseLoaded(0, params).then(function () {
+ aStartTestCase(0);
+ });
+ });
+}
diff --git a/browser/base/content/test/sanitize/browser.ini b/browser/base/content/test/sanitize/browser.ini
new file mode 100644
index 0000000000..2a0a77a288
--- /dev/null
+++ b/browser/base/content/test/sanitize/browser.ini
@@ -0,0 +1,19 @@
+[DEFAULT]
+support-files=
+ head.js
+ dummy.js
+ dummy_page.html
+
+[browser_cookiePermission.js]
+[browser_cookiePermission_aboutURL.js]
+[browser_cookiePermission_containers.js]
+[browser_cookiePermission_subDomains.js]
+[browser_purgehistory_clears_sh.js]
+[browser_sanitize-cookie-exceptions.js]
+[browser_sanitize-formhistory.js]
+[browser_sanitize-history.js]
+[browser_sanitize-offlineData.js]
+[browser_sanitize-passwordDisabledHosts.js]
+[browser_sanitize-sitepermissions.js]
+[browser_sanitize-timespans.js]
+[browser_sanitizeDialog.js]
diff --git a/browser/base/content/test/sanitize/browser_cookiePermission.js b/browser/base/content/test/sanitize/browser_cookiePermission.js
new file mode 100644
index 0000000000..9fadfa91db
--- /dev/null
+++ b/browser/base/content/test/sanitize/browser_cookiePermission.js
@@ -0,0 +1 @@
+runAllCookiePermissionTests({ name: "default", oa: {} });
diff --git a/browser/base/content/test/sanitize/browser_cookiePermission_aboutURL.js b/browser/base/content/test/sanitize/browser_cookiePermission_aboutURL.js
new file mode 100644
index 0000000000..7ae8ec158e
--- /dev/null
+++ b/browser/base/content/test/sanitize/browser_cookiePermission_aboutURL.js
@@ -0,0 +1,101 @@
+const { SiteDataTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/SiteDataTestUtils.sys.mjs"
+);
+
+function checkDataForAboutURL() {
+ return new Promise(resolve => {
+ let data = true;
+ let uri = Services.io.newURI("about:newtab");
+ let principal = Services.scriptSecurityManager.createContentPrincipal(
+ uri,
+ {}
+ );
+ let request = indexedDB.openForPrincipal(principal, "TestDatabase", 1);
+ request.onupgradeneeded = function (e) {
+ data = false;
+ };
+ request.onsuccess = function (e) {
+ resolve(data);
+ };
+ });
+}
+
+add_task(async function deleteStorageInAboutURL() {
+ info("Test about:newtab");
+
+ // Let's clean up all the data.
+ await new Promise(resolve => {
+ Services.clearData.deleteData(Ci.nsIClearDataService.CLEAR_ALL, resolve);
+ });
+
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.sanitizer.loglevel", "All"]],
+ });
+
+ // Let's create a tab with some data.
+ await SiteDataTestUtils.addToIndexedDB("about:newtab", "foo", "bar", {});
+
+ ok(await checkDataForAboutURL(), "We have data for about:newtab");
+
+ // Cleaning up.
+ await Sanitizer.runSanitizeOnShutdown();
+
+ ok(await checkDataForAboutURL(), "about:newtab data is not deleted.");
+
+ // Clean up.
+ await Sanitizer.sanitize(["cookies", "offlineApps"]);
+
+ let principal =
+ Services.scriptSecurityManager.createContentPrincipalFromOrigin(
+ "about:newtab"
+ );
+ await new Promise(aResolve => {
+ let req = Services.qms.clearStoragesForPrincipal(principal);
+ req.callback = () => {
+ aResolve();
+ };
+ });
+});
+
+add_task(async function deleteStorageOnlyCustomPermissionInAboutURL() {
+ info("Test about:newtab + permissions");
+
+ // Let's clean up all the data.
+ await new Promise(resolve => {
+ Services.clearData.deleteData(Ci.nsIClearDataService.CLEAR_ALL, resolve);
+ });
+
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.sanitizer.loglevel", "All"]],
+ });
+
+ // Custom permission without considering OriginAttributes
+ let uri = Services.io.newURI("about:newtab");
+ PermissionTestUtils.add(uri, "cookie", Ci.nsICookiePermission.ACCESS_SESSION);
+
+ // Let's create a tab with some data.
+ await SiteDataTestUtils.addToIndexedDB("about:newtab", "foo", "bar", {});
+
+ ok(await checkDataForAboutURL(), "We have data for about:newtab");
+
+ // Cleaning up.
+ await Sanitizer.runSanitizeOnShutdown();
+
+ ok(await checkDataForAboutURL(), "about:newtab data is not deleted.");
+
+ // Clean up.
+ await Sanitizer.sanitize(["cookies", "offlineApps"]);
+
+ let principal =
+ Services.scriptSecurityManager.createContentPrincipalFromOrigin(
+ "about:newtab"
+ );
+ await new Promise(aResolve => {
+ let req = Services.qms.clearStoragesForPrincipal(principal);
+ req.callback = () => {
+ aResolve();
+ };
+ });
+
+ PermissionTestUtils.remove(uri, "cookie");
+});
diff --git a/browser/base/content/test/sanitize/browser_cookiePermission_containers.js b/browser/base/content/test/sanitize/browser_cookiePermission_containers.js
new file mode 100644
index 0000000000..236c0913e8
--- /dev/null
+++ b/browser/base/content/test/sanitize/browser_cookiePermission_containers.js
@@ -0,0 +1 @@
+runAllCookiePermissionTests({ name: "container", oa: { userContextId: 1 } });
diff --git a/browser/base/content/test/sanitize/browser_cookiePermission_subDomains.js b/browser/base/content/test/sanitize/browser_cookiePermission_subDomains.js
new file mode 100644
index 0000000000..b4b62be110
--- /dev/null
+++ b/browser/base/content/test/sanitize/browser_cookiePermission_subDomains.js
@@ -0,0 +1,290 @@
+const { SiteDataTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/SiteDataTestUtils.sys.mjs"
+);
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["privacy.sanitize.sanitizeOnShutdown", true],
+ ["privacy.clearOnShutdown.cookies", true],
+ ["privacy.clearOnShutdown.offlineApps", true],
+ ["privacy.clearOnShutdown.cache", false],
+ ["privacy.clearOnShutdown.sessions", false],
+ ["privacy.clearOnShutdown.history", false],
+ ["privacy.clearOnShutdown.formdata", false],
+ ["privacy.clearOnShutdown.downloads", false],
+ ["privacy.clearOnShutdown.siteSettings", false],
+ ["browser.sanitizer.loglevel", "All"],
+ ],
+ });
+});
+// 2 domains: www.mozilla.org (session-only) mozilla.org (allowed) - after the
+// cleanp, mozilla.org must have data.
+add_task(async function subDomains1() {
+ info("Test subdomains and custom setting");
+
+ // Let's clean up all the data.
+ await new Promise(resolve => {
+ Services.clearData.deleteData(Ci.nsIClearDataService.CLEAR_ALL, resolve);
+ });
+
+ // Domains and data
+ let originA = "https://www.mozilla.org";
+ PermissionTestUtils.add(
+ originA,
+ "cookie",
+ Ci.nsICookiePermission.ACCESS_SESSION
+ );
+
+ SiteDataTestUtils.addToCookies({ origin: originA });
+ await SiteDataTestUtils.addToIndexedDB(originA);
+
+ let originB = "https://mozilla.org";
+ PermissionTestUtils.add(
+ originB,
+ "cookie",
+ Ci.nsICookiePermission.ACCESS_ALLOW
+ );
+
+ SiteDataTestUtils.addToCookies({ origin: originB });
+ await SiteDataTestUtils.addToIndexedDB(originB);
+
+ // Check
+ ok(SiteDataTestUtils.hasCookies(originA), "We have cookies for " + originA);
+ ok(
+ await SiteDataTestUtils.hasIndexedDB(originA),
+ "We have IDB for " + originA
+ );
+ ok(SiteDataTestUtils.hasCookies(originB), "We have cookies for " + originB);
+ ok(
+ await SiteDataTestUtils.hasIndexedDB(originB),
+ "We have IDB for " + originB
+ );
+
+ // Cleaning up
+ await Sanitizer.runSanitizeOnShutdown();
+
+ // Check again
+ ok(
+ !SiteDataTestUtils.hasCookies(originA),
+ "We should not have cookies for " + originA
+ );
+ ok(
+ !(await SiteDataTestUtils.hasIndexedDB(originA)),
+ "We should not have IDB for " + originA
+ );
+ ok(SiteDataTestUtils.hasCookies(originB), "We have cookies for " + originB);
+ ok(
+ await SiteDataTestUtils.hasIndexedDB(originB),
+ "We have IDB for " + originB
+ );
+
+ // Cleaning up permissions
+ PermissionTestUtils.remove(originA, "cookie");
+ PermissionTestUtils.remove(originB, "cookie");
+});
+
+// session only cookie life-time, 2 domains (sub.mozilla.org, www.mozilla.org),
+// only the former has a cookie permission.
+add_task(async function subDomains2() {
+ info("Test subdomains and custom setting with cookieBehavior == 2");
+
+ // Let's clean up all the data.
+ await new Promise(resolve => {
+ Services.clearData.deleteData(Ci.nsIClearDataService.CLEAR_ALL, resolve);
+ });
+
+ // Domains and data
+ let originA = "https://sub.mozilla.org";
+ PermissionTestUtils.add(
+ originA,
+ "cookie",
+ Ci.nsICookiePermission.ACCESS_ALLOW
+ );
+
+ SiteDataTestUtils.addToCookies({ origin: originA });
+ await SiteDataTestUtils.addToIndexedDB(originA);
+
+ let originB = "https://www.mozilla.org";
+
+ SiteDataTestUtils.addToCookies({ origin: originB });
+ await SiteDataTestUtils.addToIndexedDB(originB);
+
+ // Check
+ ok(SiteDataTestUtils.hasCookies(originA), "We have cookies for " + originA);
+ ok(
+ await SiteDataTestUtils.hasIndexedDB(originA),
+ "We have IDB for " + originA
+ );
+ ok(SiteDataTestUtils.hasCookies(originB), "We have cookies for " + originB);
+ ok(
+ await SiteDataTestUtils.hasIndexedDB(originB),
+ "We have IDB for " + originB
+ );
+
+ // Cleaning up
+ await Sanitizer.runSanitizeOnShutdown();
+
+ // Check again
+ ok(SiteDataTestUtils.hasCookies(originA), "We have cookies for " + originA);
+ ok(
+ await SiteDataTestUtils.hasIndexedDB(originA),
+ "We have IDB for " + originA
+ );
+ ok(
+ !SiteDataTestUtils.hasCookies(originB),
+ "We should not have cookies for " + originB
+ );
+ ok(
+ !(await SiteDataTestUtils.hasIndexedDB(originB)),
+ "We should not have IDB for " + originB
+ );
+
+ // Cleaning up permissions
+ PermissionTestUtils.remove(originA, "cookie");
+});
+
+// session only cookie life-time, 3 domains (sub.mozilla.org, www.mozilla.org, mozilla.org),
+// only the former has a cookie permission. Both sub.mozilla.org and mozilla.org should
+// be sustained.
+add_task(async function subDomains3() {
+ info(
+ "Test base domain and subdomains and custom setting with cookieBehavior == 2"
+ );
+
+ // Let's clean up all the data.
+ await new Promise(resolve => {
+ Services.clearData.deleteData(Ci.nsIClearDataService.CLEAR_ALL, resolve);
+ });
+
+ // Domains and data
+ let originA = "https://sub.mozilla.org";
+ PermissionTestUtils.add(
+ originA,
+ "cookie",
+ Ci.nsICookiePermission.ACCESS_ALLOW
+ );
+ SiteDataTestUtils.addToCookies({ origin: originA });
+ await SiteDataTestUtils.addToIndexedDB(originA);
+
+ let originB = "https://mozilla.org";
+ SiteDataTestUtils.addToCookies({ origin: originB });
+ await SiteDataTestUtils.addToIndexedDB(originB);
+
+ let originC = "https://www.mozilla.org";
+ SiteDataTestUtils.addToCookies({ origin: originC });
+ await SiteDataTestUtils.addToIndexedDB(originC);
+
+ // Check
+ ok(SiteDataTestUtils.hasCookies(originA), "We have cookies for " + originA);
+ ok(
+ await SiteDataTestUtils.hasIndexedDB(originA),
+ "We have IDB for " + originA
+ );
+ ok(SiteDataTestUtils.hasCookies(originB), "We have cookies for " + originB);
+ ok(
+ await SiteDataTestUtils.hasIndexedDB(originB),
+ "We have IDB for " + originB
+ );
+ ok(SiteDataTestUtils.hasCookies(originC), "We have cookies for " + originC);
+ ok(
+ await SiteDataTestUtils.hasIndexedDB(originC),
+ "We have IDB for " + originC
+ );
+
+ // Cleaning up
+ await Sanitizer.runSanitizeOnShutdown();
+
+ // Check again
+ ok(SiteDataTestUtils.hasCookies(originA), "We have cookies for " + originA);
+ ok(
+ await SiteDataTestUtils.hasIndexedDB(originA),
+ "We have IDB for " + originA
+ );
+ ok(SiteDataTestUtils.hasCookies(originB), "We have cookies for " + originB);
+ ok(
+ await SiteDataTestUtils.hasIndexedDB(originB),
+ "We have IDB for " + originB
+ );
+ ok(
+ !SiteDataTestUtils.hasCookies(originC),
+ "We should not have cookies for " + originC
+ );
+ ok(
+ !(await SiteDataTestUtils.hasIndexedDB(originC)),
+ "We should not have IDB for " + originC
+ );
+
+ // Cleaning up permissions
+ PermissionTestUtils.remove(originA, "cookie");
+});
+
+// clear on shutdown, 3 domains (sub.sub.mozilla.org, sub.mozilla.org, mozilla.org),
+// only the former has a cookie permission. Both sub.mozilla.org and mozilla.org should
+// be sustained due to Permission of sub.sub.mozilla.org
+add_task(async function subDomains4() {
+ info("Test subdomain cookie permission inheritance with two subdomains");
+
+ // Let's clean up all the data.
+ await new Promise(resolve => {
+ Services.clearData.deleteData(Ci.nsIClearDataService.CLEAR_ALL, resolve);
+ });
+
+ // Domains and data
+ let originA = "https://sub.sub.mozilla.org";
+ PermissionTestUtils.add(
+ originA,
+ "cookie",
+ Ci.nsICookiePermission.ACCESS_ALLOW
+ );
+ SiteDataTestUtils.addToCookies({ origin: originA });
+ await SiteDataTestUtils.addToIndexedDB(originA);
+
+ let originB = "https://sub.mozilla.org";
+ SiteDataTestUtils.addToCookies({ origin: originB });
+ await SiteDataTestUtils.addToIndexedDB(originB);
+
+ let originC = "https://mozilla.org";
+ SiteDataTestUtils.addToCookies({ origin: originC });
+ await SiteDataTestUtils.addToIndexedDB(originC);
+
+ // Check
+ ok(SiteDataTestUtils.hasCookies(originA), "We have cookies for " + originA);
+ ok(
+ await SiteDataTestUtils.hasIndexedDB(originA),
+ "We have IDB for " + originA
+ );
+ ok(SiteDataTestUtils.hasCookies(originB), "We have cookies for " + originB);
+ ok(
+ await SiteDataTestUtils.hasIndexedDB(originB),
+ "We have IDB for " + originB
+ );
+ ok(SiteDataTestUtils.hasCookies(originC), "We have cookies for " + originC);
+ ok(
+ await SiteDataTestUtils.hasIndexedDB(originC),
+ "We have IDB for " + originC
+ );
+
+ // Cleaning up
+ await Sanitizer.runSanitizeOnShutdown();
+
+ // Check again
+ ok(SiteDataTestUtils.hasCookies(originA), "We have cookies for " + originA);
+ ok(
+ await SiteDataTestUtils.hasIndexedDB(originA),
+ "We have IDB for " + originA
+ );
+ ok(SiteDataTestUtils.hasCookies(originB), "We have cookies for " + originB);
+ ok(
+ await SiteDataTestUtils.hasIndexedDB(originB),
+ "We have IDB for " + originB
+ );
+ ok(SiteDataTestUtils.hasCookies(originC), "We have cookies for " + originC);
+ ok(
+ await SiteDataTestUtils.hasIndexedDB(originC),
+ "We have IDB for " + originC
+ );
+
+ // Cleaning up permissions
+ PermissionTestUtils.remove(originA, "cookie");
+});
diff --git a/browser/base/content/test/sanitize/browser_purgehistory_clears_sh.js b/browser/base/content/test/sanitize/browser_purgehistory_clears_sh.js
new file mode 100644
index 0000000000..abf11017dd
--- /dev/null
+++ b/browser/base/content/test/sanitize/browser_purgehistory_clears_sh.js
@@ -0,0 +1,71 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const url =
+ "https://example.org/browser/browser/base/content/test/sanitize/dummy_page.html";
+
+add_task(async function purgeHistoryTest() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url,
+ },
+ async function purgeHistoryTestInner(browser) {
+ let backButton = browser.ownerDocument.getElementById("Browser:Back");
+ let forwardButton =
+ browser.ownerDocument.getElementById("Browser:Forward");
+
+ ok(
+ !browser.webNavigation.canGoBack,
+ "Initial value for webNavigation.canGoBack"
+ );
+ ok(
+ !browser.webNavigation.canGoForward,
+ "Initial value for webNavigation.canGoBack"
+ );
+ ok(backButton.hasAttribute("disabled"), "Back button is disabled");
+ ok(forwardButton.hasAttribute("disabled"), "Forward button is disabled");
+
+ await SpecialPowers.spawn(browser, [], async function () {
+ let startHistory = content.history.length;
+ content.history.pushState({}, "");
+ content.history.pushState({}, "");
+ content.history.back();
+ await new Promise(function (r) {
+ content.onpopstate = r;
+ });
+ let newHistory = content.history.length;
+ Assert.equal(startHistory, 1, "Initial SHistory size");
+ Assert.equal(newHistory, 3, "New SHistory size");
+ });
+
+ ok(
+ browser.webNavigation.canGoBack,
+ "New value for webNavigation.canGoBack"
+ );
+ ok(
+ browser.webNavigation.canGoForward,
+ "New value for webNavigation.canGoForward"
+ );
+ ok(!backButton.hasAttribute("disabled"), "Back button was enabled");
+ ok(!forwardButton.hasAttribute("disabled"), "Forward button was enabled");
+
+ await Sanitizer.sanitize(["history"]);
+
+ await SpecialPowers.spawn(browser, [], async function () {
+ Assert.equal(content.history.length, 1, "SHistory correctly cleared");
+ });
+
+ ok(
+ !browser.webNavigation.canGoBack,
+ "webNavigation.canGoBack correctly cleared"
+ );
+ ok(
+ !browser.webNavigation.canGoForward,
+ "webNavigation.canGoForward correctly cleared"
+ );
+ ok(backButton.hasAttribute("disabled"), "Back button was disabled");
+ ok(forwardButton.hasAttribute("disabled"), "Forward button was disabled");
+ }
+ );
+});
diff --git a/browser/base/content/test/sanitize/browser_sanitize-cookie-exceptions.js b/browser/base/content/test/sanitize/browser_sanitize-cookie-exceptions.js
new file mode 100644
index 0000000000..a3ab8aa0f3
--- /dev/null
+++ b/browser/base/content/test/sanitize/browser_sanitize-cookie-exceptions.js
@@ -0,0 +1,274 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const { SiteDataTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/SiteDataTestUtils.sys.mjs"
+);
+const { PermissionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PermissionTestUtils.sys.mjs"
+);
+
+const oneHour = 3600000000;
+
+add_task(async function sanitizeWithExceptionsOnShutdown() {
+ info(
+ "Test that cookies that are marked as allowed from the user do not get \
+ cleared when cleaning on shutdown is done"
+ );
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.sanitizer.loglevel", "All"],
+ ["privacy.sanitize.sanitizeOnShutdown", true],
+ ],
+ });
+
+ // Clean up before start
+ await new Promise(resolve => {
+ Services.clearData.deleteData(Ci.nsIClearDataService.CLEAR_ALL, resolve);
+ });
+
+ let originALLOW = "https://mozilla.org";
+ PermissionTestUtils.add(
+ originALLOW,
+ "cookie",
+ Ci.nsICookiePermission.ACCESS_ALLOW
+ );
+
+ let originDENY = "https://example123.com";
+ PermissionTestUtils.add(
+ originDENY,
+ "cookie",
+ Ci.nsICookiePermission.ACCESS_DENY
+ );
+
+ SiteDataTestUtils.addToCookies({ origin: originALLOW });
+ ok(
+ SiteDataTestUtils.hasCookies(originALLOW),
+ "We have cookies for " + originALLOW
+ );
+
+ SiteDataTestUtils.addToCookies({ origin: originDENY });
+ ok(
+ SiteDataTestUtils.hasCookies(originDENY),
+ "We have cookies for " + originDENY
+ );
+
+ await Sanitizer.runSanitizeOnShutdown();
+
+ ok(
+ SiteDataTestUtils.hasCookies(originALLOW),
+ "We should have cookies for " + originALLOW
+ );
+
+ ok(
+ !SiteDataTestUtils.hasCookies(originDENY),
+ "We should not have cookies for " + originDENY
+ );
+});
+
+add_task(async function sanitizeNoExceptionsInTimeRange() {
+ info(
+ "Test that no exceptions are made when not clearing on shutdown, e.g. clearing within a range"
+ );
+
+ // Clean up before start
+ await new Promise(resolve => {
+ Services.clearData.deleteData(Ci.nsIClearDataService.CLEAR_ALL, resolve);
+ });
+
+ let originALLOW = "https://mozilla.org";
+ PermissionTestUtils.add(
+ originALLOW,
+ "cookie",
+ Ci.nsICookiePermission.ACCESS_ALLOW
+ );
+
+ let originDENY = "https://bar123.com";
+ PermissionTestUtils.add(
+ originDENY,
+ "cookie",
+ Ci.nsICookiePermission.ACCESS_DENY
+ );
+
+ SiteDataTestUtils.addToCookies({ origin: originALLOW });
+ ok(
+ SiteDataTestUtils.hasCookies(originALLOW),
+ "We have cookies for " + originALLOW
+ );
+
+ SiteDataTestUtils.addToCookies({ origin: originDENY });
+ ok(
+ SiteDataTestUtils.hasCookies(originDENY),
+ "We have cookies for " + originDENY
+ );
+
+ let to = Date.now() * 1000;
+ let from = to - oneHour;
+ await Sanitizer.sanitize(["cookies"], { range: [from, to] });
+
+ ok(
+ !SiteDataTestUtils.hasCookies(originALLOW),
+ "We should not have cookies for " + originALLOW
+ );
+
+ ok(
+ !SiteDataTestUtils.hasCookies(originDENY),
+ "We should not have cookies for " + originDENY
+ );
+});
+
+add_task(async function sanitizeWithExceptionsOnStartup() {
+ info(
+ "Test that cookies that are marked as allowed from the user do not get \
+ cleared when cleaning on startup is done, for example after a crash"
+ );
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.sanitizer.loglevel", "All"],
+ ["privacy.sanitize.sanitizeOnShutdown", true],
+ ],
+ });
+
+ // Clean up before start
+ await new Promise(resolve => {
+ Services.clearData.deleteData(Ci.nsIClearDataService.CLEAR_ALL, resolve);
+ });
+
+ let originALLOW = "https://mozilla.org";
+ PermissionTestUtils.add(
+ originALLOW,
+ "cookie",
+ Ci.nsICookiePermission.ACCESS_ALLOW
+ );
+
+ let originDENY = "https://example123.com";
+ PermissionTestUtils.add(
+ originDENY,
+ "cookie",
+ Ci.nsICookiePermission.ACCESS_DENY
+ );
+
+ SiteDataTestUtils.addToCookies({ origin: originALLOW });
+ ok(
+ SiteDataTestUtils.hasCookies(originALLOW),
+ "We have cookies for " + originALLOW
+ );
+
+ SiteDataTestUtils.addToCookies({ origin: originDENY });
+ ok(
+ SiteDataTestUtils.hasCookies(originDENY),
+ "We have cookies for " + originDENY
+ );
+
+ let pendingSanitizations = [
+ {
+ id: "shutdown",
+ itemsToClear: ["cookies"],
+ options: {},
+ },
+ ];
+ Services.prefs.setBoolPref(Sanitizer.PREF_SANITIZE_ON_SHUTDOWN, true);
+ Services.prefs.setStringPref(
+ Sanitizer.PREF_PENDING_SANITIZATIONS,
+ JSON.stringify(pendingSanitizations)
+ );
+
+ await Sanitizer.onStartup();
+
+ ok(
+ SiteDataTestUtils.hasCookies(originALLOW),
+ "We should have cookies for " + originALLOW
+ );
+
+ ok(
+ !SiteDataTestUtils.hasCookies(originDENY),
+ "We should not have cookies for " + originDENY
+ );
+});
+
+add_task(async function sanitizeWithSessionExceptionsOnShutdown() {
+ info(
+ "Test that cookies that are marked as allowed on session is cleared when sanitizeOnShutdown is false"
+ );
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.sanitizer.loglevel", "All"],
+ ["privacy.sanitize.sanitizeOnShutdown", false],
+ ],
+ });
+
+ // Clean up before start
+ await new Promise(resolve => {
+ Services.clearData.deleteData(Ci.nsIClearDataService.CLEAR_ALL, resolve);
+ });
+
+ let originAllowSession = "https://mozilla.org";
+ PermissionTestUtils.add(
+ originAllowSession,
+ "cookie",
+ Ci.nsICookiePermission.ACCESS_SESSION
+ );
+
+ SiteDataTestUtils.addToCookies({ origin: originAllowSession });
+ ok(
+ SiteDataTestUtils.hasCookies(originAllowSession),
+ "We have cookies for " + originAllowSession
+ );
+
+ await Sanitizer.runSanitizeOnShutdown();
+
+ ok(
+ !SiteDataTestUtils.hasCookies(originAllowSession),
+ "We should not have cookies for " + originAllowSession
+ );
+});
+
+add_task(async function sanitizeWithManySessionExceptionsOnShutdown() {
+ info(
+ "Test that lots of allowed on session exceptions are cleared when sanitizeOnShutdown is false"
+ );
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["privacy.sanitize.sanitizeOnShutdown", false],
+ ["dom.quotaManager.backgroundTask.enabled", true],
+ ],
+ });
+
+ // Clean up before start
+ await new Promise(resolve => {
+ Services.clearData.deleteData(Ci.nsIClearDataService.CLEAR_ALL, resolve);
+ });
+
+ info("Setting cookies");
+
+ const origins = new Array(300)
+ .fill(0)
+ .map((v, i) => `https://mozilla${i}.org`);
+
+ for (const origin of origins) {
+ PermissionTestUtils.add(
+ origin,
+ "cookie",
+ Ci.nsICookiePermission.ACCESS_SESSION
+ );
+ SiteDataTestUtils.addToCookies({ origin });
+ }
+
+ ok(
+ origins.every(origin => SiteDataTestUtils.hasCookies(origin)),
+ "All origins have cookies"
+ );
+
+ info("Running sanitization");
+
+ await Sanitizer.runSanitizeOnShutdown();
+
+ ok(
+ origins.every(origin => !SiteDataTestUtils.hasCookies(origin)),
+ "All origins lost cookies"
+ );
+});
diff --git a/browser/base/content/test/sanitize/browser_sanitize-formhistory.js b/browser/base/content/test/sanitize/browser_sanitize-formhistory.js
new file mode 100644
index 0000000000..5547a88d64
--- /dev/null
+++ b/browser/base/content/test/sanitize/browser_sanitize-formhistory.js
@@ -0,0 +1,28 @@
+/* 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/. */
+
+add_task(async function test() {
+ // This test relies on the form history being empty to start with delete
+ // all the items first.
+ // The TabContextMenu initializes its strings only on a focus or mouseover event.
+ // Calls focus event on the TabContextMenu early in the test.
+ gBrowser.selectedTab.focus();
+ await FormHistory.update({ op: "remove" });
+
+ // Sanitize now so we can test the baseline point.
+ await Sanitizer.sanitize(["formdata"]);
+ await gFindBarPromise;
+ ok(!gFindBar.hasTransactions, "pre-test baseline for sanitizer");
+
+ gFindBar.getElement("findbar-textbox").value = "m";
+ ok(gFindBar.hasTransactions, "formdata can be cleared after input");
+
+ await Sanitizer.sanitize(["formdata"]);
+ is(
+ gFindBar.getElement("findbar-textbox").value,
+ "",
+ "findBar textbox should be empty after sanitize"
+ );
+ ok(!gFindBar.hasTransactions, "No transactions after sanitize");
+});
diff --git a/browser/base/content/test/sanitize/browser_sanitize-history.js b/browser/base/content/test/sanitize/browser_sanitize-history.js
new file mode 100644
index 0000000000..5ca2843174
--- /dev/null
+++ b/browser/base/content/test/sanitize/browser_sanitize-history.js
@@ -0,0 +1,132 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that sanitizing history will clear storage access permissions
+// for sites without cookies or site data.
+add_task(async function sanitizeStorageAccessPermissions() {
+ await new Promise(resolve => {
+ Services.clearData.deleteData(Ci.nsIClearDataService.CLEAR_ALL, resolve);
+ });
+
+ await SiteDataTestUtils.addToIndexedDB("https://sub.example.org");
+ await SiteDataTestUtils.addToCookies({ origin: "https://example.com" });
+
+ PermissionTestUtils.add(
+ "https://example.org",
+ "storageAccessAPI",
+ Services.perms.ALLOW_ACTION
+ );
+ PermissionTestUtils.add(
+ "https://example.com",
+ "storageAccessAPI",
+ Services.perms.ALLOW_ACTION
+ );
+ PermissionTestUtils.add(
+ "http://mochi.test",
+ "storageAccessAPI",
+ Services.perms.ALLOW_ACTION
+ );
+
+ // Add some time in between taking the snapshot of the timestamp
+ // to avoid flakyness.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(c => setTimeout(c, 100));
+ let timestamp = Date.now();
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(c => setTimeout(c, 100));
+
+ PermissionTestUtils.add(
+ "http://example.net",
+ "storageAccessAPI",
+ Services.perms.ALLOW_ACTION
+ );
+
+ await Sanitizer.sanitize(["history"], {
+ // Sanitizer and ClearDataService work with time range in PRTime (microseconds)
+ range: [timestamp * 1000, Date.now() * 1000],
+ });
+
+ Assert.equal(
+ PermissionTestUtils.testExactPermission(
+ "http://example.net",
+ "storageAccessAPI"
+ ),
+ Services.perms.UNKNOWN_ACTION
+ );
+ Assert.equal(
+ PermissionTestUtils.testExactPermission(
+ "http://mochi.test",
+ "storageAccessAPI"
+ ),
+ Services.perms.ALLOW_ACTION
+ );
+ Assert.equal(
+ PermissionTestUtils.testExactPermission(
+ "https://example.com",
+ "storageAccessAPI"
+ ),
+ Services.perms.ALLOW_ACTION
+ );
+ Assert.equal(
+ PermissionTestUtils.testExactPermission(
+ "https://example.org",
+ "storageAccessAPI"
+ ),
+ Services.perms.ALLOW_ACTION
+ );
+
+ await Sanitizer.sanitize(["history"]);
+
+ Assert.equal(
+ PermissionTestUtils.testExactPermission(
+ "http://mochi.test",
+ "storageAccessAPI"
+ ),
+ Services.perms.UNKNOWN_ACTION
+ );
+ Assert.equal(
+ PermissionTestUtils.testExactPermission(
+ "http://example.net",
+ "storageAccessAPI"
+ ),
+ Services.perms.UNKNOWN_ACTION
+ );
+ Assert.equal(
+ PermissionTestUtils.testExactPermission(
+ "https://example.com",
+ "storageAccessAPI"
+ ),
+ Services.perms.ALLOW_ACTION
+ );
+ Assert.equal(
+ PermissionTestUtils.testExactPermission(
+ "https://example.org",
+ "storageAccessAPI"
+ ),
+ Services.perms.ALLOW_ACTION
+ );
+
+ await Sanitizer.sanitize(["history", "siteSettings"]);
+
+ Assert.equal(
+ PermissionTestUtils.testExactPermission(
+ "http://mochi.test",
+ "storageAccessAPI"
+ ),
+ Services.perms.UNKNOWN_ACTION
+ );
+ Assert.equal(
+ PermissionTestUtils.testExactPermission(
+ "https://example.com",
+ "storageAccessAPI"
+ ),
+ Services.perms.UNKNOWN_ACTION
+ );
+ Assert.equal(
+ PermissionTestUtils.testExactPermission(
+ "https://example.org",
+ "storageAccessAPI"
+ ),
+ Services.perms.UNKNOWN_ACTION
+ );
+});
diff --git a/browser/base/content/test/sanitize/browser_sanitize-offlineData.js b/browser/base/content/test/sanitize/browser_sanitize-offlineData.js
new file mode 100644
index 0000000000..64604684b1
--- /dev/null
+++ b/browser/base/content/test/sanitize/browser_sanitize-offlineData.js
@@ -0,0 +1,255 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Bug 380852 - Delete permission manager entries in Clear Recent History
+
+const { SiteDataTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/SiteDataTestUtils.sys.mjs"
+);
+const { PromiseTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PromiseTestUtils.sys.mjs"
+);
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "sas",
+ "@mozilla.org/storage/activity-service;1",
+ "nsIStorageActivityService"
+);
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "swm",
+ "@mozilla.org/serviceworkers/manager;1",
+ "nsIServiceWorkerManager"
+);
+
+const oneHour = 3600000000;
+const fiveHours = oneHour * 5;
+
+const itemsToClear = ["cookies", "offlineApps"];
+
+function waitForUnregister(host) {
+ return new Promise(resolve => {
+ let listener = {
+ onUnregister: registration => {
+ if (registration.principal.host != host) {
+ return;
+ }
+ swm.removeListener(listener);
+ resolve(registration);
+ },
+ };
+ swm.addListener(listener);
+ });
+}
+
+async function createData(host) {
+ let origin = "https://" + host;
+ let dummySWURL =
+ getRootDirectory(gTestPath).replace("chrome://mochitests/content", origin) +
+ "dummy.js";
+
+ await SiteDataTestUtils.addToIndexedDB(origin);
+ await SiteDataTestUtils.addServiceWorker(dummySWURL);
+}
+
+function moveOriginInTime(principals, endDate, host) {
+ for (let i = 0; i < principals.length; ++i) {
+ let principal = principals.queryElementAt(i, Ci.nsIPrincipal);
+ if (principal.host == host) {
+ sas.moveOriginInTime(principal, endDate - fiveHours);
+ return true;
+ }
+ }
+ return false;
+}
+
+add_task(async function testWithRange() {
+ // We have intermittent occurrences of NS_ERROR_ABORT being
+ // thrown at closing database instances when using Santizer.sanitize().
+ // This does not seem to impact cleanup, since our tests run fine anyway.
+ PromiseTestUtils.allowMatchingRejectionsGlobally(/NS_ERROR_ABORT/);
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["dom.serviceWorkers.enabled", true],
+ ["dom.serviceWorkers.exemptFromPerDomainMax", true],
+ ["dom.serviceWorkers.testing.enabled", true],
+ ],
+ });
+
+ // The service may have picked up activity from prior tests in this run.
+ // Clear it.
+ sas.testOnlyReset();
+
+ let endDate = Date.now() * 1000;
+ let principals = sas.getActiveOrigins(endDate - oneHour, endDate);
+ is(principals.length, 0, "starting from clear activity state");
+
+ info("sanitize: " + itemsToClear.join(", "));
+ await Sanitizer.sanitize(itemsToClear, { ignoreTimespan: false });
+
+ await createData("example.org");
+ await createData("example.com");
+
+ endDate = Date.now() * 1000;
+ principals = sas.getActiveOrigins(endDate - oneHour, endDate);
+ ok(!!principals, "We have an active origin.");
+ ok(principals.length >= 2, "We have an active origin.");
+
+ let found = 0;
+ for (let i = 0; i < principals.length; ++i) {
+ let principal = principals.queryElementAt(i, Ci.nsIPrincipal);
+ if (principal.host == "example.org" || principal.host == "example.com") {
+ found++;
+ }
+ }
+
+ is(found, 2, "Our origins are active.");
+
+ ok(
+ await SiteDataTestUtils.hasIndexedDB("https://example.org"),
+ "We have indexedDB data for example.org"
+ );
+ ok(
+ SiteDataTestUtils.hasServiceWorkers("https://example.org"),
+ "We have serviceWorker data for example.org"
+ );
+
+ ok(
+ await SiteDataTestUtils.hasIndexedDB("https://example.com"),
+ "We have indexedDB data for example.com"
+ );
+ ok(
+ SiteDataTestUtils.hasServiceWorkers("https://example.com"),
+ "We have serviceWorker data for example.com"
+ );
+
+ // Let's move example.com in the past.
+ ok(
+ moveOriginInTime(principals, endDate, "example.com"),
+ "Operation completed!"
+ );
+
+ let p = waitForUnregister("example.org");
+
+ // Clear it
+ info("sanitize: " + itemsToClear.join(", "));
+ await Sanitizer.sanitize(itemsToClear, { ignoreTimespan: false });
+ await p;
+
+ ok(
+ !(await SiteDataTestUtils.hasIndexedDB("https://example.org")),
+ "We don't have indexedDB data for example.org"
+ );
+ ok(
+ !SiteDataTestUtils.hasServiceWorkers("https://example.org"),
+ "We don't have serviceWorker data for example.org"
+ );
+
+ ok(
+ await SiteDataTestUtils.hasIndexedDB("https://example.com"),
+ "We still have indexedDB data for example.com"
+ );
+ ok(
+ SiteDataTestUtils.hasServiceWorkers("https://example.com"),
+ "We still have serviceWorker data for example.com"
+ );
+
+ // We have to move example.com in the past because how we check IDB triggers
+ // a storage activity.
+ ok(
+ moveOriginInTime(principals, endDate, "example.com"),
+ "Operation completed!"
+ );
+
+ // Let's call the clean up again.
+ info("sanitize again to ensure clearing doesn't expand the activity scope");
+ await Sanitizer.sanitize(itemsToClear, { ignoreTimespan: false });
+
+ ok(
+ await SiteDataTestUtils.hasIndexedDB("https://example.com"),
+ "We still have indexedDB data for example.com"
+ );
+ ok(
+ SiteDataTestUtils.hasServiceWorkers("https://example.com"),
+ "We still have serviceWorker data for example.com"
+ );
+
+ ok(
+ !(await SiteDataTestUtils.hasIndexedDB("https://example.org")),
+ "We don't have indexedDB data for example.org"
+ );
+ ok(
+ !SiteDataTestUtils.hasServiceWorkers("https://example.org"),
+ "We don't have serviceWorker data for example.org"
+ );
+
+ sas.testOnlyReset();
+
+ // Clean up.
+ await SiteDataTestUtils.clear();
+});
+
+add_task(async function testExceptionsOnShutdown() {
+ await createData("example.org");
+ await createData("example.com");
+
+ // Set exception for example.org to not get cleaned
+ let originALLOW = "https://example.org";
+ PermissionTestUtils.add(
+ originALLOW,
+ "cookie",
+ Ci.nsICookiePermission.ACCESS_ALLOW
+ );
+
+ ok(
+ await SiteDataTestUtils.hasIndexedDB("https://example.org"),
+ "We have indexedDB data for example.org"
+ );
+ ok(
+ SiteDataTestUtils.hasServiceWorkers("https://example.org"),
+ "We have serviceWorker data for example.org"
+ );
+
+ ok(
+ await SiteDataTestUtils.hasIndexedDB("https://example.com"),
+ "We have indexedDB data for example.com"
+ );
+ ok(
+ SiteDataTestUtils.hasServiceWorkers("https://example.com"),
+ "We have serviceWorker data for example.com"
+ );
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.sanitizer.loglevel", "All"],
+ ["privacy.clearOnShutdown.offlineApps", true],
+ ["privacy.sanitize.sanitizeOnShutdown", true],
+ ],
+ });
+ // Clear it
+ await Sanitizer.runSanitizeOnShutdown();
+ // Data for example.org should not have been cleared
+ ok(
+ await SiteDataTestUtils.hasIndexedDB("https://example.org"),
+ "We still have indexedDB data for example.org"
+ );
+ ok(
+ SiteDataTestUtils.hasServiceWorkers("https://example.org"),
+ "We still have serviceWorker data for example.org"
+ );
+ // Data for example.com should be cleared
+ ok(
+ !(await SiteDataTestUtils.hasIndexedDB("https://example.com")),
+ "We don't have indexedDB data for example.com"
+ );
+ ok(
+ !SiteDataTestUtils.hasServiceWorkers("https://example.com"),
+ "We don't have serviceWorker data for example.com"
+ );
+
+ // Clean up
+ await SiteDataTestUtils.clear();
+ Services.perms.removeAll();
+});
diff --git a/browser/base/content/test/sanitize/browser_sanitize-passwordDisabledHosts.js b/browser/base/content/test/sanitize/browser_sanitize-passwordDisabledHosts.js
new file mode 100644
index 0000000000..305fe37e7e
--- /dev/null
+++ b/browser/base/content/test/sanitize/browser_sanitize-passwordDisabledHosts.js
@@ -0,0 +1,28 @@
+// Bug 474792 - Clear "Never remember passwords for this site" when
+// clearing site-specific settings in Clear Recent History dialog
+
+add_task(async function () {
+ // getLoginSavingEnabled always returns false if password capture is disabled.
+ await SpecialPowers.pushPrefEnv({ set: [["signon.rememberSignons", true]] });
+
+ // Add a disabled host
+ Services.logins.setLoginSavingEnabled("https://example.com", false);
+ // Sanity check
+ is(
+ Services.logins.getLoginSavingEnabled("https://example.com"),
+ false,
+ "example.com should be disabled for password saving since we haven't cleared that yet."
+ );
+
+ // Clear it
+ await Sanitizer.sanitize(["siteSettings"], { ignoreTimespan: false });
+
+ // Make sure it's gone
+ is(
+ Services.logins.getLoginSavingEnabled("https://example.com"),
+ true,
+ "example.com should be enabled for password saving again now that we've cleared."
+ );
+
+ await SpecialPowers.popPrefEnv();
+});
diff --git a/browser/base/content/test/sanitize/browser_sanitize-sitepermissions.js b/browser/base/content/test/sanitize/browser_sanitize-sitepermissions.js
new file mode 100644
index 0000000000..034727852a
--- /dev/null
+++ b/browser/base/content/test/sanitize/browser_sanitize-sitepermissions.js
@@ -0,0 +1,37 @@
+// Bug 380852 - Delete permission manager entries in Clear Recent History
+
+function countPermissions() {
+ return Services.perms.all.length;
+}
+
+add_task(async function test() {
+ // sanitize before we start so we have a good baseline.
+ await Sanitizer.sanitize(["siteSettings"], { ignoreTimespan: false });
+
+ // Count how many permissions we start with - some are defaults that
+ // will not be sanitized.
+ let numAtStart = countPermissions();
+
+ // Add a permission entry
+ PermissionTestUtils.add(
+ "https://example.com",
+ "testing",
+ Services.perms.ALLOW_ACTION
+ );
+
+ // Sanity check
+ ok(
+ !!Services.perms.all.length,
+ "Permission manager should have elements, since we just added one"
+ );
+
+ // Clear it
+ await Sanitizer.sanitize(["siteSettings"], { ignoreTimespan: false });
+
+ // Make sure it's gone
+ is(
+ numAtStart,
+ countPermissions(),
+ "Permission manager should have the same count it started with"
+ );
+});
diff --git a/browser/base/content/test/sanitize/browser_sanitize-timespans.js b/browser/base/content/test/sanitize/browser_sanitize-timespans.js
new file mode 100644
index 0000000000..30ccb90666
--- /dev/null
+++ b/browser/base/content/test/sanitize/browser_sanitize-timespans.js
@@ -0,0 +1,1194 @@
+requestLongerTimeout(2);
+
+const { PlacesTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PlacesTestUtils.sys.mjs"
+);
+
+// Bug 453440 - Test the timespan-based logic of the sanitizer code
+var now_mSec = Date.now();
+var now_uSec = now_mSec * 1000;
+
+const kMsecPerMin = 60 * 1000;
+const kUsecPerMin = 60 * 1000000;
+
+function promiseFormHistoryRemoved() {
+ return new Promise(resolve => {
+ Services.obs.addObserver(function onfh() {
+ Services.obs.removeObserver(onfh, "satchel-storage-changed");
+ resolve();
+ }, "satchel-storage-changed");
+ });
+}
+
+function promiseDownloadRemoved(list) {
+ return new Promise(resolve => {
+ let view = {
+ onDownloadRemoved(download) {
+ list.removeView(view);
+ resolve();
+ },
+ };
+
+ list.addView(view);
+ });
+}
+
+add_task(async function test() {
+ await setupDownloads();
+ await setupFormHistory();
+ await setupHistory();
+ await onHistoryReady();
+});
+
+async function countEntries(name, message, check) {
+ var obj = {};
+ if (name !== null) {
+ obj.fieldname = name;
+ }
+ let count = await FormHistory.count(obj);
+ check(count, message);
+}
+
+async function onHistoryReady() {
+ var hoursSinceMidnight = new Date().getHours();
+ var minutesSinceMidnight = hoursSinceMidnight * 60 + new Date().getMinutes();
+
+ // Should test cookies here, but nsICookieManager/nsICookieService
+ // doesn't let us fake creation times. bug 463127
+
+ var itemPrefs = Services.prefs.getBranch("privacy.cpd.");
+ itemPrefs.setBoolPref("history", true);
+ itemPrefs.setBoolPref("downloads", true);
+ itemPrefs.setBoolPref("cache", false);
+ itemPrefs.setBoolPref("cookies", false);
+ itemPrefs.setBoolPref("formdata", true);
+ itemPrefs.setBoolPref("offlineApps", false);
+ itemPrefs.setBoolPref("passwords", false);
+ itemPrefs.setBoolPref("sessions", false);
+ itemPrefs.setBoolPref("siteSettings", false);
+
+ let publicList = await Downloads.getList(Downloads.PUBLIC);
+ let downloadPromise = promiseDownloadRemoved(publicList);
+ let formHistoryPromise = promiseFormHistoryRemoved();
+
+ // Clear 10 minutes ago
+ let range = [now_uSec - 10 * 60 * 1000000, now_uSec];
+ await Sanitizer.sanitize(null, { range, ignoreTimespan: false });
+
+ await formHistoryPromise;
+ await downloadPromise;
+
+ ok(
+ !(await PlacesUtils.history.hasVisits("https://10minutes.com")),
+ "Pretend visit to 10minutes.com should now be deleted"
+ );
+ ok(
+ await PlacesUtils.history.hasVisits("https://1hour.com"),
+ "Pretend visit to 1hour.com should should still exist"
+ );
+ ok(
+ await PlacesUtils.history.hasVisits("https://1hour10minutes.com"),
+ "Pretend visit to 1hour10minutes.com should should still exist"
+ );
+ ok(
+ await PlacesUtils.history.hasVisits("https://2hour.com"),
+ "Pretend visit to 2hour.com should should still exist"
+ );
+ ok(
+ await PlacesUtils.history.hasVisits("https://2hour10minutes.com"),
+ "Pretend visit to 2hour10minutes.com should should still exist"
+ );
+ ok(
+ await PlacesUtils.history.hasVisits("https://4hour.com"),
+ "Pretend visit to 4hour.com should should still exist"
+ );
+ ok(
+ await PlacesUtils.history.hasVisits("https://4hour10minutes.com"),
+ "Pretend visit to 4hour10minutes.com should should still exist"
+ );
+ if (minutesSinceMidnight > 10) {
+ ok(
+ await PlacesUtils.history.hasVisits("https://today.com"),
+ "Pretend visit to today.com should still exist"
+ );
+ }
+ ok(
+ await PlacesUtils.history.hasVisits("https://before-today.com"),
+ "Pretend visit to before-today.com should still exist"
+ );
+
+ let checkZero = function (num, message) {
+ is(num, 0, message);
+ };
+ let checkOne = function (num, message) {
+ is(num, 1, message);
+ };
+
+ await countEntries(
+ "10minutes",
+ "10minutes form entry should be deleted",
+ checkZero
+ );
+ await countEntries("1hour", "1hour form entry should still exist", checkOne);
+ await countEntries(
+ "1hour10minutes",
+ "1hour10minutes form entry should still exist",
+ checkOne
+ );
+ await countEntries("2hour", "2hour form entry should still exist", checkOne);
+ await countEntries(
+ "2hour10minutes",
+ "2hour10minutes form entry should still exist",
+ checkOne
+ );
+ await countEntries("4hour", "4hour form entry should still exist", checkOne);
+ await countEntries(
+ "4hour10minutes",
+ "4hour10minutes form entry should still exist",
+ checkOne
+ );
+ if (minutesSinceMidnight > 10) {
+ await countEntries(
+ "today",
+ "today form entry should still exist",
+ checkOne
+ );
+ }
+ await countEntries(
+ "b4today",
+ "b4today form entry should still exist",
+ checkOne
+ );
+
+ ok(
+ !(await downloadExists(publicList, "fakefile-10-minutes")),
+ "10 minute download should now be deleted"
+ );
+ ok(
+ await downloadExists(publicList, "fakefile-1-hour"),
+ "<1 hour download should still be present"
+ );
+ ok(
+ await downloadExists(publicList, "fakefile-1-hour-10-minutes"),
+ "1 hour 10 minute download should still be present"
+ );
+ ok(
+ await downloadExists(publicList, "fakefile-old"),
+ "Year old download should still be present"
+ );
+ ok(
+ await downloadExists(publicList, "fakefile-2-hour"),
+ "<2 hour old download should still be present"
+ );
+ ok(
+ await downloadExists(publicList, "fakefile-2-hour-10-minutes"),
+ "2 hour 10 minute download should still be present"
+ );
+ ok(
+ await downloadExists(publicList, "fakefile-4-hour"),
+ "<4 hour old download should still be present"
+ );
+ ok(
+ await downloadExists(publicList, "fakefile-4-hour-10-minutes"),
+ "4 hour 10 minute download should still be present"
+ );
+
+ if (minutesSinceMidnight > 10) {
+ ok(
+ await downloadExists(publicList, "fakefile-today"),
+ "'Today' download should still be present"
+ );
+ }
+
+ downloadPromise = promiseDownloadRemoved(publicList);
+ formHistoryPromise = promiseFormHistoryRemoved();
+
+ // Clear 1 hour
+ Services.prefs.setIntPref(Sanitizer.PREF_TIMESPAN, 1);
+ await Sanitizer.sanitize(null, { ignoreTimespan: false });
+
+ await formHistoryPromise;
+ await downloadPromise;
+
+ ok(
+ !(await PlacesUtils.history.hasVisits("https://1hour.com")),
+ "Pretend visit to 1hour.com should now be deleted"
+ );
+ ok(
+ await PlacesUtils.history.hasVisits("https://1hour10minutes.com"),
+ "Pretend visit to 1hour10minutes.com should should still exist"
+ );
+ ok(
+ await PlacesUtils.history.hasVisits("https://2hour.com"),
+ "Pretend visit to 2hour.com should should still exist"
+ );
+ ok(
+ await PlacesUtils.history.hasVisits("https://2hour10minutes.com"),
+ "Pretend visit to 2hour10minutes.com should should still exist"
+ );
+ ok(
+ await PlacesUtils.history.hasVisits("https://4hour.com"),
+ "Pretend visit to 4hour.com should should still exist"
+ );
+ ok(
+ await PlacesUtils.history.hasVisits("https://4hour10minutes.com"),
+ "Pretend visit to 4hour10minutes.com should should still exist"
+ );
+ if (hoursSinceMidnight > 1) {
+ ok(
+ await PlacesUtils.history.hasVisits("https://today.com"),
+ "Pretend visit to today.com should still exist"
+ );
+ }
+ ok(
+ await PlacesUtils.history.hasVisits("https://before-today.com"),
+ "Pretend visit to before-today.com should still exist"
+ );
+
+ await countEntries("1hour", "1hour form entry should be deleted", checkZero);
+ await countEntries(
+ "1hour10minutes",
+ "1hour10minutes form entry should still exist",
+ checkOne
+ );
+ await countEntries("2hour", "2hour form entry should still exist", checkOne);
+ await countEntries(
+ "2hour10minutes",
+ "2hour10minutes form entry should still exist",
+ checkOne
+ );
+ await countEntries("4hour", "4hour form entry should still exist", checkOne);
+ await countEntries(
+ "4hour10minutes",
+ "4hour10minutes form entry should still exist",
+ checkOne
+ );
+ if (hoursSinceMidnight > 1) {
+ await countEntries(
+ "today",
+ "today form entry should still exist",
+ checkOne
+ );
+ }
+ await countEntries(
+ "b4today",
+ "b4today form entry should still exist",
+ checkOne
+ );
+
+ ok(
+ !(await downloadExists(publicList, "fakefile-1-hour")),
+ "<1 hour download should now be deleted"
+ );
+ ok(
+ await downloadExists(publicList, "fakefile-1-hour-10-minutes"),
+ "1 hour 10 minute download should still be present"
+ );
+ ok(
+ await downloadExists(publicList, "fakefile-old"),
+ "Year old download should still be present"
+ );
+ ok(
+ await downloadExists(publicList, "fakefile-2-hour"),
+ "<2 hour old download should still be present"
+ );
+ ok(
+ await downloadExists(publicList, "fakefile-2-hour-10-minutes"),
+ "2 hour 10 minute download should still be present"
+ );
+ ok(
+ await downloadExists(publicList, "fakefile-4-hour"),
+ "<4 hour old download should still be present"
+ );
+ ok(
+ await downloadExists(publicList, "fakefile-4-hour-10-minutes"),
+ "4 hour 10 minute download should still be present"
+ );
+
+ if (hoursSinceMidnight > 1) {
+ ok(
+ await downloadExists(publicList, "fakefile-today"),
+ "'Today' download should still be present"
+ );
+ }
+
+ downloadPromise = promiseDownloadRemoved(publicList);
+ formHistoryPromise = promiseFormHistoryRemoved();
+
+ // Clear 1 hour 10 minutes
+ range = [now_uSec - 70 * 60 * 1000000, now_uSec];
+ await Sanitizer.sanitize(null, { range, ignoreTimespan: false });
+
+ await formHistoryPromise;
+ await downloadPromise;
+
+ ok(
+ !(await PlacesUtils.history.hasVisits("https://1hour10minutes.com")),
+ "Pretend visit to 1hour10minutes.com should now be deleted"
+ );
+ ok(
+ await PlacesUtils.history.hasVisits("https://2hour.com"),
+ "Pretend visit to 2hour.com should should still exist"
+ );
+ ok(
+ await PlacesUtils.history.hasVisits("https://2hour10minutes.com"),
+ "Pretend visit to 2hour10minutes.com should should still exist"
+ );
+ ok(
+ await PlacesUtils.history.hasVisits("https://4hour.com"),
+ "Pretend visit to 4hour.com should should still exist"
+ );
+ ok(
+ await PlacesUtils.history.hasVisits("https://4hour10minutes.com"),
+ "Pretend visit to 4hour10minutes.com should should still exist"
+ );
+ if (minutesSinceMidnight > 70) {
+ ok(
+ await PlacesUtils.history.hasVisits("https://today.com"),
+ "Pretend visit to today.com should still exist"
+ );
+ }
+ ok(
+ await PlacesUtils.history.hasVisits("https://before-today.com"),
+ "Pretend visit to before-today.com should still exist"
+ );
+
+ await countEntries(
+ "1hour10minutes",
+ "1hour10minutes form entry should be deleted",
+ checkZero
+ );
+ await countEntries("2hour", "2hour form entry should still exist", checkOne);
+ await countEntries(
+ "2hour10minutes",
+ "2hour10minutes form entry should still exist",
+ checkOne
+ );
+ await countEntries("4hour", "4hour form entry should still exist", checkOne);
+ await countEntries(
+ "4hour10minutes",
+ "4hour10minutes form entry should still exist",
+ checkOne
+ );
+ if (minutesSinceMidnight > 70) {
+ await countEntries(
+ "today",
+ "today form entry should still exist",
+ checkOne
+ );
+ }
+ await countEntries(
+ "b4today",
+ "b4today form entry should still exist",
+ checkOne
+ );
+
+ ok(
+ !(await downloadExists(publicList, "fakefile-1-hour-10-minutes")),
+ "1 hour 10 minute old download should now be deleted"
+ );
+ ok(
+ await downloadExists(publicList, "fakefile-old"),
+ "Year old download should still be present"
+ );
+ ok(
+ await downloadExists(publicList, "fakefile-2-hour"),
+ "<2 hour old download should still be present"
+ );
+ ok(
+ await downloadExists(publicList, "fakefile-2-hour-10-minutes"),
+ "2 hour 10 minute download should still be present"
+ );
+ ok(
+ await downloadExists(publicList, "fakefile-4-hour"),
+ "<4 hour old download should still be present"
+ );
+ ok(
+ await downloadExists(publicList, "fakefile-4-hour-10-minutes"),
+ "4 hour 10 minute download should still be present"
+ );
+ if (minutesSinceMidnight > 70) {
+ ok(
+ await downloadExists(publicList, "fakefile-today"),
+ "'Today' download should still be present"
+ );
+ }
+
+ downloadPromise = promiseDownloadRemoved(publicList);
+ formHistoryPromise = promiseFormHistoryRemoved();
+
+ // Clear 2 hours
+ Services.prefs.setIntPref(Sanitizer.PREF_TIMESPAN, 2);
+ await Sanitizer.sanitize(null, { ignoreTimespan: false });
+
+ await formHistoryPromise;
+ await downloadPromise;
+
+ ok(
+ !(await PlacesUtils.history.hasVisits("https://2hour.com")),
+ "Pretend visit to 2hour.com should now be deleted"
+ );
+ ok(
+ await PlacesUtils.history.hasVisits("https://2hour10minutes.com"),
+ "Pretend visit to 2hour10minutes.com should should still exist"
+ );
+ ok(
+ await PlacesUtils.history.hasVisits("https://4hour.com"),
+ "Pretend visit to 4hour.com should should still exist"
+ );
+ ok(
+ await PlacesUtils.history.hasVisits("https://4hour10minutes.com"),
+ "Pretend visit to 4hour10minutes.com should should still exist"
+ );
+ if (hoursSinceMidnight > 2) {
+ ok(
+ await PlacesUtils.history.hasVisits("https://today.com"),
+ "Pretend visit to today.com should still exist"
+ );
+ }
+ ok(
+ await PlacesUtils.history.hasVisits("https://before-today.com"),
+ "Pretend visit to before-today.com should still exist"
+ );
+
+ await countEntries("2hour", "2hour form entry should be deleted", checkZero);
+ await countEntries(
+ "2hour10minutes",
+ "2hour10minutes form entry should still exist",
+ checkOne
+ );
+ await countEntries("4hour", "4hour form entry should still exist", checkOne);
+ await countEntries(
+ "4hour10minutes",
+ "4hour10minutes form entry should still exist",
+ checkOne
+ );
+ if (hoursSinceMidnight > 2) {
+ await countEntries(
+ "today",
+ "today form entry should still exist",
+ checkOne
+ );
+ }
+ await countEntries(
+ "b4today",
+ "b4today form entry should still exist",
+ checkOne
+ );
+
+ ok(
+ !(await downloadExists(publicList, "fakefile-2-hour")),
+ "<2 hour old download should now be deleted"
+ );
+ ok(
+ await downloadExists(publicList, "fakefile-old"),
+ "Year old download should still be present"
+ );
+ ok(
+ await downloadExists(publicList, "fakefile-2-hour-10-minutes"),
+ "2 hour 10 minute download should still be present"
+ );
+ ok(
+ await downloadExists(publicList, "fakefile-4-hour"),
+ "<4 hour old download should still be present"
+ );
+ ok(
+ await downloadExists(publicList, "fakefile-4-hour-10-minutes"),
+ "4 hour 10 minute download should still be present"
+ );
+ if (hoursSinceMidnight > 2) {
+ ok(
+ await downloadExists(publicList, "fakefile-today"),
+ "'Today' download should still be present"
+ );
+ }
+
+ downloadPromise = promiseDownloadRemoved(publicList);
+ formHistoryPromise = promiseFormHistoryRemoved();
+
+ // Clear 2 hours 10 minutes
+ range = [now_uSec - 130 * 60 * 1000000, now_uSec];
+ await Sanitizer.sanitize(null, { range, ignoreTimespan: false });
+
+ await formHistoryPromise;
+ await downloadPromise;
+
+ ok(
+ !(await PlacesUtils.history.hasVisits("https://2hour10minutes.com")),
+ "Pretend visit to 2hour10minutes.com should now be deleted"
+ );
+ ok(
+ await PlacesUtils.history.hasVisits("https://4hour.com"),
+ "Pretend visit to 4hour.com should should still exist"
+ );
+ ok(
+ await PlacesUtils.history.hasVisits("https://4hour10minutes.com"),
+ "Pretend visit to 4hour10minutes.com should should still exist"
+ );
+ if (minutesSinceMidnight > 130) {
+ ok(
+ await PlacesUtils.history.hasVisits("https://today.com"),
+ "Pretend visit to today.com should still exist"
+ );
+ }
+ ok(
+ await PlacesUtils.history.hasVisits("https://before-today.com"),
+ "Pretend visit to before-today.com should still exist"
+ );
+
+ await countEntries(
+ "2hour10minutes",
+ "2hour10minutes form entry should be deleted",
+ checkZero
+ );
+ await countEntries("4hour", "4hour form entry should still exist", checkOne);
+ await countEntries(
+ "4hour10minutes",
+ "4hour10minutes form entry should still exist",
+ checkOne
+ );
+ if (minutesSinceMidnight > 130) {
+ await countEntries(
+ "today",
+ "today form entry should still exist",
+ checkOne
+ );
+ }
+ await countEntries(
+ "b4today",
+ "b4today form entry should still exist",
+ checkOne
+ );
+
+ ok(
+ !(await downloadExists(publicList, "fakefile-2-hour-10-minutes")),
+ "2 hour 10 minute old download should now be deleted"
+ );
+ ok(
+ await downloadExists(publicList, "fakefile-4-hour"),
+ "<4 hour old download should still be present"
+ );
+ ok(
+ await downloadExists(publicList, "fakefile-4-hour-10-minutes"),
+ "4 hour 10 minute download should still be present"
+ );
+ ok(
+ await downloadExists(publicList, "fakefile-old"),
+ "Year old download should still be present"
+ );
+ if (minutesSinceMidnight > 130) {
+ ok(
+ await downloadExists(publicList, "fakefile-today"),
+ "'Today' download should still be present"
+ );
+ }
+
+ downloadPromise = promiseDownloadRemoved(publicList);
+ formHistoryPromise = promiseFormHistoryRemoved();
+
+ // Clear 4 hours
+ Services.prefs.setIntPref(Sanitizer.PREF_TIMESPAN, 3);
+ await Sanitizer.sanitize(null, { ignoreTimespan: false });
+
+ await formHistoryPromise;
+ await downloadPromise;
+
+ ok(
+ !(await PlacesUtils.history.hasVisits("https://4hour.com")),
+ "Pretend visit to 4hour.com should now be deleted"
+ );
+ ok(
+ await PlacesUtils.history.hasVisits("https://4hour10minutes.com"),
+ "Pretend visit to 4hour10minutes.com should should still exist"
+ );
+ if (hoursSinceMidnight > 4) {
+ ok(
+ await PlacesUtils.history.hasVisits("https://today.com"),
+ "Pretend visit to today.com should still exist"
+ );
+ }
+ ok(
+ await PlacesUtils.history.hasVisits("https://before-today.com"),
+ "Pretend visit to before-today.com should still exist"
+ );
+
+ await countEntries("4hour", "4hour form entry should be deleted", checkZero);
+ await countEntries(
+ "4hour10minutes",
+ "4hour10minutes form entry should still exist",
+ checkOne
+ );
+ if (hoursSinceMidnight > 4) {
+ await countEntries(
+ "today",
+ "today form entry should still exist",
+ checkOne
+ );
+ }
+ await countEntries(
+ "b4today",
+ "b4today form entry should still exist",
+ checkOne
+ );
+
+ ok(
+ !(await downloadExists(publicList, "fakefile-4-hour")),
+ "<4 hour old download should now be deleted"
+ );
+ ok(
+ await downloadExists(publicList, "fakefile-4-hour-10-minutes"),
+ "4 hour 10 minute download should still be present"
+ );
+ ok(
+ await downloadExists(publicList, "fakefile-old"),
+ "Year old download should still be present"
+ );
+ if (hoursSinceMidnight > 4) {
+ ok(
+ await downloadExists(publicList, "fakefile-today"),
+ "'Today' download should still be present"
+ );
+ }
+
+ downloadPromise = promiseDownloadRemoved(publicList);
+ formHistoryPromise = promiseFormHistoryRemoved();
+
+ // Clear 4 hours 10 minutes
+ range = [now_uSec - 250 * 60 * 1000000, now_uSec];
+ await Sanitizer.sanitize(null, { range, ignoreTimespan: false });
+
+ await formHistoryPromise;
+ await downloadPromise;
+
+ ok(
+ !(await PlacesUtils.history.hasVisits("https://4hour10minutes.com")),
+ "Pretend visit to 4hour10minutes.com should now be deleted"
+ );
+ if (minutesSinceMidnight > 250) {
+ ok(
+ await PlacesUtils.history.hasVisits("https://today.com"),
+ "Pretend visit to today.com should still exist"
+ );
+ }
+ ok(
+ await PlacesUtils.history.hasVisits("https://before-today.com"),
+ "Pretend visit to before-today.com should still exist"
+ );
+
+ await countEntries(
+ "4hour10minutes",
+ "4hour10minutes form entry should be deleted",
+ checkZero
+ );
+ if (minutesSinceMidnight > 250) {
+ await countEntries(
+ "today",
+ "today form entry should still exist",
+ checkOne
+ );
+ }
+ await countEntries(
+ "b4today",
+ "b4today form entry should still exist",
+ checkOne
+ );
+
+ ok(
+ !(await downloadExists(publicList, "fakefile-4-hour-10-minutes")),
+ "4 hour 10 minute download should now be deleted"
+ );
+ ok(
+ await downloadExists(publicList, "fakefile-old"),
+ "Year old download should still be present"
+ );
+ if (minutesSinceMidnight > 250) {
+ ok(
+ await downloadExists(publicList, "fakefile-today"),
+ "'Today' download should still be present"
+ );
+ }
+
+ // The 'Today' download might have been already deleted, in which case we
+ // should not wait for a download removal notification.
+ if (minutesSinceMidnight > 250) {
+ downloadPromise = promiseDownloadRemoved(publicList);
+ formHistoryPromise = promiseFormHistoryRemoved();
+ } else {
+ downloadPromise = formHistoryPromise = Promise.resolve();
+ }
+
+ // Clear Today
+ Services.prefs.setIntPref(Sanitizer.PREF_TIMESPAN, 4);
+ let progress = await Sanitizer.sanitize(null, { ignoreTimespan: false });
+ Assert.deepEqual(progress, {
+ history: "cleared",
+ formdata: "cleared",
+ downloads: "cleared",
+ });
+
+ await formHistoryPromise;
+ await downloadPromise;
+
+ // Be careful. If we add our objectss just before midnight, and sanitize
+ // runs immediately after, they won't be expired. This is expected, but
+ // we should not test in that case. We cannot just test for opposite
+ // condition because we could cross midnight just one moment after we
+ // cache our time, then we would have an even worse random failure.
+ var today = isToday(new Date(now_mSec));
+ if (today) {
+ ok(
+ !(await PlacesUtils.history.hasVisits("https://today.com")),
+ "Pretend visit to today.com should now be deleted"
+ );
+
+ await countEntries(
+ "today",
+ "today form entry should be deleted",
+ checkZero
+ );
+ ok(
+ !(await downloadExists(publicList, "fakefile-today")),
+ "'Today' download should now be deleted"
+ );
+ }
+
+ ok(
+ await PlacesUtils.history.hasVisits("https://before-today.com"),
+ "Pretend visit to before-today.com should still exist"
+ );
+ await countEntries(
+ "b4today",
+ "b4today form entry should still exist",
+ checkOne
+ );
+ ok(
+ await downloadExists(publicList, "fakefile-old"),
+ "Year old download should still be present"
+ );
+
+ downloadPromise = promiseDownloadRemoved(publicList);
+ formHistoryPromise = promiseFormHistoryRemoved();
+
+ // Choose everything
+ Services.prefs.setIntPref(Sanitizer.PREF_TIMESPAN, 0);
+ await Sanitizer.sanitize(null, { ignoreTimespan: false });
+
+ await formHistoryPromise;
+ await downloadPromise;
+
+ ok(
+ !(await PlacesUtils.history.hasVisits("https://before-today.com")),
+ "Pretend visit to before-today.com should now be deleted"
+ );
+
+ await countEntries(
+ "b4today",
+ "b4today form entry should be deleted",
+ checkZero
+ );
+
+ ok(
+ !(await downloadExists(publicList, "fakefile-old")),
+ "Year old download should now be deleted"
+ );
+}
+
+async function setupHistory() {
+ let places = [];
+
+ function addPlace(aURI, aTitle, aVisitDate) {
+ places.push({
+ uri: aURI,
+ title: aTitle,
+ visitDate: aVisitDate,
+ transition: Ci.nsINavHistoryService.TRANSITION_LINK,
+ });
+ }
+
+ addPlace(
+ "https://10minutes.com/",
+ "10 minutes ago",
+ now_uSec - 10 * kUsecPerMin
+ );
+ addPlace(
+ "https://1hour.com/",
+ "Less than 1 hour ago",
+ now_uSec - 45 * kUsecPerMin
+ );
+ addPlace(
+ "https://1hour10minutes.com/",
+ "1 hour 10 minutes ago",
+ now_uSec - 70 * kUsecPerMin
+ );
+ addPlace(
+ "https://2hour.com/",
+ "Less than 2 hours ago",
+ now_uSec - 90 * kUsecPerMin
+ );
+ addPlace(
+ "https://2hour10minutes.com/",
+ "2 hours 10 minutes ago",
+ now_uSec - 130 * kUsecPerMin
+ );
+ addPlace(
+ "https://4hour.com/",
+ "Less than 4 hours ago",
+ now_uSec - 180 * kUsecPerMin
+ );
+ addPlace(
+ "https://4hour10minutes.com/",
+ "4 hours 10 minutesago",
+ now_uSec - 250 * kUsecPerMin
+ );
+
+ let today = new Date();
+ today.setHours(0);
+ today.setMinutes(0);
+ today.setSeconds(0);
+ today.setMilliseconds(1);
+ addPlace("https://today.com/", "Today", today.getTime() * 1000);
+
+ let lastYear = new Date();
+ lastYear.setFullYear(lastYear.getFullYear() - 1);
+ addPlace(
+ "https://before-today.com/",
+ "Before Today",
+ lastYear.getTime() * 1000
+ );
+ await PlacesTestUtils.addVisits(places);
+}
+
+async function setupFormHistory() {
+ function searchEntries(terms, params) {
+ return FormHistory.search(terms, params);
+ }
+
+ // 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: "10minutes",
+ value: "10m",
+ },
+ {
+ op: "add",
+ fieldname: "1hour",
+ value: "1h",
+ },
+ {
+ op: "add",
+ fieldname: "1hour10minutes",
+ value: "1h10m",
+ },
+ {
+ op: "add",
+ fieldname: "2hour",
+ value: "2h",
+ },
+ {
+ op: "add",
+ fieldname: "2hour10minutes",
+ value: "2h10m",
+ },
+ {
+ op: "add",
+ fieldname: "4hour",
+ value: "4h",
+ },
+ {
+ op: "add",
+ fieldname: "4hour10minutes",
+ value: "4h10m",
+ },
+ {
+ op: "add",
+ fieldname: "today",
+ value: "1d",
+ },
+ {
+ op: "add",
+ fieldname: "b4today",
+ value: "1y",
+ },
+ ]);
+
+ // Artifically age the entries to the proper vintage.
+ let timestamp = now_uSec - 10 * kUsecPerMin;
+ let results = await searchEntries(["guid"], { fieldname: "10minutes" });
+ await FormHistory.update({
+ op: "update",
+ firstUsed: timestamp,
+ guid: results[0].guid,
+ });
+
+ timestamp = now_uSec - 45 * kUsecPerMin;
+ results = await searchEntries(["guid"], { fieldname: "1hour" });
+ await FormHistory.update({
+ op: "update",
+ firstUsed: timestamp,
+ guid: results[0].guid,
+ });
+
+ timestamp = now_uSec - 70 * kUsecPerMin;
+ results = await searchEntries(["guid"], { fieldname: "1hour10minutes" });
+ await FormHistory.update({
+ op: "update",
+ firstUsed: timestamp,
+ guid: results[0].guid,
+ });
+
+ timestamp = now_uSec - 90 * kUsecPerMin;
+ results = await searchEntries(["guid"], { fieldname: "2hour" });
+ await FormHistory.update({
+ op: "update",
+ firstUsed: timestamp,
+ guid: results[0].guid,
+ });
+
+ timestamp = now_uSec - 130 * kUsecPerMin;
+ results = await searchEntries(["guid"], { fieldname: "2hour10minutes" });
+ await FormHistory.update({
+ op: "update",
+ firstUsed: timestamp,
+ guid: results[0].guid,
+ });
+
+ timestamp = now_uSec - 180 * kUsecPerMin;
+ results = await searchEntries(["guid"], { fieldname: "4hour" });
+ await FormHistory.update({
+ op: "update",
+ firstUsed: timestamp,
+ guid: results[0].guid,
+ });
+
+ timestamp = now_uSec - 250 * kUsecPerMin;
+ results = await searchEntries(["guid"], { fieldname: "4hour10minutes" });
+ await FormHistory.update({
+ op: "update",
+ firstUsed: timestamp,
+ guid: results[0].guid,
+ });
+
+ let today = new Date();
+ today.setHours(0);
+ today.setMinutes(0);
+ today.setSeconds(0);
+ today.setMilliseconds(1);
+ timestamp = today.getTime() * 1000;
+ results = await searchEntries(["guid"], { fieldname: "today" });
+ await FormHistory.update({
+ op: "update",
+ firstUsed: timestamp,
+ guid: results[0].guid,
+ });
+
+ let lastYear = new Date();
+ lastYear.setFullYear(lastYear.getFullYear() - 1);
+ timestamp = lastYear.getTime() * 1000;
+ results = await searchEntries(["guid"], { fieldname: "b4today" });
+ await FormHistory.update({
+ op: "update",
+ firstUsed: timestamp,
+ guid: results[0].guid,
+ });
+
+ var checks = 0;
+ let checkOne = function (num, message) {
+ is(num, 1, message);
+ checks++;
+ };
+
+ // Sanity check.
+ await countEntries(
+ "10minutes",
+ "Checking for 10minutes form history entry creation",
+ checkOne
+ );
+ await countEntries(
+ "1hour",
+ "Checking for 1hour form history entry creation",
+ checkOne
+ );
+ await countEntries(
+ "1hour10minutes",
+ "Checking for 1hour10minutes form history entry creation",
+ checkOne
+ );
+ await countEntries(
+ "2hour",
+ "Checking for 2hour form history entry creation",
+ checkOne
+ );
+ await countEntries(
+ "2hour10minutes",
+ "Checking for 2hour10minutes form history entry creation",
+ checkOne
+ );
+ await countEntries(
+ "4hour",
+ "Checking for 4hour form history entry creation",
+ checkOne
+ );
+ await countEntries(
+ "4hour10minutes",
+ "Checking for 4hour10minutes form history entry creation",
+ checkOne
+ );
+ await countEntries(
+ "today",
+ "Checking for today form history entry creation",
+ checkOne
+ );
+ await countEntries(
+ "b4today",
+ "Checking for b4today form history entry creation",
+ checkOne
+ );
+ is(checks, 9, "9 checks made");
+}
+
+async function setupDownloads() {
+ let publicList = await Downloads.getList(Downloads.PUBLIC);
+
+ let download = await Downloads.createDownload({
+ source: "https://bugzilla.mozilla.org/show_bug.cgi?id=480169",
+ target: "fakefile-10-minutes",
+ });
+ download.startTime = new Date(now_mSec - 10 * kMsecPerMin); // 10 minutes ago
+ download.canceled = true;
+ await publicList.add(download);
+
+ download = await Downloads.createDownload({
+ source: "https://bugzilla.mozilla.org/show_bug.cgi?id=453440",
+ target: "fakefile-1-hour",
+ });
+ download.startTime = new Date(now_mSec - 45 * kMsecPerMin); // 45 minutes ago
+ download.canceled = true;
+ await publicList.add(download);
+
+ download = await Downloads.createDownload({
+ source: "https://bugzilla.mozilla.org/show_bug.cgi?id=480169",
+ target: "fakefile-1-hour-10-minutes",
+ });
+ download.startTime = new Date(now_mSec - 70 * kMsecPerMin); // 70 minutes ago
+ download.canceled = true;
+ await publicList.add(download);
+
+ download = await Downloads.createDownload({
+ source: "https://bugzilla.mozilla.org/show_bug.cgi?id=453440",
+ target: "fakefile-2-hour",
+ });
+ download.startTime = new Date(now_mSec - 90 * kMsecPerMin); // 90 minutes ago
+ download.canceled = true;
+ await publicList.add(download);
+
+ download = await Downloads.createDownload({
+ source: "https://bugzilla.mozilla.org/show_bug.cgi?id=480169",
+ target: "fakefile-2-hour-10-minutes",
+ });
+ download.startTime = new Date(now_mSec - 130 * kMsecPerMin); // 130 minutes ago
+ download.canceled = true;
+ await publicList.add(download);
+
+ download = await Downloads.createDownload({
+ source: "https://bugzilla.mozilla.org/show_bug.cgi?id=453440",
+ target: "fakefile-4-hour",
+ });
+ download.startTime = new Date(now_mSec - 180 * kMsecPerMin); // 180 minutes ago
+ download.canceled = true;
+ await publicList.add(download);
+
+ download = await Downloads.createDownload({
+ source: "https://bugzilla.mozilla.org/show_bug.cgi?id=480169",
+ target: "fakefile-4-hour-10-minutes",
+ });
+ download.startTime = new Date(now_mSec - 250 * kMsecPerMin); // 250 minutes ago
+ download.canceled = true;
+ await publicList.add(download);
+
+ // Add "today" download
+ let today = new Date();
+ today.setHours(0);
+ today.setMinutes(0);
+ today.setSeconds(0);
+ today.setMilliseconds(1);
+
+ download = await Downloads.createDownload({
+ source: "https://bugzilla.mozilla.org/show_bug.cgi?id=453440",
+ target: "fakefile-today",
+ });
+ download.startTime = today; // 12:00:01 AM this morning
+ download.canceled = true;
+ await publicList.add(download);
+
+ // Add "before today" download
+ let lastYear = new Date();
+ lastYear.setFullYear(lastYear.getFullYear() - 1);
+
+ download = await Downloads.createDownload({
+ source: "https://bugzilla.mozilla.org/show_bug.cgi?id=453440",
+ target: "fakefile-old",
+ });
+ download.startTime = lastYear;
+ download.canceled = true;
+ await publicList.add(download);
+
+ // Confirm everything worked
+ let downloads = await publicList.getAll();
+ is(downloads.length, 9, "9 Pretend downloads added");
+
+ ok(
+ await downloadExists(publicList, "fakefile-old"),
+ "Pretend download for everything case should exist"
+ );
+ ok(
+ await downloadExists(publicList, "fakefile-10-minutes"),
+ "Pretend download for 10-minutes case should exist"
+ );
+ ok(
+ await downloadExists(publicList, "fakefile-1-hour"),
+ "Pretend download for 1-hour case should exist"
+ );
+ ok(
+ await downloadExists(publicList, "fakefile-1-hour-10-minutes"),
+ "Pretend download for 1-hour-10-minutes case should exist"
+ );
+ ok(
+ await downloadExists(publicList, "fakefile-2-hour"),
+ "Pretend download for 2-hour case should exist"
+ );
+ ok(
+ await downloadExists(publicList, "fakefile-2-hour-10-minutes"),
+ "Pretend download for 2-hour-10-minutes case should exist"
+ );
+ ok(
+ await downloadExists(publicList, "fakefile-4-hour"),
+ "Pretend download for 4-hour case should exist"
+ );
+ ok(
+ await downloadExists(publicList, "fakefile-4-hour-10-minutes"),
+ "Pretend download for 4-hour-10-minutes case should exist"
+ );
+ ok(
+ await downloadExists(publicList, "fakefile-today"),
+ "Pretend download for Today case should exist"
+ );
+}
+
+/**
+ * Checks to see if the downloads with the specified id exists.
+ *
+ * @param aID
+ * The ids of the downloads to check.
+ */
+let downloadExists = async function (list, path) {
+ let listArray = await list.getAll();
+ return listArray.some(i => i.target.path == path);
+};
+
+function isToday(aDate) {
+ return aDate.getDate() == new Date().getDate();
+}
diff --git a/browser/base/content/test/sanitize/browser_sanitizeDialog.js b/browser/base/content/test/sanitize/browser_sanitizeDialog.js
new file mode 100644
index 0000000000..aece90f16e
--- /dev/null
+++ b/browser/base/content/test/sanitize/browser_sanitizeDialog.js
@@ -0,0 +1,833 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim:set ts=2 sw=2 sts=2 et: */
+/* 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/. */
+
+/**
+ * Tests the sanitize dialog (a.k.a. the clear recent history dialog).
+ * See bug 480169.
+ *
+ * The purpose of this test is not to fully flex the sanitize timespan code;
+ * browser/base/content/test/sanitize/browser_sanitize-timespans.js does that. This
+ * test checks the UI of the dialog and makes sure it's correctly connected to
+ * the sanitize timespan code.
+ *
+ * Some of this code, especially the history creation parts, was taken from
+ * browser/base/content/test/sanitize/browser_sanitize-timespans.js.
+ */
+
+ChromeUtils.defineESModuleGetters(this, {
+ PlacesTestUtils: "resource://testing-common/PlacesTestUtils.sys.mjs",
+ Timer: "resource://gre/modules/Timer.sys.mjs",
+});
+
+const kMsecPerMin = 60 * 1000;
+const kUsecPerMin = 60 * 1000000;
+
+/**
+ * Ensures that the specified URIs are either cleared or not.
+ *
+ * @param aURIs
+ * Array of page URIs
+ * @param aShouldBeCleared
+ * True if each visit to the URI should be cleared, false otherwise
+ */
+async function promiseHistoryClearedState(aURIs, aShouldBeCleared) {
+ for (let uri of aURIs) {
+ let visited = await PlacesUtils.history.hasVisits(uri);
+ Assert.equal(
+ visited,
+ !aShouldBeCleared,
+ `history visit ${uri.spec} should ${
+ aShouldBeCleared ? "no longer" : "still"
+ } exist`
+ );
+ }
+}
+
+add_setup(async function () {
+ requestLongerTimeout(3);
+ await blankSlate();
+ registerCleanupFunction(async function () {
+ await blankSlate();
+ await PlacesTestUtils.promiseAsyncUpdates();
+ });
+});
+
+/**
+ * Initializes the dialog to its default state.
+ */
+add_task(async function default_state() {
+ let dh = new DialogHelper();
+ dh.onload = function () {
+ // Select "Last Hour"
+ this.selectDuration(Sanitizer.TIMESPAN_HOUR);
+ this.acceptDialog();
+ };
+ dh.open();
+ await dh.promiseClosed;
+});
+
+/**
+ * Cancels the dialog, makes sure history not cleared.
+ */
+add_task(async function test_cancel() {
+ // Add history (within the past hour)
+ let uris = [];
+ let places = [];
+ let pURI;
+ for (let i = 0; i < 30; i++) {
+ pURI = makeURI("https://" + i + "-minutes-ago.com/");
+ places.push({ uri: pURI, visitDate: visitTimeForMinutesAgo(i) });
+ uris.push(pURI);
+ }
+ await PlacesTestUtils.addVisits(places);
+
+ let dh = new DialogHelper();
+ dh.onload = function () {
+ this.selectDuration(Sanitizer.TIMESPAN_HOUR);
+ this.checkPrefCheckbox("history", false);
+ this.cancelDialog();
+ };
+ dh.onunload = async function () {
+ await promiseHistoryClearedState(uris, false);
+ await blankSlate();
+ await promiseHistoryClearedState(uris, true);
+ };
+ dh.open();
+ await dh.promiseClosed;
+});
+
+/**
+ * Ensures that the combined history-downloads checkbox clears both history
+ * visits and downloads when checked; the dialog respects simple timespan.
+ */
+add_task(async function test_history_downloads_checked() {
+ // Add downloads (within the past hour).
+ let downloadIDs = [];
+ for (let i = 0; i < 5; i++) {
+ await addDownloadWithMinutesAgo(downloadIDs, i);
+ }
+ // Add downloads (over an hour ago).
+ let olderDownloadIDs = [];
+ for (let i = 0; i < 5; i++) {
+ await addDownloadWithMinutesAgo(olderDownloadIDs, 61 + i);
+ }
+
+ // Add history (within the past hour).
+ let uris = [];
+ let places = [];
+ let pURI;
+ for (let i = 0; i < 30; i++) {
+ pURI = makeURI("https://" + i + "-minutes-ago.com/");
+ places.push({ uri: pURI, visitDate: visitTimeForMinutesAgo(i) });
+ uris.push(pURI);
+ }
+ // Add history (over an hour ago).
+ let olderURIs = [];
+ for (let i = 0; i < 5; i++) {
+ pURI = makeURI("https://" + (61 + i) + "-minutes-ago.com/");
+ places.push({ uri: pURI, visitDate: visitTimeForMinutesAgo(61 + i) });
+ olderURIs.push(pURI);
+ }
+ let promiseSanitized = promiseSanitizationComplete();
+
+ await PlacesTestUtils.addVisits(places);
+
+ let dh = new DialogHelper();
+ dh.onload = function () {
+ this.selectDuration(Sanitizer.TIMESPAN_HOUR);
+ this.checkPrefCheckbox("history", true);
+ this.acceptDialog();
+ };
+ dh.onunload = async function () {
+ intPrefIs(
+ "sanitize.timeSpan",
+ Sanitizer.TIMESPAN_HOUR,
+ "timeSpan pref should be hour after accepting dialog with " +
+ "hour selected"
+ );
+ boolPrefIs(
+ "cpd.history",
+ true,
+ "history pref should be true after accepting dialog with " +
+ "history checkbox checked"
+ );
+ boolPrefIs(
+ "cpd.downloads",
+ true,
+ "downloads pref should be true after accepting dialog with " +
+ "history checkbox checked"
+ );
+
+ await promiseSanitized;
+
+ // History visits and downloads within one hour should be cleared.
+ await promiseHistoryClearedState(uris, true);
+ await ensureDownloadsClearedState(downloadIDs, true);
+
+ // Visits and downloads > 1 hour should still exist.
+ await promiseHistoryClearedState(olderURIs, false);
+ await ensureDownloadsClearedState(olderDownloadIDs, false);
+
+ // OK, done, cleanup after ourselves.
+ await blankSlate();
+ await promiseHistoryClearedState(olderURIs, true);
+ await ensureDownloadsClearedState(olderDownloadIDs, true);
+ };
+ dh.open();
+ await dh.promiseClosed;
+});
+
+/**
+ * Ensures that the combined history-downloads checkbox removes neither
+ * history visits nor downloads when not checked.
+ */
+add_task(async function test_history_downloads_unchecked() {
+ // Add form entries
+ let formEntries = [];
+
+ for (let i = 0; i < 5; i++) {
+ formEntries.push(await promiseAddFormEntryWithMinutesAgo(i));
+ }
+
+ // Add downloads (within the past hour).
+ let downloadIDs = [];
+ for (let i = 0; i < 5; i++) {
+ await addDownloadWithMinutesAgo(downloadIDs, i);
+ }
+
+ // Add history, downloads, form entries (within the past hour).
+ let uris = [];
+ let places = [];
+ let pURI;
+ for (let i = 0; i < 5; i++) {
+ pURI = makeURI("https://" + i + "-minutes-ago.com/");
+ places.push({ uri: pURI, visitDate: visitTimeForMinutesAgo(i) });
+ uris.push(pURI);
+ }
+
+ await PlacesTestUtils.addVisits(places);
+ let dh = new DialogHelper();
+ dh.onload = function () {
+ is(
+ this.isWarningPanelVisible(),
+ false,
+ "Warning panel should be hidden after previously accepting dialog " +
+ "with a predefined timespan"
+ );
+ this.selectDuration(Sanitizer.TIMESPAN_HOUR);
+
+ // Remove only form entries, leave history (including downloads).
+ this.checkPrefCheckbox("history", false);
+ this.checkPrefCheckbox("formdata", true);
+ this.acceptDialog();
+ };
+ dh.onunload = async function () {
+ intPrefIs(
+ "sanitize.timeSpan",
+ Sanitizer.TIMESPAN_HOUR,
+ "timeSpan pref should be hour after accepting dialog with " +
+ "hour selected"
+ );
+ boolPrefIs(
+ "cpd.history",
+ false,
+ "history pref should be false after accepting dialog with " +
+ "history checkbox unchecked"
+ );
+ boolPrefIs(
+ "cpd.downloads",
+ false,
+ "downloads pref should be false after accepting dialog with " +
+ "history checkbox unchecked"
+ );
+
+ // Of the three only form entries should be cleared.
+ await promiseHistoryClearedState(uris, false);
+ await ensureDownloadsClearedState(downloadIDs, false);
+
+ for (let entry of formEntries) {
+ let exists = await formNameExists(entry);
+ ok(!exists, "form entry " + entry + " should no longer exist");
+ }
+
+ // OK, done, cleanup after ourselves.
+ await blankSlate();
+ await promiseHistoryClearedState(uris, true);
+ await ensureDownloadsClearedState(downloadIDs, true);
+ };
+ dh.open();
+ await dh.promiseClosed;
+});
+
+/**
+ * Ensures that the "Everything" duration option works.
+ */
+add_task(async function test_everything() {
+ // Add history.
+ let uris = [];
+ let places = [];
+ let pURI;
+ // within past hour, within past two hours, within past four hours and
+ // outside past four hours
+ [10, 70, 130, 250].forEach(function (aValue) {
+ pURI = makeURI("https://" + aValue + "-minutes-ago.com/");
+ places.push({ uri: pURI, visitDate: visitTimeForMinutesAgo(aValue) });
+ uris.push(pURI);
+ });
+
+ let promiseSanitized = promiseSanitizationComplete();
+
+ await PlacesTestUtils.addVisits(places);
+ let dh = new DialogHelper();
+ dh.onload = function () {
+ is(
+ this.isWarningPanelVisible(),
+ false,
+ "Warning panel should be hidden after previously accepting dialog " +
+ "with a predefined timespan"
+ );
+ this.selectDuration(Sanitizer.TIMESPAN_EVERYTHING);
+ this.checkPrefCheckbox("history", true);
+ this.acceptDialog();
+ };
+ dh.onunload = async function () {
+ await promiseSanitized;
+ intPrefIs(
+ "sanitize.timeSpan",
+ Sanitizer.TIMESPAN_EVERYTHING,
+ "timeSpan pref should be everything after accepting dialog " +
+ "with everything selected"
+ );
+
+ await promiseHistoryClearedState(uris, true);
+ };
+ dh.open();
+ await dh.promiseClosed;
+});
+
+/**
+ * Ensures that the "Everything" warning is visible on dialog open after
+ * the previous test.
+ */
+add_task(async function test_everything_warning() {
+ // Add history.
+ let uris = [];
+ let places = [];
+ let pURI;
+ // within past hour, within past two hours, within past four hours and
+ // outside past four hours
+ [10, 70, 130, 250].forEach(function (aValue) {
+ pURI = makeURI("https://" + aValue + "-minutes-ago.com/");
+ places.push({ uri: pURI, visitDate: visitTimeForMinutesAgo(aValue) });
+ uris.push(pURI);
+ });
+
+ let promiseSanitized = promiseSanitizationComplete();
+
+ await PlacesTestUtils.addVisits(places);
+ let dh = new DialogHelper();
+ dh.onload = function () {
+ is(
+ this.isWarningPanelVisible(),
+ true,
+ "Warning panel should be visible after previously accepting dialog " +
+ "with clearing everything"
+ );
+ this.selectDuration(Sanitizer.TIMESPAN_EVERYTHING);
+ this.checkPrefCheckbox("history", true);
+ this.acceptDialog();
+ };
+ dh.onunload = async function () {
+ intPrefIs(
+ "sanitize.timeSpan",
+ Sanitizer.TIMESPAN_EVERYTHING,
+ "timeSpan pref should be everything after accepting dialog " +
+ "with everything selected"
+ );
+
+ await promiseSanitized;
+
+ await promiseHistoryClearedState(uris, true);
+ };
+ dh.open();
+ await dh.promiseClosed;
+});
+
+/**
+ * The next three tests checks that when a certain history item cannot be
+ * cleared then the checkbox should be both disabled and unchecked.
+ * In addition, we ensure that this behavior does not modify the preferences.
+ */
+add_task(async function test_cannot_clear_history() {
+ // Add form entries
+ let formEntries = [await promiseAddFormEntryWithMinutesAgo(10)];
+
+ let promiseSanitized = promiseSanitizationComplete();
+
+ // Add history.
+ let pURI = makeURI("https://" + 10 + "-minutes-ago.com/");
+ await PlacesTestUtils.addVisits({
+ uri: pURI,
+ visitDate: visitTimeForMinutesAgo(10),
+ });
+ let uris = [pURI];
+
+ let dh = new DialogHelper();
+ dh.onload = function () {
+ // Check that the relevant checkboxes are enabled
+ var cb = this.win.document.querySelectorAll(
+ "checkbox[preference='privacy.cpd.formdata']"
+ );
+ ok(
+ cb.length == 1 && !cb[0].disabled,
+ "There is formdata, checkbox to clear formdata should be enabled."
+ );
+
+ cb = this.win.document.querySelectorAll(
+ "checkbox[preference='privacy.cpd.history']"
+ );
+ ok(
+ cb.length == 1 && !cb[0].disabled,
+ "There is history, checkbox to clear history should be enabled."
+ );
+
+ this.checkAllCheckboxes();
+ this.acceptDialog();
+ };
+ dh.onunload = async function () {
+ await promiseSanitized;
+
+ await promiseHistoryClearedState(uris, true);
+
+ let exists = await formNameExists(formEntries[0]);
+ ok(!exists, "form entry " + formEntries[0] + " should no longer exist");
+ };
+ dh.open();
+ await dh.promiseClosed;
+});
+
+add_task(async function test_no_formdata_history_to_clear() {
+ let promiseSanitized = promiseSanitizationComplete();
+ let dh = new DialogHelper();
+ dh.onload = function () {
+ boolPrefIs(
+ "cpd.history",
+ true,
+ "history pref should be true after accepting dialog with " +
+ "history checkbox checked"
+ );
+ boolPrefIs(
+ "cpd.formdata",
+ true,
+ "formdata pref should be true after accepting dialog with " +
+ "formdata checkbox checked"
+ );
+
+ var cb = this.win.document.querySelectorAll(
+ "checkbox[preference='privacy.cpd.history']"
+ );
+ ok(
+ cb.length == 1 && !cb[0].disabled && cb[0].checked,
+ "There is no history, but history checkbox should always be enabled " +
+ "and will be checked from previous preference."
+ );
+
+ this.acceptDialog();
+ };
+ dh.open();
+ await dh.promiseClosed;
+ await promiseSanitized;
+});
+
+add_task(async function test_form_entries() {
+ let formEntry = await promiseAddFormEntryWithMinutesAgo(10);
+
+ let promiseSanitized = promiseSanitizationComplete();
+
+ let dh = new DialogHelper();
+ dh.onload = function () {
+ boolPrefIs(
+ "cpd.formdata",
+ true,
+ "formdata pref should persist previous value after accepting " +
+ "dialog where you could not clear formdata."
+ );
+
+ var cb = this.win.document.querySelectorAll(
+ "checkbox[preference='privacy.cpd.formdata']"
+ );
+
+ info(
+ "There exists formEntries so the checkbox should be in sync with the pref."
+ );
+ is(cb.length, 1, "There is only one checkbox for form data");
+ ok(!cb[0].disabled, "The checkbox is enabled");
+ ok(cb[0].checked, "The checkbox is checked");
+
+ this.acceptDialog();
+ };
+ dh.onunload = async function () {
+ await promiseSanitized;
+ let exists = await formNameExists(formEntry);
+ ok(!exists, "form entry " + formEntry + " should no longer exist");
+ };
+ dh.open();
+ await dh.promiseClosed;
+});
+
+// Test for offline apps permission deletion
+add_task(async function test_offline_apps_permissions() {
+ // Prepare stuff, we will work with www.example.com
+ var URL = "https://www.example.com";
+ var URI = makeURI(URL);
+ var principal = Services.scriptSecurityManager.createContentPrincipal(
+ URI,
+ {}
+ );
+
+ let promiseSanitized = promiseSanitizationComplete();
+
+ // Open the dialog
+ let dh = new DialogHelper();
+ dh.onload = function () {
+ this.selectDuration(Sanitizer.TIMESPAN_EVERYTHING);
+ // Clear only offlineApps
+ this.uncheckAllCheckboxes();
+ this.checkPrefCheckbox("siteSettings", true);
+ this.acceptDialog();
+ };
+ dh.onunload = async function () {
+ await promiseSanitized;
+
+ // Check all has been deleted (privileges, data, cache)
+ is(
+ Services.perms.testPermissionFromPrincipal(principal, "offline-app"),
+ 0,
+ "offline-app permissions removed"
+ );
+ };
+ dh.open();
+ await dh.promiseClosed;
+});
+
+var now_mSec = Date.now();
+var now_uSec = now_mSec * 1000;
+
+/**
+ * This wraps the dialog and provides some convenience methods for interacting
+ * with it.
+ *
+ * @param browserWin (optional)
+ * The browser window that the dialog is expected to open in. If not
+ * supplied, the initial browser window of the test run is used.
+ */
+function DialogHelper(browserWin = window) {
+ this._browserWin = browserWin;
+ this.win = null;
+ this.promiseClosed = new Promise(resolve => {
+ this._resolveClosed = resolve;
+ });
+}
+
+DialogHelper.prototype = {
+ /**
+ * "Presses" the dialog's OK button.
+ */
+ acceptDialog() {
+ let dialogEl = this.win.document.querySelector("dialog");
+ is(
+ dialogEl.getButton("accept").disabled,
+ false,
+ "Dialog's OK button should not be disabled"
+ );
+ dialogEl.acceptDialog();
+ },
+
+ /**
+ * "Presses" the dialog's Cancel button.
+ */
+ cancelDialog() {
+ this.win.document.querySelector("dialog").cancelDialog();
+ },
+
+ /**
+ * (Un)checks a history scope checkbox (browser & download history,
+ * form history, etc.).
+ *
+ * @param aPrefName
+ * The final portion of the checkbox's privacy.cpd.* preference name
+ * @param aCheckState
+ * True if the checkbox should be checked, false otherwise
+ */
+ checkPrefCheckbox(aPrefName, aCheckState) {
+ var pref = "privacy.cpd." + aPrefName;
+ var cb = this.win.document.querySelectorAll(
+ "checkbox[preference='" + pref + "']"
+ );
+ is(cb.length, 1, "found checkbox for " + pref + " preference");
+ if (cb[0].checked != aCheckState) {
+ cb[0].click();
+ }
+ },
+
+ /**
+ * Makes sure all the checkboxes are checked.
+ */
+ _checkAllCheckboxesCustom(check) {
+ var cb = this.win.document.querySelectorAll("checkbox[preference]");
+ ok(cb.length > 1, "found checkboxes for preferences");
+ for (var i = 0; i < cb.length; ++i) {
+ var pref = this.win.Preferences.get(cb[i].getAttribute("preference"));
+ if (!!pref.value ^ check) {
+ cb[i].click();
+ }
+ }
+ },
+
+ checkAllCheckboxes() {
+ this._checkAllCheckboxesCustom(true);
+ },
+
+ uncheckAllCheckboxes() {
+ this._checkAllCheckboxesCustom(false);
+ },
+
+ /**
+ * @return The dialog's duration dropdown
+ */
+ getDurationDropdown() {
+ return this.win.document.getElementById("sanitizeDurationChoice");
+ },
+
+ /**
+ * @return The clear-everything warning box
+ */
+ getWarningPanel() {
+ return this.win.document.getElementById("sanitizeEverythingWarningBox");
+ },
+
+ /**
+ * @return True if the "Everything" warning panel is visible (as opposed to
+ * the tree)
+ */
+ isWarningPanelVisible() {
+ return !this.getWarningPanel().hidden;
+ },
+
+ /**
+ * Opens the clear recent history dialog. Before calling this, set
+ * this.onload to a function to execute onload. It should close the dialog
+ * when done so that the tests may continue. Set this.onunload to a function
+ * to execute onunload. this.onunload is optional. If it returns true, the
+ * caller is expected to call promiseAsyncUpdates at some point; if false is
+ * returned, promiseAsyncUpdates is called automatically.
+ */
+ async open() {
+ let dialogPromise = BrowserTestUtils.promiseAlertDialogOpen(
+ null,
+ "chrome://browser/content/sanitize.xhtml",
+ {
+ isSubDialog: true,
+ }
+ );
+
+ executeSoon(() => {
+ Sanitizer.showUI(this._browserWin);
+ });
+
+ this.win = await dialogPromise;
+ this.win.addEventListener(
+ "load",
+ () => {
+ // Run onload on next tick so that gSanitizePromptDialog.init can run first.
+ executeSoon(() => this.onload());
+ },
+ { once: true }
+ );
+
+ this.win.addEventListener(
+ "unload",
+ () => {
+ // Some exceptions that reach here don't reach the test harness, but
+ // ok()/is() do...
+ (async () => {
+ if (this.onunload) {
+ await this.onunload();
+ }
+ await PlacesTestUtils.promiseAsyncUpdates();
+ this._resolveClosed();
+ this.win = null;
+ })();
+ },
+ { once: true }
+ );
+ },
+
+ /**
+ * Selects a duration in the duration dropdown.
+ *
+ * @param aDurVal
+ * One of the Sanitizer.TIMESPAN_* values
+ */
+ selectDuration(aDurVal) {
+ this.getDurationDropdown().value = aDurVal;
+ if (aDurVal === Sanitizer.TIMESPAN_EVERYTHING) {
+ is(
+ this.isWarningPanelVisible(),
+ true,
+ "Warning panel should be visible for TIMESPAN_EVERYTHING"
+ );
+ } else {
+ is(
+ this.isWarningPanelVisible(),
+ false,
+ "Warning panel should not be visible for non-TIMESPAN_EVERYTHING"
+ );
+ }
+ },
+};
+
+function promiseSanitizationComplete() {
+ return TestUtils.topicObserved("sanitizer-sanitization-complete");
+}
+
+/**
+ * Adds a download to history.
+ *
+ * @param aMinutesAgo
+ * The download will be downloaded this many minutes ago
+ */
+async function addDownloadWithMinutesAgo(aExpectedPathList, aMinutesAgo) {
+ let publicList = await Downloads.getList(Downloads.PUBLIC);
+
+ let name = "fakefile-" + aMinutesAgo + "-minutes-ago";
+ let download = await Downloads.createDownload({
+ source: "https://bugzilla.mozilla.org/show_bug.cgi?id=480169",
+ target: name,
+ });
+ download.startTime = new Date(now_mSec - aMinutesAgo * kMsecPerMin);
+ download.canceled = true;
+ publicList.add(download);
+
+ ok(
+ await downloadExists(name),
+ "Sanity check: download " + name + " should exist after creating it"
+ );
+
+ aExpectedPathList.push(name);
+}
+
+/**
+ * Adds a form entry to history.
+ *
+ * @param aMinutesAgo
+ * The entry will be added this many minutes ago
+ */
+function promiseAddFormEntryWithMinutesAgo(aMinutesAgo) {
+ let name = aMinutesAgo + "-minutes-ago";
+
+ // Artifically age the entry to the proper vintage.
+ let timestamp = now_uSec - aMinutesAgo * kUsecPerMin;
+
+ return FormHistory.update({
+ op: "add",
+ fieldname: name,
+ value: "dummy",
+ firstUsed: timestamp,
+ });
+}
+
+/**
+ * Checks if a form entry exists.
+ */
+async function formNameExists(name) {
+ return !!(await FormHistory.count({ fieldname: name }));
+}
+
+/**
+ * Removes all history visits, downloads, and form entries.
+ */
+async function blankSlate() {
+ let publicList = await Downloads.getList(Downloads.PUBLIC);
+ let downloads = await publicList.getAll();
+ for (let download of downloads) {
+ await publicList.remove(download);
+ await download.finalize(true);
+ }
+
+ await FormHistory.update({ op: "remove" });
+ await PlacesUtils.history.clear();
+}
+
+/**
+ * Ensures that the given pref is the expected value.
+ *
+ * @param aPrefName
+ * The pref's sub-branch under the privacy branch
+ * @param aExpectedVal
+ * The pref's expected value
+ * @param aMsg
+ * Passed to is()
+ */
+function boolPrefIs(aPrefName, aExpectedVal, aMsg) {
+ is(Services.prefs.getBoolPref("privacy." + aPrefName), aExpectedVal, aMsg);
+}
+
+/**
+ * Checks to see if the download with the specified path exists.
+ *
+ * @param aPath
+ * The path of the download to check
+ * @return True if the download exists, false otherwise
+ */
+async function downloadExists(aPath) {
+ let publicList = await Downloads.getList(Downloads.PUBLIC);
+ let listArray = await publicList.getAll();
+ return listArray.some(i => i.target.path == aPath);
+}
+
+/**
+ * Ensures that the specified downloads are either cleared or not.
+ *
+ * @param aDownloadIDs
+ * Array of download database IDs
+ * @param aShouldBeCleared
+ * True if each download should be cleared, false otherwise
+ */
+async function ensureDownloadsClearedState(aDownloadIDs, aShouldBeCleared) {
+ let niceStr = aShouldBeCleared ? "no longer" : "still";
+ for (let id of aDownloadIDs) {
+ is(
+ await downloadExists(id),
+ !aShouldBeCleared,
+ "download " + id + " should " + niceStr + " exist"
+ );
+ }
+}
+
+/**
+ * Ensures that the given pref is the expected value.
+ *
+ * @param aPrefName
+ * The pref's sub-branch under the privacy branch
+ * @param aExpectedVal
+ * The pref's expected value
+ * @param aMsg
+ * Passed to is()
+ */
+function intPrefIs(aPrefName, aExpectedVal, aMsg) {
+ is(Services.prefs.getIntPref("privacy." + aPrefName), aExpectedVal, aMsg);
+}
+
+/**
+ * Creates a visit time.
+ *
+ * @param aMinutesAgo
+ * The visit will be visited this many minutes ago
+ */
+function visitTimeForMinutesAgo(aMinutesAgo) {
+ return now_uSec - aMinutesAgo * kUsecPerMin;
+}
diff --git a/browser/base/content/test/sanitize/dummy.js b/browser/base/content/test/sanitize/dummy.js
new file mode 100644
index 0000000000..e69de29bb2
--- /dev/null
+++ b/browser/base/content/test/sanitize/dummy.js
diff --git a/browser/base/content/test/sanitize/dummy_page.html b/browser/base/content/test/sanitize/dummy_page.html
new file mode 100644
index 0000000000..1a87e28408
--- /dev/null
+++ b/browser/base/content/test/sanitize/dummy_page.html
@@ -0,0 +1,9 @@
+<html>
+<head>
+<title>Dummy test page</title>
+<meta http-equiv="Content-Type" content="text/html;charset=utf-8"></meta>
+</head>
+<body>
+<p>Dummy test page</p>
+</body>
+</html>
diff --git a/browser/base/content/test/sanitize/head.js b/browser/base/content/test/sanitize/head.js
new file mode 100644
index 0000000000..161ccdc9fc
--- /dev/null
+++ b/browser/base/content/test/sanitize/head.js
@@ -0,0 +1,329 @@
+ChromeUtils.defineESModuleGetters(this, {
+ Downloads: "resource://gre/modules/Downloads.sys.mjs",
+ FormHistory: "resource://gre/modules/FormHistory.sys.mjs",
+ PermissionTestUtils: "resource://testing-common/PermissionTestUtils.sys.mjs",
+ PlacesUtils: "resource://gre/modules/PlacesUtils.sys.mjs",
+ Sanitizer: "resource:///modules/Sanitizer.sys.mjs",
+ SiteDataTestUtils: "resource://testing-common/SiteDataTestUtils.sys.mjs",
+});
+
+function createIndexedDB(host, originAttributes) {
+ let uri = Services.io.newURI("https://" + host);
+ let principal = Services.scriptSecurityManager.createContentPrincipal(
+ uri,
+ originAttributes
+ );
+ return SiteDataTestUtils.addToIndexedDB(principal.origin);
+}
+
+function checkIndexedDB(host, originAttributes) {
+ return new Promise(resolve => {
+ let data = true;
+ let uri = Services.io.newURI("https://" + host);
+ let principal = Services.scriptSecurityManager.createContentPrincipal(
+ uri,
+ originAttributes
+ );
+ let request = indexedDB.openForPrincipal(principal, "TestDatabase", 1);
+ request.onupgradeneeded = function (e) {
+ data = false;
+ };
+ request.onsuccess = function (e) {
+ resolve(data);
+ };
+ });
+}
+
+function createHostCookie(host, originAttributes) {
+ Services.cookies.add(
+ host,
+ "/test",
+ "foo",
+ "bar",
+ false,
+ false,
+ false,
+ Date.now() + 24000 * 60 * 60,
+ originAttributes,
+ Ci.nsICookie.SAMESITE_NONE,
+ Ci.nsICookie.SCHEME_HTTPS
+ );
+}
+
+function createDomainCookie(host, originAttributes) {
+ Services.cookies.add(
+ "." + host,
+ "/test",
+ "foo",
+ "bar",
+ false,
+ false,
+ false,
+ Date.now() + 24000 * 60 * 60,
+ originAttributes,
+ Ci.nsICookie.SAMESITE_NONE,
+ Ci.nsICookie.SCHEME_HTTPS
+ );
+}
+
+function checkCookie(host, originAttributes) {
+ for (let cookie of Services.cookies.cookies) {
+ if (
+ ChromeUtils.isOriginAttributesEqual(
+ originAttributes,
+ cookie.originAttributes
+ ) &&
+ cookie.host.includes(host)
+ ) {
+ return true;
+ }
+ }
+ return false;
+}
+
+async function deleteOnShutdown(opt) {
+ // Let's clean up all the data.
+ await new Promise(resolve => {
+ Services.clearData.deleteData(Ci.nsIClearDataService.CLEAR_ALL, resolve);
+ });
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["privacy.sanitize.sanitizeOnShutdown", opt.sanitize],
+ ["privacy.clearOnShutdown.cookies", opt.sanitize],
+ ["privacy.clearOnShutdown.offlineApps", opt.sanitize],
+ ["browser.sanitizer.loglevel", "All"],
+ ],
+ });
+
+ // Custom permission without considering OriginAttributes
+ if (opt.cookiePermission !== undefined) {
+ let uri = Services.io.newURI("https://www.example.com");
+ PermissionTestUtils.add(uri, "cookie", opt.cookiePermission);
+ }
+
+ // Let's create a tab with some data.
+ await opt.createData(
+ (opt.fullHost ? "www." : "") + "example.org",
+ opt.originAttributes
+ );
+ ok(
+ await opt.checkData(
+ (opt.fullHost ? "www." : "") + "example.org",
+ opt.originAttributes
+ ),
+ "We have data for www.example.org"
+ );
+ await opt.createData(
+ (opt.fullHost ? "www." : "") + "example.com",
+ opt.originAttributes
+ );
+ ok(
+ await opt.checkData(
+ (opt.fullHost ? "www." : "") + "example.com",
+ opt.originAttributes
+ ),
+ "We have data for www.example.com"
+ );
+
+ // Cleaning up.
+ await Sanitizer.runSanitizeOnShutdown();
+
+ // All gone!
+ is(
+ !!(await opt.checkData(
+ (opt.fullHost ? "www." : "") + "example.org",
+ opt.originAttributes
+ )),
+ opt.expectedForOrg,
+ "Do we have data for www.example.org?"
+ );
+ is(
+ !!(await opt.checkData(
+ (opt.fullHost ? "www." : "") + "example.com",
+ opt.originAttributes
+ )),
+ opt.expectedForCom,
+ "Do we have data for www.example.com?"
+ );
+
+ // Clean up.
+ await Sanitizer.sanitize(["cookies", "offlineApps"]);
+
+ if (opt.cookiePermission !== undefined) {
+ let uri = Services.io.newURI("https://www.example.com");
+ PermissionTestUtils.remove(uri, "cookie");
+ }
+}
+
+function runAllCookiePermissionTests(originAttributes) {
+ let tests = [
+ { name: "IDB", createData: createIndexedDB, checkData: checkIndexedDB },
+ {
+ name: "Host Cookie",
+ createData: createHostCookie,
+ checkData: checkCookie,
+ },
+ {
+ name: "Domain Cookie",
+ createData: createDomainCookie,
+ checkData: checkCookie,
+ },
+ ];
+
+ // Delete all, no custom permission, data in example.com, cookie permission set
+ // for www.example.com
+ tests.forEach(methods => {
+ add_task(async function deleteStorageOnShutdown() {
+ info(
+ methods.name +
+ ": Delete all, no custom permission, data in example.com, cookie permission set for www.example.com - OA: " +
+ originAttributes.name
+ );
+ await deleteOnShutdown({
+ sanitize: true,
+ createData: methods.createData,
+ checkData: methods.checkData,
+ originAttributes: originAttributes.oa,
+ cookiePermission: undefined,
+ expectedForOrg: false,
+ expectedForCom: false,
+ fullHost: false,
+ });
+ });
+ });
+
+ // Delete all, no custom permission, data in www.example.com, cookie permission
+ // set for www.example.com
+ tests.forEach(methods => {
+ add_task(async function deleteStorageOnShutdown() {
+ info(
+ methods.name +
+ ": Delete all, no custom permission, data in www.example.com, cookie permission set for www.example.com - OA: " +
+ originAttributes.name
+ );
+ await deleteOnShutdown({
+ sanitize: true,
+ createData: methods.createData,
+ checkData: methods.checkData,
+ originAttributes: originAttributes.oa,
+ cookiePermission: undefined,
+ expectedForOrg: false,
+ expectedForCom: false,
+ fullHost: true,
+ });
+ });
+ });
+
+ // All is session, but with ALLOW custom permission, data in example.com,
+ // cookie permission set for www.example.com
+ tests.forEach(methods => {
+ add_task(async function deleteStorageWithCustomPermission() {
+ info(
+ methods.name +
+ ": All is session, but with ALLOW custom permission, data in example.com, cookie permission set for www.example.com - OA: " +
+ originAttributes.name
+ );
+ await deleteOnShutdown({
+ sanitize: true,
+ createData: methods.createData,
+ checkData: methods.checkData,
+ originAttributes: originAttributes.oa,
+ cookiePermission: Ci.nsICookiePermission.ACCESS_ALLOW,
+ expectedForOrg: false,
+ expectedForCom: true,
+ fullHost: false,
+ });
+ });
+ });
+
+ // All is session, but with ALLOW custom permission, data in www.example.com,
+ // cookie permission set for www.example.com
+ tests.forEach(methods => {
+ add_task(async function deleteStorageWithCustomPermission() {
+ info(
+ methods.name +
+ ": All is session, but with ALLOW custom permission, data in www.example.com, cookie permission set for www.example.com - OA: " +
+ originAttributes.name
+ );
+ await deleteOnShutdown({
+ sanitize: true,
+ createData: methods.createData,
+ checkData: methods.checkData,
+ originAttributes: originAttributes.oa,
+ cookiePermission: Ci.nsICookiePermission.ACCESS_ALLOW,
+ expectedForOrg: false,
+ expectedForCom: true,
+ fullHost: true,
+ });
+ });
+ });
+
+ // All is default, but with SESSION custom permission, data in example.com,
+ // cookie permission set for www.example.com
+ tests.forEach(methods => {
+ add_task(async function deleteStorageOnlyCustomPermission() {
+ info(
+ methods.name +
+ ": All is default, but with SESSION custom permission, data in example.com, cookie permission set for www.example.com - OA: " +
+ originAttributes.name
+ );
+ await deleteOnShutdown({
+ sanitize: false,
+ createData: methods.createData,
+ checkData: methods.checkData,
+ originAttributes: originAttributes.oa,
+ cookiePermission: Ci.nsICookiePermission.ACCESS_SESSION,
+ expectedForOrg: true,
+ // expected data just for example.com when using indexedDB because
+ // QuotaManager deletes for principal.
+ expectedForCom: false,
+ fullHost: false,
+ });
+ });
+ });
+
+ // All is default, but with SESSION custom permission, data in www.example.com,
+ // cookie permission set for www.example.com
+ tests.forEach(methods => {
+ add_task(async function deleteStorageOnlyCustomPermission() {
+ info(
+ methods.name +
+ ": All is default, but with SESSION custom permission, data in www.example.com, cookie permission set for www.example.com - OA: " +
+ originAttributes.name
+ );
+ await deleteOnShutdown({
+ sanitize: false,
+ createData: methods.createData,
+ checkData: methods.checkData,
+ originAttributes: originAttributes.oa,
+ cookiePermission: Ci.nsICookiePermission.ACCESS_SESSION,
+ expectedForOrg: true,
+ expectedForCom: false,
+ fullHost: true,
+ });
+ });
+ });
+
+ // Session mode, but with unsupported custom permission, data in
+ // www.example.com, cookie permission set for www.example.com
+ tests.forEach(methods => {
+ add_task(async function deleteStorageOnlyCustomPermission() {
+ info(
+ methods.name +
+ ": All is session only, but with unsupported custom custom permission, data in www.example.com, cookie permission set for www.example.com - OA: " +
+ originAttributes.name
+ );
+ await deleteOnShutdown({
+ sanitize: true,
+ createData: methods.createData,
+ checkData: methods.checkData,
+ originAttributes: originAttributes.oa,
+ cookiePermission: 123, // invalid cookie permission
+ expectedForOrg: false,
+ expectedForCom: false,
+ fullHost: true,
+ });
+ });
+ });
+}
diff --git a/browser/base/content/test/sidebar/browser.ini b/browser/base/content/test/sidebar/browser.ini
new file mode 100644
index 0000000000..5be49123b5
--- /dev/null
+++ b/browser/base/content/test/sidebar/browser.ini
@@ -0,0 +1,8 @@
+[DEFAULT]
+
+[browser_sidebar_adopt.js]
+[browser_sidebar_app_locale_changed.js]
+[browser_sidebar_keys.js]
+[browser_sidebar_move.js]
+[browser_sidebar_persist.js]
+[browser_sidebar_switcher.js]
diff --git a/browser/base/content/test/sidebar/browser_sidebar_adopt.js b/browser/base/content/test/sidebar/browser_sidebar_adopt.js
new file mode 100644
index 0000000000..344a71cb9b
--- /dev/null
+++ b/browser/base/content/test/sidebar/browser_sidebar_adopt.js
@@ -0,0 +1,74 @@
+/* This test checks that the SidebarFocused event doesn't fire in adopted
+ * windows when the sidebar gets opened during window opening, to make sure
+ * that sidebars don't steal focus from the page in this case (Bug 1394207).
+ * There's another case not covered here that has the same expected behavior -
+ * during the initial browser startup - but it would be hard to do with a mochitest. */
+
+registerCleanupFunction(() => {
+ SidebarUI.hide();
+});
+
+function failIfSidebarFocusedFires() {
+ ok(false, "This event shouldn't have fired");
+}
+
+add_setup(function () {
+ CustomizableUI.addWidgetToArea("sidebar-button", "nav-bar");
+ registerCleanupFunction(() =>
+ CustomizableUI.removeWidgetFromArea("sidebar-button")
+ );
+});
+
+add_task(async function testAdoptedTwoWindows() {
+ // First open a new window, show the sidebar in that window, and close it.
+ // Then, open another new window and confirm that the sidebar is closed since it is
+ // being adopted from the main window which doesn't have a shown sidebar. See Bug 1407737.
+ info("Ensure that sidebar state is adopted only from the opener");
+
+ let win1 = await BrowserTestUtils.openNewBrowserWindow();
+ await win1.SidebarUI.show("viewBookmarksSidebar");
+ await BrowserTestUtils.closeWindow(win1);
+
+ let win2 = await BrowserTestUtils.openNewBrowserWindow();
+ ok(
+ !win2.document.getElementById("sidebar-button").hasAttribute("checked"),
+ "Sidebar button isn't checked"
+ );
+ ok(!win2.SidebarUI.isOpen, "Sidebar is closed");
+ await BrowserTestUtils.closeWindow(win2);
+});
+
+add_task(async function testEventsReceivedInMainWindow() {
+ info(
+ "Opening the sidebar and expecting both SidebarShown and SidebarFocused events"
+ );
+
+ let initialShown = BrowserTestUtils.waitForEvent(window, "SidebarShown");
+ let initialFocus = BrowserTestUtils.waitForEvent(window, "SidebarFocused");
+
+ await SidebarUI.show("viewBookmarksSidebar");
+ await initialShown;
+ await initialFocus;
+
+ ok(true, "SidebarShown and SidebarFocused events fired on a new window");
+});
+
+add_task(async function testEventReceivedInNewWindow() {
+ info(
+ "Opening a new window and expecting the SidebarFocused event to not fire"
+ );
+
+ let promiseNewWindow = BrowserTestUtils.waitForNewWindow();
+ let win = OpenBrowserWindow();
+
+ let adoptedShown = BrowserTestUtils.waitForEvent(win, "SidebarShown");
+ win.addEventListener("SidebarFocused", failIfSidebarFocusedFires);
+ registerCleanupFunction(async function () {
+ win.removeEventListener("SidebarFocused", failIfSidebarFocusedFires);
+ await BrowserTestUtils.closeWindow(win);
+ });
+
+ await promiseNewWindow;
+ await adoptedShown;
+ ok(true, "SidebarShown event fired on an adopted window");
+});
diff --git a/browser/base/content/test/sidebar/browser_sidebar_app_locale_changed.js b/browser/base/content/test/sidebar/browser_sidebar_app_locale_changed.js
new file mode 100644
index 0000000000..5b07da9839
--- /dev/null
+++ b/browser/base/content/test/sidebar/browser_sidebar_app_locale_changed.js
@@ -0,0 +1,111 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * This file tests that the sidebar recreates the contents of the <tree> element
+ * for live app locale switching.
+ */
+
+add_task(function cleanup() {
+ registerCleanupFunction(() => {
+ SidebarUI.hide();
+ });
+});
+
+/**
+ * @param {string} sidebarName
+ */
+async function testLiveReloading(sidebarName) {
+ info("Showing the sidebar " + sidebarName);
+ await SidebarUI.show(sidebarName);
+
+ function getTreeChildren() {
+ const sidebarDoc =
+ document.querySelector("#sidebar").contentWindow.document;
+ return sidebarDoc.querySelector(".sidebar-placesTreechildren");
+ }
+
+ const childrenBefore = getTreeChildren();
+ ok(childrenBefore, "Found the sidebar children");
+ is(childrenBefore, getTreeChildren(), "The children start out as equal");
+
+ info("Simulating an app locale change.");
+ Services.obs.notifyObservers(null, "intl:app-locales-changed");
+
+ await TestUtils.waitForCondition(
+ getTreeChildren,
+ "Waiting for a new child tree element."
+ );
+
+ isnot(
+ childrenBefore,
+ getTreeChildren(),
+ "The tree's contents are re-computed."
+ );
+
+ info("Hiding the sidebar");
+ SidebarUI.hide();
+}
+
+add_task(async function test_bookmarks_sidebar() {
+ await testLiveReloading("viewBookmarksSidebar");
+});
+
+add_task(async function test_history_sidebar() {
+ await testLiveReloading("viewHistorySidebar");
+});
+
+add_task(async function test_ext_sidebar_panel_reloaded_on_locale_changes() {
+ let extension = ExtensionTestUtils.loadExtension({
+ manifest: {
+ sidebar_action: {
+ default_panel: "sidebar.html",
+ },
+ },
+ useAddonManager: "temporary",
+
+ files: {
+ "sidebar.html": `<html>
+ <head>
+ <meta charset="utf-8"/>
+ <script src="sidebar.js"></script>
+ </head>
+ <body>
+ A Test Sidebar
+ </body>
+ </html>`,
+ "sidebar.js": function () {
+ const { browser } = this;
+ window.onload = () => {
+ browser.test.sendMessage("sidebar");
+ };
+ },
+ },
+ });
+ await extension.startup();
+ // Test sidebar is opened on install
+ await extension.awaitMessage("sidebar");
+
+ // Test sidebar is opened on simulated locale changes.
+ info("Switch browser to bidi and expect the sidebar panel to be reloaded");
+
+ await SpecialPowers.pushPrefEnv({
+ set: [["intl.l10n.pseudo", "bidi"]],
+ });
+ await extension.awaitMessage("sidebar");
+ is(
+ window.document.documentElement.getAttribute("dir"),
+ "rtl",
+ "browser window changed direction to rtl as expected"
+ );
+
+ await SpecialPowers.popPrefEnv();
+ await extension.awaitMessage("sidebar");
+ is(
+ window.document.documentElement.getAttribute("dir"),
+ "ltr",
+ "browser window changed direction to ltr as expected"
+ );
+
+ await extension.unload();
+});
diff --git a/browser/base/content/test/sidebar/browser_sidebar_keys.js b/browser/base/content/test/sidebar/browser_sidebar_keys.js
new file mode 100644
index 0000000000..f12d1cf5f7
--- /dev/null
+++ b/browser/base/content/test/sidebar/browser_sidebar_keys.js
@@ -0,0 +1,108 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+async function testSidebarKeyToggle(key, options, expectedSidebarId) {
+ EventUtils.synthesizeMouseAtCenter(gURLBar.textbox, {});
+ let promiseShown = BrowserTestUtils.waitForEvent(window, "SidebarShown");
+ EventUtils.synthesizeKey(key, options);
+ await promiseShown;
+ Assert.equal(
+ document.getElementById("sidebar-box").getAttribute("sidebarcommand"),
+ expectedSidebarId
+ );
+ EventUtils.synthesizeKey(key, options);
+ Assert.ok(!SidebarUI.isOpen);
+}
+
+add_task(async function test_sidebar_keys() {
+ registerCleanupFunction(() => SidebarUI.hide());
+
+ await testSidebarKeyToggle("b", { accelKey: true }, "viewBookmarksSidebar");
+
+ let options = { accelKey: true, shiftKey: AppConstants.platform == "macosx" };
+ await testSidebarKeyToggle("h", options, "viewHistorySidebar");
+});
+
+add_task(async function test_sidebar_in_customize_mode() {
+ // Test bug 1756385 - widgets to appear unchecked in customize mode. Test that
+ // the sidebar button widget doesn't appear checked, and that the sidebar
+ // button toggle is inert while in customize mode.
+ let { CustomizableUI } = ChromeUtils.importESModule(
+ "resource:///modules/CustomizableUI.sys.mjs"
+ );
+ registerCleanupFunction(() => SidebarUI.hide());
+
+ let placement = CustomizableUI.getPlacementOfWidget("sidebar-button");
+ if (!(placement?.area == CustomizableUI.AREA_NAVBAR)) {
+ CustomizableUI.addWidgetToArea(
+ "sidebar-button",
+ CustomizableUI.AREA_NAVBAR,
+ 0
+ );
+ CustomizableUI.ensureWidgetPlacedInWindow("sidebar-button", window);
+ registerCleanupFunction(function () {
+ CustomizableUI.removeWidgetFromArea("sidebar-button");
+ });
+ }
+
+ let widgetIcon = CustomizableUI.getWidget("sidebar-button")
+ .forWindow(window)
+ .node?.querySelector(".toolbarbutton-icon");
+ // Get the alpha value of the sidebar toggle widget's background
+ let getBGAlpha = () =>
+ InspectorUtils.colorToRGBA(
+ getComputedStyle(widgetIcon).getPropertyValue("background-color")
+ ).a;
+
+ let promiseShown = BrowserTestUtils.waitForEvent(window, "SidebarShown");
+ SidebarUI.show("viewBookmarksSidebar");
+ await promiseShown;
+
+ Assert.greater(
+ getBGAlpha(),
+ 0,
+ "Sidebar widget background should appear checked"
+ );
+
+ // Enter customize mode. This should disable the toggle and make the sidebar
+ // toggle widget appear unchecked.
+ let customizationReadyPromise = BrowserTestUtils.waitForEvent(
+ gNavToolbox,
+ "customizationready"
+ );
+ gCustomizeMode.enter();
+ await customizationReadyPromise;
+
+ Assert.equal(
+ getBGAlpha(),
+ 0,
+ "Sidebar widget background should appear unchecked"
+ );
+
+ // Attempt toggle - should fail in customize mode.
+ await SidebarUI.toggle();
+ ok(SidebarUI.isOpen, "Sidebar is still open");
+
+ // Exit customize mode. This should re-enable the toggle and make the sidebar
+ // toggle widget appear checked again, since toggle() didn't hide the sidebar.
+ let afterCustomizationPromise = BrowserTestUtils.waitForEvent(
+ gNavToolbox,
+ "aftercustomization"
+ );
+ gCustomizeMode.exit();
+ await afterCustomizationPromise;
+
+ Assert.greater(
+ getBGAlpha(),
+ 0,
+ "Sidebar widget background should appear checked again"
+ );
+
+ await SidebarUI.toggle();
+ ok(!SidebarUI.isOpen, "Sidebar is closed");
+ Assert.equal(
+ getBGAlpha(),
+ 0,
+ "Sidebar widget background should appear unchecked"
+ );
+});
diff --git a/browser/base/content/test/sidebar/browser_sidebar_move.js b/browser/base/content/test/sidebar/browser_sidebar_move.js
new file mode 100644
index 0000000000..3de26b7966
--- /dev/null
+++ b/browser/base/content/test/sidebar/browser_sidebar_move.js
@@ -0,0 +1,72 @@
+registerCleanupFunction(() => {
+ Services.prefs.clearUserPref("sidebar.position_start");
+ SidebarUI.hide();
+});
+
+const EXPECTED_START_ORDINALS = [
+ ["sidebar-box", 1],
+ ["sidebar-splitter", 2],
+ ["appcontent", 3],
+];
+
+const EXPECTED_END_ORDINALS = [
+ ["sidebar-box", 3],
+ ["sidebar-splitter", 2],
+ ["appcontent", 1],
+];
+
+function getBrowserChildrenWithOrdinals() {
+ let browser = document.getElementById("browser");
+ return [...browser.children].map(node => {
+ return [node.id, node.style.order];
+ });
+}
+
+add_task(async function () {
+ await SidebarUI.show("viewBookmarksSidebar");
+ SidebarUI.showSwitcherPanel();
+
+ let reversePositionButton = document.getElementById(
+ "sidebar-reverse-position"
+ );
+ let originalLabel = reversePositionButton.getAttribute("label");
+ let box = document.getElementById("sidebar-box");
+
+ // Default (position: left)
+ Assert.deepEqual(
+ getBrowserChildrenWithOrdinals(),
+ EXPECTED_START_ORDINALS,
+ "Correct ordinal (start)"
+ );
+ ok(!box.hasAttribute("positionend"), "Positioned start");
+
+ // Moved to right
+ SidebarUI.reversePosition();
+ SidebarUI.showSwitcherPanel();
+ Assert.deepEqual(
+ getBrowserChildrenWithOrdinals(),
+ EXPECTED_END_ORDINALS,
+ "Correct ordinal (end)"
+ );
+ isnot(
+ reversePositionButton.getAttribute("label"),
+ originalLabel,
+ "Label changed"
+ );
+ ok(box.hasAttribute("positionend"), "Positioned end");
+
+ // Moved to back to left
+ SidebarUI.reversePosition();
+ SidebarUI.showSwitcherPanel();
+ Assert.deepEqual(
+ getBrowserChildrenWithOrdinals(),
+ EXPECTED_START_ORDINALS,
+ "Correct ordinal (start)"
+ );
+ ok(!box.hasAttribute("positionend"), "Positioned start");
+ is(
+ reversePositionButton.getAttribute("label"),
+ originalLabel,
+ "Label is back to normal"
+ );
+});
diff --git a/browser/base/content/test/sidebar/browser_sidebar_persist.js b/browser/base/content/test/sidebar/browser_sidebar_persist.js
new file mode 100644
index 0000000000..fe67bed9e0
--- /dev/null
+++ b/browser/base/content/test/sidebar/browser_sidebar_persist.js
@@ -0,0 +1,37 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function persist_sidebar_width() {
+ {
+ // Make the main test window not count as a browser window any longer,
+ // which allows the persitence code to kick in.
+ const docEl = document.documentElement;
+ const oldWinType = docEl.getAttribute("windowtype");
+ docEl.setAttribute("windowtype", "navigator:testrunner");
+ registerCleanupFunction(() => {
+ docEl.setAttribute("windowtype", oldWinType);
+ });
+ }
+
+ {
+ info("Showing new window and setting sidebar box");
+ const win = await BrowserTestUtils.openNewBrowserWindow();
+ await win.SidebarUI.show("viewBookmarksSidebar");
+ win.document.getElementById("sidebar-box").style.width = "100px";
+ await BrowserTestUtils.closeWindow(win);
+ }
+
+ {
+ info("Showing new window and seeing persisted width");
+ const win = await BrowserTestUtils.openNewBrowserWindow();
+ await win.SidebarUI.show("viewBookmarksSidebar");
+ is(
+ win.document.getElementById("sidebar-box").style.width,
+ "100px",
+ "Width style should be persisted"
+ );
+ await BrowserTestUtils.closeWindow(win);
+ }
+});
diff --git a/browser/base/content/test/sidebar/browser_sidebar_switcher.js b/browser/base/content/test/sidebar/browser_sidebar_switcher.js
new file mode 100644
index 0000000000..81d7c29776
--- /dev/null
+++ b/browser/base/content/test/sidebar/browser_sidebar_switcher.js
@@ -0,0 +1,64 @@
+registerCleanupFunction(() => {
+ SidebarUI.hide();
+});
+
+function showSwitcherPanelPromise() {
+ return new Promise(resolve => {
+ SidebarUI._switcherPanel.addEventListener(
+ "popupshown",
+ () => {
+ resolve();
+ },
+ { once: true }
+ );
+ SidebarUI.showSwitcherPanel();
+ });
+}
+
+function clickSwitcherButton(querySelector) {
+ let sidebarPopup = document.querySelector("#sidebarMenu-popup");
+ let switcherPromise = Promise.all([
+ BrowserTestUtils.waitForEvent(window, "SidebarFocused"),
+ BrowserTestUtils.waitForEvent(sidebarPopup, "popuphidden"),
+ ]);
+ document.querySelector(querySelector).click();
+ return switcherPromise;
+}
+
+add_task(async function () {
+ // If a sidebar is already open, close it.
+ if (!document.getElementById("sidebar-box").hidden) {
+ ok(
+ false,
+ "Unexpected sidebar found - a previous test failed to cleanup correctly"
+ );
+ SidebarUI.hide();
+ }
+
+ let sidebar = document.querySelector("#sidebar-box");
+ await SidebarUI.show("viewBookmarksSidebar");
+
+ await showSwitcherPanelPromise();
+ await clickSwitcherButton("#sidebar-switcher-history");
+ is(
+ sidebar.getAttribute("sidebarcommand"),
+ "viewHistorySidebar",
+ "History sidebar loaded"
+ );
+
+ await showSwitcherPanelPromise();
+ await clickSwitcherButton("#sidebar-switcher-tabs");
+ is(
+ sidebar.getAttribute("sidebarcommand"),
+ "viewTabsSidebar",
+ "Tabs sidebar loaded"
+ );
+
+ await showSwitcherPanelPromise();
+ await clickSwitcherButton("#sidebar-switcher-bookmarks");
+ is(
+ sidebar.getAttribute("sidebarcommand"),
+ "viewBookmarksSidebar",
+ "Bookmarks sidebar loaded"
+ );
+});
diff --git a/browser/base/content/test/siteIdentity/browser.ini b/browser/base/content/test/siteIdentity/browser.ini
new file mode 100644
index 0000000000..724669f18a
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/browser.ini
@@ -0,0 +1,152 @@
+[DEFAULT]
+support-files =
+ head.js
+ dummy_page.html
+ !/image/test/mochitest/blue.png
+
+[browser_about_blank_same_document_tabswitch.js]
+https_first_disabled = true
+support-files =
+ open-self-from-frame.html
+[browser_bug1045809.js]
+tags = mcb
+support-files =
+ file_bug1045809_1.html
+ file_bug1045809_2.html
+[browser_bug822367.js]
+tags = mcb
+support-files =
+ file_bug822367_1.html
+ file_bug822367_1.js
+ file_bug822367_2.html
+ file_bug822367_3.html
+ file_bug822367_4.html
+ file_bug822367_4.js
+ file_bug822367_4B.html
+ file_bug822367_5.html
+ file_bug822367_6.html
+[browser_bug902156.js]
+tags = mcb
+support-files =
+ file_bug902156.js
+ file_bug902156_1.html
+ file_bug902156_2.html
+ file_bug902156_3.html
+[browser_bug906190.js]
+tags = mcb
+support-files =
+ file_bug906190_1.html
+ file_bug906190_2.html
+ file_bug906190_3_4.html
+ file_bug906190_redirected.html
+ file_bug906190.js
+ file_bug906190.sjs
+[browser_check_identity_state.js]
+https_first_disabled = true
+skip-if =
+ os == "win" && os_version == "6.1" # Skip on Azure - frequent failure
+[browser_check_identity_state_pdf.js]
+https_first_disabled = true
+support-files =
+ file_pdf.pdf
+ file_pdf_blob.html
+[browser_csp_block_all_mixedcontent.js]
+tags = mcb
+support-files =
+ file_csp_block_all_mixedcontent.html
+ file_csp_block_all_mixedcontent.js
+[browser_deprecatedTLSVersions.js]
+[browser_geolocation_indicator.js]
+[browser_getSecurityInfo.js]
+https_first_disabled = true
+support-files =
+ dummy_iframe_page.html
+[browser_identityBlock_flicker.js]
+[browser_identityBlock_focus.js]
+support-files = ../permissions/permissions.html
+[browser_identityIcon_img_url.js]
+https_first_disabled = true
+support-files =
+ file_mixedPassiveContent.html
+ file_csp_block_all_mixedcontent.html
+[browser_identityPopup_HttpsOnlyMode.js]
+[browser_identityPopup_clearSiteData.js]
+skip-if = (os == "linux" && bits == 64) # Bug 1577395
+[browser_identityPopup_clearSiteData_extensions.js]
+[browser_identityPopup_custom_roots.js]
+https_first_disabled = true
+[browser_identityPopup_focus.js]
+skip-if =
+ verify
+ os == "linux" && (asan || tsan) # Bug 1723899
+[browser_identity_UI.js]
+https_first_disabled = true
+[browser_iframe_navigation.js]
+https_first_disabled = true
+support-files =
+ iframe_navigation.html
+[browser_ignore_same_page_navigation.js]
+[browser_mcb_redirect.js]
+https_first_disabled = true
+tags = mcb
+support-files =
+ test_mcb_redirect.html
+ test_mcb_redirect_image.html
+ test_mcb_double_redirect_image.html
+ test_mcb_redirect.js
+ test_mcb_redirect.sjs
+[browser_mixedContentFramesOnHttp.js]
+https_first_disabled = true
+tags = mcb
+support-files =
+ file_mixedContentFramesOnHttp.html
+ file_mixedPassiveContent.html
+[browser_mixedContentFromOnunload.js]
+https_first_disabled = true
+tags = mcb
+support-files =
+ file_mixedContentFromOnunload.html
+ file_mixedContentFromOnunload_test1.html
+ file_mixedContentFromOnunload_test2.html
+[browser_mixed_content_cert_override.js]
+skip-if = verify
+tags = mcb
+support-files =
+ test-mixedcontent-securityerrors.html
+[browser_mixed_content_with_navigation.js]
+tags = mcb
+support-files =
+ file_mixedPassiveContent.html
+ file_bug1045809_1.html
+[browser_mixed_passive_content_indicator.js]
+tags = mcb
+support-files =
+ simple_mixed_passive.html
+[browser_mixedcontent_securityflags.js]
+tags = mcb
+support-files =
+ test-mixedcontent-securityerrors.html
+[browser_navigation_failures.js]
+[browser_no_mcb_for_loopback.js]
+tags = mcb
+support-files =
+ ../general/moz.png
+ test_no_mcb_for_loopback.html
+[browser_no_mcb_for_onions.js]
+tags = mcb
+support-files =
+ test_no_mcb_for_onions.html
+[browser_no_mcb_on_http_site.js]
+https_first_disabled = true
+tags = mcb
+support-files =
+ test_no_mcb_on_http_site_img.html
+ test_no_mcb_on_http_site_img.css
+ test_no_mcb_on_http_site_font.html
+ test_no_mcb_on_http_site_font.css
+ test_no_mcb_on_http_site_font2.html
+ test_no_mcb_on_http_site_font2.css
+[browser_secure_transport_insecure_scheme.js]
+https_first_disabled = true
+[browser_session_store_pageproxystate.js]
+[browser_tab_sharing_state.js]
diff --git a/browser/base/content/test/siteIdentity/browser_about_blank_same_document_tabswitch.js b/browser/base/content/test/siteIdentity/browser_about_blank_same_document_tabswitch.js
new file mode 100644
index 0000000000..5e58b4bedb
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/browser_about_blank_same_document_tabswitch.js
@@ -0,0 +1,79 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TEST_PATH = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.org"
+);
+
+const TEST_PAGE = TEST_PATH + "open-self-from-frame.html";
+
+add_task(async function test_identityBlock_inherited_blank() {
+ await BrowserTestUtils.withNewTab("https://example.com", async browser => {
+ let identityBox = document.getElementById("identity-box");
+ // Ensure we remove the 3rd party storage permission for example.org, or
+ // it'll mess up other tests:
+ let principal = browser.contentPrincipal;
+ registerCleanupFunction(() => {
+ Services.perms.removeFromPrincipal(
+ principal,
+ "3rdPartyStorage^http://example.org"
+ );
+ });
+ is(
+ identityBox.className,
+ "verifiedDomain",
+ "Should indicate a secure site."
+ );
+ // Open a popup from the web content.
+ let popupPromise = BrowserTestUtils.waitForNewWindow();
+ await SpecialPowers.spawn(browser, [TEST_PAGE], testPage => {
+ content.open(testPage, "_blank", "height=300,width=300");
+ });
+ // Open a tab back in the main window:
+ let popup = await popupPromise;
+ info("Opened popup");
+ let popupBC = popup.gBrowser.selectedBrowser.browsingContext;
+ await TestUtils.waitForCondition(
+ () => popupBC.children[0]?.currentWindowGlobal
+ );
+
+ info("Waiting for button to appear");
+ await SpecialPowers.spawn(popupBC.children[0], [], async () => {
+ await ContentTaskUtils.waitForCondition(() =>
+ content.document.querySelector("button")
+ );
+ });
+
+ info("Got frame contents.");
+
+ let otherTabPromise = BrowserTestUtils.waitForLocationChange(
+ gBrowser,
+ TEST_PAGE
+ );
+ info("Clicking button");
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "button",
+ {},
+ popupBC.children[0]
+ );
+ info("Waiting for tab");
+ await otherTabPromise;
+
+ ok(
+ gURLBar.value.startsWith("example.org/"),
+ "URL bar value should be correct, was " + gURLBar.value
+ );
+ is(
+ identityBox.className,
+ "notSecure",
+ "Identity box should have been updated."
+ );
+
+ await BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ await BrowserTestUtils.closeWindow(popup);
+ });
+});
diff --git a/browser/base/content/test/siteIdentity/browser_bug1045809.js b/browser/base/content/test/siteIdentity/browser_bug1045809.js
new file mode 100644
index 0000000000..b39d669d0b
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/browser_bug1045809.js
@@ -0,0 +1,105 @@
+// Test that the Mixed Content Doorhanger Action to re-enable protection works
+
+const PREF_ACTIVE = "security.mixed_content.block_active_content";
+const PREF_INSECURE = "security.insecure_connection_icon.enabled";
+const TEST_URL =
+ getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+ ) + "file_bug1045809_1.html";
+
+var origBlockActive;
+
+add_task(async function () {
+ registerCleanupFunction(function () {
+ Services.prefs.setBoolPref(PREF_ACTIVE, origBlockActive);
+ gBrowser.removeCurrentTab();
+ });
+
+ // Store original preferences so we can restore settings after testing
+ origBlockActive = Services.prefs.getBoolPref(PREF_ACTIVE);
+
+ // Make sure mixed content blocking is on
+ Services.prefs.setBoolPref(PREF_ACTIVE, true);
+
+ let tab = (gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser));
+
+ // Check with insecure lock disabled
+ await SpecialPowers.pushPrefEnv({ set: [[PREF_INSECURE, false]] });
+ await runTests(tab);
+
+ // Check with insecure lock disabled
+ await SpecialPowers.pushPrefEnv({ set: [[PREF_INSECURE, true]] });
+ await runTests(tab);
+});
+
+async function runTests(tab) {
+ // Test 1: mixed content must be blocked
+ await promiseTabLoadEvent(tab, TEST_URL);
+ await test1(gBrowser.getBrowserForTab(tab));
+
+ await promiseTabLoadEvent(tab);
+ // Test 2: mixed content must NOT be blocked
+ await test2(gBrowser.getBrowserForTab(tab));
+
+ // Test 3: mixed content must be blocked again
+ await promiseTabLoadEvent(tab);
+ await test3(gBrowser.getBrowserForTab(tab));
+}
+
+async function test1(gTestBrowser) {
+ await assertMixedContentBlockingState(gTestBrowser, {
+ activeLoaded: false,
+ activeBlocked: true,
+ passiveLoaded: false,
+ });
+
+ await SpecialPowers.spawn(gTestBrowser, [], function () {
+ let iframe = content.document.getElementsByTagName("iframe")[0];
+
+ SpecialPowers.spawn(iframe, [], () => {
+ let container = content.document.getElementById("mixedContentContainer");
+ is(container, null, "Mixed Content is NOT to be found in Test1");
+ });
+ });
+
+ // Disable Mixed Content Protection for the page (and reload)
+ gIdentityHandler.disableMixedContentProtection();
+}
+
+async function test2(gTestBrowser) {
+ await assertMixedContentBlockingState(gTestBrowser, {
+ activeLoaded: true,
+ activeBlocked: false,
+ passiveLoaded: false,
+ });
+
+ await SpecialPowers.spawn(gTestBrowser, [], function () {
+ let iframe = content.document.getElementsByTagName("iframe")[0];
+
+ SpecialPowers.spawn(iframe, [], () => {
+ let container = content.document.getElementById("mixedContentContainer");
+ isnot(container, null, "Mixed Content is to be found in Test2");
+ });
+ });
+
+ // Re-enable Mixed Content Protection for the page (and reload)
+ gIdentityHandler.enableMixedContentProtection();
+}
+
+async function test3(gTestBrowser) {
+ await assertMixedContentBlockingState(gTestBrowser, {
+ activeLoaded: false,
+ activeBlocked: true,
+ passiveLoaded: false,
+ });
+
+ await SpecialPowers.spawn(gTestBrowser, [], function () {
+ let iframe = content.document.getElementsByTagName("iframe")[0];
+
+ SpecialPowers.spawn(iframe, [], () => {
+ let container = content.document.getElementById("mixedContentContainer");
+ is(container, null, "Mixed Content is NOT to be found in Test3");
+ });
+ });
+}
diff --git a/browser/base/content/test/siteIdentity/browser_bug822367.js b/browser/base/content/test/siteIdentity/browser_bug822367.js
new file mode 100644
index 0000000000..881c920899
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/browser_bug822367.js
@@ -0,0 +1,254 @@
+/*
+ * User Override Mixed Content Block - Tests for Bug 822367
+ */
+
+const PREF_DISPLAY = "security.mixed_content.block_display_content";
+const PREF_DISPLAY_UPGRADE = "security.mixed_content.upgrade_display_content";
+const PREF_ACTIVE = "security.mixed_content.block_active_content";
+
+// We alternate for even and odd test cases to simulate different hosts
+const HTTPS_TEST_ROOT = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+);
+const HTTPS_TEST_ROOT_2 = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://test1.example.com"
+);
+
+var gTestBrowser = null;
+
+add_task(async function test() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [PREF_DISPLAY, true],
+ [PREF_DISPLAY_UPGRADE, false],
+ [PREF_ACTIVE, true],
+ ],
+ });
+
+ var newTab = BrowserTestUtils.addTab(gBrowser);
+ gBrowser.selectedTab = newTab;
+ gTestBrowser = gBrowser.selectedBrowser;
+ newTab.linkedBrowser.stop();
+
+ // Mixed Script Test
+ var url = HTTPS_TEST_ROOT + "file_bug822367_1.html";
+ BrowserTestUtils.loadURIString(gTestBrowser, url);
+ await BrowserTestUtils.browserLoaded(gTestBrowser, false, url);
+});
+
+// Mixed Script Test
+add_task(async function MixedTest1A() {
+ await assertMixedContentBlockingState(gTestBrowser, {
+ activeLoaded: false,
+ activeBlocked: true,
+ passiveLoaded: false,
+ });
+
+ gTestBrowser.ownerGlobal.gIdentityHandler.disableMixedContentProtection();
+ await BrowserTestUtils.browserLoaded(gTestBrowser);
+});
+
+add_task(async function MixedTest1B() {
+ await SpecialPowers.spawn(gTestBrowser, [], async function () {
+ await ContentTaskUtils.waitForCondition(
+ () => content.document.getElementById("p1").innerHTML == "hello",
+ "Waited too long for mixed script to run in Test 1"
+ );
+ });
+ gTestBrowser.ownerGlobal.gIdentityHandler.enableMixedContentProtectionNoReload();
+});
+
+// Mixed Display Test - Doorhanger should not appear
+add_task(async function MixedTest2() {
+ var url = HTTPS_TEST_ROOT_2 + "file_bug822367_2.html";
+ BrowserTestUtils.loadURIString(gTestBrowser, url);
+ await BrowserTestUtils.browserLoaded(gTestBrowser, false, url);
+
+ await assertMixedContentBlockingState(gTestBrowser, {
+ activeLoaded: false,
+ activeBlocked: false,
+ passiveLoaded: false,
+ });
+});
+
+// Mixed Script and Display Test - User Override should cause both the script and the image to load.
+add_task(async function MixedTest3() {
+ var url = HTTPS_TEST_ROOT + "file_bug822367_3.html";
+ BrowserTestUtils.loadURIString(gTestBrowser, url);
+ await BrowserTestUtils.browserLoaded(gTestBrowser, false, url);
+});
+
+add_task(async function MixedTest3A() {
+ await assertMixedContentBlockingState(gTestBrowser, {
+ activeLoaded: false,
+ activeBlocked: true,
+ passiveLoaded: false,
+ });
+
+ gTestBrowser.ownerGlobal.gIdentityHandler.disableMixedContentProtection();
+ await BrowserTestUtils.browserLoaded(gTestBrowser);
+});
+
+add_task(async function MixedTest3B() {
+ await SpecialPowers.spawn(gTestBrowser, [], async function () {
+ let p1 = ContentTaskUtils.waitForCondition(
+ () => content.document.getElementById("p1").innerHTML == "hello",
+ "Waited too long for mixed script to run in Test 3"
+ );
+ let p2 = ContentTaskUtils.waitForCondition(
+ () => content.document.getElementById("p2").innerHTML == "bye",
+ "Waited too long for mixed image to load in Test 3"
+ );
+ await Promise.all([p1, p2]);
+ });
+
+ await assertMixedContentBlockingState(gTestBrowser, {
+ activeLoaded: true,
+ activeBlocked: false,
+ passiveLoaded: true,
+ });
+ gTestBrowser.ownerGlobal.gIdentityHandler.enableMixedContentProtectionNoReload();
+});
+
+// Location change - User override on one page doesn't propagate to another page after location change.
+add_task(async function MixedTest4() {
+ var url = HTTPS_TEST_ROOT_2 + "file_bug822367_4.html";
+ BrowserTestUtils.loadURIString(gTestBrowser, url);
+ await BrowserTestUtils.browserLoaded(gTestBrowser, false, url);
+});
+
+let preLocationChangePrincipal = null;
+add_task(async function MixedTest4A() {
+ await assertMixedContentBlockingState(gTestBrowser, {
+ activeLoaded: false,
+ activeBlocked: true,
+ passiveLoaded: false,
+ });
+
+ preLocationChangePrincipal = gTestBrowser.contentPrincipal;
+ gTestBrowser.ownerGlobal.gIdentityHandler.disableMixedContentProtection();
+ await BrowserTestUtils.browserLoaded(gTestBrowser);
+});
+
+add_task(async function MixedTest4B() {
+ let url = HTTPS_TEST_ROOT + "file_bug822367_4B.html";
+ await SpecialPowers.spawn(gTestBrowser, [url], async function (wantedUrl) {
+ await ContentTaskUtils.waitForCondition(
+ () => content.document.location == wantedUrl,
+ "Waited too long for mixed script to run in Test 4"
+ );
+ });
+});
+
+add_task(async function MixedTest4C() {
+ await assertMixedContentBlockingState(gTestBrowser, {
+ activeLoaded: false,
+ activeBlocked: true,
+ passiveLoaded: false,
+ });
+
+ await SpecialPowers.spawn(gTestBrowser, [], async function () {
+ await ContentTaskUtils.waitForCondition(
+ () => content.document.getElementById("p1").innerHTML == "",
+ "Mixed script loaded in test 4 after location change!"
+ );
+ });
+ SitePermissions.removeFromPrincipal(
+ preLocationChangePrincipal,
+ "mixed-content"
+ );
+});
+
+// Mixed script attempts to load in a document.open()
+add_task(async function MixedTest5() {
+ var url = HTTPS_TEST_ROOT + "file_bug822367_5.html";
+ BrowserTestUtils.loadURIString(gTestBrowser, url);
+ await BrowserTestUtils.browserLoaded(gTestBrowser, false, url);
+});
+
+add_task(async function MixedTest5A() {
+ await assertMixedContentBlockingState(gTestBrowser, {
+ activeLoaded: false,
+ activeBlocked: true,
+ passiveLoaded: false,
+ });
+
+ gTestBrowser.ownerGlobal.gIdentityHandler.disableMixedContentProtection();
+ await BrowserTestUtils.browserLoaded(gTestBrowser);
+});
+
+add_task(async function MixedTest5B() {
+ await SpecialPowers.spawn(gTestBrowser, [], async function () {
+ await ContentTaskUtils.waitForCondition(
+ () => content.document.getElementById("p1").innerHTML == "hello",
+ "Waited too long for mixed script to run in Test 5"
+ );
+ });
+ gTestBrowser.ownerGlobal.gIdentityHandler.enableMixedContentProtectionNoReload();
+});
+
+// Mixed script attempts to load in a document.open() that is within an iframe.
+add_task(async function MixedTest6() {
+ var url = HTTPS_TEST_ROOT_2 + "file_bug822367_6.html";
+ BrowserTestUtils.loadURIString(gTestBrowser, url);
+ await BrowserTestUtils.browserLoaded(gTestBrowser, false, url);
+});
+
+add_task(async function MixedTest6A() {
+ gTestBrowser.removeEventListener("load", MixedTest6A, true);
+ let { gIdentityHandler } = gTestBrowser.ownerGlobal;
+
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ gIdentityHandler._identityBox.classList.contains("mixedActiveBlocked"),
+ "Waited too long for control center to get mixed active blocked state"
+ );
+});
+
+add_task(async function MixedTest6B() {
+ await assertMixedContentBlockingState(gTestBrowser, {
+ activeLoaded: false,
+ activeBlocked: true,
+ passiveLoaded: false,
+ });
+
+ gTestBrowser.ownerGlobal.gIdentityHandler.disableMixedContentProtection();
+
+ await BrowserTestUtils.browserLoaded(gTestBrowser);
+});
+
+add_task(async function MixedTest6C() {
+ await SpecialPowers.spawn(gTestBrowser, [], async function () {
+ function test() {
+ try {
+ return (
+ content.document
+ .getElementById("f1")
+ .contentDocument.getElementById("p1").innerHTML == "hello"
+ );
+ } catch (e) {
+ return false;
+ }
+ }
+
+ await ContentTaskUtils.waitForCondition(
+ test,
+ "Waited too long for mixed script to run in Test 6"
+ );
+ });
+});
+
+add_task(async function MixedTest6D() {
+ await assertMixedContentBlockingState(gTestBrowser, {
+ activeLoaded: true,
+ activeBlocked: false,
+ passiveLoaded: false,
+ });
+ gTestBrowser.ownerGlobal.gIdentityHandler.enableMixedContentProtectionNoReload();
+});
+
+add_task(async function cleanup() {
+ gBrowser.removeCurrentTab();
+});
diff --git a/browser/base/content/test/siteIdentity/browser_bug902156.js b/browser/base/content/test/siteIdentity/browser_bug902156.js
new file mode 100644
index 0000000000..3485771427
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/browser_bug902156.js
@@ -0,0 +1,171 @@
+/*
+ * Description of the Tests for
+ * - Bug 902156: Persist "disable protection" option for Mixed Content Blocker
+ *
+ * 1. Navigate to the same domain via document.location
+ * - Load a html page which has mixed content
+ * - Control Center button to disable protection appears - we disable it
+ * - Load a new page from the same origin using document.location
+ * - Control Center button should not appear anymore!
+ *
+ * 2. Navigate to the same domain via simulateclick for a link on the page
+ * - Load a html page which has mixed content
+ * - Control Center button to disable protection appears - we disable it
+ * - Load a new page from the same origin simulating a click
+ * - Control Center button should not appear anymore!
+ *
+ * 3. Navigate to a differnet domain and show the content is still blocked
+ * - Load a different html page which has mixed content
+ * - Control Center button to disable protection should appear again because
+ * we navigated away from html page where we disabled the protection.
+ *
+ * Note, for all tests we set gHttpTestRoot to use 'https'.
+ */
+
+const PREF_ACTIVE = "security.mixed_content.block_active_content";
+
+// We alternate for even and odd test cases to simulate different hosts.
+const HTTPS_TEST_ROOT_1 = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://test1.example.com"
+);
+const HTTPS_TEST_ROOT_2 = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://test2.example.com"
+);
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({ set: [[PREF_ACTIVE, true]] });
+});
+
+add_task(async function test1() {
+ let url = HTTPS_TEST_ROOT_1 + "file_bug902156_1.html";
+ await BrowserTestUtils.withNewTab(url, async function (browser) {
+ await assertMixedContentBlockingState(browser, {
+ activeLoaded: false,
+ activeBlocked: true,
+ passiveLoaded: false,
+ });
+
+ // Disable Mixed Content Protection for the page (and reload)
+ let browserLoaded = BrowserTestUtils.browserLoaded(browser, false, url);
+ let { gIdentityHandler } = browser.ownerGlobal;
+ gIdentityHandler.disableMixedContentProtection();
+ await browserLoaded;
+
+ await SpecialPowers.spawn(browser, [], async function () {
+ let expected = "Mixed Content Blocker disabled";
+ await ContentTaskUtils.waitForCondition(
+ () =>
+ content.document.getElementById("mctestdiv").innerHTML == expected,
+ "Error: Waited too long for mixed script to run in Test 1"
+ );
+
+ let actual = content.document.getElementById("mctestdiv").innerHTML;
+ is(
+ actual,
+ "Mixed Content Blocker disabled",
+ "OK: Executed mixed script in Test 1"
+ );
+ });
+
+ // The Script loaded after we disabled the page, now we are going to reload the
+ // page and see if our decision is persistent
+ url = HTTPS_TEST_ROOT_1 + "file_bug902156_2.html";
+ browserLoaded = BrowserTestUtils.browserLoaded(browser, false, url);
+ BrowserTestUtils.loadURIString(browser, url);
+ await browserLoaded;
+
+ // The Control Center button should appear but isMixedContentBlocked should be NOT true,
+ // because our decision of disabling the mixed content blocker is persistent.
+ await assertMixedContentBlockingState(browser, {
+ activeLoaded: true,
+ activeBlocked: false,
+ passiveLoaded: false,
+ });
+ await SpecialPowers.spawn(browser, [], function () {
+ let actual = content.document.getElementById("mctestdiv").innerHTML;
+ is(
+ actual,
+ "Mixed Content Blocker disabled",
+ "OK: Executed mixed script in Test 1"
+ );
+ });
+ gIdentityHandler.enableMixedContentProtection();
+ });
+});
+
+// ------------------------ Test 2 ------------------------------
+
+add_task(async function test2() {
+ let url = HTTPS_TEST_ROOT_2 + "file_bug902156_2.html";
+ await BrowserTestUtils.withNewTab(url, async function (browser) {
+ await assertMixedContentBlockingState(browser, {
+ activeLoaded: false,
+ activeBlocked: true,
+ passiveLoaded: false,
+ });
+
+ // Disable Mixed Content Protection for the page (and reload)
+ let browserLoaded = BrowserTestUtils.browserLoaded(browser, false, url);
+ let { gIdentityHandler } = browser.ownerGlobal;
+ gIdentityHandler.disableMixedContentProtection();
+ await browserLoaded;
+
+ await SpecialPowers.spawn(browser, [], async function () {
+ let expected = "Mixed Content Blocker disabled";
+ await ContentTaskUtils.waitForCondition(
+ () =>
+ content.document.getElementById("mctestdiv").innerHTML == expected,
+ "Error: Waited too long for mixed script to run in Test 2"
+ );
+
+ let actual = content.document.getElementById("mctestdiv").innerHTML;
+ is(
+ actual,
+ "Mixed Content Blocker disabled",
+ "OK: Executed mixed script in Test 2"
+ );
+ });
+
+ // The Script loaded after we disabled the page, now we are going to reload the
+ // page and see if our decision is persistent
+ url = HTTPS_TEST_ROOT_2 + "file_bug902156_1.html";
+ browserLoaded = BrowserTestUtils.browserLoaded(browser, false, url);
+ // reload the page using the provided link in the html file
+ await SpecialPowers.spawn(browser, [], function () {
+ let mctestlink = content.document.getElementById("mctestlink");
+ mctestlink.click();
+ });
+ await browserLoaded;
+
+ // The Control Center button should appear but isMixedContentBlocked should be NOT true,
+ // because our decision of disabling the mixed content blocker is persistent.
+ await assertMixedContentBlockingState(browser, {
+ activeLoaded: true,
+ activeBlocked: false,
+ passiveLoaded: false,
+ });
+
+ await SpecialPowers.spawn(browser, [], function () {
+ let actual = content.document.getElementById("mctestdiv").innerHTML;
+ is(
+ actual,
+ "Mixed Content Blocker disabled",
+ "OK: Executed mixed script in Test 2"
+ );
+ });
+ gIdentityHandler.enableMixedContentProtection();
+ });
+});
+
+add_task(async function test3() {
+ let url = HTTPS_TEST_ROOT_1 + "file_bug902156_3.html";
+ await BrowserTestUtils.withNewTab(url, async function (browser) {
+ await assertMixedContentBlockingState(browser, {
+ activeLoaded: false,
+ activeBlocked: true,
+ passiveLoaded: false,
+ });
+ });
+});
diff --git a/browser/base/content/test/siteIdentity/browser_bug906190.js b/browser/base/content/test/siteIdentity/browser_bug906190.js
new file mode 100644
index 0000000000..a0410e76cb
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/browser_bug906190.js
@@ -0,0 +1,340 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/*
+ * Tests the persistence of the "disable protection" option for Mixed Content
+ * Blocker in child tabs (bug 906190).
+ */
+
+requestLongerTimeout(2);
+
+// We use the different urls for testing same origin checks before allowing
+// mixed content on child tabs.
+const HTTPS_TEST_ROOT_1 = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://test1.example.com"
+);
+const HTTPS_TEST_ROOT_2 = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://test2.example.com"
+);
+
+/**
+ * For all tests, we load the pages over HTTPS and test both:
+ * - |CTRL+CLICK|
+ * - |RIGHT CLICK -> OPEN LINK IN TAB|
+ */
+async function doTest(
+ parentTabSpec,
+ childTabSpec,
+ testTaskFn,
+ waitForMetaRefresh
+) {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: parentTabSpec,
+ },
+ async function (browser) {
+ // As a sanity check, test that active content has been blocked as expected.
+ await assertMixedContentBlockingState(gBrowser, {
+ activeLoaded: false,
+ activeBlocked: true,
+ passiveLoaded: false,
+ });
+
+ // Disable the Mixed Content Blocker for the page, which reloads it.
+ let promiseReloaded = BrowserTestUtils.browserLoaded(browser);
+ let principal = gBrowser.contentPrincipal;
+ gIdentityHandler.disableMixedContentProtection();
+ await promiseReloaded;
+
+ // Wait for the script in the page to update the contents of the test div.
+ await SpecialPowers.spawn(
+ browser,
+ [childTabSpec],
+ async childTabSpecContent => {
+ let testDiv = content.document.getElementById("mctestdiv");
+ await ContentTaskUtils.waitForCondition(
+ () => testDiv.innerHTML == "Mixed Content Blocker disabled"
+ );
+
+ // Add the link for the child tab to the page.
+ let mainDiv = content.document.createElement("div");
+
+ // eslint-disable-next-line no-unsanitized/property
+ mainDiv.innerHTML =
+ '<p><a id="linkToOpenInNewTab" href="' +
+ childTabSpecContent +
+ '">Link</a></p>';
+ content.document.body.appendChild(mainDiv);
+ }
+ );
+
+ // Execute the test in the child tabs with the two methods to open it.
+ for (let openFn of [simulateCtrlClick, simulateContextMenuOpenInTab]) {
+ let promiseTabLoaded = waitForSomeTabToLoad();
+ openFn(browser);
+ await promiseTabLoaded;
+ gBrowser.selectTabAtIndex(2);
+
+ if (waitForMetaRefresh) {
+ await waitForSomeTabToLoad();
+ }
+
+ await testTaskFn();
+
+ gBrowser.removeCurrentTab();
+ }
+
+ SitePermissions.removeFromPrincipal(principal, "mixed-content");
+ }
+ );
+}
+
+function simulateCtrlClick(browser) {
+ BrowserTestUtils.synthesizeMouseAtCenter(
+ "#linkToOpenInNewTab",
+ { ctrlKey: true, metaKey: true },
+ browser
+ );
+}
+
+function simulateContextMenuOpenInTab(browser) {
+ BrowserTestUtils.waitForEvent(document, "popupshown", false, event => {
+ // These are operations that must be executed synchronously with the event.
+ document.getElementById("context-openlinkintab").doCommand();
+ event.target.hidePopup();
+ return true;
+ });
+ BrowserTestUtils.synthesizeMouseAtCenter(
+ "#linkToOpenInNewTab",
+ { type: "contextmenu", button: 2 },
+ browser
+ );
+}
+
+// Waits for a load event somewhere in the browser but ignore events coming
+// from <xul:browser>s without a tab assigned. That are most likely browsers
+// that preload the new tab page.
+function waitForSomeTabToLoad() {
+ return BrowserTestUtils.firstBrowserLoaded(window, true, browser => {
+ let tab = gBrowser.getTabForBrowser(browser);
+ return !!tab;
+ });
+}
+
+/**
+ * Ensure the Mixed Content Blocker is enabled.
+ */
+add_task(async function test_initialize() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["security.mixed_content.block_active_content", true],
+ // We need to disable the dFPI heuristic. So, we won't have unnecessary
+ // 3rd party cookie permission that could affect following tests because
+ // it will create a permission icon on the URL bar.
+ ["privacy.restrict3rdpartystorage.heuristic.recently_visited", false],
+ ],
+ });
+});
+
+/**
+ * 1. - Load a html page which has mixed content
+ * - Doorhanger to disable protection appears - we disable it
+ * - Load a subpage from the same origin in a new tab simulating a click
+ * - Doorhanger should >> NOT << appear anymore!
+ */
+add_task(async function test_same_origin() {
+ await doTest(
+ HTTPS_TEST_ROOT_1 + "file_bug906190_1.html",
+ HTTPS_TEST_ROOT_1 + "file_bug906190_2.html",
+ async function () {
+ // The doorhanger should appear but activeBlocked should be >> NOT << true,
+ // because our decision of disabling the mixed content blocker is persistent
+ // across tabs.
+ await assertMixedContentBlockingState(gBrowser, {
+ activeLoaded: true,
+ activeBlocked: false,
+ passiveLoaded: false,
+ });
+
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => {
+ Assert.equal(
+ content.document.getElementById("mctestdiv").innerHTML,
+ "Mixed Content Blocker disabled",
+ "OK: Executed mixed script"
+ );
+ });
+ }
+ );
+});
+
+/**
+ * 2. - Load a html page which has mixed content
+ * - Doorhanger to disable protection appears - we disable it
+ * - Load a new page from a different origin in a new tab simulating a click
+ * - Doorhanger >> SHOULD << appear again!
+ */
+add_task(async function test_different_origin() {
+ await doTest(
+ HTTPS_TEST_ROOT_1 + "file_bug906190_2.html",
+ HTTPS_TEST_ROOT_2 + "file_bug906190_2.html",
+ async function () {
+ // The doorhanger should appear and activeBlocked should be >> TRUE <<,
+ // because our decision of disabling the mixed content blocker should only
+ // persist if pages are from the same domain.
+ await assertMixedContentBlockingState(gBrowser, {
+ activeLoaded: false,
+ activeBlocked: true,
+ passiveLoaded: false,
+ });
+
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => {
+ Assert.equal(
+ content.document.getElementById("mctestdiv").innerHTML,
+ "Mixed Content Blocker enabled",
+ "OK: Blocked mixed script"
+ );
+ });
+ }
+ );
+});
+
+/**
+ * 3. - Load a html page which has mixed content
+ * - Doorhanger to disable protection appears - we disable it
+ * - Load a new page from the same origin in a new tab simulating a click
+ * - Redirect to another page from the same origin using meta-refresh
+ * - Doorhanger should >> NOT << appear again!
+ */
+add_task(async function test_same_origin_metarefresh_same_origin() {
+ // file_bug906190_3_4.html redirects to page test1.example.com/* using meta-refresh
+ await doTest(
+ HTTPS_TEST_ROOT_1 + "file_bug906190_1.html",
+ HTTPS_TEST_ROOT_1 + "file_bug906190_3_4.html",
+ async function () {
+ // The doorhanger should appear but activeBlocked should be >> NOT << true!
+ await assertMixedContentBlockingState(gBrowser, {
+ activeLoaded: true,
+ activeBlocked: false,
+ passiveLoaded: false,
+ });
+
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => {
+ Assert.equal(
+ content.document.getElementById("mctestdiv").innerHTML,
+ "Mixed Content Blocker disabled",
+ "OK: Executed mixed script"
+ );
+ });
+ },
+ true
+ );
+});
+
+/**
+ * 4. - Load a html page which has mixed content
+ * - Doorhanger to disable protection appears - we disable it
+ * - Load a new page from the same origin in a new tab simulating a click
+ * - Redirect to another page from a different origin using meta-refresh
+ * - Doorhanger >> SHOULD << appear again!
+ */
+add_task(async function test_same_origin_metarefresh_different_origin() {
+ await doTest(
+ HTTPS_TEST_ROOT_2 + "file_bug906190_1.html",
+ HTTPS_TEST_ROOT_2 + "file_bug906190_3_4.html",
+ async function () {
+ // The doorhanger should appear and activeBlocked should be >> TRUE <<.
+ await assertMixedContentBlockingState(gBrowser, {
+ activeLoaded: false,
+ activeBlocked: true,
+ passiveLoaded: false,
+ });
+
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => {
+ Assert.equal(
+ content.document.getElementById("mctestdiv").innerHTML,
+ "Mixed Content Blocker enabled",
+ "OK: Blocked mixed script"
+ );
+ });
+ },
+ true
+ );
+});
+
+/**
+ * 5. - Load a html page which has mixed content
+ * - Doorhanger to disable protection appears - we disable it
+ * - Load a new page from the same origin in a new tab simulating a click
+ * - Redirect to another page from the same origin using 302 redirect
+ */
+add_task(async function test_same_origin_302redirect_same_origin() {
+ // the sjs files returns a 302 redirect- note, same origins
+ await doTest(
+ HTTPS_TEST_ROOT_1 + "file_bug906190_1.html",
+ HTTPS_TEST_ROOT_1 + "file_bug906190.sjs",
+ async function () {
+ // The doorhanger should appear but activeBlocked should be >> NOT << true.
+ // Currently it is >> TRUE << - see follow up bug 914860
+ ok(
+ !gIdentityHandler._identityBox.classList.contains("mixedActiveBlocked"),
+ "OK: Mixed Content is NOT being blocked"
+ );
+
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => {
+ Assert.equal(
+ content.document.getElementById("mctestdiv").innerHTML,
+ "Mixed Content Blocker disabled",
+ "OK: Executed mixed script"
+ );
+ });
+ }
+ );
+});
+
+/**
+ * 6. - Load a html page which has mixed content
+ * - Doorhanger to disable protection appears - we disable it
+ * - Load a new page from the same origin in a new tab simulating a click
+ * - Redirect to another page from a different origin using 302 redirect
+ */
+add_task(async function test_same_origin_302redirect_different_origin() {
+ // the sjs files returns a 302 redirect - note, different origins
+ await doTest(
+ HTTPS_TEST_ROOT_2 + "file_bug906190_1.html",
+ HTTPS_TEST_ROOT_2 + "file_bug906190.sjs",
+ async function () {
+ // The doorhanger should appear and activeBlocked should be >> TRUE <<.
+ await assertMixedContentBlockingState(gBrowser, {
+ activeLoaded: false,
+ activeBlocked: true,
+ passiveLoaded: false,
+ });
+
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async () => {
+ Assert.equal(
+ content.document.getElementById("mctestdiv").innerHTML,
+ "Mixed Content Blocker enabled",
+ "OK: Blocked mixed script"
+ );
+ });
+ }
+ );
+});
+
+/**
+ * 7. - Test memory leak issue on redirection error. See Bug 1269426.
+ */
+add_task(async function test_bad_redirection() {
+ // the sjs files returns a 302 redirect - note, different origins
+ await doTest(
+ HTTPS_TEST_ROOT_2 + "file_bug906190_1.html",
+ HTTPS_TEST_ROOT_2 + "file_bug906190.sjs?bad-redirection=1",
+ function () {
+ // Nothing to do. Just see if memory leak is reported in the end.
+ ok(true, "Nothing to do");
+ }
+ );
+});
diff --git a/browser/base/content/test/siteIdentity/browser_check_identity_state.js b/browser/base/content/test/siteIdentity/browser_check_identity_state.js
new file mode 100644
index 0000000000..e5ecbc66f2
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/browser_check_identity_state.js
@@ -0,0 +1,882 @@
+/*
+ * Test the identity mode UI for a variety of page types
+ */
+
+"use strict";
+
+const DUMMY = "browser/browser/base/content/test/siteIdentity/dummy_page.html";
+const INSECURE_ICON_PREF = "security.insecure_connection_icon.enabled";
+const INSECURE_TEXT_PREF = "security.insecure_connection_text.enabled";
+const INSECURE_PBMODE_ICON_PREF =
+ "security.insecure_connection_icon.pbmode.enabled";
+const HTTPS_FIRST_PBM_PREF = "dom.security.https_first_pbm";
+
+function loadNewTab(url) {
+ return BrowserTestUtils.openNewForegroundTab(gBrowser, url, true);
+}
+
+function getConnectionState() {
+ // Prevents items that are being lazy loaded causing issues
+ document.getElementById("identity-icon-box").click();
+ gIdentityHandler.refreshIdentityPopup();
+ return document.getElementById("identity-popup").getAttribute("connection");
+}
+
+function getSecurityConnectionBG() {
+ // Get the background image of the security connection.
+ document.getElementById("identity-icon-box").click();
+ gIdentityHandler.refreshIdentityPopup();
+ return gBrowser.ownerGlobal
+ .getComputedStyle(
+ document
+ .getElementById("identity-popup-mainView")
+ .getElementsByClassName("identity-popup-security-connection")[0]
+ )
+ .getPropertyValue("list-style-image");
+}
+
+async function getReaderModeURL() {
+ // Gets the reader mode URL from "identity-popup mainView panel header span"
+ document.getElementById("identity-icon-box").click();
+ gIdentityHandler.refreshIdentityPopup();
+
+ let headerSpan = document.getElementById(
+ "identity-popup-mainView-panel-header-span"
+ );
+ await BrowserTestUtils.waitForCondition(() =>
+ headerSpan.innerHTML.includes("example.com")
+ );
+ return headerSpan.innerHTML;
+}
+
+// This test is slow on Linux debug e10s
+requestLongerTimeout(2);
+
+add_task(async function chromeUITest() {
+ // needs to be set due to bug in ion.js that occurs when testing
+ SpecialPowers.pushPrefEnv({
+ set: [
+ ["toolkit.pioneer.testCachedContent", "[]"],
+ ["toolkit.pioneer.testCachedAddons", "[]"],
+ ],
+ });
+ // Might needs to be extended with new secure chrome pages
+ // about:debugging is a secure chrome UI but is not tested for causing problems.
+ let secureChromePages = [
+ "addons",
+ "cache",
+ "certificate",
+ "compat",
+ "config",
+ "downloads",
+ "ion",
+ "license",
+ "logins",
+ "loginsimportreport",
+ "performance",
+ "plugins",
+ "policies",
+ "preferences",
+ "processes",
+ "profiles",
+ "profiling",
+ "protections",
+ "rights",
+ "sessionrestore",
+ "studies",
+ "support",
+ "telemetry",
+ "welcomeback",
+ ];
+
+ // else skip about:crashes, it is only available with plugin
+ if (AppConstants.MOZ_CRASHREPORTER) {
+ secureChromePages.push("crashes");
+ }
+
+ let nonSecureExamplePages = [
+ "about:about",
+ "about:credits",
+ "about:home",
+ "about:logo",
+ "about:memory",
+ "about:mozilla",
+ "about:networking",
+ "about:privatebrowsing",
+ "about:robots",
+ "about:serviceWorkers",
+ "about:sync-log",
+ "about:unloads",
+ "about:url-classifier",
+ "about:webrtc",
+ "about:welcome",
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com/" + DUMMY,
+ ];
+
+ for (let i = 0; i < secureChromePages.length; i++) {
+ await BrowserTestUtils.withNewTab("about:" + secureChromePages[i], () => {
+ is(getIdentityMode(), "chromeUI", "Identity should be chromeUI");
+ });
+ }
+
+ for (let i = 0; i < nonSecureExamplePages.length; i++) {
+ console.log(nonSecureExamplePages[i]);
+ await BrowserTestUtils.withNewTab(nonSecureExamplePages[i], () => {
+ ok(getIdentityMode() != "chromeUI", "Identity should not be chromeUI");
+ });
+ }
+});
+
+async function webpageTest(secureCheck) {
+ await SpecialPowers.pushPrefEnv({ set: [[INSECURE_ICON_PREF, secureCheck]] });
+ let oldTab = await loadNewTab("about:robots");
+
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ let newTab = await loadNewTab("http://example.com/" + DUMMY);
+ if (secureCheck) {
+ is(getIdentityMode(), "notSecure", "Identity should be not secure");
+ } else {
+ is(getIdentityMode(), "unknownIdentity", "Identity should be unknown");
+ }
+
+ gBrowser.selectedTab = oldTab;
+ is(getIdentityMode(), "localResource", "Identity should be localResource");
+
+ gBrowser.selectedTab = newTab;
+ if (secureCheck) {
+ is(getIdentityMode(), "notSecure", "Identity should be not secure");
+ } else {
+ is(getIdentityMode(), "unknownIdentity", "Identity should be unknown");
+ }
+
+ gBrowser.removeTab(newTab);
+ gBrowser.removeTab(oldTab);
+ await SpecialPowers.popPrefEnv();
+}
+
+add_task(async function test_webpage() {
+ await webpageTest(false);
+ await webpageTest(true);
+});
+
+async function webpageTestTextWarning(secureCheck) {
+ await SpecialPowers.pushPrefEnv({ set: [[INSECURE_TEXT_PREF, secureCheck]] });
+ let oldTab = await loadNewTab("about:robots");
+
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ let newTab = await loadNewTab("http://example.com/" + DUMMY);
+ if (secureCheck) {
+ is(
+ getIdentityMode(),
+ "notSecure notSecureText",
+ "Identity should have not secure text"
+ );
+ } else {
+ is(getIdentityMode(), "notSecure", "Identity should be not secure");
+ }
+
+ gBrowser.selectedTab = oldTab;
+ is(getIdentityMode(), "localResource", "Identity should be localResource");
+
+ gBrowser.selectedTab = newTab;
+ if (secureCheck) {
+ is(
+ getIdentityMode(),
+ "notSecure notSecureText",
+ "Identity should have not secure text"
+ );
+ } else {
+ is(getIdentityMode(), "notSecure", "Identity should be not secure");
+ }
+
+ gBrowser.removeTab(newTab);
+ gBrowser.removeTab(oldTab);
+ await SpecialPowers.popPrefEnv();
+}
+
+add_task(async function test_webpage_text_warning() {
+ await webpageTestTextWarning(false);
+ await webpageTestTextWarning(true);
+});
+
+async function webpageTestTextWarningCombined(secureCheck) {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [INSECURE_TEXT_PREF, secureCheck],
+ [INSECURE_ICON_PREF, secureCheck],
+ ],
+ });
+ let oldTab = await loadNewTab("about:robots");
+
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ let newTab = await loadNewTab("http://example.com/" + DUMMY);
+ if (secureCheck) {
+ is(
+ getIdentityMode(),
+ "notSecure notSecureText",
+ "Identity should be not secure"
+ );
+ } else {
+ is(getIdentityMode(), "unknownIdentity", "Identity should be unknown");
+ }
+
+ gBrowser.selectedTab = oldTab;
+ is(getIdentityMode(), "unknownIdentity", "Identity should be unknown");
+
+ gBrowser.selectedTab = newTab;
+ if (secureCheck) {
+ is(
+ getIdentityMode(),
+ "notSecure notSecureText",
+ "Identity should be not secure"
+ );
+ } else {
+ is(getIdentityMode(), "unknownIdentity", "Identity should be unknown");
+ }
+
+ gBrowser.removeTab(newTab);
+ gBrowser.removeTab(oldTab);
+ await SpecialPowers.popPrefEnv();
+}
+
+add_task(async function test_webpage_text_warning_combined() {
+ await webpageTestTextWarning(false);
+ await webpageTestTextWarning(true);
+});
+
+async function blankPageTest(secureCheck) {
+ let oldTab = await loadNewTab("about:robots");
+ await SpecialPowers.pushPrefEnv({ set: [[INSECURE_ICON_PREF, secureCheck]] });
+
+ let newTab = await loadNewTab("about:blank");
+ is(
+ gURLBar.getAttribute("pageproxystate"),
+ "invalid",
+ "pageproxystate should be invalid"
+ );
+
+ gBrowser.selectedTab = oldTab;
+ is(
+ gURLBar.getAttribute("pageproxystate"),
+ "valid",
+ "pageproxystate should be valid"
+ );
+ is(getIdentityMode(), "localResource", "Identity should be localResource");
+
+ gBrowser.selectedTab = newTab;
+ is(
+ gURLBar.getAttribute("pageproxystate"),
+ "invalid",
+ "pageproxystate should be invalid"
+ );
+
+ gBrowser.removeTab(newTab);
+ gBrowser.removeTab(oldTab);
+ await SpecialPowers.popPrefEnv();
+}
+
+add_task(async function test_blank() {
+ await blankPageTest(true);
+ await blankPageTest(false);
+});
+
+async function secureTest(secureCheck) {
+ let oldTab = await loadNewTab("about:robots");
+ await SpecialPowers.pushPrefEnv({ set: [[INSECURE_ICON_PREF, secureCheck]] });
+
+ let newTab = await loadNewTab("https://example.com/" + DUMMY);
+ is(getIdentityMode(), "verifiedDomain", "Identity should be verified");
+
+ gBrowser.selectedTab = oldTab;
+ is(getIdentityMode(), "localResource", "Identity should be localResource");
+
+ gBrowser.selectedTab = newTab;
+ is(getIdentityMode(), "verifiedDomain", "Identity should be verified");
+
+ gBrowser.removeTab(newTab);
+ gBrowser.removeTab(oldTab);
+
+ await SpecialPowers.popPrefEnv();
+}
+
+add_task(async function test_secure_enabled() {
+ await secureTest(true);
+ await secureTest(false);
+});
+
+async function viewSourceTest() {
+ let sourceTab = await loadNewTab("view-source:https://example.com/" + DUMMY);
+
+ gBrowser.selectedTab = sourceTab;
+ is(
+ getIdentityMode(),
+ "verifiedDomain",
+ "Identity should be verified while viewing source"
+ );
+
+ gBrowser.removeTab(sourceTab);
+}
+
+add_task(async function test_viewSource() {
+ await viewSourceTest();
+});
+
+async function insecureTest(secureCheck) {
+ let oldTab = await loadNewTab("about:robots");
+ await SpecialPowers.pushPrefEnv({ set: [[INSECURE_ICON_PREF, secureCheck]] });
+
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ let newTab = await loadNewTab("http://example.com/" + DUMMY);
+ if (secureCheck) {
+ is(getIdentityMode(), "notSecure", "Identity should be not secure");
+ is(
+ document.getElementById("identity-icon").getAttribute("tooltiptext"),
+ gNavigatorBundle.getString("identity.notSecure.tooltip"),
+ "The insecure lock icon has a correct tooltip text."
+ );
+ } else {
+ is(getIdentityMode(), "unknownIdentity", "Identity should be unknown");
+ }
+
+ gBrowser.selectedTab = oldTab;
+ is(getIdentityMode(), "localResource", "Identity should be localResource");
+
+ gBrowser.selectedTab = newTab;
+ if (secureCheck) {
+ is(getIdentityMode(), "notSecure", "Identity should be not secure");
+ is(
+ document.getElementById("identity-icon").getAttribute("tooltiptext"),
+ gNavigatorBundle.getString("identity.notSecure.tooltip"),
+ "The insecure lock icon has a correct tooltip text."
+ );
+ } else {
+ is(getIdentityMode(), "unknownIdentity", "Identity should be unknown");
+ }
+
+ gBrowser.removeTab(newTab);
+ gBrowser.removeTab(oldTab);
+
+ await SpecialPowers.popPrefEnv();
+}
+
+add_task(async function test_insecure() {
+ await insecureTest(true);
+ await insecureTest(false);
+});
+
+async function addonsTest(secureCheck) {
+ let oldTab = await loadNewTab("about:robots");
+ await SpecialPowers.pushPrefEnv({ set: [[INSECURE_ICON_PREF, secureCheck]] });
+
+ let newTab = await loadNewTab("about:addons");
+ is(getIdentityMode(), "chromeUI", "Identity should be chrome");
+
+ gBrowser.selectedTab = oldTab;
+ is(getIdentityMode(), "localResource", "Identity should be localResource");
+
+ gBrowser.selectedTab = newTab;
+ is(getIdentityMode(), "chromeUI", "Identity should be chrome");
+
+ gBrowser.removeTab(newTab);
+ gBrowser.removeTab(oldTab);
+
+ await SpecialPowers.popPrefEnv();
+}
+
+add_task(async function test_addons() {
+ await addonsTest(true);
+ await addonsTest(false);
+});
+
+async function fileTest(secureCheck) {
+ let oldTab = await loadNewTab("about:robots");
+ await SpecialPowers.pushPrefEnv({ set: [[INSECURE_ICON_PREF, secureCheck]] });
+ let fileURI = getTestFilePath("");
+
+ let newTab = await loadNewTab(fileURI);
+ is(getConnectionState(), "file", "Connection should be file");
+
+ gBrowser.selectedTab = oldTab;
+ is(getIdentityMode(), "localResource", "Identity should be localResource");
+
+ gBrowser.selectedTab = newTab;
+ is(getConnectionState(), "file", "Connection should be file");
+
+ gBrowser.removeTab(newTab);
+ gBrowser.removeTab(oldTab);
+
+ await SpecialPowers.popPrefEnv();
+}
+
+add_task(async function test_file() {
+ await fileTest(true);
+ await fileTest(false);
+});
+
+async function resourceUriTest(secureCheck) {
+ let oldTab = await loadNewTab("about:robots");
+ await SpecialPowers.pushPrefEnv({ set: [[INSECURE_ICON_PREF, secureCheck]] });
+ let dataURI = "resource://gre/modules/XPCOMUtils.sys.mjs";
+
+ let newTab = await loadNewTab(dataURI);
+
+ is(getConnectionState(), "file", "Connection should be file");
+
+ gBrowser.selectedTab = oldTab;
+ is(
+ getIdentityMode(),
+ "localResource",
+ "Identity should be a local a resource"
+ );
+
+ gBrowser.selectedTab = newTab;
+ is(getConnectionState(), "file", "Connection should be file");
+
+ gBrowser.removeTab(newTab);
+ gBrowser.removeTab(oldTab);
+
+ await SpecialPowers.popPrefEnv();
+}
+
+add_task(async function test_resource_uri() {
+ await resourceUriTest(true);
+ await resourceUriTest(false);
+});
+
+async function noCertErrorTest(secureCheck) {
+ let oldTab = await loadNewTab("about:robots");
+ await SpecialPowers.pushPrefEnv({ set: [[INSECURE_ICON_PREF, secureCheck]] });
+ let newTab = BrowserTestUtils.addTab(gBrowser);
+ gBrowser.selectedTab = newTab;
+
+ let promise = BrowserTestUtils.waitForErrorPage(gBrowser.selectedBrowser);
+ BrowserTestUtils.loadURIString(gBrowser, "https://nocert.example.com/");
+ await promise;
+ is(
+ getIdentityMode(),
+ "certErrorPage notSecureText",
+ "Identity should be the cert error page."
+ );
+ is(
+ getConnectionState(),
+ "cert-error-page",
+ "Connection should be the cert error page."
+ );
+
+ gBrowser.selectedTab = oldTab;
+ is(getIdentityMode(), "localResource", "Identity should be localResource");
+
+ gBrowser.selectedTab = newTab;
+ is(
+ getIdentityMode(),
+ "certErrorPage notSecureText",
+ "Identity should be the cert error page."
+ );
+ is(
+ getConnectionState(),
+ "cert-error-page",
+ "Connection should be the cert error page."
+ );
+
+ gBrowser.removeTab(newTab);
+ gBrowser.removeTab(oldTab);
+
+ await SpecialPowers.popPrefEnv();
+}
+
+add_task(async function test_about_net_error_uri() {
+ await noCertErrorTest(true);
+ await noCertErrorTest(false);
+});
+
+add_task(async function httpsOnlyErrorTest() {
+ let oldTab = await loadNewTab("about:robots");
+ await SpecialPowers.pushPrefEnv({
+ set: [["dom.security.https_only_mode", true]],
+ });
+ let newTab = BrowserTestUtils.addTab(gBrowser);
+ gBrowser.selectedTab = newTab;
+
+ let promise = BrowserTestUtils.waitForErrorPage(gBrowser.selectedBrowser);
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ BrowserTestUtils.loadURIString(gBrowser, "http://nocert.example.com/");
+ await promise;
+ is(
+ getIdentityMode(),
+ "httpsOnlyErrorPage",
+ "Identity should be the https-only mode error page."
+ );
+ is(
+ getConnectionState(),
+ "https-only-error-page",
+ "Connection should be the https-only mode error page."
+ );
+
+ gBrowser.selectedTab = oldTab;
+ is(getIdentityMode(), "localResource", "Identity should be localResource");
+
+ gBrowser.selectedTab = newTab;
+ is(
+ getIdentityMode(),
+ "httpsOnlyErrorPage",
+ "Identity should be the https-only mode error page."
+ );
+ is(
+ getConnectionState(),
+ "https-only-error-page",
+ "Connection should be the https-only mode page."
+ );
+
+ gBrowser.removeTab(newTab);
+ gBrowser.removeTab(oldTab);
+
+ await SpecialPowers.popPrefEnv();
+});
+
+async function noCertErrorFromNavigationTest(secureCheck) {
+ await SpecialPowers.pushPrefEnv({ set: [[INSECURE_ICON_PREF, secureCheck]] });
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ let newTab = await loadNewTab("http://example.com/" + DUMMY);
+
+ let promise = BrowserTestUtils.waitForErrorPage(gBrowser.selectedBrowser);
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], function () {
+ content.document.getElementById("no-cert").click();
+ });
+ await promise;
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], function () {
+ is(
+ content.window.location.href,
+ "https://nocert.example.com/",
+ "Should be the cert error URL"
+ );
+ });
+
+ is(
+ newTab.linkedBrowser.documentURI.spec.startsWith("about:certerror?"),
+ true,
+ "Should be an about:certerror"
+ );
+ is(
+ getIdentityMode(),
+ "certErrorPage notSecureText",
+ "Identity should be the cert error page."
+ );
+ is(
+ getConnectionState(),
+ "cert-error-page",
+ "Connection should be the cert error page."
+ );
+
+ gBrowser.removeTab(newTab);
+
+ await SpecialPowers.popPrefEnv();
+}
+
+add_task(async function test_about_net_error_uri_from_navigation_tab() {
+ await noCertErrorFromNavigationTest(true);
+ await noCertErrorFromNavigationTest(false);
+});
+
+add_task(async function tlsErrorPageTest() {
+ const TLS10_PAGE = "https://tls1.example.com/";
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["security.tls.version.min", 3],
+ ["security.tls.version.max", 4],
+ ],
+ });
+
+ let browser;
+ let pageLoaded;
+ await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ () => {
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, TLS10_PAGE);
+ browser = gBrowser.selectedBrowser;
+ pageLoaded = BrowserTestUtils.waitForErrorPage(browser);
+ },
+ false
+ );
+
+ info("Loading and waiting for the net error");
+ await pageLoaded;
+
+ await SpecialPowers.spawn(browser, [], function () {
+ const doc = content.document;
+ ok(
+ doc.documentURI.startsWith("about:neterror"),
+ "Should be showing error page"
+ );
+ });
+
+ is(
+ getConnectionState(),
+ "cert-error-page",
+ "Connection state should be the cert error page."
+ );
+
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+
+ await SpecialPowers.popPrefEnv();
+});
+
+add_task(async function netErrorPageTest() {
+ // Connect to a server that rejects all requests, to test network error pages:
+ let { HttpServer } = ChromeUtils.import("resource://testing-common/httpd.js");
+ let server = new HttpServer();
+ server.registerPrefixHandler("/", (req, res) =>
+ res.abort(new Error("Noooope."))
+ );
+ server.start(-1);
+ let port = server.identity.primaryPort;
+ const ERROR_PAGE = `http://localhost:${port}/`;
+
+ let browser;
+ let pageLoaded;
+ await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ () => {
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, ERROR_PAGE);
+ browser = gBrowser.selectedBrowser;
+ pageLoaded = BrowserTestUtils.waitForErrorPage(browser);
+ },
+ false
+ );
+
+ info("Loading and waiting for the net error");
+ await pageLoaded;
+
+ await SpecialPowers.spawn(browser, [], function () {
+ const doc = content.document;
+ ok(
+ doc.documentURI.startsWith("about:neterror"),
+ "Should be showing error page"
+ );
+ });
+
+ is(
+ getConnectionState(),
+ "net-error-page",
+ "Connection should be the net error page."
+ );
+
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+});
+
+async function aboutBlockedTest(secureCheck) {
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ let url = "http://www.itisatrap.org/firefox/its-an-attack.html";
+ let oldTab = await loadNewTab("about:robots");
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [INSECURE_ICON_PREF, secureCheck],
+ ["urlclassifier.blockedTable", "moztest-block-simple"],
+ ],
+ });
+ let newTab = BrowserTestUtils.addTab(gBrowser);
+ gBrowser.selectedTab = newTab;
+
+ BrowserTestUtils.loadURIString(gBrowser.selectedBrowser, url);
+
+ await BrowserTestUtils.browserLoaded(
+ gBrowser.selectedBrowser,
+ false,
+ url,
+ true
+ );
+
+ is(getIdentityMode(), "unknownIdentity", "Identity should be unknown.");
+ is(getConnectionState(), "not-secure", "Connection should be not secure.");
+
+ gBrowser.selectedTab = oldTab;
+ is(getIdentityMode(), "localResource", "Identity should be localResource");
+
+ gBrowser.selectedTab = newTab;
+ is(getIdentityMode(), "unknownIdentity", "Identity should be unknown.");
+ is(getConnectionState(), "not-secure", "Connection should be not secure.");
+
+ gBrowser.removeTab(newTab);
+ gBrowser.removeTab(oldTab);
+
+ await SpecialPowers.popPrefEnv();
+}
+
+add_task(async function test_about_blocked() {
+ await aboutBlockedTest(true);
+ await aboutBlockedTest(false);
+});
+
+add_task(async function noCertErrorSecurityConnectionBGTest() {
+ let tab = BrowserTestUtils.addTab(gBrowser);
+ gBrowser.selectedTab = tab;
+ let promise = BrowserTestUtils.waitForErrorPage(gBrowser.selectedBrowser);
+ BrowserTestUtils.loadURIString(gBrowser, "https://nocert.example.com/");
+ await promise;
+
+ is(
+ getSecurityConnectionBG(),
+ `url("chrome://global/skin/icons/security-warning.svg")`,
+ "Security connection should show a warning lock icon."
+ );
+
+ BrowserTestUtils.removeTab(tab);
+});
+
+async function aboutUriTest(secureCheck) {
+ let oldTab = await loadNewTab("about:robots");
+ await SpecialPowers.pushPrefEnv({ set: [[INSECURE_ICON_PREF, secureCheck]] });
+ let aboutURI = "about:robots";
+
+ let newTab = await loadNewTab(aboutURI);
+ is(getConnectionState(), "file", "Connection should be file");
+
+ gBrowser.selectedTab = oldTab;
+ is(getIdentityMode(), "localResource", "Identity should be localResource");
+
+ gBrowser.selectedTab = newTab;
+ is(getConnectionState(), "file", "Connection should be file");
+
+ gBrowser.removeTab(newTab);
+ gBrowser.removeTab(oldTab);
+
+ await SpecialPowers.popPrefEnv();
+}
+
+add_task(async function test_about_uri() {
+ await aboutUriTest(true);
+ await aboutUriTest(false);
+});
+
+async function readerUriTest(secureCheck) {
+ await SpecialPowers.pushPrefEnv({ set: [[INSECURE_ICON_PREF, secureCheck]] });
+
+ let newTab = await loadNewTab("about:reader?url=http://example.com");
+ gBrowser.selectedTab = newTab;
+ let readerURL = await getReaderModeURL();
+ is(
+ readerURL,
+ "Site information for example.com",
+ "should be the correct URI in reader mode"
+ );
+
+ gBrowser.removeTab(newTab);
+
+ await SpecialPowers.popPrefEnv();
+}
+
+add_task(async function test_reader_uri() {
+ await readerUriTest(true);
+ await readerUriTest(false);
+});
+
+async function dataUriTest(secureCheck) {
+ let oldTab = await loadNewTab("about:robots");
+ await SpecialPowers.pushPrefEnv({ set: [[INSECURE_ICON_PREF, secureCheck]] });
+ let dataURI = "data:text/html,hi";
+
+ let newTab = await loadNewTab(dataURI);
+ if (secureCheck) {
+ is(getIdentityMode(), "notSecure", "Identity should be not secure");
+ } else {
+ is(getIdentityMode(), "unknownIdentity", "Identity should be unknown");
+ }
+
+ gBrowser.selectedTab = oldTab;
+ is(getIdentityMode(), "localResource", "Identity should be localResource");
+
+ gBrowser.selectedTab = newTab;
+ if (secureCheck) {
+ is(getIdentityMode(), "notSecure", "Identity should be not secure");
+ } else {
+ is(getIdentityMode(), "unknownIdentity", "Identity should be unknown");
+ }
+
+ gBrowser.removeTab(newTab);
+ gBrowser.removeTab(oldTab);
+
+ await SpecialPowers.popPrefEnv();
+}
+
+add_task(async function test_data_uri() {
+ await dataUriTest(true);
+ await dataUriTest(false);
+});
+
+async function pbModeTest(prefs, secureCheck) {
+ await SpecialPowers.pushPrefEnv({ set: prefs });
+
+ let privateWin = await BrowserTestUtils.openNewBrowserWindow({
+ private: true,
+ });
+ let oldTab = await BrowserTestUtils.openNewForegroundTab(
+ privateWin.gBrowser,
+ "about:robots"
+ );
+ let newTab = await BrowserTestUtils.openNewForegroundTab(
+ privateWin.gBrowser,
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com/" + DUMMY
+ );
+
+ if (secureCheck) {
+ is(
+ getIdentityMode(privateWin),
+ "notSecure",
+ "Identity should be not secure"
+ );
+ } else {
+ is(
+ getIdentityMode(privateWin),
+ "unknownIdentity",
+ "Identity should be unknown"
+ );
+ }
+
+ privateWin.gBrowser.selectedTab = oldTab;
+ is(
+ getIdentityMode(privateWin),
+ "localResource",
+ "Identity should be localResource"
+ );
+
+ privateWin.gBrowser.selectedTab = newTab;
+ if (secureCheck) {
+ is(
+ getIdentityMode(privateWin),
+ "notSecure",
+ "Identity should be not secure"
+ );
+ } else {
+ is(
+ getIdentityMode(privateWin),
+ "unknownIdentity",
+ "Identity should be unknown"
+ );
+ }
+
+ await BrowserTestUtils.closeWindow(privateWin);
+
+ await SpecialPowers.popPrefEnv();
+}
+
+add_task(async function test_pb_mode() {
+ let prefs = [
+ [INSECURE_ICON_PREF, true],
+ [INSECURE_PBMODE_ICON_PREF, true],
+ [HTTPS_FIRST_PBM_PREF, false],
+ ];
+ await pbModeTest(prefs, true);
+ prefs = [
+ [INSECURE_ICON_PREF, false],
+ [INSECURE_PBMODE_ICON_PREF, true],
+ [HTTPS_FIRST_PBM_PREF, false],
+ ];
+ await pbModeTest(prefs, true);
+ prefs = [
+ [INSECURE_ICON_PREF, false],
+ [INSECURE_PBMODE_ICON_PREF, false],
+ [HTTPS_FIRST_PBM_PREF, false],
+ ];
+ await pbModeTest(prefs, false);
+});
diff --git a/browser/base/content/test/siteIdentity/browser_check_identity_state_pdf.js b/browser/base/content/test/siteIdentity/browser_check_identity_state_pdf.js
new file mode 100644
index 0000000000..8180238e84
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/browser_check_identity_state_pdf.js
@@ -0,0 +1,77 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/*
+ * Tests that sites opened via the PDF viewer have the correct identity state.
+ */
+
+"use strict";
+
+function testIdentityMode(uri, expectedState, message) {
+ return BrowserTestUtils.withNewTab(uri, () => {
+ is(getIdentityMode(), expectedState, message);
+ });
+}
+
+/**
+ * Test site identity state for PDFs served via file URI.
+ */
+add_task(async function test_pdf_fileURI() {
+ let path = getTestFilePath("./file_pdf.pdf");
+ info("path:" + path);
+
+ await testIdentityMode(
+ path,
+ "localResource",
+ "Identity should be localResource for a PDF served via file URI"
+ );
+});
+
+/**
+ * Test site identity state for PDFs served via blob URI.
+ */
+add_task(async function test_pdf_blobURI() {
+ let uri =
+ getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+ ) + "file_pdf_blob.html";
+
+ await BrowserTestUtils.withNewTab(uri, async browser => {
+ let newTabOpened = BrowserTestUtils.waitForNewTab(gBrowser, null, true);
+
+ await BrowserTestUtils.synthesizeMouseAtCenter("a", {}, browser);
+ await newTabOpened;
+
+ is(
+ getIdentityMode(),
+ "localResource",
+ "Identity should be localResource for a PDF served via blob URI"
+ );
+
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ });
+});
+
+/**
+ * Test site identity state for PDFs served via HTTP.
+ */
+add_task(async function test_pdf_http() {
+ const PDF_URI_NOSCHEME =
+ getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "example.com"
+ ) + "file_pdf.pdf";
+
+ await testIdentityMode(
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://" + PDF_URI_NOSCHEME,
+ "notSecure",
+ "Identity should be notSecure for a PDF served via HTTP."
+ );
+ await testIdentityMode(
+ "https://" + PDF_URI_NOSCHEME,
+ "verifiedDomain",
+ "Identity should be verifiedDomain for a PDF served via HTTPS."
+ );
+});
diff --git a/browser/base/content/test/siteIdentity/browser_csp_block_all_mixedcontent.js b/browser/base/content/test/siteIdentity/browser_csp_block_all_mixedcontent.js
new file mode 100644
index 0000000000..693c9418de
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/browser_csp_block_all_mixedcontent.js
@@ -0,0 +1,60 @@
+/*
+ * Description of the Test:
+ * We load an https page which uses a CSP including block-all-mixed-content.
+ * The page tries to load a script over http. We make sure the UI is not
+ * influenced when blocking the mixed content. In particular the page
+ * should still appear fully encrypted with a green lock.
+ */
+
+const PRE_PATH = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+);
+var gTestBrowser = null;
+
+// ------------------------------------------------------
+function cleanUpAfterTests() {
+ gBrowser.removeCurrentTab();
+ window.focus();
+ finish();
+}
+
+// ------------------------------------------------------
+async function verifyUInotDegraded() {
+ // make sure that not mixed content is loaded and also not blocked
+ await assertMixedContentBlockingState(gTestBrowser, {
+ activeLoaded: false,
+ activeBlocked: false,
+ passiveLoaded: false,
+ });
+ // clean up and finish test
+ cleanUpAfterTests();
+}
+
+// ------------------------------------------------------
+function runTests() {
+ var newTab = BrowserTestUtils.addTab(gBrowser);
+ gBrowser.selectedTab = newTab;
+ gTestBrowser = gBrowser.selectedBrowser;
+ newTab.linkedBrowser.stop();
+
+ // Starting the test
+ var url = PRE_PATH + "file_csp_block_all_mixedcontent.html";
+ BrowserTestUtils.browserLoaded(gTestBrowser, false, url).then(
+ verifyUInotDegraded
+ );
+ BrowserTestUtils.loadURIString(gTestBrowser, url);
+}
+
+// ------------------------------------------------------
+function test() {
+ // Performing async calls, e.g. 'onload', we have to wait till all of them finished
+ waitForExplicitFinish();
+
+ SpecialPowers.pushPrefEnv(
+ { set: [["security.mixed_content.block_active_content", true]] },
+ function () {
+ runTests();
+ }
+ );
+}
diff --git a/browser/base/content/test/siteIdentity/browser_deprecatedTLSVersions.js b/browser/base/content/test/siteIdentity/browser_deprecatedTLSVersions.js
new file mode 100644
index 0000000000..22fa33f3c2
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/browser_deprecatedTLSVersions.js
@@ -0,0 +1,94 @@
+/*
+ * Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ *
+ * Tests for Bug 1535210 - Set SSL STATE_IS_BROKEN flag for TLS1.0 and TLS 1.1 connections
+ */
+
+const HTTPS_TLS1_0 = "https://tls1.example.com";
+const HTTPS_TLS1_1 = "https://tls11.example.com";
+const HTTPS_TLS1_2 = "https://tls12.example.com";
+const HTTPS_TLS1_3 = "https://tls13.example.com";
+
+function getIdentityMode(aWindow = window) {
+ return aWindow.document.getElementById("identity-box").className;
+}
+
+function closeIdentityPopup() {
+ let promise = BrowserTestUtils.waitForEvent(
+ gIdentityHandler._identityPopup,
+ "popuphidden"
+ );
+ gIdentityHandler._identityPopup.hidePopup();
+ return promise;
+}
+
+async function checkConnectionState(state) {
+ await openIdentityPopup();
+ is(getConnectionState(), state, "connectionState should be " + state);
+ await closeIdentityPopup();
+}
+
+function getConnectionState() {
+ return document.getElementById("identity-popup").getAttribute("connection");
+}
+
+registerCleanupFunction(function () {
+ // Set preferences back to their original values
+ Services.prefs.clearUserPref("security.tls.version.min");
+ Services.prefs.clearUserPref("security.tls.version.max");
+});
+
+add_task(async function () {
+ // Run with all versions enabled for this test.
+ Services.prefs.setIntPref("security.tls.version.min", 1);
+ Services.prefs.setIntPref("security.tls.version.max", 4);
+
+ await BrowserTestUtils.withNewTab("about:blank", async function (browser) {
+ // Try deprecated versions
+ BrowserTestUtils.loadURIString(browser, HTTPS_TLS1_0);
+ await BrowserTestUtils.browserLoaded(browser);
+ isSecurityState(browser, "broken");
+ is(
+ getIdentityMode(),
+ "unknownIdentity weakCipher",
+ "Identity should be unknownIdentity"
+ );
+ await checkConnectionState("not-secure");
+
+ BrowserTestUtils.loadURIString(browser, HTTPS_TLS1_1);
+ await BrowserTestUtils.browserLoaded(browser);
+ isSecurityState(browser, "broken");
+ is(
+ getIdentityMode(),
+ "unknownIdentity weakCipher",
+ "Identity should be unknownIdentity"
+ );
+ await checkConnectionState("not-secure");
+
+ // Transition to secure
+ BrowserTestUtils.loadURIString(browser, HTTPS_TLS1_2);
+ await BrowserTestUtils.browserLoaded(browser);
+ isSecurityState(browser, "secure");
+ is(getIdentityMode(), "verifiedDomain", "Identity should be verified");
+ await checkConnectionState("secure");
+
+ // Transition back to broken
+ BrowserTestUtils.loadURIString(browser, HTTPS_TLS1_1);
+ await BrowserTestUtils.browserLoaded(browser);
+ isSecurityState(browser, "broken");
+ is(
+ getIdentityMode(),
+ "unknownIdentity weakCipher",
+ "Identity should be unknownIdentity"
+ );
+ await checkConnectionState("not-secure");
+
+ // TLS1.3 for completeness
+ BrowserTestUtils.loadURIString(browser, HTTPS_TLS1_3);
+ await BrowserTestUtils.browserLoaded(browser);
+ isSecurityState(browser, "secure");
+ is(getIdentityMode(), "verifiedDomain", "Identity should be verified");
+ await checkConnectionState("secure");
+ });
+});
diff --git a/browser/base/content/test/siteIdentity/browser_geolocation_indicator.js b/browser/base/content/test/siteIdentity/browser_geolocation_indicator.js
new file mode 100644
index 0000000000..078b7ab975
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/browser_geolocation_indicator.js
@@ -0,0 +1,381 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+requestLongerTimeout(2);
+
+const { PermissionUI } = ChromeUtils.importESModule(
+ "resource:///modules/PermissionUI.sys.mjs"
+);
+
+const { PermissionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PermissionTestUtils.sys.mjs"
+);
+
+const CP = Cc["@mozilla.org/content-pref/service;1"].getService(
+ Ci.nsIContentPrefService2
+);
+
+const EXAMPLE_PAGE_URL = "https://example.com";
+const EXAMPLE_PAGE_URI = Services.io.newURI(EXAMPLE_PAGE_URL);
+const EXAMPLE_PAGE_PRINCIPAL =
+ Services.scriptSecurityManager.createContentPrincipal(EXAMPLE_PAGE_URI, {});
+const GEO_CONTENT_PREF_KEY = "permissions.geoLocation.lastAccess";
+const POLL_INTERVAL_FALSE_STATE = 50;
+
+async function testGeoSharingIconVisible(state = true) {
+ let sharingIcon = document.getElementById("geo-sharing-icon");
+ ok(sharingIcon, "Geo sharing icon exists");
+
+ try {
+ await TestUtils.waitForCondition(
+ () => sharingIcon.hasAttribute("sharing") === true,
+ "Waiting for geo sharing icon visibility state",
+ // If we wait for sharing icon to *not* show, waitForCondition will always timeout on correct state.
+ // In these cases we want to reduce the wait time from 5 seconds to 2.5 seconds to prevent test duration timeouts
+ !state ? POLL_INTERVAL_FALSE_STATE : undefined
+ );
+ } catch (e) {
+ ok(!state, "Geo sharing icon not showing");
+ return;
+ }
+ ok(state, "Geo sharing icon showing");
+}
+
+async function checkForDOMElement(state, id) {
+ info(`Testing state ${state} of element ${id}`);
+ let el;
+ try {
+ await TestUtils.waitForCondition(
+ () => {
+ el = document.getElementById(id);
+ return el != null;
+ },
+ `Waiting for ${id}`,
+ !state ? POLL_INTERVAL_FALSE_STATE : undefined
+ );
+ } catch (e) {
+ ok(!state, `${id} has correct state`);
+ return el;
+ }
+ ok(state, `${id} has correct state`);
+
+ return el;
+}
+
+async function testPermissionPopupGeoContainer(
+ containerVisible,
+ timestampVisible
+) {
+ // The container holds the timestamp element, therefore we can't have a
+ // visible timestamp without the container.
+ if (timestampVisible && !containerVisible) {
+ ok(false, "Can't have timestamp without container");
+ }
+
+ // Only call openPermissionPopup if popup is closed, otherwise it does not resolve
+ if (!gPermissionPanel._identityPermissionBox.hasAttribute("open")) {
+ await openPermissionPopup();
+ }
+
+ let checkContainer = checkForDOMElement(
+ containerVisible,
+ "permission-popup-geo-container"
+ );
+
+ if (containerVisible && timestampVisible) {
+ // Wait for the geo container to be fully populated.
+ // The time label is computed async.
+ let container = await checkContainer;
+ await TestUtils.waitForCondition(
+ () => container.childElementCount == 2,
+ "permission-popup-geo-container should have two elements."
+ );
+ is(
+ container.childNodes[0].classList[0],
+ "permission-popup-permission-item",
+ "Geo container should have permission item."
+ );
+ is(
+ container.childNodes[1].id,
+ "geo-access-indicator-item",
+ "Geo container should have indicator item."
+ );
+ }
+ let checkAccessIndicator = checkForDOMElement(
+ timestampVisible,
+ "geo-access-indicator-item"
+ );
+
+ return Promise.all([checkContainer, checkAccessIndicator]);
+}
+
+function openExamplePage(tabbrowser = gBrowser) {
+ return BrowserTestUtils.openNewForegroundTab(tabbrowser, EXAMPLE_PAGE_URL);
+}
+
+function requestGeoLocation(browser) {
+ return SpecialPowers.spawn(browser, [], () => {
+ return new Promise(resolve => {
+ content.navigator.geolocation.getCurrentPosition(
+ () => resolve(true),
+ error => resolve(error.code !== 1) // PERMISSION_DENIED = 1
+ );
+ });
+ });
+}
+
+function answerGeoLocationPopup(allow, remember = false) {
+ let notification = PopupNotifications.getNotification("geolocation");
+ ok(
+ PopupNotifications.isPanelOpen && notification,
+ "Geolocation notification is open"
+ );
+
+ let rememberCheck = PopupNotifications.panel.querySelector(
+ ".popup-notification-checkbox"
+ );
+ rememberCheck.checked = remember;
+
+ let popupHidden = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popuphidden"
+ );
+ if (allow) {
+ let allowBtn = PopupNotifications.panel.querySelector(
+ ".popup-notification-primary-button"
+ );
+ allowBtn.click();
+ } else {
+ let denyBtn = PopupNotifications.panel.querySelector(
+ ".popup-notification-secondary-button"
+ );
+ denyBtn.click();
+ }
+ return popupHidden;
+}
+
+function setGeoLastAccess(browser, state) {
+ return new Promise(resolve => {
+ let host = browser.currentURI.host;
+ let handler = {
+ handleCompletion: () => resolve(),
+ };
+
+ if (!state) {
+ CP.removeByDomainAndName(
+ host,
+ GEO_CONTENT_PREF_KEY,
+ browser.loadContext,
+ handler
+ );
+ return;
+ }
+ CP.set(
+ host,
+ GEO_CONTENT_PREF_KEY,
+ new Date().toString(),
+ browser.loadContext,
+ handler
+ );
+ });
+}
+
+async function testGeoLocationLastAccessSet(browser) {
+ let timestamp = await new Promise(resolve => {
+ let lastAccess = null;
+ CP.getByDomainAndName(
+ gBrowser.currentURI.spec,
+ GEO_CONTENT_PREF_KEY,
+ browser.loadContext,
+ {
+ handleResult(pref) {
+ lastAccess = pref.value;
+ },
+ handleCompletion() {
+ resolve(lastAccess);
+ },
+ }
+ );
+ });
+
+ ok(timestamp != null, "Geo last access timestamp set");
+
+ let parseSuccess = true;
+ try {
+ timestamp = new Date(timestamp);
+ } catch (e) {
+ parseSuccess = false;
+ }
+ ok(
+ parseSuccess && !isNaN(timestamp),
+ "Geo last access timestamp is valid Date"
+ );
+}
+
+async function cleanup(tab) {
+ await setGeoLastAccess(tab.linkedBrowser, false);
+ SitePermissions.removeFromPrincipal(
+ tab.linkedBrowser.contentPrincipal,
+ "geo",
+ tab.linkedBrowser
+ );
+ gBrowser.resetBrowserSharing(tab.linkedBrowser);
+ BrowserTestUtils.removeTab(tab);
+}
+
+async function testIndicatorGeoSharingState(active) {
+ let tab = await openExamplePage();
+ gBrowser.updateBrowserSharing(tab.linkedBrowser, { geo: active });
+ await testGeoSharingIconVisible(active);
+
+ await cleanup(tab);
+}
+
+async function testIndicatorExplicitAllow(persistent) {
+ let tab = await openExamplePage();
+
+ let popupShown = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popupshown"
+ );
+ info("Requesting geolocation");
+ let request = requestGeoLocation(tab.linkedBrowser);
+ await popupShown;
+ info("Allowing geolocation via popup");
+ answerGeoLocationPopup(true, persistent);
+ await request;
+
+ await Promise.all([
+ testGeoSharingIconVisible(true),
+ testPermissionPopupGeoContainer(true, true),
+ testGeoLocationLastAccessSet(tab.linkedBrowser),
+ ]);
+
+ await cleanup(tab);
+}
+
+// Indicator and permission popup entry shown after explicit PermissionUI geolocation allow
+add_task(function test_indicator_and_timestamp_after_explicit_allow() {
+ return testIndicatorExplicitAllow(false);
+});
+add_task(function test_indicator_and_timestamp_after_explicit_allow_remember() {
+ return testIndicatorExplicitAllow(true);
+});
+
+// Indicator and permission popup entry shown after auto PermissionUI geolocation allow
+add_task(async function test_indicator_and_timestamp_after_implicit_allow() {
+ PermissionTestUtils.add(
+ EXAMPLE_PAGE_URI,
+ "geo",
+ Services.perms.ALLOW_ACTION,
+ Services.perms.EXPIRE_NEVER
+ );
+ let tab = await openExamplePage();
+ let result = await requestGeoLocation(tab.linkedBrowser);
+ ok(result, "Request should be allowed");
+
+ await Promise.all([
+ testGeoSharingIconVisible(true),
+ testPermissionPopupGeoContainer(true, true),
+ testGeoLocationLastAccessSet(tab.linkedBrowser),
+ ]);
+
+ await cleanup(tab);
+});
+
+// Indicator shown when manually setting sharing state to true
+add_task(function test_indicator_sharing_state_active() {
+ return testIndicatorGeoSharingState(true);
+});
+
+// Indicator not shown when manually setting sharing state to false
+add_task(function test_indicator_sharing_state_inactive() {
+ return testIndicatorGeoSharingState(false);
+});
+
+// Permission popup shows permission if geo permission is set to persistent allow
+add_task(async function test_permission_popup_permission_scope_permanent() {
+ PermissionTestUtils.add(
+ EXAMPLE_PAGE_URI,
+ "geo",
+ Services.perms.ALLOW_ACTION,
+ Services.perms.EXPIRE_NEVER
+ );
+ let tab = await openExamplePage();
+
+ await testPermissionPopupGeoContainer(true, false); // Expect permission to be visible, but not lastAccess indicator
+
+ await cleanup(tab);
+});
+
+// Sharing state set, but no permission
+add_task(async function test_permission_popup_permission_sharing_state() {
+ let tab = await openExamplePage();
+ gBrowser.updateBrowserSharing(tab.linkedBrowser, { geo: true });
+ await testPermissionPopupGeoContainer(true, false);
+
+ await cleanup(tab);
+});
+
+// Permission popup has correct state if sharing state and last geo access timestamp are set
+add_task(
+ async function test_permission_popup_permission_sharing_state_timestamp() {
+ let tab = await openExamplePage();
+ gBrowser.updateBrowserSharing(tab.linkedBrowser, { geo: true });
+ await setGeoLastAccess(tab.linkedBrowser, true);
+
+ await testPermissionPopupGeoContainer(true, true);
+
+ await cleanup(tab);
+ }
+);
+
+// Clicking permission clear button clears permission and resets geo sharing state
+add_task(async function test_permission_popup_permission_clear() {
+ PermissionTestUtils.add(
+ EXAMPLE_PAGE_URI,
+ "geo",
+ Services.perms.ALLOW_ACTION,
+ Services.perms.EXPIRE_NEVER
+ );
+ let tab = await openExamplePage();
+ gBrowser.updateBrowserSharing(tab.linkedBrowser, { geo: true });
+
+ await openPermissionPopup();
+
+ let clearButton = document.querySelector(
+ "#permission-popup-geo-container button"
+ );
+ ok(clearButton, "Clear button is visible");
+ clearButton.click();
+
+ await Promise.all([
+ testGeoSharingIconVisible(false),
+ testPermissionPopupGeoContainer(false, false),
+ TestUtils.waitForCondition(() => {
+ let sharingState = tab._sharingState;
+ return (
+ sharingState == null ||
+ sharingState.geo == null ||
+ sharingState.geo === false
+ );
+ }, "Waiting for geo sharing state to reset"),
+ ]);
+ await cleanup(tab);
+});
+
+/**
+ * Tests that we only show the last access label once when the sharing
+ * state is updated multiple times while the popup is open.
+ */
+add_task(async function test_permission_no_duplicate_last_access_label() {
+ let tab = await openExamplePage();
+ await setGeoLastAccess(tab.linkedBrowser, true);
+ await openPermissionPopup();
+ gBrowser.updateBrowserSharing(tab.linkedBrowser, { geo: true });
+ gBrowser.updateBrowserSharing(tab.linkedBrowser, { geo: true });
+ await testPermissionPopupGeoContainer(true, true);
+ await cleanup(tab);
+});
diff --git a/browser/base/content/test/siteIdentity/browser_getSecurityInfo.js b/browser/base/content/test/siteIdentity/browser_getSecurityInfo.js
new file mode 100644
index 0000000000..b3b086f39d
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/browser_getSecurityInfo.js
@@ -0,0 +1,35 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const MOZILLA_PKIX_ERROR_BASE = Ci.nsINSSErrorsService.MOZILLA_PKIX_ERROR_BASE;
+const MOZILLA_PKIX_ERROR_SELF_SIGNED_CERT = MOZILLA_PKIX_ERROR_BASE + 14;
+
+add_task(async function test() {
+ await BrowserTestUtils.withNewTab("about:blank", async function (browser) {
+ let loaded = BrowserTestUtils.waitForErrorPage(browser);
+ BrowserTestUtils.loadURIString(browser, "https://self-signed.example.com");
+ await loaded;
+ let securityInfo = gBrowser.securityUI.secInfo;
+ ok(!securityInfo, "Found no security info");
+
+ loaded = BrowserTestUtils.browserLoaded(browser);
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ BrowserTestUtils.loadURIString(browser, "http://example.com");
+ await loaded;
+ securityInfo = gBrowser.securityUI.secInfo;
+ ok(!securityInfo, "Found no security info");
+
+ loaded = BrowserTestUtils.browserLoaded(browser);
+ BrowserTestUtils.loadURIString(browser, "https://example.com");
+ await loaded;
+ securityInfo = gBrowser.securityUI.secInfo;
+ ok(securityInfo, "Found some security info");
+ ok(securityInfo.succeededCertChain, "Has a succeeded cert chain");
+ is(securityInfo.errorCode, 0, "Has no error code");
+ is(
+ securityInfo.serverCert.commonName,
+ "example.com",
+ "Has the correct certificate"
+ );
+ });
+});
diff --git a/browser/base/content/test/siteIdentity/browser_identityBlock_flicker.js b/browser/base/content/test/siteIdentity/browser_identityBlock_flicker.js
new file mode 100644
index 0000000000..de2a137100
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/browser_identityBlock_flicker.js
@@ -0,0 +1,52 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/* Tests that the identity icons don't flicker when navigating,
+ * i.e. the box should show no intermediate identity state. */
+
+add_task(async function test() {
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:robots",
+ true
+ );
+ let identityBox = document.getElementById("identity-box");
+
+ is(
+ identityBox.className,
+ "localResource",
+ "identity box has the correct class"
+ );
+
+ let observerOptions = {
+ attributes: true,
+ attributeFilter: ["class"],
+ };
+ let classChanges = 0;
+
+ let observer = new MutationObserver(function (mutations) {
+ for (let mutation of mutations) {
+ is(mutation.type, "attributes");
+ is(mutation.attributeName, "class");
+ classChanges++;
+ is(
+ identityBox.className,
+ "verifiedDomain",
+ "identity box class changed correctly"
+ );
+ }
+ });
+ observer.observe(identityBox, observerOptions);
+
+ let loaded = BrowserTestUtils.browserLoaded(
+ tab.linkedBrowser,
+ false,
+ "https://example.com/"
+ );
+ BrowserTestUtils.loadURIString(tab.linkedBrowser, "https://example.com");
+ await loaded;
+
+ is(classChanges, 1, "Changed the className once");
+ observer.disconnect();
+ BrowserTestUtils.removeTab(tab);
+});
diff --git a/browser/base/content/test/siteIdentity/browser_identityBlock_focus.js b/browser/base/content/test/siteIdentity/browser_identityBlock_focus.js
new file mode 100644
index 0000000000..858cd3d632
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/browser_identityBlock_focus.js
@@ -0,0 +1,126 @@
+/* Tests that the identity block can be reached via keyboard
+ * shortcuts and that it has the correct tab order.
+ */
+
+const TEST_PATH = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+);
+const PERMISSIONS_PAGE = TEST_PATH + "permissions.html";
+
+// The DevEdition has the DevTools button in the toolbar by default. Remove it
+// to prevent branch-specific rules what button should be focused.
+CustomizableUI.removeWidgetFromArea("developer-button");
+
+registerCleanupFunction(async function resetToolbar() {
+ await CustomizableUI.reset();
+});
+
+add_task(async function setupHomeButton() {
+ // Put the home button in the pre-proton placement to test focus states.
+ CustomizableUI.addWidgetToArea(
+ "home-button",
+ "nav-bar",
+ CustomizableUI.getPlacementOfWidget("stop-reload-button").position + 1
+ );
+});
+
+function synthesizeKeyAndWaitForFocus(element, keyCode, options) {
+ let focused = BrowserTestUtils.waitForEvent(element, "focus");
+ EventUtils.synthesizeKey(keyCode, options);
+ return focused;
+}
+
+// Checks that the tracking protection icon container is the next element after
+// the urlbar to be focused if there are no active notification anchors.
+add_task(async function testWithoutNotifications() {
+ await SpecialPowers.pushPrefEnv({ set: [["accessibility.tabfocus", 7]] });
+ await BrowserTestUtils.withNewTab("https://example.com", async function () {
+ await synthesizeKeyAndWaitForFocus(gURLBar, "l", { accelKey: true });
+ is(document.activeElement, gURLBar.inputField, "urlbar should be focused");
+ await synthesizeKeyAndWaitForFocus(
+ gProtectionsHandler._trackingProtectionIconContainer,
+ "VK_TAB",
+ { shiftKey: true }
+ );
+ is(
+ document.activeElement,
+ gProtectionsHandler._trackingProtectionIconContainer,
+ "tracking protection icon container should be focused"
+ );
+ });
+});
+
+// Checks that when there is a notification anchor, it will receive
+// focus before the identity block.
+add_task(async function testWithNotifications() {
+ await SpecialPowers.pushPrefEnv({ set: [["accessibility.tabfocus", 7]] });
+ await BrowserTestUtils.withNewTab(PERMISSIONS_PAGE, async function (browser) {
+ let popupshown = BrowserTestUtils.waitForEvent(
+ PopupNotifications.panel,
+ "popupshown"
+ );
+ // Request a permission;
+ BrowserTestUtils.synthesizeMouseAtCenter("#geo", {}, browser);
+ await popupshown;
+
+ await synthesizeKeyAndWaitForFocus(gURLBar, "l", { accelKey: true });
+ is(document.activeElement, gURLBar.inputField, "urlbar should be focused");
+ await synthesizeKeyAndWaitForFocus(
+ gProtectionsHandler._trackingProtectionIconContainer,
+ "VK_TAB",
+ { shiftKey: true }
+ );
+ is(
+ document.activeElement,
+ gProtectionsHandler._trackingProtectionIconContainer,
+ "tracking protection icon container should be focused"
+ );
+ await synthesizeKeyAndWaitForFocus(
+ gIdentityHandler._identityIconBox,
+ "ArrowRight"
+ );
+ is(
+ document.activeElement,
+ gIdentityHandler._identityIconBox,
+ "identity block should be focused"
+ );
+ let geoIcon = document.getElementById("geo-notification-icon");
+ await synthesizeKeyAndWaitForFocus(geoIcon, "ArrowRight");
+ is(
+ document.activeElement,
+ geoIcon,
+ "notification anchor should be focused"
+ );
+ });
+});
+
+// Checks that with invalid pageproxystate the identity block is ignored.
+add_task(async function testInvalidPageProxyState() {
+ await SpecialPowers.pushPrefEnv({ set: [["accessibility.tabfocus", 7]] });
+ await BrowserTestUtils.withNewTab("about:blank", async function (browser) {
+ // Loading about:blank will automatically focus the urlbar, which, however, can
+ // race with the test code. So we only send the shortcut if the urlbar isn't focused yet.
+ if (document.activeElement != gURLBar.inputField) {
+ await synthesizeKeyAndWaitForFocus(gURLBar, "l", { accelKey: true });
+ }
+ is(document.activeElement, gURLBar.inputField, "urlbar should be focused");
+ await synthesizeKeyAndWaitForFocus(
+ document.getElementById("home-button"),
+ "VK_TAB",
+ { shiftKey: true }
+ );
+ await synthesizeKeyAndWaitForFocus(
+ document.getElementById("tabs-newtab-button"),
+ "VK_TAB",
+ { shiftKey: true }
+ );
+ isnot(
+ document.activeElement,
+ gProtectionsHandler._trackingProtectionIconContainer,
+ "tracking protection icon container should not be focused"
+ );
+ // Restore focus to the url bar.
+ gURLBar.focus();
+ });
+});
diff --git a/browser/base/content/test/siteIdentity/browser_identityIcon_img_url.js b/browser/base/content/test/siteIdentity/browser_identityIcon_img_url.js
new file mode 100644
index 0000000000..9ef8b6dfed
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/browser_identityIcon_img_url.js
@@ -0,0 +1,148 @@
+/* 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/. */
+/**
+ * Test Bug 1562881 - Ensuring the identity icon loads correct img in different
+ * circumstances.
+ */
+
+const kBaseURI = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+);
+
+const kBaseURILocalhost = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "http://127.0.0.1"
+);
+
+const TEST_CASES = [
+ {
+ type: "http",
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ testURL: "http://example.com",
+ img_url: `url("chrome://global/skin/icons/security-broken.svg")`,
+ },
+ {
+ type: "https",
+ testURL: "https://example.com",
+ img_url: `url("chrome://global/skin/icons/security.svg")`,
+ },
+ {
+ type: "non-chrome about page",
+ testURL: "about:about",
+ img_url: `url("chrome://global/skin/icons/page-portrait.svg")`,
+ },
+ {
+ type: "chrome about page",
+ testURL: "about:preferences",
+ img_url: `url("chrome://branding/content/icon${
+ window.devicePixelRatio > 1 ? 32 : 16
+ }.png")`,
+ },
+ {
+ type: "file",
+ testURL: "dummy_page.html",
+ img_url: `url("chrome://global/skin/icons/page-portrait.svg")`,
+ },
+ {
+ type: "resource",
+ testURL: "resource://gre/modules/Log.sys.mjs",
+ img_url: `url("chrome://global/skin/icons/page-portrait.svg")`,
+ },
+ {
+ type: "mixedPassiveContent",
+ testURL: kBaseURI + "file_mixedPassiveContent.html",
+ img_url: `url("chrome://global/skin/icons/security-warning.svg")`,
+ },
+ {
+ type: "mixedActiveContent",
+ testURL: kBaseURI + "file_csp_block_all_mixedcontent.html",
+ img_url: `url("chrome://global/skin/icons/security.svg")`,
+ },
+ {
+ type: "certificateError",
+ testURL: "https://self-signed.example.com",
+ img_url: `url("chrome://global/skin/icons/security-warning.svg")`,
+ },
+ {
+ type: "localhost",
+ testURL: "http://127.0.0.1",
+ img_url: `url("chrome://global/skin/icons/page-portrait.svg")`,
+ },
+ {
+ type: "localhost + http frame",
+ testURL: kBaseURILocalhost + "file_csp_block_all_mixedcontent.html",
+ img_url: `url("chrome://global/skin/icons/page-portrait.svg")`,
+ },
+ {
+ type: "data URI",
+ testURL: "data:text/html,<div>",
+ img_url: `url("chrome://global/skin/icons/security-broken.svg")`,
+ },
+ {
+ type: "view-source HTTP",
+ testURL: "view-source:http://example.com/",
+ img_url: `url("chrome://global/skin/icons/security-broken.svg")`,
+ },
+ {
+ type: "view-source HTTPS",
+ testURL: "view-source:https://example.com/",
+ img_url: `url("chrome://global/skin/icons/security.svg")`,
+ },
+];
+
+add_task(async function test() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ // By default, proxies don't apply to 127.0.0.1. We need them to for this test, though:
+ ["network.proxy.allow_hijacking_localhost", true],
+ ["security.mixed_content.upgrade_display_content", false],
+ ],
+ });
+
+ for (let testData of TEST_CASES) {
+ info(`Testing for ${testData.type}`);
+ // Open the page for testing.
+ let testURL = testData.testURL;
+
+ // Overwrite the url if it is testing the file url.
+ if (testData.type === "file") {
+ let dir = getChromeDir(getResolvedURI(gTestPath));
+ dir.append(testURL);
+ dir.normalize();
+ testURL = Services.io.newFileURI(dir).spec;
+ }
+
+ let pageLoaded;
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ () => {
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, testURL);
+ let browser = gBrowser.selectedBrowser;
+ if (testData.type === "certificateError") {
+ pageLoaded = BrowserTestUtils.waitForErrorPage(browser);
+ } else {
+ pageLoaded = BrowserTestUtils.browserLoaded(browser);
+ }
+ },
+ false
+ );
+ await pageLoaded;
+
+ let identityIcon = document.getElementById("identity-icon");
+
+ // Get the image url from the identity icon.
+ let identityIconImageURL = gBrowser.ownerGlobal
+ .getComputedStyle(identityIcon)
+ .getPropertyValue("list-style-image");
+
+ is(
+ identityIconImageURL,
+ testData.img_url,
+ "The identity icon has a correct image url."
+ );
+
+ BrowserTestUtils.removeTab(tab);
+ }
+});
diff --git a/browser/base/content/test/siteIdentity/browser_identityPopup_HttpsOnlyMode.js b/browser/base/content/test/siteIdentity/browser_identityPopup_HttpsOnlyMode.js
new file mode 100644
index 0000000000..a83f38e1f6
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/browser_identityPopup_HttpsOnlyMode.js
@@ -0,0 +1,191 @@
+/* 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/. */
+
+const HTTPS_ONLY_PERMISSION = "https-only-load-insecure";
+const WEBSITE = scheme => `${scheme}://example.com`;
+
+add_task(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [["dom.security.https_only_mode", true]],
+ });
+
+ // Site is already HTTPS, so the UI should not be visible.
+ await runTest({
+ name: "No HTTPS-Only UI",
+ initialScheme: "https",
+ initialPermission: 0,
+ permissionScheme: "https",
+ isUiVisible: false,
+ });
+
+ // Site gets upgraded to HTTPS, so the UI should be visible.
+ // Disabling HTTPS-Only Mode through the menulist should reload the page and
+ // set the permission accordingly.
+ await runTest({
+ name: "Disable HTTPS-Only",
+ initialScheme: "http",
+ initialPermission: 0,
+ permissionScheme: "https",
+ isUiVisible: true,
+ selectPermission: 1,
+ expectReload: true,
+ finalScheme: "https",
+ });
+
+ // HTTPS-Only Mode is disabled for this site, so the UI should be visible.
+ // Disabling HTTPS-Only Mode through the menulist should not reload the page
+ // but set the permission accordingly.
+ await runTest({
+ name: "Switch between off states",
+ initialScheme: "http",
+ initialPermission: 1,
+ permissionScheme: "http",
+ isUiVisible: true,
+ selectPermission: 2,
+ expectReload: false,
+ finalScheme: "http",
+ });
+
+ // HTTPS-Only Mode is disabled for this site, so the UI should be visible.
+ // Enabling HTTPS-Only Mode through the menulist should reload and upgrade the
+ // page and set the permission accordingly.
+ await runTest({
+ name: "Enable HTTPS-Only again",
+ initialScheme: "http",
+ initialPermission: 2,
+ permissionScheme: "http",
+ isUiVisible: true,
+ selectPermission: 0,
+ expectReload: true,
+ finalScheme: "https",
+ });
+});
+
+async function runTest(options) {
+ // Set the initial permission
+ setPermission(WEBSITE(options.permissionScheme), options.initialPermission);
+
+ await BrowserTestUtils.withNewTab(
+ WEBSITE(options.initialScheme),
+ async function (browser) {
+ const name = options.name + " | ";
+
+ // Check if the site has the expected scheme
+ is(
+ browser.currentURI.scheme,
+ options.permissionScheme,
+ name + "Expected scheme should match actual scheme"
+ );
+
+ // Open the identity popup.
+ let { gIdentityHandler } = gBrowser.ownerGlobal;
+ let promisePanelOpen = BrowserTestUtils.waitForEvent(
+ gBrowser.ownerGlobal,
+ "popupshown",
+ true,
+ event => event.target == gIdentityHandler._identityPopup
+ );
+ gIdentityHandler._identityIconBox.click();
+ await promisePanelOpen;
+
+ // Check if the HTTPS-Only UI is visible
+ const httpsOnlyUI = document.getElementById(
+ "identity-popup-security-httpsonlymode"
+ );
+ is(
+ gBrowser.ownerGlobal.getComputedStyle(httpsOnlyUI).display != "none",
+ options.isUiVisible,
+ options.isUiVisible
+ ? name + "HTTPS-Only UI should be visible."
+ : name + "HTTPS-Only UI shouldn't be visible."
+ );
+
+ // If it's not visible we can't do much else :)
+ if (!options.isUiVisible) {
+ return;
+ }
+
+ // Check if the value of the menulist matches the initial permission
+ const httpsOnlyMenulist = document.getElementById(
+ "identity-popup-security-httpsonlymode-menulist"
+ );
+ is(
+ parseInt(httpsOnlyMenulist.value, 10),
+ options.initialPermission,
+ name + "Menulist value should match expected permission value."
+ );
+
+ // Select another HTTPS-Only state and potentially wait for the page to reload
+ if (options.expectReload) {
+ const loaded = BrowserTestUtils.browserLoaded(browser);
+ httpsOnlyMenulist.getItemAtIndex(options.selectPermission).doCommand();
+ await loaded;
+ } else {
+ httpsOnlyMenulist.getItemAtIndex(options.selectPermission).doCommand();
+ }
+
+ // Check if the site has the expected scheme
+ is(
+ browser.currentURI.scheme,
+ options.finalScheme,
+ name + "Unexpected scheme after page reloaded."
+ );
+
+ // Check if the permission was sucessfully changed
+ is(
+ getPermission(WEBSITE(options.permissionScheme)),
+ options.selectPermission,
+ name + "Set permission should match the one selected from the menulist."
+ );
+ }
+ );
+
+ // Reset permission
+ Services.perms.removeAll();
+}
+
+function setPermission(url, newValue) {
+ let uri = Services.io.newURI(url);
+ let principal = Services.scriptSecurityManager.createContentPrincipal(
+ uri,
+ {}
+ );
+ if (newValue === 0) {
+ Services.perms.removeFromPrincipal(principal, HTTPS_ONLY_PERMISSION);
+ } else if (newValue === 1) {
+ Services.perms.addFromPrincipal(
+ principal,
+ HTTPS_ONLY_PERMISSION,
+ Ci.nsIHttpsOnlyModePermission.LOAD_INSECURE_ALLOW,
+ Ci.nsIPermissionManager.EXPIRE_NEVER
+ );
+ } else {
+ Services.perms.addFromPrincipal(
+ principal,
+ HTTPS_ONLY_PERMISSION,
+ Ci.nsIHttpsOnlyModePermission.LOAD_INSECURE_ALLOW_SESSION,
+ Ci.nsIPermissionManager.EXPIRE_SESSION
+ );
+ }
+}
+
+function getPermission(url) {
+ let uri = Services.io.newURI(url);
+ let principal = Services.scriptSecurityManager.createContentPrincipal(
+ uri,
+ {}
+ );
+ const state = Services.perms.testPermissionFromPrincipal(
+ principal,
+ HTTPS_ONLY_PERMISSION
+ );
+ switch (state) {
+ case Ci.nsIHttpsOnlyModePermission.LOAD_INSECURE_ALLOW_SESSION:
+ return 2; // Off temporarily
+ case Ci.nsIHttpsOnlyModePermission.LOAD_INSECURE_ALLOW:
+ return 1; // Off
+ default:
+ return 0; // On
+ }
+}
diff --git a/browser/base/content/test/siteIdentity/browser_identityPopup_clearSiteData.js b/browser/base/content/test/siteIdentity/browser_identityPopup_clearSiteData.js
new file mode 100644
index 0000000000..efc4f34310
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/browser_identityPopup_clearSiteData.js
@@ -0,0 +1,245 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const TEST_ORIGIN = "https://example.com";
+const TEST_SUB_ORIGIN = "https://test1.example.com";
+const TEST_ORIGIN_2 = "https://example.net";
+const REMOVE_DIALOG_URL =
+ "chrome://browser/content/preferences/dialogs/siteDataRemoveSelected.xhtml";
+
+// Greek IDN for 'example.test'.
+const TEST_IDN_ORIGIN =
+ "https://\u03C0\u03B1\u03C1\u03AC\u03B4\u03B5\u03B9\u03B3\u03BC\u03B1.\u03B4\u03BF\u03BA\u03B9\u03BC\u03AE";
+const TEST_PUNY_ORIGIN = "https://xn--hxajbheg2az3al.xn--jxalpdlp/";
+const TEST_PUNY_SUB_ORIGIN = "https://sub1.xn--hxajbheg2az3al.xn--jxalpdlp/";
+
+ChromeUtils.defineESModuleGetters(this, {
+ SiteDataTestUtils: "resource://testing-common/SiteDataTestUtils.sys.mjs",
+});
+
+async function testClearing(
+ testQuota,
+ testCookies,
+ testURI,
+ originA,
+ subOriginA,
+ originB
+) {
+ // Create a variant of originB which is partitioned under top level originA.
+ let { scheme, host } = Services.io.newURI(originA);
+ let partitionKey = `(${scheme},${host})`;
+
+ let { origin: originBPartitioned } =
+ Services.scriptSecurityManager.createContentPrincipal(
+ Services.io.newURI(originB),
+ { partitionKey }
+ );
+
+ // Add some test quota storage.
+ if (testQuota) {
+ await SiteDataTestUtils.addToIndexedDB(originA);
+ await SiteDataTestUtils.addToIndexedDB(subOriginA);
+ await SiteDataTestUtils.addToIndexedDB(originBPartitioned);
+ }
+
+ // Add some test cookies.
+ if (testCookies) {
+ SiteDataTestUtils.addToCookies({
+ origin: originA,
+ name: "test1",
+ value: "1",
+ });
+ SiteDataTestUtils.addToCookies({
+ origin: originA,
+ name: "test2",
+ value: "2",
+ });
+ SiteDataTestUtils.addToCookies({
+ origin: subOriginA,
+ name: "test3",
+ value: "1",
+ });
+
+ SiteDataTestUtils.addToCookies({
+ origin: originBPartitioned,
+ name: "test4",
+ value: "1",
+ });
+ }
+
+ await BrowserTestUtils.withNewTab(testURI, async function (browser) {
+ // Verify we have added quota storage.
+ if (testQuota) {
+ let usage = await SiteDataTestUtils.getQuotaUsage(originA);
+ Assert.greater(usage, 0, "Should have data for the base origin.");
+
+ usage = await SiteDataTestUtils.getQuotaUsage(subOriginA);
+ Assert.greater(usage, 0, "Should have data for the sub origin.");
+
+ usage = await SiteDataTestUtils.getQuotaUsage(originBPartitioned);
+ Assert.greater(usage, 0, "Should have data for the partitioned origin.");
+ }
+
+ // Open the identity popup.
+ let { gIdentityHandler } = gBrowser.ownerGlobal;
+ let promisePanelOpen = BrowserTestUtils.waitForEvent(
+ gBrowser.ownerGlobal,
+ "popupshown",
+ true,
+ event => event.target == gIdentityHandler._identityPopup
+ );
+ gIdentityHandler._identityIconBox.click();
+ await promisePanelOpen;
+
+ let clearFooter = document.getElementById(
+ "identity-popup-clear-sitedata-footer"
+ );
+ let clearButton = document.getElementById(
+ "identity-popup-clear-sitedata-button"
+ );
+ TestUtils.waitForCondition(
+ () => !clearFooter.hidden,
+ "The clear data footer is not hidden."
+ );
+
+ let cookiesCleared;
+ if (testCookies) {
+ cookiesCleared = Promise.all([
+ TestUtils.topicObserved(
+ "cookie-changed",
+ (subj, data) => data == "deleted" && subj.name == "test1"
+ ),
+ TestUtils.topicObserved(
+ "cookie-changed",
+ (subj, data) => data == "deleted" && subj.name == "test2"
+ ),
+ TestUtils.topicObserved(
+ "cookie-changed",
+ (subj, data) => data == "deleted" && subj.name == "test3"
+ ),
+ TestUtils.topicObserved(
+ "cookie-changed",
+ (subj, data) => data == "deleted" && subj.name == "test4"
+ ),
+ ]);
+ }
+
+ // Click the "Clear data" button.
+ let siteDataUpdated = TestUtils.topicObserved(
+ "sitedatamanager:sites-updated"
+ );
+ let hideEvent = BrowserTestUtils.waitForEvent(
+ gIdentityHandler._identityPopup,
+ "popuphidden"
+ );
+ let removeDialogPromise = BrowserTestUtils.promiseAlertDialogOpen(
+ "accept",
+ REMOVE_DIALOG_URL
+ );
+ clearButton.click();
+ await hideEvent;
+ await removeDialogPromise;
+
+ await siteDataUpdated;
+
+ // Check that cookies were deleted.
+ if (testCookies) {
+ await cookiesCleared;
+ let uri = Services.io.newURI(originA);
+ is(
+ Services.cookies.countCookiesFromHost(uri.host),
+ 0,
+ "Cookies from the base domain should be cleared"
+ );
+ uri = Services.io.newURI(subOriginA);
+ is(
+ Services.cookies.countCookiesFromHost(uri.host),
+ 0,
+ "Cookies from the sub domain should be cleared"
+ );
+ ok(
+ !SiteDataTestUtils.hasCookies(originBPartitioned),
+ "Partitioned cookies should be cleared"
+ );
+ }
+
+ // Check that quota storage was deleted.
+ if (testQuota) {
+ await TestUtils.waitForCondition(async () => {
+ let usage = await SiteDataTestUtils.getQuotaUsage(originA);
+ return usage == 0;
+ }, "Should have no data for the base origin.");
+
+ let usage = await SiteDataTestUtils.getQuotaUsage(subOriginA);
+ is(usage, 0, "Should have no data for the sub origin.");
+
+ usage = await SiteDataTestUtils.getQuotaUsage(originBPartitioned);
+ is(usage, 0, "Should have no data for the partitioned origin.");
+ }
+
+ // Open the site identity panel again to check that the button isn't shown anymore.
+ promisePanelOpen = BrowserTestUtils.waitForEvent(
+ gIdentityHandler._identityPopup,
+ "popupshown"
+ );
+ gIdentityHandler._identityIconBox.click();
+ await promisePanelOpen;
+
+ // Wait for a second to see if the button is shown.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(c => setTimeout(c, 1000));
+
+ ok(
+ clearFooter.hidden,
+ "The clear data footer is hidden after clearing data."
+ );
+ });
+}
+
+// Test removing quota managed storage.
+add_task(async function test_ClearSiteData() {
+ await testClearing(
+ true,
+ false,
+ TEST_ORIGIN,
+ TEST_ORIGIN,
+ TEST_SUB_ORIGIN,
+ TEST_ORIGIN_2
+ );
+});
+
+// Test removing cookies.
+add_task(async function test_ClearCookies() {
+ await testClearing(
+ false,
+ true,
+ TEST_ORIGIN,
+ TEST_ORIGIN,
+ TEST_SUB_ORIGIN,
+ TEST_ORIGIN_2
+ );
+});
+
+// Test removing both.
+add_task(async function test_ClearCookiesAndSiteData() {
+ await testClearing(
+ true,
+ true,
+ TEST_ORIGIN,
+ TEST_ORIGIN,
+ TEST_SUB_ORIGIN,
+ TEST_ORIGIN_2
+ );
+});
+
+// Test IDN Domains
+add_task(async function test_IDN_ClearCookiesAndSiteData() {
+ await testClearing(
+ true,
+ true,
+ TEST_IDN_ORIGIN,
+ TEST_PUNY_ORIGIN,
+ TEST_PUNY_SUB_ORIGIN,
+ TEST_ORIGIN_2
+ );
+});
diff --git a/browser/base/content/test/siteIdentity/browser_identityPopup_clearSiteData_extensions.js b/browser/base/content/test/siteIdentity/browser_identityPopup_clearSiteData_extensions.js
new file mode 100644
index 0000000000..2d0d9f7068
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/browser_identityPopup_clearSiteData_extensions.js
@@ -0,0 +1,80 @@
+/* -*- 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";
+
+/*
+ * Test for Bug 1661534 - Extension page: "Clear Cookies and Site Data"
+ * does nothing.
+ *
+ * Expected behavior: when viewing a page controlled by a WebExtension,
+ * the "Clear Cookies and Site Data..." button should not be visible.
+ */
+
+add_task(async function testClearSiteDataFooterHiddenForExtensions() {
+ // Create an extension that opens an options page
+ let extension = ExtensionTestUtils.loadExtension({
+ useAddonManager: "temporary",
+
+ manifest: {
+ permissions: ["tabs"],
+ options_ui: {
+ page: "options.html",
+ open_in_tab: true,
+ },
+ },
+ files: {
+ "options.html": `<!DOCTYPE html>
+ <html>
+ <head>
+ <meta charset="utf-8">
+ </head>
+ <body>
+ <h1>This is a test options page for a WebExtension</h1>
+ </body>
+ </html>`,
+ },
+ async background() {
+ await browser.runtime.openOptionsPage();
+ browser.test.sendMessage("optionsopened");
+ },
+ });
+
+ // Run the extension and wait until its options page has finished loading
+ let browser = gBrowser.selectedBrowser;
+ let browserLoadedPromise = BrowserTestUtils.browserLoaded(browser);
+ await extension.startup();
+ await extension.awaitMessage("optionsopened");
+ await browserLoadedPromise;
+
+ await SpecialPowers.spawn(browser, [], () => {
+ ok(
+ content.document.documentURI.startsWith("moz-extension://"),
+ "Extension page has now finished loading in the browser window"
+ );
+ });
+
+ // Open the site identity popup
+ let { gIdentityHandler } = gBrowser.ownerGlobal;
+ let promisePanelOpen = BrowserTestUtils.waitForEvent(
+ gBrowser.ownerGlobal,
+ "popupshown",
+ true,
+ event => event.target == gIdentityHandler._identityPopup
+ );
+ gIdentityHandler._identityIconBox.click();
+ await promisePanelOpen;
+
+ let clearSiteDataFooter = document.getElementById(
+ "identity-popup-clear-sitedata-footer"
+ );
+
+ ok(
+ clearSiteDataFooter.hidden,
+ "The clear site data footer is hidden on a WebExtension page."
+ );
+
+ // Unload the extension
+ await extension.unload();
+});
diff --git a/browser/base/content/test/siteIdentity/browser_identityPopup_custom_roots.js b/browser/base/content/test/siteIdentity/browser_identityPopup_custom_roots.js
new file mode 100644
index 0000000000..2b9ef53bb0
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/browser_identityPopup_custom_roots.js
@@ -0,0 +1,82 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/* Test that the UI for imported root certificates shows up correctly in the identity popup.
+ */
+
+const TEST_PATH = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+);
+
+// This test is incredibly simple, because our test framework already
+// imports root certificates by default, so we just visit example.com
+// and verify that the custom root certificates UI is visible.
+add_task(async function test_https() {
+ await BrowserTestUtils.withNewTab("https://example.com", async function () {
+ let promisePanelOpen = BrowserTestUtils.waitForEvent(
+ window,
+ "popupshown",
+ true,
+ event => event.target == gIdentityHandler._identityPopup
+ );
+
+ gIdentityHandler._identityIconBox.click();
+ await promisePanelOpen;
+ let customRootWarning = document.getElementById(
+ "identity-popup-security-decription-custom-root"
+ );
+ ok(
+ BrowserTestUtils.is_visible(customRootWarning),
+ "custom root warning is visible"
+ );
+
+ let securityView = document.getElementById("identity-popup-securityView");
+ let shown = BrowserTestUtils.waitForEvent(securityView, "ViewShown");
+ document.getElementById("identity-popup-security-button").click();
+ await shown;
+
+ let subPanelInfo = document.getElementById(
+ "identity-popup-content-verifier-unknown"
+ );
+ ok(
+ BrowserTestUtils.is_visible(subPanelInfo),
+ "custom root warning in sub panel is visible"
+ );
+ });
+});
+
+// Also check that there are conditions where this isn't shown.
+add_task(async function test_http() {
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ await BrowserTestUtils.withNewTab("http://example.com", async function () {
+ let promisePanelOpen = BrowserTestUtils.waitForEvent(
+ window,
+ "popupshown",
+ true,
+ event => event.target == gIdentityHandler._identityPopup
+ );
+ gIdentityHandler._identityIconBox.click();
+ await promisePanelOpen;
+ let customRootWarning = document.getElementById(
+ "identity-popup-security-decription-custom-root"
+ );
+ ok(
+ BrowserTestUtils.is_hidden(customRootWarning),
+ "custom root warning is hidden"
+ );
+
+ let securityView = document.getElementById("identity-popup-securityView");
+ let shown = BrowserTestUtils.waitForEvent(securityView, "ViewShown");
+ document.getElementById("identity-popup-security-button").click();
+ await shown;
+
+ let subPanelInfo = document.getElementById(
+ "identity-popup-content-verifier-unknown"
+ );
+ ok(
+ BrowserTestUtils.is_hidden(subPanelInfo),
+ "custom root warning in sub panel is hidden"
+ );
+ });
+});
diff --git a/browser/base/content/test/siteIdentity/browser_identityPopup_focus.js b/browser/base/content/test/siteIdentity/browser_identityPopup_focus.js
new file mode 100644
index 0000000000..80e70619ff
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/browser_identityPopup_focus.js
@@ -0,0 +1,120 @@
+/* Tests the focus behavior of the identity popup. */
+
+// Focusing on the identity box is handled by the ToolbarKeyboardNavigator
+// component (see browser/base/content/browser-toolbarKeyNav.js).
+async function focusIdentityBox() {
+ gURLBar.inputField.focus();
+ is(document.activeElement, gURLBar.inputField, "urlbar should be focused");
+ const focused = BrowserTestUtils.waitForEvent(
+ gIdentityHandler._identityIconBox,
+ "focus"
+ );
+ EventUtils.synthesizeKey("VK_TAB", { shiftKey: true });
+ EventUtils.synthesizeKey("ArrowRight");
+ await focused;
+}
+
+// Access the identity popup via mouseclick. Focus should not be moved inside.
+add_task(async function testIdentityPopupFocusClick() {
+ await SpecialPowers.pushPrefEnv({ set: [["accessibility.tabfocus", 7]] });
+ await BrowserTestUtils.withNewTab("https://example.com", async function () {
+ let shown = BrowserTestUtils.waitForEvent(
+ window,
+ "popupshown",
+ true,
+ event => event.target == gIdentityHandler._identityPopup
+ );
+ EventUtils.synthesizeMouseAtCenter(gIdentityHandler._identityIconBox, {});
+ await shown;
+ isnot(
+ Services.focus.focusedElement,
+ document.getElementById("identity-popup-security-button")
+ );
+ });
+});
+
+// Access the identity popup via keyboard. Focus should be moved inside.
+add_task(async function testIdentityPopupFocusKeyboard() {
+ await SpecialPowers.pushPrefEnv({ set: [["accessibility.tabfocus", 7]] });
+ await BrowserTestUtils.withNewTab("https://example.com", async function () {
+ await focusIdentityBox();
+ let shown = BrowserTestUtils.waitForEvent(
+ window,
+ "popupshown",
+ true,
+ event => event.target == gIdentityHandler._identityPopup
+ );
+ EventUtils.sendString(" ");
+ await shown;
+ is(
+ Services.focus.focusedElement,
+ document.getElementById("identity-popup-security-button")
+ );
+ });
+});
+
+// Access the Site Security panel, then move focus with the tab key.
+// Tabbing should be able to reach the More Information button.
+add_task(async function testSiteSecurityTabOrder() {
+ await SpecialPowers.pushPrefEnv({ set: [["accessibility.tabfocus", 7]] });
+ await BrowserTestUtils.withNewTab("https://example.com", async function () {
+ // 1. Access the identity popup.
+ await focusIdentityBox();
+ let shown = BrowserTestUtils.waitForEvent(
+ window,
+ "popupshown",
+ true,
+ event => event.target == gIdentityHandler._identityPopup
+ );
+ EventUtils.sendString(" ");
+ await shown;
+ is(
+ Services.focus.focusedElement,
+ document.getElementById("identity-popup-security-button")
+ );
+
+ // 2. Access the Site Security section.
+ let securityView = document.getElementById("identity-popup-securityView");
+ shown = BrowserTestUtils.waitForEvent(securityView, "ViewShown");
+ EventUtils.sendString(" ");
+ await shown;
+
+ // 3. Custom root learn more info should be focused by default
+ // This is probably not present in real-world scenarios, but needs to be present in our test infrastructure.
+ let customRootLearnMore = document.getElementById(
+ "identity-popup-custom-root-learn-more"
+ );
+ is(
+ Services.focus.focusedElement,
+ customRootLearnMore,
+ "learn more option for custom roots is focused"
+ );
+
+ // 4. First press of tab should move to the More Information button.
+ let moreInfoButton = document.getElementById("identity-popup-more-info");
+ let focused = BrowserTestUtils.waitForEvent(
+ gIdentityHandler._identityPopup,
+ "focusin"
+ );
+ EventUtils.sendKey("tab");
+ await focused;
+ is(
+ Services.focus.focusedElement,
+ moreInfoButton,
+ "more info button is focused"
+ );
+
+ // 5. Second press of tab should focus the Back button.
+ let backButton = gIdentityHandler._identityPopup.querySelector(
+ ".subviewbutton-back"
+ );
+ // Wait for focus to move somewhere. We use focusin because focus doesn't bubble.
+ focused = BrowserTestUtils.waitForEvent(
+ gIdentityHandler._identityPopup,
+ "focusin"
+ );
+ EventUtils.sendKey("tab");
+ await focused;
+ is(Services.focus.focusedElement, backButton, "back button is focused");
+ });
+});
diff --git a/browser/base/content/test/siteIdentity/browser_identity_UI.js b/browser/base/content/test/siteIdentity/browser_identity_UI.js
new file mode 100644
index 0000000000..bda5e225b0
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/browser_identity_UI.js
@@ -0,0 +1,192 @@
+/* Tests for correct behaviour of getHostForDisplay on identity handler */
+
+requestLongerTimeout(2);
+
+// Greek IDN for 'example.test'.
+var idnDomain =
+ "\u03C0\u03B1\u03C1\u03AC\u03B4\u03B5\u03B9\u03B3\u03BC\u03B1.\u03B4\u03BF\u03BA\u03B9\u03BC\u03AE";
+var tests = [
+ {
+ name: "normal domain",
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ location: "http://test1.example.org/",
+ hostForDisplay: "test1.example.org",
+ hasSubview: true,
+ },
+ {
+ name: "view-source",
+ location: "view-source:http://example.com/",
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ newURI: "http://example.com/",
+ hostForDisplay: "example.com",
+ hasSubview: true,
+ },
+ {
+ name: "normal HTTPS",
+ location: "https://example.com/",
+ hostForDisplay: "example.com",
+ hasSubview: true,
+ },
+ {
+ name: "IDN subdomain",
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ location: "http://sub1.xn--hxajbheg2az3al.xn--jxalpdlp/",
+ hostForDisplay: "sub1." + idnDomain,
+ hasSubview: true,
+ },
+ {
+ name: "subdomain with port",
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ location: "http://sub1.test1.example.org:8000/",
+ hostForDisplay: "sub1.test1.example.org",
+ hasSubview: true,
+ },
+ {
+ name: "subdomain HTTPS",
+ location: "https://test1.example.com/",
+ hostForDisplay: "test1.example.com",
+ hasSubview: true,
+ },
+ {
+ name: "view-source HTTPS",
+ location: "view-source:https://example.com/",
+ newURI: "https://example.com/",
+ hostForDisplay: "example.com",
+ hasSubview: true,
+ },
+ {
+ name: "IP address",
+ location: "http://127.0.0.1:8888/",
+ hostForDisplay: "127.0.0.1",
+ hasSubview: false,
+ },
+ {
+ name: "about:certificate",
+ location:
+ "about:certificate?cert=MIIHQjCCBiqgAwIBAgIQCgYwQn9bvO&cert=1pVzllk7ZFHzANBgkqhkiG9w0BAQ",
+ hostForDisplay: "about:certificate",
+ hasSubview: false,
+ },
+ {
+ name: "about:reader",
+ location: "about:reader?url=http://example.com",
+ hostForDisplay: "example.com",
+ hasSubview: false,
+ },
+ {
+ name: "chrome:",
+ location: "chrome://global/skin/in-content/info-pages.css",
+ hostForDisplay: "chrome://global/skin/in-content/info-pages.css",
+ hasSubview: false,
+ },
+];
+
+add_task(async function test() {
+ ok(gIdentityHandler, "gIdentityHandler should exist");
+
+ await BrowserTestUtils.openNewForegroundTab(gBrowser);
+
+ for (let i = 0; i < tests.length; i++) {
+ await runTest(i, true);
+ }
+
+ gBrowser.removeCurrentTab();
+ await BrowserTestUtils.openNewForegroundTab(gBrowser);
+
+ for (let i = tests.length - 1; i >= 0; i--) {
+ await runTest(i, false);
+ }
+
+ gBrowser.removeCurrentTab();
+});
+
+async function runTest(i, forward) {
+ let currentTest = tests[i];
+ let testDesc = "#" + i + " (" + currentTest.name + ")";
+ if (!forward) {
+ testDesc += " (second time)";
+ }
+
+ info("Running test " + testDesc);
+
+ let popupHidden = null;
+ if ((forward && i > 0) || (!forward && i < tests.length - 1)) {
+ popupHidden = BrowserTestUtils.waitForEvent(
+ gIdentityHandler._identityPopup,
+ "popuphidden"
+ );
+ }
+
+ let loaded = BrowserTestUtils.browserLoaded(
+ gBrowser.selectedBrowser,
+ false,
+ currentTest.location
+ );
+ BrowserTestUtils.loadURIString(
+ gBrowser.selectedBrowser,
+ currentTest.location
+ );
+ await loaded;
+ await popupHidden;
+ ok(
+ !gIdentityHandler._identityPopup ||
+ BrowserTestUtils.is_hidden(gIdentityHandler._identityPopup),
+ "Control Center is hidden"
+ );
+
+ // Sanity check other values, and the value of gIdentityHandler.getHostForDisplay()
+ is(
+ gIdentityHandler._uri.spec,
+ currentTest.newURI || currentTest.location,
+ "location matches for test " + testDesc
+ );
+ // getHostForDisplay can't be called for all modes
+ if (currentTest.hostForDisplay !== null) {
+ is(
+ gIdentityHandler.getHostForDisplay(),
+ currentTest.hostForDisplay,
+ "hostForDisplay matches for test " + testDesc
+ );
+ }
+
+ // Open the Control Center and make sure it closes after nav (Bug 1207542).
+ let popupShown = BrowserTestUtils.waitForEvent(
+ window,
+ "popupshown",
+ true,
+ event => event.target == gIdentityHandler._identityPopup
+ );
+ gIdentityHandler._identityIconBox.click();
+ info("Waiting for the Control Center to be shown");
+ await popupShown;
+ ok(
+ !BrowserTestUtils.is_hidden(gIdentityHandler._identityPopup),
+ "Control Center is visible"
+ );
+ let displayedHost = currentTest.hostForDisplay || currentTest.location;
+ ok(
+ gIdentityHandler._identityPopupMainViewHeaderLabel.textContent.includes(
+ displayedHost
+ ),
+ "identity UI header shows the host for test " + testDesc
+ );
+
+ let securityButton = gBrowser.ownerDocument.querySelector(
+ "#identity-popup-security-button"
+ );
+ is(
+ securityButton.disabled,
+ !currentTest.hasSubview,
+ "Security button has correct disabled state"
+ );
+ if (currentTest.hasSubview) {
+ // Show the subview, which is an easy way in automation to reproduce
+ // Bug 1207542, where the CC wouldn't close on navigation.
+ let promiseViewShown = BrowserTestUtils.waitForEvent(
+ gIdentityHandler._identityPopup,
+ "ViewShown"
+ );
+ securityButton.click();
+ await promiseViewShown;
+ }
+}
diff --git a/browser/base/content/test/siteIdentity/browser_iframe_navigation.js b/browser/base/content/test/siteIdentity/browser_iframe_navigation.js
new file mode 100644
index 0000000000..ac2884d31a
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/browser_iframe_navigation.js
@@ -0,0 +1,108 @@
+/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim: set ts=8 sts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that the site identity icon and related machinery reflects the correct
+// security state after navigating an iframe in various contexts.
+// See bug 1490982.
+
+const ROOT_URI = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+);
+const SECURE_TEST_URI = ROOT_URI + "iframe_navigation.html";
+// eslint-disable-next-line @microsoft/sdl/no-insecure-url
+const INSECURE_TEST_URI = SECURE_TEST_URI.replace("https://", "http://");
+
+// From a secure URI, navigate the iframe to about:blank (should still be
+// secure).
+add_task(async function () {
+ let uri = SECURE_TEST_URI + "#blank";
+ await BrowserTestUtils.withNewTab(uri, async browser => {
+ let identityMode = window.document.getElementById("identity-box").className;
+ is(identityMode, "verifiedDomain", "identity should be secure before");
+
+ await SpecialPowers.spawn(browser, [], async () => {
+ content.postMessage("", "*"); // This kicks off the navigation.
+ await ContentTaskUtils.waitForCondition(() => {
+ return !content.document.body.classList.contains("running");
+ });
+ });
+
+ let newIdentityMode =
+ window.document.getElementById("identity-box").className;
+ is(newIdentityMode, "verifiedDomain", "identity should be secure after");
+ });
+});
+
+// From a secure URI, navigate the iframe to an insecure URI (http://...)
+// (mixed active content should be blocked, should still be secure).
+add_task(async function () {
+ let uri = SECURE_TEST_URI + "#insecure";
+ await BrowserTestUtils.withNewTab(uri, async browser => {
+ let identityMode = window.document.getElementById("identity-box").className;
+ is(identityMode, "verifiedDomain", "identity should be secure before");
+
+ await SpecialPowers.spawn(browser, [], async () => {
+ content.postMessage("", "*"); // This kicks off the navigation.
+ await ContentTaskUtils.waitForCondition(() => {
+ return !content.document.body.classList.contains("running");
+ });
+ });
+
+ let newIdentityMode =
+ window.document.getElementById("identity-box").classList;
+ ok(
+ newIdentityMode.contains("mixedActiveBlocked"),
+ "identity should be blocked mixed active content after"
+ );
+ ok(
+ newIdentityMode.contains("verifiedDomain"),
+ "identity should still contain 'verifiedDomain'"
+ );
+ is(newIdentityMode.length, 2, "shouldn't have any other identity states");
+ });
+});
+
+// From an insecure URI (http://..), navigate the iframe to about:blank (should
+// still be insecure).
+add_task(async function () {
+ let uri = INSECURE_TEST_URI + "#blank";
+ await BrowserTestUtils.withNewTab(uri, async browser => {
+ let identityMode = window.document.getElementById("identity-box").className;
+ is(identityMode, "notSecure", "identity should be 'not secure' before");
+
+ await SpecialPowers.spawn(browser, [], async () => {
+ content.postMessage("", "*"); // This kicks off the navigation.
+ await ContentTaskUtils.waitForCondition(() => {
+ return !content.document.body.classList.contains("running");
+ });
+ });
+
+ let newIdentityMode =
+ window.document.getElementById("identity-box").className;
+ is(newIdentityMode, "notSecure", "identity should be 'not secure' after");
+ });
+});
+
+// From an insecure URI (http://..), navigate the iframe to a secure URI
+// (https://...) (should still be insecure).
+add_task(async function () {
+ let uri = INSECURE_TEST_URI + "#secure";
+ await BrowserTestUtils.withNewTab(uri, async browser => {
+ let identityMode = window.document.getElementById("identity-box").className;
+ is(identityMode, "notSecure", "identity should be 'not secure' before");
+
+ await SpecialPowers.spawn(browser, [], async () => {
+ content.postMessage("", "*"); // This kicks off the navigation.
+ await ContentTaskUtils.waitForCondition(() => {
+ return !content.document.body.classList.contains("running");
+ });
+ });
+
+ let newIdentityMode =
+ window.document.getElementById("identity-box").className;
+ is(newIdentityMode, "notSecure", "identity should be 'not secure' after");
+ });
+});
diff --git a/browser/base/content/test/siteIdentity/browser_ignore_same_page_navigation.js b/browser/base/content/test/siteIdentity/browser_ignore_same_page_navigation.js
new file mode 100644
index 0000000000..86ec70a0cf
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/browser_ignore_same_page_navigation.js
@@ -0,0 +1,50 @@
+/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim: set ts=8 sts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Test that the nsISecureBrowserUI implementation doesn't send extraneous OnSecurityChange events
+// when it receives OnLocationChange events with the LOCATION_CHANGE_SAME_DOCUMENT flag set.
+
+add_task(async function () {
+ await BrowserTestUtils.withNewTab("about:blank", async browser => {
+ let onLocationChangeCount = 0;
+ let onSecurityChangeCount = 0;
+ let progressListener = {
+ onStateChange() {},
+ onLocationChange() {
+ onLocationChangeCount++;
+ },
+ onSecurityChange() {
+ onSecurityChangeCount++;
+ },
+ onProgressChange() {},
+ onStatusChange() {},
+
+ QueryInterface: ChromeUtils.generateQI([
+ "nsIWebProgressListener",
+ "nsISupportsWeakReference",
+ ]),
+ };
+ browser.addProgressListener(progressListener, Ci.nsIWebProgress.NOTIFY_ALL);
+
+ let uri =
+ getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+ ) + "dummy_page.html";
+ BrowserTestUtils.loadURIString(browser, uri);
+ await BrowserTestUtils.browserLoaded(browser, false, uri);
+ is(onLocationChangeCount, 1, "should have 1 onLocationChange event");
+ is(onSecurityChangeCount, 1, "should have 1 onSecurityChange event");
+ await SpecialPowers.spawn(browser, [], async () => {
+ content.history.pushState({}, "", "https://example.com");
+ });
+ is(onLocationChangeCount, 2, "should have 2 onLocationChange events");
+ is(
+ onSecurityChangeCount,
+ 1,
+ "should still have only 1 onSecurityChange event"
+ );
+ });
+});
diff --git a/browser/base/content/test/siteIdentity/browser_mcb_redirect.js b/browser/base/content/test/siteIdentity/browser_mcb_redirect.js
new file mode 100644
index 0000000000..df7f6be15c
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/browser_mcb_redirect.js
@@ -0,0 +1,360 @@
+/*
+ * Description of the Tests for
+ * - Bug 418354 - Call Mixed content blocking on redirects
+ *
+ * Single redirect script tests
+ * 1. Load a script over https inside an https page
+ * - the server responds with a 302 redirect to a >> HTTP << script
+ * - the doorhanger should appear!
+ *
+ * 2. Load a script over https inside an http page
+ * - the server responds with a 302 redirect to a >> HTTP << script
+ * - the doorhanger should not appear!
+ *
+ * Single redirect image tests
+ * 3. Load an image over https inside an https page
+ * - the server responds with a 302 redirect to a >> HTTP << image
+ * - the image should not load
+ *
+ * 4. Load an image over https inside an http page
+ * - the server responds with a 302 redirect to a >> HTTP << image
+ * - the image should load and get cached
+ *
+ * Single redirect cached image tests
+ * 5. Using offline mode to ensure we hit the cache, load a cached image over
+ * https inside an http page
+ * - the server would have responded with a 302 redirect to a >> HTTP <<
+ * image, but instead we try to use the cached image.
+ * - the image should load
+ *
+ * 6. Using offline mode to ensure we hit the cache, load a cached image over
+ * https inside an https page
+ * - the server would have responded with a 302 redirect to a >> HTTP <<
+ * image, but instead we try to use the cached image.
+ * - the image should not load
+ *
+ * Double redirect image test
+ * 7. Load an image over https inside an http page
+ * - the server responds with a 302 redirect to a >> HTTP << server
+ * - the HTTP server responds with a 302 redirect to a >> HTTPS << image
+ * - the image should load and get cached
+ *
+ * Double redirect cached image tests
+ * 8. Using offline mode to ensure we hit the cache, load a cached image over
+ * https inside an http page
+ * - the image would have gone through two redirects: HTTPS->HTTP->HTTPS,
+ * but instead we try to use the cached image.
+ * - the image should load
+ *
+ * 9. Using offline mode to ensure we hit the cache, load a cached image over
+ * https inside an https page
+ * - the image would have gone through two redirects: HTTPS->HTTP->HTTPS,
+ * but instead we try to use the cached image.
+ * - the image should not load
+ */
+
+const PREF_ACTIVE = "security.mixed_content.block_active_content";
+const PREF_DISPLAY = "security.mixed_content.block_display_content";
+const PREF_DISPLAY_UPGRADE = "security.mixed_content.upgrade_display_content";
+const HTTPS_TEST_ROOT = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+);
+const HTTP_TEST_ROOT = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com"
+);
+const PREF_INSECURE_ICON = "security.insecure_connection_icon.enabled";
+
+var origBlockActive;
+var origBlockDisplay;
+var origUpgradeDisplay;
+var origInsecurePref;
+var gTestBrowser = null;
+
+// ------------------------ Helper Functions ---------------------
+
+registerCleanupFunction(function () {
+ // Set preferences back to their original values
+ Services.prefs.setBoolPref(PREF_ACTIVE, origBlockActive);
+ Services.prefs.setBoolPref(PREF_DISPLAY, origBlockDisplay);
+ Services.prefs.setBoolPref(PREF_DISPLAY_UPGRADE, origUpgradeDisplay);
+ Services.prefs.setBoolPref(PREF_INSECURE_ICON, origInsecurePref);
+
+ // Make sure we are online again
+ Services.io.offline = false;
+});
+
+function cleanUpAfterTests() {
+ gBrowser.removeCurrentTab();
+ window.focus();
+ finish();
+}
+
+// ------------------------ Test 1 ------------------------------
+
+function test1() {
+ Services.prefs.setBoolPref(PREF_INSECURE_ICON, false);
+
+ var url = HTTPS_TEST_ROOT + "test_mcb_redirect.html";
+ BrowserTestUtils.browserLoaded(gTestBrowser, false, url).then(
+ checkUIForTest1
+ );
+ BrowserTestUtils.loadURIString(gTestBrowser, url);
+}
+
+function testInsecure1() {
+ Services.prefs.setBoolPref(PREF_INSECURE_ICON, true);
+
+ var url = HTTPS_TEST_ROOT + "test_mcb_redirect.html";
+ BrowserTestUtils.browserLoaded(gTestBrowser, false, url).then(
+ checkUIForTest1
+ );
+ BrowserTestUtils.loadURIString(gTestBrowser, url);
+}
+
+async function checkUIForTest1() {
+ await assertMixedContentBlockingState(gTestBrowser, {
+ activeLoaded: false,
+ activeBlocked: true,
+ passiveLoaded: false,
+ });
+
+ SpecialPowers.spawn(gTestBrowser, [], async function () {
+ var expected = "script blocked";
+ await ContentTaskUtils.waitForCondition(
+ () => content.document.getElementById("mctestdiv").innerHTML == expected,
+ "OK: Expected result in innerHTML for Test1!"
+ );
+ }).then(test2);
+}
+
+// ------------------------ Test 2 ------------------------------
+
+function test2() {
+ var url = HTTP_TEST_ROOT + "test_mcb_redirect.html";
+ BrowserTestUtils.browserLoaded(gTestBrowser, false, url).then(
+ checkUIForTest2
+ );
+ BrowserTestUtils.loadURIString(gTestBrowser, url);
+}
+
+async function checkUIForTest2() {
+ await assertMixedContentBlockingState(gTestBrowser, {
+ activeLoaded: false,
+ activeBlocked: false,
+ passiveLoaded: false,
+ });
+
+ SpecialPowers.spawn(gTestBrowser, [], async function () {
+ var expected = "script executed";
+ await ContentTaskUtils.waitForCondition(
+ () => content.document.getElementById("mctestdiv").innerHTML == expected,
+ "OK: Expected result in innerHTML for Test2!"
+ );
+ }).then(test3);
+}
+
+// ------------------------ Test 3 ------------------------------
+// HTTPS page loading insecure image
+function test3() {
+ info("test3");
+ var url = HTTPS_TEST_ROOT + "test_mcb_redirect_image.html";
+ BrowserTestUtils.browserLoaded(gTestBrowser, false, url).then(
+ checkLoadEventForTest3
+ );
+ BrowserTestUtils.loadURIString(gTestBrowser, url);
+}
+
+function checkLoadEventForTest3() {
+ SpecialPowers.spawn(gTestBrowser, [], async function () {
+ var expected = "image blocked";
+ await ContentTaskUtils.waitForCondition(
+ () => content.document.getElementById("mctestdiv").innerHTML == expected,
+ "OK: Expected result in innerHTML for Test3!"
+ );
+ }).then(test4);
+}
+
+// ------------------------ Test 4 ------------------------------
+// HTTP page loading insecure image
+function test4() {
+ info("test4");
+ var url = HTTP_TEST_ROOT + "test_mcb_redirect_image.html";
+ BrowserTestUtils.browserLoaded(gTestBrowser, false, url).then(
+ checkLoadEventForTest4
+ );
+ BrowserTestUtils.loadURIString(gTestBrowser, url);
+}
+
+function checkLoadEventForTest4() {
+ SpecialPowers.spawn(gTestBrowser, [], async function () {
+ var expected = "image loaded";
+ await ContentTaskUtils.waitForCondition(
+ () => content.document.getElementById("mctestdiv").innerHTML == expected,
+ "OK: Expected result in innerHTML for Test4!"
+ );
+ }).then(test5);
+}
+
+// ------------------------ Test 5 ------------------------------
+// HTTP page laoding insecure cached image
+// Assuming test 4 succeeded, the image has already been loaded once
+// and hence should be cached per the sjs cache-control header
+// Going into offline mode to ensure we are loading from the cache.
+function test5() {
+ // Go into offline mode
+ info("test5");
+ Services.io.offline = true;
+ var url = HTTP_TEST_ROOT + "test_mcb_redirect_image.html";
+ BrowserTestUtils.browserLoaded(gTestBrowser, false, url).then(
+ checkLoadEventForTest5
+ );
+ BrowserTestUtils.loadURIString(gTestBrowser, url);
+}
+
+function checkLoadEventForTest5() {
+ SpecialPowers.spawn(gTestBrowser, [], async function () {
+ var expected = "image loaded";
+ await ContentTaskUtils.waitForCondition(
+ () => content.document.getElementById("mctestdiv").innerHTML == expected,
+ "OK: Expected result in innerHTML for Test5!"
+ );
+ }).then(() => {
+ // Go back online
+ Services.io.offline = false;
+ test6();
+ });
+}
+
+// ------------------------ Test 6 ------------------------------
+// HTTPS page loading insecure cached image
+// Assuming test 4 succeeded, the image has already been loaded once
+// and hence should be cached per the sjs cache-control header
+// Going into offline mode to ensure we are loading from the cache.
+function test6() {
+ // Go into offline mode
+ info("test6");
+ Services.io.offline = true;
+ var url = HTTPS_TEST_ROOT + "test_mcb_redirect_image.html";
+ BrowserTestUtils.browserLoaded(gTestBrowser, false, url).then(
+ checkLoadEventForTest6
+ );
+ BrowserTestUtils.loadURIString(gTestBrowser, url);
+}
+
+function checkLoadEventForTest6() {
+ SpecialPowers.spawn(gTestBrowser, [], async function () {
+ var expected = "image blocked";
+ await ContentTaskUtils.waitForCondition(
+ () => content.document.getElementById("mctestdiv").innerHTML == expected,
+ "OK: Expected result in innerHTML for Test6!"
+ );
+ }).then(() => {
+ // Go back online
+ Services.io.offline = false;
+ test7();
+ });
+}
+
+// ------------------------ Test 7 ------------------------------
+// HTTP page loading insecure image that went through a double redirect
+function test7() {
+ var url = HTTP_TEST_ROOT + "test_mcb_double_redirect_image.html";
+ BrowserTestUtils.browserLoaded(gTestBrowser, false, url).then(
+ checkLoadEventForTest7
+ );
+ BrowserTestUtils.loadURIString(gTestBrowser, url);
+}
+
+function checkLoadEventForTest7() {
+ SpecialPowers.spawn(gTestBrowser, [], async function () {
+ var expected = "image loaded";
+ await ContentTaskUtils.waitForCondition(
+ () => content.document.getElementById("mctestdiv").innerHTML == expected,
+ "OK: Expected result in innerHTML for Test7!"
+ );
+ }).then(test8);
+}
+
+// ------------------------ Test 8 ------------------------------
+// HTTP page loading insecure cached image that went through a double redirect
+// Assuming test 7 succeeded, the image has already been loaded once
+// and hence should be cached per the sjs cache-control header
+// Going into offline mode to ensure we are loading from the cache.
+function test8() {
+ // Go into offline mode
+ Services.io.offline = true;
+ var url = HTTP_TEST_ROOT + "test_mcb_double_redirect_image.html";
+ BrowserTestUtils.browserLoaded(gTestBrowser, false, url).then(
+ checkLoadEventForTest8
+ );
+ BrowserTestUtils.loadURIString(gTestBrowser, url);
+}
+
+function checkLoadEventForTest8() {
+ SpecialPowers.spawn(gTestBrowser, [], async function () {
+ var expected = "image loaded";
+ await ContentTaskUtils.waitForCondition(
+ () => content.document.getElementById("mctestdiv").innerHTML == expected,
+ "OK: Expected result in innerHTML for Test8!"
+ );
+ }).then(() => {
+ // Go back online
+ Services.io.offline = false;
+ test9();
+ });
+}
+
+// ------------------------ Test 9 ------------------------------
+// HTTPS page loading insecure cached image that went through a double redirect
+// Assuming test 7 succeeded, the image has already been loaded once
+// and hence should be cached per the sjs cache-control header
+// Going into offline mode to ensure we are loading from the cache.
+function test9() {
+ // Go into offline mode
+ Services.io.offline = true;
+ var url = HTTPS_TEST_ROOT + "test_mcb_double_redirect_image.html";
+ BrowserTestUtils.browserLoaded(gTestBrowser, false, url).then(
+ checkLoadEventForTest9
+ );
+ BrowserTestUtils.loadURIString(gTestBrowser, url);
+}
+
+function checkLoadEventForTest9() {
+ SpecialPowers.spawn(gTestBrowser, [], async function () {
+ var expected = "image blocked";
+ await ContentTaskUtils.waitForCondition(
+ () => content.document.getElementById("mctestdiv").innerHTML == expected,
+ "OK: Expected result in innerHTML for Test9!"
+ );
+ }).then(() => {
+ // Go back online
+ Services.io.offline = false;
+ cleanUpAfterTests();
+ });
+}
+
+// ------------------------ SETUP ------------------------------
+
+function test() {
+ // Performing async calls, e.g. 'onload', we have to wait till all of them finished
+ waitForExplicitFinish();
+
+ // Store original preferences so we can restore settings after testing
+ origBlockActive = Services.prefs.getBoolPref(PREF_ACTIVE);
+ origBlockDisplay = Services.prefs.getBoolPref(PREF_DISPLAY);
+ origUpgradeDisplay = Services.prefs.getBoolPref(PREF_DISPLAY_UPGRADE);
+ origInsecurePref = Services.prefs.getBoolPref(PREF_INSECURE_ICON);
+ Services.prefs.setBoolPref(PREF_ACTIVE, true);
+ Services.prefs.setBoolPref(PREF_DISPLAY, true);
+ Services.prefs.setBoolPref(PREF_DISPLAY_UPGRADE, false);
+
+ var newTab = BrowserTestUtils.addTab(gBrowser);
+ gBrowser.selectedTab = newTab;
+ gTestBrowser = gBrowser.selectedBrowser;
+ newTab.linkedBrowser.stop();
+
+ executeSoon(testInsecure1);
+}
diff --git a/browser/base/content/test/siteIdentity/browser_mixedContentFramesOnHttp.js b/browser/base/content/test/siteIdentity/browser_mixedContentFramesOnHttp.js
new file mode 100644
index 0000000000..c6096342cc
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/browser_mixedContentFramesOnHttp.js
@@ -0,0 +1,37 @@
+/*
+ * Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ *
+ * Test for Bug 1182551 -
+ *
+ * This test has a top level HTTP page with an HTTPS iframe. The HTTPS iframe
+ * includes an HTTP image. We check that the top level security state is
+ * STATE_IS_INSECURE. The mixed content from the iframe shouldn't "upgrade"
+ * the HTTP top level page to broken HTTPS.
+ */
+
+const TEST_URL =
+ getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com"
+ ) + "file_mixedContentFramesOnHttp.html";
+
+add_task(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["security.mixed_content.block_active_content", true],
+ ["security.mixed_content.block_display_content", false],
+ ["security.mixed_content.upgrade_display_content", false],
+ ],
+ });
+
+ await BrowserTestUtils.withNewTab(TEST_URL, async function (browser) {
+ isSecurityState(browser, "insecure");
+ await assertMixedContentBlockingState(browser, {
+ activeLoaded: false,
+ activeBlocked: false,
+ passiveLoaded: true,
+ });
+ });
+});
diff --git a/browser/base/content/test/siteIdentity/browser_mixedContentFromOnunload.js b/browser/base/content/test/siteIdentity/browser_mixedContentFromOnunload.js
new file mode 100644
index 0000000000..c9e11e54a7
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/browser_mixedContentFromOnunload.js
@@ -0,0 +1,68 @@
+/*
+ * Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ *
+ * Tests for Bug 947079 - Fix bug in nsSecureBrowserUIImpl that sets the wrong
+ * security state on a page because of a subresource load that is not on the
+ * same page.
+ */
+
+// We use different domains for each test and for navigation within each test
+const HTTP_TEST_ROOT_1 = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com"
+);
+const HTTPS_TEST_ROOT_1 = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://test1.example.com"
+);
+const HTTP_TEST_ROOT_2 = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.net"
+);
+const HTTPS_TEST_ROOT_2 = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://test2.example.com"
+);
+
+add_task(async function () {
+ let url = HTTP_TEST_ROOT_1 + "file_mixedContentFromOnunload.html";
+ await BrowserTestUtils.withNewTab(url, async function (browser) {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["security.mixed_content.block_active_content", true],
+ ["security.mixed_content.block_display_content", false],
+ ["security.mixed_content.upgrade_display_content", false],
+ ],
+ });
+ // Navigation from an http page to a https page with no mixed content
+ // The http page loads an http image on unload
+ url = HTTPS_TEST_ROOT_1 + "file_mixedContentFromOnunload_test1.html";
+ BrowserTestUtils.loadURIString(browser, url);
+ await BrowserTestUtils.browserLoaded(browser);
+ // check security state. Since current url is https and doesn't have any
+ // mixed content resources, we expect it to be secure.
+ isSecurityState(browser, "secure");
+ await assertMixedContentBlockingState(browser, {
+ activeLoaded: false,
+ activeBlocked: false,
+ passiveLoaded: false,
+ });
+ // Navigation from an http page to a https page that has mixed display content
+ // The https page loads an http image on unload
+ url = HTTP_TEST_ROOT_2 + "file_mixedContentFromOnunload.html";
+ BrowserTestUtils.loadURIString(browser, url);
+ await BrowserTestUtils.browserLoaded(browser);
+ url = HTTPS_TEST_ROOT_2 + "file_mixedContentFromOnunload_test2.html";
+ BrowserTestUtils.loadURIString(browser, url);
+ await BrowserTestUtils.browserLoaded(browser);
+ isSecurityState(browser, "broken");
+ await assertMixedContentBlockingState(browser, {
+ activeLoaded: false,
+ activeBlocked: false,
+ passiveLoaded: true,
+ });
+ });
+});
diff --git a/browser/base/content/test/siteIdentity/browser_mixed_content_cert_override.js b/browser/base/content/test/siteIdentity/browser_mixed_content_cert_override.js
new file mode 100644
index 0000000000..6ca9655406
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/browser_mixed_content_cert_override.js
@@ -0,0 +1,69 @@
+/*
+ * Bug 1253771 - check mixed content blocking in combination with overriden certificates
+ */
+
+"use strict";
+
+const MIXED_CONTENT_URL =
+ getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://self-signed.example.com"
+ ) + "test-mixedcontent-securityerrors.html";
+
+function getConnectionState() {
+ return document.getElementById("identity-popup").getAttribute("connection");
+}
+
+function getPopupContentVerifier() {
+ return document.getElementById("identity-popup-content-verifier");
+}
+
+function getIdentityIcon() {
+ return window.getComputedStyle(document.getElementById("identity-icon"))
+ .listStyleImage;
+}
+
+function checkIdentityPopup(icon) {
+ gIdentityHandler.refreshIdentityPopup();
+ is(getIdentityIcon(), `url("chrome://global/skin/icons/${icon}")`);
+ is(getConnectionState(), "secure-cert-user-overridden");
+ isnot(
+ getPopupContentVerifier().style.display,
+ "none",
+ "Overridden certificate warning is shown"
+ );
+ ok(
+ getPopupContentVerifier().textContent.includes("security exception"),
+ "Text shows overridden certificate warning."
+ );
+}
+
+add_task(async function () {
+ await BrowserTestUtils.openNewForegroundTab(gBrowser);
+
+ // check that a warning is shown when loading a page with mixed content and an overridden certificate
+ await loadBadCertPage(MIXED_CONTENT_URL);
+ checkIdentityPopup("security-warning.svg");
+
+ // check that the crossed out icon is shown when disabling mixed content protection
+ gIdentityHandler.disableMixedContentProtection();
+ await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+
+ checkIdentityPopup("security-broken.svg");
+
+ // check that a warning is shown even without mixed content
+ BrowserTestUtils.loadURIString(
+ gBrowser.selectedBrowser,
+ "https://self-signed.example.com"
+ );
+ await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+ checkIdentityPopup("security-warning.svg");
+
+ // remove cert exception
+ let certOverrideService = Cc[
+ "@mozilla.org/security/certoverride;1"
+ ].getService(Ci.nsICertOverrideService);
+ certOverrideService.clearValidityOverride("self-signed.example.com", -1, {});
+
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+});
diff --git a/browser/base/content/test/siteIdentity/browser_mixed_content_with_navigation.js b/browser/base/content/test/siteIdentity/browser_mixed_content_with_navigation.js
new file mode 100644
index 0000000000..48171ee876
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/browser_mixed_content_with_navigation.js
@@ -0,0 +1,131 @@
+/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim: set ts=8 sts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that the site identity indicator is properly updated when loading from
+// the BF cache. This is achieved by loading a page, navigating to another page,
+// and then going "back" to the first page, as well as the reverse (loading to
+// the other page, navigating to the page we're interested in, going back, and
+// then going forward again).
+
+const kBaseURI = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+);
+const kSecureURI = kBaseURI + "dummy_page.html";
+
+const kTestcases = [
+ {
+ uri: kBaseURI + "file_mixedPassiveContent.html",
+ expectErrorPage: false,
+ expectedIdentityMode: "mixedDisplayContent",
+ },
+ {
+ uri: kBaseURI + "file_bug1045809_1.html",
+ expectErrorPage: false,
+ expectedIdentityMode: "mixedActiveBlocked",
+ },
+ {
+ uri: "https://expired.example.com",
+ expectErrorPage: true,
+ expectedIdentityMode: "certErrorPage",
+ },
+];
+
+add_task(async function () {
+ for (let testcase of kTestcases) {
+ await run_testcase(testcase);
+ }
+});
+
+async function run_testcase(testcase) {
+ await SpecialPowers.pushPrefEnv({
+ set: [["security.mixed_content.upgrade_display_content", false]],
+ });
+ // Test the forward and back case.
+ // Start by loading an unrelated URI so that this generalizes well when the
+ // testcase would otherwise first navigate to an error page, which doesn't
+ // seem to work with withNewTab.
+ await BrowserTestUtils.withNewTab("about:blank", async browser => {
+ // Navigate to the test URI.
+ BrowserTestUtils.loadURIString(browser, testcase.uri);
+ if (!testcase.expectErrorPage) {
+ await BrowserTestUtils.browserLoaded(browser, false, testcase.uri);
+ } else {
+ await BrowserTestUtils.waitForErrorPage(browser);
+ }
+ let identityMode = window.document.getElementById("identity-box").classList;
+ ok(
+ identityMode.contains(testcase.expectedIdentityMode),
+ `identity should be ${testcase.expectedIdentityMode}`
+ );
+
+ // Navigate to a URI that should be secure.
+ BrowserTestUtils.loadURIString(browser, kSecureURI);
+ await BrowserTestUtils.browserLoaded(browser, false, kSecureURI);
+ let secureIdentityMode =
+ window.document.getElementById("identity-box").className;
+ is(secureIdentityMode, "verifiedDomain", "identity should be secure now");
+
+ // Go back to the test page.
+ browser.webNavigation.goBack();
+ if (!testcase.expectErrorPage) {
+ await BrowserTestUtils.browserStopped(browser, testcase.uri);
+ } else {
+ await BrowserTestUtils.waitForErrorPage(browser);
+ }
+ let identityModeAgain =
+ window.document.getElementById("identity-box").classList;
+ ok(
+ identityModeAgain.contains(testcase.expectedIdentityMode),
+ `identity should again be ${testcase.expectedIdentityMode}`
+ );
+ });
+
+ // Test the back and forward case.
+ // Start on a secure page.
+ await BrowserTestUtils.withNewTab(kSecureURI, async browser => {
+ let secureIdentityMode =
+ window.document.getElementById("identity-box").className;
+ is(secureIdentityMode, "verifiedDomain", "identity should start as secure");
+
+ // Navigate to the test URI.
+ BrowserTestUtils.loadURIString(browser, testcase.uri);
+ if (!testcase.expectErrorPage) {
+ await BrowserTestUtils.browserLoaded(browser, false, testcase.uri);
+ } else {
+ await BrowserTestUtils.waitForErrorPage(browser);
+ }
+ let identityMode = window.document.getElementById("identity-box").classList;
+ ok(
+ identityMode.contains(testcase.expectedIdentityMode),
+ `identity should be ${testcase.expectedIdentityMode}`
+ );
+
+ // Go back to the secure page.
+ browser.webNavigation.goBack();
+ await BrowserTestUtils.browserStopped(browser, kSecureURI);
+ let secureIdentityModeAgain =
+ window.document.getElementById("identity-box").className;
+ is(
+ secureIdentityModeAgain,
+ "verifiedDomain",
+ "identity should be secure again"
+ );
+
+ // Go forward again to the test URI.
+ browser.webNavigation.goForward();
+ if (!testcase.expectErrorPage) {
+ await BrowserTestUtils.browserStopped(browser, testcase.uri);
+ } else {
+ await BrowserTestUtils.waitForErrorPage(browser);
+ }
+ let identityModeAgain =
+ window.document.getElementById("identity-box").classList;
+ ok(
+ identityModeAgain.contains(testcase.expectedIdentityMode),
+ `identity should again be ${testcase.expectedIdentityMode}`
+ );
+ });
+}
diff --git a/browser/base/content/test/siteIdentity/browser_mixed_passive_content_indicator.js b/browser/base/content/test/siteIdentity/browser_mixed_passive_content_indicator.js
new file mode 100644
index 0000000000..909764c597
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/browser_mixed_passive_content_indicator.js
@@ -0,0 +1,18 @@
+const TEST_URL =
+ getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+ ) + "simple_mixed_passive.html";
+
+add_task(async function test_mixed_passive_content_indicator() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["security.mixed_content.upgrade_display_content", false]],
+ });
+ await BrowserTestUtils.withNewTab(TEST_URL, function () {
+ is(
+ document.getElementById("identity-box").className,
+ "unknownIdentity mixedDisplayContent",
+ "identity box has class name for mixed content"
+ );
+ });
+});
diff --git a/browser/base/content/test/siteIdentity/browser_mixedcontent_securityflags.js b/browser/base/content/test/siteIdentity/browser_mixedcontent_securityflags.js
new file mode 100644
index 0000000000..3e39426c51
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/browser_mixedcontent_securityflags.js
@@ -0,0 +1,71 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// The test loads a web page with mixed active and mixed display content and
+// makes sure that the mixed content flags on the docshell are set correctly.
+// * Using default about:config prefs (mixed active blocked, mixed display
+// loaded) we load the page and check the flags.
+// * We change the about:config prefs (mixed active blocked, mixed display
+// blocked), reload the page, and check the flags again.
+// * We override protection so all mixed content can load and check the
+// flags again.
+
+const TEST_URI =
+ getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+ ) + "test-mixedcontent-securityerrors.html";
+const PREF_DISPLAY = "security.mixed_content.block_display_content";
+const PREF_DISPLAY_UPGRADE = "security.mixed_content.upgrade_display_content";
+const PREF_ACTIVE = "security.mixed_content.block_active_content";
+var gTestBrowser = null;
+
+registerCleanupFunction(function () {
+ // Set preferences back to their original values
+ Services.prefs.clearUserPref(PREF_DISPLAY);
+ Services.prefs.clearUserPref(PREF_DISPLAY_UPGRADE);
+ Services.prefs.clearUserPref(PREF_ACTIVE);
+ gBrowser.removeCurrentTab();
+});
+
+add_task(async function blockMixedActiveContentTest() {
+ // Turn on mixed active blocking and mixed display loading and load the page.
+ Services.prefs.setBoolPref(PREF_DISPLAY, false);
+ Services.prefs.setBoolPref(PREF_DISPLAY_UPGRADE, false);
+ Services.prefs.setBoolPref(PREF_ACTIVE, true);
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URI);
+ gTestBrowser = gBrowser.getBrowserForTab(tab);
+
+ await assertMixedContentBlockingState(gTestBrowser, {
+ activeLoaded: false,
+ activeBlocked: true,
+ passiveLoaded: true,
+ });
+
+ // Turn on mixed active and mixed display blocking and reload the page.
+ Services.prefs.setBoolPref(PREF_DISPLAY, true);
+ Services.prefs.setBoolPref(PREF_ACTIVE, true);
+
+ gBrowser.reload();
+ await BrowserTestUtils.browserLoaded(gTestBrowser);
+
+ await assertMixedContentBlockingState(gTestBrowser, {
+ activeLoaded: false,
+ activeBlocked: true,
+ passiveLoaded: false,
+ });
+});
+
+add_task(async function overrideMCB() {
+ // Disable mixed content blocking (reloads page) and retest
+ let { gIdentityHandler } = gTestBrowser.ownerGlobal;
+ gIdentityHandler.disableMixedContentProtection();
+ await BrowserTestUtils.browserLoaded(gTestBrowser);
+
+ await assertMixedContentBlockingState(gTestBrowser, {
+ activeLoaded: true,
+ activeBlocked: false,
+ passiveLoaded: true,
+ });
+});
diff --git a/browser/base/content/test/siteIdentity/browser_navigation_failures.js b/browser/base/content/test/siteIdentity/browser_navigation_failures.js
new file mode 100644
index 0000000000..ddb0d93fab
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/browser_navigation_failures.js
@@ -0,0 +1,166 @@
+/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim: set ts=8 sts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Tests that the site identity indicator is properly updated for navigations
+// that fail for various reasons. In particular, we currently test TLS handshake
+// failures, about: pages that don't actually exist, and situations where the
+// TLS handshake completes but the server then closes the connection.
+// See bug 1492424, bug 1493427, and bug 1391207.
+
+const kSecureURI =
+ getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+ ) + "dummy_page.html";
+add_task(async function () {
+ await BrowserTestUtils.withNewTab(kSecureURI, async browser => {
+ let identityMode = window.document.getElementById("identity-box").className;
+ is(identityMode, "verifiedDomain", "identity should be secure before");
+
+ const TLS_HANDSHAKE_FAILURE_URI = "https://ssl3.example.com/";
+ // Try to connect to a server where the TLS handshake will fail.
+ BrowserTestUtils.loadURIString(browser, TLS_HANDSHAKE_FAILURE_URI);
+ await BrowserTestUtils.browserLoaded(
+ browser,
+ false,
+ TLS_HANDSHAKE_FAILURE_URI,
+ true
+ );
+
+ let newIdentityMode =
+ window.document.getElementById("identity-box").className;
+ is(
+ newIdentityMode,
+ "certErrorPage notSecureText",
+ "identity should be unknown (not secure) after"
+ );
+ });
+});
+
+add_task(async function () {
+ await BrowserTestUtils.withNewTab(kSecureURI, async browser => {
+ let identityMode = window.document.getElementById("identity-box").className;
+ is(identityMode, "verifiedDomain", "identity should be secure before");
+
+ const BAD_ABOUT_PAGE_URI = "about:somethingthatdoesnotexist";
+ // Try to load an about: page that doesn't exist
+ BrowserTestUtils.loadURIString(browser, BAD_ABOUT_PAGE_URI);
+ await BrowserTestUtils.browserLoaded(
+ browser,
+ false,
+ BAD_ABOUT_PAGE_URI,
+ true
+ );
+
+ let newIdentityMode =
+ window.document.getElementById("identity-box").className;
+ is(
+ newIdentityMode,
+ "unknownIdentity",
+ "identity should be unknown (not secure) after"
+ );
+ });
+});
+
+// Helper function to start a TLS server that will accept a connection, complete
+// the TLS handshake, but then close the connection.
+function startServer(cert) {
+ let tlsServer = Cc["@mozilla.org/network/tls-server-socket;1"].createInstance(
+ Ci.nsITLSServerSocket
+ );
+ tlsServer.init(-1, true, -1);
+ tlsServer.serverCert = cert;
+
+ let input, output;
+
+ let listener = {
+ onSocketAccepted(socket, transport) {
+ let connectionInfo = transport.securityCallbacks.getInterface(
+ Ci.nsITLSServerConnectionInfo
+ );
+ connectionInfo.setSecurityObserver(listener);
+ input = transport.openInputStream(0, 0, 0);
+ output = transport.openOutputStream(0, 0, 0);
+ },
+
+ onHandshakeDone(socket, status) {
+ input.asyncWait(
+ {
+ onInputStreamReady(readyInput) {
+ try {
+ input.close();
+ output.close();
+ } catch (e) {
+ info(e);
+ }
+ },
+ },
+ 0,
+ 0,
+ Services.tm.currentThread
+ );
+ },
+
+ onStopListening() {},
+ };
+
+ tlsServer.setSessionTickets(false);
+ tlsServer.asyncListen(listener);
+
+ return tlsServer;
+}
+
+// Test that if we complete a TLS handshake but the server closes the connection
+// just after doing so (resulting in a "connection reset" error page), the site
+// identity information gets updated appropriately (it should indicate "not
+// secure").
+add_task(async function () {
+ await SpecialPowers.pushPrefEnv({
+ // This test fails on some platforms if we leave IPv6 enabled.
+ set: [["network.dns.disableIPv6", true]],
+ });
+
+ let certOverrideService = Cc[
+ "@mozilla.org/security/certoverride;1"
+ ].getService(Ci.nsICertOverrideService);
+
+ let cert = getTestServerCertificate();
+ // Start a server and trust its certificate.
+ let server = startServer(cert);
+ certOverrideService.rememberValidityOverride(
+ "localhost",
+ server.port,
+ {},
+ cert,
+ true
+ );
+
+ // Un-do configuration changes we've made when the test is done.
+ registerCleanupFunction(() => {
+ certOverrideService.clearValidityOverride("localhost", server.port, {});
+ server.close();
+ });
+
+ // Open up a new tab...
+ await BrowserTestUtils.withNewTab("about:blank", async browser => {
+ const TLS_HANDSHAKE_FAILURE_URI = `https://localhost:${server.port}/`;
+ // Try to connect to a server where the TLS handshake will succeed, but then
+ // the server closes the connection right after.
+ BrowserTestUtils.loadURIString(browser, TLS_HANDSHAKE_FAILURE_URI);
+ await BrowserTestUtils.browserLoaded(
+ browser,
+ false,
+ TLS_HANDSHAKE_FAILURE_URI,
+ true
+ );
+
+ let identityMode = window.document.getElementById("identity-box").className;
+ is(
+ identityMode,
+ "certErrorPage notSecureText",
+ "identity should be 'unknown'"
+ );
+ });
+});
diff --git a/browser/base/content/test/siteIdentity/browser_no_mcb_for_loopback.js b/browser/base/content/test/siteIdentity/browser_no_mcb_for_loopback.js
new file mode 100644
index 0000000000..1c854e2849
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/browser_no_mcb_for_loopback.js
@@ -0,0 +1,88 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// The test loads a HTTPS web page with active content from HTTP loopback URLs
+// and makes sure that the mixed content flags on the docshell are not set.
+//
+// Note that the URLs referenced within the test page intentionally use the
+// unassigned port 8 because we don't want to actually load anything, we just
+// want to check that the URLs are not blocked.
+
+// The following rejections should not be left uncaught. This test has been
+// whitelisted until the issue is fixed.
+if (!gMultiProcessBrowser) {
+ const { PromiseTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PromiseTestUtils.sys.mjs"
+ );
+ PromiseTestUtils.expectUncaughtRejection(/NetworkError/);
+ PromiseTestUtils.expectUncaughtRejection(/NetworkError/);
+}
+
+const TEST_URL =
+ getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+ ) + "test_no_mcb_for_loopback.html";
+
+const LOOPBACK_PNG_URL =
+ getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "http://127.0.0.1:8888"
+ ) + "moz.png";
+
+const PREF_BLOCK_DISPLAY = "security.mixed_content.block_display_content";
+const PREF_UPGRADE_DISPLAY = "security.mixed_content.upgrade_display_content";
+const PREF_BLOCK_ACTIVE = "security.mixed_content.block_active_content";
+
+function clearAllImageCaches() {
+ let tools = Cc["@mozilla.org/image/tools;1"].getService(Ci.imgITools);
+ let imageCache = tools.getImgCacheForDocument(window.document);
+ imageCache.clearCache(true); // true=chrome
+ imageCache.clearCache(false); // false=content
+}
+
+registerCleanupFunction(function () {
+ clearAllImageCaches();
+ Services.prefs.clearUserPref(PREF_BLOCK_DISPLAY);
+ Services.prefs.clearUserPref(PREF_UPGRADE_DISPLAY);
+ Services.prefs.clearUserPref(PREF_BLOCK_ACTIVE);
+ gBrowser.removeCurrentTab();
+});
+
+add_task(async function allowLoopbackMixedContent() {
+ Services.prefs.setBoolPref(PREF_BLOCK_DISPLAY, true);
+ Services.prefs.setBoolPref(PREF_UPGRADE_DISPLAY, false);
+ Services.prefs.setBoolPref(PREF_BLOCK_ACTIVE, true);
+
+ const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL);
+ const browser = gBrowser.getBrowserForTab(tab);
+
+ // Check that loopback content served from the cache is not blocked.
+ await SpecialPowers.spawn(
+ browser,
+ [LOOPBACK_PNG_URL],
+ async function (loopbackPNGUrl) {
+ const doc = content.document;
+ const img = doc.createElement("img");
+ const promiseImgLoaded = ContentTaskUtils.waitForEvent(
+ img,
+ "load",
+ false
+ );
+ img.src = loopbackPNGUrl;
+ Assert.ok(!img.complete, "loopback image not yet loaded");
+ doc.body.appendChild(img);
+ await promiseImgLoaded;
+
+ const cachedImg = doc.createElement("img");
+ cachedImg.src = img.src;
+ Assert.ok(cachedImg.complete, "loopback image loaded from cache");
+ }
+ );
+
+ await assertMixedContentBlockingState(browser, {
+ activeBlocked: false,
+ activeLoaded: false,
+ passiveLoaded: false,
+ });
+});
diff --git a/browser/base/content/test/siteIdentity/browser_no_mcb_for_onions.js b/browser/base/content/test/siteIdentity/browser_no_mcb_for_onions.js
new file mode 100644
index 0000000000..4a5c65b125
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/browser_no_mcb_for_onions.js
@@ -0,0 +1,41 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// The test loads a HTTPS web page with active content from HTTP .onion URLs
+// and makes sure that the mixed content flags on the docshell are not set.
+//
+// Note that the URLs referenced within the test page intentionally use the
+// unassigned port 8 because we don't want to actually load anything, we just
+// want to check that the URLs are not blocked.
+
+const TEST_URL =
+ getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+ ) + "test_no_mcb_for_onions.html";
+
+const PREF_BLOCK_DISPLAY = "security.mixed_content.block_display_content";
+const PREF_BLOCK_ACTIVE = "security.mixed_content.block_active_content";
+const PREF_ONION_ALLOWLIST = "dom.securecontext.allowlist_onions";
+
+add_task(async function allowOnionMixedContent() {
+ registerCleanupFunction(function () {
+ gBrowser.removeCurrentTab();
+ });
+
+ await SpecialPowers.pushPrefEnv({ set: [[PREF_BLOCK_DISPLAY, true]] });
+ await SpecialPowers.pushPrefEnv({ set: [[PREF_BLOCK_ACTIVE, true]] });
+ await SpecialPowers.pushPrefEnv({ set: [[PREF_ONION_ALLOWLIST, true]] });
+
+ const tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ TEST_URL
+ ).catch(console.error);
+ const browser = gBrowser.getBrowserForTab(tab);
+
+ await assertMixedContentBlockingState(browser, {
+ activeBlocked: false,
+ activeLoaded: false,
+ passiveLoaded: false,
+ });
+});
diff --git a/browser/base/content/test/siteIdentity/browser_no_mcb_on_http_site.js b/browser/base/content/test/siteIdentity/browser_no_mcb_on_http_site.js
new file mode 100644
index 0000000000..30caae4ea5
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/browser_no_mcb_on_http_site.js
@@ -0,0 +1,133 @@
+/*
+ * Description of the Tests for
+ * - Bug 909920 - Mixed content warning should not show on a HTTP site
+ *
+ * Description of the tests:
+ * Test 1:
+ * 1) Load an http page
+ * 2) The page includes a css file using https
+ * 3) The css file loads an |IMAGE| << over http
+ *
+ * Test 2:
+ * 1) Load an http page
+ * 2) The page includes a css file using https
+ * 3) The css file loads a |FONT| over http
+ *
+ * Test 3:
+ * 1) Load an http page
+ * 2) The page includes a css file using https
+ * 3) The css file imports (@import) another css file using http
+ * 3) The imported css file loads a |FONT| over http
+ *
+ * Since the top-domain is >> NOT << served using https, the MCB
+ * should >> NOT << trigger a warning.
+ */
+
+const PREF_ACTIVE = "security.mixed_content.block_active_content";
+const PREF_DISPLAY = "security.mixed_content.block_display_content";
+
+const HTTP_TEST_ROOT = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com"
+);
+
+var gTestBrowser = null;
+
+function cleanUpAfterTests() {
+ gBrowser.removeCurrentTab();
+ window.focus();
+}
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [PREF_ACTIVE, true],
+ [PREF_DISPLAY, true],
+ ],
+ });
+ let url = HTTP_TEST_ROOT + "test_no_mcb_on_http_site_img.html";
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+ gTestBrowser = tab.linkedBrowser;
+});
+
+// ------------- TEST 1 -----------------------------------------
+
+add_task(async function test1() {
+ let expected =
+ "Verifying MCB does not trigger warning/error for an http page ";
+ expected += "with https css that includes http image";
+
+ await SpecialPowers.spawn(
+ gTestBrowser,
+ [expected],
+ async function (condition) {
+ await ContentTaskUtils.waitForCondition(
+ () => content.document.getElementById("testDiv").innerHTML == condition,
+ "Waited too long for status in Test 1!"
+ );
+ }
+ );
+
+ // Explicit OKs needed because the harness requires at least one call to ok.
+ ok(true, "test 1 passed");
+
+ // set up test 2
+ let url = HTTP_TEST_ROOT + "test_no_mcb_on_http_site_font.html";
+ BrowserTestUtils.loadURIString(gTestBrowser, url);
+ await BrowserTestUtils.browserLoaded(gTestBrowser);
+});
+
+// ------------- TEST 2 -----------------------------------------
+
+add_task(async function test2() {
+ let expected =
+ "Verifying MCB does not trigger warning/error for an http page ";
+ expected += "with https css that includes http font";
+
+ await SpecialPowers.spawn(
+ gTestBrowser,
+ [expected],
+ async function (condition) {
+ await ContentTaskUtils.waitForCondition(
+ () => content.document.getElementById("testDiv").innerHTML == condition,
+ "Waited too long for status in Test 2!"
+ );
+ }
+ );
+
+ ok(true, "test 2 passed");
+
+ // set up test 3
+ let url = HTTP_TEST_ROOT + "test_no_mcb_on_http_site_font2.html";
+ BrowserTestUtils.loadURIString(gTestBrowser, url);
+ await BrowserTestUtils.browserLoaded(gTestBrowser);
+});
+
+// ------------- TEST 3 -----------------------------------------
+
+add_task(async function test3() {
+ let expected =
+ "Verifying MCB does not trigger warning/error for an http page ";
+ expected +=
+ "with https css that imports another http css which includes http font";
+
+ await SpecialPowers.spawn(
+ gTestBrowser,
+ [expected],
+ async function (condition) {
+ await ContentTaskUtils.waitForCondition(
+ () => content.document.getElementById("testDiv").innerHTML == condition,
+ "Waited too long for status in Test 3!"
+ );
+ }
+ );
+
+ ok(true, "test3 passed");
+});
+
+// ------------------------------------------------------
+
+add_task(async function cleanup() {
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+});
diff --git a/browser/base/content/test/siteIdentity/browser_secure_transport_insecure_scheme.js b/browser/base/content/test/siteIdentity/browser_secure_transport_insecure_scheme.js
new file mode 100644
index 0000000000..1d282ef6de
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/browser_secure_transport_insecure_scheme.js
@@ -0,0 +1,185 @@
+/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim: set ts=8 sts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Test that an insecure resource routed over a secure transport is considered
+// insecure in terms of the site identity panel. We achieve this by running an
+// HTTP-over-TLS "proxy" and having Firefox request an http:// URI over it.
+
+/**
+ * Tests that the page info dialog "security" section labels a
+ * connection as unencrypted and does not show certificate.
+ * @param {string} uri - URI of the page to test with.
+ */
+async function testPageInfoNotEncrypted(uri) {
+ let pageInfo = BrowserPageInfo(uri, "securityTab");
+ await BrowserTestUtils.waitForEvent(pageInfo, "load");
+ let pageInfoDoc = pageInfo.document;
+ let securityTab = pageInfoDoc.getElementById("securityTab");
+ await TestUtils.waitForCondition(
+ () => BrowserTestUtils.is_visible(securityTab),
+ "Security tab should be visible."
+ );
+
+ let secLabel = pageInfoDoc.getElementById("security-technical-shortform");
+ await TestUtils.waitForCondition(
+ () => secLabel.value == "Connection Not Encrypted",
+ "pageInfo 'Security Details' should show not encrypted"
+ );
+
+ let viewCertBtn = pageInfoDoc.getElementById("security-view-cert");
+ ok(
+ viewCertBtn.collapsed,
+ "pageInfo 'View Cert' button should not be visible"
+ );
+ pageInfo.close();
+}
+
+// But first, a quick test that we don't incorrectly treat a
+// blob:https://example.com URI as secure.
+add_task(async function () {
+ let uri =
+ getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+ ) + "dummy_page.html";
+ await BrowserTestUtils.withNewTab(uri, async browser => {
+ await SpecialPowers.spawn(browser, [], async () => {
+ let debug = { hello: "world" };
+ let blob = new Blob([JSON.stringify(debug, null, 2)], {
+ type: "application/json",
+ });
+ let blobUri = URL.createObjectURL(blob);
+ content.document.location = blobUri;
+ });
+ await BrowserTestUtils.browserLoaded(browser);
+ let identityMode = window.document.getElementById("identity-box").className;
+ is(identityMode, "localResource", "identity should be 'localResource'");
+ await testPageInfoNotEncrypted(uri);
+ });
+});
+
+// This server pretends to be a HTTP over TLS proxy. It isn't really, but this
+// is sufficient for the purposes of this test.
+function startServer(cert) {
+ let tlsServer = Cc["@mozilla.org/network/tls-server-socket;1"].createInstance(
+ Ci.nsITLSServerSocket
+ );
+ tlsServer.init(-1, true, -1);
+ tlsServer.serverCert = cert;
+
+ let input, output;
+
+ let listener = {
+ onSocketAccepted(socket, transport) {
+ let connectionInfo = transport.securityCallbacks.getInterface(
+ Ci.nsITLSServerConnectionInfo
+ );
+ connectionInfo.setSecurityObserver(listener);
+ input = transport.openInputStream(0, 0, 0);
+ output = transport.openOutputStream(0, 0, 0);
+ },
+
+ onHandshakeDone(socket, status) {
+ input.asyncWait(
+ {
+ onInputStreamReady(readyInput) {
+ try {
+ let request = NetUtil.readInputStreamToString(
+ readyInput,
+ readyInput.available()
+ );
+ ok(
+ request.startsWith("GET ") && request.includes("HTTP/1.1"),
+ "expecting an HTTP/1.1 GET request"
+ );
+ let response =
+ "HTTP/1.1 200 OK\r\nContent-Type:text/plain\r\n" +
+ "Connection:Close\r\nContent-Length:2\r\n\r\nOK";
+ output.write(response, response.length);
+ } catch (e) {
+ info(e);
+ }
+ },
+ },
+ 0,
+ 0,
+ Services.tm.currentThread
+ );
+ },
+
+ onStopListening() {
+ input.close();
+ output.close();
+ },
+ };
+
+ tlsServer.setSessionTickets(false);
+ tlsServer.asyncListen(listener);
+
+ return tlsServer;
+}
+
+add_task(async function () {
+ await SpecialPowers.pushPrefEnv({
+ // This test fails on some platforms if we leave IPv6 enabled.
+ set: [["network.dns.disableIPv6", true]],
+ });
+
+ let certOverrideService = Cc[
+ "@mozilla.org/security/certoverride;1"
+ ].getService(Ci.nsICertOverrideService);
+
+ let cert = getTestServerCertificate();
+ // Start the proxy and configure Firefox to trust its certificate.
+ let server = startServer(cert);
+ certOverrideService.rememberValidityOverride(
+ "localhost",
+ server.port,
+ {},
+ cert,
+ true
+ );
+ // Configure Firefox to use the proxy.
+ let systemProxySettings = {
+ QueryInterface: ChromeUtils.generateQI(["nsISystemProxySettings"]),
+ mainThreadOnly: true,
+ PACURI: null,
+ getProxyForURI: (aSpec, aScheme, aHost, aPort) => {
+ return `HTTPS localhost:${server.port}`;
+ },
+ };
+ let oldProxyType = Services.prefs.getIntPref("network.proxy.type");
+ Services.prefs.setIntPref(
+ "network.proxy.type",
+ Ci.nsIProtocolProxyService.PROXYCONFIG_SYSTEM
+ );
+ let { MockRegistrar } = ChromeUtils.importESModule(
+ "resource://testing-common/MockRegistrar.sys.mjs"
+ );
+ let mockProxy = MockRegistrar.register(
+ "@mozilla.org/system-proxy-settings;1",
+ systemProxySettings
+ );
+ // Register cleanup to undo the configuration changes we've made.
+ registerCleanupFunction(() => {
+ certOverrideService.clearValidityOverride("localhost", server.port, {});
+ Services.prefs.setIntPref("network.proxy.type", oldProxyType);
+ MockRegistrar.unregister(mockProxy);
+ server.close();
+ });
+
+ // Navigate to 'http://example.com'. Our proxy settings will route this via
+ // the "proxy" we just started. Even though our connection to the proxy is
+ // secure, in a real situation the connection from the proxy to
+ // http://example.com won't be secure, so we treat it as not secure.
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ await BrowserTestUtils.withNewTab("http://example.com/", async browser => {
+ let identityMode = window.document.getElementById("identity-box").className;
+ is(identityMode, "notSecure", "identity should be 'not secure'");
+
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ await testPageInfoNotEncrypted("http://example.com");
+ });
+});
diff --git a/browser/base/content/test/siteIdentity/browser_session_store_pageproxystate.js b/browser/base/content/test/siteIdentity/browser_session_store_pageproxystate.js
new file mode 100644
index 0000000000..5d8c011727
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/browser_session_store_pageproxystate.js
@@ -0,0 +1,92 @@
+/* -*- Mode: C++; tab-width: 8; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
+/* vim: set ts=8 sts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const triggeringPrincipal_base64 = E10SUtils.SERIALIZED_SYSTEMPRINCIPAL;
+
+let origBrowserState = SessionStore.getBrowserState();
+
+add_setup(async function () {
+ registerCleanupFunction(() => {
+ SessionStore.setBrowserState(origBrowserState);
+ });
+});
+
+// Test that when restoring tabs via SessionStore, we directly show the correct
+// security state.
+add_task(async function test_session_store_security_state() {
+ const state = {
+ windows: [
+ {
+ tabs: [
+ {
+ entries: [
+ { url: "https://example.net", triggeringPrincipal_base64 },
+ ],
+ },
+ {
+ entries: [
+ { url: "https://example.org", triggeringPrincipal_base64 },
+ ],
+ },
+ ],
+ selected: 1,
+ },
+ ],
+ };
+
+ // Create a promise that resolves when the tabs have finished restoring.
+ let promiseTabsRestored = Promise.all([
+ TestUtils.topicObserved("sessionstore-browser-state-restored"),
+ BrowserTestUtils.waitForEvent(gBrowser.tabContainer, "SSTabRestored"),
+ ]);
+
+ SessionStore.setBrowserState(JSON.stringify(state));
+
+ await promiseTabsRestored;
+
+ is(gBrowser.selectedTab, gBrowser.tabs[0], "First tab is selected initially");
+
+ info("Switch to second tab which has not been loaded yet.");
+ BrowserTestUtils.switchTab(gBrowser, gBrowser.tabs[1]);
+ is(
+ gURLBar.textbox.getAttribute("pageproxystate"),
+ "invalid",
+ "Page proxy state is invalid after tab switch"
+ );
+
+ // Wait for valid pageproxystate. As soon as we have a valid pageproxystate,
+ // showing the identity box, it should indicate a secure connection.
+ await BrowserTestUtils.waitForMutationCondition(
+ gURLBar.textbox,
+ {
+ attributeFilter: ["pageproxystate"],
+ },
+ () => gURLBar.textbox.getAttribute("pageproxystate") == "valid"
+ );
+
+ // Wait for a tick for security state to apply.
+ await new Promise(resolve => setTimeout(resolve, 0));
+
+ is(
+ gBrowser.currentURI.spec,
+ "https://example.org/",
+ "Should have loaded example.org"
+ );
+ is(
+ gIdentityHandler._identityBox.getAttribute("pageproxystate"),
+ "valid",
+ "identityBox pageproxystate is valid"
+ );
+
+ ok(
+ gIdentityHandler._isSecureConnection,
+ "gIdentityHandler._isSecureConnection is true"
+ );
+ is(
+ gIdentityHandler._identityBox.className,
+ "verifiedDomain",
+ "identityBox class signals secure connection."
+ );
+});
diff --git a/browser/base/content/test/siteIdentity/browser_tab_sharing_state.js b/browser/base/content/test/siteIdentity/browser_tab_sharing_state.js
new file mode 100644
index 0000000000..6c6ba57c55
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/browser_tab_sharing_state.js
@@ -0,0 +1,96 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Tests gBrowser#updateBrowserSharing
+ */
+add_task(async function testBrowserSharingStateSetter() {
+ const WEBRTC_TEST_STATE = {
+ camera: 0,
+ microphone: 1,
+ paused: false,
+ sharing: "microphone",
+ showMicrophoneIndicator: true,
+ showScreenSharingIndicator: "",
+ windowId: 0,
+ };
+
+ const WEBRTC_TEST_STATE2 = {
+ camera: 1,
+ microphone: 1,
+ paused: false,
+ sharing: "camera",
+ showCameraIndicator: true,
+ showMicrophoneIndicator: true,
+ showScreenSharingIndicator: "",
+ windowId: 1,
+ };
+
+ await BrowserTestUtils.withNewTab("https://example.com", async browser => {
+ let tab = gBrowser.selectedTab;
+ is(tab._sharingState, undefined, "No sharing state initially.");
+ ok(!tab.hasAttribute("sharing"), "No tab sharing attribute initially.");
+
+ // Set an active sharing state for webrtc
+ gBrowser.updateBrowserSharing(browser, { webRTC: WEBRTC_TEST_STATE });
+ Assert.deepEqual(
+ tab._sharingState,
+ { webRTC: WEBRTC_TEST_STATE },
+ "Should have correct webRTC sharing state."
+ );
+ is(
+ tab.getAttribute("sharing"),
+ WEBRTC_TEST_STATE.sharing,
+ "Tab sharing attribute reflects webRTC sharing state."
+ );
+
+ // Set sharing state for geolocation
+ gBrowser.updateBrowserSharing(browser, { geo: true });
+ Assert.deepEqual(
+ tab._sharingState,
+ {
+ webRTC: WEBRTC_TEST_STATE,
+ geo: true,
+ },
+ "Should have sharing state for both webRTC and geolocation."
+ );
+ is(
+ tab.getAttribute("sharing"),
+ WEBRTC_TEST_STATE.sharing,
+ "Geolocation sharing doesn't update the tab sharing attribute."
+ );
+
+ // Update webRTC sharing state
+ gBrowser.updateBrowserSharing(browser, { webRTC: WEBRTC_TEST_STATE2 });
+ Assert.deepEqual(
+ tab._sharingState,
+ { geo: true, webRTC: WEBRTC_TEST_STATE2 },
+ "Should have updated webRTC sharing state while maintaining geolocation state."
+ );
+ is(
+ tab.getAttribute("sharing"),
+ WEBRTC_TEST_STATE2.sharing,
+ "Tab sharing attribute reflects webRTC sharing state."
+ );
+
+ // Clear webRTC sharing state
+ gBrowser.updateBrowserSharing(browser, { webRTC: null });
+ Assert.deepEqual(
+ tab._sharingState,
+ { geo: true, webRTC: null },
+ "Should only have sharing state for geolocation."
+ );
+ ok(
+ !tab.hasAttribute("sharing"),
+ "Ending webRTC sharing should remove tab sharing attribute."
+ );
+
+ // Clear geolocation sharing state
+ gBrowser.updateBrowserSharing(browser, { geo: null });
+ Assert.deepEqual(tab._sharingState, { geo: null, webRTC: null });
+ ok(
+ !tab.hasAttribute("sharing"),
+ "Tab sharing attribute should not be set."
+ );
+ });
+});
diff --git a/browser/base/content/test/siteIdentity/dummy_iframe_page.html b/browser/base/content/test/siteIdentity/dummy_iframe_page.html
new file mode 100644
index 0000000000..ea80367aa5
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/dummy_iframe_page.html
@@ -0,0 +1,10 @@
+<html>
+<head>
+<title>Dummy iframe test page</title>
+<meta http-equiv="Content-Type" content="text/html;charset=utf-8"></meta>
+</head>
+<body>
+ <iframe src="https://example.org"></iframe>
+<p>Dummy test page</p>
+</body>
+</html>
diff --git a/browser/base/content/test/siteIdentity/dummy_page.html b/browser/base/content/test/siteIdentity/dummy_page.html
new file mode 100644
index 0000000000..a7747a0bca
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/dummy_page.html
@@ -0,0 +1,10 @@
+<html>
+<head>
+<title>Dummy test page</title>
+<meta http-equiv="Content-Type" content="text/html;charset=utf-8"></meta>
+</head>
+<body>
+ <a href="https://nocert.example.com" id="no-cert">No Cert page</a>
+<p>Dummy test page</p>
+</body>
+</html>
diff --git a/browser/base/content/test/siteIdentity/file_bug1045809_1.html b/browser/base/content/test/siteIdentity/file_bug1045809_1.html
new file mode 100644
index 0000000000..c4f281d670
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/file_bug1045809_1.html
@@ -0,0 +1,7 @@
+<html>
+ <head>
+ </head>
+ <body>
+ <iframe src="http://test1.example.com/browser/browser/base/content/test/siteIdentity/file_bug1045809_2.html"></iframe>
+ </body>
+</html>
diff --git a/browser/base/content/test/siteIdentity/file_bug1045809_2.html b/browser/base/content/test/siteIdentity/file_bug1045809_2.html
new file mode 100644
index 0000000000..67a297dbc5
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/file_bug1045809_2.html
@@ -0,0 +1,7 @@
+<html>
+ <head>
+ </head>
+ <body>
+ <div id="mixedContentContainer">Mixed Content is here</div>
+ </body>
+</html>
diff --git a/browser/base/content/test/siteIdentity/file_bug822367_1.html b/browser/base/content/test/siteIdentity/file_bug822367_1.html
new file mode 100644
index 0000000000..a6e3fafc23
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/file_bug822367_1.html
@@ -0,0 +1,18 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test 1 for Mixed Content Blocker User Override - Mixed Script
+https://bugzilla.mozilla.org/show_bug.cgi?id=822367
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test 1 for Bug 822367</title>
+</head>
+<body>
+ <div id="testContent">
+ <p id="p1"></p>
+ </div>
+ <script src="http://example.com/browser/browser/base/content/test/siteIdentity/file_bug822367_1.js">
+ </script>
+</body>
+</html>
diff --git a/browser/base/content/test/siteIdentity/file_bug822367_1.js b/browser/base/content/test/siteIdentity/file_bug822367_1.js
new file mode 100644
index 0000000000..e4b5fb86c6
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/file_bug822367_1.js
@@ -0,0 +1 @@
+document.getElementById("p1").innerHTML = "hello";
diff --git a/browser/base/content/test/siteIdentity/file_bug822367_2.html b/browser/base/content/test/siteIdentity/file_bug822367_2.html
new file mode 100644
index 0000000000..fe56ee2130
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/file_bug822367_2.html
@@ -0,0 +1,16 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test 2 for Mixed Content Blocker User Override - Mixed Display
+https://bugzilla.mozilla.org/show_bug.cgi?id=822367
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test 2 for Bug 822367 - Mixed Display</title>
+</head>
+<body>
+ <div id="testContent">
+ <img src="http://example.com/tests/image/test/mochitest/blue.png">
+ </div>
+</body>
+</html>
diff --git a/browser/base/content/test/siteIdentity/file_bug822367_3.html b/browser/base/content/test/siteIdentity/file_bug822367_3.html
new file mode 100644
index 0000000000..0cf5db7b20
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/file_bug822367_3.html
@@ -0,0 +1,27 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test 3 for Mixed Content Blocker User Override - Mixed Script and Display
+https://bugzilla.mozilla.org/show_bug.cgi?id=822367
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test 3 for Bug 822367</title>
+ <script>
+ function foo() {
+ var x = document.createElement("p");
+ x.setAttribute("id", "p2");
+ x.innerHTML = "bye";
+ document.getElementById("testContent").appendChild(x);
+ }
+ </script>
+</head>
+<body>
+ <div id="testContent">
+ <p id="p1"></p>
+ <img src="http://example.com/tests/image/test/mochitest/blue.png" onload="foo()">
+ </div>
+ <script src="http://example.com/browser/browser/base/content/test/siteIdentity/file_bug822367_1.js">
+ </script>
+</body>
+</html>
diff --git a/browser/base/content/test/siteIdentity/file_bug822367_4.html b/browser/base/content/test/siteIdentity/file_bug822367_4.html
new file mode 100644
index 0000000000..8e5aeb67f2
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/file_bug822367_4.html
@@ -0,0 +1,18 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test 4 for Mixed Content Blocker User Override - Mixed Script and Display
+https://bugzilla.mozilla.org/show_bug.cgi?id=822367
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test 4 for Bug 822367</title>
+</head>
+<body>
+ <div id="testContent">
+ <p id="p1"></p>
+ </div>
+ <script src="http://example.com/browser/browser/base/content/test/siteIdentity/file_bug822367_4.js">
+ </script>
+</body>
+</html>
diff --git a/browser/base/content/test/siteIdentity/file_bug822367_4.js b/browser/base/content/test/siteIdentity/file_bug822367_4.js
new file mode 100644
index 0000000000..8bdc791180
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/file_bug822367_4.js
@@ -0,0 +1,2 @@
+document.location =
+ "https://example.com/browser/browser/base/content/test/siteIdentity/file_bug822367_4B.html";
diff --git a/browser/base/content/test/siteIdentity/file_bug822367_4B.html b/browser/base/content/test/siteIdentity/file_bug822367_4B.html
new file mode 100644
index 0000000000..9af942525f
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/file_bug822367_4B.html
@@ -0,0 +1,18 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test 4B for Mixed Content Blocker User Override - Location Changed
+https://bugzilla.mozilla.org/show_bug.cgi?id=822367
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test 4B Location Change for Bug 822367</title>
+</head>
+<body>
+ <div id="testContent">
+ <p id="p1"></p>
+ </div>
+ <script src="http://example.com/browser/browser/base/content/test/siteIdentity/file_bug822367_1.js">
+ </script>
+</body>
+</html>
diff --git a/browser/base/content/test/siteIdentity/file_bug822367_5.html b/browser/base/content/test/siteIdentity/file_bug822367_5.html
new file mode 100644
index 0000000000..6341539e83
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/file_bug822367_5.html
@@ -0,0 +1,23 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test 5 for Mixed Content Blocker User Override - Mixed Script in document.open()
+https://bugzilla.mozilla.org/show_bug.cgi?id=822367
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test 5 for Bug 822367</title>
+ <script>
+ function createDoc() {
+ var doc = document.open("text/html", "replace");
+ doc.write('<!DOCTYPE html><html><body><p id="p1">This is some content</p><script src="http://example.com/browser/browser/base/content/test/siteIdentity/file_bug822367_1.js">\<\/script\>\<\/body>\<\/html>');
+ doc.close();
+ }
+ </script>
+</head>
+<body>
+ <div id="testContent">
+ <img src="https://example.com/tests/image/test/mochitest/blue.png" onload="createDoc()">
+ </div>
+</body>
+</html>
diff --git a/browser/base/content/test/siteIdentity/file_bug822367_6.html b/browser/base/content/test/siteIdentity/file_bug822367_6.html
new file mode 100644
index 0000000000..2c071a785d
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/file_bug822367_6.html
@@ -0,0 +1,16 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test 6 for Mixed Content Blocker User Override - Mixed Script in document.open() within an iframe
+https://bugzilla.mozilla.org/show_bug.cgi?id=822367
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test 6 for Bug 822367</title>
+</head>
+<body>
+ <div id="testContent">
+ <iframe name="f1" id="f1" src="https://example.com/browser/browser/base/content/test/siteIdentity/file_bug822367_5.html"></iframe>
+ </div>
+</body>
+</html>
diff --git a/browser/base/content/test/siteIdentity/file_bug902156.js b/browser/base/content/test/siteIdentity/file_bug902156.js
new file mode 100644
index 0000000000..01ef4073fb
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/file_bug902156.js
@@ -0,0 +1,6 @@
+/*
+ * Once the mixed content blocker is disabled for the page, this scripts loads
+ * and updates the text inside the div container.
+ */
+document.getElementById("mctestdiv").innerHTML =
+ "Mixed Content Blocker disabled";
diff --git a/browser/base/content/test/siteIdentity/file_bug902156_1.html b/browser/base/content/test/siteIdentity/file_bug902156_1.html
new file mode 100644
index 0000000000..4cac7cfb93
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/file_bug902156_1.html
@@ -0,0 +1,15 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+ Test 1 for Bug 902156 - See file browser_bug902156.js for description.
+ https://bugzilla.mozilla.org/show_bug.cgi?id=902156
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test 1 for Bug 902156</title>
+</head>
+<body>
+ <div id="mctestdiv">Mixed Content Blocker enabled</div>
+ <script src="http://test1.example.com/browser/browser/base/content/test/siteIdentity/file_bug902156.js" ></script>
+</body>
+</html>
diff --git a/browser/base/content/test/siteIdentity/file_bug902156_2.html b/browser/base/content/test/siteIdentity/file_bug902156_2.html
new file mode 100644
index 0000000000..c815a09a93
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/file_bug902156_2.html
@@ -0,0 +1,17 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+ Test 2 for Bug 902156 - See file browser_bug902156.js for description.
+ https://bugzilla.mozilla.org/show_bug.cgi?id=902156
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test 2 for Bug 902156</title>
+</head>
+<body>
+ <div id="mctestdiv">Mixed Content Blocker enabled</div>
+ <a href="https://test2.example.com/browser/browser/base/content/test/siteIdentity/file_bug902156_1.html"
+ id="mctestlink" target="_top">Go to http site</a>
+ <script src="http://test2.example.com/browser/browser/base/content/test/siteIdentity/file_bug902156.js" ></script>
+</body>
+</html>
diff --git a/browser/base/content/test/siteIdentity/file_bug902156_3.html b/browser/base/content/test/siteIdentity/file_bug902156_3.html
new file mode 100644
index 0000000000..7a26f4b0f0
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/file_bug902156_3.html
@@ -0,0 +1,15 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+ Test 3 for Bug 902156 - See file browser_bug902156.js for description.
+ https://bugzilla.mozilla.org/show_bug.cgi?id=902156
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test 3 for Bug 902156</title>
+</head>
+<body>
+ <div id="mctestdiv">Mixed Content Blocker enabled</div>
+ <script src="http://test1.example.com/browser/browser/base/content/test/siteIdentity/file_bug902156.js" ></script>
+</body>
+</html>
diff --git a/browser/base/content/test/siteIdentity/file_bug906190.js b/browser/base/content/test/siteIdentity/file_bug906190.js
new file mode 100644
index 0000000000..01ef4073fb
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/file_bug906190.js
@@ -0,0 +1,6 @@
+/*
+ * Once the mixed content blocker is disabled for the page, this scripts loads
+ * and updates the text inside the div container.
+ */
+document.getElementById("mctestdiv").innerHTML =
+ "Mixed Content Blocker disabled";
diff --git a/browser/base/content/test/siteIdentity/file_bug906190.sjs b/browser/base/content/test/siteIdentity/file_bug906190.sjs
new file mode 100644
index 0000000000..088153d671
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/file_bug906190.sjs
@@ -0,0 +1,18 @@
+function handleRequest(request, response) {
+ var page = "<!DOCTYPE html><html><body>bug 906190</body></html>";
+ var path =
+ "https://test1.example.com/browser/browser/base/content/test/siteIdentity/";
+ var url;
+
+ if (request.queryString.includes("bad-redirection=1")) {
+ url = path + "this_page_does_not_exist.html";
+ } else {
+ url = path + "file_bug906190_redirected.html";
+ }
+
+ response.setHeader("Cache-Control", "no-cache", false);
+ response.setHeader("Content-Type", "text/html", false);
+ response.setStatusLine(request.httpVersion, "302", "Found");
+ response.setHeader("Location", url, false);
+ response.write(page);
+}
diff --git a/browser/base/content/test/siteIdentity/file_bug906190_1.html b/browser/base/content/test/siteIdentity/file_bug906190_1.html
new file mode 100644
index 0000000000..031c229f0d
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/file_bug906190_1.html
@@ -0,0 +1,15 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+ Test 1 for Bug 906190 - See file browser_bug902156.js for description.
+ https://bugzilla.mozilla.org/show_bug.cgi?id=906190
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test 1 for Bug 906190</title>
+</head>
+<body>
+ <div id="mctestdiv">Mixed Content Blocker enabled</div>
+ <script src="http://test1.example.com/browser/browser/base/content/test/siteIdentity/file_bug906190.js" ></script>
+</body>
+</html>
diff --git a/browser/base/content/test/siteIdentity/file_bug906190_2.html b/browser/base/content/test/siteIdentity/file_bug906190_2.html
new file mode 100644
index 0000000000..2a7546dca4
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/file_bug906190_2.html
@@ -0,0 +1,15 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+ Test 2 for Bug 906190 - See file browser_bug902156.js for description.
+ https://bugzilla.mozilla.org/show_bug.cgi?id=906190
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test 2 for Bug 906190</title>
+</head>
+<body>
+ <div id="mctestdiv">Mixed Content Blocker enabled</div>
+ <script src="http://test2.example.com/browser/browser/base/content/test/siteIdentity/file_bug906190.js" ></script>
+</body>
+</html>
diff --git a/browser/base/content/test/siteIdentity/file_bug906190_3_4.html b/browser/base/content/test/siteIdentity/file_bug906190_3_4.html
new file mode 100644
index 0000000000..e78e271f85
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/file_bug906190_3_4.html
@@ -0,0 +1,14 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+ Test 3 and 4 for Bug 906190 - See file browser_bug902156.js for description.
+ https://bugzilla.mozilla.org/show_bug.cgi?id=906190
+-->
+<head>
+ <meta charset="utf-8">
+ <meta http-equiv="refresh" content="0; url=https://test1.example.com/browser/browser/base/content/test/siteIdentity/file_bug906190_redirected.html">
+ <title>Test 3 and 4 for Bug 906190</title>
+</head>
+<body>
+</body>
+</html>
diff --git a/browser/base/content/test/siteIdentity/file_bug906190_redirected.html b/browser/base/content/test/siteIdentity/file_bug906190_redirected.html
new file mode 100644
index 0000000000..d0bc4a39f5
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/file_bug906190_redirected.html
@@ -0,0 +1,15 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+ Redirected Page of Test 3 to 6 for Bug 906190 - See file browser_bug902156.js for description.
+ https://bugzilla.mozilla.org/show_bug.cgi?id=906190
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Redirected Page for Bug 906190</title>
+</head>
+<body>
+ <div id="mctestdiv">Mixed Content Blocker enabled</div>
+ <script src="http://test1.example.com/browser/browser/base/content/test/siteIdentity/file_bug906190.js" ></script>
+</body>
+</html>
diff --git a/browser/base/content/test/siteIdentity/file_csp_block_all_mixedcontent.html b/browser/base/content/test/siteIdentity/file_csp_block_all_mixedcontent.html
new file mode 100644
index 0000000000..b5463d8d5b
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/file_csp_block_all_mixedcontent.html
@@ -0,0 +1,11 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Bug 1122236 - CSP: Implement block-all-mixed-content</title>
+ <meta http-equiv="Content-Security-Policy" content="block-all-mixed-content">
+</head>
+<body>
+ <script src="http://example.com/browser/browser/base/content/test/siteIdentity/file_csp_block_all_mixedcontent.js"></script>
+</body>
+</html>
diff --git a/browser/base/content/test/siteIdentity/file_csp_block_all_mixedcontent.js b/browser/base/content/test/siteIdentity/file_csp_block_all_mixedcontent.js
new file mode 100644
index 0000000000..dc6d6a64e4
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/file_csp_block_all_mixedcontent.js
@@ -0,0 +1,3 @@
+// empty script file just used for testing Bug 1122236.
+// Making sure the UI is not degraded when blocking
+// mixed content using the CSP directive: block-all-mixed-content.
diff --git a/browser/base/content/test/siteIdentity/file_mixedContentFramesOnHttp.html b/browser/base/content/test/siteIdentity/file_mixedContentFramesOnHttp.html
new file mode 100644
index 0000000000..3ed5b82641
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/file_mixedContentFramesOnHttp.html
@@ -0,0 +1,14 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test for https://bugzilla.mozilla.org/show_bug.cgi?id=1182551
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test for Bug 1182551</title>
+</head>
+<body>
+ <p>Test for Bug 1182551. This is an HTTP top level page. We include an HTTPS iframe that loads mixed passive content.</p>
+ <iframe src="https://example.org/browser/browser/base/content/test/siteIdentity/file_mixedPassiveContent.html"></iframe>
+</body>
+</html>
diff --git a/browser/base/content/test/siteIdentity/file_mixedContentFromOnunload.html b/browser/base/content/test/siteIdentity/file_mixedContentFromOnunload.html
new file mode 100644
index 0000000000..ae134f8cb0
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/file_mixedContentFromOnunload.html
@@ -0,0 +1,18 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test for https://bugzilla.mozilla.org/show_bug.cgi?id=947079
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test for Bug 947079</title>
+</head>
+<body>
+ <p>Test for Bug 947079</p>
+ <script>
+ window.addEventListener("unload", function() {
+ new Image().src = "http://mochi.test:8888/tests/image/test/mochitest/blue.png";
+ });
+ </script>
+</body>
+</html>
diff --git a/browser/base/content/test/siteIdentity/file_mixedContentFromOnunload_test1.html b/browser/base/content/test/siteIdentity/file_mixedContentFromOnunload_test1.html
new file mode 100644
index 0000000000..1d027b0362
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/file_mixedContentFromOnunload_test1.html
@@ -0,0 +1,14 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test 1 for https://bugzilla.mozilla.org/show_bug.cgi?id=947079
+Page with no insecure subresources
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test 1 for Bug 947079</title>
+</head>
+<body>
+ <p>There are no insecure resource loads on this page</p>
+</body>
+</html>
diff --git a/browser/base/content/test/siteIdentity/file_mixedContentFromOnunload_test2.html b/browser/base/content/test/siteIdentity/file_mixedContentFromOnunload_test2.html
new file mode 100644
index 0000000000..4813337cc8
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/file_mixedContentFromOnunload_test2.html
@@ -0,0 +1,15 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test 2 for https://bugzilla.mozilla.org/show_bug.cgi?id=947079
+Page with an insecure image load
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test 2 for Bug 947079</title>
+</head>
+<body>
+ <p>Page with http image load</p>
+ <img src="http://test2.example.com/tests/image/test/mochitest/blue.png">
+</body>
+</html>
diff --git a/browser/base/content/test/siteIdentity/file_mixedPassiveContent.html b/browser/base/content/test/siteIdentity/file_mixedPassiveContent.html
new file mode 100644
index 0000000000..a60ac94e8b
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/file_mixedPassiveContent.html
@@ -0,0 +1,13 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+Test for https://bugzilla.mozilla.org/show_bug.cgi?id=1182551
+-->
+<head>
+ <meta charset="utf-8">
+ <title>HTTPS page with HTTP image</title>
+</head>
+<body>
+ <img src="http://mochi.test:8888/tests/image/test/mochitest/blue.png">
+</body>
+</html>
diff --git a/browser/base/content/test/siteIdentity/file_pdf.pdf b/browser/base/content/test/siteIdentity/file_pdf.pdf
new file mode 100644
index 0000000000..593558f9a4
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/file_pdf.pdf
@@ -0,0 +1,12 @@
+%PDF-1.0
+1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj 2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj 3 0 obj<</Type/Page/MediaBox[0 0 3 3]>>endobj
+xref
+0 4
+0000000000 65535 f
+0000000010 00000 n
+0000000053 00000 n
+0000000102 00000 n
+trailer<</Size 4/Root 1 0 R>>
+startxref
+149
+%EOF \ No newline at end of file
diff --git a/browser/base/content/test/siteIdentity/file_pdf_blob.html b/browser/base/content/test/siteIdentity/file_pdf_blob.html
new file mode 100644
index 0000000000..ff6ed659a2
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/file_pdf_blob.html
@@ -0,0 +1,18 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset='utf-8'>
+</head>
+<body>
+ <script>
+ let blob = new Blob(["x"], { type: "application/pdf" });
+ let blobURL = URL.createObjectURL(blob);
+
+ let link = document.createElement("a");
+ link.innerText = "PDF blob";
+ link.target = "_blank";
+ link.href = blobURL;
+ document.body.appendChild(link);
+ </script>
+</body>
+</html>
diff --git a/browser/base/content/test/siteIdentity/head.js b/browser/base/content/test/siteIdentity/head.js
new file mode 100644
index 0000000000..d2a588a815
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/head.js
@@ -0,0 +1,435 @@
+function openIdentityPopup() {
+ gIdentityHandler._initializePopup();
+ let mainView = document.getElementById("identity-popup-mainView");
+ let viewShown = BrowserTestUtils.waitForEvent(mainView, "ViewShown");
+ gIdentityHandler._identityIconBox.click();
+ return viewShown;
+}
+
+function openPermissionPopup() {
+ gPermissionPanel._initializePopup();
+ let mainView = document.getElementById("permission-popup-mainView");
+ let viewShown = BrowserTestUtils.waitForEvent(mainView, "ViewShown");
+ gPermissionPanel.openPopup();
+ return viewShown;
+}
+
+function getIdentityMode(aWindow = window) {
+ return aWindow.document.getElementById("identity-box").className;
+}
+
+/**
+ * Waits for a load (or custom) event to finish in a given tab. If provided
+ * load an uri into the tab.
+ *
+ * @param tab
+ * The tab to load into.
+ * @param [optional] url
+ * The url to load, or the current url.
+ * @return {Promise} resolved when the event is handled.
+ * @resolves to the received event
+ * @rejects if a valid load event is not received within a meaningful interval
+ */
+function promiseTabLoadEvent(tab, url) {
+ info("Wait tab event: load");
+
+ function handle(loadedUrl) {
+ if (loadedUrl === "about:blank" || (url && loadedUrl !== url)) {
+ info(`Skipping spurious load event for ${loadedUrl}`);
+ return false;
+ }
+
+ info("Tab event received: load");
+ return true;
+ }
+
+ let loaded = BrowserTestUtils.browserLoaded(tab.linkedBrowser, false, handle);
+
+ if (url) {
+ BrowserTestUtils.loadURIString(tab.linkedBrowser, url);
+ }
+
+ return loaded;
+}
+
+// Compares the security state of the page with what is expected
+function isSecurityState(browser, expectedState) {
+ let ui = browser.securityUI;
+ if (!ui) {
+ ok(false, "No security UI to get the security state");
+ return;
+ }
+
+ const wpl = Ci.nsIWebProgressListener;
+
+ // determine the security state
+ let isSecure = ui.state & wpl.STATE_IS_SECURE;
+ let isBroken = ui.state & wpl.STATE_IS_BROKEN;
+ let isInsecure = ui.state & wpl.STATE_IS_INSECURE;
+
+ let actualState;
+ if (isSecure && !(isBroken || isInsecure)) {
+ actualState = "secure";
+ } else if (isBroken && !(isSecure || isInsecure)) {
+ actualState = "broken";
+ } else if (isInsecure && !(isSecure || isBroken)) {
+ actualState = "insecure";
+ } else {
+ actualState = "unknown";
+ }
+
+ is(
+ expectedState,
+ actualState,
+ "Expected state " +
+ expectedState +
+ " and the actual state is " +
+ actualState +
+ "."
+ );
+}
+
+/**
+ * Test the state of the identity box and control center to make
+ * sure they are correctly showing the expected mixed content states.
+ *
+ * @note The checks are done synchronously, but new code should wait on the
+ * returned Promise object to ensure the identity panel has closed.
+ * Bug 1221114 is filed to fix the existing code.
+ *
+ * @param tabbrowser
+ * @param Object states
+ * MUST include the following properties:
+ * {
+ * activeLoaded: true|false,
+ * activeBlocked: true|false,
+ * passiveLoaded: true|false,
+ * }
+ *
+ * @return {Promise}
+ * @resolves When the operation has finished and the identity panel has closed.
+ */
+async function assertMixedContentBlockingState(tabbrowser, states = {}) {
+ if (
+ !tabbrowser ||
+ !("activeLoaded" in states) ||
+ !("activeBlocked" in states) ||
+ !("passiveLoaded" in states)
+ ) {
+ throw new Error(
+ "assertMixedContentBlockingState requires a browser and a states object"
+ );
+ }
+
+ let { passiveLoaded, activeLoaded, activeBlocked } = states;
+ let { gIdentityHandler } = tabbrowser.ownerGlobal;
+ let doc = tabbrowser.ownerDocument;
+ let identityBox = gIdentityHandler._identityBox;
+ let classList = identityBox.classList;
+ let identityIcon = doc.getElementById("identity-icon");
+ let identityIconImage = tabbrowser.ownerGlobal
+ .getComputedStyle(identityIcon)
+ .getPropertyValue("list-style-image");
+
+ let stateSecure =
+ gIdentityHandler._state & Ci.nsIWebProgressListener.STATE_IS_SECURE;
+ let stateBroken =
+ gIdentityHandler._state & Ci.nsIWebProgressListener.STATE_IS_BROKEN;
+ let stateInsecure =
+ gIdentityHandler._state & Ci.nsIWebProgressListener.STATE_IS_INSECURE;
+ let stateActiveBlocked =
+ gIdentityHandler._state &
+ Ci.nsIWebProgressListener.STATE_BLOCKED_MIXED_ACTIVE_CONTENT;
+ let stateActiveLoaded =
+ gIdentityHandler._state &
+ Ci.nsIWebProgressListener.STATE_LOADED_MIXED_ACTIVE_CONTENT;
+ let statePassiveLoaded =
+ gIdentityHandler._state &
+ Ci.nsIWebProgressListener.STATE_LOADED_MIXED_DISPLAY_CONTENT;
+
+ is(
+ activeBlocked,
+ !!stateActiveBlocked,
+ "Expected state for activeBlocked matches UI state"
+ );
+ is(
+ activeLoaded,
+ !!stateActiveLoaded,
+ "Expected state for activeLoaded matches UI state"
+ );
+ is(
+ passiveLoaded,
+ !!statePassiveLoaded,
+ "Expected state for passiveLoaded matches UI state"
+ );
+
+ if (stateInsecure) {
+ const insecureConnectionIcon = Services.prefs.getBoolPref(
+ "security.insecure_connection_icon.enabled"
+ );
+ if (!insecureConnectionIcon) {
+ // HTTP request, there should be no MCB classes for the identity box and the non secure icon
+ // should always be visible regardless of MCB state.
+ ok(classList.contains("unknownIdentity"), "unknownIdentity on HTTP page");
+ ok(
+ BrowserTestUtils.is_visible(identityIcon),
+ "information icon should be still visible"
+ );
+ } else {
+ // HTTP request, there should be a broken padlock shown always.
+ ok(classList.contains("notSecure"), "notSecure on HTTP page");
+ ok(
+ !BrowserTestUtils.is_hidden(identityIcon),
+ "information icon should be visible"
+ );
+ }
+
+ ok(!classList.contains("mixedActiveContent"), "No MCB icon on HTTP page");
+ ok(!classList.contains("mixedActiveBlocked"), "No MCB icon on HTTP page");
+ ok(!classList.contains("mixedDisplayContent"), "No MCB icon on HTTP page");
+ ok(
+ !classList.contains("mixedDisplayContentLoadedActiveBlocked"),
+ "No MCB icon on HTTP page"
+ );
+ } else {
+ // Make sure the identity box UI has the correct mixedcontent states and icons
+ is(
+ classList.contains("mixedActiveContent"),
+ activeLoaded,
+ "identityBox has expected class for activeLoaded"
+ );
+ is(
+ classList.contains("mixedActiveBlocked"),
+ activeBlocked && !passiveLoaded,
+ "identityBox has expected class for activeBlocked && !passiveLoaded"
+ );
+ is(
+ classList.contains("mixedDisplayContent"),
+ passiveLoaded && !(activeLoaded || activeBlocked),
+ "identityBox has expected class for passiveLoaded && !(activeLoaded || activeBlocked)"
+ );
+ is(
+ classList.contains("mixedDisplayContentLoadedActiveBlocked"),
+ passiveLoaded && activeBlocked,
+ "identityBox has expected class for passiveLoaded && activeBlocked"
+ );
+
+ ok(
+ !BrowserTestUtils.is_hidden(identityIcon),
+ "information icon should be visible"
+ );
+ if (activeLoaded) {
+ is(
+ identityIconImage,
+ 'url("chrome://global/skin/icons/security-broken.svg")',
+ "Using active loaded icon"
+ );
+ }
+ if (activeBlocked && !passiveLoaded) {
+ is(
+ identityIconImage,
+ 'url("chrome://global/skin/icons/security.svg")',
+ "Using active blocked icon"
+ );
+ }
+ if (passiveLoaded && !(activeLoaded || activeBlocked)) {
+ is(
+ identityIconImage,
+ 'url("chrome://global/skin/icons/security-warning.svg")',
+ "Using passive loaded icon"
+ );
+ }
+ if (passiveLoaded && activeBlocked) {
+ is(
+ identityIconImage,
+ 'url("chrome://global/skin/icons/security-warning.svg")',
+ "Using active blocked and passive loaded icon"
+ );
+ }
+ }
+
+ // Make sure the identity popup has the correct mixedcontent states
+ let promisePanelOpen = BrowserTestUtils.waitForEvent(
+ tabbrowser.ownerGlobal,
+ "popupshown",
+ true,
+ event => event.target == gIdentityHandler._identityPopup
+ );
+ gIdentityHandler._identityIconBox.click();
+ await promisePanelOpen;
+ let popupAttr = doc
+ .getElementById("identity-popup")
+ .getAttribute("mixedcontent");
+ let bodyAttr = doc
+ .getElementById("identity-popup-securityView-extended-info")
+ .getAttribute("mixedcontent");
+
+ is(
+ popupAttr.includes("active-loaded"),
+ activeLoaded,
+ "identity-popup has expected attr for activeLoaded"
+ );
+ is(
+ bodyAttr.includes("active-loaded"),
+ activeLoaded,
+ "securityView-body has expected attr for activeLoaded"
+ );
+
+ is(
+ popupAttr.includes("active-blocked"),
+ activeBlocked,
+ "identity-popup has expected attr for activeBlocked"
+ );
+ is(
+ bodyAttr.includes("active-blocked"),
+ activeBlocked,
+ "securityView-body has expected attr for activeBlocked"
+ );
+
+ is(
+ popupAttr.includes("passive-loaded"),
+ passiveLoaded,
+ "identity-popup has expected attr for passiveLoaded"
+ );
+ is(
+ bodyAttr.includes("passive-loaded"),
+ passiveLoaded,
+ "securityView-body has expected attr for passiveLoaded"
+ );
+
+ // Make sure the correct icon is visible in the Control Center.
+ // This logic is controlled with CSS, so this helps prevent regressions there.
+ let securityViewBG = tabbrowser.ownerGlobal
+ .getComputedStyle(
+ document
+ .getElementById("identity-popup-securityView")
+ .getElementsByClassName("identity-popup-security-connection")[0]
+ )
+ .getPropertyValue("list-style-image");
+ let securityContentBG = tabbrowser.ownerGlobal
+ .getComputedStyle(
+ document
+ .getElementById("identity-popup-mainView")
+ .getElementsByClassName("identity-popup-security-connection")[0]
+ )
+ .getPropertyValue("list-style-image");
+
+ if (stateInsecure) {
+ is(
+ securityViewBG,
+ 'url("chrome://global/skin/icons/security-broken.svg")',
+ "CC using 'not secure' icon"
+ );
+ is(
+ securityContentBG,
+ 'url("chrome://global/skin/icons/security-broken.svg")',
+ "CC using 'not secure' icon"
+ );
+ }
+
+ if (stateSecure) {
+ is(
+ securityViewBG,
+ 'url("chrome://global/skin/icons/security.svg")',
+ "CC using secure icon"
+ );
+ is(
+ securityContentBG,
+ 'url("chrome://global/skin/icons/security.svg")',
+ "CC using secure icon"
+ );
+ }
+
+ if (stateBroken) {
+ if (activeLoaded) {
+ is(
+ securityViewBG,
+ 'url("chrome://browser/skin/controlcenter/mcb-disabled.svg")',
+ "CC using active loaded icon"
+ );
+ is(
+ securityContentBG,
+ 'url("chrome://browser/skin/controlcenter/mcb-disabled.svg")',
+ "CC using active loaded icon"
+ );
+ } else if (activeBlocked || passiveLoaded) {
+ is(
+ securityViewBG,
+ 'url("chrome://global/skin/icons/security-warning.svg")',
+ "CC using degraded icon"
+ );
+ is(
+ securityContentBG,
+ 'url("chrome://global/skin/icons/security-warning.svg")',
+ "CC using degraded icon"
+ );
+ } else {
+ // There is a case here with weak ciphers, but no bc tests are handling this yet.
+ is(
+ securityViewBG,
+ 'url("chrome://global/skin/icons/security.svg")',
+ "CC using degraded icon"
+ );
+ is(
+ securityContentBG,
+ 'url("chrome://global/skin/icons/security.svg")',
+ "CC using degraded icon"
+ );
+ }
+ }
+
+ if (activeLoaded || activeBlocked || passiveLoaded) {
+ let promiseViewShown = BrowserTestUtils.waitForEvent(
+ gIdentityHandler._identityPopup,
+ "ViewShown"
+ );
+ doc.getElementById("identity-popup-security-button").click();
+ await promiseViewShown;
+ is(
+ Array.prototype.filter.call(
+ doc
+ .getElementById("identity-popup-securityView")
+ .querySelectorAll(".identity-popup-mcb-learn-more"),
+ element => !BrowserTestUtils.is_hidden(element)
+ ).length,
+ 1,
+ "The 'Learn more' link should be visible once."
+ );
+ }
+
+ if (gIdentityHandler._identityPopup.state != "closed") {
+ let hideEvent = BrowserTestUtils.waitForEvent(
+ gIdentityHandler._identityPopup,
+ "popuphidden"
+ );
+ info("Hiding identity popup");
+ gIdentityHandler._identityPopup.hidePopup();
+ await hideEvent;
+ }
+}
+
+async function loadBadCertPage(url) {
+ let loaded = BrowserTestUtils.waitForErrorPage(gBrowser.selectedBrowser);
+ BrowserTestUtils.loadURIString(gBrowser.selectedBrowser, url);
+ await loaded;
+
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], async function () {
+ content.document.getElementById("exceptionDialogButton").click();
+ });
+ await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+}
+
+// nsITLSServerSocket needs a certificate with a corresponding private key
+// available. In mochitests, the certificate with the common name "Mochitest
+// client" has such a key.
+function getTestServerCertificate() {
+ const certDB = Cc["@mozilla.org/security/x509certdb;1"].getService(
+ Ci.nsIX509CertDB
+ );
+ for (const cert of certDB.getCerts()) {
+ if (cert.commonName == "Mochitest client") {
+ return cert;
+ }
+ }
+ return null;
+}
diff --git a/browser/base/content/test/siteIdentity/iframe_navigation.html b/browser/base/content/test/siteIdentity/iframe_navigation.html
new file mode 100644
index 0000000000..d4564569e7
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/iframe_navigation.html
@@ -0,0 +1,44 @@
+<!DOCTYPE html>
+<!-- Any copyright is dedicated to the Public Domain.
+ - http://creativecommons.org/publicdomain/zero/1.0/ -->
+<html>
+<head>
+<meta charset="UTF-8">
+</head>
+<body class="running">
+ <script>
+ window.addEventListener("message", doNavigation);
+
+ function doNavigation() {
+ let destination;
+ let destinationIdentifier = window.location.hash.substring(1);
+ switch (destinationIdentifier) {
+ case "blank":
+ destination = "about:blank";
+ break;
+ case "secure":
+ destination =
+ "https://example.com/browser/browser/base/content/test/siteIdentity/dummy_page.html";
+ break;
+ case "insecure":
+ destination =
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com/browser/browser/base/content/test/siteIdentity/dummy_page.html";
+ break;
+ }
+ setTimeout(() => {
+ let frame = document.getElementById("navigateMe");
+ frame.onload = done;
+ frame.onerror = done;
+ frame.src = destination;
+ }, 0);
+ }
+
+ function done() {
+ document.body.classList.toggle("running");
+ }
+ </script>
+ <iframe id="navigateMe" src="dummy_page.html">
+ </iframe>
+</body>
+</html>
diff --git a/browser/base/content/test/siteIdentity/insecure_opener.html b/browser/base/content/test/siteIdentity/insecure_opener.html
new file mode 100644
index 0000000000..26ed014f63
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/insecure_opener.html
@@ -0,0 +1,9 @@
+<!DOCTYPE HTML>
+<html dir="ltr" xml:lang="en-US" lang="en-US">
+ <head>
+ <meta charset="utf8">
+ </head>
+ <body>
+ <a id="link" target="_blank" href="https://example.com/browser/toolkit/components/passwordmgr/test/browser/form_basic.html">Click me, I'm "secure".</a>
+ </body>
+</html>
diff --git a/browser/base/content/test/siteIdentity/open-self-from-frame.html b/browser/base/content/test/siteIdentity/open-self-from-frame.html
new file mode 100644
index 0000000000..17d0cf56ef
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/open-self-from-frame.html
@@ -0,0 +1,6 @@
+<iframe src="about:blank"></iframe>
+<script>
+ document.querySelector("iframe").contentDocument.write(
+ `<button onclick="window.open().document.write('Hi')">click me!</button>`
+ );
+</script>
diff --git a/browser/base/content/test/siteIdentity/simple_mixed_passive.html b/browser/base/content/test/siteIdentity/simple_mixed_passive.html
new file mode 100644
index 0000000000..2e4cda790a
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/simple_mixed_passive.html
@@ -0,0 +1 @@
+<img src="http://example.com/browser/browser/base/content/test/siteIdentity/moz.png">
diff --git a/browser/base/content/test/siteIdentity/test-mixedcontent-securityerrors.html b/browser/base/content/test/siteIdentity/test-mixedcontent-securityerrors.html
new file mode 100644
index 0000000000..cb8cfdaaf5
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/test-mixedcontent-securityerrors.html
@@ -0,0 +1,21 @@
+<!--
+ Bug 875456 - Log mixed content messages from the Mixed Content Blocker to the
+ Security Pane in the Web Console
+-->
+
+<!DOCTYPE HTML>
+<html dir="ltr" xml:lang="en-US" lang="en-US">
+ <head>
+ <meta charset="utf8">
+ <title>Mixed Content test - http on https</title>
+ <script src="testscript.js"></script>
+ <!--
+ Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/
+ -->
+ </head>
+ <body>
+ <iframe src="http://example.com"></iframe>
+ <img src="http://example.com/tests/image/test/mochitest/blue.png"></img>
+ </body>
+</html>
diff --git a/browser/base/content/test/siteIdentity/test_mcb_double_redirect_image.html b/browser/base/content/test/siteIdentity/test_mcb_double_redirect_image.html
new file mode 100644
index 0000000000..adadf01944
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/test_mcb_double_redirect_image.html
@@ -0,0 +1,23 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+ Test 7-9 for Bug 1082837 - See file browser_mcb_redirect.js for description.
+ https://bugzilla.mozilla.org/show_bug.cgi?id=1082837
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Bug 1082837</title>
+ <script>
+ function image_loaded() {
+ document.getElementById("mctestdiv").innerHTML = "image loaded";
+ }
+ function image_blocked() {
+ document.getElementById("mctestdiv").innerHTML = "image blocked";
+ }
+ </script>
+</head>
+<body>
+ <div id="mctestdiv"></div>
+ <img src="https://example.com/browser/browser/base/content/test/siteIdentity/test_mcb_redirect.sjs?image_redirect_http_sjs" onload="image_loaded()" onerror="image_blocked()" ></image>
+</body>
+</html>
diff --git a/browser/base/content/test/siteIdentity/test_mcb_redirect.html b/browser/base/content/test/siteIdentity/test_mcb_redirect.html
new file mode 100644
index 0000000000..fc7ccc2764
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/test_mcb_redirect.html
@@ -0,0 +1,15 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+ Test 1 for Bug 418354 - See file browser_mcb_redirect.js for description.
+ https://bugzilla.mozilla.org/show_bug.cgi?id=418354
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Bug 418354</title>
+</head>
+<body>
+ <div id="mctestdiv">script blocked</div>
+ <script src="https://example.com/browser/browser/base/content/test/siteIdentity/test_mcb_redirect.sjs?script" ></script>
+</body>
+</html>
diff --git a/browser/base/content/test/siteIdentity/test_mcb_redirect.js b/browser/base/content/test/siteIdentity/test_mcb_redirect.js
new file mode 100644
index 0000000000..48538c9409
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/test_mcb_redirect.js
@@ -0,0 +1,5 @@
+/*
+ * Once the mixed content blocker is disabled for the page, this scripts loads
+ * and updates the text inside the div container.
+ */
+document.getElementById("mctestdiv").innerHTML = "script executed";
diff --git a/browser/base/content/test/siteIdentity/test_mcb_redirect.sjs b/browser/base/content/test/siteIdentity/test_mcb_redirect.sjs
new file mode 100644
index 0000000000..53b8cf2b08
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/test_mcb_redirect.sjs
@@ -0,0 +1,29 @@
+function handleRequest(request, response) {
+ var page =
+ "<!DOCTYPE html><html><body>bug 418354 and bug 1082837</body></html>";
+
+ let redirect;
+ if (request.queryString === "script") {
+ redirect =
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com/browser/browser/base/content/test/siteIdentity/test_mcb_redirect.js";
+ response.setHeader("Cache-Control", "no-cache", false);
+ } else if (request.queryString === "image_http") {
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ redirect = "http://example.com/tests/image/test/mochitest/blue.png";
+ response.setHeader("Cache-Control", "max-age=3600", false);
+ } else if (request.queryString === "image_redirect_http_sjs") {
+ redirect =
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com/browser/browser/base/content/test/siteIdentity/test_mcb_redirect.sjs?image_redirect_https";
+ response.setHeader("Cache-Control", "max-age=3600", false);
+ } else if (request.queryString === "image_redirect_https") {
+ redirect = "https://example.com/tests/image/test/mochitest/blue.png";
+ response.setHeader("Cache-Control", "max-age=3600", false);
+ }
+
+ response.setHeader("Content-Type", "text/html", false);
+ response.setStatusLine(request.httpVersion, "302", "Found");
+ response.setHeader("Location", redirect, false);
+ response.write(page);
+}
diff --git a/browser/base/content/test/siteIdentity/test_mcb_redirect_image.html b/browser/base/content/test/siteIdentity/test_mcb_redirect_image.html
new file mode 100644
index 0000000000..42da0d7c13
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/test_mcb_redirect_image.html
@@ -0,0 +1,23 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+ Test 3-6 for Bug 1082837 - See file browser_mcb_redirect.js for description.
+ https://bugzilla.mozilla.org/show_bug.cgi?id=1082837
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Bug 1082837</title>
+ <script>
+ function image_loaded() {
+ document.getElementById("mctestdiv").innerHTML = "image loaded";
+ }
+ function image_blocked() {
+ document.getElementById("mctestdiv").innerHTML = "image blocked";
+ }
+ </script>
+</head>
+<body>
+ <div id="mctestdiv"></div>
+ <img src="https://example.com/browser/browser/base/content/test/siteIdentity/test_mcb_redirect.sjs?image_http" onload="image_loaded()" onerror="image_blocked()" ></image>
+</body>
+</html>
diff --git a/browser/base/content/test/siteIdentity/test_no_mcb_for_loopback.html b/browser/base/content/test/siteIdentity/test_no_mcb_for_loopback.html
new file mode 100644
index 0000000000..34193d370b
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/test_no_mcb_for_loopback.html
@@ -0,0 +1,56 @@
+<!-- See browser_no_mcb_for_localhost.js -->
+<!DOCTYPE HTML>
+<html>
+ <head>
+ <meta charset="utf8">
+ <title>Bug 903966, Bug 1402530</title>
+ </head>
+
+ <style>
+ @font-face {
+ font-family: "Font-IPv4";
+ src: url("http://127.0.0.1:8/test.ttf");
+ }
+
+ @font-face {
+ font-family: "Font-IPv6";
+ src: url("http://[::1]:8/test.ttf");
+ }
+
+ #ip-v4 {
+ font-family: "Font-IPv4"
+ }
+
+ #ip-v6 {
+ font-family: "Font-IPv6"
+ }
+ </style>
+
+ <body>
+ <div id="ip-v4">test</div>
+ <div id="ip-v6">test</div>
+
+ <img src="http://127.0.0.1:8/test.png">
+ <img src="http://[::1]:8/test.png">
+ <img src="http://localhost:8/test.png">
+
+ <iframe src="http://127.0.0.1:8/test.html"></iframe>
+ <iframe src="http://[::1]:8/test.html"></iframe>
+ <iframe src="http://localhost:8/test.html"></iframe>
+ </body>
+
+ <script src="http://127.0.0.1:8/test.js"></script>
+ <script src="http://[::1]:8/test.js"></script>
+ <script src="http://localhost:8/test.js"></script>
+
+ <link href="http://127.0.0.1:8/test.css" rel="stylesheet"></link>
+ <link href="http://[::1]:8/test.css" rel="stylesheet"></link>
+ <link href="http://localhost:8/test.css" rel="stylesheet"></link>
+
+ <script>
+ fetch("http://127.0.0.1:8");
+ fetch("http://localhost:8");
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ fetch("http://[::1]:8");
+ </script>
+</html>
diff --git a/browser/base/content/test/siteIdentity/test_no_mcb_for_onions.html b/browser/base/content/test/siteIdentity/test_no_mcb_for_onions.html
new file mode 100644
index 0000000000..c73c3681a3
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/test_no_mcb_for_onions.html
@@ -0,0 +1,29 @@
+<!-- See browser_no_mcb_for_onions.js -->
+<!DOCTYPE HTML>
+<html>
+ <head>
+ <meta charset="utf8">
+ <title>Bug 1382359</title>
+ </head>
+
+ <style>
+ @font-face {
+ src: url("http://123456789abcdef.onion:8/test.ttf");
+ }
+ </style>
+
+ <body>
+ <img src="http://123456789abcdef.onion:8/test.png">
+
+ <iframe src="http://123456789abcdef.onion:8/test.html"></iframe>
+ </body>
+
+ <script src="http://123456789abcdef.onion:8/test.js"></script>
+
+ <link href="http://123456789abcdef.onion:8/test.css" rel="stylesheet"></link>
+
+ <script>
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ fetch("http://123456789abcdef.onion:8");
+ </script>
+</html>
diff --git a/browser/base/content/test/siteIdentity/test_no_mcb_on_http_site_font.css b/browser/base/content/test/siteIdentity/test_no_mcb_on_http_site_font.css
new file mode 100644
index 0000000000..0587ef7939
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/test_no_mcb_on_http_site_font.css
@@ -0,0 +1,11 @@
+@font-face {
+ font-family: testFont;
+ src: url(http://example.com/browser/devtools/client/fontinspector/test/browser_font.woff);
+}
+/* stylelint-disable font-family-no-missing-generic-family-keyword */
+body {
+ font-family: Arial;
+}
+div {
+ font-family: testFont;
+}
diff --git a/browser/base/content/test/siteIdentity/test_no_mcb_on_http_site_font.html b/browser/base/content/test/siteIdentity/test_no_mcb_on_http_site_font.html
new file mode 100644
index 0000000000..7b39be064c
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/test_no_mcb_on_http_site_font.html
@@ -0,0 +1,44 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+ Test 2 for Bug 909920 - See file browser_no_mcb_on_http_site.js for description.
+ https://bugzilla.mozilla.org/show_bug.cgi?id=909920
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test 2 for Bug 909920</title>
+ <link rel="stylesheet" type="text/css" href="https://example.com/browser/browser/base/content/test/siteIdentity/test_no_mcb_on_http_site_font.css" />
+<script src="/tests/SimpleTest/SimpleTest.js"></script>
+
+<script type="text/javascript">
+ async function checkLoadStates() {
+ let state = await SpecialPowers.getSecurityState(window);
+
+ var loadedMixedActive =
+ !!(state & SpecialPowers.Ci.nsIWebProgressListener.STATE_LOADED_MIXED_ACTIVE_CONTENT);
+ is(loadedMixedActive, false, "OK: Should not load mixed active content!");
+
+ var blockedMixedActive =
+ !!(state & SpecialPowers.Ci.nsIWebProgressListener.STATE_BLOCKED_MIXED_ACTIVE_CONTENT);
+ is(blockedMixedActive, false, "OK: Should not block mixed active content!");
+
+ var loadedMixedDisplay =
+ !!(state & SpecialPowers.Ci.nsIWebProgressListener.STATE_LOADED_MIXED_DISPLAY_CONTENT);
+ is(loadedMixedDisplay, false, "OK: Should not load mixed display content!");
+
+ var blockedMixedDisplay =
+ !!(state & SpecialPowers.Ci.nsIWebProgressListener.STATE_BLOCKED_MIXED_DISPLAY_CONTENT);
+ is(blockedMixedDisplay, false, "OK: Should not block mixed display content!");
+
+ var newValue = "Verifying MCB does not trigger warning/error for an http page with https css that includes http font";
+ // eslint-disable-next-line no-unsanitized/property
+ document.getElementById("testDiv").innerHTML = newValue;
+ }
+</script>
+</head>
+<body onload="checkLoadStates()">
+ <div class="testDiv" id="testDiv">
+ Testing MCB does not trigger warning/error for an http page with https css that includes http font
+ </div>
+</body>
+</html>
diff --git a/browser/base/content/test/siteIdentity/test_no_mcb_on_http_site_font2.css b/browser/base/content/test/siteIdentity/test_no_mcb_on_http_site_font2.css
new file mode 100644
index 0000000000..3ac6c87a6b
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/test_no_mcb_on_http_site_font2.css
@@ -0,0 +1 @@
+@import url(http://example.com/browser/browser/base/content/test/siteIdentity/test_no_mcb_on_http_site_font.css);
diff --git a/browser/base/content/test/siteIdentity/test_no_mcb_on_http_site_font2.html b/browser/base/content/test/siteIdentity/test_no_mcb_on_http_site_font2.html
new file mode 100644
index 0000000000..3da31592dd
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/test_no_mcb_on_http_site_font2.html
@@ -0,0 +1,45 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+ Test 3 for Bug 909920 - See file browser_no_mcb_on_http_site.js for description.
+ https://bugzilla.mozilla.org/show_bug.cgi?id=909920
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test 3 for Bug 909920</title>
+ <link rel="stylesheet" type="text/css" href="https://example.com/browser/browser/base/content/test/siteIdentity/test_no_mcb_on_http_site_font2.css" />
+<script src="/tests/SimpleTest/SimpleTest.js"></script>
+
+<script type="text/javascript">
+ async function checkLoadStates() {
+ let state = await SpecialPowers.getSecurityState(window);
+
+ var loadedMixedActive =
+ !!(state & SpecialPowers.Ci.nsIWebProgressListener.STATE_LOADED_MIXED_ACTIVE_CONTENT);
+ is(loadedMixedActive, false, "OK: Should not load mixed active content!");
+
+ var blockedMixedActive =
+ !!(state & SpecialPowers.Ci.nsIWebProgressListener.STATE_BLOCKED_MIXED_ACTIVE_CONTENT);
+ is(blockedMixedActive, false, "OK: Should not block mixed active content!");
+
+ var loadedMixedDisplay =
+ !!(state & SpecialPowers.Ci.nsIWebProgressListener.STATE_LOADED_MIXED_DISPLAY_CONTENT);
+ is(loadedMixedDisplay, false, "OK: Should not load mixed display content!");
+
+ var blockedMixedDisplay =
+ !!(state & SpecialPowers.Ci.nsIWebProgressListener.STATE_BLOCKED_MIXED_DISPLAY_CONTENT);
+ is(blockedMixedDisplay, false, "OK: Should not block mixed display content!");
+
+ var newValue = "Verifying MCB does not trigger warning/error for an http page ";
+ newValue += "with https css that imports another http css which includes http font";
+ // eslint-disable-next-line no-unsanitized/property
+ document.getElementById("testDiv").innerHTML = newValue;
+ }
+</script>
+</head>
+<body onload="checkLoadStates()">
+ <div class="testDiv" id="testDiv">
+ Testing MCB does not trigger warning/error for an http page with https css that imports another http css which includes http font
+ </div>
+</body>
+</html>
diff --git a/browser/base/content/test/siteIdentity/test_no_mcb_on_http_site_img.css b/browser/base/content/test/siteIdentity/test_no_mcb_on_http_site_img.css
new file mode 100644
index 0000000000..d045e21ba0
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/test_no_mcb_on_http_site_img.css
@@ -0,0 +1,3 @@
+#testDiv {
+ background: url(http://example.com/tests/image/test/mochitest/blue.png)
+}
diff --git a/browser/base/content/test/siteIdentity/test_no_mcb_on_http_site_img.html b/browser/base/content/test/siteIdentity/test_no_mcb_on_http_site_img.html
new file mode 100644
index 0000000000..10aa281959
--- /dev/null
+++ b/browser/base/content/test/siteIdentity/test_no_mcb_on_http_site_img.html
@@ -0,0 +1,44 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+ Test 1 for Bug 909920 - See file browser_no_mcb_on_http_site.js for description.
+ https://bugzilla.mozilla.org/show_bug.cgi?id=909920
+-->
+<head>
+ <meta charset="utf-8">
+ <title>Test 1 for Bug 909920</title>
+ <link rel="stylesheet" type="text/css" href="https://example.com/browser/browser/base/content/test/siteIdentity/test_no_mcb_on_http_site_img.css" />
+<script src="/tests/SimpleTest/SimpleTest.js"></script>
+
+<script type="text/javascript">
+ async function checkLoadStates() {
+ let state = await SpecialPowers.getSecurityState(window);
+
+ var loadedMixedActive =
+ !!(state & SpecialPowers.Ci.nsIWebProgressListener.STATE_LOADED_MIXED_ACTIVE_CONTENT);
+ is(loadedMixedActive, false, "OK: Should not load mixed active content!");
+
+ var blockedMixedActive =
+ !!(state & SpecialPowers.Ci.nsIWebProgressListener.STATE_BLOCKED_MIXED_ACTIVE_CONTENT);
+ is(blockedMixedActive, false, "OK: Should not block mixed active content!");
+
+ var loadedMixedDisplay =
+ !!(state & SpecialPowers.Ci.nsIWebProgressListener.STATE_LOADED_MIXED_DISPLAY_CONTENT);
+ is(loadedMixedDisplay, false, "OK: Should not load mixed display content!");
+
+ var blockedMixedDisplay =
+ !!(state & SpecialPowers.Ci.nsIWebProgressListener.STATE_BLOCKED_MIXED_DISPLAY_CONTENT);
+ is(blockedMixedDisplay, false, "OK: Should not block mixed display content!");
+
+ var newValue = "Verifying MCB does not trigger warning/error for an http page with https css that includes http image";
+ // eslint-disable-next-line no-unsanitized/property
+ document.getElementById("testDiv").innerHTML = newValue;
+ }
+</script>
+</head>
+<body onload="checkLoadStates()">
+ <div class="testDiv" id="testDiv">
+ Testing MCB does not trigger warning/error for an http page with https css that includes http image
+ </div>
+</body>
+</html>
diff --git a/browser/base/content/test/startup/browser.ini b/browser/base/content/test/startup/browser.ini
new file mode 100644
index 0000000000..00ee1d9ed2
--- /dev/null
+++ b/browser/base/content/test/startup/browser.ini
@@ -0,0 +1,2 @@
+[browser_preXULSkeletonUIRegistry.js]
+skip-if = !(os == 'win' && os_version == '10.0') # We only enable the skele UI on Win10 \ No newline at end of file
diff --git a/browser/base/content/test/startup/browser_preXULSkeletonUIRegistry.js b/browser/base/content/test/startup/browser_preXULSkeletonUIRegistry.js
new file mode 100644
index 0000000000..7b96764eba
--- /dev/null
+++ b/browser/base/content/test/startup/browser_preXULSkeletonUIRegistry.js
@@ -0,0 +1,136 @@
+ChromeUtils.defineESModuleGetters(this, {
+ WindowsRegistry: "resource://gre/modules/WindowsRegistry.sys.mjs",
+});
+
+function getFirefoxExecutableFile() {
+ let file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+ file = Services.dirsvc.get("GreBinD", Ci.nsIFile);
+
+ file.append(AppConstants.MOZ_APP_NAME + ".exe");
+ return file;
+}
+
+// This is copied from WindowsRegistry.sys.mjs, but extended to support
+// TYPE_BINARY, as that is how we represent doubles in the registry for
+// the skeleton UI. However, we didn't extend WindowsRegistry.sys.mjs itself,
+// because TYPE_BINARY is kind of a footgun for javascript callers - our
+// use case is just trivial (checking that the value is non-zero).
+function readRegKeyExtended(aRoot, aPath, aKey, aRegistryNode = 0) {
+ const kRegMultiSz = 7;
+ const kMode = Ci.nsIWindowsRegKey.ACCESS_READ | aRegistryNode;
+ let registry = Cc["@mozilla.org/windows-registry-key;1"].createInstance(
+ Ci.nsIWindowsRegKey
+ );
+ try {
+ registry.open(aRoot, aPath, kMode);
+ if (registry.hasValue(aKey)) {
+ let type = registry.getValueType(aKey);
+ switch (type) {
+ case kRegMultiSz:
+ // nsIWindowsRegKey doesn't support REG_MULTI_SZ type out of the box.
+ let str = registry.readStringValue(aKey);
+ return str.split("\0").filter(v => v);
+ case Ci.nsIWindowsRegKey.TYPE_STRING:
+ return registry.readStringValue(aKey);
+ case Ci.nsIWindowsRegKey.TYPE_INT:
+ return registry.readIntValue(aKey);
+ case Ci.nsIWindowsRegKey.TYPE_BINARY:
+ return registry.readBinaryValue(aKey);
+ default:
+ throw new Error("Unsupported registry value.");
+ }
+ }
+ } catch (ex) {
+ } finally {
+ registry.close();
+ }
+ return undefined;
+}
+
+add_task(async function testWritesEnabledOnPrefChange() {
+ Services.prefs.setBoolPref("browser.startup.preXulSkeletonUI", true);
+
+ const win = await BrowserTestUtils.openNewBrowserWindow();
+
+ const firefoxPath = getFirefoxExecutableFile().path;
+ let enabled = WindowsRegistry.readRegKey(
+ Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER,
+ "Software\\Mozilla\\Firefox\\PreXULSkeletonUISettings",
+ `${firefoxPath}|Enabled`
+ );
+ is(enabled, 1, "Pre-XUL skeleton UI is enabled in the Windows registry");
+
+ Services.prefs.setBoolPref("browser.startup.preXulSkeletonUI", false);
+ enabled = WindowsRegistry.readRegKey(
+ Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER,
+ "Software\\Mozilla\\Firefox\\PreXULSkeletonUISettings",
+ `${firefoxPath}|Enabled`
+ );
+ is(enabled, 0, "Pre-XUL skeleton UI is disabled in the Windows registry");
+
+ Services.prefs.setBoolPref("browser.startup.preXulSkeletonUI", true);
+ Services.prefs.setIntPref("browser.tabs.inTitlebar", 0);
+ enabled = WindowsRegistry.readRegKey(
+ Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER,
+ "Software\\Mozilla\\Firefox\\PreXULSkeletonUISettings",
+ `${firefoxPath}|Enabled`
+ );
+ is(enabled, 0, "Pre-XUL skeleton UI is disabled in the Windows registry");
+
+ await BrowserTestUtils.closeWindow(win);
+});
+
+add_task(async function testPersistsNecessaryValuesOnChange() {
+ // Enable the skeleton UI, since if it's disabled we won't persist the size values
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.startup.preXulSkeletonUI", true]],
+ });
+
+ const regKeys = [
+ "Width",
+ "Height",
+ "ScreenX",
+ "ScreenY",
+ "UrlbarCSSSpan",
+ "CssToDevPixelScaling",
+ "SpringsCSSSpan",
+ "SearchbarCSSSpan",
+ "Theme",
+ "Flags",
+ "Progress",
+ ];
+
+ // Remove all of the registry values to ensure old tests aren't giving us false
+ // positives
+ for (let key of regKeys) {
+ WindowsRegistry.removeRegKey(
+ Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER,
+ "Software\\Mozilla\\Firefox\\PreXULSkeletonUISettings",
+ key
+ );
+ }
+
+ const win = await BrowserTestUtils.openNewBrowserWindow();
+ const firefoxPath = getFirefoxExecutableFile().path;
+ for (let key of regKeys) {
+ let value = readRegKeyExtended(
+ Ci.nsIWindowsRegKey.ROOT_KEY_CURRENT_USER,
+ "Software\\Mozilla\\Firefox\\PreXULSkeletonUISettings",
+ `${firefoxPath}|${key}`
+ );
+ isnot(
+ typeof value,
+ "undefined",
+ `Skeleton UI registry values should have a defined value for ${key}`
+ );
+ if (value.length) {
+ let hasNonZero = false;
+ for (var i = 0; i < value.length; i++) {
+ hasNonZero = hasNonZero || value[i];
+ }
+ ok(hasNonZero, `Value should have non-zero components for ${key}`);
+ }
+ }
+
+ await BrowserTestUtils.closeWindow(win);
+});
diff --git a/browser/base/content/test/static/browser.ini b/browser/base/content/test/static/browser.ini
new file mode 100644
index 0000000000..69f2a71723
--- /dev/null
+++ b/browser/base/content/test/static/browser.ini
@@ -0,0 +1,22 @@
+[DEFAULT]
+# These tests can be prone to intermittent failures on slower systems.
+# Since the specific flavor doesn't matter from a correctness standpoint,
+# just skip the tests on sanitizer, debug and OS X verify builds.
+skip-if = (asan || tsan || debug || (verify && os == 'mac'))
+support-files =
+ head.js
+
+[browser_all_files_referenced.js]
+skip-if = verify && bits == 32 # Causes OOMs when run repeatedly
+[browser_misused_characters_in_strings.js]
+support-files =
+ bug1262648_string_with_newlines.dtd
+skip-if = os == 'win' && msix # Permafail on MSIX packages due to it running on files it shouldn't.
+[browser_parsable_css.js]
+support-files =
+ dummy_page.html
+skip-if = os == 'win' && msix # Permafail on MSIX packages due to it running on files it shouldn't.
+[browser_parsable_script.js]
+skip-if = ccov && os == 'linux' # https://bugzilla.mozilla.org/show_bug.cgi?id=1608081
+[browser_sentence_case_strings.js]
+[browser_title_case_menus.js]
diff --git a/browser/base/content/test/static/browser_all_files_referenced.js b/browser/base/content/test/static/browser_all_files_referenced.js
new file mode 100644
index 0000000000..df8a1997a7
--- /dev/null
+++ b/browser/base/content/test/static/browser_all_files_referenced.js
@@ -0,0 +1,1093 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// Note to run this test similar to try server, you need to run:
+// ./mach package
+// ./mach mochitest --appname dist <path to test>
+
+// Slow on asan builds.
+requestLongerTimeout(5);
+
+var isDevtools = SimpleTest.harnessParameters.subsuite == "devtools";
+
+// This list should contain only path prefixes. It is meant to stop the test
+// from reporting things that *are* referenced, but for which the test can't
+// find any reference because the URIs are constructed programatically.
+// If you need to whitelist specific files, please use the 'whitelist' object.
+var gExceptionPaths = [
+ "resource://app/defaults/settings/blocklists/",
+ "resource://app/defaults/settings/security-state/",
+ "resource://app/defaults/settings/main/",
+ "resource://app/defaults/preferences/",
+ "resource://gre/modules/commonjs/",
+ "resource://gre/defaults/pref/",
+
+ // These chrome resources are referenced using relative paths from JS files.
+ "chrome://global/content/certviewer/components/",
+
+ // https://github.com/mozilla/activity-stream/issues/3053
+ "chrome://activity-stream/content/data/content/tippytop/images/",
+ "chrome://activity-stream/content/data/content/tippytop/favicons/",
+ // These resources are referenced by messages delivered through Remote Settings
+ "chrome://activity-stream/content/data/content/assets/remote/",
+ "chrome://activity-stream/content/data/content/assets/mobile-download-qr-new-user-cn.svg",
+ "chrome://activity-stream/content/data/content/assets/mobile-download-qr-existing-user-cn.svg",
+ "chrome://activity-stream/content/data/content/assets/person-typing.svg",
+ "chrome://browser/content/assets/moz-vpn.svg",
+ "chrome://browser/content/assets/vpn-logo.svg",
+ "chrome://browser/content/assets/focus-promo.png",
+ "chrome://browser/content/assets/klar-qr-code.svg",
+
+ // toolkit/components/pdfjs/content/build/pdf.js
+ "resource://pdf.js/web/images/",
+
+ // Exclude the form autofill path that has been moved out of the extensions to
+ // toolkit, see bug 1691821.
+ "resource://gre-resources/autofill/",
+
+ // Exclude all search-extensions because they aren't referenced by filename
+ "resource://search-extensions/",
+
+ // Exclude all services-automation because they are used through webdriver
+ "resource://gre/modules/services-automation/",
+ "resource://services-automation/ServicesAutomation.jsm",
+
+ // Paths from this folder are constructed in NetErrorParent.sys.mjs based on
+ // the type of cert or net error the user is encountering.
+ "chrome://global/content/neterror/supportpages/",
+
+ // Points to theme preview images, which are defined in browser/ but only used
+ // in toolkit/mozapps/extensions/content/aboutaddons.js.
+ "resource://usercontext-content/builtin-themes/",
+
+ // Page data schemas are referenced programmatically.
+ "chrome://browser/content/pagedata/schemas/",
+
+ // Nimbus schemas are referenced programmatically.
+ "resource://nimbus/schemas/",
+
+ // Activity stream schemas are referenced programmatically.
+ "resource://activity-stream/schemas",
+
+ // Localization file added programatically in featureCallout.jsm
+ "resource://app/localization/en-US/browser/featureCallout.ftl",
+];
+
+// These are not part of the omni.ja file, so we find them only when running
+// the test on a non-packaged build.
+if (AppConstants.platform == "macosx") {
+ gExceptionPaths.push("resource://gre/res/cursors/");
+ gExceptionPaths.push("resource://gre/res/touchbar/");
+}
+
+if (AppConstants.MOZ_BACKGROUNDTASKS) {
+ // These preferences are active only when we're in background task mode.
+ gExceptionPaths.push("resource://gre/defaults/backgroundtasks/");
+ gExceptionPaths.push("resource://app/defaults/backgroundtasks/");
+ // `BackgroundTask_id.jsm` is loaded at runtime by `app --backgroundtask id ...`.
+ gExceptionPaths.push("resource://gre/modules/backgroundtasks/");
+ gExceptionPaths.push("resource://app/modules/backgroundtasks/");
+}
+
+// Bug 1710546 https://bugzilla.mozilla.org/show_bug.cgi?id=1710546
+if (AppConstants.NIGHTLY_BUILD) {
+ gExceptionPaths.push("resource://builtin-addons/translations/");
+}
+
+if (AppConstants.NIGHTLY_BUILD) {
+ // This is nightly-only debug tool.
+ gExceptionPaths.push(
+ "chrome://browser/content/places/interactionsViewer.html"
+ );
+}
+
+// Each whitelist entry should have a comment indicating which file is
+// referencing the whitelisted file in a way that the test can't detect, or a
+// bug number to remove or use the file if it is indeed currently unreferenced.
+var whitelist = [
+ // toolkit/components/pdfjs/content/PdfStreamConverter.jsm
+ { file: "chrome://pdf.js/locale/chrome.properties" },
+ { file: "chrome://pdf.js/locale/viewer.properties" },
+
+ // security/manager/pki/resources/content/device_manager.js
+ { file: "chrome://pippki/content/load_device.xhtml" },
+
+ // The l10n build system can't package string files only for some platforms.
+ // See bug 1339424 for why this is hard to fix.
+ {
+ file: "chrome://global/locale/fallbackMenubar.properties",
+ platforms: ["linux", "win"],
+ },
+ {
+ file: "resource://gre/localization/en-US/toolkit/printing/printDialogs.ftl",
+ platforms: ["linux", "macosx"],
+ },
+
+ // This file is referenced by the build system to generate the
+ // Firefox .desktop entry. See bug 1824327 (and perhaps bug 1526672)
+ {
+ file: "resource://app/localization/en-US/browser/linuxDesktopEntry.ftl",
+ },
+
+ // toolkit/content/aboutRights-unbranded.xhtml doesn't use aboutRights.css
+ { file: "chrome://global/skin/aboutRights.css", skipUnofficial: true },
+
+ // devtools/client/inspector/bin/dev-server.js
+ {
+ file: "chrome://devtools/content/inspector/markup/markup.xhtml",
+ isFromDevTools: true,
+ },
+
+ // used by devtools/client/memory/index.xhtml
+ { file: "chrome://global/content/third_party/d3/d3.js" },
+
+ // SpiderMonkey parser API, currently unused in browser/ and toolkit/
+ { file: "resource://gre/modules/reflect.sys.mjs" },
+
+ // extensions/pref/autoconfig/src/nsReadConfig.cpp
+ { file: "resource://gre/defaults/autoconfig/prefcalls.js" },
+
+ // browser/components/preferences/moreFromMozilla.js
+ // These files URLs are constructed programatically at run time.
+ {
+ file: "chrome://browser/content/preferences/more-from-mozilla-qr-code-simple.svg",
+ },
+ {
+ file: "chrome://browser/content/preferences/more-from-mozilla-qr-code-simple-cn.svg",
+ },
+
+ { file: "resource://gre/greprefs.js" },
+
+ // layout/mathml/nsMathMLChar.cpp
+ { file: "resource://gre/res/fonts/mathfontSTIXGeneral.properties" },
+ { file: "resource://gre/res/fonts/mathfontUnicode.properties" },
+
+ // toolkit/mozapps/extensions/AddonContentPolicy.cpp
+ { file: "resource://gre/localization/en-US/toolkit/global/cspErrors.ftl" },
+
+ // The l10n build system can't package string files only for some platforms.
+ {
+ file: "resource://gre/chrome/en-US/locale/en-US/global-platform/mac/accessible.properties",
+ platforms: ["linux", "win"],
+ },
+ {
+ file: "resource://gre/chrome/en-US/locale/en-US/global-platform/mac/intl.properties",
+ platforms: ["linux", "win"],
+ },
+ {
+ file: "resource://gre/chrome/en-US/locale/en-US/global-platform/mac/platformKeys.properties",
+ platforms: ["linux", "win"],
+ },
+ {
+ file: "resource://gre/chrome/en-US/locale/en-US/global-platform/unix/accessible.properties",
+ platforms: ["macosx", "win"],
+ },
+ {
+ file: "resource://gre/chrome/en-US/locale/en-US/global-platform/unix/intl.properties",
+ platforms: ["macosx", "win"],
+ },
+ {
+ file: "resource://gre/chrome/en-US/locale/en-US/global-platform/unix/platformKeys.properties",
+ platforms: ["macosx", "win"],
+ },
+ {
+ file: "resource://gre/chrome/en-US/locale/en-US/global-platform/win/accessible.properties",
+ platforms: ["linux", "macosx"],
+ },
+ {
+ file: "resource://gre/chrome/en-US/locale/en-US/global-platform/win/intl.properties",
+ platforms: ["linux", "macosx"],
+ },
+ {
+ file: "resource://gre/chrome/en-US/locale/en-US/global-platform/win/platformKeys.properties",
+ platforms: ["linux", "macosx"],
+ },
+
+ // Files from upstream library
+ { file: "resource://pdf.js/web/debugger.js" },
+ { file: "resource://pdf.js/web/debugger.css" },
+
+ // resource://app/modules/translation/TranslationContentHandler.jsm
+ { file: "resource://app/modules/translation/BingTranslator.jsm" },
+ { file: "resource://app/modules/translation/GoogleTranslator.jsm" },
+ { file: "resource://app/modules/translation/YandexTranslator.jsm" },
+
+ // Starting from here, files in the whitelist are bugs that need fixing.
+ // Bug 1339424 (wontfix?)
+ {
+ file: "chrome://browser/locale/taskbar.properties",
+ platforms: ["linux", "macosx"],
+ },
+ // Bug 1344267
+ { file: "chrome://remote/content/marionette/test_dialog.properties" },
+ { file: "chrome://remote/content/marionette/test_dialog.xhtml" },
+ { file: "chrome://remote/content/marionette/test_menupopup.xhtml" },
+ { file: "chrome://remote/content/marionette/test_no_xul.xhtml" },
+ { file: "chrome://remote/content/marionette/test.xhtml" },
+ // Bug 1348559
+ { file: "chrome://pippki/content/resetpassword.xhtml" },
+ // Bug 1337345
+ { file: "resource://gre/modules/Manifest.sys.mjs" },
+ // Bug 1494170
+ // (The references to these files are dynamically generated, so the test can't
+ // find the references)
+ {
+ file: "chrome://devtools/skin/images/aboutdebugging-firefox-aurora.svg",
+ isFromDevTools: true,
+ },
+ {
+ file: "chrome://devtools/skin/images/aboutdebugging-firefox-beta.svg",
+ isFromDevTools: true,
+ },
+ {
+ file: "chrome://devtools/skin/images/aboutdebugging-firefox-release.svg",
+ isFromDevTools: true,
+ },
+ { file: "chrome://devtools/skin/images/next.svg", isFromDevTools: true },
+
+ // Bug 1526672
+ {
+ file: "resource://app/localization/en-US/browser/touchbar/touchbar.ftl",
+ platforms: ["linux", "win"],
+ },
+ // Referenced by the webcompat system addon for localization
+ { file: "resource://gre/localization/en-US/toolkit/about/aboutCompat.ftl" },
+
+ // dom/media/mediacontrol/MediaControlService.cpp
+ { file: "resource://gre/localization/en-US/dom/media.ftl" },
+
+ // dom/xml/nsXMLPrettyPrinter.cpp
+ { file: "resource://gre/localization/en-US/dom/XMLPrettyPrint.ftl" },
+
+ // tookit/mozapps/update/BackgroundUpdate.jsm
+ {
+ file: "resource://gre/localization/en-US/toolkit/updates/backgroundupdate.ftl",
+ },
+ // Bug 1713242 - referenced by aboutThirdParty.html which is only for Windows
+ {
+ file: "resource://gre/localization/en-US/toolkit/about/aboutThirdParty.ftl",
+ platforms: ["linux", "macosx"],
+ },
+ // Bug 1973834 - referenced by aboutWindowsMessages.html which is only for Windows
+ {
+ file: "resource://gre/localization/en-US/toolkit/about/aboutWindowsMessages.ftl",
+ platforms: ["linux", "macosx"],
+ },
+ // Bug 1721741:
+ // (The references to these files are dynamically generated, so the test can't
+ // find the references)
+ { file: "chrome://browser/content/screenshots/copied-notification.svg" },
+
+ // toolkit/xre/MacRunFromDmgUtils.mm
+ { file: "resource://gre/localization/en-US/toolkit/global/run-from-dmg.ftl" },
+
+ // Referenced by screenshots extension
+ { file: "chrome://browser/content/screenshots/cancel.svg" },
+ { file: "chrome://browser/content/screenshots/copy.svg" },
+ { file: "chrome://browser/content/screenshots/download.svg" },
+ { file: "chrome://browser/content/screenshots/download-white.svg" },
+
+ // Bug 1824826 - Implement a view of history in Firefox View
+ { file: "resource://gre/modules/PlacesQuery.sys.mjs" },
+
+ // Should be removed in bug 1824826 when fxview-tab-list is used in Firefox View
+ { file: "resource://app/localization/en-US/browser/fxviewTabList.ftl" },
+ { file: "chrome://browser/content/firefoxview/fxview-tab-list.css" },
+ { file: "chrome://browser/content/firefoxview/fxview-tab-list.mjs" },
+ { file: "chrome://browser/content/firefoxview/fxview-tab-row.css" },
+
+ // Bug 1834176 - Imports of NetUtil can't be converted until hostutils is
+ // updated.
+ { file: "resource://gre/modules/NetUtil.sys.mjs" },
+];
+
+if (AppConstants.NIGHTLY_BUILD && AppConstants.platform != "win") {
+ // This path is refereneced in nsFxrCommandLineHandler.cpp, which is only
+ // compiled in Windows. Whitelisted this path so that non-Windows builds
+ // can access the FxR UI via --chrome rather than --fxr (which includes VR-
+ // specific functionality)
+ whitelist.push({ file: "chrome://fxr/content/fxrui.html" });
+}
+
+if (AppConstants.platform == "android") {
+ // The l10n build system can't package string files only for some platforms.
+ // Referenced by aboutGlean.html
+ whitelist.push({
+ file: "resource://gre/localization/en-US/toolkit/about/aboutGlean.ftl",
+ });
+}
+
+if (AppConstants.MOZ_UPDATE_AGENT && !AppConstants.MOZ_BACKGROUNDTASKS) {
+ // Task scheduling is only used for background updates right now.
+ whitelist.push({
+ file: "resource://gre/modules/TaskScheduler.jsm",
+ });
+}
+
+whitelist = new Set(
+ whitelist
+ .filter(
+ item =>
+ "isFromDevTools" in item == isDevtools &&
+ (!item.skipUnofficial || !AppConstants.MOZILLA_OFFICIAL) &&
+ (!item.platforms || item.platforms.includes(AppConstants.platform))
+ )
+ .map(item => item.file)
+);
+
+const ignorableWhitelist = new Set([
+ // The following files are outside of the omni.ja file, so we only catch them
+ // when testing on a non-packaged build.
+
+ // toolkit/mozapps/extensions/nsBlocklistService.js
+ "resource://app/blocklist.xml",
+
+ // dom/media/gmp/GMPParent.cpp
+ "resource://gre/gmp-clearkey/0.1/manifest.json",
+
+ // Bug 1351669 - obsolete test file
+ "resource://gre/res/test.properties",
+]);
+for (let entry of ignorableWhitelist) {
+ whitelist.add(entry);
+}
+
+if (!isDevtools) {
+ // services/sync/modules/service.sys.mjs
+ for (let module of [
+ "addons.sys.mjs",
+ "bookmarks.sys.mjs",
+ "forms.sys.mjs",
+ "history.sys.mjs",
+ "passwords.sys.mjs",
+ "prefs.sys.mjs",
+ "tabs.sys.mjs",
+ "extension-storage.sys.mjs",
+ ]) {
+ whitelist.add("resource://services-sync/engines/" + module);
+ }
+ // resource://devtools/shared/worker/loader.js,
+ // resource://devtools/shared/loader/builtin-modules.js
+ if (!AppConstants.ENABLE_WEBDRIVER) {
+ whitelist.add("resource://gre/modules/jsdebugger.sys.mjs");
+ }
+}
+
+if (AppConstants.MOZ_CODE_COVERAGE) {
+ whitelist.add(
+ "chrome://remote/content/marionette/PerTestCoverageUtils.sys.mjs"
+ );
+}
+
+const gInterestingCategories = new Set([
+ "agent-style-sheets",
+ "addon-provider-module",
+ "webextension-modules",
+ "webextension-scripts",
+ "webextension-schemas",
+ "webextension-scripts-addon",
+ "webextension-scripts-content",
+ "webextension-scripts-devtools",
+]);
+
+var gChromeReg = Cc["@mozilla.org/chrome/chrome-registry;1"].getService(
+ Ci.nsIChromeRegistry
+);
+var gChromeMap = new Map();
+var gOverrideMap = new Map();
+var gComponentsSet = new Set();
+
+// In this map when the value is a Set of URLs, the file is referenced if any
+// of the files in the Set is referenced.
+// When the value is null, the file is referenced unconditionally.
+// When the value is a string, "whitelist-direct" means that we have not found
+// any reference in the code, but have a matching whitelist entry for this file.
+// "whitelist" means that the file is indirectly whitelisted, ie. a whitelisted
+// file causes this file to be referenced.
+var gReferencesFromCode = new Map();
+
+var resHandler = Services.io
+ .getProtocolHandler("resource")
+ .QueryInterface(Ci.nsIResProtocolHandler);
+var gResourceMap = [];
+function trackResourcePrefix(prefix) {
+ let uri = Services.io.newURI("resource://" + prefix + "/");
+ gResourceMap.unshift([prefix, resHandler.resolveURI(uri)]);
+}
+trackResourcePrefix("gre");
+trackResourcePrefix("app");
+
+function getBaseUriForChromeUri(chromeUri) {
+ let chromeFile = chromeUri + "nonexistentfile.reallynothere";
+ let uri = Services.io.newURI(chromeFile);
+ let fileUri = gChromeReg.convertChromeURL(uri);
+ return fileUri.resolve(".");
+}
+
+function trackChromeUri(uri) {
+ gChromeMap.set(getBaseUriForChromeUri(uri), uri);
+}
+
+// formautofill registers resource://formautofill/ and
+// chrome://formautofill/content/ dynamically at runtime.
+// Bug 1480276 is about addressing this without this hard-coding.
+trackResourcePrefix("autofill");
+trackChromeUri("chrome://formautofill/content/");
+
+function parseManifest(manifestUri) {
+ return fetchFile(manifestUri.spec).then(data => {
+ for (let line of data.split("\n")) {
+ let [type, ...argv] = line.split(/\s+/);
+ if (type == "content" || type == "skin" || type == "locale") {
+ let chromeUri = `chrome://${argv[0]}/${type}/`;
+ // The webcompat reporter's locale directory may not exist if
+ // the addon is preffed-off, and since it's a hack until we
+ // get bz1425104 landed, we'll just skip it for now.
+ if (chromeUri === "chrome://report-site-issue/locale/") {
+ gChromeMap.set("chrome://report-site-issue/locale/", true);
+ } else {
+ trackChromeUri(chromeUri);
+ }
+ } else if (type == "override" || type == "overlay") {
+ // Overlays aren't really overrides, but behave the same in
+ // that the overlay is only referenced if the original xul
+ // file is referenced somewhere.
+ let os = "os=" + Services.appinfo.OS;
+ if (!argv.some(s => s.startsWith("os=") && s != os)) {
+ gOverrideMap.set(
+ Services.io.newURI(argv[1]).specIgnoringRef,
+ Services.io.newURI(argv[0]).specIgnoringRef
+ );
+ }
+ } else if (type == "category" && gInterestingCategories.has(argv[0])) {
+ gReferencesFromCode.set(argv[2], null);
+ } else if (type == "resource") {
+ trackResourcePrefix(argv[0]);
+ } else if (type == "component") {
+ gComponentsSet.add(argv[1]);
+ }
+ }
+ });
+}
+
+// If the given URI is a webextension manifest, extract files used by
+// any of its APIs (scripts, icons, style sheets, theme images).
+// Returns the passed in URI if the manifest is not a webextension
+// manifest, null otherwise.
+async function parseJsonManifest(uri) {
+ uri = Services.io.newURI(convertToCodeURI(uri.spec));
+
+ let raw = await fetchFile(uri.spec);
+ let data;
+ try {
+ data = JSON.parse(raw);
+ } catch (ex) {
+ return uri;
+ }
+
+ // Simplistic test for whether this is a webextension manifest:
+ if (data.manifest_version !== 2) {
+ return uri;
+ }
+
+ if (data.background?.scripts) {
+ for (let bgscript of data.background.scripts) {
+ gReferencesFromCode.set(uri.resolve(bgscript), null);
+ }
+ }
+
+ if (data.icons) {
+ for (let icon of Object.values(data.icons)) {
+ gReferencesFromCode.set(uri.resolve(icon), null);
+ }
+ }
+
+ if (data.experiment_apis) {
+ for (let api of Object.values(data.experiment_apis)) {
+ if (api.parent && api.parent.script) {
+ let script = uri.resolve(api.parent.script);
+ gReferencesFromCode.set(script, null);
+ }
+
+ if (api.schema) {
+ gReferencesFromCode.set(uri.resolve(api.schema), null);
+ }
+ }
+ }
+
+ if (data.theme_experiment && data.theme_experiment.stylesheet) {
+ let stylesheet = uri.resolve(data.theme_experiment.stylesheet);
+ gReferencesFromCode.set(stylesheet, null);
+ }
+
+ for (let themeKey of ["theme", "dark_theme"]) {
+ if (data?.[themeKey]?.images?.additional_backgrounds) {
+ for (let background of data[themeKey].images.additional_backgrounds) {
+ gReferencesFromCode.set(uri.resolve(background), null);
+ }
+ }
+ }
+
+ return null;
+}
+
+function addCodeReference(url, fromURI) {
+ let from = convertToCodeURI(fromURI.spec);
+
+ // Ignore self references.
+ if (url == from) {
+ return;
+ }
+
+ let ref;
+ if (gReferencesFromCode.has(url)) {
+ ref = gReferencesFromCode.get(url);
+ if (ref === null) {
+ return;
+ }
+ } else {
+ ref = new Set();
+ gReferencesFromCode.set(url, ref);
+ }
+ ref.add(from);
+}
+
+function listCodeReferences(refs) {
+ let refList = [];
+ if (refs) {
+ for (let ref of refs) {
+ refList.push(ref);
+ }
+ }
+ return refList.join(",");
+}
+
+function parseCSSFile(fileUri) {
+ return fetchFile(fileUri.spec).then(data => {
+ for (let line of data.split("\n")) {
+ let urls = line.match(/url\([^()]+\)/g);
+ if (!urls) {
+ // @import rules can take a string instead of a url.
+ let importMatch = line.match(/@import ['"]?([^'"]*)['"]?/);
+ if (importMatch && importMatch[1]) {
+ let url = Services.io.newURI(importMatch[1], null, fileUri).spec;
+ addCodeReference(convertToCodeURI(url), fileUri);
+ }
+ continue;
+ }
+
+ for (let url of urls) {
+ // Remove the url(" prefix and the ") suffix.
+ url = url
+ .replace(/url\(([^)]*)\)/, "$1")
+ .replace(/^"(.*)"$/, "$1")
+ .replace(/^'(.*)'$/, "$1");
+ if (url.startsWith("data:")) {
+ continue;
+ }
+
+ try {
+ url = Services.io.newURI(url, null, fileUri).specIgnoringRef;
+ addCodeReference(convertToCodeURI(url), fileUri);
+ } catch (e) {
+ ok(false, "unexpected error while resolving this URI: " + url);
+ }
+ }
+ }
+ });
+}
+
+function parseCodeFile(fileUri) {
+ return fetchFile(fileUri.spec).then(data => {
+ let baseUri;
+ for (let line of data.split("\n")) {
+ let urls = line.match(
+ /["'`]chrome:\/\/[a-zA-Z0-9-]+\/(content|skin|locale)\/[^"'` ]*["'`]/g
+ );
+
+ if (!urls) {
+ urls = line.match(/["']resource:\/\/[^"']+["']/g);
+ if (
+ urls &&
+ isDevtools &&
+ /baseURI: "resource:\/\/devtools\//.test(line)
+ ) {
+ baseUri = Services.io.newURI(urls[0].slice(1, -1));
+ continue;
+ }
+ }
+
+ if (!urls) {
+ urls = line.match(/[a-z0-9_\/-]+\.ftl/i);
+ if (urls) {
+ urls = urls[0];
+ let grePrefix = Services.io.newURI(
+ "resource://gre/localization/en-US/"
+ );
+ let appPrefix = Services.io.newURI(
+ "resource://app/localization/en-US/"
+ );
+
+ let grePrefixUrl = Services.io.newURI(urls, null, grePrefix).spec;
+ let appPrefixUrl = Services.io.newURI(urls, null, appPrefix).spec;
+
+ addCodeReference(grePrefixUrl, fileUri);
+ addCodeReference(appPrefixUrl, fileUri);
+ continue;
+ }
+ }
+
+ if (!urls) {
+ // If there's no absolute chrome URL, look for relative ones in
+ // src and href attributes.
+ let match = line.match("(?:src|href)=[\"']([^$&\"']+)");
+ if (match && match[1]) {
+ let url = Services.io.newURI(match[1], null, fileUri).spec;
+ addCodeReference(convertToCodeURI(url), fileUri);
+ }
+
+ // This handles `import` lines which may be multi-line.
+ // We have an ESLint rule, `import/no-unassigned-import` which prevents
+ // using bare `import "foo.js"`, so we don't need to handle that case
+ // here.
+ match = line.match(/from\W*['"](.*?)['"]/);
+ if (match?.[1]) {
+ let url = match[1];
+ url = Services.io.newURI(url, null, baseUri || fileUri).spec;
+ url = convertToCodeURI(url);
+ addCodeReference(url, fileUri);
+ }
+
+ if (isDevtools) {
+ let rules = [
+ ["devtools/client/locales", "chrome://devtools/locale"],
+ ["devtools/shared/locales", "chrome://devtools-shared/locale"],
+ [
+ "devtools/shared/platform",
+ "resource://devtools/shared/platform/chrome",
+ ],
+ ["devtools", "resource://devtools"],
+ ];
+
+ match = line.match(/["']((?:devtools)\/[^\\#"']+)["']/);
+ if (match && match[1]) {
+ let path = match[1];
+ for (let rule of rules) {
+ if (path.startsWith(rule[0] + "/")) {
+ path = path.replace(rule[0], rule[1]);
+ if (!/\.(properties|js|jsm|mjs|json|css)$/.test(path)) {
+ path += ".js";
+ }
+ addCodeReference(path, fileUri);
+ break;
+ }
+ }
+ }
+
+ match = line.match(/require\(['"](\.[^'"]+)['"]\)/);
+ if (match && match[1]) {
+ let url = match[1];
+ url = Services.io.newURI(url, null, baseUri || fileUri).spec;
+ url = convertToCodeURI(url);
+ if (!/\.(properties|js|jsm|mjs|json|css)$/.test(url)) {
+ url += ".js";
+ }
+ if (url.startsWith("resource://")) {
+ addCodeReference(url, fileUri);
+ } else {
+ // if we end up with a chrome:// url here, it's likely because
+ // a baseURI to a resource:// path has been defined in another
+ // .js file that is loaded in the same scope, we can't detect it.
+ }
+ }
+ }
+ continue;
+ }
+
+ for (let url of urls) {
+ // Remove quotes.
+ url = url.slice(1, -1);
+ // Remove ? or \ trailing characters.
+ if (url.endsWith("\\")) {
+ url = url.slice(0, -1);
+ }
+
+ let pos = url.indexOf("?");
+ if (pos != -1) {
+ url = url.slice(0, pos);
+ }
+
+ // Make urls like chrome://browser/skin/ point to an actual file,
+ // and remove the ref if any.
+ try {
+ url = Services.io.newURI(url).specIgnoringRef;
+ } catch (e) {
+ continue;
+ }
+
+ if (
+ isDevtools &&
+ line.includes("require(") &&
+ !/\.(properties|js|jsm|mjs|json|css)$/.test(url)
+ ) {
+ url += ".js";
+ }
+
+ addCodeReference(url, fileUri);
+ }
+ }
+ });
+}
+
+function convertToCodeURI(fileUri) {
+ let baseUri = fileUri;
+ let path = "";
+ while (true) {
+ let slashPos = baseUri.lastIndexOf("/", baseUri.length - 2);
+ if (slashPos <= 0) {
+ // File not accessible from chrome protocol, try resource://
+ for (let res of gResourceMap) {
+ if (fileUri.startsWith(res[1])) {
+ return fileUri.replace(res[1], "resource://" + res[0] + "/");
+ }
+ }
+ // Give up and return the original URL.
+ return fileUri;
+ }
+ path = baseUri.slice(slashPos + 1) + path;
+ baseUri = baseUri.slice(0, slashPos + 1);
+ if (gChromeMap.has(baseUri)) {
+ return gChromeMap.get(baseUri) + path;
+ }
+ }
+}
+
+async function chromeFileExists(aURI) {
+ try {
+ return await PerfTestHelpers.checkURIExists(aURI);
+ } catch (e) {
+ todo(false, `Failed to check if ${aURI} exists: ${e}`);
+ return false;
+ }
+}
+
+function findChromeUrlsFromArray(array, prefix) {
+ // Find the first character of the prefix...
+ for (
+ let index = 0;
+ (index = array.indexOf(prefix.charCodeAt(0), index)) != -1;
+ ++index
+ ) {
+ // Then ensure we actually have the whole prefix.
+ let found = true;
+ for (let i = 1; i < prefix.length; ++i) {
+ if (array[index + i] != prefix.charCodeAt(i)) {
+ found = false;
+ break;
+ }
+ }
+ if (!found) {
+ continue;
+ }
+
+ // C strings are null terminated, but " also terminates urls
+ // (nsIndexedToHTML.cpp contains an HTML fragment with several chrome urls)
+ // Let's also terminate the string on the # character to skip references.
+ let end = Math.min(
+ array.indexOf(0, index),
+ array.indexOf('"'.charCodeAt(0), index),
+ array.indexOf(")".charCodeAt(0), index),
+ array.indexOf("#".charCodeAt(0), index)
+ );
+ let string = "";
+ for (; index < end; ++index) {
+ string += String.fromCharCode(array[index]);
+ }
+
+ // Only keep strings that look like real chrome or resource urls.
+ if (
+ /chrome:\/\/[a-zA-Z09-]+\/(content|skin|locale)\//.test(string) ||
+ /resource:\/\/[a-zA-Z09-]*\/.*\.[a-z]+/.test(string)
+ ) {
+ gReferencesFromCode.set(string, null);
+ }
+ }
+}
+
+add_task(async function checkAllTheFiles() {
+ TestUtils.assertPackagedBuild();
+
+ const libxul = await IOUtils.read(PathUtils.xulLibraryPath);
+ findChromeUrlsFromArray(libxul, "chrome://");
+ findChromeUrlsFromArray(libxul, "resource://");
+ // Handle NS_LITERAL_STRING.
+ let uint16 = new Uint16Array(libxul.buffer);
+ findChromeUrlsFromArray(uint16, "chrome://");
+ findChromeUrlsFromArray(uint16, "resource://");
+
+ const kCodeExtensions = [
+ ".xml",
+ ".xsl",
+ ".mjs",
+ ".js",
+ ".jsm",
+ ".json",
+ ".html",
+ ".xhtml",
+ ];
+
+ let appDir = Services.dirsvc.get("GreD", Ci.nsIFile);
+ // This asynchronously produces a list of URLs (sadly, mostly sync on our
+ // test infrastructure because it runs against jarfiles there, and
+ // our zipreader APIs are all sync)
+ let uris = await generateURIsFromDirTree(
+ appDir,
+ [
+ ".css",
+ ".manifest",
+ ".jpg",
+ ".png",
+ ".gif",
+ ".svg",
+ ".ftl",
+ ".dtd",
+ ".properties",
+ ].concat(kCodeExtensions)
+ );
+
+ // Parse and remove all manifests from the list.
+ // NOTE that this must be done before filtering out devtools paths
+ // so that all chrome paths can be recorded.
+ let manifestURIs = [];
+ let jsonManifests = [];
+ uris = uris.filter(uri => {
+ let path = uri.pathQueryRef;
+ if (path.endsWith(".manifest")) {
+ manifestURIs.push(uri);
+ return false;
+ } else if (path.endsWith("/manifest.json")) {
+ jsonManifests.push(uri);
+ return false;
+ }
+
+ return true;
+ });
+
+ // Wait for all manifest to be parsed
+ await PerfTestHelpers.throttledMapPromises(manifestURIs, parseManifest);
+
+ for (let jsm of Components.manager.getComponentJSMs()) {
+ gReferencesFromCode.set(jsm, null);
+ }
+ for (let esModule of Components.manager.getComponentESModules()) {
+ gReferencesFromCode.set(esModule, null);
+ }
+
+ // manifest.json is a common name, it is used for WebExtension manifests
+ // but also for other things. To tell them apart, we have to actually
+ // read the contents. This will populate gExtensionRoots with all
+ // embedded extension APIs, and return any manifest.json files that aren't
+ // webextensions.
+ let nonWebextManifests = (
+ await Promise.all(jsonManifests.map(parseJsonManifest))
+ ).filter(uri => !!uri);
+ uris.push(...nonWebextManifests);
+
+ // We build a list of promises that get resolved when their respective
+ // files have loaded and produced no errors.
+ let allPromises = [];
+
+ for (let uri of uris) {
+ let path = uri.pathQueryRef;
+ if (path.endsWith(".css")) {
+ allPromises.push([parseCSSFile, uri]);
+ } else if (kCodeExtensions.some(ext => path.endsWith(ext))) {
+ allPromises.push([parseCodeFile, uri]);
+ }
+ }
+
+ // Wait for all the files to have actually loaded:
+ await PerfTestHelpers.throttledMapPromises(allPromises, ([task, uri]) =>
+ task(uri)
+ );
+
+ // Keep only chrome:// files, and filter out either the devtools paths or
+ // the non-devtools paths:
+ let devtoolsPrefixes = [
+ "chrome://devtools",
+ "resource://devtools/",
+ "resource://devtools-client-jsonview/",
+ "resource://devtools-client-shared/",
+ "resource://app/modules/devtools",
+ "resource://gre/modules/devtools",
+ "resource://app/localization/en-US/startup/aboutDevTools.ftl",
+ "resource://app/localization/en-US/devtools/",
+ ];
+ let hasDevtoolsPrefix = uri =>
+ devtoolsPrefixes.some(prefix => uri.startsWith(prefix));
+ let chromeFiles = [];
+ for (let uri of uris) {
+ uri = convertToCodeURI(uri.spec);
+ if (
+ (uri.startsWith("chrome://") || uri.startsWith("resource://")) &&
+ isDevtools == hasDevtoolsPrefix(uri)
+ ) {
+ chromeFiles.push(uri);
+ }
+ }
+
+ if (isDevtools) {
+ // chrome://devtools/skin/devtools-browser.css is included from browser.xhtml
+ gReferencesFromCode.set(AppConstants.BROWSER_CHROME_URL, null);
+ // devtools' css is currently included from browser.css, see bug 1204810.
+ gReferencesFromCode.set("chrome://browser/skin/browser.css", null);
+ }
+
+ let isUnreferenced = file => {
+ if (gExceptionPaths.some(e => file.startsWith(e))) {
+ return false;
+ }
+ if (gReferencesFromCode.has(file)) {
+ let refs = gReferencesFromCode.get(file);
+ if (refs === null) {
+ return false;
+ }
+ for (let ref of refs) {
+ if (isDevtools) {
+ if (
+ ref.startsWith("resource://app/components/") ||
+ (file.startsWith("chrome://") && ref.startsWith("resource://"))
+ ) {
+ return false;
+ }
+ }
+
+ if (gReferencesFromCode.has(ref)) {
+ let refType = gReferencesFromCode.get(ref);
+ if (
+ refType === null || // unconditionally referenced
+ refType == "whitelist" ||
+ refType == "whitelist-direct"
+ ) {
+ return false;
+ }
+ }
+ }
+ }
+ return !gOverrideMap.has(file) || isUnreferenced(gOverrideMap.get(file));
+ };
+
+ let unreferencedFiles = chromeFiles;
+
+ let removeReferenced = useWhitelist => {
+ let foundReference = false;
+ unreferencedFiles = unreferencedFiles.filter(f => {
+ let rv = isUnreferenced(f);
+ if (rv && f.startsWith("resource://app/")) {
+ rv = isUnreferenced(f.replace("resource://app/", "resource:///"));
+ }
+ if (rv && /^resource:\/\/(?:app|gre)\/components\/[^/]+\.js$/.test(f)) {
+ rv = !gComponentsSet.has(f.replace(/.*\//, ""));
+ }
+ if (!rv) {
+ foundReference = true;
+ if (useWhitelist) {
+ info(
+ "indirectly whitelisted file: " +
+ f +
+ " used from " +
+ listCodeReferences(gReferencesFromCode.get(f))
+ );
+ }
+ gReferencesFromCode.set(f, useWhitelist ? "whitelist" : null);
+ }
+ return rv;
+ });
+ return foundReference;
+ };
+ // First filter out the files that are referenced.
+ while (removeReferenced(false)) {
+ // As long as removeReferenced returns true, some files have been marked
+ // as referenced, so we need to run it again.
+ }
+ // Marked as referenced the files that have been explicitly whitelisted.
+ unreferencedFiles = unreferencedFiles.filter(file => {
+ if (whitelist.has(file)) {
+ whitelist.delete(file);
+ gReferencesFromCode.set(file, "whitelist-direct");
+ return false;
+ }
+ return true;
+ });
+ // Run the process again, this time when more files are marked as referenced,
+ // it's a consequence of the whitelist.
+ while (removeReferenced(true)) {
+ // As long as removeReferenced returns true, we need to run it again.
+ }
+
+ unreferencedFiles.sort();
+
+ if (isDevtools) {
+ // Bug 1351878 - handle devtools resource files
+ unreferencedFiles = unreferencedFiles.filter(file => {
+ if (file.startsWith("resource://")) {
+ info("unreferenced devtools resource file: " + file);
+ return false;
+ }
+ return true;
+ });
+ }
+
+ is(unreferencedFiles.length, 0, "there should be no unreferenced files");
+ for (let file of unreferencedFiles) {
+ let refs = gReferencesFromCode.get(file);
+ if (refs === undefined) {
+ ok(false, "unreferenced file: " + file);
+ } else {
+ let refList = listCodeReferences(refs);
+ let msg = "file only referenced from unreferenced files: " + file;
+ if (refList) {
+ msg += " referenced from " + refList;
+ }
+ ok(false, msg);
+ }
+ }
+
+ for (let file of whitelist) {
+ if (ignorableWhitelist.has(file)) {
+ info("ignored unused whitelist entry: " + file);
+ } else {
+ ok(false, "unused whitelist entry: " + file);
+ }
+ }
+
+ for (let [file, refs] of gReferencesFromCode) {
+ if (
+ isDevtools != devtoolsPrefixes.some(prefix => file.startsWith(prefix))
+ ) {
+ continue;
+ }
+
+ if (
+ (file.startsWith("chrome://") || file.startsWith("resource://")) &&
+ !(await chromeFileExists(file))
+ ) {
+ // Ignore chrome prefixes that have been automatically expanded.
+ let pathParts =
+ file.match("chrome://([^/]+)/content/([^/.]+).xul") ||
+ file.match("chrome://([^/]+)/skin/([^/.]+).css");
+ if (pathParts && pathParts[1] == pathParts[2]) {
+ continue;
+ }
+
+ // TODO: bug 1349010 - add a whitelist and make this reliable enough
+ // that we could make the test fail when this catches something new.
+ let refList = listCodeReferences(refs);
+ let msg = "missing file: " + file;
+ if (refList) {
+ msg += " referenced from " + refList;
+ }
+ info(msg);
+ }
+ }
+});
diff --git a/browser/base/content/test/static/browser_misused_characters_in_strings.js b/browser/base/content/test/static/browser_misused_characters_in_strings.js
new file mode 100644
index 0000000000..42be3b4392
--- /dev/null
+++ b/browser/base/content/test/static/browser_misused_characters_in_strings.js
@@ -0,0 +1,276 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/* This list allows pre-existing or 'unfixable' issues to remain, while we
+ * detect newly occurring issues in shipping files. It is a list of objects
+ * specifying conditions under which an error should be ignored.
+ *
+ * As each issue is found in the exceptions list, it is removed from the list.
+ * At the end of the test, there is an assertion that all items have been
+ * removed from the exceptions list, thus ensuring there are no stale
+ * entries. */
+let gExceptionsList = [
+ {
+ file: "layout_errors.properties",
+ key: "ImageMapRectBoundsError",
+ type: "double-quote",
+ },
+ {
+ file: "layout_errors.properties",
+ key: "ImageMapCircleWrongNumberOfCoords",
+ type: "double-quote",
+ },
+ {
+ file: "layout_errors.properties",
+ key: "ImageMapCircleNegativeRadius",
+ type: "double-quote",
+ },
+ {
+ file: "layout_errors.properties",
+ key: "ImageMapPolyWrongNumberOfCoords",
+ type: "double-quote",
+ },
+ {
+ file: "layout_errors.properties",
+ key: "ImageMapPolyOddNumberOfCoords",
+ type: "double-quote",
+ },
+ {
+ file: "dom.properties",
+ key: "PatternAttributeCompileFailure",
+ type: "single-quote",
+ },
+ // dom.properties is packaged twice so we need to have two exceptions for this string.
+ {
+ file: "dom.properties",
+ key: "PatternAttributeCompileFailure",
+ type: "single-quote",
+ },
+ {
+ file: "dom.properties",
+ key: "ImportMapExternalNotSupported",
+ type: "single-quote",
+ },
+ // dom.properties is packaged twice so we need to have two exceptions for this string.
+ {
+ file: "dom.properties",
+ key: "ImportMapExternalNotSupported",
+ type: "single-quote",
+ },
+];
+
+/**
+ * Check if an error should be ignored due to matching one of the exceptions
+ * defined in gExceptionsList.
+ *
+ * @param filepath The URI spec of the locale file
+ * @param key The key of the entity that is being checked
+ * @param type The type of error that has been found
+ * @return true if the error should be ignored, false otherwise.
+ */
+function ignoredError(filepath, key, type) {
+ for (let index in gExceptionsList) {
+ let exceptionItem = gExceptionsList[index];
+ if (
+ filepath.endsWith(exceptionItem.file) &&
+ key == exceptionItem.key &&
+ type == exceptionItem.type
+ ) {
+ gExceptionsList.splice(index, 1);
+ return true;
+ }
+ }
+ return false;
+}
+
+function testForError(filepath, key, str, pattern, type, helpText) {
+ if (str.match(pattern) && !ignoredError(filepath, key, type)) {
+ ok(false, `${filepath} with key=${key} has a misused ${type}. ${helpText}`);
+ }
+}
+
+function testForErrors(filepath, key, str) {
+ testForError(
+ filepath,
+ key,
+ str,
+ /(\w|^)'\w/,
+ "apostrophe",
+ "Strings with apostrophes should use foo\u2019s instead of foo's."
+ );
+ testForError(
+ filepath,
+ key,
+ str,
+ /\w\u2018\w/,
+ "incorrect-apostrophe",
+ "Strings with apostrophes should use foo\u2019s instead of foo\u2018s."
+ );
+ testForError(
+ filepath,
+ key,
+ str,
+ /'.+'/,
+ "single-quote",
+ "Single-quoted strings should use Unicode \u2018foo\u2019 instead of 'foo'."
+ );
+ testForError(
+ filepath,
+ key,
+ str,
+ /"/,
+ "double-quote",
+ 'Double-quoted strings should use Unicode \u201cfoo\u201d instead of "foo".'
+ );
+ testForError(
+ filepath,
+ key,
+ str,
+ /\.\.\./,
+ "ellipsis",
+ "Strings with an ellipsis should use the Unicode \u2026 character instead of three periods."
+ );
+}
+
+async function getAllTheFiles(extension) {
+ let appDirGreD = Services.dirsvc.get("GreD", Ci.nsIFile);
+ let appDirXCurProcD = Services.dirsvc.get("XCurProcD", Ci.nsIFile);
+ if (appDirGreD.contains(appDirXCurProcD)) {
+ return generateURIsFromDirTree(appDirGreD, [extension]);
+ }
+ if (appDirXCurProcD.contains(appDirGreD)) {
+ return generateURIsFromDirTree(appDirXCurProcD, [extension]);
+ }
+ let urisGreD = await generateURIsFromDirTree(appDirGreD, [extension]);
+ let urisXCurProcD = await generateURIsFromDirTree(appDirXCurProcD, [
+ extension,
+ ]);
+ return Array.from(new Set(urisGreD.concat(urisXCurProcD)));
+}
+
+add_task(async function checkAllTheProperties() {
+ // This asynchronously produces a list of URLs (sadly, mostly sync on our
+ // test infrastructure because it runs against jarfiles there, and
+ // our zipreader APIs are all sync)
+ let uris = await getAllTheFiles(".properties");
+ ok(
+ uris.length,
+ `Found ${uris.length} .properties files to scan for misused characters`
+ );
+
+ for (let uri of uris) {
+ let bundle = Services.strings.createBundle(uri.spec);
+
+ for (let entity of bundle.getSimpleEnumeration()) {
+ testForErrors(uri.spec, entity.key, entity.value);
+ }
+ }
+});
+
+var checkDTD = async function (aURISpec) {
+ let rawContents = await fetchFile(aURISpec);
+ // The regular expression below is adapted from:
+ // https://hg.mozilla.org/mozilla-central/file/68c0b7d6f16ce5bb023e08050102b5f2fe4aacd8/python/compare-locales/compare_locales/parser.py#l233
+ let entities = rawContents.match(
+ /<!ENTITY\s+([\w\.]*)\s+("[^"]*"|'[^']*')\s*>/g
+ );
+ if (!entities) {
+ // Some files have no entities defined.
+ return;
+ }
+ for (let entity of entities) {
+ let [, key, str] = entity.match(
+ /<!ENTITY\s+([\w\.]*)\s+("[^"]*"|'[^']*')\s*>/
+ );
+ // The matched string includes the enclosing quotation marks,
+ // we need to slice them off.
+ str = str.slice(1, -1);
+ testForErrors(aURISpec, key, str);
+ }
+};
+
+add_task(async function checkAllTheDTDs() {
+ let uris = await getAllTheFiles(".dtd");
+ ok(
+ uris.length,
+ `Found ${uris.length} .dtd files to scan for misused characters`
+ );
+ for (let uri of uris) {
+ await checkDTD(uri.spec);
+ }
+
+ // This support DTD file supplies a string with a newline to make sure
+ // the regex in checkDTD works correctly for that case.
+ let dtdLocation = gTestPath.replace(
+ /\/[^\/]*$/i,
+ "/bug1262648_string_with_newlines.dtd"
+ );
+ await checkDTD(dtdLocation);
+});
+
+add_task(async function checkAllTheFluents() {
+ let uris = await getAllTheFiles(".ftl");
+ let { FluentParser, Visitor } = ChromeUtils.import(
+ "resource://testing-common/FluentSyntax.jsm"
+ );
+
+ class TextElementVisitor extends Visitor {
+ constructor() {
+ super();
+ let domParser = new DOMParser();
+ domParser.forceEnableDTD();
+
+ this.domParser = domParser;
+ this.uri = null;
+ this.id = null;
+ this.attr = null;
+ }
+
+ visitMessage(node) {
+ this.id = node.id.name;
+ this.attr = null;
+ this.genericVisit(node);
+ }
+
+ visitTerm(node) {
+ this.id = node.id.name;
+ this.attr = null;
+ this.genericVisit(node);
+ }
+
+ visitAttribute(node) {
+ this.attr = node.id.name;
+ this.genericVisit(node);
+ }
+
+ get key() {
+ if (this.attr) {
+ return `${this.id}.${this.attr}`;
+ }
+ return this.id;
+ }
+
+ visitTextElement(node) {
+ const stripped_val = this.domParser.parseFromString(
+ "<!DOCTYPE html>" + node.value,
+ "text/html"
+ ).documentElement.textContent;
+ testForErrors(this.uri, this.key, stripped_val);
+ }
+ }
+
+ const ftlParser = new FluentParser({ withSpans: false });
+ const visitor = new TextElementVisitor();
+
+ for (let uri of uris) {
+ let rawContents = await fetchFile(uri.spec);
+ let ast = ftlParser.parse(rawContents);
+
+ visitor.uri = uri.spec;
+ visitor.visit(ast);
+ }
+});
+
+add_task(async function ensureExceptionsListIsEmpty() {
+ is(gExceptionsList.length, 0, "No remaining exceptions exist");
+});
diff --git a/browser/base/content/test/static/browser_parsable_css.js b/browser/base/content/test/static/browser_parsable_css.js
new file mode 100644
index 0000000000..6ff480fddc
--- /dev/null
+++ b/browser/base/content/test/static/browser_parsable_css.js
@@ -0,0 +1,590 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/* This list allows pre-existing or 'unfixable' CSS issues to remain, while we
+ * detect newly occurring issues in shipping CSS. It is a list of objects
+ * specifying conditions under which an error should be ignored.
+ *
+ * Every property of the objects in it needs to consist of a regular expression
+ * matching the offending error. If an object has multiple regex criteria, they
+ * ALL need to match an error in order for that error not to cause a test
+ * failure. */
+let whitelist = [
+ // CodeMirror is imported as-is, see bug 1004423.
+ { sourceName: /codemirror\.css$/i, isFromDevTools: true },
+ {
+ sourceName: /devtools\/content\/debugger\/src\/components\/([A-z\/]+).css/i,
+ isFromDevTools: true,
+ },
+ // Highlighter CSS uses a UA-only pseudo-class, see bug 985597.
+ {
+ sourceName: /highlighters\.css$/i,
+ errorMessage: /Unknown pseudo-class.*moz-native-anonymous/i,
+ isFromDevTools: true,
+ },
+ // UA-only media features.
+ {
+ sourceName: /\b(autocomplete-item)\.css$/,
+ errorMessage: /Expected media feature name but found \u2018-moz.*/i,
+ isFromDevTools: false,
+ platforms: ["windows"],
+ },
+ {
+ sourceName:
+ /\b(contenteditable|EditorOverride|svg|forms|html|mathml|ua)\.css$/i,
+ errorMessage: /Unknown pseudo-class.*-moz-/i,
+ isFromDevTools: false,
+ },
+ {
+ sourceName:
+ /\b(scrollbars|xul|html|mathml|ua|forms|svg|manageDialog|autocomplete-item-shared|formautofill)\.css$/i,
+ errorMessage: /Unknown property.*-moz-/i,
+ isFromDevTools: false,
+ },
+ {
+ sourceName: /(scrollbars|xul)\.css$/i,
+ errorMessage: /Unknown pseudo-class.*-moz-/i,
+ isFromDevTools: false,
+ },
+ // Reserved to UA sheets unless layout.css.overflow-clip-box.enabled flipped to true.
+ {
+ sourceName: /(?:res|gre-resources)\/forms\.css$/i,
+ errorMessage: /Unknown property.*overflow-clip-box/i,
+ isFromDevTools: false,
+ },
+ // These variables are declared somewhere else, and error when we load the
+ // files directly. They're all marked intermittent because their appearance
+ // in the error console seems to not be consistent.
+ {
+ sourceName: /jsonview\/css\/general\.css$/i,
+ intermittent: true,
+ errorMessage: /Property contained reference to invalid variable.*color/i,
+ isFromDevTools: true,
+ },
+ // PDF.js uses a property that is currently only supported in chrome.
+ {
+ sourceName: /web\/viewer\.css$/i,
+ errorMessage:
+ /Unknown property ‘text-size-adjust’\. {2}Declaration dropped\./i,
+ isFromDevTools: false,
+ },
+ {
+ sourceName: /overlay\.css$/i,
+ errorMessage: /Unknown pseudo-class.*moz-native-anonymous/i,
+ isFromDevTools: false,
+ },
+];
+
+if (!Services.prefs.getBoolPref("layout.css.color-mix.enabled")) {
+ // Reserved to UA sheets unless layout.css.color-mix.enabled flipped to true.
+ whitelist.push({
+ sourceName: /\b(autocomplete-item)\.css$/,
+ errorMessage: /Expected color but found \u2018color-mix\u2019./i,
+ isFromDevTools: false,
+ platforms: ["windows"],
+ });
+}
+
+if (!Services.prefs.getBoolPref("layout.css.math-depth.enabled")) {
+ // mathml.css UA sheet rule for math-depth.
+ whitelist.push({
+ sourceName: /\b(scrollbars|mathml)\.css$/i,
+ errorMessage: /Unknown property .*\bmath-depth\b/i,
+ isFromDevTools: false,
+ });
+}
+
+if (!Services.prefs.getBoolPref("layout.css.math-style.enabled")) {
+ // mathml.css UA sheet rule for math-style.
+ whitelist.push({
+ sourceName: /(?:res|gre-resources)\/mathml\.css$/i,
+ errorMessage: /Unknown property .*\bmath-style\b/i,
+ isFromDevTools: false,
+ });
+}
+
+if (!Services.prefs.getBoolPref("layout.css.scroll-anchoring.enabled")) {
+ whitelist.push({
+ sourceName: /webconsole\.css$/i,
+ errorMessage: /Unknown property .*\boverflow-anchor\b/i,
+ isFromDevTools: true,
+ });
+}
+
+if (!Services.prefs.getBoolPref("layout.css.forced-colors.enabled")) {
+ whitelist.push({
+ sourceName: /pdf\.js\/web\/viewer\.css$/,
+ errorMessage: /Expected media feature name but found ‘forced-colors’*/i,
+ isFromDevTools: false,
+ });
+}
+
+if (!Services.prefs.getBoolPref("layout.css.forced-color-adjust.enabled")) {
+ // PDF.js uses a property that is currently not enabled.
+ whitelist.push({
+ sourceName: /web\/viewer\.css$/i,
+ errorMessage:
+ /Unknown property ‘forced-color-adjust’\. {2}Declaration dropped\./i,
+ isFromDevTools: false,
+ });
+}
+
+let propNameWhitelist = [
+ // These custom properties are retrieved directly from CSSOM
+ // in videocontrols.xml to get pre-defined style instead of computed
+ // dimensions, which is why they are not referenced by CSS.
+ { propName: "--clickToPlay-width", isFromDevTools: false },
+ { propName: "--playButton-width", isFromDevTools: false },
+ { propName: "--muteButton-width", isFromDevTools: false },
+ { propName: "--castingButton-width", isFromDevTools: false },
+ { propName: "--closedCaptionButton-width", isFromDevTools: false },
+ { propName: "--fullscreenButton-width", isFromDevTools: false },
+ { propName: "--durationSpan-width", isFromDevTools: false },
+ { propName: "--durationSpan-width-long", isFromDevTools: false },
+ { propName: "--positionDurationBox-width", isFromDevTools: false },
+ { propName: "--positionDurationBox-width-long", isFromDevTools: false },
+
+ // These variables are used in a shorthand, but the CSS parser deletes the values
+ // when expanding the shorthands. See https://github.com/w3c/csswg-drafts/issues/2515
+ { propName: "--bezier-diagonal-color", isFromDevTools: true },
+
+ // This variable is used from CSS embedded in JS in adjustableTitle.js
+ { propName: "--icon-url", isFromDevTools: false },
+
+ // These are referenced from devtools files.
+ {
+ propName: "--browser-stack-z-index-devtools-splitter",
+ isFromDevTools: false,
+ },
+ { propName: "--browser-stack-z-index-rdm-toolbar", isFromDevTools: false },
+];
+
+// Add suffix to stylesheets' URI so that we always load them here and
+// have them parsed. Add a random number so that even if we run this
+// test multiple times, it would be unlikely to affect each other.
+const kPathSuffix = "?always-parse-css-" + Math.random();
+
+function dumpWhitelistItem(item) {
+ return JSON.stringify(item, (key, value) => {
+ return value instanceof RegExp ? value.toString() : value;
+ });
+}
+
+/**
+ * Check if an error should be ignored due to matching one of the whitelist
+ * objects defined in whitelist
+ *
+ * @param aErrorObject the error to check
+ * @return true if the error should be ignored, false otherwise.
+ */
+function ignoredError(aErrorObject) {
+ for (let whitelistItem of whitelist) {
+ let matches = true;
+ let catchAll = true;
+ for (let prop of ["sourceName", "errorMessage"]) {
+ if (whitelistItem.hasOwnProperty(prop)) {
+ catchAll = false;
+ if (!whitelistItem[prop].test(aErrorObject[prop] || "")) {
+ matches = false;
+ break;
+ }
+ }
+ }
+ if (catchAll) {
+ ok(
+ false,
+ "A whitelist item is catching all errors. " +
+ dumpWhitelistItem(whitelistItem)
+ );
+ continue;
+ }
+ if (matches) {
+ whitelistItem.used = true;
+ let { sourceName, errorMessage } = aErrorObject;
+ info(
+ `Ignored error "${errorMessage}" on ${sourceName} ` +
+ "because of whitelist item " +
+ dumpWhitelistItem(whitelistItem)
+ );
+ return true;
+ }
+ }
+ return false;
+}
+
+var gChromeReg = Cc["@mozilla.org/chrome/chrome-registry;1"].getService(
+ Ci.nsIChromeRegistry
+);
+var gChromeMap = new Map();
+
+var resHandler = Services.io
+ .getProtocolHandler("resource")
+ .QueryInterface(Ci.nsIResProtocolHandler);
+var gResourceMap = [];
+function trackResourcePrefix(prefix) {
+ let uri = Services.io.newURI("resource://" + prefix + "/");
+ gResourceMap.unshift([prefix, resHandler.resolveURI(uri)]);
+}
+trackResourcePrefix("gre");
+trackResourcePrefix("app");
+
+function getBaseUriForChromeUri(chromeUri) {
+ let chromeFile = chromeUri + "nonexistentfile.reallynothere";
+ let uri = Services.io.newURI(chromeFile);
+ let fileUri = gChromeReg.convertChromeURL(uri);
+ return fileUri.resolve(".");
+}
+
+function parseManifest(manifestUri) {
+ return fetchFile(manifestUri.spec).then(data => {
+ for (let line of data.split("\n")) {
+ let [type, ...argv] = line.split(/\s+/);
+ if (type == "content" || type == "skin") {
+ let chromeUri = `chrome://${argv[0]}/${type}/`;
+ gChromeMap.set(getBaseUriForChromeUri(chromeUri), chromeUri);
+ } else if (type == "resource") {
+ trackResourcePrefix(argv[0]);
+ }
+ }
+ });
+}
+
+function convertToCodeURI(fileUri) {
+ let baseUri = fileUri;
+ let path = "";
+ while (true) {
+ let slashPos = baseUri.lastIndexOf("/", baseUri.length - 2);
+ if (slashPos <= 0) {
+ // File not accessible from chrome protocol, try resource://
+ for (let res of gResourceMap) {
+ if (fileUri.startsWith(res[1])) {
+ return fileUri.replace(res[1], "resource://" + res[0] + "/");
+ }
+ }
+ // Give up and return the original URL.
+ return fileUri;
+ }
+ path = baseUri.slice(slashPos + 1) + path;
+ baseUri = baseUri.slice(0, slashPos + 1);
+ if (gChromeMap.has(baseUri)) {
+ return gChromeMap.get(baseUri) + path;
+ }
+ }
+}
+
+function messageIsCSSError(msg) {
+ // Only care about CSS errors generated by our iframe:
+ if (
+ msg instanceof Ci.nsIScriptError &&
+ msg.category.includes("CSS") &&
+ msg.sourceName.endsWith(kPathSuffix)
+ ) {
+ let sourceName = msg.sourceName.slice(0, -kPathSuffix.length);
+ let msgInfo = { sourceName, errorMessage: msg.errorMessage };
+ // Check if this error is whitelisted in whitelist
+ if (!ignoredError(msgInfo)) {
+ ok(false, `Got error message for ${sourceName}: ${msg.errorMessage}`);
+ return true;
+ }
+ }
+ return false;
+}
+
+let imageURIsToReferencesMap = new Map();
+let customPropsToReferencesMap = new Map();
+
+function neverMatches(mediaList) {
+ const perPlatformMediaQueryMap = {
+ macosx: ["(-moz-platform: macos)"],
+ win: [
+ "(-moz-platform: windows)",
+ "(-moz-platform: windows-win7)",
+ "(-moz-platform: windows-win8)",
+ "(-moz-platform: windows-win10)",
+ ],
+ linux: ["(-moz-platform: linux)"],
+ android: ["(-moz-platform: android)"],
+ };
+ for (let platform in perPlatformMediaQueryMap) {
+ const inThisPlatform = platform === AppConstants.platform;
+ for (const media of perPlatformMediaQueryMap[platform]) {
+ if (inThisPlatform && mediaList.mediaText == "not " + media) {
+ // This query can't match on this platform.
+ return true;
+ }
+ if (!inThisPlatform && mediaList.mediaText == media) {
+ // This query only matches on another platform that isn't ours.
+ return true;
+ }
+ }
+ }
+ return false;
+}
+
+function processCSSRules(container) {
+ for (let rule of container.cssRules) {
+ if (rule.media && neverMatches(rule.media)) {
+ continue;
+ }
+ if (rule.styleSheet) {
+ processCSSRules(rule.styleSheet); // @import
+ continue;
+ }
+ if (rule.cssRules) {
+ processCSSRules(rule); // @supports, @media, @layer (block), @keyframes
+ continue;
+ }
+ if (!rule.style) {
+ continue; // @layer (statement), @font-feature-values, @counter-style
+ }
+ // Extract urls from the css text.
+ // Note: CSSRule.style.cssText always has double quotes around URLs even
+ // when the original CSS file didn't.
+ let cssText = rule.style.cssText;
+ let urls = cssText.match(/url\("[^"]*"\)/g);
+ // Extract props by searching all "--" preceded by "var(" or a non-word
+ // character.
+ let props = cssText.match(/(var\(|\W|^)(--[\w\-]+)/g);
+ if (!urls && !props) {
+ continue;
+ }
+
+ for (let url of urls || []) {
+ // Remove the url(" prefix and the ") suffix.
+ url = url.replace(/url\("(.*)"\)/, "$1");
+ if (url.startsWith("data:")) {
+ continue;
+ }
+
+ // Make the url absolute and remove the ref.
+ let baseURI = Services.io.newURI(rule.parentStyleSheet.href);
+ url = Services.io.newURI(url, null, baseURI).specIgnoringRef;
+
+ // Store the image url along with the css file referencing it.
+ let baseUrl = baseURI.spec.split("?always-parse-css")[0];
+ if (!imageURIsToReferencesMap.has(url)) {
+ imageURIsToReferencesMap.set(url, new Set([baseUrl]));
+ } else {
+ imageURIsToReferencesMap.get(url).add(baseUrl);
+ }
+ }
+
+ for (let prop of props || []) {
+ if (prop.startsWith("var(")) {
+ prop = prop.substring(4);
+ let prevValue = customPropsToReferencesMap.get(prop) || 0;
+ customPropsToReferencesMap.set(prop, prevValue + 1);
+ } else {
+ // Remove the extra non-word character captured by the regular
+ // expression if needed.
+ if (prop[0] != "-") {
+ prop = prop.substring(1);
+ }
+ if (!customPropsToReferencesMap.has(prop)) {
+ customPropsToReferencesMap.set(prop, undefined);
+ }
+ }
+ }
+ }
+}
+
+function chromeFileExists(aURI) {
+ let available = 0;
+ try {
+ let channel = NetUtil.newChannel({
+ uri: aURI,
+ loadUsingSystemPrincipal: true,
+ });
+ let stream = channel.open();
+ let sstream = Cc["@mozilla.org/scriptableinputstream;1"].createInstance(
+ Ci.nsIScriptableInputStream
+ );
+ sstream.init(stream);
+ available = sstream.available();
+ sstream.close();
+ } catch (e) {
+ if (e.result != Cr.NS_ERROR_FILE_NOT_FOUND) {
+ dump("Checking " + aURI + ": " + e + "\n");
+ console.error(e);
+ }
+ }
+ return available > 0;
+}
+
+add_task(async function checkAllTheCSS() {
+ // Since we later in this test use Services.console.getMessageArray(),
+ // better to not have some messages from previous tests in the array.
+ Services.console.reset();
+
+ let appDir = Services.dirsvc.get("GreD", Ci.nsIFile);
+ // This asynchronously produces a list of URLs (sadly, mostly sync on our
+ // test infrastructure because it runs against jarfiles there, and
+ // our zipreader APIs are all sync)
+ let uris = await generateURIsFromDirTree(appDir, [".css", ".manifest"]);
+
+ // Create a clean iframe to load all the files into. This needs to live at a
+ // chrome URI so that it's allowed to load and parse any styles.
+ let testFile = getRootDirectory(gTestPath) + "dummy_page.html";
+ let { HiddenFrame } = ChromeUtils.importESModule(
+ "resource://gre/modules/HiddenFrame.sys.mjs"
+ );
+ let hiddenFrame = new HiddenFrame();
+ let win = await hiddenFrame.get();
+ let iframe = win.document.createElementNS(
+ "http://www.w3.org/1999/xhtml",
+ "html:iframe"
+ );
+ win.document.documentElement.appendChild(iframe);
+ let iframeLoaded = BrowserTestUtils.waitForEvent(iframe, "load", true);
+ iframe.contentWindow.location = testFile;
+ await iframeLoaded;
+ let doc = iframe.contentWindow.document;
+ iframe.contentWindow.docShell.cssErrorReportingEnabled = true;
+
+ // Parse and remove all manifests from the list.
+ // NOTE that this must be done before filtering out devtools paths
+ // so that all chrome paths can be recorded.
+ let manifestURIs = [];
+ uris = uris.filter(uri => {
+ if (uri.pathQueryRef.endsWith(".manifest")) {
+ manifestURIs.push(uri);
+ return false;
+ }
+ return true;
+ });
+ // Wait for all manifest to be parsed
+ await PerfTestHelpers.throttledMapPromises(manifestURIs, parseManifest);
+
+ // filter out either the devtools paths or the non-devtools paths:
+ let isDevtools = SimpleTest.harnessParameters.subsuite == "devtools";
+ let devtoolsPathBits = ["devtools"];
+ uris = uris.filter(
+ uri => isDevtools == devtoolsPathBits.some(path => uri.spec.includes(path))
+ );
+
+ let loadCSS = chromeUri =>
+ new Promise(resolve => {
+ let linkEl, onLoad, onError;
+ onLoad = e => {
+ processCSSRules(linkEl.sheet);
+ resolve();
+ linkEl.removeEventListener("load", onLoad);
+ linkEl.removeEventListener("error", onError);
+ };
+ onError = e => {
+ ok(
+ false,
+ "Loading " + linkEl.getAttribute("href") + " threw an error!"
+ );
+ resolve();
+ linkEl.removeEventListener("load", onLoad);
+ linkEl.removeEventListener("error", onError);
+ };
+ linkEl = doc.createElement("link");
+ linkEl.setAttribute("rel", "stylesheet");
+ linkEl.setAttribute("type", "text/css");
+ linkEl.addEventListener("load", onLoad);
+ linkEl.addEventListener("error", onError);
+ linkEl.setAttribute("href", chromeUri + kPathSuffix);
+ doc.head.appendChild(linkEl);
+ });
+
+ // We build a list of promises that get resolved when their respective
+ // files have loaded and produced no errors.
+ const kInContentCommonCSS = "chrome://global/skin/in-content/common.css";
+ let allPromises = uris
+ .map(uri => convertToCodeURI(uri.spec))
+ .filter(uri => uri !== kInContentCommonCSS);
+
+ // Make sure chrome://global/skin/in-content/common.css is loaded before other
+ // stylesheets in order to guarantee the --in-content variables can be
+ // correctly referenced.
+ if (allPromises.length !== uris.length) {
+ await loadCSS(kInContentCommonCSS);
+ }
+
+ // Wait for all the files to have actually loaded:
+ await PerfTestHelpers.throttledMapPromises(allPromises, loadCSS);
+
+ // Check if all the files referenced from CSS actually exist.
+ // Files in browser/ should never be referenced outside browser/.
+ for (let [image, references] of imageURIsToReferencesMap) {
+ if (!chromeFileExists(image)) {
+ for (let ref of references) {
+ ok(false, "missing " + image + " referenced from " + ref);
+ }
+ }
+
+ let imageHost = image.split("/")[2];
+ if (imageHost == "browser") {
+ for (let ref of references) {
+ let refHost = ref.split("/")[2];
+ if (!["activity-stream", "browser"].includes(refHost)) {
+ ok(
+ false,
+ "browser file " + image + " referenced outside browser in " + ref
+ );
+ }
+ }
+ }
+ }
+
+ // Check if all the properties that are defined are referenced.
+ for (let [prop, refCount] of customPropsToReferencesMap) {
+ if (!refCount) {
+ let ignored = false;
+ for (let item of propNameWhitelist) {
+ if (item.propName == prop && isDevtools == item.isFromDevTools) {
+ item.used = true;
+ if (
+ !item.platforms ||
+ item.platforms.includes(AppConstants.platform)
+ ) {
+ ignored = true;
+ }
+ break;
+ }
+ }
+ if (!ignored) {
+ ok(false, "custom property `" + prop + "` is not referenced");
+ }
+ }
+ }
+
+ let messages = Services.console.getMessageArray();
+ // Count errors (the test output will list actual issues for us, as well
+ // as the ok(false) in messageIsCSSError.
+ let errors = messages.filter(messageIsCSSError);
+ is(
+ errors.length,
+ 0,
+ "All the styles (" + allPromises.length + ") loaded without errors."
+ );
+
+ // Confirm that all whitelist rules have been used.
+ function checkWhitelist(list) {
+ for (let item of list) {
+ if (
+ !item.used &&
+ isDevtools == item.isFromDevTools &&
+ (!item.platforms || item.platforms.includes(AppConstants.platform)) &&
+ !item.intermittent
+ ) {
+ ok(false, "Unused whitelist item: " + dumpWhitelistItem(item));
+ }
+ }
+ }
+ checkWhitelist(whitelist);
+ checkWhitelist(propNameWhitelist);
+
+ // Clean up to avoid leaks:
+ doc.head.innerHTML = "";
+ doc = null;
+ iframe.remove();
+ iframe = null;
+ win = null;
+ hiddenFrame.destroy();
+ hiddenFrame = null;
+ imageURIsToReferencesMap = null;
+ customPropsToReferencesMap = null;
+});
diff --git a/browser/base/content/test/static/browser_parsable_script.js b/browser/base/content/test/static/browser_parsable_script.js
new file mode 100644
index 0000000000..982ef2f91a
--- /dev/null
+++ b/browser/base/content/test/static/browser_parsable_script.js
@@ -0,0 +1,167 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/* This list allows pre-existing or 'unfixable' JS issues to remain, while we
+ * detect newly occurring issues in shipping JS. It is a list of regexes
+ * matching files which have errors:
+ */
+
+requestLongerTimeout(2);
+
+const kWhitelist = new Set([
+ /browser\/content\/browser\/places\/controller.js$/,
+]);
+
+const kESModuleList = new Set([
+ /browser\/lockwise-card.js$/,
+ /browser\/monitor-card.js$/,
+ /browser\/proxy-card.js$/,
+ /browser\/vpn-card.js$/,
+ /toolkit\/content\/global\/certviewer\/components\/.*\.js$/,
+ /toolkit\/content\/global\/certviewer\/.*\.js$/,
+ /chrome\/pdfjs\/content\/web\/.*\.js$/,
+]);
+
+// Normally we would use reflect.jsm to get Reflect.parse. However, if
+// we do that, then all the AST data is allocated in reflect.jsm's
+// zone. That exposes a bug in our GC. The GC collects reflect.jsm's
+// zone but not the zone in which our test code lives (since no new
+// data is being allocated in it). The cross-compartment wrappers in
+// our zone that point to the AST data never get collected, and so the
+// AST data itself is never collected. We need to GC both zones at
+// once to fix the problem.
+const init = Cc["@mozilla.org/jsreflect;1"].createInstance();
+init();
+
+/**
+ * Check if an error should be ignored due to matching one of the whitelist
+ * objects defined in kWhitelist
+ *
+ * @param uri the uri to check against the whitelist
+ * @return true if the uri should be skipped, false otherwise.
+ */
+function uriIsWhiteListed(uri) {
+ for (let whitelistItem of kWhitelist) {
+ if (whitelistItem.test(uri.spec)) {
+ return true;
+ }
+ }
+ return false;
+}
+
+/**
+ * Check if a URI should be parsed as an ES module.
+ *
+ * @param uri the uri to check against the ES module list
+ * @return true if the uri should be parsed as a module, otherwise parse it as a script.
+ */
+function uriIsESModule(uri) {
+ if (uri.filePath.endsWith(".mjs")) {
+ return true;
+ }
+
+ for (let whitelistItem of kESModuleList) {
+ if (whitelistItem.test(uri.spec)) {
+ return true;
+ }
+ }
+ return false;
+}
+
+function parsePromise(uri, parseTarget) {
+ let promise = new Promise((resolve, reject) => {
+ let xhr = new XMLHttpRequest();
+ xhr.open("GET", uri, true);
+ xhr.onreadystatechange = function () {
+ if (this.readyState == this.DONE) {
+ let scriptText = this.responseText;
+ try {
+ info(`Checking ${parseTarget} ${uri}`);
+ let parseOpts = {
+ source: uri,
+ target: parseTarget,
+ };
+ Reflect.parse(scriptText, parseOpts);
+ resolve(true);
+ } catch (ex) {
+ let errorMsg = "Script error reading " + uri + ": " + ex;
+ ok(false, errorMsg);
+ resolve(false);
+ }
+ }
+ };
+ xhr.onerror = error => {
+ ok(false, "XHR error reading " + uri + ": " + error);
+ resolve(false);
+ };
+ xhr.overrideMimeType("application/javascript");
+ xhr.send(null);
+ });
+ return promise;
+}
+
+add_task(async function checkAllTheJS() {
+ // In debug builds, even on a fast machine, collecting the file list may take
+ // more than 30 seconds, and parsing all files may take four more minutes.
+ // For this reason, this test must be explictly requested in debug builds by
+ // using the "--setpref parse=<filter>" argument to mach. You can specify:
+ // - A case-sensitive substring of the file name to test (slow).
+ // - A single absolute URI printed out by a previous run (fast).
+ // - An empty string to run the test on all files (slowest).
+ let parseRequested = Services.prefs.prefHasUserValue("parse");
+ let parseValue = parseRequested && Services.prefs.getCharPref("parse");
+ if (SpecialPowers.isDebugBuild) {
+ if (!parseRequested) {
+ ok(
+ true,
+ "Test disabled on debug build. To run, execute: ./mach" +
+ " mochitest-browser --setpref parse=<case_sensitive_filter>" +
+ " browser/base/content/test/general/browser_parsable_script.js"
+ );
+ return;
+ }
+ // Request a 15 minutes timeout (30 seconds * 30) for debug builds.
+ requestLongerTimeout(30);
+ }
+
+ let uris;
+ // If an absolute URI is specified on the command line, use it immediately.
+ if (parseValue && parseValue.includes(":")) {
+ uris = [NetUtil.newURI(parseValue)];
+ } else {
+ let appDir = Services.dirsvc.get("GreD", Ci.nsIFile);
+ // This asynchronously produces a list of URLs (sadly, mostly sync on our
+ // test infrastructure because it runs against jarfiles there, and
+ // our zipreader APIs are all sync)
+ let startTimeMs = Date.now();
+ info("Collecting URIs");
+ uris = await generateURIsFromDirTree(appDir, [".js", ".jsm", ".mjs"]);
+ info("Collected URIs in " + (Date.now() - startTimeMs) + "ms");
+
+ // Apply the filter specified on the command line, if any.
+ if (parseValue) {
+ uris = uris.filter(uri => {
+ if (uri.spec.includes(parseValue)) {
+ return true;
+ }
+ info("Not checking filtered out " + uri.spec);
+ return false;
+ });
+ }
+ }
+
+ // We create an array of promises so we can parallelize all our parsing
+ // and file loading activity:
+ await PerfTestHelpers.throttledMapPromises(uris, uri => {
+ if (uriIsWhiteListed(uri)) {
+ info("Not checking whitelisted " + uri.spec);
+ return undefined;
+ }
+ let target = "script";
+ if (uriIsESModule(uri)) {
+ target = "module";
+ }
+ return parsePromise(uri.spec, target);
+ });
+ ok(true, "All files parsed");
+});
diff --git a/browser/base/content/test/static/browser_sentence_case_strings.js b/browser/base/content/test/static/browser_sentence_case_strings.js
new file mode 100644
index 0000000000..e995f76b1a
--- /dev/null
+++ b/browser/base/content/test/static/browser_sentence_case_strings.js
@@ -0,0 +1,279 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * This test file checks that our en-US builds use sentence case strings
+ * where appropriate. It's not exhaustive - some panels will show different
+ * items in different states, and this test doesn't iterate all of them.
+ */
+
+/* global PanelUI */
+
+const { CustomizableUITestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/CustomizableUITestUtils.sys.mjs"
+);
+
+const { AppMenuNotifications } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppMenuNotifications.sys.mjs"
+);
+
+// These are brand names, proper names, or other things that we expect to
+// not abide exactly to sentence case. NAMES is for single words, and PHRASES
+// is for words in a specific order.
+const NAMES = new Set(["Mozilla", "Nightly", "Firefox"]);
+const PHRASES = new Set(["Troubleshoot Mode…"]);
+
+let gCUITestUtils = new CustomizableUITestUtils(window);
+let gLocalization = new Localization(["browser/newtab/asrouter.ftl"], true);
+
+/**
+ * This recursive function will take the current main or subview, find all of
+ * the buttons that navigate to subviews inside it, and click each one
+ * individually. Upon entering the new view, we recurse. When the subviews
+ * within a view have been exhausted, we go back up a level.
+ *
+ * @generator
+ * @param {<xul:panelview>} parentView The view to start scanning for
+ * subviews.
+ * @yields {<xul:panelview>} Each found <xul:panelview>, in depth-first search
+ * order.
+ */
+async function* iterateSubviews(parentView) {
+ let navButtons = Array.from(
+ // Ensure that only enabled buttons are tested
+ parentView.querySelectorAll(".subviewbutton-nav:not([disabled])")
+ );
+ if (!navButtons) {
+ return;
+ }
+
+ for (let button of navButtons) {
+ info("Click " + button.id);
+ let panel = parentView.closest("panel");
+ let panelmultiview = parentView.closest("panelmultiview");
+ let promiseViewShown = BrowserTestUtils.waitForEvent(panel, "ViewShown");
+ button.click();
+ let viewShownEvent = await promiseViewShown;
+
+ yield viewShownEvent.originalTarget;
+
+ info("Shown " + viewShownEvent.originalTarget.id);
+ yield* iterateSubviews(viewShownEvent.originalTarget);
+ promiseViewShown = BrowserTestUtils.waitForEvent(parentView, "ViewShown");
+ panelmultiview.goBack();
+ await promiseViewShown;
+ }
+}
+
+/**
+ * Given a <xul:panelview>, look for <xul:toolbarbutton> descendants, extract
+ * any relevant strings from them, and check to see if they are in sentence
+ * case. By default, labels, textContent, and toolTipText (including dynamic
+ * toolTipText) are checked.
+ *
+ * @param {<xul:panelview>} view The <xul:panelview> to check.
+ */
+function checkToolbarButtons(view) {
+ let toolbarbuttons = view.querySelectorAll("toolbarbutton");
+ info("Checking toolbarbuttons in subview with id " + view.id);
+
+ for (let toolbarbutton of toolbarbuttons) {
+ let strings = [
+ toolbarbutton.label,
+ toolbarbutton.textContent,
+ toolbarbutton.toolTipText,
+ GetDynamicShortcutTooltipText(toolbarbutton.id),
+ ];
+ info("Checking toolbarbutton " + toolbarbutton.id);
+ for (let string of strings) {
+ checkSentenceCase(string, toolbarbutton.id);
+ }
+ }
+}
+
+function checkSubheaders(view) {
+ let subheaders = view.querySelectorAll("h2");
+ info("Checking subheaders in subview with id " + view.id);
+
+ for (let subheader of subheaders) {
+ checkSentenceCase(subheader.textContent, subheader.id);
+ }
+}
+
+async function checkUpdateBanner(view) {
+ let banner = view.querySelector("#appMenu-proton-update-banner");
+
+ const notifications = [
+ "update-downloading",
+ "update-available",
+ "update-manual",
+ "update-unsupported",
+ "update-restart",
+ ];
+
+ for (const notification of notifications) {
+ // Forcibly remove the label in order to wait for the new label.
+ banner.removeAttribute("label");
+
+ let labelPromise = BrowserTestUtils.waitForMutationCondition(
+ banner,
+ { attributes: true, attributeFilter: ["label"] },
+ () => !!banner.getAttribute("label")
+ );
+
+ AppMenuNotifications.showNotification(notification);
+
+ await labelPromise;
+
+ checkSentenceCase(banner.label, banner.id);
+
+ AppMenuNotifications.removeNotification(/.*/);
+ }
+}
+
+/**
+ * Asserts whether or not a string matches sentence case.
+ *
+ * @param {String} string The string to check for sentence case.
+ * @param {String} elementID The ID of the element being tested. This is
+ * mainly used for the assertion message to make it easier to debug
+ * failures, but items without IDs will not be checked (as these are
+ * likely using dynamic strings, like bookmarked page titles).
+ */
+function checkSentenceCase(string, elementID) {
+ if (!string || !elementID) {
+ return;
+ }
+
+ info("Testing string: " + string);
+
+ let words = string.trim().split(/\s+/);
+
+ // We expect that the first word is always capitalized. If it isn't,
+ // there's no need to keep checking the rest of the string, since we're
+ // going to fail the assertion.
+ let result = hasExpectedCapitalization(words[0], true);
+ if (result) {
+ for (let wordIndex = 1; wordIndex < words.length; ++wordIndex) {
+ let word = words[wordIndex];
+
+ if (word) {
+ if (isPartOfPhrase(words, wordIndex)) {
+ result = hasExpectedCapitalization(word, true);
+ } else {
+ let isName = NAMES.has(word);
+ result = hasExpectedCapitalization(word, isName);
+ }
+ if (!result) {
+ break;
+ }
+ }
+ }
+ }
+
+ Assert.ok(result, `${string} for ${elementID} should have sentence casing.`);
+}
+
+/**
+ * Returns true if a word is part of a phrase defined in the PHRASES set.
+ * The function will see if the word is contained within any of the defined
+ * PHRASES, and will then scan back and forward within the words array to
+ * to see if the word is indeed part of the phrase in context.
+ *
+ * @param {Array} words The full array of words being checked by the caller.
+ * @param {Number} wordIndex The index of the word being checked within the
+ * words array.
+ * @return {Boolean}
+ */
+function isPartOfPhrase(words, wordIndex) {
+ let word = words[wordIndex];
+
+ info(`Checking if ${word} is part of a phrase`);
+
+ for (let phrase of PHRASES) {
+ let phraseFragments = phrase.split(" ");
+ let fragmentIndex = phraseFragments.indexOf(word);
+
+ // If we didn't find the word within this phrase, the candidate phrase
+ // has more words than what we're analyzing, or the word doesn't have
+ // enough words before it to match the candidate phrase, then move on.
+ if (
+ fragmentIndex == -1 ||
+ words.length - phraseFragments.length < 0 ||
+ fragmentIndex > wordIndex
+ ) {
+ continue;
+ }
+
+ let wordsSlice = words.slice(
+ wordIndex - fragmentIndex,
+ wordIndex + phraseFragments.length
+ );
+ let matches = wordsSlice.every((w, index) => {
+ return phraseFragments[index] === w;
+ });
+
+ if (matches) {
+ info(`${word} is part of phrase ${phrase}`);
+ return true;
+ }
+ }
+
+ return false;
+}
+
+/**
+ * Tests that the strings under the AppMenu are in sentence case.
+ */
+add_task(async function test_sentence_case_appmenu() {
+ // Some of these panels are lazy, so it's necessary to open them in
+ // order for them to be inserted into the DOM.
+ await gCUITestUtils.openMainMenu();
+ registerCleanupFunction(async () => {
+ await gCUITestUtils.hideMainMenu();
+ });
+
+ checkToolbarButtons(PanelUI.mainView);
+ checkSubheaders(PanelUI.mainView);
+
+ for await (const view of iterateSubviews(PanelUI.mainView)) {
+ checkToolbarButtons(view);
+ checkSubheaders(view);
+ }
+
+ await checkUpdateBanner(PanelUI.mainView);
+});
+
+/**
+ * Tests that the strings under the All Tabs panel are in sentence case.
+ */
+add_task(async function test_sentence_case_all_tabs_panel() {
+ gTabsPanel.init();
+
+ const allTabsView = document.getElementById("allTabsMenu-allTabsView");
+ let allTabsPopupShownPromise = BrowserTestUtils.waitForEvent(
+ allTabsView,
+ "ViewShown"
+ );
+ gTabsPanel.showAllTabsPanel();
+ await allTabsPopupShownPromise;
+
+ registerCleanupFunction(async () => {
+ let allTabsPopupHiddenPromise = BrowserTestUtils.waitForEvent(
+ allTabsView.panelMultiView,
+ "PanelMultiViewHidden"
+ );
+ gTabsPanel.hideAllTabsPanel();
+ await allTabsPopupHiddenPromise;
+ });
+
+ checkToolbarButtons(gTabsPanel.allTabsView);
+ checkSubheaders(gTabsPanel.allTabsView);
+
+ for await (const view of iterateSubviews(gTabsPanel.allTabsView)) {
+ checkToolbarButtons(view);
+ checkSubheaders(view);
+ }
+});
diff --git a/browser/base/content/test/static/browser_title_case_menus.js b/browser/base/content/test/static/browser_title_case_menus.js
new file mode 100644
index 0000000000..9251db057b
--- /dev/null
+++ b/browser/base/content/test/static/browser_title_case_menus.js
@@ -0,0 +1,158 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * This test file checks that our en-US builds use APA-style Title Case strings
+ * where appropriate.
+ */
+
+// MINOR_WORDS are words that are okay to not be capitalized when they're
+// mid-string.
+//
+// Source: https://apastyle.apa.org/style-grammar-guidelines/capitalization/title-case
+const MINOR_WORDS = [
+ "a",
+ "an",
+ "and",
+ "as",
+ "at",
+ "but",
+ "by",
+ "for",
+ "if",
+ "in",
+ "nor",
+ "of",
+ "off",
+ "on",
+ "or",
+ "per",
+ "so",
+ "the",
+ "to",
+ "up",
+ "via",
+ "yet",
+];
+
+/**
+ * Returns a generator that will yield all of the <xul:menupopups>
+ * beneath <xul:menu> elements within a given <xul:menubar>. Each
+ * <xul:menupopup> will have the "popupshowing" and "popupshown"
+ * event fired on them to give them an opportunity to fully populate
+ * themselves before being yielded.
+ *
+ * @generator
+ * @param {<xul:menubar>} menubar The <xul:menubar> to get <xul:menupopup>s
+ * for.
+ * @yields {<xul:menupopup>} The next <xul:menupopup> under the <xul:menubar>.
+ */
+async function* iterateMenuPopups(menubar) {
+ let menus = menubar.querySelectorAll("menu");
+
+ for (let menu of menus) {
+ for (let menupopup of menu.querySelectorAll("menupopup")) {
+ // We fake the popupshowing and popupshown events to give the menupopups
+ // an opportunity to fully populate themselves. We don't actually open
+ // the menupopups because this is not possible on macOS.
+ menupopup.dispatchEvent(
+ new MouseEvent("popupshowing", { bubbles: true })
+ );
+ menupopup.dispatchEvent(new MouseEvent("popupshown", { bubbles: true }));
+
+ yield menupopup;
+
+ // Just for good measure, we'll fire the popuphiding/popuphidden events
+ // after we close the menupopups.
+ menupopup.dispatchEvent(new MouseEvent("popuphiding", { bubbles: true }));
+ menupopup.dispatchEvent(new MouseEvent("popuphidden", { bubbles: true }));
+ }
+ }
+}
+
+/**
+ * Given a <xul:menupopup>, checks all of the child elements with label
+ * properties to see if those labels are Title Cased. Skips any elements that
+ * have an empty or undefined label property.
+ *
+ * @param {<xul:menupopup>} menupopup The <xul:menupopup> to check.
+ */
+function checkMenuItems(menupopup) {
+ info("Checking menupopup with id " + menupopup.id);
+ for (let child of menupopup.children) {
+ if (child.label) {
+ info("Checking menupopup child with id " + child.id);
+ checkTitleCase(child.label, child.id);
+ }
+ }
+}
+
+/**
+ * Given a string, checks that the string is in Title Case.
+ *
+ * @param {String} string The string to check.
+ * @param {String} elementID The ID of the element associated with the string.
+ * This is included in the assertion message.
+ */
+function checkTitleCase(string, elementID) {
+ if (!string || !elementID /* document this */) {
+ return;
+ }
+
+ let words = string.trim().split(/\s+/);
+
+ // We extract the first word, and always expect it to be capitalized,
+ // even if it's a short word like one of MINOR_WORDS.
+ let firstWord = words.shift();
+ let result = hasExpectedCapitalization(firstWord, true);
+ if (result) {
+ for (let word of words) {
+ if (word) {
+ let expectCapitalized = !MINOR_WORDS.includes(word);
+ result = hasExpectedCapitalization(word, expectCapitalized);
+ if (!result) {
+ break;
+ }
+ }
+ }
+ }
+
+ Assert.ok(result, `${string} for ${elementID} should have Title Casing.`);
+}
+
+/**
+ * On Windows, macOS and GTK/KDE Linux, menubars are expected to be in Title
+ * Case in order to feel native. This test iterates the menuitem labels of the
+ * main menubar to ensure the en-US strings are all in Title Case.
+ *
+ * We use APA-style Title Case for the menubar, rather than Photon-style Title
+ * Case (https://design.firefox.com/photon/copy/capitalization.html) to match
+ * the native platform conventions.
+ */
+add_task(async function apa_test_title_case_menubar() {
+ let newWin = await BrowserTestUtils.openNewBrowserWindow();
+ let menuToolbar = newWin.document.getElementById("main-menubar");
+
+ for await (const menupopup of iterateMenuPopups(menuToolbar)) {
+ checkMenuItems(menupopup);
+ }
+
+ await BrowserTestUtils.closeWindow(newWin);
+});
+
+/**
+ * This test iterates the menuitem labels of the macOS dock menu for the
+ * application to ensure the en-US strings are all in Title Case.
+ */
+add_task(async function apa_test_title_case_macos_dock_menu() {
+ if (AppConstants.platform != "macosx") {
+ return;
+ }
+
+ let hiddenWindow = Services.appShell.hiddenDOMWindow;
+ Assert.ok(hiddenWindow, "Could get at hidden window");
+ let menupopup = hiddenWindow.document.getElementById("menu_mac_dockmenu");
+ checkMenuItems(menupopup);
+});
diff --git a/browser/base/content/test/static/bug1262648_string_with_newlines.dtd b/browser/base/content/test/static/bug1262648_string_with_newlines.dtd
new file mode 100644
index 0000000000..86cbefa5bd
--- /dev/null
+++ b/browser/base/content/test/static/bug1262648_string_with_newlines.dtd
@@ -0,0 +1,3 @@
+<!ENTITY foo.bar "This string
+contains
+newlines!">
diff --git a/browser/base/content/test/static/dummy_page.html b/browser/base/content/test/static/dummy_page.html
new file mode 100644
index 0000000000..1a87e28408
--- /dev/null
+++ b/browser/base/content/test/static/dummy_page.html
@@ -0,0 +1,9 @@
+<html>
+<head>
+<title>Dummy test page</title>
+<meta http-equiv="Content-Type" content="text/html;charset=utf-8"></meta>
+</head>
+<body>
+<p>Dummy test page</p>
+</body>
+</html>
diff --git a/browser/base/content/test/static/head.js b/browser/base/content/test/static/head.js
new file mode 100644
index 0000000000..d9b978e853
--- /dev/null
+++ b/browser/base/content/test/static/head.js
@@ -0,0 +1,177 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/* Shorthand constructors to construct an nsI(Local)File and zip reader: */
+const LocalFile = new Components.Constructor(
+ "@mozilla.org/file/local;1",
+ Ci.nsIFile,
+ "initWithPath"
+);
+const ZipReader = new Components.Constructor(
+ "@mozilla.org/libjar/zip-reader;1",
+ "nsIZipReader",
+ "open"
+);
+
+const IS_ALPHA = /^[a-z]+$/i;
+
+var { PerfTestHelpers } = ChromeUtils.importESModule(
+ "resource://testing-common/PerfTestHelpers.sys.mjs"
+);
+
+/**
+ * Returns a promise that is resolved with a list of files that have one of the
+ * extensions passed, represented by their nsIURI objects, which exist inside
+ * the directory passed.
+ *
+ * @param dir the directory which to scan for files (nsIFile)
+ * @param extensions the extensions of files we're interested in (Array).
+ */
+function generateURIsFromDirTree(dir, extensions) {
+ if (!Array.isArray(extensions)) {
+ extensions = [extensions];
+ }
+ let dirQueue = [dir.path];
+ return (async function () {
+ let rv = [];
+ while (dirQueue.length) {
+ let nextDir = dirQueue.shift();
+ let { subdirs, files } = await iterateOverPath(nextDir, extensions);
+ dirQueue.push(...subdirs);
+ rv.push(...files);
+ }
+ return rv;
+ })();
+}
+
+/**
+ * Iterate over the children of |path| and find subdirectories and files with
+ * the given extension.
+ *
+ * This function recurses into ZIP and JAR archives as well.
+ *
+ * @param {string} path The path to check.
+ * @param {string[]} extensions The file extensions we're interested in.
+ *
+ * @returns {Promise<object>}
+ * A promise that resolves to an object containing the following
+ * properties:
+ * - files: an array of nsIURIs corresponding to
+ * files that match the extensions passed
+ * - subdirs: an array of paths for subdirectories we need to recurse
+ * into (handled by generateURIsFromDirTree above)
+ */
+async function iterateOverPath(path, extensions) {
+ const children = await IOUtils.getChildren(path);
+
+ const files = [];
+ const subdirs = [];
+
+ for (const entry of children) {
+ let stat;
+ try {
+ stat = await IOUtils.stat(entry);
+ } catch (error) {
+ if (error.name === "NotFoundError") {
+ // Ignore symlinks from prior builds to subsequently removed files
+ continue;
+ }
+ throw error;
+ }
+
+ if (stat.type === "directory") {
+ subdirs.push(entry);
+ } else if (extensions.some(extension => entry.endsWith(extension))) {
+ if (await IOUtils.exists(entry)) {
+ const spec = PathUtils.toFileURI(entry);
+ files.push(Services.io.newURI(spec));
+ }
+ } else if (
+ entry.endsWith(".ja") ||
+ entry.endsWith(".jar") ||
+ entry.endsWith(".zip") ||
+ entry.endsWith(".xpi")
+ ) {
+ const file = new LocalFile(entry);
+ for (const extension of extensions) {
+ files.push(...generateEntriesFromJarFile(file, extension));
+ }
+ }
+ }
+
+ return { files, subdirs };
+}
+
+/* Helper function to generate a URI spec (NB: not an nsIURI yet!)
+ * given an nsIFile object */
+function getURLForFile(file) {
+ let fileHandler = Services.io.getProtocolHandler("file");
+ fileHandler = fileHandler.QueryInterface(Ci.nsIFileProtocolHandler);
+ return fileHandler.getURLSpecFromActualFile(file);
+}
+
+/**
+ * A generator that generates nsIURIs for particular files found in jar files
+ * like omni.ja.
+ *
+ * @param jarFile an nsIFile object for the jar file that needs checking.
+ * @param extension the extension we're interested in.
+ */
+function* generateEntriesFromJarFile(jarFile, extension) {
+ let zr = new ZipReader(jarFile);
+ const kURIStart = getURLForFile(jarFile);
+
+ for (let entry of zr.findEntries("*" + extension + "$")) {
+ // Ignore the JS cache which is stored in omni.ja
+ if (entry.startsWith("jsloader") || entry.startsWith("jssubloader")) {
+ continue;
+ }
+ let entryURISpec = "jar:" + kURIStart + "!/" + entry;
+ yield Services.io.newURI(entryURISpec);
+ }
+ zr.close();
+}
+
+function fetchFile(uri) {
+ return new Promise((resolve, reject) => {
+ let xhr = new XMLHttpRequest();
+ xhr.responseType = "text";
+ xhr.open("GET", uri, true);
+ xhr.onreadystatechange = function () {
+ if (this.readyState != this.DONE) {
+ return;
+ }
+ try {
+ resolve(this.responseText);
+ } catch (ex) {
+ ok(false, `Script error reading ${uri}: ${ex}`);
+ resolve("");
+ }
+ };
+ xhr.onerror = error => {
+ ok(false, `XHR error reading ${uri}: ${error}`);
+ resolve("");
+ };
+ xhr.send(null);
+ });
+}
+
+/**
+ * Returns whether or not a word (presumably in en-US) is capitalized per
+ * expectations.
+ *
+ * @param {String} word The single word to check.
+ * @param {boolean} expectCapitalized True if the word should be capitalized.
+ * @returns {boolean} True if the word matches the expected capitalization.
+ */
+function hasExpectedCapitalization(word, expectCapitalized) {
+ let firstChar = word[0];
+ if (!IS_ALPHA.test(firstChar)) {
+ return true;
+ }
+
+ let isCapitalized = firstChar == firstChar.toLocaleUpperCase("en-US");
+ return isCapitalized == expectCapitalized;
+}
diff --git a/browser/base/content/test/statuspanel/browser.ini b/browser/base/content/test/statuspanel/browser.ini
new file mode 100644
index 0000000000..998c5ab3b3
--- /dev/null
+++ b/browser/base/content/test/statuspanel/browser.ini
@@ -0,0 +1,7 @@
+[DEFAULT]
+support-files =
+ head.js
+
+[browser_show_statuspanel_idn.js]
+skip-if = verify
+[browser_show_statuspanel_twice.js]
diff --git a/browser/base/content/test/statuspanel/browser_show_statuspanel_idn.js b/browser/base/content/test/statuspanel/browser_show_statuspanel_idn.js
new file mode 100644
index 0000000000..62e35448f0
--- /dev/null
+++ b/browser/base/content/test/statuspanel/browser_show_statuspanel_idn.js
@@ -0,0 +1,28 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TEST_PAGE_URL = encodeURI(
+ `data:text/html;charset=utf-8,<a id="foo" href="http://nic.xn--rhqv96g/">abc</a><span id="bar">def</span>`
+);
+const TEST_STATUS_TEXT = "nic.\u4E16\u754C";
+
+/**
+ * Test that if the StatusPanel displays an IDN
+ * (Bug 1450538).
+ */
+add_task(async function test_show_statuspanel_twice() {
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ TEST_PAGE_URL
+ );
+
+ let promise = promiseStatusPanelShown(window, TEST_STATUS_TEXT);
+ SpecialPowers.spawn(tab.linkedBrowser, [], async () => {
+ content.document.links[0].focus();
+ });
+ await promise;
+
+ await BrowserTestUtils.removeTab(tab);
+});
diff --git a/browser/base/content/test/statuspanel/browser_show_statuspanel_twice.js b/browser/base/content/test/statuspanel/browser_show_statuspanel_twice.js
new file mode 100644
index 0000000000..6ed9b6d3a8
--- /dev/null
+++ b/browser/base/content/test/statuspanel/browser_show_statuspanel_twice.js
@@ -0,0 +1,29 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// eslint-disable-next-line @microsoft/sdl/no-insecure-url
+const TEST_URL = "http://example.com";
+
+/**
+ * Test that if the StatusPanel is shown for a link, and then
+ * hidden, that it can be shown again for that same link.
+ * (Bug 1445455).
+ */
+add_task(async function test_show_statuspanel_twice() {
+ let win = await BrowserTestUtils.openNewBrowserWindow();
+ win.XULBrowserWindow.overLink = TEST_URL;
+ win.StatusPanel.update();
+ await promiseStatusPanelShown(win, TEST_URL);
+
+ win.XULBrowserWindow.overLink = "";
+ win.StatusPanel.update();
+ await promiseStatusPanelHidden(win);
+
+ win.XULBrowserWindow.overLink = TEST_URL;
+ win.StatusPanel.update();
+ await promiseStatusPanelShown(win, TEST_URL);
+
+ await BrowserTestUtils.closeWindow(win);
+});
diff --git a/browser/base/content/test/statuspanel/head.js b/browser/base/content/test/statuspanel/head.js
new file mode 100644
index 0000000000..23df2e6271
--- /dev/null
+++ b/browser/base/content/test/statuspanel/head.js
@@ -0,0 +1,58 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+/**
+ * Returns a Promise that resolves when a StatusPanel for a
+ * window has finished being shown. Also asserts that the
+ * text content of the StatusPanel matches a value.
+ *
+ * @param win (browser window)
+ * The window that the StatusPanel belongs to.
+ * @param value (string)
+ * The value that the StatusPanel should show.
+ * @returns Promise
+ */
+async function promiseStatusPanelShown(win, value) {
+ let panel = win.StatusPanel.panel;
+ info("Waiting to show panel");
+ await BrowserTestUtils.waitForEvent(panel, "transitionend", e => {
+ return (
+ e.propertyName === "opacity" &&
+ win.getComputedStyle(e.target).opacity == "1"
+ );
+ });
+
+ Assert.equal(win.StatusPanel._labelElement.value, value);
+}
+
+/**
+ * Returns a Promise that resolves when a StatusPanel for a
+ * window has finished being hidden.
+ *
+ * @param win (browser window)
+ * The window that the StatusPanel belongs to.
+ */
+async function promiseStatusPanelHidden(win) {
+ let panel = win.StatusPanel.panel;
+ info("Waiting to hide panel");
+ await new Promise(resolve => {
+ let l = e => {
+ if (
+ e.propertyName === "opacity" &&
+ win.getComputedStyle(e.target).opacity == "0"
+ ) {
+ info("Panel hid after " + e.type + " event");
+ panel.removeEventListener("transitionend", l);
+ panel.removeEventListener("transitioncancel", l);
+ is(
+ getComputedStyle(panel).display,
+ "none",
+ "Should be hidden for good"
+ );
+ resolve();
+ }
+ };
+ panel.addEventListener("transitionend", l);
+ panel.addEventListener("transitioncancel", l);
+ });
+}
diff --git a/browser/base/content/test/sync/browser.ini b/browser/base/content/test/sync/browser.ini
new file mode 100644
index 0000000000..e7f6f889a0
--- /dev/null
+++ b/browser/base/content/test/sync/browser.ini
@@ -0,0 +1,13 @@
+[DEFAULT]
+support-files =
+ head.js
+
+[browser_contextmenu_sendpage.js]
+[browser_contextmenu_sendtab.js]
+[browser_fxa_badge.js]
+[browser_fxa_web_channel.js]
+https_first_disabled = true
+support-files=
+ browser_fxa_web_channel.html
+[browser_sync.js]
+[browser_synced_tabs_view.js]
diff --git a/browser/base/content/test/sync/browser_contextmenu_sendpage.js b/browser/base/content/test/sync/browser_contextmenu_sendpage.js
new file mode 100644
index 0000000000..503d246f6a
--- /dev/null
+++ b/browser/base/content/test/sync/browser_contextmenu_sendpage.js
@@ -0,0 +1,465 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const fxaDevices = [
+ {
+ id: 1,
+ name: "Foo",
+ availableCommands: { "https://identity.mozilla.com/cmd/open-uri": "baz" },
+ lastAccessTime: Date.now(),
+ },
+ {
+ id: 2,
+ name: "Bar",
+ availableCommands: { "https://identity.mozilla.com/cmd/open-uri": "boo" },
+ lastAccessTime: Date.now() + 60000, // add 30min
+ },
+ {
+ id: 3,
+ name: "Baz",
+ clientRecord: "bar",
+ lastAccessTime: Date.now() + 120000, // add 60min
+ }, // Legacy send tab target (no availableCommands).
+ { id: 4, name: "Homer" }, // Incompatible target.
+];
+
+add_setup(async function () {
+ await promiseSyncReady();
+ await Services.search.init();
+ // gSync.init() is called in a requestIdleCallback. Force its initialization.
+ gSync.init();
+ sinon
+ .stub(Weave.Service.clientsEngine, "getClientByFxaDeviceId")
+ .callsFake(fxaDeviceId => {
+ let target = fxaDevices.find(c => c.id == fxaDeviceId);
+ return target ? target.clientRecord : null;
+ });
+ sinon.stub(Weave.Service.clientsEngine, "getClientType").returns("desktop");
+ await BrowserTestUtils.openNewForegroundTab(gBrowser, "about:mozilla");
+});
+
+add_task(async function test_page_contextmenu() {
+ const sandbox = setupSendTabMocks({ fxaDevices });
+
+ await openContentContextMenu("#moztext", "context-sendpagetodevice");
+ is(
+ document.getElementById("context-sendpagetodevice").hidden,
+ false,
+ "Send page to device is shown"
+ );
+ is(
+ document.getElementById("context-sendpagetodevice").disabled,
+ false,
+ "Send page to device is enabled"
+ );
+ checkPopup([
+ { label: "Bar" },
+ { label: "Foo" },
+ "----",
+ { label: "Send to All Devices" },
+ { label: "Manage Devices..." },
+ ]);
+ await hideContentContextMenu();
+
+ sandbox.restore();
+});
+
+add_task(async function test_link_contextmenu() {
+ const sandbox = setupSendTabMocks({ fxaDevices });
+ let expectation = sandbox
+ .mock(gSync)
+ .expects("sendTabToDevice")
+ .once()
+ .withExactArgs(
+ "https://www.example.org/",
+ [fxaDevices[1]],
+ "Click on me!!"
+ );
+
+ // Add a link to the page
+ await SpecialPowers.spawn(gBrowser.selectedBrowser, [], () => {
+ let a = content.document.createElement("a");
+ a.href = "https://www.example.org";
+ a.id = "testingLink";
+ a.textContent = "Click on me!!";
+ content.document.body.appendChild(a);
+ });
+
+ let contextMenu = await openContentContextMenu(
+ "#testingLink",
+ "context-sendlinktodevice",
+ "context-sendlinktodevice-popup"
+ );
+
+ let expectedArray = ["context-openlinkintab"];
+
+ if (
+ Services.prefs.getBoolPref("privacy.userContext.enabled") &&
+ ContextualIdentityService.getPublicIdentities().length
+ ) {
+ expectedArray.push("context-openlinkinusercontext-menu");
+ }
+
+ expectedArray.push(
+ "context-openlink",
+ "context-openlinkprivate",
+ "context-sep-open",
+ "context-bookmarklink",
+ "context-savelink",
+ "context-savelinktopocket",
+ "context-copylink",
+ "context-sendlinktodevice",
+ "context-sep-sendlinktodevice",
+ "context-searchselect",
+ "frame-sep"
+ );
+
+ if (
+ Services.prefs.getBoolPref("devtools.accessibility.enabled", true) &&
+ (Services.prefs.getBoolPref("devtools.everOpened", false) ||
+ Services.prefs.getIntPref("devtools.selfxss.count", 0) > 0)
+ ) {
+ expectedArray.push("context-inspect-a11y");
+ }
+
+ expectedArray.push("context-inspect");
+
+ let menu = document.getElementById("contentAreaContextMenu");
+
+ for (let i = 0, j = 0; i < menu.children.length; i++) {
+ let item = menu.children[i];
+ if (item.hidden) {
+ continue;
+ }
+ Assert.equal(
+ item.id,
+ expectedArray[j],
+ "Ids in context menu match expected values"
+ );
+ j++;
+ }
+
+ is(
+ document.getElementById("context-sendlinktodevice").hidden,
+ false,
+ "Send link to device is shown"
+ );
+ is(
+ document.getElementById("context-sendlinktodevice").disabled,
+ false,
+ "Send link to device is enabled"
+ );
+ contextMenu.activateItem(
+ document
+ .getElementById("context-sendlinktodevice-popup")
+ .querySelector("menuitem")
+ );
+ await hideContentContextMenu();
+
+ expectation.verify();
+ sandbox.restore();
+});
+
+add_task(async function test_page_contextmenu_no_remote_clients() {
+ const sandbox = setupSendTabMocks({ fxaDevices: [] });
+
+ await openContentContextMenu("#moztext");
+ is(
+ document.getElementById("context-sendpagetodevice").hidden,
+ true,
+ "Send page to device is hidden"
+ );
+ is(
+ document.getElementById("context-sendpagetodevice").disabled,
+ false,
+ "Send tab to device is enabled"
+ );
+ checkPopup();
+ await hideContentContextMenu();
+
+ sandbox.restore();
+});
+
+add_task(async function test_page_contextmenu_one_remote_client() {
+ const sandbox = setupSendTabMocks({
+ fxaDevices: [
+ {
+ id: 1,
+ name: "Foo",
+ availableCommands: {
+ "https://identity.mozilla.com/cmd/open-uri": "baz",
+ },
+ },
+ ],
+ });
+
+ await openContentContextMenu("#moztext", "context-sendpagetodevice");
+ is(
+ document.getElementById("context-sendpagetodevice").hidden,
+ false,
+ "Send page to device is shown"
+ );
+ is(
+ document.getElementById("context-sendpagetodevice").disabled,
+ false,
+ "Send page to device is enabled"
+ );
+ checkPopup([{ label: "Foo" }]);
+ await hideContentContextMenu();
+
+ sandbox.restore();
+});
+
+add_task(async function test_page_contextmenu_not_sendable() {
+ const sandbox = setupSendTabMocks({ fxaDevices, isSendableURI: false });
+
+ await openContentContextMenu("#moztext");
+ is(
+ document.getElementById("context-sendpagetodevice").hidden,
+ true,
+ "Send page to device is hidden"
+ );
+ is(
+ document.getElementById("context-sendpagetodevice").disabled,
+ true,
+ "Send page to device is disabled"
+ );
+ checkPopup();
+ await hideContentContextMenu();
+
+ sandbox.restore();
+});
+
+add_task(async function test_page_contextmenu_not_synced_yet() {
+ const sandbox = setupSendTabMocks({ fxaDevices: null });
+
+ await openContentContextMenu("#moztext");
+ is(
+ document.getElementById("context-sendpagetodevice").hidden,
+ true,
+ "Send page to device is hidden"
+ );
+ is(
+ document.getElementById("context-sendpagetodevice").disabled,
+ true,
+ "Send page to device is disabled"
+ );
+ checkPopup();
+ await hideContentContextMenu();
+
+ sandbox.restore();
+});
+
+add_task(async function test_page_contextmenu_sync_not_ready_configured() {
+ const sandbox = setupSendTabMocks({ syncReady: false });
+
+ await openContentContextMenu("#moztext");
+ is(
+ document.getElementById("context-sendpagetodevice").hidden,
+ true,
+ "Send page to device is hidden"
+ );
+ is(
+ document.getElementById("context-sendpagetodevice").disabled,
+ true,
+ "Send page to device is disabled"
+ );
+ checkPopup();
+ await hideContentContextMenu();
+
+ sandbox.restore();
+});
+
+add_task(async function test_page_contextmenu_sync_not_ready_other_state() {
+ const sandbox = setupSendTabMocks({
+ syncReady: false,
+ state: UIState.STATUS_NOT_VERIFIED,
+ });
+
+ await openContentContextMenu("#moztext");
+ is(
+ document.getElementById("context-sendpagetodevice").hidden,
+ true,
+ "Send page to device is hidden"
+ );
+ is(
+ document.getElementById("context-sendpagetodevice").disabled,
+ false,
+ "Send page to device is enabled"
+ );
+ checkPopup();
+ await hideContentContextMenu();
+
+ sandbox.restore();
+});
+
+add_task(async function test_page_contextmenu_unconfigured() {
+ const sandbox = setupSendTabMocks({ state: UIState.STATUS_NOT_CONFIGURED });
+
+ await openContentContextMenu("#moztext");
+ is(
+ document.getElementById("context-sendpagetodevice").hidden,
+ true,
+ "Send page to device is hidden"
+ );
+ is(
+ document.getElementById("context-sendpagetodevice").disabled,
+ false,
+ "Send page to device is enabled"
+ );
+ checkPopup();
+
+ await hideContentContextMenu();
+
+ sandbox.restore();
+});
+
+add_task(async function test_page_contextmenu_not_verified() {
+ const sandbox = setupSendTabMocks({ state: UIState.STATUS_NOT_VERIFIED });
+
+ await openContentContextMenu("#moztext");
+ is(
+ document.getElementById("context-sendpagetodevice").hidden,
+ true,
+ "Send page to device is hidden"
+ );
+ is(
+ document.getElementById("context-sendpagetodevice").disabled,
+ false,
+ "Send page to device is enabled"
+ );
+ checkPopup();
+
+ await hideContentContextMenu();
+
+ sandbox.restore();
+});
+
+add_task(async function test_page_contextmenu_login_failed() {
+ const sandbox = setupSendTabMocks({ state: UIState.STATUS_LOGIN_FAILED });
+
+ await openContentContextMenu("#moztext");
+ is(
+ document.getElementById("context-sendpagetodevice").hidden,
+ true,
+ "Send page to device is hidden"
+ );
+ is(
+ document.getElementById("context-sendpagetodevice").disabled,
+ false,
+ "Send page to device is enabled"
+ );
+ checkPopup();
+
+ await hideContentContextMenu();
+
+ sandbox.restore();
+});
+
+add_task(async function test_page_contextmenu_fxa_disabled() {
+ const getter = sinon.stub(gSync, "FXA_ENABLED").get(() => false);
+ gSync.onFxaDisabled(); // Would have been called on gSync initialization if FXA_ENABLED had been set.
+ await openContentContextMenu("#moztext");
+ is(
+ document.getElementById("context-sendpagetodevice").hidden,
+ true,
+ "Send page to device is hidden"
+ );
+ await hideContentContextMenu();
+ getter.restore();
+ [...document.querySelectorAll(".sync-ui-item")].forEach(
+ e => (e.hidden = false)
+ );
+});
+
+// We are not going to bother testing the visibility of context-sendlinktodevice
+// since it uses the exact same code.
+// However, browser_contextmenu.js contains tests that verify its presence.
+
+add_task(async function teardown() {
+ Weave.Service.clientsEngine.getClientByFxaDeviceId.restore();
+ Weave.Service.clientsEngine.getClientType.restore();
+ gBrowser.removeCurrentTab();
+});
+
+function checkPopup(expectedItems = null) {
+ const popup = document.getElementById("context-sendpagetodevice-popup");
+ if (!expectedItems) {
+ is(popup.state, "closed", "Popup should be hidden.");
+ return;
+ }
+ const menuItems = popup.children;
+ for (let i = 0; i < menuItems.length; i++) {
+ const menuItem = menuItems[i];
+ const expectedItem = expectedItems[i];
+ if (expectedItem === "----") {
+ is(menuItem.nodeName, "menuseparator", "Found a separator");
+ continue;
+ }
+ is(menuItem.nodeName, "menuitem", "Found a menu item");
+ // Bug workaround, menuItem.label "…" encoding is different than ours.
+ is(
+ menuItem.label.normalize("NFKC"),
+ expectedItem.label,
+ "Correct menu item label"
+ );
+ is(
+ menuItem.disabled,
+ !!expectedItem.disabled,
+ "Correct menu item disabled state"
+ );
+ }
+ // check the length last - the above loop might have given us other clues...
+ is(
+ menuItems.length,
+ expectedItems.length,
+ "Popup has the expected children count."
+ );
+}
+
+async function openContentContextMenu(selector, openSubmenuId = null) {
+ const contextMenu = document.getElementById("contentAreaContextMenu");
+ is(contextMenu.state, "closed", "checking if popup is closed");
+
+ const awaitPopupShown = BrowserTestUtils.waitForEvent(
+ contextMenu,
+ "popupshown"
+ );
+ await BrowserTestUtils.synthesizeMouse(
+ selector,
+ 0,
+ 0,
+ {
+ type: "contextmenu",
+ button: 2,
+ shiftkey: false,
+ centered: true,
+ },
+ gBrowser.selectedBrowser
+ );
+ await awaitPopupShown;
+
+ if (openSubmenuId) {
+ const menu = document.getElementById(openSubmenuId);
+ const menuPopup = menu.menupopup;
+ const menuPopupPromise = BrowserTestUtils.waitForEvent(
+ menuPopup,
+ "popupshown"
+ );
+ menu.openMenu(true);
+ await menuPopupPromise;
+ }
+ return contextMenu;
+}
+
+async function hideContentContextMenu() {
+ const contextMenu = document.getElementById("contentAreaContextMenu");
+ const awaitPopupHidden = BrowserTestUtils.waitForEvent(
+ contextMenu,
+ "popuphidden"
+ );
+ contextMenu.hidePopup();
+ await awaitPopupHidden;
+}
diff --git a/browser/base/content/test/sync/browser_contextmenu_sendtab.js b/browser/base/content/test/sync/browser_contextmenu_sendtab.js
new file mode 100644
index 0000000000..4922869c1d
--- /dev/null
+++ b/browser/base/content/test/sync/browser_contextmenu_sendtab.js
@@ -0,0 +1,362 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const kForceOverflowWidthPx = 450;
+
+Services.scriptloader.loadSubScript(
+ "chrome://mochitests/content/browser/browser/base/content/test/general/head.js",
+ this
+);
+
+const fxaDevices = [
+ {
+ id: 1,
+ name: "Foo",
+ availableCommands: { "https://identity.mozilla.com/cmd/open-uri": "baz" },
+ lastAccessTime: Date.now(),
+ },
+ {
+ id: 2,
+ name: "Bar",
+ availableCommands: { "https://identity.mozilla.com/cmd/open-uri": "boo" },
+ lastAccessTime: Date.now() + 60000, // add 30min
+ },
+ {
+ id: 3,
+ name: "Baz",
+ clientRecord: "bar",
+ lastAccessTime: Date.now() + 120000, // add 60min
+ }, // Legacy send tab target (no availableCommands).
+ { id: 4, name: "Homer" }, // Incompatible target.
+];
+
+let [testTab] = gBrowser.visibleTabs;
+
+function updateTabContextMenu(tab = gBrowser.selectedTab) {
+ let menu = document.getElementById("tabContextMenu");
+ var evt = new Event("");
+ tab.dispatchEvent(evt);
+ // The TabContextMenu initializes its strings only on a focus or mouseover event.
+ // Calls focus event on the TabContextMenu early in the test
+ gBrowser.selectedTab.focus();
+ menu.openPopup(tab, "end_after", 0, 0, true, false, evt);
+ is(
+ window.TabContextMenu.contextTab,
+ tab,
+ "TabContextMenu context is the expected tab"
+ );
+ menu.hidePopup();
+}
+
+add_setup(async function () {
+ await promiseSyncReady();
+ await Services.search.init();
+ // gSync.init() is called in a requestIdleCallback. Force its initialization.
+ gSync.init();
+ sinon
+ .stub(Weave.Service.clientsEngine, "getClientByFxaDeviceId")
+ .callsFake(fxaDeviceId => {
+ let target = fxaDevices.find(c => c.id == fxaDeviceId);
+ return target ? target.clientRecord : null;
+ });
+ sinon.stub(Weave.Service.clientsEngine, "getClientType").returns("desktop");
+ await BrowserTestUtils.openNewForegroundTab(gBrowser, "about:mozilla");
+ registerCleanupFunction(() => {
+ gBrowser.removeCurrentTab();
+ });
+ is(gBrowser.visibleTabs.length, 2, "there are two visible tabs");
+});
+
+add_task(async function test_sendTabToDevice_callsFlushLogFile() {
+ const sandbox = setupSendTabMocks({ fxaDevices });
+ updateTabContextMenu(testTab);
+ await openTabContextMenu("context_sendTabToDevice");
+ let promiseObserved = promiseObserver("service:log-manager:flush-log-file");
+
+ await activateMenuItem();
+ await promiseObserved;
+ ok(true, "Got flush-log-file observer message");
+
+ await closeConfirmationHint();
+ sandbox.restore();
+});
+
+async function checkForConfirmationHint(targetId) {
+ const sandbox = setupSendTabMocks({ fxaDevices });
+ updateTabContextMenu(testTab);
+
+ await openTabContextMenu("context_sendTabToDevice");
+ await activateMenuItem();
+ is(
+ ConfirmationHint._panel.anchorNode.id,
+ targetId,
+ `Hint anchored to ${targetId}`
+ );
+ await closeConfirmationHint();
+ sandbox.restore();
+}
+
+add_task(async function test_sendTabToDevice_showsConfirmationHint_fxa() {
+ // We need to change the fxastatus from "not_configured" to show the FxA button.
+ is(
+ document.documentElement.getAttribute("fxastatus"),
+ "not_configured",
+ "FxA button is hidden"
+ );
+ document.documentElement.setAttribute("fxastatus", "foo");
+ await checkForConfirmationHint("fxa-toolbar-menu-button");
+ document.documentElement.setAttribute("fxastatus", "not_configured");
+});
+
+add_task(
+ async function test_sendTabToDevice_showsConfirmationHint_onOverflowMenu() {
+ // We need to change the fxastatus from "not_configured" to show the FxA button.
+ is(
+ document.documentElement.getAttribute("fxastatus"),
+ "not_configured",
+ "FxA button is hidden"
+ );
+ document.documentElement.setAttribute("fxastatus", "foo");
+
+ let navbar = document.getElementById("nav-bar");
+
+ // Resize the window so that the account button is in the overflow menu.
+ let originalWidth = window.outerWidth;
+ window.resizeTo(kForceOverflowWidthPx, window.outerHeight);
+ await TestUtils.waitForCondition(() => navbar.hasAttribute("overflowing"));
+
+ await checkForConfirmationHint("PanelUI-menu-button");
+ document.documentElement.setAttribute("fxastatus", "not_configured");
+
+ window.resizeTo(originalWidth, window.outerHeight);
+ await TestUtils.waitForCondition(() => !navbar.hasAttribute("overflowing"));
+ CustomizableUI.reset();
+ }
+);
+
+add_task(async function test_sendTabToDevice_showsConfirmationHint_appMenu() {
+ // If fxastatus is "not_configured" then the FxA button is hidden, and we
+ // should use the appMenu.
+ is(
+ document.documentElement.getAttribute("fxastatus"),
+ "not_configured",
+ "FxA button is hidden"
+ );
+ await checkForConfirmationHint("PanelUI-menu-button");
+});
+
+add_task(async function test_tab_contextmenu() {
+ const sandbox = setupSendTabMocks({ fxaDevices });
+ let expectation = sandbox
+ .mock(gSync)
+ .expects("sendTabToDevice")
+ .once()
+ .withExactArgs(
+ "about:mozilla",
+ [fxaDevices[1]],
+ "The Book of Mozilla, 6:27"
+ )
+ .returns(true);
+
+ updateTabContextMenu(testTab);
+ await openTabContextMenu("context_sendTabToDevice");
+ is(
+ document.getElementById("context_sendTabToDevice").hidden,
+ false,
+ "Send tab to device is shown"
+ );
+ is(
+ document.getElementById("context_sendTabToDevice").disabled,
+ false,
+ "Send tab to device is enabled"
+ );
+
+ await activateMenuItem();
+ await closeConfirmationHint();
+
+ expectation.verify();
+ sandbox.restore();
+});
+
+add_task(async function test_tab_contextmenu_unconfigured() {
+ const sandbox = setupSendTabMocks({ state: UIState.STATUS_NOT_CONFIGURED });
+
+ updateTabContextMenu(testTab);
+ is(
+ document.getElementById("context_sendTabToDevice").hidden,
+ true,
+ "Send tab to device is hidden"
+ );
+ is(
+ document.getElementById("context_sendTabToDevice").disabled,
+ false,
+ "Send tab to device is enabled"
+ );
+
+ sandbox.restore();
+});
+
+add_task(async function test_tab_contextmenu_not_sendable() {
+ const sandbox = setupSendTabMocks({ fxaDevices, isSendableURI: false });
+
+ updateTabContextMenu(testTab);
+ is(
+ document.getElementById("context_sendTabToDevice").hidden,
+ true,
+ "Send tab to device is hidden"
+ );
+ is(
+ document.getElementById("context_sendTabToDevice").disabled,
+ true,
+ "Send tab to device is disabled"
+ );
+
+ sandbox.restore();
+});
+
+add_task(async function test_tab_contextmenu_not_synced_yet() {
+ const sandbox = setupSendTabMocks({ fxaDevices: null });
+
+ updateTabContextMenu(testTab);
+ is(
+ document.getElementById("context_sendTabToDevice").hidden,
+ true,
+ "Send tab to device is hidden"
+ );
+ is(
+ document.getElementById("context_sendTabToDevice").disabled,
+ true,
+ "Send tab to device is disabled"
+ );
+
+ sandbox.restore();
+});
+
+add_task(async function test_tab_contextmenu_sync_not_ready_configured() {
+ const sandbox = setupSendTabMocks({ syncReady: false });
+
+ updateTabContextMenu(testTab);
+ is(
+ document.getElementById("context_sendTabToDevice").hidden,
+ true,
+ "Send tab to device is hidden"
+ );
+ is(
+ document.getElementById("context_sendTabToDevice").disabled,
+ true,
+ "Send tab to device is disabled"
+ );
+
+ sandbox.restore();
+});
+
+add_task(async function test_tab_contextmenu_sync_not_ready_other_state() {
+ const sandbox = setupSendTabMocks({
+ syncReady: false,
+ state: UIState.STATUS_NOT_VERIFIED,
+ });
+
+ updateTabContextMenu(testTab);
+ is(
+ document.getElementById("context_sendTabToDevice").hidden,
+ true,
+ "Send tab to device is hidden"
+ );
+ is(
+ document.getElementById("context_sendTabToDevice").disabled,
+ false,
+ "Send tab to device is enabled"
+ );
+
+ sandbox.restore();
+});
+
+add_task(async function test_tab_contextmenu_fxa_disabled() {
+ const getter = sinon.stub(gSync, "FXA_ENABLED").get(() => false);
+ // Simulate onFxaDisabled() being called on window open.
+ gSync.onFxaDisabled();
+
+ updateTabContextMenu(testTab);
+ is(
+ document.getElementById("context_sendTabToDevice").hidden,
+ true,
+ "Send tab to device is hidden"
+ );
+
+ getter.restore();
+ [...document.querySelectorAll(".sync-ui-item")].forEach(
+ e => (e.hidden = false)
+ );
+});
+
+add_task(async function teardown() {
+ Weave.Service.clientsEngine.getClientByFxaDeviceId.restore();
+ Weave.Service.clientsEngine.getClientType.restore();
+});
+
+async function openTabContextMenu(openSubmenuId = null) {
+ const contextMenu = document.getElementById("tabContextMenu");
+ is(contextMenu.state, "closed", "checking if popup is closed");
+
+ const awaitPopupShown = BrowserTestUtils.waitForEvent(
+ contextMenu,
+ "popupshown"
+ );
+ EventUtils.synthesizeMouseAtCenter(gBrowser.selectedTab, {
+ type: "contextmenu",
+ button: 2,
+ });
+ await awaitPopupShown;
+
+ if (openSubmenuId) {
+ const menuPopup = document.getElementById(openSubmenuId).menupopup;
+ const menuPopupPromise = BrowserTestUtils.waitForEvent(
+ menuPopup,
+ "popupshown"
+ );
+ menuPopup.openPopup();
+ await menuPopupPromise;
+ }
+}
+
+function promiseObserver(topic) {
+ return new Promise(resolve => {
+ let obs = (aSubject, aTopic, aData) => {
+ Services.obs.removeObserver(obs, aTopic);
+ resolve(aSubject);
+ };
+ Services.obs.addObserver(obs, topic);
+ });
+}
+
+function waitForConfirmationHint() {
+ return BrowserTestUtils.waitForEvent(ConfirmationHint._panel, "popuphidden");
+}
+
+async function activateMenuItem() {
+ let popupHidden = BrowserTestUtils.waitForEvent(
+ document.getElementById("tabContextMenu"),
+ "popuphidden"
+ );
+ let hintShown = BrowserTestUtils.waitForEvent(
+ ConfirmationHint._panel,
+ "popupshown"
+ );
+ let menuitem = document
+ .getElementById("context_sendTabToDevicePopupMenu")
+ .querySelector("menuitem");
+ menuitem.closest("menupopup").activateItem(menuitem);
+ await popupHidden;
+ await hintShown;
+}
+
+async function closeConfirmationHint() {
+ let hintHidden = BrowserTestUtils.waitForEvent(
+ ConfirmationHint._panel,
+ "popuphidden"
+ );
+ ConfirmationHint._panel.hidePopup();
+ await hintHidden;
+}
diff --git a/browser/base/content/test/sync/browser_fxa_badge.js b/browser/base/content/test/sync/browser_fxa_badge.js
new file mode 100644
index 0000000000..227d778d6c
--- /dev/null
+++ b/browser/base/content/test/sync/browser_fxa_badge.js
@@ -0,0 +1,70 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { AppMenuNotifications } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppMenuNotifications.sys.mjs"
+);
+
+add_task(async function test_unconfigured_no_badge() {
+ const oldUIState = UIState.get;
+
+ UIState.get = () => ({
+ status: UIState.STATUS_NOT_CONFIGURED,
+ });
+ Services.obs.notifyObservers(null, UIState.ON_UPDATE);
+ checkFxABadge(false);
+
+ UIState.get = oldUIState;
+});
+
+add_task(async function test_signedin_no_badge() {
+ const oldUIState = UIState.get;
+
+ UIState.get = () => ({
+ status: UIState.STATUS_SIGNED_IN,
+ lastSync: new Date(),
+ email: "foo@bar.com",
+ });
+ Services.obs.notifyObservers(null, UIState.ON_UPDATE);
+ checkFxABadge(false);
+
+ UIState.get = oldUIState;
+});
+
+add_task(async function test_unverified_badge_shown() {
+ const oldUIState = UIState.get;
+
+ UIState.get = () => ({
+ status: UIState.STATUS_NOT_VERIFIED,
+ email: "foo@bar.com",
+ });
+ Services.obs.notifyObservers(null, UIState.ON_UPDATE);
+ checkFxABadge(true);
+
+ UIState.get = oldUIState;
+});
+
+add_task(async function test_loginFailed_badge_shown() {
+ const oldUIState = UIState.get;
+
+ UIState.get = () => ({
+ status: UIState.STATUS_LOGIN_FAILED,
+ email: "foo@bar.com",
+ });
+ Services.obs.notifyObservers(null, UIState.ON_UPDATE);
+ checkFxABadge(true);
+
+ UIState.get = oldUIState;
+});
+
+function checkFxABadge(shouldBeShown) {
+ let fxaButton = document.getElementById("fxa-toolbar-menu-button");
+ let isShown =
+ fxaButton.hasAttribute("badge-status") ||
+ fxaButton
+ .querySelector(".toolbarbutton-badge")
+ .classList.contains("feature-callout");
+ is(isShown, shouldBeShown, "Fxa badge shown matches expected value.");
+}
diff --git a/browser/base/content/test/sync/browser_fxa_web_channel.html b/browser/base/content/test/sync/browser_fxa_web_channel.html
new file mode 100644
index 0000000000..927b3523e9
--- /dev/null
+++ b/browser/base/content/test/sync/browser_fxa_web_channel.html
@@ -0,0 +1,158 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>fxa_web_channel_test</title>
+</head>
+<body>
+<script>
+ var webChannelId = "account_updates_test";
+
+ window.onload = function() {
+ var testName = window.location.search.replace(/^\?/, "");
+
+ switch (testName) {
+ case "profile_change":
+ test_profile_change();
+ break;
+ case "login":
+ test_login();
+ break;
+ case "can_link_account":
+ test_can_link_account();
+ break;
+ case "logout":
+ test_logout();
+ break;
+ case "delete":
+ test_delete();
+ break;
+ case "firefox_view":
+ test_firefox_view();
+ break;
+ }
+ };
+
+ function test_profile_change() {
+ var event = new window.CustomEvent("WebChannelMessageToChrome", {
+ detail: JSON.stringify({
+ id: webChannelId,
+ message: {
+ command: "profile:change",
+ data: {
+ uid: "abc123",
+ },
+ },
+ }),
+ });
+
+ window.dispatchEvent(event);
+ }
+
+ function test_login() {
+ var event = new window.CustomEvent("WebChannelMessageToChrome", {
+ detail: JSON.stringify({
+ id: webChannelId,
+ message: {
+ command: "fxaccounts:login",
+ data: {
+ authAt: Date.now(),
+ email: "testuser@testuser.com",
+ keyFetchToken: "key_fetch_token",
+ sessionToken: "session_token",
+ uid: "uid",
+ unwrapBKey: "unwrap_b_key",
+ verified: true,
+ },
+ messageId: 1,
+ },
+ }),
+ });
+
+ window.dispatchEvent(event);
+ }
+
+ function test_can_link_account() {
+ window.addEventListener("WebChannelMessageToContent", function(e) {
+ // echo any responses from the browser back to the tests on the
+ // fxaccounts_webchannel_response_echo WebChannel. The tests are
+ // listening for events and do the appropriate checks.
+ var event = new window.CustomEvent("WebChannelMessageToChrome", {
+ detail: JSON.stringify({
+ id: "fxaccounts_webchannel_response_echo",
+ message: e.detail.message,
+ }),
+ });
+
+ window.dispatchEvent(event);
+ }, true);
+
+ var event = new window.CustomEvent("WebChannelMessageToChrome", {
+ detail: JSON.stringify({
+ id: webChannelId,
+ message: {
+ command: "fxaccounts:can_link_account",
+ data: {
+ email: "testuser@testuser.com",
+ },
+ messageId: 2,
+ },
+ }),
+ });
+
+ window.dispatchEvent(event);
+ }
+
+ function test_logout() {
+ var event = new window.CustomEvent("WebChannelMessageToChrome", {
+ detail: JSON.stringify({
+ id: webChannelId,
+ message: {
+ command: "fxaccounts:logout",
+ data: {
+ uid: "uid",
+ },
+ messageId: 3,
+ },
+ }),
+ });
+
+ window.dispatchEvent(event);
+ }
+
+ function test_delete() {
+ var event = new window.CustomEvent("WebChannelMessageToChrome", {
+ detail: JSON.stringify({
+ id: webChannelId,
+ message: {
+ command: "fxaccounts:delete",
+ data: {
+ uid: "uid",
+ },
+ messageId: 4,
+ },
+ }),
+ });
+
+ window.dispatchEvent(event);
+ }
+
+ function test_firefox_view() {
+ var event = new window.CustomEvent("WebChannelMessageToChrome", {
+ detail: JSON.stringify({
+ id: webChannelId,
+ message: {
+ command: "fxaccounts:firefox_view",
+ data: {
+ uid: "uid",
+ },
+ messageId: 5,
+ },
+ }),
+ });
+
+ window.dispatchEvent(event);
+ }
+</script>
+</body>
+</html>
diff --git a/browser/base/content/test/sync/browser_fxa_web_channel.js b/browser/base/content/test/sync/browser_fxa_web_channel.js
new file mode 100644
index 0000000000..903dd317ac
--- /dev/null
+++ b/browser/base/content/test/sync/browser_fxa_web_channel.js
@@ -0,0 +1,282 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+XPCOMUtils.defineLazyGetter(this, "FxAccountsCommon", function () {
+ return ChromeUtils.import("resource://gre/modules/FxAccountsCommon.js");
+});
+
+ChromeUtils.defineESModuleGetters(this, {
+ WebChannel: "resource://gre/modules/WebChannel.sys.mjs",
+});
+
+var { FxAccountsWebChannel } = ChromeUtils.importESModule(
+ "resource://gre/modules/FxAccountsWebChannel.sys.mjs"
+);
+
+// eslint-disable-next-line @microsoft/sdl/no-insecure-url
+const TEST_HTTP_PATH = "http://example.com";
+const TEST_BASE_URL =
+ TEST_HTTP_PATH +
+ "/browser/browser/base/content/test/sync/browser_fxa_web_channel.html";
+const TEST_CHANNEL_ID = "account_updates_test";
+
+var gTests = [
+ {
+ desc: "FxA Web Channel - should receive message about profile changes",
+ async run() {
+ let client = new FxAccountsWebChannel({
+ content_uri: TEST_HTTP_PATH,
+ channel_id: TEST_CHANNEL_ID,
+ });
+ let promiseObserver = new Promise((resolve, reject) => {
+ makeObserver(
+ FxAccountsCommon.ON_PROFILE_CHANGE_NOTIFICATION,
+ function (subject, topic, data) {
+ Assert.equal(data, "abc123");
+ client.tearDown();
+ resolve();
+ }
+ );
+ });
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_BASE_URL + "?profile_change",
+ },
+ async function () {
+ await promiseObserver;
+ }
+ );
+ },
+ },
+ {
+ desc: "fxa web channel - login messages should notify the fxAccounts object",
+ async run() {
+ let promiseLogin = new Promise((resolve, reject) => {
+ let login = accountData => {
+ Assert.equal(typeof accountData.authAt, "number");
+ Assert.equal(accountData.email, "testuser@testuser.com");
+ Assert.equal(accountData.keyFetchToken, "key_fetch_token");
+ Assert.equal(accountData.sessionToken, "session_token");
+ Assert.equal(accountData.uid, "uid");
+ Assert.equal(accountData.unwrapBKey, "unwrap_b_key");
+ Assert.equal(accountData.verified, true);
+
+ client.tearDown();
+ resolve();
+ };
+
+ let client = new FxAccountsWebChannel({
+ content_uri: TEST_HTTP_PATH,
+ channel_id: TEST_CHANNEL_ID,
+ helpers: {
+ login,
+ },
+ });
+ });
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_BASE_URL + "?login",
+ },
+ async function () {
+ await promiseLogin;
+ }
+ );
+ },
+ },
+ {
+ desc: "fxa web channel - can_link_account messages should respond",
+ async run() {
+ let properUrl = TEST_BASE_URL + "?can_link_account";
+
+ let promiseEcho = new Promise((resolve, reject) => {
+ let webChannelOrigin = Services.io.newURI(properUrl);
+ // responses sent to content are echoed back over the
+ // `fxaccounts_webchannel_response_echo` channel. Ensure the
+ // fxaccounts:can_link_account message is responded to.
+ let echoWebChannel = new WebChannel(
+ "fxaccounts_webchannel_response_echo",
+ webChannelOrigin
+ );
+ echoWebChannel.listen((webChannelId, message, target) => {
+ Assert.equal(message.command, "fxaccounts:can_link_account");
+ Assert.equal(message.messageId, 2);
+ Assert.equal(message.data.ok, true);
+
+ client.tearDown();
+ echoWebChannel.stopListening();
+
+ resolve();
+ });
+
+ let client = new FxAccountsWebChannel({
+ content_uri: TEST_HTTP_PATH,
+ channel_id: TEST_CHANNEL_ID,
+ helpers: {
+ shouldAllowRelink(acctName) {
+ return acctName === "testuser@testuser.com";
+ },
+ },
+ });
+ });
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: properUrl,
+ },
+ async function () {
+ await promiseEcho;
+ }
+ );
+ },
+ },
+ {
+ desc: "fxa web channel - logout messages should notify the fxAccounts object",
+ async run() {
+ let promiseLogout = new Promise((resolve, reject) => {
+ let logout = uid => {
+ Assert.equal(uid, "uid");
+
+ client.tearDown();
+ resolve();
+ };
+
+ let client = new FxAccountsWebChannel({
+ content_uri: TEST_HTTP_PATH,
+ channel_id: TEST_CHANNEL_ID,
+ helpers: {
+ logout,
+ },
+ });
+ });
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_BASE_URL + "?logout",
+ },
+ async function () {
+ await promiseLogout;
+ }
+ );
+ },
+ },
+ {
+ desc: "fxa web channel - delete messages should notify the fxAccounts object",
+ async run() {
+ let promiseDelete = new Promise((resolve, reject) => {
+ let logout = uid => {
+ Assert.equal(uid, "uid");
+
+ client.tearDown();
+ resolve();
+ };
+
+ let client = new FxAccountsWebChannel({
+ content_uri: TEST_HTTP_PATH,
+ channel_id: TEST_CHANNEL_ID,
+ helpers: {
+ logout,
+ },
+ });
+ });
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_BASE_URL + "?delete",
+ },
+ async function () {
+ await promiseDelete;
+ }
+ );
+ },
+ },
+ {
+ desc: "fxa web channel - firefox_view messages should call the openFirefoxView helper",
+ async run() {
+ let wasCalled = false;
+ let promiseMessageHandled = new Promise((resolve, reject) => {
+ let openFirefoxView = (browser, entryPoint) => {
+ wasCalled = true;
+ Assert.ok(
+ !!browser.ownerGlobal,
+ "openFirefoxView called with a browser argument"
+ );
+ Assert.equal(
+ typeof browser.ownerGlobal.FirefoxViewHandler.openTab,
+ "function",
+ "We can reach the openTab method"
+ );
+
+ client.tearDown();
+ resolve();
+ };
+
+ let client = new FxAccountsWebChannel({
+ content_uri: TEST_HTTP_PATH,
+ channel_id: TEST_CHANNEL_ID,
+ helpers: {
+ openFirefoxView,
+ },
+ });
+ });
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_BASE_URL + "?firefox_view",
+ },
+ async function () {
+ await promiseMessageHandled;
+ }
+ );
+ Assert.ok(wasCalled, "openFirefoxView did get called");
+ },
+ },
+]; // gTests
+
+function makeObserver(aObserveTopic, aObserveFunc) {
+ let callback = function (aSubject, aTopic, aData) {
+ if (aTopic == aObserveTopic) {
+ removeMe();
+ aObserveFunc(aSubject, aTopic, aData);
+ }
+ };
+
+ function removeMe() {
+ Services.obs.removeObserver(callback, aObserveTopic);
+ }
+
+ Services.obs.addObserver(callback, aObserveTopic);
+ return removeMe;
+}
+
+registerCleanupFunction(function () {
+ Services.prefs.clearUserPref(
+ "browser.tabs.remote.separatePrivilegedMozillaWebContentProcess"
+ );
+});
+
+function test() {
+ waitForExplicitFinish();
+ Services.prefs.setBoolPref(
+ "browser.tabs.remote.separatePrivilegedMozillaWebContentProcess",
+ false
+ );
+
+ (async function () {
+ for (let testCase of gTests) {
+ info("Running: " + testCase.desc);
+ await testCase.run();
+ }
+ })().then(finish, ex => {
+ Assert.ok(false, "Unexpected Exception: " + ex);
+ finish();
+ });
+}
diff --git a/browser/base/content/test/sync/browser_sync.js b/browser/base/content/test/sync/browser_sync.js
new file mode 100644
index 0000000000..a8354b7f10
--- /dev/null
+++ b/browser/base/content/test/sync/browser_sync.js
@@ -0,0 +1,751 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { CustomizableUITestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/CustomizableUITestUtils.sys.mjs"
+);
+
+let gCUITestUtils = new CustomizableUITestUtils(window);
+
+add_setup(async function () {
+ // gSync.init() is called in a requestIdleCallback. Force its initialization.
+ gSync.init();
+ // This preference gets set the very first time that the FxA menu gets opened,
+ // which can cause a state write to occur, which can confuse this test, since
+ // when in the signed-out state, we need to set the state _before_ opening
+ // the FxA menu (since the panel cannot be opened) in the signed out state.
+ await SpecialPowers.pushPrefEnv({
+ set: [["identity.fxaccounts.toolbar.accessed", true]],
+ });
+});
+
+add_task(async function test_ui_state_notification_calls_updateAllUI() {
+ let called = false;
+ let updateAllUI = gSync.updateAllUI;
+ gSync.updateAllUI = () => {
+ called = true;
+ };
+
+ Services.obs.notifyObservers(null, UIState.ON_UPDATE);
+ ok(called);
+
+ gSync.updateAllUI = updateAllUI;
+});
+
+add_task(async function test_navBar_button_visibility() {
+ const button = document.getElementById("fxa-toolbar-menu-button");
+ ok(button.closest("#nav-bar"), "button is in the #nav-bar");
+
+ const state = {
+ status: UIState.STATUS_NOT_CONFIGURED,
+ syncEnabled: true,
+ };
+ gSync.updateAllUI(state);
+ ok(
+ BrowserTestUtils.is_hidden(button),
+ "Button should be hidden with STATUS_NOT_CONFIGURED"
+ );
+
+ state.email = "foo@bar.com";
+ state.status = UIState.STATUS_NOT_VERIFIED;
+ gSync.updateAllUI(state);
+ ok(
+ BrowserTestUtils.is_visible(button),
+ "Check button visibility with STATUS_NOT_VERIFIED"
+ );
+
+ state.status = UIState.STATUS_LOGIN_FAILED;
+ gSync.updateAllUI(state);
+ ok(
+ BrowserTestUtils.is_visible(button),
+ "Check button visibility with STATUS_LOGIN_FAILED"
+ );
+
+ state.status = UIState.STATUS_SIGNED_IN;
+ gSync.updateAllUI(state);
+ ok(
+ BrowserTestUtils.is_visible(button),
+ "Check button visibility with STATUS_SIGNED_IN"
+ );
+
+ state.syncEnabled = false;
+ gSync.updateAllUI(state);
+ is(
+ BrowserTestUtils.is_visible(button),
+ true,
+ "Check button visibility when signed in, but sync disabled"
+ );
+});
+
+add_task(async function test_overflow_navBar_button_visibility() {
+ const button = document.getElementById("fxa-toolbar-menu-button");
+
+ let overflowPanel = document.getElementById("widget-overflow");
+ overflowPanel.setAttribute("animate", "false");
+ let navbar = document.getElementById(CustomizableUI.AREA_NAVBAR);
+ let originalWindowWidth = window.outerWidth;
+
+ registerCleanupFunction(function () {
+ overflowPanel.removeAttribute("animate");
+ window.resizeTo(originalWindowWidth, window.outerHeight);
+ return TestUtils.waitForCondition(
+ () => !navbar.hasAttribute("overflowing")
+ );
+ });
+
+ window.resizeTo(450, window.outerHeight);
+
+ await TestUtils.waitForCondition(() => navbar.hasAttribute("overflowing"));
+ ok(navbar.hasAttribute("overflowing"), "Should have an overflowing toolbar.");
+
+ let chevron = document.getElementById("nav-bar-overflow-button");
+ let shownPanelPromise = BrowserTestUtils.waitForEvent(
+ overflowPanel,
+ "popupshown"
+ );
+ chevron.click();
+ await shownPanelPromise;
+
+ ok(button, "fxa-toolbar-menu-button was found");
+
+ const state = {
+ status: UIState.STATUS_NOT_CONFIGURED,
+ syncEnabled: true,
+ };
+ gSync.updateAllUI(state);
+
+ ok(
+ BrowserTestUtils.is_hidden(button),
+ "Button should be hidden with STATUS_NOT_CONFIGURED"
+ );
+
+ let hidePanelPromise = BrowserTestUtils.waitForEvent(
+ overflowPanel,
+ "popuphidden"
+ );
+ chevron.click();
+ await hidePanelPromise;
+});
+
+add_task(async function setupForPanelTests() {
+ /* Proton hides the FxA toolbar button when in the nav-bar and unconfigured.
+ To test the panel in all states, we move it to the tabstrip toolbar where
+ it will always be visible.
+ */
+ CustomizableUI.addWidgetToArea(
+ "fxa-toolbar-menu-button",
+ CustomizableUI.AREA_TABSTRIP
+ );
+
+ // make sure it gets put back at the end of the tests
+ registerCleanupFunction(() => {
+ CustomizableUI.addWidgetToArea(
+ "fxa-toolbar-menu-button",
+ CustomizableUI.AREA_NAVBAR
+ );
+ });
+});
+
+add_task(async function test_ui_state_signedin() {
+ await BrowserTestUtils.openNewForegroundTab(gBrowser, "https://example.com/");
+
+ const relativeDateAnchor = new Date();
+ let state = {
+ status: UIState.STATUS_SIGNED_IN,
+ syncEnabled: true,
+ email: "foo@bar.com",
+ displayName: "Foo Bar",
+ avatarURL: "https://foo.bar",
+ lastSync: new Date(),
+ syncing: false,
+ };
+
+ const origRelativeTimeFormat = gSync.relativeTimeFormat;
+ gSync.relativeTimeFormat = {
+ formatBestUnit(date) {
+ return origRelativeTimeFormat.formatBestUnit(date, {
+ now: relativeDateAnchor,
+ });
+ },
+ };
+
+ gSync.updateAllUI(state);
+
+ await openFxaPanel();
+
+ checkMenuBarItem("sync-syncnowitem");
+ checkPanelHeader();
+ checkFxaToolbarButtonPanel({
+ headerTitle: "Manage account",
+ headerDescription: "foo@bar.com",
+ enabledItems: [
+ "PanelUI-fxa-menu-sendtab-button",
+ "PanelUI-fxa-menu-connect-device-button",
+ "PanelUI-fxa-menu-syncnow-button",
+ "PanelUI-fxa-menu-sync-prefs-button",
+ "PanelUI-fxa-menu-account-signout-button",
+ ],
+ disabledItems: [],
+ hiddenItems: ["PanelUI-fxa-menu-setup-sync-button"],
+ });
+ checkFxAAvatar("signedin");
+ gSync.relativeTimeFormat = origRelativeTimeFormat;
+ await closeFxaPanel();
+
+ await openMainPanel();
+
+ checkPanelUIStatusBar({
+ description: "foo@bar.com",
+ titleHidden: true,
+ hideFxAText: true,
+ });
+
+ await closeTabAndMainPanel();
+});
+
+add_task(async function test_ui_state_syncing_panel_closed() {
+ let state = {
+ status: UIState.STATUS_SIGNED_IN,
+ syncEnabled: true,
+ email: "foo@bar.com",
+ displayName: "Foo Bar",
+ avatarURL: "https://foo.bar",
+ lastSync: new Date(),
+ syncing: true,
+ };
+
+ gSync.updateAllUI(state);
+
+ checkSyncNowButtons(true);
+
+ // Be good citizens and remove the "syncing" state.
+ gSync.updateAllUI({
+ status: UIState.STATUS_SIGNED_IN,
+ syncEnabled: true,
+ email: "foo@bar.com",
+ lastSync: new Date(),
+ syncing: false,
+ });
+ // Because we switch from syncing to non-syncing, and there's a timeout involved.
+ await promiseObserver("test:browser-sync:activity-stop");
+});
+
+add_task(async function test_ui_state_syncing_panel_open() {
+ await BrowserTestUtils.openNewForegroundTab(gBrowser, "https://example.com/");
+
+ let state = {
+ status: UIState.STATUS_SIGNED_IN,
+ syncEnabled: true,
+ email: "foo@bar.com",
+ displayName: "Foo Bar",
+ avatarURL: "https://foo.bar",
+ lastSync: new Date(),
+ syncing: false,
+ };
+
+ gSync.updateAllUI(state);
+
+ await openFxaPanel();
+
+ checkSyncNowButtons(false);
+
+ state = {
+ status: UIState.STATUS_SIGNED_IN,
+ syncEnabled: true,
+ email: "foo@bar.com",
+ displayName: "Foo Bar",
+ avatarURL: "https://foo.bar",
+ lastSync: new Date(),
+ syncing: true,
+ };
+
+ gSync.updateAllUI(state);
+
+ checkSyncNowButtons(true);
+
+ // Be good citizens and remove the "syncing" state.
+ gSync.updateAllUI({
+ status: UIState.STATUS_SIGNED_IN,
+ syncEnabled: true,
+ email: "foo@bar.com",
+ lastSync: new Date(),
+ syncing: false,
+ });
+ // Because we switch from syncing to non-syncing, and there's a timeout involved.
+ await promiseObserver("test:browser-sync:activity-stop");
+
+ await closeFxaPanel();
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+});
+
+add_task(async function test_ui_state_panel_open_after_syncing() {
+ await BrowserTestUtils.openNewForegroundTab(gBrowser, "https://example.com/");
+
+ let state = {
+ status: UIState.STATUS_SIGNED_IN,
+ syncEnabled: true,
+ email: "foo@bar.com",
+ displayName: "Foo Bar",
+ avatarURL: "https://foo.bar",
+ lastSync: new Date(),
+ syncing: true,
+ };
+
+ gSync.updateAllUI(state);
+
+ await openFxaPanel();
+
+ checkSyncNowButtons(true);
+
+ // Be good citizens and remove the "syncing" state.
+ gSync.updateAllUI({
+ status: UIState.STATUS_SIGNED_IN,
+ syncEnabled: true,
+ email: "foo@bar.com",
+ lastSync: new Date(),
+ syncing: false,
+ });
+ // Because we switch from syncing to non-syncing, and there's a timeout involved.
+ await promiseObserver("test:browser-sync:activity-stop");
+
+ await closeFxaPanel();
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+});
+
+add_task(async function test_ui_state_unconfigured() {
+ await BrowserTestUtils.openNewForegroundTab(gBrowser, "https://example.com/");
+
+ let state = {
+ status: UIState.STATUS_NOT_CONFIGURED,
+ };
+
+ gSync.updateAllUI(state);
+
+ checkMenuBarItem("sync-setup");
+
+ checkFxAAvatar("not_configured");
+
+ let signedOffLabel = gSync.fluentStrings.formatValueSync(
+ "appmenu-fxa-signed-in-label"
+ );
+
+ await openMainPanel();
+
+ checkPanelUIStatusBar({
+ description: signedOffLabel,
+ titleHidden: true,
+ hideFxAText: false,
+ });
+ await closeTabAndMainPanel();
+});
+
+add_task(async function test_ui_state_syncdisabled() {
+ await BrowserTestUtils.openNewForegroundTab(gBrowser, "https://example.com/");
+
+ let state = {
+ status: UIState.STATUS_SIGNED_IN,
+ syncEnabled: false,
+ email: "foo@bar.com",
+ displayName: "Foo Bar",
+ avatarURL: "https://foo.bar",
+ };
+
+ gSync.updateAllUI(state);
+
+ await openFxaPanel();
+
+ checkMenuBarItem("sync-enable");
+ checkPanelHeader();
+ checkFxaToolbarButtonPanel({
+ headerTitle: "Manage account",
+ headerDescription: "foo@bar.com",
+ enabledItems: [
+ "PanelUI-fxa-menu-sendtab-button",
+ "PanelUI-fxa-menu-connect-device-button",
+ "PanelUI-fxa-menu-setup-sync-button",
+ "PanelUI-fxa-menu-account-signout-button",
+ ],
+ disabledItems: [],
+ hiddenItems: [
+ "PanelUI-fxa-menu-syncnow-button",
+ "PanelUI-fxa-menu-sync-prefs-button",
+ ],
+ });
+ checkFxAAvatar("signedin");
+ await closeFxaPanel();
+
+ await openMainPanel();
+
+ checkPanelUIStatusBar({
+ description: "foo@bar.com",
+ titleHidden: true,
+ hideFxAText: true,
+ });
+
+ await closeTabAndMainPanel();
+});
+
+add_task(async function test_ui_state_unverified() {
+ await BrowserTestUtils.openNewForegroundTab(gBrowser, "https://example.com/");
+
+ let state = {
+ status: UIState.STATUS_NOT_VERIFIED,
+ email: "foo@bar.com",
+ syncing: false,
+ };
+
+ gSync.updateAllUI(state);
+
+ await openFxaPanel();
+
+ const expectedLabel = gSync.fluentStrings.formatValueSync(
+ "account-finish-account-setup"
+ );
+
+ checkMenuBarItem("sync-unverifieditem");
+ checkPanelHeader();
+ checkFxaToolbarButtonPanel({
+ headerTitle: expectedLabel,
+ headerDescription: state.email,
+ enabledItems: [
+ "PanelUI-fxa-menu-sendtab-button",
+ "PanelUI-fxa-menu-setup-sync-button",
+ "PanelUI-fxa-menu-account-signout-button",
+ ],
+ disabledItems: ["PanelUI-fxa-menu-connect-device-button"],
+ hiddenItems: [
+ "PanelUI-fxa-menu-syncnow-button",
+ "PanelUI-fxa-menu-sync-prefs-button",
+ ],
+ });
+ checkFxAAvatar("unverified");
+ await closeFxaPanel();
+ await openMainPanel();
+
+ checkPanelUIStatusBar({
+ description: state.email,
+ title: expectedLabel,
+ titleHidden: false,
+ hideFxAText: true,
+ });
+
+ await closeTabAndMainPanel();
+});
+
+add_task(async function test_ui_state_loginFailed() {
+ await BrowserTestUtils.openNewForegroundTab(gBrowser, "https://example.com/");
+
+ let state = {
+ status: UIState.STATUS_LOGIN_FAILED,
+ email: "foo@bar.com",
+ };
+
+ gSync.updateAllUI(state);
+
+ await openFxaPanel();
+
+ const expectedLabel = gSync.fluentStrings.formatValueSync(
+ "account-disconnected2"
+ );
+
+ checkMenuBarItem("sync-reauthitem");
+ checkPanelHeader();
+ checkFxaToolbarButtonPanel({
+ headerTitle: expectedLabel,
+ headerDescription: state.email,
+ enabledItems: [
+ "PanelUI-fxa-menu-sendtab-button",
+ "PanelUI-fxa-menu-setup-sync-button",
+ "PanelUI-fxa-menu-account-signout-button",
+ ],
+ disabledItems: ["PanelUI-fxa-menu-connect-device-button"],
+ hiddenItems: [
+ "PanelUI-fxa-menu-syncnow-button",
+ "PanelUI-fxa-menu-sync-prefs-button",
+ ],
+ });
+ checkFxAAvatar("login-failed");
+ await closeFxaPanel();
+ await openMainPanel();
+
+ checkPanelUIStatusBar({
+ description: state.email,
+ title: expectedLabel,
+ titleHidden: false,
+ hideFxAText: true,
+ });
+
+ await closeTabAndMainPanel();
+});
+
+add_task(async function test_app_menu_fxa_disabled() {
+ const newWin = await BrowserTestUtils.openNewBrowserWindow();
+
+ Services.prefs.setBoolPref("identity.fxaccounts.enabled", true);
+ newWin.gSync.onFxaDisabled();
+
+ let menuButton = newWin.document.getElementById("PanelUI-menu-button");
+ menuButton.click();
+ await BrowserTestUtils.waitForEvent(newWin.PanelUI.mainView, "ViewShown");
+
+ [...newWin.document.querySelectorAll(".sync-ui-item")].forEach(
+ e => (e.hidden = false)
+ );
+
+ let hidden = BrowserTestUtils.waitForEvent(
+ newWin.document,
+ "popuphidden",
+ true
+ );
+ newWin.PanelUI.hide();
+ await hidden;
+ await BrowserTestUtils.closeWindow(newWin);
+});
+
+add_task(
+ // Can't open the history menu in tests on Mac.
+ () => AppConstants.platform != "mac",
+ async function test_history_menu_fxa_disabled() {
+ const newWin = await BrowserTestUtils.openNewBrowserWindow();
+
+ Services.prefs.setBoolPref("identity.fxaccounts.enabled", true);
+ newWin.gSync.onFxaDisabled();
+
+ const historyMenubarItem = window.document.getElementById("history-menu");
+ const historyMenu = window.document.getElementById("historyMenuPopup");
+ const syncedTabsItem = historyMenu.querySelector("#sync-tabs-menuitem");
+ const menuShown = BrowserTestUtils.waitForEvent(historyMenu, "popupshown");
+ historyMenubarItem.openMenu(true);
+ await menuShown;
+
+ Assert.equal(
+ syncedTabsItem.hidden,
+ true,
+ "Synced Tabs item should not be displayed when FxAccounts is disabled"
+ );
+ const menuHidden = BrowserTestUtils.waitForEvent(
+ historyMenu,
+ "popuphidden"
+ );
+ historyMenu.hidePopup();
+ await menuHidden;
+ await BrowserTestUtils.closeWindow(newWin);
+ }
+);
+
+function checkPanelUIStatusBar({
+ description,
+ title,
+ titleHidden,
+ hideFxAText,
+}) {
+ checkAppMenuFxAText(hideFxAText);
+ let appMenuHeaderTitle = PanelMultiView.getViewNode(
+ document,
+ "appMenu-header-title"
+ );
+ let appMenuHeaderDescription = PanelMultiView.getViewNode(
+ document,
+ "appMenu-header-description"
+ );
+ is(
+ appMenuHeaderDescription.value,
+ description,
+ "app menu description has correct value"
+ );
+ is(appMenuHeaderTitle.hidden, titleHidden, "title has correct hidden status");
+ if (!titleHidden) {
+ is(appMenuHeaderTitle.value, title, "title has correct value");
+ }
+}
+
+function checkMenuBarItem(expectedShownItemId) {
+ checkItemsVisibilities(
+ [
+ "sync-setup",
+ "sync-enable",
+ "sync-syncnowitem",
+ "sync-reauthitem",
+ "sync-unverifieditem",
+ ],
+ expectedShownItemId
+ );
+}
+
+function checkPanelHeader() {
+ let fxaPanelView = PanelMultiView.getViewNode(document, "PanelUI-fxa");
+ is(
+ fxaPanelView.getAttribute("title"),
+ gSync.fluentStrings.formatValueSync("appmenu-fxa-header2"),
+ "Panel title is correct"
+ );
+}
+
+function checkSyncNowButtons(syncing, tooltip = null) {
+ const syncButtons = document.querySelectorAll(".syncNowBtn");
+
+ for (const syncButton of syncButtons) {
+ is(
+ syncButton.getAttribute("syncstatus"),
+ syncing ? "active" : "",
+ "button active has the right value"
+ );
+ if (tooltip) {
+ is(
+ syncButton.getAttribute("tooltiptext"),
+ tooltip,
+ "button tooltiptext is set to the right value"
+ );
+ }
+ }
+
+ const syncLabels = document.querySelectorAll(".syncnow-label");
+
+ for (const syncLabel of syncLabels) {
+ if (syncing) {
+ is(
+ syncLabel.getAttribute("data-l10n-id"),
+ syncLabel.getAttribute("syncing-data-l10n-id"),
+ "label is set to the right value"
+ );
+ } else {
+ is(
+ syncLabel.getAttribute("data-l10n-id"),
+ syncLabel.getAttribute("sync-now-data-l10n-id"),
+ "label is set to the right value"
+ );
+ }
+ }
+}
+
+async function checkFxaToolbarButtonPanel({
+ headerTitle,
+ headerDescription,
+ enabledItems,
+ disabledItems,
+ hiddenItems,
+}) {
+ is(
+ document.getElementById("fxa-menu-header-title").value,
+ headerTitle,
+ "has correct title"
+ );
+ is(
+ document.getElementById("fxa-menu-header-description").value,
+ headerDescription,
+ "has correct description"
+ );
+
+ for (const id of enabledItems) {
+ const el = document.getElementById(id);
+ is(el.hasAttribute("disabled"), false, id + " is enabled");
+ }
+
+ for (const id of disabledItems) {
+ const el = document.getElementById(id);
+ is(el.getAttribute("disabled"), "true", id + " is disabled");
+ }
+
+ for (const id of hiddenItems) {
+ const el = document.getElementById(id);
+ is(el.getAttribute("hidden"), "true", id + " is hidden");
+ }
+}
+
+async function checkFxABadged() {
+ const button = document.getElementById("fxa-toolbar-menu-button");
+ await BrowserTestUtils.waitForCondition(() => {
+ return button.querySelector("label.feature-callout");
+ });
+ const badge = button.querySelector("label.feature-callout");
+ ok(badge, "expected feature-callout style badge");
+ ok(BrowserTestUtils.is_visible(badge), "expected the badge to be visible");
+}
+
+// fxaStatus is one of 'not_configured', 'unverified', 'login-failed', or 'signedin'.
+function checkFxAAvatar(fxaStatus) {
+ // Unhide the panel so computed styles can be read
+ document.querySelector("#appMenu-popup").hidden = false;
+
+ const avatarContainers = [document.getElementById("fxa-avatar-image")];
+ for (const avatar of avatarContainers) {
+ const avatarURL = getComputedStyle(avatar).listStyleImage;
+ const expected = {
+ not_configured: 'url("chrome://browser/skin/fxa/avatar-empty.svg")',
+ unverified: 'url("chrome://browser/skin/fxa/avatar.svg")',
+ signedin: 'url("chrome://browser/skin/fxa/avatar.svg")',
+ "login-failed": 'url("chrome://browser/skin/fxa/avatar.svg")',
+ };
+ ok(
+ avatarURL == expected[fxaStatus],
+ `expected avatar URL to be ${expected[fxaStatus]}, got ${avatarURL}`
+ );
+ }
+}
+
+function checkAppMenuFxAText(hideStatus) {
+ let fxaText = document.getElementById("appMenu-fxa-text");
+ let isHidden = fxaText.hidden || fxaText.style.visibility == "collapse";
+ ok(isHidden == hideStatus, "FxA text has correct hidden state");
+}
+
+// Only one item visible at a time.
+function checkItemsVisibilities(itemsIds, expectedShownItemId) {
+ for (let id of itemsIds) {
+ if (id == expectedShownItemId) {
+ ok(
+ !document.getElementById(id).hidden,
+ "menuitem " + id + " should be visible"
+ );
+ } else {
+ ok(
+ document.getElementById(id).hidden,
+ "menuitem " + id + " should be hidden"
+ );
+ }
+ }
+}
+
+function promiseObserver(topic) {
+ return new Promise(resolve => {
+ let obs = (aSubject, aTopic, aData) => {
+ Services.obs.removeObserver(obs, aTopic);
+ resolve(aSubject);
+ };
+ Services.obs.addObserver(obs, topic);
+ });
+}
+
+async function openTabAndFxaPanel() {
+ await BrowserTestUtils.openNewForegroundTab(gBrowser, "https://example.com/");
+ await openFxaPanel();
+}
+
+async function openFxaPanel() {
+ let fxaButton = document.getElementById("fxa-toolbar-menu-button");
+ fxaButton.click();
+
+ let fxaView = PanelMultiView.getViewNode(document, "PanelUI-fxa");
+ await BrowserTestUtils.waitForEvent(fxaView, "ViewShown");
+}
+
+async function closeFxaPanel() {
+ let fxaView = PanelMultiView.getViewNode(document, "PanelUI-fxa");
+ let hidden = BrowserTestUtils.waitForEvent(document, "popuphidden", true);
+ fxaView.closest("panel").hidePopup();
+ await hidden;
+}
+
+async function openMainPanel() {
+ let menuButton = document.getElementById("PanelUI-menu-button");
+ menuButton.click();
+ await BrowserTestUtils.waitForEvent(window.PanelUI.mainView, "ViewShown");
+}
+
+async function closeTabAndMainPanel() {
+ await gCUITestUtils.hideMainMenu();
+
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+}
diff --git a/browser/base/content/test/sync/browser_synced_tabs_view.js b/browser/base/content/test/sync/browser_synced_tabs_view.js
new file mode 100644
index 0000000000..eb1203825e
--- /dev/null
+++ b/browser/base/content/test/sync/browser_synced_tabs_view.js
@@ -0,0 +1,76 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+function promiseLayout() {
+ // Wait for layout to have happened.
+ return new Promise(resolve =>
+ requestAnimationFrame(() => requestAnimationFrame(resolve))
+ );
+}
+
+add_setup(async function () {
+ registerCleanupFunction(() => CustomizableUI.reset());
+});
+
+async function withOpenSyncPanel(cb) {
+ let promise = BrowserTestUtils.waitForEvent(
+ window,
+ "ViewShown",
+ true,
+ e => e.target.id == "PanelUI-remotetabs"
+ ).then(e => e.target.closest("panel"));
+
+ let panel;
+ try {
+ gSync.openSyncedTabsPanel();
+ panel = await promise;
+ is(panel.state, "open", "Panel should have opened.");
+ await cb(panel);
+ } finally {
+ panel?.hidePopup();
+ }
+}
+
+add_task(async function test_button_in_bookmarks_toolbar() {
+ CustomizableUI.addWidgetToArea("sync-button", CustomizableUI.AREA_BOOKMARKS);
+ CustomizableUI.setToolbarVisibility(CustomizableUI.AREA_BOOKMARKS, "never");
+ await promiseLayout();
+
+ await withOpenSyncPanel(async panel => {
+ is(
+ panel.anchorNode.closest("toolbarbutton"),
+ PanelUI.menuButton,
+ "Should have anchored on the menu button because the sync button isn't visible."
+ );
+ });
+});
+
+add_task(async function test_button_in_navbar() {
+ CustomizableUI.addWidgetToArea("sync-button", CustomizableUI.AREA_NAVBAR, 0);
+ await promiseLayout();
+ await withOpenSyncPanel(async panel => {
+ is(
+ panel.anchorNode.closest("toolbarbutton").id,
+ "sync-button",
+ "Should have anchored on the sync button itself."
+ );
+ });
+});
+
+add_task(async function test_button_in_overflow() {
+ CustomizableUI.addWidgetToArea(
+ "sync-button",
+ CustomizableUI.AREA_FIXED_OVERFLOW_PANEL,
+ 0
+ );
+ await promiseLayout();
+ await withOpenSyncPanel(async panel => {
+ is(
+ panel.anchorNode.closest("toolbarbutton").id,
+ "nav-bar-overflow-button",
+ "Should have anchored on the overflow button."
+ );
+ });
+});
diff --git a/browser/base/content/test/sync/head.js b/browser/base/content/test/sync/head.js
new file mode 100644
index 0000000000..10ffb2a2d2
--- /dev/null
+++ b/browser/base/content/test/sync/head.js
@@ -0,0 +1,34 @@
+const { UIState } = ChromeUtils.importESModule(
+ "resource://services-sync/UIState.sys.mjs"
+);
+const { sinon } = ChromeUtils.importESModule(
+ "resource://testing-common/Sinon.sys.mjs"
+);
+
+function promiseSyncReady() {
+ let service = Cc["@mozilla.org/weave/service;1"].getService(
+ Ci.nsISupports
+ ).wrappedJSObject;
+ return service.whenLoaded();
+}
+
+function setupSendTabMocks({
+ fxaDevices = null,
+ state = UIState.STATUS_SIGNED_IN,
+ isSendableURI = true,
+}) {
+ const sandbox = sinon.createSandbox();
+ sandbox.stub(fxAccounts.device, "recentDeviceList").get(() => fxaDevices);
+ sandbox.stub(UIState, "get").returns({
+ status: state,
+ syncEnabled: true,
+ });
+ if (isSendableURI) {
+ sandbox.stub(BrowserUtils, "getShareableURL").returnsArg(0);
+ } else {
+ sandbox.stub(BrowserUtils, "getShareableURL").returns(null);
+ }
+ sandbox.stub(fxAccounts.device, "refreshDeviceList").resolves(true);
+ sandbox.stub(fxAccounts.commands.sendTab, "send").resolves({ failed: [] });
+ return sandbox;
+}
diff --git a/browser/base/content/test/tabMediaIndicator/almostSilentAudioTrack.webm b/browser/base/content/test/tabMediaIndicator/almostSilentAudioTrack.webm
new file mode 100644
index 0000000000..0b8f8f746f
--- /dev/null
+++ b/browser/base/content/test/tabMediaIndicator/almostSilentAudioTrack.webm
Binary files differ
diff --git a/browser/base/content/test/tabMediaIndicator/audio.ogg b/browser/base/content/test/tabMediaIndicator/audio.ogg
new file mode 100644
index 0000000000..bed764fbf1
--- /dev/null
+++ b/browser/base/content/test/tabMediaIndicator/audio.ogg
Binary files differ
diff --git a/browser/base/content/test/tabMediaIndicator/audioEndedDuringPlaying.webm b/browser/base/content/test/tabMediaIndicator/audioEndedDuringPlaying.webm
new file mode 100644
index 0000000000..4f82b5da76
--- /dev/null
+++ b/browser/base/content/test/tabMediaIndicator/audioEndedDuringPlaying.webm
Binary files differ
diff --git a/browser/base/content/test/tabMediaIndicator/browser.ini b/browser/base/content/test/tabMediaIndicator/browser.ini
new file mode 100644
index 0000000000..a2ebf058fc
--- /dev/null
+++ b/browser/base/content/test/tabMediaIndicator/browser.ini
@@ -0,0 +1,33 @@
+[DEFAULT]
+subsuite = media-bc
+tags = audiochannel
+support-files =
+ almostSilentAudioTrack.webm
+ audio.ogg
+ audioEndedDuringPlaying.webm
+ file_almostSilentAudioTrack.html
+ file_autoplay_media.html
+ file_empty.html
+ file_mediaPlayback.html
+ file_mediaPlayback2.html
+ file_mediaPlaybackFrame.html
+ file_mediaPlaybackFrame2.html
+ file_silentAudioTrack.html
+ file_webAudio.html
+ gizmo.mp4
+ head.js
+ noaudio.webm
+ silentAudioTrack.webm
+
+[browser_destroy_iframe.js]
+https_first_disabled = true
+[browser_mediaPlayback.js]
+[browser_mediaPlayback_mute.js]
+[browser_mediaplayback_audibility_change.js]
+[browser_mute.js]
+[browser_mute2.js]
+[browser_mute_webAudio.js]
+[browser_sound_indicator_silent_video.js]
+[browser_webAudio_hideSoundPlayingIcon.js]
+[browser_webAudio_silentData.js]
+[browser_webaudio_audibility_change.js]
diff --git a/browser/base/content/test/tabMediaIndicator/browser_destroy_iframe.js b/browser/base/content/test/tabMediaIndicator/browser_destroy_iframe.js
new file mode 100644
index 0000000000..f977d1d664
--- /dev/null
+++ b/browser/base/content/test/tabMediaIndicator/browser_destroy_iframe.js
@@ -0,0 +1,50 @@
+const EMPTY_PAGE_URL = GetTestWebBasedURL("file_empty.html");
+const AUTPLAY_PAGE_URL = GetTestWebBasedURL("file_autoplay_media.html");
+const CORS_AUTPLAY_PAGE_URL = GetTestWebBasedURL(
+ "file_autoplay_media.html",
+ true
+);
+
+/**
+ * When an iframe that has audible media gets destroyed, if there is no other
+ * audible playing media existing in the page, then the sound indicator should
+ * disappear.
+ */
+add_task(async function testDestroyAudibleIframe() {
+ const iframesURL = [AUTPLAY_PAGE_URL, CORS_AUTPLAY_PAGE_URL];
+ for (let iframeURL of iframesURL) {
+ info(`open a tab, create an iframe and load an autoplay media page inside`);
+ const tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ EMPTY_PAGE_URL
+ );
+ await createIframeAndLoadURL(tab, iframeURL);
+
+ info(`sound indicator should appear because of audible playing media`);
+ await waitForTabSoundIndicatorAppears(tab);
+
+ info(`sound indicator should disappear after destroying iframe`);
+ await removeIframe(tab);
+ await waitForTabSoundIndicatorDisappears(tab);
+
+ info("remove tab");
+ BrowserTestUtils.removeTab(tab);
+ }
+});
+
+function createIframeAndLoadURL(tab, url) {
+ // eslint-disable-next-line no-shadow
+ return SpecialPowers.spawn(tab.linkedBrowser, [url], async url => {
+ const iframe = content.document.createElement("iframe");
+ content.document.body.appendChild(iframe);
+ iframe.src = url;
+ info(`load ${url} for iframe`);
+ await new Promise(r => (iframe.onload = r));
+ });
+}
+
+function removeIframe(tab) {
+ return SpecialPowers.spawn(tab.linkedBrowser, [], _ => {
+ content.document.getElementsByTagName("iframe")[0].remove();
+ });
+}
diff --git a/browser/base/content/test/tabMediaIndicator/browser_mediaPlayback.js b/browser/base/content/test/tabMediaIndicator/browser_mediaPlayback.js
new file mode 100644
index 0000000000..9a5f457403
--- /dev/null
+++ b/browser/base/content/test/tabMediaIndicator/browser_mediaPlayback.js
@@ -0,0 +1,42 @@
+const PAGE = GetTestWebBasedURL("file_mediaPlayback.html");
+const FRAME = GetTestWebBasedURL("file_mediaPlaybackFrame.html");
+
+function wait_for_event(browser, event) {
+ return BrowserTestUtils.waitForEvent(browser, event, false, e => {
+ is(
+ e.originalTarget,
+ browser,
+ "Event must be dispatched to correct browser."
+ );
+ ok(!e.cancelable, "The event should not be cancelable");
+ return true;
+ });
+}
+
+async function test_on_browser(url, browser) {
+ info(`run test for ${url}`);
+ const startPromise = wait_for_event(browser, "DOMAudioPlaybackStarted");
+ BrowserTestUtils.loadURIString(browser, url);
+ await startPromise;
+ await wait_for_event(browser, "DOMAudioPlaybackStopped");
+}
+
+add_task(async function test_page() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:blank",
+ },
+ test_on_browser.bind(undefined, PAGE)
+ );
+});
+
+add_task(async function test_frame() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:blank",
+ },
+ test_on_browser.bind(undefined, FRAME)
+ );
+});
diff --git a/browser/base/content/test/tabMediaIndicator/browser_mediaPlayback_mute.js b/browser/base/content/test/tabMediaIndicator/browser_mediaPlayback_mute.js
new file mode 100644
index 0000000000..05999a37cd
--- /dev/null
+++ b/browser/base/content/test/tabMediaIndicator/browser_mediaPlayback_mute.js
@@ -0,0 +1,118 @@
+const PAGE = GetTestWebBasedURL("file_mediaPlayback2.html");
+const FRAME = GetTestWebBasedURL("file_mediaPlaybackFrame2.html");
+
+function wait_for_event(browser, event) {
+ return BrowserTestUtils.waitForEvent(browser, event, false, e => {
+ is(
+ e.originalTarget,
+ browser,
+ "Event must be dispatched to correct browser."
+ );
+ return true;
+ });
+}
+
+function test_audio_in_browser() {
+ function get_audio_element() {
+ var doc = content.document;
+ var list = doc.getElementsByTagName("audio");
+ if (list.length == 1) {
+ return list[0];
+ }
+
+ // iframe?
+ list = doc.getElementsByTagName("iframe");
+
+ var iframe = list[0];
+ list = iframe.contentDocument.getElementsByTagName("audio");
+ return list[0];
+ }
+
+ var audio = get_audio_element();
+ return {
+ computedVolume: audio.computedVolume,
+ computedMuted: audio.computedMuted,
+ };
+}
+
+async function test_on_browser(url, browser) {
+ BrowserTestUtils.loadURIString(browser, url);
+ await wait_for_event(browser, "DOMAudioPlaybackStarted");
+
+ var result = await SpecialPowers.spawn(browser, [], test_audio_in_browser);
+ is(result.computedVolume, 1, "Audio volume is 1");
+ is(result.computedMuted, false, "Audio is not muted");
+
+ ok(!browser.audioMuted, "Audio should not be muted by default");
+ browser.mute();
+ ok(browser.audioMuted, "Audio should be muted now");
+
+ await wait_for_event(browser, "DOMAudioPlaybackStopped");
+
+ result = await SpecialPowers.spawn(browser, [], test_audio_in_browser);
+ is(result.computedVolume, 0, "Audio volume is 0 when muted");
+ is(result.computedMuted, true, "Audio is muted");
+}
+
+async function test_visibility(url, browser) {
+ BrowserTestUtils.loadURIString(browser, url);
+ await wait_for_event(browser, "DOMAudioPlaybackStarted");
+
+ var result = await SpecialPowers.spawn(browser, [], test_audio_in_browser);
+ is(result.computedVolume, 1, "Audio volume is 1");
+ is(result.computedMuted, false, "Audio is not muted");
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:blank",
+ },
+ function () {}
+ );
+
+ ok(!browser.audioMuted, "Audio should not be muted by default");
+ browser.mute();
+ ok(browser.audioMuted, "Audio should be muted now");
+
+ await wait_for_event(browser, "DOMAudioPlaybackStopped");
+
+ result = await SpecialPowers.spawn(browser, [], test_audio_in_browser);
+ is(result.computedVolume, 0, "Audio volume is 0 when muted");
+ is(result.computedMuted, true, "Audio is muted");
+}
+
+add_task(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [["media.useAudioChannelService.testing", true]],
+ });
+});
+
+add_task(async function test_page() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:blank",
+ },
+ test_on_browser.bind(undefined, PAGE)
+ );
+});
+
+add_task(async function test_frame() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:blank",
+ },
+ test_on_browser.bind(undefined, FRAME)
+ );
+});
+
+add_task(async function test_frame() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:blank",
+ },
+ test_visibility.bind(undefined, PAGE)
+ );
+});
diff --git a/browser/base/content/test/tabMediaIndicator/browser_mediaplayback_audibility_change.js b/browser/base/content/test/tabMediaIndicator/browser_mediaplayback_audibility_change.js
new file mode 100644
index 0000000000..87186ca838
--- /dev/null
+++ b/browser/base/content/test/tabMediaIndicator/browser_mediaplayback_audibility_change.js
@@ -0,0 +1,258 @@
+/**
+ * When media changes its audible state, the sound indicator should be
+ * updated as well, which should appear only when web audio is audible.
+ */
+add_task(async function testUpdateSoundIndicatorWhenMediaPlaybackChanges() {
+ info("create a tab loading media document");
+ const tab = await createBlankForegroundTab();
+ await initMediaPlaybackDocument(tab, "audio.ogg");
+
+ info(`sound indicator should appear when audible audio starts playing`);
+ await playMedia(tab);
+ await waitForTabSoundIndicatorAppears(tab);
+
+ info(`sound indicator should disappear when audio stops playing`);
+ await pauseMedia(tab);
+ await waitForTabSoundIndicatorDisappears(tab);
+
+ info("remove tab");
+ BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function testUpdateSoundIndicatorWhenMediaBecomeSilent() {
+ info("create a tab loading media document");
+ const tab = await createBlankForegroundTab();
+ await initMediaPlaybackDocument(tab, "audioEndedDuringPlaying.webm");
+
+ info(`sound indicator should appear when audible audio starts playing`);
+ await playMedia(tab);
+ await waitForTabSoundIndicatorAppears(tab);
+
+ info(`sound indicator should disappear when audio becomes silent`);
+ await waitForTabSoundIndicatorDisappears(tab);
+
+ info("remove tab");
+ BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function testSoundIndicatorWouldWorkForMediaWithoutPreload() {
+ info("create a tab loading media document");
+ const tab = await createBlankForegroundTab();
+ await initMediaPlaybackDocument(tab, "audio.ogg", { preload: "none" });
+
+ info(`sound indicator should appear when audible audio starts playing`);
+ await playMedia(tab);
+ await waitForTabSoundIndicatorAppears(tab);
+
+ info(`sound indicator should disappear when audio stops playing`);
+ await pauseMedia(tab);
+ await waitForTabSoundIndicatorDisappears(tab);
+
+ info("remove tab");
+ BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function testSoundIndicatorShouldDisappearAfterTabNavigation() {
+ info("create a tab loading media document");
+ const tab = await createBlankForegroundTab();
+ await initMediaPlaybackDocument(tab, "audio.ogg");
+
+ info(`sound indicator should appear when audible audio starts playing`);
+ await playMedia(tab);
+ await waitForTabSoundIndicatorAppears(tab);
+
+ info(`sound indicator should disappear after navigating tab to blank page`);
+ BrowserTestUtils.loadURIString(tab.linkedBrowser, "about:blank");
+ await waitForTabSoundIndicatorDisappears(tab);
+
+ info("remove tab");
+ BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function testSoundIndicatorForAudioStream() {
+ info("create a tab loading media document");
+ const tab = await createBlankForegroundTab();
+ await initMediaStreamPlaybackDocument(tab);
+
+ info(`sound indicator should appear when audible audio starts playing`);
+ await playMedia(tab);
+ await waitForTabSoundIndicatorAppears(tab);
+
+ info(`sound indicator should disappear when audio stops playing`);
+ await pauseMedia(tab);
+ await waitForTabSoundIndicatorDisappears(tab);
+
+ info("remove tab");
+ BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function testPerformPlayOnMediaLoadingNewSource() {
+ info("create a tab loading media document");
+ const tab = await createBlankForegroundTab();
+ await initMediaPlaybackDocument(tab, "audio.ogg");
+
+ info(`sound indicator should appear when audible audio starts playing`);
+ await playMedia(tab);
+ await waitForTabSoundIndicatorAppears(tab);
+
+ info(`sound indicator should disappear when audio stops playing`);
+ await pauseMedia(tab);
+ await waitForTabSoundIndicatorDisappears(tab);
+
+ info(`reset media src and play it again should make sound indicator appear`);
+ await assignNewSourceForAudio(tab, "audio.ogg");
+ await playMedia(tab);
+ await waitForTabSoundIndicatorAppears(tab);
+
+ info("remove tab");
+ BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function testSoundIndicatorShouldDisappearWhenAbortingMedia() {
+ info("create a tab loading media document");
+ const tab = await createBlankForegroundTab();
+ await initMediaPlaybackDocument(tab, "audio.ogg");
+
+ info(`sound indicator should appear when audible audio starts playing`);
+ await playMedia(tab);
+ await waitForTabSoundIndicatorAppears(tab);
+
+ info(`sound indicator should disappear when aborting audio source`);
+ await assignNewSourceForAudio(tab, "");
+ await waitForTabSoundIndicatorDisappears(tab);
+
+ info("remove tab");
+ BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function testNoSoundIndicatorForMediaWithoutAudioTrack() {
+ info("create a tab loading media document");
+ const tab = await createBlankForegroundTab({ needObserver: true });
+ await initMediaPlaybackDocument(tab, "noaudio.webm", { createVideo: true });
+
+ info(`no sound indicator should show for playing media without audio track`);
+ await playMedia(tab, { resolveOnTimeupdate: true });
+ ok(!tab.observer.hasEverUpdated(), "didn't ever update sound indicator");
+
+ info("remove tab");
+ BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function testSoundIndicatorWhenChangingMediaMuted() {
+ info("create a tab loading media document");
+ const tab = await createBlankForegroundTab({ needObserver: true });
+ await initMediaPlaybackDocument(tab, "audio.ogg", { muted: true });
+
+ info(`no sound indicator should show for playing muted media`);
+ await playMedia(tab, { resolveOnTimeupdate: true });
+ ok(!tab.observer.hasEverUpdated(), "didn't ever update sound indicator");
+
+ info(`unmuted media should make sound indicator appear`);
+ await Promise.all([
+ waitForTabSoundIndicatorAppears(tab),
+ updateMedia(tab, { muted: false }),
+ ]);
+
+ info("remove tab");
+ BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function testSoundIndicatorWhenChangingMediaVolume() {
+ info("create a tab loading media document");
+ const tab = await createBlankForegroundTab({ needObserver: true });
+ await initMediaPlaybackDocument(tab, "audio.ogg", { volume: 0.0 });
+
+ info(`no sound indicator should show for playing volume zero media`);
+ await playMedia(tab, { resolveOnTimeupdate: true });
+ ok(!tab.observer.hasEverUpdated(), "didn't ever update sound indicator");
+
+ info(`unmuted media by setting volume should make sound indicator appear`);
+ await Promise.all([
+ waitForTabSoundIndicatorAppears(tab),
+ updateMedia(tab, { volume: 1.0 }),
+ ]);
+
+ info("remove tab");
+ BrowserTestUtils.removeTab(tab);
+});
+
+/**
+ * Following are helper functions
+ */
+function initMediaPlaybackDocument(
+ tab,
+ fileName,
+ { preload, createVideo, muted = false, volume = 1.0 } = {}
+) {
+ return SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [fileName, preload, createVideo, muted, volume],
+ // eslint-disable-next-line no-shadow
+ async (fileName, preload, createVideo, muted, volume) => {
+ if (createVideo) {
+ content.media = content.document.createElement("video");
+ } else {
+ content.media = content.document.createElement("audio");
+ }
+ if (preload) {
+ content.media.preload = preload;
+ }
+ content.media.muted = muted;
+ content.media.volume = volume;
+ content.media.src = fileName;
+ }
+ );
+}
+
+function initMediaStreamPlaybackDocument(tab) {
+ return SpecialPowers.spawn(tab.linkedBrowser, [], async _ => {
+ content.media = content.document.createElement("audio");
+ content.media.srcObject =
+ new content.AudioContext().createMediaStreamDestination().stream;
+ });
+}
+
+function playMedia(tab, { resolveOnTimeupdate } = {}) {
+ return SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [resolveOnTimeupdate],
+ // eslint-disable-next-line no-shadow
+ async resolveOnTimeupdate => {
+ await content.media.play();
+ if (resolveOnTimeupdate) {
+ await new Promise(r => (content.media.ontimeupdate = r));
+ }
+ }
+ );
+}
+
+function pauseMedia(tab) {
+ return SpecialPowers.spawn(tab.linkedBrowser, [], async _ => {
+ content.media.pause();
+ });
+}
+
+function assignNewSourceForAudio(tab, fileName) {
+ // eslint-disable-next-line no-shadow
+ return SpecialPowers.spawn(tab.linkedBrowser, [fileName], async fileName => {
+ content.media.src = "";
+ content.media.removeAttribute("src");
+ content.media.src = fileName;
+ });
+}
+
+function updateMedia(tab, { muted, volume } = {}) {
+ return SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [muted, volume],
+ // eslint-disable-next-line no-shadow
+ (muted, volume) => {
+ if (muted != undefined) {
+ content.media.muted = muted;
+ }
+ if (volume != undefined) {
+ content.media.volume = volume;
+ }
+ }
+ );
+}
diff --git a/browser/base/content/test/tabMediaIndicator/browser_mute.js b/browser/base/content/test/tabMediaIndicator/browser_mute.js
new file mode 100644
index 0000000000..826e06c3db
--- /dev/null
+++ b/browser/base/content/test/tabMediaIndicator/browser_mute.js
@@ -0,0 +1,19 @@
+const PAGE = "data:text/html,page";
+
+function test_on_browser(browser) {
+ ok(!browser.audioMuted, "Audio should not be muted by default");
+ browser.mute();
+ ok(browser.audioMuted, "Audio should be muted now");
+ browser.unmute();
+ ok(!browser.audioMuted, "Audio should be unmuted now");
+}
+
+add_task(async function () {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: PAGE,
+ },
+ test_on_browser
+ );
+});
diff --git a/browser/base/content/test/tabMediaIndicator/browser_mute2.js b/browser/base/content/test/tabMediaIndicator/browser_mute2.js
new file mode 100644
index 0000000000..5e845454d3
--- /dev/null
+++ b/browser/base/content/test/tabMediaIndicator/browser_mute2.js
@@ -0,0 +1,32 @@
+const PAGE = "data:text/html,page";
+
+async function test_on_browser(browser) {
+ ok(!browser.audioMuted, "Audio should not be muted by default");
+ browser.mute();
+ ok(browser.audioMuted, "Audio should be muted now");
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: PAGE,
+ },
+ test_on_browser2
+ );
+
+ browser.unmute();
+ ok(!browser.audioMuted, "Audio should be unmuted now");
+}
+
+function test_on_browser2(browser) {
+ ok(!browser.audioMuted, "Audio should not be muted by default");
+}
+
+add_task(async function () {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: PAGE,
+ },
+ test_on_browser
+ );
+});
diff --git a/browser/base/content/test/tabMediaIndicator/browser_mute_webAudio.js b/browser/base/content/test/tabMediaIndicator/browser_mute_webAudio.js
new file mode 100644
index 0000000000..6f3e5f222f
--- /dev/null
+++ b/browser/base/content/test/tabMediaIndicator/browser_mute_webAudio.js
@@ -0,0 +1,75 @@
+// The tab closing code leaves an uncaught rejection. This test has been
+// whitelisted until the issue is fixed.
+if (!gMultiProcessBrowser) {
+ const { PromiseTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PromiseTestUtils.sys.mjs"
+ );
+ PromiseTestUtils.expectUncaughtRejection(/is no longer, usable/);
+}
+
+const PAGE = GetTestWebBasedURL("file_webAudio.html");
+
+function start_webAudio() {
+ var startButton = content.document.getElementById("start");
+ if (!startButton) {
+ ok(false, "Can't get the start button!");
+ }
+
+ startButton.click();
+}
+
+function stop_webAudio() {
+ var stopButton = content.document.getElementById("stop");
+ if (!stopButton) {
+ ok(false, "Can't get the stop button!");
+ }
+
+ stopButton.click();
+}
+
+add_task(async function setup_test_preference() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["media.useAudioChannelService.testing", true],
+ ["media.block-autoplay-until-in-foreground", true],
+ ],
+ });
+});
+
+add_task(async function mute_web_audio() {
+ info("- open new tab -");
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ window.gBrowser,
+ "about:blank"
+ );
+ BrowserTestUtils.loadURIString(tab.linkedBrowser, PAGE);
+ await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+
+ info("- tab should be audible -");
+ await waitForTabSoundIndicatorAppears(tab);
+
+ info("- mute browser -");
+ ok(!tab.linkedBrowser.audioMuted, "Audio should not be muted by default");
+ let tabContent = tab.querySelector(".tab-content");
+ await hoverIcon(tabContent);
+ await clickIcon(tab.overlayIcon);
+ ok(tab.linkedBrowser.audioMuted, "Audio should be muted now");
+
+ info("- stop web audip -");
+ await SpecialPowers.spawn(tab.linkedBrowser, [], stop_webAudio);
+
+ info("- start web audio -");
+ await SpecialPowers.spawn(tab.linkedBrowser, [], start_webAudio);
+
+ info("- unmute browser -");
+ ok(tab.linkedBrowser.audioMuted, "Audio should be muted now");
+ await hoverIcon(tabContent);
+ await clickIcon(tab.overlayIcon);
+ ok(!tab.linkedBrowser.audioMuted, "Audio should be unmuted now");
+
+ info("- tab should be audible -");
+ await waitForTabSoundIndicatorAppears(tab);
+
+ info("- remove tab -");
+ BrowserTestUtils.removeTab(tab);
+});
diff --git a/browser/base/content/test/tabMediaIndicator/browser_sound_indicator_silent_video.js b/browser/base/content/test/tabMediaIndicator/browser_sound_indicator_silent_video.js
new file mode 100644
index 0000000000..8f0d6961ca
--- /dev/null
+++ b/browser/base/content/test/tabMediaIndicator/browser_sound_indicator_silent_video.js
@@ -0,0 +1,88 @@
+const SILENT_PAGE = GetTestWebBasedURL("file_silentAudioTrack.html");
+const ALMOST_SILENT_PAGE = GetTestWebBasedURL(
+ "file_almostSilentAudioTrack.html"
+);
+
+function check_audio_playing_state(isPlaying) {
+ let autoPlay = content.document.getElementById("autoplay");
+ if (!autoPlay) {
+ ok(false, "Can't get the audio element!");
+ }
+
+ is(
+ autoPlay.paused,
+ !isPlaying,
+ "The playing state of autoplay audio is correct."
+ );
+
+ // wait for a while to make sure the video is playing and related event has
+ // been dispatched (if any).
+ let PLAYING_TIME_SEC = 0.5;
+ ok(PLAYING_TIME_SEC < autoPlay.duration, "The playing time is valid.");
+
+ return new Promise(resolve => {
+ autoPlay.ontimeupdate = function () {
+ if (autoPlay.currentTime > PLAYING_TIME_SEC) {
+ resolve();
+ }
+ };
+ });
+}
+
+add_task(async function should_not_show_sound_indicator_for_silent_video() {
+ info("- open new foreground tab -");
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ window.gBrowser,
+ "about:blank"
+ );
+
+ info("- tab should not have sound indicator before playing silent video -");
+ await waitForTabSoundIndicatorDisappears(tab);
+
+ info("- loading autoplay silent video -");
+ BrowserTestUtils.loadURIString(tab.linkedBrowser, SILENT_PAGE);
+ await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ await SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [true],
+ check_audio_playing_state
+ );
+
+ info("- tab should not have sound indicator after playing silent video -");
+ await waitForTabSoundIndicatorDisappears(tab);
+
+ info("- remove tab -");
+ BrowserTestUtils.removeTab(tab);
+});
+
+add_task(
+ async function should_not_show_sound_indicator_for_almost_silent_video() {
+ info("- open new foreground tab -");
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ window.gBrowser,
+ "about:blank"
+ );
+
+ info(
+ "- tab should not have sound indicator before playing almost silent video -"
+ );
+ await waitForTabSoundIndicatorDisappears(tab);
+
+ info("- loading autoplay almost silent video -");
+ BrowserTestUtils.loadURIString(tab.linkedBrowser, ALMOST_SILENT_PAGE);
+ await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ await SpecialPowers.spawn(
+ tab.linkedBrowser,
+ [true],
+ check_audio_playing_state
+ );
+
+ info(
+ "- tab should not have sound indicator after playing almost silent video -"
+ );
+ await waitForTabSoundIndicatorDisappears(tab);
+
+ info("- remove tab -");
+ BrowserTestUtils.removeTab(tab);
+ }
+);
diff --git a/browser/base/content/test/tabMediaIndicator/browser_webAudio_hideSoundPlayingIcon.js b/browser/base/content/test/tabMediaIndicator/browser_webAudio_hideSoundPlayingIcon.js
new file mode 100644
index 0000000000..be40f6e146
--- /dev/null
+++ b/browser/base/content/test/tabMediaIndicator/browser_webAudio_hideSoundPlayingIcon.js
@@ -0,0 +1,60 @@
+/**
+ * This test is used to ensure the 'sound-playing' icon would not disappear after
+ * sites call AudioContext.resume().
+ */
+"use strict";
+
+function setup_test_preference() {
+ return SpecialPowers.pushPrefEnv({
+ set: [
+ ["media.useAudioChannelService.testing", true],
+ ["browser.tabs.delayHidingAudioPlayingIconMS", 0],
+ ],
+ });
+}
+
+async function resumeAudioContext() {
+ const ac = content.ac;
+ await ac.resume();
+ ok(true, "AudioContext is resumed.");
+}
+
+async function testResumeRunningAudioContext() {
+ info(`- create new tab -`);
+ const tab = await BrowserTestUtils.openNewForegroundTab(
+ window.gBrowser,
+ "about:blank"
+ );
+ const browser = tab.linkedBrowser;
+
+ info(`- create audio context -`);
+ // We want the same audio context to be used across different content tasks.
+ await SpecialPowers.spawn(tab.linkedBrowser, [], () => {
+ content.ac = new content.AudioContext();
+ const ac = content.ac;
+ const dest = ac.destination;
+ const osc = ac.createOscillator();
+ osc.connect(dest);
+ osc.start();
+ });
+
+ info(`- wait for 'sound-playing' icon showing -`);
+ await waitForTabSoundIndicatorAppears(tab);
+
+ info(`- resume AudioContext -`);
+ await SpecialPowers.spawn(browser, [], resumeAudioContext);
+
+ info(`- 'sound-playing' icon should still exist -`);
+ await waitForTabSoundIndicatorAppears(tab);
+
+ info(`- remove tab -`);
+ await BrowserTestUtils.removeTab(tab);
+}
+
+add_task(async function start_test() {
+ info("- setup test preference -");
+ await setup_test_preference();
+
+ info("- start testing -");
+ await testResumeRunningAudioContext();
+});
diff --git a/browser/base/content/test/tabMediaIndicator/browser_webAudio_silentData.js b/browser/base/content/test/tabMediaIndicator/browser_webAudio_silentData.js
new file mode 100644
index 0000000000..5831d3c0ce
--- /dev/null
+++ b/browser/base/content/test/tabMediaIndicator/browser_webAudio_silentData.js
@@ -0,0 +1,57 @@
+/**
+ * This test is used to make sure we won't show the sound indicator for silent
+ * web audio.
+ */
+/* eslint-disable mozilla/no-arbitrary-setTimeout */
+"use strict";
+
+async function waitUntilAudioContextStarts() {
+ const ac = content.ac;
+ if (ac.state == "running") {
+ return;
+ }
+
+ await new Promise(resolve => {
+ ac.onstatechange = () => {
+ if (ac.state == "running") {
+ ac.onstatechange = null;
+ resolve();
+ }
+ };
+ });
+}
+
+add_task(async function testSilentAudioContext() {
+ info(`- create new tab -`);
+ const tab = await BrowserTestUtils.openNewForegroundTab(
+ window.gBrowser,
+ "about:blank"
+ );
+ const browser = tab.linkedBrowser;
+
+ info(`- create audio context -`);
+ // We want the same audio context to be used across different content tasks
+ await SpecialPowers.spawn(tab.linkedBrowser, [], () => {
+ content.ac = new content.AudioContext();
+ const ac = content.ac;
+ const dest = ac.destination;
+ const source = new content.OscillatorNode(content.ac);
+ const gain = new content.GainNode(content.ac);
+ gain.gain.value = 0.0;
+ source.connect(gain).connect(dest);
+ source.start();
+ });
+ info(`- check AudioContext's state -`);
+ await SpecialPowers.spawn(browser, [], waitUntilAudioContextStarts);
+ ok(true, `AudioContext is running.`);
+
+ info(`- should not show sound indicator -`);
+ // If we do the next step too early, then we can't make sure whether that the
+ // reason of no showing sound indicator is because of silent web audio, or
+ // because the indicator is just not showing yet.
+ await new Promise(r => setTimeout(r, 1000));
+ await waitForTabSoundIndicatorDisappears(tab);
+
+ info(`- remove tab -`);
+ await BrowserTestUtils.removeTab(tab);
+});
diff --git a/browser/base/content/test/tabMediaIndicator/browser_webaudio_audibility_change.js b/browser/base/content/test/tabMediaIndicator/browser_webaudio_audibility_change.js
new file mode 100644
index 0000000000..8d8ce08551
--- /dev/null
+++ b/browser/base/content/test/tabMediaIndicator/browser_webaudio_audibility_change.js
@@ -0,0 +1,172 @@
+const EMPTY_PAGE_URL = GetTestWebBasedURL("file_empty.html");
+
+/**
+ * When web audio changes its audible state, the sound indicator should be
+ * updated as well, which should appear only when web audio is audible.
+ */
+add_task(
+ async function testWebAudioAudibilityWouldAffectTheAppearenceOfTabSoundIndicator() {
+ info(`sound indicator should appear when web audio plays audible sound`);
+ const tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ EMPTY_PAGE_URL
+ );
+ await initWebAudioDocument(tab);
+ await waitForTabSoundIndicatorAppears(tab);
+
+ info(`sound indicator should disappear when suspending web audio`);
+ await suspendWebAudio(tab);
+ await waitForTabSoundIndicatorDisappears(tab);
+
+ info(`sound indicator should appear when resuming web audio`);
+ await resumeWebAudio(tab);
+ await waitForTabSoundIndicatorAppears(tab);
+
+ info(`sound indicator should disappear when muting web audio by docShell`);
+ await muteWebAudioByDocShell(tab);
+ await waitForTabSoundIndicatorDisappears(tab);
+
+ info(`sound indicator should appear when unmuting web audio by docShell`);
+ await unmuteWebAudioByDocShell(tab);
+ await waitForTabSoundIndicatorAppears(tab);
+
+ info(`sound indicator should disappear when muting web audio by gain node`);
+ await muteWebAudioByGainNode(tab);
+ await waitForTabSoundIndicatorDisappears(tab);
+
+ info(`sound indicator should appear when unmuting web audio by gain node`);
+ await unmuteWebAudioByGainNode(tab);
+ await waitForTabSoundIndicatorAppears(tab);
+
+ info(`sound indicator should disappear when closing web audio`);
+ await closeWebAudio(tab);
+ await waitForTabSoundIndicatorDisappears(tab);
+
+ info("remove tab");
+ BrowserTestUtils.removeTab(tab);
+ }
+);
+
+add_task(async function testSoundIndicatorShouldDisappearAfterTabNavigation() {
+ info("create a tab loading media document");
+ const tab = await createBlankForegroundTab();
+
+ info(`sound indicator should appear when audible web audio starts playing`);
+ await Promise.all([
+ initWebAudioDocument(tab),
+ waitForTabSoundIndicatorAppears(tab),
+ ]);
+
+ info(`sound indicator should disappear after navigating tab to blank page`);
+ await Promise.all([
+ BrowserTestUtils.loadURIString(tab.linkedBrowser, "about:blank"),
+ waitForTabSoundIndicatorDisappears(tab),
+ ]);
+
+ info("remove tab");
+ BrowserTestUtils.removeTab(tab);
+});
+
+add_task(
+ async function testSoundIndicatorShouldDisappearAfterWebAudioBecomesSilent() {
+ info("create a tab loading media document");
+ const tab = await createBlankForegroundTab();
+
+ info(`sound indicator should appear when audible web audio starts playing`);
+ await Promise.all([
+ initWebAudioDocument(tab, { duration: 0.1 }),
+ waitForTabSoundIndicatorAppears(tab),
+ ]);
+
+ info(`sound indicator should disappear after web audio become silent`);
+ await waitForTabSoundIndicatorDisappears(tab);
+
+ info("remove tab");
+ BrowserTestUtils.removeTab(tab);
+ }
+);
+
+add_task(async function testNoSoundIndicatorWhenSimplyCreateAudioContext() {
+ info("create a tab loading media document");
+ const tab = await createBlankForegroundTab({ needObserver: true });
+
+ info(`sound indicator should not appear when simply create an AudioContext`);
+ await SpecialPowers.spawn(tab.linkedBrowser, [], async _ => {
+ content.ac = new content.AudioContext();
+ while (content.ac.state != "running") {
+ info(`wait until web audio starts running`);
+ await new Promise(r => (content.ac.onstatechange = r));
+ }
+ });
+ ok(!tab.observer.hasEverUpdated(), "didn't ever update sound indicator");
+
+ info("remove tab");
+ BrowserTestUtils.removeTab(tab);
+});
+
+/**
+ * Following are helper functions
+ */
+function initWebAudioDocument(tab, { duration } = {}) {
+ // eslint-disable-next-line no-shadow
+ return SpecialPowers.spawn(tab.linkedBrowser, [duration], async duration => {
+ content.ac = new content.AudioContext();
+ const ac = content.ac;
+ const dest = ac.destination;
+ const source = new content.OscillatorNode(ac);
+ source.start(ac.currentTime);
+ if (duration != undefined) {
+ source.stop(ac.currentTime + duration);
+ }
+ // create a gain node for future muting/unmuting
+ content.gainNode = ac.createGain();
+ source.connect(content.gainNode);
+ content.gainNode.connect(dest);
+ while (ac.state != "running") {
+ info(`wait until web audio starts running`);
+ await new Promise(r => (ac.onstatechange = r));
+ }
+ });
+}
+
+function suspendWebAudio(tab) {
+ return SpecialPowers.spawn(tab.linkedBrowser, [], async _ => {
+ await content.ac.suspend();
+ });
+}
+
+function resumeWebAudio(tab) {
+ return SpecialPowers.spawn(tab.linkedBrowser, [], async _ => {
+ await content.ac.resume();
+ });
+}
+
+function closeWebAudio(tab) {
+ return SpecialPowers.spawn(tab.linkedBrowser, [], async _ => {
+ await content.ac.close();
+ });
+}
+
+function muteWebAudioByDocShell(tab) {
+ return SpecialPowers.spawn(tab.linkedBrowser, [], _ => {
+ content.docShell.allowMedia = false;
+ });
+}
+
+function unmuteWebAudioByDocShell(tab) {
+ return SpecialPowers.spawn(tab.linkedBrowser, [], _ => {
+ content.docShell.allowMedia = true;
+ });
+}
+
+function muteWebAudioByGainNode(tab) {
+ return SpecialPowers.spawn(tab.linkedBrowser, [], _ => {
+ content.gainNode.gain.setValueAtTime(0, content.ac.currentTime);
+ });
+}
+
+function unmuteWebAudioByGainNode(tab) {
+ return SpecialPowers.spawn(tab.linkedBrowser, [], _ => {
+ content.gainNode.gain.setValueAtTime(1.0, content.ac.currentTime);
+ });
+}
diff --git a/browser/base/content/test/tabMediaIndicator/file_almostSilentAudioTrack.html b/browser/base/content/test/tabMediaIndicator/file_almostSilentAudioTrack.html
new file mode 100644
index 0000000000..3ce9a68b98
--- /dev/null
+++ b/browser/base/content/test/tabMediaIndicator/file_almostSilentAudioTrack.html
@@ -0,0 +1,18 @@
+<!DOCTYPE html>
+<head>
+ <meta content="text/html;charset=utf-8" http-equiv="Content-Type">
+ <meta content="utf-8" http-equiv="encoding">
+</head>
+<body>
+<video id="autoplay" src="almostSilentAudioTrack.webm"></video>
+<script type="text/javascript">
+
+// In linux debug on try server, sometimes the download process would fail, so
+// we can't activate the "auto-play" or playing after receving "oncanplay".
+// Therefore, we just call play here.
+var video = document.getElementById("autoplay");
+video.loop = true;
+video.play();
+
+</script>
+</body>
diff --git a/browser/base/content/test/tabMediaIndicator/file_autoplay_media.html b/browser/base/content/test/tabMediaIndicator/file_autoplay_media.html
new file mode 100644
index 0000000000..f0dcfdab52
--- /dev/null
+++ b/browser/base/content/test/tabMediaIndicator/file_autoplay_media.html
@@ -0,0 +1,9 @@
+<!DOCTYPE html>
+<html>
+<head>
+<title>autoplay media page</title>
+</head>
+<body>
+<video id="video" src="gizmo.mp4" loop autoplay></video>
+</body>
+</html>
diff --git a/browser/base/content/test/tabMediaIndicator/file_empty.html b/browser/base/content/test/tabMediaIndicator/file_empty.html
new file mode 100644
index 0000000000..13d5eeee78
--- /dev/null
+++ b/browser/base/content/test/tabMediaIndicator/file_empty.html
@@ -0,0 +1,8 @@
+<!DOCTYPE html>
+<html>
+<head>
+<title>empty page</title>
+</head>
+<body>
+</body>
+</html>
diff --git a/browser/base/content/test/tabMediaIndicator/file_mediaPlayback.html b/browser/base/content/test/tabMediaIndicator/file_mediaPlayback.html
new file mode 100644
index 0000000000..5df0bc1542
--- /dev/null
+++ b/browser/base/content/test/tabMediaIndicator/file_mediaPlayback.html
@@ -0,0 +1,9 @@
+<!DOCTYPE html>
+<script type="text/javascript">
+var audio = new Audio();
+audio.oncanplay = function() {
+ audio.oncanplay = null;
+ audio.play();
+};
+audio.src = "audio.ogg";
+</script>
diff --git a/browser/base/content/test/tabMediaIndicator/file_mediaPlayback2.html b/browser/base/content/test/tabMediaIndicator/file_mediaPlayback2.html
new file mode 100644
index 0000000000..890b494a05
--- /dev/null
+++ b/browser/base/content/test/tabMediaIndicator/file_mediaPlayback2.html
@@ -0,0 +1,14 @@
+<!DOCTYPE html>
+<body>
+<script type="text/javascript">
+var audio = new Audio();
+audio.oncanplay = function() {
+ audio.oncanplay = null;
+ audio.play();
+};
+audio.src = "audio.ogg";
+audio.loop = true;
+audio.id = "v";
+document.body.appendChild(audio);
+</script>
+</body>
diff --git a/browser/base/content/test/tabMediaIndicator/file_mediaPlaybackFrame.html b/browser/base/content/test/tabMediaIndicator/file_mediaPlaybackFrame.html
new file mode 100644
index 0000000000..119db62ecc
--- /dev/null
+++ b/browser/base/content/test/tabMediaIndicator/file_mediaPlaybackFrame.html
@@ -0,0 +1,2 @@
+<!DOCTYPE html>
+<iframe src="file_mediaPlayback.html"></iframe>
diff --git a/browser/base/content/test/tabMediaIndicator/file_mediaPlaybackFrame2.html b/browser/base/content/test/tabMediaIndicator/file_mediaPlaybackFrame2.html
new file mode 100644
index 0000000000..d96a4cd4e9
--- /dev/null
+++ b/browser/base/content/test/tabMediaIndicator/file_mediaPlaybackFrame2.html
@@ -0,0 +1,2 @@
+<!DOCTYPE html>
+<iframe src="file_mediaPlayback2.html"></iframe>
diff --git a/browser/base/content/test/tabMediaIndicator/file_silentAudioTrack.html b/browser/base/content/test/tabMediaIndicator/file_silentAudioTrack.html
new file mode 100644
index 0000000000..afdf2c5297
--- /dev/null
+++ b/browser/base/content/test/tabMediaIndicator/file_silentAudioTrack.html
@@ -0,0 +1,18 @@
+<!DOCTYPE html>
+<head>
+ <meta content="text/html;charset=utf-8" http-equiv="Content-Type">
+ <meta content="utf-8" http-equiv="encoding">
+</head>
+<body>
+<video id="autoplay" src="silentAudioTrack.webm"></video>
+<script type="text/javascript">
+
+// In linux debug on try server, sometimes the download process would fail, so
+// we can't activate the "auto-play" or playing after receving "oncanplay".
+// Therefore, we just call play here.
+var video = document.getElementById("autoplay");
+video.loop = true;
+video.play();
+
+</script>
+</body>
diff --git a/browser/base/content/test/tabMediaIndicator/file_webAudio.html b/browser/base/content/test/tabMediaIndicator/file_webAudio.html
new file mode 100644
index 0000000000..f6fb5e7c07
--- /dev/null
+++ b/browser/base/content/test/tabMediaIndicator/file_webAudio.html
@@ -0,0 +1,29 @@
+<!DOCTYPE html>
+<head>
+ <meta content="text/html;charset=utf-8" http-equiv="Content-Type">
+ <meta content="utf-8" http-equiv="encoding">
+</head>
+<body>
+<pre id=state></pre>
+<button id="start" onclick="start_webaudio()">Start</button>
+<button id="stop" onclick="stop_webaudio()">Stop</button>
+<script type="text/javascript">
+ var ac = new AudioContext();
+ var dest = ac.destination;
+ var osc = ac.createOscillator();
+ osc.connect(dest);
+ osc.start();
+ document.querySelector("pre").innerText = ac.state;
+ ac.onstatechange = function() {
+ document.querySelector("pre").innerText = ac.state;
+ }
+
+ function start_webaudio() {
+ ac.resume();
+ }
+
+ function stop_webaudio() {
+ ac.suspend();
+ }
+</script>
+</body>
diff --git a/browser/base/content/test/tabMediaIndicator/gizmo.mp4 b/browser/base/content/test/tabMediaIndicator/gizmo.mp4
new file mode 100644
index 0000000000..87efad5ade
--- /dev/null
+++ b/browser/base/content/test/tabMediaIndicator/gizmo.mp4
Binary files differ
diff --git a/browser/base/content/test/tabMediaIndicator/head.js b/browser/base/content/test/tabMediaIndicator/head.js
new file mode 100644
index 0000000000..8b82e21a43
--- /dev/null
+++ b/browser/base/content/test/tabMediaIndicator/head.js
@@ -0,0 +1,158 @@
+/**
+ * Global variables for testing.
+ */
+const gEMPTY_PAGE_URL = GetTestWebBasedURL("file_empty.html");
+
+/**
+ * Return a web-based URL for a given file based on the testing directory.
+ * @param {String} fileName
+ * file that caller wants its web-based url
+ * @param {Boolean} cors [optional]
+ * if set, then return a url with different origin
+ */
+function GetTestWebBasedURL(fileName, cors = false) {
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ const origin = cors ? "http://example.org" : "http://example.com";
+ return (
+ getRootDirectory(gTestPath).replace("chrome://mochitests/content", origin) +
+ fileName
+ );
+}
+
+/**
+ * Wait until tab sound indicator appears on the given tab.
+ * @param {tabbrowser} tab
+ * given tab where tab sound indicator should appear
+ */
+async function waitForTabSoundIndicatorAppears(tab) {
+ if (!tab.soundPlaying) {
+ info("Tab sound indicator doesn't appear yet");
+ await BrowserTestUtils.waitForEvent(
+ tab,
+ "TabAttrModified",
+ false,
+ event => {
+ return event.detail.changed.includes("soundplaying");
+ }
+ );
+ }
+ ok(tab.soundPlaying, "Tab sound indicator appears");
+}
+
+/**
+ * Wait until tab sound indicator disappears on the given tab.
+ * @param {tabbrowser} tab
+ * given tab where tab sound indicator should disappear
+ */
+async function waitForTabSoundIndicatorDisappears(tab) {
+ if (tab.soundPlaying) {
+ info("Tab sound indicator doesn't disappear yet");
+ await BrowserTestUtils.waitForEvent(
+ tab,
+ "TabAttrModified",
+ false,
+ event => {
+ return event.detail.changed.includes("soundplaying");
+ }
+ );
+ }
+ ok(!tab.soundPlaying, "Tab sound indicator disappears");
+}
+
+/**
+ * Return a new foreground tab loading with an empty file.
+ * @param {boolean} needObserver
+ * If true, sets an observer property on the returned tab. This property
+ * exposes `hasEverUpdated()` which will return a bool indicating if the
+ * sound indicator has ever updated.
+ */
+async function createBlankForegroundTab({ needObserver } = {}) {
+ const tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ gEMPTY_PAGE_URL
+ );
+ if (needObserver) {
+ tab.observer = createSoundIndicatorObserver(tab);
+ }
+ return tab;
+}
+
+function createSoundIndicatorObserver(tab) {
+ let hasEverUpdated = false;
+ let listener = event => {
+ if (event.detail.changed.includes("soundplaying")) {
+ hasEverUpdated = true;
+ }
+ };
+ tab.addEventListener("TabAttrModified", listener);
+ return {
+ hasEverUpdated: () => {
+ tab.removeEventListener("TabAttrModified", listener);
+ return hasEverUpdated;
+ },
+ };
+}
+
+/**
+ * Sythesize mouse hover on the given icon, which would sythesize `mouseover`
+ * and `mousemove` event on that. Return a promise that will be resolved when
+ * the tooptip element shows.
+ * @param {tab icon} icon
+ * the icon on which we want to mouse hover
+ * @param {tooltip element} tooltip
+ * the tab tooltip elementss
+ */
+function hoverIcon(icon, tooltip) {
+ disableNonTestMouse(true);
+
+ if (!tooltip) {
+ tooltip = document.getElementById("tabbrowser-tab-tooltip");
+ }
+
+ let popupShownPromise = BrowserTestUtils.waitForEvent(tooltip, "popupshown");
+ EventUtils.synthesizeMouse(icon, 1, 1, { type: "mouseover" });
+ EventUtils.synthesizeMouse(icon, 2, 2, { type: "mousemove" });
+ EventUtils.synthesizeMouse(icon, 3, 3, { type: "mousemove" });
+ EventUtils.synthesizeMouse(icon, 4, 4, { type: "mousemove" });
+ return popupShownPromise;
+}
+
+/**
+ * Leave mouse from the given icon, which would sythesize `mouseout`
+ * and `mousemove` event on that.
+ * @param {tab icon} icon
+ * the icon on which we want to mouse hover
+ * @param {tooltip element} tooltip
+ * the tab tooltip elementss
+ */
+function leaveIcon(icon) {
+ EventUtils.synthesizeMouse(icon, 0, 0, { type: "mouseout" });
+ EventUtils.synthesizeMouseAtCenter(document.documentElement, {
+ type: "mousemove",
+ });
+ EventUtils.synthesizeMouseAtCenter(document.documentElement, {
+ type: "mousemove",
+ });
+ EventUtils.synthesizeMouseAtCenter(document.documentElement, {
+ type: "mousemove",
+ });
+
+ disableNonTestMouse(false);
+}
+
+/**
+ * Sythesize mouse click on the given icon.
+ * @param {tab icon} icon
+ * the icon on which we want to mouse hover
+ */
+async function clickIcon(icon) {
+ await hoverIcon(icon);
+ EventUtils.synthesizeMouseAtCenter(icon, { button: 0 });
+ leaveIcon(icon);
+}
+
+function disableNonTestMouse(disable) {
+ let utils = window.windowUtils;
+ utils.disableNonTestMouseEvents(disable);
+}
diff --git a/browser/base/content/test/tabMediaIndicator/noaudio.webm b/browser/base/content/test/tabMediaIndicator/noaudio.webm
new file mode 100644
index 0000000000..9207017fb6
--- /dev/null
+++ b/browser/base/content/test/tabMediaIndicator/noaudio.webm
Binary files differ
diff --git a/browser/base/content/test/tabMediaIndicator/silentAudioTrack.webm b/browser/base/content/test/tabMediaIndicator/silentAudioTrack.webm
new file mode 100644
index 0000000000..8e08a86c45
--- /dev/null
+++ b/browser/base/content/test/tabMediaIndicator/silentAudioTrack.webm
Binary files differ
diff --git a/browser/base/content/test/tabPrompts/auth-route.sjs b/browser/base/content/test/tabPrompts/auth-route.sjs
new file mode 100644
index 0000000000..4f113a8add
--- /dev/null
+++ b/browser/base/content/test/tabPrompts/auth-route.sjs
@@ -0,0 +1,28 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+function handleRequest(request, response) {
+ let body;
+ // guest:guest
+ let expectedHeader = "Basic Z3Vlc3Q6Z3Vlc3Q=";
+ // correct login credentials provided
+ if (
+ request.hasHeader("Authorization") &&
+ request.getHeader("Authorization") == expectedHeader
+ ) {
+ response.setStatusLine(request.httpVersion, 200, "OK, authorized");
+ response.setHeader("Content-Type", "text", false);
+
+ body = "success";
+ } else {
+ // incorrect credentials
+ response.setStatusLine(request.httpVersion, 401, "Unauthorized");
+ response.setHeader("WWW-Authenticate", 'Basic realm="secret"', false);
+ response.setHeader("Content-Type", "text", false);
+
+ body = "failed";
+ }
+ response.bodyOutputStream.write(body, body.length);
+}
diff --git a/browser/base/content/test/tabPrompts/browser.ini b/browser/base/content/test/tabPrompts/browser.ini
new file mode 100644
index 0000000000..2834b6d2af
--- /dev/null
+++ b/browser/base/content/test/tabPrompts/browser.ini
@@ -0,0 +1,30 @@
+[browser_abort_when_in_modal_state.js]
+[browser_auth_spoofing_protection.js]
+support-files =
+ redirect-crossDomain.html
+ redirect-sameDomain.html
+ auth-route.sjs
+[browser_auth_spoofing_url_copy.js]
+support-files =
+ redirect-crossDomain.html
+ auth-route.sjs
+[browser_auth_spoofing_url_drag_and_drop.js]
+support-files =
+ redirect-crossDomain.html
+ redirect-sameDomain.html
+ auth-route.sjs
+[browser_beforeunload_urlbar.js]
+support-files = file_beforeunload_stop.html
+[browser_closeTabSpecificPanels.js]
+skip-if = verify && debug && (os == 'linux')
+[browser_confirmFolderUpload.js]
+[browser_contentOrigins.js]
+support-files = file_beforeunload_stop.html
+[browser_multiplePrompts.js]
+[browser_openPromptInBackgroundTab.js]
+https_first_disabled = true
+support-files = openPromptOffTimeout.html
+[browser_promptFocus.js]
+[browser_prompt_closed_window.js]
+[browser_switchTabPermissionPrompt.js]
+[browser_windowPrompt.js]
diff --git a/browser/base/content/test/tabPrompts/browser_abort_when_in_modal_state.js b/browser/base/content/test/tabPrompts/browser_abort_when_in_modal_state.js
new file mode 100644
index 0000000000..cb3a1f72d6
--- /dev/null
+++ b/browser/base/content/test/tabPrompts/browser_abort_when_in_modal_state.js
@@ -0,0 +1,60 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { PromiseTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PromiseTestUtils.sys.mjs"
+);
+
+/**
+ * Check that if we're using a window-modal prompt,
+ * the next synchronous window-internal modal prompt aborts rather than
+ * leaving us in a deadlock about how to enter modal state.
+ */
+add_task(async function test_check_multiple_prompts() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["prompts.windowPromptSubDialog", true]],
+ });
+ let container = document.getElementById("window-modal-dialog");
+ let dialogPromise = BrowserTestUtils.promiseAlertDialogOpen();
+
+ let firstDialogClosedPromise = new Promise(resolve => {
+ // Avoid blocking the test on the (sync) alert by sticking it in a timeout:
+ setTimeout(() => {
+ Services.prompt.alertBC(
+ window.browsingContext,
+ Ci.nsIPrompt.MODAL_TYPE_WINDOW,
+ "Some title",
+ "some message"
+ );
+ resolve();
+ }, 0);
+ });
+ let dialogWin = await dialogPromise;
+
+ // Check circumstances of opening.
+ ok(
+ !dialogWin.docShell.chromeEventHandler,
+ "Should not have embedded the dialog."
+ );
+
+ PromiseTestUtils.expectUncaughtRejection(/could not be shown/);
+ let rv = Services.prompt.confirm(
+ window,
+ "I should not appear",
+ "because another prompt was open"
+ );
+ is(rv, false, "Prompt should have been canceled.");
+
+ info("Accepting dialog");
+ dialogWin.document.querySelector("dialog").acceptDialog();
+
+ await firstDialogClosedPromise;
+
+ await BrowserTestUtils.waitForMutationCondition(
+ container,
+ { childList: true, attributes: true },
+ () => !container.hasChildNodes() && !container.open
+ );
+});
diff --git a/browser/base/content/test/tabPrompts/browser_auth_spoofing_protection.js b/browser/base/content/test/tabPrompts/browser_auth_spoofing_protection.js
new file mode 100644
index 0000000000..ca139df8e4
--- /dev/null
+++ b/browser/base/content/test/tabPrompts/browser_auth_spoofing_protection.js
@@ -0,0 +1,232 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+let TEST_PATH = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+);
+
+let TEST_PATH_AUTH = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.org"
+);
+
+const CROSS_DOMAIN_URL = TEST_PATH + "redirect-crossDomain.html";
+
+const SAME_DOMAIN_URL = TEST_PATH + "redirect-sameDomain.html";
+
+const AUTH_URL = TEST_PATH_AUTH + "auth-route.sjs";
+
+/**
+ * Opens a new tab with a url that ether redirects us cross or same domain
+ *
+ * @param {Boolean} doConfirmPrompt - true if we want to test the case when the user accepts the prompt,
+ * false if we want to test the case when the user cancels the prompt.
+ * @param {Boolean} crossDomain - if true we will open a url that redirects us to a cross domain url,
+ * if false, we will open a url that redirects us to a same domain url
+ * @param {Boolean} prefEnabled true will enable "privacy.authPromptSpoofingProtection",
+ * false will disable the pref
+ */
+async function trigger401AndHandle(doConfirmPrompt, crossDomain, prefEnabled) {
+ await SpecialPowers.pushPrefEnv({
+ set: [["privacy.authPromptSpoofingProtection", prefEnabled]],
+ });
+ let url = crossDomain ? CROSS_DOMAIN_URL : SAME_DOMAIN_URL;
+ let dialogShown = waitForDialog(doConfirmPrompt, crossDomain, prefEnabled);
+ await BrowserTestUtils.withNewTab(url, async function () {
+ await dialogShown;
+ });
+ await new Promise(resolve => {
+ Services.clearData.deleteData(
+ Ci.nsIClearDataService.CLEAR_AUTH_CACHE,
+ resolve
+ );
+ });
+}
+
+async function waitForDialog(doConfirmPrompt, crossDomain, prefEnabled) {
+ await TestUtils.topicObserved("common-dialog-loaded");
+ let dialog = gBrowser.getTabDialogBox(gBrowser.selectedBrowser)
+ ._tabDialogManager._topDialog;
+ let dialogDocument = dialog._frame.contentDocument;
+ if (crossDomain) {
+ if (prefEnabled) {
+ Assert.equal(
+ dialog._overlay.getAttribute("hideContent"),
+ "true",
+ "Dialog overlay hides the current sites content"
+ );
+ Assert.equal(
+ window.gURLBar.value,
+ AUTH_URL,
+ "Correct location is provided by the prompt"
+ );
+ Assert.equal(
+ window.gBrowser.selectedTab.label,
+ "example.org",
+ "Tab title is manipulated"
+ );
+ // switch to another tab and make sure we dont mess up this new tabs url bar and tab title
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "https://example.org:443"
+ );
+ Assert.equal(
+ window.gURLBar.value,
+ "https://example.org",
+ "No location is provided by the prompt, correct location is displayed"
+ );
+ Assert.equal(
+ window.gBrowser.selectedTab.label,
+ "mochitest index /",
+ "Tab title is not manipulated"
+ );
+ // switch back to our tab with the prompt and make sure the url bar state and tab title is still there
+ BrowserTestUtils.removeTab(tab);
+ Assert.equal(
+ window.gURLBar.value,
+ AUTH_URL,
+ "Correct location is provided by the prompt"
+ );
+ Assert.equal(
+ window.gBrowser.selectedTab.label,
+ "example.org",
+ "Tab title is manipulated"
+ );
+ // make sure a value that the user types in has a higher priority than our prompts location
+ gBrowser.selectedBrowser.userTypedValue = "user value";
+ gURLBar.setURI();
+ Assert.equal(
+ window.gURLBar.value,
+ "user value",
+ "User typed value is shown"
+ );
+ // if the user clears the url bar we again fall back to the location of the prompt if we trigger setURI by a tab switch
+ gBrowser.selectedBrowser.userTypedValue = "";
+ gURLBar.setURI(null, true);
+ Assert.equal(
+ window.gURLBar.value,
+ AUTH_URL,
+ "Correct location is provided by the prompt"
+ );
+ // Cross domain and pref is not enabled
+ } else {
+ Assert.equal(
+ dialog._overlay.getAttribute("hideContent"),
+ "",
+ "Dialog overlay does not hide the current sites content"
+ );
+ Assert.equal(
+ window.gURLBar.value,
+ CROSS_DOMAIN_URL,
+ "No location is provided by the prompt, correct location is displayed"
+ );
+ Assert.equal(
+ window.gBrowser.selectedTab.label,
+ "example.com",
+ "Tab title is not manipulated"
+ );
+ }
+ // same domain
+ } else {
+ Assert.equal(
+ dialog._overlay.getAttribute("hideContent"),
+ "",
+ "Dialog overlay does not hide the current sites content"
+ );
+ Assert.equal(
+ window.gURLBar.value,
+ SAME_DOMAIN_URL,
+ "No location is provided by the prompt, correct location is displayed"
+ );
+ Assert.equal(
+ window.gBrowser.selectedTab.label,
+ "example.com",
+ "Tab title is not manipulated"
+ );
+ }
+
+ let onDialogClosed = BrowserTestUtils.waitForEvent(
+ window,
+ "DOMModalDialogClosed"
+ );
+ if (doConfirmPrompt) {
+ dialogDocument.getElementById("loginTextbox").value = "guest";
+ dialogDocument.getElementById("password1Textbox").value = "guest";
+ dialogDocument.getElementById("commonDialog").acceptDialog();
+ } else {
+ dialogDocument.getElementById("commonDialog").cancelDialog();
+ }
+
+ // wait for the dialog to be closed to check that the URLBar state is reset
+ await onDialogClosed;
+ // Due to bug 1812014, the url bar will be clear if we have set its value to "" while the prompt was open
+ // so we trigger a tab switch again to have the uri displayed to be able to check its value
+ gURLBar.setURI(null, true);
+ Assert.equal(
+ window.gURLBar.value,
+ crossDomain ? CROSS_DOMAIN_URL : SAME_DOMAIN_URL,
+ "No location is provided by the prompt"
+ );
+ Assert.equal(
+ window.gBrowser.selectedTab.label,
+ "example.com",
+ "Tab title is not manipulated"
+ );
+}
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [["privacy.authPromptSpoofingProtection", true]],
+ });
+});
+
+/**
+ * Tests that the 401 auth spoofing mechanisms apply if the 401 is from a different base domain than the current sites,
+ * canceling the prompt
+ */
+add_task(async function testCrossDomainCancelPrefEnabled() {
+ await trigger401AndHandle(false, true, true);
+});
+
+/**
+ * Tests that the 401 auth spoofing mechanisms apply if the 401 is from a different base domain than the current sites,
+ * accepting the prompt
+ */
+add_task(async function testCrossDomainAcceptPrefEnabled() {
+ await trigger401AndHandle(true, true, true);
+});
+
+/**
+ * Tests that the 401 auth spoofing mechanisms do not apply if "privacy.authPromptSpoofingProtection" is not set to true
+ * canceling the prompt
+ */
+add_task(async function testCrossDomainCancelPrefDisabled() {
+ await trigger401AndHandle(false, true, false);
+});
+
+/**
+ * Tests that the 401 auth spoofing mechanisms do not apply if "privacy.authPromptSpoofingProtection" is not set to true,
+ * accepting the prompt
+ */
+add_task(async function testCrossDomainAcceptPrefDisabled() {
+ await trigger401AndHandle(true, true, false);
+});
+
+/**
+ * Tests that the 401 auth spoofing mechanisms are not triggered by a 401 within the same base domain as the current sites,
+ * canceling the prompt
+ */
+add_task(async function testSameDomainCancelPrefEnabled() {
+ await trigger401AndHandle(false, false, true);
+});
+
+/**
+ * Tests that the 401 auth spoofing mechanisms are not triggered by a 401 within the same base domain as the current sites,
+ * accepting the prompt
+ */
+add_task(async function testSameDomainAcceptPrefEnabled() {
+ await trigger401AndHandle(true, false, true);
+});
diff --git a/browser/base/content/test/tabPrompts/browser_auth_spoofing_url_copy.js b/browser/base/content/test/tabPrompts/browser_auth_spoofing_url_copy.js
new file mode 100644
index 0000000000..5bea05020e
--- /dev/null
+++ b/browser/base/content/test/tabPrompts/browser_auth_spoofing_url_copy.js
@@ -0,0 +1,95 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+let TEST_PATH = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+);
+
+let TEST_PATH_AUTH = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.org"
+);
+
+const CROSS_DOMAIN_URL = TEST_PATH + "redirect-crossDomain.html";
+
+const AUTH_URL = TEST_PATH_AUTH + "auth-route.sjs";
+
+/**
+ * Opens a new tab with a url that redirects us cross domain
+ * tests that auth anti-spoofing mechanisms cover url copy while prompt is open
+ *
+ */
+async function trigger401AndHandle() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["privacy.authPromptSpoofingProtection", true]],
+ });
+ let dialogShown = waitForDialogAndCopyURL();
+ await BrowserTestUtils.withNewTab(CROSS_DOMAIN_URL, async function () {
+ await dialogShown;
+ });
+ await new Promise(resolve => {
+ Services.clearData.deleteData(
+ Ci.nsIClearDataService.CLEAR_AUTH_CACHE,
+ resolve
+ );
+ });
+}
+
+async function waitForDialogAndCopyURL() {
+ await TestUtils.topicObserved("common-dialog-loaded");
+ let dialog = gBrowser.getTabDialogBox(gBrowser.selectedBrowser)
+ ._tabDialogManager._topDialog;
+ let dialogDocument = dialog._frame.contentDocument;
+
+ //select the whole URL
+ gURLBar.focus();
+ await SimpleTest.promiseClipboardChange(AUTH_URL, () => {
+ Assert.equal(gURLBar.value, AUTH_URL, "url bar copy value set");
+ gURLBar.select();
+ goDoCommand("cmd_copy");
+ });
+
+ // select only part of the URL
+ gURLBar.focus();
+ let endOfSelectionRange = AUTH_URL.indexOf("/auth-route.sjs");
+ await SimpleTest.promiseClipboardChange(
+ AUTH_URL.substring(0, endOfSelectionRange),
+ () => {
+ Assert.equal(gURLBar.value, AUTH_URL, "url bar copy value set");
+ gURLBar.selectionStart = 0;
+ gURLBar.selectionEnd = endOfSelectionRange;
+ goDoCommand("cmd_copy");
+ }
+ );
+ let onDialogClosed = BrowserTestUtils.waitForEvent(
+ window,
+ "DOMModalDialogClosed"
+ );
+ dialogDocument.getElementById("commonDialog").cancelDialog();
+
+ await onDialogClosed;
+ Assert.equal(
+ window.gURLBar.value,
+ CROSS_DOMAIN_URL,
+ "No location is provided by the prompt"
+ );
+
+ //select the whole URL after URL is reset to normal
+ gURLBar.focus();
+ await SimpleTest.promiseClipboardChange(CROSS_DOMAIN_URL, () => {
+ Assert.equal(gURLBar.value, CROSS_DOMAIN_URL, "url bar copy value set");
+ gURLBar.select();
+ goDoCommand("cmd_copy");
+ });
+}
+
+/**
+ * Tests that the 401 auth spoofing mechanisms covers the url bar copy action properly,
+ * canceling the prompt
+ */
+add_task(async function testUrlCopy() {
+ await trigger401AndHandle();
+});
diff --git a/browser/base/content/test/tabPrompts/browser_auth_spoofing_url_drag_and_drop.js b/browser/base/content/test/tabPrompts/browser_auth_spoofing_url_drag_and_drop.js
new file mode 100644
index 0000000000..564f1fa9a7
--- /dev/null
+++ b/browser/base/content/test/tabPrompts/browser_auth_spoofing_url_drag_and_drop.js
@@ -0,0 +1,93 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+let TEST_PATH = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+);
+
+let TEST_PATH_AUTH = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.org"
+);
+
+const CROSS_DOMAIN_URL = TEST_PATH + "redirect-crossDomain.html";
+
+const SAME_DOMAIN_URL = TEST_PATH + "redirect-sameDomain.html";
+
+const AUTH_URL = TEST_PATH_AUTH + "auth-route.sjs";
+
+/**
+ * Opens a new tab with a url that ether redirects us cross or same domain
+ *
+ * @param {Boolean} crossDomain - if true we will open a url that redirects us to a cross domain url,
+ * if false, we will open a url that redirects us to a same domain url
+ */
+async function trigger401AndHandle(crossDomain) {
+ let dialogShown = waitForDialogAndDragNDropURL(crossDomain);
+ await BrowserTestUtils.withNewTab(
+ crossDomain ? CROSS_DOMAIN_URL : SAME_DOMAIN_URL,
+ async function () {
+ await dialogShown;
+ }
+ );
+ await new Promise(resolve => {
+ Services.clearData.deleteData(
+ Ci.nsIClearDataService.CLEAR_AUTH_CACHE,
+ resolve
+ );
+ });
+}
+
+async function waitForDialogAndDragNDropURL(crossDomain) {
+ await TestUtils.topicObserved("common-dialog-loaded");
+ let dialog = gBrowser.getTabDialogBox(gBrowser.selectedBrowser)
+ ._tabDialogManager._topDialog;
+ let dialogDocument = dialog._frame.contentDocument;
+
+ let urlbar = document.getElementById("urlbar-input");
+ let dataTran = new DataTransfer();
+ let urlEvent = new DragEvent("dragstart", { dataTransfer: dataTran });
+ let urlBarContainer = document.getElementById("urlbar-input-container");
+ urlBarContainer.click();
+ // trigger a drag event in the gUrlBar
+ urlbar.dispatchEvent(urlEvent);
+ // this should set some propperties on our event, like the url we are dragging
+ if (crossDomain) {
+ is(
+ urlEvent.dataTransfer.getData("text/plain"),
+ AUTH_URL,
+ "correct cross Domain URL is dragged over"
+ );
+ } else {
+ is(
+ urlEvent.dataTransfer.getData("text/plain"),
+ SAME_DOMAIN_URL,
+ "correct same domain URL is dragged over"
+ );
+ }
+
+ let onDialogClosed = BrowserTestUtils.waitForEvent(
+ window,
+ "DOMModalDialogClosed"
+ );
+ dialogDocument.getElementById("commonDialog").cancelDialog();
+
+ await onDialogClosed;
+}
+
+/**
+ * Tests that the 401 auth spoofing mechanisms covers the url bar drag and drop action propperly,
+ */
+add_task(async function testUrlDragAndDrop() {
+ await trigger401AndHandle(true);
+});
+
+/**
+ * Tests that the 401 auth spoofing mechanisms do not apply to the url bar drag and drop action if the 401 is not from a different base domain,
+ */
+add_task(async function testUrlDragAndDrop() {
+ await trigger401AndHandle(false);
+});
diff --git a/browser/base/content/test/tabPrompts/browser_beforeunload_urlbar.js b/browser/base/content/test/tabPrompts/browser_beforeunload_urlbar.js
new file mode 100644
index 0000000000..cb53783c99
--- /dev/null
+++ b/browser/base/content/test/tabPrompts/browser_beforeunload_urlbar.js
@@ -0,0 +1,75 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TEST_ROOT = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com"
+);
+
+const CONTENT_PROMPT_SUBDIALOG = Services.prefs.getBoolPref(
+ "prompts.contentPromptSubDialog",
+ false
+);
+
+add_task(async function test_beforeunload_stay_clears_urlbar() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["dom.require_user_interaction_for_beforeunload", false]],
+ });
+
+ const TEST_URL = TEST_ROOT + "file_beforeunload_stop.html";
+ await BrowserTestUtils.withNewTab(TEST_URL, async function (browser) {
+ gURLBar.focus();
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ const inputValue = "http://example.org/?q=typed";
+ gURLBar.inputField.value = inputValue.slice(0, -1);
+ EventUtils.sendString(inputValue.slice(-1));
+
+ if (CONTENT_PROMPT_SUBDIALOG) {
+ let promptOpenedPromise =
+ BrowserTestUtils.promiseAlertDialogOpen("cancel");
+ EventUtils.synthesizeKey("VK_RETURN");
+ await promptOpenedPromise;
+ await TestUtils.waitForTick();
+ } else {
+ let promptOpenedPromise = TestUtils.topicObserved(
+ "tabmodal-dialog-loaded"
+ );
+ EventUtils.synthesizeKey("VK_RETURN");
+ await promptOpenedPromise;
+ let promptElement = browser.parentNode.querySelector("tabmodalprompt");
+
+ // Click the cancel button
+ promptElement.querySelector(".tabmodalprompt-button1").click();
+ await TestUtils.waitForCondition(
+ () => promptElement.parentNode == null,
+ "tabprompt should be removed"
+ );
+ }
+
+ // Can't just compare directly with TEST_URL because the URL may be trimmed.
+ // Just need it to not be the example.org thing we typed in.
+ ok(
+ gURLBar.value.endsWith("_stop.html"),
+ "Url bar should be reset to point to the stop html file"
+ );
+ ok(
+ gURLBar.value.includes("example.com"),
+ "Url bar should be reset to example.com"
+ );
+ // Check the lock/identity icons are back:
+ is(
+ gURLBar.textbox.getAttribute("pageproxystate"),
+ "valid",
+ "Should be in valid pageproxy state."
+ );
+
+ // Now we need to get rid of the handler to avoid the prompt coming up when trying to close the
+ // tab when we exit `withNewTab`. :-)
+ await SpecialPowers.spawn(browser, [], function () {
+ content.window.onbeforeunload = null;
+ });
+ });
+});
diff --git a/browser/base/content/test/tabPrompts/browser_closeTabSpecificPanels.js b/browser/base/content/test/tabPrompts/browser_closeTabSpecificPanels.js
new file mode 100644
index 0000000000..8d789ad512
--- /dev/null
+++ b/browser/base/content/test/tabPrompts/browser_closeTabSpecificPanels.js
@@ -0,0 +1,53 @@
+"use strict";
+
+/*
+ * This test creates multiple panels, one that has been tagged as specific to its tab's content
+ * and one that isn't. When a tab loses focus, panel specific to that tab should close.
+ * The non-specific panel should remain open.
+ *
+ */
+
+add_task(async function () {
+ let tab1 = BrowserTestUtils.addTab(gBrowser, "http://mochi.test:8888/#0");
+ let tab2 = BrowserTestUtils.addTab(gBrowser, "http://mochi.test:8888/#1");
+ let specificPanel = document.createXULElement("panel");
+ specificPanel.setAttribute("tabspecific", "true");
+ specificPanel.setAttribute("noautohide", "true");
+ let generalPanel = document.createXULElement("panel");
+ generalPanel.setAttribute("noautohide", "true");
+ let anchor = document.getElementById(CustomizableUI.AREA_NAVBAR);
+
+ anchor.appendChild(specificPanel);
+ anchor.appendChild(generalPanel);
+ is(specificPanel.state, "closed", "specificPanel starts as closed");
+ is(generalPanel.state, "closed", "generalPanel starts as closed");
+
+ let specificPanelPromise = BrowserTestUtils.waitForEvent(
+ specificPanel,
+ "popupshown"
+ );
+ specificPanel.openPopupAtScreen(210, 210);
+ await specificPanelPromise;
+ is(specificPanel.state, "open", "specificPanel has been opened");
+
+ let generalPanelPromise = BrowserTestUtils.waitForEvent(
+ generalPanel,
+ "popupshown"
+ );
+ generalPanel.openPopupAtScreen(510, 510);
+ await generalPanelPromise;
+ is(generalPanel.state, "open", "generalPanel has been opened");
+
+ gBrowser.tabContainer.advanceSelectedTab(-1, true);
+ is(
+ specificPanel.state,
+ "closed",
+ "specificPanel panel is closed after its tab loses focus"
+ );
+ is(generalPanel.state, "open", "generalPanel is still open after tab switch");
+
+ specificPanel.remove();
+ generalPanel.remove();
+ gBrowser.removeTab(tab1);
+ gBrowser.removeTab(tab2);
+});
diff --git a/browser/base/content/test/tabPrompts/browser_confirmFolderUpload.js b/browser/base/content/test/tabPrompts/browser_confirmFolderUpload.js
new file mode 100644
index 0000000000..62b0ed4f2b
--- /dev/null
+++ b/browser/base/content/test/tabPrompts/browser_confirmFolderUpload.js
@@ -0,0 +1,141 @@
+/* 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"
+);
+
+/**
+ * Create a temporary test directory that will be cleaned up on test shutdown.
+ * @returns {String} - absolute directory path.
+ */
+function getTestDirectory() {
+ let tmpDir = Services.dirsvc.get("TmpD", Ci.nsIFile);
+ tmpDir.append("testdir");
+ if (!tmpDir.exists()) {
+ tmpDir.create(Ci.nsIFile.DIRECTORY_TYPE, 0o755);
+ registerCleanupFunction(() => {
+ tmpDir.remove(true);
+ });
+ }
+
+ let file1 = tmpDir.clone();
+ file1.append("foo.txt");
+ if (!file1.exists()) {
+ file1.create(Ci.nsIFile.NORMAL_FILE_TYPE, 0o600);
+ }
+
+ let file2 = tmpDir.clone();
+ file2.append("bar.txt");
+ if (!file2.exists()) {
+ file2.create(Ci.nsIFile.NORMAL_FILE_TYPE, 0o600);
+ }
+
+ return tmpDir.path;
+}
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ // Allow using our MockFilePicker in the content process.
+ ["dom.filesystem.pathcheck.disabled", true],
+ ["dom.webkitBlink.dirPicker.enabled", true],
+ ],
+ });
+});
+
+/**
+ * Create a file input, select a folder and wait for the upload confirmation
+ * prompt to open.
+ * @param {boolean} confirmUpload - Whether to accept (true) or cancel the
+ * prompt (false).
+ * @returns {Promise} - Resolves once the prompt has been closed.
+ */
+async function testUploadPrompt(confirmUpload) {
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ await BrowserTestUtils.withNewTab("http://example.com", async browser => {
+ // Create file input element
+ await ContentTask.spawn(browser, null, () => {
+ let input = content.document.createElement("input");
+ input.id = "filepicker";
+ input.setAttribute("type", "file");
+ input.setAttribute("webkitdirectory", "");
+ content.document.body.appendChild(input);
+ });
+
+ // If we're confirming the dialog, register a "change" listener on the
+ // file input.
+ let changePromise;
+ if (confirmUpload) {
+ changePromise = ContentTask.spawn(browser, null, async () => {
+ let input = content.document.getElementById("filepicker");
+ return ContentTaskUtils.waitForEvent(input, "change").then(
+ e => e.target.files.length
+ );
+ });
+ }
+
+ // Register prompt promise
+ let promptPromise = PromptTestUtils.waitForPrompt(browser, {
+ modalType: Services.prompt.MODAL_TYPE_TAB,
+ promptType: "confirmEx",
+ });
+
+ // Open filepicker
+ let path = getTestDirectory();
+ await ContentTask.spawn(browser, { path }, args => {
+ let MockFilePicker = content.SpecialPowers.MockFilePicker;
+ MockFilePicker.init(
+ content,
+ "A Mock File Picker",
+ content.SpecialPowers.Ci.nsIFilePicker.modeGetFolder
+ );
+ MockFilePicker.useDirectory(args.path);
+
+ let input = content.document.getElementById("filepicker");
+ input.click();
+ });
+
+ // Wait for confirmation prompt
+ let prompt = await promptPromise;
+ ok(prompt, "Shown upload confirmation prompt");
+ is(prompt.ui.button0.label, "Upload", "Accept button label");
+ ok(prompt.ui.button1.hasAttribute("default"), "Cancel is default button");
+
+ // Close confirmation prompt
+ await PromptTestUtils.handlePrompt(prompt, {
+ buttonNumClick: confirmUpload ? 0 : 1,
+ });
+
+ // If we accepted, wait for the input elements "change" event
+ if (changePromise) {
+ let fileCount = await changePromise;
+ is(fileCount, 2, "Should have selected 2 files");
+ } else {
+ let fileCount = await ContentTask.spawn(browser, null, () => {
+ return content.document.getElementById("filepicker").files.length;
+ });
+
+ is(fileCount, 0, "Should not have selected any files");
+ }
+
+ // Cleanup
+ await ContentTask.spawn(browser, null, () => {
+ content.SpecialPowers.MockFilePicker.cleanup();
+ });
+ });
+}
+
+// Tests the confirmation prompt that shows after the user picked a folder.
+
+// Confirm the prompt
+add_task(async function test_confirm() {
+ await testUploadPrompt(true);
+});
+
+// Cancel the prompt
+add_task(async function test_cancel() {
+ await testUploadPrompt(false);
+});
diff --git a/browser/base/content/test/tabPrompts/browser_contentOrigins.js b/browser/base/content/test/tabPrompts/browser_contentOrigins.js
new file mode 100644
index 0000000000..d39c8a3f67
--- /dev/null
+++ b/browser/base/content/test/tabPrompts/browser_contentOrigins.js
@@ -0,0 +1,217 @@
+/* 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"
+);
+
+let { HttpServer } = ChromeUtils.import("resource://testing-common/httpd.js");
+
+const TEST_ROOT = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+);
+
+async function checkAlert(
+ pageToLoad,
+ expectedTitle,
+ expectedIcon = "chrome://global/skin/icons/defaultFavicon.svg"
+) {
+ function openFn(browser) {
+ return SpecialPowers.spawn(browser, [], () => {
+ if (content.document.nodePrincipal.isSystemPrincipal) {
+ // Can't eval in privileged contexts due to CSP, just call directly:
+ content.alert("Test");
+ } else {
+ // Eval everywhere else so it gets the principal of the loaded page.
+ content.eval("alert('Test')");
+ }
+ });
+ }
+ return checkDialog(pageToLoad, openFn, expectedTitle, expectedIcon);
+}
+
+async function checkBeforeunload(
+ pageToLoad,
+ expectedTitle,
+ expectedIcon = "chrome://global/skin/icons/defaultFavicon.svg"
+) {
+ async function openFn(browser) {
+ let tab = gBrowser.getTabForBrowser(browser);
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "body",
+ {},
+ browser.browsingContext
+ );
+ return gBrowser.removeTab(tab); // trigger beforeunload.
+ }
+ return checkDialog(pageToLoad, openFn, expectedTitle, expectedIcon);
+}
+
+async function checkDialog(
+ pageToLoad,
+ openFn,
+ expectedTitle,
+ expectedIcon,
+ modalType = Ci.nsIPrompt.MODAL_TYPE_CONTENT
+) {
+ return BrowserTestUtils.withNewTab(pageToLoad, async browser => {
+ let promptPromise = PromptTestUtils.waitForPrompt(browser, {
+ modalType,
+ });
+ let spawnPromise = openFn(browser);
+ let dialog = await promptPromise;
+
+ let doc = dialog.ui.prompt.document;
+ let titleEl = doc.getElementById("titleText");
+ if (expectedTitle.value) {
+ is(titleEl.textContent, expectedTitle.value, "Title should match.");
+ } else {
+ is(
+ titleEl.dataset.l10nId,
+ expectedTitle.l10nId,
+ "Title l10n id should match."
+ );
+ }
+ ok(
+ !titleEl.parentNode.hasAttribute("overflown"),
+ "Title should fit without overflowing."
+ );
+
+ ok(BrowserTestUtils.is_visible(titleEl), "New title should be shown.");
+ ok(
+ BrowserTestUtils.is_hidden(doc.getElementById("infoTitle")),
+ "Old title should be hidden."
+ );
+ let iconCS = doc.ownerGlobal.getComputedStyle(
+ doc.querySelector(".titleIcon")
+ );
+ is(
+ iconCS.backgroundImage,
+ `url("${expectedIcon}")`,
+ "Icon is as expected."
+ );
+
+ // This is not particularly neat, but we want to also test overflow
+ // Our test systems don't have hosts that long, so just fake it:
+ if (browser.currentURI.asciiHost == "example.com") {
+ let longerDomain = "extravagantly.long.".repeat(10) + "example.com";
+ doc.documentElement.setAttribute(
+ "headertitle",
+ JSON.stringify({ raw: longerDomain, shouldUseMaskFade: true })
+ );
+ info("Wait for the prompt title to update.");
+ await BrowserTestUtils.waitForMutationCondition(
+ titleEl,
+ { characterData: true, attributes: true },
+ () =>
+ titleEl.textContent == longerDomain &&
+ titleEl.parentNode.hasAttribute("overflown")
+ );
+ is(titleEl.textContent, longerDomain, "The longer domain is reflected.");
+ ok(
+ titleEl.parentNode.hasAttribute("overflown"),
+ "The domain should overflow."
+ );
+ }
+
+ // Close the prompt again.
+ await PromptTestUtils.handlePrompt(dialog);
+ // The alert in the content process was sync, we need to make sure it gets
+ // cleaned up, but couldn't await it above because that'd hang the test!
+ await spawnPromise;
+ });
+}
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["prompts.contentPromptSubDialog", true],
+ ["prompts.modalType.httpAuth", Ci.nsIPrompt.MODAL_TYPE_TAB],
+ ["prompts.tabChromePromptSubDialog", true],
+ ],
+ });
+});
+
+add_task(async function test_check_prompt_origin_display() {
+ await checkAlert("https://example.com/", { value: "example.com" });
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ await checkAlert("http://example.com/", { value: "example.com" });
+ await checkAlert("data:text/html,<body>", {
+ l10nId: "common-dialog-title-null",
+ });
+
+ let homeDir = Services.dirsvc.get("Home", Ci.nsIFile);
+ let fileURI = Services.io.newFileURI(homeDir).spec;
+ await checkAlert(fileURI, { value: "file://" });
+
+ await checkAlert(
+ "about:config",
+ { l10nId: "common-dialog-title-system" },
+ "chrome://branding/content/icon32.png"
+ );
+
+ await checkBeforeunload(TEST_ROOT + "file_beforeunload_stop.html", {
+ value: "example.com",
+ });
+});
+
+add_task(async function test_check_auth() {
+ let server = new HttpServer();
+ registerCleanupFunction(() => {
+ return new Promise(resolve => {
+ server.stop(() => {
+ server = null;
+ resolve();
+ });
+ });
+ });
+
+ function forbiddenHandler(meta, res) {
+ res.setStatusLine(meta.httpVersion, 401, "Unauthorized");
+ res.setHeader("WWW-Authenticate", 'Basic realm="Realm"');
+ }
+ function pageHandler(meta, res) {
+ res.setStatusLine(meta.httpVersion, 200, "OK");
+ res.setHeader("Content-Type", "text/html");
+ let body = "<html><body></body></html>";
+ res.bodyOutputStream.write(body, body.length);
+ }
+ server.registerPathHandler("/forbidden", forbiddenHandler);
+ server.registerPathHandler("/page", pageHandler);
+ server.start(-1);
+
+ const HOST = `localhost:${server.identity.primaryPort}`;
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ const AUTH_URI = `http://${HOST}/forbidden`;
+
+ // Try a simple load:
+ await checkDialog(
+ "https://example.com/",
+ browser => BrowserTestUtils.loadURIString(browser, AUTH_URI),
+ HOST,
+ "chrome://global/skin/icons/defaultFavicon.svg",
+ Ci.nsIPrompt.MODAL_TYPE_TAB
+ );
+
+ let subframeLoad = function (browser) {
+ return SpecialPowers.spawn(browser, [AUTH_URI], uri => {
+ let f = content.document.createElement("iframe");
+ f.src = uri;
+ content.document.body.appendChild(f);
+ });
+ };
+
+ // Try x-origin subframe:
+ await checkDialog(
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.org/1",
+ subframeLoad,
+ HOST,
+ /* Because this is x-origin, we expect a different icon: */
+ "chrome://global/skin/icons/security-broken.svg",
+ Ci.nsIPrompt.MODAL_TYPE_TAB
+ );
+});
diff --git a/browser/base/content/test/tabPrompts/browser_multiplePrompts.js b/browser/base/content/test/tabPrompts/browser_multiplePrompts.js
new file mode 100644
index 0000000000..597b7dfd6f
--- /dev/null
+++ b/browser/base/content/test/tabPrompts/browser_multiplePrompts.js
@@ -0,0 +1,171 @@
+"use strict";
+
+const CONTENT_PROMPT_SUBDIALOG = Services.prefs.getBoolPref(
+ "prompts.contentPromptSubDialog",
+ false
+);
+
+/**
+ * Goes through a stacked series of dialogs opened with
+ * CONTENT_PROMPT_SUBDIALOG set to true, and ensures that
+ * the oldest one is front-most and has the right type. It
+ * then closes the oldest to newest dialog.
+ *
+ * @param {Element} tab The <tab> that has had content dialogs opened
+ * for it.
+ * @param {Number} promptCount How many dialogs we expected to have been
+ * opened.
+ *
+ * @return {Promise}
+ * @resolves {undefined} Once the dialogs have all been closed.
+ */
+async function closeDialogs(tab, dialogCount) {
+ let dialogElementsCount = dialogCount;
+ let dialogs =
+ tab.linkedBrowser.tabDialogBox.getContentDialogManager().dialogs;
+
+ is(
+ dialogs.length,
+ dialogElementsCount,
+ "There should be " + dialogElementsCount + " dialog(s)."
+ );
+
+ let i = dialogElementsCount - 1;
+ for (let dialog of dialogs) {
+ dialog.focus(true);
+ await dialog._dialogReady;
+
+ let dialogWindow = dialog.frameContentWindow;
+ let expectedType = ["alert", "prompt", "confirm"][i % 3];
+
+ is(
+ dialogWindow.Dialog.args.text,
+ expectedType + " countdown #" + i,
+ "The #" + i + " alert should be labelled as such."
+ );
+ i--;
+
+ dialogWindow.Dialog.ui.button0.click();
+
+ // The click is handled async; wait for an event loop turn for that to
+ // happen.
+ await new Promise(function (resolve) {
+ Services.tm.dispatchToMainThread(resolve);
+ });
+ }
+
+ dialogs = tab.linkedBrowser.tabDialogBox.getContentDialogManager().dialogs;
+ is(dialogs.length, 0, "Dialogs should all be dismissed.");
+}
+
+/**
+ * Goes through a stacked series of tabprompt modals opened with
+ * CONTENT_PROMPT_SUBDIALOG set to false, and ensures that
+ * the oldest one is front-most and has the right type. It also
+ * ensures that the other tabprompt modals are hidden. It
+ * then closes the oldest to newest dialog.
+ *
+ * @param {Element} tab The <tab> that has had tabprompt modals opened
+ * for it.
+ * @param {Number} promptCount How many modals we expected to have been
+ * opened.
+ *
+ * @return {Promise}
+ * @resolves {undefined} Once the modals have all been closed.
+ */
+async function closeTabModals(tab, promptCount) {
+ let promptElementsCount = promptCount;
+ while (promptElementsCount--) {
+ let promptElements =
+ tab.linkedBrowser.parentNode.querySelectorAll("tabmodalprompt");
+ is(
+ promptElements.length,
+ promptElementsCount + 1,
+ "There should be " + (promptElementsCount + 1) + " prompt(s)."
+ );
+ // The oldest should be the first.
+ let i = 0;
+
+ for (let promptElement of promptElements) {
+ let prompt = tab.linkedBrowser.tabModalPromptBox.getPrompt(promptElement);
+ let expectedType = ["alert", "prompt", "confirm"][i % 3];
+ is(
+ prompt.Dialog.args.text,
+ expectedType + " countdown #" + i,
+ "The #" + i + " alert should be labelled as such."
+ );
+ if (i !== promptElementsCount) {
+ is(prompt.element.hidden, true, "This prompt should be hidden.");
+ i++;
+ continue;
+ }
+
+ is(prompt.element.hidden, false, "The last prompt should not be hidden.");
+ prompt.onButtonClick(0);
+
+ // The click is handled async; wait for an event loop turn for that to
+ // happen.
+ await new Promise(function (resolve) {
+ Services.tm.dispatchToMainThread(resolve);
+ });
+ }
+ }
+
+ let promptElements =
+ tab.linkedBrowser.parentNode.querySelectorAll("tabmodalprompt");
+ is(promptElements.length, 0, "Prompts should all be dismissed.");
+}
+
+/*
+ * This test triggers multiple alerts on one single tab, because it"s possible
+ * for web content to do so. The behavior is described in bug 1266353.
+ *
+ * We assert the presentation of the multiple alerts, ensuring we show only
+ * the oldest one.
+ */
+add_task(async function () {
+ const PROMPTCOUNT = 9;
+
+ let unopenedPromptCount = PROMPTCOUNT;
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com",
+ true
+ );
+ info("Tab loaded");
+
+ let promptsOpenedPromise = BrowserTestUtils.waitForEvent(
+ tab.linkedBrowser,
+ "DOMWillOpenModalDialog",
+ false,
+ () => {
+ unopenedPromptCount--;
+ return unopenedPromptCount == 0;
+ }
+ );
+
+ await SpecialPowers.spawn(tab.linkedBrowser, [PROMPTCOUNT], maxPrompts => {
+ var i = maxPrompts;
+ let fns = ["alert", "prompt", "confirm"];
+ function openDialog() {
+ i--;
+ if (i) {
+ SpecialPowers.Services.tm.dispatchToMainThread(openDialog);
+ }
+ content[fns[i % 3]](fns[i % 3] + " countdown #" + i);
+ }
+ SpecialPowers.Services.tm.dispatchToMainThread(openDialog);
+ });
+
+ await promptsOpenedPromise;
+
+ if (CONTENT_PROMPT_SUBDIALOG) {
+ await closeDialogs(tab, PROMPTCOUNT);
+ } else {
+ await closeTabModals(tab, PROMPTCOUNT);
+ }
+
+ BrowserTestUtils.removeTab(tab);
+});
diff --git a/browser/base/content/test/tabPrompts/browser_openPromptInBackgroundTab.js b/browser/base/content/test/tabPrompts/browser_openPromptInBackgroundTab.js
new file mode 100644
index 0000000000..d95faa9665
--- /dev/null
+++ b/browser/base/content/test/tabPrompts/browser_openPromptInBackgroundTab.js
@@ -0,0 +1,262 @@
+"use strict";
+
+const { PermissionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PermissionTestUtils.sys.mjs"
+);
+
+const ROOT = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content/",
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com/"
+);
+let pageWithAlert = ROOT + "openPromptOffTimeout.html";
+
+registerCleanupFunction(function () {
+ Services.perms.removeAll();
+});
+
+/*
+ * This test opens a tab that alerts when it is hidden. We then switch away
+ * from the tab, and check that by default the tab is not automatically
+ * re-selected. We also check that a checkbox appears in the alert that allows
+ * the user to enable this automatically re-selecting. We then check that
+ * checking the checkbox does actually enable that behaviour.
+ */
+add_task(async function test_old_modal_ui() {
+ // We're intentionally testing the old modal mechanism, so disable the new one.
+ await SpecialPowers.pushPrefEnv({
+ set: [["prompts.contentPromptSubDialog", false]],
+ });
+
+ let firstTab = gBrowser.selectedTab;
+ // load page that opens prompt when page is hidden
+ let openedTab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ pageWithAlert,
+ true
+ );
+ let openedTabGotAttentionPromise = BrowserTestUtils.waitForAttribute(
+ "attention",
+ openedTab
+ );
+ // switch away from that tab again - this triggers the alert.
+ await BrowserTestUtils.switchTab(gBrowser, firstTab);
+ // ... but that's async on e10s...
+ await openedTabGotAttentionPromise;
+ // check for attention attribute
+ is(
+ openedTab.hasAttribute("attention"),
+ true,
+ "Tab with alert should have 'attention' attribute."
+ );
+ ok(!openedTab.selected, "Tab with alert should not be selected");
+
+ // switch tab back, and check the checkbox is displayed:
+ await BrowserTestUtils.switchTab(gBrowser, openedTab);
+ // check the prompt is there, and the extra row is present
+ let promptElements =
+ openedTab.linkedBrowser.parentNode.querySelectorAll("tabmodalprompt");
+ is(promptElements.length, 1, "There should be 1 prompt");
+ let ourPromptElement = promptElements[0];
+ let checkbox = ourPromptElement.querySelector(
+ "checkbox[label*='example.com']"
+ );
+ ok(checkbox, "The checkbox should be there");
+ ok(!checkbox.checked, "Checkbox shouldn't be checked");
+ // tick box and accept dialog
+ checkbox.checked = true;
+ let ourPrompt =
+ openedTab.linkedBrowser.tabModalPromptBox.getPrompt(ourPromptElement);
+ ourPrompt.onButtonClick(0);
+ // Wait for that click to actually be handled completely.
+ await new Promise(function (resolve) {
+ Services.tm.dispatchToMainThread(resolve);
+ });
+ // check permission is set
+ is(
+ Services.perms.ALLOW_ACTION,
+ PermissionTestUtils.testPermission(pageWithAlert, "focus-tab-by-prompt"),
+ "Tab switching should now be allowed"
+ );
+
+ // Check if the control center shows the correct permission.
+ let shown = BrowserTestUtils.waitForEvent(
+ window,
+ "popupshown",
+ true,
+ event => event.target == gPermissionPanel._permissionPopup
+ );
+ gPermissionPanel._identityPermissionBox.click();
+ await shown;
+ let labelText = SitePermissions.getPermissionLabel("focus-tab-by-prompt");
+ let permissionsList = document.getElementById(
+ "permission-popup-permission-list"
+ );
+ let label = permissionsList.querySelector(
+ ".permission-popup-permission-label"
+ );
+ is(label.textContent, labelText);
+ gPermissionPanel._permissionPopup.hidePopup();
+
+ // Check if the identity icon signals granted permission.
+ ok(
+ gPermissionPanel._identityPermissionBox.hasAttribute("hasPermissions"),
+ "identity-box signals granted permissions"
+ );
+
+ let openedTabSelectedPromise = BrowserTestUtils.waitForAttribute(
+ "selected",
+ openedTab,
+ "true"
+ );
+ // switch to other tab again
+ await BrowserTestUtils.switchTab(gBrowser, firstTab);
+
+ // This is sync in non-e10s, but in e10s we need to wait for this, so yield anyway.
+ // Note that the switchTab promise doesn't actually guarantee anything about *which*
+ // tab ends up as selected when its event fires, so using that here wouldn't work.
+ await openedTabSelectedPromise;
+ // should be switched back
+ ok(openedTab.selected, "Ta-dah, the other tab should now be selected again!");
+
+ // In e10s, with the conformant promise scheduling, we have to wait for next tick
+ // to ensure that the prompt is open before removing the opened tab, because the
+ // promise callback of 'openedTabSelectedPromise' could be done at the middle of
+ // RemotePrompt.openTabPrompt() while 'DOMModalDialogClosed' event is fired.
+ await TestUtils.waitForTick();
+
+ BrowserTestUtils.removeTab(openedTab);
+});
+
+add_task(async function test_new_modal_ui() {
+ // We're intentionally testing the new modal mechanism, so make sure it's enabled.
+ await SpecialPowers.pushPrefEnv({
+ set: [["prompts.contentPromptSubDialog", true]],
+ });
+
+ // Make sure we clear the focus tab permission set in the previous test
+ PermissionTestUtils.remove(pageWithAlert, "focus-tab-by-prompt");
+
+ let firstTab = gBrowser.selectedTab;
+ // load page that opens prompt when page is hidden
+ let openedTab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ pageWithAlert,
+ true
+ );
+ let openedTabGotAttentionPromise = BrowserTestUtils.waitForAttribute(
+ "attention",
+ openedTab
+ );
+ // switch away from that tab again - this triggers the alert.
+ await BrowserTestUtils.switchTab(gBrowser, firstTab);
+ // ... but that's async on e10s...
+ await openedTabGotAttentionPromise;
+ // check for attention attribute
+ is(
+ openedTab.hasAttribute("attention"),
+ true,
+ "Tab with alert should have 'attention' attribute."
+ );
+ ok(!openedTab.selected, "Tab with alert should not be selected");
+
+ // switch tab back, and check the checkbox is displayed:
+ await BrowserTestUtils.switchTab(gBrowser, openedTab);
+ // check the prompt is there
+ let promptElements = openedTab.linkedBrowser.parentNode.querySelectorAll(
+ ".content-prompt-dialog"
+ );
+
+ let dialogBox = gBrowser.getTabDialogBox(openedTab.linkedBrowser);
+ let contentPromptManager = dialogBox.getContentDialogManager();
+ is(promptElements.length, 1, "There should be 1 prompt");
+ is(
+ contentPromptManager._dialogs.length,
+ 1,
+ "Content prompt manager should have 1 dialog box."
+ );
+
+ // make sure the checkbox appears and that the permission for allowing tab switching
+ // is set when the checkbox is tickted and the dialog is accepted
+ let dialog = contentPromptManager._dialogs[0];
+
+ await dialog._dialogReady;
+
+ let dialogDoc = dialog._frame.contentWindow.document;
+ let checkbox = dialogDoc.querySelector("checkbox[label*='example.com']");
+ let button = dialogDoc.querySelector("#commonDialog").getButton("accept");
+
+ ok(checkbox, "The checkbox should be there");
+ ok(!checkbox.checked, "Checkbox shouldn't be checked");
+
+ // tick box and accept dialog
+ checkbox.checked = true;
+ button.click();
+ // Wait for that click to actually be handled completely.
+ await new Promise(function (resolve) {
+ Services.tm.dispatchToMainThread(resolve);
+ });
+
+ ok(!contentPromptManager._dialogs.length, "Dialog should be closed");
+
+ // check permission is set
+ is(
+ Services.perms.ALLOW_ACTION,
+ PermissionTestUtils.testPermission(pageWithAlert, "focus-tab-by-prompt"),
+ "Tab switching should now be allowed"
+ );
+
+ // Check if the control center shows the correct permission.
+ let shown = BrowserTestUtils.waitForEvent(
+ window,
+ "popupshown",
+ true,
+ event => event.target == gPermissionPanel._permissionPopup
+ );
+ gPermissionPanel._identityPermissionBox.click();
+ await shown;
+ let labelText = SitePermissions.getPermissionLabel("focus-tab-by-prompt");
+ let permissionsList = document.getElementById(
+ "permission-popup-permission-list"
+ );
+ let label = permissionsList.querySelector(
+ ".permission-popup-permission-label"
+ );
+ is(label.textContent, labelText);
+ gPermissionPanel.hidePopup();
+
+ // Check if the identity icon signals granted permission.
+ ok(
+ gPermissionPanel._identityPermissionBox.hasAttribute("hasPermissions"),
+ "identity-permission-box signals granted permissions"
+ );
+
+ let openedTabSelectedPromise = BrowserTestUtils.waitForAttribute(
+ "selected",
+ openedTab,
+ "true"
+ );
+
+ // switch to other tab again
+ await BrowserTestUtils.switchTab(gBrowser, firstTab);
+
+ // This is sync in non-e10s, but in e10s we need to wait for this, so yield anyway.
+ // Note that the switchTab promise doesn't actually guarantee anything about *which*
+ // tab ends up as selected when its event fires, so using that here wouldn't work.
+ await openedTabSelectedPromise;
+
+ ok(contentPromptManager._dialogs.length === 1, "Dialog opened.");
+ dialog = contentPromptManager._dialogs[0];
+ await dialog._dialogReady;
+
+ // should be switched back
+ ok(openedTab.selected, "Ta-dah, the other tab should now be selected again!");
+
+ // In e10s, with the conformant promise scheduling, we have to wait for next tick
+ // to ensure that the prompt is open before removing the opened tab, because the
+ // promise callback of 'openedTabSelectedPromise' could be done at the middle of
+ // RemotePrompt.openTabPrompt() while 'DOMModalDialogClosed' event is fired.
+ // await TestUtils.waitForTick();
+
+ await BrowserTestUtils.removeTab(openedTab);
+});
diff --git a/browser/base/content/test/tabPrompts/browser_promptFocus.js b/browser/base/content/test/tabPrompts/browser_promptFocus.js
new file mode 100644
index 0000000000..89ca064c10
--- /dev/null
+++ b/browser/base/content/test/tabPrompts/browser_promptFocus.js
@@ -0,0 +1,170 @@
+/* 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"
+);
+
+// MacOS has different default focus behavior for prompts.
+const isMacOS = Services.appinfo.OS === "Darwin";
+
+/**
+ * Tests that prompts are focused when switching tabs.
+ */
+add_task(async function test_tabdialogbox_tab_switch_focus() {
+ // Open 3 tabs
+ let tabPromises = [];
+ for (let i = 0; i < 3; i += 1) {
+ tabPromises.push(
+ BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com",
+ true
+ )
+ );
+ }
+ // Wait for tabs to be ready
+ let tabs = await Promise.all(tabPromises);
+ let [tabA, tabB, tabC] = tabs;
+
+ // Spawn two prompts, which have different default focus as determined by
+ // CommonDialog#setDefaultFocus.
+ let openPromise = PromptTestUtils.waitForPrompt(tabA.linkedBrowser, {
+ modalType: Services.prompt.MODAL_TYPE_TAB,
+ promptType: "confirm",
+ });
+ Services.prompt.asyncConfirm(
+ tabA.linkedBrowser.browsingContext,
+ Services.prompt.MODAL_TYPE_TAB,
+ null,
+ "prompt A"
+ );
+ let promptA = await openPromise;
+
+ openPromise = PromptTestUtils.waitForPrompt(tabB.linkedBrowser, {
+ modalType: Services.prompt.MODAL_TYPE_TAB,
+ promptType: "promptPassword",
+ });
+ Services.prompt.asyncPromptPassword(
+ tabB.linkedBrowser.browsingContext,
+ Services.prompt.MODAL_TYPE_TAB,
+ null,
+ "prompt B",
+ "",
+ null,
+ false
+ );
+ let promptB = await openPromise;
+
+ // Switch tabs and check if the correct element was focused.
+
+ // Switch back to the third tab which doesn't have a prompt.
+ await BrowserTestUtils.switchTab(gBrowser, tabC);
+ is(
+ Services.focus.focusedElement,
+ tabC.linkedBrowser,
+ "Tab without prompt should have focus on browser."
+ );
+
+ // Switch to first tab which has prompt
+ await BrowserTestUtils.switchTab(gBrowser, tabA);
+
+ if (isMacOS) {
+ is(
+ Services.focus.focusedElement,
+ promptA.ui.infoBody,
+ "Tab with prompt should have focus on body."
+ );
+ } else {
+ is(
+ Services.focus.focusedElement,
+ promptA.ui.button0,
+ "Tab with prompt should have focus on default button."
+ );
+ }
+
+ await PromptTestUtils.handlePrompt(promptA);
+
+ // Switch to second tab which has prompt
+ await BrowserTestUtils.switchTab(gBrowser, tabB);
+ is(
+ Services.focus.focusedElement,
+ promptB.ui.password1Textbox,
+ "Tab with password prompt should have focus on password field."
+ );
+ await PromptTestUtils.handlePrompt(promptB);
+
+ // Cleanup
+ tabs.forEach(tab => {
+ BrowserTestUtils.removeTab(tab);
+ });
+});
+
+/**
+ * Tests that an alert prompt has focus on the default element.
+ * @param {CommonDialog} prompt - Prompt to test focus for.
+ * @param {number} index - Index of the prompt to log.
+ */
+function testAlertPromptFocus(prompt, index) {
+ if (isMacOS) {
+ is(
+ Services.focus.focusedElement,
+ prompt.ui.infoBody,
+ `Prompt #${index} should have focus on body.`
+ );
+ } else {
+ is(
+ Services.focus.focusedElement,
+ prompt.ui.button0,
+ `Prompt #${index} should have focus on default button.`
+ );
+ }
+}
+
+/**
+ * Test that we set the correct focus when queuing multiple prompts.
+ */
+add_task(async function test_tabdialogbox_prompt_queue_focus() {
+ await BrowserTestUtils.withNewTab(gBrowser, async browser => {
+ const PROMPT_COUNT = 10;
+
+ let firstPromptPromise = PromptTestUtils.waitForPrompt(browser, {
+ modalType: Services.prompt.MODAL_TYPE_TAB,
+ promptType: "alert",
+ });
+
+ for (let i = 0; i < PROMPT_COUNT; i += 1) {
+ Services.prompt.asyncAlert(
+ browser.browsingContext,
+ Services.prompt.MODAL_TYPE_TAB,
+ null,
+ "prompt " + i
+ );
+ }
+
+ // Close prompts one by one and check focus.
+ let nextPromptPromise = firstPromptPromise;
+ for (let i = 0; i < PROMPT_COUNT; i += 1) {
+ let p = await nextPromptPromise;
+ testAlertPromptFocus(p, i);
+
+ if (i < PROMPT_COUNT - 1) {
+ nextPromptPromise = PromptTestUtils.waitForPrompt(browser, {
+ modalType: Services.prompt.MODAL_TYPE_TAB,
+ promptType: "alert",
+ });
+ }
+ await PromptTestUtils.handlePrompt(p);
+ }
+
+ // All prompts are closed, focus should be back on the browser.
+ is(
+ Services.focus.focusedElement,
+ browser,
+ "Tab without prompts should have focus on browser."
+ );
+ });
+});
diff --git a/browser/base/content/test/tabPrompts/browser_prompt_closed_window.js b/browser/base/content/test/tabPrompts/browser_prompt_closed_window.js
new file mode 100644
index 0000000000..4db3286691
--- /dev/null
+++ b/browser/base/content/test/tabPrompts/browser_prompt_closed_window.js
@@ -0,0 +1,40 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Check that if we loop prompts from a closed tab, they don't
+ * start showing up as window prompts.
+ */
+add_task(async function test_closed_tab_doesnt_show_prompt() {
+ let newWin = await BrowserTestUtils.openNewBrowserWindow();
+
+ // Get a promise for the initial, in-tab prompt:
+ let promptPromise = BrowserTestUtils.promiseAlertDialogOpen();
+ await ContentTask.spawn(newWin.gBrowser.selectedBrowser, [], function () {
+ // Don't want to block, so use setTimeout with 0 timeout:
+ content.setTimeout(
+ () =>
+ content.eval(
+ 'let i = 0; while (!prompt("Prompts a lot!") && i++ < 10);'
+ ),
+ 0
+ );
+ });
+ // wait for the first prompt to have appeared:
+ await promptPromise;
+
+ // Now close the containing tab, and check for windowed prompts appearing.
+ let opened = false;
+ let obs = () => {
+ opened = true;
+ };
+ Services.obs.addObserver(obs, "domwindowopened");
+ registerCleanupFunction(() =>
+ Services.obs.removeObserver(obs, "domwindowopened")
+ );
+ await BrowserTestUtils.closeWindow(newWin);
+
+ ok(!opened, "Should not have opened a prompt when closing the main window.");
+});
diff --git a/browser/base/content/test/tabPrompts/browser_switchTabPermissionPrompt.js b/browser/base/content/test/tabPrompts/browser_switchTabPermissionPrompt.js
new file mode 100644
index 0000000000..e803869b92
--- /dev/null
+++ b/browser/base/content/test/tabPrompts/browser_switchTabPermissionPrompt.js
@@ -0,0 +1,41 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_check_file_prompt() {
+ let initialTab = gBrowser.selectedTab;
+ await BrowserTestUtils.withNewTab("about:blank", async browser => {
+ await BrowserTestUtils.switchTab(gBrowser, initialTab);
+
+ let testHelper = async function (uri, expectedValue) {
+ BrowserTestUtils.loadURIString(browser, uri);
+ await BrowserTestUtils.browserLoaded(browser, false, uri);
+ let dialogFinishedShowing = TestUtils.topicObserved(
+ "common-dialog-loaded"
+ );
+ await SpecialPowers.spawn(browser, [], () => {
+ content.setTimeout(() => {
+ content.alert("Hello");
+ }, 0);
+ });
+
+ let [dialogWin] = await dialogFinishedShowing;
+ let checkbox = dialogWin.document.getElementById("checkbox");
+ info("Got: " + checkbox.label);
+ ok(
+ checkbox.label.includes(expectedValue),
+ `Checkbox label should mention domain (${expectedValue}).`
+ );
+
+ dialogWin.document.querySelector("dialog").acceptDialog();
+ };
+
+ await testHelper("https://example.com/1", "example.com");
+ await testHelper("about:robots", "about:");
+ let file = Services.io.newFileURI(
+ Services.dirsvc.get("Desk", Ci.nsIFile)
+ ).spec;
+ await testHelper(file, "file://");
+ });
+});
diff --git a/browser/base/content/test/tabPrompts/browser_windowPrompt.js b/browser/base/content/test/tabPrompts/browser_windowPrompt.js
new file mode 100644
index 0000000000..535142f485
--- /dev/null
+++ b/browser/base/content/test/tabPrompts/browser_windowPrompt.js
@@ -0,0 +1,259 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Check that the in-window modal dialogs work correctly.
+ */
+add_task(async function test_check_window_modal_prompt_service() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["prompts.windowPromptSubDialog", true]],
+ });
+ let dialogPromise = BrowserTestUtils.promiseAlertDialogOpen();
+ // Avoid blocking the test on the (sync) alert by sticking it in a timeout:
+ setTimeout(
+ () => Services.prompt.alert(window, "Some title", "some message"),
+ 0
+ );
+ let dialogWin = await dialogPromise;
+
+ // Check dialog content:
+ is(
+ dialogWin.document.getElementById("infoTitle").textContent,
+ "Some title",
+ "Title should be correct."
+ );
+ ok(
+ !dialogWin.document.getElementById("infoTitle").hidden,
+ "Title should be shown."
+ );
+ is(
+ dialogWin.document.getElementById("infoBody").textContent,
+ "some message",
+ "Body text should be correct."
+ );
+
+ // Check circumstances of opening.
+ ok(
+ dialogWin?.docShell?.chromeEventHandler,
+ "Should have embedded the dialog."
+ );
+ for (let menu of document.querySelectorAll("menubar > menu")) {
+ ok(menu.disabled, `Menu ${menu.id} should be disabled.`);
+ }
+
+ let container = dialogWin.docShell.chromeEventHandler.closest("dialog");
+ let closedPromise = BrowserTestUtils.waitForMutationCondition(
+ container,
+ { childList: true, attributes: true },
+ () => !container.hasChildNodes() && !container.open
+ );
+
+ EventUtils.sendKey("ESCAPE");
+ await closedPromise;
+
+ await BrowserTestUtils.waitForMutationCondition(
+ document.querySelector("menubar > menu"),
+ { attributes: true },
+ () => !document.querySelector("menubar > menu").disabled
+ );
+
+ // Check we cleaned up:
+ for (let menu of document.querySelectorAll("menubar > menu")) {
+ ok(!menu.disabled, `Menu ${menu.id} should not be disabled anymore.`);
+ }
+});
+
+/**
+ * Check that the dialog's own closing methods being invoked don't break things.
+ */
+add_task(async function test_check_window_modal_prompt_service() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["prompts.windowPromptSubDialog", true]],
+ });
+ let dialogPromise = BrowserTestUtils.promiseAlertDialogOpen();
+ // Avoid blocking the test on the (sync) alert by sticking it in a timeout:
+ setTimeout(
+ () => Services.prompt.alert(window, "Some title", "some message"),
+ 0
+ );
+ let dialogWin = await dialogPromise;
+
+ // Check circumstances of opening.
+ ok(
+ dialogWin?.docShell?.chromeEventHandler,
+ "Should have embedded the dialog."
+ );
+
+ let container = dialogWin.docShell.chromeEventHandler.closest("dialog");
+ let closedPromise = BrowserTestUtils.waitForMutationCondition(
+ container,
+ { childList: true, attributes: true },
+ () => !container.hasChildNodes() && !container.open
+ );
+
+ // This can also be invoked by the user if the escape key is handled
+ // outside of our embedded dialog.
+ container.close();
+ await closedPromise;
+
+ // Check we cleaned up:
+ for (let menu of document.querySelectorAll("menubar > menu")) {
+ ok(!menu.disabled, `Menu ${menu.id} should not be disabled anymore.`);
+ }
+});
+
+add_task(async function test_check_multiple_prompts() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["prompts.windowPromptSubDialog", true]],
+ });
+ let container = document.getElementById("window-modal-dialog");
+ let dialogPromise = BrowserTestUtils.promiseAlertDialogOpen();
+
+ let firstDialogClosedPromise = new Promise(resolve => {
+ // Avoid blocking the test on the (sync) alert by sticking it in a timeout:
+ setTimeout(() => {
+ Services.prompt.alert(window, "Some title", "some message");
+ resolve();
+ }, 0);
+ });
+ let dialogWin = await dialogPromise;
+
+ // Check circumstances of opening.
+ ok(
+ dialogWin?.docShell?.chromeEventHandler,
+ "Should have embedded the dialog."
+ );
+ is(container.childElementCount, 1, "Should only have 1 dialog in the DOM.");
+
+ let secondDialogClosedPromise = new Promise(resolve => {
+ // Avoid blocking the test on the (sync) alert by sticking it in a timeout:
+ setTimeout(() => {
+ Services.prompt.alert(window, "Another title", "another message");
+ resolve();
+ }, 0);
+ });
+
+ dialogPromise = BrowserTestUtils.promiseAlertDialogOpen();
+
+ info("Accepting dialog");
+ dialogWin.document.querySelector("dialog").acceptDialog();
+
+ let oldWin = dialogWin;
+
+ info("Second dialog should automatically come up.");
+ dialogWin = await dialogPromise;
+
+ isnot(oldWin, dialogWin, "Opened a new dialog.");
+ ok(container.open, "Dialog should be open.");
+
+ info("Now close the second dialog.");
+ dialogWin.document.querySelector("dialog").acceptDialog();
+
+ await firstDialogClosedPromise;
+ await secondDialogClosedPromise;
+
+ await BrowserTestUtils.waitForMutationCondition(
+ container,
+ { childList: true, attributes: true },
+ () => !container.hasChildNodes() && !container.open
+ );
+ // Check we cleaned up:
+ for (let menu of document.querySelectorAll("menubar > menu")) {
+ ok(!menu.disabled, `Menu ${menu.id} should not be disabled anymore.`);
+ }
+});
+
+/**
+ * Check that the in-window modal dialogs un-minimizes windows when necessary.
+ */
+add_task(async function test_check_minimize_response() {
+ // Window minimization doesn't necessarily work on Linux...
+ if (AppConstants.platform == "linux") {
+ return;
+ }
+ await SpecialPowers.pushPrefEnv({
+ set: [["prompts.windowPromptSubDialog", true]],
+ });
+
+ let promiseSizeModeChange = BrowserTestUtils.waitForEvent(
+ window,
+ "sizemodechange"
+ );
+ window.minimize();
+ await promiseSizeModeChange;
+ is(window.windowState, window.STATE_MINIMIZED, "Should be minimized.");
+
+ promiseSizeModeChange = BrowserTestUtils.waitForEvent(
+ window,
+ "sizemodechange"
+ );
+ let dialogPromise = BrowserTestUtils.promiseAlertDialogOpen();
+ // Use an async alert to avoid blocking.
+ Services.prompt.asyncAlert(
+ window.browsingContext,
+ Ci.nsIPrompt.MODAL_TYPE_INTERNAL_WINDOW,
+ "Some title",
+ "some message"
+ );
+ let dialogWin = await dialogPromise;
+ await promiseSizeModeChange;
+
+ isnot(
+ window.windowState,
+ window.STATE_MINIMIZED,
+ "Should no longer be minimized."
+ );
+
+ // Check dialog content:
+ is(
+ dialogWin.document.getElementById("infoTitle").textContent,
+ "Some title",
+ "Title should be correct."
+ );
+
+ let container = dialogWin.docShell.chromeEventHandler.closest("dialog");
+ let closedPromise = BrowserTestUtils.waitForMutationCondition(
+ container,
+ { childList: true, attributes: true },
+ () => !container.hasChildNodes() && !container.open
+ );
+
+ EventUtils.sendKey("ESCAPE");
+ await closedPromise;
+
+ await BrowserTestUtils.waitForMutationCondition(
+ document.querySelector("menubar > menu"),
+ { attributes: true },
+ () => !document.querySelector("menubar > menu").disabled
+ );
+});
+
+/**
+ * Tests that we get a closed callback even when closing the prompt before the
+ * underlying SubDialog has fully opened.
+ */
+add_task(async function test_closed_callback() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["prompts.windowPromptSubDialog", true]],
+ });
+
+ let promptClosedPromise = Services.prompt.asyncAlert(
+ window.browsingContext,
+ Services.prompt.MODAL_TYPE_INTERNAL_WINDOW,
+ "Hello",
+ "Hello, World!"
+ );
+
+ let dialog = gDialogBox._dialog;
+ ok(dialog, "gDialogBox should have a dialog");
+
+ // Directly close the dialog without waiting for it to initialize.
+ dialog.close();
+
+ info("Waiting for prompt close");
+ await promptClosedPromise;
+
+ ok(!gDialogBox._dialog, "gDialogBox should no longer have a dialog");
+});
diff --git a/browser/base/content/test/tabPrompts/file_beforeunload_stop.html b/browser/base/content/test/tabPrompts/file_beforeunload_stop.html
new file mode 100644
index 0000000000..7273e60c65
--- /dev/null
+++ b/browser/base/content/test/tabPrompts/file_beforeunload_stop.html
@@ -0,0 +1,8 @@
+<body>
+ <p>I will ask not to be closed.</p>
+ <script>
+ window.onbeforeunload = function() {
+ return "true";
+ };
+ </script>
+</body>
diff --git a/browser/base/content/test/tabPrompts/openPromptOffTimeout.html b/browser/base/content/test/tabPrompts/openPromptOffTimeout.html
new file mode 100644
index 0000000000..5dfd8cbeff
--- /dev/null
+++ b/browser/base/content/test/tabPrompts/openPromptOffTimeout.html
@@ -0,0 +1,10 @@
+<body>
+This page opens an alert box when the page is hidden.
+<script>
+document.addEventListener("visibilitychange", () => {
+ if (document.hidden) {
+ alert("You hid my page!");
+ }
+});
+</script>
+</body>
diff --git a/browser/base/content/test/tabPrompts/redirect-crossDomain-tabTitle-update.html b/browser/base/content/test/tabPrompts/redirect-crossDomain-tabTitle-update.html
new file mode 100644
index 0000000000..773b3e47d9
--- /dev/null
+++ b/browser/base/content/test/tabPrompts/redirect-crossDomain-tabTitle-update.html
@@ -0,0 +1,15 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8">
+ <title>example.com</title>
+ </head>
+ <body>
+ I am a friendly test page!
+ <script>
+ document.title="tab title update 1";
+ window.location.href="https://example.org:443/browser/browser/base/content/test/tabPrompts/auth-route.sjs";
+ document.title ="tab title update 2";
+ </script>
+ </body>
+</html>
diff --git a/browser/base/content/test/tabPrompts/redirect-crossDomain.html b/browser/base/content/test/tabPrompts/redirect-crossDomain.html
new file mode 100644
index 0000000000..ebae2c060a
--- /dev/null
+++ b/browser/base/content/test/tabPrompts/redirect-crossDomain.html
@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8">
+ <title>example.com</title>
+ </head>
+ <body>
+ I am a friendly test page!
+ <script>
+ window.location.href="https://example.org:443/browser/browser/base/content/test/tabPrompts/auth-route.sjs";
+ </script>
+ </body>
+</html>
diff --git a/browser/base/content/test/tabPrompts/redirect-sameDomain.html b/browser/base/content/test/tabPrompts/redirect-sameDomain.html
new file mode 100644
index 0000000000..2e50689d1e
--- /dev/null
+++ b/browser/base/content/test/tabPrompts/redirect-sameDomain.html
@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="UTF-8">
+ <title>example.com</title>
+ </head>
+ <body>
+ I am a friendly test page!
+ <script>
+ window.location.href="https://test1.example.com:443/browser/browser/base/content/test/tabPrompts/auth-route.sjs";
+ </script>
+ </body>
+</html>
diff --git a/browser/base/content/test/tabcrashed/browser.ini b/browser/base/content/test/tabcrashed/browser.ini
new file mode 100644
index 0000000000..7aee7126b8
--- /dev/null
+++ b/browser/base/content/test/tabcrashed/browser.ini
@@ -0,0 +1,21 @@
+[DEFAULT]
+skip-if = !crashreporter
+support-files =
+ head.js
+ file_contains_emptyiframe.html
+ file_iframe.html
+
+[browser_autoSubmitRequest.js]
+[browser_launchFail.js]
+[browser_multipleCrashedTabs.js]
+https_first_disabled = true
+[browser_noPermanentKey.js]
+skip-if = true # Bug 1383315
+[browser_printpreview_crash.js]
+https_first_disabled = true
+[browser_showForm.js]
+[browser_shown.js]
+skip-if =
+ (verify && !debug && (os == 'win'))
+[browser_shownRestartRequired.js]
+[browser_withoutDump.js]
diff --git a/browser/base/content/test/tabcrashed/browser_aboutRestartRequired.ini b/browser/base/content/test/tabcrashed/browser_aboutRestartRequired.ini
new file mode 100644
index 0000000000..86c442469f
--- /dev/null
+++ b/browser/base/content/test/tabcrashed/browser_aboutRestartRequired.ini
@@ -0,0 +1,19 @@
+[DEFAULT]
+skip-if =
+ !debug || !crashreporter
+support-files =
+ head.js
+prefs =
+ dom.ipc.processCount=1
+ dom.ipc.processPrelaunch.fission.number=0
+
+[browser_aboutRestartRequired_basic.js]
+[browser_aboutRestartRequired_buildid_false-positive.js]
+skip-if =
+ win11_2009 && msix && debug # bug 1823581
+[browser_aboutRestartRequired_buildid_mismatch.js]
+skip-if =
+ win11_2009 && msix && debug # bug 1823581
+[browser_aboutRestartRequired_buildid_no-platform-ini.js]
+skip-if =
+ win11_2009 && msix && debug # bug 1823581
diff --git a/browser/base/content/test/tabcrashed/browser_aboutRestartRequired_basic.js b/browser/base/content/test/tabcrashed/browser_aboutRestartRequired_basic.js
new file mode 100644
index 0000000000..d62372cbba
--- /dev/null
+++ b/browser/base/content/test/tabcrashed/browser_aboutRestartRequired_basic.js
@@ -0,0 +1,31 @@
+"use strict";
+
+// On debug builds, crashing tabs results in much thinking, which
+// slows down the test and results in intermittent test timeouts,
+// so we'll pump up the expected timeout for this test.
+requestLongerTimeout(5);
+
+SimpleTest.expectChildProcessCrash();
+
+add_task(async function test_browser_crashed_basic_event() {
+ info("Waiting for oop-browser-crashed event.");
+
+ Services.telemetry.clearScalars();
+ is(
+ getFalsePositiveTelemetry(),
+ undefined,
+ "Build ID mismatch false positive count should be undefined"
+ );
+
+ await forceCleanProcesses();
+ let eventPromise = getEventPromise("oop-browser-crashed", "basic");
+ let tab = await openNewTab(true);
+ await eventPromise;
+
+ is(
+ getFalsePositiveTelemetry(),
+ undefined,
+ "Build ID mismatch false positive count should be undefined"
+ );
+ await closeTab(tab);
+});
diff --git a/browser/base/content/test/tabcrashed/browser_aboutRestartRequired_buildid_false-positive.js b/browser/base/content/test/tabcrashed/browser_aboutRestartRequired_buildid_false-positive.js
new file mode 100644
index 0000000000..15e0b5ab31
--- /dev/null
+++ b/browser/base/content/test/tabcrashed/browser_aboutRestartRequired_buildid_false-positive.js
@@ -0,0 +1,35 @@
+"use strict";
+
+// On debug builds, crashing tabs results in much thinking, which
+// slows down the test and results in intermittent test timeouts,
+// so we'll pump up the expected timeout for this test.
+requestLongerTimeout(2);
+
+SimpleTest.expectChildProcessCrash();
+
+add_task(async function test_browser_crashed_false_positive_event() {
+ info("Waiting for oop-browser-crashed event.");
+
+ Services.telemetry.clearScalars();
+ is(
+ getFalsePositiveTelemetry(),
+ undefined,
+ "Build ID mismatch false positive count should be undefined"
+ );
+
+ ok(await ensureBuildID(), "System has correct platform.ini");
+ setBuildidMatchDontSendEnv();
+ await forceCleanProcesses();
+ let eventPromise = getEventPromise("oop-browser-crashed", "false-positive");
+ let tab = await openNewTab(false);
+ await eventPromise;
+ unsetBuildidMatchDontSendEnv();
+
+ is(
+ getFalsePositiveTelemetry(),
+ 1,
+ "Build ID mismatch false positive count should be 1"
+ );
+
+ await closeTab(tab);
+});
diff --git a/browser/base/content/test/tabcrashed/browser_aboutRestartRequired_buildid_mismatch.js b/browser/base/content/test/tabcrashed/browser_aboutRestartRequired_buildid_mismatch.js
new file mode 100644
index 0000000000..80f35db159
--- /dev/null
+++ b/browser/base/content/test/tabcrashed/browser_aboutRestartRequired_buildid_mismatch.js
@@ -0,0 +1,56 @@
+"use strict";
+
+// On debug builds, crashing tabs results in much thinking, which
+// slows down the test and results in intermittent test timeouts,
+// so we'll pump up the expected timeout for this test.
+requestLongerTimeout(2);
+
+SimpleTest.expectChildProcessCrash();
+
+add_task(async function test_browser_restartrequired_event() {
+ info("Waiting for oop-browser-buildid-mismatch event.");
+
+ Services.telemetry.clearScalars();
+ is(
+ getFalsePositiveTelemetry(),
+ undefined,
+ "Build ID mismatch false positive count should be undefined"
+ );
+
+ ok(await ensureBuildID(), "System has correct platform.ini");
+
+ let profD = Services.dirsvc.get("GreD", Ci.nsIFile);
+ let platformIniOrig = await IOUtils.readUTF8(
+ PathUtils.join(profD.path, "platform.ini")
+ );
+ let buildID = Services.appinfo.platformBuildID;
+ let platformIniNew = platformIniOrig.replace(buildID, "1234");
+
+ await IOUtils.writeUTF8(
+ PathUtils.join(profD.path, "platform.ini"),
+ platformIniNew,
+ { flush: true }
+ );
+
+ setBuildidMatchDontSendEnv();
+ await forceCleanProcesses();
+ let eventPromise = getEventPromise(
+ "oop-browser-buildid-mismatch",
+ "buildid-mismatch"
+ );
+ let tab = await openNewTab(false);
+ await eventPromise;
+ await IOUtils.writeUTF8(
+ PathUtils.join(profD.path, "platform.ini"),
+ platformIniOrig,
+ { flush: true }
+ );
+ unsetBuildidMatchDontSendEnv();
+
+ is(
+ getFalsePositiveTelemetry(),
+ undefined,
+ "Build ID mismatch false positive count should be undefined"
+ );
+ await closeTab(tab);
+});
diff --git a/browser/base/content/test/tabcrashed/browser_aboutRestartRequired_buildid_no-platform-ini.js b/browser/base/content/test/tabcrashed/browser_aboutRestartRequired_buildid_no-platform-ini.js
new file mode 100644
index 0000000000..232c79b02e
--- /dev/null
+++ b/browser/base/content/test/tabcrashed/browser_aboutRestartRequired_buildid_no-platform-ini.js
@@ -0,0 +1,50 @@
+"use strict";
+
+// On debug builds, crashing tabs results in much thinking, which
+// slows down the test and results in intermittent test timeouts,
+// so we'll pump up the expected timeout for this test.
+requestLongerTimeout(2);
+
+SimpleTest.expectChildProcessCrash();
+
+add_task(async function test_browser_crashed_no_platform_ini_event() {
+ info("Waiting for oop-browser-buildid-mismatch event.");
+
+ Services.telemetry.clearScalars();
+ is(
+ getFalsePositiveTelemetry(),
+ undefined,
+ "Build ID mismatch false positive count should be undefined"
+ );
+
+ ok(await ensureBuildID(), "System has correct platform.ini");
+
+ let profD = Services.dirsvc.get("GreD", Ci.nsIFile);
+ let platformIniOrig = await IOUtils.readUTF8(
+ PathUtils.join(profD.path, "platform.ini")
+ );
+
+ await IOUtils.remove(PathUtils.join(profD.path, "platform.ini"));
+
+ setBuildidMatchDontSendEnv();
+ await forceCleanProcesses();
+ let eventPromise = getEventPromise(
+ "oop-browser-buildid-mismatch",
+ "no-platform-ini"
+ );
+ let tab = await openNewTab(false);
+ await eventPromise;
+ await IOUtils.writeUTF8(
+ PathUtils.join(profD.path, "platform.ini"),
+ platformIniOrig,
+ { flush: true }
+ );
+ unsetBuildidMatchDontSendEnv();
+
+ is(
+ getFalsePositiveTelemetry(),
+ undefined,
+ "Build ID mismatch false positive count should be undefined"
+ );
+ await closeTab(tab);
+});
diff --git a/browser/base/content/test/tabcrashed/browser_autoSubmitRequest.js b/browser/base/content/test/tabcrashed/browser_autoSubmitRequest.js
new file mode 100644
index 0000000000..b99e8a10b9
--- /dev/null
+++ b/browser/base/content/test/tabcrashed/browser_autoSubmitRequest.js
@@ -0,0 +1,183 @@
+"use strict";
+
+const PAGE =
+ "data:text/html,<html><body>A%20regular,%20everyday,%20normal%20page.";
+const AUTOSUBMIT_PREF = "browser.crashReports.unsubmittedCheck.autoSubmit2";
+
+const { TabStateFlusher } = ChromeUtils.importESModule(
+ "resource:///modules/sessionstore/TabStateFlusher.sys.mjs"
+);
+
+// On debug builds, crashing tabs results in much thinking, which
+// slows down the test and results in intermittent test timeouts,
+// so we'll pump up the expected timeout for this test.
+requestLongerTimeout(2);
+
+/**
+ * Tests that if the user is not configured to autosubmit
+ * backlogged crash reports, that we offer to do that, and
+ * that the user can accept that offer.
+ */
+add_task(async function test_show_form() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[AUTOSUBMIT_PREF, false]],
+ });
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: PAGE,
+ },
+ async function (browser) {
+ // Make sure we've flushed the browser messages so that
+ // we can restore it.
+ await TabStateFlusher.flush(browser);
+
+ // Now crash the browser.
+ await BrowserTestUtils.crashFrame(browser);
+
+ let doc = browser.contentDocument;
+
+ // Ensure the request is visible. We can safely reach into
+ // the content since about:tabcrashed is an in-process URL.
+ let requestAutoSubmit = doc.getElementById("requestAutoSubmit");
+ Assert.ok(
+ !requestAutoSubmit.hidden,
+ "Request for autosubmission is visible."
+ );
+
+ // Since the pref is set to false, the checkbox should be
+ // unchecked.
+ let autoSubmit = doc.getElementById("autoSubmit");
+ Assert.ok(
+ !autoSubmit.checked,
+ "Checkbox for autosubmission is not checked."
+ );
+
+ // Check the checkbox, and then restore the tab.
+ autoSubmit.checked = true;
+ let restoreButton = doc.getElementById("restoreTab");
+ restoreButton.click();
+
+ await BrowserTestUtils.browserLoaded(browser, false, PAGE);
+
+ // The autosubmission pref should now be set.
+ Assert.ok(
+ Services.prefs.getBoolPref(AUTOSUBMIT_PREF),
+ "Autosubmission pref should have been set."
+ );
+ }
+ );
+});
+
+/**
+ * Tests that if the user is autosubmitting backlogged crash reports
+ * that we don't make the offer again.
+ */
+add_task(async function test_show_form() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[AUTOSUBMIT_PREF, true]],
+ });
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: PAGE,
+ },
+ async function (browser) {
+ await TabStateFlusher.flush(browser);
+ // Now crash the browser.
+ await BrowserTestUtils.crashFrame(browser);
+
+ let doc = browser.contentDocument;
+
+ // Ensure the request is NOT visible. We can safely reach into
+ // the content since about:tabcrashed is an in-process URL.
+ let requestAutoSubmit = doc.getElementById("requestAutoSubmit");
+ Assert.ok(
+ requestAutoSubmit.hidden,
+ "Request for autosubmission is not visible."
+ );
+
+ // Restore the tab.
+ let restoreButton = doc.getElementById("restoreTab");
+ restoreButton.click();
+
+ await BrowserTestUtils.browserLoaded(browser, false, PAGE);
+
+ // The autosubmission pref should still be set to true.
+ Assert.ok(
+ Services.prefs.getBoolPref(AUTOSUBMIT_PREF),
+ "Autosubmission pref should have been set."
+ );
+ }
+ );
+});
+
+/**
+ * Tests that we properly set the autoSubmit preference if the user is
+ * presented with a tabcrashed page without a crash report.
+ */
+add_task(async function test_no_offer() {
+ // We should default to sending the report.
+ Assert.ok(TabCrashHandler.prefs.getBoolPref("sendReport"));
+
+ await SpecialPowers.pushPrefEnv({
+ set: [[AUTOSUBMIT_PREF, false]],
+ });
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: PAGE,
+ },
+ async function (browser) {
+ await TabStateFlusher.flush(browser);
+
+ // Make it so that it seems like no dump is available for the next crash.
+ prepareNoDump();
+
+ // Now crash the browser.
+ await BrowserTestUtils.crashFrame(browser);
+
+ let doc = browser.contentDocument;
+
+ // Ensure the request to autosubmit is invisible, since there's no report.
+ let requestRect = doc
+ .getElementById("requestAutoSubmit")
+ .getBoundingClientRect();
+ Assert.equal(
+ 0,
+ requestRect.height,
+ "Request for autosubmission has no height"
+ );
+ Assert.equal(
+ 0,
+ requestRect.width,
+ "Request for autosubmission has no width"
+ );
+
+ // Since the pref is set to false, the checkbox should be
+ // unchecked.
+ let autoSubmit = doc.getElementById("autoSubmit");
+ Assert.ok(
+ !autoSubmit.checked,
+ "Checkbox for autosubmission is not checked."
+ );
+
+ let restoreButton = doc.getElementById("restoreTab");
+ restoreButton.click();
+
+ await BrowserTestUtils.browserLoaded(browser, false, PAGE);
+
+ // The autosubmission pref should now be set.
+ Assert.ok(
+ !Services.prefs.getBoolPref(AUTOSUBMIT_PREF),
+ "Autosubmission pref should not have changed."
+ );
+ }
+ );
+
+ // We should not have changed the default value for sending the report.
+ Assert.ok(TabCrashHandler.prefs.getBoolPref("sendReport"));
+});
diff --git a/browser/base/content/test/tabcrashed/browser_launchFail.js b/browser/base/content/test/tabcrashed/browser_launchFail.js
new file mode 100644
index 0000000000..e89038ac10
--- /dev/null
+++ b/browser/base/content/test/tabcrashed/browser_launchFail.js
@@ -0,0 +1,59 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that if the content process fails to launch in the
+ * foreground tab, that we show about:tabcrashed, but do not
+ * attempt to wait for a crash dump for it (which will never come).
+ */
+add_task(async function test_launchfail_foreground() {
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ await BrowserTestUtils.withNewTab("http://example.com", async browser => {
+ let tabcrashed = BrowserTestUtils.waitForEvent(
+ browser,
+ "AboutTabCrashedReady",
+ false,
+ null,
+ true
+ );
+ await BrowserTestUtils.simulateProcessLaunchFail(browser);
+ Assert.equal(
+ 0,
+ TabCrashHandler.queuedCrashedBrowsers,
+ "No crashed browsers should be queued."
+ );
+ await tabcrashed;
+ });
+});
+
+/**
+ * Tests that if the content process fails to launch in a background
+ * tab, that upon choosing that tab, we show about:tabcrashed, but do
+ * not attempt to wait for a crash dump for it (which will never come).
+ */
+add_task(async function test_launchfail_background() {
+ let originalTab = gBrowser.selectedTab;
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ await BrowserTestUtils.withNewTab("http://example.com", async browser => {
+ let tab = gBrowser.getTabForBrowser(browser);
+ await BrowserTestUtils.switchTab(gBrowser, originalTab);
+
+ let tabcrashed = BrowserTestUtils.waitForEvent(
+ browser,
+ "AboutTabCrashedReady",
+ false,
+ null,
+ true
+ );
+ await BrowserTestUtils.simulateProcessLaunchFail(browser);
+ Assert.equal(
+ 0,
+ TabCrashHandler.queuedCrashedBrowsers,
+ "No crashed browsers should be queued."
+ );
+ await BrowserTestUtils.switchTab(gBrowser, tab);
+ await tabcrashed;
+ });
+});
diff --git a/browser/base/content/test/tabcrashed/browser_multipleCrashedTabs.js b/browser/base/content/test/tabcrashed/browser_multipleCrashedTabs.js
new file mode 100644
index 0000000000..f29b88edb6
--- /dev/null
+++ b/browser/base/content/test/tabcrashed/browser_multipleCrashedTabs.js
@@ -0,0 +1,136 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// eslint-disable-next-line @microsoft/sdl/no-insecure-url
+const PAGE_1 = "http://example.com";
+// eslint-disable-next-line @microsoft/sdl/no-insecure-url
+const PAGE_2 = "http://example.org";
+// eslint-disable-next-line @microsoft/sdl/no-insecure-url
+const PAGE_3 = "http://example.net";
+
+/**
+ * Checks that a particular about:tabcrashed page has the attribute set to
+ * use the "multiple about:tabcrashed" UI.
+ *
+ * @param browser (<xul:browser>)
+ * The browser to check.
+ * @param expected (Boolean)
+ * True if we expect the "multiple" state to be set.
+ * @returns Promise
+ * @resolves undefined
+ * When the check has completed.
+ */
+async function assertShowingMultipleUI(browser, expected) {
+ let showingMultiple = await SpecialPowers.spawn(browser, [], async () => {
+ return (
+ content.document.getElementById("main").getAttribute("multiple") == "true"
+ );
+ });
+ Assert.equal(showingMultiple, expected, "Got the expected 'multiple' state.");
+}
+
+/**
+ * Takes a Telemetry histogram snapshot and returns the sum of all counts.
+ *
+ * @param snapshot (Object)
+ * The Telemetry histogram snapshot to examine.
+ * @return (int)
+ * The sum of all counts in the snapshot.
+ */
+function snapshotCount(snapshot) {
+ return Object.values(snapshot.values).reduce((a, b) => a + b, 0);
+}
+
+/**
+ * Switches to a tab, crashes it, and waits for about:tabcrashed
+ * to load.
+ *
+ * @param tab (<xul:tab>)
+ * The tab to switch to and crash.
+ * @returns Promise
+ * @resolves undefined
+ * When about:tabcrashed is loaded.
+ */
+async function switchToAndCrashTab(tab) {
+ let browser = tab.linkedBrowser;
+
+ await BrowserTestUtils.switchTab(gBrowser, tab);
+ let tabcrashed = BrowserTestUtils.waitForEvent(
+ browser,
+ "AboutTabCrashedReady",
+ false,
+ null,
+ true
+ );
+ await BrowserTestUtils.crashFrame(browser);
+ await tabcrashed;
+}
+
+/**
+ * Tests that the appropriate pieces of UI in the about:tabcrashed pages
+ * are updated to reflect how many other about:tabcrashed pages there
+ * are.
+ */
+add_task(async function test_multiple_tabcrashed_pages() {
+ let histogram = Services.telemetry.getHistogramById(
+ "FX_CONTENT_CRASH_NOT_SUBMITTED"
+ );
+ histogram.clear();
+
+ let tab1 = await BrowserTestUtils.openNewForegroundTab(gBrowser, PAGE_1);
+ let browser1 = tab1.linkedBrowser;
+
+ let tab2 = await BrowserTestUtils.openNewForegroundTab(gBrowser, PAGE_2);
+ let browser2 = tab2.linkedBrowser;
+
+ let tab3 = await BrowserTestUtils.openNewForegroundTab(gBrowser, PAGE_3);
+ let browser3 = tab3.linkedBrowser;
+
+ await switchToAndCrashTab(tab1);
+ Assert.ok(tab1.hasAttribute("crashed"), "tab1 has crashed");
+ Assert.ok(!tab2.hasAttribute("crashed"), "tab2 has not crashed");
+ Assert.ok(!tab3.hasAttribute("crashed"), "tab3 has not crashed");
+
+ // Should not be showing UI for multiple tabs in tab1.
+ await assertShowingMultipleUI(browser1, false);
+
+ await switchToAndCrashTab(tab2);
+ Assert.ok(tab1.hasAttribute("crashed"), "tab1 is still crashed");
+ Assert.ok(tab2.hasAttribute("crashed"), "tab2 has crashed");
+ Assert.ok(!tab3.hasAttribute("crashed"), "tab3 has not crashed");
+
+ // tab1 and tab2 should now be showing UI for multiple tab crashes.
+ await assertShowingMultipleUI(browser1, true);
+ await assertShowingMultipleUI(browser2, true);
+
+ await switchToAndCrashTab(tab3);
+ Assert.ok(tab1.hasAttribute("crashed"), "tab1 is still crashed");
+ Assert.ok(tab2.hasAttribute("crashed"), "tab2 is still crashed");
+ Assert.ok(tab3.hasAttribute("crashed"), "tab3 has crashed");
+
+ // tab1 and tab2 should now be showing UI for multiple tab crashes.
+ await assertShowingMultipleUI(browser1, true);
+ await assertShowingMultipleUI(browser2, true);
+ await assertShowingMultipleUI(browser3, true);
+
+ BrowserTestUtils.removeTab(tab1);
+ await assertShowingMultipleUI(browser2, true);
+ await assertShowingMultipleUI(browser3, true);
+
+ BrowserTestUtils.removeTab(tab2);
+ await assertShowingMultipleUI(browser3, false);
+
+ BrowserTestUtils.removeTab(tab3);
+
+ // We only record the FX_CONTENT_CRASH_NOT_SUBMITTED probe if there
+ // was a single about:tabcrashed page at unload time, so we expect
+ // only a single entry for the probe for when we removed the last
+ // crashed tab.
+ await BrowserTestUtils.waitForCondition(() => {
+ return snapshotCount(histogram.snapshot()) == 1;
+ }, `Collected value should become 1.`);
+
+ histogram.clear();
+});
diff --git a/browser/base/content/test/tabcrashed/browser_noPermanentKey.js b/browser/base/content/test/tabcrashed/browser_noPermanentKey.js
new file mode 100644
index 0000000000..ee1caa73c0
--- /dev/null
+++ b/browser/base/content/test/tabcrashed/browser_noPermanentKey.js
@@ -0,0 +1,41 @@
+"use strict";
+
+const PAGE =
+ "data:text/html,<html><body>A%20regular,%20everyday,%20normal%20page.";
+
+add_setup(async function () {
+ await setupLocalCrashReportServer();
+});
+
+/**
+ * Tests tab crash page when a browser that somehow doesn't have a permanentKey
+ * crashes.
+ */
+add_task(async function test_without_dump() {
+ return BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: PAGE,
+ },
+ async function (browser) {
+ delete browser.permanentKey;
+
+ await BrowserTestUtils.crashFrame(browser);
+ let crashReport = promiseCrashReport();
+
+ await SpecialPowers.spawn(browser, [], async function () {
+ let doc = content.document;
+ Assert.ok(
+ doc.documentElement.classList.contains("crashDumpAvailable"),
+ "Should be offering to submit a crash report."
+ );
+ // With the permanentKey gone, restoring this tab is no longer
+ // possible. We'll just close it instead.
+ let closeTab = doc.getElementById("closeTab");
+ closeTab.click();
+ });
+
+ await crashReport;
+ }
+ );
+});
diff --git a/browser/base/content/test/tabcrashed/browser_printpreview_crash.js b/browser/base/content/test/tabcrashed/browser_printpreview_crash.js
new file mode 100644
index 0000000000..3ceb4fbe17
--- /dev/null
+++ b/browser/base/content/test/tabcrashed/browser_printpreview_crash.js
@@ -0,0 +1,83 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TEST_URL =
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com/browser/browser/base/content/test/tabcrashed/file_contains_emptyiframe.html";
+const DOMAIN = "example.com";
+
+/**
+ * This is really a crashtest, but because we need PrintUtils this is written as a browser test.
+ * Test that when we don't crash when trying to print a document in the following scenario -
+ * A top level document has an iframe of different origin embedded (here example.com has test1.example.com iframe embedded)
+ * and they both set their document.domain to be "example.com".
+ */
+add_task(async function test() {
+ // 1. Open a new tab and wait for it to load the top level doc
+ let newTab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_URL);
+ let browser = newTab.linkedBrowser;
+
+ // 2. Navigate the iframe within the doc and wait for the load to complete
+ await SpecialPowers.spawn(browser, [], async function () {
+ const iframe = content.document.querySelector("iframe");
+ const loaded = new Promise(resolve => {
+ iframe.addEventListener(
+ "load",
+ () => {
+ resolve();
+ },
+ { once: true }
+ );
+ });
+ iframe.src =
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://test1.example.com/browser/browser/base/content/test/tabcrashed/file_iframe.html";
+ await loaded;
+ });
+
+ // 3. Change the top level document's domain
+ await SpecialPowers.spawn(browser, [DOMAIN], async function (domain) {
+ content.document.domain = domain;
+ });
+
+ // 4. Get the reference to the iframe and change its domain
+ const iframe = await SpecialPowers.spawn(browser, [], () => {
+ return content.document.querySelector("iframe").browsingContext;
+ });
+
+ await SpecialPowers.spawn(iframe, [DOMAIN], domain => {
+ content.document.domain = domain;
+ });
+
+ // 5. Try to print things
+ ok(
+ !document.querySelector(".printPreviewBrowser"),
+ "Should NOT be in print preview mode at the start of this test."
+ );
+
+ // Enter print preview
+ document.getElementById("cmd_print").doCommand();
+ await BrowserTestUtils.waitForCondition(() => {
+ let preview = document.querySelector(".printPreviewBrowser");
+ return preview && BrowserTestUtils.is_visible(preview);
+ });
+
+ let ppBrowser = document.querySelector(
+ ".printPreviewBrowser[previewtype=source]"
+ );
+ ok(ppBrowser, "Print preview browser was created");
+
+ ok(true, "We did not crash.");
+
+ // We haven't crashed! Exit the print preview.
+ gBrowser.getTabDialogBox(gBrowser.selectedBrowser).abortAllDialogs();
+ await BrowserTestUtils.waitForCondition(
+ () => !document.querySelector(".printPreviewBrowser")
+ );
+
+ info("We are not in print preview anymore.");
+
+ BrowserTestUtils.removeTab(newTab);
+});
diff --git a/browser/base/content/test/tabcrashed/browser_showForm.js b/browser/base/content/test/tabcrashed/browser_showForm.js
new file mode 100644
index 0000000000..9594f27f9e
--- /dev/null
+++ b/browser/base/content/test/tabcrashed/browser_showForm.js
@@ -0,0 +1,44 @@
+"use strict";
+
+const PAGE =
+ "data:text/html,<html><body>A%20regular,%20everyday,%20normal%20page.";
+
+// On debug builds, crashing tabs results in much thinking, which
+// slows down the test and results in intermittent test timeouts,
+// so we'll pump up the expected timeout for this test.
+requestLongerTimeout(2);
+
+/**
+ * Tests that we show the about:tabcrashed additional details form
+ * if the "submit a crash report" checkbox was checked by default.
+ */
+add_task(async function test_show_form() {
+ return BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: PAGE,
+ },
+ async function (browser) {
+ // Flip the pref so that the checkbox should be checked
+ // by default.
+ let pref = TabCrashHandler.prefs.root + "sendReport";
+ await SpecialPowers.pushPrefEnv({
+ set: [[pref, true]],
+ });
+
+ // Now crash the browser.
+ await BrowserTestUtils.crashFrame(browser);
+
+ let doc = browser.contentDocument;
+
+ // Ensure the checkbox is checked. We can safely reach into
+ // the content since about:tabcrashed is an in-process URL.
+ let checkbox = doc.getElementById("sendReport");
+ ok(checkbox.checked, "Send report checkbox is checked.");
+
+ // Ensure the options form is displayed.
+ let options = doc.getElementById("options");
+ ok(!options.hidden, "Showing the crash report options form.");
+ }
+ );
+});
diff --git a/browser/base/content/test/tabcrashed/browser_shown.js b/browser/base/content/test/tabcrashed/browser_shown.js
new file mode 100644
index 0000000000..b84c2c7061
--- /dev/null
+++ b/browser/base/content/test/tabcrashed/browser_shown.js
@@ -0,0 +1,150 @@
+"use strict";
+
+const PAGE =
+ "data:text/html,<html><body>A%20regular,%20everyday,%20normal%20page.";
+const COMMENTS = "Here's my test comment!";
+
+// Avoid timeouts, as in bug 1325530
+requestLongerTimeout(2);
+
+add_setup(async function () {
+ await setupLocalCrashReportServer();
+});
+
+/**
+ * This function returns a Promise that resolves once the following
+ * actions have taken place:
+ *
+ * 1) A new tab is opened up at PAGE
+ * 2) The tab is crashed
+ * 3) The about:tabcrashed page's fields are set in accordance with
+ * fieldValues
+ * 4) The tab is restored
+ * 5) A crash report is received from the testing server
+ * 6) Any tab crash prefs that were overwritten are reset
+ *
+ * @param fieldValues
+ * An Object describing how to set the about:tabcrashed
+ * fields. The following properties are accepted:
+ *
+ * comments (String)
+ * The comments to put in the comment textarea
+ * includeURL (bool)
+ * The checked value of the "Include URL" checkbox
+ *
+ * If any of these fields are missing, the defaults from
+ * the user preferences are used.
+ * @param expectedExtra
+ * An Object describing the expected values that the submitted
+ * crash report's extra data should contain.
+ * @returns Promise
+ */
+function crashTabTestHelper(fieldValues, expectedExtra) {
+ return BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: PAGE,
+ },
+ async function (browser) {
+ let prefs = TabCrashHandler.prefs;
+ let originalSendReport = prefs.getBoolPref("sendReport");
+ let originalIncludeURL = prefs.getBoolPref("includeURL");
+
+ let tab = gBrowser.getTabForBrowser(browser);
+ await BrowserTestUtils.crashFrame(browser);
+ let doc = browser.contentDocument;
+
+ // Since about:tabcrashed will run in the parent process, we can safely
+ // manipulate its DOM nodes directly
+ let comments = doc.getElementById("comments");
+ let includeURL = doc.getElementById("includeURL");
+
+ if (fieldValues.hasOwnProperty("comments")) {
+ comments.value = fieldValues.comments;
+ }
+
+ if (fieldValues.hasOwnProperty("includeURL")) {
+ includeURL.checked = fieldValues.includeURL;
+ }
+
+ let crashReport = promiseCrashReport(expectedExtra);
+ let restoreTab = browser.contentDocument.getElementById("restoreTab");
+ restoreTab.click();
+ await BrowserTestUtils.waitForEvent(tab, "SSTabRestored");
+ await crashReport;
+
+ // Submitting the crash report may have set some prefs regarding how to
+ // send tab crash reports. Let's reset them for the next test.
+ prefs.setBoolPref("sendReport", originalSendReport);
+ prefs.setBoolPref("includeURL", originalIncludeURL);
+ }
+ );
+}
+
+/**
+ * Tests what we send with the crash report by default. By default, we do not
+ * send any comments or the URL of the crashing page.
+ */
+add_task(async function test_default() {
+ await crashTabTestHelper(
+ {},
+ {
+ SubmittedFrom: "CrashedTab",
+ Throttleable: "1",
+ Comments: null,
+ URL: "",
+ }
+ );
+});
+
+/**
+ * Test just sending a comment.
+ */
+add_task(async function test_just_a_comment() {
+ await crashTabTestHelper(
+ {
+ SubmittedFrom: "CrashedTab",
+ Throttleable: "1",
+ comments: COMMENTS,
+ },
+ {
+ Comments: COMMENTS,
+ URL: "",
+ }
+ );
+});
+
+/**
+ * Test that we will send the URL of the page if includeURL is checked.
+ */
+add_task(async function test_send_URL() {
+ await crashTabTestHelper(
+ {
+ SubmittedFrom: "CrashedTab",
+ Throttleable: "1",
+ includeURL: true,
+ },
+ {
+ Comments: null,
+ URL: PAGE,
+ }
+ );
+});
+
+/**
+ * Test that we can send comments and the URL
+ */
+add_task(async function test_send_all() {
+ await crashTabTestHelper(
+ {
+ SubmittedFrom: "CrashedTab",
+ Throttleable: "1",
+ includeURL: true,
+ comments: COMMENTS,
+ },
+ {
+ Comments: COMMENTS,
+ URL: PAGE,
+ }
+ );
+});
diff --git a/browser/base/content/test/tabcrashed/browser_shownRestartRequired.js b/browser/base/content/test/tabcrashed/browser_shownRestartRequired.js
new file mode 100644
index 0000000000..9142b54a8a
--- /dev/null
+++ b/browser/base/content/test/tabcrashed/browser_shownRestartRequired.js
@@ -0,0 +1,121 @@
+"use strict";
+
+const PAGE =
+ "data:text/html,<html><body>A%20regular,%20everyday,%20normal%20page.";
+
+async function assertIsAtRestartRequiredPage(browser) {
+ let doc = browser.contentDocument;
+
+ // Since about:restartRequired will run in the parent process, we can safely
+ // manipulate its DOM nodes directly
+ let title = doc.getElementById("title");
+ let description = doc.getElementById("errorLongContent");
+ let restartButton = doc.getElementById("restart");
+
+ Assert.ok(title, "Title element exists.");
+ Assert.ok(description, "Description element exists.");
+ Assert.ok(restartButton, "Restart button exists.");
+}
+
+/**
+ * This function returns a Promise that resolves once the following
+ * actions have taken place:
+ *
+ * 1) A new tab is opened up at PAGE
+ * 2) The tab is crashed
+ * 3) The about:restartrequired page is displayed
+ *
+ * @returns Promise
+ */
+function crashTabTestHelper() {
+ return BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: PAGE,
+ },
+ async function (browser) {
+ // Simulate buildID mismatch.
+ TabCrashHandler.testBuildIDMismatch = true;
+
+ let restartRequiredLoaded = BrowserTestUtils.waitForContentEvent(
+ browser,
+ "AboutRestartRequiredLoad",
+ false,
+ null,
+ true
+ );
+ await BrowserTestUtils.crashFrame(browser, false);
+ await restartRequiredLoaded;
+ await assertIsAtRestartRequiredPage(browser);
+
+ // Reset
+ TabCrashHandler.testBuildIDMismatch = false;
+ }
+ );
+}
+
+/**
+ * Tests that the about:restartrequired page appears when buildID mismatches
+ * between parent and child processes are encountered.
+ */
+add_task(async function test_default() {
+ await crashTabTestHelper();
+});
+
+/**
+ * Tests that if the content process fails to launch in the
+ * foreground tab, that we show the restart required page, but do not
+ * attempt to wait for a crash dump for it (which will never come).
+ */
+add_task(async function test_restart_required_foreground() {
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ await BrowserTestUtils.withNewTab("http://example.com", async browser => {
+ let loaded = BrowserTestUtils.browserLoaded(browser, false, null, true);
+ await BrowserTestUtils.simulateProcessLaunchFail(
+ browser,
+ true /* restart required */
+ );
+ Assert.equal(
+ 0,
+ TabCrashHandler.queuedCrashedBrowsers,
+ "No crashed browsers should be queued."
+ );
+ await loaded;
+ await assertIsAtRestartRequiredPage(browser);
+ });
+});
+
+/**
+ * Tests that if the content process fails to launch in a background
+ * tab because a restart is required, that upon choosing that tab, we
+ * show the restart required error page, but do not attempt to wait for
+ * a crash dump for it (which will never come).
+ */
+add_task(async function test_launchfail_background() {
+ let originalTab = gBrowser.selectedTab;
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ await BrowserTestUtils.withNewTab("http://example.com", async browser => {
+ let tab = gBrowser.getTabForBrowser(browser);
+ await BrowserTestUtils.switchTab(gBrowser, originalTab);
+ await BrowserTestUtils.simulateProcessLaunchFail(
+ browser,
+ true /* restart required */
+ );
+ Assert.equal(
+ 0,
+ TabCrashHandler.queuedCrashedBrowsers,
+ "No crashed browsers should be queued."
+ );
+ let loaded = BrowserTestUtils.waitForContentEvent(
+ browser,
+ "AboutRestartRequiredLoad",
+ false,
+ null,
+ true
+ );
+ await BrowserTestUtils.switchTab(gBrowser, tab);
+ await loaded;
+
+ await assertIsAtRestartRequiredPage(browser);
+ });
+});
diff --git a/browser/base/content/test/tabcrashed/browser_withoutDump.js b/browser/base/content/test/tabcrashed/browser_withoutDump.js
new file mode 100644
index 0000000000..4439f83078
--- /dev/null
+++ b/browser/base/content/test/tabcrashed/browser_withoutDump.js
@@ -0,0 +1,42 @@
+"use strict";
+
+const PAGE =
+ "data:text/html,<html><body>A%20regular,%20everyday,%20normal%20page.";
+
+add_setup(async function () {
+ prepareNoDump();
+});
+
+/**
+ * Tests tab crash page when a dump is not available.
+ */
+add_task(async function test_without_dump() {
+ return BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: PAGE,
+ },
+ async function (browser) {
+ let tab = gBrowser.getTabForBrowser(browser);
+ await BrowserTestUtils.crashFrame(browser);
+
+ let tabClosingPromise = BrowserTestUtils.waitForTabClosing(tab);
+
+ await SpecialPowers.spawn(browser, [], async function () {
+ let doc = content.document;
+ Assert.ok(
+ !doc.documentElement.classList.contains("crashDumpAvailable"),
+ "doesn't have crash dump"
+ );
+
+ let options = doc.getElementById("options");
+ Assert.ok(options, "has crash report options");
+ Assert.ok(options.hidden, "crash report options are hidden");
+
+ doc.getElementById("closeTab").click();
+ });
+
+ await tabClosingPromise;
+ }
+ );
+});
diff --git a/browser/base/content/test/tabcrashed/file_contains_emptyiframe.html b/browser/base/content/test/tabcrashed/file_contains_emptyiframe.html
new file mode 100644
index 0000000000..5c9a339e68
--- /dev/null
+++ b/browser/base/content/test/tabcrashed/file_contains_emptyiframe.html
@@ -0,0 +1,9 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+<meta charset="utf-8">
+</head>
+<body>
+<iframe></iframe>
+</body>
+</html>
diff --git a/browser/base/content/test/tabcrashed/file_iframe.html b/browser/base/content/test/tabcrashed/file_iframe.html
new file mode 100644
index 0000000000..13f0b53574
--- /dev/null
+++ b/browser/base/content/test/tabcrashed/file_iframe.html
@@ -0,0 +1,9 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+<meta charset="utf-8">
+</head>
+<body>
+Iframe body
+</body>
+</html>
diff --git a/browser/base/content/test/tabcrashed/head.js b/browser/base/content/test/tabcrashed/head.js
new file mode 100644
index 0000000000..bc6185a283
--- /dev/null
+++ b/browser/base/content/test/tabcrashed/head.js
@@ -0,0 +1,238 @@
+"use strict";
+
+const { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+
+/**
+ * Returns a Promise that resolves once a crash report has
+ * been submitted. This function will also test the crash
+ * reports extra data to see if it matches expectedExtra.
+ *
+ * @param expectedExtra (object)
+ * An Object whose key-value pairs will be compared
+ * against the key-value pairs in the extra data of the
+ * crash report. A test failure will occur if there is
+ * a mismatch.
+ *
+ * If the value of the key-value pair is "null", this will
+ * be interpreted as "this key should not be included in the
+ * extra data", and will cause a test failure if it is detected
+ * in the crash report.
+ *
+ * Note that this will ignore any keys that are not included
+ * in expectedExtra. It's possible that the crash report
+ * will contain other extra information that is not
+ * compared against.
+ * @returns Promise
+ */
+function promiseCrashReport(expectedExtra = {}) {
+ return (async function () {
+ info("Starting wait on crash-report-status");
+ let [subject] = await TestUtils.topicObserved(
+ "crash-report-status",
+ (unused, data) => {
+ return data == "success";
+ }
+ );
+ info("Topic observed!");
+
+ if (!(subject instanceof Ci.nsIPropertyBag2)) {
+ throw new Error("Subject was not a Ci.nsIPropertyBag2");
+ }
+
+ let remoteID = getPropertyBagValue(subject, "serverCrashID");
+ if (!remoteID) {
+ throw new Error("Report should have a server ID");
+ }
+
+ let file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+ file.initWithPath(Services.crashmanager._submittedDumpsDir);
+ file.append(remoteID + ".txt");
+ if (!file.exists()) {
+ throw new Error("Report should have been received by the server");
+ }
+
+ file.remove(false);
+
+ let extra = getPropertyBagValue(subject, "extra");
+ if (!(extra instanceof Ci.nsIPropertyBag2)) {
+ throw new Error("extra was not a Ci.nsIPropertyBag2");
+ }
+
+ info("Iterating crash report extra keys");
+ for (let { name: key } of extra.enumerator) {
+ let value = extra.getPropertyAsAString(key);
+ if (key in expectedExtra) {
+ if (expectedExtra[key] == null) {
+ ok(false, `Got unexpected key ${key} with value ${value}`);
+ } else {
+ is(
+ value,
+ expectedExtra[key],
+ `Crash report had the right extra value for ${key}`
+ );
+ }
+ }
+ }
+ })();
+}
+
+/**
+ * For an nsIPropertyBag, returns the value for a given
+ * key.
+ *
+ * @param bag
+ * The nsIPropertyBag to retrieve the value from
+ * @param key
+ * The key that we want to get the value for from the
+ * bag
+ * @returns The value corresponding to the key from the bag,
+ * or null if the value could not be retrieved (for
+ * example, if no value is set at that key).
+ */
+function getPropertyBagValue(bag, key) {
+ try {
+ let val = bag.getProperty(key);
+ return val;
+ } catch (e) {
+ if (e.result != Cr.NS_ERROR_FAILURE) {
+ throw e;
+ }
+ }
+
+ return null;
+}
+
+/**
+ * Sets up the browser to send crash reports to the local crash report
+ * testing server.
+ */
+async function setupLocalCrashReportServer() {
+ const SERVER_URL =
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com/browser/toolkit/crashreporter/test/browser/crashreport.sjs";
+
+ // The test harness sets MOZ_CRASHREPORTER_NO_REPORT, which disables crash
+ // reports. This test needs them enabled. The test also needs a mock
+ // report server, and fortunately one is already set up by toolkit/
+ // crashreporter/test/Makefile.in. Assign its URL to MOZ_CRASHREPORTER_URL,
+ // which CrashSubmit.jsm uses as a server override.
+ let noReport = Services.env.get("MOZ_CRASHREPORTER_NO_REPORT");
+ let serverUrl = Services.env.get("MOZ_CRASHREPORTER_URL");
+ Services.env.set("MOZ_CRASHREPORTER_NO_REPORT", "");
+ Services.env.set("MOZ_CRASHREPORTER_URL", SERVER_URL);
+
+ registerCleanupFunction(function () {
+ Services.env.set("MOZ_CRASHREPORTER_NO_REPORT", noReport);
+ Services.env.set("MOZ_CRASHREPORTER_URL", serverUrl);
+ });
+}
+
+/**
+ * Monkey patches TabCrashHandler.getDumpID to return null in order to test
+ * about:tabcrashed when a dump is not available.
+ */
+function prepareNoDump() {
+ let originalGetDumpID = TabCrashHandler.getDumpID;
+ TabCrashHandler.getDumpID = function (browser) {
+ return null;
+ };
+ registerCleanupFunction(() => {
+ TabCrashHandler.getDumpID = originalGetDumpID;
+ });
+}
+
+const kBuildidMatchEnv = "MOZ_BUILDID_MATCH_DONTSEND";
+
+function setBuildidMatchDontSendEnv() {
+ info("Setting " + kBuildidMatchEnv + "=1");
+ Services.env.set(kBuildidMatchEnv, "1");
+}
+
+function unsetBuildidMatchDontSendEnv() {
+ info("Setting " + kBuildidMatchEnv + "=0");
+ Services.env.set(kBuildidMatchEnv, "0");
+}
+
+function getEventPromise(eventName, eventKind) {
+ return new Promise(function (resolve, reject) {
+ info("Installing event listener (" + eventKind + ")");
+ window.addEventListener(
+ eventName,
+ event => {
+ ok(true, "Received " + eventName + " (" + eventKind + ") event");
+ info("Call resolve() for " + eventKind + " event");
+ resolve();
+ },
+ { once: true }
+ );
+ info("Installed event listener (" + eventKind + ")");
+ });
+}
+
+async function ensureBuildID() {
+ let profD = Services.dirsvc.get("GreD", Ci.nsIFile);
+ let platformIniOrig = await IOUtils.readUTF8(
+ PathUtils.join(profD.path, "platform.ini")
+ );
+ let buildID = Services.appinfo.platformBuildID;
+ return platformIniOrig.indexOf(buildID) > 0;
+}
+
+async function openNewTab(forceCrash) {
+ const PAGE =
+ "data:text/html,<html><body>A%20regular,%20everyday,%20normal%20page.";
+
+ let options = {
+ gBrowser,
+ PAGE,
+ waitForLoad: false,
+ waitForStateStop: false,
+ forceNewProcess: true,
+ };
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(options);
+ if (forceCrash === true) {
+ let browser = tab.linkedBrowser;
+ await BrowserTestUtils.crashFrame(
+ browser,
+ /* shouldShowTabCrashPage */ false,
+ /* shouldClearMinidumps */ true,
+ /* BrowsingContext */ null
+ );
+ }
+
+ return tab;
+}
+
+async function closeTab(tab) {
+ await TestUtils.waitForTick();
+ BrowserTestUtils.removeTab(tab);
+}
+
+function getFalsePositiveTelemetry() {
+ const scalars = TelemetryTestUtils.getProcessScalars("parent");
+ return scalars["dom.contentprocess.buildID_mismatch_false_positive"];
+}
+
+// The logic bound to dom.ipc.processPrelaunch.enabled will react to value
+// changes: https://searchfox.org/mozilla-central/rev/ecd91b104714a8b2584a4c03175be50ccb3a7c67/dom/ipc/PreallocatedProcessManager.cpp#171-195
+// So we force flip to ensure we have no dangling process.
+async function forceCleanProcesses() {
+ const origPrefValue = SpecialPowers.getBoolPref(
+ "dom.ipc.processPrelaunch.enabled"
+ );
+ await SpecialPowers.setBoolPref(
+ "dom.ipc.processPrelaunch.enabled",
+ !origPrefValue
+ );
+ await SpecialPowers.setBoolPref(
+ "dom.ipc.processPrelaunch.enabled",
+ origPrefValue
+ );
+ const currPrefValue = SpecialPowers.getBoolPref(
+ "dom.ipc.processPrelaunch.enabled"
+ );
+ ok(currPrefValue === origPrefValue, "processPrelaunch properly re-enabled");
+}
diff --git a/browser/base/content/test/tabdialogs/browser.ini b/browser/base/content/test/tabdialogs/browser.ini
new file mode 100644
index 0000000000..2fbfa48b37
--- /dev/null
+++ b/browser/base/content/test/tabdialogs/browser.ini
@@ -0,0 +1,19 @@
+[DEFAULT]
+support-files =
+ subdialog.xhtml
+
+[browser_multiple_dialog_navigation.js]
+[browser_subdialog_esc.js]
+support-files =
+ loadDelayedReply.sjs
+[browser_tabdialogbox_content_prompts.js]
+skip-if =
+ apple_silicon && !debug # Bug 1786514
+ apple_catalina && !debug # Bug 1786514
+ win10_2004 && !debug # Bug 1786514
+support-files =
+ test_page.html
+[browser_tabdialogbox_focus.js]
+https_first_disabled = true
+[browser_tabdialogbox_navigation.js]
+https_first_disabled = true
diff --git a/browser/base/content/test/tabdialogs/browser_multiple_dialog_navigation.js b/browser/base/content/test/tabdialogs/browser_multiple_dialog_navigation.js
new file mode 100644
index 0000000000..9d66ac1d7e
--- /dev/null
+++ b/browser/base/content/test/tabdialogs/browser_multiple_dialog_navigation.js
@@ -0,0 +1,61 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_multiple_dialog_navigation() {
+ await BrowserTestUtils.withNewTab(
+ "https://example.com/gone",
+ async browser => {
+ let firstDialogPromise = BrowserTestUtils.promiseAlertDialogOpen();
+ // We're gonna queue up some dialogs, and navigate. The tasks queueing the dialog
+ // are going to get aborted when the navigation happened, but that's OK because by
+ // that time they will have done their job. Detect and swallow that specific
+ // exception:
+ let navigationCatcher = e => {
+ if (e.name == "AbortError" && e.message.includes("destroyed before")) {
+ return;
+ }
+ throw e;
+ };
+ // Queue up 2 dialogs
+ let firstTask = SpecialPowers.spawn(browser, [], async function () {
+ content.eval(`alert('hi');`);
+ }).catch(navigationCatcher);
+ let secondTask = SpecialPowers.spawn(browser, [], async function () {
+ content.eval(`alert('hi again');`);
+ }).catch(navigationCatcher);
+ info("Waiting for first dialog.");
+ let dialogWin = await firstDialogPromise;
+
+ let secondDialogPromise = BrowserTestUtils.promiseAlertDialogOpen();
+ dialogWin.document
+ .getElementById("commonDialog")
+ .getButton("accept")
+ .click();
+ dialogWin = null;
+
+ info("Wait for second dialog to appear.");
+ let secondDialogWin = await secondDialogPromise;
+ let closedPromise = BrowserTestUtils.waitForEvent(
+ secondDialogWin,
+ "unload"
+ );
+ let loadedOtherPage = BrowserTestUtils.waitForLocationChange(
+ gBrowser,
+ "https://example.org/gone"
+ );
+ BrowserTestUtils.loadURIString(browser, "https://example.org/gone");
+ info("Waiting for the next page to load.");
+ await loadedOtherPage;
+ info(
+ "Waiting for second dialog to close. If we time out here that's a bug!"
+ );
+ await closedPromise;
+ is(secondDialogWin.closed, true, "Should have closed second dialog.");
+ info("Ensure content tasks are done");
+ await secondTask;
+ await firstTask;
+ }
+ );
+});
diff --git a/browser/base/content/test/tabdialogs/browser_subdialog_esc.js b/browser/base/content/test/tabdialogs/browser_subdialog_esc.js
new file mode 100644
index 0000000000..63f2f276a3
--- /dev/null
+++ b/browser/base/content/test/tabdialogs/browser_subdialog_esc.js
@@ -0,0 +1,122 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TEST_ROOT_CHROME = getRootDirectory(gTestPath);
+const TEST_DIALOG_PATH = TEST_ROOT_CHROME + "subdialog.xhtml";
+
+const WEB_ROOT = TEST_ROOT_CHROME.replace(
+ "chrome://mochitests/content",
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com"
+);
+const TEST_LOAD_PAGE = WEB_ROOT + "loadDelayedReply.sjs";
+
+/**
+ * Tests that ESC on a SubDialog does not cancel ongoing loads in the parent.
+ */
+add_task(async function test_subdialog_esc_does_not_cancel_load() {
+ await BrowserTestUtils.withNewTab(
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com",
+ async function (browser) {
+ // Start loading a page
+ let loadStartedPromise = BrowserTestUtils.loadURIString(
+ browser,
+ TEST_LOAD_PAGE
+ );
+ let loadedPromise = BrowserTestUtils.browserLoaded(browser);
+ await loadStartedPromise;
+
+ // Open a dialog
+ let dialogBox = gBrowser.getTabDialogBox(browser);
+ let dialogClose = dialogBox.open(TEST_DIALOG_PATH, {
+ keepOpenSameOriginNav: true,
+ }).closedPromise;
+
+ let dialogs = dialogBox.getTabDialogManager()._dialogs;
+
+ is(dialogs.length, 1, "Dialog manager has a dialog.");
+
+ info("Waiting for dialogs to open.");
+ await dialogs[0]._dialogReady;
+
+ // Close the dialog with esc key
+ EventUtils.synthesizeKey("KEY_Escape");
+
+ info("Waiting for dialog to close.");
+ await dialogClose;
+
+ info("Triggering load complete");
+ fetch(TEST_LOAD_PAGE, {
+ method: "POST",
+ });
+
+ // Load must complete
+ info("Waiting for load to complete");
+ await loadedPromise;
+ ok(true, "Load completed");
+ }
+ );
+});
+
+/**
+ * Tests that ESC on a SubDialog with an open dropdown doesn't close the dialog.
+ */
+add_task(async function test_subdialog_esc_on_dropdown_does_not_close_dialog() {
+ await BrowserTestUtils.withNewTab(
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com",
+ async function (browser) {
+ // Open the test dialog
+ let dialogBox = gBrowser.getTabDialogBox(browser);
+ let dialogClose = dialogBox.open(TEST_DIALOG_PATH, {
+ keepOpenSameOriginNav: true,
+ }).closedPromise;
+
+ let dialogs = dialogBox.getTabDialogManager()._dialogs;
+
+ is(dialogs.length, 1, "Dialog manager has a dialog.");
+
+ let dialog = dialogs[0];
+
+ info("Waiting for dialog to open.");
+ await dialog._dialogReady;
+
+ // Open dropdown
+ let select = dialog._frame.contentDocument.getElementById("select");
+ let shownPromise = BrowserTestUtils.waitForSelectPopupShown(window);
+
+ info("Opening dropdown");
+ select.focus();
+ EventUtils.synthesizeKey("VK_SPACE", {}, dialog._frame.contentWindow);
+
+ let selectPopup = await shownPromise;
+
+ let hiddenPromise = BrowserTestUtils.waitForEvent(
+ selectPopup,
+ "popuphiding",
+ true
+ );
+
+ // Race dropdown closing vs SubDialog close
+ let race = Promise.race([
+ hiddenPromise.then(() => true),
+ dialogClose.then(() => false),
+ ]);
+
+ // Close the dropdown with esc key
+ info("Hitting escape key.");
+ await EventUtils.synthesizeKey("KEY_Escape");
+
+ let result = await race;
+ ok(result, "Select closed first");
+
+ await new Promise(resolve => executeSoon(resolve));
+
+ ok(!dialog._isClosing, "Dialog is not closing");
+ ok(dialog._openedURL, "Dialog is open");
+ }
+ );
+});
diff --git a/browser/base/content/test/tabdialogs/browser_tabdialogbox_content_prompts.js b/browser/base/content/test/tabdialogs/browser_tabdialogbox_content_prompts.js
new file mode 100644
index 0000000000..3067c53873
--- /dev/null
+++ b/browser/base/content/test/tabdialogs/browser_tabdialogbox_content_prompts.js
@@ -0,0 +1,179 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const CONTENT_PROMPT_PREF = "prompts.contentPromptSubDialog";
+const TEST_ROOT_CHROME = getRootDirectory(gTestPath);
+const TEST_DIALOG_PATH = TEST_ROOT_CHROME + "subdialog.xhtml";
+
+const TEST_DATA_URI = "data:text/html,<body onload='alert(1)'>";
+const TEST_EXTENSION_DATA = {
+ background() {
+ // eslint-disable-next-line no-undef
+ browser.test.sendMessage("url", browser.runtime.getURL("alert.html"));
+ },
+ manifest: {
+ name: "Test Extension",
+ },
+ files: {
+ "alert.html": `<!DOCTYPE HTML>
+<html>
+ <head>
+ <meta charset="utf-8">
+ <title>TabDialogBox Content Modal Test page</title>
+ <script src="./alert.js"></script>
+ </head>
+ <body>
+ <h1>TabDialogBox Content Modal</h1>
+ </body>
+</html>`,
+ "alert.js": `window.addEventListener("load", () => alert("Hi"));`,
+ },
+};
+// eslint-disable-next-line @microsoft/sdl/no-insecure-url
+const TEST_ORIGIN = "http://example.com";
+const TEST_PAGE =
+ TEST_ROOT_CHROME.replace("chrome://mochitests/content", TEST_ORIGIN) +
+ "test_page.html";
+
+var commonDialogsBundle = Services.strings.createBundle(
+ "chrome://global/locale/commonDialogs.properties"
+);
+
+// Setup.
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [[CONTENT_PROMPT_PREF, true]],
+ });
+});
+
+/**
+ * Test that a manager for content prompts is added to tab dialog box.
+ */
+add_task(async function test_tabdialog_content_prompts() {
+ await BrowserTestUtils.withNewTab(
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com",
+ async function (browser) {
+ info("Open a tab prompt.");
+ let dialogBox = gBrowser.getTabDialogBox(browser);
+ dialogBox.open(TEST_DIALOG_PATH);
+
+ info("Check the content prompt dialog is only created when needed.");
+ let contentPromptDialog = document.querySelector(
+ ".content-prompt-dialog"
+ );
+ ok(!contentPromptDialog, "Content prompt dialog should not be created.");
+
+ info("Open a content prompt");
+ dialogBox.open(TEST_DIALOG_PATH, {
+ modalType: Ci.nsIPrompt.MODAL_TYPE_CONTENT,
+ });
+
+ contentPromptDialog = document.querySelector(".content-prompt-dialog");
+ ok(contentPromptDialog, "Content prompt dialog should be created.");
+ let contentPromptManager = dialogBox.getContentDialogManager();
+
+ is(
+ contentPromptManager._dialogs.length,
+ 1,
+ "Content prompt manager should have 1 dialog box."
+ );
+ }
+ );
+});
+
+/**
+ * Test origin text for a null principal.
+ */
+add_task(async function test_tabdialog_null_principal_title() {
+ let dialogShown = BrowserTestUtils.waitForEvent(
+ gBrowser,
+ "DOMWillOpenModalDialog"
+ );
+
+ await BrowserTestUtils.withNewTab(TEST_DATA_URI, async function (browser) {
+ info("Waiting for dialog to open.");
+ await dialogShown;
+ await checkOriginText(browser);
+ });
+});
+
+/**
+ * Test origin text for an extension page.
+ */
+add_task(async function test_tabdialog_extension_title() {
+ let extension = ExtensionTestUtils.loadExtension(TEST_EXTENSION_DATA);
+
+ await extension.startup();
+ let url = await extension.awaitMessage("url");
+ let dialogShown = BrowserTestUtils.waitForEvent(
+ gBrowser,
+ "DOMWillOpenModalDialog"
+ );
+
+ await BrowserTestUtils.withNewTab(url, async function (browser) {
+ info("Waiting for dialog to open.");
+ await dialogShown;
+ await checkOriginText(browser, "Test Extension");
+ });
+
+ await extension.unload();
+});
+
+/**
+ * Test origin text for a regular page.
+ */
+add_task(async function test_tabdialog_page_title() {
+ let dialogShown = BrowserTestUtils.waitForEvent(
+ gBrowser,
+ "DOMWillOpenModalDialog"
+ );
+
+ await BrowserTestUtils.withNewTab(TEST_PAGE, async function (browser) {
+ info("Waiting for dialog to open.");
+ await dialogShown;
+ await checkOriginText(browser, TEST_ORIGIN);
+ });
+});
+
+/**
+ * Test helper for checking the origin header of a dialog.
+ *
+ * @param {Object} browser
+ * The browser the dialog was opened from.
+ * @param {String|null} origin
+ * The page origin that should be displayed in the header, if any.
+ */
+async function checkOriginText(browser, origin = null) {
+ info("Check the title is visible.");
+ let dialogBox = gBrowser.getTabDialogBox(browser);
+ let contentPromptManager = dialogBox.getContentDialogManager();
+ let dialog = contentPromptManager._dialogs[0];
+
+ info("Waiting for dialog frame to be ready.");
+ await dialog._dialogReady;
+
+ let dialogDoc = dialog._frame.contentWindow.document;
+ let titleSelector = "#titleText";
+ let infoTitle = dialogDoc.querySelector(titleSelector);
+ ok(BrowserTestUtils.is_visible(infoTitle), "Title text is visible");
+
+ info("Check the displayed origin text is correct.");
+ if (origin) {
+ let host = origin;
+ try {
+ host = new URL(origin).host;
+ } catch (ex) {
+ /* will fail for the extension case. */
+ }
+ is(infoTitle.textContent, host, "Origin should be in header.");
+ } else {
+ is(
+ infoTitle.dataset.l10nId,
+ "common-dialog-title-null",
+ "Null principal string should be in header."
+ );
+ }
+}
diff --git a/browser/base/content/test/tabdialogs/browser_tabdialogbox_focus.js b/browser/base/content/test/tabdialogs/browser_tabdialogbox_focus.js
new file mode 100644
index 0000000000..08c0e8828d
--- /dev/null
+++ b/browser/base/content/test/tabdialogs/browser_tabdialogbox_focus.js
@@ -0,0 +1,212 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TEST_ROOT_CHROME = getRootDirectory(gTestPath);
+const TEST_DIALOG_PATH = TEST_ROOT_CHROME + "subdialog.xhtml";
+
+/**
+ * Tests that tab dialogs are focused when switching tabs.
+ */
+add_task(async function test_tabdialogbox_tab_switch_focus() {
+ // Open 3 tabs
+ let tabPromises = [];
+ for (let i = 0; i < 3; i += 1) {
+ tabPromises.push(
+ BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com",
+ true
+ )
+ );
+ }
+
+ // Wait for tabs to be ready
+ let tabs = await Promise.all(tabPromises);
+
+ // Open subdialog in first two tabs
+ let dialogs = [];
+ for (let i = 0; i < 2; i += 1) {
+ let dialogBox = gBrowser.getTabDialogBox(tabs[i].linkedBrowser);
+ dialogBox.open(TEST_DIALOG_PATH);
+ dialogs.push(dialogBox.getTabDialogManager()._topDialog);
+ }
+
+ // Wait for dialogs to be ready
+ await Promise.all([dialogs[0]._dialogReady, dialogs[1]._dialogReady]);
+
+ // Switch to first tab which has dialog
+ await BrowserTestUtils.switchTab(gBrowser, tabs[0]);
+
+ // The textbox in the dialogs content window should be focused
+ let dialogTextbox =
+ dialogs[0]._frame.contentDocument.querySelector("#textbox");
+ is(Services.focus.focusedElement, dialogTextbox, "Dialog textbox is focused");
+
+ // Switch to second tab which has dialog
+ await BrowserTestUtils.switchTab(gBrowser, tabs[1]);
+
+ // The textbox in the dialogs content window should be focused
+ let dialogTextbox2 =
+ dialogs[1]._frame.contentDocument.querySelector("#textbox");
+ is(
+ Services.focus.focusedElement,
+ dialogTextbox2,
+ "Dialog2 textbox is focused"
+ );
+
+ // Switch to third tab which does not have a dialog
+ await BrowserTestUtils.switchTab(gBrowser, tabs[2]);
+
+ // Test that content is focused
+ is(
+ Services.focus.focusedElement,
+ tabs[2].linkedBrowser,
+ "Top level browser is focused"
+ );
+
+ // Cleanup
+ tabs.forEach(tab => {
+ BrowserTestUtils.removeTab(tab);
+ });
+});
+
+/**
+ * Tests that if we're showing multiple tab dialogs they are focused in the
+ * correct order and custom focus handlers are called.
+ */
+add_task(async function test_tabdialogbox_multiple_focus() {
+ await BrowserTestUtils.withNewTab(gBrowser, async browser => {
+ let dialogBox = gBrowser.getTabDialogBox(browser);
+ let dialogAClose = dialogBox.open(
+ TEST_DIALOG_PATH,
+ {},
+ {
+ testCustomFocusHandler: true,
+ }
+ ).closedPromise;
+ let dialogBClose = dialogBox.open(TEST_DIALOG_PATH).closedPromise;
+ let dialogCClose = dialogBox.open(
+ TEST_DIALOG_PATH,
+ {},
+ {
+ testCustomFocusHandler: true,
+ }
+ ).closedPromise;
+
+ let dialogs = dialogBox._tabDialogManager._dialogs;
+ let [dialogA, dialogB, dialogC] = dialogs;
+
+ // Wait until all dialogs are ready
+ await Promise.all(dialogs.map(dialog => dialog._dialogReady));
+
+ // Dialog A's custom focus target should be focused
+ let dialogElementA =
+ dialogA._frame.contentDocument.querySelector("#custom-focus-el");
+ is(
+ Services.focus.focusedElement,
+ dialogElementA,
+ "Dialog A custom focus target is focused"
+ );
+
+ // Close top dialog
+ dialogA.close();
+ await dialogAClose;
+
+ // Dialog B's first focus target should be focused
+ let dialogElementB =
+ dialogB._frame.contentDocument.querySelector("#textbox");
+ is(
+ Services.focus.focusedElement,
+ dialogElementB,
+ "Dialog B default focus target is focused"
+ );
+
+ // close top dialog
+ dialogB.close();
+ await dialogBClose;
+
+ // Dialog C's custom focus target should be focused
+ let dialogElementC =
+ dialogC._frame.contentDocument.querySelector("#custom-focus-el");
+ is(
+ Services.focus.focusedElement,
+ dialogElementC,
+ "Dialog C custom focus target is focused"
+ );
+
+ // Close last dialog
+ dialogC.close();
+ await dialogCClose;
+
+ is(
+ dialogBox._tabDialogManager._dialogs.length,
+ 0,
+ "All dialogs should be closed"
+ );
+ is(
+ Services.focus.focusedElement,
+ browser,
+ "Focus should be back on the browser"
+ );
+ });
+});
+
+/**
+ * Tests that other dialogs are still visible if one dialog is hidden.
+ */
+add_task(async function test_tabdialogbox_tab_switch_hidden() {
+ // Open 2 tabs
+ let tabPromises = [];
+ for (let i = 0; i < 2; i += 1) {
+ tabPromises.push(
+ BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com",
+ true
+ )
+ );
+ }
+
+ // Wait for tabs to be ready
+ let tabs = await Promise.all(tabPromises);
+
+ // Open subdialog in tabs
+ let dialogs = [];
+ let dialogBox, dialogBoxManager, browser;
+ for (let i = 0; i < 2; i += 1) {
+ dialogBox = gBrowser.getTabDialogBox(tabs[i].linkedBrowser);
+ browser = tabs[i].linkedBrowser;
+ dialogBox.open(TEST_DIALOG_PATH);
+ dialogBoxManager = dialogBox.getTabDialogManager();
+ dialogs.push(dialogBoxManager._topDialog);
+ }
+
+ // Wait for dialogs to be ready
+ await Promise.all([dialogs[0]._dialogReady, dialogs[1]._dialogReady]);
+
+ // Hide the top dialog
+ dialogBoxManager.hideDialog(browser);
+
+ ok(
+ BrowserTestUtils.is_hidden(dialogBoxManager._dialogStack),
+ "Dialog stack is hidden"
+ );
+
+ // Switch to first tab
+ await BrowserTestUtils.switchTab(gBrowser, tabs[0]);
+
+ // Check the dialog stack is showing in first tab
+ dialogBoxManager = gBrowser
+ .getTabDialogBox(tabs[0].linkedBrowser)
+ .getTabDialogManager();
+ is(dialogBoxManager._dialogStack.hidden, false, "Dialog stack is showing");
+
+ // Cleanup
+ tabs.forEach(tab => {
+ BrowserTestUtils.removeTab(tab);
+ });
+});
diff --git a/browser/base/content/test/tabdialogs/browser_tabdialogbox_navigation.js b/browser/base/content/test/tabdialogs/browser_tabdialogbox_navigation.js
new file mode 100644
index 0000000000..9e76f37f29
--- /dev/null
+++ b/browser/base/content/test/tabdialogs/browser_tabdialogbox_navigation.js
@@ -0,0 +1,174 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TEST_ROOT_CHROME = getRootDirectory(gTestPath);
+const TEST_DIALOG_PATH = TEST_ROOT_CHROME + "subdialog.xhtml";
+
+/**
+ * Tests that all tab dialogs are closed on navigation.
+ */
+add_task(async function test_tabdialogbox_multiple_close_on_nav() {
+ await BrowserTestUtils.withNewTab(
+ "https://example.com",
+ async function (browser) {
+ // Open two dialogs and wait for them to be ready.
+ let dialogBox = gBrowser.getTabDialogBox(browser);
+ let closedPromises = [
+ dialogBox.open(TEST_DIALOG_PATH).closedPromise,
+ dialogBox.open(TEST_DIALOG_PATH).closedPromise,
+ ];
+
+ let dialogs = dialogBox.getTabDialogManager()._dialogs;
+
+ is(dialogs.length, 2, "Dialog manager has two dialogs.");
+
+ info("Waiting for dialogs to open.");
+ await Promise.all(dialogs.map(dialog => dialog._dialogReady));
+
+ // Navigate to a different page
+ BrowserTestUtils.loadURIString(browser, "https://example.org");
+
+ info("Waiting for dialogs to close.");
+ await closedPromises;
+
+ ok(true, "All open dialogs should close on navigation");
+ }
+ );
+});
+
+/**
+ * Tests dialog close on navigation triggered by web content.
+ */
+add_task(async function test_tabdialogbox_close_on_content_nav() {
+ await BrowserTestUtils.withNewTab(
+ "https://example.com",
+ async function (browser) {
+ // Open a dialog and wait for it to be ready
+ let dialogBox = gBrowser.getTabDialogBox(browser);
+ let { closedPromise } = dialogBox.open(TEST_DIALOG_PATH);
+
+ let dialog = dialogBox.getTabDialogManager()._topDialog;
+
+ is(
+ dialogBox.getTabDialogManager()._dialogs.length,
+ 1,
+ "Dialog manager has one dialog."
+ );
+
+ info("Waiting for dialog to open.");
+ await dialog._dialogReady;
+
+ // Trigger a same origin navigation by the content
+ await ContentTask.spawn(browser, {}, () => {
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ content.location = "http://example.com/1";
+ });
+
+ info("Waiting for dialog to close.");
+ await closedPromise;
+ ok(
+ true,
+ "Dialog should close for same origin navigation by the content."
+ );
+
+ // Open a new dialog
+ closedPromise = dialogBox.open(TEST_DIALOG_PATH, {
+ keepOpenSameOriginNav: true,
+ }).closedPromise;
+
+ info("Waiting for dialog to open.");
+ await dialog._dialogReady;
+
+ SimpleTest.requestFlakyTimeout("Waiting to ensure dialog does not close");
+ let race = Promise.race([
+ closedPromise,
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ new Promise(resolve => setTimeout(() => resolve("success"), 1000)),
+ ]);
+
+ // Trigger a same origin navigation by the content
+ await ContentTask.spawn(browser, {}, () => {
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ content.location = "http://example.com/test";
+ });
+
+ is(
+ await race,
+ "success",
+ "Dialog should not close for same origin navigation by the content."
+ );
+
+ // Trigger a cross origin navigation by the content
+ await ContentTask.spawn(browser, {}, () => {
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ content.location = "http://example.org/test2";
+ });
+
+ info("Waiting for dialog to close");
+ await closedPromise;
+
+ ok(
+ true,
+ "Dialog should close for cross origin navigation by the content."
+ );
+ }
+ );
+});
+
+/**
+ * Hides a dialog stack and tests that behavior doesn't change. Ensures
+ * navigation triggered by web content still closes all dialogs.
+ */
+add_task(async function test_tabdialogbox_hide() {
+ await BrowserTestUtils.withNewTab(
+ "https://example.com",
+ async function (browser) {
+ // Open a dialog and wait for it to be ready
+ let dialogBox = gBrowser.getTabDialogBox(browser);
+ let dialogBoxManager = dialogBox.getTabDialogManager();
+ let closedPromises = [
+ dialogBox.open(TEST_DIALOG_PATH).closedPromise,
+ dialogBox.open(TEST_DIALOG_PATH).closedPromise,
+ ];
+
+ let dialogs = dialogBox.getTabDialogManager()._dialogs;
+
+ is(
+ dialogBox.getTabDialogManager()._dialogs.length,
+ 2,
+ "Dialog manager has two dialogs."
+ );
+
+ info("Waiting for dialogs to open.");
+ await Promise.all(dialogs.map(dialog => dialog._dialogReady));
+
+ ok(
+ !BrowserTestUtils.is_hidden(dialogBoxManager._dialogStack),
+ "Dialog stack is showing"
+ );
+
+ dialogBoxManager.hideDialog(browser);
+
+ is(
+ dialogBoxManager._dialogs.length,
+ 2,
+ "Dialog manager still has two dialogs."
+ );
+
+ ok(
+ BrowserTestUtils.is_hidden(dialogBoxManager._dialogStack),
+ "Dialog stack is hidden"
+ );
+
+ // Navigate to a different page
+ BrowserTestUtils.loadURIString(browser, "https://example.org");
+
+ info("Waiting for dialogs to close.");
+ await closedPromises;
+
+ ok(true, "All open dialogs should still close on navigation");
+ }
+ );
+});
diff --git a/browser/base/content/test/tabdialogs/loadDelayedReply.sjs b/browser/base/content/test/tabdialogs/loadDelayedReply.sjs
new file mode 100644
index 0000000000..cf046967bf
--- /dev/null
+++ b/browser/base/content/test/tabdialogs/loadDelayedReply.sjs
@@ -0,0 +1,22 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+function handleRequest(request, response) {
+ response.processAsync();
+ if (request.method === "POST") {
+ getObjectState("wait", queryResponse => {
+ if (!queryResponse) {
+ throw new Error("Wrong call order");
+ }
+ queryResponse.finish();
+
+ response.setStatusLine(request.httpVersion, 200);
+ response.write("OK");
+ response.finish();
+ });
+ return;
+ }
+ response.setStatusLine(request.httpVersion, 200);
+ response.write("OK");
+ setObjectState("wait", response);
+}
diff --git a/browser/base/content/test/tabdialogs/subdialog.xhtml b/browser/base/content/test/tabdialogs/subdialog.xhtml
new file mode 100644
index 0000000000..03b2b76d49
--- /dev/null
+++ b/browser/base/content/test/tabdialogs/subdialog.xhtml
@@ -0,0 +1,46 @@
+<?xml version="1.0"?>
+
+<!-- Any copyright is dedicated to the Public Domain.
+ - http://creativecommons.org/publicdomain/zero/1.0/ -->
+
+<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?>
+
+<window xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ title="Sample sub-dialog">
+<dialog id="subDialog">
+ <script>
+ document.addEventListener("dialogaccept", acceptSubdialog);
+ function acceptSubdialog() {
+ window.arguments[0].acceptCount++;
+ }
+ document.addEventListener("DOMContentLoaded", () => {
+ if (!window.arguments) {
+ return;
+ }
+ let [options] = window.arguments;
+ if (options?.testCustomFocusHandler) {
+ document.subDialogSetDefaultFocus = () => {
+ document.getElementById("custom-focus-el").focus();
+ }
+ }
+ }, {once: true})
+ </script>
+
+ <description id="desc">A sample sub-dialog for testing</description>
+
+ <html:input id="textbox" value="Default text" />
+
+ <html:select id="select">
+ <html:option>Foo</html:option>
+ <html:option>Bar</html:option>
+ </html:select>
+
+ <html:input id="custom-focus-el" value="Custom Focus Test" />
+
+ <separator class="thin"/>
+
+ <button oncommand="window.close();" label="Close" />
+
+</dialog>
+</window>
diff --git a/browser/base/content/test/tabdialogs/test_page.html b/browser/base/content/test/tabdialogs/test_page.html
new file mode 100644
index 0000000000..c5f17062cf
--- /dev/null
+++ b/browser/base/content/test/tabdialogs/test_page.html
@@ -0,0 +1,10 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>TabDialogBox Content Modal Test page</title>
+</head>
+<body onload='alert("Hi");'>
+ <h1>TabDialogBox Content Modal</h1>
+</body>
+</html>
diff --git a/browser/base/content/test/tabs/204.sjs b/browser/base/content/test/tabs/204.sjs
new file mode 100644
index 0000000000..22b1d300e3
--- /dev/null
+++ b/browser/base/content/test/tabs/204.sjs
@@ -0,0 +1,3 @@
+function handleRequest(request, response) {
+ response.setStatusLine(request.httpVersion, 204, "No Content");
+}
diff --git a/browser/base/content/test/tabs/blank.html b/browser/base/content/test/tabs/blank.html
new file mode 100644
index 0000000000..bcc2e389b8
--- /dev/null
+++ b/browser/base/content/test/tabs/blank.html
@@ -0,0 +1,2 @@
+<!doctype html>
+This page intentionally left blank.
diff --git a/browser/base/content/test/tabs/browser.ini b/browser/base/content/test/tabs/browser.ini
new file mode 100644
index 0000000000..68cfe44705
--- /dev/null
+++ b/browser/base/content/test/tabs/browser.ini
@@ -0,0 +1,211 @@
+[DEFAULT]
+support-files =
+ head.js
+ dummy_page.html
+ ../general/audio.ogg
+ file_mediaPlayback.html
+ test_process_flags_chrome.html
+ helper_origin_attrs_testing.js
+ file_about_srcdoc.html
+
+[browser_addAdjacentNewTab.js]
+[browser_addTab_index.js]
+[browser_adoptTab_failure.js]
+[browser_allow_process_switches_despite_related_browser.js]
+[browser_audioTabIcon.js]
+tags = audiochannel
+[browser_bfcache_exemption_about_pages.js]
+skip-if = !fission
+[browser_bug580956.js]
+[browser_bug_1387976_restore_lazy_tab_browser_muted_state.js]
+[browser_close_during_beforeunload.js]
+https_first_disabled = true
+[browser_close_tab_by_dblclick.js]
+[browser_contextmenu_openlink_after_tabnavigated.js]
+https_first_disabled = true
+skip-if =
+ verify && debug && os == "linux"
+support-files =
+ test_bug1358314.html
+[browser_dont_process_switch_204.js]
+support-files =
+ blank.html
+ 204.sjs
+[browser_e10s_about_page_triggeringprincipal.js]
+https_first_disabled = true
+skip-if = verify
+support-files =
+ file_about_child.html
+ file_about_parent.html
+[browser_e10s_about_process.js]
+[browser_e10s_chrome_process.js]
+skip-if = debug # Bug 1444565, Bug 1457887
+[browser_e10s_javascript.js]
+[browser_e10s_mozillaweb_process.js]
+[browser_e10s_switchbrowser.js]
+[browser_file_to_http_named_popup.js]
+[browser_file_to_http_script_closable.js]
+support-files = tab_that_closes.html
+[browser_hiddentab_contextmenu.js]
+[browser_lazy_tab_browser_events.js]
+[browser_link_in_tab_title_and_url_prefilled_blank_page.js]
+support-files =
+ common_link_in_tab_title_and_url_prefilled.js
+ link_in_tab_title_and_url_prefilled.html
+ request-timeout.sjs
+ wait-a-bit.sjs
+[browser_link_in_tab_title_and_url_prefilled_new_window.js]
+support-files =
+ common_link_in_tab_title_and_url_prefilled.js
+ link_in_tab_title_and_url_prefilled.html
+ request-timeout.sjs
+ wait-a-bit.sjs
+[browser_link_in_tab_title_and_url_prefilled_normal_page_blank_target.js]
+support-files =
+ common_link_in_tab_title_and_url_prefilled.js
+ link_in_tab_title_and_url_prefilled.html
+ request-timeout.sjs
+ wait-a-bit.sjs
+[browser_link_in_tab_title_and_url_prefilled_normal_page_by_script.js]
+support-files =
+ common_link_in_tab_title_and_url_prefilled.js
+ link_in_tab_title_and_url_prefilled.html
+ request-timeout.sjs
+ wait-a-bit.sjs
+[browser_link_in_tab_title_and_url_prefilled_normal_page_no_target.js]
+support-files =
+ common_link_in_tab_title_and_url_prefilled.js
+ link_in_tab_title_and_url_prefilled.html
+ request-timeout.sjs
+ wait-a-bit.sjs
+[browser_link_in_tab_title_and_url_prefilled_normal_page_other_target.js]
+support-files =
+ common_link_in_tab_title_and_url_prefilled.js
+ link_in_tab_title_and_url_prefilled.html
+ request-timeout.sjs
+ wait-a-bit.sjs
+[browser_long_data_url_label_truncation.js]
+[browser_middle_click_new_tab_button_loads_clipboard.js]
+[browser_multiselect_tabs_active_tab_selected_by_default.js]
+[browser_multiselect_tabs_bookmark.js]
+[browser_multiselect_tabs_clear_selection_when_tab_switch.js]
+[browser_multiselect_tabs_close.js]
+[browser_multiselect_tabs_close_other_tabs.js]
+[browser_multiselect_tabs_close_tabs_to_the_left.js]
+[browser_multiselect_tabs_close_tabs_to_the_right.js]
+[browser_multiselect_tabs_close_using_shortcuts.js]
+[browser_multiselect_tabs_copy_through_drag_and_drop.js]
+[browser_multiselect_tabs_drag_to_bookmarks_toolbar.js]
+[browser_multiselect_tabs_duplicate.js]
+[browser_multiselect_tabs_event.js]
+[browser_multiselect_tabs_move.js]
+[browser_multiselect_tabs_move_to_another_window_drag.js]
+[browser_multiselect_tabs_move_to_new_window_contextmenu.js]
+https_first_disabled = true
+[browser_multiselect_tabs_mute_unmute.js]
+[browser_multiselect_tabs_open_related.js]
+[browser_multiselect_tabs_pin_unpin.js]
+[browser_multiselect_tabs_play.js]
+[browser_multiselect_tabs_reload.js]
+[browser_multiselect_tabs_reopen_in_container.js]
+[browser_multiselect_tabs_reorder.js]
+[browser_multiselect_tabs_using_Ctrl.js]
+[browser_multiselect_tabs_using_Shift.js]
+[browser_multiselect_tabs_using_Shift_and_Ctrl.js]
+[browser_multiselect_tabs_using_keyboard.js]
+skip-if =
+ os == "mac" # Skipped because macOS keyboard support requires changing system settings
+[browser_multiselect_tabs_using_selectedTabs.js]
+[browser_navigatePinnedTab.js]
+https_first_disabled = true
+[browser_navigate_home_focuses_addressbar.js]
+[browser_navigate_through_urls_origin_attributes.js]
+skip-if =
+ verify && os == "mac"
+[browser_new_file_whitelisted_http_tab.js]
+https_first_disabled = true
+[browser_new_tab_in_privilegedabout_process_pref.js]
+https_first_disabled = true
+skip-if =
+ os == "linux" && debug # Bug 1581500.
+[browser_new_tab_insert_position.js]
+https_first_disabled = true
+support-files = file_new_tab_page.html
+[browser_new_tab_url.js]
+support-files = file_new_tab_page.html
+[browser_newwindow_tabstrip_overflow.js]
+[browser_open_newtab_start_observer_notification.js]
+[browser_opened_file_tab_navigated_to_web.js]
+https_first_disabled = true
+[browser_origin_attrs_in_remote_type.js]
+[browser_origin_attrs_rel.js]
+skip-if =
+ verify && os == "mac"
+support-files = file_rel_opener_noopener.html
+[browser_originalURI.js]
+support-files =
+ page_with_iframe.html
+ redirect_via_header.html
+ redirect_via_header.html^headers^
+ redirect_via_meta_tag.html
+[browser_overflowScroll.js]
+skip-if =
+ win10_2004 # Bug 1775648
+ win11_2009 # Bug 1797751
+[browser_paste_event_at_middle_click_on_link.js]
+support-files = file_anchor_elements.html
+[browser_pinnedTabs.js]
+[browser_pinnedTabs_clickOpen.js]
+[browser_pinnedTabs_closeByKeyboard.js]
+[browser_positional_attributes.js]
+skip-if =
+ verify && os == "win"
+ verify && os == "mac"
+[browser_preloadedBrowser_zoom.js]
+[browser_privilegedmozilla_process_pref.js]
+https_first_disabled = true
+[browser_progress_keyword_search_handling.js]
+https_first_disabled = true
+[browser_relatedTabs_reset.js]
+[browser_reload_deleted_file.js]
+skip-if =
+ debug && os == "mac"
+ debug && os == "linux" #Bug 1421183, disabled on Linux/OSX for leaked windows
+[browser_removeTabsToTheEnd.js]
+[browser_removeTabsToTheStart.js]
+[browser_removeTabs_order.js]
+[browser_removeTabs_skipPermitUnload.js]
+[browser_replacewithwindow_commands.js]
+[browser_switch_by_scrolling.js]
+[browser_tabCloseProbes.js]
+[browser_tabCloseSpacer.js]
+skip-if =
+ os == "linux"
+ os == "win" # Bug 1616418
+ os == "mac" #Bug 1549985
+[browser_tabContextMenu_keyboard.js]
+[browser_tabReorder.js]
+[browser_tabReorder_overflow.js]
+[browser_tabSpinnerProbe.js]
+[browser_tabSuccessors.js]
+[browser_tab_a11y_description.js]
+[browser_tab_label_during_reload.js]
+[browser_tab_label_picture_in_picture.js]
+[browser_tab_manager_close.js]
+[browser_tab_manager_drag.js]
+[browser_tab_manager_keyboard_access.js]
+[browser_tab_manager_visibility.js]
+[browser_tab_move_to_new_window_reload.js]
+[browser_tab_play.js]
+[browser_tab_tooltips.js]
+[browser_tabswitch_contextmenu.js]
+[browser_tabswitch_select.js]
+support-files = open_window_in_new_tab.html
+[browser_tabswitch_updatecommands.js]
+[browser_tabswitch_window_focus.js]
+[browser_undo_close_tabs.js]
+skip-if = true #bug 1642084
+[browser_undo_close_tabs_at_start.js]
+[browser_viewsource_of_data_URI_in_file_process.js]
+[browser_visibleTabs_bookmarkAllTabs.js]
+[browser_visibleTabs_contextMenu.js]
diff --git a/browser/base/content/test/tabs/browser_addAdjacentNewTab.js b/browser/base/content/test/tabs/browser_addAdjacentNewTab.js
new file mode 100644
index 0000000000..c9b4b45ccc
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_addAdjacentNewTab.js
@@ -0,0 +1,55 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+add_task(async function () {
+ await SpecialPowers.pushPrefEnv({
+ // Ensure we can wait for about:newtab to load.
+ set: [["browser.newtab.preload", false]],
+ });
+
+ const tab1 = await addTab();
+ const tab2 = await addTab();
+ const tab3 = await addTab();
+
+ const menuItemOpenANewTab = document.getElementById("context_openANewTab");
+
+ await BrowserTestUtils.switchTab(gBrowser, tab2);
+
+ is(tab1._tPos, 1, "First tab");
+ is(tab2._tPos, 2, "Second tab");
+ is(tab3._tPos, 3, "Third tab");
+
+ updateTabContextMenu(tab2);
+ is(menuItemOpenANewTab.hidden, false, "Open a new Tab is visible");
+
+ const newTabPromise = BrowserTestUtils.waitForNewTab(gBrowser);
+
+ // Open the tab context menu.
+ const contextMenu = document.getElementById("tabContextMenu");
+ // The TabContextMenu initializes its strings only on a focus or mouseover event.
+ // Calls focus event on the TabContextMenu early in the test.
+ gBrowser.selectedTab.focus();
+ const popupShownPromise = BrowserTestUtils.waitForEvent(
+ contextMenu,
+ "popupshown"
+ );
+ EventUtils.synthesizeMouseAtCenter(gBrowser.selectedTab, {
+ type: "contextmenu",
+ button: 2,
+ });
+ await popupShownPromise;
+
+ contextMenu.activateItem(menuItemOpenANewTab);
+
+ let newTab = await newTabPromise;
+
+ is(tab1._tPos, 1, "First tab");
+ is(tab2._tPos, 2, "Second tab");
+ is(newTab._tPos, 3, "Third tab");
+ is(tab3._tPos, 4, "Fourth tab");
+
+ BrowserTestUtils.removeTab(tab1);
+ BrowserTestUtils.removeTab(tab2);
+ BrowserTestUtils.removeTab(tab3);
+ BrowserTestUtils.removeTab(newTab);
+});
diff --git a/browser/base/content/test/tabs/browser_addTab_index.js b/browser/base/content/test/tabs/browser_addTab_index.js
new file mode 100644
index 0000000000..abfc0c213e
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_addTab_index.js
@@ -0,0 +1,8 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+function test() {
+ let tab = gBrowser.addTrustedTab("about:blank", { index: 10 });
+ is(tab._tPos, 1, "added tab index should be 1");
+ gBrowser.removeTab(tab);
+}
diff --git a/browser/base/content/test/tabs/browser_adoptTab_failure.js b/browser/base/content/test/tabs/browser_adoptTab_failure.js
new file mode 100644
index 0000000000..f20f4c0c56
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_adoptTab_failure.js
@@ -0,0 +1,107 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// 'adoptTab' aborts when swapBrowsersAndCloseOther returns false.
+// That's usually a bug, but this function forces it to happen in order to check
+// that callers will behave as good as possible when it happens accidentally.
+function makeAdoptTabFailOnceFor(gBrowser, tab) {
+ const original = gBrowser.swapBrowsersAndCloseOther;
+ gBrowser.swapBrowsersAndCloseOther = function (aOurTab, aOtherTab) {
+ if (tab !== aOtherTab) {
+ return original.call(gBrowser, aOurTab, aOtherTab);
+ }
+ gBrowser.swapBrowsersAndCloseOther = original;
+ return false;
+ };
+}
+
+add_task(async function test_adoptTab() {
+ const tab = await addTab();
+ const win2 = await BrowserTestUtils.openNewBrowserWindow();
+ const gBrowser2 = win2.gBrowser;
+
+ makeAdoptTabFailOnceFor(gBrowser2, tab);
+ is(gBrowser2.adoptTab(tab), null, "adoptTab returns null in case of failure");
+ ok(gBrowser2.adoptTab(tab), "adoptTab returns new tab in case of success");
+
+ await BrowserTestUtils.closeWindow(win2);
+});
+
+add_task(async function test_replaceTabsWithWindow() {
+ const nonAdoptableTab = await addTab("data:text/plain,nonAdoptableTab");
+ const auxiliaryTab = await addTab("data:text/plain,auxiliaryTab");
+ const selectedTab = await addTab("data:text/plain,selectedTab");
+ gBrowser.selectedTabs = [selectedTab, nonAdoptableTab, auxiliaryTab];
+
+ const windowOpenedPromise = BrowserTestUtils.waitForNewWindow();
+ const win2 = gBrowser.replaceTabsWithWindow(selectedTab);
+ await BrowserTestUtils.waitForEvent(win2, "DOMContentLoaded");
+ const gBrowser2 = win2.gBrowser;
+ makeAdoptTabFailOnceFor(gBrowser2, nonAdoptableTab);
+ await windowOpenedPromise;
+
+ // nonAdoptableTab couldn't be adopted, but the new window should have adopted
+ // the other 2 tabs, and they should be in the proper order.
+ is(gBrowser2.tabs.length, 2);
+ is(gBrowser2.tabs[0].label, "data:text/plain,auxiliaryTab");
+ is(gBrowser2.tabs[1].label, "data:text/plain,selectedTab");
+
+ gBrowser.removeTab(nonAdoptableTab);
+ await BrowserTestUtils.closeWindow(win2);
+});
+
+add_task(async function test_on_drop() {
+ const nonAdoptableTab = await addTab("data:text/html,<title>nonAdoptableTab");
+ const auxiliaryTab = await addTab("data:text/html,<title>auxiliaryTab");
+ const selectedTab = await addTab("data:text/html,<title>selectedTab");
+ gBrowser.selectedTabs = [selectedTab, nonAdoptableTab, auxiliaryTab];
+
+ const win2 = await BrowserTestUtils.openNewBrowserWindow();
+ const gBrowser2 = win2.gBrowser;
+ makeAdoptTabFailOnceFor(gBrowser2, nonAdoptableTab);
+ const initialTab = gBrowser2.tabs[0];
+ await dragAndDrop(selectedTab, initialTab, false, win2, false);
+
+ // nonAdoptableTab couldn't be adopted, but the new window should have adopted
+ // the other 2 tabs, and they should be in the right position.
+ is(gBrowser2.tabs.length, 3, "There are 3 tabs");
+ is(gBrowser2.tabs[0].label, "auxiliaryTab", "auxiliaryTab became tab 0");
+ is(gBrowser2.tabs[1].label, "selectedTab", "selectedTab became tab 1");
+ is(gBrowser2.tabs[2], initialTab, "initialTab became tab 2");
+ is(gBrowser2.selectedTab, gBrowser2.tabs[1], "Tab 1 is selected");
+ is(gBrowser2.multiSelectedTabsCount, 2, "Three multiselected tabs");
+ ok(gBrowser2.tabs[0].multiselected, "Tab 0 is multiselected");
+ ok(gBrowser2.tabs[1].multiselected, "Tab 1 is multiselected");
+ ok(!gBrowser2.tabs[2].multiselected, "Tab 2 is not multiselected");
+
+ gBrowser.removeTab(nonAdoptableTab);
+ await BrowserTestUtils.closeWindow(win2);
+});
+
+add_task(async function test_switchToTabHavingURI() {
+ const nonAdoptableTab = await addTab("data:text/plain,nonAdoptableTab");
+ const uri = nonAdoptableTab.linkedBrowser.currentURI;
+ const win2 = await BrowserTestUtils.openNewBrowserWindow();
+ const gBrowser2 = win2.gBrowser;
+
+ is(nonAdoptableTab.closing, false);
+ is(nonAdoptableTab.selected, false);
+ is(gBrowser2.tabs.length, 1);
+
+ makeAdoptTabFailOnceFor(gBrowser2, nonAdoptableTab);
+ win2.switchToTabHavingURI(uri, false, { adoptIntoActiveWindow: true });
+
+ is(nonAdoptableTab.closing, false);
+ is(nonAdoptableTab.selected, true);
+ is(gBrowser2.tabs.length, 1);
+
+ win2.switchToTabHavingURI(uri, false, { adoptIntoActiveWindow: true });
+
+ is(nonAdoptableTab.closing, true);
+ is(nonAdoptableTab.selected, false);
+ is(gBrowser2.tabs.length, 2);
+
+ await BrowserTestUtils.closeWindow(win2);
+});
diff --git a/browser/base/content/test/tabs/browser_allow_process_switches_despite_related_browser.js b/browser/base/content/test/tabs/browser_allow_process_switches_despite_related_browser.js
new file mode 100644
index 0000000000..f1b4a98021
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_allow_process_switches_despite_related_browser.js
@@ -0,0 +1,40 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+
+const DUMMY_FILE = "dummy_page.html";
+const DATA_URI = "data:text/html,Hi";
+const DATA_URI_SOURCE = "view-source:" + DATA_URI;
+
+// Test for bug 1328829.
+add_task(async function () {
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, DATA_URI);
+ registerCleanupFunction(async function () {
+ BrowserTestUtils.removeTab(tab);
+ });
+
+ let promiseTab = BrowserTestUtils.waitForNewTab(gBrowser, DATA_URI_SOURCE);
+ BrowserViewSource(tab.linkedBrowser);
+ let viewSourceTab = await promiseTab;
+ registerCleanupFunction(async function () {
+ BrowserTestUtils.removeTab(viewSourceTab);
+ });
+
+ let dummyPage = getChromeDir(getResolvedURI(gTestPath));
+ dummyPage.append(DUMMY_FILE);
+ dummyPage.normalize();
+ const uriString = Services.io.newFileURI(dummyPage).spec;
+
+ let viewSourceBrowser = viewSourceTab.linkedBrowser;
+ let promiseLoad = BrowserTestUtils.browserLoaded(
+ viewSourceBrowser,
+ false,
+ uriString
+ );
+ BrowserTestUtils.loadURIString(viewSourceBrowser, uriString);
+ let href = await promiseLoad;
+ is(
+ href,
+ uriString,
+ "Check file:// URI loads in a browser that was previously for view-source"
+ );
+});
diff --git a/browser/base/content/test/tabs/browser_audioTabIcon.js b/browser/base/content/test/tabs/browser_audioTabIcon.js
new file mode 100644
index 0000000000..3e3db58f06
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_audioTabIcon.js
@@ -0,0 +1,676 @@
+/* eslint-disable mozilla/no-arbitrary-setTimeout */
+const PAGE =
+ "https://example.com/browser/browser/base/content/test/tabs/file_mediaPlayback.html";
+const TABATTR_REMOVAL_PREFNAME = "browser.tabs.delayHidingAudioPlayingIconMS";
+const INITIAL_TABATTR_REMOVAL_DELAY_MS = Services.prefs.getIntPref(
+ TABATTR_REMOVAL_PREFNAME
+);
+
+async function pause(tab, options) {
+ let extendedDelay = options && options.extendedDelay;
+ if (extendedDelay) {
+ // Use 10s to remove possibility of race condition with attr removal.
+ Services.prefs.setIntPref(TABATTR_REMOVAL_PREFNAME, 10000);
+ }
+
+ try {
+ let browser = tab.linkedBrowser;
+ let awaitDOMAudioPlaybackStopped;
+ if (!browser.audioMuted) {
+ awaitDOMAudioPlaybackStopped = BrowserTestUtils.waitForEvent(
+ browser,
+ "DOMAudioPlaybackStopped",
+ "DOMAudioPlaybackStopped event should get fired after pause"
+ );
+ }
+ await SpecialPowers.spawn(browser, [], async function () {
+ let audio = content.document.querySelector("audio");
+ audio.pause();
+ });
+
+ // If the tab has already be muted, it means the tab won't have soundplaying,
+ // so we don't need to check this attribute.
+ if (browser.audioMuted) {
+ return;
+ }
+
+ if (extendedDelay) {
+ ok(
+ tab.hasAttribute("soundplaying"),
+ "The tab should still have the soundplaying attribute immediately after pausing"
+ );
+
+ await awaitDOMAudioPlaybackStopped;
+ ok(
+ tab.hasAttribute("soundplaying"),
+ "The tab should still have the soundplaying attribute immediately after DOMAudioPlaybackStopped"
+ );
+ }
+
+ await wait_for_tab_playing_event(tab, false);
+ ok(
+ !tab.hasAttribute("soundplaying"),
+ "The tab should not have the soundplaying attribute after the timeout has resolved"
+ );
+ } finally {
+ // Make sure other tests don't timeout if an exception gets thrown above.
+ // Need to use setIntPref instead of clearUserPref because
+ // testing/profiles/common/user.js overrides the default value to help this and
+ // other tests run faster.
+ Services.prefs.setIntPref(
+ TABATTR_REMOVAL_PREFNAME,
+ INITIAL_TABATTR_REMOVAL_DELAY_MS
+ );
+ }
+}
+
+async function hide_tab(tab) {
+ let tabHidden = BrowserTestUtils.waitForEvent(tab, "TabHide");
+ gBrowser.hideTab(tab);
+ return tabHidden;
+}
+
+async function show_tab(tab) {
+ let tabShown = BrowserTestUtils.waitForEvent(tab, "TabShow");
+ gBrowser.showTab(tab);
+ return tabShown;
+}
+
+async function test_tooltip(icon, expectedTooltip, isActiveTab, tab) {
+ let tooltip = document.getElementById("tabbrowser-tab-tooltip");
+
+ let tabContent = tab.querySelector(".tab-content");
+ await hover_icon(tabContent, tooltip);
+
+ await hover_icon(icon, tooltip);
+ if (isActiveTab) {
+ // The active tab should have the keybinding shortcut in the tooltip.
+ // We check this by ensuring that the strings are not equal but the expected
+ // message appears in the beginning.
+ isnot(
+ tooltip.getAttribute("label"),
+ expectedTooltip,
+ "Tooltips should not be equal"
+ );
+ is(
+ tooltip.getAttribute("label").indexOf(expectedTooltip),
+ 0,
+ "Correct tooltip expected"
+ );
+ } else {
+ is(
+ tooltip.getAttribute("label"),
+ expectedTooltip,
+ "Tooltips should not be equal"
+ );
+ }
+ leave_icon(icon);
+}
+
+function get_tab_state(tab) {
+ return JSON.parse(SessionStore.getTabState(tab));
+}
+
+async function test_muting_using_menu(tab, expectMuted) {
+ // Show the popup menu
+ let contextMenu = document.getElementById("tabContextMenu");
+ let popupShownPromise = BrowserTestUtils.waitForEvent(
+ contextMenu,
+ "popupshown"
+ );
+ EventUtils.synthesizeMouseAtCenter(tab, { type: "contextmenu", button: 2 });
+ await popupShownPromise;
+
+ // Check the menu
+ let expectedLabel = expectMuted ? "Unmute Tab" : "Mute Tab";
+ let expectedAccessKey = expectMuted ? "m" : "M";
+ let toggleMute = document.getElementById("context_toggleMuteTab");
+ is(toggleMute.label, expectedLabel, "Correct label expected");
+ is(toggleMute.accessKey, expectedAccessKey, "Correct accessKey expected");
+
+ is(
+ toggleMute.hasAttribute("muted"),
+ expectMuted,
+ "Should have the correct state for the muted attribute"
+ );
+ ok(
+ !toggleMute.hasAttribute("soundplaying"),
+ "Should not have the soundplaying attribute"
+ );
+
+ await play(tab);
+
+ is(
+ toggleMute.hasAttribute("muted"),
+ expectMuted,
+ "Should have the correct state for the muted attribute"
+ );
+ is(
+ !toggleMute.hasAttribute("soundplaying"),
+ expectMuted,
+ "The value of soundplaying attribute is incorrect"
+ );
+
+ await pause(tab);
+
+ is(
+ toggleMute.hasAttribute("muted"),
+ expectMuted,
+ "Should have the correct state for the muted attribute"
+ );
+ ok(
+ !toggleMute.hasAttribute("soundplaying"),
+ "Should not have the soundplaying attribute"
+ );
+
+ // Click on the menu and wait for the tab to be muted.
+ let mutedPromise = get_wait_for_mute_promise(tab, !expectMuted);
+ let popupHiddenPromise = BrowserTestUtils.waitForEvent(
+ contextMenu,
+ "popuphidden"
+ );
+ contextMenu.activateItem(toggleMute);
+ await popupHiddenPromise;
+ await mutedPromise;
+}
+
+async function test_playing_icon_on_tab(tab, browser, isPinned) {
+ let icon = isPinned ? tab.overlayIcon : tab.overlayIcon;
+ let isActiveTab = tab === gBrowser.selectedTab;
+
+ await play(tab);
+
+ await test_tooltip(icon, "Mute tab", isActiveTab, tab);
+
+ ok(
+ !("muted" in get_tab_state(tab)),
+ "No muted attribute should be persisted"
+ );
+ ok(
+ !("muteReason" in get_tab_state(tab)),
+ "No muteReason property should be persisted"
+ );
+
+ await test_mute_tab(tab, icon, true);
+
+ ok("muted" in get_tab_state(tab), "Muted attribute should be persisted");
+ ok(
+ "muteReason" in get_tab_state(tab),
+ "muteReason property should be persisted"
+ );
+
+ await test_tooltip(icon, "Unmute tab", isActiveTab, tab);
+
+ await test_mute_tab(tab, icon, false);
+
+ ok(
+ !("muted" in get_tab_state(tab)),
+ "No muted attribute should be persisted"
+ );
+ ok(
+ !("muteReason" in get_tab_state(tab)),
+ "No muteReason property should be persisted"
+ );
+
+ await test_tooltip(icon, "Mute tab", isActiveTab, tab);
+
+ await test_mute_tab(tab, icon, true);
+
+ await pause(tab);
+
+ ok(
+ tab.hasAttribute("muted") && !tab.hasAttribute("soundplaying"),
+ "Tab should still be muted but not playing"
+ );
+ ok(
+ tab.muted && !tab.soundPlaying,
+ "Tab should still be muted but not playing"
+ );
+
+ await test_tooltip(icon, "Unmute tab", isActiveTab, tab);
+
+ await test_mute_tab(tab, icon, false);
+
+ ok(
+ !tab.hasAttribute("muted") && !tab.hasAttribute("soundplaying"),
+ "Tab should not be be muted or playing"
+ );
+ ok(!tab.muted && !tab.soundPlaying, "Tab should not be be muted or playing");
+
+ // Make sure it's possible to mute using the context menu.
+ await test_muting_using_menu(tab, false);
+
+ // Make sure it's possible to unmute using the context menu.
+ await test_muting_using_menu(tab, true);
+}
+
+async function test_playing_icon_on_hidden_tab(tab) {
+ let oldSelectedTab = gBrowser.selectedTab;
+ let otherTabs = [
+ await BrowserTestUtils.openNewForegroundTab(gBrowser, PAGE, true, true),
+ await BrowserTestUtils.openNewForegroundTab(gBrowser, PAGE, true, true),
+ ];
+ let tabContainer = tab.container;
+ let alltabsButton = document.getElementById("alltabs-button");
+ let alltabsBadge = alltabsButton.badgeLabel;
+
+ function assertIconShowing() {
+ is(
+ getComputedStyle(alltabsBadge).backgroundImage,
+ 'url("chrome://browser/skin/tabbrowser/tab-audio-playing-small.svg")',
+ "The audio playing icon is shown"
+ );
+ is(
+ tabContainer.getAttribute("hiddensoundplaying"),
+ "true",
+ "There are hidden audio tabs"
+ );
+ }
+
+ function assertIconHidden() {
+ is(
+ getComputedStyle(alltabsBadge).backgroundImage,
+ "none",
+ "The audio playing icon is hidden"
+ );
+ ok(
+ !tabContainer.hasAttribute("hiddensoundplaying"),
+ "There are no hidden audio tabs"
+ );
+ }
+
+ // Keep the passed in tab selected.
+ gBrowser.selectedTab = tab;
+
+ // Play sound in the other two (visible) tabs.
+ await play(otherTabs[0]);
+ await play(otherTabs[1]);
+ assertIconHidden();
+
+ // Hide one of the noisy tabs, we see the icon.
+ await hide_tab(otherTabs[0]);
+ assertIconShowing();
+
+ // Hiding the other tab keeps the icon.
+ await hide_tab(otherTabs[1]);
+ assertIconShowing();
+
+ // Pausing both tabs will hide the icon.
+ await pause(otherTabs[0]);
+ assertIconShowing();
+ await pause(otherTabs[1]);
+ assertIconHidden();
+
+ // The icon returns when audio starts again.
+ await play(otherTabs[0]);
+ await play(otherTabs[1]);
+ assertIconShowing();
+
+ // There is still an icon after hiding one tab.
+ await show_tab(otherTabs[0]);
+ assertIconShowing();
+
+ // The icon is hidden when both of the tabs are shown.
+ await show_tab(otherTabs[1]);
+ assertIconHidden();
+
+ await BrowserTestUtils.removeTab(otherTabs[0]);
+ await BrowserTestUtils.removeTab(otherTabs[1]);
+
+ // Make sure we didn't change the selected tab.
+ gBrowser.selectedTab = oldSelectedTab;
+}
+
+async function test_swapped_browser_while_playing(oldTab, newBrowser) {
+ // The tab was muted so it won't have soundplaying attribute even it's playing.
+ ok(
+ oldTab.hasAttribute("muted"),
+ "Expected the correct muted attribute on the old tab"
+ );
+ is(
+ oldTab.muteReason,
+ null,
+ "Expected the correct muteReason attribute on the old tab"
+ );
+ ok(
+ !oldTab.hasAttribute("soundplaying"),
+ "Expected the correct soundplaying attribute on the old tab"
+ );
+
+ let newTab = gBrowser.getTabForBrowser(newBrowser);
+ let AttrChangePromise = BrowserTestUtils.waitForEvent(
+ newTab,
+ "TabAttrModified",
+ false,
+ event => {
+ return event.detail.changed.includes("muted");
+ }
+ );
+
+ gBrowser.swapBrowsersAndCloseOther(newTab, oldTab);
+ await AttrChangePromise;
+
+ ok(
+ newTab.hasAttribute("muted"),
+ "Expected the correct muted attribute on the new tab"
+ );
+ is(
+ newTab.muteReason,
+ null,
+ "Expected the correct muteReason property on the new tab"
+ );
+ ok(
+ !newTab.hasAttribute("soundplaying"),
+ "Expected the correct soundplaying attribute on the new tab"
+ );
+
+ await test_tooltip(newTab.overlayIcon, "Unmute tab", true, newTab);
+}
+
+async function test_swapped_browser_while_not_playing(oldTab, newBrowser) {
+ ok(
+ oldTab.hasAttribute("muted"),
+ "Expected the correct muted attribute on the old tab"
+ );
+ is(
+ oldTab.muteReason,
+ null,
+ "Expected the correct muteReason property on the old tab"
+ );
+ ok(
+ !oldTab.hasAttribute("soundplaying"),
+ "Expected the correct soundplaying attribute on the old tab"
+ );
+
+ let newTab = gBrowser.getTabForBrowser(newBrowser);
+ let AttrChangePromise = BrowserTestUtils.waitForEvent(
+ newTab,
+ "TabAttrModified",
+ false,
+ event => {
+ return event.detail.changed.includes("muted");
+ }
+ );
+
+ let AudioPlaybackPromise = new Promise(resolve => {
+ let observer = (subject, topic, data) => {
+ ok(false, "Should not see an audio-playback notification");
+ };
+ Services.obs.addObserver(observer, "audio-playback");
+ setTimeout(() => {
+ Services.obs.removeObserver(observer, "audio-playback");
+ resolve();
+ }, 100);
+ });
+
+ gBrowser.swapBrowsersAndCloseOther(newTab, oldTab);
+ await AttrChangePromise;
+
+ ok(
+ newTab.hasAttribute("muted"),
+ "Expected the correct muted attribute on the new tab"
+ );
+ is(
+ newTab.muteReason,
+ null,
+ "Expected the correct muteReason property on the new tab"
+ );
+ ok(
+ !newTab.hasAttribute("soundplaying"),
+ "Expected the correct soundplaying attribute on the new tab"
+ );
+
+ // Wait to see if an audio-playback event is dispatched.
+ await AudioPlaybackPromise;
+
+ ok(
+ newTab.hasAttribute("muted"),
+ "Expected the correct muted attribute on the new tab"
+ );
+ is(
+ newTab.muteReason,
+ null,
+ "Expected the correct muteReason property on the new tab"
+ );
+ ok(
+ !newTab.hasAttribute("soundplaying"),
+ "Expected the correct soundplaying attribute on the new tab"
+ );
+
+ await test_tooltip(newTab.overlayIcon, "Unmute tab", true, newTab);
+}
+
+async function test_browser_swapping(tab, browser) {
+ // First, test swapping with a playing but muted tab.
+ await play(tab);
+
+ await test_mute_tab(tab, tab.overlayIcon, true);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:blank",
+ },
+ async function (newBrowser) {
+ await test_swapped_browser_while_playing(tab, newBrowser);
+
+ // Now, test swapping with a muted but not playing tab.
+ // Note that the tab remains muted, so we only need to pause playback.
+ tab = gBrowser.getTabForBrowser(newBrowser);
+ await pause(tab);
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "about:blank",
+ },
+ secondAboutBlankBrowser =>
+ test_swapped_browser_while_not_playing(tab, secondAboutBlankBrowser)
+ );
+ }
+ );
+}
+
+async function test_click_on_pinned_tab_after_mute() {
+ async function taskFn(browser) {
+ let tab = gBrowser.getTabForBrowser(browser);
+
+ gBrowser.selectedTab = originallySelectedTab;
+ isnot(
+ tab,
+ gBrowser.selectedTab,
+ "Sanity check, the tab should not be selected!"
+ );
+
+ // Steps to reproduce the bug:
+ // Pin the tab.
+ gBrowser.pinTab(tab);
+
+ // Start playback and wait for it to finish.
+ await play(tab);
+
+ // Mute the tab.
+ let icon = tab.overlayIcon;
+ await test_mute_tab(tab, icon, true);
+
+ // Pause playback and wait for it to finish.
+ await pause(tab);
+
+ // Unmute tab.
+ await test_mute_tab(tab, icon, false);
+
+ // Now click on the tab.
+ EventUtils.synthesizeMouseAtCenter(tab.iconImage, { button: 0 });
+
+ is(tab, gBrowser.selectedTab, "Tab switch should be successful");
+
+ // Cleanup.
+ gBrowser.unpinTab(tab);
+ gBrowser.selectedTab = originallySelectedTab;
+ }
+
+ let originallySelectedTab = gBrowser.selectedTab;
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: PAGE,
+ },
+ taskFn
+ );
+}
+
+// This test only does something useful in e10s!
+async function test_cross_process_load() {
+ async function taskFn(browser) {
+ let tab = gBrowser.getTabForBrowser(browser);
+
+ // Start playback and wait for it to finish.
+ await play(tab);
+
+ let soundPlayingStoppedPromise = BrowserTestUtils.waitForEvent(
+ tab,
+ "TabAttrModified",
+ false,
+ event => event.detail.changed.includes("soundplaying")
+ );
+
+ // Go to a different process.
+ BrowserTestUtils.loadURIString(browser, "about:mozilla");
+ await BrowserTestUtils.browserLoaded(browser);
+
+ await soundPlayingStoppedPromise;
+
+ ok(
+ !tab.hasAttribute("soundplaying"),
+ "Tab should not be playing sound any more"
+ );
+ ok(!tab.soundPlaying, "Tab should not be playing sound any more");
+ }
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: PAGE,
+ },
+ taskFn
+ );
+}
+
+async function test_mute_keybinding() {
+ async function test_muting_using_keyboard(tab) {
+ let mutedPromise = get_wait_for_mute_promise(tab, true);
+ EventUtils.synthesizeKey("m", { ctrlKey: true });
+ await mutedPromise;
+ mutedPromise = get_wait_for_mute_promise(tab, false);
+ EventUtils.synthesizeKey("m", { ctrlKey: true });
+ await mutedPromise;
+ }
+ async function taskFn(browser) {
+ let tab = gBrowser.getTabForBrowser(browser);
+
+ // Make sure it's possible to mute before the tab is playing.
+ await test_muting_using_keyboard(tab);
+
+ // Start playback and wait for it to finish.
+ await play(tab);
+
+ // Make sure it's possible to mute after the tab is playing.
+ await test_muting_using_keyboard(tab);
+
+ // Pause playback and wait for it to finish.
+ await pause(tab);
+
+ // Make sure things work if the tab is pinned.
+ gBrowser.pinTab(tab);
+
+ // Make sure it's possible to mute before the tab is playing.
+ await test_muting_using_keyboard(tab);
+
+ // Start playback and wait for it to finish.
+ await play(tab);
+
+ // Make sure it's possible to mute after the tab is playing.
+ await test_muting_using_keyboard(tab);
+
+ gBrowser.unpinTab(tab);
+ }
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: PAGE,
+ },
+ taskFn
+ );
+}
+
+async function test_on_browser(browser) {
+ let tab = gBrowser.getTabForBrowser(browser);
+
+ // Test the icon in a normal tab.
+ await test_playing_icon_on_tab(tab, browser, false);
+
+ gBrowser.pinTab(tab);
+
+ // Test the icon in a pinned tab.
+ await test_playing_icon_on_tab(tab, browser, true);
+
+ gBrowser.unpinTab(tab);
+
+ // Test the sound playing icon for hidden tabs.
+ await test_playing_icon_on_hidden_tab(tab);
+
+ // Retest with another browser in the foreground tab
+ if (gBrowser.selectedBrowser.currentURI.spec == PAGE) {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: "data:text/html,test",
+ },
+ () => test_on_browser(browser)
+ );
+ } else {
+ await test_browser_swapping(tab, browser);
+ }
+}
+
+async function test_delayed_tabattr_removal() {
+ async function taskFn(browser) {
+ let tab = gBrowser.getTabForBrowser(browser);
+ await play(tab);
+
+ // Extend the delay to guarantee the soundplaying attribute
+ // is not removed from the tab when audio is stopped. Without
+ // the extended delay the attribute could be removed in the
+ // same tick and the test wouldn't catch that this broke.
+ await pause(tab, { extendedDelay: true });
+ }
+
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: PAGE,
+ },
+ taskFn
+ );
+}
+
+requestLongerTimeout(2);
+add_task(async function test_page() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: PAGE,
+ },
+ test_on_browser
+ );
+});
+
+add_task(test_click_on_pinned_tab_after_mute);
+
+add_task(test_cross_process_load);
+
+add_task(test_mute_keybinding);
+
+add_task(test_delayed_tabattr_removal);
diff --git a/browser/base/content/test/tabs/browser_bfcache_exemption_about_pages.js b/browser/base/content/test/tabs/browser_bfcache_exemption_about_pages.js
new file mode 100644
index 0000000000..bcb872604c
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_bfcache_exemption_about_pages.js
@@ -0,0 +1,176 @@
+requestLongerTimeout(2);
+
+const BASE = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+);
+
+async function navigateTo(browser, urls, expectedPersist) {
+ // Navigate to a bunch of urls
+ for (let url of urls) {
+ let loaded = BrowserTestUtils.browserLoaded(browser, false, url);
+ BrowserTestUtils.loadURIString(browser, url);
+ await loaded;
+ }
+ // When we track pageshow event, save the evt.persisted on a doc element,
+ // so it can be checked from the test directly.
+ let pageShowCheck = evt => {
+ evt.target.ownerGlobal.document.documentElement.setAttribute(
+ "persisted",
+ evt.persisted
+ );
+ return true;
+ };
+ is(
+ browser.canGoBack,
+ true,
+ `After navigating to urls=${urls}, we can go back from uri=${browser.currentURI.spec}`
+ );
+ if (expectedPersist) {
+ // If we expect the page to persist, then the uri we are testing is about:blank.
+ // Currently we are only testing cases when we go forward to about:blank page,
+ // because it gets removed from history if it is sandwiched between two
+ // regular history entries. This means we can't test a scenario such as:
+ // page X, about:blank, page Y, go back -- about:blank page will be removed, and
+ // going back from page Y will take us to page X.
+
+ // Go back from about:blank (it will be the last uri in 'urls')
+ let pageShowPromise = BrowserTestUtils.waitForContentEvent(
+ browser,
+ "pageshow"
+ );
+ info(`Navigating back from uri=${browser.currentURI.spec}`);
+ browser.goBack();
+ await pageShowPromise;
+ info(`Got pageshow event`);
+ // Now go forward
+ let forwardPageShow = BrowserTestUtils.waitForContentEvent(
+ browser,
+ "pageshow",
+ false,
+ pageShowCheck
+ );
+ info(`Navigating forward from uri=${browser.currentURI.spec}`);
+ browser.goForward();
+ await forwardPageShow;
+ // Check that the page got persisted
+ let persisted = await SpecialPowers.spawn(browser, [], async function () {
+ return content.document.documentElement.getAttribute("persisted");
+ });
+ is(
+ persisted,
+ expectedPersist.toString(),
+ `uri ${browser.currentURI.spec} should have persisted`
+ );
+ } else {
+ // Go back
+ let pageShowPromise = BrowserTestUtils.waitForContentEvent(
+ browser,
+ "pageshow",
+ false,
+ pageShowCheck
+ );
+ info(`Navigating back from uri=${browser.currentURI.spec}`);
+ browser.goBack();
+ await pageShowPromise;
+ info(`Got pageshow event`);
+ // Check that the page did not get persisted
+ let persisted = await SpecialPowers.spawn(browser, [], async function () {
+ return content.document.documentElement.getAttribute("persisted");
+ });
+ is(
+ persisted,
+ expectedPersist.toString(),
+ `uri ${browser.currentURI.spec} shouldn't have persisted`
+ );
+ }
+}
+
+add_task(async function testAboutPagesExemptFromBfcache() {
+ // If Fission is disabled, the pref is no-op.
+ await SpecialPowers.pushPrefEnv({ set: [["fission.bfcacheInParent", true]] });
+
+ // Navigate to a bunch of urls, then go back once, check that the penultimate page did not go into BFbache
+ var browser;
+ // First page is about:privatebrowsing
+ const private_test_cases = [
+ ["about:blank"],
+ ["about:blank", "about:privatebrowsing", "about:blank"],
+ ];
+ for (const urls of private_test_cases) {
+ info(`Private tab - navigate to ${urls} urls`);
+ let win = await BrowserTestUtils.openNewBrowserWindow({
+ private: true,
+ waitForTabURL: "about:privatebrowsing",
+ });
+ browser = win.gBrowser.selectedTab.linkedBrowser;
+ await navigateTo(browser, urls, false);
+ await BrowserTestUtils.closeWindow(win);
+ }
+
+ // First page is about:blank
+ const regular_test_cases = [
+ ["about:home"],
+ ["about:home", "about:blank"],
+ ["about:blank", "about:newtab"],
+ ];
+ for (const urls of regular_test_cases) {
+ info(`Regular tab - navigate to ${urls} urls`);
+ let win = await BrowserTestUtils.openNewBrowserWindow();
+ let tab = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser: win.gBrowser,
+ opening: "about:newtab",
+ });
+ browser = tab.linkedBrowser;
+ await navigateTo(browser, urls, false);
+ BrowserTestUtils.removeTab(tab);
+ await BrowserTestUtils.closeWindow(win);
+ }
+});
+
+// Test that about:blank or pages that have about:* subframes get bfcached.
+// TODO bug 1705789: add about:reader tests when we make them bfcache compatible.
+add_task(async function testAboutPagesBfcacheAllowed() {
+ // If Fission is disabled, the pref is no-op.
+ await SpecialPowers.pushPrefEnv({ set: [["fission.bfcacheInParent", true]] });
+
+ var browser;
+ // First page is about:privatebrowsing
+ // about:privatebrowsing -> about:blank, go back, go forward, - about:blank is bfcached
+ // about:privatebrowsing -> about:home -> about:blank, go back, go forward, - about:blank is bfcached
+ // about:privatebrowsing -> file_about_srcdoc.html, go back, go forward - file_about_srcdoc.html is bfcached
+ const private_test_cases = [
+ ["about:blank"],
+ ["about:home", "about:blank"],
+ [BASE + "file_about_srcdoc.html"],
+ ];
+ for (const urls of private_test_cases) {
+ info(`Private tab - navigate to ${urls} urls`);
+ let win = await BrowserTestUtils.openNewBrowserWindow({
+ private: true,
+ waitForTabURL: "about:privatebrowsing",
+ });
+ browser = win.gBrowser.selectedTab.linkedBrowser;
+ await navigateTo(browser, urls, true);
+ await BrowserTestUtils.closeWindow(win);
+ }
+
+ // First page is about:blank
+ // about:blank -> about:home -> about:blank, go back, go forward, - about:blank is bfcached
+ // about:blank -> about:home -> file_about_srcdoc.html, go back, go forward - file_about_srcdoc.html is bfcached
+ const regular_test_cases = [
+ ["about:home", "about:blank"],
+ ["about:home", BASE + "file_about_srcdoc.html"],
+ ];
+ for (const urls of regular_test_cases) {
+ info(`Regular tab - navigate to ${urls} urls`);
+ let win = await BrowserTestUtils.openNewBrowserWindow();
+ let tab = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser: win.gBrowser,
+ });
+ browser = tab.linkedBrowser;
+ await navigateTo(browser, urls, true);
+ BrowserTestUtils.removeTab(tab);
+ await BrowserTestUtils.closeWindow(win);
+ }
+});
diff --git a/browser/base/content/test/tabs/browser_bug580956.js b/browser/base/content/test/tabs/browser_bug580956.js
new file mode 100644
index 0000000000..1aa6aae129
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_bug580956.js
@@ -0,0 +1,25 @@
+function numClosedTabs() {
+ return SessionStore.getClosedTabCountForWindow(window);
+}
+
+function isUndoCloseEnabled() {
+ updateTabContextMenu();
+ return !document.getElementById("context_undoCloseTab").disabled;
+}
+
+add_task(async function test() {
+ Services.prefs.setIntPref("browser.sessionstore.max_tabs_undo", 0);
+ Services.prefs.clearUserPref("browser.sessionstore.max_tabs_undo");
+ is(numClosedTabs(), 0, "There should be 0 closed tabs.");
+ ok(!isUndoCloseEnabled(), "Undo Close Tab should be disabled.");
+
+ var tab = BrowserTestUtils.addTab(gBrowser, "http://mochi.test:8888/");
+ var browser = gBrowser.getBrowserForTab(tab);
+ await BrowserTestUtils.browserLoaded(browser);
+
+ let sessionUpdatePromise = BrowserTestUtils.waitForSessionStoreUpdate(tab);
+ BrowserTestUtils.removeTab(tab);
+ await sessionUpdatePromise;
+
+ ok(isUndoCloseEnabled(), "Undo Close Tab should be enabled.");
+});
diff --git a/browser/base/content/test/tabs/browser_bug_1387976_restore_lazy_tab_browser_muted_state.js b/browser/base/content/test/tabs/browser_bug_1387976_restore_lazy_tab_browser_muted_state.js
new file mode 100644
index 0000000000..a3436fcefb
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_bug_1387976_restore_lazy_tab_browser_muted_state.js
@@ -0,0 +1,56 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { TabState } = ChromeUtils.importESModule(
+ "resource:///modules/sessionstore/TabState.sys.mjs"
+);
+
+/**
+ * Simulate a restart of a tab by removing it, then add a lazy tab
+ * which is restored with the tabData of the removed tab.
+ *
+ * @param tab
+ * The tab to restart.
+ * @return {Object} the restored lazy tab
+ */
+const restartTab = async function (tab) {
+ let tabData = TabState.clone(tab);
+ BrowserTestUtils.removeTab(tab);
+
+ let restoredLazyTab = BrowserTestUtils.addTab(gBrowser, "", {
+ createLazyBrowser: true,
+ });
+ SessionStore.setTabState(restoredLazyTab, JSON.stringify(tabData));
+ return restoredLazyTab;
+};
+
+function get_tab_state(tab) {
+ return JSON.parse(SessionStore.getTabState(tab));
+}
+
+add_task(async function () {
+ const tab = BrowserTestUtils.addTab(gBrowser, "http://mochi.test:8888/");
+ const browser = gBrowser.getBrowserForTab(tab);
+ await BrowserTestUtils.browserLoaded(browser);
+
+ // Let's make sure the tab is not in a muted state at the beginning
+ ok(!("muted" in get_tab_state(tab)), "Tab should not be in a muted state");
+
+ info("toggling Muted audio...");
+ tab.toggleMuteAudio();
+
+ ok("muted" in get_tab_state(tab), "Tab should be in a muted state");
+
+ info("Restarting tab...");
+ let restartedTab = await restartTab(tab);
+
+ ok(
+ "muted" in get_tab_state(restartedTab),
+ "Restored tab should still be in a muted state after restart"
+ );
+ ok(!restartedTab.linkedPanel, "Restored tab should not be inserted");
+
+ BrowserTestUtils.removeTab(restartedTab);
+});
diff --git a/browser/base/content/test/tabs/browser_close_during_beforeunload.js b/browser/base/content/test/tabs/browser_close_during_beforeunload.js
new file mode 100644
index 0000000000..2a93e29c00
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_close_during_beforeunload.js
@@ -0,0 +1,46 @@
+"use strict";
+
+// Tests that a second attempt to close a window while blocked on a
+// beforeunload confirmation ignores the beforeunload listener and
+// unblocks the original close call.
+
+const CONTENT_PROMPT_SUBDIALOG = Services.prefs.getBoolPref(
+ "prompts.contentPromptSubDialog",
+ false
+);
+
+const DIALOG_TOPIC = CONTENT_PROMPT_SUBDIALOG
+ ? "common-dialog-loaded"
+ : "tabmodal-dialog-loaded";
+
+add_task(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [["dom.require_user_interaction_for_beforeunload", false]],
+ });
+
+ let win = await BrowserTestUtils.openNewBrowserWindow();
+
+ let browser = win.gBrowser.selectedBrowser;
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ BrowserTestUtils.loadURIString(browser, "http://example.com/");
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ await BrowserTestUtils.browserLoaded(browser, false, "http://example.com/");
+
+ await SpecialPowers.spawn(browser, [], () => {
+ // eslint-disable-next-line mozilla/balanced-listeners
+ content.addEventListener("beforeunload", event => {
+ event.preventDefault();
+ });
+ });
+
+ let confirmationShown = false;
+
+ BrowserUtils.promiseObserved(DIALOG_TOPIC).then(() => {
+ confirmationShown = true;
+ win.close();
+ });
+
+ win.close();
+ ok(confirmationShown, "Before unload confirmation should have been shown");
+ ok(win.closed, "Window should have been closed after second close() call");
+});
diff --git a/browser/base/content/test/tabs/browser_close_tab_by_dblclick.js b/browser/base/content/test/tabs/browser_close_tab_by_dblclick.js
new file mode 100644
index 0000000000..9d251f1ea6
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_close_tab_by_dblclick.js
@@ -0,0 +1,35 @@
+/* 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/. */
+
+const PREF_CLOSE_TAB_BY_DBLCLICK = "browser.tabs.closeTabByDblclick";
+
+function triggerDblclickOn(target) {
+ let promise = BrowserTestUtils.waitForEvent(target, "dblclick");
+ EventUtils.synthesizeMouseAtCenter(target, { clickCount: 1 });
+ EventUtils.synthesizeMouseAtCenter(target, { clickCount: 2 });
+ return promise;
+}
+
+add_task(async function dblclick() {
+ let tab = gBrowser.selectedTab;
+ await triggerDblclickOn(tab);
+ ok(!tab.closing, "Double click the selected tab won't close it");
+});
+
+add_task(async function dblclickWithPrefSet() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[PREF_CLOSE_TAB_BY_DBLCLICK, true]],
+ });
+
+ let tab = BrowserTestUtils.addTab(gBrowser, "about:mozilla", {
+ skipAnimation: true,
+ });
+ isnot(tab, gBrowser.selectedTab, "The new tab is in the background");
+
+ await triggerDblclickOn(tab);
+ is(tab, gBrowser.selectedTab, "Double click a background tab will select it");
+
+ await triggerDblclickOn(tab);
+ ok(tab.closing, "Double click the selected tab will close it");
+});
diff --git a/browser/base/content/test/tabs/browser_contextmenu_openlink_after_tabnavigated.js b/browser/base/content/test/tabs/browser_contextmenu_openlink_after_tabnavigated.js
new file mode 100644
index 0000000000..3ce653cafc
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_contextmenu_openlink_after_tabnavigated.js
@@ -0,0 +1,60 @@
+"use strict";
+
+const example_base =
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com/browser/browser/base/content/test/tabs/";
+
+add_task(async function test_contextmenu_openlink_after_tabnavigated() {
+ let url = example_base + "test_bug1358314.html";
+
+ const tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+
+ const contextMenu = document.getElementById("contentAreaContextMenu");
+ let awaitPopupShown = BrowserTestUtils.waitForEvent(
+ contextMenu,
+ "popupshown"
+ );
+ await BrowserTestUtils.synthesizeMouse(
+ "a",
+ 0,
+ 0,
+ {
+ type: "contextmenu",
+ button: 2,
+ },
+ gBrowser.selectedBrowser
+ );
+ await awaitPopupShown;
+ info("Popup Shown");
+
+ info("Navigate the tab with the opened context menu");
+ BrowserTestUtils.loadURIString(gBrowser.selectedBrowser, "about:blank");
+ await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+
+ let awaitNewTabOpen = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com/",
+ true
+ );
+
+ info("Click the 'open link in new tab' menu item");
+ let openLinkMenuItem = contextMenu.querySelector("#context-openlinkintab");
+ contextMenu.activateItem(openLinkMenuItem);
+
+ info("Wait for the new tab to be opened");
+ const newTab = await awaitNewTabOpen;
+
+ // Close the contextMenu popup if it has not been closed yet.
+ contextMenu.hidePopup();
+
+ is(
+ newTab.linkedBrowser.currentURI.spec,
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com/",
+ "Got the expected URL loaded in the new tab"
+ );
+
+ BrowserTestUtils.removeTab(newTab);
+ BrowserTestUtils.removeTab(tab);
+});
diff --git a/browser/base/content/test/tabs/browser_dont_process_switch_204.js b/browser/base/content/test/tabs/browser_dont_process_switch_204.js
new file mode 100644
index 0000000000..009ef54340
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_dont_process_switch_204.js
@@ -0,0 +1,56 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const TEST_ROOT = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+);
+const TEST_URL = TEST_ROOT + "204.sjs";
+const BLANK_URL = TEST_ROOT + "blank.html";
+
+// Test for bug 1626362.
+add_task(async function () {
+ await BrowserTestUtils.withNewTab("about:robots", async function (aBrowser) {
+ // Get the current pid for browser for comparison later, we expect this
+ // to be the parent process for about:robots.
+ let browserPid = await SpecialPowers.spawn(aBrowser, [], () => {
+ return Services.appinfo.processID;
+ });
+
+ is(
+ Services.appinfo.processID,
+ browserPid,
+ "about:robots should have loaded in the parent"
+ );
+
+ // Attempt to load a uri that returns a 204 response, and then check that
+ // we didn't process switch for it.
+ let stopped = BrowserTestUtils.browserStopped(aBrowser, TEST_URL, true);
+ BrowserTestUtils.loadURIString(aBrowser, TEST_URL);
+ await stopped;
+
+ let newPid = await SpecialPowers.spawn(aBrowser, [], () => {
+ return Services.appinfo.processID;
+ });
+
+ is(
+ browserPid,
+ newPid,
+ "Shouldn't change process when we get a 204 response"
+ );
+
+ // Load a valid http page and confirm that we did change process
+ // to confirm that we weren't in a web process to begin with.
+ let loaded = BrowserTestUtils.browserLoaded(aBrowser, false, BLANK_URL);
+ BrowserTestUtils.loadURIString(aBrowser, BLANK_URL);
+ await loaded;
+
+ newPid = await SpecialPowers.spawn(aBrowser, [], () => {
+ return Services.appinfo.processID;
+ });
+
+ isnot(browserPid, newPid, "Should change process for a valid response");
+ });
+});
diff --git a/browser/base/content/test/tabs/browser_e10s_about_page_triggeringprincipal.js b/browser/base/content/test/tabs/browser_e10s_about_page_triggeringprincipal.js
new file mode 100644
index 0000000000..08bd2278ef
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_e10s_about_page_triggeringprincipal.js
@@ -0,0 +1,208 @@
+"use strict";
+
+const kChildPage = getRootDirectory(gTestPath) + "file_about_child.html";
+const kParentPage = getRootDirectory(gTestPath) + "file_about_parent.html";
+
+const kAboutPagesRegistered = Promise.all([
+ BrowserTestUtils.registerAboutPage(
+ registerCleanupFunction,
+ "test-about-principal-child",
+ kChildPage,
+ Ci.nsIAboutModule.URI_MUST_LOAD_IN_CHILD | Ci.nsIAboutModule.ALLOW_SCRIPT
+ ),
+ BrowserTestUtils.registerAboutPage(
+ registerCleanupFunction,
+ "test-about-principal-parent",
+ kParentPage,
+ Ci.nsIAboutModule.ALLOW_SCRIPT
+ ),
+]);
+
+add_task(async function test_principal_click() {
+ await kAboutPagesRegistered;
+ await SpecialPowers.pushPrefEnv({
+ set: [["dom.security.skip_about_page_has_csp_assert", true]],
+ });
+ await BrowserTestUtils.withNewTab(
+ "about:test-about-principal-parent",
+ async function (browser) {
+ let loadPromise = BrowserTestUtils.browserLoaded(
+ browser,
+ false,
+ "about:test-about-principal-child"
+ );
+ let myLink = browser.contentDocument.getElementById(
+ "aboutchildprincipal"
+ );
+ myLink.click();
+ await loadPromise;
+
+ await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [],
+ async function () {
+ let channel = content.docShell.currentDocumentChannel;
+ is(
+ channel.originalURI.asciiSpec,
+ "about:test-about-principal-child",
+ "sanity check - make sure we test the principal for the correct URI"
+ );
+
+ let triggeringPrincipal = channel.loadInfo.triggeringPrincipal;
+ ok(
+ triggeringPrincipal.isSystemPrincipal,
+ "loading about: from privileged page must have a triggering of System"
+ );
+
+ let contentPolicyType = channel.loadInfo.externalContentPolicyType;
+ is(
+ contentPolicyType,
+ Ci.nsIContentPolicy.TYPE_DOCUMENT,
+ "sanity check - loading a top level document"
+ );
+
+ let loadingPrincipal = channel.loadInfo.loadingPrincipal;
+ is(
+ loadingPrincipal,
+ null,
+ "sanity check - load of TYPE_DOCUMENT must have a null loadingPrincipal"
+ );
+ }
+ );
+ }
+ );
+});
+
+add_task(async function test_principal_ctrl_click() {
+ await kAboutPagesRegistered;
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["security.sandbox.content.level", 1],
+ ["dom.security.skip_about_page_has_csp_assert", true],
+ ],
+ });
+
+ await BrowserTestUtils.withNewTab(
+ "about:test-about-principal-parent",
+ async function (browser) {
+ let loadPromise = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ "about:test-about-principal-child",
+ true
+ );
+ // simulate ctrl+click
+ BrowserTestUtils.synthesizeMouseAtCenter(
+ "#aboutchildprincipal",
+ { ctrlKey: true, metaKey: true },
+ gBrowser.selectedBrowser
+ );
+ let tab = await loadPromise;
+ gBrowser.selectTabAtIndex(2);
+
+ await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [],
+ async function () {
+ let channel = content.docShell.currentDocumentChannel;
+ is(
+ channel.originalURI.asciiSpec,
+ "about:test-about-principal-child",
+ "sanity check - make sure we test the principal for the correct URI"
+ );
+
+ let triggeringPrincipal = channel.loadInfo.triggeringPrincipal;
+ ok(
+ triggeringPrincipal.isSystemPrincipal,
+ "loading about: from privileged page must have a triggering of System"
+ );
+
+ let contentPolicyType = channel.loadInfo.externalContentPolicyType;
+ is(
+ contentPolicyType,
+ Ci.nsIContentPolicy.TYPE_DOCUMENT,
+ "sanity check - loading a top level document"
+ );
+
+ let loadingPrincipal = channel.loadInfo.loadingPrincipal;
+ is(
+ loadingPrincipal,
+ null,
+ "sanity check - load of TYPE_DOCUMENT must have a null loadingPrincipal"
+ );
+ }
+ );
+ BrowserTestUtils.removeTab(tab);
+ }
+ );
+});
+
+add_task(async function test_principal_right_click_open_link_in_new_tab() {
+ await kAboutPagesRegistered;
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["security.sandbox.content.level", 1],
+ ["dom.security.skip_about_page_has_csp_assert", true],
+ ],
+ });
+
+ await BrowserTestUtils.withNewTab(
+ "about:test-about-principal-parent",
+ async function (browser) {
+ let loadPromise = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ "about:test-about-principal-child",
+ true
+ );
+
+ // simulate right-click open link in tab
+ BrowserTestUtils.waitForEvent(document, "popupshown", false, event => {
+ // These are operations that must be executed synchronously with the event.
+ document.getElementById("context-openlinkintab").doCommand();
+ event.target.hidePopup();
+ return true;
+ });
+ BrowserTestUtils.synthesizeMouseAtCenter(
+ "#aboutchildprincipal",
+ { type: "contextmenu", button: 2 },
+ gBrowser.selectedBrowser
+ );
+
+ let tab = await loadPromise;
+ gBrowser.selectTabAtIndex(2);
+
+ await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [],
+ async function () {
+ let channel = content.docShell.currentDocumentChannel;
+ is(
+ channel.originalURI.asciiSpec,
+ "about:test-about-principal-child",
+ "sanity check - make sure we test the principal for the correct URI"
+ );
+
+ let triggeringPrincipal = channel.loadInfo.triggeringPrincipal;
+ ok(
+ triggeringPrincipal.isSystemPrincipal,
+ "loading about: from privileged page must have a triggering of System"
+ );
+
+ let contentPolicyType = channel.loadInfo.externalContentPolicyType;
+ is(
+ contentPolicyType,
+ Ci.nsIContentPolicy.TYPE_DOCUMENT,
+ "sanity check - loading a top level document"
+ );
+
+ let loadingPrincipal = channel.loadInfo.loadingPrincipal;
+ is(
+ loadingPrincipal,
+ null,
+ "sanity check - load of TYPE_DOCUMENT must have a null loadingPrincipal"
+ );
+ }
+ );
+ BrowserTestUtils.removeTab(tab);
+ }
+ );
+});
diff --git a/browser/base/content/test/tabs/browser_e10s_about_process.js b/browser/base/content/test/tabs/browser_e10s_about_process.js
new file mode 100644
index 0000000000..f73e8e659c
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_e10s_about_process.js
@@ -0,0 +1,174 @@
+const CHROME = {
+ id: "cb34538a-d9da-40f3-b61a-069f0b2cb9fb",
+ path: "test-chrome",
+ flags: 0,
+};
+const CANREMOTE = {
+ id: "2480d3e1-9ce4-4b84-8ae3-910b9a95cbb3",
+ path: "test-allowremote",
+ flags: Ci.nsIAboutModule.URI_CAN_LOAD_IN_CHILD,
+};
+const MUSTREMOTE = {
+ id: "f849cee5-e13e-44d2-981d-0fb3884aaead",
+ path: "test-mustremote",
+ flags: Ci.nsIAboutModule.URI_MUST_LOAD_IN_CHILD,
+};
+const CANPRIVILEGEDREMOTE = {
+ id: "a04ffafe-6c63-4266-acae-0f4b093165aa",
+ path: "test-canprivilegedremote",
+ flags:
+ Ci.nsIAboutModule.URI_MUST_LOAD_IN_CHILD |
+ Ci.nsIAboutModule.URI_CAN_LOAD_IN_PRIVILEGEDABOUT_PROCESS,
+};
+const MUSTEXTENSION = {
+ id: "f7a1798f-965b-49e9-be83-ec6ee4d7d675",
+ path: "test-mustextension",
+ flags: Ci.nsIAboutModule.URI_MUST_LOAD_IN_EXTENSION_PROCESS,
+};
+
+const TEST_MODULES = [
+ CHROME,
+ CANREMOTE,
+ MUSTREMOTE,
+ CANPRIVILEGEDREMOTE,
+ MUSTEXTENSION,
+];
+
+function AboutModule() {}
+
+AboutModule.prototype = {
+ newChannel(aURI, aLoadInfo) {
+ throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED);
+ },
+
+ getURIFlags(aURI) {
+ for (let module of TEST_MODULES) {
+ if (aURI.pathQueryRef.startsWith(module.path)) {
+ return module.flags;
+ }
+ }
+
+ ok(false, "Called getURIFlags for an unknown page " + aURI.spec);
+ return 0;
+ },
+
+ getIndexedDBOriginPostfix(aURI) {
+ return null;
+ },
+
+ QueryInterface: ChromeUtils.generateQI(["nsIAboutModule"]),
+};
+
+var AboutModuleFactory = {
+ createInstance(aIID) {
+ return new AboutModule().QueryInterface(aIID);
+ },
+
+ QueryInterface: ChromeUtils.generateQI(["nsIFactory"]),
+};
+
+add_setup(async function () {
+ SpecialPowers.setBoolPref(
+ "browser.tabs.remote.separatePrivilegedMozillaWebContentProcess",
+ true
+ );
+ let registrar = Components.manager.QueryInterface(Ci.nsIComponentRegistrar);
+ for (let module of TEST_MODULES) {
+ registrar.registerFactory(
+ Components.ID(module.id),
+ "",
+ "@mozilla.org/network/protocol/about;1?what=" + module.path,
+ AboutModuleFactory
+ );
+ }
+});
+
+registerCleanupFunction(() => {
+ SpecialPowers.clearUserPref(
+ "browser.tabs.remote.separatePrivilegedMozillaWebContentProcess"
+ );
+ let registrar = Components.manager.QueryInterface(Ci.nsIComponentRegistrar);
+ for (let module of TEST_MODULES) {
+ registrar.unregisterFactory(Components.ID(module.id), AboutModuleFactory);
+ }
+});
+
+add_task(async function test_chrome() {
+ test_url_for_process_types({
+ url: "about:" + CHROME.path,
+ chromeResult: true,
+ webContentResult: false,
+ privilegedAboutContentResult: false,
+ privilegedMozillaContentResult: false,
+ extensionProcessResult: false,
+ });
+});
+
+add_task(async function test_any() {
+ test_url_for_process_types({
+ url: "about:" + CANREMOTE.path,
+ chromeResult: true,
+ webContentResult: true,
+ privilegedAboutContentResult: true,
+ privilegedMozillaContentResult: true,
+ extensionProcessResult: true,
+ });
+});
+
+add_task(async function test_remote() {
+ test_url_for_process_types({
+ url: "about:" + MUSTREMOTE.path,
+ chromeResult: false,
+ webContentResult: true,
+ privilegedAboutContentResult: false,
+ privilegedMozillaContentResult: false,
+ extensionProcessResult: false,
+ });
+});
+
+add_task(async function test_privileged_remote_true() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.tabs.remote.separatePrivilegedContentProcess", true]],
+ });
+
+ // This shouldn't be taken literally. We will always use the privleged about
+ // content type if the URI_CAN_LOAD_IN_PRIVILEGEDABOUT_PROCESS flag is enabled and
+ // the pref is turned on.
+ test_url_for_process_types({
+ url: "about:" + CANPRIVILEGEDREMOTE.path,
+ chromeResult: false,
+ webContentResult: false,
+ privilegedAboutContentResult: true,
+ privilegedMozillaContentResult: false,
+ extensionProcessResult: false,
+ });
+});
+
+add_task(async function test_privileged_remote_false() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.tabs.remote.separatePrivilegedContentProcess", false]],
+ });
+
+ // This shouldn't be taken literally. We will always use the privleged about
+ // content type if the URI_CAN_LOAD_IN_PRIVILEGEDABOUT_PROCESS flag is enabled and
+ // the pref is turned on.
+ test_url_for_process_types({
+ url: "about:" + CANPRIVILEGEDREMOTE.path,
+ chromeResult: false,
+ webContentResult: true,
+ privilegedAboutContentResult: false,
+ privilegedMozillaContentResult: false,
+ extensionProcessResult: false,
+ });
+});
+
+add_task(async function test_extension() {
+ test_url_for_process_types({
+ url: "about:" + MUSTEXTENSION.path,
+ chromeResult: false,
+ webContentResult: false,
+ privilegedAboutContentResult: false,
+ privilegedMozillaContentResult: false,
+ extensionProcessResult: true,
+ });
+});
diff --git a/browser/base/content/test/tabs/browser_e10s_chrome_process.js b/browser/base/content/test/tabs/browser_e10s_chrome_process.js
new file mode 100644
index 0000000000..aa6a893372
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_e10s_chrome_process.js
@@ -0,0 +1,136 @@
+// Returns a function suitable for add_task which loads startURL, runs
+// transitionTask and waits for endURL to load, checking that the URLs were
+// loaded in the correct process.
+function makeTest(
+ name,
+ startURL,
+ startProcessIsRemote,
+ endURL,
+ endProcessIsRemote,
+ transitionTask
+) {
+ return async function () {
+ info("Running test " + name + ", " + transitionTask.name);
+ let browser = gBrowser.selectedBrowser;
+
+ // In non-e10s nothing should be remote
+ if (!gMultiProcessBrowser) {
+ startProcessIsRemote = false;
+ endProcessIsRemote = false;
+ }
+
+ // Load the initial URL and make sure we are in the right initial process
+ info("Loading initial URL");
+ BrowserTestUtils.loadURIString(browser, startURL);
+ await BrowserTestUtils.browserLoaded(browser, false, startURL);
+
+ is(browser.currentURI.spec, startURL, "Shouldn't have been redirected");
+ is(
+ browser.isRemoteBrowser,
+ startProcessIsRemote,
+ "Should be displayed in the right process"
+ );
+
+ let docLoadedPromise = BrowserTestUtils.browserLoaded(
+ browser,
+ false,
+ endURL
+ );
+ await transitionTask(browser, endURL);
+ await docLoadedPromise;
+
+ is(browser.currentURI.spec, endURL, "Should have made it to the final URL");
+ is(
+ browser.isRemoteBrowser,
+ endProcessIsRemote,
+ "Should be displayed in the right process"
+ );
+ };
+}
+
+const PATH = (
+ getRootDirectory(gTestPath) + "test_process_flags_chrome.html"
+).replace("chrome://mochitests", "");
+
+const CHROME = "chrome://mochitests" + PATH;
+const CANREMOTE = "chrome://mochitests-any" + PATH;
+const MUSTREMOTE = "chrome://mochitests-content" + PATH;
+
+add_setup(async function () {
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, "about:blank", {
+ forceNotRemote: true,
+ });
+});
+
+registerCleanupFunction(() => {
+ gBrowser.removeCurrentTab();
+});
+
+add_task(async function test_chrome() {
+ test_url_for_process_types({
+ url: CHROME,
+ chromeResult: true,
+ webContentResult: false,
+ privilegedAboutContentResult: false,
+ privilegedMozillaContentResult: false,
+ extensionProcessResult: false,
+ });
+});
+
+add_task(async function test_any() {
+ test_url_for_process_types({
+ url: CANREMOTE,
+ chromeResult: true,
+ webContentResult: true,
+ privilegedAboutContentResult: false,
+ privilegedMozillaContentResult: false,
+ extensionProcessResult: false,
+ });
+});
+
+add_task(async function test_remote() {
+ test_url_for_process_types({
+ url: MUSTREMOTE,
+ chromeResult: false,
+ webContentResult: true,
+ privilegedAboutContentResult: false,
+ privilegedMozillaContentResult: false,
+ extensionProcessResult: false,
+ });
+});
+
+// The set of page transitions
+var TESTS = [
+ ["chrome -> chrome", CHROME, false, CHROME, false],
+ ["chrome -> canremote", CHROME, false, CANREMOTE, false],
+ ["chrome -> mustremote", CHROME, false, MUSTREMOTE, true],
+ ["remote -> chrome", MUSTREMOTE, true, CHROME, false],
+ ["remote -> canremote", MUSTREMOTE, true, CANREMOTE, true],
+ ["remote -> mustremote", MUSTREMOTE, true, MUSTREMOTE, true],
+];
+
+// The different ways to transition from one page to another
+var TRANSITIONS = [
+ // Loads the new page by calling browser.loadURI directly
+ async function loadURI(browser, uri) {
+ info("Calling browser.loadURI");
+ BrowserTestUtils.loadURIString(browser, uri);
+ },
+
+ // Loads the new page by finding a link with the right href in the document and
+ // clicking it
+ function clickLink(browser, uri) {
+ info("Clicking link");
+ SpecialPowers.spawn(browser, [uri], function frame_script(frameUri) {
+ let link = content.document.querySelector("a[href='" + frameUri + "']");
+ link.click();
+ });
+ },
+];
+
+// Creates a set of test tasks, one for each combination of TESTS and TRANSITIONS.
+for (let test of TESTS) {
+ for (let transition of TRANSITIONS) {
+ add_task(makeTest(...test, transition));
+ }
+}
diff --git a/browser/base/content/test/tabs/browser_e10s_javascript.js b/browser/base/content/test/tabs/browser_e10s_javascript.js
new file mode 100644
index 0000000000..ffb03b4d79
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_e10s_javascript.js
@@ -0,0 +1,19 @@
+const CHROME_PROCESS = E10SUtils.NOT_REMOTE;
+const WEB_CONTENT_PROCESS = E10SUtils.WEB_REMOTE_TYPE;
+
+add_task(async function () {
+ let url = "javascript:dosomething()";
+
+ ok(
+ E10SUtils.canLoadURIInRemoteType(url, /* fission */ false, CHROME_PROCESS),
+ "Check URL in chrome process."
+ );
+ ok(
+ E10SUtils.canLoadURIInRemoteType(
+ url,
+ /* fission */ false,
+ WEB_CONTENT_PROCESS
+ ),
+ "Check URL in web content process."
+ );
+});
diff --git a/browser/base/content/test/tabs/browser_e10s_mozillaweb_process.js b/browser/base/content/test/tabs/browser_e10s_mozillaweb_process.js
new file mode 100644
index 0000000000..88542a0b16
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_e10s_mozillaweb_process.js
@@ -0,0 +1,52 @@
+add_task(async function test_privileged_remote_true() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.tabs.remote.separatePrivilegedContentProcess", true],
+ ["browser.tabs.remote.separatePrivilegedMozillaWebContentProcess", true],
+ ["browser.tabs.remote.separatedMozillaDomains", "example.org"],
+ ],
+ });
+
+ test_url_for_process_types({
+ url: "https://example.com",
+ chromeResult: false,
+ webContentResult: true,
+ privilegedAboutContentResult: false,
+ privilegedMozillaContentResult: false,
+ extensionProcessResult: false,
+ });
+ test_url_for_process_types({
+ url: "https://example.org",
+ chromeResult: false,
+ webContentResult: false,
+ privilegedAboutContentResult: false,
+ privilegedMozillaContentResult: true,
+ extensionProcessResult: false,
+ });
+});
+
+add_task(async function test_privileged_remote_false() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.tabs.remote.separatePrivilegedContentProcess", true],
+ ["browser.tabs.remote.separatePrivilegedMozillaWebContentProcess", false],
+ ],
+ });
+
+ test_url_for_process_types({
+ url: "https://example.com",
+ chromeResult: false,
+ webContentResult: true,
+ privilegedAboutContentResult: false,
+ privilegedMozillaContentResult: false,
+ extensionProcessResult: false,
+ });
+ test_url_for_process_types({
+ url: "https://example.org",
+ chromeResult: false,
+ webContentResult: true,
+ privilegedAboutContentResult: false,
+ privilegedMozillaContentResult: false,
+ extensionProcessResult: false,
+ });
+});
diff --git a/browser/base/content/test/tabs/browser_e10s_switchbrowser.js b/browser/base/content/test/tabs/browser_e10s_switchbrowser.js
new file mode 100644
index 0000000000..0104f3c60c
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_e10s_switchbrowser.js
@@ -0,0 +1,490 @@
+requestLongerTimeout(2);
+
+const DUMMY_PATH = "browser/browser/base/content/test/general/dummy_page.html";
+
+const gExpectedHistory = {
+ index: -1,
+ entries: [],
+};
+
+async function get_remote_history(browser) {
+ if (SpecialPowers.Services.appinfo.sessionHistoryInParent) {
+ let sessionHistory = browser.browsingContext?.sessionHistory;
+ if (!sessionHistory) {
+ return null;
+ }
+
+ let result = {
+ index: sessionHistory.index,
+ entries: [],
+ };
+
+ for (let i = 0; i < sessionHistory.count; i++) {
+ let entry = sessionHistory.getEntryAtIndex(i);
+ result.entries.push({
+ uri: entry.URI.spec,
+ title: entry.title,
+ });
+ }
+ return result;
+ }
+
+ return SpecialPowers.spawn(browser, [], () => {
+ let webNav = content.docShell.QueryInterface(Ci.nsIWebNavigation);
+ let sessionHistory = webNav.sessionHistory;
+ let result = {
+ index: sessionHistory.index,
+ entries: [],
+ };
+
+ for (let i = 0; i < sessionHistory.count; i++) {
+ let entry = sessionHistory.legacySHistory.getEntryAtIndex(i);
+ result.entries.push({
+ uri: entry.URI.spec,
+ title: entry.title,
+ });
+ }
+
+ return result;
+ });
+}
+
+var check_history = async function () {
+ let sessionHistory = await get_remote_history(gBrowser.selectedBrowser);
+
+ let count = sessionHistory.entries.length;
+ is(
+ count,
+ gExpectedHistory.entries.length,
+ "Should have the right number of history entries"
+ );
+ is(
+ sessionHistory.index,
+ gExpectedHistory.index,
+ "Should have the right history index"
+ );
+
+ for (let i = 0; i < count; i++) {
+ let entry = sessionHistory.entries[i];
+ info("Checking History Entry: " + entry.uri);
+ is(entry.uri, gExpectedHistory.entries[i].uri, "Should have the right URI");
+ is(
+ entry.title,
+ gExpectedHistory.entries[i].title,
+ "Should have the right title"
+ );
+ }
+};
+
+function clear_history() {
+ gExpectedHistory.index = -1;
+ gExpectedHistory.entries = [];
+}
+
+// Waits for a load and updates the known history
+var waitForLoad = async function (uriString) {
+ info("Loading " + uriString);
+ // Longwinded but this ensures we don't just shortcut to LoadInNewProcess
+ let loadURIOptions = {
+ triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
+ };
+ gBrowser.selectedBrowser.webNavigation.loadURI(
+ Services.io.newURI(uriString),
+ loadURIOptions
+ );
+
+ await BrowserTestUtils.browserStopped(gBrowser, uriString);
+
+ // Some of the documents we're using in this test use Fluent,
+ // and they may finish localization later.
+ // To prevent this test from being intermittent, we'll
+ // wait for the `document.l10n.ready` promise to resolve.
+ if (
+ gBrowser.selectedBrowser.contentWindow &&
+ gBrowser.selectedBrowser.contentWindow.document.l10n
+ ) {
+ await gBrowser.selectedBrowser.contentWindow.document.l10n.ready;
+ }
+ gExpectedHistory.index++;
+ gExpectedHistory.entries.push({
+ uri: gBrowser.currentURI.spec,
+ title: gBrowser.contentTitle,
+ });
+};
+
+// Waits for a load and updates the known history
+var waitForLoadWithFlags = async function (
+ uriString,
+ flags = Ci.nsIWebNavigation.LOAD_FLAGS_NONE
+) {
+ info("Loading " + uriString + " flags = " + flags);
+ gBrowser.selectedBrowser.loadURI(Services.io.newURI(uriString), {
+ flags,
+ triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
+ });
+
+ await BrowserTestUtils.browserStopped(gBrowser, uriString);
+ if (!(flags & Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_HISTORY)) {
+ if (flags & Ci.nsIWebNavigation.LOAD_FLAGS_REPLACE_HISTORY) {
+ gExpectedHistory.entries.pop();
+ } else {
+ gExpectedHistory.index++;
+ }
+
+ gExpectedHistory.entries.push({
+ uri: gBrowser.currentURI.spec,
+ title: gBrowser.contentTitle,
+ });
+ }
+};
+
+var back = async function () {
+ info("Going back");
+ gBrowser.goBack();
+ await BrowserTestUtils.browserStopped(gBrowser);
+ gExpectedHistory.index--;
+};
+
+var forward = async function () {
+ info("Going forward");
+ gBrowser.goForward();
+ await BrowserTestUtils.browserStopped(gBrowser);
+ gExpectedHistory.index++;
+};
+
+// Tests that navigating from a page that should be in the remote process and
+// a page that should be in the main process works and retains history
+add_task(async function test_navigation() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.navigation.requireUserInteraction", false]],
+ });
+
+ let expectedRemote = gMultiProcessBrowser;
+
+ info("1");
+ // Create a tab and load a remote page in it
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, "about:blank", {
+ skipAnimation: true,
+ });
+ let { permanentKey } = gBrowser.selectedBrowser;
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ await waitForLoad("http://example.org/" + DUMMY_PATH);
+ is(
+ gBrowser.selectedBrowser.isRemoteBrowser,
+ expectedRemote,
+ "Remote attribute should be correct"
+ );
+ is(
+ gBrowser.selectedBrowser.permanentKey,
+ permanentKey,
+ "browser.permanentKey is still the same"
+ );
+
+ info("2");
+ // Load another page
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ await waitForLoad("http://example.com/" + DUMMY_PATH);
+ is(
+ gBrowser.selectedBrowser.isRemoteBrowser,
+ expectedRemote,
+ "Remote attribute should be correct"
+ );
+ is(
+ gBrowser.selectedBrowser.permanentKey,
+ permanentKey,
+ "browser.permanentKey is still the same"
+ );
+ await check_history();
+
+ info("3");
+ // Load a non-remote page
+ await waitForLoad("about:robots");
+ await TestUtils.waitForCondition(
+ () => !!gBrowser.selectedBrowser.contentTitle.length,
+ "Waiting for about:robots title to update"
+ );
+ is(
+ gBrowser.selectedBrowser.isRemoteBrowser,
+ false,
+ "Remote attribute should be correct"
+ );
+ is(
+ gBrowser.selectedBrowser.permanentKey,
+ permanentKey,
+ "browser.permanentKey is still the same"
+ );
+ await check_history();
+
+ info("4");
+ // Load a remote page
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ await waitForLoad("http://example.org/" + DUMMY_PATH);
+ is(
+ gBrowser.selectedBrowser.isRemoteBrowser,
+ expectedRemote,
+ "Remote attribute should be correct"
+ );
+ is(
+ gBrowser.selectedBrowser.permanentKey,
+ permanentKey,
+ "browser.permanentKey is still the same"
+ );
+ await check_history();
+
+ info("5");
+ await back();
+ is(
+ gBrowser.selectedBrowser.isRemoteBrowser,
+ false,
+ "Remote attribute should be correct"
+ );
+ is(
+ gBrowser.selectedBrowser.permanentKey,
+ permanentKey,
+ "browser.permanentKey is still the same"
+ );
+ await TestUtils.waitForCondition(
+ () => !!gBrowser.selectedBrowser.contentTitle.length,
+ "Waiting for about:robots title to update"
+ );
+ await check_history();
+
+ info("6");
+ await back();
+ is(
+ gBrowser.selectedBrowser.isRemoteBrowser,
+ expectedRemote,
+ "Remote attribute should be correct"
+ );
+ is(
+ gBrowser.selectedBrowser.permanentKey,
+ permanentKey,
+ "browser.permanentKey is still the same"
+ );
+ await check_history();
+
+ info("7");
+ await forward();
+ is(
+ gBrowser.selectedBrowser.isRemoteBrowser,
+ false,
+ "Remote attribute should be correct"
+ );
+ is(
+ gBrowser.selectedBrowser.permanentKey,
+ permanentKey,
+ "browser.permanentKey is still the same"
+ );
+ await TestUtils.waitForCondition(
+ () => !!gBrowser.selectedBrowser.contentTitle.length,
+ "Waiting for about:robots title to update"
+ );
+ await check_history();
+
+ info("8");
+ await forward();
+ is(
+ gBrowser.selectedBrowser.isRemoteBrowser,
+ expectedRemote,
+ "Remote attribute should be correct"
+ );
+ is(
+ gBrowser.selectedBrowser.permanentKey,
+ permanentKey,
+ "browser.permanentKey is still the same"
+ );
+ await check_history();
+
+ info("9");
+ await back();
+ await TestUtils.waitForCondition(
+ () => !!gBrowser.selectedBrowser.contentTitle.length,
+ "Waiting for about:robots title to update"
+ );
+ is(
+ gBrowser.selectedBrowser.isRemoteBrowser,
+ false,
+ "Remote attribute should be correct"
+ );
+ is(
+ gBrowser.selectedBrowser.permanentKey,
+ permanentKey,
+ "browser.permanentKey is still the same"
+ );
+ await check_history();
+
+ info("10");
+ // Load a new remote page, this should replace the last history entry
+ gExpectedHistory.entries.splice(gExpectedHistory.entries.length - 1, 1);
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ await waitForLoad("http://example.com/" + DUMMY_PATH);
+ is(
+ gBrowser.selectedBrowser.isRemoteBrowser,
+ expectedRemote,
+ "Remote attribute should be correct"
+ );
+ is(
+ gBrowser.selectedBrowser.permanentKey,
+ permanentKey,
+ "browser.permanentKey is still the same"
+ );
+ await check_history();
+
+ info("11");
+ gBrowser.removeCurrentTab();
+ clear_history();
+});
+
+// Tests that calling gBrowser.loadURI or browser.loadURI to load a page in a
+// different process updates the browser synchronously
+add_task(async function test_synchronous() {
+ let expectedRemote = gMultiProcessBrowser;
+
+ info("1");
+ // Create a tab and load a remote page in it
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, "about:blank", {
+ skipAnimation: true,
+ });
+ let { permanentKey } = gBrowser.selectedBrowser;
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ await waitForLoad("http://example.org/" + DUMMY_PATH);
+ is(
+ gBrowser.selectedBrowser.isRemoteBrowser,
+ expectedRemote,
+ "Remote attribute should be correct"
+ );
+ is(
+ gBrowser.selectedBrowser.permanentKey,
+ permanentKey,
+ "browser.permanentKey is still the same"
+ );
+
+ info("2");
+ // Load another page
+ info("Loading about:robots");
+ BrowserTestUtils.loadURIString(gBrowser.selectedBrowser, "about:robots");
+ await BrowserTestUtils.browserStopped(gBrowser);
+ is(
+ gBrowser.selectedBrowser.isRemoteBrowser,
+ false,
+ "Remote attribute should be correct"
+ );
+ is(
+ gBrowser.selectedBrowser.permanentKey,
+ permanentKey,
+ "browser.permanentKey is still the same"
+ );
+
+ info("3");
+ // Load the remote page again
+ info("Loading http://example.org/" + DUMMY_PATH);
+ BrowserTestUtils.loadURIString(
+ gBrowser.selectedBrowser,
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.org/" + DUMMY_PATH
+ );
+ await BrowserTestUtils.browserStopped(gBrowser);
+ is(
+ gBrowser.selectedBrowser.isRemoteBrowser,
+ expectedRemote,
+ "Remote attribute should be correct"
+ );
+ is(
+ gBrowser.selectedBrowser.permanentKey,
+ permanentKey,
+ "browser.permanentKey is still the same"
+ );
+
+ info("4");
+ gBrowser.removeCurrentTab();
+ clear_history();
+});
+
+// Tests that load flags are correctly passed through to the child process with
+// normal loads
+add_task(async function test_loadflags() {
+ let expectedRemote = gMultiProcessBrowser;
+
+ info("1");
+ // Create a tab and load a remote page in it
+ gBrowser.selectedTab = BrowserTestUtils.addTab(gBrowser, "about:blank", {
+ skipAnimation: true,
+ });
+ await waitForLoadWithFlags("about:robots");
+ await TestUtils.waitForCondition(
+ () => gBrowser.selectedBrowser.contentTitle != "about:robots",
+ "Waiting for about:robots title to update"
+ );
+ is(
+ gBrowser.selectedBrowser.isRemoteBrowser,
+ false,
+ "Remote attribute should be correct"
+ );
+ await check_history();
+
+ info("2");
+ // Load a page in the remote process with some custom flags
+ await waitForLoadWithFlags(
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com/" + DUMMY_PATH,
+ Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_HISTORY
+ );
+ is(
+ gBrowser.selectedBrowser.isRemoteBrowser,
+ expectedRemote,
+ "Remote attribute should be correct"
+ );
+ await check_history();
+
+ info("3");
+ // Load a non-remote page
+ await waitForLoadWithFlags("about:robots");
+ await TestUtils.waitForCondition(
+ () => !!gBrowser.selectedBrowser.contentTitle.length,
+ "Waiting for about:robots title to update"
+ );
+ is(
+ gBrowser.selectedBrowser.isRemoteBrowser,
+ false,
+ "Remote attribute should be correct"
+ );
+ await check_history();
+
+ info("4");
+ // Load another remote page
+ await waitForLoadWithFlags(
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.org/" + DUMMY_PATH,
+ Ci.nsIWebNavigation.LOAD_FLAGS_REPLACE_HISTORY
+ );
+ is(
+ gBrowser.selectedBrowser.isRemoteBrowser,
+ expectedRemote,
+ "Remote attribute should be correct"
+ );
+ await check_history();
+
+ info("5");
+ // Load another remote page from a different origin
+ await waitForLoadWithFlags(
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com/" + DUMMY_PATH,
+ Ci.nsIWebNavigation.LOAD_FLAGS_REPLACE_HISTORY
+ );
+ is(
+ gBrowser.selectedBrowser.isRemoteBrowser,
+ expectedRemote,
+ "Remote attribute should be correct"
+ );
+ await check_history();
+
+ is(
+ gExpectedHistory.entries.length,
+ 2,
+ "Should end with the right number of history entries"
+ );
+
+ info("6");
+ gBrowser.removeCurrentTab();
+ clear_history();
+});
diff --git a/browser/base/content/test/tabs/browser_file_to_http_named_popup.js b/browser/base/content/test/tabs/browser_file_to_http_named_popup.js
new file mode 100644
index 0000000000..57e5ec7ad3
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_file_to_http_named_popup.js
@@ -0,0 +1,60 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+
+const TEST_FILE = fileURL("dummy_page.html");
+const TEST_HTTP = httpURL("dummy_page.html");
+
+// Test for Bug 1634252
+add_task(async function () {
+ await BrowserTestUtils.withNewTab(TEST_FILE, async function (fileBrowser) {
+ info("Tab ready");
+
+ async function summonPopup(firstRun) {
+ var winPromise;
+ if (firstRun) {
+ winPromise = BrowserTestUtils.waitForNewWindow({
+ url: TEST_HTTP,
+ });
+ }
+
+ await SpecialPowers.spawn(
+ fileBrowser,
+ [TEST_HTTP, firstRun],
+ (target, firstRun_) => {
+ var win = content.open(target, "named", "width=400,height=400");
+ win.focus();
+ ok(win, "window.open was successful");
+ if (firstRun_) {
+ content.document.firstWindow = win;
+ } else {
+ content.document.otherWindow = win;
+ }
+ }
+ );
+
+ if (firstRun) {
+ // We should only wait for the window the first time, because only the
+ // later times no new window should be created.
+ info("Waiting for new window");
+ var win = await winPromise;
+ ok(win, "Got a window");
+ }
+ }
+
+ info("Opening window");
+ await summonPopup(true);
+ info("Opening window again");
+ await summonPopup(false);
+
+ await SpecialPowers.spawn(fileBrowser, [], () => {
+ ok(content.document.firstWindow, "Window is non-null");
+ is(
+ content.document.otherWindow,
+ content.document.firstWindow,
+ "Windows are the same"
+ );
+
+ content.document.firstWindow.close();
+ });
+ });
+});
diff --git a/browser/base/content/test/tabs/browser_file_to_http_script_closable.js b/browser/base/content/test/tabs/browser_file_to_http_script_closable.js
new file mode 100644
index 0000000000..00ef3d7322
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_file_to_http_script_closable.js
@@ -0,0 +1,43 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+
+const TEST_FILE = fileURL("dummy_page.html");
+const TEST_HTTP = httpURL("tab_that_closes.html");
+
+// Test for Bug 1632441
+add_task(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [["dom.allow_scripts_to_close_windows", false]],
+ });
+
+ await BrowserTestUtils.withNewTab(TEST_FILE, async function (fileBrowser) {
+ info("Tab ready");
+
+ // The request will open a new tab, capture the new tab and the load in it.
+ info("Creating promise");
+ var newTabPromise = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ url => {
+ return url.endsWith("tab_that_closes.html");
+ },
+ true,
+ false
+ );
+
+ // Click the link, which will post to target="_blank"
+ info("Creating and clicking link");
+ await SpecialPowers.spawn(fileBrowser, [TEST_HTTP], target => {
+ content.open(target);
+ });
+
+ // The new tab will load.
+ info("Waiting for load");
+ var newTab = await newTabPromise;
+ ok(newTab, "Tab is loaded");
+ info("waiting for it to close");
+ await BrowserTestUtils.waitForTabClosing(newTab);
+ ok(true, "The test completes without a timeout");
+ });
+
+ await SpecialPowers.popPrefEnv();
+});
diff --git a/browser/base/content/test/tabs/browser_hiddentab_contextmenu.js b/browser/base/content/test/tabs/browser_hiddentab_contextmenu.js
new file mode 100644
index 0000000000..5c54896efb
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_hiddentab_contextmenu.js
@@ -0,0 +1,34 @@
+/* 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/. */
+
+// Tests the context menu of hidden tabs which users can open via the All Tabs
+// menu's Hidden Tabs view.
+
+add_task(async function test() {
+ is(gBrowser.visibleTabs.length, 1, "there is initially one visible tab");
+
+ let tab = BrowserTestUtils.addTab(gBrowser);
+ gBrowser.hideTab(tab);
+ ok(tab.hidden, "new tab is hidden");
+ is(gBrowser.visibleTabs.length, 1, "there is still only one visible tabs");
+
+ updateTabContextMenu(tab);
+ ok(
+ document.getElementById("context_moveTabOptions").disabled,
+ "Move Tab menu is disabled"
+ );
+ ok(
+ document.getElementById("context_closeTabsToTheStart").disabled,
+ "Close Tabs to Left is disabled"
+ );
+ ok(
+ document.getElementById("context_closeTabsToTheEnd").disabled,
+ "Close Tabs to Right is disabled"
+ );
+ ok(
+ document.getElementById("context_reopenInContainer").disabled,
+ "Open in New Container Tab menu is disabled"
+ );
+ gBrowser.removeTab(tab);
+});
diff --git a/browser/base/content/test/tabs/browser_lazy_tab_browser_events.js b/browser/base/content/test/tabs/browser_lazy_tab_browser_events.js
new file mode 100644
index 0000000000..665bdb7f69
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_lazy_tab_browser_events.js
@@ -0,0 +1,157 @@
+"use strict";
+
+// Helper that watches events that may be triggered when tab browsers are
+// swapped during the test.
+//
+// The primary purpose of this helper is to access tab browser properties
+// during tab events, to verify that there are no undesired side effects, as a
+// regression test for https://bugzilla.mozilla.org/show_bug.cgi?id=1695346
+class TabEventTracker {
+ constructor(tab) {
+ this.tab = tab;
+
+ tab.addEventListener("TabAttrModified", this);
+ tab.addEventListener("TabShow", this);
+ tab.addEventListener("TabHide", this);
+ }
+
+ handleEvent(event) {
+ let description = `${this._expectations.description} at ${event.type}`;
+ if (event.type === "TabAttrModified") {
+ description += `, changed=${event.detail.changed}`;
+ }
+
+ const browser = this.tab.linkedBrowser;
+ is(
+ browser.currentURI.spec,
+ this._expectations.tabUrl,
+ `${description} - expected currentURI`
+ );
+ ok(browser._cachedCurrentURI, `${description} - currentURI was cached`);
+
+ if (event.type === "TabAttrModified") {
+ if (event.detail.changed.includes("muted")) {
+ if (browser.audioMuted) {
+ this._counts.muted++;
+ } else {
+ this._counts.unmuted++;
+ }
+ }
+ } else if (event.type === "TabShow") {
+ this._counts.shown++;
+ } else if (event.type === "TabHide") {
+ this._counts.hidden++;
+ } else {
+ ok(false, `Unexpected event: ${event.type}`);
+ }
+ }
+
+ setExpectations(expectations) {
+ this._expectations = expectations;
+
+ this._counts = {
+ muted: 0,
+ unmuted: 0,
+ shown: 0,
+ hidden: 0,
+ };
+ }
+
+ checkExpectations() {
+ const { description, counters, tabUrl } = this._expectations;
+ Assert.deepEqual(
+ this._counts,
+ counters,
+ `${description} - events observed while swapping tab`
+ );
+ let browser = this.tab.linkedBrowser;
+ is(browser.currentURI.spec, tabUrl, `${description} - tab's currentURI`);
+
+ // Tabs without titles default to URLs without scheme, according to the
+ // logic of tabbrowser.js's setTabTitle/_setTabLabel.
+ // TODO bug 1695512: lazy tabs deviate from that expectation, so the title
+ // is the full URL instead of the URL with the scheme stripped.
+ let tabTitle = tabUrl;
+ is(browser.contentTitle, tabTitle, `${description} - tab's contentTitle`);
+ }
+}
+
+add_task(async function test_hidden_muted_lazy_tabs_and_swapping() {
+ const params = { createLazyBrowser: true };
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ const URL_HIDDEN = "http://example.com/hide";
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ const URL_MUTED = "http://example.com/mute";
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ const URL_NORMAL = "http://example.com/back";
+
+ const lazyTab = BrowserTestUtils.addTab(gBrowser, "", params);
+ const mutedTab = BrowserTestUtils.addTab(gBrowser, URL_MUTED, params);
+ const hiddenTab = BrowserTestUtils.addTab(gBrowser, URL_HIDDEN, params);
+ const normalTab = BrowserTestUtils.addTab(gBrowser, URL_NORMAL, params);
+
+ mutedTab.toggleMuteAudio();
+ gBrowser.hideTab(hiddenTab);
+
+ is(lazyTab.linkedPanel, "", "lazyTab is lazy");
+ is(hiddenTab.linkedPanel, "", "hiddenTab is lazy");
+ is(mutedTab.linkedPanel, "", "mutedTab is lazy");
+ is(normalTab.linkedPanel, "", "normalTab is lazy");
+
+ ok(mutedTab.linkedBrowser.audioMuted, "mutedTab is muted");
+ ok(hiddenTab.hidden, "hiddenTab is hidden");
+ ok(!lazyTab.linkedBrowser.audioMuted, "lazyTab was not muted");
+ ok(!lazyTab.hidden, "lazyTab was not hidden");
+
+ const tabEventTracker = new TabEventTracker(lazyTab);
+
+ tabEventTracker.setExpectations({
+ description: "mutedTab replaced lazyTab (initial)",
+ counters: {
+ muted: 1,
+ unmuted: 0,
+ shown: 0,
+ hidden: 0,
+ },
+ tabUrl: URL_MUTED,
+ });
+ gBrowser.swapBrowsersAndCloseOther(lazyTab, mutedTab);
+ tabEventTracker.checkExpectations();
+ is(lazyTab.linkedPanel, "", "muted lazyTab is still lazy");
+ ok(lazyTab.linkedBrowser.audioMuted, "muted lazyTab is now muted");
+ ok(!lazyTab.hidden, "muted lazyTab is not hidden");
+
+ tabEventTracker.setExpectations({
+ description: "hiddenTab replaced lazyTab/mutedTab",
+ counters: {
+ muted: 0,
+ unmuted: 1,
+ shown: 0,
+ hidden: 1,
+ },
+ tabUrl: URL_HIDDEN,
+ });
+ gBrowser.swapBrowsersAndCloseOther(lazyTab, hiddenTab);
+ tabEventTracker.checkExpectations();
+ is(lazyTab.linkedPanel, "", "hidden lazyTab is still lazy");
+ ok(!lazyTab.linkedBrowser.audioMuted, "hidden lazyTab is not muted any more");
+ ok(lazyTab.hidden, "hidden lazyTab has been hidden");
+
+ tabEventTracker.setExpectations({
+ description: "normalTab replaced lazyTab/hiddenTab",
+ counters: {
+ muted: 0,
+ unmuted: 0,
+ shown: 1,
+ hidden: 0,
+ },
+ tabUrl: URL_NORMAL,
+ });
+ gBrowser.swapBrowsersAndCloseOther(lazyTab, normalTab);
+ tabEventTracker.checkExpectations();
+ is(lazyTab.linkedPanel, "", "normal lazyTab is still lazy");
+ ok(!lazyTab.linkedBrowser.audioMuted, "normal lazyTab is not muted any more");
+ ok(!lazyTab.hidden, "normal lazyTab is not hidden any more");
+
+ BrowserTestUtils.removeTab(lazyTab);
+});
diff --git a/browser/base/content/test/tabs/browser_link_in_tab_title_and_url_prefilled_blank_page.js b/browser/base/content/test/tabs/browser_link_in_tab_title_and_url_prefilled_blank_page.js
new file mode 100644
index 0000000000..b573113481
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_link_in_tab_title_and_url_prefilled_blank_page.js
@@ -0,0 +1,139 @@
+/* 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/. */
+
+// Test the behavior of the tab and the urlbar when opening about:blank by clicking link.
+
+/* import-globals-from common_link_in_tab_title_and_url_prefilled.js */
+Services.scriptloader.loadSubScript(
+ "chrome://mochitests/content/browser/browser/base/content/test/tabs/common_link_in_tab_title_and_url_prefilled.js",
+ this
+);
+
+add_task(async function blank_target__foreground() {
+ await doTestInSameWindow({
+ link: "blank-page--blank-target",
+ openBy: OPEN_BY.CLICK,
+ openAs: OPEN_AS.FOREGROUND,
+ loadingState: {
+ tab: BLANK_TITLE,
+ urlbar: "",
+ },
+ async actionWhileLoading(onTabLoaded) {
+ info("Wait until loading the link target");
+ await onTabLoaded;
+ },
+ finalState: {
+ tab: BLANK_TITLE,
+ urlbar: "",
+ history: [BLANK_URL],
+ },
+ });
+});
+
+add_task(async function blank_target__background() {
+ await doTestInSameWindow({
+ link: "blank-page--blank-target",
+ openBy: OPEN_BY.CLICK,
+ openAs: OPEN_AS.BACKGROUND,
+ loadingState: {
+ tab: BLANK_TITLE,
+ urlbar: HOME_URL,
+ },
+ async actionWhileLoading(onTabLoaded) {
+ info("Wait until loading the link target");
+ await onTabLoaded;
+ },
+ finalState: {
+ tab: BLANK_TITLE,
+ urlbar: HOME_URL,
+ history: [BLANK_URL],
+ },
+ });
+});
+
+add_task(async function other_target__foreground() {
+ await doTestInSameWindow({
+ link: "blank-page--other-target",
+ openBy: OPEN_BY.CLICK,
+ openAs: OPEN_AS.FOREGROUND,
+ loadingState: {
+ tab: BLANK_TITLE,
+ urlbar: BLANK_URL,
+ },
+ async actionWhileLoading(onTabLoaded) {
+ info("Wait until loading the link target");
+ await onTabLoaded;
+ },
+ finalState: {
+ tab: BLANK_TITLE,
+ urlbar: BLANK_URL,
+ history: [BLANK_URL],
+ },
+ });
+});
+
+add_task(async function other_target__background() {
+ await doTestInSameWindow({
+ link: "blank-page--other-target",
+ openBy: OPEN_BY.CLICK,
+ openAs: OPEN_AS.BACKGROUND,
+ loadingState: {
+ tab: BLANK_TITLE,
+ urlbar: HOME_URL,
+ },
+ async actionWhileLoading(onTabLoaded) {
+ info("Wait until loading the link target");
+ await onTabLoaded;
+ },
+ finalState: {
+ tab: BLANK_TITLE,
+ urlbar: HOME_URL,
+ history: [BLANK_URL],
+ },
+ });
+});
+
+add_task(async function by_script() {
+ await doTestInSameWindow({
+ link: "blank-page--by-script",
+ openBy: OPEN_BY.CLICK,
+ openAs: OPEN_AS.FOREGROUND,
+ loadingState: {
+ tab: BLANK_TITLE,
+ urlbar: BLANK_URL,
+ },
+ async actionWhileLoading(onTabLoaded) {
+ info("Wait until loading the link target");
+ await onTabLoaded;
+ },
+ finalState: {
+ tab: BLANK_TITLE,
+ urlbar: BLANK_URL,
+ history: [BLANK_URL],
+ },
+ });
+});
+
+add_task(async function no_target() {
+ await doTestInSameWindow({
+ link: "blank-page--no-target",
+ openBy: OPEN_BY.CLICK,
+ openAs: OPEN_AS.FOREGROUND,
+ loadingState: {
+ // Inherit the title and URL until finishing loading a new link when the
+ // link is opened in same tab.
+ tab: HOME_TITLE,
+ urlbar: HOME_URL,
+ },
+ async actionWhileLoading(onTabLoaded) {
+ info("Wait until loading the link target");
+ await onTabLoaded;
+ },
+ finalState: {
+ tab: BLANK_TITLE,
+ urlbar: BLANK_URL,
+ history: [HOME_URL, BLANK_URL],
+ },
+ });
+});
diff --git a/browser/base/content/test/tabs/browser_link_in_tab_title_and_url_prefilled_new_window.js b/browser/base/content/test/tabs/browser_link_in_tab_title_and_url_prefilled_new_window.js
new file mode 100644
index 0000000000..6d18887941
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_link_in_tab_title_and_url_prefilled_new_window.js
@@ -0,0 +1,54 @@
+/* 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/. */
+
+// Test the behavior of the tab and the urlbar on new window opened by clicking
+// link.
+
+/* import-globals-from common_link_in_tab_title_and_url_prefilled.js */
+Services.scriptloader.loadSubScript(
+ "chrome://mochitests/content/browser/browser/base/content/test/tabs/common_link_in_tab_title_and_url_prefilled.js",
+ this
+);
+
+add_task(async function normal_page__blank_target() {
+ await doTestWithNewWindow({
+ link: "wait-a-bit--blank-target",
+ expectedSetURICalled: true,
+ });
+});
+
+add_task(async function normal_page__other_target() {
+ await doTestWithNewWindow({
+ link: "wait-a-bit--other-target",
+ expectedSetURICalled: false,
+ });
+});
+
+add_task(async function normal_page__by_script() {
+ await doTestWithNewWindow({
+ link: "wait-a-bit--by-script",
+ expectedSetURICalled: false,
+ });
+});
+
+add_task(async function blank_page__blank_target() {
+ await doTestWithNewWindow({
+ link: "blank-page--blank-target",
+ expectedSetURICalled: false,
+ });
+});
+
+add_task(async function blank_page__other_target() {
+ await doTestWithNewWindow({
+ link: "blank-page--other-target",
+ expectedSetURICalled: false,
+ });
+});
+
+add_task(async function blank_page__by_script() {
+ await doTestWithNewWindow({
+ link: "blank-page--by-script",
+ expectedSetURICalled: false,
+ });
+});
diff --git a/browser/base/content/test/tabs/browser_link_in_tab_title_and_url_prefilled_normal_page_blank_target.js b/browser/base/content/test/tabs/browser_link_in_tab_title_and_url_prefilled_normal_page_blank_target.js
new file mode 100644
index 0000000000..fa7bd3fa7e
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_link_in_tab_title_and_url_prefilled_normal_page_blank_target.js
@@ -0,0 +1,199 @@
+/* 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/. */
+
+// Test the behavior of the tab and the urlbar when opening normal web page by
+// clicking link that the target is "_blank".
+
+/* import-globals-from common_link_in_tab_title_and_url_prefilled.js */
+Services.scriptloader.loadSubScript(
+ "chrome://mochitests/content/browser/browser/base/content/test/tabs/common_link_in_tab_title_and_url_prefilled.js",
+ this
+);
+
+add_task(async function normal_page__foreground__click() {
+ await doTestInSameWindow({
+ link: "wait-a-bit--blank-target",
+ openBy: OPEN_BY.CLICK,
+ openAs: OPEN_AS.FOREGROUND,
+ loadingState: {
+ tab: WAIT_A_BIT_LOADING_TITLE,
+ urlbar: WAIT_A_BIT_URL,
+ },
+ async actionWhileLoading(onTabLoaded) {
+ info("Wait until loading the link target");
+ await onTabLoaded;
+ },
+ finalState: {
+ tab: WAIT_A_BIT_PAGE_TITLE,
+ urlbar: WAIT_A_BIT_URL,
+ history: [WAIT_A_BIT_URL],
+ },
+ });
+});
+
+add_task(async function normal_page__foreground__contextmenu() {
+ await doTestInSameWindow({
+ link: "wait-a-bit--blank-target",
+ openBy: OPEN_BY.CONTEXT_MENU,
+ openAs: OPEN_AS.FOREGROUND,
+ loadingState: {
+ tab: WAIT_A_BIT_LOADING_TITLE,
+ urlbar: WAIT_A_BIT_URL,
+ },
+ async actionWhileLoading(onTabLoaded) {
+ info("Wait until loading the link target");
+ await onTabLoaded;
+ },
+ finalState: {
+ tab: WAIT_A_BIT_PAGE_TITLE,
+ urlbar: WAIT_A_BIT_URL,
+ history: [WAIT_A_BIT_URL],
+ },
+ });
+});
+
+add_task(async function normal_page__foreground__abort() {
+ await doTestInSameWindow({
+ link: "wait-a-bit--blank-target",
+ openBy: OPEN_BY.CLICK,
+ openAs: OPEN_AS.FOREGROUND,
+ loadingState: {
+ tab: WAIT_A_BIT_LOADING_TITLE,
+ urlbar: WAIT_A_BIT_URL,
+ },
+ async actionWhileLoading(onTabLoaded) {
+ info("Abort loading");
+ document.getElementById("stop-button").click();
+ },
+ finalState: {
+ tab: WAIT_A_BIT_LOADING_TITLE,
+ urlbar: WAIT_A_BIT_URL,
+ history: [WAIT_A_BIT_URL],
+ },
+ });
+});
+
+add_task(async function normal_page__foreground__timeout() {
+ await doTestInSameWindow({
+ link: "request-timeout--blank-target",
+ openBy: OPEN_BY.CLICK,
+ openAs: OPEN_AS.FOREGROUND,
+ loadingState: {
+ tab: REQUEST_TIMEOUT_LOADING_TITLE,
+ urlbar: REQUEST_TIMEOUT_URL,
+ },
+ async actionWhileLoading(onTabLoaded) {
+ info("Wait until loading the link target");
+ await onTabLoaded;
+ },
+ finalState: {
+ tab: REQUEST_TIMEOUT_LOADING_TITLE,
+ urlbar: REQUEST_TIMEOUT_URL,
+ history: [REQUEST_TIMEOUT_URL],
+ },
+ });
+});
+
+add_task(async function normal_page__foreground__session_restore() {
+ await doSessionRestoreTest({
+ link: "wait-a-bit--blank-target",
+ openBy: OPEN_BY.CLICK,
+ openAs: OPEN_AS.FOREGROUND,
+ expectedSessionHistory: [WAIT_A_BIT_URL],
+ expectedSessionRestored: true,
+ });
+});
+
+add_task(async function normal_page__background__click() {
+ await doTestInSameWindow({
+ link: "wait-a-bit--blank-target",
+ openBy: OPEN_BY.CLICK,
+ openAs: OPEN_AS.BACKGROUND,
+ loadingState: {
+ tab: WAIT_A_BIT_LOADING_TITLE,
+ urlbar: HOME_URL,
+ },
+ async actionWhileLoading(onTabLoaded) {
+ info("Wait until loading the link target");
+ await onTabLoaded;
+ },
+ finalState: {
+ tab: WAIT_A_BIT_PAGE_TITLE,
+ urlbar: HOME_URL,
+ history: [WAIT_A_BIT_URL],
+ },
+ });
+});
+
+add_task(async function normal_page__background__contextmenu() {
+ await doTestInSameWindow({
+ link: "wait-a-bit--blank-target",
+ openBy: OPEN_BY.CONTEXT_MENU,
+ openAs: OPEN_AS.BACKGROUND,
+ loadingState: {
+ tab: WAIT_A_BIT_LOADING_TITLE,
+ urlbar: HOME_URL,
+ },
+ async actionWhileLoading(onTabLoaded) {
+ info("Wait until loading the link target");
+ await onTabLoaded;
+ },
+ finalState: {
+ tab: WAIT_A_BIT_PAGE_TITLE,
+ urlbar: HOME_URL,
+ history: [WAIT_A_BIT_URL],
+ },
+ });
+});
+
+add_task(async function normal_page__background__abort() {
+ await doTestInSameWindow({
+ link: "wait-a-bit--blank-target",
+ openBy: OPEN_BY.CLICK,
+ openAs: OPEN_AS.BACKGROUND,
+ loadingState: {
+ tab: WAIT_A_BIT_LOADING_TITLE,
+ urlbar: HOME_URL,
+ },
+ async actionWhileLoading(onTabLoaded) {
+ info("Abort loading");
+ document.getElementById("stop-button").click();
+ },
+ finalState: {
+ tab: WAIT_A_BIT_LOADING_TITLE,
+ urlbar: HOME_URL,
+ history: [],
+ },
+ });
+});
+
+add_task(async function normal_page__background__timeout() {
+ await doTestInSameWindow({
+ link: "request-timeout--blank-target",
+ openBy: OPEN_BY.CLICK,
+ openAs: OPEN_AS.BACKGROUND,
+ loadingState: {
+ tab: REQUEST_TIMEOUT_LOADING_TITLE,
+ urlbar: HOME_URL,
+ },
+ async actionWhileLoading(onTabLoaded) {
+ info("Wait until loading the link target");
+ await onTabLoaded;
+ },
+ finalState: {
+ tab: REQUEST_TIMEOUT_LOADING_TITLE,
+ urlbar: HOME_URL,
+ history: [REQUEST_TIMEOUT_URL],
+ },
+ });
+});
+
+add_task(async function normal_page__background__session_restore() {
+ await doSessionRestoreTest({
+ link: "wait-a-bit--blank-target",
+ openBy: OPEN_BY.CLICK,
+ openAs: OPEN_AS.BACKGROUND,
+ expectedSessionRestored: false,
+ });
+});
diff --git a/browser/base/content/test/tabs/browser_link_in_tab_title_and_url_prefilled_normal_page_by_script.js b/browser/base/content/test/tabs/browser_link_in_tab_title_and_url_prefilled_normal_page_by_script.js
new file mode 100644
index 0000000000..1284ba675f
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_link_in_tab_title_and_url_prefilled_normal_page_by_script.js
@@ -0,0 +1,84 @@
+/* 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/. */
+
+// Test the behavior of the tab and the urlbar when opening normal web page by
+// clicking link that opens by script.
+
+/* import-globals-from common_link_in_tab_title_and_url_prefilled.js */
+Services.scriptloader.loadSubScript(
+ "chrome://mochitests/content/browser/browser/base/content/test/tabs/common_link_in_tab_title_and_url_prefilled.js",
+ this
+);
+
+add_task(async function normal_page__by_script() {
+ await doTestInSameWindow({
+ link: "wait-a-bit--by-script",
+ openBy: OPEN_BY.CLICK,
+ openAs: OPEN_AS.FOREGROUND,
+ loadingState: {
+ tab: BLANK_TITLE,
+ urlbar: BLANK_URL,
+ },
+ async actionWhileLoading(onTabLoaded) {
+ info("Wait until loading the link target");
+ await onTabLoaded;
+ },
+ finalState: {
+ tab: WAIT_A_BIT_PAGE_TITLE,
+ urlbar: WAIT_A_BIT_URL,
+ history: [WAIT_A_BIT_URL],
+ },
+ });
+});
+
+add_task(async function normal_page__by_script__abort() {
+ await doTestInSameWindow({
+ link: "wait-a-bit--by-script",
+ openBy: OPEN_BY.CLICK,
+ openAs: OPEN_AS.FOREGROUND,
+ loadingState: {
+ tab: BLANK_TITLE,
+ urlbar: BLANK_URL,
+ },
+ async actionWhileLoading(onTabLoaded) {
+ info("Abort loading");
+ document.getElementById("stop-button").click();
+ },
+ finalState: {
+ tab: BLANK_TITLE,
+ urlbar: BLANK_URL,
+ history: [],
+ },
+ });
+});
+
+add_task(async function normal_page__by_script__timeout() {
+ await doTestInSameWindow({
+ link: "request-timeout--by-script",
+ openBy: OPEN_BY.CLICK,
+ openAs: OPEN_AS.FOREGROUND,
+ loadingState: {
+ tab: BLANK_TITLE,
+ urlbar: BLANK_URL,
+ },
+ async actionWhileLoading(onTabLoaded) {
+ info("Wait until loading the link target");
+ await onTabLoaded;
+ },
+ finalState: {
+ tab: REQUEST_TIMEOUT_LOADING_TITLE,
+ urlbar: REQUEST_TIMEOUT_URL,
+ history: [REQUEST_TIMEOUT_URL],
+ },
+ });
+});
+
+add_task(async function normal_page__by_script__session_restore() {
+ await doSessionRestoreTest({
+ link: "wait-a-bit--by-script",
+ openBy: OPEN_BY.CLICK,
+ openAs: OPEN_AS.FOREGROUND,
+ expectedSessionRestored: false,
+ });
+});
diff --git a/browser/base/content/test/tabs/browser_link_in_tab_title_and_url_prefilled_normal_page_no_target.js b/browser/base/content/test/tabs/browser_link_in_tab_title_and_url_prefilled_normal_page_no_target.js
new file mode 100644
index 0000000000..87544589d7
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_link_in_tab_title_and_url_prefilled_normal_page_no_target.js
@@ -0,0 +1,86 @@
+/* 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/. */
+
+// Test the behavior of the tab and the urlbar when opening normal web page by
+// clicking link that has no target.
+
+/* import-globals-from common_link_in_tab_title_and_url_prefilled.js */
+Services.scriptloader.loadSubScript(
+ "chrome://mochitests/content/browser/browser/base/content/test/tabs/common_link_in_tab_title_and_url_prefilled.js",
+ this
+);
+
+add_task(async function normal_page__no_target() {
+ await doTestInSameWindow({
+ link: "wait-a-bit--no-target",
+ openBy: OPEN_BY.CLICK,
+ openAs: OPEN_AS.FOREGROUND,
+ loadingState: {
+ // Inherit the title and URL until finishing loading a new link when the
+ // link is opened in same tab.
+ tab: HOME_TITLE,
+ urlbar: HOME_URL,
+ },
+ async actionWhileLoading(onTabLoaded) {
+ info("Wait until loading the link target");
+ await onTabLoaded;
+ },
+ finalState: {
+ tab: WAIT_A_BIT_PAGE_TITLE,
+ urlbar: WAIT_A_BIT_URL,
+ history: [HOME_URL, WAIT_A_BIT_URL],
+ },
+ });
+});
+
+add_task(async function normal_page__no_target__abort() {
+ await doTestInSameWindow({
+ link: "wait-a-bit--no-target",
+ openBy: OPEN_BY.CLICK,
+ openAs: OPEN_AS.FOREGROUND,
+ loadingState: {
+ tab: HOME_TITLE,
+ urlbar: HOME_URL,
+ },
+ async actionWhileLoading(onTabLoaded) {
+ info("Abort loading");
+ document.getElementById("stop-button").click();
+ },
+ finalState: {
+ tab: HOME_TITLE,
+ urlbar: HOME_URL,
+ history: [HOME_URL],
+ },
+ });
+});
+
+add_task(async function normal_page__no_target__timeout() {
+ await doTestInSameWindow({
+ link: "request-timeout--no-target",
+ openBy: OPEN_BY.CLICK,
+ openAs: OPEN_AS.FOREGROUND,
+ loadingState: {
+ tab: HOME_TITLE,
+ urlbar: HOME_URL,
+ },
+ async actionWhileLoading(onTabLoaded) {
+ info("Wait until loading the link target");
+ await onTabLoaded;
+ },
+ finalState: {
+ tab: REQUEST_TIMEOUT_LOADING_TITLE,
+ urlbar: REQUEST_TIMEOUT_URL,
+ history: [HOME_URL, REQUEST_TIMEOUT_URL],
+ },
+ });
+});
+
+add_task(async function normal_page__no_target__session_restore() {
+ await doSessionRestoreTest({
+ link: "wait-a-bit--no-target",
+ openBy: OPEN_BY.CLICK,
+ openAs: OPEN_AS.FOREGROUND,
+ expectedSessionRestored: false,
+ });
+});
diff --git a/browser/base/content/test/tabs/browser_link_in_tab_title_and_url_prefilled_normal_page_other_target.js b/browser/base/content/test/tabs/browser_link_in_tab_title_and_url_prefilled_normal_page_other_target.js
new file mode 100644
index 0000000000..afc647415e
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_link_in_tab_title_and_url_prefilled_normal_page_other_target.js
@@ -0,0 +1,156 @@
+/* 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/. */
+
+// Test the behavior of the tab and the urlbar when opening normal web page by
+// clicking link that the target is "other".
+
+/* import-globals-from common_link_in_tab_title_and_url_prefilled.js */
+Services.scriptloader.loadSubScript(
+ "chrome://mochitests/content/browser/browser/base/content/test/tabs/common_link_in_tab_title_and_url_prefilled.js",
+ this
+);
+
+add_task(async function normal_page__other_target__foreground() {
+ await doTestInSameWindow({
+ link: "wait-a-bit--other-target",
+ openBy: OPEN_BY.CLICK,
+ openAs: OPEN_AS.FOREGROUND,
+ loadingState: {
+ tab: BLANK_TITLE,
+ urlbar: BLANK_URL,
+ },
+ async actionWhileLoading(onTabLoaded) {
+ info("Wait until loading the link target");
+ await onTabLoaded;
+ },
+ finalState: {
+ tab: WAIT_A_BIT_PAGE_TITLE,
+ urlbar: WAIT_A_BIT_URL,
+ history: [WAIT_A_BIT_URL],
+ },
+ });
+});
+
+add_task(async function normal_page__other_target__foreground__abort() {
+ await doTestInSameWindow({
+ link: "wait-a-bit--other-target",
+ openBy: OPEN_BY.CLICK,
+ openAs: OPEN_AS.FOREGROUND,
+ loadingState: {
+ tab: BLANK_TITLE,
+ urlbar: BLANK_URL,
+ },
+ async actionWhileLoading(onTabLoaded) {
+ info("Abort loading");
+ document.getElementById("stop-button").click();
+ },
+ finalState: {
+ tab: BLANK_TITLE,
+ urlbar: BLANK_URL,
+ history: [],
+ },
+ });
+});
+
+add_task(async function normal_page__other_target__foreground__timeout() {
+ await doTestInSameWindow({
+ link: "request-timeout--other-target",
+ openBy: OPEN_BY.CLICK,
+ openAs: OPEN_AS.FOREGROUND,
+ loadingState: {
+ tab: BLANK_TITLE,
+ urlbar: BLANK_URL,
+ },
+ async actionWhileLoading(onTabLoaded) {
+ info("Wait until loading the link target");
+ await onTabLoaded;
+ },
+ finalState: {
+ tab: REQUEST_TIMEOUT_LOADING_TITLE,
+ urlbar: REQUEST_TIMEOUT_URL,
+ history: [REQUEST_TIMEOUT_URL],
+ },
+ });
+});
+
+add_task(async function normal_page__foreground__session_restore() {
+ await doSessionRestoreTest({
+ link: "wait-a-bit--other-target",
+ openBy: OPEN_BY.CLICK,
+ openAs: OPEN_AS.FOREGROUND,
+ expectedSessionRestored: false,
+ });
+});
+
+add_task(async function normal_page__other_target__background() {
+ await doTestInSameWindow({
+ link: "wait-a-bit--other-target",
+ openBy: OPEN_BY.CLICK,
+ openAs: OPEN_AS.BACKGROUND,
+ loadingState: {
+ tab: WAIT_A_BIT_LOADING_TITLE,
+ urlbar: HOME_URL,
+ },
+ async actionWhileLoading(onTabLoaded) {
+ info("Wait until loading the link target");
+ await onTabLoaded;
+ },
+ finalState: {
+ tab: WAIT_A_BIT_PAGE_TITLE,
+ urlbar: HOME_URL,
+ history: [WAIT_A_BIT_URL],
+ },
+ });
+});
+
+add_task(async function normal_page__other_target__background__abort() {
+ await doTestInSameWindow({
+ link: "wait-a-bit--other-target",
+ openBy: OPEN_BY.CLICK,
+ openAs: OPEN_AS.BACKGROUND,
+ loadingState: {
+ tab: WAIT_A_BIT_LOADING_TITLE,
+ urlbar: HOME_URL,
+ },
+ async actionWhileLoading(onTabLoaded) {
+ info("Abort loading");
+ document.getElementById("stop-button").click();
+ },
+ finalState: {
+ tab: WAIT_A_BIT_LOADING_TITLE,
+ urlbar: HOME_URL,
+ history: [],
+ },
+ });
+});
+
+add_task(async function normal_page__other_target__background__timeout() {
+ await doTestInSameWindow({
+ link: "request-timeout--other-target",
+ openBy: OPEN_BY.CLICK,
+ openAs: OPEN_AS.BACKGROUND,
+ loadingState: {
+ tab: REQUEST_TIMEOUT_LOADING_TITLE,
+ urlbar: HOME_URL,
+ },
+ async actionWhileLoading(onTabLoaded) {
+ info("Wait until loading the link target");
+ await onTabLoaded;
+ },
+ finalState: {
+ tab: REQUEST_TIMEOUT_LOADING_TITLE,
+ urlbar: HOME_URL,
+ history: [REQUEST_TIMEOUT_URL],
+ },
+ });
+});
+
+add_task(async function normal_page__foreground__session_restore() {
+ await doSessionRestoreTest({
+ link: "wait-a-bit--other-target",
+ openBy: OPEN_BY.CLICK,
+ openAs: OPEN_AS.BACKGROUND,
+ expectedSessionRestored: false,
+ });
+});
diff --git a/browser/base/content/test/tabs/browser_long_data_url_label_truncation.js b/browser/base/content/test/tabs/browser_long_data_url_label_truncation.js
new file mode 100644
index 0000000000..db0571a2c0
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_long_data_url_label_truncation.js
@@ -0,0 +1,78 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Ensure that tab labels for base64 data: URLs are always truncated
+ * to ensure that we don't hang trying to paint really long overflown
+ * text runs.
+ * This becomes a performance issue with 1mb or so long data URIs;
+ * this test uses a much shorter one for simplicity's sake.
+ */
+add_task(async function test_ensure_truncation() {
+ const MOBY = `
+ <!DOCTYPE html>
+ <meta charset="utf-8">
+ Call me Ishmael. Some years ago—never mind how
+ long precisely—having little or no money in my purse, and nothing particular
+ to interest me on shore, I thought I would sail about a little and see the
+ watery part of the world. It is a way I have of driving off the spleen and
+ regulating the circulation. Whenever I find myself growing grim about the
+ mouth; whenever it is a damp, drizzly November in my soul; whenever I find
+ myself involuntarily pausing before coffin warehouses, and bringing up the
+ rear of every funeral I meet; and especially whenever my hypos get such an
+ upper hand of me, that it requires a strong moral principle to prevent me
+ from deliberately stepping into the street, and methodically knocking
+ people's hats off—then, I account it high time to get to sea as soon as I
+ can. This is my substitute for pistol and ball. With a philosophical
+ flourish Cato throws himself upon his sword; I quietly take to the ship.
+ There is nothing surprising in this. If they but knew it, almost all men in
+ their degree, some time or other, cherish very nearly the same feelings
+ towards the ocean with me.`;
+
+ let fileReader = new FileReader();
+ const DATA_URL = await new Promise(resolve => {
+ fileReader.addEventListener("load", e => resolve(fileReader.result));
+ fileReader.readAsDataURL(new Blob([MOBY], { type: "text/html" }));
+ });
+ // Substring the full URL to avoid log clutter because Assert will print
+ // the entire thing.
+ Assert.stringContains(
+ DATA_URL.substring(0, 30),
+ "base64",
+ "data URL needs to be base64"
+ );
+
+ let newTab;
+ function tabLabelChecker() {
+ Assert.lessOrEqual(
+ newTab.label.length,
+ 501,
+ "Tab label should not exceed 500 chars + ellipsis."
+ );
+ }
+ let mutationObserver = new MutationObserver(tabLabelChecker);
+ registerCleanupFunction(() => mutationObserver.disconnect());
+
+ gBrowser.tabContainer.addEventListener(
+ "TabOpen",
+ event => {
+ newTab = event.target;
+ tabLabelChecker();
+ mutationObserver.observe(newTab, {
+ attributeFilter: ["label"],
+ });
+ },
+ { once: true }
+ );
+
+ await BrowserTestUtils.withNewTab(DATA_URL, async () => {
+ // Wait another longer-than-tick to ensure more mutation observer things have
+ // come in.
+ await new Promise(executeSoon);
+
+ // Check one last time for good measure, for the final label:
+ tabLabelChecker();
+ });
+});
diff --git a/browser/base/content/test/tabs/browser_middle_click_new_tab_button_loads_clipboard.js b/browser/base/content/test/tabs/browser_middle_click_new_tab_button_loads_clipboard.js
new file mode 100644
index 0000000000..07d0be0232
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_middle_click_new_tab_button_loads_clipboard.js
@@ -0,0 +1,255 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const searchclipboardforPref = "browser.tabs.searchclipboardfor.middleclick";
+
+const { SearchTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/SearchTestUtils.sys.mjs"
+);
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ [searchclipboardforPref, true],
+ // set preloading to false so we can await the new tab being opened.
+ ["browser.newtab.preload", false],
+ ],
+ });
+ NewTabPagePreloading.removePreloadedBrowser(window);
+ // Create an engine to use for the test.
+ SearchTestUtils.init(this);
+ await SearchTestUtils.installSearchExtension(
+ {
+ name: "MozSearch",
+ search_url: "https://example.org/",
+ search_url_get_params: "q={searchTerms}",
+ },
+ { setAsDefaultPrivate: true }
+ );
+ // We overflow tabs, close all the extra ones.
+ registerCleanupFunction(() => {
+ while (gBrowser.tabs.length > 1) {
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ }
+ });
+});
+
+add_task(async function middleclick_tabs_newtab_button_with_url_in_clipboard() {
+ var previousTabsLength = gBrowser.tabs.length;
+ info("Previous tabs count is " + previousTabsLength);
+
+ let url = "javascript:https://www.example.com/";
+ let safeUrl = "https://www.example.com/";
+ await new Promise((resolve, reject) => {
+ SimpleTest.waitForClipboard(
+ url,
+ () => {
+ Cc["@mozilla.org/widget/clipboardhelper;1"]
+ .getService(Ci.nsIClipboardHelper)
+ .copyString(url);
+ },
+ resolve,
+ () => {
+ ok(false, "Clipboard copy failed");
+ reject();
+ }
+ );
+ });
+
+ info("Middle clicking 'new tab' button");
+ let promiseTabLoaded = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ safeUrl,
+ true
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ document.getElementById("tabs-newtab-button"),
+ { button: 1 }
+ );
+
+ await promiseTabLoaded;
+ is(gBrowser.tabs.length, previousTabsLength + 1, "We created a tab");
+ is(
+ gBrowser.currentURI.spec,
+ safeUrl,
+ "New Tab URL is the safe content of the clipboard"
+ );
+
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+});
+
+add_task(
+ async function middleclick_tabs_newtab_button_with_word_in_clipboard() {
+ var previousTabsLength = gBrowser.tabs.length;
+ info("Previous tabs count is " + previousTabsLength);
+
+ let word = "word";
+ let searchUrl = "https://example.org/?q=word";
+ await new Promise((resolve, reject) => {
+ SimpleTest.waitForClipboard(
+ word,
+ () => {
+ Cc["@mozilla.org/widget/clipboardhelper;1"]
+ .getService(Ci.nsIClipboardHelper)
+ .copyString(word);
+ },
+ resolve,
+ () => {
+ ok(false, "Clipboard copy failed");
+ reject();
+ }
+ );
+ });
+
+ info("Middle clicking 'new tab' button");
+ let promiseTabLoaded = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ searchUrl,
+ true
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ document.getElementById("tabs-newtab-button"),
+ { button: 1 }
+ );
+
+ await promiseTabLoaded;
+ is(gBrowser.tabs.length, previousTabsLength + 1, "We created a tab");
+ is(
+ gBrowser.currentURI.spec,
+ searchUrl,
+ "New Tab URL is the search engine with the content of the clipboard"
+ );
+
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ }
+);
+
+add_task(async function middleclick_new_tab_button_with_url_in_clipboard() {
+ await BrowserTestUtils.overflowTabs(registerCleanupFunction, window);
+ await BrowserTestUtils.waitForCondition(() => {
+ return Array.from(gBrowser.tabs).every(tab => tab._fullyOpen);
+ });
+
+ var previousTabsLength = gBrowser.tabs.length;
+ info("Previous tabs count is " + previousTabsLength);
+
+ let url = "javascript:https://www.example.com/";
+ let safeUrl = "https://www.example.com/";
+ await new Promise((resolve, reject) => {
+ SimpleTest.waitForClipboard(
+ url,
+ () => {
+ Cc["@mozilla.org/widget/clipboardhelper;1"]
+ .getService(Ci.nsIClipboardHelper)
+ .copyString(url);
+ },
+ resolve,
+ () => {
+ ok(false, "Clipboard copy failed");
+ reject();
+ }
+ );
+ });
+
+ info("Middle clicking 'new tab' button");
+ let promiseTabLoaded = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ safeUrl,
+ true
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ document.getElementById("new-tab-button"),
+ { button: 1 }
+ );
+
+ await promiseTabLoaded;
+ is(gBrowser.tabs.length, previousTabsLength + 1, "We created a tab");
+ is(
+ gBrowser.currentURI.spec,
+ safeUrl,
+ "New Tab URL is the safe content of the clipboard"
+ );
+
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+});
+
+add_task(async function middleclick_new_tab_button_with_word_in_clipboard() {
+ var previousTabsLength = gBrowser.tabs.length;
+ info("Previous tabs count is " + previousTabsLength);
+
+ let word = "word";
+ let searchUrl = "https://example.org/?q=word";
+ await new Promise((resolve, reject) => {
+ SimpleTest.waitForClipboard(
+ word,
+ () => {
+ Cc["@mozilla.org/widget/clipboardhelper;1"]
+ .getService(Ci.nsIClipboardHelper)
+ .copyString(word);
+ },
+ resolve,
+ () => {
+ ok(false, "Clipboard copy failed");
+ reject();
+ }
+ );
+ });
+
+ info("Middle clicking 'new tab' button");
+ let promiseTabLoaded = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ searchUrl,
+ true
+ );
+ EventUtils.synthesizeMouseAtCenter(
+ document.getElementById("new-tab-button"),
+ { button: 1 }
+ );
+
+ await promiseTabLoaded;
+ is(gBrowser.tabs.length, previousTabsLength + 1, "We created a tab");
+ is(
+ gBrowser.currentURI.spec,
+ searchUrl,
+ "New Tab URL is the search engine with the content of the clipboard"
+ );
+
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+});
+
+add_task(async function middleclick_new_tab_button_with_spaces_in_clipboard() {
+ let spaces = " \n ";
+ await new Promise((resolve, reject) => {
+ SimpleTest.waitForClipboard(
+ spaces,
+ () => {
+ Cc["@mozilla.org/widget/clipboardhelper;1"]
+ .getService(Ci.nsIClipboardHelper)
+ .copyString(spaces);
+ },
+ resolve,
+ () => {
+ ok(false, "Clipboard copy failed");
+ reject();
+ }
+ );
+ });
+
+ info("Middle clicking 'new tab' button");
+ let promiseTabOpened = BrowserTestUtils.waitForNewTab(gBrowser);
+ EventUtils.synthesizeMouseAtCenter(
+ document.getElementById("new-tab-button"),
+ { button: 1 }
+ );
+
+ await promiseTabOpened;
+ is(
+ gBrowser.currentURI.spec,
+ "about:newtab",
+ "New Tab URL is the regular new tab page."
+ );
+
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+});
diff --git a/browser/base/content/test/tabs/browser_multiselect_tabs_active_tab_selected_by_default.js b/browser/base/content/test/tabs/browser_multiselect_tabs_active_tab_selected_by_default.js
new file mode 100644
index 0000000000..8edf56d3d4
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_multiselect_tabs_active_tab_selected_by_default.js
@@ -0,0 +1,52 @@
+add_task(async function multiselectActiveTabByDefault() {
+ const tab1 = await addTab();
+ const tab2 = await addTab();
+ const tab3 = await addTab();
+
+ await BrowserTestUtils.switchTab(gBrowser, tab1);
+
+ info("Try multiselecting Tab1 (active) with click+CtrlKey");
+ await triggerClickOn(tab1, { ctrlKey: true });
+
+ is(gBrowser.selectedTab, tab1, "Tab1 is active");
+ ok(
+ !tab1.multiselected,
+ "Tab1 is not multi-selected because we are not in multi-select context yet"
+ );
+ ok(!tab2.multiselected, "Tab2 is not multi-selected");
+ ok(!tab3.multiselected, "Tab3 is not multi-selected");
+ is(gBrowser.multiSelectedTabsCount, 0, "Zero tabs multi-selected");
+
+ info("We multi-select tab1 and tab2 with ctrl key down");
+ await triggerClickOn(tab2, { ctrlKey: true });
+ await triggerClickOn(tab3, { ctrlKey: true });
+
+ is(gBrowser.selectedTab, tab1, "Tab1 is active");
+ ok(tab1.multiselected, "Tab1 is multi-selected");
+ ok(tab2.multiselected, "Tab2 is multi-selected");
+ ok(tab3.multiselected, "Tab3 is multi-selected");
+ is(gBrowser.multiSelectedTabsCount, 3, "Three tabs multi-selected");
+ is(
+ gBrowser.lastMultiSelectedTab,
+ tab3,
+ "Tab3 is the last multi-selected tab"
+ );
+
+ info("Unselect tab1 from multi-selection using ctrlKey");
+
+ await BrowserTestUtils.switchTab(
+ gBrowser,
+ triggerClickOn(tab1, { ctrlKey: true })
+ );
+
+ isnot(gBrowser.selectedTab, tab1, "Tab1 is not active anymore");
+ is(gBrowser.selectedTab, tab3, "Tab3 is active");
+ ok(!tab1.multiselected, "Tab1 is not multi-selected");
+ ok(tab2.multiselected, "Tab2 is multi-selected");
+ ok(tab3.multiselected, "Tab3 is multi-selected");
+ is(gBrowser.multiSelectedTabsCount, 2, "Two tabs multi-selected");
+
+ BrowserTestUtils.removeTab(tab1);
+ BrowserTestUtils.removeTab(tab2);
+ BrowserTestUtils.removeTab(tab3);
+});
diff --git a/browser/base/content/test/tabs/browser_multiselect_tabs_bookmark.js b/browser/base/content/test/tabs/browser_multiselect_tabs_bookmark.js
new file mode 100644
index 0000000000..a24e72c0bb
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_multiselect_tabs_bookmark.js
@@ -0,0 +1,81 @@
+/* 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/. */
+
+async function addTab_example_com() {
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ const tab = BrowserTestUtils.addTab(gBrowser, "http://example.com/", {
+ skipAnimation: true,
+ });
+ const browser = gBrowser.getBrowserForTab(tab);
+ await BrowserTestUtils.browserLoaded(browser);
+ return tab;
+}
+
+add_task(async function test() {
+ let tab1 = await addTab();
+ let tab2 = await addTab();
+ let tab3 = await addTab();
+
+ let menuItemBookmarkTab = document.getElementById("context_bookmarkTab");
+ let menuItemBookmarkSelectedTabs = document.getElementById(
+ "context_bookmarkSelectedTabs"
+ );
+
+ is(gBrowser.multiSelectedTabsCount, 0, "Zero multiselected tabs");
+
+ await BrowserTestUtils.switchTab(gBrowser, tab1);
+ await triggerClickOn(tab2, { ctrlKey: true });
+
+ ok(tab1.multiselected, "Tab1 is multiselected");
+ ok(tab2.multiselected, "Tab2 is multiselected");
+ ok(!tab3.multiselected, "Tab3 is not multiselected");
+
+ // Check the context menu with a non-multiselected tab
+ updateTabContextMenu(tab3);
+ is(menuItemBookmarkTab.hidden, false, "Bookmark Tab is visible");
+ is(
+ menuItemBookmarkSelectedTabs.hidden,
+ true,
+ "Bookmark Selected Tabs is hidden"
+ );
+
+ // Check the context menu with a multiselected tab and one unique page in the selection.
+ updateTabContextMenu(tab2);
+ is(menuItemBookmarkTab.hidden, true, "Bookmark Tab is visible");
+ is(
+ menuItemBookmarkSelectedTabs.hidden,
+ false,
+ "Bookmark Selected Tabs is hidden"
+ );
+ is(
+ PlacesCommandHook.uniqueSelectedPages.length,
+ 1,
+ "No more than one unique selected page"
+ );
+
+ info("Add a different page to selection");
+ let tab4 = await addTab_example_com();
+ await triggerClickOn(tab4, { ctrlKey: true });
+
+ ok(tab4.multiselected, "Tab4 is multiselected");
+ is(gBrowser.multiSelectedTabsCount, 3, "Three multiselected tabs");
+
+ // Check the context menu with a multiselected tab and two unique pages in the selection.
+ updateTabContextMenu(tab2);
+ is(menuItemBookmarkTab.hidden, true, "Bookmark Tab is visible");
+ is(
+ menuItemBookmarkSelectedTabs.hidden,
+ false,
+ "Bookmark Selected Tabs is hidden"
+ );
+ is(
+ PlacesCommandHook.uniqueSelectedPages.length,
+ 2,
+ "More than one unique selected page"
+ );
+
+ for (let tab of [tab1, tab2, tab3, tab4]) {
+ BrowserTestUtils.removeTab(tab);
+ }
+});
diff --git a/browser/base/content/test/tabs/browser_multiselect_tabs_clear_selection_when_tab_switch.js b/browser/base/content/test/tabs/browser_multiselect_tabs_clear_selection_when_tab_switch.js
new file mode 100644
index 0000000000..6e75e29c9a
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_multiselect_tabs_clear_selection_when_tab_switch.js
@@ -0,0 +1,33 @@
+add_task(async function test() {
+ let initialTab = gBrowser.selectedTab;
+ let tab1 = await addTab();
+ let tab2 = await addTab();
+ let tab3 = await addTab();
+
+ is(gBrowser.multiSelectedTabsCount, 0, "Zero multiselected tabs");
+
+ for (let tab of [tab1, tab2, tab3]) {
+ await triggerClickOn(tab, { ctrlKey: true });
+ }
+
+ is(gBrowser.multiSelectedTabsCount, 4, "Four multiselected tabs");
+ is(gBrowser.selectedTab, initialTab, "InitialTab is the active tab");
+
+ info("Un-select the active tab");
+ await BrowserTestUtils.switchTab(
+ gBrowser,
+ triggerClickOn(initialTab, { ctrlKey: true })
+ );
+
+ is(gBrowser.multiSelectedTabsCount, 3, "Three multiselected tabs");
+ is(gBrowser.selectedTab, tab3, "Tab3 is the active tab");
+
+ await BrowserTestUtils.switchTab(gBrowser, tab1);
+
+ is(gBrowser.multiSelectedTabsCount, 0, "Selection cleared after tab-switch");
+ is(gBrowser.selectedTab, tab1, "Tab1 is the active tab");
+
+ for (let tab of [tab1, tab2, tab3]) {
+ BrowserTestUtils.removeTab(tab);
+ }
+});
diff --git a/browser/base/content/test/tabs/browser_multiselect_tabs_close.js b/browser/base/content/test/tabs/browser_multiselect_tabs_close.js
new file mode 100644
index 0000000000..2d2295c14a
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_multiselect_tabs_close.js
@@ -0,0 +1,192 @@
+const PREF_WARN_ON_CLOSE = "browser.tabs.warnOnCloseOtherTabs";
+
+async function openTabMenuFor(tab) {
+ let tabMenu = tab.ownerDocument.getElementById("tabContextMenu");
+
+ let tabMenuShown = BrowserTestUtils.waitForEvent(tabMenu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ tab,
+ { type: "contextmenu" },
+ tab.ownerGlobal
+ );
+ await tabMenuShown;
+
+ return tabMenu;
+}
+
+add_task(async function setPref() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[PREF_WARN_ON_CLOSE, false]],
+ });
+});
+
+add_task(async function usingTabCloseButton() {
+ let tab1 = await addTab();
+ let tab2 = await addTab();
+ let tab3 = await addTab();
+ let tab4 = await addTab();
+
+ is(gBrowser.multiSelectedTabsCount, 0, "Zero multiselected tabs");
+
+ await BrowserTestUtils.switchTab(gBrowser, tab1);
+ await triggerClickOn(tab2, { ctrlKey: true });
+
+ ok(tab1.multiselected, "Tab1 is multiselected");
+ ok(tab2.multiselected, "Tab2 is multiselected");
+ ok(!tab3.multiselected, "Tab3 is not multiselected");
+ ok(!tab4.multiselected, "Tab4 is not multiselected");
+ is(gBrowser.multiSelectedTabsCount, 2, "Two multiselected tabs");
+ is(gBrowser.selectedTab, tab1, "Tab1 is active");
+
+ await triggerClickOn(tab3, { ctrlKey: true });
+ is(gBrowser.multiSelectedTabsCount, 3, "Three multiselected tabs");
+ gBrowser.hideTab(tab3);
+ is(
+ gBrowser.multiSelectedTabsCount,
+ 2,
+ "Two multiselected tabs after hiding one tab"
+ );
+ gBrowser.showTab(tab3);
+ is(
+ gBrowser.multiSelectedTabsCount,
+ 3,
+ "Three multiselected tabs after re-showing hidden tab"
+ );
+ await triggerClickOn(tab3, { ctrlKey: true });
+ is(
+ gBrowser.multiSelectedTabsCount,
+ 2,
+ "Two multiselected tabs after ctrl-clicking multiselected tab"
+ );
+
+ // Closing a tab which is not multiselected
+ let tab4CloseBtn = tab4.closeButton;
+ let tab4Closing = BrowserTestUtils.waitForTabClosing(tab4);
+
+ tab4.mOverCloseButton = true;
+ ok(tab4.mOverCloseButton, "Mouse over tab4 close button");
+ tab4CloseBtn.click();
+ await tab4Closing;
+
+ is(gBrowser.selectedTab, tab1, "Tab1 is active");
+ ok(tab1.multiselected, "Tab1 is multiselected");
+ ok(!tab1.closing, "Tab1 is not closing");
+ ok(tab2.multiselected, "Tab2 is multiselected");
+ ok(!tab2.closing, "Tab2 is not closing");
+ ok(!tab3.multiselected, "Tab3 is not multiselected");
+ ok(!tab3.closing, "Tab3 is not closing");
+ ok(tab4.closing, "Tab4 is closing");
+ is(gBrowser.multiSelectedTabsCount, 2, "Two multiselected tabs");
+
+ // Closing a selected tab
+ let tab2CloseBtn = tab2.closeButton;
+ tab2.mOverCloseButton = true;
+ let tab1Closing = BrowserTestUtils.waitForTabClosing(tab1);
+ let tab2Closing = BrowserTestUtils.waitForTabClosing(tab2);
+ tab2CloseBtn.click();
+ await tab1Closing;
+ await tab2Closing;
+
+ ok(tab1.closing, "Tab1 is closing");
+ ok(tab2.closing, "Tab2 is closing");
+ ok(!tab3.closing, "Tab3 is not closing");
+ is(gBrowser.multiSelectedTabsCount, 0, "Zero multiselected tabs");
+
+ BrowserTestUtils.removeTab(tab3);
+});
+
+add_task(async function usingTabContextMenu() {
+ let tab1 = await addTab();
+ let tab2 = await addTab();
+ let tab3 = await addTab();
+ let tab4 = await addTab();
+
+ let menuItemCloseTab = document.getElementById("context_closeTab");
+ is(gBrowser.multiSelectedTabsCount, 0, "Zero multiselected tabs");
+
+ await BrowserTestUtils.switchTab(gBrowser, tab1);
+ await triggerClickOn(tab2, { ctrlKey: true });
+
+ ok(tab1.multiselected, "Tab1 is multiselected");
+ ok(tab2.multiselected, "Tab2 is multiselected");
+ ok(!tab3.multiselected, "Tab3 is not multiselected");
+ ok(!tab4.multiselected, "Tab4 is not multiselected");
+ is(gBrowser.multiSelectedTabsCount, 2, "Two multiselected tabs");
+
+ // Check the context menu with a non-multiselected tab
+ updateTabContextMenu(tab4);
+ let { args } = document.l10n.getAttributes(menuItemCloseTab);
+ is(args.tabCount, 1, "Close Tab item lists a single tab");
+
+ // Check the context menu with a multiselected tab. We have to actually open
+ // it (not just call `updateTabContextMenu`) in order for
+ // `TabContextMenu.contextTab` to stay non-null when we click an item.
+ let menu = await openTabMenuFor(tab2);
+ ({ args } = document.l10n.getAttributes(menuItemCloseTab));
+ is(args.tabCount, 2, "Close Tab item lists more than one tab");
+
+ let tab1Closing = BrowserTestUtils.waitForTabClosing(tab1);
+ let tab2Closing = BrowserTestUtils.waitForTabClosing(tab2);
+ menu.activateItem(menuItemCloseTab);
+ await tab1Closing;
+ await tab2Closing;
+
+ ok(tab1.closing, "Tab1 is closing");
+ ok(tab2.closing, "Tab2 is closing");
+ ok(!tab3.closing, "Tab3 is not closing");
+ ok(!tab4.closing, "Tab4 is not closing");
+ is(gBrowser.multiSelectedTabsCount, 0, "Zero multiselected tabs");
+
+ BrowserTestUtils.removeTab(tab3);
+ BrowserTestUtils.removeTab(tab4);
+});
+
+add_task(async function closeAllMultiselectedMiddleClick() {
+ let tab1 = await addTab();
+ let tab2 = await addTab();
+ let tab3 = await addTab();
+ let tab4 = await addTab();
+ let tab5 = await addTab();
+ let tab6 = await addTab();
+
+ is(gBrowser.multiSelectedTabsCount, 0, "Zero multiselected tabs");
+
+ // Close currently selected tab1
+ await BrowserTestUtils.switchTab(gBrowser, tab1);
+ let tab1Closing = BrowserTestUtils.waitForTabClosing(tab1);
+ await triggerMiddleClickOn(tab1);
+ await tab1Closing;
+
+ // Close a not currently selected tab2
+ await BrowserTestUtils.switchTab(gBrowser, tab3);
+ let tab2Closing = BrowserTestUtils.waitForTabClosing(tab2);
+ await triggerMiddleClickOn(tab2);
+ await tab2Closing;
+
+ // Close the not multiselected middle clicked tab6
+ await triggerClickOn(tab4, { ctrlKey: true });
+ await triggerClickOn(tab5, { ctrlKey: true });
+ ok(tab3.multiselected, "Tab3 is multiselected");
+ ok(tab4.multiselected, "Tab4 is multiselected");
+ ok(tab5.multiselected, "Tab5 is multiselected");
+ ok(!tab6.multiselected, "Tab6 is not multiselected");
+ is(gBrowser.multiSelectedTabsCount, 3, "Three multiselected tabs");
+
+ let tab6Closing = BrowserTestUtils.waitForTabClosing(tab6);
+ await triggerMiddleClickOn(tab6);
+ await tab6Closing;
+
+ // Close multiselected tabs(3, 4, 5)
+ ok(tab3.multiselected, "Tab3 is multiselected");
+ ok(tab4.multiselected, "Tab4 is multiselected");
+ ok(tab5.multiselected, "Tab5 is multiselected");
+ is(gBrowser.multiSelectedTabsCount, 3, "Three multiselected tabs");
+
+ let tab3Closing = BrowserTestUtils.waitForTabClosing(tab3);
+ let tab4Closing = BrowserTestUtils.waitForTabClosing(tab4);
+ let tab5Closing = BrowserTestUtils.waitForTabClosing(tab5);
+ await triggerMiddleClickOn(tab5);
+ await tab3Closing;
+ await tab4Closing;
+ await tab5Closing;
+});
diff --git a/browser/base/content/test/tabs/browser_multiselect_tabs_close_other_tabs.js b/browser/base/content/test/tabs/browser_multiselect_tabs_close_other_tabs.js
new file mode 100644
index 0000000000..9214fe00a4
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_multiselect_tabs_close_other_tabs.js
@@ -0,0 +1,122 @@
+const PREF_WARN_ON_CLOSE = "browser.tabs.warnOnCloseOtherTabs";
+
+add_task(async function setPref() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[PREF_WARN_ON_CLOSE, false]],
+ });
+});
+
+add_task(async function withAMultiSelectedTab() {
+ let initialTab = gBrowser.selectedTab;
+ let tab1 = await addTab();
+ let tab2 = await addTab();
+ let tab3 = await addTab();
+ let tab4 = await addTab();
+
+ is(gBrowser.multiSelectedTabsCount, 0, "Zero multiselected tabs");
+
+ await triggerClickOn(tab1, { ctrlKey: true });
+
+ let tab4Pinned = BrowserTestUtils.waitForEvent(tab4, "TabPinned");
+ gBrowser.pinTab(tab4);
+ await tab4Pinned;
+
+ ok(initialTab.multiselected, "InitialTab is multiselected");
+ ok(tab1.multiselected, "Tab1 is multiselected");
+ ok(!tab2.multiselected, "Tab2 is not multiselected");
+ ok(!tab3.multiselected, "Tab3 is not multiselected");
+ ok(!tab4.multiselected, "Tab4 is not multiselected");
+ ok(tab4.pinned, "Tab4 is pinned");
+ is(gBrowser.multiSelectedTabsCount, 2, "Two multiselected tabs");
+ is(gBrowser.selectedTab, initialTab, "InitialTab is the active tab");
+
+ let closingTabs = [tab2, tab3];
+ let tabClosingPromises = [];
+ for (let tab of closingTabs) {
+ tabClosingPromises.push(BrowserTestUtils.waitForTabClosing(tab));
+ }
+
+ gBrowser.removeAllTabsBut(tab1);
+
+ for (let promise of tabClosingPromises) {
+ await promise;
+ }
+
+ ok(!initialTab.closing, "InitialTab is not closing");
+ ok(!tab1.closing, "Tab1 is not closing");
+ ok(tab2.closing, "Tab2 is closing");
+ ok(tab3.closing, "Tab3 is closing");
+ ok(!tab4.closing, "Tab4 is not closing");
+ is(gBrowser.multiSelectedTabsCount, 2, "Two multiselected tabs");
+ is(gBrowser.selectedTab, initialTab, "InitialTab is still the active tab");
+
+ gBrowser.clearMultiSelectedTabs();
+ BrowserTestUtils.removeTab(tab1);
+ BrowserTestUtils.removeTab(tab4);
+});
+
+add_task(async function withNotAMultiSelectedTab() {
+ let initialTab = gBrowser.selectedTab;
+ let tab1 = await addTab();
+ let tab2 = await addTab();
+ let tab3 = await addTab();
+ let tab4 = await addTab();
+ let tab5 = await addTab();
+
+ is(gBrowser.multiSelectedTabsCount, 0, "Zero multiselected tabs");
+
+ await BrowserTestUtils.switchTab(gBrowser, tab1);
+ await triggerClickOn(tab2, { ctrlKey: true });
+ await triggerClickOn(tab5, { ctrlKey: true });
+
+ let tab4Pinned = BrowserTestUtils.waitForEvent(tab4, "TabPinned");
+ gBrowser.pinTab(tab4);
+ await tab4Pinned;
+
+ let tab5Pinned = BrowserTestUtils.waitForEvent(tab5, "TabPinned");
+ gBrowser.pinTab(tab5);
+ await tab5Pinned;
+
+ ok(!initialTab.multiselected, "InitialTab is not multiselected");
+ ok(tab1.multiselected, "Tab1 is multiselected");
+ ok(tab2.multiselected, "Tab2 is multiselected");
+ ok(!tab3.multiselected, "Tab3 is not multiselected");
+ ok(!tab4.multiselected, "Tab4 is not multiselected");
+ ok(tab4.pinned, "Tab4 is pinned");
+ ok(tab5.multiselected, "Tab5 is multiselected");
+ ok(tab5.pinned, "Tab5 is pinned");
+ is(gBrowser.multiSelectedTabsCount, 3, "Three multiselected tabs");
+ is(gBrowser.selectedTab, tab1, "Tab1 is the active tab");
+
+ let closingTabs = [tab1, tab2, tab3];
+ let tabClosingPromises = [];
+ for (let tab of closingTabs) {
+ tabClosingPromises.push(BrowserTestUtils.waitForTabClosing(tab));
+ }
+
+ await BrowserTestUtils.switchTab(
+ gBrowser,
+ gBrowser.removeAllTabsBut(initialTab)
+ );
+
+ for (let promise of tabClosingPromises) {
+ await promise;
+ }
+
+ ok(!initialTab.closing, "InitialTab is not closing");
+ ok(tab1.closing, "Tab1 is closing");
+ ok(tab2.closing, "Tab2 is closing");
+ ok(tab3.closing, "Tab3 is closing");
+ ok(!tab4.closing, "Tab4 is not closing");
+ ok(!tab5.closing, "Tab5 is not closing");
+ is(
+ gBrowser.multiSelectedTabsCount,
+ 0,
+ "Zero multiselected tabs, selection is cleared"
+ );
+ is(gBrowser.selectedTab, initialTab, "InitialTab is the active tab now");
+
+ for (let tab of [tab4, tab5]) {
+ BrowserTestUtils.removeTab(tab);
+ }
+});
diff --git a/browser/base/content/test/tabs/browser_multiselect_tabs_close_tabs_to_the_left.js b/browser/base/content/test/tabs/browser_multiselect_tabs_close_tabs_to_the_left.js
new file mode 100644
index 0000000000..874c161bca
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_multiselect_tabs_close_tabs_to_the_left.js
@@ -0,0 +1,131 @@
+const PREF_WARN_ON_CLOSE = "browser.tabs.warnOnCloseOtherTabs";
+
+add_task(async function setPref() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[PREF_WARN_ON_CLOSE, false]],
+ });
+});
+
+add_task(async function withAMultiSelectedTab() {
+ // don't mess with the original tab
+ let originalTab = gBrowser.selectedTab;
+ gBrowser.pinTab(originalTab);
+
+ let tab0 = await addTab();
+ let tab1 = await addTab();
+ let tab2 = await addTab();
+ let tab3 = await addTab();
+ let tab4 = await addTab();
+ let tab5 = await addTab();
+
+ is(gBrowser.multiSelectedTabsCount, 0, "Zero multiselected tabs");
+
+ await BrowserTestUtils.switchTab(gBrowser, tab2);
+ await triggerClickOn(tab4, { ctrlKey: true });
+
+ ok(!tab0.multiselected, "Tab0 is not multiselected");
+ ok(!tab1.multiselected, "Tab1 is not multiselected");
+ ok(tab2.multiselected, "Tab2 is multiselected");
+ ok(!tab3.multiselected, "Tab3 is not multiselected");
+ ok(tab4.multiselected, "Tab4 is multiselected");
+ ok(!tab5.multiselected, "Tab5 is not multiselected");
+ is(gBrowser.multiSelectedTabsCount, 2, "Two multiselected tabs");
+
+ // Tab3 will be closed because tab4 is the contextTab.
+ let closingTabs = [tab0, tab1, tab3];
+ let tabClosingPromises = [];
+ for (let tab of closingTabs) {
+ tabClosingPromises.push(BrowserTestUtils.waitForTabClosing(tab));
+ }
+
+ gBrowser.removeTabsToTheStartFrom(tab4);
+
+ for (let promise of tabClosingPromises) {
+ await promise;
+ }
+
+ ok(tab0.closing, "Tab0 is closing");
+ ok(tab1.closing, "Tab1 is closing");
+ ok(!tab2.closing, "Tab2 is not closing");
+ ok(tab3.closing, "Tab3 is closing");
+ ok(!tab4.closing, "Tab4 is not closing");
+ ok(!tab5.closing, "Tab5 is not closing");
+ is(gBrowser.multiSelectedTabsCount, 2, "Two multiselected tabs");
+
+ // cleanup
+ gBrowser.unpinTab(originalTab);
+ for (let tab of [tab2, tab4, tab5]) {
+ BrowserTestUtils.removeTab(tab);
+ }
+});
+
+add_task(async function withNotAMultiSelectedTab() {
+ // don't mess with the original tab
+ let originalTab = gBrowser.selectedTab;
+ gBrowser.pinTab(originalTab);
+
+ let tab0 = await addTab();
+ let tab1 = await addTab();
+ let tab2 = await addTab();
+ let tab3 = await addTab();
+ let tab4 = await addTab();
+ let tab5 = await addTab();
+
+ is(gBrowser.multiSelectedTabsCount, 0, "Zero multiselected tabs");
+
+ await BrowserTestUtils.switchTab(gBrowser, tab1);
+ await triggerClickOn(tab3, { ctrlKey: true });
+ await triggerClickOn(tab5, { ctrlKey: true });
+
+ ok(!tab0.multiselected, "Tab0 is not multiselected");
+ ok(tab1.multiselected, "Tab1 is multiselected");
+ ok(!tab2.multiselected, "Tab2 is not multiselected");
+ ok(tab3.multiselected, "Tab3 is multiselected");
+ ok(!tab4.multiselected, "Tab4 is not multiselected");
+ ok(tab5.multiselected, "Tab5 is multiselected");
+ is(gBrowser.multiSelectedTabsCount, 3, "Three multiselected tabs");
+
+ let closingTabs = [tab0, tab1];
+ let tabClosingPromises = [];
+ for (let tab of closingTabs) {
+ tabClosingPromises.push(BrowserTestUtils.waitForTabClosing(tab));
+ }
+
+ gBrowser.removeTabsToTheStartFrom(tab2);
+
+ for (let promise of tabClosingPromises) {
+ await promise;
+ }
+
+ ok(tab0.closing, "Tab0 is closing");
+ ok(tab1.closing, "Tab1 is closing");
+ ok(!tab2.closing, "Tab2 is not closing");
+ ok(!tab3.closing, "Tab3 is not closing");
+ ok(!tab4.closing, "Tab4 is not closing");
+ ok(!tab5.closing, "Tab5 is not closing");
+ is(gBrowser.multiSelectedTabsCount, 2, "Selection is not cleared");
+
+ closingTabs = [tab2, tab3];
+ tabClosingPromises = [];
+ for (let tab of closingTabs) {
+ tabClosingPromises.push(BrowserTestUtils.waitForTabClosing(tab));
+ }
+
+ gBrowser.removeTabsToTheStartFrom(tab4);
+
+ for (let promise of tabClosingPromises) {
+ await promise;
+ }
+
+ ok(tab2.closing, "Tab2 is closing");
+ ok(tab3.closing, "Tab3 is closing");
+ ok(!tab4.closing, "Tab4 is not closing");
+ ok(!tab5.closing, "Tab5 is not closing");
+ is(gBrowser.multiSelectedTabsCount, 0, "Selection is cleared");
+
+ // cleanup
+ gBrowser.unpinTab(originalTab);
+ for (let tab of [tab4, tab5]) {
+ BrowserTestUtils.removeTab(tab);
+ }
+});
diff --git a/browser/base/content/test/tabs/browser_multiselect_tabs_close_tabs_to_the_right.js b/browser/base/content/test/tabs/browser_multiselect_tabs_close_tabs_to_the_right.js
new file mode 100644
index 0000000000..f145930364
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_multiselect_tabs_close_tabs_to_the_right.js
@@ -0,0 +1,113 @@
+const PREF_WARN_ON_CLOSE = "browser.tabs.warnOnCloseOtherTabs";
+
+add_task(async function setPref() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[PREF_WARN_ON_CLOSE, false]],
+ });
+});
+
+add_task(async function withAMultiSelectedTab() {
+ let tab1 = await addTab();
+ let tab2 = await addTab();
+ let tab3 = await addTab();
+ let tab4 = await addTab();
+ let tab5 = await addTab();
+
+ is(gBrowser.multiSelectedTabsCount, 0, "Zero multiselected tabs");
+
+ await BrowserTestUtils.switchTab(gBrowser, tab1);
+ await triggerClickOn(tab3, { ctrlKey: true });
+
+ ok(tab1.multiselected, "Tab1 is multiselected");
+ ok(!tab2.multiselected, "Tab2 is not multiselected");
+ ok(tab3.multiselected, "Tab3 is multiselected");
+ ok(!tab4.multiselected, "Tab4 is not multiselected");
+ ok(!tab5.multiselected, "Tab5 is not multiselected");
+ is(gBrowser.multiSelectedTabsCount, 2, "Two multiselected tabs");
+
+ // Tab2 will be closed because tab1 is the contextTab.
+ let closingTabs = [tab2, tab4, tab5];
+ let tabClosingPromises = [];
+ for (let tab of closingTabs) {
+ tabClosingPromises.push(BrowserTestUtils.waitForTabClosing(tab));
+ }
+
+ gBrowser.removeTabsToTheEndFrom(tab1);
+
+ for (let promise of tabClosingPromises) {
+ await promise;
+ }
+
+ ok(!tab1.closing, "Tab1 is not closing");
+ ok(tab2.closing, "Tab2 is closing");
+ ok(!tab3.closing, "Tab3 is not closing");
+ ok(tab4.closing, "Tab4 is closing");
+ ok(tab5.closing, "Tab5 is closing");
+ is(gBrowser.multiSelectedTabsCount, 2, "Two multiselected tabs");
+
+ for (let tab of [tab1, tab2, tab3]) {
+ BrowserTestUtils.removeTab(tab);
+ }
+});
+
+add_task(async function withNotAMultiSelectedTab() {
+ let tab1 = await addTab();
+ let tab2 = await addTab();
+ let tab3 = await addTab();
+ let tab4 = await addTab();
+ let tab5 = await addTab();
+
+ is(gBrowser.multiSelectedTabsCount, 0, "Zero multiselected tabs");
+
+ await BrowserTestUtils.switchTab(gBrowser, tab1);
+ await triggerClickOn(tab3, { ctrlKey: true });
+ await triggerClickOn(tab5, { ctrlKey: true });
+
+ ok(tab1.multiselected, "Tab1 is multiselected");
+ ok(!tab2.multiselected, "Tab2 is not multiselected");
+ ok(tab3.multiselected, "Tab3 is multiselected");
+ ok(!tab4.multiselected, "Tab4 is not multiselected");
+ ok(tab5.multiselected, "Tab5 is multiselected");
+ is(gBrowser.multiSelectedTabsCount, 3, "Three multiselected tabs");
+
+ let closingTabs = [tab5];
+ let tabClosingPromises = [];
+ for (let tab of closingTabs) {
+ tabClosingPromises.push(BrowserTestUtils.waitForTabClosing(tab));
+ }
+
+ gBrowser.removeTabsToTheEndFrom(tab4);
+
+ for (let promise of tabClosingPromises) {
+ await promise;
+ }
+
+ ok(!tab1.closing, "Tab1 is not closing");
+ ok(!tab2.closing, "Tab2 is not closing");
+ ok(!tab3.closing, "Tab3 is not closing");
+ ok(!tab4.closing, "Tab4 is not closing");
+ ok(tab5.closing, "Tab5 is closing");
+ is(gBrowser.multiSelectedTabsCount, 2, "Selection is not cleared");
+
+ closingTabs = [tab3, tab4];
+ tabClosingPromises = [];
+ for (let tab of closingTabs) {
+ tabClosingPromises.push(BrowserTestUtils.waitForTabClosing(tab));
+ }
+
+ gBrowser.removeTabsToTheEndFrom(tab2);
+
+ for (let promise of tabClosingPromises) {
+ await promise;
+ }
+
+ ok(!tab1.closing, "Tab1 is not closing");
+ ok(!tab2.closing, "Tab2 is not closing");
+ ok(tab3.closing, "Tab3 is closing");
+ ok(tab4.closing, "Tab4 is closing");
+ is(gBrowser.multiSelectedTabsCount, 0, "Selection is cleared");
+
+ for (let tab of [tab1, tab2]) {
+ BrowserTestUtils.removeTab(tab);
+ }
+});
diff --git a/browser/base/content/test/tabs/browser_multiselect_tabs_close_using_shortcuts.js b/browser/base/content/test/tabs/browser_multiselect_tabs_close_using_shortcuts.js
new file mode 100644
index 0000000000..da367f6645
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_multiselect_tabs_close_using_shortcuts.js
@@ -0,0 +1,64 @@
+const PREF_WARN_ON_CLOSE = "browser.tabs.warnOnCloseOtherTabs";
+
+add_task(async function setPref() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[PREF_WARN_ON_CLOSE, false]],
+ });
+});
+
+add_task(async function using_Ctrl_W() {
+ for (let key of ["w", "VK_F4"]) {
+ let tab1 = await addTab();
+ let tab2 = await addTab();
+ let tab3 = await addTab();
+ let tab4 = await addTab();
+
+ await BrowserTestUtils.switchTab(gBrowser, triggerClickOn(tab1, {}));
+
+ is(gBrowser.multiSelectedTabsCount, 0, "Zero multiselected tabs");
+
+ await triggerClickOn(tab2, { ctrlKey: true });
+ await triggerClickOn(tab3, { ctrlKey: true });
+
+ ok(tab1.multiselected, "Tab1 is multiselected");
+ ok(tab2.multiselected, "Tab2 is multiselected");
+ ok(tab3.multiselected, "Tab3 is multiselected");
+ ok(!tab4.multiselected, "Tab4 is not multiselected");
+ is(gBrowser.multiSelectedTabsCount, 3, "Three multiselected tabs");
+
+ let tab1Closing = BrowserTestUtils.waitForTabClosing(tab1);
+ let tab2Closing = BrowserTestUtils.waitForTabClosing(tab2);
+ let tab3Closing = BrowserTestUtils.waitForTabClosing(tab3);
+
+ EventUtils.synthesizeKey(key, { accelKey: true });
+
+ // On OSX, Cmd+F4 should not close tabs.
+ const shouldBeClosing = key == "w" || AppConstants.platform != "macosx";
+
+ if (shouldBeClosing) {
+ await tab1Closing;
+ await tab2Closing;
+ await tab3Closing;
+ }
+
+ ok(!tab4.closing, "Tab4 is not closing");
+
+ if (shouldBeClosing) {
+ ok(tab1.closing, "Tab1 is closing");
+ ok(tab2.closing, "Tab2 is closing");
+ ok(tab3.closing, "Tab3 is closing");
+ is(gBrowser.multiSelectedTabsCount, 0, "Zero multiselected tabs");
+ } else {
+ ok(!tab1.closing, "Tab1 is not closing");
+ ok(!tab2.closing, "Tab2 is not closing");
+ ok(!tab3.closing, "Tab3 is not closing");
+ is(gBrowser.multiSelectedTabsCount, 3, "Still Three multiselected tabs");
+
+ BrowserTestUtils.removeTab(tab1);
+ BrowserTestUtils.removeTab(tab2);
+ BrowserTestUtils.removeTab(tab3);
+ }
+
+ BrowserTestUtils.removeTab(tab4);
+ }
+});
diff --git a/browser/base/content/test/tabs/browser_multiselect_tabs_copy_through_drag_and_drop.js b/browser/base/content/test/tabs/browser_multiselect_tabs_copy_through_drag_and_drop.js
new file mode 100644
index 0000000000..029708560a
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_multiselect_tabs_copy_through_drag_and_drop.js
@@ -0,0 +1,51 @@
+add_task(async function test() {
+ let tab0 = gBrowser.selectedTab;
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ let tab1 = await addTab("http://example.com/1");
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ let tab2 = await addTab("http://example.com/2");
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ let tab3 = await addTab("http://example.com/3");
+ let tabs = [tab0, tab1, tab2, tab3];
+
+ await BrowserTestUtils.switchTab(gBrowser, tab1);
+ await triggerClickOn(tab2, { ctrlKey: true });
+
+ is(gBrowser.selectedTab, tab1, "Tab1 is active");
+ is(gBrowser.selectedTabs.length, 2, "Two selected tabs");
+ is(gBrowser.visibleTabs.length, 4, "Four tabs in window before copy");
+
+ for (let i of [1, 2]) {
+ ok(tabs[i].multiselected, "Tab" + i + " is multiselected");
+ }
+ for (let i of [0, 3]) {
+ ok(!tabs[i].multiselected, "Tab" + i + " is not multiselected");
+ }
+
+ await dragAndDrop(tab1, tab3, true);
+
+ is(gBrowser.selectedTab, tab1, "tab1 is still active");
+ is(gBrowser.selectedTabs.length, 2, "Two selected tabs");
+ is(gBrowser.visibleTabs.length, 6, "Six tabs in window after copy");
+
+ let tab4 = gBrowser.visibleTabs[4];
+ let tab5 = gBrowser.visibleTabs[5];
+ tabs.push(tab4);
+ tabs.push(tab5);
+
+ for (let i of [1, 2]) {
+ ok(tabs[i].multiselected, "Tab" + i + " is multiselected");
+ }
+ for (let i of [0, 3, 4, 5]) {
+ ok(!tabs[i].multiselected, "Tab" + i + " is not multiselected");
+ }
+
+ await BrowserTestUtils.waitForCondition(() => getUrl(tab4) == getUrl(tab1));
+ await BrowserTestUtils.waitForCondition(() => getUrl(tab5) == getUrl(tab2));
+
+ ok(true, "Tab1 and tab2 are duplicated succesfully");
+
+ for (let tab of tabs.filter(t => t != tab0)) {
+ BrowserTestUtils.removeTab(tab);
+ }
+});
diff --git a/browser/base/content/test/tabs/browser_multiselect_tabs_drag_to_bookmarks_toolbar.js b/browser/base/content/test/tabs/browser_multiselect_tabs_drag_to_bookmarks_toolbar.js
new file mode 100644
index 0000000000..42342c889c
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_multiselect_tabs_drag_to_bookmarks_toolbar.js
@@ -0,0 +1,74 @@
+add_task(async function test() {
+ // Disable tab animations
+ gReduceMotionOverride = true;
+
+ // Open Bookmarks Toolbar
+ let bookmarksToolbar = document.getElementById("PersonalToolbar");
+ setToolbarVisibility(bookmarksToolbar, true);
+ ok(!bookmarksToolbar.collapsed, "bookmarksToolbar should be visible now");
+
+ let tab1 = await addTab("http://mochi.test:8888/1");
+ let tab2 = await addTab("http://mochi.test:8888/2");
+ let tab3 = await addTab("http://mochi.test:8888/3");
+ let tab4 = await addTab("http://mochi.test:8888/4");
+ let tab5 = await addTab("http://mochi.test:8888/5");
+
+ is(gBrowser.multiSelectedTabsCount, 0, "Zero multiselected tabs");
+
+ await BrowserTestUtils.switchTab(gBrowser, tab2);
+ await triggerClickOn(tab1, { ctrlKey: true });
+
+ ok(tab1.multiselected, "Tab1 is multiselected");
+ ok(tab2.multiselected, "Tab2 is multiselected");
+ ok(!tab3.multiselected, "Tab3 is not multiselected");
+ ok(!tab4.multiselected, "Tab4 is not multiselected");
+ ok(!tab5.multiselected, "Tab5 is not multiselected");
+ is(gBrowser.multiSelectedTabsCount, 2, "Two multiselected tabs");
+
+ // Use getElementsByClassName so the list is live and will update as items change.
+ let currentBookmarks =
+ bookmarksToolbar.getElementsByClassName("bookmark-item");
+ let startBookmarksLength = currentBookmarks.length;
+
+ // The destination element should be a non-folder bookmark
+ let destBookmarkItem = () =>
+ bookmarksToolbar.querySelector(
+ "#PlacesToolbarItems .bookmark-item:not([container])"
+ );
+
+ await EventUtils.synthesizePlainDragAndDrop({
+ srcElement: tab1,
+ destElement: destBookmarkItem(),
+ });
+ await TestUtils.waitForCondition(
+ () => currentBookmarks.length == startBookmarksLength + 2,
+ "waiting for 2 bookmarks"
+ );
+ is(
+ currentBookmarks.length,
+ startBookmarksLength + 2,
+ "Bookmark count should have increased by 2"
+ );
+
+ // Drag non-selection to the bookmarks toolbar
+ startBookmarksLength = currentBookmarks.length;
+ await EventUtils.synthesizePlainDragAndDrop({
+ srcElement: tab3,
+ destElement: destBookmarkItem(),
+ });
+ await TestUtils.waitForCondition(
+ () => currentBookmarks.length == startBookmarksLength + 1,
+ "waiting for 1 bookmark"
+ );
+ is(
+ currentBookmarks.length,
+ startBookmarksLength + 1,
+ "Bookmark count should have increased by 1"
+ );
+
+ BrowserTestUtils.removeTab(tab1);
+ BrowserTestUtils.removeTab(tab2);
+ BrowserTestUtils.removeTab(tab3);
+ BrowserTestUtils.removeTab(tab4);
+ BrowserTestUtils.removeTab(tab5);
+});
diff --git a/browser/base/content/test/tabs/browser_multiselect_tabs_duplicate.js b/browser/base/content/test/tabs/browser_multiselect_tabs_duplicate.js
new file mode 100644
index 0000000000..d9f5e58669
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_multiselect_tabs_duplicate.js
@@ -0,0 +1,136 @@
+async function openTabMenuFor(tab) {
+ let tabMenu = tab.ownerDocument.getElementById("tabContextMenu");
+
+ let tabMenuShown = BrowserTestUtils.waitForEvent(tabMenu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ tab,
+ { type: "contextmenu" },
+ tab.ownerGlobal
+ );
+ await tabMenuShown;
+
+ return tabMenu;
+}
+
+add_task(async function test() {
+ let originalTab = gBrowser.selectedTab;
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ let tab1 = await addTab("http://example.com/1");
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ let tab2 = await addTab("http://example.com/2");
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ let tab3 = await addTab("http://example.com/3");
+
+ let menuItemDuplicateTab = document.getElementById("context_duplicateTab");
+ let menuItemDuplicateTabs = document.getElementById("context_duplicateTabs");
+
+ is(gBrowser.multiSelectedTabsCount, 0, "Zero multiselected tabs");
+
+ await BrowserTestUtils.switchTab(gBrowser, tab1);
+ await triggerClickOn(tab2, { ctrlKey: true });
+
+ ok(tab1.multiselected, "Tab1 is multiselected");
+ ok(tab2.multiselected, "Tab2 is multiselected");
+ ok(!tab3.multiselected, "Tab3 is not multiselected");
+
+ // Check the context menu with a multiselected tabs
+ updateTabContextMenu(tab2);
+ is(menuItemDuplicateTab.hidden, true, "Duplicate Tab is hidden");
+ is(menuItemDuplicateTabs.hidden, false, "Duplicate Tabs is visible");
+
+ // Check the context menu with a non-multiselected tab
+ updateTabContextMenu(tab3);
+ is(menuItemDuplicateTab.hidden, false, "Duplicate Tab is visible");
+ is(menuItemDuplicateTabs.hidden, true, "Duplicate Tabs is hidden");
+
+ let newTabOpened = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com/3",
+ true
+ );
+ {
+ let menu = await openTabMenuFor(tab3);
+ menu.activateItem(menuItemDuplicateTab);
+ }
+ let tab4 = await newTabOpened;
+
+ is(
+ getUrl(tab4),
+ getUrl(tab3),
+ "tab4 should have same URL as tab3, where it was duplicated from"
+ );
+
+ // Selection should be cleared after duplication
+ ok(!tab1.multiselected, "Tab1 is not multiselected");
+ ok(!tab2.multiselected, "Tab2 is not multiselected");
+ ok(!tab3.multiselected, "Tab3 is not multiselected");
+ ok(!tab4.multiselected, "Tab4 is not multiselected");
+
+ is(gBrowser.selectedTab._tPos, tab4._tPos, "Tab4 should be selected");
+
+ await BrowserTestUtils.switchTab(gBrowser, tab1);
+ await triggerClickOn(tab3, { ctrlKey: true });
+
+ ok(tab1.multiselected, "Tab1 is multiselected");
+ ok(!tab2.multiselected, "Tab2 is not multiselected");
+ ok(tab3.multiselected, "Tab3 is multiselected");
+ ok(!tab4.multiselected, "Tab4 is not multiselected");
+
+ // Check the context menu with a non-multiselected tab
+ updateTabContextMenu(tab3);
+ is(menuItemDuplicateTab.hidden, true, "Duplicate Tab is hidden");
+ is(menuItemDuplicateTabs.hidden, false, "Duplicate Tabs is visible");
+
+ // 7 tabs because there was already one open when the test starts.
+ // Can't use BrowserTestUtils.waitForNewTab because waitForNewTab only works
+ // with one tab at a time.
+ let newTabsOpened = TestUtils.waitForCondition(
+ () => gBrowser.visibleTabs.length == 7,
+ "Wait for two tabs to get created"
+ );
+ {
+ let menu = await openTabMenuFor(tab3);
+ menu.activateItem(menuItemDuplicateTabs);
+ }
+ await newTabsOpened;
+ info("Two tabs opened");
+
+ await TestUtils.waitForCondition(() => {
+ return (
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ getUrl(gBrowser.visibleTabs[4]) == "http://example.com/1" &&
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ getUrl(gBrowser.visibleTabs[5]) == "http://example.com/3"
+ );
+ });
+
+ is(
+ originalTab,
+ gBrowser.visibleTabs[0],
+ "Original tab should still be first"
+ );
+ is(tab1, gBrowser.visibleTabs[1], "tab1 should still be second");
+ is(tab2, gBrowser.visibleTabs[2], "tab2 should still be third");
+ is(tab3, gBrowser.visibleTabs[3], "tab3 should still be fourth");
+ is(
+ getUrl(gBrowser.visibleTabs[4]),
+ getUrl(tab1),
+ "the first duplicated tab should be placed next to tab3 and have URL of tab1"
+ );
+ is(
+ getUrl(gBrowser.visibleTabs[5]),
+ getUrl(tab3),
+ "the second duplicated tab should have URL of tab3 and maintain same order"
+ );
+ is(
+ tab4,
+ gBrowser.visibleTabs[6],
+ "tab4 should now be the still be the seventh tab"
+ );
+
+ let tabsToClose = gBrowser.visibleTabs.filter(t => t != originalTab);
+ for (let tab of tabsToClose) {
+ BrowserTestUtils.removeTab(tab);
+ }
+});
diff --git a/browser/base/content/test/tabs/browser_multiselect_tabs_event.js b/browser/base/content/test/tabs/browser_multiselect_tabs_event.js
new file mode 100644
index 0000000000..992cf75e5e
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_multiselect_tabs_event.js
@@ -0,0 +1,220 @@
+/* eslint-disable mozilla/no-arbitrary-setTimeout */
+
+add_task(async function clickWithPrefSet() {
+ let detectUnexpected = true;
+ window.addEventListener("TabMultiSelect", () => {
+ if (detectUnexpected) {
+ ok(false, "Shouldn't get unexpected event");
+ }
+ });
+ async function expectEvent(callback, expectedTabs) {
+ let event = new Promise(resolve => {
+ detectUnexpected = false;
+ window.addEventListener(
+ "TabMultiSelect",
+ () => {
+ detectUnexpected = true;
+ resolve();
+ },
+ { once: true }
+ );
+ });
+ await callback();
+ await event;
+ ok(true, "Got TabMultiSelect event");
+ expectSelected(expectedTabs);
+ // Await some time to ensure no additional event is triggered
+ await new Promise(resolve => setTimeout(resolve, 100));
+ }
+ async function expectNoEvent(callback, expectedTabs) {
+ await callback();
+ expectSelected(expectedTabs);
+ // Await some time to ensure no event is triggered
+ await new Promise(resolve => setTimeout(resolve, 100));
+ }
+ function expectSelected(expected) {
+ let { selectedTabs } = gBrowser;
+ is(selectedTabs.length, expected.length, "Check number of selected tabs");
+ for (
+ let i = 0, n = Math.min(expected.length, selectedTabs.length);
+ i < n;
+ ++i
+ ) {
+ is(selectedTabs[i], expected[i], `Check the selected tab #${i + 1}`);
+ }
+ }
+
+ let initialTab = gBrowser.selectedTab;
+ let tab1, tab2, tab3;
+
+ info("Expect no event when opening tabs");
+ await expectNoEvent(async () => {
+ tab1 = await addTab();
+ tab2 = await addTab();
+ tab3 = await addTab();
+ }, [initialTab]);
+
+ info("Switching tab should trigger event");
+ await expectEvent(async () => {
+ await BrowserTestUtils.switchTab(gBrowser, tab1);
+ }, [tab1]);
+
+ info("Multiselecting tab with Ctrl+click should trigger event");
+ await expectEvent(async () => {
+ await triggerClickOn(tab2, { ctrlKey: true });
+ }, [tab1, tab2]);
+
+ info("Unselecting tab with Ctrl+click should trigger event");
+ await expectEvent(async () => {
+ await triggerClickOn(tab2, { ctrlKey: true });
+ }, [tab1]);
+
+ info("Multiselecting tabs with Shift+click should trigger event");
+ await expectEvent(async () => {
+ await triggerClickOn(tab3, { shiftKey: true });
+ }, [tab1, tab2, tab3]);
+
+ info("Expect no event if multiselection doesn't change with Shift+click");
+ await expectNoEvent(async () => {
+ await triggerClickOn(tab3, { shiftKey: true });
+ }, [tab1, tab2, tab3]);
+
+ info(
+ "Expect no event if multiselection doesn't change with Ctrl+Shift+click"
+ );
+ await expectNoEvent(async () => {
+ await triggerClickOn(tab2, { ctrlKey: true, shiftKey: true });
+ }, [tab1, tab2, tab3]);
+
+ info(
+ "Expect no event if selected tab doesn't change with gBrowser.selectedTab"
+ );
+ await expectNoEvent(async () => {
+ gBrowser.selectedTab = tab1;
+ }, [tab1, tab2, tab3]);
+
+ info(
+ "Clearing multiselection by switching tab with gBrowser.selectedTab should trigger event"
+ );
+ await expectEvent(async () => {
+ await BrowserTestUtils.switchTab(gBrowser, () => {
+ gBrowser.selectedTab = tab3;
+ });
+ }, [tab3]);
+
+ info(
+ "Click on the active and the only mutliselected tab should not trigger event"
+ );
+ await expectNoEvent(async () => {
+ await triggerClickOn(tab3, {});
+ }, [tab3]);
+
+ info(
+ "Expect no event if selected tab doesn't change with gBrowser.selectedTabs"
+ );
+ gBrowser.selectedTabs = [tab3];
+ expectSelected([tab3]);
+
+ info("Multiselecting tabs with gBrowser.selectedTabs should trigger event");
+ await expectEvent(async () => {
+ gBrowser.selectedTabs = [tab3, tab2, tab1];
+ }, [tab1, tab2, tab3]);
+
+ info(
+ "Expect no event if multiselection doesn't change with gBrowser.selectedTabs"
+ );
+ await expectNoEvent(async () => {
+ gBrowser.selectedTabs = [tab3, tab1, tab2];
+ }, [tab1, tab2, tab3]);
+
+ info("Switching tab with gBrowser.selectedTabs should trigger event");
+ await expectEvent(async () => {
+ gBrowser.selectedTabs = [tab1, tab2, tab3];
+ }, [tab1, tab2, tab3]);
+
+ info(
+ "Unmultiselection tab with removeFromMultiSelectedTabs should trigger event"
+ );
+ await expectEvent(async () => {
+ gBrowser.removeFromMultiSelectedTabs(tab3);
+ }, [tab1, tab2]);
+
+ info("Expect no event if the tab is not multiselected");
+ await expectNoEvent(async () => {
+ gBrowser.removeFromMultiSelectedTabs(tab3);
+ }, [tab1, tab2]);
+
+ info(
+ "Clearing multiselection with clearMultiSelectedTabs should trigger event"
+ );
+ await expectEvent(async () => {
+ gBrowser.clearMultiSelectedTabs();
+ }, [tab1]);
+
+ info("Expect no event if there is no multiselection to clear");
+ await expectNoEvent(async () => {
+ gBrowser.clearMultiSelectedTabs();
+ }, [tab1]);
+
+ info(
+ "Expect no event if clearMultiSelectedTabs counteracts addToMultiSelectedTabs"
+ );
+ await expectNoEvent(async () => {
+ gBrowser.addToMultiSelectedTabs(tab3);
+ gBrowser.clearMultiSelectedTabs();
+ }, [tab1]);
+
+ info(
+ "Multiselecting tab with gBrowser.addToMultiSelectedTabs should trigger event"
+ );
+ await expectEvent(async () => {
+ gBrowser.addToMultiSelectedTabs(tab2);
+ }, [tab1, tab2]);
+
+ info(
+ "Expect no event if addToMultiSelectedTabs counteracts clearMultiSelectedTabs"
+ );
+ await expectNoEvent(async () => {
+ gBrowser.clearMultiSelectedTabs();
+ gBrowser.addToMultiSelectedTabs(tab1);
+ gBrowser.addToMultiSelectedTabs(tab2);
+ }, [tab1, tab2]);
+
+ info(
+ "Expect no event if removeFromMultiSelectedTabs counteracts addToMultiSelectedTabs"
+ );
+ await expectNoEvent(async () => {
+ gBrowser.addToMultiSelectedTabs(tab3);
+ gBrowser.removeFromMultiSelectedTabs(tab3);
+ }, [tab1, tab2]);
+
+ info(
+ "Expect no event if addToMultiSelectedTabs counteracts removeFromMultiSelectedTabs"
+ );
+ await expectNoEvent(async () => {
+ gBrowser.removeFromMultiSelectedTabs(tab2);
+ gBrowser.addToMultiSelectedTabs(tab2);
+ }, [tab1, tab2]);
+
+ info("Multiselection with addRangeToMultiSelectedTabs should trigger event");
+ await expectEvent(async () => {
+ gBrowser.addRangeToMultiSelectedTabs(tab1, tab3);
+ }, [tab1, tab2, tab3]);
+
+ info("Switching to a just multiselected tab should multiselect the old one");
+ await expectEvent(async () => {
+ gBrowser.clearMultiSelectedTabs();
+ }, [tab1]);
+ await expectEvent(async () => {
+ is(tab1.multiselected, false, "tab1 is not multiselected");
+ gBrowser.addToMultiSelectedTabs(tab2);
+ gBrowser.lockClearMultiSelectionOnce();
+ gBrowser.selectedTab = tab2;
+ }, [tab1, tab2]);
+ is(tab1.multiselected, true, "tab1 becomes multiselected");
+
+ detectUnexpected = false;
+ BrowserTestUtils.removeTab(tab1);
+ BrowserTestUtils.removeTab(tab2);
+ BrowserTestUtils.removeTab(tab3);
+});
diff --git a/browser/base/content/test/tabs/browser_multiselect_tabs_move.js b/browser/base/content/test/tabs/browser_multiselect_tabs_move.js
new file mode 100644
index 0000000000..e5de60ea99
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_multiselect_tabs_move.js
@@ -0,0 +1,192 @@
+add_task(async function testMoveStartEnabledClickedFromNonSelectedTab() {
+ let tab = gBrowser.selectedTab;
+ let tab2 = await addTab();
+ let tab3 = await addTab();
+
+ let tabs = [tab2, tab3];
+
+ let menuItemMoveStartTab = document.getElementById("context_moveToStart");
+
+ await triggerClickOn(tab, {});
+ await triggerClickOn(tab2, { ctrlKey: true });
+
+ ok(tab.multiselected, "Tab is multiselected");
+ ok(tab2.multiselected, "Tab2 is multiselected");
+
+ updateTabContextMenu(tab3);
+ is(menuItemMoveStartTab.disabled, false, "Move Tab to Start is enabled");
+
+ for (let tabToRemove of tabs) {
+ BrowserTestUtils.removeTab(tabToRemove);
+ }
+});
+
+add_task(async function testMoveStartDisabledFromFirstUnpinnedTab() {
+ let tab = gBrowser.selectedTab;
+ let tab2 = await addTab();
+
+ let menuItemMoveStartTab = document.getElementById("context_moveToStart");
+
+ gBrowser.pinTab(tab);
+
+ updateTabContextMenu(tab2);
+ is(menuItemMoveStartTab.disabled, true, "Move Tab to Start is disabled");
+
+ BrowserTestUtils.removeTab(tab2);
+ gBrowser.unpinTab(tab);
+});
+
+add_task(async function testMoveStartDisabledFromFirstPinnedTab() {
+ let tab = gBrowser.selectedTab;
+ let tab2 = await addTab();
+
+ let menuItemMoveStartTab = document.getElementById("context_moveToStart");
+
+ gBrowser.pinTab(tab);
+
+ updateTabContextMenu(tab);
+ is(menuItemMoveStartTab.disabled, true, "Move Tab to Start is disabled");
+
+ BrowserTestUtils.removeTab(tab2);
+ gBrowser.unpinTab(tab);
+});
+
+add_task(async function testMoveStartDisabledFromOnlyTab() {
+ let tab = gBrowser.selectedTab;
+
+ let menuItemMoveStartTab = document.getElementById("context_moveToStart");
+
+ updateTabContextMenu(tab);
+ is(menuItemMoveStartTab.disabled, true, "Move Tab to Start is disabled");
+});
+
+add_task(async function testMoveStartDisabledFromOnlyPinnedTab() {
+ let tab = gBrowser.selectedTab;
+
+ let menuItemMoveStartTab = document.getElementById("context_moveToStart");
+
+ gBrowser.pinTab(tab);
+
+ updateTabContextMenu(tab);
+ is(menuItemMoveStartTab.disabled, true, "Move Tab to Start is disabled");
+
+ gBrowser.unpinTab(tab);
+});
+
+add_task(async function testMoveStartEnabledFromLastPinnedTab() {
+ let tab = gBrowser.selectedTab;
+ let tab2 = await addTab();
+ let tab3 = await addTab();
+
+ let tabs = [tab2, tab3];
+
+ let menuItemMoveStartTab = document.getElementById("context_moveToStart");
+
+ gBrowser.pinTab(tab);
+ gBrowser.pinTab(tab2);
+
+ updateTabContextMenu(tab2);
+ is(menuItemMoveStartTab.disabled, false, "Move Tab to Start is enabled");
+
+ for (let tabToRemove of tabs) {
+ BrowserTestUtils.removeTab(tabToRemove);
+ }
+
+ gBrowser.unpinTab(tab);
+});
+
+add_task(async function testMoveStartDisabledFromFirstVisibleTab() {
+ let tab = gBrowser.selectedTab;
+ let tab2 = await addTab();
+
+ let menuItemMoveStartTab = document.getElementById("context_moveToStart");
+
+ gBrowser.selectTabAtIndex(1);
+ gBrowser.hideTab(tab);
+
+ updateTabContextMenu(tab2);
+ is(menuItemMoveStartTab.disabled, true, "Move Tab to Start is disabled");
+
+ BrowserTestUtils.removeTab(tab2);
+ gBrowser.showTab(tab);
+});
+
+add_task(async function testMoveEndEnabledClickedFromNonSelectedTab() {
+ let tab = gBrowser.selectedTab;
+ let tab2 = await addTab();
+ let tab3 = await addTab();
+
+ let tabs = [tab2, tab3];
+
+ let menuItemMoveEndTab = document.getElementById("context_moveToEnd");
+
+ await triggerClickOn(tab2, {});
+ await triggerClickOn(tab3, { ctrlKey: true });
+
+ ok(tab2.multiselected, "Tab2 is multiselected");
+ ok(tab3.multiselected, "Tab3 is multiselected");
+
+ updateTabContextMenu(tab);
+ is(menuItemMoveEndTab.disabled, false, "Move Tab to End is enabled");
+
+ for (let tabToRemove of tabs) {
+ BrowserTestUtils.removeTab(tabToRemove);
+ }
+});
+
+add_task(async function testMoveEndDisabledFromLastPinnedTab() {
+ let tab = gBrowser.selectedTab;
+ let tab2 = await addTab();
+ let tab3 = await addTab();
+
+ let tabs = [tab2, tab3];
+
+ let menuItemMoveEndTab = document.getElementById("context_moveToEnd");
+
+ gBrowser.pinTab(tab);
+ gBrowser.pinTab(tab2);
+
+ updateTabContextMenu(tab2);
+ is(menuItemMoveEndTab.disabled, true, "Move Tab to End is disabled");
+
+ for (let tabToRemove of tabs) {
+ BrowserTestUtils.removeTab(tabToRemove);
+ }
+});
+
+add_task(async function testMoveEndDisabledFromLastVisibleTab() {
+ let tab = gBrowser.selectedTab;
+ let tab2 = await addTab();
+
+ let menuItemMoveEndTab = document.getElementById("context_moveToEnd");
+
+ gBrowser.hideTab(tab2);
+
+ updateTabContextMenu(tab);
+ is(menuItemMoveEndTab.disabled, true, "Move Tab to End is disabled");
+
+ BrowserTestUtils.removeTab(tab2);
+ gBrowser.showTab(tab);
+});
+
+add_task(async function testMoveEndDisabledFromOnlyTab() {
+ let tab = gBrowser.selectedTab;
+
+ let menuItemMoveEndTab = document.getElementById("context_moveToEnd");
+
+ updateTabContextMenu(tab);
+ is(menuItemMoveEndTab.disabled, true, "Move Tab to End is disabled");
+});
+
+add_task(async function testMoveEndDisabledFromOnlyPinnedTab() {
+ let tab = gBrowser.selectedTab;
+
+ let menuItemMoveEndTab = document.getElementById("context_moveToEnd");
+
+ gBrowser.pinTab(tab);
+
+ updateTabContextMenu(tab);
+ is(menuItemMoveEndTab.disabled, true, "Move Tab to End is disabled");
+
+ gBrowser.unpinTab(tab);
+});
diff --git a/browser/base/content/test/tabs/browser_multiselect_tabs_move_to_another_window_drag.js b/browser/base/content/test/tabs/browser_multiselect_tabs_move_to_another_window_drag.js
new file mode 100644
index 0000000000..111221c4ec
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_multiselect_tabs_move_to_another_window_drag.js
@@ -0,0 +1,118 @@
+add_task(async function test() {
+ // Disable tab animations
+ gReduceMotionOverride = true;
+
+ let tab1 = await addTab();
+ let tab2 = await addTab();
+ let tab3 = await addTab("http://mochi.test:8888/3");
+ let tab4 = await addTab();
+ let tab5 = await addTab("http://mochi.test:8888/5");
+
+ is(gBrowser.multiSelectedTabsCount, 0, "Zero multiselected tabs");
+
+ await BrowserTestUtils.switchTab(gBrowser, tab2);
+ await triggerClickOn(tab1, { ctrlKey: true });
+
+ ok(tab1.multiselected, "Tab1 is multiselected");
+ ok(tab2.multiselected, "Tab2 is multiselected");
+ ok(!tab3.multiselected, "Tab3 is not multiselected");
+ ok(!tab4.multiselected, "Tab4 is not multiselected");
+ ok(!tab5.multiselected, "Tab5 is not multiselected");
+ is(gBrowser.multiSelectedTabsCount, 2, "Two multiselected tabs");
+
+ let newWindow = gBrowser.replaceTabsWithWindow(tab1);
+
+ // waiting for tab2 to close ensure that the newWindow is created,
+ // thus newWindow.gBrowser used in the second waitForCondition
+ // will not be undefined.
+ await TestUtils.waitForCondition(
+ () => tab2.closing,
+ "Wait for tab2 to close"
+ );
+ await TestUtils.waitForCondition(
+ () => newWindow.gBrowser.visibleTabs.length == 2,
+ "Wait for all two tabs to get moved to the new window"
+ );
+
+ let gBrowser2 = newWindow.gBrowser;
+ tab1 = gBrowser2.visibleTabs[0];
+ tab2 = gBrowser2.visibleTabs[1];
+
+ if (gBrowser.selectedTab != tab3) {
+ await BrowserTestUtils.switchTab(gBrowser, tab3);
+ }
+
+ await triggerClickOn(tab5, { ctrlKey: true });
+
+ ok(tab1.multiselected, "Tab1 is multiselected");
+ ok(tab2.multiselected, "Tab2 is multiselected");
+ ok(tab3.multiselected, "Tab3 is multiselected");
+ ok(!tab4.multiselected, "Tab4 is not multiselected");
+ ok(tab5.multiselected, "Tab5 is multiselected");
+
+ await dragAndDrop(tab3, tab1, false, newWindow);
+
+ await TestUtils.waitForCondition(
+ () => gBrowser2.visibleTabs.length == 4,
+ "Moved tab3 and tab5 to second window"
+ );
+
+ tab3 = gBrowser2.visibleTabs[1];
+ tab5 = gBrowser2.visibleTabs[2];
+
+ await BrowserTestUtils.waitForCondition(
+ () => getUrl(tab3) == "http://mochi.test:8888/3"
+ );
+ await BrowserTestUtils.waitForCondition(
+ () => getUrl(tab5) == "http://mochi.test:8888/5"
+ );
+
+ ok(true, "Tab3 and tab5 are duplicated succesfully");
+
+ BrowserTestUtils.closeWindow(newWindow);
+ BrowserTestUtils.removeTab(tab4);
+});
+
+add_task(async function test_laziness() {
+ const params = { createLazyBrowser: true };
+ const url = "http://mochi.test:8888/?";
+ const tab1 = BrowserTestUtils.addTab(gBrowser, url + "1", params);
+ const tab2 = BrowserTestUtils.addTab(gBrowser, url + "2");
+ const tab3 = BrowserTestUtils.addTab(gBrowser, url + "3", params);
+
+ await BrowserTestUtils.switchTab(gBrowser, tab2);
+ await triggerClickOn(tab1, { ctrlKey: true });
+ await triggerClickOn(tab3, { ctrlKey: true });
+
+ is(gBrowser.selectedTab, tab2, "Tab2 is selected");
+ is(gBrowser.multiSelectedTabsCount, 3, "Three multiselected tabs");
+ ok(tab1.multiselected, "Tab1 is multiselected");
+ ok(tab2.multiselected, "Tab2 is multiselected");
+ ok(tab3.multiselected, "Tab3 is multiselected");
+ ok(!tab1.linkedPanel, "Tab1 is lazy");
+ ok(tab2.linkedPanel, "Tab2 is not lazy");
+ ok(!tab3.linkedPanel, "Tab3 is lazy");
+
+ const win2 = await BrowserTestUtils.openNewBrowserWindow();
+ const gBrowser2 = win2.gBrowser;
+ is(gBrowser2.tabs.length, 1, "Second window has 1 tab");
+
+ await dragAndDrop(tab2, gBrowser2.tabs[0], false, win2);
+ await TestUtils.waitForCondition(
+ () => gBrowser2.tabs.length == 4,
+ "Moved tabs into second window"
+ );
+ is(gBrowser2.tabs[1].linkedBrowser.currentURI.spec, url + "1");
+ is(gBrowser2.tabs[2].linkedBrowser.currentURI.spec, url + "2");
+ is(gBrowser2.tabs[3].linkedBrowser.currentURI.spec, url + "3");
+ is(gBrowser2.selectedTab, gBrowser2.tabs[2], "Tab2 is selected");
+ is(gBrowser2.multiSelectedTabsCount, 3, "Three multiselected tabs");
+ ok(gBrowser2.tabs[1].multiselected, "Tab1 is multiselected");
+ ok(gBrowser2.tabs[2].multiselected, "Tab2 is multiselected");
+ ok(gBrowser2.tabs[3].multiselected, "Tab3 is multiselected");
+ ok(!gBrowser2.tabs[1].linkedPanel, "Tab1 is lazy");
+ ok(gBrowser2.tabs[2].linkedPanel, "Tab2 is not lazy");
+ ok(!gBrowser2.tabs[3].linkedPanel, "Tab3 is lazy");
+
+ await BrowserTestUtils.closeWindow(win2);
+});
diff --git a/browser/base/content/test/tabs/browser_multiselect_tabs_move_to_new_window_contextmenu.js b/browser/base/content/test/tabs/browser_multiselect_tabs_move_to_new_window_contextmenu.js
new file mode 100644
index 0000000000..d668d21df8
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_multiselect_tabs_move_to_new_window_contextmenu.js
@@ -0,0 +1,129 @@
+add_task(async function test() {
+ let tab1 = await addTab();
+ let tab2 = await addTab();
+ let tab3 = await addTab();
+ let tab4 = await addTab();
+
+ is(gBrowser.multiSelectedTabsCount, 0, "Zero multiselected tabs");
+
+ await BrowserTestUtils.switchTab(gBrowser, tab2);
+ await triggerClickOn(tab1, { ctrlKey: true });
+ await triggerClickOn(tab3, { ctrlKey: true });
+
+ ok(tab1.multiselected, "Tab1 is multiselected");
+ ok(tab2.multiselected, "Tab2 is multiselected");
+ ok(tab3.multiselected, "Tab3 is multiselected");
+ ok(!tab4.multiselected, "Tab4 is not multiselected");
+ is(gBrowser.multiSelectedTabsCount, 3, "Three multiselected tabs");
+
+ let newWindow = gBrowser.replaceTabsWithWindow(tab1);
+
+ // waiting for tab2 to close ensure that the newWindow is created,
+ // thus newWindow.gBrowser used in the second waitForCondition
+ // will not be undefined.
+ await TestUtils.waitForCondition(
+ () => tab2.closing,
+ "Wait for tab2 to close"
+ );
+ await TestUtils.waitForCondition(
+ () => newWindow.gBrowser.visibleTabs.length == 3,
+ "Wait for all three tabs to get moved to the new window"
+ );
+
+ let gBrowser2 = newWindow.gBrowser;
+
+ is(gBrowser.multiSelectedTabsCount, 0, "Zero multiselected tabs");
+ is(gBrowser.visibleTabs.length, 2, "Two tabs now in the old window");
+ is(gBrowser2.visibleTabs.length, 3, "Three tabs in the new window");
+ is(
+ gBrowser2.visibleTabs.indexOf(gBrowser2.selectedTab),
+ 1,
+ "Previously active tab is still the active tab in the new window"
+ );
+
+ BrowserTestUtils.closeWindow(newWindow);
+ BrowserTestUtils.removeTab(tab4);
+});
+
+add_task(async function testLazyTabs() {
+ let params = { createLazyBrowser: true };
+ let oldTabs = [];
+ let numTabs = 4;
+ for (let i = 0; i < numTabs; ++i) {
+ oldTabs.push(
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ BrowserTestUtils.addTab(gBrowser, `http://example.com/?${i}`, params)
+ );
+ }
+
+ await BrowserTestUtils.switchTab(gBrowser, oldTabs[0]);
+ for (let i = 1; i < numTabs; ++i) {
+ await triggerClickOn(oldTabs[i], { ctrlKey: true });
+ }
+
+ isnot(oldTabs[0].linkedPanel, "", `Old tab 0 shouldn't be lazy`);
+ for (let i = 1; i < numTabs; ++i) {
+ is(oldTabs[i].linkedPanel, "", `Old tab ${i} should be lazy`);
+ }
+
+ is(gBrowser.multiSelectedTabsCount, numTabs, `${numTabs} multiselected tabs`);
+ for (let i = 0; i < numTabs; ++i) {
+ ok(oldTabs[i].multiselected, `Old tab ${i} should be multiselected`);
+ }
+
+ let tabsMoved = new Promise(resolve => {
+ let numTabsMoved = 0;
+ window.addEventListener("TabClose", async function listener(event) {
+ let oldTab = event.target;
+ let i = oldTabs.indexOf(oldTab);
+ if (i == 0) {
+ isnot(
+ oldTab.linkedPanel,
+ "",
+ `Old tab ${i} should continue not being lazy`
+ );
+ } else if (i > 0) {
+ is(oldTab.linkedPanel, "", `Old tab ${i} should continue being lazy`);
+ } else {
+ return;
+ }
+ let newTab = event.detail.adoptedBy;
+ await TestUtils.waitForCondition(() => {
+ return newTab.linkedBrowser.currentURI.spec != "about:blank";
+ }, `Wait for the new tab to finish the adoption of the old tab`);
+ if (++numTabsMoved == numTabs) {
+ window.removeEventListener("TabClose", listener);
+ resolve();
+ }
+ });
+ });
+ let newWindow = gBrowser.replaceTabsWithWindow(oldTabs[0]);
+ await tabsMoved;
+ let newTabs = newWindow.gBrowser.tabs;
+
+ isnot(newTabs[0].linkedPanel, "", `New tab 0 should continue not being lazy`);
+ for (let i = 1; i < numTabs; ++i) {
+ is(newTabs[i].linkedPanel, "", `New tab ${i} should continue being lazy`);
+ }
+
+ is(
+ newTabs[0].linkedBrowser.currentURI.spec,
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ `http://example.com/?0`,
+ `New tab 0 should have the right URL`
+ );
+ for (let i = 1; i < numTabs; ++i) {
+ is(
+ SessionStore.getLazyTabValue(newTabs[i], "url"),
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ `http://example.com/?${i}`,
+ `New tab ${i} should have the right lazy URL`
+ );
+ }
+
+ for (let i = 0; i < numTabs; ++i) {
+ ok(newTabs[i].multiselected, `New tab ${i} should be multiselected`);
+ }
+
+ BrowserTestUtils.closeWindow(newWindow);
+});
diff --git a/browser/base/content/test/tabs/browser_multiselect_tabs_mute_unmute.js b/browser/base/content/test/tabs/browser_multiselect_tabs_mute_unmute.js
new file mode 100644
index 0000000000..83de966e0c
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_multiselect_tabs_mute_unmute.js
@@ -0,0 +1,336 @@
+const PREF_DELAY_AUTOPLAY = "media.block-autoplay-until-in-foreground";
+
+add_task(async function setPref() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[PREF_DELAY_AUTOPLAY, true]],
+ });
+});
+
+add_task(async function muteTabs_usingButton() {
+ let tab0 = await addMediaTab();
+ let tab1 = await addMediaTab();
+ let tab2 = await addMediaTab();
+ let tab3 = await addMediaTab();
+ let tab4 = await addMediaTab();
+
+ let tabs = [tab0, tab1, tab2, tab3, tab4];
+
+ await BrowserTestUtils.switchTab(gBrowser, tab0);
+ await play(tab0);
+ await play(tab1, false);
+ await play(tab2, false);
+
+ // Multiselecting tab1, tab2 and tab3
+ await BrowserTestUtils.switchTab(gBrowser, tab1);
+ await triggerClickOn(tab3, { shiftKey: true });
+
+ is(gBrowser.multiSelectedTabsCount, 3, "Three multiselected tabs");
+ ok(!tab0.multiselected, "Tab0 is not multiselected");
+ ok(!tab4.multiselected, "Tab4 is not multiselected");
+
+ // tab1,tab2 and tab3 should be multiselected.
+ for (let i = 1; i <= 3; i++) {
+ ok(tabs[i].multiselected, "Tab" + i + " is multiselected");
+ }
+
+ // All five tabs are unmuted
+ for (let i = 0; i < 5; i++) {
+ ok(!muted(tabs[i]), "Tab" + i + " is not muted");
+ }
+
+ // Mute tab0 which is not multiselected, thus other tabs muted state should not be affected
+ let tab0MuteAudioBtn = tab0.overlayIcon;
+ await test_mute_tab(tab0, tab0MuteAudioBtn, true);
+
+ ok(muted(tab0), "Tab0 is muted");
+ for (let i = 1; i <= 4; i++) {
+ ok(!muted(tabs[i]), "Tab" + i + " is not muted");
+ }
+
+ // Now we multiselect tab0
+ await triggerClickOn(tab0, { ctrlKey: true });
+
+ // tab0, tab1, tab2, tab3 are multiselected
+ for (let i = 0; i <= 3; i++) {
+ ok(tabs[i].multiselected, "tab" + i + " is multiselected");
+ }
+ ok(!tab4.multiselected, "tab4 is not multiselected");
+
+ // Check mute state
+ ok(muted(tab0), "Tab0 is still muted");
+ ok(!muted(tab1), "Tab1 is not muted");
+ ok(!activeMediaBlocked(tab1), "Tab1 is not activemedia-blocked");
+ ok(activeMediaBlocked(tab2), "Tab2 is media-blocked");
+ ok(!muted(tab3), "Tab3 is not muted");
+ ok(!activeMediaBlocked(tab3), "Tab3 is not activemedia-blocked");
+ ok(!muted(tab4), "Tab4 is not muted");
+ ok(!activeMediaBlocked(tab4), "Tab4 is not activemedia-blocked");
+
+ // Mute tab1 which is multiselected, thus other multiselected tabs should be affected too
+ // in the following way:
+ // a) muted tabs (tab0) will remain muted.
+ // b) unmuted tabs (tab1, tab3) will become muted.
+ // b) media-blocked tabs (tab2) will remain media-blocked.
+ // However tab4 (unmuted) which is not multiselected should not be affected.
+ let tab1MuteAudioBtn = tab1.overlayIcon;
+ await test_mute_tab(tab1, tab1MuteAudioBtn, true);
+
+ // Check mute state
+ ok(muted(tab0), "Tab0 is still muted");
+ ok(muted(tab1), "Tab1 is muted");
+ ok(activeMediaBlocked(tab2), "Tab2 is still media-blocked");
+ ok(muted(tab3), "Tab3 is now muted");
+ ok(!muted(tab4), "Tab4 is not muted");
+ ok(!activeMediaBlocked(tab4), "Tab4 is not activemedia-blocked");
+
+ for (let tab of tabs) {
+ BrowserTestUtils.removeTab(tab);
+ }
+});
+
+add_task(async function unmuteTabs_usingButton() {
+ let tab0 = await addMediaTab();
+ let tab1 = await addMediaTab();
+ let tab2 = await addMediaTab();
+ let tab3 = await addMediaTab();
+ let tab4 = await addMediaTab();
+
+ let tabs = [tab0, tab1, tab2, tab3, tab4];
+
+ await BrowserTestUtils.switchTab(gBrowser, tab0);
+ await play(tab0);
+ await play(tab1, false);
+ await play(tab2, false);
+
+ // Mute tab3 and tab4
+ await toggleMuteAudio(tab3, true);
+ await toggleMuteAudio(tab4, true);
+
+ // Multiselecting tab0, tab1, tab2 and tab3
+ await triggerClickOn(tab3, { shiftKey: true });
+
+ // Check multiselection
+ for (let i = 0; i <= 3; i++) {
+ ok(tabs[i].multiselected, "tab" + i + " is multiselected");
+ }
+ ok(!tab4.multiselected, "tab4 is not multiselected");
+
+ // Check tabs mute state
+ ok(!muted(tab0), "Tab0 is not muted");
+ ok(!activeMediaBlocked(tab0), "Tab0 is not activemedia-blocked");
+ ok(activeMediaBlocked(tab1), "Tab1 is media-blocked");
+ ok(activeMediaBlocked(tab2), "Tab2 is media-blocked");
+ ok(muted(tab3), "Tab3 is muted");
+ ok(muted(tab4), "Tab4 is muted");
+ is(gBrowser.selectedTab, tab0, "Tab0 is active");
+
+ // unmute tab0 which is multiselected, thus other multiselected tabs should be affected too
+ // in the following way:
+ // a) muted tabs (tab3) will become unmuted.
+ // b) unmuted tabs (tab0) will remain unmuted.
+ // c) media-blocked tabs (tab1, tab2) will remain blocked.
+ // However tab4 (muted) which is not multiselected should not be affected.
+ let tab3MuteAudioBtn = tab3.overlayIcon;
+ await test_mute_tab(tab3, tab3MuteAudioBtn, false);
+
+ ok(!muted(tab0), "Tab0 is not muted");
+ ok(!activeMediaBlocked(tab0), "Tab0 is not activemedia-blocked");
+ ok(!muted(tab1), "Tab1 is not muted");
+ ok(activeMediaBlocked(tab1), "Tab1 is activemedia-blocked");
+ ok(!muted(tab2), "Tab2 is not muted");
+ ok(activeMediaBlocked(tab2), "Tab2 is activemedia-blocked");
+ ok(!muted(tab3), "Tab3 is not muted");
+ ok(!activeMediaBlocked(tab3), "Tab3 is not activemedia-blocked");
+ ok(muted(tab4), "Tab4 is muted");
+ is(gBrowser.selectedTab, tab0, "Tab0 is active");
+
+ for (let tab of tabs) {
+ BrowserTestUtils.removeTab(tab);
+ }
+});
+
+add_task(async function muteAndUnmuteTabs_usingKeyboard() {
+ let tab0 = await addMediaTab();
+ let tab1 = await addMediaTab();
+ let tab2 = await addMediaTab();
+ let tab3 = await addMediaTab();
+ let tab4 = await addMediaTab();
+
+ let tabs = [tab0, tab1, tab2, tab3, tab4];
+
+ await BrowserTestUtils.switchTab(gBrowser, tab0);
+
+ let mutedPromise = get_wait_for_mute_promise(tab0, true);
+ EventUtils.synthesizeKey("M", { ctrlKey: true });
+ await mutedPromise;
+ ok(muted(tab0), "Tab0 should be muted");
+ ok(!muted(tab1), "Tab1 should not be muted");
+ ok(!muted(tab2), "Tab2 should not be muted");
+ ok(!muted(tab3), "Tab3 should not be muted");
+ ok(!muted(tab4), "Tab4 should not be muted");
+
+ // Multiselecting tab0, tab1, tab2 and tab3
+ await triggerClickOn(tab3, { shiftKey: true });
+
+ // Check multiselection
+ for (let i = 0; i <= 3; i++) {
+ ok(tabs[i].multiselected, "tab" + i + " is multiselected");
+ }
+ ok(!tab4.multiselected, "tab4 is not multiselected");
+
+ mutedPromise = get_wait_for_mute_promise(tab0, false);
+ EventUtils.synthesizeKey("M", { ctrlKey: true });
+ await mutedPromise;
+ ok(!muted(tab0), "Tab0 should not be muted");
+ ok(!muted(tab1), "Tab1 should not be muted");
+ ok(!muted(tab2), "Tab2 should not be muted");
+ ok(!muted(tab3), "Tab3 should not be muted");
+ ok(!muted(tab4), "Tab4 should not be muted");
+
+ mutedPromise = get_wait_for_mute_promise(tab0, true);
+ EventUtils.synthesizeKey("M", { ctrlKey: true });
+ await mutedPromise;
+ ok(muted(tab0), "Tab0 should be muted");
+ ok(muted(tab1), "Tab1 should be muted");
+ ok(muted(tab2), "Tab2 should be muted");
+ ok(muted(tab3), "Tab3 should be muted");
+ ok(!muted(tab4), "Tab4 should not be muted");
+
+ for (let tab of tabs) {
+ BrowserTestUtils.removeTab(tab);
+ }
+});
+
+add_task(async function playTabs_usingButton() {
+ let tab0 = await addMediaTab();
+ let tab1 = await addMediaTab();
+ let tab2 = await addMediaTab();
+ let tab3 = await addMediaTab();
+ let tab4 = await addMediaTab();
+
+ let tabs = [tab0, tab1, tab2, tab3, tab4];
+
+ await BrowserTestUtils.switchTab(gBrowser, tab0);
+ await play(tab0);
+ await play(tab1, false);
+ await play(tab2, false);
+
+ // Multiselecting tab0, tab1, tab2 and tab3.
+ await triggerClickOn(tab3, { shiftKey: true });
+
+ // Mute tab0 and tab4
+ await toggleMuteAudio(tab0, true);
+ await toggleMuteAudio(tab4, true);
+
+ // Check multiselection
+ for (let i = 0; i <= 3; i++) {
+ ok(tabs[i].multiselected, "tab" + i + " is multiselected");
+ }
+ ok(!tab4.multiselected, "tab4 is not multiselected");
+
+ // Check mute state
+ ok(muted(tab0), "Tab0 is muted");
+ ok(activeMediaBlocked(tab1), "Tab1 is media-blocked");
+ ok(activeMediaBlocked(tab2), "Tab2 is media-blocked");
+ ok(!muted(tab3), "Tab3 is not muted");
+ ok(!activeMediaBlocked(tab3), "Tab3 is not activemedia-blocked");
+ ok(muted(tab4), "Tab4 is muted");
+ is(gBrowser.selectedTab, tab0, "Tab0 is active");
+
+ // play tab2 which is multiselected, thus other multiselected tabs should be affected too
+ // in the following way:
+ // a) muted tabs (tab0) will remain muted.
+ // b) unmuted tabs (tab3) will remain unmuted.
+ // c) media-blocked tabs (tab1, tab2) will become unblocked.
+ // However tab4 (muted) which is not multiselected should not be affected.
+ let tab2MuteAudioBtn = tab2.overlayIcon;
+ await test_mute_tab(tab2, tab2MuteAudioBtn, false);
+
+ ok(muted(tab0), "Tab0 is muted");
+ ok(!activeMediaBlocked(tab0), "Tab0 is not activemedia-blocked");
+ ok(!muted(tab1), "Tab1 is not muted");
+ ok(!activeMediaBlocked(tab1), "Tab1 is not activemedia-blocked");
+ ok(!muted(tab2), "Tab2 is not muted");
+ ok(!activeMediaBlocked(tab2), "Tab2 is not activemedia-blocked");
+ ok(!muted(tab3), "Tab3 is not muted");
+ ok(!activeMediaBlocked(tab3), "Tab3 is not activemedia-blocked");
+ ok(muted(tab4), "Tab4 is muted");
+ is(gBrowser.selectedTab, tab0, "Tab0 is active");
+
+ for (let tab of tabs) {
+ BrowserTestUtils.removeTab(tab);
+ }
+});
+
+add_task(async function checkTabContextMenu() {
+ let tab0 = await addMediaTab();
+ let tab1 = await addMediaTab();
+ let tab2 = await addMediaTab();
+ let tab3 = await addMediaTab();
+ let tabs = [tab0, tab1, tab2, tab3];
+
+ let menuItemToggleMuteTab = document.getElementById("context_toggleMuteTab");
+ let menuItemToggleMuteSelectedTabs = document.getElementById(
+ "context_toggleMuteSelectedTabs"
+ );
+
+ await play(tab0, false);
+ await toggleMuteAudio(tab0, true);
+ await play(tab1, false);
+ await toggleMuteAudio(tab2, true);
+
+ // multiselect tab0, tab1, tab2.
+ await triggerClickOn(tab0, { ctrlKey: true });
+ await triggerClickOn(tab1, { ctrlKey: true });
+ await triggerClickOn(tab2, { ctrlKey: true });
+
+ // Check multiselected tabs
+ for (let i = 0; i <= 2; i++) {
+ ok(tabs[i].multiselected, "Tab" + i + " is multi-selected");
+ }
+ ok(!tab3.multiselected, "Tab3 is not multiselected");
+
+ // Check mute state for tabs
+ ok(muted(tab0), "Tab0 is muted");
+ ok(activeMediaBlocked(tab0), "Tab0 is activemedia-blocked");
+ ok(activeMediaBlocked(tab1), "Tab1 is activemedia-blocked");
+ ok(muted(tab2), "Tab2 is muted");
+ ok(!muted(tab3, "Tab3 is not muted"));
+
+ const l10nIds = [
+ "tabbrowser-context-unmute-selected-tabs",
+ "tabbrowser-context-mute-selected-tabs",
+ "tabbrowser-context-unmute-selected-tabs",
+ ];
+
+ for (let i = 0; i <= 2; i++) {
+ updateTabContextMenu(tabs[i]);
+ ok(
+ menuItemToggleMuteTab.hidden,
+ "toggleMuteAudio menu for one tab is hidden - contextTab" + i
+ );
+ ok(
+ !menuItemToggleMuteSelectedTabs.hidden,
+ "toggleMuteAudio menu for selected tab is not hidden - contextTab" + i
+ );
+ is(
+ menuItemToggleMuteSelectedTabs.dataset.l10nId,
+ l10nIds[i],
+ l10nIds[i] + " should be shown"
+ );
+ }
+
+ updateTabContextMenu(tab3);
+ ok(
+ !menuItemToggleMuteTab.hidden,
+ "toggleMuteAudio menu for one tab is not hidden"
+ );
+ ok(
+ menuItemToggleMuteSelectedTabs.hidden,
+ "toggleMuteAudio menu for selected tab is hidden"
+ );
+
+ for (let tab of tabs) {
+ BrowserTestUtils.removeTab(tab);
+ }
+});
diff --git a/browser/base/content/test/tabs/browser_multiselect_tabs_open_related.js b/browser/base/content/test/tabs/browser_multiselect_tabs_open_related.js
new file mode 100644
index 0000000000..7751c9c420
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_multiselect_tabs_open_related.js
@@ -0,0 +1,143 @@
+add_task(async function test() {
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ let tab1 = await addTab("http://example.com/1");
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ let tab2 = await addTab("http://example.com/2");
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ let tab3 = await addTab("http://example.com/3");
+
+ await BrowserTestUtils.switchTab(gBrowser, tab1);
+ await triggerClickOn(tab2, { ctrlKey: true });
+
+ ok(tab1.multiselected, "Tab1 is multi-selected");
+ ok(tab2.multiselected, "Tab2 is multi-selected");
+ ok(!tab3.multiselected, "Tab3 is not multi-selected");
+
+ let metaKeyEvent =
+ AppConstants.platform == "macosx" ? { metaKey: true } : { ctrlKey: true };
+
+ let newTabButton = gBrowser.tabContainer.newTabButton;
+ let promiseTabOpened = BrowserTestUtils.waitForEvent(
+ gBrowser.tabContainer,
+ "TabOpen"
+ );
+ EventUtils.synthesizeMouseAtCenter(newTabButton, metaKeyEvent);
+ let openEvent = await promiseTabOpened;
+ let newTab = openEvent.target;
+
+ is(
+ newTab.previousElementSibling,
+ tab1,
+ "New tab should be opened after the selected tab (tab1)"
+ );
+ is(
+ newTab.nextElementSibling,
+ tab2,
+ "New tab should be opened after the selected tab (tab1) and before tab2"
+ );
+ BrowserTestUtils.removeTab(newTab);
+
+ await BrowserTestUtils.switchTab(gBrowser, tab1);
+ ok(!tab1.multiselected, "Tab1 is not multi-selected");
+ ok(!tab2.multiselected, "Tab2 is not multi-selected");
+ ok(!tab3.multiselected, "Tab3 is not multi-selected");
+
+ promiseTabOpened = BrowserTestUtils.waitForEvent(
+ gBrowser.tabContainer,
+ "TabOpen"
+ );
+ EventUtils.synthesizeMouseAtCenter(newTabButton, metaKeyEvent);
+ openEvent = await promiseTabOpened;
+ newTab = openEvent.target;
+ is(
+ newTab.previousElementSibling,
+ tab1,
+ "New tab should be opened after tab1 when only tab1 is selected"
+ );
+ is(
+ newTab.nextElementSibling,
+ tab2,
+ "New tab should be opened before tab2 when only tab1 is selected"
+ );
+ BrowserTestUtils.removeTab(newTab);
+
+ await BrowserTestUtils.switchTab(gBrowser, tab1);
+ await triggerClickOn(tab3, { ctrlKey: true });
+ ok(tab1.multiselected, "Tab1 is multi-selected");
+ ok(!tab2.multiselected, "Tab2 is not multi-selected");
+ ok(tab3.multiselected, "Tab3 is multi-selected");
+
+ promiseTabOpened = BrowserTestUtils.waitForEvent(
+ gBrowser.tabContainer,
+ "TabOpen"
+ );
+ EventUtils.synthesizeMouseAtCenter(newTabButton, metaKeyEvent);
+ openEvent = await promiseTabOpened;
+ newTab = openEvent.target;
+ let previous = gBrowser.tabContainer.findNextTab(newTab, { direction: -1 });
+ is(previous, tab1, "New tab should be opened after the selected tab (tab1)");
+ let next = gBrowser.tabContainer.findNextTab(newTab, { direction: 1 });
+ is(
+ next,
+ tab2,
+ "New tab should be opened after the selected tab (tab1) and before tab2"
+ );
+ BrowserTestUtils.removeTab(newTab);
+
+ await BrowserTestUtils.switchTab(gBrowser, tab1);
+ ok(!tab1.multiselected, "Tab1 is not multi-selected");
+ ok(!tab2.multiselected, "Tab2 is not multi-selected");
+ ok(!tab3.multiselected, "Tab3 is not multi-selected");
+
+ promiseTabOpened = BrowserTestUtils.waitForEvent(
+ gBrowser.tabContainer,
+ "TabOpen"
+ );
+ EventUtils.synthesizeMouseAtCenter(newTabButton, {});
+ openEvent = await promiseTabOpened;
+ newTab = openEvent.target;
+ previous = gBrowser.tabContainer.findNextTab(newTab, { direction: -1 });
+ is(
+ previous,
+ tab3,
+ "New tab should be opened after tab3 when ctrlKey is not used without multiselection"
+ );
+ next = gBrowser.tabContainer.findNextTab(newTab, { direction: 1 });
+ is(
+ next,
+ null,
+ "New tab should be opened at the end of the tabstrip when ctrlKey is not used without multiselection"
+ );
+ BrowserTestUtils.removeTab(newTab);
+
+ await BrowserTestUtils.switchTab(gBrowser, tab1);
+ await triggerClickOn(tab2, { ctrlKey: true });
+ ok(tab1.multiselected, "Tab1 is multi-selected");
+ ok(tab2.multiselected, "Tab2 is multi-selected");
+ ok(!tab3.multiselected, "Tab3 is not multi-selected");
+
+ promiseTabOpened = BrowserTestUtils.waitForEvent(
+ gBrowser.tabContainer,
+ "TabOpen"
+ );
+ EventUtils.synthesizeMouseAtCenter(newTabButton, {});
+ openEvent = await promiseTabOpened;
+ newTab = openEvent.target;
+ previous = gBrowser.tabContainer.findNextTab(newTab, { direction: -1 });
+ is(
+ previous,
+ tab3,
+ "New tab should be opened after tab3 when ctrlKey is not used with multiselection"
+ );
+ next = gBrowser.tabContainer.findNextTab(newTab, { direction: 1 });
+ is(
+ next,
+ null,
+ "New tab should be opened at the end of the tabstrip when ctrlKey is not used with multiselection"
+ );
+ BrowserTestUtils.removeTab(newTab);
+
+ BrowserTestUtils.removeTab(tab1);
+ BrowserTestUtils.removeTab(tab2);
+ BrowserTestUtils.removeTab(tab3);
+});
diff --git a/browser/base/content/test/tabs/browser_multiselect_tabs_pin_unpin.js b/browser/base/content/test/tabs/browser_multiselect_tabs_pin_unpin.js
new file mode 100644
index 0000000000..5cd71abbbe
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_multiselect_tabs_pin_unpin.js
@@ -0,0 +1,75 @@
+add_task(async function test() {
+ let tab1 = gBrowser.selectedTab;
+ let tab2 = await addTab();
+ let tab3 = await addTab();
+
+ let menuItemPinTab = document.getElementById("context_pinTab");
+ let menuItemUnpinTab = document.getElementById("context_unpinTab");
+ let menuItemPinSelectedTabs = document.getElementById(
+ "context_pinSelectedTabs"
+ );
+ let menuItemUnpinSelectedTabs = document.getElementById(
+ "context_unpinSelectedTabs"
+ );
+
+ is(gBrowser.multiSelectedTabsCount, 0, "Zero multiselected tabs");
+
+ await triggerClickOn(tab2, { ctrlKey: true });
+
+ ok(tab1.multiselected, "Tab1 is multiselected");
+ ok(tab2.multiselected, "Tab2 is multiselected");
+ ok(!tab3.multiselected, "Tab3 is not multiselected");
+
+ // Check the context menu with a non-multiselected tab
+ updateTabContextMenu(tab3);
+ ok(!tab3.pinned, "Tab3 is unpinned");
+ is(menuItemPinTab.hidden, false, "Pin Tab is visible");
+ is(menuItemUnpinTab.hidden, true, "Unpin Tab is hidden");
+ is(menuItemPinSelectedTabs.hidden, true, "Pin Selected Tabs is hidden");
+ is(menuItemUnpinSelectedTabs.hidden, true, "Unpin Selected Tabs is hidden");
+
+ // Check the context menu with a multiselected and unpinned tab
+ updateTabContextMenu(tab2);
+ ok(!tab2.pinned, "Tab2 is unpinned");
+ is(menuItemPinTab.hidden, true, "Pin Tab is hidden");
+ is(menuItemUnpinTab.hidden, true, "Unpin Tab is hidden");
+ is(menuItemPinSelectedTabs.hidden, false, "Pin Selected Tabs is visible");
+ is(menuItemUnpinSelectedTabs.hidden, true, "Unpin Selected Tabs is hidden");
+
+ let tab1Pinned = BrowserTestUtils.waitForEvent(tab1, "TabPinned");
+ let tab2Pinned = BrowserTestUtils.waitForEvent(tab2, "TabPinned");
+ menuItemPinSelectedTabs.click();
+ await tab1Pinned;
+ await tab2Pinned;
+
+ ok(tab1.pinned, "Tab1 is pinned");
+ ok(tab2.pinned, "Tab2 is pinned");
+ ok(!tab3.pinned, "Tab3 is unpinned");
+ is(tab1._tPos, 0, "Tab1 should still be first after pinning");
+ is(tab2._tPos, 1, "Tab2 should still be second after pinning");
+ is(tab3._tPos, 2, "Tab3 should still be third after pinning");
+
+ // Check the context menu with a multiselected and pinned tab
+ updateTabContextMenu(tab2);
+ ok(tab2.pinned, "Tab2 is pinned");
+ is(menuItemPinTab.hidden, true, "Pin Tab is hidden");
+ is(menuItemUnpinTab.hidden, true, "Unpin Tab is hidden");
+ is(menuItemPinSelectedTabs.hidden, true, "Pin Selected Tabs is hidden");
+ is(menuItemUnpinSelectedTabs.hidden, false, "Unpin Selected Tabs is visible");
+
+ let tab1Unpinned = BrowserTestUtils.waitForEvent(tab1, "TabUnpinned");
+ let tab2Unpinned = BrowserTestUtils.waitForEvent(tab2, "TabUnpinned");
+ menuItemUnpinSelectedTabs.click();
+ await tab1Unpinned;
+ await tab2Unpinned;
+
+ ok(!tab1.pinned, "Tab1 is unpinned");
+ ok(!tab2.pinned, "Tab2 is unpinned");
+ ok(!tab3.pinned, "Tab3 is unpinned");
+ is(tab1._tPos, 0, "Tab1 should still be first after unpinning");
+ is(tab2._tPos, 1, "Tab2 should still be second after unpinning");
+ is(tab3._tPos, 2, "Tab3 should still be third after unpinning");
+
+ BrowserTestUtils.removeTab(tab2);
+ BrowserTestUtils.removeTab(tab3);
+});
diff --git a/browser/base/content/test/tabs/browser_multiselect_tabs_play.js b/browser/base/content/test/tabs/browser_multiselect_tabs_play.js
new file mode 100644
index 0000000000..281ed50c1b
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_multiselect_tabs_play.js
@@ -0,0 +1,254 @@
+/* Any copyright is dedicated to the Public Domain.
+ * https://creativecommons.org/publicdomain/zero/1.0/ */
+
+/*
+ * Ensure multiselected tabs that are active media blocked act correctly
+ * when we try to unblock them using the "Play Tabs" icon or by calling
+ * resumeDelayedMediaOnMultiSelectedTabs()
+ */
+
+"use strict";
+
+const PREF_DELAY_AUTOPLAY = "media.block-autoplay-until-in-foreground";
+
+add_task(async function setPref() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[PREF_DELAY_AUTOPLAY, true]],
+ });
+});
+
+/*
+ * Playing blocked media will not mute the selected tabs
+ */
+add_task(async function testDelayPlayWontAffectUnmuteStatus() {
+ info("Add media tabs");
+ let tab0 = await addMediaTab();
+ let tab1 = await addMediaTab();
+
+ info("Play both tabs");
+ await play(tab0, false);
+ await play(tab1, false);
+
+ info("Multiselect tabs");
+ await triggerClickOn(tab1, { shiftKey: true });
+
+ // Check multiselection
+ ok(tab0.multiselected, "tab0 is multiselected");
+ ok(tab1.multiselected, "tab1 is multiselected");
+
+ // Check tabs are unmuted
+ ok(!muted(tab0), "Tab0 is unmuted");
+ ok(!muted(tab1), "Tab1 is unmuted");
+
+ let tab0BlockPromise = wait_for_tab_media_blocked_event(tab0, false);
+ let tab1BlockPromise = wait_for_tab_media_blocked_event(tab1, false);
+
+ // Play media on selected tabs
+ gBrowser.resumeDelayedMediaOnMultiSelectedTabs();
+
+ info("Wait for media to play");
+ await tab0BlockPromise;
+ await tab1BlockPromise;
+
+ // Check tabs are still unmuted
+ ok(!muted(tab0), "Tab0 is unmuted");
+ ok(!muted(tab1), "Tab1 is unmuted");
+
+ BrowserTestUtils.removeTab(tab0);
+ BrowserTestUtils.removeTab(tab1);
+});
+
+/*
+ * Playing blocked media will not unmute the selected tabs
+ */
+add_task(async function testDelayPlayWontAffectMuteStatus() {
+ info("Add media tabs");
+ let tab0 = await addMediaTab();
+ let tab1 = await addMediaTab();
+
+ info("Play both tabs");
+ await play(tab0, false);
+ await play(tab1, false);
+
+ // Mute both tabs
+ toggleMuteAudio(tab0, true);
+ toggleMuteAudio(tab1, true);
+
+ info("Multiselect tabs");
+ await triggerClickOn(tab1, { shiftKey: true });
+
+ // Check multiselection
+ ok(tab0.multiselected, "tab0 is multiselected");
+ ok(tab1.multiselected, "tab1 is multiselected");
+
+ // Check tabs are muted
+ ok(muted(tab0), "Tab0 is muted");
+ ok(muted(tab1), "Tab1 is muted");
+
+ let tab0BlockPromise = wait_for_tab_media_blocked_event(tab0, false);
+ let tab1BlockPromise = wait_for_tab_media_blocked_event(tab1, false);
+
+ // Play media on selected tabs
+ gBrowser.resumeDelayedMediaOnMultiSelectedTabs();
+
+ info("Wait for media to play");
+ await tab0BlockPromise;
+ await tab1BlockPromise;
+
+ // Check tabs are still muted
+ ok(muted(tab0), "Tab0 is muted");
+ ok(muted(tab1), "Tab1 is muted");
+
+ BrowserTestUtils.removeTab(tab0);
+ BrowserTestUtils.removeTab(tab1);
+});
+
+/*
+ * The "Play Tabs" icon unblocks media
+ */
+add_task(async function testDelayPlayWhenUsingButton() {
+ info("Add media tabs");
+ let tab0 = await addMediaTab();
+ let tab1 = await addMediaTab();
+ let tab2 = await addMediaTab();
+ let tab3 = await addMediaTab();
+ let tab4 = await addMediaTab();
+
+ let tabs = [tab0, tab1, tab2, tab3, tab4];
+
+ // All tabs are initially unblocked due to not being played yet
+ ok(!activeMediaBlocked(tab0), "Tab0 is not activemedia-blocked");
+ ok(!activeMediaBlocked(tab1), "Tab1 is not activemedia-blocked");
+ ok(!activeMediaBlocked(tab2), "Tab2 is not activemedia-blocked");
+ ok(!activeMediaBlocked(tab3), "Tab3 is not activemedia-blocked");
+ ok(!activeMediaBlocked(tab4), "Tab4 is not activemedia-blocked");
+
+ // Playing tabs 0, 1, and 2 will block them
+ info("Play tabs 0, 1, and 2");
+ await play(tab0, false);
+ await play(tab1, false);
+ await play(tab2, false);
+ ok(activeMediaBlocked(tab0), "Tab0 is activemedia-blocked");
+ ok(activeMediaBlocked(tab1), "Tab1 is activemedia-blocked");
+ ok(activeMediaBlocked(tab2), "Tab2 is activemedia-blocked");
+
+ // tab3 and tab4 are still unblocked
+ ok(!activeMediaBlocked(tab3), "Tab3 is not activemedia-blocked");
+ ok(!activeMediaBlocked(tab4), "Tab4 is not activemedia-blocked");
+
+ // Multiselect tab0, tab1, tab2, and tab3.
+ info("Multiselect tabs");
+ await triggerClickOn(tab3, { shiftKey: true });
+
+ // Check multiselection
+ for (let i = 0; i <= 3; i++) {
+ ok(tabs[i].multiselected, `tab${i} is multiselected`);
+ }
+ ok(!tab4.multiselected, "tab4 is not multiselected");
+
+ let tab0BlockPromise = wait_for_tab_media_blocked_event(tab0, false);
+ let tab1BlockPromise = wait_for_tab_media_blocked_event(tab1, false);
+ let tab2BlockPromise = wait_for_tab_media_blocked_event(tab2, false);
+
+ // Use the overlay icon on tab2 to play media on the selected tabs
+ info("Press play tab2 icon");
+ await pressIcon(tab2.overlayIcon);
+
+ // tab0, tab1, and tab2 were played and multiselected
+ // They will now be unblocked and playing media
+ info("Wait for tabs to play");
+ await tab0BlockPromise;
+ await tab1BlockPromise;
+ await tab2BlockPromise;
+ ok(!activeMediaBlocked(tab0), "Tab0 is not activemedia-blocked");
+ ok(!activeMediaBlocked(tab1), "Tab1 is not activemedia-blocked");
+ ok(!activeMediaBlocked(tab2), "Tab2 is not activemedia-blocked");
+ // tab3 was also multiselected but never played
+ // It will be unblocked but not playing media
+ ok(!activeMediaBlocked(tab3), "Tab3 is not activemedia-blocked");
+ // tab4 was not multiselected and was never played
+ // It remains in its original state
+ ok(!activeMediaBlocked(tab4), "Tab4 is not activemedia-blocked");
+
+ for (let tab of tabs) {
+ BrowserTestUtils.removeTab(tab);
+ }
+});
+
+/*
+ * Tab context menus have to show the menu icons "Play Tab" or "Play Tabs"
+ * depending on the number of tabs selected, and whether blocked media is present
+ */
+add_task(async function testTabContextMenu() {
+ info("Add media tabs");
+ let tab0 = await addMediaTab();
+ let tab1 = await addMediaTab();
+ let tab2 = await addMediaTab();
+ let tab3 = await addMediaTab();
+ let tabs = [tab0, tab1, tab2, tab3];
+
+ let menuItemPlayTab = document.getElementById("context_playTab");
+ let menuItemPlaySelectedTabs = document.getElementById(
+ "context_playSelectedTabs"
+ );
+
+ // Multiselect tab0, tab1, and tab2.
+ info("Multiselect tabs");
+ await triggerClickOn(tab0, { ctrlKey: true });
+ await triggerClickOn(tab1, { ctrlKey: true });
+ await triggerClickOn(tab2, { ctrlKey: true });
+
+ // Check multiselected tabs
+ for (let i = 0; i <= 2; i++) {
+ ok(tabs[i].multiselected, `tab${i} is multi-selected`);
+ }
+ ok(!tab3.multiselected, "tab3 is not multiselected");
+
+ // No active media yet:
+ // - "Play Tab" is hidden
+ // - "Play Tabs" is hidden
+ for (let i = 0; i <= 2; i++) {
+ updateTabContextMenu(tabs[i]);
+ ok(menuItemPlayTab.hidden, `tab${i} "Play Tab" is hidden`);
+ ok(menuItemPlaySelectedTabs.hidden, `tab${i} "Play Tabs" is hidden`);
+ ok(!activeMediaBlocked(tabs[i]), `tab${i} is not active media blocked`);
+ }
+
+ info("Play tabs 0, 1, and 2");
+ await play(tab0, false);
+ await play(tab1, false);
+ await play(tab2, false);
+
+ // Active media blocked:
+ // - "Play Tab" is hidden
+ // - "Play Tabs" is visible
+ for (let i = 0; i <= 2; i++) {
+ updateTabContextMenu(tabs[i]);
+ ok(menuItemPlayTab.hidden, `tab${i} "Play Tab" is hidden`);
+ ok(!menuItemPlaySelectedTabs.hidden, `tab${i} "Play Tabs" is visible`);
+ ok(activeMediaBlocked(tabs[i]), `tab${i} is active media blocked`);
+ }
+
+ info("Play Media on tabs 0, 1, and 2");
+ gBrowser.resumeDelayedMediaOnMultiSelectedTabs();
+
+ // Active media is unblocked:
+ // - "Play Tab" is hidden
+ // - "Play Tabs" is hidden
+ for (let i = 0; i <= 2; i++) {
+ updateTabContextMenu(tabs[i]);
+ ok(menuItemPlayTab.hidden, `tab${i} "Play Tab" is hidden`);
+ ok(menuItemPlaySelectedTabs.hidden, `tab${i} "Play Tabs" is hidden`);
+ ok(!activeMediaBlocked(tabs[i]), `tab${i} is not active media blocked`);
+ }
+
+ // tab3 is untouched
+ updateTabContextMenu(tab3);
+ ok(menuItemPlayTab.hidden, 'tab3 "Play Tab" is hidden');
+ ok(menuItemPlaySelectedTabs.hidden, 'tab3 "Play Tabs" is hidden');
+ ok(!activeMediaBlocked(tab3), "tab3 is not active media blocked");
+
+ for (let tab of tabs) {
+ BrowserTestUtils.removeTab(tab);
+ }
+});
diff --git a/browser/base/content/test/tabs/browser_multiselect_tabs_reload.js b/browser/base/content/test/tabs/browser_multiselect_tabs_reload.js
new file mode 100644
index 0000000000..7a68fd66d5
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_multiselect_tabs_reload.js
@@ -0,0 +1,82 @@
+async function tabLoaded(tab) {
+ const browser = gBrowser.getBrowserForTab(tab);
+ await BrowserTestUtils.browserLoaded(browser);
+ return true;
+}
+
+add_task(async function test_usingTabContextMenu() {
+ let tab1 = await addTab();
+ let tab2 = await addTab();
+ let tab3 = await addTab();
+
+ let menuItemReloadTab = document.getElementById("context_reloadTab");
+ let menuItemReloadSelectedTabs = document.getElementById(
+ "context_reloadSelectedTabs"
+ );
+
+ await BrowserTestUtils.switchTab(gBrowser, tab1);
+ await triggerClickOn(tab2, { ctrlKey: true });
+
+ ok(tab1.multiselected, "Tab1 is multi-selected");
+ ok(tab2.multiselected, "Tab2 is multi-selected");
+ ok(!tab3.multiselected, "Tab3 is not multi-selected");
+
+ updateTabContextMenu(tab3);
+ is(menuItemReloadTab.hidden, false, "Reload Tab is visible");
+ is(menuItemReloadSelectedTabs.hidden, true, "Reload Tabs is hidden");
+
+ updateTabContextMenu(tab2);
+ is(menuItemReloadTab.hidden, true, "Reload Tab is hidden");
+ is(menuItemReloadSelectedTabs.hidden, false, "Reload Tabs is visible");
+
+ let tab1Loaded = tabLoaded(tab1);
+ let tab2Loaded = tabLoaded(tab2);
+ menuItemReloadSelectedTabs.click();
+ await tab1Loaded;
+ await tab2Loaded;
+
+ // We got here because tab1 and tab2 are reloaded. Otherwise the test would have timed out and failed.
+ ok(true, "Tab1 and Tab2 are reloaded");
+
+ BrowserTestUtils.removeTab(tab1);
+ BrowserTestUtils.removeTab(tab2);
+ BrowserTestUtils.removeTab(tab3);
+});
+
+add_task(async function test_usingKeyboardShortcuts() {
+ let keys = [
+ ["R", { accelKey: true }],
+ ["R", { accelKey: true, shift: true }],
+ ["VK_F5", {}],
+ ];
+
+ if (AppConstants.platform != "macosx") {
+ keys.push(["VK_F5", { accelKey: true }]);
+ }
+
+ for (let key of keys) {
+ let tab1 = await addTab();
+ let tab2 = await addTab();
+ let tab3 = await addTab();
+
+ await BrowserTestUtils.switchTab(gBrowser, tab1);
+ await triggerClickOn(tab2, { ctrlKey: true });
+
+ ok(tab1.multiselected, "Tab1 is multi-selected");
+ ok(tab2.multiselected, "Tab2 is multi-selected");
+ ok(!tab3.multiselected, "Tab3 is not multi-selected");
+
+ let tab1Loaded = tabLoaded(tab1);
+ let tab2Loaded = tabLoaded(tab2);
+ EventUtils.synthesizeKey(key[0], key[1]);
+ await tab1Loaded;
+ await tab2Loaded;
+
+ // We got here because tab1 and tab2 are reloaded. Otherwise the test would have timed out and failed.
+ ok(true, "Tab1 and Tab2 are reloaded");
+
+ BrowserTestUtils.removeTab(tab1);
+ BrowserTestUtils.removeTab(tab2);
+ BrowserTestUtils.removeTab(tab3);
+ }
+});
diff --git a/browser/base/content/test/tabs/browser_multiselect_tabs_reopen_in_container.js b/browser/base/content/test/tabs/browser_multiselect_tabs_reopen_in_container.js
new file mode 100644
index 0000000000..0c9c913844
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_multiselect_tabs_reopen_in_container.js
@@ -0,0 +1,133 @@
+"use strict";
+
+const PREF_PRIVACY_USER_CONTEXT_ENABLED = "privacy.userContext.enabled";
+
+async function openTabMenuFor(tab) {
+ let tabMenu = tab.ownerDocument.getElementById("tabContextMenu");
+
+ let tabMenuShown = BrowserTestUtils.waitForEvent(tabMenu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(
+ tab,
+ { type: "contextmenu" },
+ tab.ownerGlobal
+ );
+ await tabMenuShown;
+
+ return tabMenu;
+}
+
+async function openReopenMenuForTab(tab) {
+ await openTabMenuFor(tab);
+
+ let reopenItem = tab.ownerDocument.getElementById(
+ "context_reopenInContainer"
+ );
+ ok(!reopenItem.hidden, "Reopen in Container item should be shown");
+
+ let reopenMenu = reopenItem.getElementsByTagName("menupopup")[0];
+ let reopenMenuShown = BrowserTestUtils.waitForEvent(reopenMenu, "popupshown");
+ reopenItem.openMenu(true);
+ await reopenMenuShown;
+
+ return reopenMenu;
+}
+
+function checkMenuItem(reopenMenu, shown, hidden) {
+ for (let id of shown) {
+ ok(
+ reopenMenu.querySelector(`menuitem[data-usercontextid="${id}"]`),
+ `User context id ${id} should exist`
+ );
+ }
+ for (let id of hidden) {
+ ok(
+ !reopenMenu.querySelector(`menuitem[data-usercontextid="${id}"]`),
+ `User context id ${id} shouldn't exist`
+ );
+ }
+}
+
+function openTabInContainer(gBrowser, tab, reopenMenu, id) {
+ let tabPromise = BrowserTestUtils.waitForNewTab(gBrowser, getUrl(tab), true);
+ let menuitem = reopenMenu.querySelector(
+ `menuitem[data-usercontextid="${id}"]`
+ );
+ reopenMenu.activateItem(menuitem);
+ return tabPromise;
+}
+
+add_task(async function testReopen() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[PREF_PRIVACY_USER_CONTEXT_ENABLED, true]],
+ });
+
+ let tab1 = await addTab("http://mochi.test:8888/1");
+ let tab2 = await addTab("http://mochi.test:8888/2");
+ let tab3 = await addTab("http://mochi.test:8888/3");
+ let tab4 = BrowserTestUtils.addTab(gBrowser, "http://mochi.test:8888/3", {
+ createLazyBrowser: true,
+ });
+
+ await BrowserTestUtils.switchTab(gBrowser, tab1);
+
+ await triggerClickOn(tab2, { ctrlKey: true });
+ await triggerClickOn(tab4, { ctrlKey: true });
+
+ for (let tab of [tab1, tab2, tab3, tab4]) {
+ ok(
+ !tab.hasAttribute("usercontextid"),
+ "Tab with No Container should be opened"
+ );
+ }
+
+ ok(tab1.multiselected, "Tab1 is multi-selected");
+ ok(tab2.multiselected, "Tab2 is multi-selected");
+ ok(!tab3.multiselected, "Tab3 is not multi-selected");
+ ok(tab4.multiselected, "Tab4 is multi-selected");
+
+ is(gBrowser.visibleTabs.length, 5, "We have 5 tabs open");
+
+ let reopenMenu1 = await openReopenMenuForTab(tab1);
+ checkMenuItem(reopenMenu1, [1, 2, 3, 4], [0]);
+ let containerTab1 = await openTabInContainer(
+ gBrowser,
+ tab1,
+ reopenMenu1,
+ "1"
+ );
+
+ let tabs = gBrowser.visibleTabs;
+ is(tabs.length, 8, "Now we have 8 tabs open");
+
+ is(containerTab1._tPos, 2, "containerTab1 position is 3");
+ is(
+ containerTab1.getAttribute("usercontextid"),
+ "1",
+ "Tab(1) with UCI=1 should be opened"
+ );
+ is(getUrl(containerTab1), getUrl(tab1), "Same page (tab1) should be opened");
+
+ let containerTab2 = tabs[4];
+ is(
+ containerTab2.getAttribute("usercontextid"),
+ "1",
+ "Tab(2) with UCI=1 should be opened"
+ );
+ await TestUtils.waitForCondition(function () {
+ return getUrl(containerTab2) == getUrl(tab2);
+ }, "Same page (tab2) should be opened");
+
+ let containerTab4 = tabs[7];
+ is(
+ containerTab2.getAttribute("usercontextid"),
+ "1",
+ "Tab(4) with UCI=1 should be opened"
+ );
+ await TestUtils.waitForCondition(function () {
+ return getUrl(containerTab4) == getUrl(tab4);
+ }, "Same page (tab4) should be opened");
+
+ for (let tab of tabs.filter(t => t != tabs[0])) {
+ BrowserTestUtils.removeTab(tab);
+ }
+});
diff --git a/browser/base/content/test/tabs/browser_multiselect_tabs_reorder.js b/browser/base/content/test/tabs/browser_multiselect_tabs_reorder.js
new file mode 100644
index 0000000000..c3b3356608
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_multiselect_tabs_reorder.js
@@ -0,0 +1,65 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+add_task(async function () {
+ // Disable tab animations
+ gReduceMotionOverride = true;
+
+ let tab0 = gBrowser.selectedTab;
+ let tab1 = await addTab();
+ let tab2 = await addTab();
+ let tab3 = await addTab();
+ let tab4 = await addTab();
+ let tab5 = await addTab();
+ let tabs = [tab0, tab1, tab2, tab3, tab4, tab5];
+
+ await BrowserTestUtils.switchTab(gBrowser, tab1);
+ await triggerClickOn(tab3, { ctrlKey: true });
+ await triggerClickOn(tab5, { ctrlKey: true });
+
+ is(gBrowser.selectedTab, tab1, "Tab1 is active");
+ is(gBrowser.selectedTabs.length, 3, "Three selected tabs");
+
+ for (let i of [1, 3, 5]) {
+ ok(tabs[i].multiselected, "Tab" + i + " is multiselected");
+ }
+ for (let i of [0, 2, 4]) {
+ ok(!tabs[i].multiselected, "Tab" + i + " is not multiselected");
+ }
+ for (let i of [0, 1, 2, 3, 4, 5]) {
+ is(tabs[i]._tPos, i, "Tab" + i + " position is :" + i);
+ }
+
+ await dragAndDrop(tab3, tab4, false);
+
+ is(gBrowser.selectedTab, tab3, "Dragged tab (tab3) is now active");
+ is(gBrowser.selectedTabs.length, 3, "Three selected tabs");
+
+ for (let i of [1, 3, 5]) {
+ ok(tabs[i].multiselected, "Tab" + i + " is still multiselected");
+ }
+ for (let i of [0, 2, 4]) {
+ ok(!tabs[i].multiselected, "Tab" + i + " is still not multiselected");
+ }
+
+ is(tab0._tPos, 0, "Tab0 position (0) doesn't change");
+
+ // Multiselected tabs gets grouped at the start of the slide.
+ is(
+ tab1._tPos,
+ tab3._tPos - 1,
+ "Tab1 is located right at the left of the dragged tab (tab3)"
+ );
+ is(
+ tab5._tPos,
+ tab3._tPos + 1,
+ "Tab5 is located right at the right of the dragged tab (tab3)"
+ );
+ is(tab3._tPos, 4, "Dragged tab (tab3) position is 4");
+
+ is(tab4._tPos, 2, "Drag target (tab4) has shifted to position 2");
+
+ for (let tab of tabs.filter(t => t != tab0)) {
+ BrowserTestUtils.removeTab(tab);
+ }
+});
diff --git a/browser/base/content/test/tabs/browser_multiselect_tabs_using_Ctrl.js b/browser/base/content/test/tabs/browser_multiselect_tabs_using_Ctrl.js
new file mode 100644
index 0000000000..93a14a87a7
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_multiselect_tabs_using_Ctrl.js
@@ -0,0 +1,60 @@
+add_task(async function click() {
+ const initialFocusedTab = await addTab();
+ await BrowserTestUtils.switchTab(gBrowser, initialFocusedTab);
+ const tab = await addTab();
+
+ await triggerClickOn(tab, { ctrlKey: true });
+ ok(
+ tab.multiselected && gBrowser._multiSelectedTabsSet.has(tab),
+ "Tab should be (multi) selected after click"
+ );
+ isnot(gBrowser.selectedTab, tab, "Multi-selected tab is not focused");
+ is(gBrowser.selectedTab, initialFocusedTab, "Focused tab doesn't change");
+
+ await triggerClickOn(tab, { ctrlKey: true });
+ ok(
+ !tab.multiselected && !gBrowser._multiSelectedTabsSet.has(tab),
+ "Tab is not (multi) selected anymore"
+ );
+ is(
+ gBrowser.selectedTab,
+ initialFocusedTab,
+ "Focused tab still doesn't change"
+ );
+
+ BrowserTestUtils.removeTab(initialFocusedTab);
+ BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function clearSelection() {
+ const tab1 = await addTab();
+ const tab2 = await addTab();
+ const tab3 = await addTab();
+
+ await BrowserTestUtils.switchTab(gBrowser, tab1);
+ info("We multi-select tab2 with ctrl key down");
+ await triggerClickOn(tab2, { ctrlKey: true });
+
+ ok(
+ tab1.multiselected && gBrowser._multiSelectedTabsSet.has(tab1),
+ "Tab1 is (multi) selected"
+ );
+ ok(
+ tab2.multiselected && gBrowser._multiSelectedTabsSet.has(tab2),
+ "Tab2 is (multi) selected"
+ );
+ is(gBrowser.multiSelectedTabsCount, 2, "Two tabs (multi) selected");
+ isnot(tab3, gBrowser.selectedTab, "Tab3 doesn't have focus");
+
+ info("We select tab3 with Ctrl key up");
+ await triggerClickOn(tab3, { ctrlKey: false });
+
+ ok(!tab1.multiselected, "Tab1 is not (multi) selected");
+ ok(!tab2.multiselected, "Tab2 is not (multi) selected");
+ is(gBrowser.multiSelectedTabsCount, 0, "Multi-selection is cleared");
+ is(tab3, gBrowser.selectedTab, "Tab3 has focus");
+
+ BrowserTestUtils.removeTab(tab1);
+ BrowserTestUtils.removeTab(tab2);
+ BrowserTestUtils.removeTab(tab3);
+});
diff --git a/browser/base/content/test/tabs/browser_multiselect_tabs_using_Shift.js b/browser/base/content/test/tabs/browser_multiselect_tabs_using_Shift.js
new file mode 100644
index 0000000000..ac647bae3c
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_multiselect_tabs_using_Shift.js
@@ -0,0 +1,159 @@
+add_task(async function noItemsInTheCollectionBeforeShiftClicking() {
+ let tab1 = await addTab();
+ let tab2 = await addTab();
+ let tab3 = await addTab();
+ let tab4 = await addTab();
+ let tab5 = await addTab();
+
+ await BrowserTestUtils.switchTab(gBrowser, tab1);
+
+ is(gBrowser.selectedTab, tab1, "Tab1 has focus now");
+ is(gBrowser.multiSelectedTabsCount, 0, "No tab is multi-selected");
+
+ gBrowser.hideTab(tab3);
+ ok(tab3.hidden, "Tab3 is hidden");
+
+ info("Click on tab4 while holding shift key");
+ await triggerClickOn(tab4, { shiftKey: true });
+
+ ok(
+ tab1.multiselected && gBrowser._multiSelectedTabsSet.has(tab1),
+ "Tab1 is multi-selected"
+ );
+ ok(
+ tab2.multiselected && gBrowser._multiSelectedTabsSet.has(tab2),
+ "Tab2 is multi-selected"
+ );
+ ok(
+ !tab3.multiselected && !gBrowser._multiSelectedTabsSet.has(tab3),
+ "Hidden tab3 is not multi-selected"
+ );
+ ok(
+ tab4.multiselected && gBrowser._multiSelectedTabsSet.has(tab4),
+ "Tab4 is multi-selected"
+ );
+ ok(
+ !tab5.multiselected && !gBrowser._multiSelectedTabsSet.has(tab5),
+ "Tab5 is not multi-selected"
+ );
+ is(gBrowser.multiSelectedTabsCount, 3, "three multi-selected tabs");
+ is(gBrowser.selectedTab, tab1, "Tab1 still has focus");
+
+ BrowserTestUtils.removeTab(tab1);
+ BrowserTestUtils.removeTab(tab2);
+ BrowserTestUtils.removeTab(tab3);
+ BrowserTestUtils.removeTab(tab4);
+ BrowserTestUtils.removeTab(tab5);
+});
+
+add_task(async function itemsInTheCollectionBeforeShiftClicking() {
+ let tab1 = await addTab();
+ let tab2 = await addTab();
+ let tab3 = await addTab();
+ let tab4 = await addTab();
+ let tab5 = await addTab();
+
+ await BrowserTestUtils.switchTab(gBrowser, () => triggerClickOn(tab1, {}));
+
+ is(gBrowser.selectedTab, tab1, "Tab1 has focus now");
+ is(gBrowser.multiSelectedTabsCount, 0, "No tab is multi-selected");
+
+ await triggerClickOn(tab3, { ctrlKey: true });
+ is(gBrowser.selectedTab, tab1, "Tab1 still has focus");
+ is(gBrowser.multiSelectedTabsCount, 2, "Two tabs are multi-selected");
+ ok(
+ tab1.multiselected && gBrowser._multiSelectedTabsSet.has(tab1),
+ "Tab1 is multi-selected"
+ );
+ ok(
+ tab3.multiselected && gBrowser._multiSelectedTabsSet.has(tab3),
+ "Tab3 is multi-selected"
+ );
+
+ info("Click on tab5 while holding Shift key");
+ await BrowserTestUtils.switchTab(
+ gBrowser,
+ triggerClickOn(tab5, { shiftKey: true })
+ );
+
+ is(gBrowser.selectedTab, tab3, "Tab3 has focus");
+ ok(
+ !tab1.multiselected && !gBrowser._multiSelectedTabsSet.has(tab1),
+ "Tab1 is not multi-selected"
+ );
+ ok(
+ !tab2.multiselected && !gBrowser._multiSelectedTabsSet.has(tab2),
+ "Tab2 is not multi-selected "
+ );
+ ok(
+ tab3.multiselected && gBrowser._multiSelectedTabsSet.has(tab3),
+ "Tab3 is multi-selected"
+ );
+ ok(
+ tab4.multiselected && gBrowser._multiSelectedTabsSet.has(tab4),
+ "Tab4 is multi-selected"
+ );
+ ok(
+ tab5.multiselected && gBrowser._multiSelectedTabsSet.has(tab5),
+ "Tab5 is multi-selected"
+ );
+ is(gBrowser.multiSelectedTabsCount, 3, "Three tabs are multi-selected");
+
+ info("Click on tab4 while holding Shift key");
+ await triggerClickOn(tab4, { shiftKey: true });
+
+ is(gBrowser.selectedTab, tab3, "Tab3 has focus");
+ ok(
+ !tab1.multiselected && !gBrowser._multiSelectedTabsSet.has(tab1),
+ "Tab1 is not multi-selected"
+ );
+ ok(
+ !tab2.multiselected && !gBrowser._multiSelectedTabsSet.has(tab2),
+ "Tab2 is not multi-selected "
+ );
+ ok(
+ tab3.multiselected && gBrowser._multiSelectedTabsSet.has(tab3),
+ "Tab3 is multi-selected"
+ );
+ ok(
+ tab4.multiselected && gBrowser._multiSelectedTabsSet.has(tab4),
+ "Tab4 is multi-selected"
+ );
+ ok(
+ !tab5.multiselected && !gBrowser._multiSelectedTabsSet.has(tab5),
+ "Tab5 is not multi-selected"
+ );
+ is(gBrowser.multiSelectedTabsCount, 2, "Two tabs are multi-selected");
+
+ info("Click on tab1 while holding Shift key");
+ await triggerClickOn(tab1, { shiftKey: true });
+
+ is(gBrowser.selectedTab, tab3, "Tab3 has focus");
+ ok(
+ tab1.multiselected && gBrowser._multiSelectedTabsSet.has(tab1),
+ "Tab1 is multi-selected"
+ );
+ ok(
+ tab2.multiselected && gBrowser._multiSelectedTabsSet.has(tab2),
+ "Tab2 is multi-selected "
+ );
+ ok(
+ tab3.multiselected && gBrowser._multiSelectedTabsSet.has(tab3),
+ "Tab3 is multi-selected"
+ );
+ ok(
+ !tab4.multiselected && !gBrowser._multiSelectedTabsSet.has(tab4),
+ "Tab4 is not multi-selected"
+ );
+ ok(
+ !tab5.multiselected && !gBrowser._multiSelectedTabsSet.has(tab5),
+ "Tab5 is not multi-selected"
+ );
+ is(gBrowser.multiSelectedTabsCount, 3, "Three tabs are multi-selected");
+
+ BrowserTestUtils.removeTab(tab1);
+ BrowserTestUtils.removeTab(tab2);
+ BrowserTestUtils.removeTab(tab3);
+ BrowserTestUtils.removeTab(tab4);
+ BrowserTestUtils.removeTab(tab5);
+});
diff --git a/browser/base/content/test/tabs/browser_multiselect_tabs_using_Shift_and_Ctrl.js b/browser/base/content/test/tabs/browser_multiselect_tabs_using_Shift_and_Ctrl.js
new file mode 100644
index 0000000000..9e26a5562e
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_multiselect_tabs_using_Shift_and_Ctrl.js
@@ -0,0 +1,75 @@
+add_task(async function selectionWithShiftPreviously() {
+ const tab1 = await addTab();
+ const tab2 = await addTab();
+ const tab3 = await addTab();
+ const tab4 = await addTab();
+ const tab5 = await addTab();
+
+ await BrowserTestUtils.switchTab(gBrowser, tab3);
+
+ is(gBrowser.multiSelectedTabsCount, 0, "No tab is multi-selected");
+
+ info("Click on tab5 with Shift down");
+ await triggerClickOn(tab5, { shiftKey: true });
+
+ is(gBrowser.selectedTab, tab3, "Tab3 has focus");
+ ok(!tab1.multiselected, "Tab1 is not multi-selected");
+ ok(!tab2.multiselected, "Tab2 is not multi-selected ");
+ ok(tab3.multiselected, "Tab3 is multi-selected");
+ ok(tab4.multiselected, "Tab4 is multi-selected");
+ ok(tab5.multiselected, "Tab5 is multi-selected");
+ is(gBrowser.multiSelectedTabsCount, 3, "Three tabs are multi-selected");
+
+ info("Click on tab1 with both Ctrl/Cmd and Shift down");
+ await triggerClickOn(tab1, { ctrlKey: true, shiftKey: true });
+
+ is(gBrowser.selectedTab, tab3, "Tab3 has focus");
+ ok(tab1.multiselected, "Tab1 is multi-selected");
+ ok(tab2.multiselected, "Tab2 is multi-selected ");
+ ok(tab3.multiselected, "Tab3 is multi-selected");
+ ok(tab4.multiselected, "Tab4 is multi-selected");
+ ok(tab5.multiselected, "Tab5 is multi-selected");
+ is(gBrowser.multiSelectedTabsCount, 5, "Five tabs are multi-selected");
+
+ for (let tab of [tab1, tab2, tab3, tab4, tab5]) {
+ BrowserTestUtils.removeTab(tab);
+ }
+});
+
+add_task(async function selectionWithCtrlPreviously() {
+ const tab1 = await addTab();
+ const tab2 = await addTab();
+ const tab3 = await addTab();
+ const tab4 = await addTab();
+ const tab5 = await addTab();
+
+ await BrowserTestUtils.switchTab(gBrowser, tab1);
+
+ is(gBrowser.multiSelectedTabsCount, 0, "No tab is multi-selected");
+
+ info("Click on tab3 with Ctrl key down");
+ await triggerClickOn(tab3, { ctrlKey: true });
+
+ is(gBrowser.selectedTab, tab1, "Tab1 has focus");
+ ok(tab1.multiselected, "Tab1 is multi-selected");
+ ok(!tab2.multiselected, "Tab2 is not multi-selected ");
+ ok(tab3.multiselected, "Tab3 is multi-selected");
+ ok(!tab4.multiselected, "Tab4 is not multi-selected");
+ ok(!tab5.multiselected, "Tab5 is not multi-selected");
+ is(gBrowser.multiSelectedTabsCount, 2, "Two tabs are multi-selected");
+
+ info("Click on tab5 with both Ctrl/Cmd and Shift down");
+ await triggerClickOn(tab5, { ctrlKey: true, shiftKey: true });
+
+ is(gBrowser.selectedTab, tab1, "Tab3 has focus");
+ ok(tab1.multiselected, "Tab1 is multi-selected");
+ ok(!tab2.multiselected, "Tab2 is not multi-selected ");
+ ok(tab3.multiselected, "Tab3 is multi-selected");
+ ok(tab4.multiselected, "Tab4 is multi-selected");
+ ok(tab5.multiselected, "Tab5 is multi-selected");
+ is(gBrowser.multiSelectedTabsCount, 4, "Four tabs are multi-selected");
+
+ for (let tab of [tab1, tab2, tab3, tab4, tab5]) {
+ BrowserTestUtils.removeTab(tab);
+ }
+});
diff --git a/browser/base/content/test/tabs/browser_multiselect_tabs_using_keyboard.js b/browser/base/content/test/tabs/browser_multiselect_tabs_using_keyboard.js
new file mode 100644
index 0000000000..cdb0b7bf0c
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_multiselect_tabs_using_keyboard.js
@@ -0,0 +1,147 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+function synthesizeKeyAndWaitForFocus(element, keyCode, options) {
+ let focused = BrowserTestUtils.waitForEvent(element, "focus");
+ EventUtils.synthesizeKey(keyCode, options);
+ return focused;
+}
+
+function synthesizeKeyAndWaitForTabToGetKeyboardFocus(tab, keyCode, options) {
+ let focused = TestUtils.waitForCondition(() => {
+ return tab.classList.contains("keyboard-focused-tab");
+ }, "Waiting for tab to get keyboard focus");
+ EventUtils.synthesizeKey(keyCode, options);
+ return focused;
+}
+
+add_setup(async function () {
+ // The DevEdition has the DevTools button in the toolbar by default. Remove it
+ // to prevent branch-specific rules what button should be focused.
+ CustomizableUI.removeWidgetFromArea("developer-button");
+
+ let prevActiveElement = document.activeElement;
+ registerCleanupFunction(() => {
+ CustomizableUI.reset();
+ prevActiveElement.focus();
+ });
+});
+
+add_task(async function changeSelectionUsingKeyboard() {
+ const tab1 = await addTab("http://mochi.test:8888/1");
+ const tab2 = await addTab("http://mochi.test:8888/2");
+ const tab3 = await addTab("http://mochi.test:8888/3");
+ const tab4 = await addTab("http://mochi.test:8888/4");
+ const tab5 = await addTab("http://mochi.test:8888/5");
+
+ await BrowserTestUtils.switchTab(gBrowser, tab3);
+ info("Move focus to location bar using the keyboard");
+ await synthesizeKeyAndWaitForFocus(gURLBar, "l", { accelKey: true });
+ is(document.activeElement, gURLBar.inputField, "urlbar should be focused");
+
+ info("Move focus to the selected tab using the keyboard");
+ let trackingProtectionIconContainer = document.querySelector(
+ "#tracking-protection-icon-container"
+ );
+ await synthesizeKeyAndWaitForFocus(
+ trackingProtectionIconContainer,
+ "VK_TAB",
+ { shiftKey: true }
+ );
+ is(
+ document.activeElement,
+ trackingProtectionIconContainer,
+ "tracking protection icon container should be focused"
+ );
+ await synthesizeKeyAndWaitForFocus(
+ document.getElementById("reload-button"),
+ "VK_TAB",
+ { shiftKey: true }
+ );
+ await synthesizeKeyAndWaitForFocus(
+ document.getElementById("tabs-newtab-button"),
+ "VK_TAB",
+ { shiftKey: true }
+ );
+ await synthesizeKeyAndWaitForFocus(tab3, "VK_TAB", { shiftKey: true });
+ is(document.activeElement, tab3, "Tab3 should be focused");
+
+ info("Move focus to tab 1 using the keyboard");
+ await synthesizeKeyAndWaitForTabToGetKeyboardFocus(tab2, "KEY_ArrowLeft", {
+ accelKey: true,
+ });
+ await synthesizeKeyAndWaitForTabToGetKeyboardFocus(tab1, "KEY_ArrowLeft", {
+ accelKey: true,
+ });
+ is(
+ gBrowser.tabContainer.ariaFocusedItem,
+ tab1,
+ "Tab1 should be the ariaFocusedItem"
+ );
+
+ ok(!tab1.multiselected, "Tab1 shouldn't be multiselected");
+ info("Select tab1 using keyboard");
+ EventUtils.synthesizeKey("VK_SPACE", { accelKey: true });
+ ok(tab1.multiselected, "Tab1 should be multiselected");
+
+ info("Move focus to tab 5 using the keyboard");
+ await synthesizeKeyAndWaitForTabToGetKeyboardFocus(tab2, "KEY_ArrowRight", {
+ accelKey: true,
+ });
+ await synthesizeKeyAndWaitForTabToGetKeyboardFocus(tab3, "KEY_ArrowRight", {
+ accelKey: true,
+ });
+ await synthesizeKeyAndWaitForTabToGetKeyboardFocus(tab4, "KEY_ArrowRight", {
+ accelKey: true,
+ });
+ await synthesizeKeyAndWaitForTabToGetKeyboardFocus(tab5, "KEY_ArrowRight", {
+ accelKey: true,
+ });
+
+ ok(!tab5.multiselected, "Tab5 shouldn't be multiselected");
+ info("Select tab5 using keyboard");
+ EventUtils.synthesizeKey("VK_SPACE", { accelKey: true });
+ ok(tab5.multiselected, "Tab5 should be multiselected");
+
+ ok(
+ tab1.multiselected && gBrowser._multiSelectedTabsSet.has(tab1),
+ "Tab1 is (multi) selected"
+ );
+ ok(
+ tab3.multiselected && gBrowser._multiSelectedTabsSet.has(tab3),
+ "Tab3 is (multi) selected"
+ );
+ ok(
+ tab5.multiselected && gBrowser._multiSelectedTabsSet.has(tab5),
+ "Tab5 is (multi) selected"
+ );
+ is(gBrowser.multiSelectedTabsCount, 3, "Three tabs (multi) selected");
+ is(tab3, gBrowser.selectedTab, "Tab3 is still the selected tab");
+
+ await synthesizeKeyAndWaitForFocus(tab4, "KEY_ArrowLeft", {});
+ is(
+ tab4,
+ gBrowser.selectedTab,
+ "Tab4 is now selected tab since tab5 had keyboard focus"
+ );
+
+ is(tab4.previousElementSibling, tab3, "tab4 should be after tab3");
+ is(tab4.nextElementSibling, tab5, "tab4 should be before tab5");
+
+ let tabsReordered = BrowserTestUtils.waitForCondition(() => {
+ return (
+ tab4.previousElementSibling == tab2 && tab4.nextElementSibling == tab3
+ );
+ }, "tab4 should now be after tab2 and before tab3");
+ EventUtils.synthesizeKey("KEY_ArrowLeft", { accelKey: true, shiftKey: true });
+ await tabsReordered;
+
+ is(tab4.previousElementSibling, tab2, "tab4 should be after tab2");
+ is(tab4.nextElementSibling, tab3, "tab4 should be before tab3");
+
+ BrowserTestUtils.removeTab(tab1);
+ BrowserTestUtils.removeTab(tab2);
+ BrowserTestUtils.removeTab(tab3);
+ BrowserTestUtils.removeTab(tab4);
+ BrowserTestUtils.removeTab(tab5);
+});
diff --git a/browser/base/content/test/tabs/browser_multiselect_tabs_using_selectedTabs.js b/browser/base/content/test/tabs/browser_multiselect_tabs_using_selectedTabs.js
new file mode 100644
index 0000000000..0db980bf6b
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_multiselect_tabs_using_selectedTabs.js
@@ -0,0 +1,72 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+add_task(async function () {
+ function testSelectedTabs(tabs) {
+ is(
+ gBrowser.tabContainer.getAttribute("aria-multiselectable"),
+ "true",
+ "tabbrowser should be marked as aria-multiselectable"
+ );
+ gBrowser.selectedTabs = tabs;
+ let { selectedTab, selectedTabs, _multiSelectedTabsSet } = gBrowser;
+ is(selectedTab, tabs[0], "The selected tab should be the expected one");
+ if (tabs.length == 1) {
+ ok(
+ !selectedTab.multiselected,
+ "Selected tab shouldn't be multi-selected because we are not in multi-select context yet"
+ );
+ ok(
+ !_multiSelectedTabsSet.has(selectedTab),
+ "Selected tab shouldn't be in _multiSelectedTabsSet"
+ );
+ is(selectedTabs.length, 1, "selectedTabs should contain a single tab");
+ is(
+ selectedTabs[0],
+ selectedTab,
+ "selectedTabs should contain the selected tab"
+ );
+ ok(
+ !selectedTab.hasAttribute("aria-selected"),
+ "Selected tab shouldn't be marked as aria-selected when only one tab is selected"
+ );
+ } else {
+ const uniqueTabs = [...new Set(tabs)];
+ is(
+ selectedTabs.length,
+ uniqueTabs.length,
+ "Check number of selected tabs"
+ );
+ for (let tab of uniqueTabs) {
+ ok(tab.multiselected, "Tab should be multi-selected");
+ ok(
+ _multiSelectedTabsSet.has(tab),
+ "Tab should be in _multiSelectedTabsSet"
+ );
+ ok(selectedTabs.includes(tab), "Tab should be in selectedTabs");
+ is(
+ tab.getAttribute("aria-selected"),
+ "true",
+ "Selected tab should be marked as aria-selected"
+ );
+ }
+ }
+ }
+
+ const tab1 = await addTab();
+ const tab2 = await addTab();
+ const tab3 = await addTab();
+
+ testSelectedTabs([tab1]);
+ testSelectedTabs([tab2]);
+ testSelectedTabs([tab2, tab1]);
+ testSelectedTabs([tab1, tab2]);
+ testSelectedTabs([tab3, tab2]);
+ testSelectedTabs([tab3, tab1]);
+ testSelectedTabs([tab1, tab2, tab1]);
+
+ BrowserTestUtils.removeTab(tab1);
+ BrowserTestUtils.removeTab(tab2);
+ BrowserTestUtils.removeTab(tab3);
+});
diff --git a/browser/base/content/test/tabs/browser_navigatePinnedTab.js b/browser/base/content/test/tabs/browser_navigatePinnedTab.js
new file mode 100644
index 0000000000..f1828af9c6
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_navigatePinnedTab.js
@@ -0,0 +1,71 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function () {
+ // Test that changing the URL in a pinned tab works correctly
+
+ let TEST_LINK_INITIAL = "about:mozilla";
+ let TEST_LINK_CHANGED = "about:support";
+
+ let appTab = BrowserTestUtils.addTab(gBrowser, TEST_LINK_INITIAL);
+ let browser = appTab.linkedBrowser;
+ await BrowserTestUtils.browserLoaded(browser);
+
+ gBrowser.pinTab(appTab);
+ is(appTab.pinned, true, "Tab was successfully pinned");
+
+ let initialTabsNo = gBrowser.tabs.length;
+
+ gBrowser.selectedTab = appTab;
+ gURLBar.focus();
+ gURLBar.value = TEST_LINK_CHANGED;
+
+ gURLBar.goButton.click();
+ await BrowserTestUtils.browserLoaded(browser);
+
+ is(
+ appTab.linkedBrowser.currentURI.spec,
+ TEST_LINK_CHANGED,
+ "New page loaded in the app tab"
+ );
+ is(gBrowser.tabs.length, initialTabsNo, "No additional tabs were opened");
+
+ // Now check that opening a link that does create a new tab works,
+ // and also that it nulls out the opener.
+ let pageLoadPromise = BrowserTestUtils.browserLoaded(
+ appTab.linkedBrowser,
+ false,
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com/"
+ );
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ BrowserTestUtils.loadURIString(appTab.linkedBrowser, "http://example.com/");
+ info("Started loading example.com");
+ await pageLoadPromise;
+ info("Loaded example.com");
+ let newTabPromise = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.org/"
+ );
+ await SpecialPowers.spawn(browser, [], async function () {
+ let link = content.document.createElement("a");
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ link.href = "http://example.org/";
+ content.document.body.appendChild(link);
+ link.click();
+ });
+ info("Created & clicked link");
+ let extraTab = await newTabPromise;
+ info("Got a new tab");
+ await SpecialPowers.spawn(extraTab.linkedBrowser, [], async function () {
+ is(content.opener, null, "No opener should be available");
+ });
+ BrowserTestUtils.removeTab(extraTab);
+});
+
+registerCleanupFunction(function () {
+ gBrowser.removeTab(gBrowser.selectedTab);
+});
diff --git a/browser/base/content/test/tabs/browser_navigate_home_focuses_addressbar.js b/browser/base/content/test/tabs/browser_navigate_home_focuses_addressbar.js
new file mode 100644
index 0000000000..55efdba851
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_navigate_home_focuses_addressbar.js
@@ -0,0 +1,21 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+
+const TEST_HTTP = httpURL("dummy_page.html");
+
+// Test for Bug 1634272
+add_task(async function () {
+ await BrowserTestUtils.withNewTab(TEST_HTTP, async function (browser) {
+ info("Tab ready");
+
+ CustomizableUI.addWidgetToArea("home-button", "nav-bar");
+ registerCleanupFunction(() =>
+ CustomizableUI.removeWidgetFromArea("home-button")
+ );
+
+ document.getElementById("home-button").click();
+ await BrowserTestUtils.browserLoaded(browser, false, HomePage.get());
+ is(gURLBar.value, "", "URL bar should be empty");
+ ok(gURLBar.focused, "URL bar should be focused");
+ });
+});
diff --git a/browser/base/content/test/tabs/browser_navigate_through_urls_origin_attributes.js b/browser/base/content/test/tabs/browser_navigate_through_urls_origin_attributes.js
new file mode 100644
index 0000000000..226817a350
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_navigate_through_urls_origin_attributes.js
@@ -0,0 +1,177 @@
+/* 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/. */
+
+/* import-globals-from helper_origin_attrs_testing.js */
+loadTestSubscript("helper_origin_attrs_testing.js");
+
+const PATH = "browser/browser/base/content/test/tabs/blank.html";
+
+var TEST_CASES = [
+ { uri: "https://example.com/" + PATH },
+ { uri: "https://example.org/" + PATH },
+ { uri: "about:preferences" },
+ { uri: "about:config" },
+];
+
+// 3 container tabs, 1 regular tab and 1 private tab
+const NUM_PAGES_OPEN_FOR_EACH_TEST_CASE = 5;
+var remoteTypes;
+var xulFrameLoaderCreatedCounter = {};
+
+function handleEventLocal(aEvent) {
+ if (aEvent.type != "XULFrameLoaderCreated") {
+ return;
+ }
+ // Ignore <browser> element in about:preferences and any other special pages
+ if ("gBrowser" in aEvent.target.ownerGlobal) {
+ xulFrameLoaderCreatedCounter.numCalledSoFar++;
+ }
+}
+
+var gPrevRemoteTypeRegularTab;
+var gPrevRemoteTypeContainerTab;
+var gPrevRemoteTypePrivateTab;
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["privacy.userContext.enabled", true],
+ // don't preload tabs so we don't have extra XULFrameLoaderCreated events
+ // firing
+ ["browser.newtab.preload", false],
+ ],
+ });
+
+ requestLongerTimeout(4);
+});
+
+function setupRemoteTypes() {
+ gPrevRemoteTypeRegularTab = null;
+ gPrevRemoteTypeContainerTab = {};
+ gPrevRemoteTypePrivateTab = null;
+
+ remoteTypes = getExpectedRemoteTypes(
+ gFissionBrowser,
+ NUM_PAGES_OPEN_FOR_EACH_TEST_CASE
+ );
+}
+
+add_task(async function testNavigate() {
+ setupRemoteTypes();
+ /**
+ * Open a regular tab, 3 container tabs and a private window, load about:blank or about:privatebrowsing
+ * For each test case
+ * load the uri
+ * verify correct remote type
+ * close tabs
+ */
+
+ let regularPage = await openURIInRegularTab("about:blank", window);
+ gPrevRemoteTypeRegularTab = regularPage.tab.linkedBrowser.remoteType;
+ let containerPages = [];
+ for (
+ var user_context_id = 1;
+ user_context_id <= NUM_USER_CONTEXTS;
+ user_context_id++
+ ) {
+ let containerPage = await openURIInContainer(
+ "about:blank",
+ window,
+ user_context_id
+ );
+ gPrevRemoteTypeContainerTab[user_context_id] =
+ containerPage.tab.linkedBrowser.remoteType;
+ containerPages.push(containerPage);
+ }
+
+ let privatePage = await openURIInPrivateTab();
+ gPrevRemoteTypePrivateTab = privatePage.tab.linkedBrowser.remoteType;
+
+ for (const testCase of TEST_CASES) {
+ let uri = testCase.uri;
+
+ await loadURIAndCheckRemoteType(
+ regularPage.tab.linkedBrowser,
+ uri,
+ "regular tab",
+ gPrevRemoteTypeRegularTab
+ );
+ gPrevRemoteTypeRegularTab = regularPage.tab.linkedBrowser.remoteType;
+
+ for (const page of containerPages) {
+ await loadURIAndCheckRemoteType(
+ page.tab.linkedBrowser,
+ uri,
+ `container tab ${page.user_context_id}`,
+ gPrevRemoteTypeContainerTab[page.user_context_id]
+ );
+ gPrevRemoteTypeContainerTab[page.user_context_id] =
+ page.tab.linkedBrowser.remoteType;
+ }
+
+ await loadURIAndCheckRemoteType(
+ privatePage.tab.linkedBrowser,
+ uri,
+ "private tab",
+ gPrevRemoteTypePrivateTab
+ );
+ gPrevRemoteTypePrivateTab = privatePage.tab.linkedBrowser.remoteType;
+ }
+ // Close tabs
+ containerPages.forEach(containerPage => {
+ BrowserTestUtils.removeTab(containerPage.tab);
+ });
+ BrowserTestUtils.removeTab(regularPage.tab);
+ BrowserTestUtils.removeTab(privatePage.tab);
+});
+
+async function loadURIAndCheckRemoteType(
+ aBrowser,
+ aURI,
+ aText,
+ aPrevRemoteType
+) {
+ let expectedCurr = remoteTypes.shift();
+ initXulFrameLoaderCreatedCounter(xulFrameLoaderCreatedCounter);
+ aBrowser.ownerGlobal.gBrowser.addEventListener(
+ "XULFrameLoaderCreated",
+ handleEventLocal
+ );
+ let loaded = BrowserTestUtils.browserLoaded(aBrowser, false, aURI);
+ info(`About to load ${aURI} in ${aText}`);
+ await BrowserTestUtils.loadURIString(aBrowser, aURI);
+ await loaded;
+
+ // Verify correct remote type
+ is(
+ expectedCurr,
+ aBrowser.remoteType,
+ `correct remote type for ${aURI} ${aText}`
+ );
+
+ // Verify XULFrameLoaderCreated firing correct number of times
+ info(
+ `XULFrameLoaderCreated was fired ${xulFrameLoaderCreatedCounter.numCalledSoFar} time(s) for ${aURI} ${aText}`
+ );
+ var numExpected =
+ expectedCurr == aPrevRemoteType &&
+ // With BFCache in the parent we'll get a XULFrameLoaderCreated even if
+ // expectedCurr == aPrevRemoteType, because we store the old frameloader
+ // in the BFCache. We have to make an exception for loads in the parent
+ // process (which have a null aPrevRemoteType/expectedCurr) because
+ // BFCache in the parent disables caching for those loads.
+ (!SpecialPowers.Services.appinfo.sessionHistoryInParent || !expectedCurr)
+ ? 0
+ : 1;
+ is(
+ xulFrameLoaderCreatedCounter.numCalledSoFar,
+ numExpected,
+ `XULFrameLoaderCreated fired correct number of times for ${aURI} ${aText}
+ prev=${aPrevRemoteType} curr =${aBrowser.remoteType}`
+ );
+ aBrowser.ownerGlobal.gBrowser.removeEventListener(
+ "XULFrameLoaderCreated",
+ handleEventLocal
+ );
+}
diff --git a/browser/base/content/test/tabs/browser_new_file_whitelisted_http_tab.js b/browser/base/content/test/tabs/browser_new_file_whitelisted_http_tab.js
new file mode 100644
index 0000000000..9375f3f164
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_new_file_whitelisted_http_tab.js
@@ -0,0 +1,37 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+
+// eslint-disable-next-line @microsoft/sdl/no-insecure-url
+const TEST_HTTP = "http://example.org/";
+
+// Test for bug 1378377.
+add_task(async function () {
+ // Set prefs to ensure file content process.
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.tabs.remote.separateFileUriProcess", true]],
+ });
+
+ await BrowserTestUtils.withNewTab(TEST_HTTP, async function (fileBrowser) {
+ ok(
+ E10SUtils.isWebRemoteType(fileBrowser.remoteType),
+ "Check that tab normally has web remote type."
+ );
+ });
+
+ // Set prefs to whitelist TEST_HTTP for file:// URI use.
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["capability.policy.policynames", "allowFileURI"],
+ ["capability.policy.allowFileURI.sites", TEST_HTTP],
+ ["capability.policy.allowFileURI.checkloaduri.enabled", "allAccess"],
+ ],
+ });
+
+ await BrowserTestUtils.withNewTab(TEST_HTTP, async function (fileBrowser) {
+ is(
+ fileBrowser.remoteType,
+ E10SUtils.FILE_REMOTE_TYPE,
+ "Check that tab now has file remote type."
+ );
+ });
+});
diff --git a/browser/base/content/test/tabs/browser_new_tab_in_privilegedabout_process_pref.js b/browser/base/content/test/tabs/browser_new_tab_in_privilegedabout_process_pref.js
new file mode 100644
index 0000000000..6ab6ce198e
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_new_tab_in_privilegedabout_process_pref.js
@@ -0,0 +1,230 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+/**
+ * Tests to ensure that Activity Stream loads in the privileged about:
+ * content process. Normal http web pages should load in the web content
+ * process.
+ * Ref: Bug 1469072.
+ */
+
+const ABOUT_BLANK = "about:blank";
+const ABOUT_HOME = "about:home";
+const ABOUT_NEWTAB = "about:newtab";
+const ABOUT_WELCOME = "about:welcome";
+// eslint-disable-next-line @microsoft/sdl/no-insecure-url
+const TEST_HTTP = "http://example.org/";
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.newtab.preload", false],
+ ["browser.tabs.remote.separatePrivilegedContentProcess", true],
+ ["dom.ipc.processCount.privilegedabout", 1],
+ ["dom.ipc.keepProcessesAlive.privilegedabout", 1],
+ ],
+ });
+});
+
+/*
+ * Test to ensure that the Activity Stream tabs open in privileged about: content
+ * process. We will first open an about:newtab page that acts as a reference to
+ * the privileged about: content process. With the reference, we can then open
+ * Activity Stream links in a new tab and ensure that the new tab opens in the same
+ * privileged about: content process as our reference.
+ */
+add_task(async function activity_stream_in_privileged_content_process() {
+ Services.ppmm.releaseCachedProcesses();
+
+ await BrowserTestUtils.withNewTab(ABOUT_NEWTAB, async function (browser1) {
+ checkBrowserRemoteType(browser1, E10SUtils.PRIVILEGEDABOUT_REMOTE_TYPE);
+
+ // Note the processID for about:newtab for comparison later.
+ let privilegedPid = browser1.frameLoader.remoteTab.osPid;
+
+ for (let url of [
+ ABOUT_NEWTAB,
+ ABOUT_WELCOME,
+ ABOUT_HOME,
+ `${ABOUT_NEWTAB}#foo`,
+ `${ABOUT_WELCOME}#bar`,
+ `${ABOUT_HOME}#baz`,
+ `${ABOUT_NEWTAB}?q=foo`,
+ `${ABOUT_WELCOME}?q=bar`,
+ `${ABOUT_HOME}?q=baz`,
+ ]) {
+ await BrowserTestUtils.withNewTab(url, async function (browser2) {
+ is(
+ browser2.frameLoader.remoteTab.osPid,
+ privilegedPid,
+ "Check that about:newtab tabs are in the same privileged about: content process."
+ );
+ });
+ }
+ });
+
+ Services.ppmm.releaseCachedProcesses();
+});
+
+/*
+ * Test to ensure that a process switch occurs when navigating between normal
+ * web pages and Activity Stream pages in the same tab.
+ */
+add_task(async function process_switching_through_loading_in_the_same_tab() {
+ Services.ppmm.releaseCachedProcesses();
+
+ await BrowserTestUtils.withNewTab(TEST_HTTP, async function (browser) {
+ checkBrowserRemoteType(browser, E10SUtils.WEB_REMOTE_TYPE);
+
+ for (let [url, remoteType] of [
+ [ABOUT_NEWTAB, E10SUtils.PRIVILEGEDABOUT_REMOTE_TYPE],
+ [ABOUT_BLANK, E10SUtils.WEB_REMOTE_TYPE],
+ [TEST_HTTP, E10SUtils.WEB_REMOTE_TYPE],
+ [ABOUT_HOME, E10SUtils.PRIVILEGEDABOUT_REMOTE_TYPE],
+ [TEST_HTTP, E10SUtils.WEB_REMOTE_TYPE],
+ [ABOUT_WELCOME, E10SUtils.PRIVILEGEDABOUT_REMOTE_TYPE],
+ [TEST_HTTP, E10SUtils.WEB_REMOTE_TYPE],
+ [ABOUT_BLANK, E10SUtils.WEB_REMOTE_TYPE],
+ [`${ABOUT_NEWTAB}#foo`, E10SUtils.PRIVILEGEDABOUT_REMOTE_TYPE],
+ [TEST_HTTP, E10SUtils.WEB_REMOTE_TYPE],
+ [`${ABOUT_WELCOME}#bar`, E10SUtils.PRIVILEGEDABOUT_REMOTE_TYPE],
+ [TEST_HTTP, E10SUtils.WEB_REMOTE_TYPE],
+ [`${ABOUT_HOME}#baz`, E10SUtils.PRIVILEGEDABOUT_REMOTE_TYPE],
+ [TEST_HTTP, E10SUtils.WEB_REMOTE_TYPE],
+ [`${ABOUT_NEWTAB}?q=foo`, E10SUtils.PRIVILEGEDABOUT_REMOTE_TYPE],
+ [TEST_HTTP, E10SUtils.WEB_REMOTE_TYPE],
+ [`${ABOUT_WELCOME}?q=bar`, E10SUtils.PRIVILEGEDABOUT_REMOTE_TYPE],
+ [TEST_HTTP, E10SUtils.WEB_REMOTE_TYPE],
+ [`${ABOUT_HOME}?q=baz`, E10SUtils.PRIVILEGEDABOUT_REMOTE_TYPE],
+ [TEST_HTTP, E10SUtils.WEB_REMOTE_TYPE],
+ ]) {
+ BrowserTestUtils.loadURIString(browser, url);
+ await BrowserTestUtils.browserLoaded(browser, false, url);
+ checkBrowserRemoteType(browser, remoteType);
+ }
+ });
+
+ Services.ppmm.releaseCachedProcesses();
+});
+
+/*
+ * Test to ensure that a process switch occurs when navigating between normal
+ * web pages and Activity Stream pages using the browser's navigation features
+ * such as history and location change.
+ */
+add_task(async function process_switching_through_navigation_features() {
+ Services.ppmm.releaseCachedProcesses();
+
+ await BrowserTestUtils.withNewTab(
+ ABOUT_NEWTAB,
+ async function (initialBrowser) {
+ checkBrowserRemoteType(
+ initialBrowser,
+ E10SUtils.PRIVILEGEDABOUT_REMOTE_TYPE
+ );
+
+ // Note the processID for about:newtab for comparison later.
+ let privilegedPid = initialBrowser.frameLoader.remoteTab.osPid;
+
+ function assertIsPrivilegedProcess(browser, desc) {
+ is(
+ browser.messageManager.remoteType,
+ E10SUtils.PRIVILEGEDABOUT_REMOTE_TYPE,
+ `Check that ${desc} is loaded in privileged about: content process.`
+ );
+ is(
+ browser.frameLoader.remoteTab.osPid,
+ privilegedPid,
+ `Check that ${desc} is loaded in original privileged process.`
+ );
+ }
+
+ // Check that about:newtab opened from JS in about:newtab page is in the same process.
+ let promiseTabOpened = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ ABOUT_NEWTAB,
+ true
+ );
+ await SpecialPowers.spawn(initialBrowser, [ABOUT_NEWTAB], uri => {
+ content.open(uri, "_blank");
+ });
+ let newTab = await promiseTabOpened;
+ registerCleanupFunction(async function () {
+ BrowserTestUtils.removeTab(newTab);
+ });
+ let browser = newTab.linkedBrowser;
+ assertIsPrivilegedProcess(browser, "new tab opened from about:newtab");
+
+ // Check that reload does not break the privileged about: content process affinity.
+ BrowserReload();
+ await BrowserTestUtils.browserLoaded(browser, false, ABOUT_NEWTAB);
+ assertIsPrivilegedProcess(browser, "about:newtab after reload");
+
+ // Load http webpage
+ BrowserTestUtils.loadURIString(browser, TEST_HTTP);
+ await BrowserTestUtils.browserLoaded(browser, false, TEST_HTTP);
+ checkBrowserRemoteType(browser, E10SUtils.WEB_REMOTE_TYPE);
+
+ // Check that using the history back feature switches back to privileged about: content process.
+ let promiseLocation = BrowserTestUtils.waitForLocationChange(
+ gBrowser,
+ ABOUT_NEWTAB
+ );
+ browser.goBack();
+ await promiseLocation;
+ // We will need to ensure that the process flip has fully completed so that
+ // the navigation history data will be available when we do browser.goForward();
+ await BrowserTestUtils.browserLoaded(browser);
+ assertIsPrivilegedProcess(browser, "about:newtab after history goBack");
+
+ // Check that using the history forward feature switches back to the web content process.
+ promiseLocation = BrowserTestUtils.waitForLocationChange(
+ gBrowser,
+ TEST_HTTP
+ );
+ browser.goForward();
+ await promiseLocation;
+ // We will need to ensure that the process flip has fully completed so that
+ // the navigation history data will be available when we do browser.gotoIndex(0);
+ await BrowserTestUtils.browserLoaded(browser);
+ checkBrowserRemoteType(
+ browser,
+ E10SUtils.WEB_REMOTE_TYPE,
+ "Check that tab runs in the web content process after using history goForward."
+ );
+
+ // Check that goto history index does not break the affinity.
+ promiseLocation = BrowserTestUtils.waitForLocationChange(
+ gBrowser,
+ ABOUT_NEWTAB
+ );
+ browser.gotoIndex(0);
+ await promiseLocation;
+ is(
+ browser.frameLoader.remoteTab.osPid,
+ privilegedPid,
+ "Check that about:newtab is in privileged about: content process after history gotoIndex."
+ );
+ assertIsPrivilegedProcess(
+ browser,
+ "about:newtab after history goToIndex"
+ );
+
+ BrowserTestUtils.loadURIString(browser, TEST_HTTP);
+ await BrowserTestUtils.browserLoaded(browser, false, TEST_HTTP);
+ checkBrowserRemoteType(browser, E10SUtils.WEB_REMOTE_TYPE);
+
+ // Check that location change causes a change in process type as well.
+ await SpecialPowers.spawn(browser, [ABOUT_NEWTAB], uri => {
+ content.location = uri;
+ });
+ await BrowserTestUtils.browserLoaded(browser, false, ABOUT_NEWTAB);
+ assertIsPrivilegedProcess(browser, "about:newtab after location change");
+ }
+ );
+
+ Services.ppmm.releaseCachedProcesses();
+});
diff --git a/browser/base/content/test/tabs/browser_new_tab_insert_position.js b/browser/base/content/test/tabs/browser_new_tab_insert_position.js
new file mode 100644
index 0000000000..d54aed738b
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_new_tab_insert_position.js
@@ -0,0 +1,288 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+ChromeUtils.defineESModuleGetters(this, {
+ SessionStore: "resource:///modules/sessionstore/SessionStore.sys.mjs",
+ TabStateFlusher: "resource:///modules/sessionstore/TabStateFlusher.sys.mjs",
+});
+
+const triggeringPrincipal_base64 = E10SUtils.SERIALIZED_SYSTEMPRINCIPAL;
+
+function promiseBrowserStateRestored(state) {
+ if (typeof state != "string") {
+ state = JSON.stringify(state);
+ }
+ // We wait for the notification that restore is done, and for the notification
+ // that the active tab is loaded and restored.
+ let promise = Promise.all([
+ TestUtils.topicObserved("sessionstore-browser-state-restored"),
+ BrowserTestUtils.waitForEvent(gBrowser.tabContainer, "SSTabRestored"),
+ ]);
+ SessionStore.setBrowserState(state);
+ return promise;
+}
+
+function promiseRemoveThenUndoCloseTab(tab) {
+ // We wait for the notification that restore is done, and for the notification
+ // that the active tab is loaded and restored.
+ let promise = Promise.all([
+ TestUtils.topicObserved("sessionstore-closed-objects-changed"),
+ BrowserTestUtils.waitForEvent(gBrowser.tabContainer, "SSTabRestored"),
+ ]);
+ BrowserTestUtils.removeTab(tab);
+ SessionStore.undoCloseTab(window, 0);
+ return promise;
+}
+
+// Compare the current browser tab order against the session state ordering, they should always match.
+function verifyTabState(state) {
+ let newStateTabs = JSON.parse(state).windows[0].tabs;
+ for (let i = 0; i < gBrowser.tabs.length; i++) {
+ is(
+ gBrowser.tabs[i].linkedBrowser.currentURI.spec,
+ newStateTabs[i].entries[0].url,
+ `tab pos ${i} matched ${gBrowser.tabs[i].linkedBrowser.currentURI.spec}`
+ );
+ }
+}
+
+const bulkLoad = [
+ "http://mochi.test:8888/#5",
+ "http://mochi.test:8888/#6",
+ "http://mochi.test:8888/#7",
+ "http://mochi.test:8888/#8",
+];
+
+const sessData = {
+ windows: [
+ {
+ tabs: [
+ {
+ entries: [
+ { url: "http://mochi.test:8888/#0", triggeringPrincipal_base64 },
+ ],
+ },
+ {
+ entries: [
+ { url: "http://mochi.test:8888/#1", triggeringPrincipal_base64 },
+ ],
+ },
+ {
+ entries: [
+ { url: "http://mochi.test:8888/#3", triggeringPrincipal_base64 },
+ ],
+ },
+ {
+ entries: [
+ { url: "http://mochi.test:8888/#4", triggeringPrincipal_base64 },
+ ],
+ },
+ ],
+ },
+ ],
+};
+// eslint-disable-next-line @microsoft/sdl/no-insecure-url
+const urlbarURL = "http://example.com/#urlbar";
+
+async function doTest(aInsertRelatedAfterCurrent, aInsertAfterCurrent) {
+ const kDescription =
+ "(aInsertRelatedAfterCurrent=" +
+ aInsertRelatedAfterCurrent +
+ ", aInsertAfterCurrent=" +
+ aInsertAfterCurrent +
+ "): ";
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.tabs.opentabfor.middleclick", true],
+ ["browser.tabs.loadBookmarksInBackground", false],
+ ["browser.tabs.insertRelatedAfterCurrent", aInsertRelatedAfterCurrent],
+ ["browser.tabs.insertAfterCurrent", aInsertAfterCurrent],
+ ],
+ });
+
+ let oldState = SessionStore.getBrowserState();
+
+ await promiseBrowserStateRestored(sessData);
+
+ // Create a *opener* tab page which has a link to "example.com".
+ let pageURL = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com"
+ );
+ pageURL = `${pageURL}file_new_tab_page.html`;
+ let openerTab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ pageURL
+ );
+ const openerTabIndex = 1;
+ gBrowser.moveTabTo(openerTab, openerTabIndex);
+
+ // Open a related tab via Middle click on the cell and test its position.
+ let openTabIndex =
+ aInsertRelatedAfterCurrent || aInsertAfterCurrent
+ ? openerTabIndex + 1
+ : gBrowser.tabs.length;
+ let openTabDescription =
+ aInsertRelatedAfterCurrent || aInsertAfterCurrent
+ ? "immediately to the right"
+ : "at rightmost";
+
+ let newTabPromise = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com/#linkclick",
+ true
+ );
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#link_to_example_com",
+ { button: 1 },
+ gBrowser.selectedBrowser
+ );
+ let openTab = await newTabPromise;
+ is(
+ openTab.linkedBrowser.currentURI.spec,
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.com/#linkclick",
+ "Middle click should open site to correct url."
+ );
+ is(
+ openTab._tPos,
+ openTabIndex,
+ kDescription +
+ "Middle click should open site in a new tab " +
+ openTabDescription
+ );
+ if (aInsertRelatedAfterCurrent || aInsertAfterCurrent) {
+ is(openTab.owner, openerTab, "tab owner is set correctly");
+ }
+ is(openTab.openerTab, openerTab, "opener tab is set");
+
+ // Open an unrelated tab from the URL bar and test its position.
+ openTabIndex = aInsertAfterCurrent
+ ? openerTabIndex + 1
+ : gBrowser.tabs.length;
+ openTabDescription = aInsertAfterCurrent
+ ? "immediately to the right"
+ : "at rightmost";
+
+ gURLBar.focus();
+ gURLBar.select();
+ newTabPromise = BrowserTestUtils.waitForNewTab(gBrowser, urlbarURL, true);
+ EventUtils.sendString(urlbarURL);
+ EventUtils.synthesizeKey("KEY_Alt", {
+ altKey: true,
+ code: "AltLeft",
+ type: "keydown",
+ });
+ EventUtils.synthesizeKey("KEY_Enter", { altKey: true, code: "Enter" });
+ EventUtils.synthesizeKey("KEY_Alt", {
+ altKey: false,
+ code: "AltLeft",
+ type: "keyup",
+ });
+ let unrelatedTab = await newTabPromise;
+
+ is(
+ gBrowser.selectedBrowser.currentURI.spec,
+ unrelatedTab.linkedBrowser.currentURI.spec,
+ `${kDescription} ${urlbarURL} should be loaded in the current tab.`
+ );
+ is(
+ unrelatedTab._tPos,
+ openTabIndex,
+ `${kDescription} Alt+Enter in the URL bar should open page in a new tab ${openTabDescription}`
+ );
+ is(unrelatedTab.owner, openerTab, "owner tab is set correctly");
+ ok(!unrelatedTab.openerTab, "no opener tab is set");
+
+ // Closing this should go back to the last selected tab, which just happens to be "openerTab"
+ // but is not in fact the opener.
+ BrowserTestUtils.removeTab(unrelatedTab);
+ is(
+ gBrowser.selectedTab,
+ openerTab,
+ kDescription + `openerTab should be selected after closing unrelated tab`
+ );
+
+ // Go back to the opener tab. Closing the child tab should return to the opener.
+ BrowserTestUtils.removeTab(openTab);
+ is(
+ gBrowser.selectedTab,
+ openerTab,
+ kDescription + "openerTab should be selected after closing related tab"
+ );
+
+ // Flush before messing with browser state.
+ for (let tab of gBrowser.tabs) {
+ await TabStateFlusher.flush(tab.linkedBrowser);
+ }
+
+ // Get the session state, verify SessionStore gives us expected data.
+ let newState = SessionStore.getBrowserState();
+ verifyTabState(newState);
+
+ // Remove the tab at the end, then undo. It should reappear where it was.
+ await promiseRemoveThenUndoCloseTab(gBrowser.tabs[gBrowser.tabs.length - 1]);
+ verifyTabState(newState);
+
+ // Remove a tab in the middle, then undo. It should reappear where it was.
+ await promiseRemoveThenUndoCloseTab(gBrowser.tabs[2]);
+ verifyTabState(newState);
+
+ // Bug 1442679 - Test bulk opening with loadTabs loads the tabs in order
+
+ let loadPromises = Promise.all(
+ bulkLoad.map(url =>
+ BrowserTestUtils.waitForNewTab(gBrowser, url, false, true)
+ )
+ );
+ // loadTabs will insertAfterCurrent
+ let nextTab = aInsertAfterCurrent
+ ? gBrowser.selectedTab._tPos + 1
+ : gBrowser.tabs.length;
+
+ gBrowser.loadTabs(bulkLoad, {
+ inBackground: true,
+ triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
+ });
+ await loadPromises;
+ for (let i = nextTab, j = 0; j < bulkLoad.length; i++, j++) {
+ is(
+ gBrowser.tabs[i].linkedBrowser.currentURI.spec,
+ bulkLoad[j],
+ `bulkLoad tab pos ${i} matched`
+ );
+ }
+
+ // Now we want to test that positioning remains correct after a session restore.
+
+ // Restore pre-test state so we can restore and test tab ordering.
+ await promiseBrowserStateRestored(oldState);
+
+ // Restore test state and verify it is as it was.
+ await promiseBrowserStateRestored(newState);
+ verifyTabState(newState);
+
+ // Restore pre-test state for next test.
+ await promiseBrowserStateRestored(oldState);
+}
+
+add_task(async function test_settings_insertRelatedAfter() {
+ // Firefox default settings.
+ await doTest(true, false);
+});
+
+add_task(async function test_settings_insertAfter() {
+ await doTest(true, true);
+});
+
+add_task(async function test_settings_always_insertAfter() {
+ await doTest(false, true);
+});
+
+add_task(async function test_settings_always_insertAtEnd() {
+ await doTest(false, false);
+});
diff --git a/browser/base/content/test/tabs/browser_new_tab_url.js b/browser/base/content/test/tabs/browser_new_tab_url.js
new file mode 100644
index 0000000000..233cb4e59e
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_new_tab_url.js
@@ -0,0 +1,29 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+"use strict";
+
+add_task(async function test_browser_open_newtab_default_url() {
+ BrowserOpenTab();
+ const tab = gBrowser.selectedTab;
+
+ if (tab.linkedBrowser.currentURI.spec !== window.BROWSER_NEW_TAB_URL) {
+ // If about:newtab is not loaded immediately, wait for any location change.
+ await BrowserTestUtils.waitForLocationChange(gBrowser);
+ }
+
+ is(tab.linkedBrowser.currentURI.spec, window.BROWSER_NEW_TAB_URL);
+
+ BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function test_browser_open_newtab_specific_url() {
+ const url = "https://example.com";
+
+ BrowserOpenTab({ url });
+ const tab = gBrowser.selectedTab;
+
+ await BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ is(tab.linkedBrowser.currentURI.spec, "https://example.com/");
+
+ BrowserTestUtils.removeTab(tab);
+});
diff --git a/browser/base/content/test/tabs/browser_newwindow_tabstrip_overflow.js b/browser/base/content/test/tabs/browser_newwindow_tabstrip_overflow.js
new file mode 100644
index 0000000000..f2577cc8b2
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_newwindow_tabstrip_overflow.js
@@ -0,0 +1,41 @@
+/* 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/. */
+
+const DEFAULT_THEME = "default-theme@mozilla.org";
+
+async function selectTheme(id) {
+ let theme = await AddonManager.getAddonByID(id || DEFAULT_THEME);
+ await theme.enable();
+}
+
+registerCleanupFunction(() => {
+ return selectTheme(null);
+});
+
+add_task(async function withoutLWT() {
+ let win = await BrowserTestUtils.openNewBrowserWindow();
+ ok(
+ !win.gBrowser.tabContainer.hasAttribute("overflow"),
+ "tab container not overflowing"
+ );
+ ok(
+ !win.gBrowser.tabContainer.arrowScrollbox.hasAttribute("overflowing"),
+ "arrow scrollbox not overflowing"
+ );
+ await BrowserTestUtils.closeWindow(win);
+});
+
+add_task(async function withLWT() {
+ await selectTheme("firefox-compact-light@mozilla.org");
+ let win = await BrowserTestUtils.openNewBrowserWindow();
+ ok(
+ !win.gBrowser.tabContainer.hasAttribute("overflow"),
+ "tab container not overflowing"
+ );
+ ok(
+ !win.gBrowser.tabContainer.arrowScrollbox.hasAttribute("overflowing"),
+ "arrow scrollbox not overflowing"
+ );
+ await BrowserTestUtils.closeWindow(win);
+});
diff --git a/browser/base/content/test/tabs/browser_open_newtab_start_observer_notification.js b/browser/base/content/test/tabs/browser_open_newtab_start_observer_notification.js
new file mode 100644
index 0000000000..cb9fc3c6d7
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_open_newtab_start_observer_notification.js
@@ -0,0 +1,28 @@
+"use strict";
+
+add_task(async function test_browser_open_newtab_start_observer_notification() {
+ let observerFiredPromise = new Promise(resolve => {
+ function observe(subject) {
+ Services.obs.removeObserver(observe, "browser-open-newtab-start");
+ resolve(subject.wrappedJSObject);
+ }
+ Services.obs.addObserver(observe, "browser-open-newtab-start");
+ });
+
+ // We're calling BrowserOpenTab() (rather the using BrowserTestUtils
+ // because we want to be sure that it triggers the event to fire, since
+ // it's very close to where various user-actions are triggered.
+ BrowserOpenTab();
+ const newTabCreatedPromise = await observerFiredPromise;
+ const browser = await newTabCreatedPromise;
+ const tab = gBrowser.selectedTab;
+
+ ok(true, "browser-open-newtab-start observer not called");
+ Assert.deepEqual(
+ browser,
+ tab.linkedBrowser,
+ "browser-open-newtab-start notified with the created browser"
+ );
+
+ BrowserTestUtils.removeTab(tab);
+});
diff --git a/browser/base/content/test/tabs/browser_opened_file_tab_navigated_to_web.js b/browser/base/content/test/tabs/browser_opened_file_tab_navigated_to_web.js
new file mode 100644
index 0000000000..e6b30a207d
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_opened_file_tab_navigated_to_web.js
@@ -0,0 +1,56 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+
+const TEST_FILE = "dummy_page.html";
+// eslint-disable-next-line @microsoft/sdl/no-insecure-url
+const WEB_ADDRESS = "http://example.org/";
+
+// Test for bug 1321020.
+add_task(async function () {
+ let dir = getChromeDir(getResolvedURI(gTestPath));
+ dir.append(TEST_FILE);
+
+ // The file can be a symbolic link on local build. Normalize it to make sure
+ // the path matches to the actual URI opened in the new tab.
+ dir.normalize();
+
+ const uriString = Services.io.newFileURI(dir).spec;
+ const openedUriString = uriString + "?opened";
+
+ // Open first file:// page.
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, uriString);
+ registerCleanupFunction(async function () {
+ BrowserTestUtils.removeTab(tab);
+ });
+
+ // Open new file:// tab from JavaScript in first file:// page.
+ let promiseTabOpened = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ openedUriString,
+ true
+ );
+ await SpecialPowers.spawn(tab.linkedBrowser, [openedUriString], uri => {
+ content.open(uri, "_blank");
+ });
+
+ let openedTab = await promiseTabOpened;
+ registerCleanupFunction(async function () {
+ BrowserTestUtils.removeTab(openedTab);
+ });
+
+ let openedBrowser = openedTab.linkedBrowser;
+
+ // Ensure that new file:// tab can be navigated to web content.
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ BrowserTestUtils.loadURIString(openedBrowser, "http://example.org/");
+ let href = await BrowserTestUtils.browserLoaded(
+ openedBrowser,
+ false,
+ WEB_ADDRESS
+ );
+ is(
+ href,
+ WEB_ADDRESS,
+ "Check that new file:// page has navigated successfully to web content"
+ );
+});
diff --git a/browser/base/content/test/tabs/browser_origin_attrs_in_remote_type.js b/browser/base/content/test/tabs/browser_origin_attrs_in_remote_type.js
new file mode 100644
index 0000000000..00bdb83cdd
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_origin_attrs_in_remote_type.js
@@ -0,0 +1,106 @@
+/* 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";
+
+/* import-globals-from helper_origin_attrs_testing.js */
+loadTestSubscript("helper_origin_attrs_testing.js");
+
+const PATH = "browser/browser/base/content/test/tabs/blank.html";
+
+var TEST_CASES = [
+ { uri: "https://example.com/" + PATH },
+ { uri: "https://example.org/" + PATH },
+ { uri: "about:preferences" },
+ { uri: "about:config" },
+ // file:// uri will be added in setup()
+];
+
+// 3 container tabs, 1 regular tab and 1 private tab
+const NUM_PAGES_OPEN_FOR_EACH_TEST_CASE = 5;
+var remoteTypes;
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["privacy.userContext.enabled", true],
+ // don't preload tabs so we don't have extra XULFrameLoaderCreated events
+ // firing
+ ["browser.newtab.preload", false],
+ ],
+ });
+ requestLongerTimeout(5);
+
+ // Add a file:// uri
+ let dir = getChromeDir(getResolvedURI(gTestPath));
+ dir.append("blank.html");
+ // The file can be a symbolic link on local build. Normalize it to make sure
+ // the path matches to the actual URI opened in the new tab.
+ dir.normalize();
+ const uriString = Services.io.newFileURI(dir).spec;
+ TEST_CASES.push({ uri: uriString });
+});
+
+function setupRemoteTypes() {
+ remoteTypes = getExpectedRemoteTypes(
+ gFissionBrowser,
+ NUM_PAGES_OPEN_FOR_EACH_TEST_CASE
+ );
+ remoteTypes = remoteTypes.concat(
+ Array(NUM_PAGES_OPEN_FOR_EACH_TEST_CASE).fill("file")
+ ); // file uri
+}
+
+add_task(async function test_user_identity_simple() {
+ setupRemoteTypes();
+ var currentRemoteType;
+
+ for (let testData of TEST_CASES) {
+ info(`Will open ${testData.uri} in different tabs`);
+ // Open uri without a container
+ info(`About to open a regular page`);
+ currentRemoteType = remoteTypes.shift();
+ let page_regular = await openURIInRegularTab(testData.uri, window);
+ is(
+ page_regular.tab.linkedBrowser.remoteType,
+ currentRemoteType,
+ "correct remote type"
+ );
+
+ // Open the same uri in different user contexts
+ info(`About to open container pages`);
+ let containerPages = [];
+ for (
+ var user_context_id = 1;
+ user_context_id <= NUM_USER_CONTEXTS;
+ user_context_id++
+ ) {
+ currentRemoteType = remoteTypes.shift();
+ let containerPage = await openURIInContainer(
+ testData.uri,
+ window,
+ user_context_id
+ );
+ is(
+ containerPage.tab.linkedBrowser.remoteType,
+ currentRemoteType,
+ "correct remote type"
+ );
+ containerPages.push(containerPage);
+ }
+
+ // Open the same uri in a private browser
+ currentRemoteType = remoteTypes.shift();
+ let page_private = await openURIInPrivateTab(testData.uri);
+ let privateRemoteType = page_private.tab.linkedBrowser.remoteType;
+ is(privateRemoteType, currentRemoteType, "correct remote type");
+
+ // Close all the tabs
+ containerPages.forEach(page => {
+ BrowserTestUtils.removeTab(page.tab);
+ });
+ BrowserTestUtils.removeTab(page_regular.tab);
+ BrowserTestUtils.removeTab(page_private.tab);
+ }
+});
diff --git a/browser/base/content/test/tabs/browser_origin_attrs_rel.js b/browser/base/content/test/tabs/browser_origin_attrs_rel.js
new file mode 100644
index 0000000000..b4a2a826f4
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_origin_attrs_rel.js
@@ -0,0 +1,281 @@
+/* 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";
+
+/* import-globals-from helper_origin_attrs_testing.js */
+loadTestSubscript("helper_origin_attrs_testing.js");
+
+const PATH =
+ "browser/browser/base/content/test/tabs/file_rel_opener_noopener.html";
+const URI_EXAMPLECOM =
+ "https://example.com/browser/browser/base/content/test/tabs/blank.html";
+const URI_EXAMPLEORG =
+ "https://example.org/browser/browser/base/content/test/tabs/blank.html";
+var TEST_CASES = ["https://example.com/" + PATH, "https://example.org/" + PATH];
+// How many times we navigate (exclude going back)
+const NUM_NAVIGATIONS = 5;
+// Remote types we expect for all pages that we open, in the order of being opened
+// (we don't include remote type for when we navigate back after clicking on a link)
+var remoteTypes;
+var xulFrameLoaderCreatedCounter = {};
+var LINKS_INFO = [
+ {
+ uri: URI_EXAMPLECOM,
+ id: "link_noopener_examplecom",
+ },
+ {
+ uri: URI_EXAMPLECOM,
+ id: "link_opener_examplecom",
+ },
+ {
+ uri: URI_EXAMPLEORG,
+ id: "link_noopener_exampleorg",
+ },
+ {
+ uri: URI_EXAMPLEORG,
+ id: "link_opener_exampleorg",
+ },
+];
+
+function handleEventLocal(aEvent) {
+ if (aEvent.type != "XULFrameLoaderCreated") {
+ return;
+ }
+ // Ignore <browser> element in about:preferences and any other special pages
+ if ("gBrowser" in aEvent.target.ownerGlobal) {
+ xulFrameLoaderCreatedCounter.numCalledSoFar++;
+ }
+}
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["privacy.userContext.enabled", true],
+ // don't preload tabs so we don't have extra XULFrameLoaderCreated events
+ // firing
+ ["browser.newtab.preload", false],
+ ],
+ });
+ requestLongerTimeout(3);
+});
+
+function setupRemoteTypes() {
+ if (gFissionBrowser) {
+ remoteTypes = {
+ initial: [
+ "webIsolated=https://example.com",
+ "webIsolated=https://example.com^userContextId=1",
+ "webIsolated=https://example.com^userContextId=2",
+ "webIsolated=https://example.com^userContextId=3",
+ "webIsolated=https://example.com^privateBrowsingId=1",
+ "webIsolated=https://example.org",
+ "webIsolated=https://example.org^userContextId=1",
+ "webIsolated=https://example.org^userContextId=2",
+ "webIsolated=https://example.org^userContextId=3",
+ "webIsolated=https://example.org^privateBrowsingId=1",
+ ],
+ regular: {},
+ 1: {},
+ 2: {},
+ 3: {},
+ private: {},
+ };
+ remoteTypes.regular[URI_EXAMPLECOM] = "webIsolated=https://example.com";
+ remoteTypes.regular[URI_EXAMPLEORG] = "webIsolated=https://example.org";
+ remoteTypes["1"][URI_EXAMPLECOM] =
+ "webIsolated=https://example.com^userContextId=1";
+ remoteTypes["1"][URI_EXAMPLEORG] =
+ "webIsolated=https://example.org^userContextId=1";
+ remoteTypes["2"][URI_EXAMPLECOM] =
+ "webIsolated=https://example.com^userContextId=2";
+ remoteTypes["2"][URI_EXAMPLEORG] =
+ "webIsolated=https://example.org^userContextId=2";
+ remoteTypes["3"][URI_EXAMPLECOM] =
+ "webIsolated=https://example.com^userContextId=3";
+ remoteTypes["3"][URI_EXAMPLEORG] =
+ "webIsolated=https://example.org^userContextId=3";
+ remoteTypes.private[URI_EXAMPLECOM] =
+ "webIsolated=https://example.com^privateBrowsingId=1";
+ remoteTypes.private[URI_EXAMPLEORG] =
+ "webIsolated=https://example.org^privateBrowsingId=1";
+ } else {
+ let web = Array(NUM_NAVIGATIONS).fill("web");
+ remoteTypes = {
+ initial: [...web, ...web],
+ regular: {},
+ 1: {},
+ 2: {},
+ 3: {},
+ private: {},
+ };
+ remoteTypes.regular[URI_EXAMPLECOM] = "web";
+ remoteTypes.regular[URI_EXAMPLEORG] = "web";
+ remoteTypes["1"][URI_EXAMPLECOM] = "web";
+ remoteTypes["1"][URI_EXAMPLEORG] = "web";
+ remoteTypes["2"][URI_EXAMPLECOM] = "web";
+ remoteTypes["2"][URI_EXAMPLEORG] = "web";
+ remoteTypes["3"][URI_EXAMPLECOM] = "web";
+ remoteTypes["3"][URI_EXAMPLEORG] = "web";
+ remoteTypes.private[URI_EXAMPLECOM] = "web";
+ remoteTypes.private[URI_EXAMPLEORG] = "web";
+ }
+}
+
+add_task(async function test_user_identity_simple() {
+ setupRemoteTypes();
+ /**
+ * For each test case
+ * - open regular, private and container tabs and load uri
+ * - in all the tabs, click on 4 links, going back each time in between clicks
+ * and verifying the remote type stays the same throughout
+ * - close tabs
+ */
+
+ for (var idx = 0; idx < TEST_CASES.length; idx++) {
+ var uri = TEST_CASES[idx];
+ info(`Will open ${uri} in different tabs`);
+
+ // Open uri without a container
+ let page_regular = await openURIInRegularTab(uri);
+ is(
+ page_regular.tab.linkedBrowser.remoteType,
+ remoteTypes.initial.shift(),
+ "correct remote type"
+ );
+
+ let pages_usercontexts = [];
+ for (
+ var user_context_id = 1;
+ user_context_id <= NUM_USER_CONTEXTS;
+ user_context_id++
+ ) {
+ let containerPage = await openURIInContainer(
+ uri,
+ window,
+ user_context_id.toString()
+ );
+ is(
+ containerPage.tab.linkedBrowser.remoteType,
+ remoteTypes.initial.shift(),
+ "correct remote type"
+ );
+ pages_usercontexts.push(containerPage);
+ }
+
+ // Open the same uri in a private browser
+ let page_private = await openURIInPrivateTab(uri);
+ is(
+ page_private.tab.linkedBrowser.remoteType,
+ remoteTypes.initial.shift(),
+ "correct remote type"
+ );
+
+ info(`Opened initial set of pages`);
+
+ for (const linkInfo of LINKS_INFO) {
+ info(
+ `Will make all tabs click on link ${linkInfo.uri} id ${linkInfo.id}`
+ );
+ info(`Will click on link ${linkInfo.uri} in regular tab`);
+ await clickOnLink(
+ page_regular.tab.linkedBrowser,
+ uri,
+ linkInfo,
+ "regular"
+ );
+
+ info(`Will click on link ${linkInfo.uri} in private tab`);
+ await clickOnLink(
+ page_private.tab.linkedBrowser,
+ uri,
+ linkInfo,
+ "private"
+ );
+
+ for (const page of pages_usercontexts) {
+ info(
+ `Will click on link ${linkInfo.uri} in container ${page.user_context_id}`
+ );
+ await clickOnLink(
+ page.tab.linkedBrowser,
+ uri,
+ linkInfo,
+ page.user_context_id.toString()
+ );
+ }
+ }
+
+ // Close all the tabs
+ pages_usercontexts.forEach(page => {
+ BrowserTestUtils.removeTab(page.tab);
+ });
+ BrowserTestUtils.removeTab(page_regular.tab);
+ BrowserTestUtils.removeTab(page_private.tab);
+ }
+});
+
+async function clickOnLink(aBrowser, aCurrURI, aLinkInfo, aIdxForRemoteTypes) {
+ var remoteTypeBeforeNavigation = aBrowser.remoteType;
+ var currRemoteType;
+
+ // Add a listener
+ initXulFrameLoaderCreatedCounter(xulFrameLoaderCreatedCounter);
+ aBrowser.ownerGlobal.gBrowser.addEventListener(
+ "XULFrameLoaderCreated",
+ handleEventLocal
+ );
+
+ // Retrieve the expected remote type
+ var expectedRemoteType = remoteTypes[aIdxForRemoteTypes][aLinkInfo.uri];
+
+ // Click on the link
+ info(`Clicking on link, expected remote type= ${expectedRemoteType}`);
+ let newTabLoaded = BrowserTestUtils.waitForNewTab(
+ aBrowser.ownerGlobal.gBrowser,
+ aLinkInfo.uri,
+ true
+ );
+ SpecialPowers.spawn(aBrowser, [aLinkInfo.id], link_id => {
+ content.document.getElementById(link_id).click();
+ });
+
+ // Wait for the new tab to be opened
+ info(`About to wait for the clicked link to load in browser`);
+ let newTab = await newTabLoaded;
+
+ // Check remote type, once we have opened a new tab
+ info(`Finished waiting for the clicked link to load in browser`);
+ currRemoteType = newTab.linkedBrowser.remoteType;
+ is(currRemoteType, expectedRemoteType, "Got correct remote type");
+
+ // Verify firing of XULFrameLoaderCreated event
+ info(
+ `XULFrameLoaderCreated was fired ${xulFrameLoaderCreatedCounter.numCalledSoFar}
+ time(s) for tab ${aIdxForRemoteTypes} when clicking on ${aLinkInfo.id} from page ${aCurrURI}`
+ );
+ var numExpected;
+ if (!gFissionBrowser && aLinkInfo.id.includes("noopener")) {
+ numExpected = 1;
+ } else {
+ numExpected = currRemoteType == remoteTypeBeforeNavigation ? 1 : 2;
+ }
+ info(
+ `num XULFrameLoaderCreated events expected ${numExpected}, curr ${currRemoteType} prev ${remoteTypeBeforeNavigation}`
+ );
+ is(
+ xulFrameLoaderCreatedCounter.numCalledSoFar,
+ numExpected,
+ `XULFrameLoaderCreated was fired ${xulFrameLoaderCreatedCounter.numCalledSoFar}
+ time(s) for tab ${aIdxForRemoteTypes} when clicking on ${aLinkInfo.id} from page ${aCurrURI}`
+ );
+
+ // Remove the event listener
+ aBrowser.ownerGlobal.gBrowser.removeEventListener(
+ "XULFrameLoaderCreated",
+ handleEventLocal
+ );
+
+ BrowserTestUtils.removeTab(newTab);
+}
diff --git a/browser/base/content/test/tabs/browser_originalURI.js b/browser/base/content/test/tabs/browser_originalURI.js
new file mode 100644
index 0000000000..4c644832e2
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_originalURI.js
@@ -0,0 +1,181 @@
+/* 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/. */
+
+/*
+ These tests ensure the originalURI property of the <browser> element
+ has consistent behavior when the URL of a <browser> changes.
+*/
+
+const EXAMPLE_URL = "https://example.com/some/path";
+const EXAMPLE_URL_2 = "http://mochi.test:8888/";
+
+/*
+ Load a page with no redirect.
+*/
+add_task(async function no_redirect() {
+ await BrowserTestUtils.withNewTab(EXAMPLE_URL, async browser => {
+ info("Page loaded.");
+ assertUrlEqualsOriginalURI(EXAMPLE_URL, browser.originalURI);
+ });
+});
+
+/*
+ Load a page, go to another page, then go back and forth.
+*/
+add_task(async function back_and_forth() {
+ await BrowserTestUtils.withNewTab(EXAMPLE_URL, async browser => {
+ info("Page loaded.");
+
+ info("Try loading another page.");
+ let pageLoadPromise = BrowserTestUtils.browserLoaded(
+ browser,
+ false,
+ EXAMPLE_URL_2
+ );
+ BrowserTestUtils.loadURIString(browser, EXAMPLE_URL_2);
+ await pageLoadPromise;
+ info("Other page finished loading.");
+ assertUrlEqualsOriginalURI(EXAMPLE_URL_2, browser.originalURI);
+
+ let pageShowPromise = BrowserTestUtils.waitForContentEvent(
+ browser,
+ "pageshow"
+ );
+ browser.goBack();
+ info("Go back.");
+ await pageShowPromise;
+
+ info("Loaded previous page.");
+ assertUrlEqualsOriginalURI(EXAMPLE_URL, browser.originalURI);
+
+ pageShowPromise = BrowserTestUtils.waitForContentEvent(browser, "pageshow");
+ browser.goForward();
+ info("Go forward.");
+ await pageShowPromise;
+
+ info("Loaded next page.");
+ assertUrlEqualsOriginalURI(EXAMPLE_URL_2, browser.originalURI);
+ });
+});
+
+/*
+ Redirect using the Location interface.
+*/
+add_task(async function location_href() {
+ await BrowserTestUtils.withNewTab("about:blank", async browser => {
+ let pageLoadPromise = BrowserTestUtils.browserLoaded(
+ browser,
+ false,
+ EXAMPLE_URL
+ );
+ info("Loading page with location.href interface.");
+ await SpecialPowers.spawn(browser, [EXAMPLE_URL], href => {
+ content.document.location.href = href;
+ });
+ await pageLoadPromise;
+ info("Page loaded.");
+ assertUrlEqualsOriginalURI(EXAMPLE_URL, browser.originalURI);
+ });
+});
+
+/*
+ Redirect using History API, should not update the originalURI.
+*/
+add_task(async function push_state() {
+ await BrowserTestUtils.withNewTab(EXAMPLE_URL, async browser => {
+ info("Page loaded.");
+
+ info("Pushing state via History API.");
+ await SpecialPowers.spawn(browser, [], () => {
+ let newUrl = content.document.location.href + "/after?page=images";
+ content.history.pushState(null, "", newUrl);
+ });
+ Assert.equal(
+ browser.currentURI.displaySpec,
+ EXAMPLE_URL + "/after?page=images",
+ "Current URI should be modified by push state."
+ );
+ assertUrlEqualsOriginalURI(EXAMPLE_URL, browser.originalURI);
+ });
+});
+
+/*
+ Redirect using the <meta> tag.
+*/
+add_task(async function meta_tag() {
+ let URL = httpURL("redirect_via_meta_tag.html");
+ await BrowserTestUtils.withNewTab(URL, async browser => {
+ info("Page loaded.");
+
+ let pageLoadPromise = BrowserTestUtils.browserLoaded(
+ browser,
+ false,
+ EXAMPLE_URL_2
+ );
+ await pageLoadPromise;
+ info("Redirected to ", EXAMPLE_URL_2);
+ assertUrlEqualsOriginalURI(URL, browser.originalURI);
+ });
+});
+
+/*
+ Redirect using header from a server.
+*/
+add_task(async function server_header() {
+ let URL = httpURL("redirect_via_header.html");
+ await BrowserTestUtils.withNewTab(URL, async browser => {
+ info("Page loaded.");
+
+ Assert.equal(
+ browser.currentURI.displaySpec,
+ EXAMPLE_URL,
+ `Browser should be re-directed to ${EXAMPLE_URL}`
+ );
+ assertUrlEqualsOriginalURI(URL, browser.originalURI);
+ });
+});
+
+/*
+ Load a page with an iFrame and then try having the
+ iFrame load another page.
+*/
+add_task(async function page_with_iframe() {
+ let URL = httpURL("page_with_iframe.html");
+ await BrowserTestUtils.withNewTab("about:blank", async browser => {
+ info("Blank page loaded.");
+
+ info("Load URL.");
+ BrowserTestUtils.loadURIString(browser, URL);
+ // Make sure the iFrame is finished loading.
+ await BrowserTestUtils.browserLoaded(
+ browser,
+ true,
+ "https://example.com/another/site"
+ );
+ info("iFrame finished loading.");
+ assertUrlEqualsOriginalURI(URL, browser.originalURI);
+
+ info("Change location of the iframe.");
+ let pageLoadPromise = BrowserTestUtils.browserLoaded(
+ browser,
+ true,
+ EXAMPLE_URL_2
+ );
+ await SpecialPowers.spawn(browser, [EXAMPLE_URL_2], url => {
+ content.document.getElementById("hidden-iframe").contentWindow.location =
+ url;
+ });
+ await pageLoadPromise;
+ info("iFrame finished loading.");
+ assertUrlEqualsOriginalURI(URL, browser.originalURI);
+ });
+});
+
+function assertUrlEqualsOriginalURI(url, originalURI) {
+ let uri = Services.io.newURI(url);
+ Assert.ok(
+ uri.equals(gBrowser.selectedBrowser.originalURI),
+ `URI - ${uri.displaySpec} is not equal to the originalURI - ${originalURI.displaySpec}`
+ );
+}
diff --git a/browser/base/content/test/tabs/browser_overflowScroll.js b/browser/base/content/test/tabs/browser_overflowScroll.js
new file mode 100644
index 0000000000..e30311bef1
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_overflowScroll.js
@@ -0,0 +1,111 @@
+"use strict";
+
+requestLongerTimeout(2);
+
+/**
+ * Tests that scrolling the tab strip via the scroll buttons scrolls the right
+ * amount in non-smoothscroll mode.
+ */
+add_task(async function () {
+ let arrowScrollbox = gBrowser.tabContainer.arrowScrollbox;
+ let scrollbox = arrowScrollbox.scrollbox;
+
+ let rect = ele => ele.getBoundingClientRect();
+ let width = ele => rect(ele).width;
+
+ let left = ele => rect(ele).left;
+ let right = ele => rect(ele).right;
+ let isLeft = (ele, msg) => is(left(ele), left(scrollbox), msg);
+ let isRight = (ele, msg) => is(right(ele), right(scrollbox), msg);
+ let elementFromPoint = x => arrowScrollbox._elementFromPoint(x);
+ let nextLeftElement = () => elementFromPoint(left(scrollbox) - 1);
+ let nextRightElement = () => elementFromPoint(right(scrollbox) + 1);
+ let firstScrollable = () => gBrowser.tabs[gBrowser._numPinnedTabs];
+ let waitForNextFrame = async function () {
+ await new Promise(requestAnimationFrame);
+ await new Promise(resolve => Services.tm.dispatchToMainThread(resolve));
+ };
+
+ await BrowserTestUtils.overflowTabs(registerCleanupFunction, window, {
+ overflowAtStart: false,
+ overflowTabFactor: 3,
+ });
+
+ gBrowser.pinTab(gBrowser.tabs[0]);
+
+ await BrowserTestUtils.waitForCondition(() => {
+ return Array.from(gBrowser.tabs).every(tab => tab._fullyOpen);
+ });
+
+ ok(
+ arrowScrollbox.hasAttribute("overflowing"),
+ "Tab strip should be overflowing"
+ );
+
+ let upButton = arrowScrollbox._scrollButtonUp;
+ let downButton = arrowScrollbox._scrollButtonDown;
+ let element;
+
+ gBrowser.selectedTab = firstScrollable();
+ await TestUtils.waitForTick();
+
+ ok(
+ left(scrollbox) <= left(firstScrollable()),
+ "Selecting the first tab scrolls it into view " +
+ "(" +
+ left(scrollbox) +
+ " <= " +
+ left(firstScrollable()) +
+ ")"
+ );
+
+ element = nextRightElement();
+ EventUtils.synthesizeMouseAtCenter(downButton, {});
+ await waitForNextFrame();
+ isRight(element, "Scrolled one tab to the right with a single click");
+
+ gBrowser.selectedTab = gBrowser.tabs[gBrowser.tabs.length - 1];
+ await waitForNextFrame();
+ ok(
+ right(gBrowser.selectedTab) <= right(scrollbox),
+ "Selecting the last tab scrolls it into view " +
+ "(" +
+ right(gBrowser.selectedTab) +
+ " <= " +
+ right(scrollbox) +
+ ")"
+ );
+
+ element = nextLeftElement();
+ EventUtils.synthesizeMouseAtCenter(upButton, {});
+ await waitForNextFrame();
+ isLeft(element, "Scrolled one tab to the left with a single click");
+
+ let elementPoint = left(scrollbox) - width(scrollbox);
+ element = elementFromPoint(elementPoint);
+ element = element.nextElementSibling;
+
+ EventUtils.synthesizeMouseAtCenter(upButton, { clickCount: 2 });
+ await waitForNextFrame();
+ await BrowserTestUtils.waitForCondition(
+ () => !gBrowser.tabContainer.arrowScrollbox._isScrolling
+ );
+ isLeft(element, "Scrolled one page of tabs with a double click");
+
+ EventUtils.synthesizeMouseAtCenter(upButton, { clickCount: 3 });
+ await waitForNextFrame();
+ var firstScrollableLeft = left(firstScrollable());
+ ok(
+ left(scrollbox) <= firstScrollableLeft,
+ "Scrolled to the start with a triple click " +
+ "(" +
+ left(scrollbox) +
+ " <= " +
+ firstScrollableLeft +
+ ")"
+ );
+
+ while (gBrowser.tabs.length > 1) {
+ BrowserTestUtils.removeTab(gBrowser.tabs[0]);
+ }
+});
diff --git a/browser/base/content/test/tabs/browser_paste_event_at_middle_click_on_link.js b/browser/base/content/test/tabs/browser_paste_event_at_middle_click_on_link.js
new file mode 100644
index 0000000000..a6b7f96410
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_paste_event_at_middle_click_on_link.js
@@ -0,0 +1,156 @@
+"use strict";
+
+add_task(async function doCheckPasteEventAtMiddleClickOnAnchorElement() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.tabs.opentabfor.middleclick", true],
+ ["middlemouse.paste", true],
+ ["middlemouse.contentLoadURL", false],
+ ["general.autoScroll", false],
+ ],
+ });
+
+ await new Promise((resolve, reject) => {
+ SimpleTest.waitForClipboard(
+ "Text in the clipboard",
+ () => {
+ Cc["@mozilla.org/widget/clipboardhelper;1"]
+ .getService(Ci.nsIClipboardHelper)
+ .copyString("Text in the clipboard");
+ },
+ resolve,
+ () => {
+ ok(false, "Clipboard copy failed");
+ reject();
+ }
+ );
+ });
+
+ is(
+ gBrowser.tabs.length,
+ 1,
+ "Number of tabs should be 1 at starting this test #1"
+ );
+
+ let pageURL = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+ );
+ pageURL = `${pageURL}file_anchor_elements.html`;
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, pageURL);
+
+ let pasteEventCount = 0;
+ BrowserTestUtils.addContentEventListener(
+ gBrowser.selectedBrowser,
+ "paste",
+ () => {
+ ++pasteEventCount;
+ }
+ );
+
+ // Click the usual link.
+ ok(true, "Clicking on usual link...");
+ let newTabPromise = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ "https://example.com/#a_with_href",
+ true
+ );
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#a_with_href",
+ { button: 1 },
+ gBrowser.selectedBrowser
+ );
+ let openTabForUsualLink = await newTabPromise;
+ is(
+ openTabForUsualLink.linkedBrowser.currentURI.spec,
+ "https://example.com/#a_with_href",
+ "Middle click should open site to correct url at clicking on usual link"
+ );
+ is(
+ pasteEventCount,
+ 0,
+ "paste event should be suppressed when clicking on usual link"
+ );
+
+ // Click the link in editing host.
+ is(
+ gBrowser.tabs.length,
+ 3,
+ "Number of tabs should be 3 at starting this test #2"
+ );
+ ok(true, "Clicking on editable link...");
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#editable_a_with_href",
+ { button: 1 },
+ gBrowser.selectedBrowser
+ );
+ await TestUtils.waitForCondition(
+ () => pasteEventCount >= 1,
+ "Waiting for paste event caused by clicking on editable link"
+ );
+ is(
+ pasteEventCount,
+ 1,
+ "paste event should be suppressed when clicking on editable link"
+ );
+ is(
+ gBrowser.tabs.length,
+ 3,
+ "Clicking on editable link shouldn't open new tab"
+ );
+
+ // Click the link in non-editable area in editing host.
+ ok(true, "Clicking on non-editable link in an editing host...");
+ newTabPromise = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ "https://example.com/#non-editable_a_with_href",
+ true
+ );
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#non-editable_a_with_href",
+ { button: 1 },
+ gBrowser.selectedBrowser
+ );
+ let openTabForNonEditableLink = await newTabPromise;
+ is(
+ openTabForNonEditableLink.linkedBrowser.currentURI.spec,
+ "https://example.com/#non-editable_a_with_href",
+ "Middle click should open site to correct url at clicking on non-editable link in an editing host."
+ );
+ is(
+ pasteEventCount,
+ 1,
+ "paste event should be suppressed when clicking on non-editable link in an editing host"
+ );
+
+ // Click the <a> element without href attribute.
+ is(
+ gBrowser.tabs.length,
+ 4,
+ "Number of tabs should be 4 at starting this test #3"
+ );
+ ok(true, "Clicking on anchor element without href...");
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#a_with_name",
+ { button: 1 },
+ gBrowser.selectedBrowser
+ );
+ await TestUtils.waitForCondition(
+ () => pasteEventCount >= 2,
+ "Waiting for paste event caused by clicking on anchor element without href"
+ );
+ is(
+ pasteEventCount,
+ 2,
+ "paste event should be suppressed when clicking on anchor element without href"
+ );
+ is(
+ gBrowser.tabs.length,
+ 4,
+ "Clicking on anchor element without href shouldn't open new tab"
+ );
+
+ BrowserTestUtils.removeTab(tab);
+ BrowserTestUtils.removeTab(openTabForUsualLink);
+ BrowserTestUtils.removeTab(openTabForNonEditableLink);
+});
diff --git a/browser/base/content/test/tabs/browser_pinnedTabs.js b/browser/base/content/test/tabs/browser_pinnedTabs.js
new file mode 100644
index 0000000000..856a08093d
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_pinnedTabs.js
@@ -0,0 +1,97 @@
+var tabs;
+
+function index(tab) {
+ return Array.prototype.indexOf.call(gBrowser.tabs, tab);
+}
+
+function indexTest(tab, expectedIndex, msg) {
+ var diag = "tab " + tab + " should be at index " + expectedIndex;
+ if (msg) {
+ msg = msg + " (" + diag + ")";
+ } else {
+ msg = diag;
+ }
+ is(index(tabs[tab]), expectedIndex, msg);
+}
+
+function PinUnpinHandler(tab, eventName) {
+ this.eventCount = 0;
+ var self = this;
+ tab.addEventListener(
+ eventName,
+ function () {
+ self.eventCount++;
+ },
+ { capture: true, once: true }
+ );
+ gBrowser.tabContainer.addEventListener(
+ eventName,
+ function (e) {
+ if (e.originalTarget == tab) {
+ self.eventCount++;
+ }
+ },
+ { capture: true, once: true }
+ );
+}
+
+function test() {
+ tabs = [
+ gBrowser.selectedTab,
+ BrowserTestUtils.addTab(gBrowser),
+ BrowserTestUtils.addTab(gBrowser),
+ BrowserTestUtils.addTab(gBrowser),
+ ];
+ indexTest(0, 0);
+ indexTest(1, 1);
+ indexTest(2, 2);
+ indexTest(3, 3);
+
+ // Discard one of the test tabs to verify that pinning/unpinning
+ // discarded tabs does not regress (regression test for Bug 1852391).
+ gBrowser.discardBrowser(tabs[1], true);
+
+ var eh = new PinUnpinHandler(tabs[3], "TabPinned");
+ gBrowser.pinTab(tabs[3]);
+ is(eh.eventCount, 2, "TabPinned event should be fired");
+ indexTest(0, 1);
+ indexTest(1, 2);
+ indexTest(2, 3);
+ indexTest(3, 0);
+
+ eh = new PinUnpinHandler(tabs[1], "TabPinned");
+ gBrowser.pinTab(tabs[1]);
+ is(eh.eventCount, 2, "TabPinned event should be fired");
+ indexTest(0, 2);
+ indexTest(1, 1);
+ indexTest(2, 3);
+ indexTest(3, 0);
+
+ gBrowser.moveTabTo(tabs[3], 3);
+ indexTest(3, 1, "shouldn't be able to mix a pinned tab into normal tabs");
+
+ gBrowser.moveTabTo(tabs[2], 0);
+ indexTest(2, 2, "shouldn't be able to mix a normal tab into pinned tabs");
+
+ eh = new PinUnpinHandler(tabs[1], "TabUnpinned");
+ gBrowser.unpinTab(tabs[1]);
+ is(eh.eventCount, 2, "TabUnpinned event should be fired");
+ indexTest(
+ 1,
+ 1,
+ "unpinning a tab should move a tab to the start of normal tabs"
+ );
+
+ eh = new PinUnpinHandler(tabs[3], "TabUnpinned");
+ gBrowser.unpinTab(tabs[3]);
+ is(eh.eventCount, 2, "TabUnpinned event should be fired");
+ indexTest(
+ 3,
+ 0,
+ "unpinning a tab should move a tab to the start of normal tabs"
+ );
+
+ gBrowser.removeTab(tabs[1]);
+ gBrowser.removeTab(tabs[2]);
+ gBrowser.removeTab(tabs[3]);
+}
diff --git a/browser/base/content/test/tabs/browser_pinnedTabs_clickOpen.js b/browser/base/content/test/tabs/browser_pinnedTabs_clickOpen.js
new file mode 100644
index 0000000000..04420814b0
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_pinnedTabs_clickOpen.js
@@ -0,0 +1,58 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+function index(tab) {
+ return Array.prototype.indexOf.call(gBrowser.tabs, tab);
+}
+
+async function testNewTabPosition(expectedPosition, modifiers = {}) {
+ let opening = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ "http://mochi.test:8888/"
+ );
+ BrowserTestUtils.synthesizeMouseAtCenter(
+ "#link",
+ modifiers,
+ gBrowser.selectedBrowser
+ );
+ let newtab = await opening;
+ is(index(newtab), expectedPosition, "clicked tab is in correct position");
+ return newtab;
+}
+
+// Test that a tab opened from a pinned tab is not in the pinned region.
+add_task(async function test_pinned_content_click() {
+ let testUri =
+ 'data:text/html;charset=utf-8,<a href="http://mochi.test:8888/" target="_blank" id="link">link</a>';
+ let tabs = [
+ gBrowser.selectedTab,
+ await BrowserTestUtils.openNewForegroundTab(gBrowser, testUri),
+ BrowserTestUtils.addTab(gBrowser),
+ ];
+ gBrowser.pinTab(tabs[1]);
+ gBrowser.pinTab(tabs[2]);
+
+ // First test new active tabs open at the start of non-pinned tabstrip.
+ let newtab1 = await testNewTabPosition(2);
+ // Switch back to our test tab.
+ await BrowserTestUtils.switchTab(gBrowser, tabs[1]);
+ let newtab2 = await testNewTabPosition(2);
+
+ gBrowser.removeTab(newtab1);
+ gBrowser.removeTab(newtab2);
+
+ // Second test new background tabs open in order.
+ let modifiers =
+ AppConstants.platform == "macosx" ? { metaKey: true } : { ctrlKey: true };
+ await BrowserTestUtils.switchTab(gBrowser, tabs[1]);
+
+ newtab1 = await testNewTabPosition(2, modifiers);
+ newtab2 = await testNewTabPosition(3, modifiers);
+
+ gBrowser.removeTab(tabs[1]);
+ gBrowser.removeTab(tabs[2]);
+ gBrowser.removeTab(newtab1);
+ gBrowser.removeTab(newtab2);
+});
diff --git a/browser/base/content/test/tabs/browser_pinnedTabs_closeByKeyboard.js b/browser/base/content/test/tabs/browser_pinnedTabs_closeByKeyboard.js
new file mode 100644
index 0000000000..fbcd0bb492
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_pinnedTabs_closeByKeyboard.js
@@ -0,0 +1,72 @@
+/* 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/. */
+
+function test() {
+ waitForExplicitFinish();
+
+ function testState(aPinned) {
+ function elemAttr(id, attr) {
+ return document.getElementById(id).getAttribute(attr);
+ }
+
+ is(
+ elemAttr("key_close", "disabled"),
+ "",
+ "key_closed should always be enabled"
+ );
+ is(
+ elemAttr("menu_close", "key"),
+ "key_close",
+ "menu_close should always have key_close set"
+ );
+ }
+
+ let unpinnedTab = gBrowser.selectedTab;
+ ok(!unpinnedTab.pinned, "We should have started with a regular tab selected");
+
+ testState(false);
+
+ let pinnedTab = BrowserTestUtils.addTab(gBrowser);
+ gBrowser.pinTab(pinnedTab);
+
+ // Just pinning the tab shouldn't change the key state.
+ testState(false);
+
+ // Test key state after selecting a tab.
+ gBrowser.selectedTab = pinnedTab;
+ testState(true);
+
+ gBrowser.selectedTab = unpinnedTab;
+ testState(false);
+
+ gBrowser.selectedTab = pinnedTab;
+ testState(true);
+
+ // Test the key state after un/pinning the tab.
+ gBrowser.unpinTab(pinnedTab);
+ testState(false);
+
+ gBrowser.pinTab(pinnedTab);
+ testState(true);
+
+ // Test that accel+w in a pinned tab selects the next tab.
+ let pinnedTab2 = BrowserTestUtils.addTab(gBrowser);
+ gBrowser.pinTab(pinnedTab2);
+ gBrowser.selectedTab = pinnedTab;
+
+ EventUtils.synthesizeKey("w", { accelKey: true });
+ is(gBrowser.tabs.length, 3, "accel+w in a pinned tab didn't close it");
+ is(
+ gBrowser.selectedTab,
+ unpinnedTab,
+ "accel+w in a pinned tab selected the first unpinned tab"
+ );
+
+ // Test the key state after removing the tab.
+ gBrowser.removeTab(pinnedTab);
+ gBrowser.removeTab(pinnedTab2);
+ testState(false);
+
+ finish();
+}
diff --git a/browser/base/content/test/tabs/browser_positional_attributes.js b/browser/base/content/test/tabs/browser_positional_attributes.js
new file mode 100644
index 0000000000..619c5cc517
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_positional_attributes.js
@@ -0,0 +1,60 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+var tabs = [];
+
+function addTab(aURL) {
+ tabs.push(
+ BrowserTestUtils.addTab(gBrowser, aURL, {
+ skipAnimation: true,
+ })
+ );
+}
+
+function switchTab(index) {
+ return BrowserTestUtils.switchTab(gBrowser, gBrowser.tabs[index]);
+}
+
+function testAttrib(tabIndex, attrib, expected) {
+ is(
+ gBrowser.tabs[tabIndex].hasAttribute(attrib),
+ expected,
+ `tab #${tabIndex} should${
+ expected ? "" : "n't"
+ } have the ${attrib} attribute`
+ );
+}
+
+add_setup(async function () {
+ is(gBrowser.tabs.length, 1, "one tab is open initially");
+
+ addTab("http://mochi.test:8888/#0");
+ addTab("http://mochi.test:8888/#1");
+ addTab("http://mochi.test:8888/#2");
+ addTab("http://mochi.test:8888/#3");
+
+ is(gBrowser.tabs.length, 5, "five tabs are open after setup");
+});
+
+// Add several new tabs in sequence, hiding some, to ensure that the
+// correct attributes get set
+add_task(async function test() {
+ testAttrib(0, "visuallyselected", true);
+
+ await switchTab(2);
+
+ testAttrib(2, "visuallyselected", true);
+});
+
+add_task(async function test_pinning() {
+ await switchTab(3);
+ testAttrib(3, "visuallyselected", true);
+ // Causes gBrowser.tabs to change indices
+ gBrowser.pinTab(gBrowser.tabs[3]);
+ testAttrib(0, "visuallyselected", true);
+});
+
+add_task(function cleanup() {
+ tabs.forEach(gBrowser.removeTab, gBrowser);
+});
diff --git a/browser/base/content/test/tabs/browser_preloadedBrowser_zoom.js b/browser/base/content/test/tabs/browser_preloadedBrowser_zoom.js
new file mode 100644
index 0000000000..698cf82022
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_preloadedBrowser_zoom.js
@@ -0,0 +1,89 @@
+"use strict";
+
+const ZOOM_CHANGE_TOPIC = "browser-fullZoom:location-change";
+
+/**
+ * Helper to check the zoom level of the preloaded browser
+ */
+async function checkPreloadedZoom(level, message) {
+ // Clear up any previous preloaded to test a fresh version
+ NewTabPagePreloading.removePreloadedBrowser(window);
+ NewTabPagePreloading.maybeCreatePreloadedBrowser(window);
+
+ // Wait for zoom handling of preloaded
+ const browser = gBrowser.preloadedBrowser;
+ await new Promise(resolve =>
+ Services.obs.addObserver(function obs(subject) {
+ if (subject === browser) {
+ Services.obs.removeObserver(obs, ZOOM_CHANGE_TOPIC);
+ resolve();
+ }
+ }, ZOOM_CHANGE_TOPIC)
+ );
+
+ is(browser.fullZoom.toFixed(2), level, message);
+
+ // Clean up for other tests
+ NewTabPagePreloading.removePreloadedBrowser(window);
+}
+
+add_task(async function test_default_zoom() {
+ await checkPreloadedZoom("1.00", "default preloaded zoom is 1");
+});
+
+/**
+ * Helper to open about:newtab and zoom then check matching preloaded zoom
+ */
+async function zoomNewTab(changeZoom, message) {
+ const tab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:newtab"
+ );
+ changeZoom();
+ const level = tab.linkedBrowser.fullZoom.toFixed(2);
+ BrowserTestUtils.removeTab(tab);
+
+ // Wait for the the update of the full-zoom content pref value, that happens
+ // asynchronously after changing the zoom level.
+ let cps2 = Cc["@mozilla.org/content-pref/service;1"].getService(
+ Ci.nsIContentPrefService2
+ );
+
+ await BrowserTestUtils.waitForCondition(() => {
+ return new Promise(resolve => {
+ cps2.getByDomainAndName(
+ "about:newtab",
+ "browser.content.full-zoom",
+ null,
+ {
+ handleResult(pref) {
+ resolve(level == pref.value);
+ },
+ handleCompletion() {
+ console.log("handleCompletion");
+ },
+ }
+ );
+ });
+ });
+
+ await checkPreloadedZoom(level, `${message}: ${level}`);
+}
+
+add_task(async function test_preloaded_zoom_out() {
+ await zoomNewTab(() => FullZoom.reduce(), "zoomed out applied to preloaded");
+});
+
+add_task(async function test_preloaded_zoom_in() {
+ await zoomNewTab(() => {
+ FullZoom.enlarge();
+ FullZoom.enlarge();
+ }, "zoomed in applied to preloaded");
+});
+
+add_task(async function test_preloaded_zoom_default() {
+ await zoomNewTab(
+ () => FullZoom.reduce(),
+ "zoomed back to default applied to preloaded"
+ );
+});
diff --git a/browser/base/content/test/tabs/browser_privilegedmozilla_process_pref.js b/browser/base/content/test/tabs/browser_privilegedmozilla_process_pref.js
new file mode 100644
index 0000000000..86ef66c936
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_privilegedmozilla_process_pref.js
@@ -0,0 +1,212 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+/**
+ * Tests to ensure that Mozilla Privileged Webpages load in the privileged
+ * mozilla web content process. Normal http web pages should load in the web
+ * content process.
+ * Ref: Bug 1539595.
+ */
+
+// High and Low Privilege
+const TEST_HIGH1 = "https://example.org/";
+const TEST_HIGH2 = "https://test1.example.org/";
+// eslint-disable-next-line @microsoft/sdl/no-insecure-url
+const TEST_LOW1 = "http://example.org/";
+const TEST_LOW2 = "https://example.com/";
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["browser.tabs.remote.separatePrivilegedMozillaWebContentProcess", true],
+ ["browser.tabs.remote.separatedMozillaDomains", "example.org"],
+ ["dom.ipc.processCount.privilegedmozilla", 1],
+ ],
+ });
+});
+
+/*
+ * Test to ensure that the tabs open in privileged mozilla content process. We
+ * will first open a page that acts as a reference to the privileged mozilla web
+ * content process. With the reference, we can then open other links in a new tab
+ * and ensure that the new tab opens in the same privileged mozilla content process
+ * as our reference.
+ */
+add_task(async function webpages_in_privileged_content_process() {
+ Services.ppmm.releaseCachedProcesses();
+
+ await BrowserTestUtils.withNewTab(TEST_HIGH1, async function (browser1) {
+ checkBrowserRemoteType(browser1, E10SUtils.PRIVILEGEDMOZILLA_REMOTE_TYPE);
+
+ // Note the processID for about:newtab for comparison later.
+ let privilegedPid = browser1.frameLoader.remoteTab.osPid;
+
+ for (let url of [
+ TEST_HIGH1,
+ `${TEST_HIGH1}#foo`,
+ `${TEST_HIGH1}?q=foo`,
+ TEST_HIGH2,
+ `${TEST_HIGH2}#foo`,
+ `${TEST_HIGH2}?q=foo`,
+ ]) {
+ await BrowserTestUtils.withNewTab(url, async function (browser2) {
+ is(
+ browser2.frameLoader.remoteTab.osPid,
+ privilegedPid,
+ "Check that privileged pages are in the same privileged mozilla content process."
+ );
+ });
+ }
+ });
+
+ Services.ppmm.releaseCachedProcesses();
+});
+
+/*
+ * Test to ensure that a process switch occurs when navigating between normal
+ * web pages and unprivileged pages in the same tab.
+ */
+add_task(async function process_switching_through_loading_in_the_same_tab() {
+ Services.ppmm.releaseCachedProcesses();
+
+ await BrowserTestUtils.withNewTab(TEST_LOW1, async function (browser) {
+ checkBrowserRemoteType(browser, E10SUtils.WEB_REMOTE_TYPE);
+
+ for (let [url, remoteType] of [
+ [TEST_HIGH1, E10SUtils.PRIVILEGEDMOZILLA_REMOTE_TYPE],
+ [TEST_LOW1, E10SUtils.WEB_REMOTE_TYPE],
+ [TEST_HIGH1, E10SUtils.PRIVILEGEDMOZILLA_REMOTE_TYPE],
+ [TEST_LOW2, E10SUtils.WEB_REMOTE_TYPE],
+ [TEST_HIGH1, E10SUtils.PRIVILEGEDMOZILLA_REMOTE_TYPE],
+ [TEST_LOW1, E10SUtils.WEB_REMOTE_TYPE],
+ [TEST_LOW2, E10SUtils.WEB_REMOTE_TYPE],
+ [`${TEST_HIGH1}#foo`, E10SUtils.PRIVILEGEDMOZILLA_REMOTE_TYPE],
+ [TEST_LOW1, E10SUtils.WEB_REMOTE_TYPE],
+ [`${TEST_HIGH1}#bar`, E10SUtils.PRIVILEGEDMOZILLA_REMOTE_TYPE],
+ [TEST_LOW2, E10SUtils.WEB_REMOTE_TYPE],
+ [`${TEST_HIGH1}#baz`, E10SUtils.PRIVILEGEDMOZILLA_REMOTE_TYPE],
+ [TEST_LOW1, E10SUtils.WEB_REMOTE_TYPE],
+ [`${TEST_HIGH1}?q=foo`, E10SUtils.PRIVILEGEDMOZILLA_REMOTE_TYPE],
+ [TEST_LOW2, E10SUtils.WEB_REMOTE_TYPE],
+ [`${TEST_HIGH1}?q=bar`, E10SUtils.PRIVILEGEDMOZILLA_REMOTE_TYPE],
+ [TEST_LOW1, E10SUtils.WEB_REMOTE_TYPE],
+ [`${TEST_HIGH1}?q=baz`, E10SUtils.PRIVILEGEDMOZILLA_REMOTE_TYPE],
+ [TEST_LOW2, E10SUtils.WEB_REMOTE_TYPE],
+ ]) {
+ BrowserTestUtils.loadURIString(browser, url);
+ await BrowserTestUtils.browserLoaded(browser, false, url);
+ checkBrowserRemoteType(browser, remoteType);
+ }
+ });
+
+ Services.ppmm.releaseCachedProcesses();
+});
+
+/*
+ * Test to ensure that a process switch occurs when navigating between normal
+ * web pages and privileged pages using the browser's navigation features
+ * such as history and location change.
+ */
+add_task(async function process_switching_through_navigation_features() {
+ Services.ppmm.releaseCachedProcesses();
+
+ await BrowserTestUtils.withNewTab(TEST_HIGH1, async function (browser) {
+ checkBrowserRemoteType(browser, E10SUtils.PRIVILEGEDMOZILLA_REMOTE_TYPE);
+
+ // Note the processID for about:newtab for comparison later.
+ let privilegedPid = browser.frameLoader.remoteTab.osPid;
+
+ // Check that about:newtab opened from JS in about:newtab page is in the same process.
+ let promiseTabOpened = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ TEST_HIGH1,
+ true
+ );
+ await SpecialPowers.spawn(browser, [TEST_HIGH1], uri => {
+ content.open(uri, "_blank");
+ });
+ let newTab = await promiseTabOpened;
+ registerCleanupFunction(async function () {
+ BrowserTestUtils.removeTab(newTab);
+ });
+ browser = newTab.linkedBrowser;
+ is(
+ browser.frameLoader.remoteTab.osPid,
+ privilegedPid,
+ "Check that new tab opened from privileged page is loaded in privileged mozilla content process."
+ );
+
+ // Check that reload does not break the privileged mozilla content process affinity.
+ BrowserReload();
+ await BrowserTestUtils.browserLoaded(browser, false, TEST_HIGH1);
+ is(
+ browser.frameLoader.remoteTab.osPid,
+ privilegedPid,
+ "Check that privileged page is still in privileged mozilla content process after reload."
+ );
+
+ // Load http webpage
+ BrowserTestUtils.loadURIString(browser, TEST_LOW1);
+ await BrowserTestUtils.browserLoaded(browser, false, TEST_LOW1);
+ checkBrowserRemoteType(browser, E10SUtils.WEB_REMOTE_TYPE);
+
+ // Check that using the history back feature switches back to privileged mozilla content process.
+ let promiseLocation = BrowserTestUtils.waitForLocationChange(
+ gBrowser,
+ TEST_HIGH1
+ );
+ browser.goBack();
+ await promiseLocation;
+ is(
+ browser.frameLoader.remoteTab.osPid,
+ privilegedPid,
+ "Check that privileged page is still in privileged mozilla content process after history goBack."
+ );
+
+ // Check that using the history forward feature switches back to the web content process.
+ promiseLocation = BrowserTestUtils.waitForLocationChange(
+ gBrowser,
+ TEST_LOW1
+ );
+ browser.goForward();
+ await promiseLocation;
+ checkBrowserRemoteType(
+ browser,
+ E10SUtils.WEB_REMOTE_TYPE,
+ "Check that tab runs in the web content process after using history goForward."
+ );
+
+ // Check that goto history index does not break the affinity.
+ promiseLocation = BrowserTestUtils.waitForLocationChange(
+ gBrowser,
+ TEST_HIGH1
+ );
+ browser.gotoIndex(0);
+ await promiseLocation;
+ is(
+ browser.frameLoader.remoteTab.osPid,
+ privilegedPid,
+ "Check that privileged page is in privileged mozilla content process after history gotoIndex."
+ );
+
+ BrowserTestUtils.loadURIString(browser, TEST_LOW2);
+ await BrowserTestUtils.browserLoaded(browser, false, TEST_LOW2);
+ checkBrowserRemoteType(browser, E10SUtils.WEB_REMOTE_TYPE);
+
+ // Check that location change causes a change in process type as well.
+ await SpecialPowers.spawn(browser, [TEST_HIGH1], uri => {
+ content.location = uri;
+ });
+ await BrowserTestUtils.browserLoaded(browser, false, TEST_HIGH1);
+ is(
+ browser.frameLoader.remoteTab.osPid,
+ privilegedPid,
+ "Check that privileged page is in privileged mozilla content process after location change."
+ );
+ });
+
+ Services.ppmm.releaseCachedProcesses();
+});
diff --git a/browser/base/content/test/tabs/browser_progress_keyword_search_handling.js b/browser/base/content/test/tabs/browser_progress_keyword_search_handling.js
new file mode 100644
index 0000000000..648cda9332
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_progress_keyword_search_handling.js
@@ -0,0 +1,91 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { SearchTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/SearchTestUtils.sys.mjs"
+);
+
+SearchTestUtils.init(this);
+
+const kButton = document.getElementById("reload-button");
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.fixup.dns_first_for_single_words", true]],
+ });
+
+ // Create an engine to use for the test.
+ await SearchTestUtils.installSearchExtension(
+ {
+ name: "MozSearch",
+ search_url: "https://example.com/",
+ search_url_get_params: "q={searchTerms}",
+ },
+ { setAsDefault: true }
+ );
+});
+
+/*
+ * When loading a keyword search as a result of an unknown host error,
+ * check that we can stop the load.
+ * See https://bugzilla.mozilla.org/show_bug.cgi?id=235825
+ */
+add_task(async function test_unknown_host() {
+ await BrowserTestUtils.withNewTab("about:blank", async browser => {
+ const kNonExistingHost = "idontreallyexistonthisnetwork";
+ let searchPromise = BrowserTestUtils.browserStarted(
+ browser,
+ Services.uriFixup.keywordToURI(kNonExistingHost).preferredURI.spec
+ );
+
+ gURLBar.value = kNonExistingHost;
+ gURLBar.select();
+ EventUtils.synthesizeKey("KEY_Enter");
+
+ await searchPromise;
+ // With parent initiated loads, we need to give XULBrowserWindow
+ // time to process the STATE_START event and set the attribute to true.
+ await new Promise(resolve => executeSoon(resolve));
+
+ ok(kButton.hasAttribute("displaystop"), "Should be showing stop");
+
+ await TestUtils.waitForCondition(
+ () => !kButton.hasAttribute("displaystop")
+ );
+ ok(
+ !kButton.hasAttribute("displaystop"),
+ "Should no longer be showing stop after search"
+ );
+ });
+});
+
+/*
+ * When NOT loading a keyword search as a result of an unknown host error,
+ * check that the stop button goes back to being a reload button.
+ * See https://bugzilla.mozilla.org/show_bug.cgi?id=1591183
+ */
+add_task(async function test_unknown_host_without_search() {
+ await BrowserTestUtils.withNewTab("about:blank", async browser => {
+ const kNonExistingHost = "idontreallyexistonthisnetwork.example.com";
+ let searchPromise = BrowserTestUtils.browserLoaded(
+ browser,
+ false,
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://" + kNonExistingHost + "/",
+ true /* want an error page */
+ );
+ gURLBar.value = kNonExistingHost;
+ gURLBar.select();
+ EventUtils.synthesizeKey("KEY_Enter");
+ await searchPromise;
+ await TestUtils.waitForCondition(
+ () => !kButton.hasAttribute("displaystop")
+ );
+ ok(
+ !kButton.hasAttribute("displaystop"),
+ "Should not be showing stop on error page"
+ );
+ });
+});
diff --git a/browser/base/content/test/tabs/browser_relatedTabs_reset.js b/browser/base/content/test/tabs/browser_relatedTabs_reset.js
new file mode 100644
index 0000000000..531a9e723d
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_relatedTabs_reset.js
@@ -0,0 +1,81 @@
+/* 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/. */
+
+add_task(async function () {
+ is(gBrowser.tabs.length, 1, "one tab is open initially");
+
+ const TestPage =
+ "http://mochi.test:8888/browser/browser/base/content/test/general/dummy_page.html";
+
+ // Add several new tabs in sequence
+ let tabs = [];
+ let ReferrerInfo = Components.Constructor(
+ "@mozilla.org/referrer-info;1",
+ "nsIReferrerInfo",
+ "init"
+ );
+
+ function getPrincipal(url, attrs) {
+ let uri = Services.io.newURI(url);
+ if (!attrs) {
+ attrs = {};
+ }
+ return Services.scriptSecurityManager.createContentPrincipal(uri, attrs);
+ }
+
+ function addTab(aURL, aReferrer) {
+ let referrerInfo = new ReferrerInfo(
+ Ci.nsIReferrerInfo.EMPTY,
+ true,
+ aReferrer
+ );
+ let triggeringPrincipal = getPrincipal(aURL);
+ let tab = BrowserTestUtils.addTab(gBrowser, aURL, {
+ referrerInfo,
+ triggeringPrincipal,
+ });
+ tabs.push(tab);
+ return BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ }
+
+ function loadTab(tab, url) {
+ let loaded = BrowserTestUtils.browserLoaded(tab.linkedBrowser);
+ info("Loading page: " + url);
+ BrowserTestUtils.loadURIString(tab.linkedBrowser, url);
+ return loaded;
+ }
+
+ function testPosition(tabNum, expectedPosition, msg) {
+ is(
+ Array.prototype.indexOf.call(gBrowser.tabs, tabs[tabNum]),
+ expectedPosition,
+ msg
+ );
+ }
+
+ // Initial selected tab
+ await addTab("http://mochi.test:8888/#0");
+ testPosition(0, 1, "Initial tab opened in position 1");
+ gBrowser.selectedTab = tabs[0];
+
+ // Related tabs
+ await addTab("http://mochi.test:8888/#1", gBrowser.currentURI);
+ testPosition(1, 2, "Related tab was opened to the far right");
+
+ await addTab("http://mochi.test:8888/#2", gBrowser.currentURI);
+ testPosition(2, 3, "Related tab was opened to the far right");
+
+ // Load a new page
+ await loadTab(tabs[0], TestPage);
+
+ // Add a new related tab after the page load
+ await addTab("http://mochi.test:8888/#3", gBrowser.currentURI);
+ testPosition(
+ 3,
+ 2,
+ "Tab opened to the right of initial tab after system navigation"
+ );
+
+ tabs.forEach(gBrowser.removeTab, gBrowser);
+});
diff --git a/browser/base/content/test/tabs/browser_reload_deleted_file.js b/browser/base/content/test/tabs/browser_reload_deleted_file.js
new file mode 100644
index 0000000000..2051dbfac7
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_reload_deleted_file.js
@@ -0,0 +1,36 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+
+const uuidGenerator = Services.uuid;
+
+const DUMMY_FILE = "dummy_page.html";
+
+// Test for bug 1327942.
+add_task(async function () {
+ // Copy dummy page to unique file in TmpD, so that we can safely delete it.
+ let dummyPage = getChromeDir(getResolvedURI(gTestPath));
+ dummyPage.append(DUMMY_FILE);
+ let disappearingPage = Services.dirsvc.get("TmpD", Ci.nsIFile);
+ let uniqueName = uuidGenerator.generateUUID().toString();
+ dummyPage.copyTo(disappearingPage, uniqueName);
+ disappearingPage.append(uniqueName);
+
+ // Get file:// URI for new page and load in a new tab.
+ const uriString = Services.io.newFileURI(disappearingPage).spec;
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, uriString);
+ registerCleanupFunction(async function () {
+ BrowserTestUtils.removeTab(tab);
+ });
+
+ // Delete the page, simulate a click of the reload button and check that we
+ // get a neterror page.
+ disappearingPage.remove(false);
+ document.getElementById("reload-button").doCommand();
+ await BrowserTestUtils.waitForErrorPage(tab.linkedBrowser);
+ await SpecialPowers.spawn(tab.linkedBrowser, [], function () {
+ ok(
+ content.document.documentURI.startsWith("about:neterror"),
+ "Check that a neterror page was loaded."
+ );
+ });
+});
diff --git a/browser/base/content/test/tabs/browser_removeTabsToTheEnd.js b/browser/base/content/test/tabs/browser_removeTabsToTheEnd.js
new file mode 100644
index 0000000000..a9540f708b
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_removeTabsToTheEnd.js
@@ -0,0 +1,30 @@
+/* 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/. */
+
+add_task(async function removeTabsToTheEnd() {
+ // Add three new tabs after the original tab. Pin the second one.
+ let firstTab = await addTab();
+ let pinnedTab = await addTab();
+ let lastTab = await addTab();
+ gBrowser.pinTab(pinnedTab);
+
+ // Check that there is only one closable tab from firstTab to the end
+ is(
+ gBrowser.getTabsToTheEndFrom(firstTab).length,
+ 1,
+ "One unpinned tab towards the end"
+ );
+
+ // Remove tabs to the end
+ gBrowser.removeTabsToTheEndFrom(firstTab);
+
+ ok(!firstTab.closing, "First tab is not closing");
+ ok(!pinnedTab.closing, "Pinned tab is not closing");
+ ok(lastTab.closing, "Last tab is closing");
+
+ // cleanup
+ for (let tab of [firstTab, pinnedTab]) {
+ BrowserTestUtils.removeTab(tab);
+ }
+});
diff --git a/browser/base/content/test/tabs/browser_removeTabsToTheStart.js b/browser/base/content/test/tabs/browser_removeTabsToTheStart.js
new file mode 100644
index 0000000000..685da35881
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_removeTabsToTheStart.js
@@ -0,0 +1,35 @@
+/* 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/. */
+
+add_task(async function removeTabsToTheStart() {
+ // don't mess with the original tab
+ let originalTab = gBrowser.selectedTab;
+ gBrowser.pinTab(originalTab);
+
+ // Add three new tabs after the original tab. Pin the second one.
+ let firstTab = await addTab();
+ let pinnedTab = await addTab();
+ let lastTab = await addTab();
+ gBrowser.pinTab(pinnedTab);
+
+ // Check that there is only one closable tab from lastTab to the start
+ is(
+ gBrowser.getTabsToTheStartFrom(lastTab).length,
+ 1,
+ "One unpinned tab towards the start"
+ );
+
+ // Remove tabs to the start
+ gBrowser.removeTabsToTheStartFrom(lastTab);
+
+ ok(firstTab.closing, "First tab is closing");
+ ok(!pinnedTab.closing, "Pinned tab is not closing");
+ ok(!lastTab.closing, "Last tab is not closing");
+
+ // cleanup
+ gBrowser.unpinTab(originalTab);
+ for (let tab of [pinnedTab, lastTab]) {
+ BrowserTestUtils.removeTab(tab);
+ }
+});
diff --git a/browser/base/content/test/tabs/browser_removeTabs_order.js b/browser/base/content/test/tabs/browser_removeTabs_order.js
new file mode 100644
index 0000000000..071cc03716
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_removeTabs_order.js
@@ -0,0 +1,40 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { sinon } = ChromeUtils.importESModule(
+ "resource://testing-common/Sinon.sys.mjs"
+);
+
+add_task(async function () {
+ let tab1 = await addTab();
+ let tab2 = await addTab();
+ let tab3 = await addTab();
+ let tabs = [tab1, tab2, tab3];
+
+ // Add a beforeunload event listener in one of the tabs; it should be called
+ // before closing any of the tabs.
+ await ContentTask.spawn(tab2.linkedBrowser, null, async function () {
+ content.window.addEventListener("beforeunload", function (event) {}, true);
+ });
+
+ let permitUnloadSpy = sinon.spy(tab2.linkedBrowser, "asyncPermitUnload");
+ let removeTabSpy = sinon.spy(gBrowser, "removeTab");
+
+ gBrowser.removeTabs(tabs);
+
+ Assert.ok(permitUnloadSpy.calledOnce, "permitUnload was called only once");
+ Assert.equal(
+ removeTabSpy.callCount,
+ tabs.length,
+ "removeTab was called for every tab"
+ );
+ Assert.ok(
+ permitUnloadSpy.lastCall.calledBefore(removeTabSpy.firstCall),
+ "permitUnload was called before for first removeTab call"
+ );
+
+ removeTabSpy.restore();
+ permitUnloadSpy.restore();
+});
diff --git a/browser/base/content/test/tabs/browser_removeTabs_skipPermitUnload.js b/browser/base/content/test/tabs/browser_removeTabs_skipPermitUnload.js
new file mode 100644
index 0000000000..1016ead94c
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_removeTabs_skipPermitUnload.js
@@ -0,0 +1,115 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests for waiting for beforeunload before replacing a session.
+ */
+
+const { PromptTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PromptTestUtils.sys.mjs"
+);
+
+// The first two urls are intentionally different domains to force pages
+// to load in different tabs.
+const TEST_PATH = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+);
+const TEST_URL = "https://example.com/";
+
+const BUILDER_URL = "https://example.com/document-builder.sjs?html=";
+const PAGE_MARKUP = `
+<html>
+<head>
+ <script>
+ window.onbeforeunload = function() {
+ return true;
+ };
+ </script>
+</head>
+<body>TEST PAGE</body>
+</html>
+`;
+const TEST_URL2 = BUILDER_URL + encodeURI(PAGE_MARKUP);
+
+let win;
+let nonBeforeUnloadTab;
+let beforeUnloadTab;
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [["dom.require_user_interaction_for_beforeunload", false]],
+ });
+
+ // Run tests in a new window to avoid affecting the main test window.
+ win = await BrowserTestUtils.openNewBrowserWindow();
+
+ BrowserTestUtils.loadURIString(win.gBrowser.selectedBrowser, TEST_URL);
+ await BrowserTestUtils.browserLoaded(
+ win.gBrowser.selectedBrowser,
+ false,
+ TEST_URL
+ );
+ nonBeforeUnloadTab = win.gBrowser.selectedTab;
+ beforeUnloadTab = await BrowserTestUtils.openNewForegroundTab(
+ win.gBrowser,
+ TEST_URL2
+ );
+
+ registerCleanupFunction(async () => {
+ await BrowserTestUtils.closeWindow(win);
+ });
+});
+
+add_task(async function test_runBeforeUnloadForTabs() {
+ let unloadDialogPromise = PromptTestUtils.handleNextPrompt(
+ win,
+ {
+ modalType: Ci.nsIPrompt.MODAL_TYPE_CONTENT,
+ promptType: "confirmEx",
+ },
+ // Click the cancel button.
+ { buttonNumClick: 1 }
+ );
+
+ let unloadBlocked = await win.gBrowser.runBeforeUnloadForTabs(
+ win.gBrowser.tabs
+ );
+
+ await unloadDialogPromise;
+
+ Assert.ok(unloadBlocked, "Should have reported the unload was blocked");
+ Assert.equal(win.gBrowser.tabs.length, 2, "Should have left all tabs open");
+
+ unloadDialogPromise = PromptTestUtils.handleNextPrompt(
+ win,
+ {
+ modalType: Ci.nsIPrompt.MODAL_TYPE_CONTENT,
+ promptType: "confirmEx",
+ },
+ // Click the ok button.
+ { buttonNumClick: 0 }
+ );
+
+ unloadBlocked = await win.gBrowser.runBeforeUnloadForTabs(win.gBrowser.tabs);
+
+ await unloadDialogPromise;
+
+ Assert.ok(!unloadBlocked, "Should have reported the unload was not blocked");
+ Assert.equal(win.gBrowser.tabs.length, 2, "Should have left all tabs open");
+});
+
+add_task(async function test_skipPermitUnload() {
+ let closePromise = BrowserTestUtils.waitForTabClosing(beforeUnloadTab);
+
+ await win.gBrowser.removeAllTabsBut(nonBeforeUnloadTab, {
+ animate: false,
+ skipPermitUnload: true,
+ });
+
+ await closePromise;
+
+ Assert.equal(win.gBrowser.tabs.length, 1, "Should have left one tab open");
+});
diff --git a/browser/base/content/test/tabs/browser_replacewithwindow_commands.js b/browser/base/content/test/tabs/browser_replacewithwindow_commands.js
new file mode 100644
index 0000000000..1e6f2b8f57
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_replacewithwindow_commands.js
@@ -0,0 +1,42 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+// This test verifies that focus is handled correctly when a
+// tab is dragged out to a new window, by checking that the
+// copy and select all commands are enabled properly.
+add_task(async function () {
+ let tab1 = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "https://www.example.com"
+ );
+ let tab2 = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "https://www.example.com"
+ );
+
+ let delayedStartupPromise = BrowserTestUtils.waitForNewWindow();
+ let win = gBrowser.replaceTabWithWindow(tab2);
+ await delayedStartupPromise;
+
+ let copyCommand = win.document.getElementById("cmd_copy");
+ info("Waiting for copy to be enabled");
+ await BrowserTestUtils.waitForMutationCondition(
+ copyCommand,
+ { attributes: true },
+ () => {
+ return !copyCommand.hasAttribute("disabled");
+ }
+ );
+
+ ok(
+ !win.document.getElementById("cmd_copy").hasAttribute("disabled"),
+ "copy is enabled"
+ );
+ ok(
+ !win.document.getElementById("cmd_selectAll").hasAttribute("disabled"),
+ "select all is enabled"
+ );
+
+ await BrowserTestUtils.closeWindow(win);
+ await BrowserTestUtils.removeTab(tab1);
+});
diff --git a/browser/base/content/test/tabs/browser_switch_by_scrolling.js b/browser/base/content/test/tabs/browser_switch_by_scrolling.js
new file mode 100644
index 0000000000..7d62234d7f
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_switch_by_scrolling.js
@@ -0,0 +1,51 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function wheel_switches_tabs() {
+ Services.prefs.setBoolPref("toolkit.tabbox.switchByScrolling", true);
+
+ let newTab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:blank"
+ );
+ await BrowserTestUtils.switchTab(gBrowser, () => {
+ EventUtils.synthesizeWheel(newTab, 4, 4, {
+ deltaMode: WheelEvent.DOM_DELTA_LINE,
+ deltaY: -1.0,
+ });
+ });
+ ok(!newTab.selected, "New tab should no longer be selected.");
+ BrowserTestUtils.removeTab(newTab);
+});
+
+add_task(async function wheel_switches_tabs_overflow() {
+ Services.prefs.setBoolPref("toolkit.tabbox.switchByScrolling", true);
+
+ let arrowScrollbox = gBrowser.tabContainer.arrowScrollbox;
+ let tabs = [];
+
+ while (!arrowScrollbox.hasAttribute("overflowing")) {
+ tabs.push(
+ await BrowserTestUtils.openNewForegroundTab(gBrowser, "about:blank")
+ );
+ }
+
+ let newTab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:blank"
+ );
+ await BrowserTestUtils.switchTab(gBrowser, () => {
+ EventUtils.synthesizeWheel(newTab, 4, 4, {
+ deltaMode: WheelEvent.DOM_DELTA_LINE,
+ deltaY: -1.0,
+ });
+ });
+ ok(!newTab.selected, "New tab should no longer be selected.");
+
+ BrowserTestUtils.removeTab(newTab);
+ for (let tab of tabs) {
+ BrowserTestUtils.removeTab(tab);
+ }
+});
diff --git a/browser/base/content/test/tabs/browser_tabCloseProbes.js b/browser/base/content/test/tabs/browser_tabCloseProbes.js
new file mode 100644
index 0000000000..4e5aca8482
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_tabCloseProbes.js
@@ -0,0 +1,112 @@
+"use strict";
+
+var gAnimHistogram = Services.telemetry.getHistogramById(
+ "FX_TAB_CLOSE_TIME_ANIM_MS"
+);
+var gNoAnimHistogram = Services.telemetry.getHistogramById(
+ "FX_TAB_CLOSE_TIME_NO_ANIM_MS"
+);
+
+/**
+ * Takes a Telemetry histogram snapshot and returns the sum of all counts.
+ *
+ * @param snapshot (Object)
+ * The Telemetry histogram snapshot to examine.
+ * @return (int)
+ * The sum of all counts in the snapshot.
+ */
+function snapshotCount(snapshot) {
+ // Use Array.prototype.reduce to sum up all of the
+ // snapshot.count entries
+ return Object.values(snapshot.values).reduce((a, b) => a + b, 0);
+}
+
+/**
+ * Takes a Telemetry histogram snapshot and makes sure
+ * that the sum of all counts equals expectedCount.
+ *
+ * @param snapshot (Object)
+ * The Telemetry histogram snapshot to examine.
+ * @param expectedCount (int)
+ * What we expect the number of incremented counts to be. For example,
+ * If we expect this probe to have only had a single recording, this
+ * would be 1. If we expected it to have not recorded any data at all,
+ * this would be 0.
+ */
+function assertCount(snapshot, expectedCount) {
+ Assert.equal(
+ snapshotCount(snapshot),
+ expectedCount,
+ `Should only be ${expectedCount} collected value.`
+ );
+}
+
+/**
+ * Takes a Telemetry histogram and waits for the sum of all counts becomes
+ * equal to expectedCount.
+ *
+ * @param histogram (Object)
+ * The Telemetry histogram to examine.
+ * @param expectedCount (int)
+ * What we expect the number of incremented counts to become.
+ * @return (Promise)
+ * @resolves When the histogram snapshot count becomes the expected count.
+ */
+function waitForSnapshotCount(histogram, expectedCount) {
+ return BrowserTestUtils.waitForCondition(() => {
+ return snapshotCount(histogram.snapshot()) == expectedCount;
+ }, `Collected value should become ${expectedCount}.`);
+}
+
+add_setup(async function () {
+ // Force-enable tab animations
+ gReduceMotionOverride = false;
+
+ // These probes are opt-in, meaning we only capture them if extended
+ // Telemetry recording is enabled.
+ let oldCanRecord = Services.telemetry.canRecordExtended;
+ Services.telemetry.canRecordExtended = true;
+ registerCleanupFunction(() => {
+ Services.telemetry.canRecordExtended = oldCanRecord;
+ });
+});
+
+/**
+ * Tests the FX_TAB_CLOSE_TIME_ANIM_MS probe by closing a tab with the tab
+ * close animation.
+ */
+add_task(async function test_close_time_anim_probe() {
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser);
+ await BrowserTestUtils.waitForCondition(() => tab._fullyOpen);
+
+ gAnimHistogram.clear();
+ gNoAnimHistogram.clear();
+
+ BrowserTestUtils.removeTab(tab, { animate: true });
+
+ await waitForSnapshotCount(gAnimHistogram, 1);
+ assertCount(gNoAnimHistogram.snapshot(), 0);
+
+ gAnimHistogram.clear();
+ gNoAnimHistogram.clear();
+});
+
+/**
+ * Tests the FX_TAB_CLOSE_TIME_NO_ANIM_MS probe by closing a tab without the
+ * tab close animation.
+ */
+add_task(async function test_close_time_no_anim_probe() {
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser);
+ await BrowserTestUtils.waitForCondition(() => tab._fullyOpen);
+
+ gAnimHistogram.clear();
+ gNoAnimHistogram.clear();
+
+ BrowserTestUtils.removeTab(tab, { animate: false });
+
+ await waitForSnapshotCount(gNoAnimHistogram, 1);
+ assertCount(gAnimHistogram.snapshot(), 0);
+
+ gAnimHistogram.clear();
+ gNoAnimHistogram.clear();
+});
diff --git a/browser/base/content/test/tabs/browser_tabCloseSpacer.js b/browser/base/content/test/tabs/browser_tabCloseSpacer.js
new file mode 100644
index 0000000000..6996546be2
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_tabCloseSpacer.js
@@ -0,0 +1,91 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests that while clicking to close tabs, the close button remains under the mouse
+ * even when an underflow happens.
+ */
+add_task(async function () {
+ // Disable tab animations
+ gReduceMotionOverride = true;
+
+ let downButton = gBrowser.tabContainer.arrowScrollbox._scrollButtonDown;
+ let closingTabsSpacer = gBrowser.tabContainer._closingTabsSpacer;
+ let arrowScrollbox = gBrowser.tabContainer.arrowScrollbox;
+
+ await BrowserTestUtils.overflowTabs(registerCleanupFunction, window);
+
+ // Make sure scrolling finished.
+ await new Promise(resolve => {
+ arrowScrollbox.addEventListener("scrollend", resolve, { once: true });
+ });
+
+ ok(
+ gBrowser.tabContainer.hasAttribute("overflow"),
+ "Tab strip should be overflowing"
+ );
+ isnot(downButton.clientWidth, 0, "down button has some width");
+ is(closingTabsSpacer.clientWidth, 0, "spacer has no width");
+
+ let originalCloseButtonLocation = getLastCloseButtonLocation();
+
+ info(
+ "Removing half the tabs and making sure the last close button doesn't move"
+ );
+ let numTabs = gBrowser.tabs.length / 2;
+ while (gBrowser.tabs.length > numTabs) {
+ let lastCloseButtonLocation = getLastCloseButtonLocation();
+ Assert.equal(
+ lastCloseButtonLocation.top,
+ originalCloseButtonLocation.top,
+ "The top of all close buttons should be equal"
+ );
+ Assert.equal(
+ lastCloseButtonLocation.bottom,
+ originalCloseButtonLocation.bottom,
+ "The bottom of all close buttons should be equal"
+ );
+ Assert.equal(
+ lastCloseButtonLocation.right,
+ originalCloseButtonLocation.right,
+ "The right side of the close button should remain consistent"
+ );
+ // Ignore 'left' since non-hovered tabs have their close button
+ // narrower to display more tab label.
+
+ EventUtils.synthesizeMouseAtCenter(getLastCloseButton(), {});
+ await new Promise(r => requestAnimationFrame(r));
+ }
+
+ ok(!gBrowser.tabContainer.hasAttribute("overflow"), "not overflowing");
+ ok(
+ gBrowser.tabContainer.hasAttribute("using-closing-tabs-spacer"),
+ "using spacer"
+ );
+
+ is(downButton.clientWidth, 0, "down button has no width");
+ isnot(closingTabsSpacer.clientWidth, 0, "spacer has some width");
+});
+
+function getLastCloseButton() {
+ let lastTab = gBrowser.tabs[gBrowser.tabs.length - 1];
+ return lastTab.closeButton;
+}
+
+function getLastCloseButtonLocation() {
+ let rect = getLastCloseButton().getBoundingClientRect();
+ return {
+ left: Math.round(rect.left),
+ top: Math.round(rect.top),
+ width: Math.round(rect.width),
+ height: Math.round(rect.height),
+ };
+}
+
+registerCleanupFunction(() => {
+ while (gBrowser.tabs.length > 1) {
+ BrowserTestUtils.removeTab(gBrowser.tabs[0]);
+ }
+});
diff --git a/browser/base/content/test/tabs/browser_tabContextMenu_keyboard.js b/browser/base/content/test/tabs/browser_tabContextMenu_keyboard.js
new file mode 100644
index 0000000000..f3a2066653
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_tabContextMenu_keyboard.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/. */
+
+async function openContextMenu() {
+ let contextMenu = document.getElementById("tabContextMenu");
+ let popupShown = BrowserTestUtils.waitForEvent(contextMenu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(gBrowser.selectedTab, {
+ type: "contextmenu",
+ button: 2,
+ });
+ await popupShown;
+}
+
+async function closeContextMenu() {
+ let contextMenu = document.getElementById("tabContextMenu");
+ let popupHidden = BrowserTestUtils.waitForEvent(contextMenu, "popuphidden");
+ EventUtils.synthesizeKey("KEY_Escape");
+ await popupHidden;
+}
+
+add_task(async function test() {
+ if (Services.prefs.getBoolPref("widget.macos.native-context-menus", false)) {
+ ok(
+ true,
+ "This bug is not possible when native context menus are enabled on macOS."
+ );
+ return;
+ }
+ // Ensure tabs are focusable.
+ await SpecialPowers.pushPrefEnv({ set: [["accessibility.tabfocus", 7]] });
+
+ // There should be one tab when we start the test.
+ let tab1 = gBrowser.selectedTab;
+ let tab2 = BrowserTestUtils.addTab(gBrowser);
+ tab1.focus();
+ is(document.activeElement, tab1, "tab1 should be focused");
+
+ // Ensure that DownArrow doesn't switch to tab2 while the context menu is open.
+ await openContextMenu();
+ EventUtils.synthesizeKey("KEY_ArrowDown");
+ await closeContextMenu();
+ is(gBrowser.selectedTab, tab1, "tab1 should still be active");
+ if (AppConstants.platform == "macosx") {
+ // On Mac, focus doesn't return to the tab after dismissing the context menu.
+ // Since we're not testing that here, work around it by just focusing again.
+ tab1.focus();
+ }
+ is(document.activeElement, tab1, "tab1 should be focused");
+
+ // Switch to tab2 by pressing DownArrow.
+ await BrowserTestUtils.switchTab(gBrowser, () =>
+ EventUtils.synthesizeKey("KEY_ArrowDown")
+ );
+ is(gBrowser.selectedTab, tab2, "should have switched to tab2");
+ is(document.activeElement, tab2, "tab2 should now be focused");
+ // Ensure that UpArrow doesn't switch to tab1 while the context menu is open.
+ await openContextMenu();
+ EventUtils.synthesizeKey("KEY_ArrowUp");
+ await closeContextMenu();
+ is(gBrowser.selectedTab, tab2, "tab2 should still be active");
+
+ gBrowser.removeTab(tab2);
+});
diff --git a/browser/base/content/test/tabs/browser_tabReorder.js b/browser/base/content/test/tabs/browser_tabReorder.js
new file mode 100644
index 0000000000..c5ae459065
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_tabReorder.js
@@ -0,0 +1,64 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+add_task(async function () {
+ let initialTabsLength = gBrowser.tabs.length;
+
+ let newTab1 = (gBrowser.selectedTab = BrowserTestUtils.addTab(
+ gBrowser,
+ "about:robots",
+ { skipAnimation: true }
+ ));
+ let newTab2 = (gBrowser.selectedTab = BrowserTestUtils.addTab(
+ gBrowser,
+ "about:about",
+ { skipAnimation: true }
+ ));
+ let newTab3 = (gBrowser.selectedTab = BrowserTestUtils.addTab(
+ gBrowser,
+ "about:config",
+ { skipAnimation: true }
+ ));
+ registerCleanupFunction(function () {
+ while (gBrowser.tabs.length > initialTabsLength) {
+ gBrowser.removeTab(gBrowser.tabs[initialTabsLength]);
+ }
+ });
+
+ is(gBrowser.tabs.length, initialTabsLength + 3, "new tabs are opened");
+ is(gBrowser.tabs[initialTabsLength], newTab1, "newTab1 position is correct");
+ is(
+ gBrowser.tabs[initialTabsLength + 1],
+ newTab2,
+ "newTab2 position is correct"
+ );
+ is(
+ gBrowser.tabs[initialTabsLength + 2],
+ newTab3,
+ "newTab3 position is correct"
+ );
+
+ await dragAndDrop(newTab1, newTab2, false);
+ is(gBrowser.tabs.length, initialTabsLength + 3, "tabs are still there");
+ is(
+ gBrowser.tabs[initialTabsLength],
+ newTab2,
+ "newTab2 and newTab1 are swapped"
+ );
+ is(
+ gBrowser.tabs[initialTabsLength + 1],
+ newTab1,
+ "newTab1 and newTab2 are swapped"
+ );
+ is(gBrowser.tabs[initialTabsLength + 2], newTab3, "newTab3 stays same place");
+
+ await dragAndDrop(newTab2, newTab1, true);
+ is(gBrowser.tabs.length, initialTabsLength + 4, "a tab is duplicated");
+ is(gBrowser.tabs[initialTabsLength], newTab2, "newTab2 stays same place");
+ is(gBrowser.tabs[initialTabsLength + 1], newTab1, "newTab1 stays same place");
+ is(
+ gBrowser.tabs[initialTabsLength + 3],
+ newTab3,
+ "a new tab is inserted before newTab3"
+ );
+});
diff --git a/browser/base/content/test/tabs/browser_tabReorder_overflow.js b/browser/base/content/test/tabs/browser_tabReorder_overflow.js
new file mode 100644
index 0000000000..a74677204f
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_tabReorder_overflow.js
@@ -0,0 +1,62 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+requestLongerTimeout(2);
+
+add_task(async function () {
+ let initialTabsLength = gBrowser.tabs.length;
+
+ let arrowScrollbox = gBrowser.tabContainer.arrowScrollbox;
+ let tabMinWidth = parseInt(
+ getComputedStyle(gBrowser.selectedTab, null).minWidth
+ );
+
+ let width = ele => ele.getBoundingClientRect().width;
+
+ let tabCountForOverflow = Math.ceil(
+ (width(arrowScrollbox) / tabMinWidth) * 1.1
+ );
+
+ let newTab1 = (gBrowser.selectedTab = BrowserTestUtils.addTab(
+ gBrowser,
+ "about:robots",
+ { skipAnimation: true }
+ ));
+ let newTab2 = (gBrowser.selectedTab = BrowserTestUtils.addTab(
+ gBrowser,
+ "about:about",
+ { skipAnimation: true }
+ ));
+ let newTab3 = (gBrowser.selectedTab = BrowserTestUtils.addTab(
+ gBrowser,
+ "about:config",
+ { skipAnimation: true }
+ ));
+
+ await BrowserTestUtils.overflowTabs(registerCleanupFunction, window, {
+ overflowAtStart: false,
+ });
+
+ registerCleanupFunction(function () {
+ while (gBrowser.tabs.length > initialTabsLength) {
+ gBrowser.removeTab(
+ gBrowser.tabContainer.getItemAtIndex(initialTabsLength)
+ );
+ }
+ });
+
+ let tabs = gBrowser.tabs;
+ is(tabs.length, tabCountForOverflow, "new tabs are opened");
+ is(tabs[initialTabsLength], newTab1, "newTab1 position is correct");
+ is(tabs[initialTabsLength + 1], newTab2, "newTab2 position is correct");
+ is(tabs[initialTabsLength + 2], newTab3, "newTab3 position is correct");
+
+ await dragAndDrop(newTab1, newTab2, false);
+ tabs = gBrowser.tabs;
+ is(tabs.length, tabCountForOverflow, "tabs are still there");
+ is(tabs[initialTabsLength], newTab2, "newTab2 and newTab1 are swapped");
+ is(tabs[initialTabsLength + 1], newTab1, "newTab1 and newTab2 are swapped");
+ is(tabs[initialTabsLength + 2], newTab3, "newTab3 stays same place");
+});
diff --git a/browser/base/content/test/tabs/browser_tabSpinnerProbe.js b/browser/base/content/test/tabs/browser_tabSpinnerProbe.js
new file mode 100644
index 0000000000..9115d4fc6c
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_tabSpinnerProbe.js
@@ -0,0 +1,101 @@
+"use strict";
+
+/**
+ * Tests the FX_TAB_SWITCH_SPINNER_VISIBLE_MS and
+ * FX_TAB_SWITCH_SPINNER_VISIBLE_LONG_MS telemetry probes
+ */
+const MIN_HANG_TIME = 500; // ms
+const MAX_HANG_TIME = 5 * 1000; // ms
+
+/**
+ * Returns the sum of all values in an array.
+ * @param {Array} aArray An array of integers
+ * @return {Number} The sum of the integers in the array
+ */
+function sum(aArray) {
+ return aArray.reduce(function (previousValue, currentValue) {
+ return previousValue + currentValue;
+ });
+}
+
+/**
+ * Causes the content process for a remote <xul:browser> to run
+ * some busy JS for aMs milliseconds.
+ *
+ * @param {<xul:browser>} browser
+ * The browser that's running in the content process that we're
+ * going to hang.
+ * @param {int} aMs
+ * The amount of time, in milliseconds, to hang the content process.
+ *
+ * @return {Promise}
+ * Resolves once the hang is done.
+ */
+function hangContentProcess(browser, aMs) {
+ return ContentTask.spawn(browser, aMs, function (ms) {
+ let then = Date.now();
+ while (Date.now() - then < ms) {
+ // Let's burn some CPU...
+ }
+ });
+}
+
+/**
+ * A generator intended to be run as a Task. It tests one of the tab spinner
+ * telemetry probes.
+ * @param {String} aProbe The probe to test. Should be one of:
+ * - FX_TAB_SWITCH_SPINNER_VISIBLE_MS
+ * - FX_TAB_SWITCH_SPINNER_VISIBLE_LONG_MS
+ */
+async function testProbe(aProbe) {
+ info(`Testing probe: ${aProbe}`);
+ let histogram = Services.telemetry.getHistogramById(aProbe);
+ let delayTime = MIN_HANG_TIME + 1; // Pick a bucket arbitrarily
+
+ // The tab spinner does not show up instantly. We need to hang for a little
+ // bit of extra time to account for the tab spinner delay.
+ delayTime += gBrowser.selectedTab.linkedBrowser
+ .getTabBrowser()
+ ._getSwitcher().TAB_SWITCH_TIMEOUT;
+
+ // In order for a spinner to be shown, the tab must have presented before.
+ let origTab = gBrowser.selectedTab;
+ let hangTab = await BrowserTestUtils.openNewForegroundTab(gBrowser);
+ let hangBrowser = hangTab.linkedBrowser;
+ ok(hangBrowser.isRemoteBrowser, "New tab should be remote.");
+ ok(hangBrowser.frameLoader.remoteTab.hasPresented, "New tab has presented.");
+
+ // Now switch back to the original tab and set up our hang.
+ await BrowserTestUtils.switchTab(gBrowser, origTab);
+
+ let tabHangPromise = hangContentProcess(hangBrowser, delayTime);
+ histogram.clear();
+ let hangTabSwitch = BrowserTestUtils.switchTab(gBrowser, hangTab);
+ await tabHangPromise;
+ await hangTabSwitch;
+
+ // Now we should have a hang in our histogram.
+ let snapshot = histogram.snapshot();
+ BrowserTestUtils.removeTab(hangTab);
+ ok(
+ sum(Object.values(snapshot.values)) > 0,
+ `Spinner probe should now have a value in some bucket`
+ );
+}
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["dom.ipc.processCount", 1],
+ // We can interrupt JS to paint now, which is great for
+ // users, but bad for testing spinners. We temporarily
+ // disable that feature for this test so that we can
+ // easily get ourselves into a predictable tab spinner
+ // state.
+ ["browser.tabs.remote.force-paint", false],
+ ],
+ });
+});
+
+add_task(testProbe.bind(null, "FX_TAB_SWITCH_SPINNER_VISIBLE_MS"));
+add_task(testProbe.bind(null, "FX_TAB_SWITCH_SPINNER_VISIBLE_LONG_MS"));
diff --git a/browser/base/content/test/tabs/browser_tabSuccessors.js b/browser/base/content/test/tabs/browser_tabSuccessors.js
new file mode 100644
index 0000000000..9f577b6200
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_tabSuccessors.js
@@ -0,0 +1,131 @@
+add_task(async function test() {
+ const tabs = [gBrowser.selectedTab];
+ for (let i = 0; i < 6; ++i) {
+ tabs.push(BrowserTestUtils.addTab(gBrowser));
+ }
+
+ // Check that setSuccessor works.
+ gBrowser.setSuccessor(tabs[0], tabs[2]);
+ is(tabs[0].successor, tabs[2], "setSuccessor sets successor");
+ ok(tabs[2].predecessors.has(tabs[0]), "setSuccessor adds predecessor");
+
+ BrowserTestUtils.removeTab(tabs[0]);
+ is(
+ gBrowser.selectedTab,
+ tabs[2],
+ "When closing a selected tab, select its successor"
+ );
+
+ // Check that the successor of a hidden tab becomes the successor of the
+ // tab's predecessors.
+ gBrowser.setSuccessor(tabs[1], tabs[2]);
+ gBrowser.setSuccessor(tabs[3], tabs[1]);
+ ok(!tabs[2].predecessors.has(tabs[3]));
+
+ gBrowser.hideTab(tabs[1]);
+ is(
+ tabs[3].successor,
+ tabs[2],
+ "A predecessor of a hidden tab should take as its successor the hidden tab's successor"
+ );
+ ok(tabs[2].predecessors.has(tabs[3]));
+
+ gBrowser.showTab(tabs[1]);
+
+ // Check that the successor of a closed tab also becomes the successor of the
+ // tab's predecessors.
+ gBrowser.setSuccessor(tabs[1], tabs[2]);
+ gBrowser.setSuccessor(tabs[3], tabs[1]);
+ ok(!tabs[2].predecessors.has(tabs[3]));
+
+ BrowserTestUtils.removeTab(tabs[1]);
+ is(
+ tabs[3].successor,
+ tabs[2],
+ "A predecessor of a closed tab should take as its successor the closed tab's successor"
+ );
+ ok(tabs[2].predecessors.has(tabs[3]));
+
+ // Check that clearing a successor makes the browser fall back to selecting
+ // the owner or next tab.
+ await BrowserTestUtils.switchTab(gBrowser, tabs[3]);
+ gBrowser.setSuccessor(tabs[3], null);
+ is(tabs[3].successor, null, "setSuccessor(..., null) should clear successor");
+ ok(
+ !tabs[2].predecessors.has(tabs[3]),
+ "setSuccessor(..., null) should remove the old successor from predecessors"
+ );
+
+ BrowserTestUtils.removeTab(tabs[3]);
+ is(
+ gBrowser.selectedTab,
+ tabs[4],
+ "When the active tab is closed and its successor has been cleared, select the next tab"
+ );
+
+ // Like closing or hiding a tab, moving a tab to another window should also
+ // result in its successor becoming the successor of the moved tab's
+ // predecessors.
+ gBrowser.setSuccessor(tabs[4], tabs[2]);
+ gBrowser.setSuccessor(tabs[2], tabs[5]);
+ const secondWin = gBrowser.replaceTabsWithWindow(tabs[2]);
+ await TestUtils.waitForCondition(
+ () => tabs[2].closing,
+ "Wait for tab to be transferred"
+ );
+ is(
+ tabs[4].successor,
+ tabs[5],
+ "A predecessor of a tab moved to another window should take as its successor the moved tab's successor"
+ );
+
+ // Trying to set a successor across windows should fail.
+ let threw = false;
+ try {
+ gBrowser.setSuccessor(tabs[4], secondWin.gBrowser.selectedTab);
+ } catch (ex) {
+ threw = true;
+ }
+ ok(threw, "No cross window successors");
+ is(tabs[4].successor, tabs[5], "Successor should remain unchanged");
+
+ threw = false;
+ try {
+ secondWin.gBrowser.setSuccessor(tabs[4], null);
+ } catch (ex) {
+ threw = true;
+ }
+ ok(threw, "No setting successors for another window's tab");
+ is(tabs[4].successor, tabs[5], "Successor should remain unchanged");
+
+ BrowserTestUtils.closeWindow(secondWin);
+
+ // A tab can't be its own successor
+ gBrowser.setSuccessor(tabs[4], tabs[4]);
+ is(
+ tabs[4].successor,
+ null,
+ "Successor should be cleared instead of pointing to itself"
+ );
+
+ gBrowser.setSuccessor(tabs[4], tabs[5]);
+ gBrowser.setSuccessor(tabs[5], tabs[4]);
+ is(
+ tabs[4].successor,
+ tabs[5],
+ "Successors can form cycles of length > 1 [a]"
+ );
+ is(
+ tabs[5].successor,
+ tabs[4],
+ "Successors can form cycles of length > 1 [b]"
+ );
+ BrowserTestUtils.removeTab(tabs[5]);
+ is(
+ tabs[4].successor,
+ null,
+ "Successor should be cleared instead of pointing to itself"
+ );
+
+ gBrowser.removeTab(tabs[4]);
+});
diff --git a/browser/base/content/test/tabs/browser_tab_a11y_description.js b/browser/base/content/test/tabs/browser_tab_a11y_description.js
new file mode 100644
index 0000000000..04f9a54a1b
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_tab_a11y_description.js
@@ -0,0 +1,74 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+async function waitForFocusAfterKey(ariaFocus, element, key, accel = false) {
+ let event = ariaFocus ? "AriaFocus" : "focus";
+ let friendlyKey = key;
+ if (accel) {
+ friendlyKey = "Accel+" + key;
+ }
+ key = "KEY_" + key;
+ let focused = BrowserTestUtils.waitForEvent(element, event);
+ EventUtils.synthesizeKey(key, { accelKey: accel });
+ await focused;
+ ok(true, element.label + " got " + event + " after " + friendlyKey);
+}
+
+function getA11yDescription(element) {
+ let descId = element.getAttribute("aria-describedby");
+ if (!descId) {
+ return null;
+ }
+ let descElem = document.getElementById(descId);
+ if (!descElem) {
+ return null;
+ }
+ return descElem.textContent;
+}
+
+add_task(async function testTabA11yDescription() {
+ const tab1 = await addTab("http://mochi.test:8888/1", { userContextId: 1 });
+ tab1.label = "tab1";
+ const context1 = ContextualIdentityService.getUserContextLabel(1);
+ const tab2 = await addTab("http://mochi.test:8888/2", { userContextId: 2 });
+ tab2.label = "tab2";
+ const context2 = ContextualIdentityService.getUserContextLabel(2);
+
+ await BrowserTestUtils.switchTab(gBrowser, tab1);
+ let focused = BrowserTestUtils.waitForEvent(tab1, "focus");
+ tab1.focus();
+ await focused;
+ ok(true, "tab1 initially focused");
+ ok(
+ getA11yDescription(tab1).endsWith(context1),
+ "tab1 has correct a11y description"
+ );
+ ok(!getA11yDescription(tab2), "tab2 has no a11y description");
+
+ info("Moving DOM focus to tab2");
+ await waitForFocusAfterKey(false, tab2, "ArrowRight");
+ ok(
+ getA11yDescription(tab2).endsWith(context2),
+ "tab2 has correct a11y description"
+ );
+ ok(!getA11yDescription(tab1), "tab1 has no a11y description");
+
+ info("Moving ARIA focus to tab1");
+ await waitForFocusAfterKey(true, tab1, "ArrowLeft", true);
+ ok(
+ getA11yDescription(tab1).endsWith(context1),
+ "tab1 has correct a11y description"
+ );
+ ok(!getA11yDescription(tab2), "tab2 has no a11y description");
+
+ info("Removing ARIA focus (reverting to DOM focus)");
+ await waitForFocusAfterKey(true, tab2, "ArrowRight");
+ ok(
+ getA11yDescription(tab2).endsWith(context2),
+ "tab2 has correct a11y description"
+ );
+ ok(!getA11yDescription(tab1), "tab1 has no a11y description");
+
+ BrowserTestUtils.removeTab(tab1);
+ BrowserTestUtils.removeTab(tab2);
+});
diff --git a/browser/base/content/test/tabs/browser_tab_label_during_reload.js b/browser/base/content/test/tabs/browser_tab_label_during_reload.js
new file mode 100644
index 0000000000..ec0728c34a
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_tab_label_during_reload.js
@@ -0,0 +1,41 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function () {
+ let tab = (gBrowser.selectedTab = BrowserTestUtils.addTab(
+ gBrowser,
+ "about:preferences"
+ ));
+ let browser = tab.linkedBrowser;
+ let labelChanges = 0;
+ let attrModifiedListener = event => {
+ if (event.detail.changed.includes("label")) {
+ labelChanges++;
+ }
+ };
+ tab.addEventListener("TabAttrModified", attrModifiedListener);
+
+ await BrowserTestUtils.browserLoaded(browser);
+ is(labelChanges, 1, "number of label changes during initial load");
+ isnot(tab.label, "", "about:preferences tab label isn't empty");
+ isnot(
+ tab.label,
+ "about:preferences",
+ "about:preferences tab label isn't the URI"
+ );
+ is(
+ tab.label,
+ browser.contentTitle,
+ "about:preferences tab label matches browser.contentTitle"
+ );
+
+ labelChanges = 0;
+ browser.reload();
+ await BrowserTestUtils.browserLoaded(browser);
+ is(labelChanges, 0, "number of label changes during reload");
+
+ tab.removeEventListener("TabAttrModified", attrModifiedListener);
+ gBrowser.removeTab(tab);
+});
diff --git a/browser/base/content/test/tabs/browser_tab_label_picture_in_picture.js b/browser/base/content/test/tabs/browser_tab_label_picture_in_picture.js
new file mode 100644
index 0000000000..dae4ffc444
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_tab_label_picture_in_picture.js
@@ -0,0 +1,30 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_pip_label_changes_tab() {
+ let newWin = await BrowserTestUtils.openNewBrowserWindow();
+
+ let pipTab = newWin.document.querySelector(".tabbrowser-tab[selected]");
+ pipTab.setAttribute("pictureinpicture", true);
+
+ let pipLabel = pipTab.querySelector(".tab-icon-sound-pip-label");
+
+ await BrowserTestUtils.withNewTab("about:blank", async browser => {
+ let selectedTab = newWin.document.querySelector(
+ ".tabbrowser-tab[selected]"
+ );
+ Assert.ok(
+ selectedTab != pipTab,
+ "Picture in picture tab is not selected tab"
+ );
+
+ selectedTab = await BrowserTestUtils.switchTab(newWin.gBrowser, () =>
+ pipLabel.click()
+ );
+ Assert.ok(selectedTab == pipTab, "Picture in picture tab is selected tab");
+ });
+
+ await BrowserTestUtils.closeWindow(newWin);
+});
diff --git a/browser/base/content/test/tabs/browser_tab_manager_close.js b/browser/base/content/test/tabs/browser_tab_manager_close.js
new file mode 100644
index 0000000000..d04b2a6c0a
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_tab_manager_close.js
@@ -0,0 +1,84 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const URL1 = "data:text/plain,tab1";
+const URL2 = "data:text/plain,tab2";
+const URL3 = "data:text/plain,tab3";
+const URL4 = "data:text/plain,tab4";
+const URL5 = "data:text/plain,tab5";
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.tabs.tabmanager.enabled", true]],
+ });
+});
+
+/**
+ * Tests that middle-clicking on a tab in the Tab Manager will close it.
+ */
+add_task(async function test_tab_manager_close_middle_click() {
+ let win =
+ await BrowserTestUtils.openNewWindowWithFlushedCacheForMozSupports();
+ win.gTabsPanel.init();
+ await addTabTo(win.gBrowser, URL1);
+ await addTabTo(win.gBrowser, URL2);
+ await addTabTo(win.gBrowser, URL3);
+ await addTabTo(win.gBrowser, URL4);
+ await addTabTo(win.gBrowser, URL5);
+
+ let button = win.document.getElementById("alltabs-button");
+ let allTabsView = win.document.getElementById("allTabsMenu-allTabsView");
+ let allTabsPopupShownPromise = BrowserTestUtils.waitForEvent(
+ allTabsView,
+ "ViewShown"
+ );
+ button.click();
+ await allTabsPopupShownPromise;
+
+ let list = win.document.getElementById("allTabsMenu-allTabsView-tabs");
+ while (win.gBrowser.tabs.length > 1) {
+ let row = list.lastElementChild;
+ let tabClosing = BrowserTestUtils.waitForTabClosing(row.tab);
+ EventUtils.synthesizeMouseAtCenter(row, { button: 1 }, win);
+ await tabClosing;
+ Assert.ok(true, "Closed a tab with middle-click.");
+ }
+ await BrowserTestUtils.closeWindow(win);
+});
+
+/**
+ * Tests that clicking the close button next to a tab manager item
+ * will close it.
+ */
+add_task(async function test_tab_manager_close_button() {
+ let win =
+ await BrowserTestUtils.openNewWindowWithFlushedCacheForMozSupports();
+ win.gTabsPanel.init();
+ await addTabTo(win.gBrowser, URL1);
+ await addTabTo(win.gBrowser, URL2);
+ await addTabTo(win.gBrowser, URL3);
+ await addTabTo(win.gBrowser, URL4);
+ await addTabTo(win.gBrowser, URL5);
+
+ let button = win.document.getElementById("alltabs-button");
+ let allTabsView = win.document.getElementById("allTabsMenu-allTabsView");
+ let allTabsPopupShownPromise = BrowserTestUtils.waitForEvent(
+ allTabsView,
+ "ViewShown"
+ );
+ button.click();
+ await allTabsPopupShownPromise;
+
+ let list = win.document.getElementById("allTabsMenu-allTabsView-tabs");
+ while (win.gBrowser.tabs.length > 1) {
+ let row = list.lastElementChild;
+ let tabClosing = BrowserTestUtils.waitForTabClosing(row.tab);
+ let closeButton = row.lastElementChild;
+ EventUtils.synthesizeMouseAtCenter(closeButton, { button: 1 }, win);
+ await tabClosing;
+ Assert.ok(true, "Closed a tab with the close button.");
+ }
+ await BrowserTestUtils.closeWindow(win);
+});
diff --git a/browser/base/content/test/tabs/browser_tab_manager_drag.js b/browser/base/content/test/tabs/browser_tab_manager_drag.js
new file mode 100644
index 0000000000..1dc1933e3e
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_tab_manager_drag.js
@@ -0,0 +1,259 @@
+/**
+ * Test reordering the tabs in the Tab Manager, moving the tab between the
+ * Tab Manager and tab bar.
+ */
+
+"use strict";
+
+const { TelemetryTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/TelemetryTestUtils.sys.mjs"
+);
+
+const URL1 = "data:text/plain,tab1";
+const URL2 = "data:text/plain,tab2";
+const URL3 = "data:text/plain,tab3";
+const URL4 = "data:text/plain,tab4";
+const URL5 = "data:text/plain,tab5";
+
+function assertOrder(order, expected, message) {
+ is(
+ JSON.stringify(order),
+ JSON.stringify(expected),
+ `The order of the tabs ${message}`
+ );
+}
+
+function toIndex(url) {
+ const m = url.match(/^data:text\/plain,tab(\d)/);
+ if (m) {
+ return parseInt(m[1]);
+ }
+ return 0;
+}
+
+function getOrderOfList(list) {
+ return [...list.querySelectorAll("toolbaritem")].map(row => {
+ const url = row.firstElementChild.tab.linkedBrowser.currentURI.spec;
+ return toIndex(url);
+ });
+}
+
+function getOrderOfTabs(tabs) {
+ return tabs.map(tab => {
+ const url = tab.linkedBrowser.currentURI.spec;
+ return toIndex(url);
+ });
+}
+
+async function testWithNewWindow(func) {
+ Services.prefs.setBoolPref("browser.tabs.tabmanager.enabled", true);
+
+ const newWindow =
+ await BrowserTestUtils.openNewWindowWithFlushedCacheForMozSupports();
+
+ await Promise.all([
+ addTabTo(newWindow.gBrowser, URL1),
+ addTabTo(newWindow.gBrowser, URL2),
+ addTabTo(newWindow.gBrowser, URL3),
+ addTabTo(newWindow.gBrowser, URL4),
+ addTabTo(newWindow.gBrowser, URL5),
+ ]);
+
+ newWindow.gTabsPanel.init();
+
+ const button = newWindow.document.getElementById("alltabs-button");
+
+ const allTabsView = newWindow.document.getElementById(
+ "allTabsMenu-allTabsView"
+ );
+ const allTabsPopupShownPromise = BrowserTestUtils.waitForEvent(
+ allTabsView,
+ "ViewShown"
+ );
+ button.click();
+ await allTabsPopupShownPromise;
+
+ await func(newWindow);
+
+ await BrowserTestUtils.closeWindow(newWindow);
+
+ Services.prefs.clearUserPref("browser.tabs.tabmanager.enabled");
+}
+
+add_task(async function test_reorder() {
+ await testWithNewWindow(async function (newWindow) {
+ Services.telemetry.clearScalars();
+
+ const list = newWindow.document.getElementById(
+ "allTabsMenu-allTabsView-tabs"
+ );
+
+ assertOrder(getOrderOfList(list), [0, 1, 2, 3, 4, 5], "before reorder");
+
+ let rows;
+ rows = list.querySelectorAll("toolbaritem");
+ EventUtils.synthesizeDrop(
+ rows[3],
+ rows[1],
+ null,
+ "move",
+ newWindow,
+ newWindow,
+ { clientX: 0, clientY: 0 }
+ );
+
+ assertOrder(getOrderOfList(list), [0, 3, 1, 2, 4, 5], "after moving up");
+
+ rows = list.querySelectorAll("toolbaritem");
+ EventUtils.synthesizeDrop(
+ rows[1],
+ rows[5],
+ null,
+ "move",
+ newWindow,
+ newWindow,
+ { clientX: 0, clientY: 0 }
+ );
+
+ assertOrder(getOrderOfList(list), [0, 1, 2, 4, 3, 5], "after moving down");
+
+ rows = list.querySelectorAll("toolbaritem");
+ EventUtils.synthesizeDrop(
+ rows[4],
+ rows[3],
+ null,
+ "move",
+ newWindow,
+ newWindow,
+ { clientX: 0, clientY: 0 }
+ );
+
+ assertOrder(
+ getOrderOfList(list),
+ [0, 1, 2, 3, 4, 5],
+ "after moving up again"
+ );
+
+ let scalars = TelemetryTestUtils.getProcessScalars("parent", false, true);
+ TelemetryTestUtils.assertScalar(
+ scalars,
+ "browser.ui.interaction.all_tabs_panel_dragstart_tab_event_count",
+ 3
+ );
+ });
+});
+
+function tabOf(row) {
+ return row.firstElementChild.tab;
+}
+
+add_task(async function test_move_to_tab_bar() {
+ await testWithNewWindow(async function (newWindow) {
+ Services.telemetry.clearScalars();
+
+ const list = newWindow.document.getElementById(
+ "allTabsMenu-allTabsView-tabs"
+ );
+
+ assertOrder(getOrderOfList(list), [0, 1, 2, 3, 4, 5], "before reorder");
+
+ let rows;
+ rows = list.querySelectorAll("toolbaritem");
+ EventUtils.synthesizeDrop(
+ rows[3],
+ tabOf(rows[1]),
+ null,
+ "move",
+ newWindow,
+ newWindow,
+ { clientX: 0, clientY: 0 }
+ );
+
+ assertOrder(
+ getOrderOfList(list),
+ [0, 3, 1, 2, 4, 5],
+ "after moving up with tab bar"
+ );
+
+ rows = list.querySelectorAll("toolbaritem");
+ EventUtils.synthesizeDrop(
+ rows[1],
+ tabOf(rows[4]),
+ null,
+ "move",
+ newWindow,
+ newWindow,
+ { clientX: 0, clientY: 0 }
+ );
+
+ assertOrder(
+ getOrderOfList(list),
+ [0, 1, 2, 3, 4, 5],
+ "after moving down with tab bar"
+ );
+
+ let scalars = TelemetryTestUtils.getProcessScalars("parent", false, true);
+ TelemetryTestUtils.assertScalar(
+ scalars,
+ "browser.ui.interaction.all_tabs_panel_dragstart_tab_event_count",
+ 2
+ );
+ });
+});
+
+add_task(async function test_move_to_different_tab_bar() {
+ const newWindow2 =
+ await BrowserTestUtils.openNewWindowWithFlushedCacheForMozSupports();
+
+ await testWithNewWindow(async function (newWindow) {
+ Services.telemetry.clearScalars();
+
+ const list = newWindow.document.getElementById(
+ "allTabsMenu-allTabsView-tabs"
+ );
+
+ assertOrder(
+ getOrderOfList(list),
+ [0, 1, 2, 3, 4, 5],
+ "before reorder in newWindow"
+ );
+ assertOrder(
+ getOrderOfTabs(newWindow2.gBrowser.tabs),
+ [0],
+ "before reorder in newWindow2"
+ );
+
+ let rows;
+ rows = list.querySelectorAll("toolbaritem");
+ EventUtils.synthesizeDrop(
+ rows[3],
+ newWindow2.gBrowser.tabs[0],
+ null,
+ "move",
+ newWindow,
+ newWindow2,
+ { clientX: 0, clientY: 0 }
+ );
+
+ assertOrder(
+ getOrderOfList(list),
+ [0, 1, 2, 4, 5],
+ "after moving to other window in newWindow"
+ );
+
+ assertOrder(
+ getOrderOfTabs(newWindow2.gBrowser.tabs),
+ [3, 0],
+ "after moving to other window in newWindow2"
+ );
+
+ let scalars = TelemetryTestUtils.getProcessScalars("parent", false, true);
+ TelemetryTestUtils.assertScalar(
+ scalars,
+ "browser.ui.interaction.all_tabs_panel_dragstart_tab_event_count",
+ 1
+ );
+ });
+
+ await BrowserTestUtils.closeWindow(newWindow2);
+});
diff --git a/browser/base/content/test/tabs/browser_tab_manager_keyboard_access.js b/browser/base/content/test/tabs/browser_tab_manager_keyboard_access.js
new file mode 100644
index 0000000000..444db5d3be
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_tab_manager_keyboard_access.js
@@ -0,0 +1,38 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Check we can open the tab manager using the keyboard.
+ * Note that navigation to buttons in the toolbar is covered
+ * by other tests.
+ */
+add_task(async function test_open_tabmanager_keyboard() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.tabs.tabmanager.enabled", true]],
+ });
+ let newWindow =
+ await BrowserTestUtils.openNewWindowWithFlushedCacheForMozSupports();
+ let elem = newWindow.document.getElementById("alltabs-button");
+
+ // Borrowed from forceFocus() in the keyboard directory head.js
+ elem.setAttribute("tabindex", "-1");
+ elem.focus();
+ elem.removeAttribute("tabindex");
+
+ let focused = BrowserTestUtils.waitForEvent(newWindow, "focus", true);
+ EventUtils.synthesizeKey(" ", {}, newWindow);
+ let event = await focused;
+ ok(
+ event.originalTarget.closest("#allTabsMenu-allTabsView"),
+ "Focus inside all tabs menu after toolbar button pressed"
+ );
+ let hidden = BrowserTestUtils.waitForEvent(
+ event.target.closest("panel"),
+ "popuphidden"
+ );
+ EventUtils.synthesizeKey("KEY_Escape", { shiftKey: false }, newWindow);
+ await hidden;
+ await BrowserTestUtils.closeWindow(newWindow);
+});
diff --git a/browser/base/content/test/tabs/browser_tab_manager_visibility.js b/browser/base/content/test/tabs/browser_tab_manager_visibility.js
new file mode 100644
index 0000000000..cfec0fa528
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_tab_manager_visibility.js
@@ -0,0 +1,55 @@
+/**
+ * Test the Tab Manager visibility respects browser.tabs.tabmanager.enabled preference
+ * */
+
+"use strict";
+
+// The hostname for the test URIs.
+const TEST_HOSTNAME = "https://example.com";
+const DUMMY_PAGE_PATH = "/browser/base/content/test/tabs/dummy_page.html";
+
+add_task(async function tab_manager_visibility_preference_on() {
+ Services.prefs.setBoolPref("browser.tabs.tabmanager.enabled", true);
+
+ let newWindow =
+ await BrowserTestUtils.openNewWindowWithFlushedCacheForMozSupports();
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser: newWindow.gBrowser,
+ url: TEST_HOSTNAME + DUMMY_PAGE_PATH,
+ },
+ async function (browser) {
+ await Assert.ok(
+ BrowserTestUtils.is_visible(
+ newWindow.document.getElementById("alltabs-button")
+ ),
+ "tab manage menu is visible when browser.tabs.tabmanager.enabled preference is set to true"
+ );
+ }
+ );
+ Services.prefs.clearUserPref("browser.tabs.tabmanager.enabled");
+ BrowserTestUtils.closeWindow(newWindow);
+});
+
+add_task(async function tab_manager_visibility_preference_off() {
+ Services.prefs.setBoolPref("browser.tabs.tabmanager.enabled", false);
+
+ let newWindow =
+ await BrowserTestUtils.openNewWindowWithFlushedCacheForMozSupports();
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser: newWindow.gBrowser,
+ url: TEST_HOSTNAME + DUMMY_PAGE_PATH,
+ },
+ async function (browser) {
+ await Assert.ok(
+ BrowserTestUtils.is_hidden(
+ newWindow.document.getElementById("alltabs-button")
+ ),
+ "tab manage menu is hidden when browser.tabs.tabmanager.enabled preference is set to true"
+ );
+ }
+ );
+ Services.prefs.clearUserPref("browser.tabs.tabmanager.enabled");
+ BrowserTestUtils.closeWindow(newWindow);
+});
diff --git a/browser/base/content/test/tabs/browser_tab_move_to_new_window_reload.js b/browser/base/content/test/tabs/browser_tab_move_to_new_window_reload.js
new file mode 100644
index 0000000000..6af8f440fd
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_tab_move_to_new_window_reload.js
@@ -0,0 +1,51 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const { TabStateFlusher } = ChromeUtils.importESModule(
+ "resource:///modules/sessionstore/TabStateFlusher.sys.mjs"
+);
+
+// Move a tab to a new window the reload it. In Bug 1691135 it would not
+// reload.
+add_task(async function test() {
+ let tab1 = await addTab();
+ let tab2 = await addTab();
+
+ is(gBrowser.multiSelectedTabsCount, 0, "Zero multiselected tabs");
+
+ await BrowserTestUtils.switchTab(gBrowser, tab1);
+ let prevBrowser = tab1.linkedBrowser;
+
+ let delayedStartupPromise = BrowserTestUtils.waitForNewWindow();
+ let newWindow = gBrowser.replaceTabsWithWindow(tab1);
+ await delayedStartupPromise;
+
+ ok(
+ !prevBrowser.frameLoader,
+ "the swapped-from browser's frameloader has been destroyed"
+ );
+
+ let gBrowser2 = newWindow.gBrowser;
+
+ is(gBrowser.visibleTabs.length, 2, "Two tabs now in the old window");
+ is(gBrowser2.visibleTabs.length, 1, "One tabs in the new window");
+
+ tab1 = gBrowser2.visibleTabs[0];
+ ok(tab1, "Got a tab1");
+ await tab1.focus();
+
+ await TabStateFlusher.flush(tab1.linkedBrowser);
+
+ info("Reloading");
+ let tab1Loaded = BrowserTestUtils.browserLoaded(
+ gBrowser2.getBrowserForTab(tab1)
+ );
+
+ gBrowser2.reload();
+ ok(await tab1Loaded, "Tab reloaded");
+
+ await BrowserTestUtils.closeWindow(newWindow);
+ await BrowserTestUtils.removeTab(tab2);
+});
diff --git a/browser/base/content/test/tabs/browser_tab_play.js b/browser/base/content/test/tabs/browser_tab_play.js
new file mode 100644
index 0000000000..f98956eeb4
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_tab_play.js
@@ -0,0 +1,216 @@
+/* Any copyright is dedicated to the Public Domain.
+ * https://creativecommons.org/publicdomain/zero/1.0/ */
+
+/*
+ * Ensure tabs that are active media blocked act correctly
+ * when we try to unblock them using the "Play Tab" icon or by calling
+ * resumeDelayedMedia()
+ */
+
+"use strict";
+
+const PREF_DELAY_AUTOPLAY = "media.block-autoplay-until-in-foreground";
+
+async function playMedia(tab, { expectBlocked }) {
+ let blockedPromise = wait_for_tab_media_blocked_event(tab, expectBlocked);
+ tab.resumeDelayedMedia();
+ await blockedPromise;
+ is(activeMediaBlocked(tab), expectBlocked, "tab has wrong media block state");
+}
+
+add_task(async function setPref() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[PREF_DELAY_AUTOPLAY, true]],
+ });
+});
+
+/*
+ * Playing blocked media will not mute the selected tabs
+ */
+add_task(async function testDelayPlayWontAffectUnmuteStatus() {
+ info("Add media tabs");
+ let tab0 = await addMediaTab();
+ let tab1 = await addMediaTab();
+
+ let tabs = [tab0, tab1];
+
+ info("Play both tabs");
+ await play(tab0, false);
+ await play(tab1, false);
+
+ // Check tabs are unmuted
+ ok(!muted(tab0), "Tab0 is unmuted");
+ ok(!muted(tab1), "Tab1 is unmuted");
+
+ info("Play media on tab0");
+ await playMedia(tab0, { expectBlocked: false });
+
+ // Check tabs are still unmuted
+ ok(!muted(tab0), "Tab0 is unmuted");
+ ok(!muted(tab1), "Tab1 is unmuted");
+
+ info("Play media on tab1");
+ await playMedia(tab1, { expectBlocked: false });
+
+ // Check tabs are still unmuted
+ ok(!muted(tab0), "Tab0 is unmuted");
+ ok(!muted(tab1), "Tab1 is unmuted");
+
+ for (let tab of tabs) {
+ BrowserTestUtils.removeTab(tab);
+ }
+});
+
+/*
+ * Playing blocked media will not unmute the selected tabs
+ */
+add_task(async function testDelayPlayWontAffectMuteStatus() {
+ info("Add media tabs");
+ let tab0 = await addMediaTab();
+ let tab1 = await addMediaTab();
+
+ info("Play both tabs");
+ await play(tab0, false);
+ await play(tab1, false);
+
+ // Mute both tabs
+ toggleMuteAudio(tab0, true);
+ toggleMuteAudio(tab1, true);
+
+ // Check tabs are muted
+ ok(muted(tab0), "Tab0 is muted");
+ ok(muted(tab1), "Tab1 is muted");
+
+ info("Play media on tab0");
+ await playMedia(tab0, { expectBlocked: false });
+
+ // Check tabs are still muted
+ ok(muted(tab0), "Tab0 is muted");
+ ok(muted(tab1), "Tab1 is muted");
+
+ info("Play media on tab1");
+ await playMedia(tab1, { expectBlocked: false });
+
+ // Check tabs are still muted
+ ok(muted(tab0), "Tab0 is muted");
+ ok(muted(tab1), "Tab1 is muted");
+
+ BrowserTestUtils.removeTab(tab0);
+ BrowserTestUtils.removeTab(tab1);
+});
+
+/*
+ * Switching tabs will unblock media
+ */
+add_task(async function testDelayPlayWhenSwitchingTab() {
+ info("Add media tabs");
+ let tab0 = await addMediaTab();
+ let tab1 = await addMediaTab();
+
+ info("Play both tabs");
+ await play(tab0, false);
+ await play(tab1, false);
+
+ // Both tabs are initially active media blocked after being played
+ ok(activeMediaBlocked(tab0), "Tab0 is activemedia-blocked");
+ ok(activeMediaBlocked(tab1), "Tab1 is activemedia-blocked");
+
+ info("Switch to tab0");
+ await BrowserTestUtils.switchTab(gBrowser, tab0);
+ is(gBrowser.selectedTab, tab0, "Tab0 is active");
+
+ // tab0 unblocked, tab1 blocked
+ ok(!activeMediaBlocked(tab0), "Tab0 is not activemedia-blocked");
+ ok(activeMediaBlocked(tab1), "Tab1 is activemedia-blocked");
+
+ info("Switch to tab1");
+ await BrowserTestUtils.switchTab(gBrowser, tab1);
+ is(gBrowser.selectedTab, tab1, "Tab1 is active");
+
+ // tab0 unblocked, tab1 unblocked
+ ok(!activeMediaBlocked(tab0), "Tab0 is not activemedia-blocked");
+ ok(!activeMediaBlocked(tab1), "Tab1 is not activemedia-blocked");
+
+ BrowserTestUtils.removeTab(tab0);
+ BrowserTestUtils.removeTab(tab1);
+});
+
+/*
+ * The "Play Tab" icon unblocks media
+ */
+add_task(async function testDelayPlayWhenUsingButton() {
+ info("Add media tabs");
+ let tab0 = await addMediaTab();
+ let tab1 = await addMediaTab();
+
+ info("Play both tabs");
+ await play(tab0, false);
+ await play(tab1, false);
+
+ // Both tabs are initially active media blocked after being played
+ ok(activeMediaBlocked(tab0), "Tab0 is activemedia-blocked");
+ ok(activeMediaBlocked(tab1), "Tab1 is activemedia-blocked");
+
+ info("Press the Play Tab icon on tab0");
+ await pressIcon(tab0.overlayIcon);
+
+ // tab0 unblocked, tab1 blocked
+ ok(!activeMediaBlocked(tab0), "Tab0 is not activemedia-blocked");
+ ok(activeMediaBlocked(tab1), "Tab1 is activemedia-blocked");
+
+ info("Press the Play Tab icon on tab1");
+ await pressIcon(tab1.overlayIcon);
+
+ // tab0 unblocked, tab1 unblocked
+ ok(!activeMediaBlocked(tab0), "Tab0 is not activemedia-blocked");
+ ok(!activeMediaBlocked(tab1), "Tab1 is not activemedia-blocked");
+
+ BrowserTestUtils.removeTab(tab0);
+ BrowserTestUtils.removeTab(tab1);
+});
+
+/*
+ * Tab context menus have to show the menu icons "Play Tab" or "Play Tabs"
+ * depending on the number of tabs selected, and whether blocked media is present
+ */
+add_task(async function testTabContextMenu() {
+ info("Add media tab");
+ let tab0 = await addMediaTab();
+
+ let menuItemPlayTab = document.getElementById("context_playTab");
+ let menuItemPlaySelectedTabs = document.getElementById(
+ "context_playSelectedTabs"
+ );
+
+ // No active media yet:
+ // - "Play Tab" is hidden
+ // - "Play Tabs" is hidden
+ updateTabContextMenu(tab0);
+ ok(menuItemPlayTab.hidden, 'tab0 "Play Tab" is hidden');
+ ok(menuItemPlaySelectedTabs.hidden, 'tab0 "Play Tabs" is hidden');
+ ok(!activeMediaBlocked(tab0), "tab0 is not active media blocked");
+
+ info("Play tab0");
+ await play(tab0, false);
+
+ // Active media blocked:
+ // - "Play Tab" is visible
+ // - "Play Tabs" is hidden
+ updateTabContextMenu(tab0);
+ ok(!menuItemPlayTab.hidden, 'tab0 "Play Tab" is visible');
+ ok(menuItemPlaySelectedTabs.hidden, 'tab0 "Play Tabs" is hidden');
+ ok(activeMediaBlocked(tab0), "tab0 is active media blocked");
+
+ info("Play media on tab0");
+ await playMedia(tab0, { expectBlocked: false });
+
+ // Media is playing:
+ // - "Play Tab" is hidden
+ // - "Play Tabs" is hidden
+ updateTabContextMenu(tab0);
+ ok(menuItemPlayTab.hidden, 'tab0 "Play Tab" is hidden');
+ ok(menuItemPlaySelectedTabs.hidden, 'tab0 "Play Tabs" is hidden');
+ ok(!activeMediaBlocked(tab0), "tab0 is not active media blocked");
+
+ BrowserTestUtils.removeTab(tab0);
+});
diff --git a/browser/base/content/test/tabs/browser_tab_tooltips.js b/browser/base/content/test/tabs/browser_tab_tooltips.js
new file mode 100644
index 0000000000..0fb70c5a07
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_tab_tooltips.js
@@ -0,0 +1,108 @@
+// Offset within the tab for the mouse event
+const MOUSE_OFFSET = 7;
+
+// Normal tooltips are positioned vertically at least this amount
+const MIN_VERTICAL_TOOLTIP_OFFSET = 18;
+
+function openTooltip(node, tooltip) {
+ let tooltipShownPromise = BrowserTestUtils.waitForEvent(
+ tooltip,
+ "popupshown"
+ );
+ window.windowUtils.disableNonTestMouseEvents(true);
+ EventUtils.synthesizeMouse(node, 2, 2, { type: "mouseover" });
+ EventUtils.synthesizeMouse(node, 4, 4, { type: "mousemove" });
+ EventUtils.synthesizeMouse(node, MOUSE_OFFSET, MOUSE_OFFSET, {
+ type: "mousemove",
+ });
+ EventUtils.synthesizeMouse(node, 2, 2, { type: "mouseout" });
+ window.windowUtils.disableNonTestMouseEvents(false);
+ return tooltipShownPromise;
+}
+
+function closeTooltip(node, tooltip) {
+ let tooltipHiddenPromise = BrowserTestUtils.waitForEvent(
+ tooltip,
+ "popuphidden"
+ );
+ EventUtils.synthesizeMouse(document.documentElement, 2, 2, {
+ type: "mousemove",
+ });
+ return tooltipHiddenPromise;
+}
+
+// This test verifies that the tab tooltip appears at the correct location, aligned
+// with the bottom of the tab, and that the tooltip appears near the close button.
+add_task(async function () {
+ const tabUrl =
+ "data:text/html,<html><head><title>A Tab</title></head><body>Hello</body></html>";
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, tabUrl);
+
+ let tooltip = document.getElementById("tabbrowser-tab-tooltip");
+ await openTooltip(tab, tooltip);
+
+ let tabRect = tab.getBoundingClientRect();
+ let tooltipRect = tooltip.getBoundingClientRect();
+
+ isfuzzy(
+ tooltipRect.left,
+ tabRect.left + MOUSE_OFFSET,
+ 1,
+ "tooltip left position for tab"
+ );
+ ok(
+ tooltipRect.top >= tabRect.top + MIN_VERTICAL_TOOLTIP_OFFSET + MOUSE_OFFSET,
+ "tooltip top position for tab"
+ );
+ is(
+ tooltip.getAttribute("position"),
+ "",
+ "tooltip position attribute for tab"
+ );
+
+ await closeTooltip(tab, tooltip);
+
+ await openTooltip(tab.closeButton, tooltip);
+
+ let closeButtonRect = tab.closeButton.getBoundingClientRect();
+ tooltipRect = tooltip.getBoundingClientRect();
+
+ isfuzzy(
+ tooltipRect.left,
+ closeButtonRect.left + MOUSE_OFFSET,
+ 1,
+ "tooltip left position for close button"
+ );
+ ok(
+ tooltipRect.top >
+ closeButtonRect.top + MIN_VERTICAL_TOOLTIP_OFFSET + MOUSE_OFFSET,
+ "tooltip top position for close button"
+ );
+ ok(
+ !tooltip.hasAttribute("position"),
+ "tooltip position attribute for close button"
+ );
+
+ await closeTooltip(tab, tooltip);
+
+ BrowserTestUtils.removeTab(tab);
+});
+
+// This test verifies that a mouse wheel closes the tooltip.
+add_task(async function () {
+ const tabUrl =
+ "data:text/html,<html><head><title>A Tab</title></head><body>Hello</body></html>";
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, tabUrl);
+
+ let tooltip = document.getElementById("tabbrowser-tab-tooltip");
+ await openTooltip(tab, tooltip);
+
+ EventUtils.synthesizeWheel(tab, 4, 4, {
+ deltaMode: WheelEvent.DOM_DELTA_LINE,
+ deltaY: 1.0,
+ });
+
+ is(tooltip.state, "closed", "wheel event closed the tooltip");
+
+ BrowserTestUtils.removeTab(tab);
+});
diff --git a/browser/base/content/test/tabs/browser_tabswitch_contextmenu.js b/browser/base/content/test/tabs/browser_tabswitch_contextmenu.js
new file mode 100644
index 0000000000..bd671a86c6
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_tabswitch_contextmenu.js
@@ -0,0 +1,45 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Don't switch tabs via the keyboard while the contextmenu is open.
+ */
+add_task(async function cant_tabswitch_mid_contextmenu() {
+ let tab1 = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "https://example.com/idontexist"
+ );
+ let tab2 = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "https://example.org/idontexist"
+ );
+
+ const contextMenu = document.getElementById("contentAreaContextMenu");
+ let promisePopupShown = BrowserTestUtils.waitForPopupEvent(
+ contextMenu,
+ "shown"
+ );
+ await BrowserTestUtils.synthesizeMouse(
+ "body",
+ 0,
+ 0,
+ {
+ type: "contextmenu",
+ button: 2,
+ },
+ tab2.linkedBrowser
+ );
+ await promisePopupShown;
+ EventUtils.synthesizeKey("VK_TAB", { accelKey: true });
+ ok(tab2.selected, "tab2 should stay selected");
+ BrowserTestUtils.removeTab(tab1);
+ BrowserTestUtils.removeTab(tab2);
+ let promisePopupHidden = BrowserTestUtils.waitForPopupEvent(
+ contextMenu,
+ "hidden"
+ );
+ contextMenu.hidePopup();
+ await promisePopupHidden;
+});
diff --git a/browser/base/content/test/tabs/browser_tabswitch_select.js b/browser/base/content/test/tabs/browser_tabswitch_select.js
new file mode 100644
index 0000000000..3868764bed
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_tabswitch_select.js
@@ -0,0 +1,63 @@
+/* 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 () {
+ let tab1 = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:support"
+ );
+
+ let tab2 = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "data:text/html,Goodbye"
+ );
+
+ gURLBar.select();
+
+ await BrowserTestUtils.switchTab(gBrowser, tab1);
+
+ let focusPromise = BrowserTestUtils.waitForEvent(
+ gURLBar.inputField,
+ "select",
+ true
+ );
+ await BrowserTestUtils.switchTab(gBrowser, tab2);
+ await focusPromise;
+
+ is(gURLBar.selectionStart, 0, "url is selected");
+ is(gURLBar.selectionEnd, 22, "url is selected");
+
+ // Now check that the url bar is focused when a new tab is opened while in fullscreen.
+
+ let fullScreenEntered = TestUtils.waitForCondition(
+ () => document.documentElement.getAttribute("sizemode") == "fullscreen"
+ );
+ BrowserFullScreen();
+ await fullScreenEntered;
+
+ tab2.linkedBrowser.focus();
+
+ // Open a new tab
+ focusPromise = BrowserTestUtils.waitForEvent(
+ gURLBar.inputField,
+ "focus",
+ true
+ );
+ EventUtils.synthesizeKey("T", { accelKey: true });
+ await focusPromise;
+
+ is(document.activeElement, gURLBar.inputField, "urlbar is focused");
+
+ let fullScreenExited = TestUtils.waitForCondition(
+ () => document.documentElement.getAttribute("sizemode") != "fullscreen"
+ );
+ BrowserFullScreen();
+ await fullScreenExited;
+
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ BrowserTestUtils.removeTab(tab1);
+ BrowserTestUtils.removeTab(tab2);
+});
diff --git a/browser/base/content/test/tabs/browser_tabswitch_updatecommands.js b/browser/base/content/test/tabs/browser_tabswitch_updatecommands.js
new file mode 100644
index 0000000000..b5d2762eec
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_tabswitch_updatecommands.js
@@ -0,0 +1,28 @@
+// This test ensures that only one command update happens when switching tabs.
+
+"use strict";
+
+add_task(async function () {
+ const uri = "data:text/html,<body><input>";
+ let tab1 = await BrowserTestUtils.openNewForegroundTab(gBrowser, uri);
+ let tab2 = await BrowserTestUtils.openNewForegroundTab(gBrowser, uri);
+
+ let updates = [];
+ function countUpdates(event) {
+ updates.push(new Error().stack);
+ }
+ let updater = document.getElementById("editMenuCommandSetAll");
+ updater.addEventListener("commandupdate", countUpdates, true);
+ await BrowserTestUtils.switchTab(gBrowser, tab1);
+
+ is(updates.length, 1, "only one command update per tab switch");
+ if (updates.length > 1) {
+ for (let stack of updates) {
+ info("Update stack:\n" + stack);
+ }
+ }
+
+ updater.removeEventListener("commandupdate", countUpdates, true);
+ BrowserTestUtils.removeTab(tab1);
+ BrowserTestUtils.removeTab(tab2);
+});
diff --git a/browser/base/content/test/tabs/browser_tabswitch_window_focus.js b/browser/base/content/test/tabs/browser_tabswitch_window_focus.js
new file mode 100644
index 0000000000..a808ab4f09
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_tabswitch_window_focus.js
@@ -0,0 +1,78 @@
+"use strict";
+
+// Allow to open popups without any kind of interaction.
+SpecialPowers.pushPrefEnv({ set: [["dom.disable_window_flip", false]] });
+
+const FILE = getRootDirectory(gTestPath) + "open_window_in_new_tab.html";
+
+add_task(async function () {
+ info("Opening first tab: " + FILE);
+ let firstTab = await BrowserTestUtils.openNewForegroundTab(gBrowser, FILE);
+
+ let promiseTabOpened = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ FILE + "?open-click",
+ true
+ );
+ info("Opening second tab using a click");
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#open-click",
+ {},
+ firstTab.linkedBrowser
+ );
+
+ info("Waiting for the second tab to be opened");
+ let secondTab = await promiseTabOpened;
+
+ info("Going back to the first tab");
+ await BrowserTestUtils.switchTab(gBrowser, firstTab);
+
+ info("Focusing second tab by clicking on the first tab");
+ await BrowserTestUtils.switchTab(gBrowser, async function () {
+ await SpecialPowers.spawn(firstTab.linkedBrowser, [""], async function () {
+ content.document.querySelector("#focus").click();
+ });
+ });
+
+ is(gBrowser.selectedTab, secondTab, "Should've switched tabs");
+
+ await BrowserTestUtils.removeTab(firstTab);
+ await BrowserTestUtils.removeTab(secondTab);
+});
+
+add_task(async function () {
+ info("Opening first tab: " + FILE);
+ let firstTab = await BrowserTestUtils.openNewForegroundTab(gBrowser, FILE);
+
+ let promiseTabOpened = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ FILE + "?open-mousedown",
+ true
+ );
+ info("Opening second tab using a click");
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "#open-mousedown",
+ { type: "mousedown" },
+ firstTab.linkedBrowser
+ );
+
+ info("Waiting for the second tab to be opened");
+ let secondTab = await promiseTabOpened;
+
+ is(gBrowser.selectedTab, secondTab, "Should've switched tabs");
+
+ info("Ensuring we don't switch back");
+ await new Promise(resolve => {
+ // We need to wait for something _not_ happening, so we need to use an arbitrary setTimeout.
+ //
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ setTimeout(function () {
+ is(gBrowser.selectedTab, secondTab, "Should've remained in original tab");
+ resolve();
+ }, 500);
+ });
+
+ info("cleanup");
+ await BrowserTestUtils.removeTab(firstTab);
+ await BrowserTestUtils.removeTab(secondTab);
+});
diff --git a/browser/base/content/test/tabs/browser_undo_close_tabs.js b/browser/base/content/test/tabs/browser_undo_close_tabs.js
new file mode 100644
index 0000000000..e4e9da5d5d
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_undo_close_tabs.js
@@ -0,0 +1,171 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const PREF_WARN_ON_CLOSE = "browser.tabs.warnOnCloseOtherTabs";
+
+add_task(async function setPref() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[PREF_WARN_ON_CLOSE, false]],
+ });
+});
+
+add_task(async function withMultiSelectedTabs() {
+ let initialTab = gBrowser.selectedTab;
+ let tab1 = await addTab("https://example.com/1");
+ let tab2 = await addTab("https://example.com/2");
+ let tab3 = await addTab("https://example.com/3");
+ let tab4 = await addTab("https://example.com/4");
+
+ is(gBrowser.multiSelectedTabsCount, 0, "Zero multiselected tabs");
+
+ gBrowser.selectedTab = tab2;
+ await triggerClickOn(tab4, { shiftKey: true });
+
+ ok(!initialTab.multiselected, "InitialTab is not multiselected");
+ ok(!tab1.multiselected, "Tab1 is not multiselected");
+ ok(tab2.multiselected, "Tab2 is multiselected");
+ ok(tab3.multiselected, "Tab3 is multiselected");
+ ok(tab4.multiselected, "Tab4 is multiselected");
+ is(gBrowser.multiSelectedTabsCount, 3, "Three multiselected tabs");
+
+ gBrowser.removeMultiSelectedTabs();
+ await TestUtils.waitForCondition(
+ () => SessionStore.getLastClosedTabCount(window) == 3,
+ "wait for the multi selected tabs to close in SessionStore"
+ );
+ is(
+ SessionStore.getLastClosedTabCount(window),
+ 3,
+ "SessionStore should know how many tabs were just closed"
+ );
+
+ undoCloseTab();
+ await TestUtils.waitForCondition(
+ () => gBrowser.tabs.length == 5,
+ "wait for the tabs to reopen"
+ );
+
+ is(
+ SessionStore.getLastClosedTabCount(window),
+ SessionStore.getClosedTabCountForWindow(window) ? 1 : 0,
+ "LastClosedTabCount should be reset"
+ );
+
+ info("waiting for the browsers to finish loading");
+ // Check that the tabs are restored in the correct order
+ for (let tabId of [2, 3, 4]) {
+ let browser = gBrowser.tabs[tabId].linkedBrowser;
+ await ContentTask.spawn(browser, tabId, async aTabId => {
+ await ContentTaskUtils.waitForCondition(() => {
+ return (
+ content?.document?.readyState == "complete" &&
+ content?.document?.location.href == "https://example.com/" + aTabId
+ );
+ }, "waiting for tab " + aTabId + " to load");
+ });
+ }
+
+ gBrowser.removeAllTabsBut(initialTab);
+});
+
+add_task(async function withBothGroupsAndTab() {
+ let initialTab = gBrowser.selectedTab;
+ let tab1 = await addTab("https://example.com/1");
+ let tab2 = await addTab("https://example.com/2");
+ let tab3 = await addTab("https://example.com/3");
+
+ gBrowser.selectedTab = tab2;
+ await triggerClickOn(tab3, { shiftKey: true });
+
+ ok(!initialTab.multiselected, "InitialTab is not multiselected");
+ ok(!tab1.multiselected, "Tab1 is not multiselected");
+ ok(tab2.multiselected, "Tab2 is multiselected");
+ ok(tab3.multiselected, "Tab3 is multiselected");
+ is(gBrowser.multiSelectedTabsCount, 2, "Two multiselected tabs");
+
+ gBrowser.removeMultiSelectedTabs();
+ await TestUtils.waitForCondition(
+ () => gBrowser.tabs.length == 2,
+ "wait for the multiselected tabs to close"
+ );
+
+ is(
+ SessionStore.getLastClosedTabCount(window),
+ 2,
+ "SessionStore should know how many tabs were just closed"
+ );
+
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ let tab4 = await addTab("http://example.com/4");
+
+ is(
+ SessionStore.getLastClosedTabCount(window),
+ 2,
+ "LastClosedTabCount should be the same"
+ );
+
+ gBrowser.removeTab(tab4);
+
+ await TestUtils.waitForCondition(
+ () => SessionStore.getLastClosedTabCount(window) == 1,
+ "wait for the tab to close in SessionStore"
+ );
+
+ let count = 3;
+ for (let i = 0; i < 3; i++) {
+ is(
+ SessionStore.getLastClosedTabCount(window),
+ 1,
+ "LastClosedTabCount should be one"
+ );
+ undoCloseTab();
+
+ await TestUtils.waitForCondition(
+ () => gBrowser.tabs.length == count,
+ "wait for the tabs to reopen"
+ );
+ count++;
+ }
+
+ gBrowser.removeAllTabsBut(initialTab);
+});
+
+add_task(async function withCloseTabsToTheRight() {
+ let initialTab = gBrowser.selectedTab;
+ let tab1 = await addTab("https://example.com/1");
+ await addTab("https://example.com/2");
+ await addTab("https://example.com/3");
+ await addTab("https://example.com/4");
+
+ gBrowser.removeTabsToTheEndFrom(tab1);
+ await TestUtils.waitForCondition(
+ () => gBrowser.tabs.length == 2,
+ "wait for the multiselected tabs to close"
+ );
+ is(
+ SessionStore.getLastClosedTabCount(window),
+ 3,
+ "SessionStore should know how many tabs were just closed"
+ );
+
+ undoCloseTab();
+ await TestUtils.waitForCondition(
+ () => gBrowser.tabs.length == 5,
+ "wait for the tabs to reopen"
+ );
+ info("waiting for the browsers to finish loading");
+ // Check that the tabs are restored in the correct order
+ for (let tabId of [2, 3, 4]) {
+ let browser = gBrowser.tabs[tabId].linkedBrowser;
+ ContentTask.spawn(browser, tabId, async aTabId => {
+ await ContentTaskUtils.waitForCondition(() => {
+ return (
+ content?.document?.readyState == "complete" &&
+ content?.document?.location.href == "https://example.com/" + aTabId
+ );
+ }, "waiting for tab " + aTabId + " to load");
+ });
+ }
+
+ gBrowser.removeAllTabsBut(initialTab);
+});
diff --git a/browser/base/content/test/tabs/browser_undo_close_tabs_at_start.js b/browser/base/content/test/tabs/browser_undo_close_tabs_at_start.js
new file mode 100644
index 0000000000..9ad79ea1c8
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_undo_close_tabs_at_start.js
@@ -0,0 +1,74 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const PREF_WARN_ON_CLOSE = "browser.tabs.warnOnCloseOtherTabs";
+
+add_task(async function setPref() {
+ await SpecialPowers.pushPrefEnv({
+ set: [[PREF_WARN_ON_CLOSE, false]],
+ });
+});
+
+add_task(async function replaceEmptyTabs() {
+ const win = await BrowserTestUtils.openNewBrowserWindow();
+ const tabbrowser = win.gBrowser;
+ ok(
+ tabbrowser.tabs.length == 1 && tabbrowser.tabs[0].isEmpty,
+ "One blank tab should be opened."
+ );
+
+ let blankTab = tabbrowser.tabs[0];
+ await BrowserTestUtils.openNewForegroundTab(
+ tabbrowser,
+ "https://example.com/1"
+ );
+ await BrowserTestUtils.openNewForegroundTab(
+ tabbrowser,
+ "https://example.com/2"
+ );
+ await BrowserTestUtils.openNewForegroundTab(
+ tabbrowser,
+ "https://example.com/3"
+ );
+
+ is(tabbrowser.tabs.length, 4, "There should be 4 tabs opened.");
+
+ tabbrowser.removeAllTabsBut(blankTab);
+
+ await TestUtils.waitForCondition(
+ () =>
+ SessionStore.getLastClosedTabCount(win) == 3 &&
+ tabbrowser.tabs.length == 1,
+ "wait for the tabs to close in SessionStore"
+ );
+ is(
+ SessionStore.getLastClosedTabCount(win),
+ 3,
+ "SessionStore should know how many tabs were just closed"
+ );
+
+ is(tabbrowser.selectedTab, blankTab, "The blank tab should be selected.");
+
+ win.undoCloseTab();
+
+ await TestUtils.waitForCondition(
+ () => tabbrowser.tabs.length == 3,
+ "wait for the tabs to reopen"
+ );
+
+ is(
+ SessionStore.getLastClosedTabCount(win),
+ SessionStore.getClosedTabCountForWindow(win) ? 1 : 0,
+ "LastClosedTabCount should be reset"
+ );
+
+ ok(
+ !tabbrowser.tabs.includes(blankTab),
+ "The blank tab should have been replaced."
+ );
+
+ // We can't (at the time of writing) check tab order.
+
+ // Cleanup
+ await BrowserTestUtils.closeWindow(win);
+});
diff --git a/browser/base/content/test/tabs/browser_viewsource_of_data_URI_in_file_process.js b/browser/base/content/test/tabs/browser_viewsource_of_data_URI_in_file_process.js
new file mode 100644
index 0000000000..aca9166afe
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_viewsource_of_data_URI_in_file_process.js
@@ -0,0 +1,53 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set ft=javascript ts=2 et sw=2 tw=80: */
+
+const DUMMY_FILE = "dummy_page.html";
+const DATA_URI = "data:text/html,Hi";
+const DATA_URI_SOURCE = "view-source:" + DATA_URI;
+
+// Test for bug 1345807.
+add_task(async function () {
+ // Open file:// page.
+ let dir = getChromeDir(getResolvedURI(gTestPath));
+ dir.append(DUMMY_FILE);
+ const uriString = Services.io.newFileURI(dir).spec;
+
+ await BrowserTestUtils.withNewTab(uriString, async function (fileBrowser) {
+ let filePid = await SpecialPowers.spawn(fileBrowser, [], () => {
+ return Services.appinfo.processID;
+ });
+
+ // Navigate to data URI.
+ let promiseLoad = BrowserTestUtils.browserLoaded(
+ fileBrowser,
+ false,
+ DATA_URI
+ );
+ BrowserTestUtils.loadURIString(fileBrowser, DATA_URI);
+ let href = await promiseLoad;
+ is(href, DATA_URI, "Check data URI loaded.");
+ let dataPid = await SpecialPowers.spawn(fileBrowser, [], () => {
+ return Services.appinfo.processID;
+ });
+ is(dataPid, filePid, "Check that data URI loaded in file content process.");
+
+ // Make sure we can view-source on the data URI page.
+ let promiseTab = BrowserTestUtils.waitForNewTab(gBrowser, DATA_URI_SOURCE);
+ BrowserViewSource(fileBrowser);
+ let viewSourceTab = await promiseTab;
+ registerCleanupFunction(async function () {
+ BrowserTestUtils.removeTab(viewSourceTab);
+ });
+ await SpecialPowers.spawn(
+ viewSourceTab.linkedBrowser,
+ [DATA_URI_SOURCE],
+ uri => {
+ is(
+ content.document.documentURI,
+ uri,
+ "Check that a view-source page was loaded."
+ );
+ }
+ );
+ });
+});
diff --git a/browser/base/content/test/tabs/browser_visibleTabs_bookmarkAllTabs.js b/browser/base/content/test/tabs/browser_visibleTabs_bookmarkAllTabs.js
new file mode 100644
index 0000000000..d3b439ce8f
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_visibleTabs_bookmarkAllTabs.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/. */
+
+function test() {
+ waitForExplicitFinish();
+
+ // There should be one tab when we start the test
+ let [origTab] = gBrowser.visibleTabs;
+ is(gBrowser.visibleTabs.length, 1, "1 tab should be open");
+
+ // Add a tab
+ let testTab1 = BrowserTestUtils.addTab(gBrowser);
+ is(gBrowser.visibleTabs.length, 2, "2 tabs should be open");
+
+ let testTab2 = BrowserTestUtils.addTab(gBrowser, "about:mozilla");
+ is(gBrowser.visibleTabs.length, 3, "3 tabs should be open");
+ // Wait for tab load, the code checks for currentURI.
+ testTab2.linkedBrowser.addEventListener(
+ "load",
+ function () {
+ // Hide the original tab
+ gBrowser.selectedTab = testTab2;
+ gBrowser.showOnlyTheseTabs([testTab2]);
+ is(gBrowser.visibleTabs.length, 1, "1 tab should be visible");
+
+ // Add a tab that will get pinned
+ let pinned = BrowserTestUtils.addTab(gBrowser);
+ is(gBrowser.visibleTabs.length, 2, "2 tabs should be visible now");
+ gBrowser.pinTab(pinned);
+ is(
+ BookmarkTabHidden(),
+ false,
+ "Bookmark Tab should be visible on a normal tab"
+ );
+ gBrowser.selectedTab = pinned;
+ is(
+ BookmarkTabHidden(),
+ false,
+ "Bookmark Tab should be visible on a pinned tab"
+ );
+
+ // Show all tabs
+ let allTabs = Array.from(gBrowser.tabs);
+ gBrowser.showOnlyTheseTabs(allTabs);
+
+ // reset the environment
+ gBrowser.removeTab(testTab2);
+ gBrowser.removeTab(testTab1);
+ gBrowser.removeTab(pinned);
+ is(gBrowser.visibleTabs.length, 1, "only orig is left and visible");
+ is(gBrowser.tabs.length, 1, "sanity check that it matches");
+ is(gBrowser.selectedTab, origTab, "got the orig tab");
+ is(origTab.hidden, false, "and it's not hidden -- visible!");
+ finish();
+ },
+ { capture: true, once: true }
+ );
+}
+
+function BookmarkTabHidden() {
+ updateTabContextMenu();
+ return document.getElementById("context_bookmarkTab").hidden;
+}
diff --git a/browser/base/content/test/tabs/browser_visibleTabs_contextMenu.js b/browser/base/content/test/tabs/browser_visibleTabs_contextMenu.js
new file mode 100644
index 0000000000..202c43ce47
--- /dev/null
+++ b/browser/base/content/test/tabs/browser_visibleTabs_contextMenu.js
@@ -0,0 +1,115 @@
+/* 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/. */
+
+const remoteClientsFixture = [
+ { id: 1, name: "Foo" },
+ { id: 2, name: "Bar" },
+];
+
+add_task(async function test() {
+ // There should be one tab when we start the test
+ let [origTab] = gBrowser.visibleTabs;
+ is(gBrowser.visibleTabs.length, 1, "there is one visible tab");
+ let testTab = BrowserTestUtils.addTab(gBrowser);
+ is(gBrowser.visibleTabs.length, 2, "there are now two visible tabs");
+
+ // Check the context menu with two tabs
+ updateTabContextMenu(origTab);
+ is(
+ document.getElementById("context_closeTab").disabled,
+ false,
+ "Close Tab is enabled"
+ );
+
+ // Hide the original tab.
+ gBrowser.selectedTab = testTab;
+ gBrowser.showOnlyTheseTabs([testTab]);
+ is(gBrowser.visibleTabs.length, 1, "now there is only one visible tab");
+
+ // Check the context menu with one tab.
+ updateTabContextMenu(testTab);
+ is(
+ document.getElementById("context_closeTab").disabled,
+ false,
+ "Close Tab is enabled when more than one tab exists"
+ );
+
+ // Add a tab that will get pinned
+ // So now there's one pinned tab, one visible unpinned tab, and one hidden tab
+ let pinned = BrowserTestUtils.addTab(gBrowser);
+ gBrowser.pinTab(pinned);
+ is(gBrowser.visibleTabs.length, 2, "now there are two visible tabs");
+
+ // Check the context menu on the pinned tab
+ updateTabContextMenu(pinned);
+ ok(
+ !document.getElementById("context_closeTabOptions").disabled,
+ "Close Multiple Tabs is enabled on pinned tab"
+ );
+ ok(
+ !document.getElementById("context_closeOtherTabs").disabled,
+ "Close Other Tabs is enabled on pinned tab"
+ );
+ ok(
+ document.getElementById("context_closeTabsToTheStart").disabled,
+ "Close Tabs To The Start is disabled on pinned tab"
+ );
+ ok(
+ !document.getElementById("context_closeTabsToTheEnd").disabled,
+ "Close Tabs To The End is enabled on pinned tab"
+ );
+
+ // Check the context menu on the unpinned visible tab
+ updateTabContextMenu(testTab);
+ ok(
+ document.getElementById("context_closeTabOptions").disabled,
+ "Close Multiple Tabs is disabled on single unpinned tab"
+ );
+ ok(
+ document.getElementById("context_closeOtherTabs").disabled,
+ "Close Other Tabs is disabled on single unpinned tab"
+ );
+ ok(
+ document.getElementById("context_closeTabsToTheStart").disabled,
+ "Close Tabs To The Start is disabled on single unpinned tab"
+ );
+ ok(
+ document.getElementById("context_closeTabsToTheEnd").disabled,
+ "Close Tabs To The End is disabled on single unpinned tab"
+ );
+
+ // Show all tabs
+ let allTabs = Array.from(gBrowser.tabs);
+ gBrowser.showOnlyTheseTabs(allTabs);
+
+ // Check the context menu now
+ updateTabContextMenu(testTab);
+ ok(
+ !document.getElementById("context_closeTabOptions").disabled,
+ "Close Multiple Tabs is enabled on unpinned tab when there's another unpinned tab"
+ );
+ ok(
+ !document.getElementById("context_closeOtherTabs").disabled,
+ "Close Other Tabs is enabled on unpinned tab when there's another unpinned tab"
+ );
+ ok(
+ !document.getElementById("context_closeTabsToTheStart").disabled,
+ "Close Tabs To The Start is enabled on last unpinned tab when there's another unpinned tab"
+ );
+ ok(
+ document.getElementById("context_closeTabsToTheEnd").disabled,
+ "Close Tabs To The End is disabled on last unpinned tab"
+ );
+
+ // Check the context menu of the original tab
+ // Close Tabs To The End should now be enabled
+ updateTabContextMenu(origTab);
+ ok(
+ !document.getElementById("context_closeTabsToTheEnd").disabled,
+ "Close Tabs To The End is enabled on unpinned tab when followed by another"
+ );
+
+ gBrowser.removeTab(testTab);
+ gBrowser.removeTab(pinned);
+});
diff --git a/browser/base/content/test/tabs/common_link_in_tab_title_and_url_prefilled.js b/browser/base/content/test/tabs/common_link_in_tab_title_and_url_prefilled.js
new file mode 100644
index 0000000000..dfba084995
--- /dev/null
+++ b/browser/base/content/test/tabs/common_link_in_tab_title_and_url_prefilled.js
@@ -0,0 +1,255 @@
+/* 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/. */
+
+const { sinon } = ChromeUtils.importESModule(
+ "resource://testing-common/Sinon.sys.mjs"
+);
+
+const TEST_ROOT = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content",
+ "https://example.com"
+);
+const HOME_URL = `${TEST_ROOT}link_in_tab_title_and_url_prefilled.html`;
+const HOME_TITLE = HOME_URL.substring("https://".length);
+const WAIT_A_BIT_URL = `${TEST_ROOT}wait-a-bit.sjs`;
+const WAIT_A_BIT_LOADING_TITLE = WAIT_A_BIT_URL.substring("https://".length);
+const WAIT_A_BIT_PAGE_TITLE = "wait a bit";
+const REQUEST_TIMEOUT_URL = `${TEST_ROOT}request-timeout.sjs`;
+const REQUEST_TIMEOUT_LOADING_TITLE = REQUEST_TIMEOUT_URL.substring(
+ "https://".length
+);
+const BLANK_URL = "about:blank";
+const BLANK_TITLE = "New Tab";
+
+const OPEN_BY = {
+ CLICK: "click",
+ CONTEXT_MENU: "context_menu",
+};
+
+const OPEN_AS = {
+ FOREGROUND: "foreground",
+ BACKGROUND: "background",
+};
+
+async function doTestInSameWindow({
+ link,
+ openBy,
+ openAs,
+ loadingState,
+ actionWhileLoading,
+ finalState,
+}) {
+ await BrowserTestUtils.withNewTab("about:blank", async browser => {
+ // NOTE: The behavior after the click <a href="about:blank">link</a>
+ // (no target) is different when the URL is opened directly with
+ // BrowserTestUtils.withNewTab() and when it is loaded later.
+ // Specifically, if we load `about:blank`, expect to see `New Tab` as the
+ // title of the tab, but the former will continue to display the URL that
+ // was previously displayed. Therefore, use the latter way.
+ BrowserTestUtils.loadURIString(browser, HOME_URL);
+ await BrowserTestUtils.browserLoaded(
+ gBrowser.selectedBrowser,
+ false,
+ HOME_URL
+ );
+
+ info(`Open link for ${link} by ${openBy} as ${openAs}`);
+ const onNewTabCreated = waitForNewTabWithLoadRequest();
+ const href = await openLink(browser, link, openBy, openAs);
+
+ info("Wait until starting to load in the target tab");
+ const target = await onNewTabCreated;
+ Assert.equal(target.selected, openAs === OPEN_AS.FOREGROUND);
+ Assert.equal(gURLBar.value, loadingState.urlbar);
+ Assert.equal(target.textLabel.textContent, loadingState.tab);
+
+ await actionWhileLoading(
+ BrowserTestUtils.browserLoaded(target.linkedBrowser, false, href)
+ );
+
+ info("Check the final result");
+ Assert.equal(gURLBar.value, finalState.urlbar);
+ Assert.equal(target.textLabel.textContent, finalState.tab);
+ const sessionHistory = await new Promise(r =>
+ SessionStore.getSessionHistory(target, r)
+ );
+ Assert.deepEqual(
+ sessionHistory.entries.map(e => e.url),
+ finalState.history
+ );
+
+ BrowserTestUtils.removeTab(target);
+ });
+}
+
+async function doTestWithNewWindow({ link, expectedSetURICalled }) {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.link.open_newwindow", 2]],
+ });
+
+ await BrowserTestUtils.withNewTab(HOME_URL, async browser => {
+ const onNewWindowOpened = BrowserTestUtils.domWindowOpenedAndLoaded();
+
+ info(`Open link for ${link}`);
+ const href = await openLink(
+ browser,
+ link,
+ OPEN_BY.CLICK,
+ OPEN_AS.FOREGROUND
+ );
+
+ info("Wait until opening a new window");
+ const win = await onNewWindowOpened;
+
+ info("Check whether gURLBar.setURI is called while loading the page");
+ const sandbox = sinon.createSandbox();
+ registerCleanupFunction(() => {
+ sandbox.restore();
+ });
+ let isSetURIWhileLoading = false;
+ sandbox.stub(win.gURLBar, "setURI").callsFake(uri => {
+ if (
+ !uri &&
+ win.gBrowser.selectedBrowser.browsingContext.nonWebControlledBlankURI
+ ) {
+ isSetURIWhileLoading = true;
+ }
+ });
+ await BrowserTestUtils.browserLoaded(
+ win.gBrowser.selectedBrowser,
+ false,
+ href
+ );
+ sandbox.restore();
+
+ Assert.equal(isSetURIWhileLoading, expectedSetURICalled);
+ Assert.equal(
+ !!win.gBrowser.selectedBrowser.browsingContext.nonWebControlledBlankURI,
+ expectedSetURICalled
+ );
+
+ await BrowserTestUtils.closeWindow(win);
+ });
+
+ await SpecialPowers.popPrefEnv();
+}
+
+async function doSessionRestoreTest({
+ link,
+ openBy,
+ openAs,
+ expectedSessionHistory,
+ expectedSessionRestored,
+}) {
+ await BrowserTestUtils.withNewTab("about:blank", async browser => {
+ BrowserTestUtils.loadURIString(browser, HOME_URL);
+ await BrowserTestUtils.browserLoaded(
+ gBrowser.selectedBrowser,
+ false,
+ HOME_URL
+ );
+
+ info(`Open link for ${link} by ${openBy} as ${openAs}`);
+ const onNewTabCreated = waitForNewTabWithLoadRequest();
+ const href = await openLink(browser, link, openBy, openAs);
+ const target = await onNewTabCreated;
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ target.linkedBrowser.browsingContext
+ .mostRecentLoadingSessionHistoryEntry
+ );
+
+ info("Close the session");
+ const sessionPromise = BrowserTestUtils.waitForSessionStoreUpdate(target);
+ BrowserTestUtils.removeTab(target);
+ await sessionPromise;
+
+ info("Restore the session");
+ const restoredTab = SessionStore.undoCloseTab(window, 0);
+ await BrowserTestUtils.browserLoaded(restoredTab.linkedBrowser);
+
+ info("Check the loaded URL of restored tab");
+ Assert.equal(
+ restoredTab.linkedBrowser.currentURI.spec === href,
+ expectedSessionRestored
+ );
+
+ if (expectedSessionRestored) {
+ info("Check the session history of restored tab");
+ const sessionHistory = await new Promise(r =>
+ SessionStore.getSessionHistory(restoredTab, r)
+ );
+ Assert.deepEqual(
+ sessionHistory.entries.map(e => e.url),
+ expectedSessionHistory
+ );
+ }
+
+ BrowserTestUtils.removeTab(restoredTab);
+ });
+}
+
+async function openLink(browser, link, openBy, openAs) {
+ let href;
+ const openAsBackground = openAs === OPEN_AS.BACKGROUND;
+ if (openBy === OPEN_BY.CLICK) {
+ href = await synthesizeMouse(browser, link, {
+ ctrlKey: openAsBackground,
+ metaKey: openAsBackground,
+ });
+ } else if (openBy === OPEN_BY.CONTEXT_MENU) {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.tabs.loadInBackground", openAsBackground]],
+ });
+
+ const contextMenu = document.getElementById("contentAreaContextMenu");
+ const onPopupShown = BrowserTestUtils.waitForEvent(
+ contextMenu,
+ "popupshown"
+ );
+
+ href = await synthesizeMouse(browser, link, {
+ type: "contextmenu",
+ button: 2,
+ });
+
+ await onPopupShown;
+
+ const openLinkMenuItem = contextMenu.querySelector(
+ "#context-openlinkintab"
+ );
+ contextMenu.activateItem(openLinkMenuItem);
+
+ await SpecialPowers.popPrefEnv();
+ } else {
+ throw new Error("Invalid openBy");
+ }
+
+ return href;
+}
+
+async function synthesizeMouse(browser, link, event) {
+ return SpecialPowers.spawn(
+ browser,
+ [link, event],
+ (linkInContent, eventInContent) => {
+ const target = content.document.getElementById(linkInContent);
+ EventUtils.synthesizeMouseAtCenter(target, eventInContent, content);
+ return target.href;
+ }
+ );
+}
+
+async function waitForNewTabWithLoadRequest() {
+ return new Promise(resolve =>
+ gBrowser.addTabsProgressListener({
+ onStateChange(aBrowser, aWebProgress, aRequest, aStateFlags, aStatus) {
+ if (aStateFlags & Ci.nsIWebProgressListener.STATE_START) {
+ gBrowser.removeTabsProgressListener(this);
+ resolve(gBrowser.getTabForBrowser(aBrowser));
+ }
+ },
+ })
+ );
+}
diff --git a/browser/base/content/test/tabs/dummy_page.html b/browser/base/content/test/tabs/dummy_page.html
new file mode 100644
index 0000000000..1a87e28408
--- /dev/null
+++ b/browser/base/content/test/tabs/dummy_page.html
@@ -0,0 +1,9 @@
+<html>
+<head>
+<title>Dummy test page</title>
+<meta http-equiv="Content-Type" content="text/html;charset=utf-8"></meta>
+</head>
+<body>
+<p>Dummy test page</p>
+</body>
+</html>
diff --git a/browser/base/content/test/tabs/file_about_child.html b/browser/base/content/test/tabs/file_about_child.html
new file mode 100644
index 0000000000..41fb745451
--- /dev/null
+++ b/browser/base/content/test/tabs/file_about_child.html
@@ -0,0 +1,10 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test for Bug 1329032</title>
+</head>
+<body>
+ Just an about page that only loads in the child!
+</body>
+</html>
diff --git a/browser/base/content/test/tabs/file_about_parent.html b/browser/base/content/test/tabs/file_about_parent.html
new file mode 100644
index 0000000000..0d910f860b
--- /dev/null
+++ b/browser/base/content/test/tabs/file_about_parent.html
@@ -0,0 +1,10 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+ <meta charset="utf-8">
+ <title>Test for Bug 1329032</title>
+</head>
+<body>
+ <a href="about:test-about-principal-child" id="aboutchildprincipal">about:test-about-principal-child</a>
+</body>
+</html>
diff --git a/browser/base/content/test/tabs/file_about_srcdoc.html b/browser/base/content/test/tabs/file_about_srcdoc.html
new file mode 100644
index 0000000000..0a8d0d74bf
--- /dev/null
+++ b/browser/base/content/test/tabs/file_about_srcdoc.html
@@ -0,0 +1,9 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8">
+ </head>
+ <body>
+ <iframe srcdoc="hello world!"></iframe>
+ </body>
+</html>
diff --git a/browser/base/content/test/tabs/file_anchor_elements.html b/browser/base/content/test/tabs/file_anchor_elements.html
new file mode 100644
index 0000000000..598a3bd825
--- /dev/null
+++ b/browser/base/content/test/tabs/file_anchor_elements.html
@@ -0,0 +1,12 @@
+<!DOCTYPE html>
+<head>
+ <meta charset="utf-8">
+ <title>Testing whether paste event is fired at middle click on anchor elements</title>
+</head>
+<body>
+ <p>Here is an <a id="a_with_href" href="https://example.com/#a_with_href">anchor element</a></p>
+ <p contenteditable>Here is an <a id="editable_a_with_href" href="https://example.com/#editable_a_with_href">editable anchor element</a></p>
+ <p contenteditable>Here is <span contenteditable="false"><a id="non-editable_a_with_href" href="https://example.com/#non-editable_a_with_href">non-editable anchor element</a></span>
+ <p>Here is an <a id="a_with_name" name="a_with_name">anchor element without href</a></p>
+</body>
+</html>
diff --git a/browser/base/content/test/tabs/file_mediaPlayback.html b/browser/base/content/test/tabs/file_mediaPlayback.html
new file mode 100644
index 0000000000..a6979287e2
--- /dev/null
+++ b/browser/base/content/test/tabs/file_mediaPlayback.html
@@ -0,0 +1,2 @@
+<!DOCTYPE html>
+<audio src="audio.ogg" controls loop>
diff --git a/browser/base/content/test/tabs/file_new_tab_page.html b/browser/base/content/test/tabs/file_new_tab_page.html
new file mode 100644
index 0000000000..4ef22a8c7c
--- /dev/null
+++ b/browser/base/content/test/tabs/file_new_tab_page.html
@@ -0,0 +1,9 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8">
+ </head>
+ <body>
+ <a href="http://example.com/#linkclick" id="link_to_example_com">go to example.com</a>
+ </body>
+</html>
diff --git a/browser/base/content/test/tabs/file_rel_opener_noopener.html b/browser/base/content/test/tabs/file_rel_opener_noopener.html
new file mode 100644
index 0000000000..78e872005c
--- /dev/null
+++ b/browser/base/content/test/tabs/file_rel_opener_noopener.html
@@ -0,0 +1,12 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8">
+ </head>
+ <body>
+ <a target="_blank" rel="noopener" href="https://example.com/browser/browser/base/content/test/tabs/blank.html" id="link_noopener_examplecom">Go to example.com</a>
+ <a target="_blank" rel="opener" href="https://example.com/browser/browser/base/content/test/tabs/blank.html" id="link_opener_examplecom">Go to example.com</a>
+ <a target="_blank" rel="noopener" href="https://example.org/browser/browser/base/content/test/tabs/blank.html" id="link_noopener_exampleorg">Go to example.org</a>
+ <a target="_blank" rel="opener" href="https://example.org/browser/browser/base/content/test/tabs/blank.html" id="link_opener_exampleorg">Go to example.org</a>
+ </body>
+</html>
diff --git a/browser/base/content/test/tabs/head.js b/browser/base/content/test/tabs/head.js
new file mode 100644
index 0000000000..abd6c060f7
--- /dev/null
+++ b/browser/base/content/test/tabs/head.js
@@ -0,0 +1,564 @@
+function updateTabContextMenu(tab) {
+ let menu = document.getElementById("tabContextMenu");
+ if (!tab) {
+ tab = gBrowser.selectedTab;
+ }
+ var evt = new Event("");
+ tab.dispatchEvent(evt);
+ menu.openPopup(tab, "end_after", 0, 0, true, false, evt);
+ is(
+ window.TabContextMenu.contextTab,
+ tab,
+ "TabContextMenu context is the expected tab"
+ );
+ menu.hidePopup();
+}
+
+function triggerClickOn(target, options) {
+ let promise = BrowserTestUtils.waitForEvent(target, "click");
+ if (AppConstants.platform == "macosx") {
+ options = {
+ metaKey: options.ctrlKey,
+ shiftKey: options.shiftKey,
+ };
+ }
+ EventUtils.synthesizeMouseAtCenter(target, options);
+ return promise;
+}
+
+function triggerMiddleClickOn(target) {
+ let promise = BrowserTestUtils.waitForEvent(target, "click");
+ EventUtils.synthesizeMouseAtCenter(target, { button: 1 });
+ return promise;
+}
+
+async function addTab(url = "http://mochi.test:8888/", params) {
+ return addTabTo(gBrowser, url, params);
+}
+
+async function addTabTo(
+ targetBrowser,
+ url = "http://mochi.test:8888/",
+ params = {}
+) {
+ params.skipAnimation = true;
+ const tab = BrowserTestUtils.addTab(targetBrowser, url, params);
+ const browser = targetBrowser.getBrowserForTab(tab);
+ await BrowserTestUtils.browserLoaded(browser);
+ return tab;
+}
+
+async function addMediaTab() {
+ const PAGE =
+ "https://example.com/browser/browser/base/content/test/tabs/file_mediaPlayback.html";
+ const tab = BrowserTestUtils.addTab(gBrowser, PAGE, { skipAnimation: true });
+ const browser = gBrowser.getBrowserForTab(tab);
+ await BrowserTestUtils.browserLoaded(browser);
+ return tab;
+}
+
+function muted(tab) {
+ return tab.linkedBrowser.audioMuted;
+}
+
+function activeMediaBlocked(tab) {
+ return tab.activeMediaBlocked;
+}
+
+async function toggleMuteAudio(tab, expectMuted) {
+ let mutedPromise = get_wait_for_mute_promise(tab, expectMuted);
+ tab.toggleMuteAudio();
+ await mutedPromise;
+}
+
+async function pressIcon(icon) {
+ let tooltip = document.getElementById("tabbrowser-tab-tooltip");
+ await hover_icon(icon, tooltip);
+ EventUtils.synthesizeMouseAtCenter(icon, { button: 0 });
+ leave_icon(icon);
+}
+
+async function wait_for_tab_playing_event(tab, expectPlaying) {
+ if (tab.soundPlaying == expectPlaying) {
+ ok(true, "The tab should " + (expectPlaying ? "" : "not ") + "be playing");
+ return true;
+ }
+ return BrowserTestUtils.waitForEvent(tab, "TabAttrModified", false, event => {
+ if (event.detail.changed.includes("soundplaying")) {
+ is(
+ tab.hasAttribute("soundplaying"),
+ expectPlaying,
+ "The tab should " + (expectPlaying ? "" : "not ") + "be playing"
+ );
+ is(
+ tab.soundPlaying,
+ expectPlaying,
+ "The tab should " + (expectPlaying ? "" : "not ") + "be playing"
+ );
+ return true;
+ }
+ return false;
+ });
+}
+
+async function wait_for_tab_media_blocked_event(tab, expectMediaBlocked) {
+ if (tab.activeMediaBlocked == expectMediaBlocked) {
+ ok(
+ true,
+ "The tab should " +
+ (expectMediaBlocked ? "" : "not ") +
+ "be activemedia-blocked"
+ );
+ return true;
+ }
+ return BrowserTestUtils.waitForEvent(tab, "TabAttrModified", false, event => {
+ if (event.detail.changed.includes("activemedia-blocked")) {
+ is(
+ tab.hasAttribute("activemedia-blocked"),
+ expectMediaBlocked,
+ "The tab should " +
+ (expectMediaBlocked ? "" : "not ") +
+ "be activemedia-blocked"
+ );
+ is(
+ tab.activeMediaBlocked,
+ expectMediaBlocked,
+ "The tab should " +
+ (expectMediaBlocked ? "" : "not ") +
+ "be activemedia-blocked"
+ );
+ return true;
+ }
+ return false;
+ });
+}
+
+async function is_audio_playing(tab) {
+ let browser = tab.linkedBrowser;
+ let isPlaying = await SpecialPowers.spawn(browser, [], async function () {
+ let audio = content.document.querySelector("audio");
+ return !audio.paused;
+ });
+ return isPlaying;
+}
+
+async function play(tab, expectPlaying = true) {
+ let browser = tab.linkedBrowser;
+ await SpecialPowers.spawn(browser, [], async function () {
+ let audio = content.document.querySelector("audio");
+ audio.play();
+ });
+
+ // If the tab has already been muted, it means the tab won't get soundplaying,
+ // so we don't need to check this attribute.
+ if (browser.audioMuted) {
+ return;
+ }
+
+ if (expectPlaying) {
+ await wait_for_tab_playing_event(tab, true);
+ } else {
+ await wait_for_tab_media_blocked_event(tab, true);
+ }
+}
+
+function disable_non_test_mouse(disable) {
+ let utils = window.windowUtils;
+ utils.disableNonTestMouseEvents(disable);
+}
+
+function hover_icon(icon, tooltip) {
+ disable_non_test_mouse(true);
+
+ let popupShownPromise = BrowserTestUtils.waitForEvent(tooltip, "popupshown");
+ EventUtils.synthesizeMouse(icon, 1, 1, { type: "mouseover" });
+ EventUtils.synthesizeMouse(icon, 2, 2, { type: "mousemove" });
+ EventUtils.synthesizeMouse(icon, 3, 3, { type: "mousemove" });
+ EventUtils.synthesizeMouse(icon, 4, 4, { type: "mousemove" });
+ return popupShownPromise;
+}
+
+function leave_icon(icon) {
+ EventUtils.synthesizeMouse(icon, 0, 0, { type: "mouseout" });
+ EventUtils.synthesizeMouseAtCenter(document.documentElement, {
+ type: "mousemove",
+ });
+ EventUtils.synthesizeMouseAtCenter(document.documentElement, {
+ type: "mousemove",
+ });
+ EventUtils.synthesizeMouseAtCenter(document.documentElement, {
+ type: "mousemove",
+ });
+
+ disable_non_test_mouse(false);
+}
+
+// The set of tabs which have ever had their mute state changed.
+// Used to determine whether the tab should have a muteReason value.
+let everMutedTabs = new WeakSet();
+
+function get_wait_for_mute_promise(tab, expectMuted) {
+ return BrowserTestUtils.waitForEvent(tab, "TabAttrModified", false, event => {
+ if (
+ event.detail.changed.includes("muted") ||
+ event.detail.changed.includes("activemedia-blocked")
+ ) {
+ is(
+ tab.hasAttribute("muted"),
+ expectMuted,
+ "The tab should " + (expectMuted ? "" : "not ") + "be muted"
+ );
+ is(
+ tab.muted,
+ expectMuted,
+ "The tab muted property " + (expectMuted ? "" : "not ") + "be true"
+ );
+
+ if (expectMuted || everMutedTabs.has(tab)) {
+ everMutedTabs.add(tab);
+ is(tab.muteReason, null, "The tab should have a null muteReason value");
+ } else {
+ is(
+ tab.muteReason,
+ undefined,
+ "The tab should have an undefined muteReason value"
+ );
+ }
+ return true;
+ }
+ return false;
+ });
+}
+
+async function test_mute_tab(tab, icon, expectMuted) {
+ let mutedPromise = get_wait_for_mute_promise(tab, expectMuted);
+
+ let activeTab = gBrowser.selectedTab;
+
+ let tooltip = document.getElementById("tabbrowser-tab-tooltip");
+
+ await hover_icon(icon, tooltip);
+ EventUtils.synthesizeMouseAtCenter(icon, { button: 0 });
+ leave_icon(icon);
+
+ is(
+ gBrowser.selectedTab,
+ activeTab,
+ "Clicking on mute should not change the currently selected tab"
+ );
+
+ // If the audio is playing, we should check whether clicking on icon affects
+ // the media element's playing state.
+ let isAudioPlaying = await is_audio_playing(tab);
+ if (isAudioPlaying) {
+ await wait_for_tab_playing_event(tab, !expectMuted);
+ }
+
+ return mutedPromise;
+}
+
+async function dragAndDrop(
+ tab1,
+ tab2,
+ copy,
+ destWindow = window,
+ afterTab = true
+) {
+ let rect = tab2.getBoundingClientRect();
+ let event = {
+ ctrlKey: copy,
+ altKey: copy,
+ clientX: rect.left + rect.width / 2 + 10 * (afterTab ? 1 : -1),
+ clientY: rect.top + rect.height / 2,
+ };
+
+ if (destWindow != window) {
+ // Make sure that both tab1 and tab2 are visible
+ window.focus();
+ window.moveTo(rect.left, rect.top + rect.height * 3);
+ }
+
+ let originalTPos = tab1._tPos;
+ EventUtils.synthesizeDrop(
+ tab1,
+ tab2,
+ null,
+ copy ? "copy" : "move",
+ window,
+ destWindow,
+ event
+ );
+ // Ensure dnd suppression is cleared.
+ EventUtils.synthesizeMouseAtCenter(tab2, { type: "mouseup" }, destWindow);
+ if (!copy && destWindow == window) {
+ await BrowserTestUtils.waitForCondition(
+ () => tab1._tPos != originalTPos,
+ "Waiting for tab position to be updated"
+ );
+ } else if (destWindow != window) {
+ await BrowserTestUtils.waitForCondition(
+ () => tab1.closing,
+ "Waiting for tab closing"
+ );
+ }
+}
+
+function getUrl(tab) {
+ return tab.linkedBrowser.currentURI.spec;
+}
+
+/**
+ * Takes a xul:browser and makes sure that the remoteTypes for the browser in
+ * both the parent and the child processes are the same.
+ *
+ * @param {xul:browser} browser
+ * A xul:browser.
+ * @param {string} expectedRemoteType
+ * The expected remoteType value for the browser in both the parent
+ * and child processes.
+ * @param {optional string} message
+ * If provided, shows this string as the message when remoteType values
+ * do not match. If not present, it uses the default message defined
+ * in the function parameters.
+ */
+function checkBrowserRemoteType(
+ browser,
+ expectedRemoteType,
+ message = `Ensures that tab runs in the ${expectedRemoteType} content process.`
+) {
+ // Check both parent and child to ensure that they have the correct remoteType.
+ if (expectedRemoteType == E10SUtils.WEB_REMOTE_TYPE) {
+ ok(E10SUtils.isWebRemoteType(browser.remoteType), message);
+ ok(
+ E10SUtils.isWebRemoteType(browser.messageManager.remoteType),
+ "Parent and child process should agree on the remote type."
+ );
+ } else {
+ is(browser.remoteType, expectedRemoteType, message);
+ is(
+ browser.messageManager.remoteType,
+ expectedRemoteType,
+ "Parent and child process should agree on the remote type."
+ );
+ }
+}
+
+function test_url_for_process_types({
+ url,
+ chromeResult,
+ webContentResult,
+ privilegedAboutContentResult,
+ privilegedMozillaContentResult,
+ extensionProcessResult,
+}) {
+ const CHROME_PROCESS = E10SUtils.NOT_REMOTE;
+ const WEB_CONTENT_PROCESS = E10SUtils.WEB_REMOTE_TYPE;
+ const PRIVILEGEDABOUT_CONTENT_PROCESS = E10SUtils.PRIVILEGEDABOUT_REMOTE_TYPE;
+ const PRIVILEGEDMOZILLA_CONTENT_PROCESS =
+ E10SUtils.PRIVILEGEDMOZILLA_REMOTE_TYPE;
+ const EXTENSION_PROCESS = E10SUtils.EXTENSION_REMOTE_TYPE;
+
+ is(
+ E10SUtils.canLoadURIInRemoteType(url, /* fission */ false, CHROME_PROCESS),
+ chromeResult,
+ "Check URL in chrome process."
+ );
+ is(
+ E10SUtils.canLoadURIInRemoteType(
+ url,
+ /* fission */ false,
+ WEB_CONTENT_PROCESS
+ ),
+ webContentResult,
+ "Check URL in web content process."
+ );
+ is(
+ E10SUtils.canLoadURIInRemoteType(
+ url,
+ /* fission */ false,
+ PRIVILEGEDABOUT_CONTENT_PROCESS
+ ),
+ privilegedAboutContentResult,
+ "Check URL in privileged about content process."
+ );
+ is(
+ E10SUtils.canLoadURIInRemoteType(
+ url,
+ /* fission */ false,
+ PRIVILEGEDMOZILLA_CONTENT_PROCESS
+ ),
+ privilegedMozillaContentResult,
+ "Check URL in privileged mozilla content process."
+ );
+ is(
+ E10SUtils.canLoadURIInRemoteType(
+ url,
+ /* fission */ false,
+ EXTENSION_PROCESS
+ ),
+ extensionProcessResult,
+ "Check URL in extension process."
+ );
+
+ is(
+ E10SUtils.canLoadURIInRemoteType(
+ url + "#foo",
+ /* fission */ false,
+ CHROME_PROCESS
+ ),
+ chromeResult,
+ "Check URL with ref in chrome process."
+ );
+ is(
+ E10SUtils.canLoadURIInRemoteType(
+ url + "#foo",
+ /* fission */ false,
+ WEB_CONTENT_PROCESS
+ ),
+ webContentResult,
+ "Check URL with ref in web content process."
+ );
+ is(
+ E10SUtils.canLoadURIInRemoteType(
+ url + "#foo",
+ /* fission */ false,
+ PRIVILEGEDABOUT_CONTENT_PROCESS
+ ),
+ privilegedAboutContentResult,
+ "Check URL with ref in privileged about content process."
+ );
+ is(
+ E10SUtils.canLoadURIInRemoteType(
+ url + "#foo",
+ /* fission */ false,
+ PRIVILEGEDMOZILLA_CONTENT_PROCESS
+ ),
+ privilegedMozillaContentResult,
+ "Check URL with ref in privileged mozilla content process."
+ );
+ is(
+ E10SUtils.canLoadURIInRemoteType(
+ url + "#foo",
+ /* fission */ false,
+ EXTENSION_PROCESS
+ ),
+ extensionProcessResult,
+ "Check URL with ref in extension process."
+ );
+
+ is(
+ E10SUtils.canLoadURIInRemoteType(
+ url + "?foo",
+ /* fission */ false,
+ CHROME_PROCESS
+ ),
+ chromeResult,
+ "Check URL with query in chrome process."
+ );
+ is(
+ E10SUtils.canLoadURIInRemoteType(
+ url + "?foo",
+ /* fission */ false,
+ WEB_CONTENT_PROCESS
+ ),
+ webContentResult,
+ "Check URL with query in web content process."
+ );
+ is(
+ E10SUtils.canLoadURIInRemoteType(
+ url + "?foo",
+ /* fission */ false,
+ PRIVILEGEDABOUT_CONTENT_PROCESS
+ ),
+ privilegedAboutContentResult,
+ "Check URL with query in privileged about content process."
+ );
+ is(
+ E10SUtils.canLoadURIInRemoteType(
+ url + "?foo",
+ /* fission */ false,
+ PRIVILEGEDMOZILLA_CONTENT_PROCESS
+ ),
+ privilegedMozillaContentResult,
+ "Check URL with query in privileged mozilla content process."
+ );
+ is(
+ E10SUtils.canLoadURIInRemoteType(
+ url + "?foo",
+ /* fission */ false,
+ EXTENSION_PROCESS
+ ),
+ extensionProcessResult,
+ "Check URL with query in extension process."
+ );
+
+ is(
+ E10SUtils.canLoadURIInRemoteType(
+ url + "?foo#bar",
+ /* fission */ false,
+ CHROME_PROCESS
+ ),
+ chromeResult,
+ "Check URL with query and ref in chrome process."
+ );
+ is(
+ E10SUtils.canLoadURIInRemoteType(
+ url + "?foo#bar",
+ /* fission */ false,
+ WEB_CONTENT_PROCESS
+ ),
+ webContentResult,
+ "Check URL with query and ref in web content process."
+ );
+ is(
+ E10SUtils.canLoadURIInRemoteType(
+ url + "?foo#bar",
+ /* fission */ false,
+ PRIVILEGEDABOUT_CONTENT_PROCESS
+ ),
+ privilegedAboutContentResult,
+ "Check URL with query and ref in privileged about content process."
+ );
+ is(
+ E10SUtils.canLoadURIInRemoteType(
+ url + "?foo#bar",
+ /* fission */ false,
+ PRIVILEGEDMOZILLA_CONTENT_PROCESS
+ ),
+ privilegedMozillaContentResult,
+ "Check URL with query and ref in privileged mozilla content process."
+ );
+ is(
+ E10SUtils.canLoadURIInRemoteType(
+ url + "?foo#bar",
+ /* fission */ false,
+ EXTENSION_PROCESS
+ ),
+ extensionProcessResult,
+ "Check URL with query and ref in extension process."
+ );
+}
+
+/*
+ * Get a file URL for the local file name.
+ */
+function fileURL(filename) {
+ let ifile = getChromeDir(getResolvedURI(gTestPath));
+ ifile.append(filename);
+ return Services.io.newFileURI(ifile).spec;
+}
+
+/*
+ * Get a http URL for the local file name.
+ */
+function httpURL(filename, host = "https://example.com/") {
+ let root = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content/",
+ host
+ );
+ return root + filename;
+}
+
+function loadTestSubscript(filePath) {
+ Services.scriptloader.loadSubScript(new URL(filePath, gTestPath).href, this);
+}
diff --git a/browser/base/content/test/tabs/helper_origin_attrs_testing.js b/browser/base/content/test/tabs/helper_origin_attrs_testing.js
new file mode 100644
index 0000000000..5c7938baca
--- /dev/null
+++ b/browser/base/content/test/tabs/helper_origin_attrs_testing.js
@@ -0,0 +1,158 @@
+/* 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 NUM_USER_CONTEXTS = 3;
+
+var xulFrameLoaderCreatedListenerInfo;
+
+function initXulFrameLoaderListenerInfo() {
+ xulFrameLoaderCreatedListenerInfo = {};
+ xulFrameLoaderCreatedListenerInfo.numCalledSoFar = 0;
+}
+
+function handleEvent(aEvent) {
+ if (aEvent.type != "XULFrameLoaderCreated") {
+ return;
+ }
+ // Ignore <browser> element in about:preferences and any other special pages
+ if ("gBrowser" in aEvent.target.ownerGlobal) {
+ xulFrameLoaderCreatedListenerInfo.numCalledSoFar++;
+ }
+}
+
+async function openURIInRegularTab(uri, win = window) {
+ info(`Opening url ${uri} in a regular tab`);
+
+ initXulFrameLoaderListenerInfo();
+ win.gBrowser.addEventListener("XULFrameLoaderCreated", handleEvent);
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(win.gBrowser, uri);
+ info(
+ `XULFrameLoaderCreated was fired ${xulFrameLoaderCreatedListenerInfo.numCalledSoFar} time(s) for ${uri} in regular tab`
+ );
+
+ is(
+ xulFrameLoaderCreatedListenerInfo.numCalledSoFar,
+ 1,
+ "XULFrameLoaderCreated fired correct number of times"
+ );
+
+ win.gBrowser.removeEventListener("XULFrameLoaderCreated", handleEvent);
+ return { tab, uri };
+}
+
+async function openURIInContainer(uri, win, userContextId) {
+ info(`Opening url ${uri} in user context ${userContextId}`);
+ initXulFrameLoaderListenerInfo();
+ win.gBrowser.addEventListener("XULFrameLoaderCreated", handleEvent);
+
+ let tab = BrowserTestUtils.addTab(win.gBrowser, uri, {
+ userContextId,
+ });
+ is(
+ tab.getAttribute("usercontextid"),
+ userContextId.toString(),
+ "New tab has correct user context id"
+ );
+
+ let browser = tab.linkedBrowser;
+
+ await BrowserTestUtils.browserLoaded(browser, false, uri);
+ info(
+ `XULFrameLoaderCreated was fired ${xulFrameLoaderCreatedListenerInfo.numCalledSoFar}
+ time(s) for ${uri} in container tab ${userContextId}`
+ );
+
+ is(
+ xulFrameLoaderCreatedListenerInfo.numCalledSoFar,
+ 1,
+ "XULFrameLoaderCreated fired correct number of times"
+ );
+
+ win.gBrowser.removeEventListener("XULFrameLoaderCreated", handleEvent);
+
+ return { tab, user_context_id: userContextId, uri };
+}
+
+async function openURIInPrivateTab(uri) {
+ info(
+ `Opening url ${
+ uri ? uri : "about:privatebrowsing"
+ } in a private browsing tab`
+ );
+ let win = await BrowserTestUtils.openNewBrowserWindow({
+ private: true,
+ waitForTabURL: "about:privatebrowsing",
+ });
+ if (!uri) {
+ return { tab: win.gBrowser.selectedTab, uri: "about:privatebrowsing" };
+ }
+ initXulFrameLoaderListenerInfo();
+ win.gBrowser.addEventListener("XULFrameLoaderCreated", handleEvent);
+
+ const browser = win.gBrowser.selectedTab.linkedBrowser;
+ let prevRemoteType = browser.remoteType;
+ let loaded = BrowserTestUtils.browserLoaded(browser, false, uri);
+ BrowserTestUtils.loadURIString(browser, uri);
+ await loaded;
+ let currRemoteType = browser.remoteType;
+
+ info(
+ `XULFrameLoaderCreated was fired ${xulFrameLoaderCreatedListenerInfo.numCalledSoFar} time(s) for ${uri} in private tab`
+ );
+
+ if (
+ SpecialPowers.Services.appinfo.sessionHistoryInParent &&
+ currRemoteType == prevRemoteType &&
+ uri == "about:blank"
+ ) {
+ // about:blank page gets flagged for being eligible to go into bfcache
+ // and thus we create a new XULFrameLoader for these pages
+ is(
+ xulFrameLoaderCreatedListenerInfo.numCalledSoFar,
+ 1,
+ "XULFrameLoaderCreated fired correct number of times"
+ );
+ } else {
+ is(
+ xulFrameLoaderCreatedListenerInfo.numCalledSoFar,
+ currRemoteType == prevRemoteType ? 0 : 1,
+ "XULFrameLoaderCreated fired correct number of times"
+ );
+ }
+
+ win.gBrowser.removeEventListener("XULFrameLoaderCreated", handleEvent);
+ return { tab: win.gBrowser.selectedTab, uri };
+}
+
+function initXulFrameLoaderCreatedCounter(aXulFrameLoaderCreatedListenerInfo) {
+ aXulFrameLoaderCreatedListenerInfo.numCalledSoFar = 0;
+}
+
+// Expected remote types for the following tests:
+// browser/base/content/test/tabs/browser_navigate_through_urls_origin_attributes.js
+// browser/base/content/test/tabs/browser_origin_attrs_in_remote_type.js
+function getExpectedRemoteTypes(gFissionBrowser, numPagesOpen) {
+ var remoteTypes;
+ if (gFissionBrowser) {
+ remoteTypes = [
+ "webIsolated=https://example.com",
+ "webIsolated=https://example.com^userContextId=1",
+ "webIsolated=https://example.com^userContextId=2",
+ "webIsolated=https://example.com^userContextId=3",
+ "webIsolated=https://example.com^privateBrowsingId=1",
+ "webIsolated=https://example.org",
+ "webIsolated=https://example.org^userContextId=1",
+ "webIsolated=https://example.org^userContextId=2",
+ "webIsolated=https://example.org^userContextId=3",
+ "webIsolated=https://example.org^privateBrowsingId=1",
+ ];
+ } else {
+ remoteTypes = Array(numPagesOpen * 2).fill("web"); // example.com and example.org
+ }
+ remoteTypes = remoteTypes.concat(Array(numPagesOpen * 2).fill(null)); // about: pages
+ return remoteTypes;
+}
diff --git a/browser/base/content/test/tabs/link_in_tab_title_and_url_prefilled.html b/browser/base/content/test/tabs/link_in_tab_title_and_url_prefilled.html
new file mode 100644
index 0000000000..a7561f4099
--- /dev/null
+++ b/browser/base/content/test/tabs/link_in_tab_title_and_url_prefilled.html
@@ -0,0 +1,30 @@
+<style>
+a { display: block; }
+</style>
+
+<a id="wait-a-bit--blank-target" href="wait-a-bit.sjs" target="_blank">wait-a-bit - _blank target</a>
+<a id="wait-a-bit--other-target" href="wait-a-bit.sjs" target="other">wait-a-bit - other target</a>
+<a id="wait-a-bit--by-script">wait-a-bit - script</a>
+<a id="wait-a-bit--no-target" href="wait-a-bit.sjs">wait-a-bit - no target</a>
+
+<a id="request-timeout--blank-target" href="request-timeout.sjs" target="_blank">request-timeout - _blank target</a>
+<a id="request-timeout--other-target" href="request-timeout.sjs" target="other">request-timeout - other target</a>
+<a id="request-timeout--by-script">request-timeout - script</a>
+<a id="request-timeout--no-target" href="request-timeout.sjs">request-timeout - no target</a>
+
+<a id="blank-page--blank-target" href="about:blank" target="_blank">about:blank - _blank target</a>
+<a id="blank-page--other-target" href="about:blank" target="other">about:blank - other target</a>
+<a id="blank-page--by-script">blank - script</a>
+<a id="blank-page--no-target" href="about:blank">about:blank - no target</a>
+
+<script>
+document.getElementById("wait-a-bit--by-script").addEventListener("click", () => {
+ window.open("wait-a-bit.sjs", "_blank");
+})
+document.getElementById("request-timeout--by-script").addEventListener("click", () => {
+ window.open("request-timeout.sjs", "_blank");
+})
+document.getElementById("blank-page--by-script").addEventListener("click", () => {
+ window.open("about:blank", "_blank");
+})
+</script>
diff --git a/browser/base/content/test/tabs/open_window_in_new_tab.html b/browser/base/content/test/tabs/open_window_in_new_tab.html
new file mode 100644
index 0000000000..2bd7613d26
--- /dev/null
+++ b/browser/base/content/test/tabs/open_window_in_new_tab.html
@@ -0,0 +1,15 @@
+<!doctype html>
+<script>
+function openWindow(id) {
+ window.childWindow = window.open(location.href + "?" + id, "", "");
+}
+</script>
+<button id="open-click" onclick="openWindow('open-click')">Open window</button>
+<button id="focus" onclick="window.childWindow.focus()">Focus window</button>
+<button id="open-mousedown">Open window</button>
+<script>
+document.getElementById("open-mousedown").addEventListener("mousedown", function(e) {
+ openWindow(this.id);
+ e.preventDefault();
+});
+</script>
diff --git a/browser/base/content/test/tabs/page_with_iframe.html b/browser/base/content/test/tabs/page_with_iframe.html
new file mode 100644
index 0000000000..5d821cf980
--- /dev/null
+++ b/browser/base/content/test/tabs/page_with_iframe.html
@@ -0,0 +1,12 @@
+<!-- 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/. -->
+
+<html>
+ <head>
+ <title>This page has an iFrame</title>
+ </head>
+ <body>
+ <iframe id="hidden-iframe" style="visibility: hidden;" src="https://example.com/another/site"></iframe>
+ </body>
+</html>
diff --git a/browser/base/content/test/tabs/redirect_via_header.html b/browser/base/content/test/tabs/redirect_via_header.html
new file mode 100644
index 0000000000..5fedca6b4e
--- /dev/null
+++ b/browser/base/content/test/tabs/redirect_via_header.html
@@ -0,0 +1,9 @@
+<!-- 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/. -->
+
+<html>
+ <head>
+ <title>Redirect via the header associated with this file</title>
+ </head>
+</html>
diff --git a/browser/base/content/test/tabs/redirect_via_header.html^headers^ b/browser/base/content/test/tabs/redirect_via_header.html^headers^
new file mode 100644
index 0000000000..7543b06689
--- /dev/null
+++ b/browser/base/content/test/tabs/redirect_via_header.html^headers^
@@ -0,0 +1,2 @@
+HTTP 302 Found
+Location: https://example.com/some/path
diff --git a/browser/base/content/test/tabs/redirect_via_meta_tag.html b/browser/base/content/test/tabs/redirect_via_meta_tag.html
new file mode 100644
index 0000000000..42b775055f
--- /dev/null
+++ b/browser/base/content/test/tabs/redirect_via_meta_tag.html
@@ -0,0 +1,13 @@
+<!-- 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/. -->
+
+<html>
+ <head>
+ <title>Page that redirects</title>
+ <meta http-equiv="refresh" content="1;url=http://mochi.test:8888/" />
+ </head>
+ <body>
+ <p>This page has moved to http://mochi.test:8888/</p>
+ </body>
+</html>
diff --git a/browser/base/content/test/tabs/request-timeout.sjs b/browser/base/content/test/tabs/request-timeout.sjs
new file mode 100644
index 0000000000..00e95ca4c0
--- /dev/null
+++ b/browser/base/content/test/tabs/request-timeout.sjs
@@ -0,0 +1,8 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+async function handleRequest(request, response) {
+ response.setStatusLine("1.1", 408, "Request Timeout");
+}
diff --git a/browser/base/content/test/tabs/tab_that_closes.html b/browser/base/content/test/tabs/tab_that_closes.html
new file mode 100644
index 0000000000..795baec18b
--- /dev/null
+++ b/browser/base/content/test/tabs/tab_that_closes.html
@@ -0,0 +1,15 @@
+<html>
+<head>
+<title>Dummy test page</title>
+<meta http-equiv="Content-Type" content="text/html;charset=utf-8"></meta>
+</head>
+<body>
+ <h1>This tab will close</h2>
+ <script>
+ // We use half a second timeout because this can race in debug builds.
+ setTimeout( () => {
+ window.close();
+ }, 500);
+ </script>
+</body>
+</html>
diff --git a/browser/base/content/test/tabs/test_bug1358314.html b/browser/base/content/test/tabs/test_bug1358314.html
new file mode 100644
index 0000000000..9aa2019752
--- /dev/null
+++ b/browser/base/content/test/tabs/test_bug1358314.html
@@ -0,0 +1,10 @@
+<!DOCTYPE html>
+<html>
+ <head>
+ <meta charset="utf-8">
+ </head>
+ <body>
+ <p>Test page</p>
+ <a href="/">Link</a>
+ </body>
+</html>
diff --git a/browser/base/content/test/tabs/test_process_flags_chrome.html b/browser/base/content/test/tabs/test_process_flags_chrome.html
new file mode 100644
index 0000000000..c447d7ffb0
--- /dev/null
+++ b/browser/base/content/test/tabs/test_process_flags_chrome.html
@@ -0,0 +1,10 @@
+<!DOCTYPE html>
+
+<html>
+<body>
+<p>chrome: test page</p>
+<p><a href="chrome://mochitests/content/browser/browser/base/content/test/tabs/test_process_flags_chrome.html">chrome</a></p>
+<p><a href="chrome://mochitests-any/content/browser/browser/base/content/test/tabs/test_process_flags_chrome.html">canremote</a></p>
+<p><a href="chrome://mochitests-content/content/browser/browser/base/content/test/tabs/test_process_flags_chrome.html">mustremote</a></p>
+</body>
+</html>
diff --git a/browser/base/content/test/tabs/wait-a-bit.sjs b/browser/base/content/test/tabs/wait-a-bit.sjs
new file mode 100644
index 0000000000..e90133d752
--- /dev/null
+++ b/browser/base/content/test/tabs/wait-a-bit.sjs
@@ -0,0 +1,23 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+// eslint-disable-next-line mozilla/no-redeclare-with-import-autofix
+const { setTimeout } = ChromeUtils.importESModule(
+ "resource://gre/modules/Timer.sys.mjs"
+);
+
+async function handleRequest(request, response) {
+ response.seizePower();
+
+ await new Promise(r => setTimeout(r, 2000));
+
+ response.write("HTTP/1.1 200 OK\r\n");
+ const body = "<title>wait a bit</title><body>ok</body>";
+ response.write("Content-Type: text/html\r\n");
+ response.write(`Content-Length: ${body.length}\r\n`);
+ response.write("\r\n");
+ response.write(body);
+ response.finish();
+}
diff --git a/browser/base/content/test/touch/browser.ini b/browser/base/content/test/touch/browser.ini
new file mode 100644
index 0000000000..7b14c74211
--- /dev/null
+++ b/browser/base/content/test/touch/browser.ini
@@ -0,0 +1,4 @@
+[DEFAULT]
+
+[browser_menu_touch.js]
+skip-if = !(os == 'win' && os_version == '10.0')
diff --git a/browser/base/content/test/touch/browser_menu_touch.js b/browser/base/content/test/touch/browser_menu_touch.js
new file mode 100644
index 0000000000..0cb605675c
--- /dev/null
+++ b/browser/base/content/test/touch/browser_menu_touch.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/. */
+
+/* This test checks that toolbar menus are in touchmode
+ * when opened through a touch event. */
+
+async function openAndCheckMenu(menu, target) {
+ is(menu.state, "closed", `Menu panel (${menu.id}) is initally closed.`);
+
+ let popupshown = BrowserTestUtils.waitForEvent(menu, "popupshown");
+ EventUtils.synthesizeNativeTapAtCenter(target);
+ await popupshown;
+
+ is(menu.state, "open", `Menu panel (${menu.id}) is open.`);
+ is(
+ menu.getAttribute("touchmode"),
+ "true",
+ `Menu panel (${menu.id}) is in touchmode.`
+ );
+
+ menu.hidePopup();
+
+ popupshown = BrowserTestUtils.waitForEvent(menu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(target, {});
+ await popupshown;
+
+ is(menu.state, "open", `Menu panel (${menu.id}) is open.`);
+ ok(
+ !menu.hasAttribute("touchmode"),
+ `Menu panel (${menu.id}) is not in touchmode.`
+ );
+
+ menu.hidePopup();
+}
+
+async function openAndCheckLazyMenu(id, target) {
+ let menu = document.getElementById(id);
+
+ EventUtils.synthesizeNativeTapAtCenter(target);
+ let ev = await BrowserTestUtils.waitForEvent(
+ window,
+ "popupshown",
+ true,
+ e => e.target.id == id
+ );
+ menu = ev.target;
+
+ is(menu.state, "open", `Menu panel (${menu.id}) is open.`);
+ is(
+ menu.getAttribute("touchmode"),
+ "true",
+ `Menu panel (${menu.id}) is in touchmode.`
+ );
+
+ menu.hidePopup();
+
+ let popupshown = BrowserTestUtils.waitForEvent(menu, "popupshown");
+ EventUtils.synthesizeMouseAtCenter(target, {});
+ await popupshown;
+
+ is(menu.state, "open", `Menu panel (${menu.id}) is open.`);
+ ok(
+ !menu.hasAttribute("touchmode"),
+ `Menu panel (${menu.id}) is not in touchmode.`
+ );
+
+ menu.hidePopup();
+}
+
+// The customization UI menu is not attached to the document when it is
+// closed and hence requires special attention.
+async function openAndCheckCustomizationUIMenu(target) {
+ EventUtils.synthesizeNativeTapAtCenter(target);
+
+ await BrowserTestUtils.waitForCondition(
+ () => document.getElementById("customizationui-widget-panel") != null
+ );
+ let menu = document.getElementById("customizationui-widget-panel");
+
+ if (menu.state != "open") {
+ await BrowserTestUtils.waitForEvent(menu, "popupshown");
+ is(menu.state, "open", `Menu for ${target.id} is open`);
+ }
+
+ is(
+ menu.getAttribute("touchmode"),
+ "true",
+ `Menu for ${target.id} is in touchmode.`
+ );
+
+ menu.hidePopup();
+
+ EventUtils.synthesizeMouseAtCenter(target, {});
+
+ await BrowserTestUtils.waitForCondition(
+ () => document.getElementById("customizationui-widget-panel") != null
+ );
+ menu = document.getElementById("customizationui-widget-panel");
+
+ if (menu.state != "open") {
+ await BrowserTestUtils.waitForEvent(menu, "popupshown");
+ is(menu.state, "open", `Menu for ${target.id} is open`);
+ }
+
+ ok(
+ !menu.hasAttribute("touchmode"),
+ `Menu for ${target.id} is not in touchmode.`
+ );
+
+ menu.hidePopup();
+}
+
+// Ensure that we can run touch events properly for windows [10]
+add_setup(async function () {
+ let isWindows = AppConstants.isPlatformAndVersionAtLeast("win", "10.0");
+ await SpecialPowers.pushPrefEnv({
+ set: [["apz.test.fails_with_native_injection", isWindows]],
+ });
+});
+
+// Test main ("hamburger") menu.
+add_task(async function test_main_menu_touch() {
+ let mainMenu = document.getElementById("appMenu-popup");
+ let target = document.getElementById("PanelUI-menu-button");
+ await openAndCheckMenu(mainMenu, target);
+});
+
+// Test the page action menu.
+add_task(async function test_page_action_panel_touch() {
+ // The page action menu only appears on a web page.
+ await BrowserTestUtils.withNewTab("https://example.com", async function () {
+ // The page actions button is not normally visible, so we must
+ // unhide it.
+ BrowserPageActions.mainButtonNode.style.visibility = "visible";
+ registerCleanupFunction(() => {
+ BrowserPageActions.mainButtonNode.style.removeProperty("visibility");
+ });
+ let target = document.getElementById("pageActionButton");
+ await openAndCheckLazyMenu("pageActionPanel", target);
+ });
+});
+
+// Test the customizationUI panel, which is used for various menus
+// such as library, history, sync, developer and encoding.
+add_task(async function test_customizationui_panel_touch() {
+ CustomizableUI.addWidgetToArea("library-button", CustomizableUI.AREA_NAVBAR);
+ CustomizableUI.addWidgetToArea(
+ "history-panelmenu",
+ CustomizableUI.AREA_NAVBAR
+ );
+
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ CustomizableUI.getPlacementOfWidget("library-button").area == "nav-bar"
+ );
+
+ let target = document.getElementById("library-button");
+ await openAndCheckCustomizationUIMenu(target);
+
+ target = document.getElementById("history-panelmenu");
+ await openAndCheckCustomizationUIMenu(target);
+
+ CustomizableUI.reset();
+});
+
+// Test the overflow menu panel.
+add_task(async function test_overflow_panel_touch() {
+ // Move something in the overflow menu to make the button appear.
+ CustomizableUI.addWidgetToArea(
+ "library-button",
+ CustomizableUI.AREA_FIXED_OVERFLOW_PANEL
+ );
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ CustomizableUI.getPlacementOfWidget("library-button").area ==
+ CustomizableUI.AREA_FIXED_OVERFLOW_PANEL
+ );
+
+ let overflowPanel = document.getElementById("widget-overflow");
+ let target = document.getElementById("nav-bar-overflow-button");
+ await openAndCheckMenu(overflowPanel, target);
+
+ CustomizableUI.reset();
+});
+
+// Test the list all tabs menu.
+add_task(async function test_list_all_tabs_touch() {
+ // Force the menu button to be shown.
+ let tabs = document.getElementById("tabbrowser-tabs");
+ if (!tabs.hasAttribute("overflow")) {
+ tabs.setAttribute("overflow", true);
+ registerCleanupFunction(() => tabs.removeAttribute("overflow"));
+ }
+
+ let target = document.getElementById("alltabs-button");
+ await openAndCheckCustomizationUIMenu(target);
+});
diff --git a/browser/base/content/test/utilityOverlay/browser.ini b/browser/base/content/test/utilityOverlay/browser.ini
new file mode 100644
index 0000000000..236f7a6f97
--- /dev/null
+++ b/browser/base/content/test/utilityOverlay/browser.ini
@@ -0,0 +1,2 @@
+[DEFAULT]
+[browser_openWebLinkIn.js]
diff --git a/browser/base/content/test/utilityOverlay/browser_openWebLinkIn.js b/browser/base/content/test/utilityOverlay/browser_openWebLinkIn.js
new file mode 100644
index 0000000000..53f11e49c0
--- /dev/null
+++ b/browser/base/content/test/utilityOverlay/browser_openWebLinkIn.js
@@ -0,0 +1,185 @@
+/* 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";
+
+// Stolen from https://searchfox.org/mozilla-central/source/browser/base/content/test/popups/browser_popup_close_main_window.js
+// When calling this function, the main window where the test runs will be
+// hidden from various APIs, so that they won't be able to find it. This makes
+// it possible to test some behaviors when only private windows are present.
+function concealMainWindow() {
+ info("Concealing main window.");
+ let oldWinType = document.documentElement.getAttribute("windowtype");
+ // Check if we've already done this to allow calling multiple times:
+ if (oldWinType != "navigator:testrunner") {
+ // Make the main test window not count as a browser window any longer
+ document.documentElement.setAttribute("windowtype", "navigator:testrunner");
+ BrowserWindowTracker.untrackForTestsOnly(window);
+
+ registerCleanupFunction(() => {
+ info("Unconcealing the main window in the cleanup phase.");
+ BrowserWindowTracker.track(window);
+ document.documentElement.setAttribute("windowtype", oldWinType);
+ });
+ }
+}
+
+const EXAMPLE_URL = "https://example.org/";
+add_task(async function test_open_tab() {
+ const waitForTabPromise = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ EXAMPLE_URL
+ );
+ const contentBrowser = await new Promise(resolveOnContentBrowserCreated =>
+ openWebLinkIn(EXAMPLE_URL, "tab", {
+ resolveOnContentBrowserCreated,
+ })
+ );
+
+ const tab = await waitForTabPromise;
+ is(
+ contentBrowser,
+ tab.linkedBrowser,
+ "We get a content browser that is the tab's linked browser as the result of opening a tab"
+ );
+
+ BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function test_open_window() {
+ const waitForWindowPromise = BrowserTestUtils.waitForNewWindow();
+ const contentBrowser = await new Promise(resolveOnContentBrowserCreated =>
+ openWebLinkIn(EXAMPLE_URL, "window", {
+ resolveOnContentBrowserCreated,
+ })
+ );
+
+ const win = await waitForWindowPromise;
+ is(
+ contentBrowser,
+ win.gBrowser.selectedBrowser,
+ "We get the content browser for the newly opened window as a result of opening a window"
+ );
+
+ await BrowserTestUtils.closeWindow(win);
+});
+
+add_task(async function test_open_private_window() {
+ const waitForWindowPromise = BrowserTestUtils.waitForNewWindow();
+ const contentBrowser = await new Promise(resolveOnContentBrowserCreated =>
+ openWebLinkIn(EXAMPLE_URL, "window", {
+ resolveOnContentBrowserCreated,
+ private: true,
+ })
+ );
+
+ const win = await waitForWindowPromise;
+ ok(
+ PrivateBrowsingUtils.isBrowserPrivate(win),
+ "The new window is a private window."
+ );
+ is(
+ contentBrowser,
+ win.gBrowser.selectedBrowser,
+ "We get the content browser for the newly opened window as a result of opening a private window"
+ );
+
+ await BrowserTestUtils.closeWindow(win);
+});
+
+add_task(async function test_open_private_tab_from_private_window() {
+ const privateWindow = await BrowserTestUtils.openNewBrowserWindow({
+ private: true,
+ });
+ const waitForTabPromise = BrowserTestUtils.waitForNewTab(
+ privateWindow.gBrowser,
+ EXAMPLE_URL
+ );
+ const contentBrowser = await new Promise(resolveOnContentBrowserCreated =>
+ privateWindow.openWebLinkIn(EXAMPLE_URL, "tab", {
+ resolveOnContentBrowserCreated,
+ })
+ );
+
+ const tab = await waitForTabPromise;
+ ok(
+ PrivateBrowsingUtils.isBrowserPrivate(tab),
+ "The new tab was opened in a private browser."
+ );
+ is(
+ contentBrowser,
+ tab.linkedBrowser,
+ "We get a content browser that is the tab's linked browser as the result of opening a private tab in a private window"
+ );
+
+ await BrowserTestUtils.closeWindow(privateWindow);
+});
+
+add_task(async function test_open_non_private_tab_from_private_window() {
+ const privateWindow = await BrowserTestUtils.openNewBrowserWindow({
+ private: true,
+ });
+
+ // Opening this tab from the private window should open it in the non-private window.
+ const waitForTabPromise = BrowserTestUtils.waitForNewTab(
+ gBrowser,
+ EXAMPLE_URL
+ );
+
+ const contentBrowser = await new Promise(resolveOnContentBrowserCreated =>
+ privateWindow.openWebLinkIn(EXAMPLE_URL, "tab", {
+ forceNonPrivate: true,
+ resolveOnContentBrowserCreated,
+ })
+ );
+
+ const nonPrivateTab = await waitForTabPromise;
+ ok(
+ !PrivateBrowsingUtils.isBrowserPrivate(nonPrivateTab),
+ "The new window isn't a private window."
+ );
+ is(
+ contentBrowser,
+ nonPrivateTab.linkedBrowser,
+ "We get a content browser that is the non private tab's linked browser as the result of opening a non private tab from a private window"
+ );
+
+ BrowserTestUtils.removeTab(nonPrivateTab);
+ await BrowserTestUtils.closeWindow(privateWindow);
+});
+
+add_task(async function test_open_non_private_tab_from_only_private_window() {
+ const privateWindow = await BrowserTestUtils.openNewBrowserWindow({
+ private: true,
+ });
+
+ // In this test we'll hide the existing window from window trackers, because
+ // we want to test that we open a new window when there's only a private
+ // window.
+ concealMainWindow();
+
+ // Opening this tab from the private window should open it in a new non-private window.
+ const waitForWindowPromise = BrowserTestUtils.waitForNewWindow({
+ url: EXAMPLE_URL,
+ });
+ const contentBrowser = await new Promise(resolveOnContentBrowserCreated =>
+ privateWindow.openWebLinkIn(EXAMPLE_URL, "tab", {
+ forceNonPrivate: true,
+ resolveOnContentBrowserCreated,
+ })
+ );
+
+ const nonPrivateWindow = await waitForWindowPromise;
+ ok(
+ !PrivateBrowsingUtils.isBrowserPrivate(nonPrivateWindow),
+ "The new window isn't a private window."
+ );
+ is(
+ contentBrowser,
+ nonPrivateWindow.gBrowser.selectedBrowser,
+ "We get the content browser for the newly opened non private window from a private window, as a result of opening a non private tab."
+ );
+
+ await BrowserTestUtils.closeWindow(nonPrivateWindow);
+ await BrowserTestUtils.closeWindow(privateWindow);
+});
diff --git a/browser/base/content/test/webextensions/.eslintrc.js b/browser/base/content/test/webextensions/.eslintrc.js
new file mode 100644
index 0000000000..e57058ecb1
--- /dev/null
+++ b/browser/base/content/test/webextensions/.eslintrc.js
@@ -0,0 +1,7 @@
+"use strict";
+
+module.exports = {
+ env: {
+ webextensions: true,
+ },
+};
diff --git a/browser/base/content/test/webextensions/browser.ini b/browser/base/content/test/webextensions/browser.ini
new file mode 100644
index 0000000000..d90da6c891
--- /dev/null
+++ b/browser/base/content/test/webextensions/browser.ini
@@ -0,0 +1,33 @@
+[DEFAULT]
+support-files =
+ head.js
+ file_install_extensions.html
+ browser_legacy_webext.xpi
+ browser_webext_permissions.xpi
+ browser_webext_nopermissions.xpi
+ browser_webext_unsigned.xpi
+ browser_webext_update1.xpi
+ browser_webext_update2.xpi
+ browser_webext_update_icon1.xpi
+ browser_webext_update_icon2.xpi
+ browser_webext_update_perms1.xpi
+ browser_webext_update_perms2.xpi
+ browser_webext_update_origins1.xpi
+ browser_webext_update_origins2.xpi
+ browser_webext_update.json
+
+[browser_aboutaddons_blanktab.js]
+[browser_extension_sideloading.js]
+[browser_extension_update_background.js]
+[browser_extension_update_background_noprompt.js]
+[browser_permissions_dismiss.js]
+[browser_permissions_installTrigger.js]
+[browser_permissions_local_file.js]
+[browser_permissions_mozAddonManager.js]
+[browser_permissions_optional.js]
+[browser_permissions_pointerevent.js]
+[browser_permissions_unsigned.js]
+skip-if = require_signing
+[browser_update_checkForUpdates.js]
+skip-if = os == "win" && os_version == "6.1" # Skip on Azure - frequent failure
+[browser_update_interactive_noprompt.js]
diff --git a/browser/base/content/test/webextensions/browser_aboutaddons_blanktab.js b/browser/base/content/test/webextensions/browser_aboutaddons_blanktab.js
new file mode 100644
index 0000000000..228fe71815
--- /dev/null
+++ b/browser/base/content/test/webextensions/browser_aboutaddons_blanktab.js
@@ -0,0 +1,26 @@
+/* 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/. */
+
+add_task(async function testBlankTabReusedAboutAddons() {
+ await BrowserTestUtils.withNewTab({ gBrowser }, async browser => {
+ let tabCount = gBrowser.tabs.length;
+ is(browser, gBrowser.selectedBrowser, "New tab is selected");
+
+ // Opening about:addons shouldn't change the selected tab.
+ BrowserOpenAddonsMgr();
+
+ is(browser, gBrowser.selectedBrowser, "No new tab was opened");
+
+ // Wait for about:addons to load.
+ await BrowserTestUtils.browserLoaded(browser);
+
+ is(
+ browser.currentURI.spec,
+ "about:addons",
+ "about:addons should load into blank tab."
+ );
+
+ is(gBrowser.tabs.length, tabCount, "Still the same number of tabs");
+ });
+});
diff --git a/browser/base/content/test/webextensions/browser_extension_sideloading.js b/browser/base/content/test/webextensions/browser_extension_sideloading.js
new file mode 100644
index 0000000000..f1a7dca436
--- /dev/null
+++ b/browser/base/content/test/webextensions/browser_extension_sideloading.js
@@ -0,0 +1,404 @@
+/* eslint-disable mozilla/no-arbitrary-setTimeout */
+const { AddonManagerPrivate } = ChromeUtils.importESModule(
+ "resource://gre/modules/AddonManager.sys.mjs"
+);
+
+const { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+
+AddonTestUtils.initMochitest(this);
+
+AddonTestUtils.hookAMTelemetryEvents();
+
+const kSideloaded = true;
+
+async function createWebExtension(details) {
+ let options = {
+ manifest: {
+ browser_specific_settings: { gecko: { id: details.id } },
+
+ name: details.name,
+
+ permissions: details.permissions,
+ },
+ };
+
+ if (details.iconURL) {
+ options.manifest.icons = { 64: details.iconURL };
+ }
+
+ let xpi = AddonTestUtils.createTempWebExtensionFile(options);
+
+ await AddonTestUtils.manuallyInstall(xpi);
+}
+
+function promiseEvent(eventEmitter, event) {
+ return new Promise(resolve => {
+ eventEmitter.once(event, resolve);
+ });
+}
+
+function getAddonElement(managerWindow, addonId) {
+ return TestUtils.waitForCondition(
+ () =>
+ managerWindow.document.querySelector(`addon-card[addon-id="${addonId}"]`),
+ `Found entry for sideload extension addon "${addonId}" in HTML about:addons`
+ );
+}
+
+function assertSideloadedAddonElementState(addonElement, pressed) {
+ const enableBtn = addonElement.querySelector('[action="toggle-disabled"]');
+ is(
+ enableBtn.pressed,
+ pressed,
+ `The enable button is ${!pressed ? " not " : ""} pressed`
+ );
+ is(enableBtn.localName, "moz-toggle", "The enable button is a toggle");
+}
+
+function clickEnableExtension(addonElement) {
+ addonElement.querySelector('[action="toggle-disabled"]').click();
+}
+
+add_task(async function test_sideloading() {
+ const DEFAULT_ICON_URL =
+ "chrome://mozapps/skin/extensions/extensionGeneric.svg";
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["xpinstall.signatures.required", false],
+ ["extensions.autoDisableScopes", 15],
+ ["extensions.ui.ignoreUnsigned", true],
+ ],
+ });
+
+ const ID1 = "addon1@tests.mozilla.org";
+ await createWebExtension({
+ id: ID1,
+ name: "Test 1",
+ userDisabled: true,
+ permissions: ["history", "https://*/*"],
+ iconURL: "foo-icon.png",
+ });
+
+ const ID2 = "addon2@tests.mozilla.org";
+ await createWebExtension({
+ id: ID2,
+ name: "Test 2",
+ permissions: ["<all_urls>"],
+ });
+
+ const ID3 = "addon3@tests.mozilla.org";
+ await createWebExtension({
+ id: ID3,
+ name: "Test 3",
+ permissions: ["<all_urls>"],
+ });
+
+ testCleanup = async function () {
+ // clear out ExtensionsUI state about sideloaded extensions so
+ // subsequent tests don't get confused.
+ ExtensionsUI.sideloaded.clear();
+ ExtensionsUI.emit("change");
+ };
+
+ // Navigate away from the starting page to force about:addons to load
+ // in a new tab during the tests below.
+ BrowserTestUtils.loadURIString(gBrowser.selectedBrowser, "about:robots");
+ await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+
+ registerCleanupFunction(async function () {
+ // Return to about:blank when we're done
+ BrowserTestUtils.loadURIString(gBrowser.selectedBrowser, "about:blank");
+ await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+ });
+
+ let changePromise = new Promise(resolve => {
+ ExtensionsUI.on("change", function listener() {
+ ExtensionsUI.off("change", listener);
+ resolve();
+ });
+ });
+ ExtensionsUI._checkForSideloaded();
+ await changePromise;
+
+ // Check for the addons badge on the hamburger menu
+ let menuButton = document.getElementById("PanelUI-menu-button");
+ is(
+ menuButton.getAttribute("badge-status"),
+ "addon-alert",
+ "Should have addon alert badge"
+ );
+
+ // Find the menu entries for sideloaded extensions
+ await gCUITestUtils.openMainMenu();
+
+ let addons = PanelUI.addonNotificationContainer;
+ is(
+ addons.children.length,
+ 3,
+ "Have 3 menu entries for sideloaded extensions"
+ );
+
+ info(
+ "Test disabling sideloaded addon 1 using the permission prompt secondary button"
+ );
+
+ // Click the first sideloaded extension
+ let popupPromise = promisePopupNotificationShown("addon-webext-permissions");
+ addons.children[0].click();
+
+ // The click should hide the main menu. This is currently synchronous.
+ ok(PanelUI.panel.state != "open", "Main menu is closed or closing.");
+
+ // When we get the permissions prompt, we should be at the extensions
+ // list in about:addons
+ let panel = await popupPromise;
+ is(
+ gBrowser.currentURI.spec,
+ "about:addons",
+ "Foreground tab is at about:addons"
+ );
+
+ const VIEW = "addons://list/extension";
+ let win = gBrowser.selectedBrowser.contentWindow;
+
+ await TestUtils.waitForCondition(
+ () => !win.gViewController.isLoading,
+ "about:addons view is fully loaded"
+ );
+ is(
+ win.gViewController.currentViewId,
+ VIEW,
+ "about:addons is at extensions list"
+ );
+
+ // Check the contents of the notification, then choose "Cancel"
+ checkNotification(
+ panel,
+ /\/foo-icon\.png$/,
+ [
+ ["webext-perms-host-description-all-urls"],
+ ["webext-perms-description-history"],
+ ],
+ kSideloaded
+ );
+
+ panel.secondaryButton.click();
+
+ let [addon1, addon2, addon3] = await AddonManager.getAddonsByIDs([
+ ID1,
+ ID2,
+ ID3,
+ ]);
+ ok(addon1.seen, "Addon should be marked as seen");
+ is(addon1.userDisabled, true, "Addon 1 should still be disabled");
+ is(addon2.userDisabled, true, "Addon 2 should still be disabled");
+ is(addon3.userDisabled, true, "Addon 3 should still be disabled");
+
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+
+ // Should still have 2 entries in the hamburger menu
+ await gCUITestUtils.openMainMenu();
+
+ addons = PanelUI.addonNotificationContainer;
+ is(
+ addons.children.length,
+ 2,
+ "Have 2 menu entries for sideloaded extensions"
+ );
+
+ // Close the hamburger menu and go directly to the addons manager
+ await gCUITestUtils.hideMainMenu();
+
+ win = await BrowserOpenAddonsMgr(VIEW);
+ await waitAboutAddonsViewLoaded(win.document);
+
+ // about:addons addon entry element.
+ const addonElement = await getAddonElement(win, ID2);
+
+ assertSideloadedAddonElementState(addonElement, false);
+
+ info("Test enabling sideloaded addon 2 from about:addons enable button");
+
+ // When clicking enable we should see the permissions notification
+ popupPromise = promisePopupNotificationShown("addon-webext-permissions");
+ clickEnableExtension(addonElement);
+ panel = await popupPromise;
+ checkNotification(
+ panel,
+ DEFAULT_ICON_URL,
+ [["webext-perms-host-description-all-urls"]],
+ kSideloaded
+ );
+
+ // Test incognito checkbox in post install notification
+ function setupPostInstallNotificationTest() {
+ let promiseNotificationShown =
+ promiseAppMenuNotificationShown("addon-installed");
+ return async function (addon) {
+ info(`Expect post install notification for "${addon.name}"`);
+ let postInstallPanel = await promiseNotificationShown;
+ let incognitoCheckbox = postInstallPanel.querySelector(
+ "#addon-incognito-checkbox"
+ );
+ is(
+ window.AppMenuNotifications.activeNotification.options.name,
+ addon.name,
+ "Got the expected addon name in the active notification"
+ );
+ ok(
+ incognitoCheckbox,
+ "Got an incognito checkbox in the post install notification panel"
+ );
+ ok(!incognitoCheckbox.hidden, "Incognito checkbox should not be hidden");
+ // Dismiss post install notification.
+ postInstallPanel.button.click();
+ };
+ }
+
+ // Setup async test for post install notification on addon 2
+ let testPostInstallIncognitoCheckbox = setupPostInstallNotificationTest();
+
+ // Accept the permissions
+ panel.button.click();
+ await promiseEvent(ExtensionsUI, "change");
+
+ addon2 = await AddonManager.getAddonByID(ID2);
+ is(addon2.userDisabled, false, "Addon 2 should be enabled");
+ assertSideloadedAddonElementState(addonElement, true);
+
+ // Test post install notification on addon 2.
+ await testPostInstallIncognitoCheckbox(addon2);
+
+ // Should still have 1 entry in the hamburger menu
+ await gCUITestUtils.openMainMenu();
+
+ addons = PanelUI.addonNotificationContainer;
+ is(addons.children.length, 1, "Have 1 menu entry for sideloaded extensions");
+
+ // Close the hamburger menu and go to the detail page for this addon
+ await gCUITestUtils.hideMainMenu();
+
+ win = await BrowserOpenAddonsMgr(
+ `addons://detail/${encodeURIComponent(ID3)}`
+ );
+
+ info("Test enabling sideloaded addon 3 from app menu");
+ // Trigger addon 3 install as triggered from the app menu, to be able to cover the
+ // post install notification that should be triggered when the permission
+ // dialog is accepted from that flow.
+ popupPromise = promisePopupNotificationShown("addon-webext-permissions");
+ ExtensionsUI.showSideloaded(gBrowser, addon3);
+
+ panel = await popupPromise;
+ checkNotification(
+ panel,
+ DEFAULT_ICON_URL,
+ [["webext-perms-host-description-all-urls"]],
+ kSideloaded
+ );
+
+ // Setup async test for post install notification on addon 3
+ testPostInstallIncognitoCheckbox = setupPostInstallNotificationTest();
+
+ // Accept the permissions
+ panel.button.click();
+ await promiseEvent(ExtensionsUI, "change");
+
+ addon3 = await AddonManager.getAddonByID(ID3);
+ is(addon3.userDisabled, false, "Addon 3 should be enabled");
+
+ // Test post install notification on addon 3.
+ await testPostInstallIncognitoCheckbox(addon3);
+
+ isnot(
+ menuButton.getAttribute("badge-status"),
+ "addon-alert",
+ "Should no longer have addon alert badge"
+ );
+
+ await new Promise(resolve => setTimeout(resolve, 100));
+
+ for (let addon of [addon1, addon2, addon3]) {
+ await addon.uninstall();
+ }
+
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+
+ // Assert that the expected AddonManager telemetry are being recorded.
+ const expectedExtra = { source: "app-profile", method: "sideload" };
+
+ const baseEvent = { object: "extension", extra: expectedExtra };
+ const createBaseEventAddon = n => ({
+ ...baseEvent,
+ value: `addon${n}@tests.mozilla.org`,
+ });
+ const getEventsForAddonId = (events, addonId) =>
+ events.filter(ev => ev.value === addonId);
+
+ const amEvents = AddonTestUtils.getAMTelemetryEvents();
+
+ // Test telemetry events for addon1 (1 permission and 1 origin).
+ info("Test telemetry events collected for addon1");
+
+ const baseEventAddon1 = createBaseEventAddon(1);
+ const collectedEventsAddon1 = getEventsForAddonId(
+ amEvents,
+ baseEventAddon1.value
+ );
+ const expectedEventsAddon1 = [
+ {
+ ...baseEventAddon1,
+ method: "sideload_prompt",
+ extra: { ...expectedExtra, num_strings: "2" },
+ },
+ { ...baseEventAddon1, method: "uninstall" },
+ ];
+
+ let i = 0;
+ for (let event of collectedEventsAddon1) {
+ Assert.deepEqual(
+ event,
+ expectedEventsAddon1[i++],
+ "Got the expected telemetry event"
+ );
+ }
+
+ is(
+ collectedEventsAddon1.length,
+ expectedEventsAddon1.length,
+ "Got the expected number of telemetry events for addon1"
+ );
+
+ const baseEventAddon2 = createBaseEventAddon(2);
+ const collectedEventsAddon2 = getEventsForAddonId(
+ amEvents,
+ baseEventAddon2.value
+ );
+ const expectedEventsAddon2 = [
+ {
+ ...baseEventAddon2,
+ method: "sideload_prompt",
+ extra: { ...expectedExtra, num_strings: "1" },
+ },
+ { ...baseEventAddon2, method: "enable" },
+ { ...baseEventAddon2, method: "uninstall" },
+ ];
+
+ i = 0;
+ for (let event of collectedEventsAddon2) {
+ Assert.deepEqual(
+ event,
+ expectedEventsAddon2[i++],
+ "Got the expected telemetry event"
+ );
+ }
+
+ is(
+ collectedEventsAddon2.length,
+ expectedEventsAddon2.length,
+ "Got the expected number of telemetry events for addon2"
+ );
+});
diff --git a/browser/base/content/test/webextensions/browser_extension_update_background.js b/browser/base/content/test/webextensions/browser_extension_update_background.js
new file mode 100644
index 0000000000..b0a4a31439
--- /dev/null
+++ b/browser/base/content/test/webextensions/browser_extension_update_background.js
@@ -0,0 +1,282 @@
+const { AddonManagerPrivate } = ChromeUtils.importESModule(
+ "resource://gre/modules/AddonManager.sys.mjs"
+);
+
+const { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+
+AddonTestUtils.initMochitest(this);
+AddonTestUtils.hookAMTelemetryEvents();
+
+const ID = "update2@tests.mozilla.org";
+const ID_ICON = "update_icon2@tests.mozilla.org";
+const ID_PERMS = "update_perms@tests.mozilla.org";
+const ID_LEGACY = "legacy_update@tests.mozilla.org";
+const FAKE_INSTALL_TELEMETRY_SOURCE = "fake-install-source";
+
+requestLongerTimeout(2);
+
+function promiseViewLoaded(tab, viewid) {
+ let win = tab.linkedBrowser.contentWindow;
+ if (
+ win.gViewController &&
+ !win.gViewController.isLoading &&
+ win.gViewController.currentViewId == viewid
+ ) {
+ return Promise.resolve();
+ }
+
+ return waitAboutAddonsViewLoaded(win.document);
+}
+
+function getBadgeStatus() {
+ let menuButton = document.getElementById("PanelUI-menu-button");
+ return menuButton.getAttribute("badge-status");
+}
+
+// Set some prefs that apply to all the tests in this file
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ // We don't have pre-pinned certificates for the local mochitest server
+ ["extensions.install.requireBuiltInCerts", false],
+ ["extensions.update.requireBuiltInCerts", false],
+ ],
+ });
+
+ // Navigate away from the initial page so that about:addons always
+ // opens in a new tab during tests
+ BrowserTestUtils.loadURIString(gBrowser.selectedBrowser, "about:robots");
+ await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+
+ registerCleanupFunction(async function () {
+ // Return to about:blank when we're done
+ BrowserTestUtils.loadURIString(gBrowser.selectedBrowser, "about:blank");
+ await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+ });
+});
+
+// Helper function to test background updates.
+async function backgroundUpdateTest(url, id, checkIconFn) {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ // Turn on background updates
+ ["extensions.update.enabled", true],
+
+ // Point updates to the local mochitest server
+ [
+ "extensions.update.background.url",
+ `${BASE}/browser_webext_update.json`,
+ ],
+ ],
+ });
+
+ // Install version 1.0 of the test extension
+ let addon = await promiseInstallAddon(url, {
+ source: FAKE_INSTALL_TELEMETRY_SOURCE,
+ });
+ let addonId = addon.id;
+
+ ok(addon, "Addon was installed");
+ is(getBadgeStatus(), "", "Should not start out with an addon alert badge");
+
+ // Trigger an update check and wait for the update for this addon
+ // to be downloaded.
+ let updatePromise = promiseInstallEvent(addon, "onDownloadEnded");
+
+ AddonManagerPrivate.backgroundUpdateCheck();
+ await updatePromise;
+
+ is(getBadgeStatus(), "addon-alert", "Should have addon alert badge");
+
+ // Find the menu entry for the update
+ await gCUITestUtils.openMainMenu();
+
+ let addons = PanelUI.addonNotificationContainer;
+ is(addons.children.length, 1, "Have a menu entry for the update");
+
+ // Click the menu item
+ let tabPromise = BrowserTestUtils.waitForNewTab(gBrowser, "about:addons");
+ let popupPromise = promisePopupNotificationShown("addon-webext-permissions");
+ addons.children[0].click();
+
+ // The click should hide the main menu. This is currently synchronous.
+ ok(PanelUI.panel.state != "open", "Main menu is closed or closing.");
+
+ // about:addons should load and go to the list of extensions
+ let tab = await tabPromise;
+ is(
+ tab.linkedBrowser.currentURI.spec,
+ "about:addons",
+ "Browser is at about:addons"
+ );
+
+ const VIEW = "addons://list/extension";
+ await promiseViewLoaded(tab, VIEW);
+ let win = tab.linkedBrowser.contentWindow;
+ ok(!win.gViewController.isLoading, "about:addons view is fully loaded");
+ is(
+ win.gViewController.currentViewId,
+ VIEW,
+ "about:addons is at extensions list"
+ );
+
+ // Wait for the permission prompt, check the contents
+ let panel = await popupPromise;
+ checkIconFn(panel.getAttribute("icon"));
+
+ // The original extension has 1 promptable permission and the new one
+ // has 2 (history and <all_urls>) plus 1 non-promptable permission (cookies).
+ // So we should only see the 1 new promptable permission in the notification.
+ let singlePermissionEl = document.getElementById(
+ "addon-webext-perm-single-entry"
+ );
+ ok(!singlePermissionEl.hidden, "Single permission entry is not hidden");
+ ok(singlePermissionEl.textContent, "Single permission entry text is set");
+
+ // Cancel the update.
+ panel.secondaryButton.click();
+
+ addon = await AddonManager.getAddonByID(id);
+ is(addon.version, "1.0", "Should still be running the old version");
+
+ BrowserTestUtils.removeTab(tab);
+
+ // Alert badge and hamburger menu items should be gone
+ is(getBadgeStatus(), "", "Addon alert badge should be gone");
+
+ await gCUITestUtils.openMainMenu();
+ addons = PanelUI.addonNotificationContainer;
+ is(addons.children.length, 0, "Update menu entries should be gone");
+ await gCUITestUtils.hideMainMenu();
+
+ // Re-check for an update
+ updatePromise = promiseInstallEvent(addon, "onDownloadEnded");
+ await AddonManagerPrivate.backgroundUpdateCheck();
+ await updatePromise;
+
+ is(getBadgeStatus(), "addon-alert", "Should have addon alert badge");
+
+ // Find the menu entry for the update
+ await gCUITestUtils.openMainMenu();
+
+ addons = PanelUI.addonNotificationContainer;
+ is(addons.children.length, 1, "Have a menu entry for the update");
+
+ // Click the menu item
+ tabPromise = BrowserTestUtils.waitForNewTab(gBrowser, "about:addons", true);
+ popupPromise = promisePopupNotificationShown("addon-webext-permissions");
+
+ addons.children[0].click();
+
+ // Wait for about:addons to load
+ tab = await tabPromise;
+ is(tab.linkedBrowser.currentURI.spec, "about:addons");
+
+ await promiseViewLoaded(tab, VIEW);
+ win = tab.linkedBrowser.contentWindow;
+ ok(!win.gViewController.isLoading, "about:addons view is fully loaded");
+ is(
+ win.gViewController.currentViewId,
+ VIEW,
+ "about:addons is at extensions list"
+ );
+
+ // Wait for the permission prompt and accept it this time
+ updatePromise = waitForUpdate(addon);
+ panel = await popupPromise;
+ panel.button.click();
+
+ addon = await updatePromise;
+ is(addon.version, "2.0", "Should have upgraded to the new version");
+
+ BrowserTestUtils.removeTab(tab);
+
+ is(getBadgeStatus(), "", "Addon alert badge should be gone");
+
+ await addon.uninstall();
+ await SpecialPowers.popPrefEnv();
+
+ // Test that the expected telemetry events have been recorded (and that they include the
+ // permission_prompt event).
+ const amEvents = AddonTestUtils.getAMTelemetryEvents();
+ const updateEvents = amEvents
+ .filter(evt => evt.method === "update")
+ .map(evt => {
+ delete evt.value;
+ return evt;
+ });
+
+ Assert.deepEqual(
+ updateEvents.map(evt => evt.extra && evt.extra.step),
+ [
+ // First update (cancelled).
+ "started",
+ "download_started",
+ "download_completed",
+ "permissions_prompt",
+ "cancelled",
+ // Second update (completed).
+ "started",
+ "download_started",
+ "download_completed",
+ "permissions_prompt",
+ "completed",
+ ],
+ "Got the steps from the collected telemetry events"
+ );
+
+ const method = "update";
+ const object = "extension";
+ const baseExtra = {
+ addon_id: addonId,
+ source: FAKE_INSTALL_TELEMETRY_SOURCE,
+ step: "permissions_prompt",
+ updated_from: "app",
+ };
+
+ // Expect the telemetry events to have num_strings set to 1, as only the origin permissions is going
+ // to be listed in the permission prompt.
+ Assert.deepEqual(
+ updateEvents.filter(
+ evt => evt.extra && evt.extra.step === "permissions_prompt"
+ ),
+ [
+ { method, object, extra: { ...baseExtra, num_strings: "1" } },
+ { method, object, extra: { ...baseExtra, num_strings: "1" } },
+ ],
+ "Got the expected permission_prompts events"
+ );
+}
+
+function checkDefaultIcon(icon) {
+ is(
+ icon,
+ "chrome://mozapps/skin/extensions/extensionGeneric.svg",
+ "Popup has the default extension icon"
+ );
+}
+
+add_task(() =>
+ backgroundUpdateTest(
+ `${BASE}/browser_webext_update1.xpi`,
+ ID,
+ checkDefaultIcon
+ )
+);
+function checkNonDefaultIcon(icon) {
+ // The icon should come from the extension, don't bother with the precise
+ // path, just make sure we've got a jar url pointing to the right path
+ // inside the jar.
+ ok(icon.startsWith("jar:file://"), "Icon is a jar url");
+ ok(icon.endsWith("/icon.png"), "Icon is icon.png inside a jar");
+}
+
+add_task(() =>
+ backgroundUpdateTest(
+ `${BASE}/browser_webext_update_icon1.xpi`,
+ ID_ICON,
+ checkNonDefaultIcon
+ )
+);
diff --git a/browser/base/content/test/webextensions/browser_extension_update_background_noprompt.js b/browser/base/content/test/webextensions/browser_extension_update_background_noprompt.js
new file mode 100644
index 0000000000..81f13302bf
--- /dev/null
+++ b/browser/base/content/test/webextensions/browser_extension_update_background_noprompt.js
@@ -0,0 +1,121 @@
+const { AddonManagerPrivate } = ChromeUtils.importESModule(
+ "resource://gre/modules/AddonManager.sys.mjs"
+);
+
+const { AddonTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/AddonTestUtils.sys.mjs"
+);
+
+AddonTestUtils.initMochitest(this);
+AddonTestUtils.hookAMTelemetryEvents();
+
+const ID_PERMS = "update_perms@tests.mozilla.org";
+const ID_ORIGINS = "update_origins@tests.mozilla.org";
+
+function getBadgeStatus() {
+ let menuButton = document.getElementById("PanelUI-menu-button");
+ return menuButton.getAttribute("badge-status");
+}
+
+// Set some prefs that apply to all the tests in this file
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ // We don't have pre-pinned certificates for the local mochitest server
+ ["extensions.install.requireBuiltInCerts", false],
+ ["extensions.update.requireBuiltInCerts", false],
+ // Don't require the extensions to be signed
+ ["xpinstall.signatures.required", false],
+ ],
+ });
+
+ // Navigate away from the initial page so that about:addons always
+ // opens in a new tab during tests
+ BrowserTestUtils.loadURIString(gBrowser.selectedBrowser, "about:robots");
+ await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+
+ registerCleanupFunction(async function () {
+ // Return to about:blank when we're done
+ BrowserTestUtils.loadURIString(gBrowser.selectedBrowser, "about:blank");
+ await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+ });
+});
+
+// Helper function to test an upgrade that should not show a prompt
+async function testNoPrompt(origUrl, id) {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ // Turn on background updates
+ ["extensions.update.enabled", true],
+
+ // Point updates to the local mochitest server
+ [
+ "extensions.update.background.url",
+ `${BASE}/browser_webext_update.json`,
+ ],
+ ],
+ });
+
+ // Install version 1.0 of the test extension
+ let addon = await promiseInstallAddon(origUrl);
+
+ ok(addon, "Addon was installed");
+
+ let sawPopup = false;
+ PopupNotifications.panel.addEventListener(
+ "popupshown",
+ () => (sawPopup = true),
+ { once: true }
+ );
+
+ // Trigger an update check and wait for the update to be applied.
+ let updatePromise = waitForUpdate(addon);
+ AddonManagerPrivate.backgroundUpdateCheck();
+ await updatePromise;
+
+ // There should be no notifications about the update
+ is(getBadgeStatus(), "", "Should not have addon alert badge");
+
+ await gCUITestUtils.openMainMenu();
+ let addons = PanelUI.addonNotificationContainer;
+ is(addons.children.length, 0, "Have 0 updates in the PanelUI menu");
+ await gCUITestUtils.hideMainMenu();
+
+ ok(!sawPopup, "Should not have seen permissions notification");
+
+ addon = await AddonManager.getAddonByID(id);
+ is(addon.version, "2.0", "Update should have applied");
+
+ await addon.uninstall();
+ await SpecialPowers.popPrefEnv();
+
+ // Test that the expected telemetry events have been recorded (and that they do not
+ // include the permission_prompt event).
+ const amEvents = AddonTestUtils.getAMTelemetryEvents();
+ const updateEventsSteps = amEvents
+ .filter(evt => {
+ return evt.method === "update" && evt.extra && evt.extra.addon_id == id;
+ })
+ .map(evt => {
+ return evt.extra.step;
+ });
+
+ // Expect telemetry events related to a completed update with no permissions_prompt event.
+ Assert.deepEqual(
+ updateEventsSteps,
+ ["started", "download_started", "download_completed", "completed"],
+ "Got the steps from the collected telemetry events"
+ );
+}
+
+// Test that an update that adds new non-promptable permissions is just
+// applied without showing a notification dialog.
+add_task(() =>
+ testNoPrompt(`${BASE}/browser_webext_update_perms1.xpi`, ID_PERMS)
+);
+
+// Test that an update that narrows origin permissions is just applied without
+// showing a notification promt
+add_task(() =>
+ testNoPrompt(`${BASE}/browser_webext_update_origins1.xpi`, ID_ORIGINS)
+);
diff --git a/browser/base/content/test/webextensions/browser_legacy_webext.xpi b/browser/base/content/test/webextensions/browser_legacy_webext.xpi
new file mode 100644
index 0000000000..a3bdf6f832
--- /dev/null
+++ b/browser/base/content/test/webextensions/browser_legacy_webext.xpi
Binary files differ
diff --git a/browser/base/content/test/webextensions/browser_permissions_dismiss.js b/browser/base/content/test/webextensions/browser_permissions_dismiss.js
new file mode 100644
index 0000000000..11c12389cc
--- /dev/null
+++ b/browser/base/content/test/webextensions/browser_permissions_dismiss.js
@@ -0,0 +1,112 @@
+"use strict";
+
+const INSTALL_PAGE = `${BASE}/file_install_extensions.html`;
+const INSTALL_XPI = `${BASE}/browser_webext_permissions.xpi`;
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["extensions.webapi.testing", true],
+ ["extensions.install.requireBuiltInCerts", false],
+ ],
+ });
+
+ registerCleanupFunction(async () => {
+ await SpecialPowers.popPrefEnv();
+ });
+});
+
+add_task(async function test_tab_switch_dismiss() {
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, INSTALL_PAGE);
+
+ let installCanceled = new Promise(resolve => {
+ let listener = {
+ onInstallCancelled() {
+ AddonManager.removeInstallListener(listener);
+ resolve();
+ },
+ };
+ AddonManager.addInstallListener(listener);
+ });
+
+ SpecialPowers.spawn(gBrowser.selectedBrowser, [INSTALL_XPI], function (url) {
+ content.wrappedJSObject.installMozAM(url);
+ });
+
+ await promisePopupNotificationShown("addon-webext-permissions");
+ let permsUL = document.getElementById("addon-webext-perm-list");
+ is(permsUL.childElementCount, 5, `Permissions list has 5 entries`);
+
+ let permsLearnMore = document.getElementById("addon-webext-perm-info");
+ ok(
+ BrowserTestUtils.is_visible(permsLearnMore),
+ "Learn more link is shown on Permission popup"
+ );
+ is(
+ permsLearnMore.href,
+ Services.urlFormatter.formatURLPref("app.support.baseURL") +
+ "extension-permissions",
+ "Learn more link has desired URL"
+ );
+
+ // Switching tabs dismisses the notification and cancels the install.
+ let switchTo = await BrowserTestUtils.openNewForegroundTab(gBrowser);
+ BrowserTestUtils.removeTab(switchTo);
+ await installCanceled;
+
+ let addon = await AddonManager.getAddonByID("permissions@test.mozilla.org");
+ is(addon, null, "Extension is not installed");
+
+ BrowserTestUtils.removeTab(tab);
+});
+
+add_task(async function test_add_tab_by_user_and_switch() {
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, INSTALL_PAGE);
+
+ let listener = {
+ onInstallCancelled() {
+ this.canceledPromise = Promise.resolve();
+ },
+ };
+ AddonManager.addInstallListener(listener);
+
+ SpecialPowers.spawn(gBrowser.selectedBrowser, [INSTALL_XPI], function (url) {
+ content.wrappedJSObject.installMozAM(url);
+ });
+
+ // Show addon permission notification.
+ await promisePopupNotificationShown("addon-webext-permissions");
+ is(
+ document.getElementById("addon-webext-perm-list").childElementCount,
+ 5,
+ "Permissions list has 5 entries"
+ );
+
+ // Open about:newtab page in a new tab.
+ let newTab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "about:newtab",
+ false
+ );
+
+ // Switch to tab that is opening addon permission notification.
+ gBrowser.selectedTab = tab;
+ is(
+ document.getElementById("addon-webext-perm-list").childElementCount,
+ 5,
+ "Permission notification is shown again"
+ );
+ ok(!listener.canceledPromise, "Extension installation is not canceled");
+
+ // Cancel installation.
+ document.querySelector(".popup-notification-secondary-button").click();
+ await listener.canceledPromise;
+ info("Extension installation is canceled");
+
+ let addon = await AddonManager.getAddonByID("permissions@test.mozilla.org");
+ is(addon, null, "Extension is not installed");
+
+ AddonManager.removeInstallListener(listener);
+ BrowserTestUtils.removeTab(tab);
+ BrowserTestUtils.removeTab(newTab);
+});
diff --git a/browser/base/content/test/webextensions/browser_permissions_installTrigger.js b/browser/base/content/test/webextensions/browser_permissions_installTrigger.js
new file mode 100644
index 0000000000..6cd99d699b
--- /dev/null
+++ b/browser/base/content/test/webextensions/browser_permissions_installTrigger.js
@@ -0,0 +1,26 @@
+"use strict";
+
+const INSTALL_PAGE = `${BASE}/file_install_extensions.html`;
+
+async function installTrigger(filename) {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["extensions.InstallTrigger.enabled", true],
+ ["extensions.InstallTriggerImpl.enabled", true],
+ // Relax the user input requirements while running this test.
+ ["xpinstall.userActivation.required", false],
+ ],
+ });
+ BrowserTestUtils.loadURIString(gBrowser.selectedBrowser, INSTALL_PAGE);
+ await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+
+ SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [`${BASE}/${filename}`],
+ async function (url) {
+ content.wrappedJSObject.installTrigger(url);
+ }
+ );
+}
+
+add_task(() => testInstallMethod(installTrigger, "installAmo"));
diff --git a/browser/base/content/test/webextensions/browser_permissions_local_file.js b/browser/base/content/test/webextensions/browser_permissions_local_file.js
new file mode 100644
index 0000000000..a2fdc34db3
--- /dev/null
+++ b/browser/base/content/test/webextensions/browser_permissions_local_file.js
@@ -0,0 +1,43 @@
+"use strict";
+
+async function installFile(filename) {
+ const ChromeRegistry = Cc["@mozilla.org/chrome/chrome-registry;1"].getService(
+ Ci.nsIChromeRegistry
+ );
+ let chromeUrl = Services.io.newURI(gTestPath);
+ let fileUrl = ChromeRegistry.convertChromeURL(chromeUrl);
+ let file = fileUrl.QueryInterface(Ci.nsIFileURL).file;
+ file.leafName = filename;
+
+ let MockFilePicker = SpecialPowers.MockFilePicker;
+ MockFilePicker.init(window);
+ MockFilePicker.setFiles([file]);
+ MockFilePicker.afterOpenCallback = MockFilePicker.cleanup;
+
+ let { document } = await BrowserOpenAddonsMgr("addons://list/extension");
+
+ // Do the install...
+ await waitAboutAddonsViewLoaded(document);
+ let installButton = document.querySelector('[action="install-from-file"]');
+ installButton.click();
+}
+
+add_task(async function test_install_extension_from_local_file() {
+ // Listen for the first installId so we can check it later.
+ let firstInstallId = null;
+ AddonManager.addInstallListener({
+ onNewInstall(install) {
+ firstInstallId = install.installId;
+ AddonManager.removeInstallListener(this);
+ },
+ });
+
+ // Install the add-ons.
+ await testInstallMethod(installFile, "installLocal");
+
+ // Check we got an installId.
+ ok(
+ firstInstallId != null && !isNaN(firstInstallId),
+ "There was an installId found"
+ );
+});
diff --git a/browser/base/content/test/webextensions/browser_permissions_mozAddonManager.js b/browser/base/content/test/webextensions/browser_permissions_mozAddonManager.js
new file mode 100644
index 0000000000..1370ff18f7
--- /dev/null
+++ b/browser/base/content/test/webextensions/browser_permissions_mozAddonManager.js
@@ -0,0 +1,18 @@
+"use strict";
+
+const INSTALL_PAGE = `${BASE}/file_install_extensions.html`;
+
+async function installMozAM(filename) {
+ BrowserTestUtils.loadURIString(gBrowser.selectedBrowser, INSTALL_PAGE);
+ await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+
+ await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [`${BASE}/${filename}`],
+ async function (url) {
+ await content.wrappedJSObject.installMozAM(url);
+ }
+ );
+}
+
+add_task(() => testInstallMethod(installMozAM, "installAmo"));
diff --git a/browser/base/content/test/webextensions/browser_permissions_optional.js b/browser/base/content/test/webextensions/browser_permissions_optional.js
new file mode 100644
index 0000000000..7c8a654cbc
--- /dev/null
+++ b/browser/base/content/test/webextensions/browser_permissions_optional.js
@@ -0,0 +1,52 @@
+"use strict";
+add_task(async function test_request_permissions_without_prompt() {
+ async function pageScript() {
+ const NO_PROMPT_PERM = "activeTab";
+ window.addEventListener(
+ "keypress",
+ async () => {
+ let permGranted = await browser.permissions.request({
+ permissions: [NO_PROMPT_PERM],
+ });
+ browser.test.assertTrue(
+ permGranted,
+ `${NO_PROMPT_PERM} permission was granted.`
+ );
+ let perms = await browser.permissions.getAll();
+ browser.test.assertTrue(
+ perms.permissions.includes(NO_PROMPT_PERM),
+ `${NO_PROMPT_PERM} permission exists.`
+ );
+ browser.test.sendMessage("permsGranted");
+ },
+ { once: true }
+ );
+ browser.test.sendMessage("pageReady");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background() {
+ browser.test.sendMessage("ready", browser.runtime.getURL("page.html"));
+ },
+ files: {
+ "page.html": `<html><head><script src="page.js"></script></head></html>`,
+ "page.js": pageScript,
+ },
+ manifest: {
+ optional_permissions: ["activeTab"],
+ },
+ });
+ await extension.startup();
+
+ let url = await extension.awaitMessage("ready");
+
+ await BrowserTestUtils.withNewTab({ gBrowser, url }, async browser => {
+ await extension.awaitMessage("pageReady");
+
+ await BrowserTestUtils.synthesizeKey("a", {}, browser);
+
+ await extension.awaitMessage("permsGranted");
+ });
+
+ await extension.unload();
+});
diff --git a/browser/base/content/test/webextensions/browser_permissions_pointerevent.js b/browser/base/content/test/webextensions/browser_permissions_pointerevent.js
new file mode 100644
index 0000000000..188aa8e3bf
--- /dev/null
+++ b/browser/base/content/test/webextensions/browser_permissions_pointerevent.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_pointerevent() {
+ async function contentScript() {
+ document.addEventListener("pointerdown", async e => {
+ browser.test.assertTrue(true, "Should receive pointerdown");
+ e.preventDefault();
+ });
+
+ document.addEventListener("mousedown", e => {
+ browser.test.assertTrue(true, "Should receive mousedown");
+ });
+
+ document.addEventListener("mouseup", e => {
+ browser.test.assertTrue(true, "Should receive mouseup");
+ });
+
+ document.addEventListener("pointerup", e => {
+ browser.test.assertTrue(true, "Should receive pointerup");
+ browser.test.sendMessage("done");
+ });
+ browser.test.sendMessage("pageReady");
+ }
+
+ let extension = ExtensionTestUtils.loadExtension({
+ background() {
+ browser.test.sendMessage("ready", browser.runtime.getURL("page.html"));
+ },
+ files: {
+ "page.html": `<html><head><script src="page.js"></script></head></html>`,
+ "page.js": contentScript,
+ },
+ });
+ await extension.startup();
+ let url = await extension.awaitMessage("ready");
+ await BrowserTestUtils.withNewTab({ gBrowser, url }, async browser => {
+ await extension.awaitMessage("pageReady");
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "html",
+ { type: "mousedown", button: 0 },
+ browser
+ );
+ await BrowserTestUtils.synthesizeMouseAtCenter(
+ "html",
+ { type: "mouseup", button: 0 },
+ browser
+ );
+ await extension.awaitMessage("done");
+ });
+ await extension.unload();
+});
diff --git a/browser/base/content/test/webextensions/browser_permissions_unsigned.js b/browser/base/content/test/webextensions/browser_permissions_unsigned.js
new file mode 100644
index 0000000000..dff7ad872e
--- /dev/null
+++ b/browser/base/content/test/webextensions/browser_permissions_unsigned.js
@@ -0,0 +1,63 @@
+"use strict";
+
+const ID = "permissions@test.mozilla.org";
+const WARNING_ICON = "chrome://global/skin/icons/warning.svg";
+
+add_task(async function test_unsigned() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["extensions.webapi.testing", true],
+ ["extensions.install.requireBuiltInCerts", false],
+ ["extensions.InstallTrigger.enabled", true],
+ ["extensions.InstallTriggerImpl.enabled", true],
+ // Relax the user input requirements while running this test.
+ ["xpinstall.userActivation.required", false],
+ ],
+ });
+
+ let testURI = makeURI("https://example.com/");
+ PermissionTestUtils.add(testURI, "install", Services.perms.ALLOW_ACTION);
+ registerCleanupFunction(() => PermissionTestUtils.remove(testURI, "install"));
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser);
+
+ BrowserTestUtils.loadURIString(
+ gBrowser.selectedBrowser,
+ `${BASE}/file_install_extensions.html`
+ );
+ await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+
+ SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [`${BASE}/browser_webext_unsigned.xpi`],
+ async function (url) {
+ content.wrappedJSObject.installTrigger(url);
+ }
+ );
+
+ let panel = await promisePopupNotificationShown("addon-webext-permissions");
+
+ is(panel.getAttribute("icon"), WARNING_ICON);
+ let description = panel.querySelector(
+ ".popup-notification-description"
+ ).textContent;
+ const expected = formatExtValue("webext-perms-header-unsigned-with-perms", {
+ extension: "<>",
+ });
+ for (let part of expected.split("<>")) {
+ ok(
+ description.includes(part),
+ "Install notification includes unsigned warning"
+ );
+ }
+
+ // cancel the install
+ let promise = promiseInstallEvent({ id: ID }, "onInstallCancelled");
+ panel.secondaryButton.click();
+ await promise;
+
+ let addon = await AddonManager.getAddonByID(ID);
+ is(addon, null, "Extension is not installed");
+
+ BrowserTestUtils.removeTab(tab);
+});
diff --git a/browser/base/content/test/webextensions/browser_update_checkForUpdates.js b/browser/base/content/test/webextensions/browser_update_checkForUpdates.js
new file mode 100644
index 0000000000..b902527cae
--- /dev/null
+++ b/browser/base/content/test/webextensions/browser_update_checkForUpdates.js
@@ -0,0 +1,17 @@
+// Invoke the "Check for Updates" menu item
+function checkAll(win) {
+ triggerPageOptionsAction(win, "check-for-updates");
+ return new Promise(resolve => {
+ let observer = {
+ observe(subject, topic, data) {
+ Services.obs.removeObserver(observer, "EM-update-check-finished");
+ resolve();
+ },
+ };
+ Services.obs.addObserver(observer, "EM-update-check-finished");
+ });
+}
+
+// Test "Check for Updates" with both auto-update settings
+add_task(() => interactiveUpdateTest(true, checkAll));
+add_task(() => interactiveUpdateTest(false, checkAll));
diff --git a/browser/base/content/test/webextensions/browser_update_interactive_noprompt.js b/browser/base/content/test/webextensions/browser_update_interactive_noprompt.js
new file mode 100644
index 0000000000..016eb22667
--- /dev/null
+++ b/browser/base/content/test/webextensions/browser_update_interactive_noprompt.js
@@ -0,0 +1,77 @@
+// Set some prefs that apply to all the tests in this file
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ // We don't have pre-pinned certificates for the local mochitest server
+ ["extensions.install.requireBuiltInCerts", false],
+ ["extensions.update.requireBuiltInCerts", false],
+
+ // Don't require the extensions to be signed
+ ["xpinstall.signatures.required", false],
+
+ // Point updates to the local mochitest server
+ ["extensions.update.url", `${BASE}/browser_webext_update.json`],
+ ],
+ });
+});
+
+// Helper to test that an update of a given extension does not
+// generate any permission prompts.
+async function testUpdateNoPrompt(
+ filename,
+ id,
+ initialVersion = "1.0",
+ updateVersion = "2.0"
+) {
+ // Navigate away to ensure that BrowserOpenAddonMgr() opens a new tab
+ BrowserTestUtils.loadURIString(gBrowser.selectedBrowser, "about:mozilla");
+ await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+
+ // Install initial version of the test extension
+ let addon = await promiseInstallAddon(`${BASE}/${filename}`);
+ ok(addon, "Addon was installed");
+ is(addon.version, initialVersion, "Version 1 of the addon is installed");
+
+ // Go to Extensions in about:addons
+ let win = await BrowserOpenAddonsMgr("addons://list/extension");
+
+ await waitAboutAddonsViewLoaded(win.document);
+
+ let sawPopup = false;
+ function popupListener() {
+ sawPopup = true;
+ }
+ PopupNotifications.panel.addEventListener("popupshown", popupListener);
+
+ // Trigger an update check, we should see the update get applied
+ let updatePromise = waitForUpdate(addon);
+ triggerPageOptionsAction(win, "check-for-updates");
+ await updatePromise;
+
+ addon = await AddonManager.getAddonByID(id);
+ is(addon.version, updateVersion, "Should have upgraded");
+
+ ok(!sawPopup, "Should not have seen a permission notification");
+ PopupNotifications.panel.removeEventListener("popupshown", popupListener);
+
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ await addon.uninstall();
+}
+
+// Test that we don't see a prompt when no new promptable permissions
+// are added.
+add_task(() =>
+ testUpdateNoPrompt(
+ "browser_webext_update_perms1.xpi",
+ "update_perms@tests.mozilla.org"
+ )
+);
+
+// Test that an update that narrows origin permissions is just applied without
+// showing a notification promt
+add_task(() =>
+ testUpdateNoPrompt(
+ "browser_webext_update_origins1.xpi",
+ "update_origins@tests.mozilla.org"
+ )
+);
diff --git a/browser/base/content/test/webextensions/browser_webext_nopermissions.xpi b/browser/base/content/test/webextensions/browser_webext_nopermissions.xpi
new file mode 100644
index 0000000000..ab97d96a11
--- /dev/null
+++ b/browser/base/content/test/webextensions/browser_webext_nopermissions.xpi
Binary files differ
diff --git a/browser/base/content/test/webextensions/browser_webext_permissions.xpi b/browser/base/content/test/webextensions/browser_webext_permissions.xpi
new file mode 100644
index 0000000000..a8c8c38ef8
--- /dev/null
+++ b/browser/base/content/test/webextensions/browser_webext_permissions.xpi
Binary files differ
diff --git a/browser/base/content/test/webextensions/browser_webext_unsigned.xpi b/browser/base/content/test/webextensions/browser_webext_unsigned.xpi
new file mode 100644
index 0000000000..55779530ce
--- /dev/null
+++ b/browser/base/content/test/webextensions/browser_webext_unsigned.xpi
Binary files differ
diff --git a/browser/base/content/test/webextensions/browser_webext_update.json b/browser/base/content/test/webextensions/browser_webext_update.json
new file mode 100644
index 0000000000..ae18044e9c
--- /dev/null
+++ b/browser/base/content/test/webextensions/browser_webext_update.json
@@ -0,0 +1,70 @@
+{
+ "addons": {
+ "update2@tests.mozilla.org": {
+ "updates": [
+ {
+ "version": "2.0",
+ "update_link": "https://example.com/browser/browser/base/content/test/webextensions/browser_webext_update2.xpi",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "1"
+ }
+ }
+ }
+ ]
+ },
+ "update_icon2@tests.mozilla.org": {
+ "updates": [
+ {
+ "version": "2.0",
+ "update_link": "https://example.com/browser/browser/base/content/test/webextensions/browser_webext_update_icon2.xpi",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "1"
+ }
+ }
+ }
+ ]
+ },
+ "update_perms@tests.mozilla.org": {
+ "updates": [
+ {
+ "version": "2.0",
+ "update_link": "https://example.com/browser/browser/base/content/test/webextensions/browser_webext_update_perms2.xpi",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "1"
+ }
+ }
+ }
+ ]
+ },
+ "legacy_update@tests.mozilla.org": {
+ "updates": [
+ {
+ "version": "2.0",
+ "update_link": "https://example.com/browser/browser/base/content/test/webextensions/browser_legacy_webext.xpi",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "1",
+ "advisory_max_version": "*"
+ }
+ }
+ }
+ ]
+ },
+ "update_origins@tests.mozilla.org": {
+ "updates": [
+ {
+ "version": "2.0",
+ "update_link": "https://example.com/browser/browser/base/content/test/webextensions/browser_webext_update_origins2.xpi",
+ "applications": {
+ "gecko": {
+ "strict_min_version": "1"
+ }
+ }
+ }
+ ]
+ }
+ }
+}
diff --git a/browser/base/content/test/webextensions/browser_webext_update1.xpi b/browser/base/content/test/webextensions/browser_webext_update1.xpi
new file mode 100644
index 0000000000..086b3839b9
--- /dev/null
+++ b/browser/base/content/test/webextensions/browser_webext_update1.xpi
Binary files differ
diff --git a/browser/base/content/test/webextensions/browser_webext_update2.xpi b/browser/base/content/test/webextensions/browser_webext_update2.xpi
new file mode 100644
index 0000000000..19967c39c0
--- /dev/null
+++ b/browser/base/content/test/webextensions/browser_webext_update2.xpi
Binary files differ
diff --git a/browser/base/content/test/webextensions/browser_webext_update_icon1.xpi b/browser/base/content/test/webextensions/browser_webext_update_icon1.xpi
new file mode 100644
index 0000000000..24cb7616d2
--- /dev/null
+++ b/browser/base/content/test/webextensions/browser_webext_update_icon1.xpi
Binary files differ
diff --git a/browser/base/content/test/webextensions/browser_webext_update_icon2.xpi b/browser/base/content/test/webextensions/browser_webext_update_icon2.xpi
new file mode 100644
index 0000000000..fd9cf7eb0e
--- /dev/null
+++ b/browser/base/content/test/webextensions/browser_webext_update_icon2.xpi
Binary files differ
diff --git a/browser/base/content/test/webextensions/browser_webext_update_origins1.xpi b/browser/base/content/test/webextensions/browser_webext_update_origins1.xpi
new file mode 100644
index 0000000000..2909f8e8fd
--- /dev/null
+++ b/browser/base/content/test/webextensions/browser_webext_update_origins1.xpi
Binary files differ
diff --git a/browser/base/content/test/webextensions/browser_webext_update_origins2.xpi b/browser/base/content/test/webextensions/browser_webext_update_origins2.xpi
new file mode 100644
index 0000000000..b1051affb1
--- /dev/null
+++ b/browser/base/content/test/webextensions/browser_webext_update_origins2.xpi
Binary files differ
diff --git a/browser/base/content/test/webextensions/browser_webext_update_perms1.xpi b/browser/base/content/test/webextensions/browser_webext_update_perms1.xpi
new file mode 100644
index 0000000000..f4942f9082
--- /dev/null
+++ b/browser/base/content/test/webextensions/browser_webext_update_perms1.xpi
Binary files differ
diff --git a/browser/base/content/test/webextensions/browser_webext_update_perms2.xpi b/browser/base/content/test/webextensions/browser_webext_update_perms2.xpi
new file mode 100644
index 0000000000..2c023edc9d
--- /dev/null
+++ b/browser/base/content/test/webextensions/browser_webext_update_perms2.xpi
Binary files differ
diff --git a/browser/base/content/test/webextensions/file_install_extensions.html b/browser/base/content/test/webextensions/file_install_extensions.html
new file mode 100644
index 0000000000..9dd8ae830d
--- /dev/null
+++ b/browser/base/content/test/webextensions/file_install_extensions.html
@@ -0,0 +1,19 @@
+<!DOCTYPE html>
+
+<html>
+<head>
+ <meta charset="utf-8">
+</head>
+<body>
+<script type="text/javascript">
+function installMozAM(url) {
+ return navigator.mozAddonManager.createInstall({url})
+ .then(install => install.install());
+}
+
+function installTrigger(url) {
+ InstallTrigger.install({extension: url});
+}
+</script>
+</body>
+</html>
diff --git a/browser/base/content/test/webextensions/head.js b/browser/base/content/test/webextensions/head.js
new file mode 100644
index 0000000000..71d1e6d009
--- /dev/null
+++ b/browser/base/content/test/webextensions/head.js
@@ -0,0 +1,650 @@
+ChromeUtils.defineESModuleGetters(this, {
+ AddonTestUtils: "resource://testing-common/AddonTestUtils.sys.mjs",
+});
+
+const BASE = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content/",
+ "https://example.com/"
+);
+
+XPCOMUtils.defineLazyGetter(this, "Management", () => {
+ // eslint-disable-next-line no-shadow
+ const { Management } = ChromeUtils.importESModule(
+ "resource://gre/modules/Extension.sys.mjs"
+ );
+ return Management;
+});
+
+let { CustomizableUITestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/CustomizableUITestUtils.sys.mjs"
+);
+let gCUITestUtils = new CustomizableUITestUtils(window);
+
+const { PermissionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PermissionTestUtils.sys.mjs"
+);
+
+let extL10n = null;
+/**
+ * @param {string} id
+ * @param {object} [args]
+ * @returns {string}
+ */
+function formatExtValue(id, args) {
+ if (!extL10n) {
+ extL10n = new Localization(
+ [
+ "toolkit/global/extensions.ftl",
+ "toolkit/global/extensionPermissions.ftl",
+ "branding/brand.ftl",
+ ],
+ true
+ );
+ }
+ return extL10n.formatValueSync(id, args);
+}
+
+/**
+ * Wait for the given PopupNotification to display
+ *
+ * @param {string} name
+ * The name of the notification to wait for.
+ *
+ * @returns {Promise}
+ * Resolves with the notification window.
+ */
+function promisePopupNotificationShown(name) {
+ return new Promise(resolve => {
+ function popupshown() {
+ let notification = PopupNotifications.getNotification(name);
+ if (!notification) {
+ return;
+ }
+
+ ok(notification, `${name} notification shown`);
+ ok(PopupNotifications.isPanelOpen, "notification panel open");
+
+ PopupNotifications.panel.removeEventListener("popupshown", popupshown);
+ resolve(PopupNotifications.panel.firstElementChild);
+ }
+
+ PopupNotifications.panel.addEventListener("popupshown", popupshown);
+ });
+}
+
+function promiseAppMenuNotificationShown(id) {
+ const { AppMenuNotifications } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppMenuNotifications.sys.mjs"
+ );
+ return new Promise(resolve => {
+ function popupshown() {
+ let notification = AppMenuNotifications.activeNotification;
+ if (!notification) {
+ return;
+ }
+
+ is(notification.id, id, `${id} notification shown`);
+ ok(PanelUI.isNotificationPanelOpen, "notification panel open");
+
+ PanelUI.notificationPanel.removeEventListener("popupshown", popupshown);
+
+ let popupnotificationID = PanelUI._getPopupId(notification);
+ let popupnotification = document.getElementById(popupnotificationID);
+
+ resolve(popupnotification);
+ }
+ PanelUI.notificationPanel.addEventListener("popupshown", popupshown);
+ });
+}
+
+/**
+ * Wait for a specific install event to fire for a given addon
+ *
+ * @param {AddonWrapper} addon
+ * The addon to watch for an event on
+ * @param {string}
+ * The name of the event to watch for (e.g., onInstallEnded)
+ *
+ * @returns {Promise}
+ * Resolves when the event triggers with the first argument
+ * to the event handler as the resolution value.
+ */
+function promiseInstallEvent(addon, event) {
+ return new Promise(resolve => {
+ let listener = {};
+ listener[event] = (install, arg) => {
+ if (install.addon.id == addon.id) {
+ AddonManager.removeInstallListener(listener);
+ resolve(arg);
+ }
+ };
+ AddonManager.addInstallListener(listener);
+ });
+}
+
+/**
+ * Install an (xpi packaged) extension
+ *
+ * @param {string} url
+ * URL of the .xpi file to install
+ * @param {Object?} installTelemetryInfo
+ * an optional object that contains additional details used by the telemetry events.
+ *
+ * @returns {Promise}
+ * Resolves when the extension has been installed with the Addon
+ * object as the resolution value.
+ */
+async function promiseInstallAddon(url, telemetryInfo) {
+ let install = await AddonManager.getInstallForURL(url, { telemetryInfo });
+ install.install();
+
+ let addon = await new Promise(resolve => {
+ install.addListener({
+ onInstallEnded(_install, _addon) {
+ resolve(_addon);
+ },
+ });
+ });
+
+ if (addon.isWebExtension) {
+ await new Promise(resolve => {
+ function listener(event, extension) {
+ if (extension.id == addon.id) {
+ Management.off("ready", listener);
+ resolve();
+ }
+ }
+ Management.on("ready", listener);
+ });
+ }
+
+ return addon;
+}
+
+/**
+ * Wait for an update to the given webextension to complete.
+ * (This does not actually perform an update, it just watches for
+ * the events that occur as a result of an update.)
+ *
+ * @param {AddonWrapper} addon
+ * The addon to be updated.
+ *
+ * @returns {Promise}
+ * Resolves when the extension has ben updated.
+ */
+async function waitForUpdate(addon) {
+ let installPromise = promiseInstallEvent(addon, "onInstallEnded");
+ let readyPromise = new Promise(resolve => {
+ function listener(event, extension) {
+ if (extension.id == addon.id) {
+ Management.off("ready", listener);
+ resolve();
+ }
+ }
+ Management.on("ready", listener);
+ });
+
+ let [newAddon] = await Promise.all([installPromise, readyPromise]);
+ return newAddon;
+}
+
+function waitAboutAddonsViewLoaded(doc) {
+ return BrowserTestUtils.waitForEvent(doc, "view-loaded");
+}
+
+/**
+ * Trigger an action from the page options menu.
+ */
+function triggerPageOptionsAction(win, action) {
+ win.document.querySelector(`#page-options [action="${action}"]`).click();
+}
+
+function isDefaultIcon(icon) {
+ return icon == "chrome://mozapps/skin/extensions/extensionGeneric.svg";
+}
+
+/**
+ * Check the contents of a permission popup notification
+ *
+ * @param {Window} panel
+ * The popup window.
+ * @param {string|regexp|function} checkIcon
+ * The icon expected to appear in the notification. If this is a
+ * string, it must match the icon url exactly. If it is a
+ * regular expression it is tested against the icon url, and if
+ * it is a function, it is called with the icon url and returns
+ * true if the url is correct.
+ * @param {array} permissions
+ * The expected entries in the permissions list. Each element
+ * in this array is itself a 2-element array with the string key
+ * for the item (e.g., "webext-perms-description-foo") and an
+ * optional formatting parameter.
+ * @param {boolean} sideloaded
+ * Whether the notification is for a sideloaded extenion.
+ */
+function checkNotification(panel, checkIcon, permissions, sideloaded) {
+ let icon = panel.getAttribute("icon");
+ let ul = document.getElementById("addon-webext-perm-list");
+ let singleDataEl = document.getElementById("addon-webext-perm-single-entry");
+ let learnMoreLink = document.getElementById("addon-webext-perm-info");
+
+ if (checkIcon instanceof RegExp) {
+ ok(
+ checkIcon.test(icon),
+ `Notification icon is correct ${JSON.stringify(icon)} ~= ${checkIcon}`
+ );
+ } else if (typeof checkIcon == "function") {
+ ok(checkIcon(icon), "Notification icon is correct");
+ } else {
+ is(icon, checkIcon, "Notification icon is correct");
+ }
+
+ let description = panel.querySelector(
+ ".popup-notification-description"
+ ).textContent;
+ let descL10nId = "webext-perms-header";
+ if (permissions.length) {
+ descL10nId = "webext-perms-header-with-perms";
+ }
+ if (sideloaded) {
+ descL10nId = "webext-perms-sideload-header";
+ }
+ const exp = formatExtValue(descL10nId, { extension: "<>" }).split("<>");
+ ok(description.startsWith(exp.at(0)), "Description is the expected one");
+ ok(description.endsWith(exp.at(-1)), "Description is the expected one");
+
+ is(
+ learnMoreLink.hidden,
+ !permissions.length,
+ "Permissions learn more is hidden if there are no permissions"
+ );
+
+ if (!permissions.length) {
+ ok(ul.hidden, "Permissions list is hidden");
+ ok(singleDataEl.hidden, "Single permission data entry is hidden");
+ ok(
+ !(ul.childElementCount || singleDataEl.textContent),
+ "Permission list and single permission element have no entries"
+ );
+ } else if (permissions.length === 1) {
+ ok(ul.hidden, "Permissions list is hidden");
+ ok(!ul.childElementCount, "Permission list has no entries");
+ ok(singleDataEl.textContent, "Single permission data label has been set");
+ } else {
+ ok(singleDataEl.hidden, "Single permission data entry is hidden");
+ ok(
+ !singleDataEl.textContent,
+ "Single permission data label has not been set"
+ );
+ for (let i in permissions) {
+ let [key, param] = permissions[i];
+ const expected = formatExtValue(key, param);
+ is(
+ ul.children[i].textContent,
+ expected,
+ `Permission number ${i + 1} is correct`
+ );
+ }
+ }
+}
+
+/**
+ * Test that install-time permission prompts work for a given
+ * installation method.
+ *
+ * @param {Function} installFn
+ * Callable that takes the name of an xpi file to install and
+ * starts to install it. Should return a Promise that resolves
+ * when the install is finished or rejects if the install is canceled.
+ * @param {string} telemetryBase
+ * If supplied, the base type for telemetry events that should be
+ * recorded for this install method.
+ *
+ * @returns {Promise}
+ */
+async function testInstallMethod(installFn, telemetryBase) {
+ const PERMS_XPI = "browser_webext_permissions.xpi";
+ const NO_PERMS_XPI = "browser_webext_nopermissions.xpi";
+ const ID = "permissions@test.mozilla.org";
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["extensions.webapi.testing", true],
+ ["extensions.install.requireBuiltInCerts", false],
+ ],
+ });
+
+ let testURI = makeURI("https://example.com/");
+ PermissionTestUtils.add(testURI, "install", Services.perms.ALLOW_ACTION);
+ registerCleanupFunction(() => PermissionTestUtils.remove(testURI, "install"));
+
+ async function runOnce(filename, cancel) {
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser);
+
+ let installPromise = new Promise(resolve => {
+ let listener = {
+ onDownloadCancelled() {
+ AddonManager.removeInstallListener(listener);
+ resolve(false);
+ },
+
+ onDownloadFailed() {
+ AddonManager.removeInstallListener(listener);
+ resolve(false);
+ },
+
+ onInstallCancelled() {
+ AddonManager.removeInstallListener(listener);
+ resolve(false);
+ },
+
+ onInstallEnded() {
+ AddonManager.removeInstallListener(listener);
+ resolve(true);
+ },
+
+ onInstallFailed() {
+ AddonManager.removeInstallListener(listener);
+ resolve(false);
+ },
+ };
+ AddonManager.addInstallListener(listener);
+ });
+
+ let installMethodPromise = installFn(filename);
+
+ let panel = await promisePopupNotificationShown("addon-webext-permissions");
+ if (filename == PERMS_XPI) {
+ // The icon should come from the extension, don't bother with the precise
+ // path, just make sure we've got a jar url pointing to the right path
+ // inside the jar.
+ checkNotification(panel, /^jar:file:\/\/.*\/icon\.png$/, [
+ [
+ "webext-perms-host-description-wildcard",
+ { domain: "wildcard.domain" },
+ ],
+ [
+ "webext-perms-host-description-one-site",
+ { domain: "singlehost.domain" },
+ ],
+ ["webext-perms-description-nativeMessaging"],
+ // The below permissions are deliberately in this order as permissions
+ // are sorted alphabetically by the permission string to match AMO.
+ ["webext-perms-description-history"],
+ ["webext-perms-description-tabs"],
+ ]);
+ } else if (filename == NO_PERMS_XPI) {
+ checkNotification(panel, isDefaultIcon, []);
+ }
+
+ if (cancel) {
+ panel.secondaryButton.click();
+ try {
+ await installMethodPromise;
+ } catch (err) {}
+ } else {
+ // Look for post-install notification
+ let postInstallPromise =
+ promiseAppMenuNotificationShown("addon-installed");
+ panel.button.click();
+
+ // Press OK on the post-install notification
+ panel = await postInstallPromise;
+ panel.button.click();
+
+ await installMethodPromise;
+ }
+
+ let result = await installPromise;
+ let addon = await AddonManager.getAddonByID(ID);
+ if (cancel) {
+ ok(!result, "Installation was cancelled");
+ is(addon, null, "Extension is not installed");
+ } else {
+ ok(result, "Installation completed");
+ isnot(addon, null, "Extension is installed");
+ await addon.uninstall();
+ }
+
+ BrowserTestUtils.removeTab(tab);
+ }
+
+ // A few different tests for each installation method:
+ // 1. Start installation of an extension that requests no permissions,
+ // verify the notification contents, then cancel the install
+ await runOnce(NO_PERMS_XPI, true);
+
+ // 2. Same as #1 but with an extension that requests some permissions.
+ await runOnce(PERMS_XPI, true);
+
+ // 3. Repeat with the same extension from step 2 but this time,
+ // accept the permissions to install the extension. (Then uninstall
+ // the extension to clean up.)
+ await runOnce(PERMS_XPI, false);
+
+ await SpecialPowers.popPrefEnv();
+}
+
+// Helper function to test a specific scenario for interactive updates.
+// `checkFn` is a callable that triggers a check for updates.
+// `autoUpdate` specifies whether the test should be run with
+// updates applied automatically or not.
+async function interactiveUpdateTest(autoUpdate, checkFn) {
+ AddonTestUtils.initMochitest(this);
+
+ const ID = "update2@tests.mozilla.org";
+ const FAKE_INSTALL_SOURCE = "fake-install-source";
+
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ // We don't have pre-pinned certificates for the local mochitest server
+ ["extensions.install.requireBuiltInCerts", false],
+ ["extensions.update.requireBuiltInCerts", false],
+
+ ["extensions.update.autoUpdateDefault", autoUpdate],
+
+ // Point updates to the local mochitest server
+ ["extensions.update.url", `${BASE}/browser_webext_update.json`],
+ ],
+ });
+
+ AddonTestUtils.hookAMTelemetryEvents();
+
+ // Trigger an update check, manually applying the update if we're testing
+ // without auto-update.
+ async function triggerUpdate(win, addon) {
+ let manualUpdatePromise;
+ if (!autoUpdate) {
+ manualUpdatePromise = new Promise(resolve => {
+ let listener = {
+ onNewInstall() {
+ AddonManager.removeInstallListener(listener);
+ resolve();
+ },
+ };
+ AddonManager.addInstallListener(listener);
+ });
+ }
+
+ let promise = checkFn(win, addon);
+
+ if (manualUpdatePromise) {
+ await manualUpdatePromise;
+
+ let doc = win.document;
+ if (win.gViewController.currentViewId !== "addons://updates/available") {
+ let showUpdatesBtn = doc.querySelector("addon-updates-message").button;
+ await TestUtils.waitForCondition(() => {
+ return !showUpdatesBtn.hidden;
+ }, "Wait for show updates button");
+ let viewChanged = waitAboutAddonsViewLoaded(doc);
+ showUpdatesBtn.click();
+ await viewChanged;
+ }
+ let card = await TestUtils.waitForCondition(() => {
+ return doc.querySelector(`addon-card[addon-id="${ID}"]`);
+ }, `Wait addon card for "${ID}"`);
+ let updateBtn = card.querySelector('panel-item[action="install-update"]');
+ ok(updateBtn, `Found update button for "${ID}"`);
+ updateBtn.click();
+ }
+
+ return { promise };
+ }
+
+ // Navigate away from the starting page to force about:addons to load
+ // in a new tab during the tests below.
+ BrowserTestUtils.loadURIString(gBrowser.selectedBrowser, "about:mozilla");
+ await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+
+ // Install version 1.0 of the test extension
+ let addon = await promiseInstallAddon(`${BASE}/browser_webext_update1.xpi`, {
+ source: FAKE_INSTALL_SOURCE,
+ });
+ ok(addon, "Addon was installed");
+ is(addon.version, "1.0", "Version 1 of the addon is installed");
+
+ let win = await BrowserOpenAddonsMgr("addons://list/extension");
+
+ await waitAboutAddonsViewLoaded(win.document);
+
+ // Trigger an update check
+ let popupPromise = promisePopupNotificationShown("addon-webext-permissions");
+ let { promise: checkPromise } = await triggerUpdate(win, addon);
+ let panel = await popupPromise;
+
+ // Click the cancel button, wait to see the cancel event
+ let cancelPromise = promiseInstallEvent(addon, "onInstallCancelled");
+ panel.secondaryButton.click();
+ await cancelPromise;
+
+ addon = await AddonManager.getAddonByID(ID);
+ is(addon.version, "1.0", "Should still be running the old version");
+
+ // Make sure the update check is completely finished.
+ await checkPromise;
+
+ // Trigger a new update check
+ popupPromise = promisePopupNotificationShown("addon-webext-permissions");
+ checkPromise = (await triggerUpdate(win, addon)).promise;
+
+ // This time, accept the upgrade
+ let updatePromise = waitForUpdate(addon);
+ panel = await popupPromise;
+ panel.button.click();
+
+ addon = await updatePromise;
+ is(addon.version, "2.0", "Should have upgraded");
+
+ await checkPromise;
+
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ await addon.uninstall();
+ await SpecialPowers.popPrefEnv();
+
+ const collectedUpdateEvents = AddonTestUtils.getAMTelemetryEvents().filter(
+ evt => {
+ return evt.method === "update";
+ }
+ );
+
+ Assert.deepEqual(
+ collectedUpdateEvents.map(evt => evt.extra.step),
+ [
+ // First update is cancelled on the permission prompt.
+ "started",
+ "download_started",
+ "download_completed",
+ "permissions_prompt",
+ "cancelled",
+ // Second update is expected to be completed.
+ "started",
+ "download_started",
+ "download_completed",
+ "permissions_prompt",
+ "completed",
+ ],
+ "Got the expected sequence on update telemetry events"
+ );
+
+ ok(
+ collectedUpdateEvents.every(evt => evt.extra.addon_id === ID),
+ "Every update telemetry event should have the expected addon_id extra var"
+ );
+
+ ok(
+ collectedUpdateEvents.every(
+ evt => evt.extra.source === FAKE_INSTALL_SOURCE
+ ),
+ "Every update telemetry event should have the expected source extra var"
+ );
+
+ ok(
+ collectedUpdateEvents.every(evt => evt.extra.updated_from === "user"),
+ "Every update telemetry event should have the update_from extra var 'user'"
+ );
+
+ let hasPermissionsExtras = collectedUpdateEvents
+ .filter(evt => {
+ return evt.extra.step === "permissions_prompt";
+ })
+ .every(evt => {
+ return Number.isInteger(parseInt(evt.extra.num_strings, 10));
+ });
+
+ ok(
+ hasPermissionsExtras,
+ "Every 'permissions_prompt' update telemetry event should have the permissions extra vars"
+ );
+
+ let hasDownloadTimeExtras = collectedUpdateEvents
+ .filter(evt => {
+ return evt.extra.step === "download_completed";
+ })
+ .every(evt => {
+ const download_time = parseInt(evt.extra.download_time, 10);
+ return !isNaN(download_time) && download_time > 0;
+ });
+
+ ok(
+ hasDownloadTimeExtras,
+ "Every 'download_completed' update telemetry event should have a download_time extra vars"
+ );
+}
+
+// The tests in this directory install a bunch of extensions but they
+// need to uninstall them before exiting, as a stray leftover extension
+// after one test can foul up subsequent tests.
+// So, add a task to run before any tests that grabs a list of all the
+// add-ons that are pre-installed in the test environment and then checks
+// the list of installed add-ons at the end of the test to make sure no
+// new add-ons have been added.
+// Individual tests can store a cleanup function in the testCleanup global
+// to ensure it gets called before the final check is performed.
+let testCleanup;
+add_setup(async function head_setup() {
+ let addons = await AddonManager.getAllAddons();
+ let existingAddons = new Set(addons.map(a => a.id));
+
+ registerCleanupFunction(async function () {
+ if (testCleanup) {
+ await testCleanup();
+ testCleanup = null;
+ }
+
+ for (let addon of await AddonManager.getAllAddons()) {
+ // Builtin search extensions may have been installed by SearchService
+ // during the test run, ignore those.
+ if (
+ !existingAddons.has(addon.id) &&
+ !(addon.isBuiltin && addon.id.endsWith("@search.mozilla.org"))
+ ) {
+ ok(
+ false,
+ `Addon ${addon.id} was left installed at the end of the test`
+ );
+ await addon.uninstall();
+ }
+ }
+ });
+});
diff --git a/browser/base/content/test/webrtc/browser.ini b/browser/base/content/test/webrtc/browser.ini
new file mode 100644
index 0000000000..63ae7704c8
--- /dev/null
+++ b/browser/base/content/test/webrtc/browser.ini
@@ -0,0 +1,118 @@
+[DEFAULT]
+support-files =
+ get_user_media.html
+ get_user_media2.html
+ get_user_media_in_frame.html
+ get_user_media_in_xorigin_frame.html
+ get_user_media_in_xorigin_frame_ancestor.html
+ head.js
+ peerconnection_connect.html
+ single_peerconnection.html
+
+prefs =
+ privacy.webrtc.allowSilencingNotifications=true
+ privacy.webrtc.legacyGlobalIndicator=false
+ privacy.webrtc.sharedTabWarning=false
+ privacy.webrtc.deviceGracePeriodTimeoutMs=0
+
+[browser_WebrtcGlobalInformation.js]
+[browser_device_controls_menus.js]
+skip-if =
+ debug # bug 1369731
+ apple_silicon # bug 1707735
+ apple_catalina # platform migration
+[browser_devices_get_user_media.js]
+https_first_disabled = true
+skip-if =
+ os == "linux" && bits == 64 # linux: bug 976544, Bug 1616011
+ os == "win" && os_version == "6.1" # Skip on Azure - frequent failure
+[browser_devices_get_user_media_anim.js]
+https_first_disabled = true
+skip-if =
+ os == "win" && os_version == "6.1" # Skip on Azure - frequent failure
+[browser_devices_get_user_media_by_device_id.js]
+https_first_disabled = true
+[browser_devices_get_user_media_default_permissions.js]
+https_first_disabled = true
+[browser_devices_get_user_media_in_frame.js]
+https_first_disabled = true
+skip-if = debug # bug 1369731
+ os == "win" && os_version == "6.1" # Skip on Azure - frequent failure
+[browser_devices_get_user_media_in_xorigin_frame.js]
+https_first_disabled = true
+skip-if =
+ debug # bug 1369731
+ apple_silicon # bug 1707735
+ apple_catalina # platform migration
+ os == "win" && os_version == "6.1" # Skip on Azure - frequent failure
+[browser_devices_get_user_media_in_xorigin_frame_chain.js]
+https_first_disabled = true
+[browser_devices_get_user_media_multi_process.js]
+https_first_disabled = true
+skip-if =
+ (debug && os == "win") # bug 1393761
+ apple_silicon # bug 1707735
+ apple_catalina # platform migration
+[browser_devices_get_user_media_paused.js]
+https_first_disabled = true
+skip-if =
+ (os == "win" && !debug) # Bug 1440900
+ (os =="linux" && !debug && bits == 64) # Bug 1440900
+ apple_silicon # bug 1707735
+ apple_catalina # platform migration
+[browser_devices_get_user_media_queue_request.js]
+https_first_disabled = true
+skip-if =
+ os == "linux" # Bug 1775945
+ os == "win" && !debug # Bug 1775945
+[browser_devices_get_user_media_screen.js]
+https_first_disabled = true
+skip-if =
+ (os == 'linux') # Bug 1503991
+ apple_silicon # bug 1707735
+ apple_catalina # platform migration
+ os == 'win' # high frequency intermittent, bug 1739107
+[browser_devices_get_user_media_screen_tab_close.js]
+skip-if =
+ apple_catalina # platform migration
+ apple_silicon # bug 1707735
+[browser_devices_get_user_media_tear_off_tab.js]
+https_first_disabled = true
+skip-if =
+ apple_catalina # platform migration
+ apple_silicon # bug 1707735
+ os == "linux" # Bug 1775945
+ os == "win" && !debug # Bug 1775945
+[browser_devices_get_user_media_unprompted_access.js]
+skip-if =
+ os == "linux" && bits == 64 && !debug # Bug 1712012
+https_first_disabled = true
+[browser_devices_get_user_media_unprompted_access_in_frame.js]
+https_first_disabled = true
+[browser_devices_get_user_media_unprompted_access_queue_request.js]
+https_first_disabled = true
+[browser_devices_get_user_media_unprompted_access_tear_off_tab.js]
+https_first_disabled = true
+skip-if = (os == "win" && bits == 64) # win8: bug 1334752
+[browser_devices_select_audio_output.js]
+[browser_global_mute_toggles.js]
+[browser_indicator_popuphiding.js]
+skip-if =
+ apple_silicon # bug 1707735
+ apple_catalina # platform migration
+[browser_notification_silencing.js]
+skip-if =
+ apple_silicon # bug 1707735
+ apple_catalina # platform migration
+[browser_stop_sharing_button.js]
+skip-if =
+ apple_silicon # bug 1707735
+ apple_catalina # platform migration
+[browser_stop_streams_on_indicator_close.js]
+skip-if =
+ apple_silicon # bug 1707735
+ apple_catalina # platform migration
+[browser_tab_switch_warning.js]
+skip-if =
+ apple_catalina # platform migration
+[browser_webrtc_hooks.js]
diff --git a/browser/base/content/test/webrtc/browser_WebrtcGlobalInformation.js b/browser/base/content/test/webrtc/browser_WebrtcGlobalInformation.js
new file mode 100644
index 0000000000..d66aa00461
--- /dev/null
+++ b/browser/base/content/test/webrtc/browser_WebrtcGlobalInformation.js
@@ -0,0 +1,484 @@
+/* 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 ProcessTools = Cc["@mozilla.org/processtools-service;1"].getService(
+ Ci.nsIProcessToolsService
+);
+
+let getStatsReports = async (filter = "") => {
+ let { reports } = await new Promise(r =>
+ WebrtcGlobalInformation.getAllStats(r, filter)
+ );
+
+ ok(Array.isArray(reports), "|reports| is an array");
+
+ let sanityCheckReport = report => {
+ isnot(report.pcid, "", "pcid is non-empty");
+ if (filter.length) {
+ is(report.pcid, filter, "pcid matches filter");
+ }
+
+ // Check for duplicates
+ const checkForDuplicateId = statsArray => {
+ ok(Array.isArray(statsArray), "|statsArray| is an array");
+ const ids = new Set();
+ statsArray.forEach(stat => {
+ is(typeof stat.id, "string", "|stat.id| is a string");
+ ok(
+ !ids.has(stat.id),
+ `Id ${stat.id} should appear only once. Stat was ${JSON.stringify(
+ stat
+ )}`
+ );
+ ids.add(stat.id);
+ });
+ };
+
+ checkForDuplicateId(report.inboundRtpStreamStats);
+ checkForDuplicateId(report.outboundRtpStreamStats);
+ checkForDuplicateId(report.remoteInboundRtpStreamStats);
+ checkForDuplicateId(report.remoteOutboundRtpStreamStats);
+ checkForDuplicateId(report.rtpContributingSourceStats);
+ checkForDuplicateId(report.iceCandidatePairStats);
+ checkForDuplicateId(report.iceCandidateStats);
+ checkForDuplicateId(report.trickledIceCandidateStats);
+ checkForDuplicateId(report.dataChannelStats);
+ checkForDuplicateId(report.codecStats);
+ };
+
+ reports.forEach(sanityCheckReport);
+ return reports;
+};
+
+const getStatsHistoryPcIds = async () => {
+ return new Promise(r => WebrtcGlobalInformation.getStatsHistoryPcIds(r));
+};
+
+const getStatsHistorySince = async (pcid, after, sdpAfter) => {
+ return new Promise(r =>
+ WebrtcGlobalInformation.getStatsHistorySince(r, pcid, after, sdpAfter)
+ );
+};
+
+let getLogging = async () => {
+ let logs = await new Promise(r => WebrtcGlobalInformation.getLogging("", r));
+ ok(Array.isArray(logs), "|logs| is an array");
+ return logs;
+};
+
+let checkStatsReportCount = async (count, filter = "") => {
+ let reports = await getStatsReports(filter);
+ is(reports.length, count, `|reports| should have length ${count}`);
+ if (reports.length != count) {
+ info(`reports = ${JSON.stringify(reports)}`);
+ }
+ return reports;
+};
+
+let checkLoggingEmpty = async () => {
+ let logs = await getLogging();
+ is(logs.length, 0, "Logging is empty");
+ if (logs.length) {
+ info(`logs = ${JSON.stringify(logs)}`);
+ }
+ return logs;
+};
+
+let checkLoggingNonEmpty = async () => {
+ let logs = await getLogging();
+ isnot(logs.length, 0, "Logging is not empty");
+ return logs;
+};
+
+let clearAndCheck = async () => {
+ WebrtcGlobalInformation.clearAllStats();
+ WebrtcGlobalInformation.clearLogging();
+ await checkStatsReportCount(0);
+ await checkLoggingEmpty();
+};
+
+let openTabInNewProcess = async file => {
+ let rootDir = getRootDirectory(gTestPath);
+ rootDir = rootDir.replace(
+ "chrome://mochitests/content/",
+ "https://example.com/"
+ );
+ let absoluteURI = rootDir + file;
+
+ return BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ opening: absoluteURI,
+ forceNewProcess: true,
+ });
+};
+
+let killTabProcess = async tab => {
+ await SpecialPowers.spawn(tab.linkedBrowser, [], () => {
+ ChromeUtils.privateNoteIntentionalCrash();
+ });
+ ProcessTools.kill(tab.linkedBrowser.frameLoader.remoteTab.osPid);
+};
+
+add_task(async () => {
+ info("Test that clearAllStats is callable");
+ WebrtcGlobalInformation.clearAllStats();
+ ok(true, "clearAllStats returns");
+});
+
+add_task(async () => {
+ info("Test that clearLogging is callable");
+ WebrtcGlobalInformation.clearLogging();
+ ok(true, "clearLogging returns");
+});
+
+add_task(async () => {
+ info(
+ "Test that getAllStats is callable, and returns 0 results when no RTCPeerConnections have existed"
+ );
+ await checkStatsReportCount(0);
+});
+
+add_task(async () => {
+ info(
+ "Test that getLogging is callable, and returns 0 results when no RTCPeerConnections have existed"
+ );
+ await checkLoggingEmpty();
+});
+
+add_task(async () => {
+ info("Test that we can get stats/logging for a PC on the parent process");
+ await clearAndCheck();
+ let pc = new RTCPeerConnection();
+ await pc.setLocalDescription(
+ await pc.createOffer({ offerToReceiveAudio: true })
+ );
+ // Let ICE stack go quiescent
+ await new Promise(r => {
+ pc.onicegatheringstatechange = () => {
+ if (pc.iceGatheringState == "complete") {
+ r();
+ }
+ };
+ });
+ await checkStatsReportCount(1);
+ await checkLoggingNonEmpty();
+ pc.close();
+ pc = null;
+ // Closing a PC should not do anything to the ICE logging
+ await checkLoggingNonEmpty();
+ // There's just no way to get a signal that the ICE stack has stopped logging
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => setTimeout(r, 2000));
+ await clearAndCheck();
+});
+
+add_task(async () => {
+ info("Test that we can get stats/logging for a PC on a content process");
+ await clearAndCheck();
+ let tab = await openTabInNewProcess("single_peerconnection.html");
+ await checkStatsReportCount(1);
+ await checkLoggingNonEmpty();
+ await killTabProcess(tab);
+ BrowserTestUtils.removeTab(tab);
+ await clearAndCheck();
+});
+
+add_task(async () => {
+ info(
+ "Test that we can get stats/logging for two connected PCs on a content process"
+ );
+ await clearAndCheck();
+ let tab = await openTabInNewProcess("peerconnection_connect.html");
+ await checkStatsReportCount(2);
+ await checkLoggingNonEmpty();
+ await killTabProcess(tab);
+ BrowserTestUtils.removeTab(tab);
+ await clearAndCheck();
+});
+
+add_task(async () => {
+ info("Test filtering for stats reports (parent process)");
+ await clearAndCheck();
+ let pc1 = new RTCPeerConnection();
+ let pc2 = new RTCPeerConnection();
+ let allReports = await checkStatsReportCount(2);
+ await checkStatsReportCount(1, allReports[0].pcid);
+ pc1.close();
+ pc2.close();
+ pc1 = null;
+ pc2 = null;
+ await checkStatsReportCount(1, allReports[0].pcid);
+ await clearAndCheck();
+});
+
+add_task(async () => {
+ info("Test filtering for stats reports (content process)");
+ await clearAndCheck();
+ let tab1 = await openTabInNewProcess("single_peerconnection.html");
+ let tab2 = await openTabInNewProcess("single_peerconnection.html");
+ let allReports = await checkStatsReportCount(2);
+ await checkStatsReportCount(1, allReports[0].pcid);
+ await killTabProcess(tab1);
+ BrowserTestUtils.removeTab(tab1);
+ await killTabProcess(tab2);
+ BrowserTestUtils.removeTab(tab2);
+ await checkStatsReportCount(1, allReports[0].pcid);
+ await clearAndCheck();
+});
+
+add_task(async () => {
+ info("Test that stats/logging persists when PC is closed (parent process)");
+ await clearAndCheck();
+ let pc = new RTCPeerConnection();
+ // This stuff will generate logging
+ await pc.setLocalDescription(
+ await pc.createOffer({ offerToReceiveAudio: true })
+ );
+ // Once gathering is done, the ICE stack should go quiescent
+ await new Promise(r => {
+ pc.onicegatheringstatechange = () => {
+ if (pc.iceGatheringState == "complete") {
+ r();
+ }
+ };
+ });
+ let reports = await checkStatsReportCount(1);
+ isnot(
+ window.browsingContext.browserId,
+ undefined,
+ "browserId is defined for parent process"
+ );
+ is(
+ reports[0].browserId,
+ window.browsingContext.browserId,
+ "browserId for stats report matches parent process"
+ );
+ await checkLoggingNonEmpty();
+ pc.close();
+ pc = null;
+ await checkStatsReportCount(1);
+ await checkLoggingNonEmpty();
+ await clearAndCheck();
+});
+
+add_task(async () => {
+ info("Test that stats/logging persists when PC is closed (content process)");
+ await clearAndCheck();
+ let tab = await openTabInNewProcess("single_peerconnection.html");
+ let { browserId } = tab.linkedBrowser;
+ let reports = await checkStatsReportCount(1);
+ is(reports[0].browserId, browserId, "browserId for stats report matches tab");
+ isnot(
+ browserId,
+ window.browsingContext.browserId,
+ "tab browser id is not the same as parent process browser id"
+ );
+ await checkLoggingNonEmpty();
+ await killTabProcess(tab);
+ BrowserTestUtils.removeTab(tab);
+ await checkStatsReportCount(1);
+ await checkLoggingNonEmpty();
+ await clearAndCheck();
+});
+
+const set_int_pref_returning_unsetter = (pref, num) => {
+ const value = Services.prefs.getIntPref(pref);
+ Services.prefs.setIntPref(pref, num);
+ return () => Services.prefs.setIntPref(pref, value);
+};
+
+const stats_history_is_enabled = () => {
+ return Services.prefs.getBoolPref("media.aboutwebrtc.hist.enabled");
+};
+
+const set_max_histories_to_retain = num =>
+ set_int_pref_returning_unsetter(
+ "media.aboutwebrtc.hist.closed_stats_to_retain",
+ num
+ );
+
+const set_history_storage_window_s = num =>
+ set_int_pref_returning_unsetter(
+ "media.aboutwebrtc.hist.storage_window_s",
+ num
+ );
+
+add_task(async () => {
+ if (!stats_history_is_enabled()) {
+ return;
+ }
+ info(
+ "Test that stats history is available after close until clearLongTermStats is called"
+ );
+ await clearAndCheck();
+ const pc = new RTCPeerConnection();
+
+ const ids = await getStatsHistoryPcIds();
+ is(ids.length, 1, "There is a single PeerConnection Id for stats history.");
+
+ let firstLen = 0;
+ // I "don't love" this but we don't have a anything we can await on ... yet.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => setTimeout(r, 2000));
+ {
+ const history = await getStatsHistorySince(ids[0]);
+ firstLen = history.reports.length;
+ ok(
+ history.reports.length,
+ "There is at least a single PeerConnection stats history before close."
+ );
+ }
+ // I "don't love" this but we don't have a anything we can await on ... yet.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => setTimeout(r, 2000));
+ {
+ const history = await getStatsHistorySince(ids[0]);
+ const secondLen = history.reports.length;
+ ok(
+ secondLen > firstLen,
+ "After waiting there are more history entries available."
+ );
+ }
+ pc.close();
+ // After close for final stats and pc teardown to settle
+ // I "don't love" this but we don't have a anything we can await on ... yet.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => setTimeout(r, 2000));
+ {
+ const history = await getStatsHistorySince(ids[0]);
+ ok(
+ history.reports.length,
+ "There is at least a single PeerConnection stats history after close."
+ );
+ }
+ await clearAndCheck();
+ {
+ const history = await getStatsHistorySince(ids[0]);
+ is(
+ history.reports.length,
+ 0,
+ "After PC.close and clearing the stats there are no history reports"
+ );
+ }
+ {
+ const ids1 = await getStatsHistoryPcIds();
+ is(
+ ids1.length,
+ 0,
+ "After PC.close and clearing the stats there are no history pcids"
+ );
+ }
+ {
+ const pc2 = new RTCPeerConnection();
+ const pc3 = new RTCPeerConnection();
+ let idsN = await getStatsHistoryPcIds();
+ is(
+ idsN.length,
+ 2,
+ "There are two pcIds after creating two PeerConnections"
+ );
+ pc2.close();
+ // After close for final stats and pc teardown to settle
+ // I "don't love" this but we don't have a anything we can await on ... yet.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => setTimeout(r, 2000));
+ await WebrtcGlobalInformation.clearAllStats();
+ idsN = await getStatsHistoryPcIds();
+ is(
+ idsN.length,
+ 1,
+ "There is one pcIds after closing one of two PeerConnections and clearing stats"
+ );
+ pc3.close();
+ // After close for final stats and pc teardown to settle
+ // I "don't love" this but we don't have a anything we can await on ... yet.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => setTimeout(r, 2000));
+ }
+});
+
+add_task(async () => {
+ if (!stats_history_is_enabled()) {
+ return;
+ }
+ const restoreHistRetainPref = set_max_histories_to_retain(7);
+ info("Test that the proper number of pcIds are available");
+ await clearAndCheck();
+ const pc01 = new RTCPeerConnection();
+ const pc02 = new RTCPeerConnection();
+ const pc03 = new RTCPeerConnection();
+ const pc04 = new RTCPeerConnection();
+ const pc05 = new RTCPeerConnection();
+ const pc06 = new RTCPeerConnection();
+ const pc07 = new RTCPeerConnection();
+ const pc08 = new RTCPeerConnection();
+ const pc09 = new RTCPeerConnection();
+ const pc10 = new RTCPeerConnection();
+ const pc11 = new RTCPeerConnection();
+ const pc12 = new RTCPeerConnection();
+ const pc13 = new RTCPeerConnection();
+ // I "don't love" this but we don't have a anything we can await on ... yet.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => setTimeout(r, 2000));
+ {
+ const ids = await getStatsHistoryPcIds();
+ is(ids.length, 13, "There is are 13 PeerConnection Ids for stats history.");
+ }
+ pc01.close();
+ pc02.close();
+ pc03.close();
+ pc04.close();
+ pc05.close();
+ pc06.close();
+ pc07.close();
+ pc08.close();
+ pc09.close();
+ pc10.close();
+ pc11.close();
+ pc12.close();
+ pc13.close();
+ // I "don't love" this but we don't have a anything we can await on ... yet.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => setTimeout(r, 5000));
+ {
+ const ids = await getStatsHistoryPcIds();
+ is(
+ ids.length,
+ 7,
+ "After closing 13 PCs there are no more than the max closed (7) PeerConnection Ids for stats history."
+ );
+ }
+ restoreHistRetainPref();
+ await clearAndCheck();
+});
+
+add_task(async () => {
+ if (!stats_history_is_enabled()) {
+ return;
+ }
+ // If you change this, please check if the setTimeout should be updated.
+ // NOTE: the unit here is _integer_ seconds.
+ const STORAGE_WINDOW_S = 1;
+ const restoreStorageWindowPref =
+ set_history_storage_window_s(STORAGE_WINDOW_S);
+ info("Test that history items are being aged out");
+ await clearAndCheck();
+ const pc = new RTCPeerConnection();
+ // I "don't love" this but we don't have a anything we can await on ... yet.
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ await new Promise(r => setTimeout(r, STORAGE_WINDOW_S * 2 * 1000));
+ const ids = await getStatsHistoryPcIds();
+ const { reports } = await getStatsHistorySince(ids[0]);
+ const first = reports[0];
+ const last = reports.at(-1);
+ ok(
+ last.timestamp - first.timestamp <= STORAGE_WINDOW_S * 1000,
+ "History reports should be aging out according to the storage window pref"
+ );
+ pc.close();
+ restoreStorageWindowPref();
+ await clearAndCheck();
+});
diff --git a/browser/base/content/test/webrtc/browser_device_controls_menus.js b/browser/base/content/test/webrtc/browser_device_controls_menus.js
new file mode 100644
index 0000000000..3d6602bc5e
--- /dev/null
+++ b/browser/base/content/test/webrtc/browser_device_controls_menus.js
@@ -0,0 +1,55 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TEST_ROOT = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content/",
+ "https://example.com/"
+);
+const TEST_PAGE = TEST_ROOT + "get_user_media.html";
+
+/**
+ * Regression test for bug 1669801, where sharing a window would
+ * result in a device control menu that showed the wrong count.
+ */
+add_task(async function test_bug_1669801() {
+ let prefs = [
+ [PREF_PERMISSION_FAKE, true],
+ [PREF_AUDIO_LOOPBACK, ""],
+ [PREF_VIDEO_LOOPBACK, ""],
+ [PREF_FAKE_STREAMS, true],
+ [PREF_FOCUS_SOURCE, false],
+ ];
+ await SpecialPowers.pushPrefEnv({ set: prefs });
+
+ await BrowserTestUtils.withNewTab(TEST_PAGE, async browser => {
+ let indicatorPromise = promiseIndicatorWindow();
+
+ await shareDevices(
+ browser,
+ false /* camera */,
+ false /* microphone */,
+ SHARE_WINDOW
+ );
+
+ let indicator = await indicatorPromise;
+ let doc = indicator.document;
+
+ let menupopup = doc.querySelector("menupopup[type='Screen']");
+ let popupShownPromise = BrowserTestUtils.waitForEvent(
+ menupopup,
+ "popupshown"
+ );
+ menupopup.openPopup(doc.body, {});
+ await popupShownPromise;
+
+ let popupHiddenPromise = BrowserTestUtils.waitForEvent(
+ menupopup,
+ "popuphidden"
+ );
+ menupopup.hidePopup();
+ await popupHiddenPromise;
+ await closeStream();
+ });
+});
diff --git a/browser/base/content/test/webrtc/browser_devices_get_user_media.js b/browser/base/content/test/webrtc/browser_devices_get_user_media.js
new file mode 100644
index 0000000000..3ef88b976d
--- /dev/null
+++ b/browser/base/content/test/webrtc/browser_devices_get_user_media.js
@@ -0,0 +1,949 @@
+/* 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/. */
+
+requestLongerTimeout(2);
+
+const permissionError =
+ "error: NotAllowedError: The request is not allowed " +
+ "by the user agent or the platform in the current context.";
+
+var gTests = [
+ {
+ desc: "getUserMedia audio+video",
+ run: async function checkAudioVideo() {
+ let promise = promisePopupNotificationShown("webRTC-shareDevices");
+ let observerPromise = expectObserverCalled("getUserMedia:request");
+ await promiseRequestDevice(true, true);
+ await promise;
+ await observerPromise;
+
+ is(
+ PopupNotifications.getNotification("webRTC-shareDevices").anchorID,
+ "webRTC-shareDevices-notification-icon",
+ "anchored to device icon"
+ );
+ checkDeviceSelectors(["microphone", "camera"]);
+
+ let indicator = promiseIndicatorWindow();
+ let observerPromise1 = expectObserverCalled(
+ "getUserMedia:response:allow"
+ );
+ let observerPromise2 = expectObserverCalled("recording-device-events");
+
+ promise = promiseMessage("ok", () => {
+ PopupNotifications.panel.firstElementChild.button.click();
+ });
+ await observerPromise1;
+ await observerPromise2;
+ await promise;
+
+ Assert.deepEqual(
+ await getMediaCaptureState(),
+ { audio: true, video: true },
+ "expected camera and microphone to be shared"
+ );
+
+ await indicator;
+ await checkSharingUI({ audio: true, video: true });
+ await closeStream();
+ },
+ },
+
+ {
+ desc: "getUserMedia audio only",
+ run: async function checkAudioOnly() {
+ let observerPromise = expectObserverCalled("getUserMedia:request");
+ let promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(true);
+ await promise;
+ await observerPromise;
+
+ is(
+ PopupNotifications.getNotification("webRTC-shareDevices").anchorID,
+ "webRTC-shareMicrophone-notification-icon",
+ "anchored to mic icon"
+ );
+ checkDeviceSelectors(["microphone"]);
+
+ let indicator = promiseIndicatorWindow();
+ let observerPromise1 = expectObserverCalled(
+ "getUserMedia:response:allow"
+ );
+ let observerPromise2 = expectObserverCalled("recording-device-events");
+
+ promise = promiseMessage("ok", () => {
+ PopupNotifications.panel.firstElementChild.button.click();
+ });
+ await observerPromise1;
+ await observerPromise2;
+ await promise;
+ Assert.deepEqual(
+ await getMediaCaptureState(),
+ { audio: true },
+ "expected microphone to be shared"
+ );
+
+ await indicator;
+ await checkSharingUI({ audio: true });
+ await closeStream();
+ },
+ },
+
+ {
+ desc: "getUserMedia video only",
+ run: async function checkVideoOnly() {
+ let observerPromise = expectObserverCalled("getUserMedia:request");
+ let promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(false, true);
+ await promise;
+ await observerPromise;
+
+ is(
+ PopupNotifications.getNotification("webRTC-shareDevices").anchorID,
+ "webRTC-shareDevices-notification-icon",
+ "anchored to device icon"
+ );
+ checkDeviceSelectors(["camera"]);
+
+ let indicator = promiseIndicatorWindow();
+ let observerPromise1 = expectObserverCalled(
+ "getUserMedia:response:allow"
+ );
+ let observerPromise2 = expectObserverCalled("recording-device-events");
+ await promiseMessage("ok", () => {
+ PopupNotifications.panel.firstElementChild.button.click();
+ });
+ await observerPromise1;
+ await observerPromise2;
+ Assert.deepEqual(
+ await getMediaCaptureState(),
+ { video: true },
+ "expected camera to be shared"
+ );
+
+ await indicator;
+ await checkSharingUI({ video: true });
+ await closeStream();
+ },
+ },
+
+ {
+ desc: 'getUserMedia audio+video, user clicks "Don\'t Share"',
+ run: async function checkDontShare() {
+ let observerPromise = expectObserverCalled("getUserMedia:request");
+ let promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(true, true);
+ await promise;
+ await observerPromise;
+ checkDeviceSelectors(["microphone", "camera"]);
+
+ let observerPromise1 = expectObserverCalled("getUserMedia:response:deny");
+ let observerPromise2 = expectObserverCalled("recording-window-ended");
+ await promiseMessage(permissionError, () => {
+ activateSecondaryAction(kActionDeny);
+ });
+ await observerPromise1;
+ await observerPromise2;
+
+ await checkNotSharing();
+
+ // Verify that we set 'Temporarily blocked' permissions.
+ let browser = gBrowser.selectedBrowser;
+ let blockedPerms = document.getElementById(
+ "blocked-permissions-container"
+ );
+
+ let { state, scope } = SitePermissions.getForPrincipal(
+ null,
+ "camera",
+ browser
+ );
+ Assert.equal(state, SitePermissions.BLOCK);
+ Assert.equal(scope, SitePermissions.SCOPE_TEMPORARY);
+ ok(
+ blockedPerms.querySelector(
+ ".blocked-permission-icon.camera-icon[showing=true]"
+ ),
+ "the blocked camera icon is shown"
+ );
+
+ ({ state, scope } = SitePermissions.getForPrincipal(
+ null,
+ "microphone",
+ browser
+ ));
+ Assert.equal(state, SitePermissions.BLOCK);
+ Assert.equal(scope, SitePermissions.SCOPE_TEMPORARY);
+ ok(
+ blockedPerms.querySelector(
+ ".blocked-permission-icon.microphone-icon[showing=true]"
+ ),
+ "the blocked microphone icon is shown"
+ );
+
+ info("requesting devices again to check temporarily blocked permissions");
+ promise = promiseMessage(permissionError);
+ observerPromise1 = expectObserverCalled("getUserMedia:request");
+ observerPromise2 = expectObserverCalled("getUserMedia:response:deny");
+ let observerPromise3 = expectObserverCalled("recording-window-ended");
+ await promiseRequestDevice(true, true);
+ await promise;
+ await observerPromise1;
+ await observerPromise2;
+ await observerPromise3;
+ await checkNotSharing();
+
+ SitePermissions.removeFromPrincipal(
+ browser.contentPrincipal,
+ "camera",
+ browser
+ );
+ SitePermissions.removeFromPrincipal(
+ browser.contentPrincipal,
+ "microphone",
+ browser
+ );
+ },
+ },
+
+ {
+ desc: "getUserMedia audio+video: stop sharing",
+ run: async function checkStopSharing() {
+ let observerPromise = expectObserverCalled("getUserMedia:request");
+ let promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(true, true);
+ await promise;
+ await observerPromise;
+ checkDeviceSelectors(["microphone", "camera"]);
+
+ let indicator = promiseIndicatorWindow();
+ let observerPromise1 = expectObserverCalled(
+ "getUserMedia:response:allow"
+ );
+ let observerPromise2 = expectObserverCalled("recording-device-events");
+ await promiseMessage("ok", () => {
+ PopupNotifications.panel.firstElementChild.button.click();
+ });
+ await observerPromise1;
+ await observerPromise2;
+ Assert.deepEqual(
+ await getMediaCaptureState(),
+ { audio: true, video: true },
+ "expected camera and microphone to be shared"
+ );
+
+ await indicator;
+ await checkSharingUI({ video: true, audio: true });
+
+ await stopSharing();
+
+ // the stream is already closed, but this will do some cleanup anyway
+ await closeStream(true);
+
+ // After stop sharing, gUM(audio+camera) causes a prompt.
+ observerPromise = expectObserverCalled("getUserMedia:request");
+ promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(true, true);
+ await promise;
+ await observerPromise;
+ checkDeviceSelectors(["microphone", "camera"]);
+
+ observerPromise1 = expectObserverCalled("getUserMedia:response:deny");
+ observerPromise2 = expectObserverCalled("recording-window-ended");
+ await promiseMessage(permissionError, () => {
+ activateSecondaryAction(kActionDeny);
+ });
+
+ await observerPromise1;
+ await observerPromise2;
+ await checkNotSharing();
+ SitePermissions.removeFromPrincipal(
+ null,
+ "screen",
+ gBrowser.selectedBrowser
+ );
+ SitePermissions.removeFromPrincipal(
+ null,
+ "camera",
+ gBrowser.selectedBrowser
+ );
+ SitePermissions.removeFromPrincipal(
+ null,
+ "microphone",
+ gBrowser.selectedBrowser
+ );
+ },
+ },
+
+ {
+ desc: "getUserMedia audio+video: reloading the page removes all gUM UI",
+ run: async function checkReloading() {
+ let observerPromise = expectObserverCalled("getUserMedia:request");
+ let promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(true, true);
+ await promise;
+ await observerPromise;
+ checkDeviceSelectors(["microphone", "camera"]);
+
+ let indicator = promiseIndicatorWindow();
+ let observerPromise1 = expectObserverCalled(
+ "getUserMedia:response:allow"
+ );
+ let observerPromise2 = expectObserverCalled("recording-device-events");
+ await promiseMessage("ok", () => {
+ PopupNotifications.panel.firstElementChild.button.click();
+ });
+ await observerPromise1;
+ await observerPromise2;
+ Assert.deepEqual(
+ await getMediaCaptureState(),
+ { audio: true, video: true },
+ "expected camera and microphone to be shared"
+ );
+
+ await indicator;
+ await checkSharingUI({ video: true, audio: true });
+
+ await reloadAndAssertClosedStreams();
+
+ observerPromise = expectObserverCalled("getUserMedia:request");
+ // After the reload, gUM(audio+camera) causes a prompt.
+ promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(true, true);
+ await promise;
+ await observerPromise;
+ checkDeviceSelectors(["microphone", "camera"]);
+
+ observerPromise1 = expectObserverCalled("getUserMedia:response:deny");
+ observerPromise2 = expectObserverCalled("recording-window-ended");
+
+ await promiseMessage(permissionError, () => {
+ activateSecondaryAction(kActionDeny);
+ });
+
+ await observerPromise1;
+ await observerPromise2;
+ await checkNotSharing();
+ SitePermissions.removeFromPrincipal(
+ null,
+ "screen",
+ gBrowser.selectedBrowser
+ );
+ SitePermissions.removeFromPrincipal(
+ null,
+ "camera",
+ gBrowser.selectedBrowser
+ );
+ SitePermissions.removeFromPrincipal(
+ null,
+ "microphone",
+ gBrowser.selectedBrowser
+ );
+ },
+ },
+
+ {
+ desc: "getUserMedia prompt: Always/Never Share",
+ run: async function checkRememberCheckbox() {
+ let elt = id => document.getElementById(id);
+
+ async function checkPerm(
+ aRequestAudio,
+ aRequestVideo,
+ aExpectedAudioPerm,
+ aExpectedVideoPerm,
+ aNever
+ ) {
+ let observerPromise = expectObserverCalled("getUserMedia:request");
+ let promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(aRequestAudio, aRequestVideo);
+ await promise;
+ await observerPromise;
+
+ is(
+ elt("webRTC-selectMicrophone").hidden,
+ !aRequestAudio,
+ "microphone selector expected to be " +
+ (aRequestAudio ? "visible" : "hidden")
+ );
+
+ is(
+ elt("webRTC-selectCamera").hidden,
+ !aRequestVideo,
+ "camera selector expected to be " +
+ (aRequestVideo ? "visible" : "hidden")
+ );
+
+ let expected = {};
+ let observerPromises = [];
+ let expectedMessage = aNever ? permissionError : "ok";
+ if (expectedMessage == "ok") {
+ observerPromises.push(
+ expectObserverCalled("getUserMedia:response:allow")
+ );
+ observerPromises.push(
+ expectObserverCalled("recording-device-events")
+ );
+ if (aRequestVideo) {
+ expected.video = true;
+ }
+ if (aRequestAudio) {
+ expected.audio = true;
+ }
+ } else {
+ observerPromises.push(
+ expectObserverCalled("getUserMedia:response:deny")
+ );
+ observerPromises.push(expectObserverCalled("recording-window-ended"));
+ }
+ await promiseMessage(expectedMessage, () => {
+ activateSecondaryAction(aNever ? kActionNever : kActionAlways);
+ });
+ await Promise.all(observerPromises);
+ Assert.deepEqual(
+ await getMediaCaptureState(),
+ expected,
+ "expected " + Object.keys(expected).join(" and ") + " to be shared"
+ );
+
+ function checkDevicePermissions(aDevice, aExpected) {
+ let uri = gBrowser.selectedBrowser.documentURI;
+ let devicePerms = PermissionTestUtils.testExactPermission(
+ uri,
+ aDevice
+ );
+ if (aExpected === undefined) {
+ is(
+ devicePerms,
+ Services.perms.UNKNOWN_ACTION,
+ "no " + aDevice + " persistent permissions"
+ );
+ } else {
+ is(
+ devicePerms,
+ aExpected
+ ? Services.perms.ALLOW_ACTION
+ : Services.perms.DENY_ACTION,
+ aDevice + " persistently " + (aExpected ? "allowed" : "denied")
+ );
+ }
+ PermissionTestUtils.remove(uri, aDevice);
+ }
+ checkDevicePermissions("microphone", aExpectedAudioPerm);
+ checkDevicePermissions("camera", aExpectedVideoPerm);
+
+ if (expectedMessage == "ok") {
+ await closeStream();
+ }
+ }
+
+ // 3 cases where the user accepts the device prompt.
+ info("audio+video, user grants, expect both Services.perms set to allow");
+ await checkPerm(true, true, true, true);
+ info(
+ "audio only, user grants, check audio perm set to allow, video perm not set"
+ );
+ await checkPerm(true, false, true, undefined);
+ info(
+ "video only, user grants, check video perm set to allow, audio perm not set"
+ );
+ await checkPerm(false, true, undefined, true);
+
+ // 3 cases where the user rejects the device request by using 'Never Share'.
+ info(
+ "audio only, user denies, expect audio perm set to deny, video not set"
+ );
+ await checkPerm(true, false, false, undefined, true);
+ info(
+ "video only, user denies, expect video perm set to deny, audio perm not set"
+ );
+ await checkPerm(false, true, undefined, false, true);
+ info("audio+video, user denies, expect both Services.perms set to deny");
+ await checkPerm(true, true, false, false, true);
+ },
+ },
+
+ {
+ desc: "getUserMedia without prompt: use persistent permissions",
+ run: async function checkUsePersistentPermissions() {
+ async function usePerm(
+ aAllowAudio,
+ aAllowVideo,
+ aRequestAudio,
+ aRequestVideo,
+ aExpectStream
+ ) {
+ let uri = gBrowser.selectedBrowser.documentURI;
+
+ if (aAllowAudio !== undefined) {
+ PermissionTestUtils.add(
+ uri,
+ "microphone",
+ aAllowAudio
+ ? Services.perms.ALLOW_ACTION
+ : Services.perms.DENY_ACTION
+ );
+ }
+ if (aAllowVideo !== undefined) {
+ PermissionTestUtils.add(
+ uri,
+ "camera",
+ aAllowVideo
+ ? Services.perms.ALLOW_ACTION
+ : Services.perms.DENY_ACTION
+ );
+ }
+
+ if (aExpectStream === undefined) {
+ // Check that we get a prompt.
+ let observerPromise = expectObserverCalled("getUserMedia:request");
+ let promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(aRequestAudio, aRequestVideo);
+ await promise;
+ await observerPromise;
+
+ // Deny the request to cleanup...
+ let observerPromise1 = expectObserverCalled(
+ "getUserMedia:response:deny"
+ );
+ let observerPromise2 = expectObserverCalled("recording-window-ended");
+ await promiseMessage(permissionError, () => {
+ activateSecondaryAction(kActionDeny);
+ });
+ await observerPromise1;
+ await observerPromise2;
+
+ let browser = gBrowser.selectedBrowser;
+ SitePermissions.removeFromPrincipal(null, "camera", browser);
+ SitePermissions.removeFromPrincipal(null, "microphone", browser);
+ } else {
+ let expectedMessage = aExpectStream ? "ok" : permissionError;
+
+ let observerPromises = [expectObserverCalled("getUserMedia:request")];
+ if (expectedMessage == "ok") {
+ observerPromises.push(
+ expectObserverCalled("getUserMedia:response:allow"),
+ expectObserverCalled("recording-device-events")
+ );
+ } else {
+ observerPromises.push(
+ expectObserverCalled("getUserMedia:response:deny"),
+ expectObserverCalled("recording-window-ended")
+ );
+ }
+
+ let promise = promiseMessage(expectedMessage);
+ await promiseRequestDevice(aRequestAudio, aRequestVideo);
+ await promise;
+ await Promise.all(observerPromises);
+
+ if (expectedMessage == "ok") {
+ await promiseNoPopupNotification("webRTC-shareDevices");
+
+ // Check what's actually shared.
+ let expected = {};
+ if (aAllowVideo && aRequestVideo) {
+ expected.video = true;
+ }
+ if (aAllowAudio && aRequestAudio) {
+ expected.audio = true;
+ }
+ Assert.deepEqual(
+ await getMediaCaptureState(),
+ expected,
+ "expected " +
+ Object.keys(expected).join(" and ") +
+ " to be shared"
+ );
+
+ await closeStream();
+ }
+ }
+
+ PermissionTestUtils.remove(uri, "camera");
+ PermissionTestUtils.remove(uri, "microphone");
+ }
+
+ // Set both permissions identically
+ info("allow audio+video, request audio+video, expect ok (audio+video)");
+ await usePerm(true, true, true, true, true);
+ info("deny audio+video, request audio+video, expect denied");
+ await usePerm(false, false, true, true, false);
+
+ // Allow audio, deny video.
+ info("allow audio, deny video, request audio+video, expect denied");
+ await usePerm(true, false, true, true, false);
+ info("allow audio, deny video, request audio, expect ok (audio)");
+ await usePerm(true, false, true, false, true);
+ info("allow audio, deny video, request video, expect denied");
+ await usePerm(true, false, false, true, false);
+
+ // Deny audio, allow video.
+ info("deny audio, allow video, request audio+video, expect denied");
+ await usePerm(false, true, true, true, false);
+ info("deny audio, allow video, request audio, expect denied");
+ await usePerm(false, true, true, false, false);
+ info("deny audio, allow video, request video, expect ok (video)");
+ await usePerm(false, true, false, true, true);
+
+ // Allow audio, video not set.
+ info("allow audio, request audio+video, expect prompt");
+ await usePerm(true, undefined, true, true, undefined);
+ info("allow audio, request audio, expect ok (audio)");
+ await usePerm(true, undefined, true, false, true);
+ info("allow audio, request video, expect prompt");
+ await usePerm(true, undefined, false, true, undefined);
+
+ // Deny audio, video not set.
+ info("deny audio, request audio+video, expect denied");
+ await usePerm(false, undefined, true, true, false);
+ info("deny audio, request audio, expect denied");
+ await usePerm(false, undefined, true, false, false);
+ info("deny audio, request video, expect prompt");
+ await usePerm(false, undefined, false, true, undefined);
+
+ // Allow video, audio not set.
+ info("allow video, request audio+video, expect prompt");
+ await usePerm(undefined, true, true, true, undefined);
+ info("allow video, request audio, expect prompt");
+ await usePerm(undefined, true, true, false, undefined);
+ info("allow video, request video, expect ok (video)");
+ await usePerm(undefined, true, false, true, true);
+
+ // Deny video, audio not set.
+ info("deny video, request audio+video, expect denied");
+ await usePerm(undefined, false, true, true, false);
+ info("deny video, request audio, expect prompt");
+ await usePerm(undefined, false, true, false, undefined);
+ info("deny video, request video, expect denied");
+ await usePerm(undefined, false, false, true, false);
+ },
+ },
+
+ {
+ desc: "Stop Sharing removes permissions",
+ run: async function checkStopSharingRemovesPermissions() {
+ async function stopAndCheckPerm(
+ aRequestAudio,
+ aRequestVideo,
+ aStopAudio = aRequestAudio,
+ aStopVideo = aRequestVideo
+ ) {
+ let uri = gBrowser.selectedBrowser.documentURI;
+
+ // Initially set both permissions to 'allow'.
+ PermissionTestUtils.add(uri, "microphone", Services.perms.ALLOW_ACTION);
+ PermissionTestUtils.add(uri, "camera", Services.perms.ALLOW_ACTION);
+ // Also set device-specific temporary allows.
+ SitePermissions.setForPrincipal(
+ gBrowser.contentPrincipal,
+ "microphone^myDevice",
+ SitePermissions.ALLOW,
+ SitePermissions.SCOPE_TEMPORARY,
+ gBrowser.selectedBrowser,
+ 10000000
+ );
+ SitePermissions.setForPrincipal(
+ gBrowser.contentPrincipal,
+ "camera^myDevice2",
+ SitePermissions.ALLOW,
+ SitePermissions.SCOPE_TEMPORARY,
+ gBrowser.selectedBrowser,
+ 10000000
+ );
+
+ if (aRequestAudio || aRequestVideo) {
+ let indicator = promiseIndicatorWindow();
+ let observerPromise1 = expectObserverCalled("getUserMedia:request");
+ let observerPromise2 = expectObserverCalled(
+ "getUserMedia:response:allow"
+ );
+ let observerPromise3 = expectObserverCalled(
+ "recording-device-events"
+ );
+ // Start sharing what's been requested.
+ let promise = promiseMessage("ok");
+ await promiseRequestDevice(aRequestAudio, aRequestVideo);
+ await promise;
+ await observerPromise1;
+ await observerPromise2;
+ await observerPromise3;
+
+ await indicator;
+ await checkSharingUI(
+ { video: aRequestVideo, audio: aRequestAudio },
+ undefined,
+ undefined,
+ {
+ video: { scope: SitePermissions.SCOPE_PERSISTENT },
+ audio: { scope: SitePermissions.SCOPE_PERSISTENT },
+ }
+ );
+ await stopSharing(aStopVideo ? "camera" : "microphone");
+ } else {
+ await revokePermission(aStopVideo ? "camera" : "microphone");
+ }
+
+ // Check that permissions have been removed as expected.
+ let audioPerm = SitePermissions.getForPrincipal(
+ gBrowser.contentPrincipal,
+ "microphone",
+ gBrowser.selectedBrowser
+ );
+ let audioPermDevice = SitePermissions.getForPrincipal(
+ gBrowser.contentPrincipal,
+ "microphone^myDevice",
+ gBrowser.selectedBrowser
+ );
+
+ if (
+ aRequestAudio ||
+ aRequestVideo ||
+ aStopAudio ||
+ (aStopVideo && aRequestAudio)
+ ) {
+ Assert.deepEqual(
+ audioPerm,
+ {
+ state: SitePermissions.UNKNOWN,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ },
+ "microphone permissions removed"
+ );
+ Assert.deepEqual(
+ audioPermDevice,
+ {
+ state: SitePermissions.UNKNOWN,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ },
+ "microphone device-specific permissions removed"
+ );
+ } else {
+ Assert.deepEqual(
+ audioPerm,
+ {
+ state: SitePermissions.ALLOW,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ },
+ "microphone permissions untouched"
+ );
+ Assert.deepEqual(
+ audioPermDevice,
+ {
+ state: SitePermissions.ALLOW,
+ scope: SitePermissions.SCOPE_TEMPORARY,
+ },
+ "microphone device-specific permissions untouched"
+ );
+ }
+
+ let videoPerm = SitePermissions.getForPrincipal(
+ gBrowser.contentPrincipal,
+ "camera",
+ gBrowser.selectedBrowser
+ );
+ let videoPermDevice = SitePermissions.getForPrincipal(
+ gBrowser.contentPrincipal,
+ "camera^myDevice2",
+ gBrowser.selectedBrowser
+ );
+ if (
+ aRequestAudio ||
+ aRequestVideo ||
+ aStopVideo ||
+ (aStopAudio && aRequestVideo)
+ ) {
+ Assert.deepEqual(
+ videoPerm,
+ {
+ state: SitePermissions.UNKNOWN,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ },
+ "camera permissions removed"
+ );
+ Assert.deepEqual(
+ videoPermDevice,
+ {
+ state: SitePermissions.UNKNOWN,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ },
+ "camera device-specific permissions removed"
+ );
+ } else {
+ Assert.deepEqual(
+ videoPerm,
+ {
+ state: SitePermissions.ALLOW,
+ scope: SitePermissions.SCOPE_PERSISTENT,
+ },
+ "camera permissions untouched"
+ );
+ Assert.deepEqual(
+ videoPermDevice,
+ {
+ state: SitePermissions.ALLOW,
+ scope: SitePermissions.SCOPE_TEMPORARY,
+ },
+ "camera device-specific permissions untouched"
+ );
+ }
+ await checkNotSharing();
+
+ // Cleanup.
+ await closeStream(true);
+
+ SitePermissions.removeFromPrincipal(
+ gBrowser.contentPrincipal,
+ "camera",
+ gBrowser.selectedBrowser
+ );
+ SitePermissions.removeFromPrincipal(
+ gBrowser.contentPrincipal,
+ "microphone",
+ gBrowser.selectedBrowser
+ );
+ }
+
+ info("request audio+video, stop sharing video resets both");
+ await stopAndCheckPerm(true, true);
+ info("request audio only, stop sharing audio resets both");
+ await stopAndCheckPerm(true, false);
+ info("request video only, stop sharing video resets both");
+ await stopAndCheckPerm(false, true);
+ info("request audio only, stop sharing video resets both");
+ await stopAndCheckPerm(true, false, false, true);
+ info("request video only, stop sharing audio resets both");
+ await stopAndCheckPerm(false, true, true, false);
+ info("request neither, stop audio affects audio only");
+ await stopAndCheckPerm(false, false, true, false);
+ info("request neither, stop video affects video only");
+ await stopAndCheckPerm(false, false, false, true);
+ },
+ },
+
+ {
+ desc: "test showPermissionPanel",
+ run: async function checkShowPermissionPanel() {
+ if (!USING_LEGACY_INDICATOR) {
+ // The indicator only links to the permission panel for the
+ // legacy indicator.
+ return;
+ }
+
+ let observerPromise = expectObserverCalled("getUserMedia:request");
+ let promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(false, true);
+ await promise;
+ await observerPromise;
+ checkDeviceSelectors(["camera"]);
+
+ let indicator = promiseIndicatorWindow();
+ let observerPromise1 = expectObserverCalled(
+ "getUserMedia:response:allow"
+ );
+ let observerPromise2 = expectObserverCalled("recording-device-events");
+ await promiseMessage("ok", () => {
+ PopupNotifications.panel.firstElementChild.button.click();
+ });
+ await observerPromise1;
+ await observerPromise2;
+ Assert.deepEqual(
+ await getMediaCaptureState(),
+ { video: true },
+ "expected camera to be shared"
+ );
+
+ await indicator;
+ await checkSharingUI({ video: true });
+
+ ok(permissionPopupHidden(), "permission panel should be hidden");
+ if (IS_MAC) {
+ let activeStreams = webrtcUI.getActiveStreams(true, false, false);
+ webrtcUI.showSharingDoorhanger(activeStreams[0]);
+ } else {
+ let win = Services.wm.getMostRecentWindow(
+ "Browser:WebRTCGlobalIndicator"
+ );
+
+ let elt = win.document.getElementById("audioVideoButton");
+ EventUtils.synthesizeMouseAtCenter(elt, {}, win);
+ }
+
+ await TestUtils.waitForCondition(
+ () => !permissionPopupHidden(),
+ "wait for permission panel to open"
+ );
+ ok(!permissionPopupHidden(), "permission panel should be open");
+
+ gPermissionPanel._permissionPopup.hidePopup();
+
+ await closeStream();
+ },
+ },
+
+ {
+ desc: "'Always Allow' disabled on http pages",
+ run: async function checkNoAlwaysOnHttp() {
+ // Load an http page instead of the https version.
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["media.devices.insecure.enabled", true],
+ ["media.getusermedia.insecure.enabled", true],
+ // explicitly testing an http page, setting
+ // https-first to false.
+ ["dom.security.https_first", false],
+ ],
+ });
+
+ // Disable while loading a new page
+ await disableObserverVerification();
+
+ let browser = gBrowser.selectedBrowser;
+ BrowserTestUtils.loadURIString(
+ browser,
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ browser.documentURI.spec.replace("https://", "http://")
+ );
+ await BrowserTestUtils.browserLoaded(browser);
+
+ await enableObserverVerification();
+
+ // Initially set both permissions to 'allow'.
+ let uri = browser.documentURI;
+ PermissionTestUtils.add(uri, "microphone", Services.perms.ALLOW_ACTION);
+ PermissionTestUtils.add(uri, "camera", Services.perms.ALLOW_ACTION);
+
+ // Request devices and expect a prompt despite the saved 'Allow' permission,
+ // because the connection isn't secure.
+ let observerPromise = expectObserverCalled("getUserMedia:request");
+ let promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(true, true);
+ await promise;
+ await observerPromise;
+
+ // Ensure that checking the 'Remember this decision' checkbox disables
+ // 'Allow'.
+ let notification = PopupNotifications.panel.firstElementChild;
+ let checkbox = notification.checkbox;
+ ok(!!checkbox, "checkbox is present");
+ ok(!checkbox.checked, "checkbox is not checked");
+ checkbox.click();
+ ok(checkbox.checked, "checkbox now checked");
+ ok(notification.button.disabled, "Allow button is disabled");
+ ok(
+ !notification.hasAttribute("warninghidden"),
+ "warning message is shown"
+ );
+
+ // Cleanup.
+ await closeStream(true);
+ PermissionTestUtils.remove(uri, "camera");
+ PermissionTestUtils.remove(uri, "microphone");
+ },
+ },
+];
+
+add_task(async function test() {
+ await runTests(gTests);
+});
diff --git a/browser/base/content/test/webrtc/browser_devices_get_user_media_anim.js b/browser/base/content/test/webrtc/browser_devices_get_user_media_anim.js
new file mode 100644
index 0000000000..dd20a672c3
--- /dev/null
+++ b/browser/base/content/test/webrtc/browser_devices_get_user_media_anim.js
@@ -0,0 +1,106 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+var gTests = [
+ {
+ desc: "device sharing animation on background tabs",
+ run: async function checkAudioVideo() {
+ async function getStreamAndCheckBackgroundAnim(aAudio, aVideo, aSharing) {
+ // Get a stream
+ let observerPromise = expectObserverCalled("getUserMedia:request");
+ let popupPromise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(aAudio, aVideo);
+ await popupPromise;
+ await observerPromise;
+
+ let observerPromise1 = expectObserverCalled(
+ "getUserMedia:response:allow"
+ );
+ let observerPromise2 = expectObserverCalled("recording-device-events");
+ await promiseMessage("ok", () => {
+ PopupNotifications.panel.firstElementChild.button.click();
+ });
+ await observerPromise1;
+ await observerPromise2;
+ let expected = {};
+ if (aVideo) {
+ expected.video = true;
+ }
+ if (aAudio) {
+ expected.audio = true;
+ }
+ Assert.deepEqual(
+ await getMediaCaptureState(),
+ expected,
+ "expected " + Object.keys(expected).join(" and ") + " to be shared"
+ );
+
+ // Check the attribute on the tab, and check there's no visible
+ // sharing icon on the tab
+ let tab = gBrowser.selectedTab;
+ is(
+ tab.getAttribute("sharing"),
+ aSharing,
+ "the tab has the attribute to show the " + aSharing + " icon"
+ );
+ let icon = tab.sharingIcon;
+ is(
+ window.getComputedStyle(icon).display,
+ "none",
+ "the animated sharing icon of the tab is hidden"
+ );
+
+ // After selecting a new tab, check the attribute is still there,
+ // and the icon is now visible.
+ await BrowserTestUtils.switchTab(
+ gBrowser,
+ BrowserTestUtils.addTab(gBrowser)
+ );
+ is(
+ gBrowser.selectedTab.getAttribute("sharing"),
+ "",
+ "the new tab doesn't have the 'sharing' attribute"
+ );
+ is(
+ tab.getAttribute("sharing"),
+ aSharing,
+ "the tab still has the 'sharing' attribute"
+ );
+ isnot(
+ window.getComputedStyle(icon).display,
+ "none",
+ "the animated sharing icon of the tab is now visible"
+ );
+
+ // Ensure the icon disappears when selecting the tab.
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+ ok(tab.selected, "the tab with ongoing sharing is selected again");
+ is(
+ window.getComputedStyle(icon).display,
+ "none",
+ "the animated sharing icon is gone after selecting the tab again"
+ );
+
+ // And finally verify the attribute is removed when closing the stream.
+ await closeStream();
+
+ // TODO(Bug 1304997): Fix the race in closeStream() and remove this
+ // TestUtils.waitForCondition().
+ await TestUtils.waitForCondition(() => !tab.getAttribute("sharing"));
+ is(
+ tab.getAttribute("sharing"),
+ "",
+ "the tab no longer has the 'sharing' attribute after closing the stream"
+ );
+ }
+
+ await getStreamAndCheckBackgroundAnim(true, true, "camera");
+ await getStreamAndCheckBackgroundAnim(false, true, "camera");
+ await getStreamAndCheckBackgroundAnim(true, false, "microphone");
+ },
+ },
+];
+
+add_task(async function test() {
+ await runTests(gTests);
+});
diff --git a/browser/base/content/test/webrtc/browser_devices_get_user_media_by_device_id.js b/browser/base/content/test/webrtc/browser_devices_get_user_media_by_device_id.js
new file mode 100644
index 0000000000..3e5ca0668a
--- /dev/null
+++ b/browser/base/content/test/webrtc/browser_devices_get_user_media_by_device_id.js
@@ -0,0 +1,82 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TEST_ROOT = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content/",
+ "https://example.com/"
+);
+const TEST_PAGE = TEST_ROOT + "get_user_media.html";
+
+/**
+ * Utility function that should be called after a request for a device
+ * has been made. This function will allow sharing that device, and then
+ * immediately close the stream.
+ */
+async function allowStreamsThenClose() {
+ let observerPromise1 = expectObserverCalled("getUserMedia:response:allow");
+ let observerPromise2 = expectObserverCalled("recording-device-events");
+ await promiseMessage("ok", () => {
+ PopupNotifications.panel.firstElementChild.button.click();
+ });
+ await observerPromise1;
+ await observerPromise2;
+
+ await closeStream();
+}
+
+/**
+ * Tests that if a site requests a particular device by ID, that
+ * the Permission Panel menulist for that device shows only that
+ * device and is disabled.
+ */
+add_task(async function test_get_user_media_by_device_id() {
+ let prefs = [
+ [PREF_PERMISSION_FAKE, true],
+ [PREF_AUDIO_LOOPBACK, ""],
+ [PREF_VIDEO_LOOPBACK, ""],
+ [PREF_FAKE_STREAMS, true],
+ [PREF_FOCUS_SOURCE, false],
+ ];
+ await SpecialPowers.pushPrefEnv({ set: prefs });
+
+ let devices = await navigator.mediaDevices.enumerateDevices();
+ let audioId = devices
+ .filter(d => d.kind == "audioinput")
+ .map(d => d.deviceId)[0];
+ let videoId = devices
+ .filter(d => d.kind == "videoinput")
+ .map(d => d.deviceId)[0];
+
+ await BrowserTestUtils.withNewTab(TEST_PAGE, async browser => {
+ let promise = promisePopupNotificationShown("webRTC-shareDevices");
+ let observerPromise = expectObserverCalled("getUserMedia:request");
+ await promiseRequestDevice({ deviceId: { exact: audioId } });
+ await promise;
+ await observerPromise;
+ checkDeviceSelectors(["microphone"]);
+
+ await allowStreamsThenClose();
+
+ promise = promisePopupNotificationShown("webRTC-shareDevices");
+ observerPromise = expectObserverCalled("getUserMedia:request");
+ await promiseRequestDevice(false, { deviceId: { exact: videoId } });
+ await promise;
+ await observerPromise;
+ checkDeviceSelectors(["camera"]);
+
+ await allowStreamsThenClose();
+
+ promise = promisePopupNotificationShown("webRTC-shareDevices");
+ observerPromise = expectObserverCalled("getUserMedia:request");
+ await promiseRequestDevice(
+ { deviceId: { exact: audioId } },
+ { deviceId: { exact: videoId } }
+ );
+ await promise;
+ await observerPromise;
+ checkDeviceSelectors(["microphone", "camera"]);
+ await allowStreamsThenClose();
+ });
+});
diff --git a/browser/base/content/test/webrtc/browser_devices_get_user_media_default_permissions.js b/browser/base/content/test/webrtc/browser_devices_get_user_media_default_permissions.js
new file mode 100644
index 0000000000..e6464fd4aa
--- /dev/null
+++ b/browser/base/content/test/webrtc/browser_devices_get_user_media_default_permissions.js
@@ -0,0 +1,209 @@
+/* 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/. */
+
+const permissionError =
+ "error: NotAllowedError: The request is not allowed " +
+ "by the user agent or the platform in the current context.";
+
+const CAMERA_PREF = "permissions.default.camera";
+const MICROPHONE_PREF = "permissions.default.microphone";
+
+var gTests = [
+ {
+ desc: "getUserMedia audio+video: globally blocking camera",
+ run: async function checkAudioVideo() {
+ Services.prefs.setIntPref(CAMERA_PREF, SitePermissions.BLOCK);
+
+ // Requesting audio+video shouldn't work.
+ await Promise.all([
+ expectObserverCalled("getUserMedia:request"),
+ expectObserverCalled("getUserMedia:response:deny"),
+ expectObserverCalled("recording-window-ended"),
+ promiseMessage(permissionError),
+ promiseRequestDevice(true, true),
+ ]);
+ await checkNotSharing();
+
+ // Requesting only video shouldn't work.
+ await Promise.all([
+ expectObserverCalled("getUserMedia:request"),
+ expectObserverCalled("getUserMedia:response:deny"),
+ expectObserverCalled("recording-window-ended"),
+ promiseMessage(permissionError),
+ promiseRequestDevice(false, true),
+ ]);
+ await checkNotSharing();
+
+ // Requesting audio should work.
+ const observerPromise = expectObserverCalled("getUserMedia:request");
+ const promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(true);
+ await promise;
+ await observerPromise;
+
+ is(
+ PopupNotifications.getNotification("webRTC-shareDevices").anchorID,
+ "webRTC-shareMicrophone-notification-icon",
+ "anchored to mic icon"
+ );
+ checkDeviceSelectors(["microphone"]);
+
+ let indicator = promiseIndicatorWindow();
+ let observerPromise1 = expectObserverCalled(
+ "getUserMedia:response:allow"
+ );
+ let observerPromise2 = expectObserverCalled("recording-device-events");
+ await promiseMessage("ok", () => {
+ PopupNotifications.panel.firstElementChild.button.click();
+ });
+ await observerPromise1;
+ await observerPromise2;
+ Assert.deepEqual(
+ await getMediaCaptureState(),
+ { audio: true },
+ "expected microphone to be shared"
+ );
+
+ await indicator;
+ await checkSharingUI({ audio: true });
+ await closeStream();
+ Services.prefs.clearUserPref(CAMERA_PREF);
+ },
+ },
+
+ {
+ desc: "getUserMedia video: globally blocking camera + local exception",
+ run: async function checkAudioVideo() {
+ let browser = gBrowser.selectedBrowser;
+ Services.prefs.setIntPref(CAMERA_PREF, SitePermissions.BLOCK);
+ // Overwrite the permission for that URI, requesting video should work again.
+ PermissionTestUtils.add(
+ browser.currentURI,
+ "camera",
+ Services.perms.ALLOW_ACTION
+ );
+
+ // Requesting video should work.
+ let indicator = promiseIndicatorWindow();
+ let promises = [
+ expectObserverCalled("getUserMedia:request"),
+ expectObserverCalled("getUserMedia:response:allow"),
+ expectObserverCalled("recording-device-events"),
+ ];
+
+ let promise = promiseMessage("ok");
+ await promiseRequestDevice(false, true);
+ await promise;
+
+ await Promise.all(promises);
+ await indicator;
+ await checkSharingUI({ video: true }, undefined, undefined, {
+ video: { scope: SitePermissions.SCOPE_PERSISTENT },
+ });
+ await closeStream();
+
+ PermissionTestUtils.remove(browser.currentURI, "camera");
+ Services.prefs.clearUserPref(CAMERA_PREF);
+ },
+ },
+
+ {
+ desc: "getUserMedia audio+video: globally blocking microphone",
+ run: async function checkAudioVideo() {
+ Services.prefs.setIntPref(MICROPHONE_PREF, SitePermissions.BLOCK);
+
+ // Requesting audio+video shouldn't work.
+ await Promise.all([
+ expectObserverCalled("getUserMedia:request"),
+ expectObserverCalled("getUserMedia:response:deny"),
+ expectObserverCalled("recording-window-ended"),
+ promiseMessage(permissionError),
+ promiseRequestDevice(true, true),
+ ]);
+ await checkNotSharing();
+
+ // Requesting only audio shouldn't work.
+ await Promise.all([
+ expectObserverCalled("getUserMedia:request"),
+ expectObserverCalled("getUserMedia:response:deny"),
+ expectObserverCalled("recording-window-ended"),
+ promiseMessage(permissionError),
+ promiseRequestDevice(true),
+ ]);
+
+ // Requesting video should work.
+ const observerPromise = expectObserverCalled("getUserMedia:request");
+ const promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(false, true);
+ await promise;
+ await observerPromise;
+
+ is(
+ PopupNotifications.getNotification("webRTC-shareDevices").anchorID,
+ "webRTC-shareDevices-notification-icon",
+ "anchored to device icon"
+ );
+ checkDeviceSelectors(["camera"]);
+
+ let indicator = promiseIndicatorWindow();
+ let observerPromise1 = expectObserverCalled(
+ "getUserMedia:response:allow"
+ );
+ let observerPromise2 = expectObserverCalled("recording-device-events");
+ await promiseMessage("ok", () => {
+ PopupNotifications.panel.firstElementChild.button.click();
+ });
+ await observerPromise1;
+ await observerPromise2;
+ Assert.deepEqual(
+ await getMediaCaptureState(),
+ { video: true },
+ "expected camera to be shared"
+ );
+
+ await indicator;
+ await checkSharingUI({ video: true });
+ await closeStream();
+ Services.prefs.clearUserPref(MICROPHONE_PREF);
+ },
+ },
+
+ {
+ desc: "getUserMedia audio: globally blocking microphone + local exception",
+ run: async function checkAudioVideo() {
+ let browser = gBrowser.selectedBrowser;
+ Services.prefs.setIntPref(MICROPHONE_PREF, SitePermissions.BLOCK);
+ // Overwrite the permission for that URI, requesting video should work again.
+ PermissionTestUtils.add(
+ browser.currentURI,
+ "microphone",
+ Services.perms.ALLOW_ACTION
+ );
+
+ // Requesting audio should work.
+ let indicator = promiseIndicatorWindow();
+ let promises = [
+ expectObserverCalled("getUserMedia:request"),
+ expectObserverCalled("getUserMedia:response:allow"),
+ expectObserverCalled("recording-device-events"),
+ ];
+ let promise = promiseMessage("ok");
+ await promiseRequestDevice(true);
+ await promise;
+
+ await Promise.all(promises);
+ await indicator;
+ await checkSharingUI({ audio: true }, undefined, undefined, {
+ audio: { scope: SitePermissions.SCOPE_PERSISTENT },
+ });
+ await closeStream();
+
+ PermissionTestUtils.remove(browser.currentURI, "microphone");
+ Services.prefs.clearUserPref(MICROPHONE_PREF);
+ },
+ },
+];
+add_task(async function test() {
+ await runTests(gTests);
+});
diff --git a/browser/base/content/test/webrtc/browser_devices_get_user_media_grace.js b/browser/base/content/test/webrtc/browser_devices_get_user_media_grace.js
new file mode 100644
index 0000000000..0df69bb9da
--- /dev/null
+++ b/browser/base/content/test/webrtc/browser_devices_get_user_media_grace.js
@@ -0,0 +1,388 @@
+/* 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";
+
+requestLongerTimeout(2);
+
+const permissionError =
+ "error: NotAllowedError: The request is not allowed " +
+ "by the user agent or the platform in the current context.";
+
+const SAME_ORIGIN = "https://example.com";
+const CROSS_ORIGIN = "https://example.org";
+
+const PATH = "/browser/browser/base/content/test/webrtc/get_user_media.html";
+const PATH2 = "/browser/browser/base/content/test/webrtc/get_user_media2.html";
+
+const GRACE_PERIOD_MS = 3000;
+const WAIT_PERIOD_MS = GRACE_PERIOD_MS + 500;
+
+// We're inherently testing timeouts (grace periods)
+/* eslint-disable mozilla/no-arbitrary-setTimeout */
+const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
+const perms = SitePermissions;
+
+// These tests focus on camera and microphone, so we define some helpers.
+
+async function prompt(audio, video) {
+ let observerPromise = expectObserverCalled("getUserMedia:request");
+ let promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(audio, video);
+ await promise;
+ await observerPromise;
+ const expectedDeviceSelectorTypes = [
+ audio && "microphone",
+ video && "camera",
+ ].filter(x => x);
+ checkDeviceSelectors(expectedDeviceSelectorTypes);
+}
+
+async function allow(audio, video) {
+ let indicator = promiseIndicatorWindow();
+ let observerPromise1 = expectObserverCalled("getUserMedia:response:allow");
+ let observerPromise2 = expectObserverCalled("recording-device-events");
+ await promiseMessage("ok", () => {
+ PopupNotifications.panel.firstElementChild.button.click();
+ });
+ await observerPromise1;
+ await observerPromise2;
+ Assert.deepEqual(
+ Object.assign({ audio: false, video: false }, await getMediaCaptureState()),
+ { audio, video },
+ `expected ${video ? "camera " : ""} ${audio ? "microphone " : ""}shared`
+ );
+ await indicator;
+ await checkSharingUI({ audio, video });
+}
+
+async function deny(action) {
+ let observerPromise1 = expectObserverCalled("getUserMedia:response:deny");
+ let observerPromise2 = expectObserverCalled("recording-window-ended");
+ await promiseMessage(permissionError, () => {
+ activateSecondaryAction(action);
+ });
+ await observerPromise1;
+ await observerPromise2;
+ await checkNotSharing();
+}
+
+async function noPrompt(audio, video) {
+ let observerPromises = [
+ expectObserverCalled("getUserMedia:request"),
+ expectObserverCalled("getUserMedia:response:allow"),
+ expectObserverCalled("recording-device-events"),
+ ];
+ let promise = promiseMessage("ok");
+ await promiseRequestDevice(audio, video);
+ await promise;
+ await Promise.all(observerPromises);
+ await promiseNoPopupNotification("webRTC-shareDevices");
+ Assert.deepEqual(
+ Object.assign({ audio: false, video: false }, await getMediaCaptureState()),
+ { audio, video },
+ `expected ${video ? "camera " : ""} ${audio ? "microphone " : ""}shared`
+ );
+ await checkSharingUI({ audio, video });
+}
+
+async function navigate(browser, url) {
+ await disableObserverVerification();
+ let loaded = BrowserTestUtils.browserLoaded(browser, false, url);
+ await SpecialPowers.spawn(
+ browser,
+ [url],
+ u => (content.document.location = u)
+ );
+ await loaded;
+ await enableObserverVerification();
+}
+
+var gTests = [
+ {
+ desc: "getUserMedia camera+mic survives track.stop but not past grace",
+ run: async function checkAudioVideoGracePastStop() {
+ await prompt(true, true);
+ await allow(true, true);
+
+ info(
+ "After closing all streams, gUM(camera+mic) returns a stream " +
+ "without prompting within grace period."
+ );
+ await closeStream();
+ await checkNotSharingWithinGracePeriod();
+ await noPrompt(true, true);
+
+ info(
+ "After closing all streams, gUM(mic) returns a stream " +
+ "without prompting within grace period."
+ );
+ await closeStream();
+ await checkNotSharingWithinGracePeriod();
+ await noPrompt(true, false);
+
+ info(
+ "After closing all streams, gUM(camera) returns a stream " +
+ "without prompting within grace period."
+ );
+ await closeStream();
+ await checkNotSharingWithinGracePeriod();
+ await noPrompt(false, true);
+
+ info("gUM(screen) still causes a prompt.");
+ let observerPromise = expectObserverCalled("getUserMedia:request");
+ let promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(false, true, null, "screen");
+ await promise;
+ await observerPromise;
+
+ is(
+ PopupNotifications.getNotification("webRTC-shareDevices").anchorID,
+ "webRTC-shareScreen-notification-icon",
+ "anchored to device icon"
+ );
+ checkDeviceSelectors(["screen"]);
+
+ observerPromise = expectObserverCalled("getUserMedia:response:deny");
+ await promiseMessage(permissionError, () => {
+ activateSecondaryAction(kActionDeny);
+ });
+ await observerPromise;
+ perms.removeFromPrincipal(null, "screen", gBrowser.selectedBrowser);
+
+ await closeStream();
+ info("Closed stream. Waiting past grace period.");
+ await checkNotSharingWithinGracePeriod();
+ await wait(WAIT_PERIOD_MS);
+ await checkNotSharing();
+
+ info("After grace period expires, gUM(camera) causes a prompt.");
+ await prompt(false, true);
+ await deny(kActionDeny);
+ perms.removeFromPrincipal(null, "camera", gBrowser.selectedBrowser);
+
+ info("After grace period expires, gUM(mic) causes a prompt.");
+ await prompt(true, false);
+ await deny(kActionDeny);
+ perms.removeFromPrincipal(null, "microphone", gBrowser.selectedBrowser);
+ },
+ },
+
+ {
+ desc: "getUserMedia camera+mic survives page reload but not past grace",
+ run: async function checkAudioVideoGracePastReload(browser) {
+ await prompt(true, true);
+ await allow(true, true);
+ await closeStream();
+
+ await reloadFromContent();
+ info(
+ "After page reload, gUM(camera+mic) returns a stream " +
+ "without prompting within grace period."
+ );
+ await checkNotSharingWithinGracePeriod();
+ await noPrompt(true, true);
+ await closeStream();
+
+ await reloadAsUser();
+ info(
+ "After user page reload, gUM(camera+mic) returns a stream " +
+ "without prompting within grace period."
+ );
+ await checkNotSharingWithinGracePeriod();
+ await noPrompt(true, true);
+
+ info("gUM(screen) still causes a prompt.");
+ let observerPromise = expectObserverCalled("getUserMedia:request");
+ let promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(false, true, null, "screen");
+ await promise;
+ await observerPromise;
+
+ is(
+ PopupNotifications.getNotification("webRTC-shareDevices").anchorID,
+ "webRTC-shareScreen-notification-icon",
+ "anchored to device icon"
+ );
+ checkDeviceSelectors(["screen"]);
+
+ observerPromise = expectObserverCalled("getUserMedia:response:deny");
+ await promiseMessage(permissionError, () => {
+ activateSecondaryAction(kActionDeny);
+ });
+ await observerPromise;
+ perms.removeFromPrincipal(null, "screen", gBrowser.selectedBrowser);
+
+ await closeStream();
+ info("Closed stream. Waiting past grace period.");
+ await checkNotSharingWithinGracePeriod();
+ await wait(WAIT_PERIOD_MS);
+ await checkNotSharing();
+
+ info("After grace period expires, gUM(camera) causes a prompt.");
+ await prompt(false, true);
+ await deny(kActionDeny);
+ perms.removeFromPrincipal(null, "camera", gBrowser.selectedBrowser);
+
+ info("After grace period expires, gUM(mic) causes a prompt.");
+ await prompt(true, false);
+ await deny(kActionDeny);
+ perms.removeFromPrincipal(null, "microphone", gBrowser.selectedBrowser);
+ },
+ },
+
+ {
+ desc: "getUserMedia camera+mic grace period does not carry over to new tab",
+ run: async function checkAudioVideoGraceEndsNewTab() {
+ await prompt(true, true);
+ await allow(true, true);
+
+ info("Open same page in a new tab");
+ await disableObserverVerification();
+ await BrowserTestUtils.withNewTab(SAME_ORIGIN + PATH, async browser => {
+ info("In new tab, gUM(camera+mic) causes a prompt.");
+ await prompt(true, true);
+ });
+ info("Closed tab");
+ await enableObserverVerification();
+ await closeStream();
+ info("Closed stream. Waiting past grace period.");
+ await checkNotSharingWithinGracePeriod();
+ await wait(WAIT_PERIOD_MS);
+ await checkNotSharing();
+
+ info("After grace period expires, gUM(camera+mic) causes a prompt.");
+ await prompt(true, true);
+ await deny(kActionDeny);
+ perms.removeFromPrincipal(null, "camera", gBrowser.selectedBrowser);
+ perms.removeFromPrincipal(null, "microphone", gBrowser.selectedBrowser);
+ },
+ },
+
+ {
+ desc: "getUserMedia camera+mic survives navigation but not past grace",
+ run: async function checkAudioVideoGracePastNavigation(browser) {
+ // Use longer grace period in this test to accommodate navigation
+ const LONG_GRACE_PERIOD_MS = 9000;
+ const LONG_WAIT_PERIOD_MS = LONG_GRACE_PERIOD_MS + 500;
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["privacy.webrtc.deviceGracePeriodTimeoutMs", LONG_GRACE_PERIOD_MS],
+ ],
+ });
+ await prompt(true, true);
+ await allow(true, true);
+ await closeStream();
+
+ info("Navigate to a second same-origin page");
+ await navigate(browser, SAME_ORIGIN + PATH2);
+ info(
+ "After navigating to second same-origin page, gUM(camera+mic) " +
+ "returns a stream without prompting within grace period."
+ );
+ await checkNotSharingWithinGracePeriod();
+ await noPrompt(true, true);
+ await closeStream();
+
+ info("Closed stream. Waiting past grace period.");
+ await checkNotSharingWithinGracePeriod();
+ await wait(LONG_WAIT_PERIOD_MS);
+ await checkNotSharing();
+
+ info("After grace period expires, gUM(camera+mic) causes a prompt.");
+ await prompt(true, true);
+ await allow(true, true);
+
+ info("Navigate to a different-origin page");
+ await navigate(browser, CROSS_ORIGIN + PATH2);
+ info(
+ "After navigating to a different-origin page, gUM(camera+mic) " +
+ "causes a prompt."
+ );
+ await prompt(true, true);
+ await deny(kActionDeny);
+ perms.removeFromPrincipal(null, "camera", gBrowser.selectedBrowser);
+ perms.removeFromPrincipal(null, "microphone", gBrowser.selectedBrowser);
+
+ info("Navigate back to the first page");
+ await navigate(browser, SAME_ORIGIN + PATH);
+ info(
+ "After navigating back to the first page, gUM(camera+mic) " +
+ "returns a stream without prompting within grace period."
+ );
+ await checkNotSharingWithinGracePeriod();
+ await noPrompt(true, true);
+ await closeStream();
+ info("Closed stream. Waiting past grace period.");
+ await checkNotSharingWithinGracePeriod();
+ await wait(LONG_WAIT_PERIOD_MS);
+ await checkNotSharing();
+
+ info("After grace period expires, gUM(camera+mic) causes a prompt.");
+ await prompt(true, true);
+ await deny(kActionDeny);
+ perms.removeFromPrincipal(null, "camera", gBrowser.selectedBrowser);
+ perms.removeFromPrincipal(null, "microphone", gBrowser.selectedBrowser);
+ },
+ },
+
+ {
+ desc: "getUserMedia camera+mic grace period cleared on permission block",
+ run: async function checkAudioVideoGraceEndsNewTab(browser) {
+ await SpecialPowers.pushPrefEnv({
+ set: [["privacy.webrtc.deviceGracePeriodTimeoutMs", 10000]],
+ });
+ info("Set up longer camera grace period.");
+ await prompt(false, true);
+ await allow(false, true);
+ await closeStream();
+ let principal = gBrowser.selectedBrowser.contentPrincipal;
+ info("Request both to get prompted so we can block both.");
+ await prompt(true, true);
+ // We need to remember this decision to set a block permission here and not just 'Not now' the request, see Bug:1609578
+ await deny(kActionNever);
+ // Clear the block so we can prompt again.
+ perms.removeFromPrincipal(principal, "camera", gBrowser.selectedBrowser);
+ perms.removeFromPrincipal(
+ principal,
+ "microphone",
+ gBrowser.selectedBrowser
+ );
+
+ info("Revoking permission clears camera grace period.");
+ await prompt(false, true);
+ await deny(kActionDeny);
+ perms.removeFromPrincipal(null, "camera", gBrowser.selectedBrowser);
+
+ info("Set up longer microphone grace period.");
+ await prompt(true, false);
+ await allow(true, false);
+ await closeStream();
+
+ info("Request both to get prompted so we can block both.");
+ await prompt(true, true);
+ // We need to remember this decision to be able to set a block permission here
+ await deny(kActionNever);
+ perms.removeFromPrincipal(principal, "camera", gBrowser.selectedBrowser);
+ perms.removeFromPrincipal(
+ principal,
+ "microphone",
+ gBrowser.selectedBrowser
+ );
+
+ info("Revoking permission clears microphone grace period.");
+ await prompt(true, false);
+ // We need to remember this decision to be able to set a block permission here
+ await deny(kActionNever);
+ perms.removeFromPrincipal(null, "microphone", gBrowser.selectedBrowser);
+ },
+ },
+];
+
+add_task(async function test() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["privacy.webrtc.deviceGracePeriodTimeoutMs", GRACE_PERIOD_MS]],
+ });
+ await runTests(gTests);
+});
diff --git a/browser/base/content/test/webrtc/browser_devices_get_user_media_in_frame.js b/browser/base/content/test/webrtc/browser_devices_get_user_media_in_frame.js
new file mode 100644
index 0000000000..81e04cebce
--- /dev/null
+++ b/browser/base/content/test/webrtc/browser_devices_get_user_media_in_frame.js
@@ -0,0 +1,775 @@
+/* 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/. */
+
+SpecialPowers.pushPrefEnv({
+ set: [["permissions.delegation.enabled", true]],
+});
+
+// This test has been seen timing out locally in non-opt debug builds.
+requestLongerTimeout(2);
+
+var gTests = [
+ {
+ desc: "getUserMedia audio+video",
+ run: async function checkAudioVideo(aBrowser, aSubFrames) {
+ let {
+ bc: frame1BC,
+ id: frame1ID,
+ observeBC: frame1ObserveBC,
+ } = (
+ await getBrowsingContextsAndFrameIdsForSubFrames(
+ aBrowser.browsingContext,
+ aSubFrames
+ )
+ )[0];
+
+ let observerPromise = expectObserverCalled(
+ "getUserMedia:request",
+ 1,
+ frame1ObserveBC
+ );
+ let promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(true, true, frame1ID, undefined, frame1BC);
+ await promise;
+ await observerPromise;
+
+ is(
+ PopupNotifications.getNotification("webRTC-shareDevices").anchorID,
+ "webRTC-shareDevices-notification-icon",
+ "anchored to device icon"
+ );
+ checkDeviceSelectors(["microphone", "camera"]);
+ is(
+ PopupNotifications.panel.firstElementChild.getAttribute("popupid"),
+ "webRTC-shareDevices",
+ "panel using devices icon"
+ );
+
+ let indicator = promiseIndicatorWindow();
+ let observerPromise1 = expectObserverCalled(
+ "getUserMedia:response:allow",
+ 1,
+ frame1ObserveBC
+ );
+ let observerPromise2 = expectObserverCalled(
+ "recording-device-events",
+ 1,
+ frame1ObserveBC
+ );
+ await promiseMessage("ok", () => {
+ PopupNotifications.panel.firstElementChild.button.click();
+ });
+ await observerPromise1;
+ await observerPromise2;
+ Assert.deepEqual(
+ await getMediaCaptureState(),
+ { audio: true, video: true },
+ "expected camera and microphone to be shared"
+ );
+
+ await indicator;
+ await checkSharingUI({ audio: true, video: true });
+ await closeStream(false, frame1ID, undefined, frame1BC, frame1ObserveBC);
+ },
+ },
+
+ {
+ desc: "getUserMedia audio+video: stop sharing",
+ run: async function checkStopSharing(aBrowser, aSubFrames) {
+ let {
+ bc: frame1BC,
+ id: frame1ID,
+ observeBC: frame1ObserveBC,
+ } = (
+ await getBrowsingContextsAndFrameIdsForSubFrames(
+ aBrowser.browsingContext,
+ aSubFrames
+ )
+ )[0];
+
+ let observerPromise = expectObserverCalled(
+ "getUserMedia:request",
+ 1,
+ frame1ObserveBC
+ );
+ let promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(true, true, frame1ID, undefined, frame1BC);
+ await promise;
+ await observerPromise;
+ checkDeviceSelectors(["microphone", "camera"]);
+
+ let indicator = promiseIndicatorWindow();
+ let observerPromise1 = expectObserverCalled(
+ "getUserMedia:response:allow",
+ 1,
+ frame1ObserveBC
+ );
+ let observerPromise2 = expectObserverCalled(
+ "recording-device-events",
+ 1,
+ frame1ObserveBC
+ );
+ await promiseMessage("ok", () => {
+ activateSecondaryAction(kActionAlways);
+ });
+ await observerPromise1;
+ await observerPromise2;
+ Assert.deepEqual(
+ await getMediaCaptureState(),
+ { audio: true, video: true },
+ "expected camera and microphone to be shared"
+ );
+
+ await indicator;
+ await checkSharingUI({ video: true, audio: true }, undefined, undefined, {
+ video: { scope: SitePermissions.SCOPE_PERSISTENT },
+ audio: { scope: SitePermissions.SCOPE_PERSISTENT },
+ });
+
+ let uri = Services.io.newURI("https://example.com/");
+ is(
+ PermissionTestUtils.testExactPermission(uri, "microphone"),
+ Services.perms.ALLOW_ACTION,
+ "microphone persistently allowed"
+ );
+ is(
+ PermissionTestUtils.testExactPermission(uri, "camera"),
+ Services.perms.ALLOW_ACTION,
+ "camera persistently allowed"
+ );
+
+ await stopSharing("camera", false, frame1ObserveBC);
+
+ // The persistent permissions for the frame should have been removed.
+ is(
+ PermissionTestUtils.testExactPermission(uri, "microphone"),
+ Services.perms.UNKNOWN_ACTION,
+ "microphone not persistently allowed"
+ );
+ is(
+ PermissionTestUtils.testExactPermission(uri, "camera"),
+ Services.perms.UNKNOWN_ACTION,
+ "camera not persistently allowed"
+ );
+
+ // the stream is already closed, but this will do some cleanup anyway
+ await closeStream(true, frame1ID, undefined, frame1BC, frame1ObserveBC);
+ },
+ },
+
+ {
+ desc: "getUserMedia audio+video: Revoking active devices in frame does not add grace period.",
+ run: async function checkStopSharingGracePeriod(aBrowser, aSubFrames) {
+ let {
+ bc: frame1BC,
+ id: frame1ID,
+ observeBC: frame1ObserveBC,
+ } = (
+ await getBrowsingContextsAndFrameIdsForSubFrames(
+ aBrowser.browsingContext,
+ aSubFrames
+ )
+ )[0];
+
+ let observerPromise = expectObserverCalled(
+ "getUserMedia:request",
+ 1,
+ frame1ObserveBC
+ );
+ let promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(true, true, frame1ID, undefined, frame1BC);
+ await promise;
+ await observerPromise;
+ checkDeviceSelectors(["microphone", "camera"]);
+
+ let indicator = promiseIndicatorWindow();
+ let observerPromise1 = expectObserverCalled(
+ "getUserMedia:response:allow",
+ 1,
+ frame1ObserveBC
+ );
+ let observerPromise2 = expectObserverCalled(
+ "recording-device-events",
+ 1,
+ frame1ObserveBC
+ );
+ await promiseMessage("ok", () => {
+ PopupNotifications.panel.firstElementChild.button.click();
+ });
+ await observerPromise1;
+ await observerPromise2;
+ Assert.deepEqual(
+ await getMediaCaptureState(),
+ { audio: true, video: true },
+ "expected camera and microphone to be shared"
+ );
+
+ await indicator;
+ await checkSharingUI({ video: true, audio: true });
+
+ // Stop sharing for camera and test that we stopped sharing.
+ await stopSharing("camera", false, frame1ObserveBC);
+
+ // There shouldn't be any grace period permissions at this point.
+ ok(
+ !SitePermissions.getAllForBrowser(aBrowser).length,
+ "Should not set any permissions."
+ );
+
+ // A new request should result in a prompt.
+ observerPromise = expectObserverCalled(
+ "getUserMedia:request",
+ 1,
+ frame1ObserveBC
+ );
+ let notificationPromise = promisePopupNotificationShown(
+ "webRTC-shareDevices"
+ );
+ await promiseRequestDevice(true, true, frame1ID, undefined, frame1BC);
+ await notificationPromise;
+ await observerPromise;
+
+ let denyPromise = expectObserverCalled(
+ "getUserMedia:response:deny",
+ 1,
+ frame1ObserveBC
+ );
+ let recordingEndedPromise = expectObserverCalled(
+ "recording-window-ended",
+ 1,
+ frame1ObserveBC
+ );
+ const permissionError =
+ "error: NotAllowedError: The request is not allowed " +
+ "by the user agent or the platform in the current context.";
+ await promiseMessage(permissionError, () => {
+ activateSecondaryAction(kActionDeny);
+ });
+ await denyPromise;
+ await recordingEndedPromise;
+
+ // Clean up the temporary blocks from the prompt deny.
+ SitePermissions.clearTemporaryBlockPermissions(aBrowser);
+
+ // the stream is already closed, but this will do some cleanup anyway
+ await closeStream(true, frame1ID, undefined, frame1BC, frame1ObserveBC);
+ },
+ },
+
+ {
+ desc: "getUserMedia audio+video: reloading the frame removes all sharing UI",
+ run: async function checkReloading(aBrowser, aSubFrames) {
+ let {
+ bc: frame1BC,
+ id: frame1ID,
+ observeBC: frame1ObserveBC,
+ } = (
+ await getBrowsingContextsAndFrameIdsForSubFrames(
+ aBrowser.browsingContext,
+ aSubFrames
+ )
+ )[0];
+
+ let observerPromise = expectObserverCalled(
+ "getUserMedia:request",
+ 1,
+ frame1ObserveBC
+ );
+ let promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(true, true, frame1ID, undefined, frame1BC);
+ await promise;
+ await observerPromise;
+ checkDeviceSelectors(["microphone", "camera"]);
+
+ let observerPromise1 = expectObserverCalled(
+ "getUserMedia:response:allow",
+ 1,
+ frame1ObserveBC
+ );
+ let observerPromise2 = expectObserverCalled(
+ "recording-device-events",
+ 1,
+ frame1ObserveBC
+ );
+ let indicator = promiseIndicatorWindow();
+ await promiseMessage("ok", () => {
+ PopupNotifications.panel.firstElementChild.button.click();
+ });
+ await observerPromise1;
+ await observerPromise2;
+ Assert.deepEqual(
+ await getMediaCaptureState(),
+ { audio: true, video: true },
+ "expected camera and microphone to be shared"
+ );
+
+ await indicator;
+ await checkSharingUI({ video: true, audio: true });
+
+ // Disable while loading a new page
+ await disableObserverVerification();
+
+ info("reloading the frame");
+ let promises = [
+ expectObserverCalledOnClose(
+ "recording-device-stopped",
+ 1,
+ frame1ObserveBC
+ ),
+ expectObserverCalledOnClose(
+ "recording-device-events",
+ 1,
+ frame1ObserveBC
+ ),
+ expectObserverCalledOnClose(
+ "recording-window-ended",
+ 1,
+ frame1ObserveBC
+ ),
+ ];
+ await promiseReloadFrame(frame1ID, frame1BC);
+ await Promise.all(promises);
+
+ await enableObserverVerification();
+
+ await checkNotSharing();
+ },
+ },
+
+ {
+ desc: "getUserMedia audio+video: reloading the frame removes prompts",
+ run: async function checkReloadingRemovesPrompts(aBrowser, aSubFrames) {
+ let {
+ bc: frame1BC,
+ id: frame1ID,
+ observeBC: frame1ObserveBC,
+ } = (
+ await getBrowsingContextsAndFrameIdsForSubFrames(
+ aBrowser.browsingContext,
+ aSubFrames
+ )
+ )[0];
+
+ let observerPromise = expectObserverCalled(
+ "getUserMedia:request",
+ 1,
+ frame1ObserveBC
+ );
+ let promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(true, true, frame1ID, undefined, frame1BC);
+ await promise;
+ await observerPromise;
+ checkDeviceSelectors(["microphone", "camera"]);
+
+ info("reloading the frame");
+ promise = expectObserverCalledOnClose(
+ "recording-window-ended",
+ 1,
+ frame1ObserveBC
+ );
+ await promiseReloadFrame(frame1ID, frame1BC);
+ await promise;
+ await promiseNoPopupNotification("webRTC-shareDevices");
+
+ await checkNotSharing();
+ },
+ },
+
+ {
+ desc: "getUserMedia audio+video: with two frames sharing at the same time, sharing UI shows all shared devices",
+ run: async function checkFrameOverridingSharingUI(aBrowser, aSubFrames) {
+ // This tests an edge case discovered in bug 1440356 that works like this
+ // - Share audio and video in iframe 1.
+ // - Share only video in iframe 2.
+ // The WebRTC UI should still show both video and audio indicators.
+
+ let bcsAndFrameIds = await getBrowsingContextsAndFrameIdsForSubFrames(
+ aBrowser.browsingContext,
+ aSubFrames
+ );
+ let {
+ bc: frame1BC,
+ id: frame1ID,
+ observeBC: frame1ObserveBC,
+ } = bcsAndFrameIds[0];
+
+ let observerPromise = expectObserverCalled(
+ "getUserMedia:request",
+ 1,
+ frame1ObserveBC
+ );
+ let promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(true, true, frame1ID, undefined, frame1BC);
+ await promise;
+ await observerPromise;
+ checkDeviceSelectors(["microphone", "camera"]);
+
+ let indicator = promiseIndicatorWindow();
+ let observerPromise1 = expectObserverCalled(
+ "getUserMedia:response:allow",
+ 1,
+ frame1ObserveBC
+ );
+ let observerPromise2 = expectObserverCalled(
+ "recording-device-events",
+ 1,
+ frame1ObserveBC
+ );
+ await promiseMessage("ok", () => {
+ PopupNotifications.panel.firstElementChild.button.click();
+ });
+ await observerPromise1;
+ await observerPromise2;
+ Assert.deepEqual(
+ await getMediaCaptureState(),
+ { audio: true, video: true },
+ "expected camera and microphone to be shared"
+ );
+
+ await indicator;
+ await checkSharingUI({ video: true, audio: true });
+
+ // Check that requesting a new device from a different frame
+ // doesn't override sharing UI.
+ let {
+ bc: frame2BC,
+ id: frame2ID,
+ observeBC: frame2ObserveBC,
+ } = bcsAndFrameIds[1];
+
+ observerPromise = expectObserverCalled(
+ "getUserMedia:request",
+ 1,
+ frame2ObserveBC
+ );
+ promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(false, true, frame2ID, undefined, frame2BC);
+ await promise;
+ await observerPromise;
+ checkDeviceSelectors(["camera"]);
+
+ observerPromise1 = expectObserverCalled(
+ "getUserMedia:response:allow",
+ 1,
+ frame2ObserveBC
+ );
+ observerPromise2 = expectObserverCalled(
+ "recording-device-events",
+ 1,
+ frame2ObserveBC
+ );
+
+ await promiseMessage("ok", () => {
+ PopupNotifications.panel.firstElementChild.button.click();
+ });
+ await observerPromise1;
+ await observerPromise2;
+ Assert.deepEqual(
+ await getMediaCaptureState(),
+ { audio: true, video: true },
+ "expected camera and microphone to be shared"
+ );
+
+ await checkSharingUI({ video: true, audio: true });
+
+ // Check that ending the stream with the other frame
+ // doesn't override sharing UI.
+
+ observerPromise = expectObserverCalledOnClose(
+ "recording-window-ended",
+ 1,
+ frame2ObserveBC
+ );
+ promise = expectObserverCalledOnClose(
+ "recording-device-events",
+ 1,
+ frame2ObserveBC
+ );
+ await promiseReloadFrame(frame2ID, frame2BC);
+ await promise;
+
+ await observerPromise;
+ await checkSharingUI({ video: true, audio: true });
+
+ await closeStream(false, frame1ID, undefined, frame1BC, frame1ObserveBC);
+ await checkNotSharing();
+ },
+ },
+
+ {
+ desc: "getUserMedia audio+video: reloading a frame updates the sharing UI",
+ run: async function checkUpdateWhenReloading(aBrowser, aSubFrames) {
+ // We'll share only the cam in the first frame, then share both in the
+ // second frame, then reload the second frame. After each step, we'll check
+ // the UI is in the correct state.
+ let bcsAndFrameIds = await getBrowsingContextsAndFrameIdsForSubFrames(
+ aBrowser.browsingContext,
+ aSubFrames
+ );
+ let {
+ bc: frame1BC,
+ id: frame1ID,
+ observeBC: frame1ObserveBC,
+ } = bcsAndFrameIds[0];
+
+ let observerPromise = expectObserverCalled(
+ "getUserMedia:request",
+ 1,
+ frame1ObserveBC
+ );
+ let promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(false, true, frame1ID, undefined, frame1BC);
+ await promise;
+ await observerPromise;
+ checkDeviceSelectors(["camera"]);
+
+ let indicator = promiseIndicatorWindow();
+ let observerPromise1 = expectObserverCalled(
+ "getUserMedia:response:allow",
+ 1,
+ frame1ObserveBC
+ );
+ let observerPromise2 = expectObserverCalled(
+ "recording-device-events",
+ 1,
+ frame1ObserveBC
+ );
+ await promiseMessage("ok", () => {
+ PopupNotifications.panel.firstElementChild.button.click();
+ });
+ await observerPromise1;
+ await observerPromise2;
+ Assert.deepEqual(
+ await getMediaCaptureState(),
+ { video: true },
+ "expected camera to be shared"
+ );
+
+ await indicator;
+ await checkSharingUI({ video: true, audio: false });
+
+ let {
+ bc: frame2BC,
+ id: frame2ID,
+ observeBC: frame2ObserveBC,
+ } = bcsAndFrameIds[1];
+
+ observerPromise = expectObserverCalled(
+ "getUserMedia:request",
+ 1,
+ frame2ObserveBC
+ );
+ promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(true, true, frame2ID, undefined, frame2BC);
+ await promise;
+ await observerPromise;
+ checkDeviceSelectors(["microphone", "camera"]);
+
+ observerPromise1 = expectObserverCalled(
+ "getUserMedia:response:allow",
+ 1,
+ frame2ObserveBC
+ );
+ observerPromise2 = expectObserverCalled(
+ "recording-device-events",
+ 1,
+ frame2ObserveBC
+ );
+ await promiseMessage("ok", () => {
+ PopupNotifications.panel.firstElementChild.button.click();
+ });
+ await observerPromise1;
+ await observerPromise2;
+ Assert.deepEqual(
+ await getMediaCaptureState(),
+ { audio: true, video: true },
+ "expected camera and microphone to be shared"
+ );
+
+ await checkSharingUI({ video: true, audio: true });
+
+ info("reloading the second frame");
+
+ observerPromise1 = expectObserverCalledOnClose(
+ "recording-device-events",
+ 1,
+ frame2ObserveBC
+ );
+ observerPromise2 = expectObserverCalledOnClose(
+ "recording-window-ended",
+ 1,
+ frame2ObserveBC
+ );
+ await promiseReloadFrame(frame2ID, frame2BC);
+ await observerPromise1;
+ await observerPromise2;
+
+ await checkSharingUI({ video: true, audio: false });
+
+ await closeStream(false, frame1ID, undefined, frame1BC, frame1ObserveBC);
+ await checkNotSharing();
+ },
+ },
+
+ {
+ desc: "getUserMedia audio+video: reloading the top level page removes all sharing UI",
+ run: async function checkReloading(aBrowser, aSubFrames) {
+ let {
+ bc: frame1BC,
+ id: frame1ID,
+ observeBC: frame1ObserveBC,
+ } = (
+ await getBrowsingContextsAndFrameIdsForSubFrames(
+ aBrowser.browsingContext,
+ aSubFrames
+ )
+ )[0];
+
+ let observerPromise = expectObserverCalled(
+ "getUserMedia:request",
+ 1,
+ frame1ObserveBC
+ );
+ let promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(true, true, frame1ID, undefined, frame1BC);
+ await promise;
+ await observerPromise;
+ checkDeviceSelectors(["microphone", "camera"]);
+
+ let indicator = promiseIndicatorWindow();
+ let observerPromise1 = expectObserverCalled(
+ "getUserMedia:response:allow",
+ 1,
+ frame1ObserveBC
+ );
+ let observerPromise2 = expectObserverCalled(
+ "recording-device-events",
+ 1,
+ frame1ObserveBC
+ );
+ await promiseMessage("ok", () => {
+ PopupNotifications.panel.firstElementChild.button.click();
+ });
+ await observerPromise1;
+ await observerPromise2;
+ Assert.deepEqual(
+ await getMediaCaptureState(),
+ { audio: true, video: true },
+ "expected camera and microphone to be shared"
+ );
+
+ await indicator;
+ await checkSharingUI({ video: true, audio: true });
+
+ await reloadAndAssertClosedStreams();
+ },
+ },
+
+ {
+ desc: "getUserMedia audio+video: closing a window with two frames sharing at the same time, closes the indicator",
+ skipObserverVerification: true,
+ run: async function checkFrameIndicatorClosedUI(aBrowser, aSubFrames) {
+ // This tests a case where the indicator didn't close when audio/video is
+ // shared in two subframes and then the tabs are closed.
+
+ let tabsToRemove = [gBrowser.selectedTab];
+
+ for (let t = 0; t < 2; t++) {
+ let {
+ bc: frame1BC,
+ id: frame1ID,
+ observeBC: frame1ObserveBC,
+ } = (
+ await getBrowsingContextsAndFrameIdsForSubFrames(
+ gBrowser.selectedBrowser.browsingContext,
+ aSubFrames
+ )
+ )[0];
+
+ let observerPromise = expectObserverCalled(
+ "getUserMedia:request",
+ 1,
+ frame1ObserveBC
+ );
+ let promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(true, true, frame1ID, undefined, frame1BC);
+ await promise;
+ await observerPromise;
+ checkDeviceSelectors(["microphone", "camera"]);
+
+ // During the second pass, the indicator is already open.
+ let indicator = t == 0 ? promiseIndicatorWindow() : Promise.resolve();
+
+ let observerPromise1 = expectObserverCalled(
+ "getUserMedia:response:allow",
+ 1,
+ frame1ObserveBC
+ );
+ let observerPromise2 = expectObserverCalled(
+ "recording-device-events",
+ 1,
+ frame1ObserveBC
+ );
+ await promiseMessage("ok", () => {
+ PopupNotifications.panel.firstElementChild.button.click();
+ });
+ await observerPromise1;
+ await observerPromise2;
+ Assert.deepEqual(
+ await getMediaCaptureState(),
+ { audio: true, video: true },
+ "expected camera and microphone to be shared"
+ );
+
+ await indicator;
+ await checkSharingUI({ video: true, audio: true });
+
+ // The first time around, open another tab with the same uri.
+ // The second time, just open a normal test tab.
+ let uri = t == 0 ? gBrowser.selectedBrowser.currentURI.spec : undefined;
+ tabsToRemove.push(
+ await BrowserTestUtils.openNewForegroundTab(gBrowser, uri)
+ );
+ }
+
+ BrowserTestUtils.removeTab(tabsToRemove[0]);
+ BrowserTestUtils.removeTab(tabsToRemove[1]);
+
+ await checkNotSharing();
+ },
+ },
+];
+
+add_task(async function test_inprocess() {
+ await runTests(gTests, {
+ relativeURI: "get_user_media_in_frame.html",
+ subFrames: { frame1: {}, frame2: {} },
+ });
+});
+
+add_task(async function test_outofprocess() {
+ const origin1 = encodeURI("https://test1.example.org");
+ const origin2 = encodeURI("https://www.mozilla.org:443");
+ const query = `origin=${origin1}&origin=${origin2}`;
+ const observe = SpecialPowers.useRemoteSubframes;
+ await runTests(gTests, {
+ relativeURI: `get_user_media_in_frame.html?${query}`,
+ subFrames: { frame1: { observe }, frame2: { observe } },
+ });
+});
+
+add_task(async function test_inprocess_in_outofprocess() {
+ const oopOrigin = encodeURI("https://www.mozilla.org");
+ const sameOrigin = encodeURI("https://example.com");
+ const query = `origin=${oopOrigin}&nested=${sameOrigin}&nested=${sameOrigin}`;
+ await runTests(gTests, {
+ relativeURI: `get_user_media_in_frame.html?${query}`,
+ subFrames: {
+ frame1: {
+ noTest: true,
+ children: { frame1: {}, frame2: {} },
+ },
+ },
+ });
+});
diff --git a/browser/base/content/test/webrtc/browser_devices_get_user_media_in_xorigin_frame.js b/browser/base/content/test/webrtc/browser_devices_get_user_media_in_xorigin_frame.js
new file mode 100644
index 0000000000..8c0b0476f3
--- /dev/null
+++ b/browser/base/content/test/webrtc/browser_devices_get_user_media_in_xorigin_frame.js
@@ -0,0 +1,798 @@
+/* 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/. */
+const permissionError =
+ "error: NotAllowedError: The request is not allowed " +
+ "by the user agent or the platform in the current context.";
+
+const PromptResult = {
+ ALLOW: "allow",
+ DENY: "deny",
+ PROMPT: "prompt",
+};
+
+const Perms = Services.perms;
+
+async function promptNoDelegate(aThirdPartyOrgin, audio = true, video = true) {
+ // Persistent allowed first party origin
+ const uri = gBrowser.selectedBrowser.documentURI;
+ if (audio) {
+ PermissionTestUtils.add(uri, "microphone", Services.perms.ALLOW_ACTION);
+ }
+ if (video) {
+ PermissionTestUtils.add(uri, "camera", Services.perms.ALLOW_ACTION);
+ }
+
+ // Check that we get a prompt.
+ const observerPromise = expectObserverCalled("getUserMedia:request");
+ const promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(audio, video, "frame4");
+ await promise;
+ await observerPromise;
+
+ // The 'Remember this decision' checkbox is hidden.
+ const notification = PopupNotifications.panel.firstElementChild;
+ const checkbox = notification.checkbox;
+ ok(!!checkbox, "checkbox is present");
+ ok(checkbox.hidden, "checkbox is not visible");
+ ok(!checkbox.checked, "checkbox not checked");
+
+ // Check the label of the notification should be the first party
+ is(
+ PopupNotifications.getNotification("webRTC-shareDevices").options.name,
+ uri.host,
+ "Use first party's origin"
+ );
+
+ // Check the secondName of the notification should be the third party
+ is(
+ PopupNotifications.getNotification("webRTC-shareDevices").options
+ .secondName,
+ aThirdPartyOrgin,
+ "Use third party's origin as secondName"
+ );
+
+ let indicator = promiseIndicatorWindow();
+ let observerPromise1 = expectObserverCalled("getUserMedia:response:allow");
+ let observerPromise2 = expectObserverCalled("recording-device-events");
+ await promiseMessage("ok", () =>
+ EventUtils.synthesizeMouseAtCenter(notification.button, {})
+ );
+ await observerPromise1;
+ await observerPromise2;
+ let state = await getMediaCaptureState();
+ is(
+ !!state.audio,
+ audio,
+ `expected microphone to be ${audio ? "" : "not"} shared`
+ );
+ is(
+ !!state.video,
+ video,
+ `expected camera to be ${video ? "" : "not"} shared`
+ );
+ await indicator;
+ await checkSharingUI({ audio, video }, undefined, undefined, {
+ video: { scope: SitePermissions.SCOPE_PERSISTENT },
+ audio: { scope: SitePermissions.SCOPE_PERSISTENT },
+ });
+
+ // Cleanup.
+ await closeStream(false, "frame4");
+
+ PermissionTestUtils.remove(uri, "camera");
+ PermissionTestUtils.remove(uri, "microphone");
+}
+
+async function promptNoDelegateScreenSharing(aThirdPartyOrgin) {
+ // Persistent allow screen sharing
+ const uri = gBrowser.selectedBrowser.documentURI;
+ PermissionTestUtils.add(uri, "screen", Services.perms.ALLOW_ACTION);
+
+ const observerPromise = expectObserverCalled("getUserMedia:request");
+ const promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(false, true, "frame4", "screen");
+ await promise;
+ await observerPromise;
+
+ checkDeviceSelectors(["screen"]);
+ const notification = PopupNotifications.panel.firstElementChild;
+
+ // The 'Remember this decision' checkbox is hidden.
+ const checkbox = notification.checkbox;
+ ok(!!checkbox, "checkbox is present");
+
+ if (ALLOW_SILENCING_NOTIFICATIONS) {
+ ok(!checkbox.hidden, "Notification silencing checkbox is visible");
+ } else {
+ ok(checkbox.hidden, "checkbox is not visible");
+ }
+
+ ok(!checkbox.checked, "checkbox not checked");
+
+ // Check the label of the notification should be the first party
+ is(
+ PopupNotifications.getNotification("webRTC-shareDevices").options.name,
+ uri.host,
+ "Use first party's origin"
+ );
+
+ // Check the secondName of the notification should be the third party
+ is(
+ PopupNotifications.getNotification("webRTC-shareDevices").options
+ .secondName,
+ aThirdPartyOrgin,
+ "Use third party's origin as secondName"
+ );
+
+ const menulist = document.getElementById("webRTC-selectWindow-menulist");
+ const count = menulist.itemCount;
+ menulist.getItemAtIndex(count - 1).doCommand();
+ ok(!notification.button.disabled, "Allow button is enabled");
+
+ const indicator = promiseIndicatorWindow();
+ const observerPromise1 = expectObserverCalled("getUserMedia:response:allow");
+ const observerPromise2 = expectObserverCalled("recording-device-events");
+ await promiseMessage("ok", () =>
+ EventUtils.synthesizeMouseAtCenter(notification.button, {})
+ );
+ await observerPromise1;
+ await observerPromise2;
+ Assert.deepEqual(
+ await getMediaCaptureState(),
+ { screen: "Screen" },
+ "expected screen to be shared"
+ );
+
+ await indicator;
+ await checkSharingUI({ screen: "Screen" }, undefined, undefined, {
+ screen: { scope: SitePermissions.SCOPE_PERSISTENT },
+ });
+ await closeStream(false, "frame4");
+
+ PermissionTestUtils.remove(uri, "screen");
+}
+
+var gTests = [
+ {
+ desc: "'Always Allow' enabled on third party pages, when origin is explicitly allowed",
+ run: async function checkNoAlwaysOnThirdParty() {
+ // Initially set both permissions to 'prompt'.
+ const uri = gBrowser.selectedBrowser.documentURI;
+ PermissionTestUtils.add(uri, "microphone", Services.perms.PROMPT_ACTION);
+ PermissionTestUtils.add(uri, "camera", Services.perms.PROMPT_ACTION);
+
+ const observerPromise = expectObserverCalled("getUserMedia:request");
+ const promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(true, true, "frame1");
+ await promise;
+ await observerPromise;
+ checkDeviceSelectors(["microphone", "camera"]);
+
+ // The 'Remember this decision' checkbox is visible.
+ const notification = PopupNotifications.panel.firstElementChild;
+ const checkbox = notification.checkbox;
+ ok(!!checkbox, "checkbox is present");
+ ok(!checkbox.hidden, "checkbox is visible");
+ ok(!checkbox.checked, "checkbox not checked");
+
+ // Check the label of the notification should be the first party
+ is(
+ PopupNotifications.getNotification("webRTC-shareDevices").options.name,
+ uri.host,
+ "Use first party's origin"
+ );
+
+ const indicator = promiseIndicatorWindow();
+ const observerPromise1 = expectObserverCalled(
+ "getUserMedia:response:allow"
+ );
+ const observerPromise2 = expectObserverCalled("recording-device-events");
+ await promiseMessage("ok", () =>
+ EventUtils.synthesizeMouseAtCenter(notification.button, {})
+ );
+ await observerPromise1;
+ await observerPromise2;
+ Assert.deepEqual(
+ await getMediaCaptureState(),
+ { audio: true, video: true },
+ "expected camera and microphone to be shared"
+ );
+ await indicator;
+ await checkSharingUI({ audio: true, video: true });
+
+ // Cleanup.
+ await closeStream(false, "frame1");
+ PermissionTestUtils.remove(uri, "camera");
+ PermissionTestUtils.remove(uri, "microphone");
+ },
+ },
+ {
+ desc: "'Always Allow' disabled when sharing screen in third party iframes, when origin is explicitly allowed",
+ run: async function checkScreenSharing() {
+ const observerPromise = expectObserverCalled("getUserMedia:request");
+ const promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(false, true, "frame1", "screen");
+ await promise;
+ await observerPromise;
+
+ checkDeviceSelectors(["screen"]);
+ const notification = PopupNotifications.panel.firstElementChild;
+
+ // The 'Remember this decision' checkbox is visible.
+ const checkbox = notification.checkbox;
+ ok(!!checkbox, "checkbox is present");
+ ok(!checkbox.hidden, "checkbox is visible");
+ ok(!checkbox.checked, "checkbox not checked");
+
+ const menulist = document.getElementById("webRTC-selectWindow-menulist");
+ const count = menulist.itemCount;
+ ok(
+ count >= 4,
+ "There should be the 'Select Window or Screen' item, a separator and at least one window and one screen"
+ );
+
+ const noWindowOrScreenItem = menulist.getItemAtIndex(0);
+ ok(
+ noWindowOrScreenItem.hasAttribute("selected"),
+ "the 'Select Window or Screen' item is selected"
+ );
+ is(
+ menulist.selectedItem,
+ noWindowOrScreenItem,
+ "'Select Window or Screen' is the selected item"
+ );
+ is(menulist.value, "-1", "no window or screen is selected by default");
+ ok(
+ noWindowOrScreenItem.disabled,
+ "'Select Window or Screen' item is disabled"
+ );
+ ok(notification.button.disabled, "Allow button is disabled");
+ ok(
+ notification.hasAttribute("invalidselection"),
+ "Notification is marked as invalid"
+ );
+
+ menulist.getItemAtIndex(count - 1).doCommand();
+ ok(!notification.button.disabled, "Allow button is enabled");
+
+ const indicator = promiseIndicatorWindow();
+ const observerPromise1 = expectObserverCalled(
+ "getUserMedia:response:allow"
+ );
+ const observerPromise2 = expectObserverCalled("recording-device-events");
+ await promiseMessage("ok", () =>
+ EventUtils.synthesizeMouseAtCenter(notification.button, {})
+ );
+ await observerPromise1;
+ await observerPromise2;
+ Assert.deepEqual(
+ await getMediaCaptureState(),
+ { screen: "Screen" },
+ "expected screen to be shared"
+ );
+
+ await indicator;
+ await checkSharingUI({ screen: "Screen" });
+ await closeStream(false, "frame1");
+ },
+ },
+
+ {
+ desc: "getUserMedia use persistent permissions from first party",
+ run: async function checkUsePersistentPermissionsFirstParty() {
+ async function checkPersistentPermission(
+ aPermission,
+ aRequestType,
+ aIframeId,
+ aExpect
+ ) {
+ info(
+ `Test persistent permission ${aPermission} type ${aRequestType} expect ${aExpect}`
+ );
+ const uri = gBrowser.selectedBrowser.documentURI;
+ // Persistent allow/deny for first party uri
+ PermissionTestUtils.add(uri, aRequestType, aPermission);
+
+ let audio = aRequestType == "microphone";
+ let video = aRequestType == "camera";
+ const screen = aRequestType == "screen" ? "screen" : undefined;
+ if (screen) {
+ audio = false;
+ video = true;
+ }
+ if (aExpect == PromptResult.PROMPT) {
+ // Check that we get a prompt.
+ const observerPromise = expectObserverCalled("getUserMedia:request");
+ const observerPromise1 = expectObserverCalled(
+ "getUserMedia:response:deny"
+ );
+ const observerPromise2 = expectObserverCalled(
+ "recording-window-ended"
+ );
+ const promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(audio, video, aIframeId, screen);
+ await promise;
+ await observerPromise;
+
+ // Check the label of the notification should be the first party
+ is(
+ PopupNotifications.getNotification("webRTC-shareDevices").options
+ .name,
+ uri.host,
+ "Use first party's origin"
+ );
+
+ // Deny the request to cleanup...
+ await promiseMessage(permissionError, () => {
+ activateSecondaryAction(kActionDeny);
+ });
+ await observerPromise1;
+ await observerPromise2;
+ let browser = gBrowser.selectedBrowser;
+ SitePermissions.removeFromPrincipal(null, aRequestType, browser);
+ } else if (aExpect == PromptResult.ALLOW) {
+ const observerPromise = expectObserverCalled("getUserMedia:request");
+ const observerPromise1 = expectObserverCalled(
+ "getUserMedia:response:allow"
+ );
+ const observerPromise2 = expectObserverCalled(
+ "recording-device-events"
+ );
+ const promise = promiseMessage("ok");
+ await promiseRequestDevice(audio, video, aIframeId, screen);
+ await promise;
+ await observerPromise;
+
+ await promiseNoPopupNotification("webRTC-shareDevices");
+ await observerPromise1;
+ await observerPromise2;
+
+ let expected = {};
+ if (audio) {
+ expected.audio = audio;
+ }
+ if (video) {
+ expected.video = video;
+ }
+
+ Assert.deepEqual(
+ await getMediaCaptureState(),
+ expected,
+ "expected " + Object.keys(expected).join(" and ") + " to be shared"
+ );
+
+ await closeStream(false, aIframeId);
+ } else if (aExpect == PromptResult.DENY) {
+ const promises = [];
+ // frame3 disallows by feature Permissions Policy before request.
+ if (aIframeId != "frame3") {
+ promises.push(
+ expectObserverCalled("getUserMedia:request"),
+ expectObserverCalled("getUserMedia:response:deny")
+ );
+ }
+ promises.push(
+ expectObserverCalled("recording-window-ended"),
+ promiseMessage(permissionError),
+ promiseRequestDevice(audio, video, aIframeId, screen)
+ );
+ await Promise.all(promises);
+ }
+
+ PermissionTestUtils.remove(uri, aRequestType);
+ }
+
+ await checkPersistentPermission(
+ Perms.PROMPT_ACTION,
+ "camera",
+ "frame1",
+ PromptResult.PROMPT
+ );
+ await checkPersistentPermission(
+ Perms.DENY_ACTION,
+ "camera",
+ "frame1",
+ PromptResult.DENY
+ );
+ await checkPersistentPermission(
+ Perms.ALLOW_ACTION,
+ "camera",
+ "frame1",
+ PromptResult.ALLOW
+ );
+
+ await checkPersistentPermission(
+ Perms.PROMPT_ACTION,
+ "microphone",
+ "frame1",
+ PromptResult.PROMPT
+ );
+ await checkPersistentPermission(
+ Perms.DENY_ACTION,
+ "microphone",
+ "frame1",
+ PromptResult.DENY
+ );
+ await checkPersistentPermission(
+ Perms.ALLOW_ACTION,
+ "microphone",
+ "frame1",
+ PromptResult.ALLOW
+ );
+
+ // Wildcard attributes still get delegation when their src is unchanged.
+ await checkPersistentPermission(
+ Perms.PROMPT_ACTION,
+ "camera",
+ "frame4",
+ PromptResult.PROMPT
+ );
+ await checkPersistentPermission(
+ Perms.DENY_ACTION,
+ "camera",
+ "frame4",
+ PromptResult.DENY
+ );
+ await checkPersistentPermission(
+ Perms.ALLOW_ACTION,
+ "camera",
+ "frame4",
+ PromptResult.ALLOW
+ );
+
+ // Wildcard attributes still get delegation when their src is unchanged.
+ await checkPersistentPermission(
+ Perms.PROMPT_ACTION,
+ "microphone",
+ "frame4",
+ PromptResult.PROMPT
+ );
+ await checkPersistentPermission(
+ Perms.DENY_ACTION,
+ "microphone",
+ "frame4",
+ PromptResult.DENY
+ );
+ await checkPersistentPermission(
+ Perms.ALLOW_ACTION,
+ "microphone",
+ "frame4",
+ PromptResult.ALLOW
+ );
+
+ await checkPersistentPermission(
+ Perms.PROMPT_ACTION,
+ "screen",
+ "frame1",
+ PromptResult.PROMPT
+ );
+ await checkPersistentPermission(
+ Perms.DENY_ACTION,
+ "screen",
+ "frame1",
+ PromptResult.DENY
+ );
+ // Always prompt screen sharing
+ await checkPersistentPermission(
+ Perms.ALLOW_ACTION,
+ "screen",
+ "frame1",
+ PromptResult.PROMPT
+ );
+ await checkPersistentPermission(
+ Perms.ALLOW_ACTION,
+ "screen",
+ "frame4",
+ PromptResult.PROMPT
+ );
+
+ // Denied by default if allow is not defined
+ await checkPersistentPermission(
+ Perms.PROMPT_ACTION,
+ "camera",
+ "frame3",
+ PromptResult.DENY
+ );
+ await checkPersistentPermission(
+ Perms.DENY_ACTION,
+ "camera",
+ "frame3",
+ PromptResult.DENY
+ );
+ await checkPersistentPermission(
+ Perms.ALLOW_ACTION,
+ "camera",
+ "frame3",
+ PromptResult.DENY
+ );
+
+ await checkPersistentPermission(
+ Perms.PROMPT_ACTION,
+ "microphone",
+ "frame3",
+ PromptResult.DENY
+ );
+ await checkPersistentPermission(
+ Perms.DENY_ACTION,
+ "microphone",
+ "frame3",
+ PromptResult.DENY
+ );
+ await checkPersistentPermission(
+ Perms.ALLOW_ACTION,
+ "microphone",
+ "frame3",
+ PromptResult.DENY
+ );
+
+ await checkPersistentPermission(
+ Perms.PROMPT_ACTION,
+ "screen",
+ "frame3",
+ PromptResult.DENY
+ );
+ await checkPersistentPermission(
+ Perms.DENY_ACTION,
+ "screen",
+ "frame3",
+ PromptResult.DENY
+ );
+ await checkPersistentPermission(
+ Perms.ALLOW_ACTION,
+ "screen",
+ "frame3",
+ PromptResult.DENY
+ );
+ },
+ },
+
+ {
+ desc: "getUserMedia use temporary blocked permissions from first party",
+ run: async function checkUseTempPermissionsBlockFirstParty() {
+ async function checkTempPermission(aRequestType) {
+ let browser = gBrowser.selectedBrowser;
+ let observerPromise = expectObserverCalled("getUserMedia:request");
+ let observerPromise1 = expectObserverCalled(
+ "getUserMedia:response:deny"
+ );
+ let observerPromise2 = expectObserverCalled("recording-window-ended");
+ let promise = promisePopupNotificationShown("webRTC-shareDevices");
+ let audio = aRequestType == "microphone";
+ let video = aRequestType == "camera";
+ const screen = aRequestType == "screen" ? "screen" : undefined;
+ if (screen) {
+ audio = false;
+ video = true;
+ }
+
+ await promiseRequestDevice(audio, video, null, screen);
+ await promise;
+ await observerPromise;
+
+ // Temporarily grant/deny from top level
+ // Only need to check allow and deny temporary permissions
+ await promiseMessage(permissionError, () => {
+ activateSecondaryAction(kActionDeny);
+ });
+ await observerPromise1;
+ await observerPromise2;
+ await checkNotSharing();
+
+ observerPromise = expectObserverCalled("getUserMedia:request");
+ observerPromise1 = expectObserverCalled("getUserMedia:response:deny");
+ observerPromise2 = expectObserverCalled("recording-window-ended");
+ promise = promiseMessage(permissionError);
+ await promiseRequestDevice(audio, video, "frame1", screen);
+ await promise;
+
+ await observerPromise;
+ await observerPromise1;
+ await observerPromise2;
+
+ SitePermissions.removeFromPrincipal(null, aRequestType, browser);
+ }
+
+ // At the moment we only save temporary deny
+ await checkTempPermission("camera");
+ await checkTempPermission("microphone");
+ await checkTempPermission("screen");
+ },
+ },
+ {
+ desc: "Don't reprompt while actively sharing in maybe unsafe permission delegation",
+ run: async function checkNoRepromptNoDelegate() {
+ // Change location to ensure that we're treated as potentially unsafe.
+ await promiseChangeLocationFrame(
+ "frame4",
+ "https://test2.example.com/browser/browser/base/content/test/webrtc/get_user_media.html"
+ );
+
+ // Check that we get a prompt.
+ let observerPromise = expectObserverCalled("getUserMedia:request");
+ let promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(true, true, "frame4");
+ await promise;
+ await observerPromise;
+
+ // Check the secondName of the notification should be the third party
+ is(
+ PopupNotifications.getNotification("webRTC-shareDevices").options
+ .secondName,
+ "test2.example.com",
+ "Use third party's origin as secondName"
+ );
+
+ const notification = PopupNotifications.panel.firstElementChild;
+ let indicator = promiseIndicatorWindow();
+ let observerPromise1 = expectObserverCalled(
+ "getUserMedia:response:allow"
+ );
+ let observerPromise2 = expectObserverCalled("recording-device-events");
+ await promiseMessage("ok", () =>
+ EventUtils.synthesizeMouseAtCenter(notification.button, {})
+ );
+ await observerPromise1;
+ await observerPromise2;
+
+ let state = await getMediaCaptureState();
+ is(!!state.audio, true, "expected microphone to be shared");
+ is(!!state.video, true, "expected camera to be shared");
+ await indicator;
+ await checkSharingUI({ audio: true, video: true });
+
+ // Check that we now don't get a prompt.
+ observerPromise = expectObserverCalled("getUserMedia:request");
+ observerPromise1 = expectObserverCalled("getUserMedia:response:allow");
+ observerPromise2 = expectObserverCalled("recording-device-events");
+ promise = promiseMessage("ok");
+ await promiseRequestDevice(true, true, "frame4");
+ await promise;
+ await observerPromise;
+
+ await promiseNoPopupNotification("webRTC-shareDevices");
+ await observerPromise1;
+ await observerPromise2;
+
+ state = await getMediaCaptureState();
+ is(!!state.audio, true, "expected microphone to be shared");
+ is(!!state.video, true, "expected camera to be shared");
+ await checkSharingUI({ audio: true, video: true });
+
+ // Cleanup.
+ await closeStream(false, "frame4");
+ },
+ },
+ {
+ desc: "Change location, prompt and display both first party and third party origin in maybe unsafe permission delegation",
+ run: async function checkPromptNoDelegateChangeLoxation() {
+ await promiseChangeLocationFrame(
+ "frame4",
+ "https://test2.example.com/browser/browser/base/content/test/webrtc/get_user_media.html"
+ );
+ await promptNoDelegate("test2.example.com");
+ },
+ },
+ {
+ desc: "Change location, prompt and display both first party and third party origin when sharing screen in unsafe permission delegation",
+ run: async function checkPromptNoDelegateScreenSharingChangeLocation() {
+ await promiseChangeLocationFrame(
+ "frame4",
+ "https://test2.example.com/browser/browser/base/content/test/webrtc/get_user_media.html"
+ );
+ await promptNoDelegateScreenSharing("test2.example.com");
+ },
+ },
+ {
+ desc: "Prompt and display both first party and third party origin and temporary deny in frame does not change permission scope",
+ skipObserverVerification: true,
+ run: async function checkPromptBothOriginsTempDenyFrame() {
+ // Change location to ensure that we're treated as potentially unsafe.
+ await promiseChangeLocationFrame(
+ "frame4",
+ "https://test2.example.com/browser/browser/base/content/test/webrtc/get_user_media.html"
+ );
+
+ // Persistent allowed first party origin
+ let browser = gBrowser.selectedBrowser;
+ let uri = gBrowser.selectedBrowser.documentURI;
+ let principal = Services.scriptSecurityManager.createContentPrincipal(
+ uri,
+ {}
+ );
+
+ let observerPromise = expectObserverCalled("getUserMedia:request");
+ let promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(true, true);
+ await promise;
+ await observerPromise;
+
+ // Ensure that checking the 'Remember this decision'
+ let notification = PopupNotifications.panel.firstElementChild;
+ let checkbox = notification.checkbox;
+ ok(!!checkbox, "checkbox is present");
+ ok(!checkbox.checked, "checkbox is not checked");
+ checkbox.click();
+
+ let indicator = promiseIndicatorWindow();
+ let observerPromise1 = expectObserverCalled(
+ "getUserMedia:response:allow"
+ );
+ let observerPromise2 = expectObserverCalled("recording-device-events");
+ await promiseMessage("ok", () =>
+ EventUtils.synthesizeMouseAtCenter(notification.button, {})
+ );
+ await observerPromise1;
+ await observerPromise2;
+ Assert.deepEqual(
+ await getMediaCaptureState(),
+ { audio: true, video: true },
+ "expected camera and microphone to be shared"
+ );
+ await indicator;
+ await checkSharingUI({ audio: true, video: true }, undefined, undefined, {
+ audio: { scope: SitePermissions.SCOPE_PERSISTENT },
+ video: { scope: SitePermissions.SCOPE_PERSISTENT },
+ });
+ await closeStream(true);
+
+ // Check that we get a prompt.
+ observerPromise = expectObserverCalled("getUserMedia:request");
+ promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(true, true, "frame4");
+ await promise;
+ await observerPromise;
+
+ // The 'Remember this decision' checkbox is hidden.
+ notification = PopupNotifications.panel.firstElementChild;
+ checkbox = notification.checkbox;
+ ok(!!checkbox, "checkbox is present");
+ ok(checkbox.hidden, "checkbox is not visible");
+
+ observerPromise1 = expectObserverCalled("getUserMedia:response:deny");
+ observerPromise2 = expectObserverCalled("recording-window-ended");
+ await promiseMessage(permissionError, () => {
+ activateSecondaryAction(kActionDeny);
+ });
+
+ await observerPromise1;
+ await observerPromise2;
+ await checkNotSharing();
+
+ // Make sure we are not changing the scope and state of persistent
+ // permission
+ let { state, scope } = SitePermissions.getForPrincipal(
+ principal,
+ "camera",
+ browser
+ );
+ Assert.equal(state, SitePermissions.ALLOW);
+ Assert.equal(scope, SitePermissions.SCOPE_PERSISTENT);
+
+ ({ state, scope } = SitePermissions.getForPrincipal(
+ principal,
+ "microphone",
+ browser
+ ));
+ Assert.equal(state, SitePermissions.ALLOW);
+ Assert.equal(scope, SitePermissions.SCOPE_PERSISTENT);
+
+ PermissionTestUtils.remove(uri, "camera");
+ PermissionTestUtils.remove(uri, "microphone");
+ },
+ },
+];
+
+add_task(async function test() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["permissions.delegation.enabled", true],
+ ["dom.security.featurePolicy.header.enabled", true],
+ ["dom.security.featurePolicy.webidl.enabled", true],
+ ],
+ });
+
+ await runTests(gTests, {
+ relativeURI: "get_user_media_in_xorigin_frame.html",
+ });
+});
diff --git a/browser/base/content/test/webrtc/browser_devices_get_user_media_in_xorigin_frame_chain.js b/browser/base/content/test/webrtc/browser_devices_get_user_media_in_xorigin_frame_chain.js
new file mode 100644
index 0000000000..ad398994f0
--- /dev/null
+++ b/browser/base/content/test/webrtc/browser_devices_get_user_media_in_xorigin_frame_chain.js
@@ -0,0 +1,251 @@
+/* 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/. */
+const permissionError =
+ "error: NotAllowedError: The request is not allowed " +
+ "by the user agent or the platform in the current context.";
+
+const PromptResult = {
+ ALLOW: "allow",
+ DENY: "deny",
+ PROMPT: "prompt",
+};
+
+const Perms = Services.perms;
+
+function expectObserverCalledAncestor(aTopic, browsingContext) {
+ if (!gMultiProcessBrowser) {
+ return expectObserverCalledInProcess(aTopic);
+ }
+
+ return BrowserTestUtils.contentTopicObserved(browsingContext, aTopic);
+}
+
+function enableObserverVerificationAncestor(browsingContext) {
+ // Skip these checks in single process mode as it isn't worth implementing it.
+ if (!gMultiProcessBrowser) {
+ return Promise.resolve();
+ }
+
+ return BrowserTestUtils.startObservingTopics(browsingContext, observerTopics);
+}
+
+function disableObserverVerificationAncestor(browsingContextt) {
+ if (!gMultiProcessBrowser) {
+ return Promise.resolve();
+ }
+
+ return BrowserTestUtils.stopObservingTopics(
+ browsingContextt,
+ observerTopics
+ ).catch(reason => {
+ ok(false, "Failed " + reason);
+ });
+}
+
+function promiseRequestDeviceAncestor(
+ aRequestAudio,
+ aRequestVideo,
+ aType,
+ aBrowser,
+ aBadDevice = false
+) {
+ info("requesting devices");
+ return SpecialPowers.spawn(
+ aBrowser,
+ [{ aRequestAudio, aRequestVideo, aType, aBadDevice }],
+ async function (args) {
+ let global =
+ content.wrappedJSObject.document.getElementById("frame4").contentWindow;
+ global.requestDevice(
+ args.aRequestAudio,
+ args.aRequestVideo,
+ args.aType,
+ args.aBadDevice
+ );
+ }
+ );
+}
+
+async function closeStreamAncestor(browser) {
+ let observerPromises = [];
+ observerPromises.push(
+ expectObserverCalledAncestor("recording-device-events", browser)
+ );
+ observerPromises.push(
+ expectObserverCalledAncestor("recording-window-ended", browser)
+ );
+
+ info("closing the stream");
+ await SpecialPowers.spawn(browser, [], async () => {
+ let global =
+ content.wrappedJSObject.document.getElementById("frame4").contentWindow;
+ global.closeStream();
+ });
+
+ await Promise.all(observerPromises);
+
+ await assertWebRTCIndicatorStatus(null);
+}
+
+var gTests = [
+ {
+ desc: "getUserMedia use persistent permissions from first party if third party is explicitly trusted",
+ skipObserverVerification: true,
+ run: async function checkPermissionsAncestorChain() {
+ async function checkPermission(aPermission, aRequestType, aExpect) {
+ info(
+ `Test persistent permission ${aPermission} type ${aRequestType} expect ${aExpect}`
+ );
+ const uri = gBrowser.selectedBrowser.documentURI;
+ // Persistent allow/deny for first party uri
+ PermissionTestUtils.add(uri, aRequestType, aPermission);
+
+ let audio = aRequestType == "microphone";
+ let video = aRequestType == "camera";
+ const screen = aRequestType == "screen" ? "screen" : undefined;
+ if (screen) {
+ audio = false;
+ video = true;
+ }
+ const iframeAncestor = await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [],
+ () => {
+ return content.document.getElementById("frameAncestor")
+ .browsingContext;
+ }
+ );
+
+ if (aExpect == PromptResult.PROMPT) {
+ // Check that we get a prompt.
+ const observerPromise = expectObserverCalledAncestor(
+ "getUserMedia:request",
+ iframeAncestor
+ );
+ const promise = promisePopupNotificationShown("webRTC-shareDevices");
+
+ await promiseRequestDeviceAncestor(
+ audio,
+ video,
+ screen,
+ iframeAncestor
+ );
+ await promise;
+ await observerPromise;
+
+ // Check the label of the notification should be the first party
+ is(
+ PopupNotifications.getNotification("webRTC-shareDevices").options
+ .name,
+ uri.host,
+ "Use first party's origin"
+ );
+ const observerPromise1 = expectObserverCalledAncestor(
+ "getUserMedia:response:deny",
+ iframeAncestor
+ );
+ const observerPromise2 = expectObserverCalledAncestor(
+ "recording-window-ended",
+ iframeAncestor
+ );
+ // Deny the request to cleanup...
+ activateSecondaryAction(kActionDeny);
+ await observerPromise1;
+ await observerPromise2;
+ let browser = gBrowser.selectedBrowser;
+ SitePermissions.removeFromPrincipal(null, aRequestType, browser);
+ } else if (aExpect == PromptResult.ALLOW) {
+ const observerPromise = expectObserverCalledAncestor(
+ "getUserMedia:request",
+ iframeAncestor
+ );
+ const observerPromise1 = expectObserverCalledAncestor(
+ "getUserMedia:response:allow",
+ iframeAncestor
+ );
+ const observerPromise2 = expectObserverCalledAncestor(
+ "recording-device-events",
+ iframeAncestor
+ );
+ await promiseRequestDeviceAncestor(
+ audio,
+ video,
+ screen,
+ iframeAncestor
+ );
+ await observerPromise;
+
+ await promiseNoPopupNotification("webRTC-shareDevices");
+ await observerPromise1;
+ await observerPromise2;
+
+ let expected = {};
+ if (audio) {
+ expected.audio = audio;
+ }
+ if (video) {
+ expected.video = video;
+ }
+
+ Assert.deepEqual(
+ await getMediaCaptureState(),
+ expected,
+ "expected " + Object.keys(expected).join(" and ") + " to be shared"
+ );
+
+ await closeStreamAncestor(iframeAncestor);
+ } else if (aExpect == PromptResult.DENY) {
+ const observerPromise = expectObserverCalledAncestor(
+ "recording-window-ended",
+ iframeAncestor
+ );
+ await promiseRequestDeviceAncestor(
+ audio,
+ video,
+ screen,
+ iframeAncestor
+ );
+ await observerPromise;
+ }
+
+ PermissionTestUtils.remove(uri, aRequestType);
+ }
+
+ await checkPermission(Perms.PROMPT_ACTION, "camera", PromptResult.PROMPT);
+ await checkPermission(Perms.DENY_ACTION, "camera", PromptResult.DENY);
+ await checkPermission(Perms.ALLOW_ACTION, "camera", PromptResult.ALLOW);
+
+ await checkPermission(
+ Perms.PROMPT_ACTION,
+ "microphone",
+ PromptResult.PROMPT
+ );
+ await checkPermission(Perms.DENY_ACTION, "microphone", PromptResult.DENY);
+ await checkPermission(
+ Perms.ALLOW_ACTION,
+ "microphone",
+ PromptResult.ALLOW
+ );
+
+ await checkPermission(Perms.PROMPT_ACTION, "screen", PromptResult.PROMPT);
+ await checkPermission(Perms.DENY_ACTION, "screen", PromptResult.DENY);
+ // Always prompt screen sharing
+ await checkPermission(Perms.ALLOW_ACTION, "screen", PromptResult.PROMPT);
+ },
+ },
+];
+
+add_task(async function test() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["permissions.delegation.enabled", true],
+ ["dom.security.featurePolicy.header.enabled", true],
+ ["dom.security.featurePolicy.webidl.enabled", true],
+ ],
+ });
+
+ await runTests(gTests, {
+ relativeURI: "get_user_media_in_xorigin_frame_ancestor.html",
+ });
+});
diff --git a/browser/base/content/test/webrtc/browser_devices_get_user_media_multi_process.js b/browser/base/content/test/webrtc/browser_devices_get_user_media_multi_process.js
new file mode 100644
index 0000000000..fa88b5d030
--- /dev/null
+++ b/browser/base/content/test/webrtc/browser_devices_get_user_media_multi_process.js
@@ -0,0 +1,517 @@
+/* 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/. */
+
+var gTests = [
+ {
+ desc: "getUserMedia audio in a first process + video in a second process",
+ // These tests call enableObserverVerification manually on a second tab, so
+ // don't add listeners to the first tab.
+ skipObserverVerification: true,
+ run: async function checkMultiProcess() {
+ // The main purpose of this test is to ensure webrtc sharing indicators
+ // work with multiple content processes, but it makes sense to run this
+ // test without e10s too to ensure using webrtc devices in two different
+ // tabs is handled correctly.
+
+ // Request audio in the first tab.
+ let observerPromise = expectObserverCalled("getUserMedia:request");
+ let promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(true);
+ await promise;
+ await observerPromise;
+
+ checkDeviceSelectors(["microphone"]);
+
+ let observerPromise1 = expectObserverCalled(
+ "getUserMedia:response:allow"
+ );
+ let observerPromise2 = expectObserverCalled("recording-device-events");
+ let indicator = promiseIndicatorWindow();
+ await promiseMessage("ok", () => {
+ PopupNotifications.panel.firstElementChild.button.click();
+ });
+ await observerPromise1;
+ await observerPromise2;
+ Assert.deepEqual(
+ await getMediaCaptureState(),
+ { audio: true },
+ "expected microphone to be shared"
+ );
+
+ await indicator;
+ await checkSharingUI({ audio: true });
+
+ ok(
+ webrtcUI.showGlobalIndicator,
+ "webrtcUI wants the global indicator shown"
+ );
+ ok(
+ !webrtcUI.showCameraIndicator,
+ "webrtcUI wants the camera indicator hidden"
+ );
+ ok(
+ webrtcUI.showMicrophoneIndicator,
+ "webrtcUI wants the mic indicator shown"
+ );
+ is(
+ webrtcUI.getActiveStreams(false, true).length,
+ 1,
+ "1 active audio stream"
+ );
+ is(
+ webrtcUI.getActiveStreams(true, true, true).length,
+ 1,
+ "1 active stream"
+ );
+
+ // If we have reached the max process count already, increase it to ensure
+ // our new tab can have its own content process.
+ let childCount = Services.ppmm.childCount;
+ let maxContentProcess = Services.prefs.getIntPref("dom.ipc.processCount");
+ // The first check is because if we are on a branch where e10s-multi is
+ // disabled, we want to keep testing e10s with a single content process.
+ // The + 1 is because ppmm.childCount also counts the chrome process
+ // (which also runs process scripts).
+ if (maxContentProcess > 1 && childCount == maxContentProcess + 1) {
+ await SpecialPowers.pushPrefEnv({
+ set: [["dom.ipc.processCount", childCount]],
+ });
+ }
+
+ // Open a new tab with a different hostname.
+ let url = gBrowser.currentURI.spec.replace(
+ "https://example.com/",
+ "http://127.0.0.1:8888/"
+ );
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+
+ await enableObserverVerification();
+
+ // Request video.
+ observerPromise = expectObserverCalled("getUserMedia:request");
+ promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(false, true);
+ await promise;
+ await observerPromise;
+
+ checkDeviceSelectors(["camera"]);
+
+ observerPromise1 = expectObserverCalled("getUserMedia:response:allow");
+ observerPromise2 = expectObserverCalled("recording-device-events");
+ await promiseMessage("ok", () => {
+ PopupNotifications.panel.firstElementChild.button.click();
+ });
+ await observerPromise1;
+ await observerPromise2;
+
+ await checkSharingUI({ video: true }, window, {
+ audio: true,
+ video: true,
+ });
+
+ ok(
+ webrtcUI.showGlobalIndicator,
+ "webrtcUI wants the global indicator shown"
+ );
+ ok(
+ webrtcUI.showCameraIndicator,
+ "webrtcUI wants the camera indicator shown"
+ );
+ ok(
+ webrtcUI.showMicrophoneIndicator,
+ "webrtcUI wants the mic indicator shown"
+ );
+ is(
+ webrtcUI.getActiveStreams(false, true).length,
+ 1,
+ "1 active audio stream"
+ );
+ is(webrtcUI.getActiveStreams(true).length, 1, "1 active video stream");
+ is(
+ webrtcUI.getActiveStreams(true, true, true).length,
+ 2,
+ "2 active streams"
+ );
+
+ info("removing the second tab");
+
+ await disableObserverVerification();
+
+ BrowserTestUtils.removeTab(tab);
+
+ // Check that we still show the sharing indicators for the first tab's stream.
+ await Promise.all([
+ TestUtils.waitForCondition(() => !webrtcUI.showCameraIndicator),
+ TestUtils.waitForCondition(
+ () => webrtcUI.getActiveStreams(true, true, true).length == 1
+ ),
+ ]);
+
+ ok(
+ webrtcUI.showGlobalIndicator,
+ "webrtcUI wants the global indicator shown"
+ );
+ ok(
+ !webrtcUI.showCameraIndicator,
+ "webrtcUI wants the camera indicator hidden"
+ );
+ ok(
+ webrtcUI.showMicrophoneIndicator,
+ "webrtcUI wants the mic indicator shown"
+ );
+ is(
+ webrtcUI.getActiveStreams(false, true).length,
+ 1,
+ "1 active audio stream"
+ );
+
+ await checkSharingUI({ audio: true });
+
+ // Close the first tab's stream and verify that all indicators are removed.
+ await closeStream(false, null, true);
+
+ ok(
+ !webrtcUI.showGlobalIndicator,
+ "webrtcUI wants the global indicator hidden"
+ );
+ is(
+ webrtcUI.getActiveStreams(true, true, true).length,
+ 0,
+ "0 active streams"
+ );
+ },
+ },
+
+ {
+ desc: "getUserMedia camera in a first process + camera in a second process",
+ skipObserverVerification: true,
+ run: async function checkMultiProcessCamera() {
+ let observerPromise = expectObserverCalled("getUserMedia:request");
+ // Request camera in the first tab.
+ let promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(false, true);
+ await promise;
+ await observerPromise;
+
+ checkDeviceSelectors(["camera"]);
+
+ let indicator = promiseIndicatorWindow();
+ let observerPromise1 = expectObserverCalled(
+ "getUserMedia:response:allow"
+ );
+ let observerPromise2 = expectObserverCalled("recording-device-events");
+ await promiseMessage("ok", () => {
+ PopupNotifications.panel.firstElementChild.button.click();
+ });
+ await observerPromise1;
+ await observerPromise2;
+ Assert.deepEqual(
+ await getMediaCaptureState(),
+ { video: true },
+ "expected camera to be shared"
+ );
+
+ await indicator;
+ await checkSharingUI({ video: true });
+
+ ok(
+ webrtcUI.showGlobalIndicator,
+ "webrtcUI wants the global indicator shown"
+ );
+ ok(
+ webrtcUI.showCameraIndicator,
+ "webrtcUI wants the camera indicator shown"
+ );
+ ok(
+ !webrtcUI.showMicrophoneIndicator,
+ "webrtcUI wants the mic indicator hidden"
+ );
+ is(webrtcUI.getActiveStreams(true).length, 1, "1 active camera stream");
+ is(
+ webrtcUI.getActiveStreams(true, true, true).length,
+ 1,
+ "1 active stream"
+ );
+
+ // If we have reached the max process count already, increase it to ensure
+ // our new tab can have its own content process.
+ let childCount = Services.ppmm.childCount;
+ let maxContentProcess = Services.prefs.getIntPref("dom.ipc.processCount");
+ // The first check is because if we are on a branch where e10s-multi is
+ // disabled, we want to keep testing e10s with a single content process.
+ // The + 1 is because ppmm.childCount also counts the chrome process
+ // (which also runs process scripts).
+ if (maxContentProcess > 1 && childCount == maxContentProcess + 1) {
+ await SpecialPowers.pushPrefEnv({
+ set: [["dom.ipc.processCount", childCount]],
+ });
+ }
+
+ // Open a new tab with a different hostname.
+ let url = gBrowser.currentURI.spec.replace(
+ "https://example.com/",
+ "http://127.0.0.1:8888/"
+ );
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+
+ await enableObserverVerification();
+
+ // Request camera in the second tab.
+ observerPromise = expectObserverCalled("getUserMedia:request");
+ promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(false, true);
+ await promise;
+ await observerPromise;
+
+ checkDeviceSelectors(["camera"]);
+
+ observerPromise1 = expectObserverCalled("getUserMedia:response:allow");
+ observerPromise2 = expectObserverCalled("recording-device-events");
+ await promiseMessage("ok", () => {
+ PopupNotifications.panel.firstElementChild.button.click();
+ });
+ await observerPromise1;
+ await observerPromise2;
+
+ await checkSharingUI({ video: true }, window, { video: true });
+
+ ok(
+ webrtcUI.showGlobalIndicator,
+ "webrtcUI wants the global indicator shown"
+ );
+ ok(
+ webrtcUI.showCameraIndicator,
+ "webrtcUI wants the camera indicator shown"
+ );
+ ok(
+ !webrtcUI.showMicrophoneIndicator,
+ "webrtcUI wants the mic indicator hidden"
+ );
+ is(webrtcUI.getActiveStreams(true).length, 2, "2 active camera streams");
+ is(
+ webrtcUI.getActiveStreams(true, true, true).length,
+ 2,
+ "2 active streams"
+ );
+
+ await disableObserverVerification();
+
+ info("removing the second tab");
+ BrowserTestUtils.removeTab(tab);
+
+ // Check that we still show the sharing indicators for the first tab's stream.
+ await Promise.all([
+ TestUtils.waitForCondition(() => webrtcUI.showCameraIndicator),
+ TestUtils.waitForCondition(
+ () => webrtcUI.getActiveStreams(true).length == 1
+ ),
+ ]);
+ ok(
+ webrtcUI.showGlobalIndicator,
+ "webrtcUI wants the global indicator shown"
+ );
+ ok(
+ webrtcUI.showCameraIndicator,
+ "webrtcUI wants the camera indicator shown"
+ );
+ ok(
+ !webrtcUI.showMicrophoneIndicator,
+ "webrtcUI wants the mic indicator hidden"
+ );
+ is(
+ webrtcUI.getActiveStreams(true, true, true).length,
+ 1,
+ "1 active stream"
+ );
+
+ await checkSharingUI({ video: true });
+
+ // Close the first tab's stream and verify that all indicators are removed.
+ await closeStream(false, null, true);
+
+ ok(
+ !webrtcUI.showGlobalIndicator,
+ "webrtcUI wants the global indicator hidden"
+ );
+ is(
+ webrtcUI.getActiveStreams(true, true, true).length,
+ 0,
+ "0 active streams"
+ );
+ },
+ },
+
+ {
+ desc: "getUserMedia screen sharing in a first process + screen sharing in a second process",
+ skipObserverVerification: true,
+ run: async function checkMultiProcessScreen() {
+ // Request screen sharing in the first tab.
+ let observerPromise = expectObserverCalled("getUserMedia:request");
+ let promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(false, true, null, "screen");
+ await promise;
+ await observerPromise;
+
+ is(
+ PopupNotifications.getNotification("webRTC-shareDevices").anchorID,
+ "webRTC-shareScreen-notification-icon",
+ "anchored to device icon"
+ );
+ checkDeviceSelectors(["screen"]);
+
+ // Select the last screen so that we can have a stream.
+ let menulist = document.getElementById("webRTC-selectWindow-menulist");
+ menulist.getItemAtIndex(menulist.itemCount - 1).doCommand();
+
+ let indicator = promiseIndicatorWindow();
+ let observerPromise1 = expectObserverCalled(
+ "getUserMedia:response:allow"
+ );
+ let observerPromise2 = expectObserverCalled("recording-device-events");
+ await promiseMessage("ok", () => {
+ PopupNotifications.panel.firstElementChild.button.click();
+ });
+ await observerPromise1;
+ await observerPromise2;
+ Assert.deepEqual(
+ await getMediaCaptureState(),
+ { screen: "Screen" },
+ "expected screen to be shared"
+ );
+
+ await indicator;
+ await checkSharingUI({ screen: "Screen" });
+
+ ok(
+ webrtcUI.showGlobalIndicator,
+ "webrtcUI wants the global indicator shown"
+ );
+ ok(
+ webrtcUI.showScreenSharingIndicator,
+ "webrtcUI wants the screen sharing indicator shown"
+ );
+ ok(
+ !webrtcUI.showCameraIndicator,
+ "webrtcUI wants the camera indicator hidden"
+ );
+ ok(
+ !webrtcUI.showMicrophoneIndicator,
+ "webrtcUI wants the mic indicator hidden"
+ );
+ is(
+ webrtcUI.getActiveStreams(false, false, true).length,
+ 1,
+ "1 active screen sharing stream"
+ );
+ is(
+ webrtcUI.getActiveStreams(true, true, true).length,
+ 1,
+ "1 active stream"
+ );
+
+ // If we have reached the max process count already, increase it to ensure
+ // our new tab can have its own content process.
+ let childCount = Services.ppmm.childCount;
+ let maxContentProcess = Services.prefs.getIntPref("dom.ipc.processCount");
+ // The first check is because if we are on a branch where e10s-multi is
+ // disabled, we want to keep testing e10s with a single content process.
+ // The + 1 is because ppmm.childCount also counts the chrome process
+ // (which also runs process scripts).
+ if (maxContentProcess > 1 && childCount == maxContentProcess + 1) {
+ await SpecialPowers.pushPrefEnv({
+ set: [["dom.ipc.processCount", childCount]],
+ });
+ }
+
+ // Open a new tab with a different hostname.
+ let url = gBrowser.currentURI.spec.replace(
+ "https://example.com/",
+ "https://example.com/"
+ );
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, url);
+
+ await enableObserverVerification();
+
+ // Request screen sharing in the second tab.
+ observerPromise = expectObserverCalled("getUserMedia:request");
+ promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(false, true, null, "screen");
+ await promise;
+ await observerPromise;
+
+ is(
+ PopupNotifications.getNotification("webRTC-shareDevices").anchorID,
+ "webRTC-shareScreen-notification-icon",
+ "anchored to device icon"
+ );
+ checkDeviceSelectors(["screen"]);
+
+ // Select the last screen so that we can have a stream.
+ menulist.getItemAtIndex(menulist.itemCount - 1).doCommand();
+
+ observerPromise1 = expectObserverCalled("getUserMedia:response:allow");
+ observerPromise2 = expectObserverCalled("recording-device-events");
+ await promiseMessage("ok", () => {
+ PopupNotifications.panel.firstElementChild.button.click();
+ });
+ await observerPromise1;
+ await observerPromise2;
+
+ await checkSharingUI({ screen: "Screen" }, window, { screen: "Screen" });
+
+ ok(
+ webrtcUI.showGlobalIndicator,
+ "webrtcUI wants the global indicator shown"
+ );
+ ok(
+ webrtcUI.showScreenSharingIndicator,
+ "webrtcUI wants the screen sharing indicator shown"
+ );
+ ok(
+ !webrtcUI.showCameraIndicator,
+ "webrtcUI wants the camera indicator hidden"
+ );
+ ok(
+ !webrtcUI.showMicrophoneIndicator,
+ "webrtcUI wants the mic indicator hidden"
+ );
+ is(
+ webrtcUI.getActiveStreams(false, false, true).length,
+ 2,
+ "2 active desktop sharing streams"
+ );
+ is(
+ webrtcUI.getActiveStreams(true, true, true).length,
+ 2,
+ "2 active streams"
+ );
+
+ await disableObserverVerification();
+
+ info("removing the second tab");
+ BrowserTestUtils.removeTab(tab);
+
+ await TestUtils.waitForCondition(
+ () => webrtcUI.getActiveStreams(true, true, true).length == 1
+ );
+
+ // Close the first tab's stream and verify that all indicators are removed.
+ await closeStream(false, null, true);
+
+ ok(
+ !webrtcUI.showGlobalIndicator,
+ "webrtcUI wants the global indicator hidden"
+ );
+ is(
+ webrtcUI.getActiveStreams(true, true, true).length,
+ 0,
+ "0 active streams"
+ );
+ },
+ },
+];
+
+add_task(async function test() {
+ await runTests(gTests);
+});
diff --git a/browser/base/content/test/webrtc/browser_devices_get_user_media_paused.js b/browser/base/content/test/webrtc/browser_devices_get_user_media_paused.js
new file mode 100644
index 0000000000..27271c2a45
--- /dev/null
+++ b/browser/base/content/test/webrtc/browser_devices_get_user_media_paused.js
@@ -0,0 +1,999 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+function setCameraMuted(mute) {
+ return sendObserverNotification(
+ mute ? "getUserMedia:muteVideo" : "getUserMedia:unmuteVideo"
+ );
+}
+
+function setMicrophoneMuted(mute) {
+ return sendObserverNotification(
+ mute ? "getUserMedia:muteAudio" : "getUserMedia:unmuteAudio"
+ );
+}
+
+function sendObserverNotification(topic) {
+ const windowId = gBrowser.selectedBrowser.innerWindowID;
+ return SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [{ topic, windowId }],
+ function (args) {
+ Services.obs.notifyObservers(
+ content.window,
+ args.topic,
+ JSON.stringify(args.windowId)
+ );
+ }
+ );
+}
+
+function setTrackEnabled(audio, video) {
+ return SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [{ audio, video }],
+ function (args) {
+ let stream = content.wrappedJSObject.gStreams[0];
+ if (args.audio != null) {
+ stream.getAudioTracks()[0].enabled = args.audio;
+ }
+ if (args.video != null) {
+ stream.getVideoTracks()[0].enabled = args.video;
+ }
+ }
+ );
+}
+
+async function getVideoTrackMuted() {
+ return SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [],
+ () => content.wrappedJSObject.gStreams[0].getVideoTracks()[0].muted
+ );
+}
+
+async function getVideoTrackEvents() {
+ return SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [],
+ () => content.wrappedJSObject.gVideoEvents
+ );
+}
+
+async function getAudioTrackMuted() {
+ return SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [],
+ () => content.wrappedJSObject.gStreams[0].getAudioTracks()[0].muted
+ );
+}
+
+async function getAudioTrackEvents() {
+ return SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [],
+ () => content.wrappedJSObject.gAudioEvents
+ );
+}
+
+function cloneTracks(audio, video) {
+ return SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [{ audio, video }],
+ function (args) {
+ if (!content.wrappedJSObject.gClones) {
+ content.wrappedJSObject.gClones = [];
+ }
+ let clones = content.wrappedJSObject.gClones;
+ let stream = content.wrappedJSObject.gStreams[0];
+ if (args.audio != null) {
+ clones.push(stream.getAudioTracks()[0].clone());
+ }
+ if (args.video != null) {
+ clones.push(stream.getVideoTracks()[0].clone());
+ }
+ }
+ );
+}
+
+function stopClonedTracks(audio, video) {
+ return SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [{ audio, video }],
+ function (args) {
+ let clones = content.wrappedJSObject.gClones || [];
+ if (args.audio != null) {
+ clones.filter(t => t.kind == "audio").forEach(t => t.stop());
+ }
+ if (args.video != null) {
+ clones.filter(t => t.kind == "video").forEach(t => t.stop());
+ }
+ let liveClones = clones.filter(t => t.readyState == "live");
+ if (!liveClones.length) {
+ delete content.wrappedJSObject.gClones;
+ } else {
+ content.wrappedJSObject.gClones = liveClones;
+ }
+ }
+ );
+}
+
+var gTests = [
+ {
+ desc: "getUserMedia audio+video: disabling the stream shows the paused indicator",
+ run: async function checkDisabled() {
+ let observerPromise = expectObserverCalled("getUserMedia:request");
+ let promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(true, true);
+ await promise;
+ await observerPromise;
+ checkDeviceSelectors(["microphone", "camera"]);
+
+ let indicator = promiseIndicatorWindow();
+ let observerPromise1 = expectObserverCalled(
+ "getUserMedia:response:allow"
+ );
+ let observerPromise2 = expectObserverCalled("recording-device-events");
+ await promiseMessage("ok", () => {
+ PopupNotifications.panel.firstElementChild.button.click();
+ });
+ await observerPromise1;
+ await observerPromise2;
+ Assert.deepEqual(
+ await getMediaCaptureState(),
+ { audio: true, video: true },
+ "expected camera and microphone to be shared"
+ );
+ await indicator;
+ await checkSharingUI({
+ video: STATE_CAPTURE_ENABLED,
+ audio: STATE_CAPTURE_ENABLED,
+ });
+
+ // Disable both audio and video.
+ observerPromise = expectObserverCalled("recording-device-events", 2);
+ await setTrackEnabled(false, false);
+
+ // Wait for capture state to propagate to the UI asynchronously.
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ window.gPermissionPanel._sharingState.webRTC.camera ==
+ STATE_CAPTURE_DISABLED,
+ "video should be disabled"
+ );
+
+ await observerPromise;
+
+ // The identity UI should show both as disabled.
+ await checkSharingUI({
+ video: STATE_CAPTURE_DISABLED,
+ audio: STATE_CAPTURE_DISABLED,
+ });
+
+ // Enable only audio again.
+ observerPromise = expectObserverCalled("recording-device-events");
+ await setTrackEnabled(true);
+
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ window.gPermissionPanel._sharingState.webRTC.microphone ==
+ STATE_CAPTURE_ENABLED,
+ "audio should be enabled"
+ );
+
+ await observerPromise;
+
+ // The identity UI should show only video as disabled.
+ await checkSharingUI({
+ video: STATE_CAPTURE_DISABLED,
+ audio: STATE_CAPTURE_ENABLED,
+ });
+
+ // Enable video again.
+ observerPromise = expectObserverCalled("recording-device-events");
+ await setTrackEnabled(null, true);
+
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ window.gPermissionPanel._sharingState.webRTC.camera ==
+ STATE_CAPTURE_ENABLED,
+ "video should be enabled"
+ );
+
+ await observerPromise;
+
+ // Both streams should show as running.
+ await checkSharingUI({
+ video: STATE_CAPTURE_ENABLED,
+ audio: STATE_CAPTURE_ENABLED,
+ });
+ await closeStream();
+ },
+ },
+
+ {
+ desc: "getUserMedia audio+video: disabling the original tracks and stopping enabled clones shows the paused indicator",
+ run: async function checkDisabledAfterCloneStop() {
+ let observerPromise = expectObserverCalled("getUserMedia:request");
+ let promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(true, true);
+ await promise;
+ await observerPromise;
+ checkDeviceSelectors(["microphone", "camera"]);
+
+ let indicator = promiseIndicatorWindow();
+ let observerPromise1 = expectObserverCalled(
+ "getUserMedia:response:allow"
+ );
+ let observerPromise2 = expectObserverCalled("recording-device-events");
+ await promiseMessage("ok", () => {
+ PopupNotifications.panel.firstElementChild.button.click();
+ });
+ await observerPromise1;
+ await observerPromise2;
+ Assert.deepEqual(
+ await getMediaCaptureState(),
+ { audio: true, video: true },
+ "expected camera and microphone to be shared"
+ );
+ await indicator;
+ await checkSharingUI({
+ video: STATE_CAPTURE_ENABLED,
+ audio: STATE_CAPTURE_ENABLED,
+ });
+
+ // Clone audio and video, their state will be enabled
+ await cloneTracks(true, true);
+
+ // Disable both audio and video.
+ await setTrackEnabled(false, false);
+
+ observerPromise = expectObserverCalled("recording-device-events", 2);
+
+ // Stop the clones. This should disable the sharing indicators.
+ await stopClonedTracks(true, true);
+
+ // Wait for capture state to propagate to the UI asynchronously.
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ window.gPermissionPanel._sharingState.webRTC.camera ==
+ STATE_CAPTURE_DISABLED &&
+ window.gPermissionPanel._sharingState.webRTC.microphone ==
+ STATE_CAPTURE_DISABLED,
+ "video and audio should be disabled"
+ );
+
+ await observerPromise;
+
+ // The identity UI should show both as disabled.
+ await checkSharingUI({
+ video: STATE_CAPTURE_DISABLED,
+ audio: STATE_CAPTURE_DISABLED,
+ });
+
+ // Enable only audio again.
+ observerPromise = expectObserverCalled("recording-device-events");
+ await setTrackEnabled(true);
+
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ window.gPermissionPanel._sharingState.webRTC.microphone ==
+ STATE_CAPTURE_ENABLED,
+ "audio should be enabled"
+ );
+
+ await observerPromise;
+
+ // The identity UI should show only video as disabled.
+ await checkSharingUI({
+ video: STATE_CAPTURE_DISABLED,
+ audio: STATE_CAPTURE_ENABLED,
+ });
+
+ // Enable video again.
+ observerPromise = expectObserverCalled("recording-device-events");
+ await setTrackEnabled(null, true);
+
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ window.gPermissionPanel._sharingState.webRTC.camera ==
+ STATE_CAPTURE_ENABLED,
+ "video should be enabled"
+ );
+
+ await observerPromise;
+
+ // Both streams should show as running.
+ await checkSharingUI({
+ video: STATE_CAPTURE_ENABLED,
+ audio: STATE_CAPTURE_ENABLED,
+ });
+ await closeStream();
+ },
+ },
+
+ {
+ desc: "getUserMedia screen: disabling the stream shows the paused indicator",
+ run: async function checkScreenDisabled() {
+ let observerPromise = expectObserverCalled("getUserMedia:request");
+ let promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(false, true, null, "screen");
+ await promise;
+ await observerPromise;
+
+ is(
+ PopupNotifications.getNotification("webRTC-shareDevices").anchorID,
+ "webRTC-shareScreen-notification-icon",
+ "anchored to device icon"
+ );
+ checkDeviceSelectors(["screen"]);
+
+ let menulist = document.getElementById("webRTC-selectWindow-menulist");
+ menulist.getItemAtIndex(menulist.itemCount - 1).doCommand();
+
+ let indicator = promiseIndicatorWindow();
+ let observerPromise1 = expectObserverCalled(
+ "getUserMedia:response:allow"
+ );
+ let observerPromise2 = expectObserverCalled("recording-device-events");
+ await promiseMessage("ok", () => {
+ PopupNotifications.panel.firstElementChild.button.click();
+ });
+ await observerPromise1;
+ await observerPromise2;
+ Assert.deepEqual(
+ await getMediaCaptureState(),
+ { screen: "Screen" },
+ "expected screen to be shared"
+ );
+
+ await indicator;
+ await checkSharingUI({ screen: "Screen" });
+
+ observerPromise = expectObserverCalled("recording-device-events");
+ await setTrackEnabled(null, false);
+
+ // Wait for capture state to propagate to the UI asynchronously.
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ window.gPermissionPanel._sharingState.webRTC.screen == "ScreenPaused",
+ "screen should be disabled"
+ );
+ await observerPromise;
+ await checkSharingUI({ screen: "ScreenPaused" }, window, {
+ screen: "Screen",
+ });
+
+ observerPromise = expectObserverCalled("recording-device-events");
+ await setTrackEnabled(null, true);
+
+ await BrowserTestUtils.waitForCondition(
+ () => window.gPermissionPanel._sharingState.webRTC.screen == "Screen",
+ "screen should be enabled"
+ );
+ await observerPromise;
+ await checkSharingUI({ screen: "Screen" });
+ await closeStream();
+ },
+ },
+
+ {
+ desc: "getUserMedia audio+video: muting the camera shows the muted indicator",
+ run: async function checkCameraMuted() {
+ let observerPromise = expectObserverCalled("getUserMedia:request");
+ let promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(true, true);
+ await promise;
+ await observerPromise;
+ checkDeviceSelectors(["microphone", "camera"]);
+
+ let indicator = promiseIndicatorWindow();
+ let observerPromise1 = expectObserverCalled(
+ "getUserMedia:response:allow"
+ );
+ let observerPromise2 = expectObserverCalled("recording-device-events");
+ await promiseMessage("ok", () => {
+ PopupNotifications.panel.firstElementChild.button.click();
+ });
+ await observerPromise1;
+ await observerPromise2;
+ Assert.deepEqual(
+ await getMediaCaptureState(),
+ { audio: true, video: true },
+ "expected camera and microphone to be shared"
+ );
+ await indicator;
+ await checkSharingUI({
+ video: STATE_CAPTURE_ENABLED,
+ audio: STATE_CAPTURE_ENABLED,
+ });
+ is(await getVideoTrackMuted(), false, "video track starts unmuted");
+ Assert.deepEqual(
+ await getVideoTrackEvents(),
+ [],
+ "no video track events fired yet"
+ );
+
+ // Mute camera.
+ observerPromise = expectObserverCalled("recording-device-events");
+ await setCameraMuted(true);
+
+ // Wait for capture state to propagate to the UI asynchronously.
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ window.gPermissionPanel._sharingState.webRTC.camera ==
+ STATE_CAPTURE_DISABLED,
+ "video should be muted"
+ );
+
+ await observerPromise;
+
+ // The identity UI should show only camera as disabled.
+ await checkSharingUI({
+ video: STATE_CAPTURE_DISABLED,
+ audio: STATE_CAPTURE_ENABLED,
+ });
+ is(await getVideoTrackMuted(), true, "video track is muted");
+ Assert.deepEqual(await getVideoTrackEvents(), ["mute"], "mute fired");
+
+ // Unmute video again.
+ observerPromise = expectObserverCalled("recording-device-events");
+ await setCameraMuted(false);
+
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ window.gPermissionPanel._sharingState.webRTC.camera ==
+ STATE_CAPTURE_ENABLED,
+ "video should be enabled"
+ );
+
+ await observerPromise;
+
+ // Both streams should show as running.
+ await checkSharingUI({
+ video: STATE_CAPTURE_ENABLED,
+ audio: STATE_CAPTURE_ENABLED,
+ });
+ is(await getVideoTrackMuted(), false, "video track is unmuted");
+ Assert.deepEqual(
+ await getVideoTrackEvents(),
+ ["mute", "unmute"],
+ "unmute fired"
+ );
+ await closeStream();
+ },
+ },
+
+ {
+ desc: "getUserMedia audio+video: muting the microphone shows the muted indicator",
+ run: async function checkMicrophoneMuted() {
+ let observerPromise = expectObserverCalled("getUserMedia:request");
+ let promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(true, true);
+ await promise;
+ await observerPromise;
+ checkDeviceSelectors(["microphone", "camera"]);
+
+ let indicator = promiseIndicatorWindow();
+ let observerPromise1 = expectObserverCalled(
+ "getUserMedia:response:allow"
+ );
+ let observerPromise2 = expectObserverCalled("recording-device-events");
+ await promiseMessage("ok", () => {
+ PopupNotifications.panel.firstElementChild.button.click();
+ });
+ await observerPromise1;
+ await observerPromise2;
+ Assert.deepEqual(
+ await getMediaCaptureState(),
+ { audio: true, video: true },
+ "expected camera and microphone to be shared"
+ );
+ await indicator;
+ await checkSharingUI({
+ video: STATE_CAPTURE_ENABLED,
+ audio: STATE_CAPTURE_ENABLED,
+ });
+ is(await getAudioTrackMuted(), false, "audio track starts unmuted");
+ Assert.deepEqual(
+ await getAudioTrackEvents(),
+ [],
+ "no audio track events fired yet"
+ );
+
+ // Mute microphone.
+ observerPromise = expectObserverCalled("recording-device-events");
+ await setMicrophoneMuted(true);
+
+ // Wait for capture state to propagate to the UI asynchronously.
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ window.gPermissionPanel._sharingState.webRTC.microphone ==
+ STATE_CAPTURE_DISABLED,
+ "audio should be muted"
+ );
+
+ await observerPromise;
+
+ // The identity UI should show only microphone as disabled.
+ await checkSharingUI({
+ video: STATE_CAPTURE_ENABLED,
+ audio: STATE_CAPTURE_DISABLED,
+ });
+ is(await getAudioTrackMuted(), true, "audio track is muted");
+ Assert.deepEqual(await getAudioTrackEvents(), ["mute"], "mute fired");
+
+ // Unmute audio again.
+ observerPromise = expectObserverCalled("recording-device-events");
+ await setMicrophoneMuted(false);
+
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ window.gPermissionPanel._sharingState.webRTC.microphone ==
+ STATE_CAPTURE_ENABLED,
+ "audio should be enabled"
+ );
+
+ await observerPromise;
+
+ // Both streams should show as running.
+ await checkSharingUI({
+ video: STATE_CAPTURE_ENABLED,
+ audio: STATE_CAPTURE_ENABLED,
+ });
+ is(await getAudioTrackMuted(), false, "audio track is unmuted");
+ Assert.deepEqual(
+ await getAudioTrackEvents(),
+ ["mute", "unmute"],
+ "unmute fired"
+ );
+ await closeStream();
+ },
+ },
+
+ {
+ desc: "getUserMedia audio+video: disabling & muting camera in combination",
+ // Test the following combinations of disabling and muting camera:
+ // 1. Disable video track only.
+ // 2. Mute camera & disable audio (to have a condition to wait for)
+ // 3. Enable both audio and video tracks (only audio should flow).
+ // 4. Unmute camera again (video should flow).
+ // 5. Mute camera & disable both tracks.
+ // 6. Unmute camera & enable audio (only audio should flow)
+ // 7. Enable video track again (video should flow).
+ run: async function checkDisabledMutedCombination() {
+ let observerPromise = expectObserverCalled("getUserMedia:request");
+ let promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(true, true);
+ await promise;
+ await observerPromise;
+ checkDeviceSelectors(["microphone", "camera"]);
+
+ let indicator = promiseIndicatorWindow();
+ let observerPromise1 = expectObserverCalled(
+ "getUserMedia:response:allow"
+ );
+ let observerPromise2 = expectObserverCalled("recording-device-events");
+ await promiseMessage("ok", () => {
+ PopupNotifications.panel.firstElementChild.button.click();
+ });
+ await observerPromise1;
+ await observerPromise2;
+ Assert.deepEqual(
+ await getMediaCaptureState(),
+ { audio: true, video: true },
+ "expected camera and microphone to be shared"
+ );
+ await indicator;
+ await checkSharingUI({
+ video: STATE_CAPTURE_ENABLED,
+ audio: STATE_CAPTURE_ENABLED,
+ });
+
+ // 1. Disable video track only.
+ observerPromise = expectObserverCalled("recording-device-events");
+ await setTrackEnabled(null, false);
+
+ // Wait for capture state to propagate to the UI asynchronously.
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ window.gPermissionPanel._sharingState.webRTC.camera ==
+ STATE_CAPTURE_DISABLED,
+ "video should be disabled"
+ );
+
+ await observerPromise;
+
+ // The identity UI should show only video as disabled.
+ await checkSharingUI({
+ video: STATE_CAPTURE_DISABLED,
+ audio: STATE_CAPTURE_ENABLED,
+ });
+ is(await getVideoTrackMuted(), false, "video track still unmuted");
+ Assert.deepEqual(
+ await getVideoTrackEvents(),
+ [],
+ "no video track events fired yet"
+ );
+
+ // 2. Mute camera & disable audio (to have a condition to wait for)
+ observerPromise = expectObserverCalled("recording-device-events", 2);
+ await setCameraMuted(true);
+ await setTrackEnabled(false, null);
+
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ window.gPermissionPanel._sharingState.webRTC.microphone ==
+ STATE_CAPTURE_DISABLED,
+ "audio should be disabled"
+ );
+
+ await observerPromise;
+
+ // The identity UI should show both as disabled.
+ await checkSharingUI({
+ video: STATE_CAPTURE_DISABLED,
+ audio: STATE_CAPTURE_DISABLED,
+ });
+ is(await getVideoTrackMuted(), true, "video track is muted");
+ Assert.deepEqual(
+ await getVideoTrackEvents(),
+ ["mute"],
+ "mute is still fired even though track was disabled"
+ );
+
+ // 3. Enable both audio and video tracks (only audio should flow).
+ observerPromise = expectObserverCalled("recording-device-events", 2);
+ await setTrackEnabled(true, true);
+
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ window.gPermissionPanel._sharingState.webRTC.microphone ==
+ STATE_CAPTURE_ENABLED,
+ "audio should be enabled"
+ );
+
+ await observerPromise;
+
+ // The identity UI should show only audio as enabled, as video is muted.
+ await checkSharingUI({
+ video: STATE_CAPTURE_DISABLED,
+ audio: STATE_CAPTURE_ENABLED,
+ });
+ is(await getVideoTrackMuted(), true, "video track is still muted");
+ Assert.deepEqual(await getVideoTrackEvents(), ["mute"], "no new events");
+
+ // 4. Unmute camera again (video should flow).
+ observerPromise = expectObserverCalled("recording-device-events");
+ await setCameraMuted(false);
+
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ window.gPermissionPanel._sharingState.webRTC.camera ==
+ STATE_CAPTURE_ENABLED,
+ "video should be enabled"
+ );
+
+ await observerPromise;
+
+ // Both streams should show as running.
+ await checkSharingUI({
+ video: STATE_CAPTURE_ENABLED,
+ audio: STATE_CAPTURE_ENABLED,
+ });
+ is(await getVideoTrackMuted(), false, "video track is unmuted");
+ Assert.deepEqual(
+ await getVideoTrackEvents(),
+ ["mute", "unmute"],
+ "unmute fired"
+ );
+
+ // 5. Mute camera & disable both tracks.
+ observerPromise = expectObserverCalled("recording-device-events", 3);
+ await setCameraMuted(true);
+ await setTrackEnabled(false, false);
+
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ window.gPermissionPanel._sharingState.webRTC.camera ==
+ STATE_CAPTURE_DISABLED,
+ "video should be disabled"
+ );
+
+ await observerPromise;
+
+ // The identity UI should show both as disabled.
+ await checkSharingUI({
+ video: STATE_CAPTURE_DISABLED,
+ audio: STATE_CAPTURE_DISABLED,
+ });
+ is(await getVideoTrackMuted(), true, "video track is muted");
+ Assert.deepEqual(
+ await getVideoTrackEvents(),
+ ["mute", "unmute", "mute"],
+ "mute fired afain"
+ );
+
+ // 6. Unmute camera & enable audio (only audio should flow)
+ observerPromise = expectObserverCalled("recording-device-events", 2);
+ await setCameraMuted(false);
+ await setTrackEnabled(true, null);
+
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ window.gPermissionPanel._sharingState.webRTC.microphone ==
+ STATE_CAPTURE_ENABLED,
+ "audio should be enabled"
+ );
+
+ await observerPromise;
+
+ // Only audio should show as running, as video track is still disabled.
+ await checkSharingUI({
+ video: STATE_CAPTURE_DISABLED,
+ audio: STATE_CAPTURE_ENABLED,
+ });
+ is(await getVideoTrackMuted(), false, "video track is unmuted");
+ Assert.deepEqual(
+ await getVideoTrackEvents(),
+ ["mute", "unmute", "mute", "unmute"],
+ "unmute fired even though track is disabled"
+ );
+
+ // 7. Enable video track again (video should flow).
+ observerPromise = expectObserverCalled("recording-device-events");
+ await setTrackEnabled(null, true);
+
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ window.gPermissionPanel._sharingState.webRTC.camera ==
+ STATE_CAPTURE_ENABLED,
+ "video should be enabled"
+ );
+
+ await observerPromise;
+
+ // The identity UI should show both as running again.
+ await checkSharingUI({
+ video: STATE_CAPTURE_ENABLED,
+ audio: STATE_CAPTURE_ENABLED,
+ });
+ is(await getVideoTrackMuted(), false, "video track remains unmuted");
+ Assert.deepEqual(
+ await getVideoTrackEvents(),
+ ["mute", "unmute", "mute", "unmute"],
+ "no new events fired"
+ );
+ await closeStream();
+ },
+ },
+
+ {
+ desc: "getUserMedia audio+video: disabling & muting microphone in combination",
+ // Test the following combinations of disabling and muting microphone:
+ // 1. Disable audio track only.
+ // 2. Mute microphone & disable video (to have a condition to wait for)
+ // 3. Enable both audio and video tracks (only video should flow).
+ // 4. Unmute microphone again (audio should flow).
+ // 5. Mute microphone & disable both tracks.
+ // 6. Unmute microphone & enable video (only video should flow)
+ // 7. Enable audio track again (audio should flow).
+ run: async function checkDisabledMutedCombination() {
+ let observerPromise = expectObserverCalled("getUserMedia:request");
+ let promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(true, true);
+ await promise;
+ await observerPromise;
+ checkDeviceSelectors(["microphone", "camera"]);
+
+ let indicator = promiseIndicatorWindow();
+ let observerPromise1 = expectObserverCalled(
+ "getUserMedia:response:allow"
+ );
+ let observerPromise2 = expectObserverCalled("recording-device-events");
+ await promiseMessage("ok", () => {
+ PopupNotifications.panel.firstElementChild.button.click();
+ });
+ await observerPromise1;
+ await observerPromise2;
+ Assert.deepEqual(
+ await getMediaCaptureState(),
+ { audio: true, video: true },
+ "expected camera and microphone to be shared"
+ );
+ await indicator;
+ await checkSharingUI({
+ video: STATE_CAPTURE_ENABLED,
+ audio: STATE_CAPTURE_ENABLED,
+ });
+
+ // 1. Disable audio track only.
+ observerPromise = expectObserverCalled("recording-device-events");
+ await setTrackEnabled(false, null);
+
+ // Wait for capture state to propagate to the UI asynchronously.
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ window.gPermissionPanel._sharingState.webRTC.microphone ==
+ STATE_CAPTURE_DISABLED,
+ "audio should be disabled"
+ );
+
+ await observerPromise;
+
+ // The identity UI should show only audio as disabled.
+ await checkSharingUI({
+ video: STATE_CAPTURE_ENABLED,
+ audio: STATE_CAPTURE_DISABLED,
+ });
+ is(await getAudioTrackMuted(), false, "audio track still unmuted");
+ Assert.deepEqual(
+ await getAudioTrackEvents(),
+ [],
+ "no audio track events fired yet"
+ );
+
+ // 2. Mute microphone & disable video (to have a condition to wait for)
+ observerPromise = expectObserverCalled("recording-device-events", 2);
+ await setMicrophoneMuted(true);
+ await setTrackEnabled(null, false);
+
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ window.gPermissionPanel._sharingState.webRTC.camera ==
+ STATE_CAPTURE_DISABLED,
+ "camera should be disabled"
+ );
+
+ await observerPromise;
+
+ // The identity UI should show both as disabled.
+ await checkSharingUI({
+ video: STATE_CAPTURE_DISABLED,
+ audio: STATE_CAPTURE_DISABLED,
+ });
+ is(await getAudioTrackMuted(), true, "audio track is muted");
+ Assert.deepEqual(
+ await getAudioTrackEvents(),
+ ["mute"],
+ "mute is still fired even though track was disabled"
+ );
+
+ // 3. Enable both audio and video tracks (only video should flow).
+ observerPromise = expectObserverCalled("recording-device-events", 2);
+ await setTrackEnabled(true, true);
+
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ window.gPermissionPanel._sharingState.webRTC.camera ==
+ STATE_CAPTURE_ENABLED,
+ "video should be enabled"
+ );
+
+ await observerPromise;
+
+ // The identity UI should show only video as enabled, as audio is muted.
+ await checkSharingUI({
+ video: STATE_CAPTURE_ENABLED,
+ audio: STATE_CAPTURE_DISABLED,
+ });
+ is(await getAudioTrackMuted(), true, "audio track is still muted");
+ Assert.deepEqual(await getAudioTrackEvents(), ["mute"], "no new events");
+
+ // 4. Unmute microphone again (audio should flow).
+ observerPromise = expectObserverCalled("recording-device-events");
+ await setMicrophoneMuted(false);
+
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ window.gPermissionPanel._sharingState.webRTC.microphone ==
+ STATE_CAPTURE_ENABLED,
+ "audio should be enabled"
+ );
+
+ await observerPromise;
+
+ // Both streams should show as running.
+ await checkSharingUI({
+ video: STATE_CAPTURE_ENABLED,
+ audio: STATE_CAPTURE_ENABLED,
+ });
+ is(await getAudioTrackMuted(), false, "audio track is unmuted");
+ Assert.deepEqual(
+ await getAudioTrackEvents(),
+ ["mute", "unmute"],
+ "unmute fired"
+ );
+
+ // 5. Mute microphone & disable both tracks.
+ observerPromise = expectObserverCalled("recording-device-events", 3);
+ await setMicrophoneMuted(true);
+ await setTrackEnabled(false, false);
+
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ window.gPermissionPanel._sharingState.webRTC.microphone ==
+ STATE_CAPTURE_DISABLED,
+ "audio should be disabled"
+ );
+
+ await observerPromise;
+
+ // The identity UI should show both as disabled.
+ await checkSharingUI({
+ video: STATE_CAPTURE_DISABLED,
+ audio: STATE_CAPTURE_DISABLED,
+ });
+ is(await getAudioTrackMuted(), true, "audio track is muted");
+ Assert.deepEqual(
+ await getAudioTrackEvents(),
+ ["mute", "unmute", "mute"],
+ "mute fired again"
+ );
+
+ // 6. Unmute microphone & enable video (only video should flow)
+ observerPromise = expectObserverCalled("recording-device-events", 2);
+ await setMicrophoneMuted(false);
+ await setTrackEnabled(null, true);
+
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ window.gPermissionPanel._sharingState.webRTC.camera ==
+ STATE_CAPTURE_ENABLED,
+ "video should be enabled"
+ );
+
+ await observerPromise;
+
+ // Only video should show as running, as audio track is still disabled.
+ await checkSharingUI({
+ video: STATE_CAPTURE_ENABLED,
+ audio: STATE_CAPTURE_DISABLED,
+ });
+ is(await getAudioTrackMuted(), false, "audio track is unmuted");
+ Assert.deepEqual(
+ await getAudioTrackEvents(),
+ ["mute", "unmute", "mute", "unmute"],
+ "unmute fired even though track is disabled"
+ );
+
+ // 7. Enable audio track again (audio should flow).
+ observerPromise = expectObserverCalled("recording-device-events");
+ await setTrackEnabled(true, null);
+
+ await BrowserTestUtils.waitForCondition(
+ () =>
+ window.gPermissionPanel._sharingState.webRTC.microphone ==
+ STATE_CAPTURE_ENABLED,
+ "audio should be enabled"
+ );
+
+ await observerPromise;
+
+ // The identity UI should show both as running again.
+ await checkSharingUI({
+ video: STATE_CAPTURE_ENABLED,
+ audio: STATE_CAPTURE_ENABLED,
+ });
+ is(await getAudioTrackMuted(), false, "audio track remains unmuted");
+ Assert.deepEqual(
+ await getAudioTrackEvents(),
+ ["mute", "unmute", "mute", "unmute"],
+ "no new events fired"
+ );
+ await closeStream();
+ },
+ },
+];
+
+add_task(async function test() {
+ await SpecialPowers.pushPrefEnv({
+ set: [
+ ["media.getusermedia.camera.off_while_disabled.delay_ms", 0],
+ ["media.getusermedia.microphone.off_while_disabled.delay_ms", 0],
+ ],
+ });
+
+ SimpleTest.requestCompleteLog();
+ await runTests(gTests);
+});
diff --git a/browser/base/content/test/webrtc/browser_devices_get_user_media_queue_request.js b/browser/base/content/test/webrtc/browser_devices_get_user_media_queue_request.js
new file mode 100644
index 0000000000..1c90787640
--- /dev/null
+++ b/browser/base/content/test/webrtc/browser_devices_get_user_media_queue_request.js
@@ -0,0 +1,383 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+const permissionError =
+ "error: NotAllowedError: The request is not allowed " +
+ "by the user agent or the platform in the current context.";
+
+const badDeviceError =
+ "error: NotReadableError: Failed to allocate videosource";
+
+var gTests = [
+ {
+ desc: "test 'Not now' label queueing audio twice behind allow video",
+ run: async function testQueuingDenyAudioBehindAllowVideo() {
+ let observerPromise = expectObserverCalled("getUserMedia:request");
+ let promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(false, true);
+ await promiseRequestDevice(true, false);
+ await promiseRequestDevice(true, false);
+ await promise;
+ promise = promisePopupNotificationShown("webRTC-shareDevices");
+ checkDeviceSelectors(["camera"]);
+ await observerPromise;
+ let indicator = promiseIndicatorWindow();
+
+ observerPromise = expectObserverCalled("getUserMedia:request");
+ let observerPromise1 = expectObserverCalled(
+ "getUserMedia:response:allow"
+ );
+ let observerPromise2 = expectObserverCalled("recording-device-events");
+ await promiseMessage("ok", () => {
+ is(
+ PopupNotifications.panel.firstElementChild.secondaryButton.label,
+ "Block",
+ "We offer Block because of no active camera/mic device"
+ );
+ PopupNotifications.panel.firstElementChild.button.click();
+ });
+
+ await observerPromise1;
+ await observerPromise2;
+ Assert.deepEqual(
+ await getMediaCaptureState(),
+ { video: true },
+ "expected camera to be shared"
+ );
+
+ await indicator;
+ await checkSharingUI({ audio: false, video: true });
+
+ await promise;
+ promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await observerPromise;
+ observerPromise = expectObserverCalled("getUserMedia:request");
+ checkDeviceSelectors(["microphone"]);
+
+ observerPromise1 = expectObserverCalled("getUserMedia:response:deny");
+ await promiseMessage(permissionError, () => {
+ is(
+ PopupNotifications.panel.firstElementChild.secondaryButton.label,
+ "Not now",
+ "We offer Not now option because of an allowed camera/mic device"
+ );
+ activateSecondaryAction(kActionDeny);
+ });
+
+ await observerPromise1;
+
+ await promise;
+ await observerPromise;
+ checkDeviceSelectors(["microphone"]);
+
+ observerPromise = expectObserverCalled("getUserMedia:response:deny");
+ await promiseMessage(permissionError, () => {
+ is(
+ PopupNotifications.panel.firstElementChild.secondaryButton.label,
+ "Not now",
+ "We offer Not now option again because of an allowed camera/mic device"
+ );
+ activateSecondaryAction(kActionDeny);
+ });
+
+ await observerPromise;
+
+ // Clean up the active camera in the activePerms map
+ webrtcUI.activePerms.delete(gBrowser.selectedBrowser.outerWindowID);
+
+ // close all streams
+ await closeStream();
+ },
+ },
+
+ {
+ desc: "test 'Not now'/'Block' label queueing microphone behind screen behind allow camera",
+ run: async function testQueuingAudioAndScreenBehindAllowVideo() {
+ let observerPromise = expectObserverCalled("getUserMedia:request");
+ let promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(false, true);
+ await promiseRequestDevice(false, true, null, "screen");
+ await promiseRequestDevice(true, false);
+ await promise;
+ promise = promisePopupNotificationShown("webRTC-shareDevices");
+ checkDeviceSelectors(["camera"]);
+ await observerPromise;
+
+ observerPromise = expectObserverCalled("getUserMedia:request");
+ let observerPromise1 = expectObserverCalled(
+ "getUserMedia:response:allow"
+ );
+ let observerPromise2 = expectObserverCalled("recording-device-events");
+ await promiseMessage("ok", () => {
+ is(
+ PopupNotifications.panel.firstElementChild.secondaryButton.label,
+ "Block",
+ "We offer Block because of no active camera/mic device"
+ );
+ PopupNotifications.panel.firstElementChild.button.click();
+ });
+ await observerPromise1;
+ await observerPromise2;
+ Assert.deepEqual(
+ await getMediaCaptureState(),
+ { video: true },
+ "expected camera to be shared"
+ );
+ await checkSharingUI({ audio: false, video: true });
+
+ await promise;
+ promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await observerPromise;
+ observerPromise = expectObserverCalled("getUserMedia:request");
+ checkDeviceSelectors(["screen"]);
+ observerPromise = expectObserverCalled("getUserMedia:response:deny");
+ await promiseMessage(permissionError, () => {
+ is(
+ PopupNotifications.panel.firstElementChild.secondaryButton.label,
+ "Not now",
+ "We offer Not now option because we are asking for screen"
+ );
+ activateSecondaryAction(kActionDeny);
+ });
+ await observerPromise;
+
+ await promise;
+ await observerPromise;
+ checkDeviceSelectors(["microphone"]);
+ observerPromise = expectObserverCalled("getUserMedia:response:deny");
+ await promiseMessage(permissionError, () => {
+ is(
+ PopupNotifications.panel.firstElementChild.secondaryButton.label,
+ "Not now",
+ "We offer Not now option because we are asking for mic and cam is already active"
+ );
+ activateSecondaryAction(kActionDeny);
+ });
+ await observerPromise;
+
+ // Clean up
+ webrtcUI.activePerms.delete(gBrowser.selectedBrowser.outerWindowID);
+ // close all streams
+ await closeStream();
+ },
+ },
+
+ {
+ desc: "test queueing allow video behind deny audio",
+ run: async function testQueuingAllowVideoBehindDenyAudio() {
+ let observerPromise = expectObserverCalled("getUserMedia:request");
+ let promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(true, false);
+ await promiseRequestDevice(false, true);
+ await promise;
+ promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await observerPromise;
+ checkDeviceSelectors(["microphone"]);
+
+ let observerPromises = [
+ expectObserverCalled("getUserMedia:request"),
+ expectObserverCalled("getUserMedia:response:deny"),
+ ];
+
+ await promiseMessage(permissionError, () => {
+ activateSecondaryAction(kActionDeny);
+ });
+
+ await Promise.all(observerPromises);
+ checkDeviceSelectors(["camera"]);
+
+ let indicator = promiseIndicatorWindow();
+
+ let observerPromise1 = expectObserverCalled(
+ "getUserMedia:response:allow"
+ );
+ let observerPromise2 = expectObserverCalled("recording-device-events");
+ await promiseMessage("ok", () => {
+ PopupNotifications.panel.firstElementChild.button.click();
+ });
+
+ await observerPromise1;
+ await observerPromise2;
+ Assert.deepEqual(
+ await getMediaCaptureState(),
+ { video: true },
+ "expected camera to be shared"
+ );
+ await indicator;
+ await checkSharingUI({ audio: false, video: true });
+
+ SitePermissions.removeFromPrincipal(
+ null,
+ "microphone",
+ gBrowser.selectedBrowser
+ );
+
+ // close all streams
+ await closeStream();
+ },
+ },
+
+ {
+ desc: "test queueing allow audio behind allow video with error",
+ run: async function testQueuingAllowAudioBehindAllowVideoWithError() {
+ let observerPromise = expectObserverCalled("getUserMedia:request");
+ let promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(
+ false,
+ true,
+ null,
+ null,
+ gBrowser.selectedBrowser,
+ true
+ );
+ await promiseRequestDevice(true, false);
+ await observerPromise;
+ await promise;
+ promise = promisePopupNotificationShown("webRTC-shareDevices");
+
+ checkDeviceSelectors(["camera"]);
+
+ let observerPromise1 = expectObserverCalled("getUserMedia:request");
+ let observerPromise2 = expectObserverCalled(
+ "getUserMedia:response:allow"
+ );
+ await promiseMessage(badDeviceError, () => {
+ PopupNotifications.panel.firstElementChild.button.click();
+ });
+
+ await observerPromise1;
+ await observerPromise2;
+ await promise;
+ checkDeviceSelectors(["microphone"]);
+
+ let indicator = promiseIndicatorWindow();
+
+ observerPromise1 = expectObserverCalled("getUserMedia:response:allow");
+ observerPromise2 = expectObserverCalled("recording-device-events");
+ await promiseMessage("ok", () => {
+ PopupNotifications.panel.firstElementChild.button.click();
+ });
+
+ await observerPromise1;
+ await observerPromise2;
+ Assert.deepEqual(
+ await getMediaCaptureState(),
+ { audio: true },
+ "expected microphone to be shared"
+ );
+ await indicator;
+ await checkSharingUI({ audio: true, video: false });
+
+ // Clean up the active microphone in the activePerms map
+ webrtcUI.activePerms.delete(gBrowser.selectedBrowser.outerWindowID);
+
+ // close all streams
+ await closeStream();
+ },
+ },
+
+ {
+ desc: "test queueing audio+video behind deny audio",
+ run: async function testQueuingAllowVideoBehindDenyAudio() {
+ let observerPromise = expectObserverCalled("getUserMedia:request");
+ let promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(true, false);
+ await promiseRequestDevice(true, true);
+ await promise;
+ await observerPromise;
+ checkDeviceSelectors(["microphone"]);
+
+ let observerPromises = [
+ expectObserverCalled("getUserMedia:request"),
+ expectObserverCalled("getUserMedia:response:deny", 2),
+ expectObserverCalled("recording-window-ended"),
+ ];
+
+ await promiseMessage(
+ permissionError,
+ () => {
+ activateSecondaryAction(kActionDeny);
+ },
+ 2
+ );
+ await Promise.all(observerPromises);
+
+ SitePermissions.removeFromPrincipal(
+ null,
+ "microphone",
+ gBrowser.selectedBrowser
+ );
+ },
+ },
+
+ {
+ desc: "test queueing audio, video behind reload after pending audio, video",
+ run: async function testQueuingDenyAudioBehindAllowVideo() {
+ let observerPromise = expectObserverCalled("getUserMedia:request");
+ let promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(true, false);
+ await promiseRequestDevice(false, true);
+ await promise;
+ await observerPromise;
+ checkDeviceSelectors(["microphone"]);
+
+ await reloadAndAssertClosedStreams();
+
+ observerPromise = expectObserverCalled("getUserMedia:request");
+ // After the reload, gUM(audio) causes a prompt.
+ promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(true, false);
+ await promiseRequestDevice(false, true);
+ await promise;
+ await observerPromise;
+ checkDeviceSelectors(["microphone"]);
+
+ let indicator = promiseIndicatorWindow();
+ let observerPromise1 = expectObserverCalled(
+ "getUserMedia:response:allow"
+ );
+ let observerPromise2 = expectObserverCalled("recording-device-events");
+
+ // expect pending camera prompt to appear after ok'ing microphone one.
+ observerPromise = expectObserverCalled("getUserMedia:request");
+ promise = promisePopupNotificationShown("webRTC-shareDevices");
+
+ await promiseMessage("ok", () => {
+ PopupNotifications.panel.firstElementChild.button.click();
+ });
+ await observerPromise1;
+ await observerPromise2;
+ Assert.deepEqual(
+ await getMediaCaptureState(),
+ { audio: true },
+ "expected microphone to be shared"
+ );
+ await indicator;
+ await checkSharingUI({ video: false, audio: true });
+
+ await promise;
+ await observerPromise;
+ checkDeviceSelectors(["camera"]);
+
+ observerPromise1 = expectObserverCalled("getUserMedia:response:allow");
+ observerPromise2 = expectObserverCalled("recording-device-events");
+ await promiseMessage("ok", () => {
+ PopupNotifications.panel.firstElementChild.button.click();
+ });
+ await observerPromise1;
+ await observerPromise2;
+ Assert.deepEqual(
+ await getMediaCaptureState(),
+ { audio: true, video: true },
+ "expected microphone and camera to be shared"
+ );
+
+ // close all streams
+ await closeStream();
+ },
+ },
+];
+
+add_task(async function test() {
+ await runTests(gTests);
+});
diff --git a/browser/base/content/test/webrtc/browser_devices_get_user_media_screen.js b/browser/base/content/test/webrtc/browser_devices_get_user_media_screen.js
new file mode 100644
index 0000000000..d09d7f2c5f
--- /dev/null
+++ b/browser/base/content/test/webrtc/browser_devices_get_user_media_screen.js
@@ -0,0 +1,949 @@
+/* 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/. */
+
+// The rejection "The fetching process for the media resource was aborted by the
+// user agent at the user's request." is left unhandled in some cases. This bug
+// should be fixed, but for the moment this file allows a class of rejections.
+//
+// NOTE: Allowing a whole class of rejections should be avoided. Normally you
+// should use "expectUncaughtRejection" to flag individual failures.
+const { PromiseTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PromiseTestUtils.sys.mjs"
+);
+PromiseTestUtils.allowMatchingRejectionsGlobally(/aborted by the user agent/);
+
+const permissionError =
+ "error: NotAllowedError: The request is not allowed " +
+ "by the user agent or the platform in the current context.";
+
+const notFoundError = "error: NotFoundError: The object can not be found here.";
+
+const isHeadless = Services.env.get("MOZ_HEADLESS");
+
+function verifyTabSharingPopup(expectedItems) {
+ let event = new MouseEvent("popupshowing");
+ let sharingMenu = document.getElementById("tabSharingMenuPopup");
+ sharingMenu.dispatchEvent(event);
+
+ is(
+ sharingMenu.children.length,
+ expectedItems.length,
+ "correct number of items on tab sharing menu"
+ );
+ for (let i = 0; i < expectedItems.length; i++) {
+ is(
+ JSON.parse(sharingMenu.children[i].getAttribute("data-l10n-args"))
+ .itemList,
+ expectedItems[i],
+ "label of item " + i + " + was correct"
+ );
+ }
+
+ sharingMenu.dispatchEvent(new MouseEvent("popuphiding"));
+}
+
+var gTests = [
+ {
+ desc: "getUserMedia window/screen picking screen",
+ run: async function checkWindowOrScreen() {
+ let observerPromise = expectObserverCalled("getUserMedia:request");
+ let promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(false, true, null, "screen");
+ await promise;
+ await observerPromise;
+
+ is(
+ PopupNotifications.getNotification("webRTC-shareDevices").anchorID,
+ "webRTC-shareScreen-notification-icon",
+ "anchored to device icon"
+ );
+ checkDeviceSelectors(["screen"]);
+ let notification = PopupNotifications.panel.firstElementChild;
+
+ let menulist = document.getElementById("webRTC-selectWindow-menulist");
+ let count = menulist.itemCount;
+ ok(
+ count >= 4,
+ "There should be the 'Select Window or Screen' item, a separator and at least one window and one screen"
+ );
+
+ let noWindowOrScreenItem = menulist.getItemAtIndex(0);
+ ok(
+ noWindowOrScreenItem.hasAttribute("selected"),
+ "the 'Select Window or Screen' item is selected"
+ );
+ is(
+ menulist.selectedItem,
+ noWindowOrScreenItem,
+ "'Select Window or Screen' is the selected item"
+ );
+ is(menulist.value, "-1", "no window or screen is selected by default");
+ ok(
+ noWindowOrScreenItem.disabled,
+ "'Select Window or Screen' item is disabled"
+ );
+ ok(notification.button.disabled, "Allow button is disabled");
+ ok(
+ notification.hasAttribute("invalidselection"),
+ "Notification is marked as invalid"
+ );
+
+ let separator = menulist.getItemAtIndex(1);
+ is(
+ separator.localName,
+ "menuseparator",
+ "the second item is a separator"
+ );
+
+ ok(
+ document.getElementById("webRTC-all-windows-shared").hidden,
+ "the 'all windows will be shared' warning should be hidden while there's no selection"
+ );
+ ok(
+ document.getElementById("webRTC-preview").hidden,
+ "the preview area is hidden"
+ );
+
+ let scaryScreenIndex;
+ for (let i = 2; i < count; ++i) {
+ let item = menulist.getItemAtIndex(i);
+ is(
+ parseInt(item.getAttribute("value")),
+ i - 2,
+ "the window/screen item has the correct index"
+ );
+ let type = item.getAttribute("devicetype");
+ ok(
+ ["window", "screen"].includes(type),
+ "the devicetype attribute is set correctly"
+ );
+ if (type == "screen") {
+ ok(item.scary, "the screen item is marked as scary");
+ scaryScreenIndex = i;
+ }
+ }
+ ok(
+ typeof scaryScreenIndex == "number",
+ "there's at least one scary screen, as as all screens are"
+ );
+
+ // Select a screen, a preview with a scary warning should appear.
+ menulist.getItemAtIndex(scaryScreenIndex).doCommand();
+ ok(
+ !document.getElementById("webRTC-all-windows-shared").hidden,
+ "the 'all windows will be shared' warning should now be visible"
+ );
+ await TestUtils.waitForCondition(
+ () => !document.getElementById("webRTC-preview").hidden,
+ "preview unhide",
+ 100,
+ 100
+ );
+ ok(
+ !document.getElementById("webRTC-preview").hidden,
+ "the preview area is visible"
+ );
+ ok(
+ !document.getElementById("webRTC-previewWarningBox").hidden,
+ "the scary warning is visible"
+ );
+ ok(!notification.button.disabled, "Allow button is enabled");
+
+ // Select the 'Select Window or Screen' item again, the preview should be hidden.
+ menulist.getItemAtIndex(0).doCommand();
+ ok(
+ document.getElementById("webRTC-all-windows-shared").hidden,
+ "the 'all windows will be shared' warning should now be hidden"
+ );
+ ok(
+ document.getElementById("webRTC-preview").hidden,
+ "the preview area is hidden"
+ );
+
+ // Select the scary screen again so that we can have a stream.
+ menulist.getItemAtIndex(scaryScreenIndex).doCommand();
+
+ let indicator = promiseIndicatorWindow();
+ let observerPromise1 = expectObserverCalled(
+ "getUserMedia:response:allow"
+ );
+ let observerPromise2 = expectObserverCalled("recording-device-events");
+ await promiseMessage("ok", () => {
+ PopupNotifications.panel.firstElementChild.button.click();
+ });
+ await observerPromise1;
+ await observerPromise2;
+ Assert.deepEqual(
+ await getMediaCaptureState(),
+ { screen: "Screen" },
+ "expected screen to be shared"
+ );
+
+ await indicator;
+ await checkSharingUI({ screen: "Screen" });
+ verifyTabSharingPopup(["screen"]);
+
+ // we always show prompt for screen sharing.
+ promise = promisePopupNotificationShown("webRTC-shareDevices");
+ observerPromise = expectObserverCalled("getUserMedia:request");
+ await promiseRequestDevice(false, true, null, "screen");
+ await promise;
+ await observerPromise;
+
+ is(
+ PopupNotifications.getNotification("webRTC-shareDevices").anchorID,
+ "webRTC-shareScreen-notification-icon",
+ "anchored to device icon"
+ );
+ checkDeviceSelectors(["screen"]);
+
+ observerPromise = expectObserverCalled("getUserMedia:response:deny");
+ await promiseMessage(permissionError, () => {
+ activateSecondaryAction(kActionDeny);
+ });
+
+ await observerPromise;
+ SitePermissions.removeFromPrincipal(
+ null,
+ "screen",
+ gBrowser.selectedBrowser
+ );
+ await closeStream();
+ },
+ },
+
+ {
+ desc: "getUserMedia window/screen picking window",
+ run: async function checkWindowOrScreen() {
+ let observerPromise = expectObserverCalled("getUserMedia:request");
+ let promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(false, true, null, "window");
+ await promise;
+ await observerPromise;
+
+ is(
+ PopupNotifications.getNotification("webRTC-shareDevices").anchorID,
+ "webRTC-shareScreen-notification-icon",
+ "anchored to device icon"
+ );
+ checkDeviceSelectors(["screen"]);
+ let notification = PopupNotifications.panel.firstElementChild;
+
+ let menulist = document.getElementById("webRTC-selectWindow-menulist");
+ let count = menulist.itemCount;
+ ok(
+ count >= 4,
+ "There should be the 'Select Window or Screen' item, a separator and at least one window and one screen"
+ );
+
+ let noWindowOrScreenItem = menulist.getItemAtIndex(0);
+ ok(
+ noWindowOrScreenItem.hasAttribute("selected"),
+ "the 'Select Window or Screen' item is selected"
+ );
+ is(
+ menulist.selectedItem,
+ noWindowOrScreenItem,
+ "'Select Window or Screen' is the selected item"
+ );
+ is(menulist.value, "-1", "no window or screen is selected by default");
+ ok(
+ noWindowOrScreenItem.disabled,
+ "'Select Window or Screen' item is disabled"
+ );
+ ok(notification.button.disabled, "Allow button is disabled");
+ ok(
+ notification.hasAttribute("invalidselection"),
+ "Notification is marked as invalid"
+ );
+
+ let separator = menulist.getItemAtIndex(1);
+ is(
+ separator.localName,
+ "menuseparator",
+ "the second item is a separator"
+ );
+
+ ok(
+ document.getElementById("webRTC-all-windows-shared").hidden,
+ "the 'all windows will be shared' warning should be hidden while there's no selection"
+ );
+ ok(
+ document.getElementById("webRTC-preview").hidden,
+ "the preview area is hidden"
+ );
+
+ let scaryWindowIndexes = [],
+ nonScaryWindowIndex,
+ scaryScreenIndex;
+ for (let i = 2; i < count; ++i) {
+ let item = menulist.getItemAtIndex(i);
+ is(
+ parseInt(item.getAttribute("value")),
+ i - 2,
+ "the window/screen item has the correct index"
+ );
+ let type = item.getAttribute("devicetype");
+ ok(
+ ["window", "screen"].includes(type),
+ "the devicetype attribute is set correctly"
+ );
+ if (type == "screen") {
+ ok(item.scary, "the screen item is marked as scary");
+ scaryScreenIndex = i;
+ } else if (item.scary) {
+ scaryWindowIndexes.push(i);
+ } else {
+ nonScaryWindowIndex = i;
+ }
+ }
+ if (isHeadless) {
+ is(
+ scaryWindowIndexes.length,
+ 0,
+ "there are no scary Firefox windows in headless mode"
+ );
+ } else {
+ ok(
+ scaryWindowIndexes.length,
+ "there's at least one scary window, as Firefox is running"
+ );
+ }
+ ok(
+ typeof scaryScreenIndex == "number",
+ "there's at least one scary screen, as all screens are"
+ );
+
+ if (!isHeadless) {
+ // Select one scary window, a preview with a scary warning should appear.
+ let scaryWindowIndex;
+ for (scaryWindowIndex of scaryWindowIndexes) {
+ menulist.getItemAtIndex(scaryWindowIndex).doCommand();
+ ok(
+ document.getElementById("webRTC-all-windows-shared").hidden,
+ "the 'all windows will be shared' warning should still be hidden"
+ );
+ try {
+ await TestUtils.waitForCondition(
+ () => !document.getElementById("webRTC-preview").hidden,
+ "",
+ 100,
+ 100
+ );
+ break;
+ } catch (e) {
+ // A "scary window" is Firefox. Multiple Firefox windows have been
+ // observed to come and go during try runs, so we won't know which one
+ // is ours. To avoid intermittents, we ignore preview failing due to
+ // these going away on us, provided it succeeds on one of them.
+ }
+ }
+ ok(
+ !document.getElementById("webRTC-preview").hidden,
+ "the preview area is visible"
+ );
+ ok(
+ !document.getElementById("webRTC-previewWarningBox").hidden,
+ "the scary warning is visible"
+ );
+ // Select the 'Select Window' item again, the preview should be hidden.
+ menulist.getItemAtIndex(0).doCommand();
+ ok(
+ document.getElementById("webRTC-preview").hidden,
+ "the preview area is hidden"
+ );
+
+ // Select the first window again so that we can have a stream.
+ menulist.getItemAtIndex(scaryWindowIndex).doCommand();
+ }
+
+ let sharingNonScaryWindow = typeof nonScaryWindowIndex == "number";
+
+ // If we have a non-scary window, select it and verify the warning isn't displayed.
+ // A non-scary window may not always exist on test machines.
+ if (sharingNonScaryWindow) {
+ menulist.getItemAtIndex(nonScaryWindowIndex).doCommand();
+ ok(
+ document.getElementById("webRTC-all-windows-shared").hidden,
+ "the 'all windows will be shared' warning should still be hidden"
+ );
+ await TestUtils.waitForCondition(
+ () => !document.getElementById("webRTC-preview").hidden,
+ "preview unhide",
+ 100,
+ 100
+ );
+ ok(
+ !document.getElementById("webRTC-preview").hidden,
+ "the preview area is visible"
+ );
+ ok(
+ document.getElementById("webRTC-previewWarningBox").hidden,
+ "the scary warning is hidden"
+ );
+ } else {
+ info("no non-scary window available on this test machine");
+ }
+
+ let indicator = promiseIndicatorWindow();
+ let observerPromise1 = expectObserverCalled(
+ "getUserMedia:response:allow"
+ );
+ let observerPromise2 = expectObserverCalled("recording-device-events");
+ await promiseMessage("ok", () => {
+ PopupNotifications.panel.firstElementChild.button.click();
+ });
+ await observerPromise1;
+ await observerPromise2;
+ Assert.deepEqual(
+ await getMediaCaptureState(),
+ { screen: "Window" },
+ "expected screen to be shared"
+ );
+
+ await indicator;
+ if (sharingNonScaryWindow) {
+ await checkSharingUI({ screen: "Window" });
+ } else {
+ await checkSharingUI({ screen: "Window", browserwindow: true });
+ }
+
+ verifyTabSharingPopup(["window"]);
+
+ await closeStream();
+ },
+ },
+
+ {
+ desc: "getUserMedia audio + window/screen",
+ run: async function checkAudioVideo() {
+ if (AppConstants.platform == "macosx") {
+ todo(
+ false,
+ "Bug 1323481 - On Mac on treeherder, but not locally, requesting microphone + screen never makes the permission prompt appear, and so causes the test to timeout"
+ );
+ return;
+ }
+
+ let observerPromise = expectObserverCalled("getUserMedia:request");
+ let promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(true, true, null, "window");
+ await promise;
+ await observerPromise;
+
+ is(
+ PopupNotifications.getNotification("webRTC-shareDevices").anchorID,
+ "webRTC-shareScreen-notification-icon",
+ "anchored to device icon"
+ );
+ checkDeviceSelectors(["microphone", "screen"]);
+
+ let menulist = document.getElementById("webRTC-selectWindow-menulist");
+ let count = menulist.itemCount;
+ ok(
+ count >= 4,
+ "There should be the 'Select Window or Screen' item, a separator and at least one window and one screen"
+ );
+
+ // Select a screen, a preview with a scary warning should appear.
+ menulist.getItemAtIndex(count - 1).doCommand();
+ ok(
+ !document.getElementById("webRTC-all-windows-shared").hidden,
+ "the 'all windows will be shared' warning should now be visible"
+ );
+ await TestUtils.waitForCondition(
+ () => !document.getElementById("webRTC-preview").hidden,
+ "preview unhide",
+ 100,
+ 100
+ );
+ ok(
+ !document.getElementById("webRTC-preview").hidden,
+ "the preview area is visible"
+ );
+ ok(
+ !document.getElementById("webRTC-previewWarningBox").hidden,
+ "the scary warning is visible"
+ );
+
+ let indicator = promiseIndicatorWindow();
+ let observerPromise1 = expectObserverCalled(
+ "getUserMedia:response:allow"
+ );
+ let observerPromise2 = expectObserverCalled("recording-device-events");
+ await promiseMessage("ok", () => {
+ PopupNotifications.panel.firstElementChild.button.click();
+ });
+ await observerPromise1;
+ await observerPromise2;
+ Assert.deepEqual(
+ await getMediaCaptureState(),
+ { audio: true, screen: "Screen" },
+ "expected screen and microphone to be shared"
+ );
+
+ await indicator;
+ await checkSharingUI({ audio: true, screen: "Screen" });
+
+ verifyTabSharingPopup(["microphone and screen"]);
+
+ await closeStream();
+ },
+ },
+
+ {
+ desc: 'getUserMedia screen, user clicks "Don\'t Allow"',
+ run: async function checkDontShare() {
+ let observerPromise = expectObserverCalled("getUserMedia:request");
+ let promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(false, true, null, "screen");
+ await promise;
+ await observerPromise;
+ checkDeviceSelectors(["screen"]);
+
+ let observerPromise1 = expectObserverCalled("getUserMedia:response:deny");
+ let observerPromise2 = expectObserverCalled("recording-window-ended");
+ await promiseMessage(permissionError, () => {
+ activateSecondaryAction(kActionDeny);
+ });
+
+ await observerPromise1;
+ await observerPromise2;
+ await checkNotSharing();
+ SitePermissions.removeFromPrincipal(
+ null,
+ "screen",
+ gBrowser.selectedBrowser
+ );
+ SitePermissions.removeFromPrincipal(
+ null,
+ "camera",
+ gBrowser.selectedBrowser
+ );
+ },
+ },
+
+ {
+ desc: "getUserMedia audio + window/screen: stop sharing",
+ run: async function checkStopSharing() {
+ if (AppConstants.platform == "macosx") {
+ todo(
+ false,
+ "Bug 1323481 - On Mac on treeherder, but not locally, requesting microphone + screen never makes the permission prompt appear, and so causes the test to timeout"
+ );
+ return;
+ }
+
+ async function share(deviceTypes) {
+ let promise = promisePopupNotificationShown("webRTC-shareDevices");
+ let observerPromise = expectObserverCalled("getUserMedia:request");
+ await promiseRequestDevice(
+ /* audio */ deviceTypes.includes("microphone"),
+ /* video */ deviceTypes.some(t => t == "screen" || t == "camera"),
+ null,
+ deviceTypes.includes("screen") && "window"
+ );
+ await promise;
+ await observerPromise;
+ checkDeviceSelectors(deviceTypes);
+ if (screen) {
+ let menulist = document.getElementById(
+ "webRTC-selectWindow-menulist"
+ );
+ menulist.getItemAtIndex(menulist.itemCount - 1).doCommand();
+ }
+ let observerPromise1 = expectObserverCalled(
+ "getUserMedia:response:allow"
+ );
+ let observerPromise2 = expectObserverCalled("recording-device-events");
+ await promiseMessage("ok", () => {
+ PopupNotifications.panel.firstElementChild.button.click();
+ });
+ await observerPromise1;
+ await observerPromise2;
+ }
+
+ async function check(expected = {}, expectedSharingLabel) {
+ let shared = Object.keys(expected).join(" and ");
+ if (shared) {
+ Assert.deepEqual(
+ await getMediaCaptureState(),
+ expected,
+ "expected " + shared + " to be shared"
+ );
+ await checkSharingUI(expected);
+ verifyTabSharingPopup([expectedSharingLabel]);
+ } else {
+ await checkNotSharing();
+ verifyTabSharingPopup([""]);
+ }
+ }
+
+ info("Share screen and microphone");
+ let indicator = promiseIndicatorWindow();
+ await share(["microphone", "screen"]);
+ await indicator;
+ await check({ audio: true, screen: "Screen" }, "microphone and screen");
+
+ info("Share camera");
+ await share(["camera"]);
+ await check(
+ { video: true, audio: true, screen: "Screen" },
+ "microphone, screen, and camera"
+ );
+
+ info("Stop the screen share, mic+cam should continue");
+ await stopSharing("screen", true);
+ await check({ video: true, audio: true }, "microphone and camera");
+
+ info("Stop the camera, everything should stop.");
+ await stopSharing("camera");
+
+ info("Now, share only the screen...");
+ indicator = promiseIndicatorWindow();
+ await share(["screen"]);
+ await indicator;
+ await check({ screen: "Screen" }, "screen");
+
+ info("... and add camera and microphone in a second request.");
+ await share(["microphone", "camera"]);
+ await check(
+ { video: true, audio: true, screen: "Screen" },
+ "screen, microphone, and camera"
+ );
+
+ info("Stop the camera, this should stop everything.");
+ await stopSharing("camera");
+ },
+ },
+
+ {
+ desc: "getUserMedia window/screen: reloading the page removes all gUM UI",
+ run: async function checkReloading() {
+ let observerPromise = expectObserverCalled("getUserMedia:request");
+ let promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(false, true, null, "screen");
+ await promise;
+ await observerPromise;
+ checkDeviceSelectors(["screen"]);
+ let menulist = document.getElementById("webRTC-selectWindow-menulist");
+ menulist.getItemAtIndex(menulist.itemCount - 1).doCommand();
+
+ let indicator = promiseIndicatorWindow();
+ let observerPromise1 = expectObserverCalled(
+ "getUserMedia:response:allow"
+ );
+ let observerPromise2 = expectObserverCalled("recording-device-events");
+ await promiseMessage("ok", () => {
+ PopupNotifications.panel.firstElementChild.button.click();
+ });
+ await observerPromise1;
+ await observerPromise2;
+ Assert.deepEqual(
+ await getMediaCaptureState(),
+ { screen: "Screen" },
+ "expected screen to be shared"
+ );
+
+ await indicator;
+ await checkSharingUI({ screen: "Screen" });
+ verifyTabSharingPopup(["screen"]);
+
+ await reloadAndAssertClosedStreams();
+ },
+ },
+
+ {
+ desc: "test showControlCenter from screen icon",
+ run: async function checkShowControlCenter() {
+ if (!USING_LEGACY_INDICATOR) {
+ info(
+ "Skipping since this test doesn't apply to the new global sharing " +
+ "indicator."
+ );
+ return;
+ }
+ let observerPromise = expectObserverCalled("getUserMedia:request");
+ let promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(false, true, null, "screen");
+ await promise;
+ await observerPromise;
+ checkDeviceSelectors(["screen"]);
+ let menulist = document.getElementById("webRTC-selectWindow-menulist");
+ menulist.getItemAtIndex(menulist.itemCount - 1).doCommand();
+
+ let observerPromise1 = expectObserverCalled(
+ "getUserMedia:response:allow"
+ );
+ let observerPromise2 = expectObserverCalled("recording-device-events");
+ let indicator = promiseIndicatorWindow();
+ await promiseMessage("ok", () => {
+ PopupNotifications.panel.firstElementChild.button.click();
+ });
+ await observerPromise1;
+ await observerPromise2;
+ Assert.deepEqual(
+ await getMediaCaptureState(),
+ { screen: "Screen" },
+ "expected screen to be shared"
+ );
+ await indicator;
+ await checkSharingUI({ screen: "Screen" });
+ verifyTabSharingPopup(["screen"]);
+
+ ok(permissionPopupHidden(), "control center should be hidden");
+ if (IS_MAC) {
+ let activeStreams = webrtcUI.getActiveStreams(false, false, true);
+ webrtcUI.showSharingDoorhanger(activeStreams[0]);
+ } else {
+ let win = Services.wm.getMostRecentWindow(
+ "Browser:WebRTCGlobalIndicator"
+ );
+ let elt = win.document.getElementById("screenShareButton");
+ EventUtils.synthesizeMouseAtCenter(elt, {}, win);
+ }
+ await TestUtils.waitForCondition(
+ () => !permissionPopupHidden(),
+ "wait for control center to open"
+ );
+ ok(!permissionPopupHidden(), "control center should be open");
+
+ gPermissionPanel._permissionPopup.hidePopup();
+
+ await closeStream();
+ },
+ },
+
+ {
+ desc: "Only persistent block is possible for screen sharing",
+ run: async function checkPersistentPermissions() {
+ // This test doesn't apply when the notification silencing
+ // feature is enabled, since the "Remember this decision"
+ // checkbox doesn't exist.
+ if (ALLOW_SILENCING_NOTIFICATIONS) {
+ return;
+ }
+
+ let browser = gBrowser.selectedBrowser;
+ let devicePerms = SitePermissions.getForPrincipal(
+ browser.contentPrincipal,
+ "screen",
+ browser
+ );
+ is(
+ devicePerms.state,
+ SitePermissions.UNKNOWN,
+ "starting without screen persistent permissions"
+ );
+
+ let observerPromise = expectObserverCalled("getUserMedia:request");
+ let promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(false, true, null, "screen");
+ await promise;
+ await observerPromise;
+ checkDeviceSelectors(["screen"]);
+ document
+ .getElementById("webRTC-selectWindow-menulist")
+ .getItemAtIndex(2)
+ .doCommand();
+
+ // Ensure that checking the 'Remember this decision' checkbox disables
+ // 'Allow'.
+ let notification = PopupNotifications.panel.firstElementChild;
+ ok(
+ notification.hasAttribute("warninghidden"),
+ "warning message is hidden"
+ );
+ let checkbox = notification.checkbox;
+ ok(!!checkbox, "checkbox is present");
+ ok(!checkbox.checked, "checkbox is not checked");
+ checkbox.click();
+ ok(checkbox.checked, "checkbox now checked");
+ ok(notification.button.disabled, "Allow button is disabled");
+ ok(
+ !notification.hasAttribute("warninghidden"),
+ "warning message is shown"
+ );
+
+ // Click "Don't Allow" to save a persistent block permission.
+ let observerPromise1 = expectObserverCalled("getUserMedia:response:deny");
+ let observerPromise2 = expectObserverCalled("recording-window-ended");
+ await promiseMessage(permissionError, () => {
+ activateSecondaryAction(kActionDeny);
+ });
+ await observerPromise1;
+ await observerPromise2;
+ await checkNotSharing();
+
+ let permission = SitePermissions.getForPrincipal(
+ browser.contentPrincipal,
+ "screen",
+ browser
+ );
+ is(permission.state, SitePermissions.BLOCK, "screen sharing is blocked");
+ is(
+ permission.scope,
+ SitePermissions.SCOPE_PERSISTENT,
+ "screen sharing is persistently blocked"
+ );
+
+ // Request screensharing again, expect an immediate failure.
+ await Promise.all([
+ expectObserverCalled("getUserMedia:request"),
+ expectObserverCalled("getUserMedia:response:deny"),
+ expectObserverCalled("recording-window-ended"),
+ promiseMessage(permissionError),
+ promiseRequestDevice(false, true, null, "screen"),
+ ]);
+
+ // Now set the permission to allow and expect a prompt.
+ SitePermissions.setForPrincipal(
+ browser.contentPrincipal,
+ "screen",
+ SitePermissions.ALLOW
+ );
+
+ // Request devices and expect a prompt despite the saved 'Allow' permission.
+ observerPromise = expectObserverCalled("getUserMedia:request");
+ promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(false, true, null, "screen");
+ await promise;
+ await observerPromise;
+
+ // The 'remember' checkbox shouldn't be checked anymore.
+ notification = PopupNotifications.panel.firstElementChild;
+ ok(
+ notification.hasAttribute("warninghidden"),
+ "warning message is hidden"
+ );
+ checkbox = notification.checkbox;
+ ok(!!checkbox, "checkbox is present");
+ ok(!checkbox.checked, "checkbox is not checked");
+
+ // Deny the request to cleanup...
+ observerPromise1 = expectObserverCalled("getUserMedia:response:deny");
+ observerPromise2 = expectObserverCalled("recording-window-ended");
+ await promiseMessage(permissionError, () => {
+ activateSecondaryAction(kActionDeny);
+ });
+ await observerPromise1;
+ await observerPromise2;
+ SitePermissions.removeFromPrincipal(
+ browser.contentPrincipal,
+ "screen",
+ browser
+ );
+ },
+ },
+
+ {
+ desc: "Switching between menu options maintains correct main action state while window sharing",
+ skipObserverVerification: true,
+ run: async function checkDoorhangerState() {
+ await enableObserverVerification();
+
+ let win = await BrowserTestUtils.openNewBrowserWindow();
+ await BrowserTestUtils.openNewForegroundTab(win.gBrowser, "about:newtab");
+ BrowserWindowTracker.orderedWindows[1].focus();
+
+ let observerPromise = expectObserverCalled("getUserMedia:request");
+ let promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(false, true, null, "window");
+ await promise;
+ await observerPromise;
+
+ let menulist = document.getElementById("webRTC-selectWindow-menulist");
+ let notification = PopupNotifications.panel.firstElementChild;
+ let checkbox = notification.checkbox;
+
+ menulist.getItemAtIndex(2).doCommand();
+ checkbox.click();
+ ok(checkbox.checked, "checkbox now checked");
+
+ if (ALLOW_SILENCING_NOTIFICATIONS) {
+ // When the notification silencing feature is enabled, the checkbox
+ // controls that feature, and its state should not disable the
+ // "Allow" button.
+ ok(!notification.button.disabled, "Allow button is not disabled");
+ } else {
+ ok(notification.button.disabled, "Allow button is disabled");
+ ok(
+ !notification.hasAttribute("warninghidden"),
+ "warning message is shown"
+ );
+ }
+
+ menulist.getItemAtIndex(3).doCommand();
+ ok(checkbox.checked, "checkbox still checked");
+ if (ALLOW_SILENCING_NOTIFICATIONS) {
+ // When the notification silencing feature is enabled, the checkbox
+ // controls that feature, and its state should not disable the
+ // "Allow" button.
+ ok(!notification.button.disabled, "Allow button remains not disabled");
+ } else {
+ ok(notification.button.disabled, "Allow button remains disabled");
+ ok(
+ !notification.hasAttribute("warninghidden"),
+ "warning message is still shown"
+ );
+ }
+
+ await disableObserverVerification();
+
+ observerPromise = expectObserverCalled("recording-window-ended");
+
+ gBrowser.removeCurrentTab();
+ win.close();
+
+ await observerPromise;
+
+ await openNewTestTab();
+ },
+ },
+ {
+ desc: "Switching between tabs does not bleed state into other prompts",
+ skipObserverVerification: true,
+ run: async function checkSwitchingTabs() {
+ // Open a new window in the background to have a choice in the menulist.
+ let win = await BrowserTestUtils.openNewBrowserWindow();
+ await BrowserTestUtils.openNewForegroundTab(win.gBrowser, "about:newtab");
+ await enableObserverVerification();
+ BrowserWindowTracker.orderedWindows[1].focus();
+
+ let observerPromise = expectObserverCalled("getUserMedia:request");
+ let promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(false, true, null, "window");
+ await promise;
+ await observerPromise;
+
+ let notification = PopupNotifications.panel.firstElementChild;
+ ok(notification.button.disabled, "Allow button is disabled");
+ await disableObserverVerification();
+
+ await openNewTestTab("get_user_media_in_xorigin_frame.html");
+ await enableObserverVerification();
+
+ observerPromise = expectObserverCalled("getUserMedia:request");
+ promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(true, true, "frame1");
+ await promise;
+ await observerPromise;
+
+ notification = PopupNotifications.panel.firstElementChild;
+ ok(!notification.button.disabled, "Allow button is not disabled");
+
+ await disableObserverVerification();
+
+ gBrowser.removeCurrentTab();
+ gBrowser.removeCurrentTab();
+ win.close();
+
+ await openNewTestTab();
+ },
+ },
+];
+
+add_task(async function test() {
+ await runTests(gTests);
+});
diff --git a/browser/base/content/test/webrtc/browser_devices_get_user_media_screen_tab_close.js b/browser/base/content/test/webrtc/browser_devices_get_user_media_screen_tab_close.js
new file mode 100644
index 0000000000..9b6cd2fd8e
--- /dev/null
+++ b/browser/base/content/test/webrtc/browser_devices_get_user_media_screen_tab_close.js
@@ -0,0 +1,73 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TEST_ROOT = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content/",
+ "https://example.com/"
+);
+const TEST_PAGE = TEST_ROOT + "get_user_media.html";
+
+/**
+ * Tests that the given tab is the currently selected tab.
+ * @param {Element} aTab - Tab to test.
+ */
+function testSelected(aTab) {
+ is(aTab, gBrowser.selectedTab, "Tab is gBrowser.selectedTab");
+ is(aTab.getAttribute("selected"), "true", "Tab has property 'selected'");
+ is(
+ aTab.getAttribute("visuallyselected"),
+ "true",
+ "Tab has property 'visuallyselected'"
+ );
+}
+
+/**
+ * Tests that when closing a tab with active screen sharing, the screen sharing
+ * ends and the tab closes properly.
+ */
+add_task(async function testScreenSharingTabClose() {
+ let initialTab = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "https://example.com"
+ );
+
+ // Open another foreground tab and ensure its selected.
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_PAGE);
+ testSelected(tab);
+
+ // Start screen sharing in active tab
+ await shareDevices(tab.linkedBrowser, false, false, SHARE_WINDOW);
+ ok(tab._sharingState.webRTC.screen, "Tab has webRTC screen sharing state");
+
+ let recordingEndedPromise = expectObserverCalled(
+ "recording-window-ended",
+ 1,
+ tab.linkedBrowser.browsingContext
+ );
+ let tabClosedPromise = BrowserTestUtils.waitForCondition(
+ () => gBrowser.selectedTab == initialTab,
+ "Waiting for tab to close"
+ );
+
+ // Close tab
+ BrowserTestUtils.removeTab(tab, { animate: true });
+
+ // Wait for screen sharing to end
+ await recordingEndedPromise;
+
+ // Wait for tab to be fully closed
+ await tabClosedPromise;
+
+ // Test that we're back to the initial tab.
+ testSelected(initialTab);
+
+ // There should be no active sharing for the selected tab.
+ ok(
+ !gBrowser.selectedTab._sharingState?.webRTC?.screen,
+ "Selected tab doesn't have webRTC screen sharing state"
+ );
+
+ BrowserTestUtils.removeTab(initialTab);
+});
diff --git a/browser/base/content/test/webrtc/browser_devices_get_user_media_tear_off_tab.js b/browser/base/content/test/webrtc/browser_devices_get_user_media_tear_off_tab.js
new file mode 100644
index 0000000000..ef69d15971
--- /dev/null
+++ b/browser/base/content/test/webrtc/browser_devices_get_user_media_tear_off_tab.js
@@ -0,0 +1,100 @@
+/* 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/. */
+
+var gTests = [
+ {
+ desc: "getUserMedia: tearing-off a tab keeps sharing indicators",
+ skipObserverVerification: true,
+ run: async function checkTearingOff() {
+ await enableObserverVerification();
+
+ let promise = promisePopupNotificationShown("webRTC-shareDevices");
+ let observerPromise = expectObserverCalled("getUserMedia:request");
+ await promiseRequestDevice(true, true);
+ await promise;
+ await observerPromise;
+ checkDeviceSelectors(["microphone", "camera"]);
+
+ let indicator = promiseIndicatorWindow();
+ let observerPromise1 = expectObserverCalled(
+ "getUserMedia:response:allow"
+ );
+ let observerPromise2 = expectObserverCalled("recording-device-events");
+ await promiseMessage("ok", () => {
+ PopupNotifications.panel.firstElementChild.button.click();
+ });
+ await observerPromise1;
+ await observerPromise2;
+ Assert.deepEqual(
+ await getMediaCaptureState(),
+ { audio: true, video: true },
+ "expected camera and microphone to be shared"
+ );
+
+ await indicator;
+ await checkSharingUI({ video: true, audio: true });
+
+ // Don't listen to observer notifications in the tab any more, as
+ // they will need to be switched to the new window.
+ await disableObserverVerification();
+
+ info("tearing off the tab");
+ let win = gBrowser.replaceTabWithWindow(gBrowser.selectedTab);
+ await whenDelayedStartupFinished(win);
+ await checkSharingUI({ audio: true, video: true }, win);
+
+ await enableObserverVerification(win.gBrowser.selectedBrowser);
+
+ // Clicking the global sharing indicator should open the control center in
+ // the second window.
+ ok(permissionPopupHidden(win), "control center should be hidden");
+ let activeStreams = webrtcUI.getActiveStreams(true, false, false);
+ webrtcUI.showSharingDoorhanger(activeStreams[0]);
+ // If the popup gets hidden before being shown, by stray focus/activate
+ // events, don't bother failing the test. It's enough to know that we
+ // started showing the popup.
+ let popup = win.gPermissionPanel._permissionPopup;
+ let hiddenEvent = BrowserTestUtils.waitForEvent(popup, "popuphidden");
+ let shownEvent = BrowserTestUtils.waitForEvent(popup, "popupshown");
+ let ev = await Promise.race([hiddenEvent, shownEvent]);
+ ok(ev.type, "Tried to show popup");
+ win.gPermissionPanel._permissionPopup.hidePopup();
+
+ ok(
+ permissionPopupHidden(window),
+ "control center should be hidden in the first window"
+ );
+
+ await disableObserverVerification();
+
+ // Closing the new window should remove all sharing indicators.
+ let promises = [
+ expectObserverCalledOnClose(
+ "recording-device-events",
+ 1,
+ win.gBrowser.selectedBrowser
+ ),
+ expectObserverCalledOnClose(
+ "recording-window-ended",
+ 1,
+ win.gBrowser.selectedBrowser
+ ),
+ ];
+
+ await BrowserTestUtils.closeWindow(win);
+ await Promise.all(promises);
+ await checkNotSharing();
+ },
+ },
+];
+
+add_task(async function test() {
+ await SpecialPowers.pushPrefEnv({ set: [["dom.ipc.processCount", 1]] });
+
+ // An empty tab where we can load the content script without leaving it
+ // behind at the end of the test.
+ BrowserTestUtils.addTab(gBrowser);
+
+ await runTests(gTests);
+});
diff --git a/browser/base/content/test/webrtc/browser_devices_get_user_media_unprompted_access.js b/browser/base/content/test/webrtc/browser_devices_get_user_media_unprompted_access.js
new file mode 100644
index 0000000000..e3276cebc4
--- /dev/null
+++ b/browser/base/content/test/webrtc/browser_devices_get_user_media_unprompted_access.js
@@ -0,0 +1,666 @@
+/* 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/. */
+
+const permissionError =
+ "error: NotAllowedError: The request is not allowed " +
+ "by the user agent or the platform in the current context.";
+
+var gTests = [
+ {
+ desc: "getUserMedia audio+camera",
+ run: async function checkAudioVideoWhileLiveTracksExist_audio_camera() {
+ let promise = promisePopupNotificationShown("webRTC-shareDevices");
+ let observerPromise = expectObserverCalled("getUserMedia:request");
+ await promiseRequestDevice(true, true);
+ await promise;
+ await observerPromise;
+ let indicator = promiseIndicatorWindow();
+
+ let observerPromise1 = expectObserverCalled(
+ "getUserMedia:response:allow"
+ );
+ let observerPromise2 = expectObserverCalled("recording-device-events");
+
+ await promiseMessage("ok", () => {
+ PopupNotifications.panel.firstElementChild.button.click();
+ });
+
+ await observerPromise1;
+ await observerPromise2;
+ Assert.deepEqual(
+ await getMediaCaptureState(),
+ { audio: true, video: true },
+ "expected camera and microphone to be shared"
+ );
+ await indicator;
+ await checkSharingUI({ audio: true, video: true });
+
+ // If there's an active audio+camera stream,
+ // gUM(audio+camera) returns a stream without prompting;
+ let observerPromises = [
+ expectObserverCalled("getUserMedia:request"),
+ expectObserverCalled("getUserMedia:response:allow"),
+ expectObserverCalled("recording-device-events"),
+ ];
+
+ promise = promiseMessage("ok");
+
+ await promiseRequestDevice(true, true);
+ await promise;
+ await Promise.all(observerPromises);
+
+ await promiseNoPopupNotification("webRTC-shareDevices");
+
+ Assert.deepEqual(
+ await getMediaCaptureState(),
+ { audio: true, video: true },
+ "expected camera and microphone to be shared"
+ );
+
+ await checkSharingUI({ audio: true, video: true });
+
+ // gUM(screen) causes a prompt.
+ observerPromise = expectObserverCalled("getUserMedia:request");
+ promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(false, true, null, "screen");
+ await promise;
+ await observerPromise;
+
+ is(
+ PopupNotifications.getNotification("webRTC-shareDevices").anchorID,
+ "webRTC-shareScreen-notification-icon",
+ "anchored to device icon"
+ );
+ checkDeviceSelectors(["screen"]);
+
+ observerPromise = expectObserverCalled("getUserMedia:response:deny");
+ await promiseMessage(permissionError, () => {
+ activateSecondaryAction(kActionDeny);
+ });
+ await observerPromise;
+
+ // Revoke screen block (only). Don't over-revoke ahead of remaining steps.
+ SitePermissions.removeFromPrincipal(
+ null,
+ "screen",
+ gBrowser.selectedBrowser
+ );
+
+ // After closing all streams, gUM(audio+camera) causes a prompt.
+ await closeStream();
+ observerPromise = expectObserverCalled("getUserMedia:request");
+ promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(true, true);
+ await promise;
+ await observerPromise;
+ checkDeviceSelectors(["microphone", "camera"]);
+
+ observerPromise1 = expectObserverCalled("getUserMedia:response:deny");
+ observerPromise2 = expectObserverCalled("recording-window-ended");
+
+ await promiseMessage(permissionError, () => {
+ activateSecondaryAction(kActionDeny);
+ });
+
+ await observerPromise1;
+ await observerPromise2;
+
+ await checkNotSharing();
+ SitePermissions.removeFromPrincipal(
+ null,
+ "screen",
+ gBrowser.selectedBrowser
+ );
+ SitePermissions.removeFromPrincipal(
+ null,
+ "camera",
+ gBrowser.selectedBrowser
+ );
+ SitePermissions.removeFromPrincipal(
+ null,
+ "microphone",
+ gBrowser.selectedBrowser
+ );
+ },
+ },
+
+ {
+ desc: "getUserMedia audio+camera -camera",
+ run: async function checkAudioVideoWhileLiveTracksExist_audio_nocamera() {
+ // State: fresh
+
+ {
+ const popupShown = promisePopupNotificationShown("webRTC-shareDevices");
+ const request = expectObserverCalled("getUserMedia:request");
+ await promiseRequestDevice(true, true);
+ await popupShown;
+ await request;
+ const indicator = promiseIndicatorWindow();
+ const response = expectObserverCalled("getUserMedia:response:allow");
+ const deviceEvents = expectObserverCalled("recording-device-events");
+ await promiseMessage("ok", () => {
+ PopupNotifications.panel.firstElementChild.button.click();
+ });
+ await response;
+ await deviceEvents;
+ Assert.deepEqual(
+ await getMediaCaptureState(),
+ { audio: true, video: true },
+ "expected camera and microphone to be shared"
+ );
+ await indicator;
+ await checkSharingUI({ audio: true, video: true });
+
+ // Stop the camera track.
+ await stopTracks("video");
+ await checkSharingUI({ audio: true, video: false });
+ }
+
+ // State: live audio
+
+ {
+ // If there's an active audio track from an audio+camera request,
+ // gUM(camera) causes a prompt.
+ const request = expectObserverCalled("getUserMedia:request");
+ const popupShown = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(false, true);
+ await popupShown;
+ await request;
+ checkDeviceSelectors(["camera"]);
+
+ // Allow and stop the camera again.
+ const response = expectObserverCalled("getUserMedia:response:allow");
+ const deviceEvents = expectObserverCalled("recording-device-events");
+ await promiseMessage("ok", () => {
+ PopupNotifications.panel.firstElementChild.button.click();
+ });
+ await response;
+ await deviceEvents;
+ Assert.deepEqual(
+ await getMediaCaptureState(),
+ { audio: true, video: true },
+ "expected camera and microphone to be shared"
+ );
+ await checkSharingUI({ audio: true, video: true });
+
+ await stopTracks("video");
+ await checkSharingUI({ audio: true, video: false });
+ }
+
+ // State: live audio
+
+ {
+ // If there's an active audio track from an audio+camera request,
+ // gUM(audio+camera) causes a prompt.
+ const request = expectObserverCalled("getUserMedia:request");
+ const popupShown = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(true, true);
+ await popupShown;
+ await request;
+ checkDeviceSelectors(["microphone", "camera"]);
+
+ // Allow and stop the camera again.
+ const response = expectObserverCalled("getUserMedia:response:allow");
+ const deviceEvents = expectObserverCalled("recording-device-events");
+ await promiseMessage("ok", () => {
+ PopupNotifications.panel.firstElementChild.button.click();
+ });
+ await response;
+ await deviceEvents;
+ Assert.deepEqual(
+ await getMediaCaptureState(),
+ { audio: true, video: true },
+ "expected camera and microphone to be shared"
+ );
+ await checkSharingUI({ audio: true, video: true });
+
+ await stopTracks("video");
+ await checkSharingUI({ audio: true, video: false });
+ }
+
+ // State: live audio
+
+ {
+ // After closing all streams, gUM(audio) causes a prompt.
+ await closeStream();
+ const request = expectObserverCalled("getUserMedia:request");
+ const popupShown = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(true, false);
+ await popupShown;
+ await request;
+ checkDeviceSelectors(["microphone"]);
+
+ const response = expectObserverCalled("getUserMedia:response:deny");
+ const windowEnded = expectObserverCalled("recording-window-ended");
+
+ await promiseMessage(permissionError, () => {
+ activateSecondaryAction(kActionDeny);
+ });
+
+ await response;
+ await windowEnded;
+
+ await checkNotSharing();
+ SitePermissions.removeFromPrincipal(
+ null,
+ "camera",
+ gBrowser.selectedBrowser
+ );
+ SitePermissions.removeFromPrincipal(
+ null,
+ "microphone",
+ gBrowser.selectedBrowser
+ );
+ }
+ },
+ },
+
+ {
+ desc: "getUserMedia audio+camera -audio",
+ run: async function checkAudioVideoWhileLiveTracksExist_camera_noaudio() {
+ // State: fresh
+
+ {
+ const popupShown = promisePopupNotificationShown("webRTC-shareDevices");
+ const request = expectObserverCalled("getUserMedia:request");
+ await promiseRequestDevice(true, true);
+ await popupShown;
+ await request;
+ const indicator = promiseIndicatorWindow();
+ const response = expectObserverCalled("getUserMedia:response:allow");
+ const deviceEvents = expectObserverCalled("recording-device-events");
+ await promiseMessage("ok", () => {
+ PopupNotifications.panel.firstElementChild.button.click();
+ });
+ await response;
+ await deviceEvents;
+ Assert.deepEqual(
+ await getMediaCaptureState(),
+ { audio: true, video: true },
+ "expected camera and microphone to be shared"
+ );
+ await indicator;
+ await checkSharingUI({ audio: true, video: true });
+
+ // Stop the audio track.
+ await stopTracks("audio");
+ await checkSharingUI({ audio: false, video: true });
+ }
+
+ // State: live camera
+
+ {
+ // If there's an active video track from an audio+camera request,
+ // gUM(audio) causes a prompt.
+ const request = expectObserverCalled("getUserMedia:request");
+ const popupShown = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(true, false);
+ await popupShown;
+ await request;
+ checkDeviceSelectors(["microphone"]);
+
+ // Allow and stop the microphone again.
+ const response = expectObserverCalled("getUserMedia:response:allow");
+ const deviceEvents = expectObserverCalled("recording-device-events");
+ await promiseMessage("ok", () => {
+ PopupNotifications.panel.firstElementChild.button.click();
+ });
+ await response;
+ await deviceEvents;
+ Assert.deepEqual(
+ await getMediaCaptureState(),
+ { audio: true, video: true },
+ "expected camera and microphone to be shared"
+ );
+ await checkSharingUI({ audio: true, video: true });
+
+ await stopTracks("audio");
+ await checkSharingUI({ audio: false, video: true });
+ }
+
+ // State: live camera
+
+ {
+ // If there's an active video track from an audio+camera request,
+ // gUM(audio+camera) causes a prompt.
+ const request = expectObserverCalled("getUserMedia:request");
+ const popupShown = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(true, true);
+ await popupShown;
+ await request;
+ checkDeviceSelectors(["microphone", "camera"]);
+
+ // Allow and stop the microphone again.
+ const response = expectObserverCalled("getUserMedia:response:allow");
+ const deviceEvents = expectObserverCalled("recording-device-events");
+ await promiseMessage("ok", () => {
+ PopupNotifications.panel.firstElementChild.button.click();
+ });
+ await response;
+ await deviceEvents;
+ Assert.deepEqual(
+ await getMediaCaptureState(),
+ { audio: true, video: true },
+ "expected camera and microphone to be shared"
+ );
+ await checkSharingUI({ audio: true, video: true });
+
+ await stopTracks("audio");
+ await checkSharingUI({ audio: false, video: true });
+ }
+
+ // State: live camera
+
+ {
+ // After closing all streams, gUM(camera) causes a prompt.
+ await closeStream();
+ const request = expectObserverCalled("getUserMedia:request");
+ const popupShown = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(false, true);
+ await popupShown;
+ await request;
+ checkDeviceSelectors(["camera"]);
+
+ const response = expectObserverCalled("getUserMedia:response:deny");
+ const windowEnded = expectObserverCalled("recording-window-ended");
+
+ await promiseMessage(permissionError, () => {
+ activateSecondaryAction(kActionDeny);
+ });
+
+ await response;
+ await windowEnded;
+
+ await checkNotSharing();
+ SitePermissions.removeFromPrincipal(
+ null,
+ "camera",
+ gBrowser.selectedBrowser
+ );
+ SitePermissions.removeFromPrincipal(
+ null,
+ "microphone",
+ gBrowser.selectedBrowser
+ );
+ }
+ },
+ },
+
+ {
+ desc: "getUserMedia camera",
+ run: async function checkAudioVideoWhileLiveTracksExist_camera() {
+ let observerPromise = expectObserverCalled("getUserMedia:request");
+ let promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(false, true);
+ await promise;
+ await observerPromise;
+ let indicator = promiseIndicatorWindow();
+
+ let observerPromise1 = expectObserverCalled(
+ "getUserMedia:response:allow"
+ );
+ let observerPromise2 = expectObserverCalled("recording-device-events");
+
+ await promiseMessage("ok", () => {
+ PopupNotifications.panel.firstElementChild.button.click();
+ });
+
+ await observerPromise1;
+ await observerPromise2;
+ Assert.deepEqual(
+ await getMediaCaptureState(),
+ { video: true },
+ "expected camera to be shared"
+ );
+ await indicator;
+ await checkSharingUI({ audio: false, video: true });
+
+ // If there's an active camera stream,
+ // gUM(audio) causes a prompt;
+ observerPromise = expectObserverCalled("getUserMedia:request");
+ promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(true, false);
+ await promise;
+ await observerPromise;
+ checkDeviceSelectors(["microphone"]);
+
+ observerPromise = expectObserverCalled("getUserMedia:response:deny");
+
+ await promiseMessage(permissionError, () => {
+ activateSecondaryAction(kActionDeny);
+ });
+
+ await observerPromise;
+ SitePermissions.removeFromPrincipal(
+ null,
+ "screen",
+ gBrowser.selectedBrowser
+ );
+ SitePermissions.removeFromPrincipal(
+ null,
+ "camera",
+ gBrowser.selectedBrowser
+ );
+ SitePermissions.removeFromPrincipal(
+ null,
+ "microphone",
+ gBrowser.selectedBrowser
+ );
+
+ // gUM(audio+camera) causes a prompt;
+ observerPromise = expectObserverCalled("getUserMedia:request");
+ promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(true, true);
+ await promise;
+ await observerPromise;
+ checkDeviceSelectors(["microphone", "camera"]);
+
+ observerPromise = expectObserverCalled("getUserMedia:response:deny");
+
+ await promiseMessage(permissionError, () => {
+ activateSecondaryAction(kActionDeny);
+ });
+
+ await observerPromise;
+ SitePermissions.removeFromPrincipal(
+ null,
+ "screen",
+ gBrowser.selectedBrowser
+ );
+ SitePermissions.removeFromPrincipal(
+ null,
+ "camera",
+ gBrowser.selectedBrowser
+ );
+ SitePermissions.removeFromPrincipal(
+ null,
+ "microphone",
+ gBrowser.selectedBrowser
+ );
+
+ // gUM(screen) causes a prompt;
+ observerPromise = expectObserverCalled("getUserMedia:request");
+ promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(false, true, null, "screen");
+ await promise;
+ await observerPromise;
+
+ is(
+ PopupNotifications.getNotification("webRTC-shareDevices").anchorID,
+ "webRTC-shareScreen-notification-icon",
+ "anchored to device icon"
+ );
+ checkDeviceSelectors(["screen"]);
+
+ observerPromise = expectObserverCalled("getUserMedia:response:deny");
+
+ await promiseMessage(permissionError, () => {
+ activateSecondaryAction(kActionDeny);
+ });
+
+ await observerPromise;
+ SitePermissions.removeFromPrincipal(
+ null,
+ "screen",
+ gBrowser.selectedBrowser
+ );
+ SitePermissions.removeFromPrincipal(
+ null,
+ "camera",
+ gBrowser.selectedBrowser
+ );
+ SitePermissions.removeFromPrincipal(
+ null,
+ "microphone",
+ gBrowser.selectedBrowser
+ );
+
+ // gUM(camera) returns a stream without prompting.
+ let observerPromises = [
+ expectObserverCalled("getUserMedia:request"),
+ expectObserverCalled("getUserMedia:response:allow"),
+ expectObserverCalled("recording-device-events"),
+ ];
+
+ promise = promiseMessage("ok");
+ await promiseRequestDevice(false, true);
+ await promise;
+ await Promise.all(observerPromises);
+
+ await promiseNoPopupNotification("webRTC-shareDevices");
+
+ Assert.deepEqual(
+ await getMediaCaptureState(),
+ { video: true },
+ "expected camera to be shared"
+ );
+
+ await checkSharingUI({ audio: false, video: true });
+
+ // close all streams
+ await closeStream();
+ },
+ },
+
+ {
+ desc: "getUserMedia audio",
+ run: async function checkAudioVideoWhileLiveTracksExist_audio() {
+ let observerPromise = expectObserverCalled("getUserMedia:request");
+ let promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(true, false);
+ await promise;
+ await observerPromise;
+ let indicator = promiseIndicatorWindow();
+
+ let observerPromise1 = expectObserverCalled(
+ "getUserMedia:response:allow"
+ );
+ let observerPromise2 = expectObserverCalled("recording-device-events");
+
+ await promiseMessage("ok", () => {
+ PopupNotifications.panel.firstElementChild.button.click();
+ });
+
+ await observerPromise1;
+ await observerPromise2;
+ Assert.deepEqual(
+ await getMediaCaptureState(),
+ { audio: true },
+ "expected microphone to be shared"
+ );
+ await indicator;
+ await checkSharingUI({ audio: true, video: false });
+
+ // If there's an active audio stream,
+ // gUM(camera) causes a prompt;
+ observerPromise = expectObserverCalled("getUserMedia:request");
+ promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(false, true);
+ await promise;
+ await observerPromise;
+ checkDeviceSelectors(["camera"]);
+
+ observerPromise = expectObserverCalled("getUserMedia:response:deny");
+
+ await promiseMessage(permissionError, () => {
+ activateSecondaryAction(kActionDeny);
+ });
+
+ await observerPromise;
+ SitePermissions.removeFromPrincipal(
+ null,
+ "screen",
+ gBrowser.selectedBrowser
+ );
+ SitePermissions.removeFromPrincipal(
+ null,
+ "camera",
+ gBrowser.selectedBrowser
+ );
+ SitePermissions.removeFromPrincipal(
+ null,
+ "microphone",
+ gBrowser.selectedBrowser
+ );
+
+ // gUM(audio+camera) causes a prompt;
+ observerPromise = expectObserverCalled("getUserMedia:request");
+ promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(true, true);
+ await promise;
+ await observerPromise;
+ checkDeviceSelectors(["microphone", "camera"]);
+
+ observerPromise = expectObserverCalled("getUserMedia:response:deny");
+
+ await promiseMessage(permissionError, () => {
+ activateSecondaryAction(kActionDeny);
+ });
+
+ await observerPromise;
+ SitePermissions.removeFromPrincipal(
+ null,
+ "screen",
+ gBrowser.selectedBrowser
+ );
+ SitePermissions.removeFromPrincipal(
+ null,
+ "camera",
+ gBrowser.selectedBrowser
+ );
+ SitePermissions.removeFromPrincipal(
+ null,
+ "microphone",
+ gBrowser.selectedBrowser
+ );
+
+ // gUM(audio) returns a stream without prompting.
+ let observerPromises = [
+ expectObserverCalled("getUserMedia:request"),
+ expectObserverCalled("getUserMedia:response:allow"),
+ expectObserverCalled("recording-device-events"),
+ ];
+ promise = promiseMessage("ok");
+ await promiseRequestDevice(true, false);
+ await promise;
+ await observerPromises;
+ await promiseNoPopupNotification("webRTC-shareDevices");
+
+ Assert.deepEqual(
+ await getMediaCaptureState(),
+ { audio: true },
+ "expected microphone to be shared"
+ );
+
+ await checkSharingUI({ audio: true, video: false });
+
+ // close all streams
+ await closeStream();
+ },
+ },
+];
+
+add_task(async function test() {
+ await runTests(gTests);
+});
diff --git a/browser/base/content/test/webrtc/browser_devices_get_user_media_unprompted_access_in_frame.js b/browser/base/content/test/webrtc/browser_devices_get_user_media_unprompted_access_in_frame.js
new file mode 100644
index 0000000000..15329ec666
--- /dev/null
+++ b/browser/base/content/test/webrtc/browser_devices_get_user_media_unprompted_access_in_frame.js
@@ -0,0 +1,309 @@
+/* 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/. */
+
+const permissionError =
+ "error: NotAllowedError: The request is not allowed " +
+ "by the user agent or the platform in the current context.";
+
+var gTests = [
+ {
+ desc: "getUserMedia audio+camera in frame 1",
+ run: async function checkAudioVideoWhileLiveTracksExist_frame() {
+ let observerPromise = expectObserverCalled("getUserMedia:request");
+ let promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(true, true, "frame1");
+ await promise;
+ await observerPromise;
+ checkDeviceSelectors(["microphone", "camera"]);
+
+ let indicator = promiseIndicatorWindow();
+ let observerPromise1 = expectObserverCalled(
+ "getUserMedia:response:allow"
+ );
+ let observerPromise2 = expectObserverCalled("recording-device-events");
+ await promiseMessage("ok", () => {
+ PopupNotifications.panel.firstElementChild.button.click();
+ });
+ await observerPromise1;
+ await observerPromise2;
+ Assert.deepEqual(
+ await getMediaCaptureState(),
+ { audio: true, video: true },
+ "expected camera and microphone to be shared"
+ );
+
+ await indicator;
+ await checkSharingUI({ video: true, audio: true });
+
+ info("gUM(audio+camera) in frame 2 should prompt");
+ observerPromise = expectObserverCalled("getUserMedia:request");
+ promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(true, true, "frame2");
+ await promise;
+ await observerPromise;
+ checkDeviceSelectors(["microphone", "camera"]);
+
+ observerPromise1 = expectObserverCalled("getUserMedia:response:deny");
+ observerPromise2 = expectObserverCalled("recording-window-ended");
+
+ await promiseMessage(permissionError, () => {
+ activateSecondaryAction(kActionDeny);
+ });
+
+ await observerPromise1;
+ await observerPromise2;
+
+ SitePermissions.removeFromPrincipal(
+ null,
+ "screen",
+ gBrowser.selectedBrowser
+ );
+ SitePermissions.removeFromPrincipal(
+ null,
+ "camera",
+ gBrowser.selectedBrowser
+ );
+ SitePermissions.removeFromPrincipal(
+ null,
+ "microphone",
+ gBrowser.selectedBrowser
+ );
+
+ // If there's an active audio+camera stream in frame 1,
+ // gUM(audio+camera) in frame 1 returns a stream without prompting;
+ let observerPromises = [
+ expectObserverCalled("getUserMedia:request"),
+ expectObserverCalled("getUserMedia:response:allow"),
+ expectObserverCalled("recording-device-events"),
+ ];
+ promise = promiseMessage("ok");
+ await promiseRequestDevice(true, true, "frame1");
+ await promise;
+ await observerPromises;
+ await promiseNoPopupNotification("webRTC-shareDevices");
+
+ // close the stream
+ await closeStream(false, "frame1");
+ },
+ },
+
+ {
+ desc: "getUserMedia audio+camera in frame 1 - part II",
+ run: async function checkAudioVideoWhileLiveTracksExist_frame_partII() {
+ let observerPromise = expectObserverCalled("getUserMedia:request");
+ let promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(true, true, "frame1");
+ await promise;
+ await observerPromise;
+ checkDeviceSelectors(["microphone", "camera"]);
+
+ let indicator = promiseIndicatorWindow();
+ let observerPromise1 = expectObserverCalled(
+ "getUserMedia:response:allow"
+ );
+ let observerPromise2 = expectObserverCalled("recording-device-events");
+ await promiseMessage("ok", () => {
+ PopupNotifications.panel.firstElementChild.button.click();
+ });
+ await observerPromise1;
+ await observerPromise2;
+ Assert.deepEqual(
+ await getMediaCaptureState(),
+ { audio: true, video: true },
+ "expected camera and microphone to be shared"
+ );
+
+ await indicator;
+ await checkSharingUI({ video: true, audio: true });
+
+ // If there's an active audio+camera stream in frame 1,
+ // gUM(audio+camera) in the top level window causes a prompt;
+ observerPromise = expectObserverCalled("getUserMedia:request");
+ promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(true, true);
+ await promise;
+ await observerPromise;
+ checkDeviceSelectors(["microphone", "camera"]);
+
+ observerPromise1 = expectObserverCalled("getUserMedia:response:deny");
+ observerPromise2 = expectObserverCalled("recording-window-ended");
+
+ await promiseMessage(permissionError, () => {
+ activateSecondaryAction(kActionDeny);
+ });
+
+ await observerPromise1;
+ await observerPromise2;
+
+ // close the stream
+ await closeStream(false, "frame1");
+ SitePermissions.removeFromPrincipal(
+ null,
+ "screen",
+ gBrowser.selectedBrowser
+ );
+ SitePermissions.removeFromPrincipal(
+ null,
+ "camera",
+ gBrowser.selectedBrowser
+ );
+ SitePermissions.removeFromPrincipal(
+ null,
+ "microphone",
+ gBrowser.selectedBrowser
+ );
+ },
+ },
+
+ {
+ desc: "getUserMedia audio+camera in frame 1 - reload",
+ run: async function checkAudioVideoWhileLiveTracksExist_frame_reload() {
+ let observerPromise = expectObserverCalled("getUserMedia:request");
+ let promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(true, true, "frame1");
+ await promise;
+ await observerPromise;
+ checkDeviceSelectors(["microphone", "camera"]);
+
+ let indicator = promiseIndicatorWindow();
+ let observerPromise1 = expectObserverCalled(
+ "getUserMedia:response:allow"
+ );
+ let observerPromise2 = expectObserverCalled("recording-device-events");
+ await promiseMessage("ok", () => {
+ PopupNotifications.panel.firstElementChild.button.click();
+ });
+ await observerPromise1;
+ await observerPromise2;
+ Assert.deepEqual(
+ await getMediaCaptureState(),
+ { audio: true, video: true },
+ "expected camera and microphone to be shared"
+ );
+
+ await indicator;
+ await checkSharingUI({ video: true, audio: true });
+
+ // reload frame 1
+ let observerPromises = [
+ expectObserverCalled("recording-device-stopped"),
+ expectObserverCalled("recording-device-events"),
+ expectObserverCalled("recording-window-ended"),
+ ];
+ await promiseReloadFrame("frame1");
+
+ await Promise.all(observerPromises);
+ await checkNotSharing();
+
+ // After the reload,
+ // gUM(audio+camera) in frame 1 causes a prompt.
+ observerPromise = expectObserverCalled("getUserMedia:request");
+ promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(true, true, "frame1");
+ await promise;
+ await observerPromise;
+ checkDeviceSelectors(["microphone", "camera"]);
+
+ observerPromise1 = expectObserverCalled("getUserMedia:response:deny");
+ observerPromise2 = expectObserverCalled("recording-window-ended");
+
+ await promiseMessage(permissionError, () => {
+ activateSecondaryAction(kActionDeny);
+ });
+
+ await observerPromise1;
+ await observerPromise2;
+ SitePermissions.removeFromPrincipal(
+ null,
+ "screen",
+ gBrowser.selectedBrowser
+ );
+ SitePermissions.removeFromPrincipal(
+ null,
+ "camera",
+ gBrowser.selectedBrowser
+ );
+ SitePermissions.removeFromPrincipal(
+ null,
+ "microphone",
+ gBrowser.selectedBrowser
+ );
+ },
+ },
+
+ {
+ desc: "getUserMedia audio+camera at the top level window",
+ run: async function checkAudioVideoWhileLiveTracksExist_topLevel() {
+ // create an active audio+camera stream at the top level window
+ let observerPromise = expectObserverCalled("getUserMedia:request");
+ let promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(true, true);
+ await promise;
+ await observerPromise;
+ checkDeviceSelectors(["microphone", "camera"]);
+ let indicator = promiseIndicatorWindow();
+
+ let observerPromise1 = expectObserverCalled(
+ "getUserMedia:response:allow"
+ );
+ let observerPromise2 = expectObserverCalled("recording-device-events");
+
+ await promiseMessage("ok", () => {
+ PopupNotifications.panel.firstElementChild.button.click();
+ });
+
+ await observerPromise1;
+ await observerPromise2;
+ Assert.deepEqual(
+ await getMediaCaptureState(),
+ { audio: true, video: true },
+ "expected camera and microphone to be shared"
+ );
+
+ await indicator;
+ await checkSharingUI({ audio: true, video: true });
+
+ // If there's an active audio+camera stream at the top level window,
+ // gUM(audio+camera) in frame 2 causes a prompt.
+ observerPromise = expectObserverCalled("getUserMedia:request");
+ promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(true, true, "frame2");
+ await promise;
+ await observerPromise;
+ checkDeviceSelectors(["microphone", "camera"]);
+
+ observerPromise1 = expectObserverCalled("getUserMedia:response:deny");
+ observerPromise2 = expectObserverCalled("recording-window-ended");
+
+ await promiseMessage(permissionError, () => {
+ activateSecondaryAction(kActionDeny);
+ });
+
+ await observerPromise1;
+ await observerPromise2;
+
+ // close the stream
+ await closeStream();
+ SitePermissions.removeFromPrincipal(
+ null,
+ "screen",
+ gBrowser.selectedBrowser
+ );
+ SitePermissions.removeFromPrincipal(
+ null,
+ "camera",
+ gBrowser.selectedBrowser
+ );
+ SitePermissions.removeFromPrincipal(
+ null,
+ "microphone",
+ gBrowser.selectedBrowser
+ );
+ },
+ },
+];
+
+add_task(async function test() {
+ await runTests(gTests, { relativeURI: "get_user_media_in_frame.html" });
+});
diff --git a/browser/base/content/test/webrtc/browser_devices_get_user_media_unprompted_access_queue_request.js b/browser/base/content/test/webrtc/browser_devices_get_user_media_unprompted_access_queue_request.js
new file mode 100644
index 0000000000..ed270967d5
--- /dev/null
+++ b/browser/base/content/test/webrtc/browser_devices_get_user_media_unprompted_access_queue_request.js
@@ -0,0 +1,47 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/ */
+
+var gTests = [
+ {
+ desc: "test queueing allow video behind allow video",
+ run: async function testQueuingAllowVideoBehindAllowVideo() {
+ let observerPromise = expectObserverCalled("getUserMedia:request");
+ let promise = promisePopupNotificationShown("webRTC-shareDevices");
+ await promiseRequestDevice(false, true);
+ await promiseRequestDevice(false, true);
+ await promise;
+ checkDeviceSelectors(["camera"]);
+ await observerPromise;
+
+ let promises = [
+ expectObserverCalled("getUserMedia:request"),
+ expectObserverCalled("getUserMedia:response:allow", 2),
+ expectObserverCalled("recording-device-events", 2),
+ ];
+
+ await promiseMessage(
+ "ok",
+ () => {
+ PopupNotifications.panel.firstElementChild.button.click();
+ },
+ 2
+ );
+ await Promise.all(promises);
+
+ await promiseNoPopupNotification("webRTC-shareDevices");
+ Assert.deepEqual(
+ await getMediaCaptureState(),
+ { video: true },
+ "expected camera to be shared"
+ );
+
+ // close all streams
+ await closeStream();
+ },
+ },
+];
+
+add_task(async function test() {
+ SimpleTest.requestCompleteLog();
+ await runTests(gTests);
+});
diff --git a/browser/base/content/test/webrtc/browser_devices_get_user_media_unprompted_access_tear_off_tab.js b/browser/base/content/test/webrtc/browser_devices_get_user_media_unprompted_access_tear_off_tab.js
new file mode 100644
index 0000000000..4a48a93853
--- /dev/null
+++ b/browser/base/content/test/webrtc/browser_devices_get_user_media_unprompted_access_tear_off_tab.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/. */
+
+var gTests = [
+ {
+ desc: "getUserMedia: tearing-off a tab",
+ skipObserverVerification: true,
+ run: async function checkAudioVideoWhileLiveTracksExist_TearingOff() {
+ await enableObserverVerification();
+
+ let promise = promisePopupNotificationShown("webRTC-shareDevices");
+ let observerPromise = expectObserverCalled("getUserMedia:request");
+ await promiseRequestDevice(true, true);
+ await promise;
+ await observerPromise;
+ checkDeviceSelectors(["microphone", "camera"]);
+
+ let indicator = promiseIndicatorWindow();
+ let observerPromise1 = expectObserverCalled(
+ "getUserMedia:response:allow"
+ );
+ let observerPromise2 = expectObserverCalled("recording-device-events");
+ await promiseMessage("ok", () => {
+ PopupNotifications.panel.firstElementChild.button.click();
+ });
+ await observerPromise1;
+ await observerPromise2;
+ Assert.deepEqual(
+ await getMediaCaptureState(),
+ { audio: true, video: true },
+ "expected camera and microphone to be shared"
+ );
+
+ await indicator;
+ await checkSharingUI({ video: true, audio: true });
+
+ // Don't listen to observer notifications in the tab any more, as
+ // they will need to be switched to the new window.
+ await disableObserverVerification();
+
+ info("tearing off the tab");
+ let win = gBrowser.replaceTabWithWindow(gBrowser.selectedTab);
+ await whenDelayedStartupFinished(win);
+ await SimpleTest.promiseFocus(win);
+ await checkSharingUI({ audio: true, video: true }, win);
+
+ await enableObserverVerification(win.gBrowser.selectedBrowser);
+
+ info("request audio+video and check if there is no prompt");
+ let observerPromises = [
+ expectObserverCalled(
+ "getUserMedia:request",
+ 1,
+ win.gBrowser.selectedBrowser
+ ),
+ expectObserverCalled(
+ "getUserMedia:response:allow",
+ 1,
+ win.gBrowser.selectedBrowser
+ ),
+ expectObserverCalled(
+ "recording-device-events",
+ 1,
+ win.gBrowser.selectedBrowser
+ ),
+ ];
+ await promiseRequestDevice(
+ true,
+ true,
+ null,
+ null,
+ win.gBrowser.selectedBrowser
+ );
+ await Promise.all(observerPromises);
+
+ await disableObserverVerification();
+
+ observerPromises = [
+ expectObserverCalledOnClose(
+ "recording-device-events",
+ 1,
+ win.gBrowser.selectedBrowser
+ ),
+ expectObserverCalledOnClose(
+ "recording-window-ended",
+ 1,
+ win.gBrowser.selectedBrowser
+ ),
+ ];
+
+ await BrowserTestUtils.closeWindow(win);
+ await Promise.all(observerPromises);
+
+ await checkNotSharing();
+ },
+ },
+];
+
+add_task(async function test() {
+ await SpecialPowers.pushPrefEnv({ set: [["dom.ipc.processCount", 1]] });
+
+ // An empty tab where we can load the content script without leaving it
+ // behind at the end of the test.
+ BrowserTestUtils.addTab(gBrowser);
+
+ await runTests(gTests);
+});
diff --git a/browser/base/content/test/webrtc/browser_devices_select_audio_output.js b/browser/base/content/test/webrtc/browser_devices_select_audio_output.js
new file mode 100644
index 0000000000..87d2d42a3a
--- /dev/null
+++ b/browser/base/content/test/webrtc/browser_devices_select_audio_output.js
@@ -0,0 +1,233 @@
+/* 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/. */
+
+requestLongerTimeout(2);
+
+const permissionError =
+ "error: NotAllowedError: The request is not allowed " +
+ "by the user agent or the platform in the current context.";
+
+async function requestAudioOutput(options) {
+ await Promise.all([
+ expectObserverCalled("getUserMedia:request"),
+ expectObserverCalled("recording-window-ended"),
+ promiseRequestAudioOutput(options),
+ ]);
+}
+
+async function requestAudioOutputExpectingPrompt(options) {
+ await Promise.all([
+ promisePopupNotificationShown("webRTC-shareDevices"),
+ requestAudioOutput(options),
+ ]);
+
+ is(
+ PopupNotifications.getNotification("webRTC-shareDevices").anchorID,
+ "webRTC-shareSpeaker-notification-icon",
+ "anchored to device icon"
+ );
+ checkDeviceSelectors(["speaker"]);
+}
+
+async function requestAudioOutputExpectingDeny(options) {
+ await Promise.all([
+ requestAudioOutput(options),
+ expectObserverCalled("getUserMedia:response:deny"),
+ promiseMessage(permissionError),
+ ]);
+}
+
+async function simulateAudioOutputRequest(options) {
+ await SpecialPowers.spawn(
+ gBrowser.selectedBrowser,
+ [options],
+ function simPrompt({ deviceCount, deviceId }) {
+ const devices = [...Array(deviceCount).keys()].map(i => ({
+ type: "audiooutput",
+ rawName: `name ${i}`,
+ deviceIndex: i,
+ rawId: `rawId ${i}`,
+ id: `id ${i}`,
+ QueryInterface: ChromeUtils.generateQI([Ci.nsIMediaDevice]),
+ }));
+ const req = {
+ type: "selectaudiooutput",
+ windowID: content.windowGlobalChild.outerWindowId,
+ devices,
+ getConstraints: () => ({}),
+ getAudioOutputOptions: () => ({ deviceId }),
+ isSecure: true,
+ isHandlingUserInput: true,
+ };
+ const { WebRTCChild } = SpecialPowers.ChromeUtils.importESModule(
+ "resource:///actors/WebRTCChild.sys.mjs"
+ );
+ WebRTCChild.observe(req, "getUserMedia:request");
+ }
+ );
+}
+
+async function allowPrompt() {
+ const observerPromise = expectObserverCalled("getUserMedia:response:allow");
+ PopupNotifications.panel.firstElementChild.button.click();
+ await observerPromise;
+}
+
+async function allow() {
+ await Promise.all([promiseMessage("ok"), allowPrompt()]);
+}
+
+async function denyPrompt() {
+ const observerPromise = expectObserverCalled("getUserMedia:response:deny");
+ activateSecondaryAction(kActionDeny);
+ await observerPromise;
+}
+
+async function deny() {
+ await Promise.all([promiseMessage(permissionError), denyPrompt()]);
+}
+
+async function escapePrompt() {
+ const observerPromise = expectObserverCalled("getUserMedia:response:deny");
+ EventUtils.synthesizeKey("KEY_Escape");
+ await observerPromise;
+}
+
+async function escape() {
+ await Promise.all([promiseMessage(permissionError), escapePrompt()]);
+}
+
+var gTests = [
+ {
+ desc: 'User clicks "Allow" and revokes',
+ run: async function checkAllow() {
+ await requestAudioOutputExpectingPrompt();
+ await allow();
+
+ info("selectAudioOutput() with no deviceId again should prompt again.");
+ await requestAudioOutputExpectingPrompt();
+ await allow();
+
+ info("selectAudioOutput() with same deviceId should not prompt again.");
+ await Promise.all([
+ expectObserverCalled("getUserMedia:response:allow"),
+ promiseMessage("ok"),
+ requestAudioOutput({ requestSameDevice: true }),
+ ]);
+
+ await revokePermission("speaker", true);
+ info("Same deviceId should prompt again after revoked permission.");
+ await requestAudioOutputExpectingPrompt({ requestSameDevice: true });
+ await allow();
+ await revokePermission("speaker", true);
+ },
+ },
+ {
+ desc: 'User clicks "Not Now"',
+ run: async function checkNotNow() {
+ await requestAudioOutputExpectingPrompt();
+ is(
+ PopupNotifications.getNotification("webRTC-shareDevices")
+ .secondaryActions[0].label,
+ "Not now",
+ "first secondary action label"
+ );
+ await deny();
+ info("selectAudioOutput() after Not Now should prompt again.");
+ await requestAudioOutputExpectingPrompt();
+ await escape();
+ },
+ },
+ {
+ desc: 'User presses "Esc"',
+ run: async function checkEsc() {
+ await requestAudioOutputExpectingPrompt();
+ await escape();
+ info("selectAudioOutput() after Esc should prompt again.");
+ await requestAudioOutputExpectingPrompt();
+ await allow();
+ await revokePermission("speaker", true);
+ },
+ },
+ {
+ desc: 'User clicks "Always Block"',
+ run: async function checkAlwaysBlock() {
+ await requestAudioOutputExpectingPrompt();
+ await Promise.all([
+ expectObserverCalled("getUserMedia:response:deny"),
+ promiseMessage(permissionError),
+ activateSecondaryAction(kActionNever),
+ ]);
+ info("selectAudioOutput() after Always Block should not prompt again.");
+ await requestAudioOutputExpectingDeny();
+ await revokePermission("speaker", true);
+ },
+ },
+ {
+ desc: "Single Device",
+ run: async function checkSingle() {
+ await Promise.all([
+ promisePopupNotificationShown("webRTC-shareDevices"),
+ simulateAudioOutputRequest({ deviceCount: 1 }),
+ ]);
+ checkDeviceSelectors(["speaker"]);
+ await escapePrompt();
+ },
+ },
+ {
+ desc: "Multi Device with deviceId",
+ run: async function checkMulti() {
+ const deviceCount = 4;
+ await Promise.all([
+ promisePopupNotificationShown("webRTC-shareDevices"),
+ simulateAudioOutputRequest({ deviceCount, deviceId: "id 2" }),
+ ]);
+ const selectorList = document.getElementById(
+ `webRTC-selectSpeaker-menulist`
+ );
+ is(selectorList.selectedIndex, 2, "pre-selected index");
+ checkDeviceSelectors(["speaker"]);
+ await allowPrompt();
+
+ info("Expect same-device request allowed without prompt");
+ await Promise.all([
+ expectObserverCalled("getUserMedia:response:allow"),
+ simulateAudioOutputRequest({ deviceCount, deviceId: "id 2" }),
+ ]);
+
+ info("Expect prompt for different-device request");
+ await Promise.all([
+ promisePopupNotificationShown("webRTC-shareDevices"),
+ simulateAudioOutputRequest({ deviceCount, deviceId: "id 1" }),
+ ]);
+ await denyPrompt();
+
+ info("Expect prompt again for denied-device request");
+ await Promise.all([
+ promisePopupNotificationShown("webRTC-shareDevices"),
+ simulateAudioOutputRequest({ deviceCount, deviceId: "id 1" }),
+ ]);
+ await escapePrompt();
+
+ await revokePermission("speaker", true);
+ },
+ },
+ {
+ desc: "SitePermissions speaker block",
+ run: async function checkPermissionsBlock() {
+ SitePermissions.setForPrincipal(
+ gBrowser.contentPrincipal,
+ "speaker",
+ SitePermissions.BLOCK
+ );
+ await requestAudioOutputExpectingDeny();
+ SitePermissions.removeFromPrincipal(gBrowser.contentPrincipal, "speaker");
+ },
+ },
+];
+
+add_task(async function test() {
+ await SpecialPowers.pushPrefEnv({ set: [["media.setsinkid.enabled", true]] });
+ await runTests(gTests);
+});
diff --git a/browser/base/content/test/webrtc/browser_global_mute_toggles.js b/browser/base/content/test/webrtc/browser_global_mute_toggles.js
new file mode 100644
index 0000000000..54713fa8c3
--- /dev/null
+++ b/browser/base/content/test/webrtc/browser_global_mute_toggles.js
@@ -0,0 +1,293 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TEST_ROOT = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content/",
+ "https://example.com/"
+);
+const TEST_PAGE = TEST_ROOT + "get_user_media.html";
+const MUTE_TOPICS = [
+ "getUserMedia:muteVideo",
+ "getUserMedia:unmuteVideo",
+ "getUserMedia:muteAudio",
+ "getUserMedia:unmuteAudio",
+];
+
+add_setup(async function () {
+ let prefs = [
+ [PREF_PERMISSION_FAKE, true],
+ [PREF_AUDIO_LOOPBACK, ""],
+ [PREF_VIDEO_LOOPBACK, ""],
+ [PREF_FAKE_STREAMS, true],
+ [PREF_FOCUS_SOURCE, false],
+ ["privacy.webrtc.globalMuteToggles", true],
+ ];
+ await SpecialPowers.pushPrefEnv({ set: prefs });
+});
+
+/**
+ * Returns a Promise that resolves when the content process for
+ * <browser> fires the right observer notification based on the
+ * value of isMuted for the camera.
+ *
+ * Note: Callers must ensure that they first call
+ * BrowserTestUtils.startObservingTopics to monitor the mute and
+ * unmute observer notifications for this to work properly.
+ *
+ * @param {<xul:browser>} browser - The browser running in the content process
+ * to be monitored.
+ * @param {Boolean} isMuted - True if the muted topic should be fired.
+ * @return {Promise}
+ * @resolves {undefined} When the notification fires.
+ */
+function waitForCameraMuteState(browser, isMuted) {
+ let topic = isMuted ? "getUserMedia:muteVideo" : "getUserMedia:unmuteVideo";
+ return BrowserTestUtils.contentTopicObserved(browser.browsingContext, topic);
+}
+
+/**
+ * Returns a Promise that resolves when the content process for
+ * <browser> fires the right observer notification based on the
+ * value of isMuted for the microphone.
+ *
+ * Note: Callers must ensure that they first call
+ * BrowserTestUtils.startObservingTopics to monitor the mute and
+ * unmute observer notifications for this to work properly.
+ *
+ * @param {<xul:browser>} browser - The browser running in the content process
+ * to be monitored.
+ * @param {Boolean} isMuted - True if the muted topic should be fired.
+ * @return {Promise}
+ * @resolves {undefined} When the notification fires.
+ */
+function waitForMicrophoneMuteState(browser, isMuted) {
+ let topic = isMuted ? "getUserMedia:muteAudio" : "getUserMedia:unmuteAudio";
+ return BrowserTestUtils.contentTopicObserved(browser.browsingContext, topic);
+}
+
+/**
+ * Tests that the global mute toggles fire the right observer
+ * notifications in pre-existing content processes.
+ */
+add_task(async function test_notifications() {
+ await BrowserTestUtils.withNewTab(TEST_PAGE, async browser => {
+ let indicatorPromise = promiseIndicatorWindow();
+
+ await shareDevices(browser, true /* camera */, true /* microphone */);
+
+ let indicator = await indicatorPromise;
+ let doc = indicator.document;
+
+ let microphoneMute = doc.getElementById("microphone-mute-toggle");
+ let cameraMute = doc.getElementById("camera-mute-toggle");
+
+ Assert.ok(
+ !microphoneMute.checked,
+ "Microphone toggle should not start checked."
+ );
+ Assert.ok(!cameraMute.checked, "Camera toggle should not start checked.");
+
+ await BrowserTestUtils.startObservingTopics(
+ browser.browsingContext,
+ MUTE_TOPICS
+ );
+
+ info("Muting microphone...");
+ let microphoneMuted = waitForMicrophoneMuteState(browser, true);
+ microphoneMute.click();
+ await microphoneMuted;
+ info("Microphone successfully muted.");
+
+ info("Muting camera...");
+ let cameraMuted = waitForCameraMuteState(browser, true);
+ cameraMute.click();
+ await cameraMuted;
+ info("Camera successfully muted.");
+
+ Assert.ok(
+ microphoneMute.checked,
+ "Microphone toggle should now be checked."
+ );
+ Assert.ok(cameraMute.checked, "Camera toggle should now be checked.");
+
+ info("Unmuting microphone...");
+ let microphoneUnmuted = waitForMicrophoneMuteState(browser, false);
+ microphoneMute.click();
+ await microphoneUnmuted;
+ info("Microphone successfully unmuted.");
+
+ info("Unmuting camera...");
+ let cameraUnmuted = waitForCameraMuteState(browser, false);
+ cameraMute.click();
+ await cameraUnmuted;
+ info("Camera successfully unmuted.");
+
+ await BrowserTestUtils.stopObservingTopics(
+ browser.browsingContext,
+ MUTE_TOPICS
+ );
+ });
+});
+
+/**
+ * Tests that if sharing stops while muted, and the indicator closes,
+ * then the mute state is reset.
+ */
+add_task(async function test_closing_indicator_resets_mute() {
+ await BrowserTestUtils.withNewTab(TEST_PAGE, async browser => {
+ let indicatorPromise = promiseIndicatorWindow();
+
+ await shareDevices(browser, true /* camera */, true /* microphone */);
+
+ let indicator = await indicatorPromise;
+ let doc = indicator.document;
+
+ let microphoneMute = doc.getElementById("microphone-mute-toggle");
+ let cameraMute = doc.getElementById("camera-mute-toggle");
+
+ Assert.ok(
+ !microphoneMute.checked,
+ "Microphone toggle should not start checked."
+ );
+ Assert.ok(!cameraMute.checked, "Camera toggle should not start checked.");
+
+ await BrowserTestUtils.startObservingTopics(
+ browser.browsingContext,
+ MUTE_TOPICS
+ );
+
+ info("Muting microphone...");
+ let microphoneMuted = waitForMicrophoneMuteState(browser, true);
+ microphoneMute.click();
+ await microphoneMuted;
+ info("Microphone successfully muted.");
+
+ info("Muting camera...");
+ let cameraMuted = waitForCameraMuteState(browser, true);
+ cameraMute.click();
+ await cameraMuted;
+ info("Camera successfully muted.");
+
+ Assert.ok(
+ microphoneMute.checked,
+ "Microphone toggle should now be checked."
+ );
+ Assert.ok(cameraMute.checked, "Camera toggle should now be checked.");
+
+ let allUnmuted = Promise.all([
+ waitForMicrophoneMuteState(browser, false),
+ waitForCameraMuteState(browser, false),
+ ]);
+
+ await closeStream();
+ await allUnmuted;
+
+ await BrowserTestUtils.stopObservingTopics(
+ browser.browsingContext,
+ MUTE_TOPICS
+ );
+ });
+});
+
+/**
+ * Test that if the global mute state is set, then newly created
+ * content processes also have their tracks muted after sending
+ * a getUserMedia request.
+ */
+add_task(async function test_new_processes() {
+ let tab1 = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ url: TEST_PAGE,
+ });
+ let browser1 = tab1.linkedBrowser;
+
+ let indicatorPromise = promiseIndicatorWindow();
+
+ await shareDevices(browser1, true /* camera */, true /* microphone */);
+
+ let indicator = await indicatorPromise;
+ let doc = indicator.document;
+
+ let microphoneMute = doc.getElementById("microphone-mute-toggle");
+ let cameraMute = doc.getElementById("camera-mute-toggle");
+
+ Assert.ok(
+ !microphoneMute.checked,
+ "Microphone toggle should not start checked."
+ );
+ Assert.ok(!cameraMute.checked, "Camera toggle should not start checked.");
+
+ await BrowserTestUtils.startObservingTopics(
+ browser1.browsingContext,
+ MUTE_TOPICS
+ );
+
+ info("Muting microphone...");
+ let microphoneMuted = waitForMicrophoneMuteState(browser1, true);
+ microphoneMute.click();
+ await microphoneMuted;
+ info("Microphone successfully muted.");
+
+ info("Muting camera...");
+ let cameraMuted = waitForCameraMuteState(browser1, true);
+ cameraMute.click();
+ await cameraMuted;
+ info("Camera successfully muted.");
+
+ // We'll make sure a new process is being launched by observing
+ // for the ipc:content-created notification.
+ let processLaunched = TestUtils.topicObserved("ipc:content-created");
+
+ let tab2 = await BrowserTestUtils.openNewForegroundTab({
+ gBrowser,
+ url: TEST_PAGE,
+ forceNewProcess: true,
+ });
+ let browser2 = tab2.linkedBrowser;
+
+ await processLaunched;
+
+ await BrowserTestUtils.startObservingTopics(
+ browser2.browsingContext,
+ MUTE_TOPICS
+ );
+
+ let microphoneMuted2 = waitForMicrophoneMuteState(browser2, true);
+ let cameraMuted2 = waitForCameraMuteState(browser2, true);
+ info("Sharing the microphone and camera from a new process.");
+ await shareDevices(browser2, true /* camera */, true /* microphone */);
+ await Promise.all([microphoneMuted2, cameraMuted2]);
+
+ info("Unmuting microphone...");
+ let microphoneUnmuted = Promise.all([
+ waitForMicrophoneMuteState(browser1, false),
+ waitForMicrophoneMuteState(browser2, false),
+ ]);
+ microphoneMute.click();
+ await microphoneUnmuted;
+ info("Microphone successfully unmuted.");
+
+ info("Unmuting camera...");
+ let cameraUnmuted = Promise.all([
+ waitForCameraMuteState(browser1, false),
+ waitForCameraMuteState(browser2, false),
+ ]);
+ cameraMute.click();
+ await cameraUnmuted;
+ info("Camera successfully unmuted.");
+
+ await BrowserTestUtils.stopObservingTopics(
+ browser1.browsingContext,
+ MUTE_TOPICS
+ );
+
+ await BrowserTestUtils.stopObservingTopics(
+ browser2.browsingContext,
+ MUTE_TOPICS
+ );
+
+ BrowserTestUtils.removeTab(tab2);
+ BrowserTestUtils.removeTab(tab1);
+});
diff --git a/browser/base/content/test/webrtc/browser_indicator_popuphiding.js b/browser/base/content/test/webrtc/browser_indicator_popuphiding.js
new file mode 100644
index 0000000000..8d02eb5c70
--- /dev/null
+++ b/browser/base/content/test/webrtc/browser_indicator_popuphiding.js
@@ -0,0 +1,50 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TEST_ROOT = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content/",
+ "https://example.com/"
+);
+const TEST_PAGE = TEST_ROOT + "get_user_media.html";
+
+/**
+ * Regression test for bug 1668838 - make sure that a popuphiding
+ * event that fires for any popup not related to the device control
+ * menus is ignored and doesn't cause the targets contents to be all
+ * removed.
+ */
+add_task(async function test_popuphiding() {
+ let prefs = [
+ [PREF_PERMISSION_FAKE, true],
+ [PREF_AUDIO_LOOPBACK, ""],
+ [PREF_VIDEO_LOOPBACK, ""],
+ [PREF_FAKE_STREAMS, true],
+ [PREF_FOCUS_SOURCE, false],
+ ];
+ await SpecialPowers.pushPrefEnv({ set: prefs });
+
+ await BrowserTestUtils.withNewTab(TEST_PAGE, async browser => {
+ let indicatorPromise = promiseIndicatorWindow();
+
+ await shareDevices(
+ browser,
+ true /* camera */,
+ true /* microphone */,
+ SHARE_SCREEN
+ );
+
+ let indicator = await indicatorPromise;
+ let doc = indicator.document;
+
+ Assert.ok(doc.body, "Should have a document body in the indicator.");
+
+ let event = new indicator.MouseEvent("popuphiding", { bubbles: true });
+ doc.documentElement.dispatchEvent(event);
+
+ Assert.ok(doc.body, "Should still have a document body in the indicator.");
+ });
+
+ await checkNotSharing();
+});
diff --git a/browser/base/content/test/webrtc/browser_notification_silencing.js b/browser/base/content/test/webrtc/browser_notification_silencing.js
new file mode 100644
index 0000000000..e2c5c76e2d
--- /dev/null
+++ b/browser/base/content/test/webrtc/browser_notification_silencing.js
@@ -0,0 +1,231 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TEST_ROOT = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content/",
+ "https://example.com/"
+);
+const TEST_PAGE = TEST_ROOT + "get_user_media.html";
+
+/**
+ * Tests that the screen / window sharing permission popup offers the ability
+ * for users to silence DOM notifications while sharing.
+ */
+
+/**
+ * Helper function that exercises a specific browser to test whether or not the
+ * user can silence notifications via the display sharing permission panel.
+ *
+ * First, we ensure that notification silencing is disabled by default. Then, we
+ * request screen sharing from the browser, and check the checkbox that
+ * silences notifications. Once screen sharing is established, then we ensure
+ * that notification silencing is enabled. Then we stop sharing, and ensure that
+ * notification silencing is disabled again.
+ *
+ * @param {<xul:browser>} aBrowser - The window to run the test on. This browser
+ * should have TEST_PAGE loaded.
+ * @return Promise
+ * @resolves undefined - When the test on the browser is complete.
+ */
+async function testNotificationSilencing(aBrowser) {
+ let hasIndicator = Services.wm
+ .getEnumerator("Browser:WebRTCGlobalIndicator")
+ .hasMoreElements();
+
+ let window = aBrowser.ownerGlobal;
+
+ let alertsService = Cc["@mozilla.org/alerts-service;1"]
+ .getService(Ci.nsIAlertsService)
+ .QueryInterface(Ci.nsIAlertsDoNotDisturb);
+ Assert.ok(alertsService, "Alerts Service implements nsIAlertsDoNotDisturb");
+ Assert.ok(
+ !alertsService.suppressForScreenSharing,
+ "Should not be silencing notifications to start."
+ );
+
+ let observerPromise = expectObserverCalled(
+ "getUserMedia:request",
+ 1,
+ aBrowser
+ );
+ let promise = promisePopupNotificationShown(
+ "webRTC-shareDevices",
+ null,
+ window
+ );
+ let indicatorPromise = hasIndicator
+ ? Promise.resolve()
+ : promiseIndicatorWindow();
+ await promiseRequestDevice(false, true, null, "screen", aBrowser);
+ await promise;
+ await observerPromise;
+
+ checkDeviceSelectors(["screen"], window);
+
+ let document = window.document;
+
+ // Select one of the windows / screens. It doesn't really matter which.
+ let menulist = document.getElementById("webRTC-selectWindow-menulist");
+ menulist.getItemAtIndex(menulist.itemCount - 1).doCommand();
+
+ let notification = window.PopupNotifications.panel.firstElementChild;
+ Assert.ok(
+ notification.hasAttribute("warninghidden"),
+ "Notification silencing warning message is hidden by default"
+ );
+
+ let checkbox = notification.checkbox;
+ Assert.ok(!!checkbox, "Notification silencing checkbox is present");
+ Assert.ok(!checkbox.checked, "checkbox is not checked by default");
+ checkbox.click();
+ Assert.ok(checkbox.checked, "checkbox now checked");
+ // The orginal behaviour of the checkbox disabled the Allow button. Let's
+ // make sure we're not still doing that.
+ Assert.ok(!notification.button.disabled, "Allow button is not disabled");
+ Assert.ok(
+ notification.hasAttribute("warninghidden"),
+ "No warning message is shown"
+ );
+
+ let observerPromise1 = expectObserverCalled(
+ "getUserMedia:response:allow",
+ 1,
+ aBrowser
+ );
+ let observerPromise2 = expectObserverCalled(
+ "recording-device-events",
+ 1,
+ aBrowser
+ );
+ await promiseMessage(
+ "ok",
+ () => {
+ notification.button.click();
+ },
+ 1,
+ aBrowser
+ );
+ await observerPromise1;
+ await observerPromise2;
+ let indicator = await indicatorPromise;
+
+ Assert.ok(
+ alertsService.suppressForScreenSharing,
+ "Should now be silencing notifications"
+ );
+
+ let indicatorClosedPromise = hasIndicator
+ ? Promise.resolve()
+ : BrowserTestUtils.domWindowClosed(indicator);
+
+ await stopSharing("screen", true, aBrowser, window);
+ await indicatorClosedPromise;
+
+ Assert.ok(
+ !alertsService.suppressForScreenSharing,
+ "Should no longer be silencing notifications"
+ );
+}
+
+add_setup(async function () {
+ // Set prefs so that permissions prompts are shown and loopback devices
+ // are not used. To test the chrome we want prompts to be shown, and
+ // these tests are flakey when using loopback devices (though it would
+ // be desirable to make them work with loopback in future). See bug 1643711.
+ let prefs = [
+ [PREF_PERMISSION_FAKE, true],
+ [PREF_AUDIO_LOOPBACK, ""],
+ [PREF_VIDEO_LOOPBACK, ""],
+ [PREF_FAKE_STREAMS, true],
+ [PREF_FOCUS_SOURCE, false],
+ ];
+
+ await SpecialPowers.pushPrefEnv({ set: prefs });
+});
+
+/**
+ * Tests notification silencing in a normal browser window.
+ */
+add_task(async function testNormalWindow() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ await testNotificationSilencing(browser);
+ }
+ );
+});
+
+/**
+ * Tests notification silencing in a private browser window.
+ */
+add_task(async function testPrivateWindow() {
+ let privateWindow = await BrowserTestUtils.openNewBrowserWindow({
+ private: true,
+ });
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser: privateWindow.gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ await testNotificationSilencing(browser);
+ }
+ );
+
+ await BrowserTestUtils.closeWindow(privateWindow);
+});
+
+/**
+ * Tests notification silencing when sharing a screen while already
+ * sharing the microphone. Alone ensures that if we stop sharing the
+ * screen, but continue sharing the microphone, that notification
+ * silencing ends.
+ */
+add_task(async function testWhileSharingMic() {
+ await BrowserTestUtils.withNewTab(
+ {
+ gBrowser,
+ url: TEST_PAGE,
+ },
+ async browser => {
+ let promise = promisePopupNotificationShown("webRTC-shareDevices");
+ let observerPromise = expectObserverCalled("getUserMedia:request");
+ await promiseRequestDevice(true, true);
+ await promise;
+ await observerPromise;
+
+ let indicatorPromise = promiseIndicatorWindow();
+ let observerPromise1 = expectObserverCalled(
+ "getUserMedia:response:allow"
+ );
+ let observerPromise2 = expectObserverCalled("recording-device-events");
+
+ promise = promiseMessage("ok", () => {
+ PopupNotifications.panel.firstElementChild.button.click();
+ });
+ await observerPromise1;
+ await observerPromise2;
+ await promise;
+
+ Assert.deepEqual(
+ await getMediaCaptureState(),
+ { audio: true, video: true },
+ "expected camera and microphone to be shared"
+ );
+
+ let indicator = await indicatorPromise;
+ await checkSharingUI({ audio: true, video: true });
+
+ await testNotificationSilencing(browser);
+
+ let indicatorClosedPromise = BrowserTestUtils.domWindowClosed(indicator);
+ await closeStream();
+ await indicatorClosedPromise;
+ }
+ );
+});
diff --git a/browser/base/content/test/webrtc/browser_stop_sharing_button.js b/browser/base/content/test/webrtc/browser_stop_sharing_button.js
new file mode 100644
index 0000000000..17ab66abc4
--- /dev/null
+++ b/browser/base/content/test/webrtc/browser_stop_sharing_button.js
@@ -0,0 +1,175 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TEST_ROOT = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content/",
+ "https://example.com/"
+);
+const TEST_PAGE = TEST_ROOT + "get_user_media.html";
+
+add_setup(async function () {
+ let prefs = [
+ [PREF_PERMISSION_FAKE, true],
+ [PREF_AUDIO_LOOPBACK, ""],
+ [PREF_VIDEO_LOOPBACK, ""],
+ [PREF_FAKE_STREAMS, true],
+ [PREF_FOCUS_SOURCE, false],
+ ];
+ await SpecialPowers.pushPrefEnv({ set: prefs });
+});
+
+/**
+ * Tests that if the user chooses to "Stop Sharing" a display while
+ * also sharing their microphone or camera, that only the display
+ * stream is stopped.
+ */
+add_task(async function test_stop_sharing() {
+ await BrowserTestUtils.withNewTab(TEST_PAGE, async browser => {
+ let indicatorPromise = promiseIndicatorWindow();
+
+ await shareDevices(
+ browser,
+ true /* camera */,
+ true /* microphone */,
+ SHARE_SCREEN
+ );
+
+ let indicator = await indicatorPromise;
+
+ let stopSharingButton = indicator.document.getElementById("stop-sharing");
+ let stopSharingPromise = expectObserverCalled("recording-device-events");
+ stopSharingButton.click();
+ await stopSharingPromise;
+
+ // Ensure that we're still sharing the other streams.
+ await checkSharingUI({ audio: true, video: true });
+
+ // Ensure that the "display-share" section of the indicator is now hidden
+ Assert.ok(
+ BrowserTestUtils.is_hidden(
+ indicator.document.getElementById("display-share")
+ ),
+ "The display-share section of the indicator should now be hidden."
+ );
+ });
+});
+
+/**
+ * Tests that if the user chooses to "Stop Sharing" a display while
+ * sharing their display on multiple sites, all of those display sharing
+ * streams are closed.
+ */
+add_task(async function test_stop_sharing_multiple() {
+ let indicatorPromise = promiseIndicatorWindow();
+
+ info("Opening first tab");
+ let tab1 = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_PAGE);
+ info("Sharing camera, microphone and screen");
+ await shareDevices(tab1.linkedBrowser, true, true, SHARE_SCREEN);
+
+ info("Opening second tab");
+ let tab2 = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_PAGE);
+ info("Sharing camera and screen");
+ await shareDevices(tab2.linkedBrowser, true, false, SHARE_SCREEN);
+
+ let indicator = await indicatorPromise;
+
+ let stopSharingButton = indicator.document.getElementById("stop-sharing");
+ let stopSharingPromise = TestUtils.waitForCondition(() => {
+ return !webrtcUI.showScreenSharingIndicator;
+ });
+ stopSharingButton.click();
+ await stopSharingPromise;
+
+ Assert.equal(gBrowser.selectedTab, tab2, "Should have tab2 selected.");
+ await checkSharingUI({ audio: false, video: true }, window, {
+ audio: true,
+ video: true,
+ });
+ BrowserTestUtils.removeTab(tab2);
+
+ Assert.equal(gBrowser.selectedTab, tab1, "Should have tab1 selected.");
+ await checkSharingUI({ audio: true, video: true }, window, {
+ audio: true,
+ video: true,
+ });
+
+ // Ensure that the "display-share" section of the indicator is now hidden
+ Assert.ok(
+ BrowserTestUtils.is_hidden(
+ indicator.document.getElementById("display-share")
+ ),
+ "The display-share section of the indicator should now be hidden."
+ );
+
+ BrowserTestUtils.removeTab(tab1);
+});
+
+/**
+ * Tests that if the user chooses to "Stop Sharing" a display, persistent
+ * permissions are not removed for camera or microphone devices.
+ */
+add_task(async function test_keep_permissions() {
+ await BrowserTestUtils.withNewTab(TEST_PAGE, async browser => {
+ let indicatorPromise = promiseIndicatorWindow();
+
+ await shareDevices(
+ browser,
+ true /* camera */,
+ true /* microphone */,
+ SHARE_SCREEN,
+ true /* remember */
+ );
+
+ let indicator = await indicatorPromise;
+
+ let stopSharingButton = indicator.document.getElementById("stop-sharing");
+ let stopSharingPromise = expectObserverCalled("recording-device-events");
+ stopSharingButton.click();
+ await stopSharingPromise;
+
+ // Ensure that we're still sharing the other streams.
+ await checkSharingUI({ audio: true, video: true }, undefined, undefined, {
+ audio: { scope: SitePermissions.SCOPE_PERSISTENT },
+ video: { scope: SitePermissions.SCOPE_PERSISTENT },
+ });
+
+ // Ensure that the "display-share" section of the indicator is now hidden
+ Assert.ok(
+ BrowserTestUtils.is_hidden(
+ indicator.document.getElementById("display-share")
+ ),
+ "The display-share section of the indicator should now be hidden."
+ );
+
+ let { state: micState, scope: micScope } = SitePermissions.getForPrincipal(
+ browser.contentPrincipal,
+ "microphone",
+ browser
+ );
+
+ Assert.equal(micState, SitePermissions.ALLOW);
+ Assert.equal(micScope, SitePermissions.SCOPE_PERSISTENT);
+
+ let { state: camState, scope: camScope } = SitePermissions.getForPrincipal(
+ browser.contentPrincipal,
+ "camera",
+ browser
+ );
+ Assert.equal(camState, SitePermissions.ALLOW);
+ Assert.equal(camScope, SitePermissions.SCOPE_PERSISTENT);
+
+ SitePermissions.removeFromPrincipal(
+ browser.contentPrincipal,
+ "camera",
+ browser
+ );
+ SitePermissions.removeFromPrincipal(
+ browser.contentPrincipal,
+ "microphone",
+ browser
+ );
+ });
+});
diff --git a/browser/base/content/test/webrtc/browser_stop_streams_on_indicator_close.js b/browser/base/content/test/webrtc/browser_stop_streams_on_indicator_close.js
new file mode 100644
index 0000000000..b5d4229fdc
--- /dev/null
+++ b/browser/base/content/test/webrtc/browser_stop_streams_on_indicator_close.js
@@ -0,0 +1,215 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TEST_ROOT = getRootDirectory(gTestPath).replace(
+ "chrome://mochitests/content/",
+ "https://example.com/"
+);
+const TEST_PAGE = TEST_ROOT + "get_user_media.html";
+
+add_setup(async function () {
+ let prefs = [
+ [PREF_PERMISSION_FAKE, true],
+ [PREF_AUDIO_LOOPBACK, ""],
+ [PREF_VIDEO_LOOPBACK, ""],
+ [PREF_FAKE_STREAMS, true],
+ [PREF_FOCUS_SOURCE, false],
+ ];
+ await SpecialPowers.pushPrefEnv({ set: prefs });
+});
+
+/**
+ * Tests that if the indicator is closed somehow by the user when streams
+ * still ongoing, that all of those streams it represents are stopped, and
+ * the most recent tab that a stream was shared with is selected.
+ *
+ * This test makes sure the global mute toggles for camera and microphone
+ * are disabled, so the indicator only represents display streams, and only
+ * those streams should be stopped on close.
+ */
+add_task(async function test_close_indicator_no_global_toggles() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["privacy.webrtc.globalMuteToggles", false]],
+ });
+
+ let indicatorPromise = promiseIndicatorWindow();
+
+ info("Opening first tab");
+ let tab1 = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_PAGE);
+ info("Sharing camera, microphone and screen");
+ await shareDevices(tab1.linkedBrowser, true, true, SHARE_SCREEN, false);
+
+ info("Opening second tab");
+ let tab2 = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_PAGE);
+ info("Sharing camera, microphone and screen");
+ await shareDevices(tab2.linkedBrowser, true, true, SHARE_SCREEN, true);
+
+ info("Opening third tab");
+ let tab3 = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_PAGE);
+ info("Sharing screen");
+ await shareDevices(tab3.linkedBrowser, false, false, SHARE_SCREEN, false);
+
+ info("Opening fourth tab");
+ let tab4 = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "https://example.com"
+ );
+
+ Assert.equal(
+ gBrowser.selectedTab,
+ tab4,
+ "Most recently opened tab is selected"
+ );
+
+ let indicator = await indicatorPromise;
+
+ indicator.close();
+
+ // Wait a tick of the event loop to give the unload handler in the indicator
+ // a chance to run.
+ await new Promise(resolve => executeSoon(resolve));
+
+ // Make sure the media capture state has a chance to flush up to the parent.
+ await getMediaCaptureState();
+
+ // The camera and microphone streams should still be active.
+ let camStreams = webrtcUI.getActiveStreams(true, false);
+ Assert.equal(camStreams.length, 2, "Should have found two camera streams");
+ let micStreams = webrtcUI.getActiveStreams(false, true);
+ Assert.equal(
+ micStreams.length,
+ 2,
+ "Should have found two microphone streams"
+ );
+
+ // The camera and microphone permission were remembered for tab2, so check to
+ // make sure that the permissions remain.
+ let { state: camState, scope: camScope } = SitePermissions.getForPrincipal(
+ tab2.linkedBrowser.contentPrincipal,
+ "camera",
+ tab2.linkedBrowser
+ );
+ Assert.equal(camState, SitePermissions.ALLOW);
+ Assert.equal(camScope, SitePermissions.SCOPE_PERSISTENT);
+
+ let { state: micState, scope: micScope } = SitePermissions.getForPrincipal(
+ tab2.linkedBrowser.contentPrincipal,
+ "microphone",
+ tab2.linkedBrowser
+ );
+ Assert.equal(micState, SitePermissions.ALLOW);
+ Assert.equal(micScope, SitePermissions.SCOPE_PERSISTENT);
+
+ Assert.equal(
+ gBrowser.selectedTab,
+ tab3,
+ "Most recently tab that streams were shared with is selected"
+ );
+
+ SitePermissions.removeFromPrincipal(
+ tab2.linkedBrowser.contentPrincipal,
+ "camera",
+ tab2.linkedBrowser
+ );
+
+ SitePermissions.removeFromPrincipal(
+ tab2.linkedBrowser.contentPrincipal,
+ "microphone",
+ tab2.linkedBrowser
+ );
+
+ BrowserTestUtils.removeTab(tab1);
+ BrowserTestUtils.removeTab(tab2);
+ BrowserTestUtils.removeTab(tab3);
+ BrowserTestUtils.removeTab(tab4);
+});
+
+/**
+ * Tests that if the indicator is closed somehow by the user when streams
+ * still ongoing, that all of those streams is represents are stopped, and
+ * the most recent tab that a stream was shared with is selected.
+ *
+ * This test makes sure the global mute toggles are enabled. This means that
+ * when the user manages to close the indicator, we should revoke camera
+ * and microphone permissions too.
+ */
+add_task(async function test_close_indicator_with_global_toggles() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["privacy.webrtc.globalMuteToggles", true]],
+ });
+
+ let indicatorPromise = promiseIndicatorWindow();
+
+ info("Opening first tab");
+ let tab1 = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_PAGE);
+ info("Sharing camera, microphone and screen");
+ await shareDevices(tab1.linkedBrowser, true, true, SHARE_SCREEN, false);
+
+ info("Opening second tab");
+ let tab2 = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_PAGE);
+ info("Sharing camera, microphone and screen");
+ await shareDevices(tab2.linkedBrowser, true, true, SHARE_SCREEN, true);
+
+ info("Opening third tab");
+ let tab3 = await BrowserTestUtils.openNewForegroundTab(gBrowser, TEST_PAGE);
+ info("Sharing screen");
+ await shareDevices(tab3.linkedBrowser, false, false, SHARE_SCREEN, false);
+
+ info("Opening fourth tab");
+ let tab4 = await BrowserTestUtils.openNewForegroundTab(
+ gBrowser,
+ "https://example.com"
+ );
+
+ Assert.equal(
+ gBrowser.selectedTab,
+ tab4,
+ "Most recently opened tab is selected"
+ );
+
+ let indicator = await indicatorPromise;
+
+ indicator.close();
+
+ // Wait a tick of the event loop to give the unload handler in the indicator
+ // a chance to run.
+ await new Promise(resolve => executeSoon(resolve));
+
+ Assert.deepEqual(
+ await getMediaCaptureState(),
+ {},
+ "expected nothing to be shared"
+ );
+
+ // Ensuring we no longer have any active streams.
+ let streams = webrtcUI.getActiveStreams(true, true, true, true);
+ Assert.equal(streams.length, 0, "Should have found no active streams");
+
+ // The camera and microphone permissions should have been cleared.
+ let { state: camState } = SitePermissions.getForPrincipal(
+ tab2.linkedBrowser.contentPrincipal,
+ "camera",
+ tab2.linkedBrowser
+ );
+ Assert.equal(camState, SitePermissions.UNKNOWN);
+
+ let { state: micState } = SitePermissions.getForPrincipal(
+ tab2.linkedBrowser.contentPrincipal,
+ "microphone",
+ tab2.linkedBrowser
+ );
+ Assert.equal(micState, SitePermissions.UNKNOWN);
+
+ Assert.equal(
+ gBrowser.selectedTab,
+ tab3,
+ "Most recently tab that streams were shared with is selected"
+ );
+
+ BrowserTestUtils.removeTab(tab1);
+ BrowserTestUtils.removeTab(tab2);
+ BrowserTestUtils.removeTab(tab3);
+ BrowserTestUtils.removeTab(tab4);
+});
diff --git a/browser/base/content/test/webrtc/browser_tab_switch_warning.js b/browser/base/content/test/webrtc/browser_tab_switch_warning.js
new file mode 100644
index 0000000000..f0625ab4ca
--- /dev/null
+++ b/browser/base/content/test/webrtc/browser_tab_switch_warning.js
@@ -0,0 +1,538 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+/**
+ * Tests the warning that is displayed when switching to background
+ * tabs when sharing the browser window or screen
+ */
+
+// The number of tabs to have in the background for testing.
+const NEW_BACKGROUND_TABS_TO_OPEN = 5;
+const WARNING_PANEL_ID = "sharing-tabs-warning-panel";
+const ALLOW_BUTTON_ID = "sharing-warning-proceed-to-tab";
+const DISABLE_WARNING_FOR_SESSION_CHECKBOX_ID =
+ "sharing-warning-disable-for-session";
+const WINDOW_SHARING_HEADER_ID = "sharing-warning-window-panel-header";
+const SCREEN_SHARING_HEADER_ID = "sharing-warning-screen-panel-header";
+// The number of milliseconds we're willing to wait for the
+// warning panel before we decide that it's not coming.
+const WARNING_PANEL_TIMEOUT_MS = 1000;
+const CTRL_TAB_RUO_PREF = "browser.ctrlTab.sortByRecentlyUsed";
+
+/**
+ * Common helper function that pretendToShareWindow and pretendToShareScreen
+ * call into. Ensures that the first tab is selected, and then (optionally)
+ * does the first "freebie" tab switch to the second tab.
+ *
+ * @param {boolean} doFirstTabSwitch - True if this function should take
+ * care of doing the "freebie" tab switch for you.
+ * @return {Promise}
+ * @resolves {undefined} - Once the simulation is set up.
+ */
+async function pretendToShareDisplay(doFirstTabSwitch) {
+ Assert.equal(
+ gBrowser.selectedTab,
+ gBrowser.tabs[0],
+ "Should start on the first tab."
+ );
+
+ webrtcUI.sharingDisplay = true;
+ if (doFirstTabSwitch) {
+ await BrowserTestUtils.switchTab(gBrowser, gBrowser.tabs[1]);
+ }
+}
+
+/**
+ * Simulates the sharing of a particular browser window. The
+ * simulation doesn't actually share the window over WebRTC, but
+ * does enough to convince webrtcUI that the window is in the shared
+ * window list.
+ *
+ * It is assumed that the first tab is the selected tab when calling
+ * this function.
+ *
+ * This helper function can also automatically perform the first
+ * "freebie" tab switch that never warns. This is its default behaviour.
+ *
+ * @param {DOM Window} aWindow - The window that we're simulating sharing.
+ * @param {boolean} doFirstTabSwitch - True if this function should take
+ * care of doing the "freebie" tab switch for you. Defaults to true.
+ * @return {Promise}
+ * @resolves {undefined} - Once the simulation is set up.
+ */
+async function pretendToShareWindow(aWindow, doFirstTabSwitch = true) {
+ // Poke into webrtcUI so that it thinks that the current browser
+ // window is being shared.
+ webrtcUI.sharedBrowserWindows.add(aWindow);
+ await pretendToShareDisplay(doFirstTabSwitch);
+}
+
+/**
+ * Simulates the sharing of the screen. The simulation doesn't actually share
+ * the screen over WebRTC, but does enough to convince webrtcUI that the screen
+ * is being shared.
+ *
+ * It is assumed that the first tab is the selected tab when calling
+ * this function.
+ *
+ * This helper function can also automatically perform the first
+ * "freebie" tab switch that never warns. This is its default behaviour.
+ *
+ * @param {boolean} doFirstTabSwitch - True if this function should take
+ * care of doing the "freebie" tab switch for you. Defaults to true.
+ * @return {Promise}
+ * @resolves {undefined} - Once the simulation is set up.
+ */
+async function pretendToShareScreen(doFirstTabSwitch = true) {
+ // Poke into webrtcUI so that it thinks that the current screen is being
+ // shared.
+ webrtcUI.sharingScreen = true;
+ await pretendToShareDisplay(doFirstTabSwitch);
+}
+
+/**
+ * Resets webrtcUI's notion of what is being shared. This also clears
+ * out any simulated shared windows, and resets any state that only
+ * persists for a sharing session.
+ *
+ * This helper function will also:
+ * 1. Switch back to the first tab if it's not already selected.
+ * 2. Check if the tab switch warning panel is open, and if so, close it.
+ *
+ * @return {Promise}
+ * @resolves {undefined} - Once the state is reset.
+ */
+async function resetDisplaySharingState() {
+ let firstTabBC = gBrowser.browsers[0].browsingContext;
+ webrtcUI.streamAddedOrRemoved(firstTabBC, { remove: true });
+
+ if (gBrowser.selectedTab !== gBrowser.tabs[0]) {
+ await BrowserTestUtils.switchTab(gBrowser, gBrowser.tabs[0]);
+ }
+
+ let panel = document.getElementById(WARNING_PANEL_ID);
+ if (panel && (panel.state == "open" || panel.state == "showing")) {
+ info("Closing the warning panel.");
+ let panelHidden = BrowserTestUtils.waitForEvent(panel, "popuphidden");
+ panel.hidePopup();
+ await panelHidden;
+ }
+}
+
+/**
+ * Checks to make sure that a tab switch warning doesn't show
+ * within WARNING_PANEL_TIMEOUT_MS milliseconds.
+ *
+ * @return {Promise}
+ * @resolves {undefined} - Once the check is complete.
+ */
+async function ensureNoWarning() {
+ let timerExpired = false;
+ let sawWarning = false;
+
+ let resolver;
+ let timeoutOrPopupShowingPromise = new Promise(resolve => {
+ resolver = resolve;
+ });
+
+ let onPopupShowing = event => {
+ if (event.target.id == WARNING_PANEL_ID) {
+ sawWarning = true;
+ resolver();
+ }
+ };
+ // The panel might not have been lazily-inserted yet, so we
+ // attach the popupshowing handler to the window instead.
+ window.addEventListener("popupshowing", onPopupShowing);
+
+ // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
+ let timer = setTimeout(() => {
+ timerExpired = true;
+ resolver();
+ }, WARNING_PANEL_TIMEOUT_MS);
+
+ await timeoutOrPopupShowingPromise;
+
+ clearTimeout(timer);
+ window.removeEventListener("popupshowing", onPopupShowing);
+
+ Assert.ok(timerExpired, "Timer should have expired.");
+ Assert.ok(!sawWarning, "Should not have shown the tab switch warning.");
+}
+
+/**
+ * Checks to make sure that a tab switch warning appears for
+ * a particular tab.
+ *
+ * @param {<xul:tab>} tab - The tab that the warning should be anchored to.
+ * @return {Promise}
+ * @resolves {undefined} - Once the check is complete.
+ */
+async function ensureWarning(tab) {
+ let popupShowingEvent = await BrowserTestUtils.waitForEvent(
+ window,
+ "popupshowing",
+ false,
+ event => {
+ return event.target.id == WARNING_PANEL_ID;
+ }
+ );
+ let panel = popupShowingEvent.target;
+
+ Assert.equal(
+ panel.anchorNode,
+ tab,
+ "Expected the warning to be anchored to the right tab."
+ );
+}
+
+add_setup(async function () {
+ await SpecialPowers.pushPrefEnv({
+ set: [["privacy.webrtc.sharedTabWarning", true]],
+ });
+
+ // Loads up NEW_BACKGROUND_TABS_TO_OPEN background tabs at about:blank,
+ // and waits until they're fully open.
+ let uris = new Array(NEW_BACKGROUND_TABS_TO_OPEN).fill("about:blank");
+
+ let loadPromises = Promise.all(
+ uris.map(uri => BrowserTestUtils.waitForNewTab(gBrowser, uri, false, true))
+ );
+
+ gBrowser.loadTabs(uris, {
+ inBackground: true,
+ triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
+ });
+
+ await loadPromises;
+
+ // Switches to the first tab and closes all of the rest.
+ registerCleanupFunction(async () => {
+ await resetDisplaySharingState();
+ gBrowser.removeAllTabsBut(gBrowser.tabs[0]);
+ });
+});
+
+/**
+ * Tests that when sharing the window that the first tab switch does _not_ show
+ * the warning. This is because we presume that the first tab switch since
+ * starting display sharing is for a tab that is intentionally being shared.
+ */
+add_task(async function testFirstTabSwitchAllowed() {
+ pretendToShareWindow(window, false);
+
+ let targetTab = gBrowser.tabs[1];
+
+ let noWarningPromise = ensureNoWarning();
+ await BrowserTestUtils.switchTab(gBrowser, targetTab);
+ await noWarningPromise;
+
+ await resetDisplaySharingState();
+});
+
+/**
+ * Tests that the second tab switch after sharing is not allowed
+ * without a warning. Also tests that the warning can "allow"
+ * the tab switch to proceed, and that no warning is subsequently
+ * shown for the "allowed" tab. Finally, ensures that if the sharing
+ * session ends and a new session begins, that warnings are shown
+ * again for the allowed tabs.
+ */
+add_task(async function testWarningOnSecondTabSwitch() {
+ pretendToShareWindow(window);
+ let originalTab = gBrowser.selectedTab;
+
+ // pretendToShareWindow will have switched us to the second
+ // tab automatically as the first "freebie" tab switch
+
+ let targetTab = gBrowser.tabs[2];
+
+ // Ensure that we show the warning on the second tab switch
+ let warningPromise = ensureWarning(targetTab);
+ await BrowserTestUtils.switchTab(gBrowser, targetTab);
+ await warningPromise;
+
+ // Not only should we have warned, but we should have prevented
+ // the tab switch from occurring.
+ Assert.equal(
+ gBrowser.selectedTab,
+ originalTab,
+ "Should still be on the original tab."
+ );
+
+ // Now test the "Allow" button in the warning to make sure the tab
+ // switch goes through.
+ let tabSwitchPromise = BrowserTestUtils.waitForEvent(
+ gBrowser,
+ "TabSwitchDone"
+ );
+ let allowButton = document.getElementById(ALLOW_BUTTON_ID);
+ allowButton.click();
+ await tabSwitchPromise;
+
+ Assert.equal(
+ gBrowser.selectedTab,
+ targetTab,
+ "Should have switched tabs to the target."
+ );
+
+ // We shouldn't see a warning when switching back to that first
+ // "freebie" tab.
+ let noWarningPromise = ensureNoWarning();
+ await BrowserTestUtils.switchTab(gBrowser, originalTab);
+ await noWarningPromise;
+
+ Assert.equal(
+ gBrowser.selectedTab,
+ originalTab,
+ "Should have switched tabs back to the original tab."
+ );
+
+ // We shouldn't see a warning when switching back to the tab that
+ // we had just allowed.
+ noWarningPromise = ensureNoWarning();
+ await BrowserTestUtils.switchTab(gBrowser, targetTab);
+ await noWarningPromise;
+
+ Assert.equal(
+ gBrowser.selectedTab,
+ targetTab,
+ "Should have switched tabs back to the target tab."
+ );
+
+ // Reset the sharing state, and make sure that warnings can
+ // be displayed again.
+ await resetDisplaySharingState();
+ pretendToShareWindow(window);
+
+ // pretendToShareWindow will have switched us to the second
+ // tab automatically as the first "freebie" tab switch
+ //
+ // Make sure we get the warning again when switching to the
+ // target tab.
+ warningPromise = ensureWarning(targetTab);
+ await BrowserTestUtils.switchTab(gBrowser, targetTab);
+ await warningPromise;
+
+ await resetDisplaySharingState();
+});
+
+/**
+ * Tests that warnings can be skipped for a session via the
+ * checkbox in the warning panel. Also checks that once the
+ * session ends and a new one begins that warnings are displayed
+ * again.
+ */
+add_task(async function testDisableWarningForSession() {
+ pretendToShareWindow(window);
+
+ // pretendToShareWindow will have switched us to the second
+ // tab automatically as the first "freebie" tab switch
+ let targetTab = gBrowser.tabs[2];
+
+ // Ensure that we show the warning on the second tab switch
+ let warningPromise = ensureWarning(targetTab);
+ await BrowserTestUtils.switchTab(gBrowser, targetTab);
+ await warningPromise;
+
+ // Check the checkbox to suppress warnings for the rest of this session.
+ let checkbox = document.getElementById(
+ DISABLE_WARNING_FOR_SESSION_CHECKBOX_ID
+ );
+ checkbox.checked = true;
+
+ // Now test the "Allow" button in the warning to make sure the tab
+ // switch goes through.
+ let tabSwitchPromise = BrowserTestUtils.waitForEvent(
+ gBrowser,
+ "TabSwitchDone"
+ );
+ let allowButton = document.getElementById(ALLOW_BUTTON_ID);
+ allowButton.click();
+ await tabSwitchPromise;
+
+ Assert.equal(
+ gBrowser.selectedTab,
+ targetTab,
+ "Should have switched tabs to the target tab."
+ );
+
+ // Switching to the 4th and 5th tabs should now not show warnings.
+ let noWarningPromise = ensureNoWarning();
+ await BrowserTestUtils.switchTab(gBrowser, gBrowser.tabs[3]);
+ await noWarningPromise;
+
+ noWarningPromise = ensureNoWarning();
+ await BrowserTestUtils.switchTab(gBrowser, gBrowser.tabs[4]);
+ await noWarningPromise;
+
+ // Reset the sharing state, and make sure that warnings can
+ // be displayed again.
+ await resetDisplaySharingState();
+ pretendToShareWindow(window);
+
+ // pretendToShareWindow will have switched us to the second
+ // tab automatically as the first "freebie" tab switch
+ //
+ // Make sure we get the warning again when switching to the
+ // target tab.
+ warningPromise = ensureWarning(targetTab);
+ await BrowserTestUtils.switchTab(gBrowser, targetTab);
+ await warningPromise;
+
+ await resetDisplaySharingState();
+});
+
+/**
+ * Tests that we don't show a warning when sharing a different
+ * window than the one we're switching tabs in.
+ */
+add_task(async function testOtherWindow() {
+ let otherWin = await BrowserTestUtils.openNewBrowserWindow();
+ await SimpleTest.promiseFocus(window);
+ pretendToShareWindow(otherWin);
+
+ // Switching to the 4th and 5th tabs should now not show warnings.
+ let noWarningPromise = ensureNoWarning();
+ await BrowserTestUtils.switchTab(gBrowser, gBrowser.tabs[3]);
+ await noWarningPromise;
+
+ noWarningPromise = ensureNoWarning();
+ await BrowserTestUtils.switchTab(gBrowser, gBrowser.tabs[4]);
+ await noWarningPromise;
+
+ await BrowserTestUtils.closeWindow(otherWin);
+
+ await resetDisplaySharingState();
+});
+
+/**
+ * Tests that we show a different label when sharing the screen
+ * vs when sharing a window.
+ */
+add_task(async function testWindowVsScreenLabel() {
+ pretendToShareWindow(window);
+
+ // pretendToShareWindow will have switched us to the second
+ // tab automatically as the first "freebie" tab switch.
+ // Let's now switch to the third tab.
+ let targetTab = gBrowser.tabs[2];
+
+ // Ensure that we show the warning on this second tab switch
+ let warningPromise = ensureWarning(targetTab);
+ await BrowserTestUtils.switchTab(gBrowser, targetTab);
+ await warningPromise;
+
+ let windowHeader = document.getElementById(WINDOW_SHARING_HEADER_ID);
+ let screenHeader = document.getElementById(SCREEN_SHARING_HEADER_ID);
+ Assert.ok(
+ !BrowserTestUtils.is_hidden(windowHeader),
+ "Should be showing window sharing header"
+ );
+ Assert.ok(
+ BrowserTestUtils.is_hidden(screenHeader),
+ "Should not be showing screen sharing header"
+ );
+
+ // Reset the sharing state, and then pretend to share the screen.
+ await resetDisplaySharingState();
+ pretendToShareScreen();
+
+ // Ensure that we show the warning on this second tab switch
+ warningPromise = ensureWarning(targetTab);
+ await BrowserTestUtils.switchTab(gBrowser, targetTab);
+ await warningPromise;
+
+ Assert.ok(
+ BrowserTestUtils.is_hidden(windowHeader),
+ "Should not be showing window sharing header"
+ );
+ Assert.ok(
+ !BrowserTestUtils.is_hidden(screenHeader),
+ "Should be showing screen sharing header"
+ );
+ await resetDisplaySharingState();
+});
+
+/**
+ * Tests that tab switching via the keyboard can also trigger the
+ * tab switch warnings.
+ */
+add_task(async function testKeyboardTabSwitching() {
+ let pressCtrlTab = async (expectPanel = false) => {
+ let promise;
+ if (expectPanel) {
+ promise = BrowserTestUtils.waitForEvent(ctrlTab.panel, "popupshown");
+ } else {
+ promise = BrowserTestUtils.waitForEvent(document, "keyup");
+ }
+ EventUtils.synthesizeKey("VK_TAB", {
+ ctrlKey: true,
+ shiftKey: false,
+ });
+ await promise;
+ };
+
+ let releaseCtrl = async () => {
+ let promise;
+ if (ctrlTab.isOpen) {
+ promise = BrowserTestUtils.waitForEvent(ctrlTab.panel, "popuphidden");
+ } else {
+ promise = BrowserTestUtils.waitForEvent(document, "keyup");
+ }
+ EventUtils.synthesizeKey("VK_CONTROL", { type: "keyup" });
+ return promise;
+ };
+
+ // Ensure that the (on by default) ctrl-tab switch panel is enabled.
+ await SpecialPowers.pushPrefEnv({
+ set: [[CTRL_TAB_RUO_PREF, true]],
+ });
+
+ pretendToShareWindow(window);
+ let originalTab = gBrowser.selectedTab;
+ await pressCtrlTab(true);
+
+ // The Ctrl-Tab MRU list should be:
+ // 0: Second tab (currently selected)
+ // 1: First tab
+ // 2: Last tab
+ //
+ // Having pressed Ctrl-Tab once, 1 (First tab) is selected in the
+ // panel. We want a tab that will warn, so let's hit Ctrl-Tab again
+ // to choose 2 (Last tab).
+ let targetTab = ctrlTab.tabList[2];
+ await pressCtrlTab();
+
+ let warningPromise = ensureWarning(targetTab);
+ await releaseCtrl();
+ await warningPromise;
+
+ // Hide the warning without allowing the tab switch.
+ let panel = document.getElementById(WARNING_PANEL_ID);
+ panel.hidePopup();
+
+ Assert.equal(
+ gBrowser.selectedTab,
+ originalTab,
+ "Should not have changed from the original tab."
+ );
+
+ // Now switch to the in-order tab switching keyboard shortcut mode.
+ await SpecialPowers.popPrefEnv();
+ await SpecialPowers.pushPrefEnv({
+ set: [[CTRL_TAB_RUO_PREF, false]],
+ });
+
+ // Hitting Ctrl-Tab should choose the _next_ tab over from
+ // the originalTab, which should be the third tab.
+ targetTab = gBrowser.tabs[2];
+
+ warningPromise = ensureWarning(targetTab);
+ await pressCtrlTab();
+ await warningPromise;
+
+ await resetDisplaySharingState();
+});
diff --git a/browser/base/content/test/webrtc/browser_webrtc_hooks.js b/browser/base/content/test/webrtc/browser_webrtc_hooks.js
new file mode 100644
index 0000000000..e980b15286
--- /dev/null
+++ b/browser/base/content/test/webrtc/browser_webrtc_hooks.js
@@ -0,0 +1,371 @@
+/* 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/. */
+
+const ORIGIN = "https://example.com";
+
+async function tryPeerConnection(browser, expectedError = null) {
+ let errtype = await SpecialPowers.spawn(browser, [], async function () {
+ let pc = new content.RTCPeerConnection();
+ try {
+ await pc.createOffer({ offerToReceiveAudio: true });
+ return null;
+ } catch (err) {
+ return err.name;
+ }
+ });
+
+ let detail = expectedError
+ ? `createOffer() threw a ${expectedError}`
+ : "createOffer() succeeded";
+ is(errtype, expectedError, detail);
+}
+
+// Helper for tests that use the peer-request-allowed and -blocked events.
+// A test that expects some of those events does the following:
+// - call Events.on() before the test to setup event handlers
+// - call Events.expect(name) after a specific event is expected to have
+// occured. This will fail if the event didn't occur, and will return
+// the details passed to the handler for furhter checking.
+// - call Events.off() at the end of the test to clean up. At this point, if
+// any events were triggered that the test did not expect, the test fails.
+const Events = {
+ events: ["peer-request-allowed", "peer-request-blocked"],
+ details: new Map(),
+ handlers: new Map(),
+ on() {
+ for (let event of this.events) {
+ let handler = data => {
+ if (this.details.has(event)) {
+ ok(false, `Got multiple ${event} events`);
+ }
+ this.details.set(event, data);
+ };
+ webrtcUI.on(event, handler);
+ this.handlers.set(event, handler);
+ }
+ },
+ expect(event) {
+ let result = this.details.get(event);
+ isnot(result, undefined, `${event} event was triggered`);
+ this.details.delete(event);
+
+ // All events should have a good origin
+ is(result.origin, ORIGIN, `${event} event has correct origin`);
+
+ return result;
+ },
+ off() {
+ for (let event of this.events) {
+ webrtcUI.off(event, this.handlers.get(event));
+ this.handlers.delete(event);
+ }
+ for (let [event] of this.details) {
+ ok(false, `Got unexpected event ${event}`);
+ }
+ },
+};
+
+var gTests = [
+ {
+ desc: "Basic peer-request-allowed event",
+ run: async function testPeerRequestEvent(browser) {
+ Events.on();
+
+ await tryPeerConnection(browser);
+
+ let details = Events.expect("peer-request-allowed");
+ isnot(
+ details.callID,
+ undefined,
+ "peer-request-allowed event includes callID"
+ );
+ isnot(
+ details.windowID,
+ undefined,
+ "peer-request-allowed event includes windowID"
+ );
+
+ Events.off();
+ },
+ },
+
+ {
+ desc: "Immediate peer connection blocker can allow",
+ run: async function testBlocker(browser) {
+ Events.on();
+
+ let blockerCalled = false;
+ let blocker = params => {
+ is(
+ params.origin,
+ ORIGIN,
+ "Peer connection blocker origin parameter is correct"
+ );
+ blockerCalled = true;
+ return "allow";
+ };
+
+ webrtcUI.addPeerConnectionBlocker(blocker);
+
+ await tryPeerConnection(browser);
+ is(blockerCalled, true, "Blocker was called");
+ Events.expect("peer-request-allowed");
+
+ webrtcUI.removePeerConnectionBlocker(blocker);
+ Events.off();
+ },
+ },
+
+ {
+ desc: "Deferred peer connection blocker can allow",
+ run: async function testDeferredBlocker(browser) {
+ Events.on();
+
+ let blocker = params => Promise.resolve("allow");
+ webrtcUI.addPeerConnectionBlocker(blocker);
+
+ await tryPeerConnection(browser);
+ Events.expect("peer-request-allowed");
+
+ webrtcUI.removePeerConnectionBlocker(blocker);
+ Events.off();
+ },
+ },
+
+ {
+ desc: "Immediate peer connection blocker can deny",
+ run: async function testBlockerDeny(browser) {
+ Events.on();
+
+ let blocker = params => "deny";
+ webrtcUI.addPeerConnectionBlocker(blocker);
+
+ await tryPeerConnection(browser, "NotAllowedError");
+
+ Events.expect("peer-request-blocked");
+
+ webrtcUI.removePeerConnectionBlocker(blocker);
+ Events.off();
+ },
+ },
+
+ {
+ desc: "Multiple blockers work (both allow)",
+ run: async function testMultipleAllowBlockers(browser) {
+ Events.on();
+
+ let blocker1Called = false,
+ blocker1 = params => {
+ blocker1Called = true;
+ return "allow";
+ };
+ webrtcUI.addPeerConnectionBlocker(blocker1);
+
+ let blocker2Called = false,
+ blocker2 = params => {
+ blocker2Called = true;
+ return "allow";
+ };
+ webrtcUI.addPeerConnectionBlocker(blocker2);
+
+ await tryPeerConnection(browser);
+
+ Events.expect("peer-request-allowed");
+ ok(blocker1Called, "First blocker was called");
+ ok(blocker2Called, "Second blocker was called");
+
+ webrtcUI.removePeerConnectionBlocker(blocker1);
+ webrtcUI.removePeerConnectionBlocker(blocker2);
+ Events.off();
+ },
+ },
+
+ {
+ desc: "Multiple blockers work (allow then deny)",
+ run: async function testAllowDenyBlockers(browser) {
+ Events.on();
+
+ let blocker1Called = false,
+ blocker1 = params => {
+ blocker1Called = true;
+ return "allow";
+ };
+ webrtcUI.addPeerConnectionBlocker(blocker1);
+
+ let blocker2Called = false,
+ blocker2 = params => {
+ blocker2Called = true;
+ return "deny";
+ };
+ webrtcUI.addPeerConnectionBlocker(blocker2);
+
+ await tryPeerConnection(browser, "NotAllowedError");
+
+ Events.expect("peer-request-blocked");
+ ok(blocker1Called, "First blocker was called");
+ ok(blocker2Called, "Second blocker was called");
+
+ webrtcUI.removePeerConnectionBlocker(blocker1);
+ webrtcUI.removePeerConnectionBlocker(blocker2);
+ Events.off();
+ },
+ },
+
+ {
+ desc: "Multiple blockers work (deny first)",
+ run: async function testDenyAllowBlockers(browser) {
+ Events.on();
+
+ let blocker1Called = false,
+ blocker1 = params => {
+ blocker1Called = true;
+ return "deny";
+ };
+ webrtcUI.addPeerConnectionBlocker(blocker1);
+
+ let blocker2Called = false,
+ blocker2 = params => {
+ blocker2Called = true;
+ return "allow";
+ };
+ webrtcUI.addPeerConnectionBlocker(blocker2);
+
+ await tryPeerConnection(browser, "NotAllowedError");
+
+ Events.expect("peer-request-blocked");
+ ok(blocker1Called, "First blocker was called");
+ ok(
+ !blocker2Called,
+ "Peer connection blocker after a deny is not invoked"
+ );
+
+ webrtcUI.removePeerConnectionBlocker(blocker1);
+ webrtcUI.removePeerConnectionBlocker(blocker2);
+ Events.off();
+ },
+ },
+
+ {
+ desc: "Blockers may be removed",
+ run: async function testRemoveBlocker(browser) {
+ Events.on();
+
+ let blocker1Called = false,
+ blocker1 = params => {
+ blocker1Called = true;
+ return "allow";
+ };
+ webrtcUI.addPeerConnectionBlocker(blocker1);
+
+ let blocker2Called = false,
+ blocker2 = params => {
+ blocker2Called = true;
+ return "allow";
+ };
+ webrtcUI.addPeerConnectionBlocker(blocker2);
+ webrtcUI.removePeerConnectionBlocker(blocker1);
+
+ await tryPeerConnection(browser);
+
+ Events.expect("peer-request-allowed");
+
+ ok(!blocker1Called, "Removed peer connection blocker is not invoked");
+ ok(blocker2Called, "Second peer connection blocker was invoked");
+
+ webrtcUI.removePeerConnectionBlocker(blocker2);
+ Events.off();
+ },
+ },
+
+ {
+ desc: "Blocker that throws is ignored",
+ run: async function testBlockerThrows(browser) {
+ Events.on();
+ let blocker1Called = false,
+ blocker1 = params => {
+ blocker1Called = true;
+ throw new Error("kaboom");
+ };
+ webrtcUI.addPeerConnectionBlocker(blocker1);
+
+ let blocker2Called = false,
+ blocker2 = params => {
+ blocker2Called = true;
+ return "allow";
+ };
+ webrtcUI.addPeerConnectionBlocker(blocker2);
+
+ await tryPeerConnection(browser);
+
+ Events.expect("peer-request-allowed");
+ ok(blocker1Called, "First blocker was invoked");
+ ok(blocker2Called, "Second blocker was invoked");
+
+ webrtcUI.removePeerConnectionBlocker(blocker1);
+ webrtcUI.removePeerConnectionBlocker(blocker2);
+ Events.off();
+ },
+ },
+
+ {
+ desc: "Cancel peer request",
+ run: async function testBlockerCancel(browser) {
+ let blocker,
+ blockerPromise = new Promise(resolve => {
+ blocker = params => {
+ resolve();
+ // defer indefinitely
+ return new Promise(innerResolve => {});
+ };
+ });
+ webrtcUI.addPeerConnectionBlocker(blocker);
+
+ await SpecialPowers.spawn(browser, [], async function () {
+ new content.RTCPeerConnection().createOffer({
+ offerToReceiveAudio: true,
+ });
+ });
+
+ await blockerPromise;
+
+ let eventPromise = new Promise(resolve => {
+ webrtcUI.on("peer-request-cancel", function listener(details) {
+ resolve(details);
+ webrtcUI.off("peer-request-cancel", listener);
+ });
+ });
+
+ await SpecialPowers.spawn(browser, [], async function () {
+ content.location.reload();
+ });
+
+ let details = await eventPromise;
+ isnot(
+ details.callID,
+ undefined,
+ "peer-request-cancel event includes callID"
+ );
+ is(
+ details.origin,
+ ORIGIN,
+ "peer-request-cancel event has correct origin"
+ );
+
+ webrtcUI.removePeerConnectionBlocker(blocker);
+ },
+ },
+];
+
+add_task(async function test() {
+ await runTests(gTests, {
+ skipObserverVerification: true,
+ cleanup() {
+ is(
+ webrtcUI.peerConnectionBlockers.size,
+ 0,
+ "Peer connection blockers list is empty"
+ );
+ },
+ });
+});
diff --git a/browser/base/content/test/webrtc/get_user_media.html b/browser/base/content/test/webrtc/get_user_media.html
new file mode 100644
index 0000000000..8003785e14
--- /dev/null
+++ b/browser/base/content/test/webrtc/get_user_media.html
@@ -0,0 +1,124 @@
+<!DOCTYPE html>
+<html>
+<head><meta charset="UTF-8"></head>
+<body>
+<div id="message"></div>
+<script>
+// Specifies whether we are using fake streams to run this automation
+var useFakeStreams = true;
+try {
+ var audioDevice = SpecialPowers.getCharPref("media.audio_loopback_dev");
+ var videoDevice = SpecialPowers.getCharPref("media.video_loopback_dev");
+ dump("TEST DEVICES: Using media devices:\n");
+ dump("audio: " + audioDevice + "\nvideo: " + videoDevice + "\n");
+ useFakeStreams = false;
+} catch (e) {
+ dump("TEST DEVICES: No test devices found (in media.{audio,video}_loopback_dev, using fake streams.\n");
+ useFakeStreams = true;
+}
+
+function message(m) {
+ // eslint-disable-next-line no-unsanitized/property
+ document.getElementById("message").innerHTML += `${m}<br>`;
+ top.postMessage(m, "*");
+}
+
+var gStreams = [];
+var gVideoEvents = [];
+var gAudioEvents = [];
+
+async function requestDevice(aAudio, aVideo, aShare, aBadDevice = false) {
+ const opts = {video: aVideo, audio: aAudio};
+ if (aShare) {
+ opts.video = { mediaSource: aShare };
+ SpecialPowers.wrap(document).notifyUserGestureActivation();
+ }
+ if (useFakeStreams) {
+ opts.fake = true;
+ }
+
+ if (aVideo && aBadDevice) {
+ opts.video = {
+ deviceId: "bad device",
+ };
+ opts.fake = true;
+ }
+
+ if (aAudio && aBadDevice) {
+ opts.audio = {
+ deviceId: "bad device",
+ };
+ opts.fake = true;
+ }
+
+ try {
+ const stream = await navigator.mediaDevices.getUserMedia(opts)
+ gStreams.push(stream);
+
+ const videoTrack = stream.getVideoTracks()[0];
+ if (videoTrack) {
+ for (const name of ["mute", "unmute", "ended"]) {
+ videoTrack.addEventListener(name, () => gVideoEvents.push(name));
+ }
+ }
+
+ const audioTrack = stream.getAudioTracks()[0];
+ if (audioTrack) {
+ for (const name of ["mute", "unmute", "ended"]) {
+ audioTrack.addEventListener(name, () => gAudioEvents.push(name));
+ }
+ }
+ message("ok");
+ } catch (err) {
+ message("error: " + err);
+ }
+}
+
+let selectedAudioOutputId;
+async function requestAudioOutput(options = {}) {
+ const audioOutputOptions = options.requestSameDevice && {
+ deviceId: selectedAudioOutputId,
+ };
+ SpecialPowers.wrap(document).notifyUserGestureActivation();
+ try {
+ ({ deviceId: selectedAudioOutputId } =
+ await navigator.mediaDevices.selectAudioOutput(audioOutputOptions));
+ message("ok");
+ } catch (err) {
+ message("error: " + err);
+ }
+}
+
+message("pending");
+
+function stopTracks(aKind) {
+ for (let stream of gStreams) {
+ for (let track of stream.getTracks()) {
+ if (track.kind == aKind) {
+ track.stop();
+ stream.removeTrack(track);
+ }
+ }
+ }
+ gStreams = gStreams.filter(s => !!s.getTracks().length);
+ if (aKind == "video") {
+ gVideoEvents = [];
+ } else if (aKind == "audio") {
+ gAudioEvents = [];
+ }
+}
+
+function closeStream() {
+ for (let stream of gStreams) {
+ for (let track of stream.getTracks()) {
+ track.stop();
+ }
+ }
+ gStreams = [];
+ gVideoEvents = [];
+ gAudioEvents = [];
+ message("closed");
+}
+</script>
+</body>
+</html>
diff --git a/browser/base/content/test/webrtc/get_user_media2.html b/browser/base/content/test/webrtc/get_user_media2.html
new file mode 100644
index 0000000000..810b00d47b
--- /dev/null
+++ b/browser/base/content/test/webrtc/get_user_media2.html
@@ -0,0 +1,107 @@
+<!DOCTYPE html>
+<html>
+<head><meta charset="UTF-8"></head>
+<body>
+<div id="message"></div>
+<script>
+// Specifies whether we are using fake streams to run this automation
+var useFakeStreams = true;
+try {
+ var audioDevice = SpecialPowers.getCharPref("media.audio_loopback_dev");
+ var videoDevice = SpecialPowers.getCharPref("media.video_loopback_dev");
+ dump("TEST DEVICES: Using media devices:\n");
+ dump("audio: " + audioDevice + "\nvideo: " + videoDevice + "\n");
+ useFakeStreams = false;
+} catch (e) {
+ dump("TEST DEVICES: No test devices found (in media.{audio,video}_loopback_dev, using fake streams.\n");
+ useFakeStreams = true;
+}
+
+function message(m) {
+ // eslint-disable-next-line no-unsanitized/property
+ document.getElementById("message").innerHTML += `${m}<br>`;
+ top.postMessage(m, "*");
+}
+
+var gStreams = [];
+var gVideoEvents = [];
+var gAudioEvents = [];
+
+async function requestDevice(aAudio, aVideo, aShare, aBadDevice = false) {
+ const opts = {video: aVideo, audio: aAudio};
+ if (aShare) {
+ opts.video = { mediaSource: aShare };
+ }
+ if (useFakeStreams) {
+ opts.fake = true;
+ }
+
+ if (aVideo && aBadDevice) {
+ opts.video = {
+ deviceId: "bad device",
+ };
+ opts.fake = true;
+ }
+
+ if (aAudio && aBadDevice) {
+ opts.audio = {
+ deviceId: "bad device",
+ };
+ opts.fake = true;
+ }
+
+ try {
+ const stream = await navigator.mediaDevices.getUserMedia(opts)
+ gStreams.push(stream);
+
+ const videoTrack = stream.getVideoTracks()[0];
+ if (videoTrack) {
+ for (const name of ["mute", "unmute", "ended"]) {
+ videoTrack.addEventListener(name, () => gVideoEvents.push(name));
+ }
+ }
+
+ const audioTrack = stream.getAudioTracks()[0];
+ if (audioTrack) {
+ for (const name of ["mute", "unmute", "ended"]) {
+ audioTrack.addEventListener(name, () => gAudioEvents.push(name));
+ }
+ }
+ message("ok");
+ } catch (err) {
+ message("error: " + err);
+ }
+}
+message("pending");
+
+function stopTracks(aKind) {
+ for (let stream of gStreams) {
+ for (let track of stream.getTracks()) {
+ if (track.kind == aKind) {
+ track.stop();
+ stream.removeTrack(track);
+ }
+ }
+ }
+ gStreams = gStreams.filter(s => !!s.getTracks().length);
+ if (aKind == "video") {
+ gVideoEvents = [];
+ } else if (aKind == "audio") {
+ gAudioEvents = [];
+ }
+}
+
+function closeStream() {
+ for (let stream of gStreams) {
+ for (let track of stream.getTracks()) {
+ track.stop();
+ }
+ }
+ gStreams = [];
+ gVideoEvents = [];
+ gAudioEvents = [];
+ message("closed");
+}
+</script>
+</body>
+</html>
diff --git a/browser/base/content/test/webrtc/get_user_media_in_frame.html b/browser/base/content/test/webrtc/get_user_media_in_frame.html
new file mode 100644
index 0000000000..5bffd9cb8c
--- /dev/null
+++ b/browser/base/content/test/webrtc/get_user_media_in_frame.html
@@ -0,0 +1,98 @@
+<!DOCTYPE html>
+<html>
+<head><meta charset="UTF-8"></head>
+<body>
+<div id="message"></div>
+<script>
+// Specifies whether we are using fake streams to run this automation
+var useFakeStreams = true;
+try {
+ var audioDevice = SpecialPowers.getCharPref("media.audio_loopback_dev");
+ var videoDevice = SpecialPowers.getCharPref("media.video_loopback_dev");
+ dump("TEST DEVICES: Using media devices:\n");
+ dump("audio: " + audioDevice + "\nvideo: " + videoDevice + "\n");
+ useFakeStreams = false;
+} catch (e) {
+ dump("TEST DEVICES: No test devices found (in media.{audio,video}_loopback_dev, using fake streams.\n");
+ useFakeStreams = true;
+}
+
+function message(m) {
+ // eslint-disable-next-line no-unsanitized/property
+ document.getElementById("message").innerHTML += `${m}<br>`;
+ window.top.postMessage(m, "*");
+}
+
+var gStreams = [];
+
+function requestDevice(aAudio, aVideo, aShare) {
+ var opts = {video: aVideo, audio: aAudio};
+ if (aShare) {
+ opts.video = { mediaSource: aShare };
+ } else if (useFakeStreams) {
+ opts.fake = true;
+ }
+
+ window.navigator.mediaDevices.getUserMedia(opts)
+ .then(stream => {
+ gStreams.push(stream);
+ message("ok");
+ }, err => message("error: " + err));
+}
+message("pending");
+
+function stopTracks(aKind) {
+ for (let stream of gStreams) {
+ for (let track of stream.getTracks()) {
+ if (track.kind == aKind) {
+ track.stop();
+ stream.removeTrack(track);
+ }
+ }
+ }
+ gStreams = gStreams.filter(s => !!s.getTracks().length);
+}
+
+function closeStream() {
+ for (let stream of gStreams) {
+ for (let track of stream.getTracks()) {
+ track.stop();
+ }
+ }
+ gStreams = [];
+ message("closed");
+}
+
+const query = document.location.search.substring(1);
+const params = new URLSearchParams(query);
+const origins = params.getAll("origin");
+const nested = params.getAll("nested");
+const gumpage = nested.length
+ ? "get_user_media_in_frame.html"
+ : "get_user_media.html";
+let id = 1;
+if (!origins.length) {
+ for(let i = 0; i < 2; ++i) {
+ const iframe = document.createElement("iframe");
+ iframe.id = `frame${id++}`;
+ iframe.src = gumpage;
+ document.body.appendChild(iframe);
+ }
+} else {
+ for (let origin of origins) {
+ const iframe = document.createElement("iframe");
+ iframe.id = `frame${id++}`;
+ const base = new URL("browser/browser/base/content/test/webrtc/", origin).href;
+ const url = new URL(gumpage, base);
+ for (let nestedOrigin of nested) {
+ url.searchParams.append("origin", nestedOrigin);
+ }
+ iframe.src = url.href;
+ iframe.allow = "camera;microphone";
+ iframe.style = `width:${300 * Math.max(1, nested.length) + (nested.length ? 50 : 0)}px;`;
+ document.body.appendChild(iframe);
+ }
+}
+</script>
+</body>
+</html>
diff --git a/browser/base/content/test/webrtc/get_user_media_in_xorigin_frame.html b/browser/base/content/test/webrtc/get_user_media_in_xorigin_frame.html
new file mode 100644
index 0000000000..6a1c88cbe1
--- /dev/null
+++ b/browser/base/content/test/webrtc/get_user_media_in_xorigin_frame.html
@@ -0,0 +1,71 @@
+<!DOCTYPE html>
+<html>
+<head><meta charset="UTF-8"></head>
+<body>
+<div id="message"></div>
+<script>
+// Specifies whether we are using fake streams to run this automation
+var useFakeStreams = true;
+try {
+ var audioDevice = SpecialPowers.getCharPref("media.audio_loopback_dev");
+ var videoDevice = SpecialPowers.getCharPref("media.video_loopback_dev");
+ dump("TEST DEVICES: Using media devices:\n");
+ dump("audio: " + audioDevice + "\nvideo: " + videoDevice + "\n");
+ useFakeStreams = false;
+} catch (e) {
+ dump("TEST DEVICES: No test devices found (in media.{audio,video}_loopback_dev, using fake streams.\n");
+ useFakeStreams = true;
+}
+
+function message(m) {
+ // eslint-disable-next-line no-unsanitized/property
+ document.getElementById("message").innerHTML += `${m}<br>`;
+ window.parent.postMessage(m, "*");
+}
+
+var gStreams = [];
+
+function requestDevice(aAudio, aVideo, aShare) {
+ var opts = {video: aVideo, audio: aAudio};
+ if (aShare) {
+ opts.video = { mediaSource: aShare };
+ } else if (useFakeStreams) {
+ opts.fake = true;
+ }
+
+ window.navigator.mediaDevices.getUserMedia(opts)
+ .then(stream => {
+ gStreams.push(stream);
+ message("ok");
+ }, err => message("error: " + err));
+}
+message("pending");
+
+function stopTracks(aKind) {
+ for (let stream of gStreams) {
+ for (let track of stream.getTracks()) {
+ if (track.kind == aKind) {
+ track.stop();
+ stream.removeTrack(track);
+ }
+ }
+ }
+ gStreams = gStreams.filter(s => !!s.getTracks().length);
+}
+
+function closeStream() {
+ for (let stream of gStreams) {
+ for (let track of stream.getTracks()) {
+ track.stop();
+ }
+ }
+ gStreams = [];
+ message("closed");
+}
+</script>
+<iframe id="frame1" allow="camera;microphone;display-capture" src="https://test1.example.com/browser/browser/base/content/test/webrtc/get_user_media.html"></iframe>
+<iframe id="frame2" allow="camera;microphone" src="https://test1.example.com/browser/browser/base/content/test/webrtc/get_user_media.html"></iframe>
+<iframe id="frame3" src="https://test1.example.com/browser/browser/base/content/test/webrtc/get_user_media.html"></iframe>
+<iframe id="frame4" allow="camera *;microphone *;display-capture *" src="https://test1.example.com/browser/browser/base/content/test/webrtc/get_user_media.html"></iframe>
+</body>
+</html>
diff --git a/browser/base/content/test/webrtc/get_user_media_in_xorigin_frame_ancestor.html b/browser/base/content/test/webrtc/get_user_media_in_xorigin_frame_ancestor.html
new file mode 100644
index 0000000000..bed446a7da
--- /dev/null
+++ b/browser/base/content/test/webrtc/get_user_media_in_xorigin_frame_ancestor.html
@@ -0,0 +1,12 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+<title>Permissions Test</title>
+<meta http-equiv="Content-Type" content="text/html;charset=utf-8"></meta>
+</head>
+<body>
+ <iframe id="frameAncestor"
+ src="https://test2.example.com/browser/browser/base/content/test/webrtc/get_user_media_in_xorigin_frame.html"
+ allow="camera 'src' https://test1.example.com;microphone 'src' https://test1.example.com;display-capture 'src' https://test1.example.com"></iframe>
+</body>
+</html>
diff --git a/browser/base/content/test/webrtc/gracePeriod/browser.ini b/browser/base/content/test/webrtc/gracePeriod/browser.ini
new file mode 100644
index 0000000000..0f9503fe81
--- /dev/null
+++ b/browser/base/content/test/webrtc/gracePeriod/browser.ini
@@ -0,0 +1,15 @@
+[DEFAULT]
+support-files =
+ ../get_user_media.html
+ ../get_user_media_in_frame.html
+ ../get_user_media_in_xorigin_frame.html
+ ../get_user_media_in_xorigin_frame_ancestor.html
+ ../head.js
+prefs =
+ privacy.webrtc.allowSilencingNotifications=true
+ privacy.webrtc.legacyGlobalIndicator=false
+ privacy.webrtc.sharedTabWarning=false
+
+[../browser_devices_get_user_media_grace.js]
+skip-if =
+ os == "win" && os_version == "6.1" # Skip on Azure - frequent failure
diff --git a/browser/base/content/test/webrtc/head.js b/browser/base/content/test/webrtc/head.js
new file mode 100644
index 0000000000..13526e91b6
--- /dev/null
+++ b/browser/base/content/test/webrtc/head.js
@@ -0,0 +1,1338 @@
+var { PermissionTestUtils } = ChromeUtils.importESModule(
+ "resource://testing-common/PermissionTestUtils.sys.mjs"
+);
+
+const PREF_PERMISSION_FAKE = "media.navigator.permission.fake";
+const PREF_AUDIO_LOOPBACK = "media.audio_loopback_dev";
+const PREF_VIDEO_LOOPBACK = "media.video_loopback_dev";
+const PREF_FAKE_STREAMS = "media.navigator.streams.fake";
+const PREF_FOCUS_SOURCE = "media.getusermedia.window.focus_source.enabled";
+
+const STATE_CAPTURE_ENABLED = Ci.nsIMediaManagerService.STATE_CAPTURE_ENABLED;
+const STATE_CAPTURE_DISABLED = Ci.nsIMediaManagerService.STATE_CAPTURE_DISABLED;
+
+const USING_LEGACY_INDICATOR = Services.prefs.getBoolPref(
+ "privacy.webrtc.legacyGlobalIndicator",
+ false
+);
+
+const ALLOW_SILENCING_NOTIFICATIONS = Services.prefs.getBoolPref(
+ "privacy.webrtc.allowSilencingNotifications",
+ false
+);
+
+const SHOW_GLOBAL_MUTE_TOGGLES = Services.prefs.getBoolPref(
+ "privacy.webrtc.globalMuteToggles",
+ false
+);
+
+const INDICATOR_PATH = USING_LEGACY_INDICATOR
+ ? "chrome://browser/content/webrtcLegacyIndicator.xhtml"
+ : "chrome://browser/content/webrtcIndicator.xhtml";
+
+const IS_MAC = AppConstants.platform == "macosx";
+
+const SHARE_SCREEN = 1;
+const SHARE_WINDOW = 2;
+
+let observerTopics = [
+ "getUserMedia:response:allow",
+ "getUserMedia:revoke",
+ "getUserMedia:response:deny",
+ "getUserMedia:request",
+ "recording-device-events",
+ "recording-window-ended",
+];
+
+// Structured hierarchy of subframes. Keys are frame id:s, The children member
+// contains nested sub frames if any. The noTest member make a frame be ignored
+// for testing if true.
+let gObserveSubFrames = {};
+// Object of subframes to test. Each element contains the members bc and id, for
+// the frames BrowsingContext and id, respectively.
+let gSubFramesToTest = [];
+let gBrowserContextsToObserve = [];
+
+function whenDelayedStartupFinished(aWindow) {
+ return TestUtils.topicObserved(
+ "browser-delayed-startup-finished",
+ subject => subject == aWindow
+ );
+}
+
+function promiseIndicatorWindow() {
+ let startTime = performance.now();
+
+ // We don't show the legacy indicator window on Mac.
+ if (USING_LEGACY_INDICATOR && IS_MAC) {
+ return Promise.resolve();
+ }
+
+ return new Promise(resolve => {
+ Services.obs.addObserver(function obs(win) {
+ win.addEventListener(
+ "load",
+ function () {
+ if (win.location.href !== INDICATOR_PATH) {
+ info("ignoring a window with this url: " + win.location.href);
+ return;
+ }
+
+ Services.obs.removeObserver(obs, "domwindowopened");
+ executeSoon(() => {
+ ChromeUtils.addProfilerMarker("promiseIndicatorWindow", {
+ startTime,
+ category: "Test",
+ });
+ resolve(win);
+ });
+ },
+ { once: true }
+ );
+ }, "domwindowopened");
+ });
+}
+
+async function assertWebRTCIndicatorStatus(expected) {
+ let ui = ChromeUtils.import("resource:///modules/webrtcUI.jsm").webrtcUI;
+ let expectedState = expected ? "visible" : "hidden";
+ let msg = "WebRTC indicator " + expectedState;
+ if (!expected && ui.showGlobalIndicator) {
+ // It seems the global indicator is not always removed synchronously
+ // in some cases.
+ await TestUtils.waitForCondition(
+ () => !ui.showGlobalIndicator,
+ "waiting for the global indicator to be hidden"
+ );
+ }
+ is(ui.showGlobalIndicator, !!expected, msg);
+
+ let expectVideo = false,
+ expectAudio = false,
+ expectScreen = "";
+ if (expected) {
+ if (expected.video) {
+ expectVideo = true;
+ }
+ if (expected.audio) {
+ expectAudio = true;
+ }
+ if (expected.screen) {
+ expectScreen = expected.screen;
+ }
+ }
+ is(
+ Boolean(ui.showCameraIndicator),
+ expectVideo,
+ "camera global indicator as expected"
+ );
+ is(
+ Boolean(ui.showMicrophoneIndicator),
+ expectAudio,
+ "microphone global indicator as expected"
+ );
+ is(
+ ui.showScreenSharingIndicator,
+ expectScreen,
+ "screen global indicator as expected"
+ );
+
+ for (let win of Services.wm.getEnumerator("navigator:browser")) {
+ let menu = win.document.getElementById("tabSharingMenu");
+ is(
+ !!menu && !menu.hidden,
+ !!expected,
+ "WebRTC menu should be " + expectedState
+ );
+ }
+
+ if (USING_LEGACY_INDICATOR && IS_MAC) {
+ return;
+ }
+
+ if (!expected) {
+ let win = Services.wm.getMostRecentWindow("Browser:WebRTCGlobalIndicator");
+ if (win) {
+ await new Promise((resolve, reject) => {
+ win.addEventListener("unload", function listener(e) {
+ if (e.target == win.document) {
+ win.removeEventListener("unload", listener);
+ executeSoon(resolve);
+ }
+ });
+ });
+ }
+ }
+
+ let indicator = Services.wm.getEnumerator("Browser:WebRTCGlobalIndicator");
+ let hasWindow = indicator.hasMoreElements();
+ is(hasWindow, !!expected, "popup " + msg);
+ if (hasWindow) {
+ let document = indicator.getNext().document;
+ let docElt = document.documentElement;
+
+ if (document.readyState != "complete") {
+ info("Waiting for the sharing indicator's document to load");
+ await new Promise(resolve => {
+ document.addEventListener(
+ "readystatechange",
+ function onReadyStateChange() {
+ if (document.readyState != "complete") {
+ return;
+ }
+ document.removeEventListener(
+ "readystatechange",
+ onReadyStateChange
+ );
+ executeSoon(resolve);
+ }
+ );
+ });
+ }
+
+ if (
+ !USING_LEGACY_INDICATOR &&
+ expected.screen &&
+ expected.screen.startsWith("Window")
+ ) {
+ // These tests were originally written to express window sharing by
+ // having expected.screen start with "Window". This meant that the
+ // legacy indicator is expected to have the "sharingscreen" attribute
+ // set to true when sharing a window.
+ //
+ // The new indicator, however, differentiates between screen, window
+ // and browser window sharing. If we're using the new indicator, we
+ // update the expectations accordingly. This can be removed once we
+ // are able to remove the tests for the legacy indicator.
+ expected.screen = null;
+ expected.window = true;
+ }
+
+ if (!USING_LEGACY_INDICATOR && !SHOW_GLOBAL_MUTE_TOGGLES) {
+ expected.video = false;
+ expected.audio = false;
+
+ let visible = docElt.getAttribute("visible") == "true";
+
+ if (!expected.screen && !expected.window && !expected.browserwindow) {
+ ok(!visible, "Indicator should not be visible in this configuation.");
+ } else {
+ ok(visible, "Indicator should be visible.");
+ }
+ }
+
+ for (let item of ["video", "audio", "screen", "window", "browserwindow"]) {
+ let expectedValue;
+
+ if (USING_LEGACY_INDICATOR) {
+ expectedValue = expected && expected[item] ? "true" : "";
+ } else {
+ expectedValue = expected && expected[item] ? "true" : null;
+ }
+
+ is(
+ docElt.getAttribute("sharing" + item),
+ expectedValue,
+ item + " global indicator attribute as expected"
+ );
+ }
+
+ ok(!indicator.hasMoreElements(), "only one global indicator window");
+ }
+}
+
+function promiseNotificationShown(notification) {
+ let win = notification.browser.ownerGlobal;
+ if (win.PopupNotifications.panel.state == "open") {
+ return Promise.resolve();
+ }
+ let panelPromise = BrowserTestUtils.waitForPopupEvent(
+ win.PopupNotifications.panel,
+ "shown"
+ );
+ notification.reshow();
+ return panelPromise;
+}
+
+function ignoreEvent(aSubject, aTopic, aData) {
+ // With e10s disabled, our content script receives notifications for the
+ // preview displayed in our screen sharing permission prompt; ignore them.
+ const kBrowserURL = AppConstants.BROWSER_CHROME_URL;
+ const nsIPropertyBag = Ci.nsIPropertyBag;
+ if (
+ aTopic == "recording-device-events" &&
+ aSubject.QueryInterface(nsIPropertyBag).getProperty("requestURL") ==
+ kBrowserURL
+ ) {
+ return true;
+ }
+ if (aTopic == "recording-window-ended") {
+ let win = Services.wm.getOuterWindowWithId(aData).top;
+ if (win.document.documentURI == kBrowserURL) {
+ return true;
+ }
+ }
+ return false;
+}
+
+function expectObserverCalledInProcess(aTopic, aCount = 1) {
+ let promises = [];
+ for (let count = aCount; count > 0; count--) {
+ promises.push(TestUtils.topicObserved(aTopic, ignoreEvent));
+ }
+ return promises;
+}
+
+function expectObserverCalled(
+ aTopic,
+ aCount = 1,
+ browser = gBrowser.selectedBrowser
+) {
+ if (!gMultiProcessBrowser) {
+ return expectObserverCalledInProcess(aTopic, aCount);
+ }
+
+ let browsingContext = Element.isInstance(browser)
+ ? browser.browsingContext
+ : browser;
+
+ return BrowserTestUtils.contentTopicObserved(browsingContext, aTopic, aCount);
+}
+
+// This is a special version of expectObserverCalled that should only
+// be used when expecting a notification upon closing a window. It uses
+// the per-process message manager instead of actors to send the
+// notifications.
+function expectObserverCalledOnClose(
+ aTopic,
+ aCount = 1,
+ browser = gBrowser.selectedBrowser
+) {
+ if (!gMultiProcessBrowser) {
+ return expectObserverCalledInProcess(aTopic, aCount);
+ }
+
+ let browsingContext = Element.isInstance(browser)
+ ? browser.browsingContext
+ : browser;
+
+ return new Promise(resolve => {
+ BrowserTestUtils.sendAsyncMessage(
+ browsingContext,
+ "BrowserTestUtils:ObserveTopic",
+ {
+ topic: aTopic,
+ count: 1,
+ filterFunctionSource: ((subject, topic, data) => {
+ Services.cpmm.sendAsyncMessage("WebRTCTest:ObserverCalled", {
+ topic,
+ });
+ return true;
+ }).toSource(),
+ }
+ );
+
+ function observerCalled(message) {
+ if (message.data.topic == aTopic) {
+ Services.ppmm.removeMessageListener(
+ "WebRTCTest:ObserverCalled",
+ observerCalled
+ );
+ resolve();
+ }
+ }
+ Services.ppmm.addMessageListener(
+ "WebRTCTest:ObserverCalled",
+ observerCalled
+ );
+ });
+}
+
+function promiseMessage(
+ aMessage,
+ aAction,
+ aCount = 1,
+ browser = gBrowser.selectedBrowser
+) {
+ let startTime = performance.now();
+ let promise = ContentTask.spawn(
+ browser,
+ [aMessage, aCount],
+ async function ([expectedMessage, expectedCount]) {
+ return new Promise(resolve => {
+ function listenForMessage({ data }) {
+ if (
+ (!expectedMessage || data == expectedMessage) &&
+ --expectedCount == 0
+ ) {
+ content.removeEventListener("message", listenForMessage);
+ resolve(data);
+ }
+ }
+ content.addEventListener("message", listenForMessage);
+ });
+ }
+ );
+ if (aAction) {
+ aAction();
+ }
+ return promise.then(data => {
+ ChromeUtils.addProfilerMarker(
+ "promiseMessage",
+ { startTime, category: "Test" },
+ data
+ );
+ return data;
+ });
+}
+
+function promisePopupNotificationShown(aName, aAction, aWindow = window) {
+ let startTime = performance.now();
+ return new Promise(resolve => {
+ aWindow.PopupNotifications.panel.addEventListener(
+ "popupshown",
+ function () {
+ ok(
+ !!aWindow.PopupNotifications.getNotification(aName),
+ aName + " notification shown"
+ );
+ ok(aWindow.PopupNotifications.isPanelOpen, "notification panel open");
+ ok(
+ !!aWindow.PopupNotifications.panel.firstElementChild,
+ "notification panel populated"
+ );
+
+ executeSoon(() => {
+ ChromeUtils.addProfilerMarker(
+ "promisePopupNotificationShown",
+ { startTime, category: "Test" },
+ aName
+ );
+ resolve();
+ });
+ },
+ { once: true }
+ );
+
+ if (aAction) {
+ aAction();
+ }
+ });
+}
+
+async function promisePopupNotification(aName) {
+ return TestUtils.waitForCondition(
+ () => PopupNotifications.getNotification(aName),
+ aName + " notification appeared"
+ );
+}
+
+async function promiseNoPopupNotification(aName) {
+ return TestUtils.waitForCondition(
+ () => !PopupNotifications.getNotification(aName),
+ aName + " notification removed"
+ );
+}
+
+const kActionAlways = 1;
+const kActionDeny = 2;
+const kActionNever = 3;
+
+async function activateSecondaryAction(aAction) {
+ let notification = PopupNotifications.panel.firstElementChild;
+ switch (aAction) {
+ case kActionNever:
+ if (notification.notification.secondaryActions.length > 1) {
+ // "Always Block" is the first (and only) item in the menupopup.
+ await Promise.all([
+ BrowserTestUtils.waitForEvent(notification.menupopup, "popupshown"),
+ notification.menubutton.click(),
+ ]);
+ notification.menupopup.querySelector("menuitem").click();
+ return;
+ }
+ if (!notification.checkbox.checked) {
+ notification.checkbox.click();
+ }
+ // fallthrough
+ case kActionDeny:
+ notification.secondaryButton.click();
+ break;
+ case kActionAlways:
+ if (!notification.checkbox.checked) {
+ notification.checkbox.click();
+ }
+ notification.button.click();
+ break;
+ }
+}
+
+async function getMediaCaptureState() {
+ let startTime = performance.now();
+
+ function gatherBrowsingContexts(aBrowsingContext) {
+ let list = [aBrowsingContext];
+
+ let children = aBrowsingContext.children;
+ for (let child of children) {
+ list.push(...gatherBrowsingContexts(child));
+ }
+
+ return list;
+ }
+
+ function combine(x, y) {
+ if (
+ x == Ci.nsIMediaManagerService.STATE_CAPTURE_ENABLED ||
+ y == Ci.nsIMediaManagerService.STATE_CAPTURE_ENABLED
+ ) {
+ return Ci.nsIMediaManagerService.STATE_CAPTURE_ENABLED;
+ }
+ if (
+ x == Ci.nsIMediaManagerService.STATE_CAPTURE_DISABLED ||
+ y == Ci.nsIMediaManagerService.STATE_CAPTURE_DISABLED
+ ) {
+ return Ci.nsIMediaManagerService.STATE_CAPTURE_DISABLED;
+ }
+ return Ci.nsIMediaManagerService.STATE_NOCAPTURE;
+ }
+
+ let video = Ci.nsIMediaManagerService.STATE_NOCAPTURE;
+ let audio = Ci.nsIMediaManagerService.STATE_NOCAPTURE;
+ let screen = Ci.nsIMediaManagerService.STATE_NOCAPTURE;
+ let window = Ci.nsIMediaManagerService.STATE_NOCAPTURE;
+ let browser = Ci.nsIMediaManagerService.STATE_NOCAPTURE;
+
+ for (let bc of gatherBrowsingContexts(
+ gBrowser.selectedBrowser.browsingContext
+ )) {
+ let state = await SpecialPowers.spawn(bc, [], async function () {
+ let mediaManagerService = Cc[
+ "@mozilla.org/mediaManagerService;1"
+ ].getService(Ci.nsIMediaManagerService);
+
+ let hasCamera = {};
+ let hasMicrophone = {};
+ let hasScreenShare = {};
+ let hasWindowShare = {};
+ let hasBrowserShare = {};
+ let devices = {};
+ mediaManagerService.mediaCaptureWindowState(
+ content,
+ hasCamera,
+ hasMicrophone,
+ hasScreenShare,
+ hasWindowShare,
+ hasBrowserShare,
+ devices,
+ false
+ );
+
+ return {
+ video: hasCamera.value,
+ audio: hasMicrophone.value,
+ screen: hasScreenShare.value,
+ window: hasWindowShare.value,
+ browser: hasBrowserShare.value,
+ };
+ });
+
+ video = combine(state.video, video);
+ audio = combine(state.audio, audio);
+ screen = combine(state.screen, screen);
+ window = combine(state.window, window);
+ browser = combine(state.browser, browser);
+ }
+
+ let result = {};
+
+ if (video != Ci.nsIMediaManagerService.STATE_NOCAPTURE) {
+ result.video = true;
+ }
+ if (audio != Ci.nsIMediaManagerService.STATE_NOCAPTURE) {
+ result.audio = true;
+ }
+
+ if (screen != Ci.nsIMediaManagerService.STATE_NOCAPTURE) {
+ result.screen = "Screen";
+ } else if (window != Ci.nsIMediaManagerService.STATE_NOCAPTURE) {
+ result.screen = "Window";
+ } else if (browser != Ci.nsIMediaManagerService.STATE_NOCAPTURE) {
+ result.screen = "Browser";
+ }
+
+ ChromeUtils.addProfilerMarker("getMediaCaptureState", {
+ startTime,
+ category: "Test",
+ });
+ return result;
+}
+
+async function stopSharing(
+ aType = "camera",
+ aShouldKeepSharing = false,
+ aFrameBC,
+ aWindow = window
+) {
+ let promiseRecordingEvent = expectObserverCalled(
+ "recording-device-events",
+ 1,
+ aFrameBC
+ );
+ let observerPromise1 = expectObserverCalled(
+ "getUserMedia:revoke",
+ 1,
+ aFrameBC
+ );
+
+ // If we are stopping screen sharing and expect to still have another stream,
+ // "recording-window-ended" won't be fired.
+ let observerPromise2 = null;
+ if (!aShouldKeepSharing) {
+ observerPromise2 = expectObserverCalled(
+ "recording-window-ended",
+ 1,
+ aFrameBC
+ );
+ }
+
+ await revokePermission(aType, aShouldKeepSharing, aFrameBC, aWindow);
+ await promiseRecordingEvent;
+ await observerPromise1;
+ await observerPromise2;
+
+ if (!aShouldKeepSharing) {
+ await checkNotSharing();
+ }
+}
+
+async function revokePermission(
+ aType = "camera",
+ aShouldKeepSharing = false,
+ aFrameBC,
+ aWindow = window
+) {
+ aWindow.gPermissionPanel._identityPermissionBox.click();
+ let popup = aWindow.gPermissionPanel._permissionPopup;
+ // If the popup gets hidden before being shown, by stray focus/activate
+ // events, don't bother failing the test. It's enough to know that we
+ // started showing the popup.
+ let hiddenEvent = BrowserTestUtils.waitForEvent(popup, "popuphidden");
+ let shownEvent = BrowserTestUtils.waitForEvent(popup, "popupshown");
+ await Promise.race([hiddenEvent, shownEvent]);
+ let doc = aWindow.document;
+ let permissions = doc.getElementById("permission-popup-permission-list");
+ let cancelButton = permissions.querySelector(
+ ".permission-popup-permission-icon." +
+ aType +
+ "-icon ~ " +
+ ".permission-popup-permission-remove-button"
+ );
+
+ cancelButton.click();
+ popup.hidePopup();
+
+ if (!aShouldKeepSharing) {
+ await checkNotSharing();
+ }
+}
+
+function getBrowsingContextForFrame(aBrowsingContext, aFrameId) {
+ if (!aFrameId) {
+ return aBrowsingContext;
+ }
+
+ return SpecialPowers.spawn(aBrowsingContext, [aFrameId], frameId => {
+ return content.document.getElementById(frameId).browsingContext;
+ });
+}
+
+async function getBrowsingContextsAndFrameIdsForSubFrames(
+ aBrowsingContext,
+ aSubFrames
+) {
+ let pendingBrowserSubFrames = [
+ { bc: aBrowsingContext, subFrames: aSubFrames },
+ ];
+ let browsingContextsAndFrames = [];
+ while (pendingBrowserSubFrames.length) {
+ let { bc, subFrames } = pendingBrowserSubFrames.shift();
+ for (let id of Object.keys(subFrames)) {
+ let subBc = await getBrowsingContextForFrame(bc, id);
+ if (subFrames[id].children) {
+ pendingBrowserSubFrames.push({
+ bc: subBc,
+ subFrames: subFrames[id].children,
+ });
+ }
+ if (subFrames[id].noTest) {
+ continue;
+ }
+ let observeBC = subFrames[id].observe ? subBc : undefined;
+ browsingContextsAndFrames.push({ bc: subBc, id, observeBC });
+ }
+ }
+ return browsingContextsAndFrames;
+}
+
+async function promiseRequestDevice(
+ aRequestAudio,
+ aRequestVideo,
+ aFrameId,
+ aType,
+ aBrowsingContext,
+ aBadDevice = false
+) {
+ info("requesting devices");
+ let bc =
+ aBrowsingContext ??
+ (await getBrowsingContextForFrame(gBrowser.selectedBrowser, aFrameId));
+ return SpecialPowers.spawn(
+ bc,
+ [{ aRequestAudio, aRequestVideo, aType, aBadDevice }],
+ async function (args) {
+ let global = content.wrappedJSObject;
+ global.requestDevice(
+ args.aRequestAudio,
+ args.aRequestVideo,
+ args.aType,
+ args.aBadDevice
+ );
+ }
+ );
+}
+
+async function promiseRequestAudioOutput(options) {
+ info("requesting audio output");
+ const bc = gBrowser.selectedBrowser;
+ return SpecialPowers.spawn(bc, [options], async function (opts) {
+ const global = content.wrappedJSObject;
+ global.requestAudioOutput(Cu.cloneInto(opts, content));
+ });
+}
+
+async function stopTracks(
+ aKind,
+ aAlreadyStopped,
+ aLastTracks,
+ aFrameId,
+ aBrowsingContext,
+ aBrowsingContextToObserve
+) {
+ // If the observers are listening to other frames, listen for a notification
+ // on the right subframe.
+ let frameBC =
+ aBrowsingContext ??
+ (await getBrowsingContextForFrame(
+ gBrowser.selectedBrowser.browsingContext,
+ aFrameId
+ ));
+
+ let observerPromises = [];
+ if (!aAlreadyStopped) {
+ observerPromises.push(
+ expectObserverCalled(
+ "recording-device-events",
+ 1,
+ aBrowsingContextToObserve
+ )
+ );
+ }
+ if (aLastTracks) {
+ observerPromises.push(
+ expectObserverCalled(
+ "recording-window-ended",
+ 1,
+ aBrowsingContextToObserve
+ )
+ );
+ }
+
+ info(`Stopping all ${aKind} tracks`);
+ await SpecialPowers.spawn(frameBC, [aKind], async function (kind) {
+ content.wrappedJSObject.stopTracks(kind);
+ });
+
+ await Promise.all(observerPromises);
+}
+
+async function closeStream(
+ aAlreadyClosed,
+ aFrameId,
+ aDontFlushObserverVerification,
+ aBrowsingContext,
+ aBrowsingContextToObserve
+) {
+ // Check that spurious notifications that occur while closing the
+ // stream are handled separately. Tests that use skipObserverVerification
+ // should pass true for aDontFlushObserverVerification.
+ if (!aDontFlushObserverVerification) {
+ await disableObserverVerification();
+ await enableObserverVerification();
+ }
+
+ // If the observers are listening to other frames, listen for a notification
+ // on the right subframe.
+ let frameBC =
+ aBrowsingContext ??
+ (await getBrowsingContextForFrame(
+ gBrowser.selectedBrowser.browsingContext,
+ aFrameId
+ ));
+
+ let observerPromises = [];
+ if (!aAlreadyClosed) {
+ observerPromises.push(
+ expectObserverCalled(
+ "recording-device-events",
+ 1,
+ aBrowsingContextToObserve
+ )
+ );
+ observerPromises.push(
+ expectObserverCalled(
+ "recording-window-ended",
+ 1,
+ aBrowsingContextToObserve
+ )
+ );
+ }
+
+ info("closing the stream");
+ await SpecialPowers.spawn(frameBC, [], async function () {
+ content.wrappedJSObject.closeStream();
+ });
+
+ await Promise.all(observerPromises);
+
+ await assertWebRTCIndicatorStatus(null);
+}
+
+async function reloadAsUser() {
+ info("reloading as a user");
+
+ const reloadButton = document.getElementById("reload-button");
+ await TestUtils.waitForCondition(() => !reloadButton.disabled);
+ // Disable observers as the page is being reloaded which can destroy
+ // the actors listening to the notifications.
+ await disableObserverVerification();
+
+ let loadedPromise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+ reloadButton.click();
+ await loadedPromise;
+
+ await enableObserverVerification();
+}
+
+async function reloadFromContent() {
+ info("reloading from content");
+
+ // Disable observers as the page is being reloaded which can destroy
+ // the actors listening to the notifications.
+ await disableObserverVerification();
+
+ let loadedPromise = BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+ await ContentTask.spawn(gBrowser.selectedBrowser, null, () =>
+ content.location.reload()
+ );
+
+ await loadedPromise;
+
+ await enableObserverVerification();
+}
+
+async function reloadAndAssertClosedStreams() {
+ await reloadFromContent();
+ await checkNotSharing();
+}
+
+/**
+ * @param {("microphone"|"camera"|"screen")[]} aExpectedTypes
+ * @param {Window} [aWindow]
+ */
+function checkDeviceSelectors(aExpectedTypes, aWindow = window) {
+ for (const type of aExpectedTypes) {
+ if (!["microphone", "camera", "screen", "speaker"].includes(type)) {
+ throw new Error(`Bad device type name ${type}`);
+ }
+ }
+ let document = aWindow.document;
+
+ let expectedDescribedBy = "webRTC-shareDevices-notification-description";
+ for (let type of ["Camera", "Microphone", "Speaker"]) {
+ let selector = document.getElementById(`webRTC-select${type}`);
+ if (!aExpectedTypes.includes(type.toLowerCase())) {
+ ok(selector.hidden, `${type} selector hidden`);
+ continue;
+ }
+ ok(!selector.hidden, `${type} selector visible`);
+ let selectorList = document.getElementById(`webRTC-select${type}-menulist`);
+ let label = document.getElementById(
+ `webRTC-select${type}-single-device-label`
+ );
+ // If there's only 1 device listed, then we should show the label
+ // instead of the menulist.
+ if (selectorList.itemCount == 1) {
+ ok(selectorList.hidden, `${type} selector list should be hidden.`);
+ ok(!label.hidden, `${type} selector label should not be hidden.`);
+ is(
+ label.value,
+ selectorList.selectedItem.getAttribute("label"),
+ `${type} label should be showing the lone device label.`
+ );
+ expectedDescribedBy += ` webRTC-select${type}-icon webRTC-select${type}-single-device-label`;
+ } else {
+ ok(!selectorList.hidden, `${type} selector list should not be hidden.`);
+ ok(label.hidden, `${type} selector label should be hidden.`);
+ }
+ }
+ let ariaDescribedby =
+ aWindow.PopupNotifications.panel.getAttribute("aria-describedby");
+ is(ariaDescribedby, expectedDescribedBy, "aria-describedby");
+
+ let screenSelector = document.getElementById("webRTC-selectWindowOrScreen");
+ if (aExpectedTypes.includes("screen")) {
+ ok(!screenSelector.hidden, "screen selector visible");
+ } else {
+ ok(screenSelector.hidden, "screen selector hidden");
+ }
+}
+
+/**
+ * Tests the siteIdentity icons, the permission panel and the global indicator
+ * UI state.
+ * @param {Object} aExpected - Expected state for the current tab.
+ * @param {window} [aWin] - Top level chrome window to test state of.
+ * @param {Object} [aExpectedGlobal] - Expected state for all tabs.
+ * @param {Object} [aExpectedPerm] - Expected permission states keyed by device
+ * type.
+ */
+async function checkSharingUI(
+ aExpected,
+ aWin = window,
+ aExpectedGlobal = null,
+ aExpectedPerm = null
+) {
+ function isPaused(streamState) {
+ if (typeof streamState == "string") {
+ return streamState.includes("Paused");
+ }
+ return streamState == STATE_CAPTURE_DISABLED;
+ }
+
+ let doc = aWin.document;
+ // First check the icon above the control center (i) icon.
+ let permissionBox = doc.getElementById("identity-permission-box");
+ let webrtcSharingIcon = doc.getElementById("webrtc-sharing-icon");
+ ok(webrtcSharingIcon.hasAttribute("sharing"), "sharing attribute is set");
+ let sharing = webrtcSharingIcon.getAttribute("sharing");
+ if (aExpected.screen) {
+ is(sharing, "screen", "showing screen icon in the identity block");
+ } else if (aExpected.video == STATE_CAPTURE_ENABLED) {
+ is(sharing, "camera", "showing camera icon in the identity block");
+ } else if (aExpected.audio == STATE_CAPTURE_ENABLED) {
+ is(sharing, "microphone", "showing mic icon in the identity block");
+ } else if (aExpected.video) {
+ is(sharing, "camera", "showing camera icon in the identity block");
+ } else if (aExpected.audio) {
+ is(sharing, "microphone", "showing mic icon in the identity block");
+ }
+
+ let allStreamsPaused = Object.values(aExpected).every(isPaused);
+ is(
+ webrtcSharingIcon.hasAttribute("paused"),
+ allStreamsPaused,
+ "sharing icon(s) should be in paused state when paused"
+ );
+
+ // Then check the sharing indicators inside the permission popup.
+ permissionBox.click();
+ let popup = aWin.gPermissionPanel._permissionPopup;
+ // If the popup gets hidden before being shown, by stray focus/activate
+ // events, don't bother failing the test. It's enough to know that we
+ // started showing the popup.
+ let hiddenEvent = BrowserTestUtils.waitForEvent(popup, "popuphidden");
+ let shownEvent = BrowserTestUtils.waitForEvent(popup, "popupshown");
+ await Promise.race([hiddenEvent, shownEvent]);
+ let permissions = doc.getElementById("permission-popup-permission-list");
+ for (let id of ["microphone", "camera", "screen"]) {
+ let convertId = idToConvert => {
+ if (idToConvert == "camera") {
+ return "video";
+ }
+ if (idToConvert == "microphone") {
+ return "audio";
+ }
+ return idToConvert;
+ };
+ let expected = aExpected[convertId(id)];
+
+ // Extract the expected permission for the device type.
+ // Defaults to temporary allow.
+ let { state, scope } = aExpectedPerm?.[convertId(id)] || {};
+ if (state == null) {
+ state = SitePermissions.ALLOW;
+ }
+ if (scope == null) {
+ scope = SitePermissions.SCOPE_TEMPORARY;
+ }
+
+ is(
+ !!aWin.gPermissionPanel._sharingState.webRTC[id],
+ !!expected,
+ "sharing state for " + id + " as expected"
+ );
+ let item = permissions.querySelectorAll(
+ ".permission-popup-permission-item-" + id
+ );
+ let stateLabel = item?.[0]?.querySelector(
+ ".permission-popup-permission-state-label"
+ );
+ let icon = permissions.querySelectorAll(
+ ".permission-popup-permission-icon." + id + "-icon"
+ );
+ if (expected) {
+ is(item.length, 1, "should show " + id + " item in permission panel");
+ is(
+ stateLabel?.textContent,
+ SitePermissions.getCurrentStateLabel(state, id, scope),
+ "should show correct item label for " + id
+ );
+ is(icon.length, 1, "should show " + id + " icon in permission panel");
+ is(
+ icon[0].classList.contains("in-use"),
+ expected && !isPaused(expected),
+ "icon should have the in-use class, unless paused"
+ );
+ } else if (!icon.length && !item.length && !stateLabel) {
+ ok(true, "should not show " + id + " item in the permission panel");
+ ok(true, "should not show " + id + " icon in the permission panel");
+ ok(
+ true,
+ "should not show " + id + " state label in the permission panel"
+ );
+ } else {
+ // This will happen if there are persistent permissions set.
+ ok(
+ !icon[0].classList.contains("in-use"),
+ "if shown, the " + id + " icon should not have the in-use class"
+ );
+ is(item.length, 1, "should not show more than 1 " + id + " item");
+ is(icon.length, 1, "should not show more than 1 " + id + " icon");
+ }
+ }
+ aWin.gPermissionPanel._permissionPopup.hidePopup();
+ await TestUtils.waitForCondition(
+ () => permissionPopupHidden(aWin),
+ "identity popup should be hidden"
+ );
+
+ // Check the global indicators.
+ await assertWebRTCIndicatorStatus(aExpectedGlobal || aExpected);
+}
+
+async function checkNotSharing() {
+ Assert.deepEqual(
+ await getMediaCaptureState(),
+ {},
+ "expected nothing to be shared"
+ );
+
+ ok(
+ !document.getElementById("webrtc-sharing-icon").hasAttribute("sharing"),
+ "no sharing indicator on the control center icon"
+ );
+
+ await assertWebRTCIndicatorStatus(null);
+}
+
+async function checkNotSharingWithinGracePeriod() {
+ Assert.deepEqual(
+ await getMediaCaptureState(),
+ {},
+ "expected nothing to be shared"
+ );
+
+ ok(
+ document.getElementById("webrtc-sharing-icon").hasAttribute("sharing"),
+ "has sharing indicator on the control center icon"
+ );
+ ok(
+ document.getElementById("webrtc-sharing-icon").hasAttribute("paused"),
+ "sharing indicator is paused"
+ );
+
+ await assertWebRTCIndicatorStatus(null);
+}
+
+async function promiseReloadFrame(aFrameId, aBrowsingContext) {
+ let loadedPromise = BrowserTestUtils.browserLoaded(
+ gBrowser.selectedBrowser,
+ true,
+ arg => {
+ return true;
+ }
+ );
+ let bc =
+ aBrowsingContext ??
+ (await getBrowsingContextForFrame(
+ gBrowser.selectedBrowser.browsingContext,
+ aFrameId
+ ));
+ await SpecialPowers.spawn(bc, [], async function () {
+ content.location.reload();
+ });
+ return loadedPromise;
+}
+
+function promiseChangeLocationFrame(aFrameId, aNewLocation) {
+ return SpecialPowers.spawn(
+ gBrowser.selectedBrowser.browsingContext,
+ [{ aFrameId, aNewLocation }],
+ async function (args) {
+ let frame = content.wrappedJSObject.document.getElementById(
+ args.aFrameId
+ );
+ return new Promise(resolve => {
+ function listener() {
+ frame.removeEventListener("load", listener, true);
+ resolve();
+ }
+ frame.addEventListener("load", listener, true);
+
+ content.wrappedJSObject.document.getElementById(
+ args.aFrameId
+ ).contentWindow.location = args.aNewLocation;
+ });
+ }
+ );
+}
+
+async function openNewTestTab(leaf = "get_user_media.html") {
+ let rootDir = getRootDirectory(gTestPath);
+ rootDir = rootDir.replace(
+ "chrome://mochitests/content/",
+ "https://example.com/"
+ );
+ let absoluteURI = rootDir + leaf;
+
+ let tab = await BrowserTestUtils.openNewForegroundTab(gBrowser, absoluteURI);
+ return tab.linkedBrowser;
+}
+
+// Enabling observer verification adds listeners for all of the webrtc
+// observer topics. If any notifications occur for those topics that
+// were not explicitly requested, a failure will occur.
+async function enableObserverVerification(browser = gBrowser.selectedBrowser) {
+ // Skip these checks in single process mode as it isn't worth implementing it.
+ if (!gMultiProcessBrowser) {
+ return;
+ }
+
+ gBrowserContextsToObserve = [browser.browsingContext];
+
+ // A list of subframe indicies to also add observers to. This only
+ // supports one nested level.
+ if (gObserveSubFrames) {
+ let bcsAndFrameIds = await getBrowsingContextsAndFrameIdsForSubFrames(
+ browser,
+ gObserveSubFrames
+ );
+ for (let { observeBC } of bcsAndFrameIds) {
+ if (observeBC) {
+ gBrowserContextsToObserve.push(observeBC);
+ }
+ }
+ }
+
+ for (let bc of gBrowserContextsToObserve) {
+ await BrowserTestUtils.startObservingTopics(bc, observerTopics);
+ }
+}
+
+async function disableObserverVerification() {
+ if (!gMultiProcessBrowser) {
+ return;
+ }
+
+ for (let bc of gBrowserContextsToObserve) {
+ await BrowserTestUtils.stopObservingTopics(bc, observerTopics).catch(
+ reason => {
+ ok(false, "Failed " + reason);
+ }
+ );
+ }
+}
+
+function permissionPopupHidden(win = window) {
+ let popup = win.gPermissionPanel._permissionPopup;
+ return !popup || popup.state == "closed";
+}
+
+async function runTests(tests, options = {}) {
+ let browser = await openNewTestTab(options.relativeURI);
+
+ is(
+ PopupNotifications._currentNotifications.length,
+ 0,
+ "should start the test without any prior popup notification"
+ );
+ ok(
+ permissionPopupHidden(),
+ "should start the test with the permission panel hidden"
+ );
+
+ // Set prefs so that permissions prompts are shown and loopback devices
+ // are not used. To test the chrome we want prompts to be shown, and
+ // these tests are flakey when using loopback devices (though it would
+ // be desirable to make them work with loopback in future). See bug 1643711.
+ let prefs = [
+ [PREF_PERMISSION_FAKE, true],
+ [PREF_AUDIO_LOOPBACK, ""],
+ [PREF_VIDEO_LOOPBACK, ""],
+ [PREF_FAKE_STREAMS, true],
+ [PREF_FOCUS_SOURCE, false],
+ ];
+ await SpecialPowers.pushPrefEnv({ set: prefs });
+
+ // When the frames are in different processes, add observers to each frame,
+ // to ensure that the notifications don't get sent in the wrong process.
+ gObserveSubFrames = SpecialPowers.useRemoteSubframes ? options.subFrames : {};
+
+ for (let testCase of tests) {
+ let startTime = performance.now();
+ info(testCase.desc);
+ if (
+ !testCase.skipObserverVerification &&
+ !options.skipObserverVerification
+ ) {
+ await enableObserverVerification();
+ }
+ await testCase.run(browser, options.subFrames);
+ if (
+ !testCase.skipObserverVerification &&
+ !options.skipObserverVerification
+ ) {
+ await disableObserverVerification();
+ }
+ if (options.cleanup) {
+ await options.cleanup();
+ }
+ ChromeUtils.addProfilerMarker(
+ "browser-test",
+ { startTime, category: "Test" },
+ testCase.desc
+ );
+ }
+
+ // Some tests destroy the original tab and leave a new one in its place.
+ BrowserTestUtils.removeTab(gBrowser.selectedTab);
+}
+
+/**
+ * Given a browser from a tab in this window, chooses to share
+ * some combination of camera, mic or screen.
+ *
+ * @param {<xul:browser} browser - The browser to share devices with.
+ * @param {boolean} camera - True to share a camera device.
+ * @param {boolean} mic - True to share a microphone device.
+ * @param {Number} [screenOrWin] - One of either SHARE_WINDOW or SHARE_SCREEN
+ * to share a window or screen. Defaults to neither.
+ * @param {boolean} remember - True to persist the permission to the
+ * SitePermissions database as SitePermissions.SCOPE_PERSISTENT. Note that
+ * callers are responsible for clearing this persistent permission.
+ * @return {Promise}
+ * @resolves {undefined} - Once the sharing is complete.
+ */
+async function shareDevices(
+ browser,
+ camera,
+ mic,
+ screenOrWin = 0,
+ remember = false
+) {
+ if (camera || mic) {
+ let promise = promisePopupNotificationShown(
+ "webRTC-shareDevices",
+ null,
+ window
+ );
+
+ await promiseRequestDevice(mic, camera, null, null, browser);
+ await promise;
+
+ const expectedDeviceSelectorTypes = [
+ camera && "camera",
+ mic && "microphone",
+ ].filter(x => x);
+ checkDeviceSelectors(expectedDeviceSelectorTypes);
+ let observerPromise1 = expectObserverCalled("getUserMedia:response:allow");
+ let observerPromise2 = expectObserverCalled("recording-device-events");
+
+ let rememberCheck = PopupNotifications.panel.querySelector(
+ ".popup-notification-checkbox"
+ );
+ rememberCheck.checked = remember;
+
+ promise = promiseMessage("ok", () => {
+ PopupNotifications.panel.firstElementChild.button.click();
+ });
+
+ await observerPromise1;
+ await observerPromise2;
+ await promise;
+ }
+
+ if (screenOrWin) {
+ let promise = promisePopupNotificationShown(
+ "webRTC-shareDevices",
+ null,
+ window
+ );
+
+ await promiseRequestDevice(false, true, null, "screen", browser);
+ await promise;
+
+ checkDeviceSelectors(["screen"], window);
+
+ let document = window.document;
+
+ let menulist = document.getElementById("webRTC-selectWindow-menulist");
+ let displayMediaSource;
+
+ if (screenOrWin == SHARE_SCREEN) {
+ displayMediaSource = "screen";
+ } else if (screenOrWin == SHARE_WINDOW) {
+ displayMediaSource = "window";
+ } else {
+ throw new Error("Got an invalid argument to shareDevices.");
+ }
+
+ let menuitem = null;
+ for (let i = 0; i < menulist.itemCount; ++i) {
+ let current = menulist.getItemAtIndex(i);
+ if (current.mediaSource == displayMediaSource) {
+ menuitem = current;
+ break;
+ }
+ }
+
+ Assert.ok(menuitem, "Should have found an appropriate display menuitem");
+ menuitem.doCommand();
+
+ let notification = window.PopupNotifications.panel.firstElementChild;
+
+ let observerPromise1 = expectObserverCalled("getUserMedia:response:allow");
+ let observerPromise2 = expectObserverCalled("recording-device-events");
+ await promiseMessage(
+ "ok",
+ () => {
+ notification.button.click();
+ },
+ 1,
+ browser
+ );
+ await observerPromise1;
+ await observerPromise2;
+ }
+}
diff --git a/browser/base/content/test/webrtc/legacyIndicator/browser.ini b/browser/base/content/test/webrtc/legacyIndicator/browser.ini
new file mode 100644
index 0000000000..dc1c9e3104
--- /dev/null
+++ b/browser/base/content/test/webrtc/legacyIndicator/browser.ini
@@ -0,0 +1,63 @@
+[DEFAULT]
+support-files =
+ ../get_user_media.html
+ ../get_user_media_in_frame.html
+ ../get_user_media_in_xorigin_frame.html
+ ../get_user_media_in_xorigin_frame_ancestor.html
+ ../head.js
+prefs =
+ privacy.webrtc.allowSilencingNotifications=false
+ privacy.webrtc.legacyGlobalIndicator=true
+ privacy.webrtc.sharedTabWarning=false
+ privacy.webrtc.deviceGracePeriodTimeoutMs=0
+
+[../browser_devices_get_user_media.js]
+skip-if =
+ (os == "linux") # linux: bug 976544, bug 1616011
+ os == "win" && os_version == "6.1" # Skip on Azure - frequent failure
+[../browser_devices_get_user_media_anim.js]
+skip-if =
+ os == "win" && os_version == "6.1" # Skip on Azure - frequent failure
+[../browser_devices_get_user_media_default_permissions.js]
+[../browser_devices_get_user_media_in_frame.js]
+skip-if = debug # bug 1369731
+ os == "win" && os_version == "6.1" # Skip on Azure - frequent failure
+[../browser_devices_get_user_media_in_xorigin_frame.js]
+skip-if =
+ debug # bug 1369731
+ apple_silicon # bug 1707735
+ apple_catalina # platform migration
+ os == "win" && os_version == "6.1" # Skip on Azure - frequent failure
+[../browser_devices_get_user_media_in_xorigin_frame_chain.js]
+[../browser_devices_get_user_media_multi_process.js]
+skip-if =
+ (debug && os == "win") # bug 1393761
+ apple_silicon # bug 1707735
+ apple_catalina # platform migration
+[../browser_devices_get_user_media_paused.js]
+skip-if =
+ (os == "win" && !debug) # Bug 1440900
+ (os =="linux" && !debug && bits == 64) # Bug 1440900
+ apple_silicon # bug 1707735
+ apple_catalina # platform migration
+[../browser_devices_get_user_media_queue_request.js]
+skip-if =
+ os == "linux" # Bug 1775945
+ os == "win" && !debug # Bug 1775945
+[../browser_devices_get_user_media_screen.js]
+skip-if =
+ (os == 'linux') # Bug 1503991
+ apple_silicon # bug 1707735
+ apple_catalina # platform migration
+ os == "win"
+[../browser_devices_get_user_media_tear_off_tab.js]
+skip-if =
+ os == "linux" # Bug 1775945
+ os == "win" && !debug # Bug 1775945
+[../browser_devices_get_user_media_unprompted_access.js]
+skip-if = (os == "linux") # Bug 1712012
+[../browser_devices_get_user_media_unprompted_access_in_frame.js]
+[../browser_devices_get_user_media_unprompted_access_queue_request.js]
+[../browser_devices_get_user_media_unprompted_access_tear_off_tab.js]
+skip-if = (os == "win" && bits == 64) # win8: bug 1334752
+[../browser_webrtc_hooks.js]
diff --git a/browser/base/content/test/webrtc/peerconnection_connect.html b/browser/base/content/test/webrtc/peerconnection_connect.html
new file mode 100644
index 0000000000..5af6a4aafd
--- /dev/null
+++ b/browser/base/content/test/webrtc/peerconnection_connect.html
@@ -0,0 +1,39 @@
+<!DOCTYPE html>
+<html>
+<head><meta charset="UTF-8"></head>
+<body>
+<div id="Page that opens a two peerconnections, and starts ICE"></div>
+<script>
+ const test = async () => {
+ const offerer = new RTCPeerConnection();
+ const answerer = new RTCPeerConnection();
+ offerer.addTransceiver('audio');
+
+ async function iceConnected(pc) {
+ return new Promise(r => {
+ if (pc.iceConnectionState == "connected") {
+ r();
+ }
+ pc.oniceconnectionstatechange = () => {
+ if (pc.iceConnectionState == "connected") {
+ r();
+ }
+ }
+ });
+ }
+
+ offerer.onicecandidate = e => answerer.addIceCandidate(e.candidate);
+ answerer.onicecandidate = e => offerer.addIceCandidate(e.candidate);
+ await offerer.setLocalDescription();
+ await answerer.setRemoteDescription(offerer.localDescription);
+ await answerer.setLocalDescription();
+ await offerer.setRemoteDescription(answerer.localDescription);
+ await iceConnected(offerer);
+ await iceConnected(answerer);
+ offerer.close();
+ answerer.close();
+ };
+ test();
+</script>
+</body>
+</html>
diff --git a/browser/base/content/test/webrtc/single_peerconnection.html b/browser/base/content/test/webrtc/single_peerconnection.html
new file mode 100644
index 0000000000..4b4432c51b
--- /dev/null
+++ b/browser/base/content/test/webrtc/single_peerconnection.html
@@ -0,0 +1,23 @@
+<!DOCTYPE html>
+<html>
+<head><meta charset="UTF-8"></head>
+<body>
+<div id="Page that opens a single peerconnection"></div>
+<script>
+ let test = async () => {
+ let pc = new RTCPeerConnection();
+ pc.addTransceiver('audio');
+ pc.addTransceiver('video');
+ await pc.setLocalDescription();
+ await new Promise(r => {
+ pc.onicegatheringstatechange = () => {
+ if (pc.iceGatheringState == "complete") {
+ r();
+ }
+ };
+ });
+ };
+ test();
+</script>
+</body>
+</html>
diff --git a/browser/base/content/test/zoom/browser.ini b/browser/base/content/test/zoom/browser.ini
new file mode 100644
index 0000000000..720cf9b88e
--- /dev/null
+++ b/browser/base/content/test/zoom/browser.ini
@@ -0,0 +1,34 @@
+[DEFAULT]
+support-files =
+ head.js
+ ../general/moz.png
+ zoom_test.html
+
+[browser_background_link_zoom_reset.js]
+https_first_disabled = true
+[browser_background_zoom.js]
+https_first_disabled = true
+[browser_default_zoom.js]
+[browser_default_zoom_fission.js]
+[browser_default_zoom_multitab.js]
+https_first_disabled = true
+[browser_default_zoom_sitespecific.js]
+[browser_image_zoom_tabswitch.js]
+https_first_disabled = true
+skip-if = (os == "mac") #Bug 1526628
+[browser_mousewheel_zoom.js]
+https_first_disabled = true
+[browser_sitespecific_background_pref.js]
+https_first_disabled = true
+[browser_sitespecific_image_zoom.js]
+[browser_sitespecific_video_zoom.js]
+https_first_disabled = true
+support-files =
+ ../general/video.ogg
+skip-if = os == "win" && debug || (verify && debug && (os == 'linux')) # Bug 1315042
+[browser_subframe_textzoom.js]
+[browser_tabswitch_zoom_flicker.js]
+https_first_disabled = true
+skip-if = (debug && os == "linux" && bits == 64) || (!debug && os == "win") # Bug 1652383
+[browser_tooltip_zoom.js]
+[browser_zoom_commands.js]
diff --git a/browser/base/content/test/zoom/browser_background_link_zoom_reset.js b/browser/base/content/test/zoom/browser_background_link_zoom_reset.js
new file mode 100644
index 0000000000..ac224cc4a5
--- /dev/null
+++ b/browser/base/content/test/zoom/browser_background_link_zoom_reset.js
@@ -0,0 +1,45 @@
+/* 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/. */
+const TEST_PAGE = "/browser/browser/base/content/test/zoom/zoom_test.html";
+var gTestTab, gBgTab, gTestZoom;
+
+function testBackgroundLoad() {
+ (async function () {
+ is(
+ ZoomManager.zoom,
+ gTestZoom,
+ "opening a background tab should not change foreground zoom"
+ );
+
+ await FullZoomHelper.removeTabAndWaitForLocationChange(gBgTab);
+
+ await FullZoom.reset();
+ await FullZoomHelper.removeTabAndWaitForLocationChange(gTestTab);
+ finish();
+ })();
+}
+
+function testInitialZoom() {
+ (async function () {
+ is(ZoomManager.zoom, 1, "initial zoom level should be 1");
+ FullZoom.enlarge();
+
+ gTestZoom = ZoomManager.zoom;
+ isnot(gTestZoom, 1, "zoom level should have changed");
+
+ gBgTab = BrowserTestUtils.addTab(gBrowser);
+ await FullZoomHelper.load(gBgTab, "http://mochi.test:8888" + TEST_PAGE);
+ })().then(testBackgroundLoad, FullZoomHelper.failAndContinue(finish));
+}
+
+function test() {
+ waitForExplicitFinish();
+
+ (async function () {
+ gTestTab = BrowserTestUtils.addTab(gBrowser);
+ await FullZoomHelper.selectTabAndWaitForLocationChange(gTestTab);
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ await FullZoomHelper.load(gTestTab, "http://example.org" + TEST_PAGE);
+ })().then(testInitialZoom, FullZoomHelper.failAndContinue(finish));
+}
diff --git a/browser/base/content/test/zoom/browser_background_zoom.js b/browser/base/content/test/zoom/browser_background_zoom.js
new file mode 100644
index 0000000000..a4faf29ad9
--- /dev/null
+++ b/browser/base/content/test/zoom/browser_background_zoom.js
@@ -0,0 +1,115 @@
+var gTestPage =
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.org/browser/browser/base/content/test/zoom/zoom_test.html";
+var gTestImage =
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.org/browser/browser/base/content/test/general/moz.png";
+var gTab1, gTab2, gTab3;
+var gLevel;
+
+function test() {
+ waitForExplicitFinish();
+ registerCleanupFunction(async () => {
+ await new Promise(resolve => {
+ ContentPrefService2.removeByName(FullZoom.name, Cu.createLoadContext(), {
+ handleCompletion: resolve,
+ });
+ });
+ });
+
+ (async function () {
+ gTab1 = BrowserTestUtils.addTab(gBrowser, gTestPage);
+ gTab2 = BrowserTestUtils.addTab(gBrowser);
+ gTab3 = BrowserTestUtils.addTab(gBrowser);
+
+ await FullZoomHelper.selectTabAndWaitForLocationChange(gTab1);
+ await FullZoomHelper.load(gTab1, gTestPage);
+ await FullZoomHelper.load(gTab2, gTestPage);
+ })().then(secondPageLoaded, FullZoomHelper.failAndContinue(finish));
+}
+
+function secondPageLoaded() {
+ (async function () {
+ FullZoomHelper.zoomTest(gTab1, 1, "Initial zoom of tab 1 should be 1");
+ FullZoomHelper.zoomTest(gTab2, 1, "Initial zoom of tab 2 should be 1");
+ FullZoomHelper.zoomTest(gTab3, 1, "Initial zoom of tab 3 should be 1");
+
+ // Now have three tabs, two with the test page, one blank. Tab 1 is selected
+ // Zoom tab 1
+ FullZoom.enlarge();
+ gLevel = ZoomManager.getZoomForBrowser(gBrowser.getBrowserForTab(gTab1));
+
+ ok(gLevel > 1, "New zoom for tab 1 should be greater than 1");
+ FullZoomHelper.zoomTest(gTab2, 1, "Zooming tab 1 should not affect tab 2");
+ FullZoomHelper.zoomTest(gTab3, 1, "Zooming tab 1 should not affect tab 3");
+
+ await FullZoomHelper.load(gTab3, gTestPage);
+ })().then(thirdPageLoaded, FullZoomHelper.failAndContinue(finish));
+}
+
+function thirdPageLoaded() {
+ (async function () {
+ FullZoomHelper.zoomTest(gTab1, gLevel, "Tab 1 should still be zoomed");
+ FullZoomHelper.zoomTest(gTab2, 1, "Tab 2 should still not be affected");
+ FullZoomHelper.zoomTest(
+ gTab3,
+ gLevel,
+ "Tab 3 should have zoomed as it was loading in the background"
+ );
+
+ // Switching to tab 2 should update its zoom setting.
+ await FullZoomHelper.selectTabAndWaitForLocationChange(gTab2);
+ FullZoomHelper.zoomTest(gTab1, gLevel, "Tab 1 should still be zoomed");
+ FullZoomHelper.zoomTest(gTab2, gLevel, "Tab 2 should be zoomed now");
+ FullZoomHelper.zoomTest(gTab3, gLevel, "Tab 3 should still be zoomed");
+
+ await FullZoomHelper.load(gTab1, gTestImage);
+ })().then(imageLoaded, FullZoomHelper.failAndContinue(finish));
+}
+
+function imageLoaded() {
+ (async function () {
+ FullZoomHelper.zoomTest(
+ gTab1,
+ 1,
+ "Zoom should be 1 when image was loaded in the background"
+ );
+ await FullZoomHelper.selectTabAndWaitForLocationChange(gTab1);
+ FullZoomHelper.zoomTest(
+ gTab1,
+ 1,
+ "Zoom should still be 1 when tab with image is selected"
+ );
+ })().then(imageZoomSwitch, FullZoomHelper.failAndContinue(finish));
+}
+
+function imageZoomSwitch() {
+ (async function () {
+ await FullZoomHelper.navigate(FullZoomHelper.BACK);
+ await FullZoomHelper.navigate(FullZoomHelper.FORWARD);
+ FullZoomHelper.zoomTest(
+ gTab1,
+ 1,
+ "Tab 1 should not be zoomed when an image loads"
+ );
+
+ await FullZoomHelper.selectTabAndWaitForLocationChange(gTab2);
+ FullZoomHelper.zoomTest(
+ gTab1,
+ 1,
+ "Tab 1 should still not be zoomed when deselected"
+ );
+ })().then(finishTest, FullZoomHelper.failAndContinue(finish));
+}
+
+var finishTestStarted = false;
+function finishTest() {
+ (async function () {
+ ok(!finishTestStarted, "finishTest called more than once");
+ finishTestStarted = true;
+ await FullZoomHelper.selectTabAndWaitForLocationChange(gTab1);
+ await FullZoomHelper.removeTabAndWaitForLocationChange(gTab1);
+ await FullZoomHelper.removeTabAndWaitForLocationChange(gTab2);
+ await FullZoomHelper.removeTabAndWaitForLocationChange(gTab3);
+ })().then(finish, FullZoomHelper.failAndContinue(finish));
+}
diff --git a/browser/base/content/test/zoom/browser_default_zoom.js b/browser/base/content/test/zoom/browser_default_zoom.js
new file mode 100644
index 0000000000..bf0533fdad
--- /dev/null
+++ b/browser/base/content/test/zoom/browser_default_zoom.js
@@ -0,0 +1,149 @@
+/* Any copyright is dedicated to the Public Domain.
+ * https://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_init_default_zoom() {
+ const TEST_PAGE_URL =
+ "data:text/html;charset=utf-8,<body>test_init_default_zoom</body>";
+
+ // Prepare the test tab
+ info("Creating tab");
+ let tab = BrowserTestUtils.addTab(gBrowser, TEST_PAGE_URL);
+ let tabBrowser = gBrowser.getBrowserForTab(tab);
+ await FullZoomHelper.selectTabAndWaitForLocationChange(tab);
+
+ // 100% global zoom
+ info("Getting default zoom");
+ let defaultZoom = await FullZoomHelper.getGlobalValue();
+ is(defaultZoom, 1, "Global zoom is init at 100%");
+ // 100% tab zoom
+ is(
+ ZoomManager.getZoomForBrowser(tabBrowser),
+ 1,
+ "Current zoom is init at 100%"
+ );
+ info("Removing tab");
+ await FullZoomHelper.removeTabAndWaitForLocationChange();
+});
+
+add_task(async function test_set_default_zoom() {
+ const TEST_PAGE_URL =
+ "data:text/html;charset=utf-8,<body>test_set_default_zoom</body>";
+
+ // Prepare the test tab
+ info("Creating tab");
+ let tab = BrowserTestUtils.addTab(gBrowser, TEST_PAGE_URL);
+ let tabBrowser = gBrowser.getBrowserForTab(tab);
+ await FullZoomHelper.selectTabAndWaitForLocationChange(tab);
+
+ // 120% global zoom
+ info("Changing default zoom");
+ await FullZoomHelper.changeDefaultZoom(120);
+ let defaultZoom = await FullZoomHelper.getGlobalValue();
+ is(defaultZoom, 1.2, "Global zoom is at 120%");
+
+ // 120% tab zoom
+ await TestUtils.waitForCondition(() => {
+ info("Current zoom is: " + ZoomManager.getZoomForBrowser(tabBrowser));
+ return ZoomManager.getZoomForBrowser(tabBrowser) == 1.2;
+ });
+ is(
+ ZoomManager.getZoomForBrowser(tabBrowser),
+ 1.2,
+ "Current zoom matches changed default zoom"
+ );
+ info("Removing tab");
+ await FullZoomHelper.removeTabAndWaitForLocationChange();
+ await FullZoomHelper.changeDefaultZoom(100);
+});
+
+add_task(async function test_enlarge_reduce_reset_local_zoom() {
+ const TEST_PAGE_URL =
+ "data:text/html;charset=utf-8,<body>test_enlarge_reduce_reset_local_zoom</body>";
+
+ // Prepare the test tab
+ info("Creating tab");
+ let tab = BrowserTestUtils.addTab(gBrowser, TEST_PAGE_URL);
+ let tabBrowser = gBrowser.getBrowserForTab(tab);
+ await FullZoomHelper.selectTabAndWaitForLocationChange(tab);
+
+ // 120% global zoom
+ info("Changing default zoom");
+ await FullZoomHelper.changeDefaultZoom(120);
+ let defaultZoom = await FullZoomHelper.getGlobalValue();
+ is(defaultZoom, 1.2, "Global zoom is at 120%");
+
+ await TestUtils.waitForCondition(() => {
+ info("Current tab zoom is ", ZoomManager.getZoomForBrowser(tabBrowser));
+ return ZoomManager.getZoomForBrowser(tabBrowser) == 1.2;
+ });
+ is(
+ ZoomManager.getZoomForBrowser(tabBrowser),
+ 1.2,
+ "Current tab zoom matches default zoom"
+ );
+
+ await FullZoom.enlarge();
+ info("Enlarged!");
+ defaultZoom = await FullZoomHelper.getGlobalValue();
+ info("Current global zoom is " + defaultZoom);
+
+ // 133% tab zoom
+ await TestUtils.waitForCondition(() => {
+ info("Current tab zoom is ", ZoomManager.getZoomForBrowser(tabBrowser));
+ return ZoomManager.getZoomForBrowser(tabBrowser) == 1.33;
+ });
+ is(
+ ZoomManager.getZoomForBrowser(tabBrowser),
+ 1.33,
+ "Increasing zoom changes zoom of current tab."
+ );
+ defaultZoom = await FullZoomHelper.getGlobalValue();
+ // 120% global zoom
+ is(
+ defaultZoom,
+ 1.2,
+ "Increasing zoom of current tab doesn't change default zoom."
+ );
+ info("Reducing...");
+ info("Current tab zoom is ", ZoomManager.getZoomForBrowser(tabBrowser));
+ await FullZoom.reduce(); // 120% tab zoom
+ info("Current tab zoom is ", ZoomManager.getZoomForBrowser(tabBrowser));
+ await FullZoom.reduce(); // 110% tab zoom
+ info("Current tab zoom is ", ZoomManager.getZoomForBrowser(tabBrowser));
+ await FullZoom.reduce(); // 100% tab zoom
+ info("Current tab zoom is ", ZoomManager.getZoomForBrowser(tabBrowser));
+
+ await TestUtils.waitForCondition(() => {
+ info("Current tab zoom is ", ZoomManager.getZoomForBrowser(tabBrowser));
+ return ZoomManager.getZoomForBrowser(tabBrowser) == 1;
+ });
+ is(
+ ZoomManager.getZoomForBrowser(tabBrowser),
+ 1,
+ "Decreasing zoom changes zoom of current tab."
+ );
+ defaultZoom = await FullZoomHelper.getGlobalValue();
+ // 120% global zoom
+ is(
+ defaultZoom,
+ 1.2,
+ "Decreasing zoom of current tab doesn't change default zoom."
+ );
+ info("Resetting...");
+ FullZoom.reset(); // 120% tab zoom
+ await TestUtils.waitForCondition(() => {
+ info("Current tab zoom is ", ZoomManager.getZoomForBrowser(tabBrowser));
+ return ZoomManager.getZoomForBrowser(tabBrowser) == 1.2;
+ });
+ is(
+ ZoomManager.getZoomForBrowser(tabBrowser),
+ 1.2,
+ "Reseting zoom causes current tab to zoom to default zoom."
+ );
+
+ // no reset necessary, it was performed as part of the test
+ info("Removing tab");
+ await FullZoomHelper.removeTabAndWaitForLocationChange();
+});
diff --git a/browser/base/content/test/zoom/browser_default_zoom_fission.js b/browser/base/content/test/zoom/browser_default_zoom_fission.js
new file mode 100644
index 0000000000..4d3d8b3896
--- /dev/null
+++ b/browser/base/content/test/zoom/browser_default_zoom_fission.js
@@ -0,0 +1,114 @@
+/* Any copyright is dedicated to the Public Domain.
+ * https://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_sitespecific_iframe_global_zoom() {
+ const TEST_PAGE_URL =
+ 'data:text/html;charset=utf-8,<body>test_sitespecific_iframe_global_zoom<iframe src=""></iframe></body>';
+ const TEST_IFRAME_URL = "https://example.com/";
+
+ // Prepare the test tab
+ console.log("Creating tab");
+ let tab = BrowserTestUtils.addTab(gBrowser, TEST_PAGE_URL);
+ let tabBrowser = gBrowser.getBrowserForTab(tab);
+ console.log("Loading tab");
+ await FullZoomHelper.selectTabAndWaitForLocationChange(tab);
+
+ // 67% global zoom
+ console.log("Changing default zoom");
+ await FullZoomHelper.changeDefaultZoom(67);
+ let defaultZoom = await FullZoomHelper.getGlobalValue();
+ is(defaultZoom, 0.67, "Global zoom is at 67%");
+ await TestUtils.waitForCondition(() => {
+ console.log("Current zoom is: ", ZoomManager.getZoomForBrowser(tabBrowser));
+ return ZoomManager.getZoomForBrowser(tabBrowser) == 0.67;
+ });
+
+ let zoomLevel = ZoomManager.getZoomForBrowser(tabBrowser).toFixed(2);
+ is(zoomLevel, "0.67", "tab zoom has been set to 67%");
+
+ let frameLoadedPromise = BrowserTestUtils.browserLoaded(
+ tabBrowser,
+ true,
+ TEST_IFRAME_URL
+ );
+ console.log("Spawinging iframe");
+ SpecialPowers.spawn(tabBrowser, [TEST_IFRAME_URL], url => {
+ content.document.querySelector("iframe").src = url;
+ });
+ console.log("Awaiting frame promise");
+ let loadedURL = await frameLoadedPromise;
+ is(loadedURL, TEST_IFRAME_URL, "got the load event for the iframe");
+
+ let frameZoom = await SpecialPowers.spawn(
+ gBrowser.selectedBrowser.browsingContext.children[0],
+ [],
+ async () => {
+ await ContentTaskUtils.waitForCondition(() => {
+ return content.docShell.browsingContext.fullZoom.toFixed(2) == 0.67;
+ });
+ return content.docShell.browsingContext.fullZoom.toFixed(2);
+ }
+ );
+
+ is(frameZoom, zoomLevel, "global zoom is reflected in iframe");
+ console.log("Removing tab");
+ await FullZoomHelper.removeTabAndWaitForLocationChange();
+});
+
+add_task(async function test_sitespecific_global_zoom_enlarge() {
+ const TEST_PAGE_URL =
+ 'data:text/html;charset=utf-8,<body>test_sitespecific_global_zoom_enlarge<iframe src=""></iframe></body>';
+ const TEST_IFRAME_URL = "https://example.org/";
+
+ // Prepare the test tab
+ console.log("Adding tab");
+ let tab = BrowserTestUtils.addTab(gBrowser, TEST_PAGE_URL);
+ let tabBrowser = gBrowser.getBrowserForTab(tab);
+ console.log("Awaiting tab load");
+ await FullZoomHelper.selectTabAndWaitForLocationChange(tab);
+
+ // 67% global zoom persists from previous test
+
+ let frameLoadedPromise = BrowserTestUtils.browserLoaded(
+ tabBrowser,
+ true,
+ TEST_IFRAME_URL
+ );
+ console.log("Spawning iframe");
+ SpecialPowers.spawn(tabBrowser, [TEST_IFRAME_URL], url => {
+ content.document.querySelector("iframe").src = url;
+ });
+ console.log("Awaiting iframe load");
+ let loadedURL = await frameLoadedPromise;
+ is(loadedURL, TEST_IFRAME_URL, "got the load event for the iframe");
+ console.log("Enlarging tab");
+ await FullZoom.enlarge();
+ // 80% local zoom
+ await TestUtils.waitForCondition(() => {
+ console.log("Current zoom is: ", ZoomManager.getZoomForBrowser(tabBrowser));
+ return ZoomManager.getZoomForBrowser(tabBrowser) == 0.8;
+ });
+
+ is(
+ ZoomManager.getZoomForBrowser(tabBrowser).toFixed(2),
+ "0.80",
+ "Local zoom is increased"
+ );
+
+ let frameZoom = await SpecialPowers.spawn(
+ gBrowser.selectedBrowser.browsingContext.children[0],
+ [],
+ async () => {
+ await ContentTaskUtils.waitForCondition(() => {
+ return content.docShell.browsingContext.fullZoom.toFixed(2) == 0.8;
+ });
+ return content.docShell.browsingContext.fullZoom.toFixed(2);
+ }
+ );
+
+ is(frameZoom, "0.80", "(without fission) iframe zoom matches page zoom");
+ console.log("Removing tab");
+ await FullZoomHelper.removeTabAndWaitForLocationChange();
+});
diff --git a/browser/base/content/test/zoom/browser_default_zoom_multitab.js b/browser/base/content/test/zoom/browser_default_zoom_multitab.js
new file mode 100644
index 0000000000..d204020889
--- /dev/null
+++ b/browser/base/content/test/zoom/browser_default_zoom_multitab.js
@@ -0,0 +1,190 @@
+/* Any copyright is dedicated to the Public Domain.
+ * https://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_multidomain_global_zoom() {
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ const TEST_PAGE_URL_1 = "http://example.com/";
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ const TEST_PAGE_URL_2 = "http://example.org/";
+
+ // Prepare the test tabs
+ console.log("Creating tab 1");
+ let tab1 = BrowserTestUtils.addTab(gBrowser, TEST_PAGE_URL_1);
+ let tabBrowser1 = gBrowser.getBrowserForTab(tab1);
+ await FullZoomHelper.selectTabAndWaitForLocationChange(tab1);
+
+ console.log("Creating tab 2");
+ let tab2 = BrowserTestUtils.addTab(gBrowser, TEST_PAGE_URL_2);
+ let tabBrowser2 = gBrowser.getBrowserForTab(tab2);
+ await FullZoomHelper.selectTabAndWaitForLocationChange(tab2);
+
+ // 67% global zoom
+ console.log("Changing default zoom");
+ await FullZoomHelper.changeDefaultZoom(67);
+ let defaultZoom = await FullZoomHelper.getGlobalValue();
+ is(defaultZoom, 0.67, "Global zoom is set to 67%");
+
+ // 67% local zoom tab 1
+ console.log("Selecting tab 1");
+ await FullZoomHelper.selectTabAndWaitForLocationChange(tab1);
+ await TestUtils.waitForCondition(() => {
+ console.log(
+ "Currnet zoom is: ",
+ ZoomManager.getZoomForBrowser(tabBrowser1)
+ );
+ return ZoomManager.getZoomForBrowser(tabBrowser1) == 0.67;
+ });
+ is(
+ ZoomManager.getZoomForBrowser(tabBrowser1),
+ 0.67,
+ "Setting default zoom causes tab 1 (background) to zoom to default zoom."
+ );
+
+ // 67% local zoom tab 2
+ console.log("Selecting tab 2");
+ await FullZoomHelper.selectTabAndWaitForLocationChange(tab2);
+ await TestUtils.waitForCondition(() => {
+ console.log(
+ "Current zoom is: ",
+ ZoomManager.getZoomForBrowser(tabBrowser2)
+ );
+ return ZoomManager.getZoomForBrowser(tabBrowser2) == 0.67;
+ });
+ is(
+ ZoomManager.getZoomForBrowser(tabBrowser2),
+ 0.67,
+ "Setting default zoom causes tab 2 (foreground) to zoom to default zoom."
+ );
+ console.log("Enlarging tab");
+ await FullZoom.enlarge();
+ // 80% local zoom tab 2
+ await TestUtils.waitForCondition(() => {
+ console.log(
+ "Current zoom is: ",
+ ZoomManager.getZoomForBrowser(tabBrowser2)
+ );
+ return ZoomManager.getZoomForBrowser(tabBrowser2) == 0.8;
+ });
+ is(
+ ZoomManager.getZoomForBrowser(tabBrowser2),
+ 0.8,
+ "Enlarging local zoom of tab 2."
+ );
+
+ // 67% local zoom tab 1
+ console.log("Selecting tab 1");
+ await FullZoomHelper.selectTabAndWaitForLocationChange(tab1);
+ await TestUtils.waitForCondition(() => {
+ console.log(
+ "Current zoom is: ",
+ ZoomManager.getZoomForBrowser(tabBrowser1)
+ );
+ return ZoomManager.getZoomForBrowser(tabBrowser1) == 0.67;
+ });
+ is(
+ ZoomManager.getZoomForBrowser(tabBrowser1),
+ 0.67,
+ "Tab 1 is unchanged by tab 2's enlarge call."
+ );
+ console.log("Removing tab");
+ await FullZoomHelper.removeTabAndWaitForLocationChange();
+ console.log("Removing tab");
+ await FullZoomHelper.removeTabAndWaitForLocationChange();
+});
+
+add_task(async function test_site_specific_global_zoom() {
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ const TEST_PAGE_URL_1 = "http://example.net/";
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ const TEST_PAGE_URL_2 = "http://example.net/";
+
+ // Prepare the test tabs
+ console.log("Adding tab 1");
+ let tab1 = BrowserTestUtils.addTab(gBrowser, TEST_PAGE_URL_1);
+ console.log("Getting tab 1 browser");
+ let tabBrowser1 = gBrowser.getBrowserForTab(tab1);
+ await FullZoomHelper.selectTabAndWaitForLocationChange(tab1);
+
+ console.log("Adding tab 2");
+ let tab2 = BrowserTestUtils.addTab(gBrowser, TEST_PAGE_URL_2);
+ console.log("Getting tab 2 browser");
+ let tabBrowser2 = gBrowser.getBrowserForTab(tab2);
+ await FullZoomHelper.selectTabAndWaitForLocationChange(tab2);
+
+ console.log("checking global zoom");
+ // 67% global zoom persists from previous test
+ let defaultZoom = await FullZoomHelper.getGlobalValue();
+ is(defaultZoom, 0.67, "Default zoom is 67%");
+
+ // 67% local zoom tab 1
+ console.log("Selecting tab 1");
+ await FullZoomHelper.selectTabAndWaitForLocationChange(tab1);
+ console.log("Awaiting condition");
+ await TestUtils.waitForCondition(() => {
+ console.log(
+ "Current tab 1 zoom is: ",
+ ZoomManager.getZoomForBrowser(tabBrowser1)
+ );
+ return ZoomManager.getZoomForBrowser(tabBrowser1) == 0.67;
+ });
+ is(
+ ZoomManager.getZoomForBrowser(tabBrowser1),
+ 0.67,
+ "Setting default zoom causes tab 1 (background) to zoom to default zoom."
+ );
+
+ // 67% local zoom tab 2
+ console.log("Selecting tab 2");
+ await FullZoomHelper.selectTabAndWaitForLocationChange(tab2);
+ console.log("Awaiting condition");
+ await TestUtils.waitForCondition(() => {
+ console.log(
+ "Current tab 2 zoom is: ",
+ ZoomManager.getZoomForBrowser(tabBrowser2)
+ );
+ return ZoomManager.getZoomForBrowser(tabBrowser2) == 0.67;
+ });
+ is(
+ ZoomManager.getZoomForBrowser(tabBrowser2),
+ 0.67,
+ "Setting default zoom causes tab 2 (foreground) to zoom to default zoom."
+ );
+
+ // 80% site specific zoom
+ console.log("Selecting tab 1");
+ await FullZoomHelper.selectTabAndWaitForLocationChange(tab1);
+ console.log("Enlarging");
+ await FullZoom.enlarge();
+ await TestUtils.waitForCondition(() => {
+ console.log(
+ "Current tab 1 zoom is: ",
+ ZoomManager.getZoomForBrowser(tabBrowser1)
+ );
+ return ZoomManager.getZoomForBrowser(tabBrowser1) == 0.8;
+ });
+ is(
+ ZoomManager.getZoomForBrowser(tabBrowser1),
+ 0.8,
+ "Changed local zoom in tab one."
+ );
+ console.log("Selecting tab 2");
+ await FullZoomHelper.selectTabAndWaitForLocationChange(tab2);
+ await TestUtils.waitForCondition(() => {
+ console.log(
+ "Current tab 2 zoom is: ",
+ ZoomManager.getZoomForBrowser(tabBrowser2)
+ );
+ return ZoomManager.getZoomForBrowser(tabBrowser2) == 0.8;
+ });
+ is(
+ ZoomManager.getZoomForBrowser(tabBrowser2),
+ 0.8,
+ "Second tab respects site specific zoom."
+ );
+ console.log("Removing tab");
+ await FullZoomHelper.removeTabAndWaitForLocationChange();
+ console.log("Removing tab");
+ await FullZoomHelper.removeTabAndWaitForLocationChange();
+});
diff --git a/browser/base/content/test/zoom/browser_default_zoom_multitab_002.js b/browser/base/content/test/zoom/browser_default_zoom_multitab_002.js
new file mode 100644
index 0000000000..3e85bffa51
--- /dev/null
+++ b/browser/base/content/test/zoom/browser_default_zoom_multitab_002.js
@@ -0,0 +1,93 @@
+/* Any copyright is dedicated to the Public Domain.
+ * https://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_site_specific_global_zoom() {
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ const TEST_PAGE_URL_1 = "http://example.net";
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ const TEST_PAGE_URL_2 = "http://example.net";
+
+ // Prepare the test tabs
+ console.log("Adding tab 1");
+ let tab1 = BrowserTestUtils.addTab(gBrowser);
+ let tabBrowser1 = gBrowser.getBrowserForTab(tab1);
+ await FullZoomHelper.selectTabAndWaitForLocationChange(tab1);
+ await FullZoomHelper.load(tab1, TEST_PAGE_URL_1);
+
+ console.log("Adding tab 2");
+ let tab2 = BrowserTestUtils.addTab(gBrowser);
+ let tabBrowser2 = gBrowser.getBrowserForTab(tab2);
+ await FullZoomHelper.selectTabAndWaitForLocationChange(tab2);
+ await FullZoomHelper.load(tab2, TEST_PAGE_URL_2);
+
+ // 67% global zoom
+ await FullZoomHelper.changeDefaultZoom(67);
+ let defaultZoom = await FullZoomHelper.getGlobalValue();
+ is(defaultZoom, 0.67, "Global zoom is set to 67%");
+
+ // 67% local zoom tab 1
+ await FullZoomHelper.selectTabAndWaitForLocationChange(tab1);
+ await TestUtils.waitForCondition(() => {
+ console.log(
+ "Current tab 1 zoom is: ",
+ ZoomManager.getZoomForBrowser(tabBrowser1)
+ );
+ return ZoomManager.getZoomForBrowser(tabBrowser1) == 0.67;
+ });
+ is(
+ ZoomManager.getZoomForBrowser(tabBrowser1),
+ 0.67,
+ "Setting default zoom causes tab 1 (background) to zoom to default zoom."
+ );
+
+ // 67% local zoom tab 2
+ await FullZoomHelper.selectTabAndWaitForLocationChange(tab2);
+ await TestUtils.waitForCondition(() => {
+ console.log(
+ "Current tab 2 zoom is: ",
+ ZoomManager.getZoomForBrowser(tabBrowser2)
+ );
+ return ZoomManager.getZoomForBrowser(tabBrowser2) == 0.67;
+ });
+ is(
+ ZoomManager.getZoomForBrowser(tabBrowser2),
+ 0.67,
+ "Setting default zoom causes tab 2 (foreground) to zoom to default zoom."
+ );
+
+ // 80% site specific zoom
+ await FullZoomHelper.selectTabAndWaitForLocationChange(tab1);
+ console.log("Enlarging");
+ await FullZoom.enlarge();
+ await TestUtils.waitForCondition(() => {
+ console.log(
+ "Current tab 1 zoom is: ",
+ ZoomManager.getZoomForBrowser(tabBrowser1)
+ );
+ return ZoomManager.getZoomForBrowser(tabBrowser1) == 0.8;
+ });
+ is(
+ ZoomManager.getZoomForBrowser(tabBrowser1),
+ 0.8,
+ "Changed local zoom in tab one."
+ );
+
+ await FullZoomHelper.selectTabAndWaitForLocationChange(tab2);
+ await TestUtils.waitForCondition(() => {
+ console.log(
+ "Current tab 2 zoom is: ",
+ ZoomManager.getZoomForBrowser(tabBrowser2)
+ );
+ return ZoomManager.getZoomForBrowser(tabBrowser2) == 0.8;
+ });
+ is(
+ ZoomManager.getZoomForBrowser(tabBrowser2),
+ 0.8,
+ "Second tab respects site specific zoom."
+ );
+
+ await FullZoomHelper.removeTabAndWaitForLocationChange();
+ await FullZoomHelper.removeTabAndWaitForLocationChange();
+});
diff --git a/browser/base/content/test/zoom/browser_default_zoom_sitespecific.js b/browser/base/content/test/zoom/browser_default_zoom_sitespecific.js
new file mode 100644
index 0000000000..923f3b687a
--- /dev/null
+++ b/browser/base/content/test/zoom/browser_default_zoom_sitespecific.js
@@ -0,0 +1,108 @@
+/* Any copyright is dedicated to the Public Domain.
+ * https://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+add_task(async function test_disabled_ss_multi() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.zoom.siteSpecific", false]],
+ });
+ const TEST_PAGE_URL = "https://example.org/";
+
+ // Prepare the test tabs
+ let tab2 = BrowserTestUtils.addTab(gBrowser, TEST_PAGE_URL);
+ let tabBrowser2 = gBrowser.getBrowserForTab(tab2);
+ let isLoaded = BrowserTestUtils.browserLoaded(
+ tabBrowser2,
+ false,
+ TEST_PAGE_URL
+ );
+ await FullZoomHelper.selectTabAndWaitForLocationChange(tab2);
+ await isLoaded;
+
+ let zoomLevel = ZoomManager.getZoomForBrowser(tabBrowser2);
+ is(zoomLevel, 1, "tab 2 zoom has been set to 100%");
+
+ let tab1 = BrowserTestUtils.addTab(gBrowser, TEST_PAGE_URL);
+ let tabBrowser1 = gBrowser.getBrowserForTab(tab1);
+ isLoaded = BrowserTestUtils.browserLoaded(tabBrowser1, false, TEST_PAGE_URL);
+ await FullZoomHelper.selectTabAndWaitForLocationChange(tab1);
+ await isLoaded;
+
+ zoomLevel = ZoomManager.getZoomForBrowser(tabBrowser1);
+ is(zoomLevel, 1, "tab 1 zoom has been set to 100%");
+
+ // 67% global zoom
+ await FullZoomHelper.changeDefaultZoom(67);
+ let defaultZoom = await FullZoomHelper.getGlobalValue();
+ is(defaultZoom, 0.67, "Global zoom is at 67%");
+
+ await TestUtils.waitForCondition(
+ () => ZoomManager.getZoomForBrowser(tabBrowser1) == 0.67
+ );
+ zoomLevel = ZoomManager.getZoomForBrowser(tabBrowser1);
+ is(zoomLevel, 0.67, "tab 1 zoom has been set to 67%");
+
+ await FullZoom.enlarge();
+
+ zoomLevel = ZoomManager.getZoomForBrowser(tabBrowser1);
+ is(zoomLevel, 0.8, "tab 1 zoom has been set to 80%");
+
+ await FullZoomHelper.selectTabAndWaitForLocationChange(tab2);
+ zoomLevel = ZoomManager.getZoomForBrowser(tabBrowser2);
+ is(zoomLevel, 1, "tab 2 zoom remains 100%");
+
+ let tab3 = BrowserTestUtils.addTab(gBrowser, TEST_PAGE_URL);
+ let tabBrowser3 = gBrowser.getBrowserForTab(tab3);
+ isLoaded = BrowserTestUtils.browserLoaded(tabBrowser3, false, TEST_PAGE_URL);
+ await FullZoomHelper.selectTabAndWaitForLocationChange(tab3);
+ await isLoaded;
+
+ zoomLevel = ZoomManager.getZoomForBrowser(tabBrowser3);
+ is(zoomLevel, 0.67, "tab 3 zoom has been set to 67%");
+
+ await FullZoomHelper.removeTabAndWaitForLocationChange();
+ await FullZoomHelper.removeTabAndWaitForLocationChange();
+ await FullZoomHelper.removeTabAndWaitForLocationChange();
+});
+
+add_task(async function test_disabled_ss_custom() {
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.zoom.siteSpecific", false]],
+ });
+ const TEST_PAGE_URL = "https://example.org/";
+
+ // 150% global zoom
+ await FullZoomHelper.changeDefaultZoom(150);
+ let defaultZoom = await FullZoomHelper.getGlobalValue();
+ is(defaultZoom, 1.5, "Global zoom is at 150%");
+
+ // Prepare test tab
+ let tab1 = BrowserTestUtils.addTab(gBrowser, TEST_PAGE_URL);
+ let tabBrowser1 = gBrowser.getBrowserForTab(tab1);
+ let isLoaded = BrowserTestUtils.browserLoaded(
+ tabBrowser1,
+ false,
+ TEST_PAGE_URL
+ );
+ await FullZoomHelper.selectTabAndWaitForLocationChange(tab1);
+ await isLoaded;
+
+ await TestUtils.waitForCondition(
+ () => ZoomManager.getZoomForBrowser(tabBrowser1) == 1.5
+ );
+ let zoomLevel = ZoomManager.getZoomForBrowser(tabBrowser1);
+ is(zoomLevel, 1.5, "tab 1 zoom has been set to 150%");
+
+ await FullZoom.enlarge();
+
+ zoomLevel = ZoomManager.getZoomForBrowser(tabBrowser1);
+ is(zoomLevel, 1.7, "tab 1 zoom has been set to 170%");
+
+ await BrowserTestUtils.reloadTab(tab1);
+
+ zoomLevel = ZoomManager.getZoomForBrowser(tabBrowser1);
+ is(zoomLevel, 1.7, "tab 1 zoom remains 170%");
+
+ await FullZoomHelper.removeTabAndWaitForLocationChange();
+});
diff --git a/browser/base/content/test/zoom/browser_image_zoom_tabswitch.js b/browser/base/content/test/zoom/browser_image_zoom_tabswitch.js
new file mode 100644
index 0000000000..83ffab26e8
--- /dev/null
+++ b/browser/base/content/test/zoom/browser_image_zoom_tabswitch.js
@@ -0,0 +1,39 @@
+/* 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 test() {
+ let tab1, tab2;
+ const TEST_IMAGE =
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.org/browser/browser/base/content/test/general/moz.png";
+
+ waitForExplicitFinish();
+
+ (async function () {
+ tab1 = BrowserTestUtils.addTab(gBrowser);
+ tab2 = BrowserTestUtils.addTab(gBrowser);
+ await FullZoomHelper.selectTabAndWaitForLocationChange(tab1);
+ await FullZoomHelper.load(tab1, TEST_IMAGE);
+
+ is(ZoomManager.zoom, 1, "initial zoom level for first should be 1");
+
+ FullZoom.enlarge();
+ let zoom = ZoomManager.zoom;
+ isnot(zoom, 1, "zoom level should have changed");
+
+ await FullZoomHelper.selectTabAndWaitForLocationChange(tab2);
+ is(ZoomManager.zoom, 1, "initial zoom level for second tab should be 1");
+
+ await FullZoomHelper.selectTabAndWaitForLocationChange(tab1);
+ is(
+ ZoomManager.zoom,
+ zoom,
+ "zoom level for first tab should not have changed"
+ );
+
+ await FullZoomHelper.removeTabAndWaitForLocationChange(tab1);
+ await FullZoomHelper.removeTabAndWaitForLocationChange(tab2);
+ })().then(finish, FullZoomHelper.failAndContinue(finish));
+}
diff --git a/browser/base/content/test/zoom/browser_mousewheel_zoom.js b/browser/base/content/test/zoom/browser_mousewheel_zoom.js
new file mode 100644
index 0000000000..a814f1dba6
--- /dev/null
+++ b/browser/base/content/test/zoom/browser_mousewheel_zoom.js
@@ -0,0 +1,72 @@
+/* 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 TEST_PAGE =
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.org/browser/browser/base/content/test/zoom/zoom_test.html";
+
+var gTab1, gTab2, gLevel1;
+
+function test() {
+ waitForExplicitFinish();
+
+ // Scroll on Ctrl + mousewheel
+ SpecialPowers.pushPrefEnv({ set: [["mousewheel.with_control.action", 3]] });
+
+ (async function () {
+ gTab1 = BrowserTestUtils.addTab(gBrowser);
+ gTab2 = BrowserTestUtils.addTab(gBrowser);
+
+ await FullZoomHelper.selectTabAndWaitForLocationChange(gTab1);
+ await FullZoomHelper.load(gTab1, TEST_PAGE);
+ await FullZoomHelper.load(gTab2, TEST_PAGE);
+ })().then(zoomTab1, FullZoomHelper.failAndContinue(finish));
+}
+
+function zoomTab1() {
+ (async function () {
+ is(gBrowser.selectedTab, gTab1, "Tab 1 is selected");
+ FullZoomHelper.zoomTest(gTab1, 1, "Initial zoom of tab 1 should be 1");
+ FullZoomHelper.zoomTest(gTab2, 1, "Initial zoom of tab 2 should be 1");
+
+ let browser1 = gBrowser.getBrowserForTab(gTab1);
+ await BrowserTestUtils.synthesizeMouse(
+ null,
+ 10,
+ 10,
+ {
+ wheel: true,
+ ctrlKey: true,
+ deltaY: -1,
+ deltaMode: WheelEvent.DOM_DELTA_LINE,
+ },
+ browser1
+ );
+
+ info("Waiting for tab 1 to be zoomed");
+ await TestUtils.waitForCondition(() => {
+ gLevel1 = ZoomManager.getZoomForBrowser(browser1);
+ return gLevel1 > 1;
+ });
+
+ await FullZoomHelper.selectTabAndWaitForLocationChange(gTab2);
+ FullZoomHelper.zoomTest(
+ gTab2,
+ gLevel1,
+ "Tab 2 should have zoomed along with tab 1"
+ );
+ })().then(finishTest, FullZoomHelper.failAndContinue(finish));
+}
+
+function finishTest() {
+ (async function () {
+ await FullZoomHelper.selectTabAndWaitForLocationChange(gTab1);
+ await FullZoom.reset();
+ await FullZoomHelper.removeTabAndWaitForLocationChange(gTab1);
+ await FullZoomHelper.selectTabAndWaitForLocationChange(gTab2);
+ await FullZoom.reset();
+ await FullZoomHelper.removeTabAndWaitForLocationChange(gTab2);
+ })().then(finish, FullZoomHelper.failAndContinue(finish));
+}
diff --git a/browser/base/content/test/zoom/browser_sitespecific_background_pref.js b/browser/base/content/test/zoom/browser_sitespecific_background_pref.js
new file mode 100644
index 0000000000..5756c4d8d3
--- /dev/null
+++ b/browser/base/content/test/zoom/browser_sitespecific_background_pref.js
@@ -0,0 +1,35 @@
+function test() {
+ waitForExplicitFinish();
+
+ (async function () {
+ let testPage =
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.org/browser/browser/base/content/test/zoom/zoom_test.html";
+ let tab1 = BrowserTestUtils.addTab(gBrowser);
+ await FullZoomHelper.selectTabAndWaitForLocationChange(tab1);
+ await FullZoomHelper.load(tab1, testPage);
+
+ let tab2 = BrowserTestUtils.addTab(gBrowser);
+ await FullZoomHelper.load(tab2, testPage);
+
+ await FullZoom.enlarge();
+ let tab1Zoom = ZoomManager.getZoomForBrowser(tab1.linkedBrowser);
+
+ await FullZoomHelper.selectTabAndWaitForLocationChange(tab2);
+ let tab2Zoom = ZoomManager.getZoomForBrowser(tab2.linkedBrowser);
+ is(tab2Zoom, tab1Zoom, "Zoom should affect background tabs");
+
+ Services.prefs.setBoolPref("browser.zoom.updateBackgroundTabs", false);
+ await FullZoom.reset();
+ gBrowser.selectedTab = tab1;
+ tab1Zoom = ZoomManager.getZoomForBrowser(tab1.linkedBrowser);
+ tab2Zoom = ZoomManager.getZoomForBrowser(tab2.linkedBrowser);
+ isnot(tab1Zoom, tab2Zoom, "Zoom should not affect background tabs");
+
+ if (Services.prefs.prefHasUserValue("browser.zoom.updateBackgroundTabs")) {
+ Services.prefs.clearUserPref("browser.zoom.updateBackgroundTabs");
+ }
+ await FullZoomHelper.removeTabAndWaitForLocationChange(tab1);
+ await FullZoomHelper.removeTabAndWaitForLocationChange(tab2);
+ })().then(finish, FullZoomHelper.failAndContinue(finish));
+}
diff --git a/browser/base/content/test/zoom/browser_sitespecific_image_zoom.js b/browser/base/content/test/zoom/browser_sitespecific_image_zoom.js
new file mode 100644
index 0000000000..fae454fd04
--- /dev/null
+++ b/browser/base/content/test/zoom/browser_sitespecific_image_zoom.js
@@ -0,0 +1,52 @@
+var tabElm, zoomLevel;
+function start_test_prefNotSet() {
+ (async function () {
+ is(ZoomManager.zoom, 1, "initial zoom level should be 1");
+ FullZoom.enlarge();
+
+ // capture the zoom level to test later
+ zoomLevel = ZoomManager.zoom;
+ isnot(zoomLevel, 1, "zoom level should have changed");
+
+ await FullZoomHelper.load(
+ gBrowser.selectedTab,
+ "http://mochi.test:8888/browser/browser/base/content/test/general/moz.png"
+ );
+ })().then(continue_test_prefNotSet, FullZoomHelper.failAndContinue(finish));
+}
+
+function continue_test_prefNotSet() {
+ (async function () {
+ is(ZoomManager.zoom, 1, "zoom level pref should not apply to an image");
+ await FullZoom.reset();
+
+ await FullZoomHelper.load(
+ gBrowser.selectedTab,
+ "http://mochi.test:8888/browser/browser/base/content/test/zoom/zoom_test.html"
+ );
+ })().then(end_test_prefNotSet, FullZoomHelper.failAndContinue(finish));
+}
+
+function end_test_prefNotSet() {
+ (async function () {
+ is(ZoomManager.zoom, zoomLevel, "the zoom level should have persisted");
+
+ // Reset the zoom so that other tests have a fresh zoom level
+ await FullZoom.reset();
+ await FullZoomHelper.removeTabAndWaitForLocationChange();
+ finish();
+ })();
+}
+
+function test() {
+ waitForExplicitFinish();
+
+ (async function () {
+ tabElm = BrowserTestUtils.addTab(gBrowser);
+ await FullZoomHelper.selectTabAndWaitForLocationChange(tabElm);
+ await FullZoomHelper.load(
+ tabElm,
+ "http://mochi.test:8888/browser/browser/base/content/test/zoom/zoom_test.html"
+ );
+ })().then(start_test_prefNotSet, FullZoomHelper.failAndContinue(finish));
+}
diff --git a/browser/base/content/test/zoom/browser_sitespecific_video_zoom.js b/browser/base/content/test/zoom/browser_sitespecific_video_zoom.js
new file mode 100644
index 0000000000..ba61a2f5a5
--- /dev/null
+++ b/browser/base/content/test/zoom/browser_sitespecific_video_zoom.js
@@ -0,0 +1,128 @@
+/* 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 TEST_PAGE =
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.org/browser/browser/base/content/test/zoom/zoom_test.html";
+const TEST_VIDEO =
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.org/browser/browser/base/content/test/general/video.ogg";
+
+var gTab1, gTab2, gLevel1;
+
+function test() {
+ waitForExplicitFinish();
+
+ (async function () {
+ gTab1 = BrowserTestUtils.addTab(gBrowser);
+ gTab2 = BrowserTestUtils.addTab(gBrowser);
+
+ await FullZoomHelper.selectTabAndWaitForLocationChange(gTab1);
+ await FullZoomHelper.load(gTab1, TEST_PAGE);
+ await FullZoomHelper.load(gTab2, TEST_VIDEO);
+ })().then(zoomTab1, FullZoomHelper.failAndContinue(finish));
+}
+
+function zoomTab1() {
+ (async function () {
+ is(gBrowser.selectedTab, gTab1, "Tab 1 is selected");
+
+ // Reset zoom level if we run this test > 1 time in same browser session.
+ var level1 = ZoomManager.getZoomForBrowser(
+ gBrowser.getBrowserForTab(gTab1)
+ );
+ if (level1 > 1) {
+ FullZoom.reduce();
+ }
+
+ FullZoomHelper.zoomTest(gTab1, 1, "Initial zoom of tab 1 should be 1");
+ FullZoomHelper.zoomTest(gTab2, 1, "Initial zoom of tab 2 should be 1");
+
+ FullZoom.enlarge();
+ gLevel1 = ZoomManager.getZoomForBrowser(gBrowser.getBrowserForTab(gTab1));
+
+ ok(gLevel1 > 1, "New zoom for tab 1 should be greater than 1");
+ FullZoomHelper.zoomTest(gTab2, 1, "Zooming tab 1 should not affect tab 2");
+
+ await FullZoomHelper.selectTabAndWaitForLocationChange(gTab2);
+ FullZoomHelper.zoomTest(
+ gTab2,
+ 1,
+ "Tab 2 is still unzoomed after it is selected"
+ );
+ FullZoomHelper.zoomTest(gTab1, gLevel1, "Tab 1 is still zoomed");
+ })().then(zoomTab2, FullZoomHelper.failAndContinue(finish));
+}
+
+function zoomTab2() {
+ (async function () {
+ is(gBrowser.selectedTab, gTab2, "Tab 2 is selected");
+
+ FullZoom.reduce();
+ let level2 = ZoomManager.getZoomForBrowser(
+ gBrowser.getBrowserForTab(gTab2)
+ );
+
+ ok(level2 < 1, "New zoom for tab 2 should be less than 1");
+ FullZoomHelper.zoomTest(
+ gTab1,
+ gLevel1,
+ "Zooming tab 2 should not affect tab 1"
+ );
+
+ await FullZoomHelper.selectTabAndWaitForLocationChange(gTab1);
+ FullZoomHelper.zoomTest(
+ gTab1,
+ gLevel1,
+ "Tab 1 should have the same zoom after it's selected"
+ );
+ })().then(testNavigation, FullZoomHelper.failAndContinue(finish));
+}
+
+function testNavigation() {
+ (async function () {
+ await FullZoomHelper.load(gTab1, TEST_VIDEO);
+ FullZoomHelper.zoomTest(
+ gTab1,
+ 1,
+ "Zoom should be 1 when a video was loaded"
+ );
+ await waitForNextTurn(); // trying to fix orange bug 806046
+ await FullZoomHelper.navigate(FullZoomHelper.BACK);
+ FullZoomHelper.zoomTest(
+ gTab1,
+ gLevel1,
+ "Zoom should be restored when a page is loaded"
+ );
+ await waitForNextTurn(); // trying to fix orange bug 806046
+ await FullZoomHelper.navigate(FullZoomHelper.FORWARD);
+ FullZoomHelper.zoomTest(
+ gTab1,
+ 1,
+ "Zoom should be 1 again when navigating back to a video"
+ );
+ })().then(finishTest, FullZoomHelper.failAndContinue(finish));
+}
+
+function waitForNextTurn() {
+ return new Promise(resolve => {
+ setTimeout(() => resolve(), 0);
+ });
+}
+
+var finishTestStarted = false;
+function finishTest() {
+ (async function () {
+ ok(!finishTestStarted, "finishTest called more than once");
+ finishTestStarted = true;
+
+ await FullZoomHelper.selectTabAndWaitForLocationChange(gTab1);
+ await FullZoom.reset();
+ await FullZoomHelper.removeTabAndWaitForLocationChange(gTab1);
+ await FullZoomHelper.selectTabAndWaitForLocationChange(gTab2);
+ await FullZoom.reset();
+ await FullZoomHelper.removeTabAndWaitForLocationChange(gTab2);
+ })().then(finish, FullZoomHelper.failAndContinue(finish));
+}
diff --git a/browser/base/content/test/zoom/browser_subframe_textzoom.js b/browser/base/content/test/zoom/browser_subframe_textzoom.js
new file mode 100644
index 0000000000..e5d40cf585
--- /dev/null
+++ b/browser/base/content/test/zoom/browser_subframe_textzoom.js
@@ -0,0 +1,52 @@
+/* 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/. */
+
+/*
+ * Test the fix for bug 441778 to ensure site-specific page zoom doesn't get
+ * modified by sub-document loads of content from a different domain.
+ */
+
+function test() {
+ waitForExplicitFinish();
+
+ const TEST_PAGE_URL = 'data:text/html,<body><iframe src=""></iframe></body>';
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ const TEST_IFRAME_URL = "http://test2.example.org/";
+
+ (async function () {
+ // Prepare the test tab
+ let tab = BrowserTestUtils.addTab(gBrowser);
+ await FullZoomHelper.selectTabAndWaitForLocationChange(tab);
+
+ let testBrowser = tab.linkedBrowser;
+
+ await FullZoomHelper.load(tab, TEST_PAGE_URL);
+
+ // Change the zoom level and then save it so we can compare it to the level
+ // after loading the sub-document.
+ FullZoom.enlarge();
+ var zoomLevel = ZoomManager.zoom;
+
+ // Start the sub-document load.
+ await new Promise(resolve => {
+ executeSoon(function () {
+ BrowserTestUtils.browserLoaded(testBrowser, true).then(url => {
+ is(url, TEST_IFRAME_URL, "got the load event for the iframe");
+ is(
+ ZoomManager.zoom,
+ zoomLevel,
+ "zoom is retained after sub-document load"
+ );
+
+ FullZoomHelper.removeTabAndWaitForLocationChange().then(() =>
+ resolve()
+ );
+ });
+ SpecialPowers.spawn(testBrowser, [TEST_IFRAME_URL], url => {
+ content.document.querySelector("iframe").src = url;
+ });
+ });
+ });
+ })().then(finish, FullZoomHelper.failAndContinue(finish));
+}
diff --git a/browser/base/content/test/zoom/browser_tabswitch_zoom_flicker.js b/browser/base/content/test/zoom/browser_tabswitch_zoom_flicker.js
new file mode 100644
index 0000000000..df1c3816ae
--- /dev/null
+++ b/browser/base/content/test/zoom/browser_tabswitch_zoom_flicker.js
@@ -0,0 +1,45 @@
+var tab;
+
+function test() {
+ // ----------
+ // Test setup
+
+ waitForExplicitFinish();
+
+ Services.prefs.setBoolPref("browser.zoom.updateBackgroundTabs", true);
+ Services.prefs.setBoolPref("browser.zoom.siteSpecific", true);
+
+ let uri =
+ // eslint-disable-next-line @microsoft/sdl/no-insecure-url
+ "http://example.org/browser/browser/base/content/test/zoom/zoom_test.html";
+
+ (async function () {
+ tab = BrowserTestUtils.addTab(gBrowser);
+ await FullZoomHelper.load(tab, uri);
+
+ // -------------------------------------------------------------------
+ // Test - Trigger a tab switch that should update the zoom level
+ await FullZoomHelper.selectTabAndWaitForLocationChange(tab);
+ ok(true, "applyPrefToSetting was called");
+ })().then(endTest, FullZoomHelper.failAndContinue(endTest));
+}
+
+// -------------
+// Test clean-up
+function endTest() {
+ (async function () {
+ await FullZoomHelper.removeTabAndWaitForLocationChange(tab);
+
+ tab = null;
+
+ if (Services.prefs.prefHasUserValue("browser.zoom.updateBackgroundTabs")) {
+ Services.prefs.clearUserPref("browser.zoom.updateBackgroundTabs");
+ }
+
+ if (Services.prefs.prefHasUserValue("browser.zoom.siteSpecific")) {
+ Services.prefs.clearUserPref("browser.zoom.siteSpecific");
+ }
+
+ finish();
+ })();
+}
diff --git a/browser/base/content/test/zoom/browser_tooltip_zoom.js b/browser/base/content/test/zoom/browser_tooltip_zoom.js
new file mode 100644
index 0000000000..f7627b1749
--- /dev/null
+++ b/browser/base/content/test/zoom/browser_tooltip_zoom.js
@@ -0,0 +1,41 @@
+add_task(async function test_zoom_tooltip() {
+ const TEST_PAGE_URL = 'data:text/html,<html title="tooltiptext">';
+ await BrowserTestUtils.withNewTab(TEST_PAGE_URL, async function (browser) {
+ FullZoom.setZoom(2.0, browser);
+
+ const tooltip = document.getElementById("remoteBrowserTooltip");
+ const popupShown = new Promise(resolve => {
+ tooltip.addEventListener("popupshown", resolve, { once: true });
+ });
+
+ // Fire a mousemove to trigger the tooltip.
+ // Margin from the anchor and stuff depends on the platform, but these
+ // should be big enough so that all platforms pass, but not big enough so
+ // that it'd pass even when messing up the coordinates would.
+ const DISTANCE = 300;
+ const EPSILON = 25;
+
+ EventUtils.synthesizeMouse(browser, DISTANCE, DISTANCE, {
+ type: "mousemove",
+ });
+
+ await popupShown;
+ ok(
+ true,
+ `popup should be shown (coords: ${tooltip.screenX}, ${tooltip.screenY})`
+ );
+
+ isfuzzy(
+ tooltip.screenX,
+ browser.screenX + DISTANCE,
+ EPSILON,
+ "Should be at the right x position, more or less"
+ );
+ isfuzzy(
+ tooltip.screenY,
+ browser.screenY + DISTANCE,
+ EPSILON,
+ "Should be at the right y position, more or less"
+ );
+ });
+});
diff --git a/browser/base/content/test/zoom/browser_zoom_commands.js b/browser/base/content/test/zoom/browser_zoom_commands.js
new file mode 100644
index 0000000000..88b6f42059
--- /dev/null
+++ b/browser/base/content/test/zoom/browser_zoom_commands.js
@@ -0,0 +1,203 @@
+/* Any copyright is dedicated to the Public Domain.
+ http://creativecommons.org/publicdomain/zero/1.0/ */
+
+"use strict";
+
+const TEST_PAGE_URL =
+ "data:text/html;charset=utf-8,<body>test_zoom_levels</body>";
+
+/**
+ * Waits for the zoom commands in the window to have the expected enabled
+ * state.
+ *
+ * @param {Object} expectedState
+ * An object where each key represents one of the zoom commands,
+ * and the value is a boolean that is true if the command should
+ * be enabled, and false if it should be disabled.
+ *
+ * The keys are "enlarge", "reduce" and "reset" for readability,
+ * and internally this function maps those keys to the appropriate
+ * commands.
+ * @returns Promise
+ * @resolves undefined
+ */
+async function waitForCommandEnabledState(expectedState) {
+ const COMMAND_MAP = {
+ enlarge: "cmd_fullZoomEnlarge",
+ reduce: "cmd_fullZoomReduce",
+ reset: "cmd_fullZoomReset",
+ };
+
+ await TestUtils.waitForCondition(() => {
+ for (let commandKey in expectedState) {
+ let commandID = COMMAND_MAP[commandKey];
+ let command = document.getElementById(commandID);
+ let expectedEnabled = expectedState[commandKey];
+
+ if (command.hasAttribute("disabled") == expectedEnabled) {
+ return false;
+ }
+ }
+ Assert.ok("Commands finally reached the expected state.");
+ return true;
+ }, "Waiting for commands to reach the right state.");
+}
+
+/**
+ * Tests that the "Zoom Text Only" command is in the right checked
+ * state.
+ *
+ * @param {boolean} isChecked
+ * True if the command should have its "checked" attribute set to
+ * "true". Otherwise, ensures that the attribute is set to "false".
+ */
+function assertTextZoomCommandCheckedState(isChecked) {
+ let command = document.getElementById("cmd_fullZoomToggle");
+ Assert.equal(
+ command.getAttribute("checked"),
+ "" + isChecked,
+ "Text zoom command has expected checked attribute"
+ );
+}
+
+/**
+ * Tests that zoom commands are properly updated when changing
+ * zoom levels and/or preferences on an individual browser.
+ */
+add_task(async function test_update_browser_zoom() {
+ await BrowserTestUtils.withNewTab(TEST_PAGE_URL, async browser => {
+ let currentZoom = await FullZoomHelper.getGlobalValue();
+ Assert.equal(
+ currentZoom,
+ 1,
+ "We expect to start at the default zoom level."
+ );
+
+ await waitForCommandEnabledState({
+ enlarge: true,
+ reduce: true,
+ reset: false,
+ });
+ assertTextZoomCommandCheckedState(false);
+
+ // We'll run two variations of this test - one with text zoom enabled,
+ // and the other without.
+ for (let textZoom of [true, false]) {
+ info(`Running variation with textZoom set to ${textZoom}`);
+
+ await SpecialPowers.pushPrefEnv({
+ set: [["browser.zoom.full", !textZoom]],
+ });
+
+ // 120% global zoom
+ info("Changing default zoom by a single level");
+ ZoomManager.zoom = 1.2;
+
+ await waitForCommandEnabledState({
+ enlarge: true,
+ reduce: true,
+ reset: true,
+ });
+ await assertTextZoomCommandCheckedState(textZoom);
+
+ // Now max out the zoom level.
+ ZoomManager.zoom = ZoomManager.MAX;
+
+ await waitForCommandEnabledState({
+ enlarge: false,
+ reduce: true,
+ reset: true,
+ });
+ await assertTextZoomCommandCheckedState(textZoom);
+
+ // Now min out the zoom level.
+ ZoomManager.zoom = ZoomManager.MIN;
+ await waitForCommandEnabledState({
+ enlarge: true,
+ reduce: false,
+ reset: true,
+ });
+ await assertTextZoomCommandCheckedState(textZoom);
+
+ // Now reset back to the default zoom level
+ ZoomManager.zoom = 1;
+ await waitForCommandEnabledState({
+ enlarge: true,
+ reduce: true,
+ reset: false,
+ });
+ await assertTextZoomCommandCheckedState(textZoom);
+ }
+ });
+});
+
+/**
+ * Tests that zoom commands are properly updated when changing
+ * zoom levels when the default zoom is not at 1.0.
+ */
+add_task(async function test_update_browser_zoom() {
+ await BrowserTestUtils.withNewTab(TEST_PAGE_URL, async browser => {
+ let currentZoom = await FullZoomHelper.getGlobalValue();
+ Assert.equal(
+ currentZoom,
+ 1,
+ "We expect to start at the default zoom level."
+ );
+
+ // Now change the default zoom to 200%, which is what we'll switch
+ // back to when choosing to reset the zoom level.
+ //
+ // It's a bit maddening that changeDefaultZoom takes values in integer
+ // units from 30 to 500, whereas ZoomManager.zoom takes things in float
+ // units from 0.3 to 5.0, but c'est la vie for now.
+ await FullZoomHelper.changeDefaultZoom(200);
+ registerCleanupFunction(async () => {
+ await FullZoomHelper.changeDefaultZoom(100);
+ });
+
+ await waitForCommandEnabledState({
+ enlarge: true,
+ reduce: true,
+ reset: false,
+ });
+
+ // 120% global zoom
+ info("Changing default zoom by a single level");
+ ZoomManager.zoom = 2.2;
+
+ await waitForCommandEnabledState({
+ enlarge: true,
+ reduce: true,
+ reset: true,
+ });
+ await assertTextZoomCommandCheckedState(false);
+
+ // Now max out the zoom level.
+ ZoomManager.zoom = ZoomManager.MAX;
+
+ await waitForCommandEnabledState({
+ enlarge: false,
+ reduce: true,
+ reset: true,
+ });
+ await assertTextZoomCommandCheckedState(false);
+
+ // Now min out the zoom level.
+ ZoomManager.zoom = ZoomManager.MIN;
+ await waitForCommandEnabledState({
+ enlarge: true,
+ reduce: false,
+ reset: true,
+ });
+ await assertTextZoomCommandCheckedState(false);
+
+ // Now reset back to the default zoom level
+ ZoomManager.zoom = 2;
+ await waitForCommandEnabledState({
+ enlarge: true,
+ reduce: true,
+ reset: false,
+ });
+ await assertTextZoomCommandCheckedState(false);
+ });
+});
diff --git a/browser/base/content/test/zoom/head.js b/browser/base/content/test/zoom/head.js
new file mode 100644
index 0000000000..d5f09aa5e2
--- /dev/null
+++ b/browser/base/content/test/zoom/head.js
@@ -0,0 +1,223 @@
+/* Any copyright is dedicated to the Public Domain.
+ * https://creativecommons.org/publicdomain/zero/1.0/ */
+
+let gContentPrefs = Cc["@mozilla.org/content-pref/service;1"].getService(
+ Ci.nsIContentPrefService2
+);
+
+let gLoadContext = Cu.createLoadContext();
+
+registerCleanupFunction(async function () {
+ await new Promise(resolve => {
+ gContentPrefs.removeByName(window.FullZoom.name, gLoadContext, {
+ handleResult() {},
+ handleCompletion() {
+ resolve();
+ },
+ });
+ });
+});
+
+var FullZoomHelper = {
+ async changeDefaultZoom(newZoom) {
+ let nonPrivateLoadContext = Cu.createLoadContext();
+ /* Because our setGlobal function takes in a browsing context, and
+ * because we want to keep this property consistent across both private
+ * and non-private contexts, we crate a non-private context and use that
+ * to set the property, regardless of our actual context.
+ */
+
+ let parsedZoomValue = parseFloat((parseInt(newZoom) / 100).toFixed(2));
+ await new Promise(resolve => {
+ gContentPrefs.setGlobal(
+ FullZoom.name,
+ parsedZoomValue,
+ nonPrivateLoadContext,
+ {
+ handleCompletion(reason) {
+ resolve();
+ },
+ }
+ );
+ });
+ // The zoom level is used to update the commands associated with
+ // increasing, decreasing or resetting the Zoom levels. There are
+ // a series of async things we need to wait for (writing the content
+ // pref to the database, and then reading that content pref back out
+ // again and reacting to it), so waiting for the zoom level to reach
+ // the expected level is actually simplest to make sure we're okay to
+ // proceed.
+ await TestUtils.waitForCondition(() => {
+ return ZoomManager.zoom == parsedZoomValue;
+ });
+ },
+
+ async getGlobalValue() {
+ return new Promise(resolve => {
+ let cachedVal = parseFloat(
+ gContentPrefs.getCachedGlobal(FullZoom.name, gLoadContext)
+ );
+ if (cachedVal) {
+ // We've got cached information, though it may be we've cached
+ // an undefined value, or the cached info is invalid. To ensure
+ // a valid return, we opt to return the default 1.0 in the
+ // undefined and invalid cases.
+ resolve(parseFloat(cachedVal.value) || 1.0);
+ return;
+ }
+ let value = 1.0;
+ gContentPrefs.getGlobal(FullZoom.name, gLoadContext, {
+ handleResult(pref) {
+ if (pref.value) {
+ value = parseFloat(pref.value);
+ }
+ },
+ handleCompletion(reason) {
+ resolve(value);
+ },
+ handleError(error) {
+ console.error(error);
+ },
+ });
+ });
+ },
+
+ waitForLocationChange: function waitForLocationChange() {
+ return new Promise(resolve => {
+ Services.obs.addObserver(function obs(subj, topic, data) {
+ Services.obs.removeObserver(obs, topic);
+ resolve();
+ }, "browser-fullZoom:location-change");
+ });
+ },
+
+ selectTabAndWaitForLocationChange: function selectTabAndWaitForLocationChange(
+ tab
+ ) {
+ if (!tab) {
+ throw new Error("tab must be given.");
+ }
+ if (gBrowser.selectedTab == tab) {
+ return Promise.resolve();
+ }
+
+ return Promise.all([
+ BrowserTestUtils.switchTab(gBrowser, tab),
+ this.waitForLocationChange(),
+ ]);
+ },
+
+ removeTabAndWaitForLocationChange: function removeTabAndWaitForLocationChange(
+ tab
+ ) {
+ tab = tab || gBrowser.selectedTab;
+ let selected = gBrowser.selectedTab == tab;
+ gBrowser.removeTab(tab);
+ if (selected) {
+ return this.waitForLocationChange();
+ }
+ return Promise.resolve();
+ },
+
+ load: function load(tab, url) {
+ return new Promise(resolve => {
+ let didLoad = false;
+ let didZoom = false;
+
+ promiseTabLoadEvent(tab, url).then(event => {
+ didLoad = true;
+ if (didZoom) {
+ resolve();
+ }
+ }, true);
+
+ this.waitForLocationChange().then(function () {
+ didZoom = true;
+ if (didLoad) {
+ resolve();
+ }
+ });
+ });
+ },
+
+ zoomTest: function zoomTest(tab, val, msg) {
+ is(ZoomManager.getZoomForBrowser(tab.linkedBrowser), val, msg);
+ },
+
+ BACK: 0,
+ FORWARD: 1,
+ navigate: function navigate(direction) {
+ return new Promise(resolve => {
+ let didPs = false;
+ let didZoom = false;
+
+ BrowserTestUtils.waitForContentEvent(
+ gBrowser.selectedBrowser,
+ "pageshow",
+ true
+ ).then(() => {
+ didPs = true;
+ if (didZoom) {
+ resolve();
+ }
+ });
+
+ if (direction == this.BACK) {
+ gBrowser.goBack();
+ } else if (direction == this.FORWARD) {
+ gBrowser.goForward();
+ }
+
+ this.waitForLocationChange().then(function () {
+ didZoom = true;
+ if (didPs) {
+ resolve();
+ }
+ });
+ });
+ },
+
+ failAndContinue: function failAndContinue(func) {
+ return function (err) {
+ console.error(err);
+ ok(false, err);
+ func();
+ };
+ },
+};
+
+/**
+ * Waits for a load (or custom) event to finish in a given tab. If provided
+ * load an uri into the tab.
+ *
+ * @param tab
+ * The tab to load into.
+ * @param [optional] url
+ * The url to load, or the current url.
+ * @return {Promise} resolved when the event is handled.
+ * @resolves to the received event
+ * @rejects if a valid load event is not received within a meaningful interval
+ */
+async function promiseTabLoadEvent(tab, url) {
+ console.info("Wait tab event: load");
+ if (url) {
+ console.info("Expecting load for: ", url);
+ }
+ function handle(loadedUrl) {
+ if (loadedUrl === "about:blank" || (url && loadedUrl !== url)) {
+ console.info(`Skipping spurious load event for ${loadedUrl}`);
+ return false;
+ }
+
+ console.info("Tab event received: load");
+ return true;
+ }
+
+ let loaded = BrowserTestUtils.browserLoaded(tab.linkedBrowser, false, handle);
+
+ if (url) {
+ BrowserTestUtils.loadURIString(tab.linkedBrowser, url);
+ }
+
+ return loaded;
+}
diff --git a/browser/base/content/test/zoom/zoom_test.html b/browser/base/content/test/zoom/zoom_test.html
new file mode 100644
index 0000000000..bf80490cad
--- /dev/null
+++ b/browser/base/content/test/zoom/zoom_test.html
@@ -0,0 +1,14 @@
+<!DOCTYPE HTML>
+<html>
+<!--
+https://bugzilla.mozilla.org/show_bug.cgi?id=416661
+-->
+ <head>
+ <title>Test for zoom setting</title>
+
+ </head>
+ <body>
+ <a href="https://bugzilla.mozilla.org/show_bug.cgi?id=416661">Bug 416661</a>
+ <p>Site specific zoom settings should not apply to image documents.</p>
+ </body>
+</html>
diff --git a/browser/base/content/titlebar-items.inc.xhtml b/browser/base/content/titlebar-items.inc.xhtml
new file mode 100644
index 0000000000..14f1048a08
--- /dev/null
+++ b/browser/base/content/titlebar-items.inc.xhtml
@@ -0,0 +1,24 @@
+# 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/.
+
+<hbox class="titlebar-buttonbox-container" skipintoolbarset="true">
+ <hbox class="titlebar-buttonbox titlebar-color">
+ <toolbarbutton class="titlebar-button titlebar-min"
+ oncommand="window.minimize();"
+ data-l10n-id="browser-window-minimize-button"
+ />
+ <toolbarbutton class="titlebar-button titlebar-max"
+ oncommand="window.maximize();"
+ data-l10n-id="browser-window-maximize-button"
+ />
+ <toolbarbutton class="titlebar-button titlebar-restore"
+ oncommand="window.fullScreen ? BrowserFullScreen() : window.restore();"
+ data-l10n-id="browser-window-restore-down-button"
+ />
+ <toolbarbutton class="titlebar-button titlebar-close"
+ command="cmd_closeWindow"
+ data-l10n-id="browser-window-close-button"
+ />
+ </hbox>
+</hbox>
diff --git a/browser/base/content/unified-extensions-viewcache.inc.xhtml b/browser/base/content/unified-extensions-viewcache.inc.xhtml
new file mode 100644
index 0000000000..66b565b175
--- /dev/null
+++ b/browser/base/content/unified-extensions-viewcache.inc.xhtml
@@ -0,0 +1,40 @@
+# 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/.
+
+<!--
+ This is the template used for the `unified-extensions-item` custom element,
+ defined in `browser/base/content/browser-unified-extensions.js`.
+-->
+<html:template id="unified-extensions-item-template">
+ <toolbarbutton class="unified-extensions-item-action-button subviewbutton">
+ <image class="unified-extensions-item-icon"
+ role="presentation"
+ src="chrome://mozapps/skin/extensions/extensionGeneric.svg" />
+
+ <vbox class="unified-extensions-item-contents">
+ <label class="unified-extensions-item-name" />
+ <!--
+ IMPORTANT: if you change the order of the labels in the deck,
+ please update the indexes in `browser/base/content/browser-addons.js`.
+ -->
+ <deck class="unified-extensions-item-message-deck">
+ <label class="unified-extensions-item-message unified-extensions-item-message-default">
+ <!-- A message describing the current Origin Controls state -->
+ </label>
+ <label class="unified-extensions-item-message unified-extensions-item-message-hover">
+ <!-- A message indicating what a user can do when clicking the action button -->
+ </label>
+ <label class="unified-extensions-item-message unified-extensions-item-message-hover-menu-button"
+ data-l10n-id="unified-extensions-item-message-manage">
+ </label>
+ </deck>
+ </vbox>
+ </toolbarbutton>
+
+ <toolbarbutton class="unified-extensions-item-menu-button subviewbutton subviewbutton-iconic"
+ closemenu="none"
+ context="unified-extensions-context-menu"
+ data-l10n-id="unified-extensions-item-open-menu"
+ data-navigable-with-tab-only="true" />
+</html:template>
diff --git a/browser/base/content/utilityOverlay.js b/browser/base/content/utilityOverlay.js
new file mode 100644
index 0000000000..fb9b1672c8
--- /dev/null
+++ b/browser/base/content/utilityOverlay.js
@@ -0,0 +1,611 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*-
+ * 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/. */
+
+// Services = object with smart getters for common XPCOM services
+var { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+var { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+
+ChromeUtils.defineESModuleGetters(this, {
+ BrowserUtils: "resource://gre/modules/BrowserUtils.sys.mjs",
+ ContextualIdentityService:
+ "resource://gre/modules/ContextualIdentityService.sys.mjs",
+ ExtensionSettingsStore:
+ "resource://gre/modules/ExtensionSettingsStore.sys.mjs",
+ PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs",
+ ShellService: "resource:///modules/ShellService.sys.mjs",
+ URILoadingHelper: "resource:///modules/URILoadingHelper.sys.mjs",
+});
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+ AboutNewTab: "resource:///modules/AboutNewTab.jsm",
+ BrowserWindowTracker: "resource:///modules/BrowserWindowTracker.jsm",
+});
+
+XPCOMUtils.defineLazyGetter(this, "ReferrerInfo", () =>
+ Components.Constructor(
+ "@mozilla.org/referrer-info;1",
+ "nsIReferrerInfo",
+ "init"
+ )
+);
+
+Object.defineProperty(this, "BROWSER_NEW_TAB_URL", {
+ enumerable: true,
+ get() {
+ if (PrivateBrowsingUtils.isWindowPrivate(window)) {
+ if (
+ !PrivateBrowsingUtils.permanentPrivateBrowsing &&
+ !AboutNewTab.newTabURLOverridden
+ ) {
+ return "about:privatebrowsing";
+ }
+ // If an extension controls the setting and does not have private
+ // browsing permission, use the default setting.
+ let extensionControlled = Services.prefs.getBoolPref(
+ "browser.newtab.extensionControlled",
+ false
+ );
+ let privateAllowed = Services.prefs.getBoolPref(
+ "browser.newtab.privateAllowed",
+ false
+ );
+ // There is a potential on upgrade that the prefs are not set yet, so we double check
+ // for moz-extension.
+ if (
+ !privateAllowed &&
+ (extensionControlled ||
+ AboutNewTab.newTabURL.startsWith("moz-extension://"))
+ ) {
+ return "about:privatebrowsing";
+ }
+ }
+ return AboutNewTab.newTabURL;
+ },
+});
+
+var TAB_DROP_TYPE = "application/x-moz-tabbrowser-tab";
+
+var gBidiUI = false;
+
+/**
+ * Determines whether the given url is considered a special URL for new tabs.
+ */
+function isBlankPageURL(aURL) {
+ return (
+ aURL == "about:blank" ||
+ aURL == "about:home" ||
+ aURL == BROWSER_NEW_TAB_URL ||
+ aURL == "chrome://browser/content/blanktab.html"
+ );
+}
+
+function doGetProtocolFlags(aURI) {
+ return Services.io.getDynamicProtocolFlags(aURI);
+}
+
+function openUILink(
+ url,
+ event,
+ aIgnoreButton,
+ aIgnoreAlt,
+ aAllowThirdPartyFixup,
+ aPostData,
+ aReferrerInfo
+) {
+ return URILoadingHelper.openUILink(
+ window,
+ url,
+ event,
+ aIgnoreButton,
+ aIgnoreAlt,
+ aAllowThirdPartyFixup,
+ aPostData,
+ aReferrerInfo
+ );
+}
+
+// This is here for historical reasons. bug 1742889 covers cleaning this up.
+function getRootEvent(aEvent) {
+ return BrowserUtils.getRootEvent(aEvent);
+}
+
+// This is here for historical reasons. bug 1742889 covers cleaning this up.
+function whereToOpenLink(e, ignoreButton, ignoreAlt) {
+ return BrowserUtils.whereToOpenLink(e, ignoreButton, ignoreAlt);
+}
+
+function openTrustedLinkIn(url, where, params) {
+ URILoadingHelper.openTrustedLinkIn(window, url, where, params);
+}
+
+function openWebLinkIn(url, where, params) {
+ URILoadingHelper.openWebLinkIn(window, url, where, params);
+}
+
+function openLinkIn(url, where, params) {
+ return URILoadingHelper.openLinkIn(window, url, where, params);
+}
+
+// Used as an onclick handler for UI elements with link-like behavior.
+// e.g. onclick="checkForMiddleClick(this, event);"
+// Not needed for menuitems because those fire command events even on middle clicks.
+function checkForMiddleClick(node, event) {
+ // We should be using the disabled property here instead of the attribute,
+ // but some elements that this function is used with don't support it (e.g.
+ // menuitem).
+ if (node.getAttribute("disabled") == "true") {
+ return;
+ } // Do nothing
+
+ if (event.target.tagName == "menuitem") {
+ // Menu items fire command on middle-click by themselves.
+ return;
+ }
+
+ if (event.button == 1) {
+ /* Execute the node's oncommand or command.
+ */
+
+ let cmdEvent = document.createEvent("xulcommandevent");
+ cmdEvent.initCommandEvent(
+ "command",
+ true,
+ true,
+ window,
+ 0,
+ event.ctrlKey,
+ event.altKey,
+ event.shiftKey,
+ event.metaKey,
+ 0,
+ event,
+ event.mozInputSource
+ );
+ node.dispatchEvent(cmdEvent);
+
+ // Stop the propagation of the click event, to prevent the event from being
+ // handled more than once.
+ // E.g. see https://bugzilla.mozilla.org/show_bug.cgi?id=1657992#c4
+ event.stopPropagation();
+ event.preventDefault();
+
+ // If the middle-click was on part of a menu, close the menu.
+ // (Menus close automatically with left-click but not with middle-click.)
+ closeMenus(event.target);
+ }
+}
+
+// Populate a menu with user-context menu items. This method should be called
+// by onpopupshowing passing the event as first argument.
+function createUserContextMenu(
+ event,
+ {
+ isContextMenu = false,
+ excludeUserContextId = 0,
+ showDefaultTab = false,
+ useAccessKeys = true,
+ } = {}
+) {
+ while (event.target.hasChildNodes()) {
+ event.target.firstChild.remove();
+ }
+
+ let bundle = Services.strings.createBundle(
+ "chrome://browser/locale/browser.properties"
+ );
+ let docfrag = document.createDocumentFragment();
+
+ // If we are excluding a userContextId, we want to add a 'no-container' item.
+ if (excludeUserContextId || showDefaultTab) {
+ let menuitem = document.createXULElement("menuitem");
+ menuitem.setAttribute("data-usercontextid", "0");
+ menuitem.setAttribute(
+ "label",
+ bundle.GetStringFromName("userContextNone.label")
+ );
+ menuitem.setAttribute(
+ "accesskey",
+ bundle.GetStringFromName("userContextNone.accesskey")
+ );
+
+ if (!isContextMenu) {
+ menuitem.setAttribute("command", "Browser:NewUserContextTab");
+ }
+
+ docfrag.appendChild(menuitem);
+
+ let menuseparator = document.createXULElement("menuseparator");
+ docfrag.appendChild(menuseparator);
+ }
+
+ ContextualIdentityService.getPublicIdentities().forEach(identity => {
+ if (identity.userContextId == excludeUserContextId) {
+ return;
+ }
+
+ let menuitem = document.createXULElement("menuitem");
+ menuitem.setAttribute("data-usercontextid", identity.userContextId);
+ menuitem.setAttribute(
+ "label",
+ ContextualIdentityService.getUserContextLabel(identity.userContextId)
+ );
+
+ if (identity.accessKey && useAccessKeys) {
+ menuitem.setAttribute(
+ "accesskey",
+ bundle.GetStringFromName(identity.accessKey)
+ );
+ }
+
+ menuitem.classList.add("menuitem-iconic");
+ menuitem.classList.add("identity-color-" + identity.color);
+
+ if (!isContextMenu) {
+ menuitem.setAttribute("command", "Browser:NewUserContextTab");
+ }
+
+ menuitem.classList.add("identity-icon-" + identity.icon);
+
+ docfrag.appendChild(menuitem);
+ });
+
+ if (!isContextMenu) {
+ docfrag.appendChild(document.createXULElement("menuseparator"));
+
+ let menuitem = document.createXULElement("menuitem");
+ menuitem.setAttribute(
+ "label",
+ bundle.GetStringFromName("userContext.aboutPage.label")
+ );
+ if (useAccessKeys) {
+ menuitem.setAttribute(
+ "accesskey",
+ bundle.GetStringFromName("userContext.aboutPage.accesskey")
+ );
+ }
+ menuitem.setAttribute("command", "Browser:OpenAboutContainers");
+ docfrag.appendChild(menuitem);
+ }
+
+ event.target.appendChild(docfrag);
+ return true;
+}
+
+// Closes all popups that are ancestors of the node.
+function closeMenus(node) {
+ if ("tagName" in node) {
+ if (
+ node.namespaceURI ==
+ "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" &&
+ (node.tagName == "menupopup" || node.tagName == "popup")
+ ) {
+ node.hidePopup();
+ }
+
+ closeMenus(node.parentNode);
+ }
+}
+
+/** This function takes in a key element and compares it to the keys pressed during an event.
+ *
+ * @param aEvent
+ * The KeyboardEvent event you want to compare against your key.
+ *
+ * @param aKey
+ * The <key> element checked to see if it was called in aEvent.
+ * For example, aKey can be a variable set to document.getElementById("key_close")
+ * to check if the close command key was pressed in aEvent.
+ */
+function eventMatchesKey(aEvent, aKey) {
+ let keyPressed = aKey.getAttribute("key").toLowerCase();
+ let keyModifiers = aKey.getAttribute("modifiers");
+ let modifiers = ["Alt", "Control", "Meta", "Shift"];
+
+ if (aEvent.key != keyPressed) {
+ return false;
+ }
+ let eventModifiers = modifiers.filter(modifier =>
+ aEvent.getModifierState(modifier)
+ );
+ // Check if aEvent has a modifier and aKey doesn't
+ if (eventModifiers.length && !keyModifiers.length) {
+ return false;
+ }
+ // Check whether aKey's modifiers match aEvent's modifiers
+ if (keyModifiers) {
+ keyModifiers = keyModifiers.split(/[\s,]+/);
+ // Capitalize first letter of aKey's modifers to compare to aEvent's modifier
+ keyModifiers.forEach(function (modifier, index) {
+ if (modifier == "accel") {
+ keyModifiers[index] =
+ AppConstants.platform == "macosx" ? "Meta" : "Control";
+ } else {
+ keyModifiers[index] = modifier[0].toUpperCase() + modifier.slice(1);
+ }
+ });
+ return modifiers.every(
+ modifier =>
+ keyModifiers.includes(modifier) == aEvent.getModifierState(modifier)
+ );
+ }
+ return true;
+}
+
+// Gather all descendent text under given document node.
+function gatherTextUnder(root) {
+ var text = "";
+ var node = root.firstChild;
+ var depth = 1;
+ while (node && depth > 0) {
+ // See if this node is text.
+ if (node.nodeType == Node.TEXT_NODE) {
+ // Add this text to our collection.
+ text += " " + node.data;
+ } else if (HTMLImageElement.isInstance(node)) {
+ // If it has an "alt" attribute, add that.
+ var altText = node.getAttribute("alt");
+ if (altText && altText != "") {
+ text += " " + altText;
+ }
+ }
+ // Find next node to test.
+ // First, see if this node has children.
+ if (node.hasChildNodes()) {
+ // Go to first child.
+ node = node.firstChild;
+ depth++;
+ } else {
+ // No children, try next sibling (or parent next sibling).
+ while (depth > 0 && !node.nextSibling) {
+ node = node.parentNode;
+ depth--;
+ }
+ if (node.nextSibling) {
+ node = node.nextSibling;
+ }
+ }
+ }
+ // Strip leading and tailing whitespace.
+ text = text.trim();
+ // Compress remaining whitespace.
+ text = text.replace(/\s+/g, " ");
+ return text;
+}
+
+// This function exists for legacy reasons.
+function getShellService() {
+ return ShellService;
+}
+
+function isBidiEnabled() {
+ // first check the pref.
+ if (Services.prefs.getBoolPref("bidi.browser.ui", false)) {
+ return true;
+ }
+
+ // now see if the app locale is an RTL one.
+ const isRTL = Services.locale.isAppLocaleRTL;
+
+ if (isRTL) {
+ Services.prefs.setBoolPref("bidi.browser.ui", true);
+ }
+ return isRTL;
+}
+
+function openAboutDialog() {
+ for (let win of Services.wm.getEnumerator("Browser:About")) {
+ // Only open one about window (Bug 599573)
+ if (win.closed) {
+ continue;
+ }
+ win.focus();
+ return;
+ }
+
+ var features = "chrome,";
+ if (AppConstants.platform == "win") {
+ features += "centerscreen,dependent";
+ } else if (AppConstants.platform == "macosx") {
+ features += "centerscreen,resizable=no,minimizable=no";
+ } else {
+ features += "centerscreen,dependent,dialog=no";
+ }
+
+ window.openDialog("chrome://browser/content/aboutDialog.xhtml", "", features);
+}
+
+async function openPreferences(paneID, extraArgs) {
+ // This function is duplicated from preferences.js.
+ function internalPrefCategoryNameToFriendlyName(aName) {
+ return (aName || "").replace(/^pane./, function (toReplace) {
+ return toReplace[4].toLowerCase();
+ });
+ }
+
+ let win = Services.wm.getMostRecentWindow("navigator:browser");
+ let friendlyCategoryName = internalPrefCategoryNameToFriendlyName(paneID);
+ let params;
+ if (extraArgs && extraArgs.urlParams) {
+ params = new URLSearchParams();
+ let urlParams = extraArgs.urlParams;
+ for (let name in urlParams) {
+ if (urlParams[name] !== undefined) {
+ params.set(name, urlParams[name]);
+ }
+ }
+ }
+ let preferencesURL =
+ "about:preferences" +
+ (params ? "?" + params : "") +
+ (friendlyCategoryName ? "#" + friendlyCategoryName : "");
+ let newLoad = true;
+ let browser = null;
+ if (!win) {
+ let windowArguments = Cc["@mozilla.org/array;1"].createInstance(
+ Ci.nsIMutableArray
+ );
+ let supportsStringPrefURL = Cc[
+ "@mozilla.org/supports-string;1"
+ ].createInstance(Ci.nsISupportsString);
+ supportsStringPrefURL.data = preferencesURL;
+ windowArguments.appendElement(supportsStringPrefURL);
+
+ win = Services.ww.openWindow(
+ null,
+ AppConstants.BROWSER_CHROME_URL,
+ "_blank",
+ "chrome,dialog=no,all",
+ windowArguments
+ );
+ } else {
+ let shouldReplaceFragment = friendlyCategoryName
+ ? "whenComparingAndReplace"
+ : "whenComparing";
+ newLoad = !win.switchToTabHavingURI(preferencesURL, true, {
+ ignoreFragment: shouldReplaceFragment,
+ replaceQueryString: true,
+ triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
+ });
+ browser = win.gBrowser.selectedBrowser;
+ }
+
+ if (!newLoad && paneID) {
+ if (browser.contentDocument?.readyState != "complete") {
+ await new Promise(resolve => {
+ browser.addEventListener("load", resolve, {
+ capture: true,
+ once: true,
+ });
+ });
+ }
+ browser.contentWindow.gotoPref(paneID);
+ }
+}
+
+/**
+ * Opens the troubleshooting information (about:support) page for this version
+ * of the application.
+ */
+function openTroubleshootingPage() {
+ openTrustedLinkIn("about:support", "tab");
+}
+
+/**
+ * Opens the feedback page for this version of the application.
+ */
+function openFeedbackPage() {
+ var url = Services.urlFormatter.formatURLPref("app.feedback.baseURL");
+ openTrustedLinkIn(url, "tab");
+}
+
+/**
+ * Appends UTM parameters to then opens the SUMO URL for device migration.
+ */
+function openSwitchingDevicesPage() {
+ let url = getHelpLinkURL("switching-devices");
+ let parsedUrl = new URL(url);
+ parsedUrl.searchParams.set("utm_content", "switch-to-new-device");
+ parsedUrl.searchParams.set("utm_source", "help-menu");
+ parsedUrl.searchParams.set("utm_medium", "firefox-desktop");
+ parsedUrl.searchParams.set("utm_campaign", "migration");
+ openTrustedLinkIn(parsedUrl.href, "tab");
+}
+
+function buildHelpMenu() {
+ document.getElementById("feedbackPage").disabled =
+ !Services.policies.isAllowed("feedbackCommands");
+
+ document.getElementById("helpSafeMode").disabled =
+ !Services.policies.isAllowed("safeMode");
+
+ document.getElementById("troubleShooting").disabled =
+ !Services.policies.isAllowed("aboutSupport");
+
+ let supportMenu = Services.policies.getSupportMenu();
+ if (supportMenu) {
+ let menuitem = document.getElementById("helpPolicySupport");
+ menuitem.hidden = false;
+ menuitem.setAttribute("label", supportMenu.Title);
+ if ("AccessKey" in supportMenu) {
+ menuitem.setAttribute("accesskey", supportMenu.AccessKey);
+ }
+ document.getElementById("helpPolicySeparator").hidden = false;
+ }
+
+ // Enable/disable the "Report Web Forgery" menu item.
+ if (typeof gSafeBrowsing != "undefined") {
+ gSafeBrowsing.setReportPhishingMenu();
+ }
+
+ // We're testing to see if the WebCompat team's "Report Site Issue"
+ // access point makes sense in the Help menu. Normally checking this
+ // pref wouldn't be enough, since there's also the case that the
+ // add-on has somehow been disabled by the user or third-party software
+ // without flipping the pref. Since this add-on is only used on pre-release
+ // channels, and since the jury is still out on whether or not the Help menu
+ // is the right place for this item, we're going to do a least-effort
+ // approach here and assume that the pref is enough to determine whether the
+ // menuitem should appear.
+ //
+ // See bug 1690573 for further details.
+ let reportSiteIssueEnabled = Services.prefs.getBoolPref(
+ "extensions.webcompat-reporter.enabled",
+ false
+ );
+ let reportSiteIssue = document.getElementById("help_reportSiteIssue");
+ reportSiteIssue.hidden = !reportSiteIssueEnabled;
+ if (reportSiteIssueEnabled) {
+ let uri = gBrowser.currentURI;
+ let isReportablePage =
+ uri && (uri.schemeIs("http") || uri.schemeIs("https"));
+ reportSiteIssue.disabled = !isReportablePage;
+ }
+
+ if (NimbusFeatures.deviceMigration.getVariable("helpMenuHidden")) {
+ let helpMenuItem = document.getElementById("helpSwitchDevice");
+ helpMenuItem.hidden = true;
+ }
+}
+
+function isElementVisible(aElement) {
+ if (!aElement) {
+ return false;
+ }
+
+ // If aElement or a direct or indirect parent is hidden or collapsed,
+ // height, width or both will be 0.
+ var rect = aElement.getBoundingClientRect();
+ return rect.height > 0 && rect.width > 0;
+}
+
+function makeURLAbsolute(aBase, aUrl) {
+ // Note: makeURI() will throw if aUri is not a valid URI
+ return makeURI(aUrl, null, makeURI(aBase)).spec;
+}
+
+function getHelpLinkURL(aHelpTopic) {
+ var url = Services.urlFormatter.formatURLPref("app.support.baseURL");
+ return url + aHelpTopic;
+}
+
+// aCalledFromModal is optional
+function openHelpLink(aHelpTopic, aCalledFromModal, aWhere) {
+ var url = getHelpLinkURL(aHelpTopic);
+ var where = aWhere;
+ if (!aWhere) {
+ where = aCalledFromModal ? "window" : "tab";
+ }
+
+ openTrustedLinkIn(url, where);
+}
+
+function openPrefsHelp(aEvent) {
+ let helpTopic = aEvent.target.getAttribute("helpTopic");
+ openHelpLink(helpTopic);
+}
diff --git a/browser/base/content/webext-panels.js b/browser/base/content/webext-panels.js
new file mode 100644
index 0000000000..d9f8b03a29
--- /dev/null
+++ b/browser/base/content/webext-panels.js
@@ -0,0 +1,184 @@
+/* -*- indent-tabs-mode: nil; js-indent-level: 4 -*- */
+/* 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/. */
+
+// Via webext-panels.xhtml
+/* import-globals-from browser.js */
+/* import-globals-from nsContextMenu.js */
+
+ChromeUtils.defineESModuleGetters(this, {
+ ExtensionParent: "resource://gre/modules/ExtensionParent.sys.mjs",
+});
+
+const { ExtensionUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/ExtensionUtils.sys.mjs"
+);
+
+var { promiseEvent } = ExtensionUtils;
+
+function getBrowser(panel) {
+ let browser = document.getElementById("webext-panels-browser");
+ if (browser) {
+ return Promise.resolve(browser);
+ }
+
+ let stack = document.getElementById("webext-panels-stack");
+ if (!stack) {
+ stack = document.createXULElement("stack");
+ stack.setAttribute("flex", "1");
+ stack.setAttribute("id", "webext-panels-stack");
+ document.documentElement.appendChild(stack);
+ }
+
+ browser = document.createXULElement("browser");
+ browser.setAttribute("id", "webext-panels-browser");
+ browser.setAttribute("type", "content");
+ browser.setAttribute("flex", "1");
+ browser.setAttribute("disableglobalhistory", "true");
+ browser.setAttribute("messagemanagergroup", "webext-browsers");
+ browser.setAttribute("webextension-view-type", panel.viewType);
+ browser.setAttribute("context", "contentAreaContextMenu");
+ browser.setAttribute("tooltip", "aHTMLTooltip");
+ browser.setAttribute("autocompletepopup", "PopupAutoComplete");
+
+ // Ensure that the browser is going to run in the same bc group as the other
+ // extension pages from the same addon.
+ browser.setAttribute(
+ "initialBrowsingContextGroupId",
+ panel.extension.policy.browsingContextGroupId
+ );
+
+ let readyPromise;
+ if (panel.extension.remote) {
+ browser.setAttribute("remote", "true");
+ let oa = E10SUtils.predictOriginAttributes({ browser });
+ browser.setAttribute(
+ "remoteType",
+ E10SUtils.getRemoteTypeForURI(
+ panel.uri,
+ /* remote */ true,
+ /* fission */ false,
+ E10SUtils.EXTENSION_REMOTE_TYPE,
+ null,
+ oa
+ )
+ );
+ browser.setAttribute("maychangeremoteness", "true");
+
+ readyPromise = promiseEvent(browser, "XULFrameLoaderCreated");
+ } else {
+ readyPromise = Promise.resolve();
+ }
+
+ stack.appendChild(browser);
+
+ browser.addEventListener(
+ "DoZoomEnlargeBy10",
+ () => {
+ let { ZoomManager } = browser.ownerGlobal;
+ let zoom = browser.fullZoom;
+ zoom += 0.1;
+ if (zoom > ZoomManager.MAX) {
+ zoom = ZoomManager.MAX;
+ }
+ browser.fullZoom = zoom;
+ },
+ true
+ );
+ browser.addEventListener(
+ "DoZoomReduceBy10",
+ () => {
+ let { ZoomManager } = browser.ownerGlobal;
+ let zoom = browser.fullZoom;
+ zoom -= 0.1;
+ if (zoom < ZoomManager.MIN) {
+ zoom = ZoomManager.MIN;
+ }
+ browser.fullZoom = zoom;
+ },
+ true
+ );
+
+ const initBrowser = () => {
+ ExtensionParent.apiManager.emit(
+ "extension-browser-inserted",
+ browser,
+ panel.browserInsertedData
+ );
+
+ browser.messageManager.loadFrameScript(
+ "chrome://extensions/content/ext-browser-content.js",
+ false,
+ true
+ );
+
+ let options =
+ panel.browserStyle !== false
+ ? { stylesheets: ExtensionParent.extensionStylesheets }
+ : {};
+ browser.messageManager.sendAsyncMessage("Extension:InitBrowser", options);
+ return browser;
+ };
+
+ browser.addEventListener("DidChangeBrowserRemoteness", initBrowser);
+ return readyPromise.then(initBrowser);
+}
+
+// Stub tabbrowser implementation for use by the tab-modal alert code.
+var gBrowser = {
+ get selectedBrowser() {
+ return document.getElementById("webext-panels-browser");
+ },
+
+ getTabForBrowser(browser) {
+ return null;
+ },
+
+ getTabModalPromptBox(browser) {
+ if (!browser.tabModalPromptBox) {
+ browser.tabModalPromptBox = new TabModalPromptBox(browser);
+ }
+ return browser.tabModalPromptBox;
+ },
+};
+
+function updatePosition() {
+ // We need both of these to make sure we update the position
+ // after any lower level updates have finished.
+ requestAnimationFrame(() =>
+ setTimeout(() => {
+ let browser = document.getElementById("webext-panels-browser");
+ if (browser && browser.isRemoteBrowser) {
+ browser.frameLoader.requestUpdatePosition();
+ }
+ }, 0)
+ );
+}
+
+function loadPanel(extensionId, extensionUrl, browserStyle) {
+ let browserEl = document.getElementById("webext-panels-browser");
+ if (browserEl) {
+ if (browserEl.currentURI.spec === extensionUrl) {
+ return;
+ }
+ // Forces runtime disconnect. Remove the stack (parent).
+ browserEl.parentNode.remove();
+ }
+
+ let policy = WebExtensionPolicy.getByID(extensionId);
+
+ let sidebar = {
+ uri: extensionUrl,
+ extension: policy.extension,
+ browserStyle,
+ viewType: "sidebar",
+ };
+
+ getBrowser(sidebar).then(browser => {
+ let uri = Services.io.newURI(policy.getURL());
+ let triggeringPrincipal =
+ Services.scriptSecurityManager.createContentPrincipal(uri, {});
+ browser.fixupAndLoadURIString(extensionUrl, { triggeringPrincipal });
+ });
+}
diff --git a/browser/base/content/webext-panels.xhtml b/browser/base/content/webext-panels.xhtml
new file mode 100644
index 0000000000..b2e9fd5cf8
--- /dev/null
+++ b/browser/base/content/webext-panels.xhtml
@@ -0,0 +1,64 @@
+<?xml version="1.0"?>
+
+# -*- Mode: Java; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 4 -*-
+# 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/.
+
+<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?>
+<?xml-stylesheet href="chrome://browser/skin/" type="text/css"?>
+<?xml-stylesheet href="chrome://browser/content/usercontext/usercontext.css" type="text/css"?>
+
+<window id="webextpanels-window"
+ xmlns:html="http://www.w3.org/1999/xhtml"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul">
+ <script src="chrome://global/content/contentAreaUtils.js"/>
+ <script src="chrome://browser/content/browser.js"/>
+ <script src="chrome://browser/content/places/browserPlacesViews.js"/>
+ <script src="chrome://browser/content/browser-places.js"/>
+ <script src="chrome://browser/content/webext-panels.js"/>
+ <script src="chrome://global/content/globalOverlay.js"/>
+ <script src="chrome://browser/content/utilityOverlay.js"/>
+ <script src="chrome://global/content/editMenuOverlay.js"/>
+
+ <linkset>
+ <html:link rel="localization" href="toolkit/branding/brandings.ftl"/>
+ <html:link rel="localization" href="toolkit/global/textActions.ftl"/>
+ <html:link rel="localization" href="browser/browserContext.ftl"/>
+ </linkset>
+
+ <commandset id="mainCommandset">
+ <command id="Browser:Back"
+ oncommand="getPanelBrowser().webNavigation.goBack();"
+ disabled="true"/>
+ <command id="Browser:Forward"
+ oncommand="getPanelBrowser().webNavigation.goForward();"
+ disabled="true"/>
+ <command id="Browser:Stop" oncommand="PanelBrowserStop();"/>
+ <command id="Browser:Reload" oncommand="PanelBrowserReload();"/>
+ </commandset>
+
+ <popupset id="mainPopupSet">
+ <tooltip id="aHTMLTooltip" page="true"/>
+
+ <panel is="autocomplete-richlistbox-popup"
+ type="autocomplete-richlistbox"
+ id="PopupAutoComplete"
+ noautofocus="true"
+ hidden="true"
+ overflowpadding="4"
+ norolluponanchor="true" />
+
+ <menupopup id="contentAreaContextMenu"
+ onpopupshowing="if (event.target != this)
+ return true;
+ gContextMenu = new nsContextMenu(this, event.shiftKey);
+ return gContextMenu.shouldDisplay;"
+ onpopuphiding="if (event.target != this)
+ return;
+ gContextMenu.hiding(this);
+ gContextMenu = null;">
+#include browser-context.inc
+ </menupopup>
+ </popupset>
+</window>
diff --git a/browser/base/content/webrtcIndicator.js b/browser/base/content/webrtcIndicator.js
new file mode 100644
index 0000000000..ba817f5561
--- /dev/null
+++ b/browser/base/content/webrtcIndicator.js
@@ -0,0 +1,603 @@
+/* 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/. */
+
+const { XPCOMUtils } = ChromeUtils.importESModule(
+ "resource://gre/modules/XPCOMUtils.sys.mjs"
+);
+const { AppConstants } = ChromeUtils.importESModule(
+ "resource://gre/modules/AppConstants.sys.mjs"
+);
+const { showStreamSharingMenu, webrtcUI } = ChromeUtils.import(
+ "resource:///modules/webrtcUI.jsm"
+);
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "MacOSWebRTCStatusbarIndicator",
+ "resource:///modules/webrtcUI.jsm"
+);
+
+ChromeUtils.defineModuleGetter(
+ this,
+ "BrowserWindowTracker",
+ "resource:///modules/BrowserWindowTracker.jsm"
+);
+
+XPCOMUtils.defineLazyServiceGetter(
+ this,
+ "gScreenManager",
+ "@mozilla.org/gfx/screenmanager;1",
+ "nsIScreenManager"
+);
+
+/**
+ * Public function called by webrtcUI to update the indicator
+ * display when the active streams change.
+ */
+function updateIndicatorState() {
+ WebRTCIndicator.updateIndicatorState();
+}
+
+/**
+ * Public function called by webrtcUI to indicate that webrtcUI
+ * is about to close the indicator. This is so that we can differentiate
+ * between closes that are caused by webrtcUI, and closes that are
+ * caused by other reasons (like the user closing the window via the
+ * OS window controls somehow).
+ *
+ * If the window is closed without having called this method first, the
+ * indicator will ask webrtcUI to shutdown any remaining streams and then
+ * select and focus the most recent browser tab that a stream was shared
+ * with.
+ */
+function closingInternally() {
+ WebRTCIndicator.closingInternally();
+}
+
+/**
+ * Main control object for the WebRTC global indicator
+ */
+const WebRTCIndicator = {
+ init(event) {
+ addEventListener("load", this);
+ addEventListener("unload", this);
+
+ // If the user customizes the position of the indicator, we will
+ // not try to re-center it on the primary display after indicator
+ // state updates.
+ this.positionCustomized = false;
+
+ this.updatingIndicatorState = false;
+ this.loaded = false;
+ this.isClosingInternally = false;
+
+ this.statusBar = null;
+ this.statusBarMenus = new Set();
+
+ this.showGlobalMuteToggles = Services.prefs.getBoolPref(
+ "privacy.webrtc.globalMuteToggles",
+ false
+ );
+
+ this.hideGlobalIndicator = Services.prefs.getBoolPref(
+ "privacy.webrtc.hideGlobalIndicator",
+ false
+ );
+
+ if (this.hideGlobalIndicator) {
+ this.setVisibility(false);
+ }
+ },
+
+ /**
+ * Controls the visibility of the global indicator. Also sets the value of
+ * a "visible" attribute on the document element to "true" or "false".
+ *
+ * @param isVisible (boolean)
+ * Whether or not the global indicator should be visible.
+ */
+ setVisibility(isVisible) {
+ let baseWin = window.docShell.treeOwner.QueryInterface(Ci.nsIBaseWindow);
+ baseWin.visibility = isVisible;
+ // AppWindow::GetVisibility _always_ returns true (see
+ // https://bugzilla.mozilla.org/show_bug.cgi?id=306245), so we'll set an
+ // attribute on the document to make it easier for tests to know that the
+ // indicator is not visible.
+ document.documentElement.setAttribute("visible", isVisible);
+ },
+
+ /**
+ * Exposed externally so that webrtcUI can alert the indicator to
+ * update itself when sharing states have changed.
+ */
+ updateIndicatorState() {
+ // It's possible that we were called externally before the indicator
+ // finished loading. If so, then bail out - we're going to call
+ // updateIndicatorState ourselves automatically once the load
+ // event fires.
+ if (!this.loaded) {
+ return;
+ }
+
+ // We've started to update the indicator state. We set this flag so
+ // that the MozUpdateWindowPos event handler doesn't interpret indicator
+ // state updates as window movement caused by the user.
+ this.updatingIndicatorState = true;
+
+ let showCameraIndicator = webrtcUI.showCameraIndicator;
+ let showMicrophoneIndicator = webrtcUI.showMicrophoneIndicator;
+ let showScreenSharingIndicator = webrtcUI.showScreenSharingIndicator;
+ if (this.statusBar) {
+ let statusMenus = new Map([
+ ["Camera", showCameraIndicator],
+ ["Microphone", showMicrophoneIndicator],
+ ["Screen", showScreenSharingIndicator],
+ ]);
+
+ for (let [name, shouldShow] of statusMenus) {
+ let menu = document.getElementById(`webRTC-sharing${name}-menu`);
+ if (shouldShow && !this.statusBarMenus.has(menu)) {
+ this.statusBar.addItem(menu);
+ this.statusBarMenus.add(menu);
+ } else if (!shouldShow && this.statusBarMenus.has(menu)) {
+ this.statusBar.removeItem(menu);
+ this.statusBarMenus.delete(menu);
+ }
+ }
+ }
+
+ if (!this.showGlobalMuteToggles && !webrtcUI.showScreenSharingIndicator) {
+ this.setVisibility(false);
+ } else if (!this.hideGlobalIndicator) {
+ this.setVisibility(true);
+ }
+
+ if (this.showGlobalMuteToggles) {
+ this.updateWindowAttr("sharingvideo", showCameraIndicator);
+ this.updateWindowAttr("sharingaudio", showMicrophoneIndicator);
+ }
+
+ let sharingScreen = showScreenSharingIndicator.startsWith("Screen");
+ this.updateWindowAttr("sharingscreen", sharingScreen);
+
+ // We don't currently support the browser-tab sharing case, so we don't
+ // check if the screen sharing indicator starts with "Browser".
+
+ // We special-case sharing a window, because we want to have a slightly
+ // different UI if we're sharing a browser window.
+ let sharingWindow = showScreenSharingIndicator.startsWith("Window");
+ this.updateWindowAttr("sharingwindow", sharingWindow);
+
+ if (sharingWindow) {
+ // Get the active window streams and see if any of them are "scary".
+ // If so, then we're sharing a browser window.
+ let activeStreams = webrtcUI.getActiveStreams(
+ false /* camera */,
+ false /* microphone */,
+ false /* screen */,
+ true /* window */
+ );
+ let hasBrowserWindow = activeStreams.some(stream => {
+ return stream.devices.some(device => device.scary);
+ });
+
+ this.updateWindowAttr("sharingbrowserwindow", hasBrowserWindow);
+ this.sharingBrowserWindow = hasBrowserWindow;
+ } else {
+ this.updateWindowAttr("sharingbrowserwindow");
+ this.sharingBrowserWindow = false;
+ }
+
+ // The label that's displayed when sharing a display followed a priority.
+ // The more "risky" we deem the display is for sharing, the higher priority.
+ // This gives us the following priorities, from highest to lowest.
+ //
+ // 1. Screen
+ // 2. Browser window
+ // 3. Other application window
+ // 4. Browser tab (unimplemented)
+ //
+ // The CSS for the indicator does the work of showing or hiding these labels
+ // for us, but we need to update the aria-labelledby attribute on the container
+ // of those labels to make it clearer for screenreaders which one the user cares
+ // about.
+ let displayShare = document.getElementById("display-share");
+ let labelledBy;
+ if (sharingScreen) {
+ labelledBy = "screen-share-info";
+ } else if (this.sharingBrowserWindow) {
+ labelledBy = "browser-window-share-info";
+ } else if (sharingWindow) {
+ labelledBy = "window-share-info";
+ }
+ displayShare.setAttribute("aria-labelledby", labelledBy);
+
+ if (window.windowState != window.STATE_MINIMIZED) {
+ // Resize and ensure the window position is correct
+ // (sizeToContent messes with our position).
+ let docElStyle = document.documentElement.style;
+ docElStyle.minWidth = docElStyle.maxWidth = "unset";
+ docElStyle.minHeight = docElStyle.maxHeight = "unset";
+ window.sizeToContent();
+
+ // On Linux GTK, the style of window we're using by default is resizable. We
+ // workaround this by setting explicit limits on the height and width of the
+ // window.
+ if (AppConstants.platform == "linux") {
+ let { width, height } = window.windowUtils.getBoundsWithoutFlushing(
+ document.documentElement
+ );
+
+ docElStyle.minWidth = docElStyle.maxWidth = `${width}px`;
+ docElStyle.minHeight = docElStyle.maxHeight = `${height}px`;
+ }
+
+ this.ensureOnScreen();
+
+ if (!this.positionCustomized) {
+ this.centerOnLatestBrowser();
+ }
+ }
+
+ this.updatingIndicatorState = false;
+ },
+
+ /**
+ * After the indicator has been updated, checks to see if it has expanded
+ * such that part of the indicator is now outside of the screen. If so,
+ * it then adjusts the position to put the entire indicator on screen.
+ */
+ ensureOnScreen() {
+ let desiredX = Math.max(window.screenX, screen.availLeft);
+ let maxX =
+ screen.availLeft +
+ screen.availWidth -
+ document.documentElement.clientWidth;
+ window.moveTo(Math.min(desiredX, maxX), window.screenY);
+ },
+
+ /**
+ * If the indicator is first being opened, we'll find the browser window
+ * associated with the most recent share, and pin the indicator to the
+ * very top of the content area.
+ */
+ centerOnLatestBrowser() {
+ let activeStreams = webrtcUI.getActiveStreams(
+ true /* camera */,
+ true /* microphone */,
+ true /* screen */,
+ true /* window */
+ );
+
+ if (!activeStreams.length) {
+ return;
+ }
+
+ let browser = activeStreams[activeStreams.length - 1].browser;
+ let browserWindow = browser.ownerGlobal;
+ let browserRect =
+ browserWindow.windowUtils.getBoundsWithoutFlushing(browser);
+
+ // This should be called in initialize right after we've just called
+ // updateIndicatorState. Since updateIndicatorState uses
+ // window.sizeToContent, the layout information should be up to date,
+ // and so the numbers that we get without flushing should be sufficient.
+ let { width: windowWidth } = window.windowUtils.getBoundsWithoutFlushing(
+ document.documentElement
+ );
+
+ window.moveTo(
+ browserWindow.mozInnerScreenX +
+ browserRect.left +
+ (browserRect.width - windowWidth) / 2,
+ browserWindow.mozInnerScreenY + browserRect.top
+ );
+ },
+
+ handleEvent(event) {
+ switch (event.type) {
+ case "load": {
+ this.onLoad();
+ break;
+ }
+ case "unload": {
+ this.onUnload();
+ break;
+ }
+ case "click": {
+ this.onClick(event);
+ break;
+ }
+ case "change": {
+ this.onChange(event);
+ break;
+ }
+ case "MozUpdateWindowPos": {
+ if (!this.updatingIndicatorState) {
+ // The window moved while not updating the indicator state,
+ // so the user probably moved it.
+ this.positionCustomized = true;
+ }
+ break;
+ }
+ case "sizemodechange": {
+ if (window.windowState != window.STATE_MINIMIZED) {
+ this.updateIndicatorState();
+ }
+ break;
+ }
+ case "popupshowing": {
+ this.onPopupShowing(event);
+ break;
+ }
+ case "popuphiding": {
+ this.onPopupHiding(event);
+ break;
+ }
+ case "command": {
+ this.onCommand(event);
+ break;
+ }
+ case "DOMWindowClose":
+ case "close": {
+ this.onClose(event);
+ break;
+ }
+ }
+ },
+
+ onLoad() {
+ this.loaded = true;
+
+ if (AppConstants.platform == "macosx" || AppConstants.platform == "win") {
+ this.statusBar = Cc["@mozilla.org/widget/systemstatusbar;1"].getService(
+ Ci.nsISystemStatusBar
+ );
+ }
+
+ this.updateIndicatorState();
+
+ window.addEventListener("click", this);
+ window.addEventListener("change", this);
+ window.addEventListener("sizemodechange", this);
+
+ // There are two ways that the dialog can close - either via the
+ // .close() window method, or via the OS. We handle both of those
+ // cases here.
+ window.addEventListener("DOMWindowClose", this);
+ window.addEventListener("close", this);
+
+ if (this.statusBar) {
+ // We only want these events for the system status bar menus.
+ window.addEventListener("popupshowing", this);
+ window.addEventListener("popuphiding", this);
+ window.addEventListener("command", this);
+ }
+
+ window.windowRoot.addEventListener("MozUpdateWindowPos", this);
+
+ // Alert accessibility implementations stuff just changed. We only need to do
+ // this initially, because changes after this will automatically fire alert
+ // events if things change materially.
+ let ev = new CustomEvent("AlertActive", {
+ bubbles: true,
+ cancelable: true,
+ });
+ document.documentElement.dispatchEvent(ev);
+
+ this.loaded = true;
+ },
+
+ onClose(event) {
+ // This event is fired from when the indicator window tries to be closed.
+ // If we preventDefault() the event, we are able to cancel that close
+ // attempt.
+ //
+ // We want to do that if we're not showing the global mute toggles
+ // and we're still sharing a camera or a microphone so that we can
+ // keep the status bar indicators present (since those status bar
+ // indicators are bound to this window).
+ if (
+ !this.showGlobalMuteToggles &&
+ (webrtcUI.showCameraIndicator || webrtcUI.showMicrophoneIndicator)
+ ) {
+ event.preventDefault();
+ this.setVisibility(false);
+ }
+
+ if (!this.isClosingInternally) {
+ // Something has tried to close the indicator, but it wasn't webrtcUI.
+ // This means we might still have some streams being shared. To protect
+ // the user from unknowingly sharing streams, we shut those streams
+ // down.
+ //
+ // This only includes the camera and microphone streams if the user
+ // has the global mute toggles enabled, since these toggles visually
+ // associate the indicator with those streams.
+ let activeStreams = webrtcUI.getActiveStreams(
+ this.showGlobalMuteToggles /* camera */,
+ this.showGlobalMuteToggles /* microphone */,
+ true /* screen */,
+ true /* window */
+ );
+ webrtcUI.stopSharingStreams(
+ activeStreams,
+ this.showGlobalMuteToggles /* camera */,
+ this.showGlobalMuteToggles /* microphone */,
+ true /* screen */,
+ true /* window */
+ );
+ }
+ },
+
+ onUnload() {
+ Services.ppmm.sharedData.set("WebRTC:GlobalCameraMute", false);
+ Services.ppmm.sharedData.set("WebRTC:GlobalMicrophoneMute", false);
+ Services.ppmm.sharedData.flush();
+
+ if (this.statusBar) {
+ for (let menu of this.statusBarMenus) {
+ this.statusBar.removeItem(menu);
+ }
+ }
+ },
+
+ onClick(event) {
+ switch (event.target.id) {
+ case "stop-sharing": {
+ let activeStreams = webrtcUI.getActiveStreams(
+ false /* camera */,
+ false /* microphone */,
+ true /* screen */,
+ true /* window */
+ );
+
+ if (!activeStreams.length) {
+ return;
+ }
+
+ // getActiveStreams is filtering for streams that have screen
+ // sharing, but those streams might _also_ be sharing other
+ // devices like camera or microphone. This is why we need to
+ // tell stopSharingStreams explicitly which device type we want
+ // to stop.
+ webrtcUI.stopSharingStreams(
+ activeStreams,
+ false /* camera */,
+ false /* microphone */,
+ true /* screen */,
+ true /* window */
+ );
+ break;
+ }
+ case "minimize": {
+ window.minimize();
+ break;
+ }
+ }
+ },
+
+ onChange(event) {
+ switch (event.target.id) {
+ case "microphone-mute-toggle": {
+ this.toggleMicrophoneMute(event.target);
+ break;
+ }
+ case "camera-mute-toggle": {
+ this.toggleCameraMute(event.target);
+ break;
+ }
+ }
+ },
+
+ onPopupShowing(event) {
+ if (this.eventIsForDeviceMenuPopup(event)) {
+ // When the indicator is hidden by default, opening the menu from the
+ // system tray _might_ cause the indicator to try to become visible again.
+ // We work around this by re-hiding it if it wasn't already visible.
+ if (document.documentElement.getAttribute("visible") != "true") {
+ let baseWin = window.docShell.treeOwner.QueryInterface(
+ Ci.nsIBaseWindow
+ );
+ baseWin.visibility = false;
+ }
+
+ showStreamSharingMenu(window, event, true);
+ }
+ },
+
+ onPopupHiding(event) {
+ if (!this.eventIsForDeviceMenuPopup(event)) {
+ return;
+ }
+
+ let menu = event.target;
+ while (menu.firstChild) {
+ menu.firstChild.remove();
+ }
+ },
+
+ onCommand(event) {
+ webrtcUI.showSharingDoorhanger(event.target.stream, event);
+ },
+
+ /**
+ * Returns true if an event was fired for one of the shared device
+ * menupopups.
+ *
+ * @param event (Event)
+ * The event to check.
+ * @returns True if the event was for one of the shared device
+ * menupopups.
+ */
+ eventIsForDeviceMenuPopup(event) {
+ let menupopup = event.target;
+ let type = menupopup.getAttribute("type");
+
+ return ["Camera", "Microphone", "Screen"].includes(type);
+ },
+
+ /**
+ * Mutes or unmutes the microphone globally based on the checked
+ * state of toggleEl. Also updates the tooltip of toggleEl once
+ * the state change is done.
+ *
+ * @param toggleEl (Element)
+ * The input[type="checkbox"] for toggling the microphone mute
+ * state.
+ */
+ toggleMicrophoneMute(toggleEl) {
+ Services.ppmm.sharedData.set(
+ "WebRTC:GlobalMicrophoneMute",
+ toggleEl.checked
+ );
+ Services.ppmm.sharedData.flush();
+ let l10nId =
+ "webrtc-microphone-" + (toggleEl.checked ? "muted" : "unmuted");
+ document.l10n.setAttributes(toggleEl, l10nId);
+ },
+
+ /**
+ * Mutes or unmutes the camera globally based on the checked
+ * state of toggleEl. Also updates the tooltip of toggleEl once
+ * the state change is done.
+ *
+ * @param toggleEl (Element)
+ * The input[type="checkbox"] for toggling the camera mute
+ * state.
+ */
+ toggleCameraMute(toggleEl) {
+ Services.ppmm.sharedData.set("WebRTC:GlobalCameraMute", toggleEl.checked);
+ Services.ppmm.sharedData.flush();
+ let l10nId = "webrtc-camera-" + (toggleEl.checked ? "muted" : "unmuted");
+ document.l10n.setAttributes(toggleEl, l10nId);
+ },
+
+ /**
+ * Updates an attribute on the <window> element.
+ *
+ * @param attr (String)
+ * The name of the attribute to update.
+ * @param value (String?)
+ * A string to set the attribute to. If the value is false-y,
+ * the attribute is removed.
+ */
+ updateWindowAttr(attr, value) {
+ let docEl = document.documentElement;
+ if (value) {
+ docEl.setAttribute(attr, "true");
+ } else {
+ docEl.removeAttribute(attr);
+ }
+ },
+
+ /**
+ * See the documentation on the script global closingInternally() function.
+ */
+ closingInternally() {
+ this.isClosingInternally = true;
+ },
+};
+
+WebRTCIndicator.init();
diff --git a/browser/base/content/webrtcIndicator.xhtml b/browser/base/content/webrtcIndicator.xhtml
new file mode 100644
index 0000000000..d466d27f68
--- /dev/null
+++ b/browser/base/content/webrtcIndicator.xhtml
@@ -0,0 +1,86 @@
+<?xml version="1.0"?>
+<!-- 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/. -->
+
+<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?>
+<?xml-stylesheet href="chrome://browser/skin/webRTC-indicator.css" type="text/css"?>
+
+<!DOCTYPE html>
+
+<html
+ xmlns="http://www.w3.org/1999/xhtml"
+ xmlns:xul="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ id="webrtcIndicator"
+ windowtype="Browser:WebRTCGlobalIndicator"
+ chromemargin="0,0,0,0"
+>
+ <head>
+ <link rel="localization" href="branding/brand.ftl" />
+ <link rel="localization" href="browser/webrtcIndicator.ftl" />
+ <title data-l10n-id="webrtc-indicator-title"></title>
+ <script src="chrome://global/content/customElements.js" />
+ <script src="chrome://browser/content/webrtcIndicator.js"></script>
+ </head>
+
+ <xul:menu
+ id="webRTC-sharingCamera-menu"
+ data-l10n-id="webrtc-camera-system-menu"
+ >
+ <xul:menupopup type="Camera"> </xul:menupopup>
+ </xul:menu>
+ <xul:menu
+ id="webRTC-sharingMicrophone-menu"
+ data-l10n-id="webrtc-microphone-system-menu"
+ >
+ <xul:menupopup type="Microphone"> </xul:menupopup>
+ </xul:menu>
+ <xul:menu
+ id="webRTC-sharingScreen-menu"
+ data-l10n-id="webrtc-screen-system-menu"
+ >
+ <xul:menupopup type="Screen"> </xul:menupopup>
+ </xul:menu>
+
+ <body role="alert">
+ <div id="drag-indicator" />
+ <div id="display-share" class="row-item" role="group" aria-labelledby="">
+ <image id="display-share-icon" />
+
+ <span id="window-share-info" data-l10n-id="webrtc-sharing-window" />
+ <span
+ id="browser-window-share-info"
+ data-l10n-id="webrtc-sharing-browser-window"
+ />
+ <span id="screen-share-info" data-l10n-id="webrtc-sharing-screen" />
+ <button
+ id="stop-sharing"
+ class="stop-button"
+ data-l10n-id="webrtc-stop-sharing-button"
+ />
+ </div>
+ <div class="row-item separator" />
+ <div id="device-share" class="row-item">
+ <input
+ type="checkbox"
+ id="microphone-mute-toggle"
+ class="control-icon"
+ data-l10n-id="webrtc-microphone-unmuted"
+ />
+ <input
+ type="checkbox"
+ id="camera-mute-toggle"
+ class="control-icon"
+ data-l10n-id="webrtc-camera-unmuted"
+ />
+ </div>
+ <div class="row-item separator" />
+ <div id="window-controls" class="row-item">
+ <button
+ id="minimize"
+ class="control-icon"
+ data-l10n-id="webrtc-minimize"
+ />
+ </div>
+ </body>
+</html>
diff --git a/browser/base/content/webrtcLegacyIndicator.js b/browser/base/content/webrtcLegacyIndicator.js
new file mode 100644
index 0000000000..e75b4f3dab
--- /dev/null
+++ b/browser/base/content/webrtcLegacyIndicator.js
@@ -0,0 +1,206 @@
+/* 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/. */
+
+const { webrtcUI } = ChromeUtils.import("resource:///modules/webrtcUI.jsm");
+
+function init(event) {
+ for (let id of ["audioVideoButton", "screenSharePopup"]) {
+ let popup = document.getElementById(id);
+ popup.addEventListener("popupshowing", onPopupMenuShowing);
+ popup.addEventListener("popuphiding", onPopupMenuHiding);
+ popup.addEventListener("command", onPopupMenuCommand);
+ }
+
+ let fxButton = document.getElementById("firefoxButton");
+ fxButton.addEventListener("click", onFirefoxButtonClick);
+ fxButton.addEventListener("mousedown", PositionHandler);
+
+ updateIndicatorState();
+
+ // Alert accessibility implementations stuff just changed. We only need to do
+ // this initially, because changes after this will automatically fire alert
+ // events if things change materially.
+ let ev = new CustomEvent("AlertActive", { bubbles: true, cancelable: true });
+ document.documentElement.dispatchEvent(ev);
+}
+
+function updateIndicatorState() {
+ updateWindowAttr("sharingvideo", webrtcUI.showCameraIndicator);
+ updateWindowAttr("sharingaudio", webrtcUI.showMicrophoneIndicator);
+ updateWindowAttr("sharingscreen", webrtcUI.showScreenSharingIndicator);
+
+ // Camera and microphone button tooltip.
+ const audioVideoButton = document.getElementById("audioVideoButton");
+ let avL10nId;
+ if (webrtcUI.showCameraIndicator) {
+ avL10nId = webrtcUI.showMicrophoneIndicator
+ ? "webrtc-indicator-sharing-camera-and-microphone"
+ : "webrtc-indicator-sharing-camera";
+ } else {
+ avL10nId = webrtcUI.showMicrophoneIndicator
+ ? "webrtc-indicator-sharing-microphone"
+ : "";
+ }
+ if (avL10nId) {
+ document.l10n.setAttributes(audioVideoButton, avL10nId);
+ } else {
+ audioVideoButton.removeAttribute("data-l10n-id");
+ audioVideoButton.removeAttribute("tooltiptext");
+ }
+
+ // Screen sharing button tooltip.
+ const screenShareButton = document.getElementById("screenShareButton");
+ let ssL10nId;
+ const ssi = webrtcUI.showScreenSharingIndicator;
+ switch (ssi) {
+ case "Application":
+ ssL10nId = "webrtc-indicator-sharing-application";
+ break;
+ case "Browser":
+ ssL10nId = "webrtc-indicator-sharing-browser";
+ break;
+ case "Screen":
+ ssL10nId = "webrtc-indicator-sharing-screen";
+ break;
+ case "Window":
+ ssL10nId = "webrtc-indicator-sharing-window";
+ break;
+ default:
+ if (ssi) {
+ console.error(`Unknown showScreenSharingIndicator: ${ssi}`);
+ }
+ ssL10nId = "";
+ }
+ if (ssL10nId) {
+ document.l10n.setAttributes(screenShareButton, ssL10nId);
+ } else {
+ screenShareButton.removeAttribute("data-l10n-id");
+ screenShareButton.removeAttribute("tooltiptext");
+ }
+
+ // Resize and ensure the window position is correct
+ // (sizeToContent messes with our position).
+ window.sizeToContent();
+ PositionHandler.adjustPosition();
+}
+
+function updateWindowAttr(attr, value) {
+ let docEl = document.documentElement;
+ if (value) {
+ docEl.setAttribute(attr, "true");
+ } else {
+ docEl.removeAttribute(attr);
+ }
+}
+
+function onPopupMenuShowing(event) {
+ let popup = event.target;
+
+ let activeStreams;
+ if (popup.getAttribute("type") == "Devices") {
+ activeStreams = webrtcUI.getActiveStreams(true, true, false);
+ } else {
+ activeStreams = webrtcUI.getActiveStreams(false, false, true, true);
+ }
+ if (activeStreams.length) {
+ let index = activeStreams.length - 1;
+ webrtcUI.showSharingDoorhanger(activeStreams[index], event);
+ event.preventDefault();
+ return;
+ }
+
+ for (let stream of activeStreams) {
+ let item = document.createElement("menuitem");
+ item.setAttribute("label", stream.browser.contentTitle || stream.uri);
+ item.setAttribute("tooltiptext", stream.uri);
+ item.stream = stream;
+ popup.appendChild(item);
+ }
+}
+
+function onPopupMenuHiding(event) {
+ let popup = event.target;
+ while (popup.firstChild) {
+ popup.firstChild.remove();
+ }
+}
+
+function onPopupMenuCommand(event) {
+ webrtcUI.showSharingDoorhanger(event.target.stream, event);
+}
+
+function onFirefoxButtonClick(event) {
+ event.target.blur();
+ let activeStreams = webrtcUI.getActiveStreams(true, true, true, true);
+ activeStreams[0].browser.ownerGlobal.focus();
+}
+
+var PositionHandler = {
+ positionCustomized: false,
+ threshold: 10,
+ adjustPosition() {
+ if (!this.positionCustomized) {
+ // Center the window horizontally on the screen (not the available area).
+ // Until we have moved the window to y=0, 'screen.width' may give a value
+ // for a secondary screen, so use values from the screen manager instead.
+ let primaryScreen = Cc["@mozilla.org/gfx/screenmanager;1"].getService(
+ Ci.nsIScreenManager
+ ).primaryScreen;
+ let widthDevPix = {};
+ primaryScreen.GetRect({}, {}, widthDevPix, {});
+ let availTopDevPix = {};
+ primaryScreen.GetAvailRect({}, availTopDevPix, {}, {});
+ let scaleFactor = primaryScreen.defaultCSSScaleFactor;
+ let widthCss = widthDevPix.value / scaleFactor;
+ window.moveTo(
+ (widthCss - document.documentElement.clientWidth) / 2,
+ availTopDevPix.value / scaleFactor
+ );
+ } else {
+ // This will ensure we're at y=0.
+ this.setXPosition(window.screenX);
+ }
+ },
+ setXPosition(desiredX) {
+ // Ensure the indicator isn't moved outside the available area of the screen.
+ desiredX = Math.max(desiredX, screen.availLeft);
+ let maxX =
+ screen.availLeft +
+ screen.availWidth -
+ document.documentElement.clientWidth;
+ window.moveTo(Math.min(desiredX, maxX), screen.availTop);
+ },
+ handleEvent(aEvent) {
+ switch (aEvent.type) {
+ case "mousedown":
+ if (aEvent.button != 0 || aEvent.defaultPrevented) {
+ return;
+ }
+
+ this._startMouseX = aEvent.screenX;
+ this._startWindowX = window.screenX;
+ this._deltaX = this._startMouseX - this._startWindowX;
+
+ window.addEventListener("mousemove", this);
+ window.addEventListener("mouseup", this);
+ break;
+
+ case "mousemove":
+ let moveOffset = Math.abs(aEvent.screenX - this._startMouseX);
+ if (this._dragFullyStarted || moveOffset > this.threshold) {
+ this.setXPosition(aEvent.screenX - this._deltaX);
+ this._dragFullyStarted = true;
+ }
+ break;
+
+ case "mouseup":
+ this._dragFullyStarted = false;
+ window.removeEventListener("mousemove", this);
+ window.removeEventListener("mouseup", this);
+ this.positionCustomized =
+ Math.abs(this._startWindowX - window.screenX) >= this.threshold;
+ break;
+ }
+ },
+};
diff --git a/browser/base/content/webrtcLegacyIndicator.xhtml b/browser/base/content/webrtcLegacyIndicator.xhtml
new file mode 100644
index 0000000000..7d17d491ff
--- /dev/null
+++ b/browser/base/content/webrtcLegacyIndicator.xhtml
@@ -0,0 +1,40 @@
+<?xml version="1.0"?>
+
+# 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/.
+
+<?xml-stylesheet href="chrome://global/skin/global.css" type="text/css"?>
+<?xml-stylesheet href="chrome://browser/skin/webRTC-legacy-indicator.css" type="text/css"?>
+
+<!DOCTYPE window>
+
+<window xmlns:html="http://www.w3.org/1999/xhtml"
+ xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"
+ id="webrtcIndicator"
+ role="alert"
+#ifndef XP_MACOSX
+ data-l10n-id="webrtc-indicator-window"
+#endif
+ windowtype="Browser:WebRTCGlobalIndicator"
+ onload="init(event);"
+ sizemode="normal"
+ hidechrome="true"
+ orient="horizontal"
+ >
+ <linkset>
+ <html:link rel="localization" href="branding/brand.ftl"/>
+ <html:link rel="localization" href="browser/webrtcIndicator.ftl"/>
+ </linkset>
+
+ <script src="chrome://browser/content/webrtcLegacyIndicator.js"/>
+
+ <button id="firefoxButton"/>
+ <button id="audioVideoButton" type="menu">
+ <menupopup id="audioVideoPopup" type="Devices"/>
+ </button>
+ <separator id="shareSeparator"/>
+ <button id="screenShareButton" type="menu">
+ <menupopup id="screenSharePopup" type="Screen"/>
+ </button>
+</window>
diff --git a/browser/base/jar.mn b/browser/base/jar.mn
new file mode 100644
index 0000000000..dcbfaf7e52
--- /dev/null
+++ b/browser/base/jar.mn
@@ -0,0 +1,109 @@
+# 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/.
+browser.jar:
+% content browser %content/browser/ contentaccessible=yes
+
+ content/browser/aboutDialog-appUpdater.js (content/aboutDialog-appUpdater.js)
+* content/browser/aboutDialog.xhtml (content/aboutDialog.xhtml)
+ content/browser/aboutDialog.js (content/aboutDialog.js)
+ content/browser/aboutDialog.css (content/aboutDialog.css)
+ content/browser/aboutRestartRequired.js (content/aboutRestartRequired.js)
+ content/browser/aboutRestartRequired.xhtml (content/aboutRestartRequired.xhtml)
+ content/browser/aboutRobots.xhtml (content/aboutRobots.xhtml)
+ content/browser/aboutRobots.js (content/aboutRobots.js)
+ content/browser/aboutRobots.css (content/aboutRobots.css)
+ content/browser/logos/etp-mobile.svg (content/logos/etp-mobile.svg)
+ content/browser/logos/lockwise.svg (content/logos/lockwise.svg)
+ content/browser/logos/monitor.svg (content/logos/monitor.svg)
+ content/browser/logos/relay.svg (content/logos/relay.svg)
+ content/browser/logos/vpn-promo-logo.svg (content/logos/vpn-promo-logo.svg)
+ content/browser/logos/proxy-light.svg (content/logos/proxy-light.svg)
+ content/browser/logos/proxy-dark.svg (content/logos/proxy-dark.svg)
+ content/browser/logos/send.svg (content/logos/send.svg)
+ content/browser/logos/tracking-protection.svg (content/logos/tracking-protection.svg)
+ content/browser/logos/tracking-protection-dark-theme.svg (content/logos/tracking-protection-dark-theme.svg)
+ content/browser/logos/vpn-dark.svg (content/logos/vpn-dark.svg)
+ content/browser/logos/vpn-light.svg (content/logos/vpn-light.svg)
+ content/browser/logos/fxa-logo.svg (content/logos/fxa-logo.svg)
+ content/browser/aboutRobots-icon.png (content/aboutRobots-icon.png)
+ content/browser/aboutFrameCrashed.html (content/aboutFrameCrashed.html)
+ content/browser/aboutTabCrashed.css (content/aboutTabCrashed.css)
+ content/browser/aboutTabCrashed.js (content/aboutTabCrashed.js)
+ content/browser/aboutTabCrashed.xhtml (content/aboutTabCrashed.xhtml)
+ content/browser/blanktab.html (content/blanktab.html)
+ content/browser/browser.css (content/browser.css)
+ content/browser/browser.js (content/browser.js)
+* content/browser/browser.xhtml (content/browser.xhtml)
+ content/browser/browser-a11yUtils.js (content/browser-a11yUtils.js)
+ content/browser/browser-addons.js (content/browser-addons.js)
+ content/browser/browser-allTabsMenu.js (content/browser-allTabsMenu.js)
+ content/browser/browser-captivePortal.js (content/browser-captivePortal.js)
+ content/browser/browser-ctrlTab.js (content/browser-ctrlTab.js)
+ content/browser/browser-customization.js (content/browser-customization.js)
+ content/browser/browser-data-submission-info-bar.js (content/browser-data-submission-info-bar.js)
+#ifndef MOZILLA_OFFICIAL
+ content/browser/browser-development-helpers.js (content/browser-development-helpers.js)
+#endif
+ content/browser/browser-fullScreenAndPointerLock.js (content/browser-fullScreenAndPointerLock.js)
+ content/browser/browser-fullZoom.js (content/browser-fullZoom.js)
+ content/browser/browser-gestureSupport.js (content/browser-gestureSupport.js)
+ content/browser/browser-pageActions.js (content/browser-pageActions.js)
+ content/browser/browser-pagestyle.js (content/browser-pagestyle.js)
+ content/browser/browser-places.js (content/browser-places.js)
+ content/browser/browser-safebrowsing.js (content/browser-safebrowsing.js)
+ content/browser/browser-sidebar.js (content/browser-sidebar.js)
+ content/browser/browser-siteIdentity.js (content/browser-siteIdentity.js)
+ content/browser/browser-sitePermissionPanel.js (content/browser-sitePermissionPanel.js)
+ content/browser/browser-siteProtections.js (content/browser-siteProtections.js)
+ content/browser/browser-sync.js (content/browser-sync.js)
+ content/browser/browser-tabsintitlebar.js (content/browser-tabsintitlebar.js)
+ content/browser/browser-toolbarKeyNav.js (content/browser-toolbarKeyNav.js)
+ content/browser/browser-thumbnails.js (content/browser-thumbnails.js)
+ content/browser/browser-unified-extensions.js (content/browser-unified-extensions.js)
+ content/browser/browser-graphics-utils.js (content/browser-graphics-utils.js)
+ content/browser/browser-webrtc.js (content/browser-webrtc.js)
+* content/browser/pageinfo/pageInfo.xhtml (content/pageinfo/pageInfo.xhtml)
+ content/browser/pageinfo/pageInfo.js (content/pageinfo/pageInfo.js)
+ content/browser/pageinfo/pageInfo.css (content/pageinfo/pageInfo.css)
+ content/browser/pageinfo/permissions.js (content/pageinfo/permissions.js)
+ content/browser/pageinfo/security.js (content/pageinfo/security.js)
+ content/browser/robot.ico (content/robot.ico)
+ content/browser/static-robot.png (content/static-robot.png)
+ content/browser/safeMode.css (content/safeMode.css)
+ content/browser/safeMode.js (content/safeMode.js)
+ content/browser/safeMode.xhtml (content/safeMode.xhtml)
+ content/browser/sanitize.xhtml (content/sanitize.xhtml)
+ content/browser/sanitizeDialog.js (content/sanitizeDialog.js)
+ content/browser/sanitizeDialog.css (content/sanitizeDialog.css)
+ content/browser/tabbrowser.css (content/tabbrowser.css)
+ content/browser/tabbrowser.js (content/tabbrowser.js)
+ content/browser/tabbrowser-tab.js (content/tabbrowser-tab.js)
+ content/browser/tabbrowser-tabs.js (content/tabbrowser-tabs.js)
+ content/browser/utilityOverlay.js (content/utilityOverlay.js)
+ content/browser/webext-panels.js (content/webext-panels.js)
+* content/browser/webext-panels.xhtml (content/webext-panels.xhtml)
+ content/browser/nsContextMenu.js (content/nsContextMenu.js)
+ content/browser/contentTheme.js (content/contentTheme.js)
+#ifdef XP_MACOSX
+# XXX: We should exclude this one as well (bug 71895)
+* content/browser/hiddenWindowMac.xhtml (content/hiddenWindowMac.xhtml)
+ content/browser/nonbrowser-mac.js (content/nonbrowser-mac.js)
+#endif
+#ifndef XP_MACOSX
+* content/browser/webrtcLegacyIndicator.xhtml (content/webrtcLegacyIndicator.xhtml)
+ content/browser/webrtcLegacyIndicator.js (content/webrtcLegacyIndicator.js)
+#endif
+ content/browser/webrtcIndicator.xhtml (content/webrtcIndicator.xhtml)
+ content/browser/webrtcIndicator.js (content/webrtcIndicator.js)
+# the following files are browser-specific overrides
+* content/browser/license.html (/toolkit/content/license.html)
+% override chrome://global/content/license.html chrome://browser/content/license.html
+ content/browser/blockedSite.xhtml (content/blockedSite.xhtml)
+ content/browser/blockedSite.js (content/blockedSite.js)
+ content/browser/spotlight.html (content/spotlight.html)
+ content/browser/spotlight.js (content/spotlight.js)
+* content/browser/default-bookmarks.html (content/default-bookmarks.html)
+
+# L10n resources and overrides.
+% override chrome://global/locale/appstrings.properties chrome://browser/locale/appstrings.properties
diff --git a/browser/base/moz.build b/browser/base/moz.build
new file mode 100644
index 0000000000..8c6d23deb0
--- /dev/null
+++ b/browser/base/moz.build
@@ -0,0 +1,91 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# 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/.
+
+with Files("**"):
+ BUG_COMPONENT = ("Firefox", "General")
+
+SPHINX_TREES["sslerrorreport"] = "content/docs/sslerrorreport"
+SPHINX_TREES["tabbrowser"] = "content/docs/tabbrowser"
+
+with Files("content/docs/sslerrorreport/**"):
+ SCHEDULES.exclusive = ["docs"]
+
+MOCHITEST_CHROME_MANIFESTS += [
+ "content/test/chrome/chrome.ini",
+]
+
+BROWSER_CHROME_MANIFESTS += [
+ "content/test/about/browser.ini",
+ "content/test/alerts/browser.ini",
+ "content/test/backforward/browser.ini",
+ "content/test/caps/browser.ini",
+ "content/test/captivePortal/browser.ini",
+ "content/test/contentTheme/browser.ini",
+ "content/test/contextMenu/browser.ini",
+ "content/test/favicons/browser.ini",
+ "content/test/forms/browser.ini",
+ "content/test/fullscreen/browser.ini",
+ "content/test/general/browser.ini",
+ "content/test/gesture/browser.ini",
+ "content/test/historySwipeAnimation/browser.ini",
+ "content/test/keyboard/browser.ini",
+ "content/test/menubar/browser.ini",
+ "content/test/metaTags/browser.ini",
+ "content/test/notificationbox/browser.ini",
+ "content/test/outOfProcess/browser.ini",
+ "content/test/pageActions/browser.ini",
+ "content/test/pageinfo/browser.ini",
+ "content/test/pageStyle/browser.ini",
+ "content/test/permissions/browser.ini",
+ "content/test/plugins/browser.ini",
+ "content/test/popupNotifications/browser.ini",
+ "content/test/popups/browser.ini",
+ "content/test/protectionsUI/browser.ini",
+ "content/test/referrer/browser.ini",
+ "content/test/sanitize/browser.ini",
+ "content/test/sidebar/browser.ini",
+ "content/test/siteIdentity/browser.ini",
+ "content/test/startup/browser.ini",
+ "content/test/static/browser.ini",
+ "content/test/statuspanel/browser.ini",
+ "content/test/sync/browser.ini",
+ "content/test/tabcrashed/browser.ini",
+ "content/test/tabcrashed/browser_aboutRestartRequired.ini",
+ "content/test/tabdialogs/browser.ini",
+ "content/test/tabMediaIndicator/browser.ini",
+ "content/test/tabPrompts/browser.ini",
+ "content/test/tabs/browser.ini",
+ "content/test/touch/browser.ini",
+ "content/test/utilityOverlay/browser.ini",
+ "content/test/webextensions/browser.ini",
+ "content/test/webrtc/browser.ini",
+ "content/test/webrtc/gracePeriod/browser.ini",
+ "content/test/webrtc/legacyIndicator/browser.ini",
+ "content/test/zoom/browser.ini",
+]
+
+DIRS += [
+ "content/test/performance/",
+]
+
+PERFTESTS_MANIFESTS += ["content/test/perftest.ini"]
+
+DEFINES["MOZ_APP_VERSION"] = CONFIG["MOZ_APP_VERSION"]
+DEFINES["MOZ_APP_VERSION_DISPLAY"] = CONFIG["MOZ_APP_VERSION_DISPLAY"]
+
+DEFINES["APP_LICENSE_BLOCK"] = "%s/content/overrides/app-license.html" % SRCDIR
+
+if CONFIG["MOZ_WIDGET_TOOLKIT"] in ("windows", "gtk", "cocoa"):
+ DEFINES["CONTEXT_COPY_IMAGE_CONTENTS"] = 1
+
+if CONFIG["MOZ_WIDGET_TOOLKIT"] in ("windows", "gtk"):
+ DEFINES["MENUBAR_CAN_AUTOHIDE"] = 1
+
+if CONFIG["OS_ARCH"] == "WINNT" and CONFIG["MOZ_DEFAULT_BROWSER_AGENT"]:
+ # Impacts `/toolkit/content/license.html`.
+ DEFINES["MOZ_DEFAULT_BROWSER_AGENT"] = True
+
+JAR_MANIFESTS += ["jar.mn"]